diff --git a/README.md b/README.md index 1a9050b..2df2445 100644 --- a/README.md +++ b/README.md @@ -189,11 +189,18 @@ as in the editor: - **A definition at a time.** An edit is split into the definitions it touches - imports first, then what it adds, then what it changes, then what it removes - so a replay builds the change up one function, type or constant at a time, in the order the agent staged it. -- **Looks too.** `find`, `references`, `file` and `dependencies` calls land as looks at what they - named, so you can see what an agent read before it wrote anything. +- **Inspects too.** What an agent reads lands as an inspect of what it read, so you can see what + it looked at before it wrote anything: `find`, `references`, `file` and `dependencies` calls as + ccc answers them, and its own reads - Claude Code's `Read`, a `cat`, `sed -n` or `grep` of a + file in its shell, a subagent's too - from its transcript, as it writes them. +- **Follow live changes.** The switch on the right is green while each change is shown as it lands + (`L` turns it on and off). While it is on, the step controls fold away to the left behind `»`, + and they open again by themselves once it is off. - **At your pace.** Live keeps the time between steps as it was; ½×, ¼×, ⅛× and ⅒× stretch it, and following runs behind by however many steps are still waiting. -- **One step on its own.** While a step is on show (◎), what it did not touch fades back, and a big +- **Big views in groups.** A view of 50 or more components or functions is laid out a group at a + time +- **Granular Changes.** While a step is on show (◎), what it did not touch fades back, and a big scene draws only what is wired to it, laid out together to fit the screen. Click the canvas to bring the rest back. diff --git a/assets/vis/vis.css b/assets/vis/vis.css index 2ba7ddd..d38bfea 100644 --- a/assets/vis/vis.css +++ b/assets/vis/vis.css @@ -3,6 +3,7 @@ purple for selection, slate lines. Dark is derived from the same roles. */ :root { + --foot: 12px; --paper: #fbfcfd; --grid: rgba(0, 0, 0, .06); --surface: #ffffff; @@ -254,8 +255,8 @@ button, input { font: inherit; color: inherit; } background-image: linear-gradient(to right, var(--grid) 1px, transparent 1px), linear-gradient(to bottom, var(--grid) 1px, transparent 1px); - background-size: calc(20px * var(--zoom, 1)) calc(20px * var(--zoom, 1)); - background-position: calc(var(--pan-x, 0) * 1px) calc(var(--pan-y, 0) * 1px); + /* the grid's size and position follow the camera - see `applyView` */ + background-size: 20px 20px; touch-action: none; } .viewport.panning { cursor: grabbing; } @@ -279,7 +280,8 @@ button, input { font: inherit; color: inherit; } left: 0; top: 0; transform-origin: 0 0; - transform: translate(calc(var(--pan-x, 0) * 1px), calc(var(--pan-y, 0) * 1px)) scale(var(--zoom, 1)); + /* moved as one layer, so a pan or zoom repaints none of what it holds */ + will-change: transform; } .surface > * { position: absolute; left: 0; top: 0; } .wires { overflow: visible; width: 1px; height: 1px; pointer-events: none; } @@ -304,6 +306,44 @@ button, input { font: inherit; color: inherit; } .frame.group { border-style: solid; border-width: 1px; border-radius: 10px; } .frame.group > .title { top: 8px; bottom: auto; font-size: 12px; font-weight: 600; } +.group-card { + position: absolute; + box-sizing: border-box; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + gap: .35em; + padding: 1em 1.4em; + font-size: min(calc(13px * var(--gz, 1)), calc(var(--w, 400px) / 16), calc(var(--h, 300px) / 7)); + text-align: center; + background: var(--surface); + border: calc(2px * var(--gz, 1)) solid var(--border); + border-radius: calc(12px * var(--gz, 1)); + box-shadow: var(--shadow); + cursor: zoom-in; + overflow: hidden; + transition: border-color .15s; +} +.group-card:hover { border-color: var(--primary); } +.group-card .g-what { font-size: .8em; letter-spacing: .06em; color: var(--muted); overflow-wrap: anywhere; } +.group-card .g-title { font-size: 1.8em; font-weight: 650; line-height: 1.15; color: var(--text); overflow-wrap: anywhere; } +.group-card .g-sub { color: var(--muted); } +.group-card .g-mark { margin-top: .3em; padding: .15em .8em; border-radius: 1em; background: var(--chg); color: #fff; font-size: .9em; } +.group-card .g-mark[hidden] { display: none; } +.group-card.chg { + --chg: var(--primary); + border-color: var(--chg); + box-shadow: var(--shadow), 0 0 0 calc(5px * var(--gz, 1)) color-mix(in srgb, var(--chg) 28%, transparent); +} +.group-card.chg-added { --chg: var(--green); } +.group-card.chg-removed { --chg: var(--red); } +.group-card.chg-inspect { --chg: var(--blue); } +.group-wires path { pointer-events: none; } +.group-wires .gwire { fill: none; stroke: var(--edge); } +.group-wires .ghead { fill: var(--edge); } +.group-wires text { fill: var(--muted); stroke: var(--paper); paint-order: stroke; text-anchor: middle; dominant-baseline: central; font-weight: 600; } + /* ---------- C4 nodes (doc canvas cards) ---------- */ .node { @@ -324,10 +364,12 @@ button, input { font: inherit; color: inherit; } .node.dragging { box-shadow: var(--shadow-lift); cursor: grabbing; z-index: 5; transition: none; } .node.dim { opacity: .35; } .node.hit { box-shadow: var(--shadow), 0 0 0 4px var(--primary-glow); border-color: var(--primary); } +/* a node's insides never move the layout around it */ +.node { contain: layout style; } -.card { width: 240px; padding: 10px 12px 10px 16px; } +.card { width: 240px; padding: 10px 12px calc(10px + var(--foot)) 16px; } .card.wide { width: 300px; } -.card.system { width: 340px; padding: 16px 18px 16px 22px; } +.card.system { width: 340px; padding: 16px 18px calc(16px + var(--foot)) 22px; } .card .stripe { position: absolute; left: -2px; @@ -389,7 +431,7 @@ button, input { font: inherit; color: inherit; } pointer-events: auto; cursor: pointer; } -.elabel.dim { opacity: .3; } +.dimming .elabel:not(.hot) { opacity: .3; } .elabel.declared { border-style: dashed; } /* ---------- wires ---------- */ @@ -399,7 +441,12 @@ button, input { font: inherit; color: inherit; } .wire.data { stroke-width: 1.5; stroke-dasharray: 4 3; } .wire.back { stroke-dasharray: 6 4; opacity: .6; } .wire.declared { stroke-dasharray: 6 4; } -.wire.dim { opacity: .2; } +/* the scene's wires dimmed as one drawing, the lit ones copied above it - see `highlight` */ +#wires { transition: opacity .12s; } +#wires.dimming { opacity: .2; } +.wires.lit path { pointer-events: none; } +/* what the collection on show added stays green while it is on show - see `showKept` */ +.wire.kept-new { stroke: var(--green); } .wire.hot { stroke: var(--primary); stroke-width: 2.75; opacity: 1; } .arrowhead { fill: var(--edge); } .arrowhead.hot { fill: var(--primary); } @@ -409,7 +456,7 @@ button, input { font: inherit; color: inherit; } .bp { min-width: 170px; max-width: 340px; - padding: 0 0 6px; + padding: 0 0 calc(6px + var(--foot)); overflow: visible; } .bp .head { @@ -449,6 +496,8 @@ button, input { font: inherit; color: inherit; } white-space: nowrap; } .bp .pin.in { grid-column: 1; } +/* rules what a function returns off from what it takes */ +.bp .rows .sep { grid-column: 1 / -1; border-top: 1px solid var(--edge); margin: 4px 0; } .bp .pin.out { grid-column: 2; justify-content: flex-end; text-align: right; } .bp .pin .lbl { overflow: hidden; text-overflow: ellipsis; max-width: 170px; } .bp .pin.data .lbl { font-family: var(--mono); font-size: 11px; color: var(--muted); } @@ -472,7 +521,7 @@ button, input { font: inherit; color: inherit; } .bp .pin.in i { margin-left: -18px; } .bp .pin.out i { margin-right: -18px; order: 2; } .bp .body { padding: 2px 12px 2px; font: 12px var(--mono); color: var(--muted); overflow-wrap: anywhere; max-width: 320px; } -.bp.compact { min-width: 0; border-radius: 16px; padding: 0; } +.bp.compact { min-width: 0; border-radius: 16px; padding: 0 0 var(--foot); } .bp.compact .rows { display: flex; align-items: center; } .bp.compact .pin { padding: 0 10px; min-height: 28px; } .bp.compact .chain { font: 11px var(--mono); color: var(--muted); padding: 5px 0; max-width: 260px; overflow-wrap: anywhere; } @@ -642,13 +691,22 @@ button, input { font: inherit; color: inherit; } } .message[hidden] { display: none; } .message b { color: var(--text); display: block; margin-bottom: 4px; } -.note-bar { +/* the view's note, and under it the step on show's, centred at the top */ +.top-notes { position: fixed; left: 50%; top: 64px; z-index: 12; transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; max-width: 70vw; + pointer-events: none; +} +.top-notes > * { pointer-events: auto; max-width: 100%; } +.note-bar { padding: 6px 12px; background: var(--surface); border: 1px solid var(--border); @@ -665,15 +723,36 @@ button, input { font: inherit; color: inherit; } written yet is dashed amber */ .node.chg { --chg: var(--primary); + --chg-label: 'Modified'; border-color: var(--chg); box-shadow: var(--shadow), 0 0 0 3px color-mix(in srgb, var(--chg) 30%, transparent); animation: chg-pulse 1.4s ease-out 2; } -.node.chg-added { --chg: var(--green); } -.node.chg-removed { --chg: var(--red); opacity: .85; } -.node.chg-pending { --chg: var(--orange); border-style: dashed; } +.node.chg-added { --chg: var(--green); --chg-label: 'Added'; } +.node.chg-removed { --chg: var(--red); --chg-label: 'Removed'; opacity: .85; } +.node.chg-pending { --chg: var(--orange); --chg-label: 'Proposed'; border-style: dashed; } /* what an agent looked at, rather than changed */ -.node.chg-read { --chg: var(--blue); } +.node.chg-inspect { --chg: var(--blue); --chg-label: 'Inspected'; } +/* what happened to a node, in a word on a label across its foot */ +.node.chg::after, +.node.kept-new::after, +.node.leaving::after { + content: var(--chg-label, ''); + position: absolute; + left: 50%; + bottom: 0; + transform: translate(-50%, 50%); + padding: 1px 12px 2px; + border: 1.5px solid var(--chg); + border-radius: 7px; + background: color-mix(in srgb, var(--chg) 16%, var(--surface)); + color: color-mix(in srgb, var(--chg) 85%, var(--text)); + font: 600 12px/1.35 var(--font); + letter-spacing: .02em; + white-space: nowrap; + pointer-events: none; + z-index: 3; +} @keyframes chg-pulse { 0% { box-shadow: var(--shadow), 0 0 0 0 color-mix(in srgb, var(--chg) 60%, transparent); } 70% { box-shadow: var(--shadow), 0 0 0 16px color-mix(in srgb, var(--chg) 0%, transparent); } @@ -682,6 +761,78 @@ button, input { font: inherit; color: inherit; } @media (prefers-reduced-motion: reduce) { .node.chg { animation: none; } .viewport.glide .surface { transition: none !important; } + .node.arrived, .pin.stamp, .pin.stamp::after, .pin.stamp .lbl, .pin.stamp i, .txt-change, .wire.wire-new { animation: none !important; } +} + +/* ---------- motion: see `morph` and `travel` ---------- */ +/* the view left behind, lifted off as one picture */ +.ghost { position: absolute; left: 0; top: 0; transform-origin: 0 0; pointer-events: none; will-change: transform, opacity; } +.ghost > * { position: absolute; left: 0; top: 0; } +.node.leaving { --chg: var(--red); --chg-label: 'Removed'; pointer-events: none; border-color: var(--red); } +/* a node that came into a view drawn again */ +.node.arrived:not(.chg) { animation: arrived 1.2s ease-out 1; } +@keyframes arrived { + 0%, 30% { box-shadow: var(--shadow), 0 0 0 3px color-mix(in srgb, var(--green) 55%, transparent); border-color: var(--green); } +} +.node.kept-new:not(.chg) { --chg: var(--green); --chg-label: 'Added'; border-color: var(--green); } +/* a node the ask added, marked by a step that reached it, is an addition still */ +.node.kept-new.chg-modified:not(.chg-pending) { --chg: var(--green); --chg-label: 'Added'; } +.bp .pin.kept-new { background: color-mix(in srgb, var(--green) 14%, transparent); } +.bp .pin.kept-new .lbl { color: color-mix(in srgb, var(--green) 75%, var(--text)); } +/* A pin that came, or whose words changed, stamped onto its node - see + `stamp`: the words land from above and press in, the pin's dot pops on, + and a ring of ink spreads from where it struck - green for one that came, + amber for one that changed - as the mark it leaves fades. */ +.bp .pin.stamp { --ink: var(--green); animation: stamp-mark 1.5s ease-out var(--stamp-at, 0s) 1 backwards; } +.bp .pin.stamp.changed { --ink: var(--orange); } +.bp .pin.stamp .lbl { animation: stamp-press .5s cubic-bezier(.3, 1.5, .55, 1) var(--stamp-at, 0s) 1 backwards; } +.bp .pin.stamp i { animation: stamp-dot .35s cubic-bezier(.3, 1.6, .55, 1) var(--stamp-at, 0s) 1 backwards; } +.bp .pin.stamp::after { + content: ''; + position: absolute; + inset: 1px 8px; + border: 2px solid var(--ink); + border-radius: 5px; + opacity: 0; + pointer-events: none; + animation: stamp-ink .75s ease-out calc(var(--stamp-at, 0s) + .2s) 1; +} +@keyframes stamp-press { + 0% { opacity: 0; transform: scale(1.9) translateY(-3px); filter: blur(1.5px); } + 55% { opacity: 1; transform: scale(.92); filter: none; } + 80% { transform: scale(1.03); } +} +@keyframes stamp-dot { + 0% { transform: scale(0); } +} +@keyframes stamp-ink { + 0% { opacity: .95; transform: scale(.94); } + 100% { opacity: 0; transform: scale(1.16, 1.5); } +} +@keyframes stamp-mark { + 0% { background: transparent; } + 20%, 45% { background: color-mix(in srgb, var(--ink) 24%, transparent); } +} +@keyframes words-change { + 0%, 35% { background: color-mix(in srgb, var(--orange) 28%, transparent); } +} +.bp .pin .lbl-was { + position: absolute; + font: 11px var(--mono); + color: var(--red); + text-decoration: line-through; + white-space: nowrap; + pointer-events: none; +} +.bp .pin.pin-out { position: absolute; pointer-events: none; } +.bp .pin.pin-out .lbl { color: var(--red); text-decoration: line-through; } +.txt-change { animation: words-change 1.2s ease-out 1; border-radius: 3px; } +/* the wires of a view drawn again: the old fade, a gone one red, a new one green */ +.ghost-wires .wire { opacity: .7; } +.ghost-wires .wire.gone { stroke: var(--red); opacity: 1; } +.wire.wire-new { animation: wire-new 1.6s ease-out 1; } +@keyframes wire-new { + 0%, 45% { stroke: var(--green); } } .viewport.glide .surface { transition: transform .6s cubic-bezier(.2, .7, .2, 1); } .viewport.glide { transition: background-size .6s, background-position .6s; } @@ -707,11 +858,17 @@ button, input { font: inherit; color: inherit; } /* no edits yet: only the switches for what comes stay, in the corner */ .timeline.empty { left: auto; } .timeline.empty > :not(.tl-ws):not(.tl-follow):not(.tl-speed):not(.tl-focus) { display: none; } -body.with-tl-switches .zoom { bottom: 62px; } -body.with-timeline .legend { bottom: 62px; } -body.with-timeline .zoom { bottom: 62px; } -body.with-timeline .panel { bottom: 62px; } -.tl-controls { display: flex; gap: 2px; } +/* what sits above the timeline keeps clear of it, however many rows it wraps + to - `--tl-top` is how far its top is from the window's foot */ +body.with-tl-switches .zoom, +body.with-timeline .legend, +body.with-timeline .zoom, +body.with-timeline .panel { bottom: calc(var(--tl-top, 52px) + 10px); } +.tl-controls { display: flex; flex: none; gap: 2px; max-width: 120px; overflow: hidden; transition: max-width .2s ease, opacity .2s ease; } +/* following, folded away to the left - the flex gap after them goes too */ +.tl-controls.folded { max-width: 0; opacity: 0; margin-right: -8px; } +.timeline .tl-unfold { min-width: 22px; padding: 0 4px; color: var(--muted); font-weight: 400; } +.timeline .tl-unfold[hidden] { display: none; } .timeline button { height: 28px; min-width: 28px; @@ -723,6 +880,8 @@ body.with-timeline .panel { bottom: 62px; } font-weight: 600; cursor: pointer; white-space: nowrap; + /* a switch keeps the width its words need - the track, the ask and the step give way instead */ + flex: none; } .timeline button:hover:not(:disabled) { background: var(--hover); color: var(--tool-active); } .timeline button:disabled { opacity: .4; cursor: default; } @@ -745,7 +904,7 @@ body.with-timeline .panel { bottom: 62px; } .tl-ticks .tick.reverted { background: var(--red); } .tl-ticks .tick.discarded { background: var(--grey); opacity: .45; } .tl-ticks .tick.edited { background: var(--blue); } -.tl-ticks .tick.read { background: var(--primary); opacity: .4; height: 30%; } +.tl-ticks .tick.inspect { background: var(--primary); opacity: .4; height: 30%; } .tl-ticks .tick.at { height: 100%; opacity: 1; box-shadow: 0 0 0 2px var(--primary); } /* a change made by hand is a little person: the tick its body, this its head */ .tl-ticks .tick.human { position: relative; height: 40%; } @@ -771,7 +930,6 @@ body.with-timeline .panel { bottom: 62px; } .tl-step .person, .replay-badge .person, .tl-ask .person, .ask-row .person { color: var(--blue); } .tl-ask .glyph, .ask-row .glyph { color: var(--primary); } /* where one ask's steps end and the next one's begin */ -.tl-ticks .tl-sep { flex: none; align-self: stretch; width: 2px; margin: 0 2px; border-radius: 1px; background: var(--border); } .timeline .tl-ask { flex: 0 1 auto; min-width: 0; @@ -783,6 +941,8 @@ body.with-timeline .panel { bottom: 62px; } font-weight: 400; font-size: 12px; } +.timeline .tl-ask:not([hidden]) { display: inline-flex; align-items: center; gap: 6px; } +.timeline .tl-ask .ask-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .timeline .tl-ask[hidden] { display: none; } .panel .ask-session { display: flex; @@ -822,6 +982,7 @@ body.with-timeline .panel { bottom: 62px; } overflow: hidden; } .panel .ask-n { flex: none; color: var(--muted); font-size: 11px; } +.timeline .tl-ask .ask-n { flex: none; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; } .panel .ask-play { flex: none; border: 0; background: none; color: var(--tool); cursor: pointer; } .panel .ask-play:hover { color: var(--primary); } .timeline .tl-step { @@ -835,18 +996,56 @@ body.with-timeline .panel { bottom: 62px; } color: var(--muted); } .timeline .tl-step:disabled { opacity: 1; } -.tl-side { display: flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } +.tl-side { display: flex; flex: none; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } .tl-side[hidden] { display: none; } .timeline .tl-side button { border-radius: 0; font-weight: 400; font-size: 12px; } .timeline .tl-side button[aria-pressed="true"] { background: var(--primary-soft); color: var(--primary); } -.timeline .tl-follow { border: 1px solid var(--border); border-radius: 14px; font-weight: 400; } -.timeline .tl-follow[aria-pressed="true"] { border-color: var(--primary); color: var(--primary); background: var(--primary-soft); } +/* the follow switch: green while it follows live changes, grey while it does not */ +.timeline .tl-follow { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 10px 0 6px; + border: 1px solid var(--border); + border-radius: 14px; + font-weight: 400; + color: var(--muted); +} +.tl-follow .switch { + position: relative; + flex: none; + width: 26px; + height: 15px; + border-radius: 8px; + background: var(--grey); + opacity: .6; + transition: background .15s, opacity .15s; +} +.tl-follow .switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 11px; + height: 11px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgb(0 0 0 / .25); + transition: transform .15s; +} +.timeline .tl-follow[aria-checked="true"] { + border-color: var(--green); + color: color-mix(in srgb, var(--green) 80%, var(--text)); + background: color-mix(in srgb, var(--green) 12%, transparent); +} +.timeline .tl-follow[aria-checked="true"]:hover:not(:disabled) { + color: color-mix(in srgb, var(--green) 80%, var(--text)); + background: color-mix(in srgb, var(--green) 20%, transparent); +} +.tl-follow[aria-checked="true"] .switch { background: var(--green); opacity: 1; } +.tl-follow[aria-checked="true"] .switch::after { transform: translateX(11px); } .replay-badge { - position: fixed; - left: 12px; - top: 64px; - z-index: 12; padding: 4px 10px; border: 1px solid var(--orange); border-radius: 14px; @@ -854,12 +1053,12 @@ body.with-timeline .panel { bottom: 62px; } color: var(--text); font-size: 12px; box-shadow: var(--shadow-bar); - max-width: calc(100vw - 24px); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .replay-badge[hidden] { display: none; } +.replay-badge .off-map { color: var(--muted); } .panel .chg-row { display: flex; align-items: baseline; gap: 8px; } .panel .chg-tag { @@ -872,7 +1071,7 @@ body.with-timeline .panel { bottom: 62px; } } .panel .chg-tag.added { background: var(--green); } .panel .chg-tag.removed { background: var(--red); } -.panel .chg-tag.read { background: var(--blue); } +.panel .chg-tag.inspect { background: var(--blue); } .panel .diff-file { margin: 8px 0 2px; font: 12px var(--mono); } .panel .diff-file span { color: var(--muted); } .panel .diff pre { @@ -894,8 +1093,7 @@ body.with-timeline .panel { bottom: 62px; } .search input { width: 160px; } .panel { width: auto; left: 12px; top: auto; height: 45vh; } .panel:not([hidden]) ~ .zoom { right: 12px; bottom: calc(45vh + 24px); } - body.with-timeline .panel { bottom: 62px; } - body.with-timeline .panel:not([hidden]) ~ .zoom { bottom: calc(45vh + 74px); } + body.with-timeline .panel:not([hidden]) ~ .zoom { bottom: calc(45vh + var(--tl-top, 52px) + 22px); } } /* a VS Code side bar: one column - the crumbs get a row of their own above @@ -910,11 +1108,22 @@ body.with-timeline .panel { bottom: 62px; } .search svg { top: 8px; } .tools .chip { padding: 0 6px; font-size: 11px; } .viewport { top: 84px; } - .panel, .note-bar, .replay-badge { top: 94px; } + .panel, .top-notes { top: 94px; } + .panel { left: 6px; right: 6px; } +} + +/* too narrow for the track beside every switch: it takes a row of its own, + rather than a switch being pushed out of the bar */ +@media (max-width: 720px) { .timeline { flex-wrap: wrap; left: 6px; right: 6px; bottom: 6px; } .tl-track { order: 5; flex-basis: 100%; } .timeline .tl-step { flex: 1 1 0; max-width: none; } .timeline .tl-ask { max-width: 12ch; } - body.with-timeline .zoom, body.with-timeline .panel { bottom: 88px; } - .panel { left: 6px; right: 6px; } + /* `Following · 12`, the word left to the tooltip */ + .tl-follow .tl-behind { display: none; } +} + +@media (max-width: 560px) { + /* `Follow`, the rest of its name left to the tooltip */ + .tl-follow .tl-follow-more { display: none; } } diff --git a/assets/vis/vis.html b/assets/vis/vis.html index d43e3aa..4d2db14 100644 --- a/assets/vis/vis.html +++ b/assets/vis/vis.html @@ -37,8 +37,11 @@ +
+
+ @@ -58,12 +61,16 @@
- + +
+ + +
- diff --git a/assets/vis/vis.js b/assets/vis/vis.js index e1d0b68..fbbaee2 100644 --- a/assets/vis/vis.js +++ b/assets/vis/vis.js @@ -133,7 +133,7 @@ const timer = setTimeout(() => { waiting.delete(id); reject(new Error('the extension did not answer')); - }, 30000); + }, 65000); waiting.set(id, m => { clearTimeout(timer); resolve(m); @@ -270,8 +270,12 @@ } } - // the live map at the route in the address - any replay ends here - async function render(keepView) { + // a level that timed out, rather than one that could not be drawn + const busy = e => /did not answer|timed out/.test(e.message || ''); + + // the live map at the route in the address - any replay ends here; a level + // the analyser was too busy to answer for is asked for once more + async function render(keepView, again) { const seq = ++renderSeq; endReplay(); const route = parseRoute(); @@ -303,6 +307,11 @@ } catch (e) { if (seq !== renderSeq) return; clearCanvas(); + if (!again && busy(e)) { + showMessage('Still drawing this level', 'the analyser is busy - asking again'); + setTimeout(() => seq === renderSeq && render(keepView, true), 1500); + return; + } showMessage('Could not draw this level', e.message); } crumbs(); @@ -353,7 +362,7 @@ gap.style.visibility = 'hidden'; return gap; } - const e = h('div', `pin ${side} ${p.data ? 'data' : p.more ? 'more' : 'exec'}`); + const e = h('div', `pin ${side} ${p.data ? 'data' : p.more ? 'more' : 'exec'}${p.ret ? ' ret' : ''}`); if (p.pin) e.dataset.pin = p.pin; if (p.colour) e.style.setProperty('--pin', p.colour); if (p.title) e.title = p.title; @@ -361,8 +370,12 @@ return e; } + // the pin for what a function returns + const returnsPin = (type, pin) => ({ ret: true, data: true, label: 'Returns ' + type, title: 'returns ' + type, colour: typeColour(type), pin }); + // a node in the blueprint style: a header in the node's colour, then rows of - // input pins on the left and output pins on the right + // input pins on the left and output pins on the right - what it returns + // last, ruled off from the rest function bp({ accent, k, t, q, badges, ins, outs, body, cls }) { const n = h('div', 'node bp' + (cls ? ' ' + cls : '')); n.style.setProperty('--accent', accent || 'var(--grey)'); @@ -379,8 +392,13 @@ if (body) n.append(h('div', 'body', body)); const rows = h('div', 'rows'); const left = ins || []; - const right = outs || []; + const right = (outs || []).filter(p => !p.ret); + const returns = (outs || []).filter(p => p.ret); for (let i = 0; i < Math.max(left.length, right.length); i++) rows.append(pinEl(left[i], 'in'), pinEl(right[i], 'out')); + if (returns.length) { + rows.append(h('div', 'sep')); + for (const p of returns) rows.append(pinEl(null, 'in'), pinEl(p, 'out')); + } n.append(rows); return n; } @@ -660,7 +678,7 @@ return { pin: 'c' + c.to, label: target ? target.name : '?', title: `line ${c.line}` }; }); if (f.calls_total > calls.length) outs.push({ more: true, label: `+${f.calls_total - calls.length} more` }); - if (f.ret) outs.push({ data: true, label: f.ret, colour: typeColour(f.ret), pin: 'ret' }); + if (f.ret) outs.push(returnsPin(f.ret, 'ret')); const badges = []; if (f.entry && !f.module && !f.test) badges.push(badge('mode entry', 'entry', 'nothing else in the map calls it')); if (f.recursive) badges.push(badge('mode', '↻', 'calls itself')); @@ -737,8 +755,15 @@ const f = d.function; const nodes = []; let seq = 0; + // a step is known again in the next drawing by what it is, not where it + // falls - its kind and words, and which of the same it is + const seen = new Map(); const add = (el, kind, data, drill) => { - const n = { id: 'n' + seq++, kind, data, el, drill }; + const what = data ? `${data.qual ? data.qual + '.' : ''}${data.name || data.label || data.kind || ''}` : ''; + const base = `${kind}:${what}`; + const k = seen.get(base) || 0; + seen.set(base, k + 1); + const n = { id: 'n' + seq++, mkey: `${base}#${k}`, kind, data, el, drill }; nodes.push(n); return n; }; @@ -753,7 +778,7 @@ q: `${f.file}:${f.line}`, badges: entryBadges, body: d.callers_total ? `called by ${d.callers.slice(0, 3).map(c => c.name).join(', ')}${d.callers_total > 3 ? ` +${d.callers_total - 3}` : ''}` : f.module ? null : 'nothing in the map calls it', - outs: [{ pin: 'out', label: 'then' }, ...d.params.map((p, i) => ({ pin: 'p' + i, data: true, label: p, colour: typeColour(p.split(/[:\s]/).pop()) }))].concat(f.ret ? [{ data: true, label: '→ ' + f.ret, colour: typeColour(f.ret) }] : []), + outs: [{ pin: 'out', label: 'then' }, ...d.params.map((p, i) => ({ pin: 'p' + i, data: true, label: p, colour: typeColour(p.split(/[:\s]/).pop()) }))].concat(f.ret ? [returnsPin(f.ret)] : []), }), 'entry', { ...d, step: null } @@ -838,7 +863,7 @@ } const tree = blocks(d.steps); - const end = add(bp({ cls: 'terminal', accent: 'var(--faint)', k: 'end', t: f.ret ? 'Falls off the end' : 'End', ins: [{ pin: 'in', label: '' }] }), 'end', { t: 'end' }); + const end = add(bp({ cls: 'terminal', accent: 'var(--faint)', k: 'end', t: 'End of scope', ins: [{ pin: 'in', label: '' }] }), 'end', { t: 'end' }); return { key: 'flow', kind: 'bp', @@ -1024,6 +1049,401 @@ return result; } + // ------------------------------------------------------------- groups + + // a group of this many nodes or more splits into the groups within it, + // and a scene this big reads, zoomed out, as its groups + const GROUP_MIN = 50; + // zoomed out to this a group's nodes fold away into its card, and back in + // to this they return - a gap between, so it never flickers + const GROUP_HIDE = 0.4; + const GROUP_SHOW = 0.5; + // a group that holds groups opens into them once it covers this much of + // the screen, and folds again below this - and one opened by a click stays + // open until zoomed back out past where it fit + const OPEN_AT = 0.75; + const FOLD_AT = 0.6; + const HOLD_AT = 0.85; + // directories with fewer components than this, side by side, are one group + const GROUP_SMALL = 5; + // functions to a run of a file, about, when nothing else groups them + const FN_RUN = 30; + // room round a group's nodes for its outline - its title along the top + const GROUP_PAD = { left: 28, top: 40, right: 28, bottom: 28 }; + const MARKS = ['added', 'removed', 'modified', 'inspect']; + // the shape groups are packed into, about the screen's + const SHELF_ASPECT = 1.6; + // a bundle of wires is counted from this many calls + const BUNDLE_COUNTED = 5; + + // The groups a big scene's nodes fall into, each the context they share - + // a container's components by directory, a directory with too many in it + // split into the directories within it; a file's functions by the type + // that owns them - each before the groups within it. None for a scene + // small enough to read whole. + function groupScene(scene) { + const comps = scene.nodes.filter(n => n.kind === 'component'); + const fns = scene.nodes.filter(n => n.kind === 'function'); + let root; + let unit; + if (comps.length >= GROUP_MIN) [root, unit] = [dirTree(comps), 'component']; + else if (fns.length >= GROUP_MIN) { + const owner = n => (n.data.test ? 'tests' : n.data.module ? 'top level' : n.data.owner || 'free functions'); + const by = new Map(); + for (const n of fns) by.set(owner(n), [...(by.get(owner(n)) || []), n.id]); + const children = [...by].sort((a, b) => a[0].localeCompare(b[0])).map(([key, members]) => ({ key, title: key, path: '', members, children: [] })); + // A group of many with nothing more to tell them apart - a script's free + // functions - splits into runs of the file, a few dozen at a time, each + // called by the functions it runs from and to. + const fn = new Map(fns.map(n => [n.id, n])); + for (const c of children) { + if (c.members.length < GROUP_MIN) continue; + const inOrder = c.members.map(id => fn.get(id)).sort((a, b) => a.data.line - b.data.line); + const size = Math.ceil(inOrder.length / Math.ceil(inOrder.length / FN_RUN)); + for (let at = 0; at < inOrder.length; at += size) { + const run = inOrder.slice(at, at + size); + const [a, b] = [run[0].data, run[run.length - 1].data]; + c.children.push({ key: `${c.key} ${a.line}`, title: `${a.name} … ${b.name}`, path: `lines ${a.line}–${b.end || b.line}`, members: run.map(n => n.id), children: [] }); + } + } + [root, unit] = [{ key: 'functions', title: 'functions', path: '', members: fns.map(n => n.id), children }, 'function']; + } else return null; + // the whole scene one card only when nothing splits it + const tops = root.children.length ? root.children : [root]; + const all = []; + const chains = new Map(); + const walk = (c, up) => { + Object.assign(c, { i: all.length, parent: up[up.length - 1] || null, unit }); + all.push(c); + const chain = [...up, c]; + if (!c.children.length) for (const id of c.members) chains.set(id, chain); + for (const k of c.children) walk(k, chain); + }; + for (const t of tops) walk(t, []); + scene.tops = tops; + scene.chains = chains; + return all; + } + + // A container's components as a tree of their directories: one that holds + // only one directory is passed through, and one with too many components + // splits into the directories in it - its own files a group of their own. + function dirTree(nodes) { + const segs = new Map(nodes.map(n => [n.id, (n.data.dir || '').split('/').filter(Boolean)])); + const named = key => { + const cut = key.lastIndexOf('/'); + return { title: key.slice(cut + 1) || key, path: cut > 0 ? key.slice(0, cut + 1) : '' }; + }; + // `name` - the directory that sets the group apart from the ones beside + // it, which it is called by, however deep its own files sit below it + const build = (items, depth, name) => { + let d = depth; + while (items.every(n => segs.get(n.id).length > d) && new Set(items.map(n => segs.get(n.id)[d])).size === 1) d++; + const key = segs.get(items[0].id).slice(0, d).join('/') || '.'; + const c = { key, ...named(key), members: items.map(n => n.id), children: [] }; + if (name && name !== c.title) Object.assign(c, { title: name, path: key }); + if (items.length < GROUP_MIN) return c; + const by = new Map(); + for (const n of items) { + const s = segs.get(n.id)[d] ?? ''; + if (!by.has(s)) by.set(s, []); + by.get(s).push(n); + } + if (by.size < 2) return c; + const subs = [...by].sort((a, b) => a[0].localeCompare(b[0])); + // a run of little directories reads better as one group than as a card each + const small = subs.filter(([s, sub]) => s && sub.length < GROUP_SMALL); + const merged = small.length >= 2 ? new Set(small.map(([s]) => s)) : new Set(); + for (const [s, sub] of subs) { + if (merged.has(s)) continue; + c.children.push(s ? build(sub, d + 1, s) : { key: key + '/', title: c.title + ' files', path: key + '/', members: sub.map(n => n.id), children: [] }); + } + if (merged.size) { + const members = small.flatMap(([, sub]) => sub.map(n => n.id)); + c.children.push({ key: key + '/…', title: `${merged.size} smaller directories`, path: key + '/', members, children: [] }); + } + return c; + }; + return build(nodes, 0); + } + + // a group and the groups it is in, outermost first + function chainOf(g) { + const out = []; + for (let x = g; x; x = x.parent) out.unshift(x); + return out; + } + + // the classes a node or wire takes from the groups it is in - see `regroup` + const groupClasses = (scene, id) => ((scene.chains && scene.chains.get(id)) || []).map(g => 'cg-' + g.i); + + // is a node out of sight in a folded group + const hiddenByGroup = id => !!S.collapsed && ((S.scene.chains && S.scene.chains.get(id)) || []).some(g => S.collapsed.has(g.i)); + + // A big scene laid out a group at a time: each group's nodes together, the + // groups in a group laid out as nodes of their own, and so on up - so each + // group holds a region of its own, which folded is one overview card. + function layeredGroups(scene) { + const byId = new Map(scene.nodes.map(n => [n.id, n])); + const opts = { gapX: scene.gapX, gapY: scene.gapY, order: scene.order, maxCol: 8 }; + const padW = GROUP_PAD.left + GROUP_PAD.right; + const padH = GROUP_PAD.top + GROUP_PAD.bottom; + // Parts packed in rows, the biggest first, into a block the shape of the + // screen - the calls between groups are their bundled wires, so where a + // group sits needs to follow them less than it needs to be found - and + // where each part's own nodes then sit. + const arrange = (parts, gap) => { + const size = p => (p.k ? p.k.members.length : 0); + const sorted = [...parts].sort((a, b) => size(b) - size(a) || b.h - a.h || String(a.k ? a.k.key : a.id).localeCompare(String(b.k ? b.k.key : b.id))); + const area = parts.reduce((a, p) => a + (p.w + gap) * (p.h + gap), 0); + const row = Math.max(...parts.map(p => p.w), Math.sqrt(area * SHELF_ASPECT)); + const where = new Map(); + let [x, y, tall] = [0, 0, 0]; + for (const p of sorted) { + if (x > 0 && x + p.w > row) [x, y, tall] = [0, y + tall + gap, 0]; + where.set(p.id, { x, y }); + x += p.w + gap; + tall = Math.max(tall, p.h); + } + const pos = new Map(); + let w = 0; + let h = 0; + for (const p of parts) { + const o = where.get(p.id) || { x: 0, y: 0 }; + if (p.k) for (const [id, q] of p.pos) pos.set(id, { x: o.x + q.x, y: o.y + q.y }); + else pos.set(p.id, o); + w = Math.max(w, o.x + p.w); + h = Math.max(h, o.y + p.h); + } + return { pos, w, h }; + }; + // one group laid out: where each of its nodes sits within its box, and the box's size + const lay = c => { + let inner; + if (c.children.length) inner = arrange(c.children.map(k => ({ id: 'g:' + k.i, k, ...lay(k) })), 50); + else { + const members = c.members.map(id => byId.get(id)).filter(Boolean); + const ids = new Set(c.members); + const at = layered(members, scene.edges.filter(e => ids.has(e.from) && ids.has(e.to)), opts); + inner = { pos: new Map(), w: 0, h: 0 }; + for (const n of members) { + const p = at.get(n.id) || { x: 0, y: 0 }; + inner.pos.set(n.id, p); + inner.w = Math.max(inner.w, p.x + n.w); + inner.h = Math.max(inner.h, p.y + n.h); + } + } + const pos = new Map(); + for (const [id, q] of inner.pos) pos.set(id, { x: GROUP_PAD.left + q.x, y: GROUP_PAD.top + q.y }); + return { pos, w: inner.w + padW, h: inner.h + padH }; + }; + const parts = scene.tops.map(t => ({ id: 'g:' + t.i, k: t, ...lay(t) })); + const grouped = new Set(scene.tops.flatMap(t => t.members)); + for (const n of scene.nodes) if (!grouped.has(n.id)) parts.push(n); + return arrange(parts, 90).pos; + } + + // a group's box: round its nodes, or round the boxes of the groups in it, + // with room for its outline - worked out afresh, so it follows a dragged node + function clusterBox(c) { + let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity]; + const take = (a, b, c2, d) => ([x0, y0, x1, y1] = [Math.min(x0, a), Math.min(y0, b), Math.max(x1, c2), Math.max(y1, d)]); + if (c.children.length) { + for (const k of c.children) { + const b = clusterBox(k); + if (b) take(b.x, b.y, b.x + b.w, b.y + b.h); + } + } else { + for (const id of c.members) { + const n = S.byId.get(id); + if (n) take(n.x, n.y, n.x + n.w, n.y + n.h); + } + } + c.box = x0 === Infinity ? null : { x: x0 - GROUP_PAD.left, y: y0 - GROUP_PAD.top, w: x1 - x0 + GROUP_PAD.left + GROUP_PAD.right, h: y1 - y0 + GROUP_PAD.top + GROUP_PAD.bottom }; + return c.box; + } + + // a group as one line: how much is in it + function groupSummary(g) { + const ms = g.members.map(id => S.byId.get(id)).filter(Boolean); + const within = g.children.length ? ` in ${plural(g.children.length, 'group')}` : ''; + if (g.unit === 'component') { + const lines = ms.reduce((a, n) => a + (n.data.lines || 0), 0); + const langs = [...new Set(ms.map(n => langName(n.data.language)))].slice(0, 3).join(', '); + return `${plural(ms.length, 'component')}${within} · ${plural(lines, 'line')}${langs ? ' · ' + langs : ''}`; + } + const entries = ms.filter(n => n.data.entry).length; + return plural(ms.length, 'function') + within + (entries ? ` · ${plural(entries, 'entry point')}` : ''); + } + + // the rules that fold groups away, written as the zoom changes + function groupStyle() { + let el = document.getElementById('group-style'); + if (!el) { + el = document.createElement('style'); + el.id = 'group-style'; + document.head.append(el); + } + return el; + } + + // Which groups are folded at this zoom: one with nodes of its own, until + // zoomed in far enough to read them; one holding groups, until zoomed in + // near to where it alone would fill the screen. A folded group's nodes, + // wires and inner groups go, and its card shows - by a rule each, so + // folding a group of hundreds touches one style. + function regroup() { + const sc = S.scene; + if (!sc || !sc.groups) return; + const vp = $('viewport'); + const z = S.zoom; + // the first look starts from everything folded + const was = S.collapsed || new Set(sc.groups.map(g => g.i)); + const now = new Set(); + const [vw, vh] = [vp.clientWidth, vp.clientHeight]; + // the zoom the whole scene fits at - nothing opens by zoom until past it, so + // the scene fitted reads as its top groups however big a share one takes + if (S.sceneFit == null) { + const b = bounds(); + S.sceneFit = clamp(Math.min((vw - 96) / b.w, (vh - 150) / b.h), minZoom(), 1.15); + } + const past = z > S.sceneFit * 1.15; + const visit = c => { + const folded = was.has(c.i); + let open = z >= (folded ? GROUP_SHOW : GROUP_HIDE); + if (!open && c.children.length && c.box) { + // how much of the screen it would take, wherever it is panned to + const cover = (Math.min(c.box.w * z, vw) * Math.min(c.box.h * z, vh)) / (vw * vh); + const fit = Math.min((vw - 96) / c.box.w, (vh - 150) / c.box.h); + open = (past && cover >= (folded ? OPEN_AT : FOLD_AT)) || (S.opened.has(c.i) && z >= fit * HOLD_AT); + } + if (open) return c.children.forEach(visit); + now.add(c.i); + S.opened.delete(c.i); + }; + sc.tops.forEach(visit); + const same = !!S.collapsed && now.size === S.collapsed.size && [...now].every(i => S.collapsed.has(i)); + S.collapsed = now; + if (!same) { + groupStyle().textContent = [...now].map(i => `.cg-${i}, .gframe-${i} { display: none !important; } .gcard-${i} { display: flex !important; }`).join('\n'); + for (const g of sc.groups) if (now.has(g.i) && g.el && g.box) placeCard(g); + } + if (!same || (now.size && z !== S.groupZoom)) drawGroupWires(); + } + + function placeCard(g) { + Object.assign(g.el.style, { left: g.box.x + 'px', top: g.box.y + 'px', width: g.box.w + 'px', height: g.box.h + 'px' }); + g.el.style.setProperty('--w', g.box.w + 'px'); + g.el.style.setProperty('--h', g.box.h + 'px'); + } + + // Each group as one overview card over the region its nodes hold - shown + // while it is folded: what it is, how much is in it, and how much of it + // the step on show touched. A click opens it. + function drawGroups() { + const host = $('groups'); + host.textContent = ''; + for (const g of S.scene.groups || []) { + if (!g.box) continue; + const el = h('div', 'group-card gcard-' + g.i); + el.classList.add(...chainOf(g).slice(0, -1).map(x => 'cg-' + x.i)); + g.mark = h('div', 'g-mark'); + g.mark.hidden = true; + const what = g.path || (g.unit === 'component' ? 'components' : ['tests', 'top level', 'free functions'].includes(g.key) ? 'functions' : 'type'); + el.append(h('div', 'g-what', what), h('div', 'g-title', g.title), h('div', 'g-sub', groupSummary(g)), g.mark); + el.title = `${g.key} - click, or zoom in, to open it`; + el.addEventListener('click', ev => { + ev.stopPropagation(); + openGroup(g); + }); + g.el = el; + placeCard(g); + host.append(el); + } + markGroups(); + } + + // a group opened: one of nodes zoomed in until they can be read, one of + // groups until it fills the screen and opens into them + function openGroup(g) { + if (!g.children.length) return flyTo(g.members.map(id => S.byId.get(id)).filter(Boolean)); + S.opened.add(g.i); + const vp = $('viewport'); + const b = g.box; + S.zoom = clamp(Math.min((vp.clientWidth - 96) / b.w, (vp.clientHeight - 150) / b.h), minZoom(), 1.15); + S.panX = vp.clientWidth / 2 - (b.x + b.w / 2) * S.zoom; + S.panY = vp.clientHeight / 2 - (b.y + b.h / 2) * S.zoom; + glide(); + applyView(); + } + + // a group whose nodes the step on show touched is marked as most of them are, and counts them + function markGroups() { + for (const g of (S.scene && S.scene.groups) || []) { + if (!g.el) continue; + const hows = []; + for (const id of g.members) { + const n = S.byId.get(id); + const how = n && MARKS.find(m => n.el.classList.contains('chg-' + m)); + if (how) hows.push(how); + } + g.el.classList.remove('chg', ...MARKS.map(m => 'chg-' + m)); + g.mark.hidden = !hows.length; + if (!hows.length) continue; + const most = MARKS.map(m => [m, hows.filter(x => x === m).length]).sort((p, q) => q[1] - p[1])[0][0]; + g.el.classList.add('chg', 'chg-' + most); + g.mark.textContent = `${hows.length} ${most === 'inspect' ? 'inspected' : 'changed'}`; + } + } + + // The wires to and between folded groups: one for each pair of what is in + // sight, as thick as the calls it carries and counted - drawn for the zoom, + // so they read the same however far out. + function drawGroupWires() { + const svg = $('group-wires'); + svg.textContent = ''; + const sc = S.scene; + if (!sc || !sc.groups || !S.collapsed || !S.collapsed.size) return; + const z = 1 / S.zoom; + S.groupZoom = S.zoom; + $('groups').style.setProperty('--gz', String(z)); + // what stands for a node: the outermost folded group it is in, if any + const stand = id => ((sc.chains && sc.chains.get(id)) || []).find(g => S.collapsed.has(g.i)) || null; + const all = new Map(); + for (const e of sc.edges) { + const a = stand(e.from); + const b = stand(e.to); + if ((!a && !b) || a === b) continue; + const k = `${a ? 'g' + a.i : e.from}>${b ? 'g' + b.i : e.to}`; + if (!all.has(k)) all.set(k, { a: a ? a.box : S.byId.get(e.from), b: b ? b.box : S.byId.get(e.to), count: 0 }); + all.get(k).count += (e.data && e.data.count) || 1; + } + const ns = 'http://www.w3.org/2000/svg'; + const make = (tag, attrs) => { + const x = document.createElementNS(ns, tag); + for (const [k, v] of Object.entries(attrs)) x.setAttribute(k, v); + return x; + }; + for (const { a, b, count } of all.values()) { + if (!a || !b || a.w == null || b.w == null) continue; + const [p1, p2] = sideAnchors(a, b); + const len = Math.hypot(p2.x - p1.x, p2.y - p1.y) || 1; + const ux = (p2.x - p1.x) / len; + const uy = (p2.y - p1.y) / len; + const s = 9 * z; + const bx = p2.x - ux * s; + const by = p2.y - uy * s; + // a thin bundle is told by its thickness alone - counting every one crowds the cards + const label = make('text', { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2, 'font-size': 12 * z, style: `stroke-width: ${4 * z}` }); + label.textContent = count >= BUNDLE_COUNTED ? `×${count}` : ''; + svg.append( + make('path', { d: `M${p1.x} ${p1.y} L${bx} ${by}`, class: 'gwire', 'vector-effect': 'non-scaling-stroke', style: `stroke-width: ${1.5 + Math.min(4, Math.log2(count))}` }), + make('path', { d: `M${p2.x} ${p2.y} L${bx - uy * s * 0.55} ${by + ux * s * 0.55} L${bx + uy * s * 0.55} ${by - ux * s * 0.55} z`, class: 'ghead' }), + label + ); + } + } + // the context level: the system in the middle, what calls in on its left, // what it reaches on its right function layoutContext(scene) { @@ -1129,25 +1549,39 @@ $('note').hidden = true; S.scene = null; S.byId = new Map(); - } - + S.lit = null; + $('lit-wires').textContent = ''; + $('wires').classList.remove('dimming'); + $('groups').textContent = ''; + $('group-wires').textContent = ''; + groupStyle().textContent = ''; + S.collapsed = null; + S.opened = new Set(); + S.sceneFit = null; + S.groupZoom = null; + S.alsoLit = new Set(); + } + + // Each pin's dot centre, from the node's outer corner - as drawn, to the + // fraction of a pixel, and back out of the scale it is drawn at, whatever + // the camera or a running animation holds. function measurePins(n) { n.pins = new Map(); + const box = n.el.getBoundingClientRect(); + const k = box.width / (n.el.offsetWidth || 1) || 1; for (const p of n.el.querySelectorAll('[data-pin]')) { - const dot = p.querySelector('i') || p; - let x = dot.offsetWidth / 2; - let y = dot.offsetHeight / 2; - let e = dot; - while (e && e !== n.el) { - x += e.offsetLeft; - y += e.offsetTop; - e = e.offsetParent; - } - n.pins.set(p.dataset.pin, { x, y }); + const dot = (p.querySelector('i') || p).getBoundingClientRect(); + n.pins.set(p.dataset.pin, { x: (dot.left + dot.width / 2 - box.left) / k, y: (dot.top + dot.height / 2 - box.top) / k }); } } function mount(scene, keepView) { + const was = remember(); + const same = !!was && sameView(was.route, S.route); + const via = S.via; + S.via = null; + // another view: the one on show lifted off as a picture, to move away as this one comes in + const ghost = was && !same ? lift(was) : null; clearCanvas(); // the list of asks belongs to no one level, so it stays while they are walked if (!S.asksPanel) hidePanel(); @@ -1167,9 +1601,23 @@ n.h = n.el.offsetHeight; measurePins(n); } + // a big scene is laid out a group at a time, and zoomed out reads as its groups + if (scene.groups === undefined) { + scene.groups = scene.layout === 'layered' ? groupScene(scene) : null; + if (scene.groups) { + // laid out afresh, apart from where its nodes sat ungrouped + scene.key += ':grouped'; + scene.frames = [ + ...(scene.frames || []), + ...scene.groups.map(g => ({ title: g.key, sub: plural(g.members.length, g.unit), members: g.members, cls: 'group', group: g })), + ]; + } + } + for (const n of scene.nodes) n.el.classList.add(...groupClasses(scene, n.id)); let pos; if (scene.layout === 'flow') pos = layoutFlow(scene); else if (scene.layout === 'context') pos = layoutContext(scene); + else if (scene.groups) pos = layeredGroups(scene); else pos = layered(scene.nodes, scene.edges, scene); const saved = scene.persist ? store.get('pos:' + scene.key, {}) : {}; for (const n of scene.nodes) { @@ -1181,6 +1629,7 @@ } drawFrames(); drawEdges(); + drawGroups(); if (scene.note) { $('note').textContent = scene.note; $('note').hidden = false; @@ -1190,8 +1639,375 @@ if (keepView) applyView(); else fit(); const changed = applyMarks(); - if (S.fly && changed.length) flyTo(changed); + for (const n of changed) if (n.el.classList.contains('chg-added')) keepNew('node:' + morphKey(n)); + // the camera goes straight to where it ends, and the motion carries it there + const moving = same || !!ghost; + if (S.fly && changed.length) flyTo(changed, moving); S.fly = false; + if (same) morph(was); + else if (ghost) travel(was, ghost, via); + showKept(); + showOffMap(); + S.shownRoute = S.route && { ...S.route }; + } + + // ------------------------------------------------------------- additions kept + + // What each step added - nodes, the pins of a parameter, a return or a + // call, and wires - by the view it was seen in. Those of the collection on + // show, up to the step on show, stay green: a flash fades to grey long + // before the ask is done. Another collection on show, they rest. + const KEPT = new Map(); + // steps whose additions are kept, at most - the timeline holds fewer + const KEPT_MAX = 400; + + // a view as its additions are kept: a function by its name, so a step that + // moves it keeps them + const keptView = r => (!r ? '' : r.level === 'flow' ? `flow:${r.file}@${r.name || r.line}` : href(r)); + + function keepNew(key) { + const ev = T.at != null ? T.steps[T.at] : null; + if (!ev) return; + const v = keptView(S.route); + if (!KEPT.has(ev.seq)) KEPT.set(ev.seq, new Map()); + const views = KEPT.get(ev.seq); + if (!views.has(v)) views.set(v, new Set()); + views.get(v).add(key); + if (KEPT.size > KEPT_MAX) { + const held = new Set(T.steps.map(s => s.seq)); + for (const seq of KEPT.keys()) if (!held.has(seq)) KEPT.delete(seq); + } + } + + // the additions of the collection on show, up to the step on show, green on the scene + function showKept() { + for (const el of document.querySelectorAll('.kept-new')) el.classList.remove('kept-new'); + if (!S.scene || T.at == null) return; + const v = keptView(S.route); + const kept = new Set(); + for (const i of shownSteps()) { + if (i > T.at) break; + for (const k of KEPT.get(T.steps[i].seq)?.get(v) || []) kept.add(k); + } + if (!kept.size) return; + const small = S.scene.nodes.length <= MOTION_MAX; + for (const n of S.scene.nodes) { + const mk = morphKey(n); + if (kept.has('node:' + mk)) n.el.classList.add('kept-new'); + if (small && n.el.classList.contains('bp')) for (const p of pinsOf(n)) if (kept.has(`pin:${mk}|${p.key}`)) p.el.classList.add('kept-new'); + } + const keyOf = id => (S.byId.has(id) ? morphKey(S.byId.get(id)) : String(id)); + for (const e of S.scene.edges) if (e.path && kept.has('wire:' + wireKey(e, keyOf))) e.path.classList.add('kept-new'); + } + + // ------------------------------------------------------------- motion + + // motion is for those who want it + const calm = () => matchMedia('(prefers-reduced-motion: reduce)').matches; + // a scene past this many nodes changes in a cut - moving thousands of + // elements at once is a stall, not an animation + const MOTION_MAX = 600; + // and past this many coming and going, they simply appear + const MOTION_CROWD = 150; + const EASE = 'cubic-bezier(.2, .7, .2, 1)'; + const MOVE_MS = 480; + const DEPTH = { system: 0, containers: 1, components: 2, code: 3, flow: 4 }; + // words on a node that, changed, are pointed out + const WORDS = ['.head .t', '.head .q', '.body', '.chain', '.name', '.tech']; + + // a node as the next scene knows it again + const morphKey = n => n.mkey || n.key || n.id; + + // The same view drawn again - a step's other side, a function after an + // edit, the map after a save - rather than a move to another. + function sameView(a, b) { + if (!a || !b || a.level !== b.level) return false; + if (a.level === 'flow') return a.file === b.file && (a.name ? a.name === b.name : a.line === b.line); + return href(a) === href(b); + } + + // a parameter is its name - `n: i32` is still `n` whatever its type becomes + const pinName = label => label.split(':')[0].trim(); + + // a node's pins as the eye reads them: the side, what each says, how far down + function pinsOf(n) { + const out = []; + const seen = new Map(); + const top = n.el.getBoundingClientRect().top; + for (const p of n.el.querySelectorAll('.rows > .pin')) { + const label = p.querySelector('.lbl')?.textContent || ''; + if (p.style.visibility === 'hidden' || (!label && !p.dataset.pin)) continue; + const side = p.classList.contains('in') ? 'in' : 'out'; + const base = `${side}:${p.classList.contains('ret') ? 'ret' : pinName(label) || p.dataset.pin}`; + const k = seen.get(base) || 0; + seen.set(base, k + 1); + out.push({ key: `${base}#${k}`, label, side, el: p, top: (p.getBoundingClientRect().top - top) / S.zoom }); + } + return out; + } + + const wordsOf = el => new Map(WORDS.map(s => [s, el.querySelector(s)?.textContent ?? null])); + + // a wire by the nodes it joins, as the next scene knows them again + function wireKey(e, keyOf) { + const pin = S.scene.layout === 'flow' ? e.fromPin || '' : ''; + return `${keyOf(e.from)}:${pin}>${keyOf(e.to)}|${e.cls || ''}`; + } + + // what the canvas shows now, for the scene that follows to move on from + function remember() { + if (!S.scene || !S.shownRoute || calm()) return null; + const size = S.scene.nodes.length; + const nodes = new Map(); + const small = size <= MOTION_MAX; + for (const n of S.scene.nodes) { + nodes.set(morphKey(n), { n, x: n.x, y: n.y, pins: small && n.el.classList.contains('bp') ? pinsOf(n) : [], words: small ? wordsOf(n.el) : null }); + } + const keyOf = id => (S.byId.has(id) ? morphKey(S.byId.get(id)) : String(id)); + const wires = new Map(); + if (small) for (const e of S.scene.edges) if (e.path) wires.set(wireKey(e, keyOf), { d: e.path.getAttribute('d'), from: keyOf(e.from), to: keyOf(e.to) }); + return { route: S.shownRoute, size, nodes, wires, view: { zoom: S.zoom, panX: S.panX, panY: S.panY } }; + } + + // an animation that tidies up after itself, and is no promise of one + function animate(el, frames, opts, after) { + const a = el.animate(frames, opts); + if (after) a.finished.then(after, after); + return a; + } + + // a class put on for the length of its animation, again if it was already + function flash(el, cls) { + el.classList.remove(cls); + void el.offsetWidth; + el.classList.add(cls); + el.addEventListener('animationend', () => el.classList.remove(cls), { once: true }); + } + + const cameraOf = v => `translate(${v.panX}px, ${v.panY}px) scale(${v.zoom})`; + const gridOf = v => ({ backgroundSize: `${20 * v.zoom}px ${20 * v.zoom}px`, backgroundPosition: `${v.panX}px ${v.panY}px` }); + + // the camera carried from where it was to where it is now + function carryCamera(from) { + const to = { zoom: S.zoom, panX: S.panX, panY: S.panY }; + if (Math.abs(from.zoom - to.zoom) < 1e-3 && Math.abs(from.panX - to.panX) < 0.5 && Math.abs(from.panY - to.panY) < 0.5) return; + $('viewport').classList.remove('glide'); + for (const a of $('surface').getAnimations()) a.cancel(); + animate($('surface'), [{ transform: cameraOf(from) }, { transform: cameraOf(to) }], { duration: MOVE_MS, easing: EASE }); + animate($('viewport'), [gridOf(from), gridOf(to)], { duration: MOVE_MS, easing: EASE }); + } + + // The same view drawn again: what stayed glides to where it sits now, what + // came grows in, what went fades where it was, and the pins and words of a + // node that changed say so - a parameter, a return type, a call. + function morph(was) { + carryCamera(was.view); + const now = S.scene.nodes; + if (now.length > MOTION_MAX || was.size > MOTION_MAX) return; + const kept = new Set(now.map(morphKey).filter(k => was.nodes.has(k))); + const crowd = now.length - kept.size + was.nodes.size - kept.size > MOTION_CROWD; + // pins stamped so far, so each lands after the one before + let stamped = 0; + for (const n of now) { + const old = was.nodes.get(morphKey(n)); + if (!old) { + keepNew('node:' + morphKey(n)); + if (!crowd) enter(n.el); + continue; + } + const dx = old.x - n.x; + const dy = old.y - n.y; + if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) animate(n.el, [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: MOVE_MS, easing: EASE }); + if (old.pins.length || n.el.classList.contains('bp')) stamped = morphPins(n, old.pins, stamped); + if (old.words) morphWords(n.el, old.words); + } + if (!crowd) for (const [k, old] of was.nodes) if (!kept.has(k)) leave(old.n.el); + morphWires(was.wires); + } + + // a node that was not there before grows in, and glows a moment + function enter(el) { + animate(el, [{ opacity: 0, transform: 'scale(.86)' }, { opacity: 1, transform: 'none' }], { duration: 420, delay: 140, easing: EASE, fill: 'backwards' }); + flash(el, 'arrived'); + } + + // a node gone from the scene fades out where it was + function leave(el) { + if (el.isConnected) return; + el.classList.add('leaving'); + el.classList.remove('selected', 'hit', 'faded'); + delete el.dataset.id; + $('nodes').append(el); + animate(el, [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'scale(.86)' }], { duration: 520, easing: EASE, fill: 'forwards' }, () => el.remove()); + } + + // when the first pin is stamped - once the nodes have settled - the gap + // to the next, how many land one by one before the rest land together, + // and how long a stamp's ink takes to dry + const STAMP_AT = 340; + const STAMP_GAP = 170; + const STAMP_RUN = 10; + const STAMP_MS = 1700; + + // A node's pins against what they were. One that came, or whose words + // changed, is stamped onto the node, one after another; one that went + // slides off the node struck through. `stamped` is how many landed before + // this node's, and the count is handed on. + function morphPins(n, before, stamped) { + const was = new Map(before.map(p => [p.key, p])); + const now = new Set(); + for (const p of pinsOf(n)) { + now.add(p.key); + const old = was.get(p.key); + if (old && old.label === p.label) continue; + if (!old) keepNew(`pin:${morphKey(n)}|${p.key}`); + stamp(p.el, STAMP_AT + Math.min(stamped++, STAMP_RUN) * STAMP_GAP, old ? old.label : null); + } + for (const p of before) if (!now.has(p.key)) pinLeave(n, p); + return stamped; + } + + // A pin pressed onto its node `at` ms from now: it lands from above, presses + // in and leaves a ring of ink - green for one that came, amber for one + // whose words changed. Those old words stay put until the stamp comes + // down, and lift off as it lands. + function stamp(pin, at, old) { + pin.style.setProperty('--stamp-at', at + 'ms'); + pin.classList.toggle('changed', old != null); + pin.classList.add('stamp'); + setTimeout(() => pin.classList.remove('stamp', 'changed'), at + STAMP_MS); + const lbl = old != null && pin.querySelector('.lbl'); + if (!lbl) return; + const ghost = h('span', 'lbl-was', old); + ghost.style.left = lbl.offsetLeft + 'px'; + ghost.style.top = lbl.offsetTop + 'px'; + pin.append(ghost); + animate(ghost, [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(7px) scale(.9)' }], { duration: 260, delay: Math.max(0, at - 120), easing: 'ease-in', fill: 'both' }, () => ghost.remove()); + } + + function pinLeave(n, p) { + const g = p.el; + if (g.isConnected) return; + g.classList.add('pin-out'); + g.removeAttribute('data-pin'); + g.style.top = Math.max(0, Math.min(p.top, n.el.offsetHeight - 22)) + 'px'; + g.style[p.side === 'in' ? 'left' : 'right'] = '0'; + n.el.append(g); + const off = p.side === 'in' ? -28 : 28; + animate(g, [{ opacity: 1, transform: 'none' }, { opacity: 1, transform: 'none', offset: 0.35 }, { opacity: 0, transform: `translateX(${off}px)` }], { duration: 900, easing: EASE, fill: 'forwards' }, () => g.remove()); + } + + function morphWords(el, before) { + for (const [sel, old] of before) { + const x = el.querySelector(sel); + if (x && old != null && x.textContent !== old) flash(x, 'txt-change'); + } + } + + // The wires settle in once the nodes have, the old fading as they go. A + // wire that came draws itself in, with both its ends kept in sight; one + // that went fades red, and a node it leaves that nothing on show still + // reaches fades back after it. + function morphWires(before) { + const keyOf = id => (S.byId.has(id) ? morphKey(S.byId.get(id)) : String(id)); + const now = new Map(); + for (const e of S.scene.edges) if (e.path) now.set(wireKey(e, keyOf), e); + const svg = $('wires'); + const old = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + old.setAttribute('class', 'ghost-wires'); + const byKey = new Map(S.scene.nodes.map(n => [morphKey(n), n])); + for (const [k, w] of before) { + const p = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + p.setAttribute('d', w.d); + p.setAttribute('class', 'wire' + (now.has(k) ? '' : ' gone')); + old.append(p); + if (now.has(k)) continue; + for (const end of [w.from, w.to]) { + const n = byKey.get(end); + if (n && n.el.classList.contains('faded')) animate(n.el, [{ opacity: 1 }, { opacity: 1, offset: 0.4 }, { opacity: 0.2 }], { duration: 900, easing: 'ease-out' }); + } + } + svg.insertBefore(old, $('wire-layer')); + animate(old, [{ opacity: 1 }, { opacity: 0 }], { duration: 360, easing: 'ease-out', fill: 'forwards' }, () => old.remove()); + animate($('wire-layer'), [{ opacity: 0 }, { opacity: 0, offset: 0.55 }, { opacity: 1 }], { duration: MOVE_MS + 260 }); + const fresh = [...now].filter(([k]) => !before.has(k)); + for (const [k, e] of fresh) { + keepNew('wire:' + k); + // both ends of a new connection stay in sight, however the step is seen + e.path.classList.remove('faded'); + for (const id of [e.from, e.to]) { + const n = S.byId.get(id); + if (!n) continue; + S.alsoLit.add(id); + n.el.classList.remove('faded'); + } + } + if (!before.size || fresh.length > MOTION_CROWD) return; + for (const [, e] of fresh) { + const len = e.path.getTotalLength(); + animate(e.path, [{ strokeDasharray: `${len}`, strokeDashoffset: `${len}` }, { strokeDasharray: `${len}`, strokeDashoffset: '0' }], { duration: 520, delay: MOVE_MS, easing: EASE, fill: 'backwards' }); + flash(e.path, 'wire-new'); + } + } + + // the view on show lifted off the canvas as one picture - one move at a + // time, so a move made before the last one finished cuts it short + function lift(was) { + for (const g of document.querySelectorAll('.ghost')) g.remove(); + for (const a of $('surface').getAnimations()) a.cancel(); + if (was.size > MOTION_MAX) return null; + const ghost = h('div', 'ghost'); + ghost.style.transform = $('surface').style.transform; + for (const id of ['frames', 'wires', 'labels', 'nodes', 'groups', 'group-wires']) { + const c = $(id).cloneNode(true); + c.removeAttribute('id'); + for (const x of c.querySelectorAll('[id]')) x.removeAttribute('id'); + ghost.append(c); + } + // folded as it was - the rules that fold its groups go with the next scene + for (const i of S.collapsed || []) { + for (const x of ghost.querySelectorAll(`.cg-${i}, .gframe-${i}`)) x.remove(); + for (const x of ghost.querySelectorAll(`.gcard-${i}`)) x.style.display = 'flex'; + } + $('viewport').append(ghost); + return ghost; + } + + // From one view to another. Going in, the old view swells through what was + // opened as the new one grows out of it; coming back up, the old view + // shrinks into where it sits in the new. Elsewhere, one fades into the other. + function travel(was, ghost, via) { + const vp = $('viewport').getBoundingClientRect(); + const surface = $('surface'); + const final = surface.style.transform; + const from = ghost.style.transform; + const done = () => ghost.remove(); + const deeper = (DEPTH[S.route.level] ?? 0) > (DEPTH[was.route.level] ?? 0); + let at = deeper ? via : null; + if (!deeper && (DEPTH[S.route.level] ?? 0) < (DEPTH[was.route.level] ?? 0)) { + // the node in this view that opens the one left + const back = S.scene.nodes.find(n => n.drill && sameView(n.drill, was.route)); + if (back) at = { left: vp.left + S.panX + back.x * S.zoom, top: vp.top + S.panY + back.y * S.zoom, width: back.w * S.zoom, height: back.h * S.zoom }; + } + $('viewport').classList.remove('glide'); + if (!at || !at.width || !at.height) { + animate(ghost, [{ opacity: 1 }, { opacity: 0 }], { duration: 260, easing: 'ease-out', fill: 'forwards' }, done); + animate(surface, [{ opacity: 0, transform: `translateY(8px) ${final}` }, { opacity: 1, transform: final }], { duration: 360, delay: 80, easing: EASE, fill: 'backwards' }); + return; + } + const cx = at.left + at.width / 2 - vp.left; + const cy = at.top + at.height / 2 - vp.top; + // how small the whole view is, held within the node + const k = clamp(Math.max(at.width / vp.width, at.height / vp.height), 0.12, 0.9); + const about = (s, t) => `translate(${cx}px, ${cy}px) scale(${s}) translate(${-cx}px, ${-cy}px) ${t}`; + const big = Math.min(1 / k, 4); + if (deeper) { + animate(ghost, [{ opacity: 1, transform: from }, { opacity: 0, transform: about(big, from) }], { duration: MOVE_MS, easing: EASE, fill: 'forwards' }, done); + animate(surface, [{ opacity: 0, transform: about(k, final) }, { opacity: 1, transform: final }], { duration: MOVE_MS, easing: EASE }); + } else { + animate(ghost, [{ opacity: 1, transform: from }, { opacity: 0, transform: about(k, from) }], { duration: MOVE_MS, easing: EASE, fill: 'forwards' }, done); + animate(surface, [{ opacity: 0, transform: about(Math.min(big, 1.6), final) }, { opacity: 1, transform: final }], { duration: MOVE_MS, easing: EASE }); + } } // a step's scene past this many nodes keeps only what it touched and what @@ -1235,14 +2051,27 @@ function drawFrames() { const host = $('frames'); host.textContent = ''; + // each group's outline holds the outlines of the groups in it + for (const t of S.scene.tops || []) clusterBox(t); for (const f of S.scene.frames || []) { const members = f.members.map(id => S.byId.get(id)).filter(Boolean); if (!members.length) continue; - const x0 = Math.min(...members.map(n => n.x)) - 28; - const y0 = Math.min(...members.map(n => n.y)) - 28; - const x1 = Math.max(...members.map(n => n.x + n.w)) + 28; - const y1 = Math.max(...members.map(n => n.y + n.h)) + 56; + let x0 = Math.min(...members.map(n => n.x)) - 28; + let y0 = Math.min(...members.map(n => n.y)) - 28; + let x1 = Math.max(...members.map(n => n.x + n.w)) + 28; + let y1 = Math.max(...members.map(n => n.y + n.h)) + 56; + const g = f.group && f.group.box; + if (g) [x0, y0, x1, y1] = [g.x, g.y, g.x + g.w, g.y + g.h]; + else { + // and an outline round groups holds them whole + const mine = new Set(f.members); + for (const t of S.scene.tops || []) { + if (!t.box || !t.members.some(id => mine.has(id))) continue; + [x0, y0, x1, y1] = [Math.min(x0, t.box.x - 28), Math.min(y0, t.box.y - 28), Math.max(x1, t.box.x + t.box.w + 28), Math.max(y1, t.box.y + t.box.h + 56)]; + } + } const box = h('div', 'frame' + (f.cls ? ' ' + f.cls : '')); + if (f.group) box.classList.add('gframe-' + f.group.i, ...chainOf(f.group).slice(0, -1).map(x => 'cg-' + x.i)); Object.assign(box.style, { left: x0 + 'px', top: y0 + 'px', width: x1 - x0 + 'px', height: y1 - y0 + 'px' }); const title = h('div', 'title', f.title); if (f.sub) title.append(h('small', null, `[${f.sub}]`)); @@ -1287,8 +2116,25 @@ return curve(p1, p2); } + // the reach of each bend of a wire that runs along a floor, and the least run worth drawing between them + const BEND = 64; + const FLOOR_RUN = 48; + + // Down to a floor below what lies between, along it, and up into the + // target. Too close for a run along the floor, the two bends would fold + // back over each other - so they meet level at the lowest point instead, + // one smooth dip. function below(p1, p2, yb) { - return `M${p1.x} ${p1.y} C${p1.x + 34} ${p1.y} ${p1.x + 34} ${yb} ${p1.x + 64} ${yb} L${p2.x - 64} ${yb} C${p2.x - 34} ${yb} ${p2.x - 34} ${p2.y} ${p2.x} ${p2.y}`; + const span = p2.x - p1.x; + if (span >= 2 * BEND + FLOOR_RUN) { + return `M${p1.x} ${p1.y} C${p1.x + 34} ${p1.y} ${p1.x + 34} ${yb} ${p1.x + BEND} ${yb} L${p2.x - BEND} ${yb} C${p2.x - 34} ${yb} ${p2.x - 34} ${p2.y} ${p2.x} ${p2.y}`; + } + const mx = (p1.x + p2.x) / 2; + // which way the dip runs along its floor, and how level it lies there + const dir = span < 0 ? -1 : 1; + const level = Math.max(12, Math.abs(span) / 4); + const lead = clamp(Math.abs(span) / 2, 24, 40); + return `M${p1.x} ${p1.y} C${p1.x + lead} ${p1.y} ${mx - dir * level} ${yb} ${mx} ${yb} C${mx + dir * level} ${yb} ${p2.x - lead} ${p2.y} ${p2.x} ${p2.y}`; } function backTo(p1, n, yb) { @@ -1335,6 +2181,10 @@ wires.append(path, hit); e.path = path; e.hit = hit; + // a wire at a grouped node goes while a group of its stands in for it + e.groups = [...new Set([...groupClasses(S.scene, e.from), ...groupClasses(S.scene, e.to)])]; + path.classList.add(...e.groups); + hit.classList.add(...e.groups); if (c4 && e.label && e.mid) { const lab = h('div', 'elabel' + (e.cls === 'declared' ? ' declared' : ''), e.label); lab.style.left = e.mid.x + 'px'; @@ -1345,6 +2195,7 @@ ev.stopPropagation(); selectEdge(e); }); + lab.classList.add(...e.groups); labels.append(lab); e.labelEl = lab; } @@ -1365,40 +2216,99 @@ e.labelEl.style.top = e.mid.y + 'px'; } } + // the lit copies follow the wires they copy + if (S.lit && S.lit.edges.some(e => e.from === id || e.to === id)) highlight(S.lit.id); + } + + // the wires at each node of the scene, gathered the first time they are asked for + function touching(id) { + const sc = S.scene; + if (!sc.touching) { + sc.touching = new Map(); + for (const e of sc.edges) { + for (const end of new Set([e.from, e.to])) { + if (!sc.touching.has(end)) sc.touching.set(end, []); + sc.touching.get(end).push(e); + } + } + } + return (sc.touching.get(id) || []).filter(e => e.path); } + // A node's wires lit and the rest dimmed. The scene's wires are one drawing, + // dimmed whole and never redrawn; the lit ones are copies on a layer of + // their own - so a pointer moving over thousands of wires redraws a handful. function highlight(id) { if (!S.scene) return; const c4 = S.scene.kind === 'c4'; + const quiet = S.scene.quietLabels; + const was = S.lit || { edges: [], nodes: [] }; + for (const e of was.edges) { + if (!e.labelEl) continue; + e.labelEl.classList.remove('hot'); + if (quiet) e.labelEl.style.display = 'none'; + } + for (const n of was.nodes) n.el.classList.remove('hit'); + const edges = id == null ? [] : touching(id); + const lit = $('lit-wires'); + lit.textContent = ''; const near = new Set(); - for (const e of S.scene.edges) { - if (!e.path) continue; - const hot = id != null && (e.from === id || e.to === id); - e.path.classList.toggle('hot', hot); - e.path.classList.toggle('dim', id != null && !hot); - if (c4) e.path.setAttribute('marker-end', hot ? 'url(#arrow-hot)' : 'url(#arrow)'); + for (const e of edges) { + const copy = e.path.cloneNode(false); + copy.classList.add('hot'); + if (c4) copy.setAttribute('marker-end', 'url(#arrow-hot)'); + lit.append(copy); if (e.labelEl) { - e.labelEl.classList.toggle('dim', id != null && !hot); - if (S.scene.quietLabels) e.labelEl.style.display = hot ? '' : 'none'; - } - if (hot) { - near.add(e.from); - near.add(e.to); + e.labelEl.classList.add('hot'); + if (quiet) e.labelEl.style.display = ''; } + near.add(e.from); + near.add(e.to); } - for (const n of S.scene.nodes) n.el.classList.toggle('hit', id != null && n.id !== id && near.has(n.id)); + near.delete(id); + const nodes = [...near].map(n => S.byId.get(n)).filter(Boolean); + for (const n of nodes) n.el.classList.add('hit'); + S.lit = { id, edges, nodes }; + $('wires').classList.toggle('dimming', id != null); + $('labels').classList.toggle('dimming', id != null); + } + + // Hover lights a node's wires a moment after the pointer reaches it, and + // lets them go a little later, so a pointer crossing the gaps between nodes + // never flashes the whole scene. + let hoverTimer = 0; + function hoverSoon() { + clearTimeout(hoverTimer); + hoverTimer = setTimeout(() => S.selected == null && highlight(S.hover), S.hover == null ? 150 : 30); } // ------------------------------------------------------------- view + // The camera: the surface moved and scaled as one layer, and the grid with + // it - set on those two alone, never as a property the thousands of nodes + // below would inherit and each be styled again for. function applyView() { const vp = $('viewport'); - vp.style.setProperty('--zoom', S.zoom); - vp.style.setProperty('--pan-x', S.panX); - vp.style.setProperty('--pan-y', S.panY); + $('surface').style.transform = `translate(${S.panX}px, ${S.panY}px) scale(${S.zoom})`; + vp.style.backgroundSize = `${20 * S.zoom}px ${20 * S.zoom}px`; + vp.style.backgroundPosition = `${S.panX}px ${S.panY}px`; // at 30% and below the grid goes - see `.viewport.far` vp.classList.toggle('far', S.zoom <= 0.3); - $('zoom-level').textContent = Math.round(S.zoom * 100) + '%'; + // far out, a whole percent would read 0 + const pct = S.zoom * 100; + $('zoom-level').textContent = (pct >= 10 ? Math.round(pct) : pct >= 1 ? pct.toFixed(1) : pct.toPrecision(2)) + '%'; + // a big scene zoomed out reads as its groups, and zoomed back in as its nodes + regroup(); + } + + // the camera once a frame, however many pointer or wheel events the frame brings + let viewFrame = 0; + function viewSoon() { + if (viewFrame) return; + viewFrame = requestAnimationFrame(() => { + viewFrame = 0; + applyView(); + }); } function bounds() { @@ -1421,12 +2331,19 @@ // and is often far wider than the screen: past the point where its text // stops being legible it opens on the entry node instead. `whole` forces // the full view. + // as far out as the camera goes: as far as wanted - the floor only keeps + // the zoom from reaching nothing + const MIN_ZOOM = 1e-6; + const minZoom = () => MIN_ZOOM; + function fit(whole) { + // fitted whole, a big scene reads as its top groups again + if (S.opened) S.opened.clear(); const vp = $('viewport'); const b = bounds(); const vw = vp.clientWidth; const vh = vp.clientHeight; - const z = clamp(Math.min((vw - 96) / b.w, (vh - 150) / b.h), 0.08, 1.15); + const z = clamp(Math.min((vw - 96) / b.w, (vh - 150) / b.h), minZoom(), 1.15); const entry = S.scene && S.scene.entry; if (whole !== true && entry && z < 0.55) { S.zoom = clamp((vh - 150) / b.h, 0.55, 1); @@ -1441,11 +2358,11 @@ } function zoomAt(mx, my, factor) { - const z = clamp(S.zoom * factor, 0.08, 3); + const z = clamp(S.zoom * factor, minZoom(), 3); S.panX = mx - ((mx - S.panX) * z) / S.zoom; S.panY = my - ((my - S.panY) * z) / S.zoom; S.zoom = z; - applyView(); + viewSoon(); } function zoomBy(factor) { @@ -1460,6 +2377,8 @@ S.selected = id; highlight(id); if (id == null) return hidePanel(); + // a node a folded group stands in for is gone to, near enough to see it + if (hiddenByGroup(id)) flyTo([S.byId.get(id)]); showPanelFor(S.byId.get(id)); clearOfPanel(S.byId.get(id)); } @@ -1485,7 +2404,10 @@ } function drill(n) { - if (n && n.drill) go(n.drill); + if (!n || !n.drill) return; + // where the view opened from, for it to grow out of + S.via = n.el.getBoundingClientRect(); + go(n.drill); } function hidePanel() { @@ -1836,7 +2758,7 @@ const id = nodeEl ? nodeEl.dataset.id : null; if (id !== S.hover) { S.hover = id; - highlight(id); + hoverSoon(); } } return; @@ -1854,7 +2776,7 @@ if (drag.mode === 'pan') { S.panX = drag.px + dx; S.panY = drag.py + dy; - applyView(); + viewSoon(); return; } const n = drag.n; @@ -1910,7 +2832,7 @@ } else { S.panX -= e.deltaX; S.panY -= e.deltaY; - applyView(); + viewSoon(); } }, { passive: false } @@ -1984,7 +2906,7 @@ else if (e.key === 'Enter' && S.selected) drill(S.byId.get(S.selected)); else if (e.key === ',' || e.key === '<') stepBy(-1); else if (e.key === '.' || e.key === '>') stepBy(1); - else if (e.key === 'l' || e.key === 'L') followLive(); + else if (e.key === 'l' || e.key === 'L') toggleFollow(); else if (e.key === '[') groupBy(-1); else if (e.key === ']') groupBy(1); }); @@ -2032,10 +2954,10 @@ pumping: false, // the step shown last while following, and when shown: null, + // the step controls opened by hand while following + unfolded: false, // each step on show is seen alone: what it touched in full, the rest faded focus: store.get('focus', true), - // the agent session on show - one at a time, and undefined for the latest - session: undefined, }; const MAX_TIMELINE = 200; const STATUS = { @@ -2044,7 +2966,7 @@ reverted: 'reverted - put back', discarded: 'discarded - dropped unwritten', edited: 'edited by hand - saved outside ccc’s edit tools', - read: 'read - an agent looked at it through ccc’s tools', + inspect: 'inspect - an agent read it, through ccc’s read tools or its own', }; const sameFn = (t, f) => t.file === f.file && t.name === f.name && (t.owner || null) === (f.owner || null); @@ -2096,6 +3018,8 @@ A.of = new Map(); A.groups.forEach((g, i) => g.steps.forEach(seq => A.of.set(seq, i))); drawTimeline(); + // which steps make up the collection on show may have just been learned + showKept(); if (S.asksPanel) panelAsks(); } @@ -2109,7 +3033,7 @@ function summary(ev) { const fn = ev.focus && ev.focus.level === 'flow' ? ev.focus.name : null; const files = ev.files.length === 1 ? base(ev.files[0].path) : plural(ev.files_total, 'file'); - if (ev.status === 'read') return `looked at ${fn || files}`; + if (ev.status === 'inspect') return `inspected ${fn || files}`; const added = ev.files.reduce((n, f) => n + f.added, 0); const removed = ev.files.reduce((n, f) => n + f.removed, 0); // one part of a step: the definition it is about, and which part of how many @@ -2151,10 +3075,11 @@ const fn = S.scene.entry && S.scene.entry.data.function; const t = fn && ev.functions.find(x => sameFn(x, fn)); if (!t || layoutOnly(t)) return null; - if (n.kind === 'entry') return t.change === 'modified' ? null : t.change; const lines = t['lines_' + side] || []; + // the entry stands for the signature: marked when a change reaches its first line + if (n.kind === 'entry') return t.change !== 'modified' ? t.change : t[side] && within(t[side].start, lines) ? 'modified' : null; const hit = n.kind === 'calls' ? d.calls.some(c => within(c.line, lines)) : within(d.line, lines); - return hit ? (t.change === 'added' || t.change === 'read' ? t.change : 'modified') : null; + return hit ? (t.change === 'added' || t.change === 'inspect' ? t.change : 'modified') : null; } case 'code': { if (n.kind !== 'function' && n.kind !== 'proxy') return null; @@ -2174,8 +3099,8 @@ } } - // how a level above the code took part: looked at, or changed - const touched = ev => (ev.status === 'read' ? 'read' : 'modified'); + // how a level above the code took part: inspected, or changed + const touched = ev => (ev.status === 'inspect' ? 'inspect' : 'modified'); // A change with no step of its own - lines removed, a statement that calls // nothing - is marked on the step it lands after, or on the entry. @@ -2206,7 +3131,7 @@ hit.push(n); }; for (const n of S.scene.nodes) { - n.el.classList.remove('chg', 'chg-added', 'chg-modified', 'chg-removed', 'chg-pending', 'chg-read'); + n.el.classList.remove('chg', 'chg-added', 'chg-modified', 'chg-removed', 'chg-pending', 'chg-inspect'); const how = m ? changeOf(n, m.ev, m.side) : null; if (how) mark(n, how); } @@ -2215,6 +3140,7 @@ if (near) mark(near, 'modified'); } fade(hit); + markGroups(); return hit; } @@ -2223,7 +3149,8 @@ // into the canvas. function fade(hit) { const on = !!(S.scene && S.focusing && hit.length); - const keep = new Set(hit.map(n => n.id)); + // what the step touched, and what a connection it made reaches + const keep = new Set([...hit.map(n => n.id), ...(S.alsoLit || [])]); for (const n of S.scene ? S.scene.nodes : []) n.el.classList.toggle('faded', on && !keep.has(n.id)); for (const e of S.scene ? S.scene.edges : []) { const lit = keep.has(e.from) || keep.has(e.to); @@ -2242,7 +3169,8 @@ } // glide the camera onto the marked nodes, near enough to read them - function flyTo(nodes) { + // `now` - straight there, as the motion of a new scene carries the camera + function flyTo(nodes, now) { const vp = $('viewport'); const bar = $('timeline'); const x0 = Math.min(...nodes.map(n => n.x)); @@ -2251,11 +3179,12 @@ const y1 = Math.max(...nodes.map(n => n.y + n.h)); const vw = vp.clientWidth - ($('panel').hidden ? 0 : 352); const vh = vp.clientHeight - (bar.hidden ? 0 : bar.offsetHeight + 12); - const floor = S.route && S.route.level === 'flow' ? 0.6 : 0.3; + // near enough a grouped scene that its nodes are back + const floor = S.route && S.route.level === 'flow' ? 0.6 : S.scene && S.scene.groups ? GROUP_SHOW + 0.05 : 0.3; S.zoom = clamp(Math.min((vw - 120) / Math.max(1, x1 - x0), (vh - 120) / Math.max(1, y1 - y0)), floor, 1.1); S.panX = vw / 2 - ((x0 + x1) / 2) * S.zoom; S.panY = vh / 2 - ((y0 + y1) / 2) * S.zoom; - glide(); + if (!now) glide(); applyView(); } @@ -2267,6 +3196,9 @@ glideTimer = setTimeout(() => vp.classList.remove('glide'), 700); } + // how long a write is shown as it was before it becomes what it is + const BEFORE_MS = 650; + // a step just landed: show it where it happened, as the map holds it now async function arrive(ev) { const i = T.steps.indexOf(ev); @@ -2291,8 +3223,18 @@ } // a step not written yet: draw the map as it would leave it, to look over if (ev.status === 'staged' && ev.snapshot && ev.focus) return replay(T.at, 'after', true); - // a look moved nothing, so the overview on show still holds - if (ev.status !== 'staged' && ev.status !== 'read') S.overview = null; + // A write to one function or file seen from elsewhere: shown first as it + // was, a moment, then becoming what it is - its pins, calls and words + // moving to their new places. + const near = ev.focus && (ev.focus.level === 'flow' || ev.focus.level === 'code'); + const was = focusRoute(ev, 'before'); + if (near && was && ev.snapshot && ['applied', 'reverted', 'edited'].includes(ev.status) && !calm() && !sameView(S.shownRoute, was)) { + await replay(T.at, 'before', true); + await sleep(BEFORE_MS / T.speed); + if (T.steps[T.at] !== ev || !T.follow) return; + } + // an inspect moved nothing, so the overview on show still holds + if (ev.status !== 'staged' && ev.status !== 'inspect') S.overview = null; try { await ensureOverview(); } catch { @@ -2300,20 +3242,53 @@ } S.marks = { ev, side: ev.on_disk }; S.fly = true; - const route = focusRoute(ev, ev.on_disk); + // A step in a file the map does not hold - a stylesheet, a README - is seen + // where the latest step before it that the map holds happened, never + // wherever the view was left: following goes on from the latest point. + if (!focusRoute(ev, ev.on_disk)) S.offMapStep = ev; + const route = focusRoute(ev, ev.on_disk) || placeBefore(T.at); if (route) go(route); else render(true); } + // A step in files the map does not draw - a stylesheet, a README - shown + // by what it changed, in the panel, once the view it is seen from is up; + // never over the list of asks. + function showOffMap() { + const ev = S.offMapStep; + S.offMapStep = null; + if (ev && !S.asksPanel) panelEvent(ev); + } + + // where on the map the latest step before `i` happened, if any did + function placeBefore(i) { + for (let j = Math.min(i, T.steps.length) - 1; j >= 0; j--) { + const r = focusRoute(T.steps[j], T.steps[j].on_disk); + if (r) return r; + } + return null; + } + + // The follow switch: on, each live change is shown as it lands; off, the + // step on show stays. + function toggleFollow() { + if (!(T.follow && (!T.replay || T.replay.live))) return followLive(); + stopPlay(); + T.follow = false; + T.queue = []; + drawTimeline(); + } + // the live map again, the newest step shown as if it had just landed, and // each step after it followed function followLive() { stopPlay(); T.follow = true; - // live is now, whatever was still waiting its turn, in the latest session + // the step controls fold away again + T.unfolded = false; + // live is now, whatever was still waiting its turn, in the latest ask T.queue = []; T.shown = null; - T.session = undefined; endReplay(); const last = T.steps[T.steps.length - 1]; if (!last || last.status === 'discarded') { @@ -2351,8 +3326,6 @@ T.queue.shift(); if (!T.steps.includes(ev)) continue; T.shown = { ev, at: Date.now() }; - // following is the latest session - T.session = undefined; await arrive(ev); } } finally { @@ -2412,9 +3385,14 @@ } catch { return; } - const route = focusRoute(ev, ev.on_disk); + // a step the map has no place for shows what it changed in the panel + if (!focusRoute(ev, ev.on_disk)) S.offMapStep = ev; + const route = focusRoute(ev, ev.on_disk) || placeBefore(i); if (route) go(route); - else drawTimeline(); + else { + drawTimeline(); + showOffMap(); + } return; } const snap = full[side]; @@ -2463,7 +3441,7 @@ drawTimeline(); } - // the step before or after the one on show, within the session on show + // the step before or after the one on show, within the ask on show function stepBy(d) { const shown = shownSteps(); if (!shown.length) return; @@ -2501,39 +3479,45 @@ drawTimeline(); } - // Runs of asks by the agent session they came from. Changes made by hand, - // and steps no transcript explains, go with the session they fall in. - function sessionSpans() { - const spans = []; - for (const [gi, g] of A.groups.entries()) { - const s = g.ask ? g.ask.session : null; - let cur = spans[spans.length - 1]; - if (!cur || (s && cur.session && cur.session !== s)) spans.push((cur = { session: s, groups: new Set() })); - else if (s && !cur.session) cur.session = s; - cur.groups.add(gi); - } - return spans; + // The collection a group of steps belongs to: every step of one ask, + // wherever they fall, or one run of changes made by hand, or of steps no + // transcript explains. The timeline holds one collection at a time. + const collectionOf = gi => (A.groups[gi]?.ask ? 'ask:' + A.groups[gi].ask.id : 'run:' + gi); + + // the collection of the step on show - undefined while its ask is not known yet + const collectionAt = () => { + const gi = T.at != null ? A.of.get(T.steps[T.at]?.seq) : undefined; + return gi === undefined ? undefined : collectionOf(gi); + }; + + // The collections on the timeline in the order they began, each with the + // numbers of its steps. + function collections() { + const out = new Map(); + A.groups.forEach((g, gi) => { + const key = collectionOf(gi); + if (!out.has(key)) out.set(key, { key, group: g, steps: [] }); + out.get(key).steps.push(...g.steps); + }); + return [...out.values()]; } - // The steps of the session on show, as indices into `steps` - the latest - // unless one was picked. A step no ask is known for yet is the newest, so - // it goes with the latest. + // The steps of the collection on show, as indices into `steps`: that of the + // step on show, else the latest - so following, each new ask starts the + // timeline afresh. A step no ask is known for yet is the newest, so it goes + // with the latest. function shownSteps() { - const spans = sessionSpans(); - if (spans.length < 2) return T.steps.map((_, i) => i); - const latest = spans[spans.length - 1]; - const want = (T.session !== undefined && spans.find(s => s.session === T.session)) || latest; + if (!A.groups.length) return T.steps.map((_, i) => i); + const latest = collectionOf(A.groups.length - 1); + const want = collectionAt() || latest; const out = []; T.steps.forEach((ev, i) => { const gi = A.of.get(ev.seq); - if (gi === undefined ? want === latest : want.groups.has(gi)) out.push(i); + if (gi === undefined ? want === latest : collectionOf(gi) === want) out.push(i); }); return out; } - // the session a group of steps belongs to - const sessionOf = gi => (sessionSpans().find(s => s.groups.has(gi)) || {}).session; - function drawTimeline() { const bar = $('timeline'); const n = T.steps.length; @@ -2544,16 +3528,11 @@ document.body.classList.toggle('with-tl-switches', !n); const ticks = $('tl-ticks'); ticks.textContent = ''; - let run; - // one session at a time - a long history drawn whole is what lags + // one collection at a time - scrubbing stays inside it, and a long history drawn whole is what lags const shown = shownSteps(); - shown.forEach((i, k) => { + shown.forEach(i => { const ev = T.steps[i]; - const gi = A.of.get(ev.seq); - // each ask's steps sit together, set off from the next - if (k > 0 && gi !== run) ticks.append(h('b', 'tl-sep')); - run = gi; - const g = A.groups[gi]; + const g = A.groups[A.of.get(ev.seq)]; const t = h('i', `tick ${ev.status}${byHand(ev) ? ' human' : ''}${layoutOnly(ev) ? ' ws' : ''}${i === T.at ? ' at' : ''}`); t.title = `${g ? groupText(g) + '\n' : ''}#${ev.seq} ${ev.status} · ${ev.tool} · ${summary(ev)}${why(ev) ? ' · ' + why(ev) : ''}`; t.dataset.i = i; @@ -2570,11 +3549,15 @@ const g = groupOf(ev); const chip = $('tl-ask'); chip.hidden = !A.groups.length; - chip.replaceChildren(...(g ? [groupIcon(g)] : []), h('span', 'ask-label', g ? groupText(g) : 'Asks')); - chip.title = `${g ? groupText(g) + '\n\n' : ''}Every ask and what it changed - [ and ] step between them`; - const cur = ev ? A.of.get(ev.seq) : undefined; - for (const row of document.querySelectorAll('#panel .ask-row')) row.classList.toggle('at', +row.dataset.g === cur); const k = shown.indexOf(T.at); + chip.replaceChildren( + ...(g ? [groupIcon(g)] : []), + h('span', 'ask-label', g ? groupText(g) : 'Asks'), + ...(g && k >= 0 ? [h('span', 'ask-n', `${k + 1} / ${shown.length}`)] : []) + ); + chip.title = `${g ? groupText(g) + '\n\n' : ''}Every ask and what it changed - [ and ] step between them`; + const cur = collectionAt(); + for (const row of document.querySelectorAll('#panel .ask-row')) row.classList.toggle('at', row.dataset.c === cur); $('tl-prev').disabled = !shown.length || k === 0; $('tl-next').disabled = !shown.length || T.at == null || k >= shown.length - 1; $('tl-play').disabled = !n; @@ -2587,20 +3570,38 @@ b.disabled = !(full && full[b.dataset.side]); } const live = T.follow && (!T.replay || T.replay.live); - $('tl-follow').setAttribute('aria-pressed', String(live)); + $('tl-follow').setAttribute('aria-checked', String(live)); // slowed, following runs behind by the steps still waiting their turn - $('tl-follow').textContent = live ? (T.queue.length ? `Following · ${T.queue.length} behind` : 'Following') : 'Follow'; + $('tl-behind').replaceChildren(...(live && T.queue.length ? [` · ${T.queue.length}`, h('span', 'tl-behind', ' behind')] : [])); + // Following, the step controls fold away to the left behind an arrow + // that opens them - and stopping opens them too. + const folded = live && !T.unfolded; + const controls = $('tl-controls'); + controls.classList.toggle('folded', folded); + controls.inert = folded; + const unfold = $('tl-unfold'); + unfold.hidden = !live; + unfold.textContent = folded ? '»' : '«'; + unfold.title = folded ? 'Show the step controls' : 'Fold the step controls away'; + unfold.setAttribute('aria-label', unfold.title.toLowerCase()); + unfold.setAttribute('aria-expanded', String(!folded)); $('tl-ws').setAttribute('aria-pressed', String(T.ws)); $('tl-speed').textContent = SPEED_LABEL[T.speed]; $('tl-focus').setAttribute('aria-pressed', String(T.focus)); const badge = $('replay-badge'); - badge.hidden = !T.replay || !ev; + // a step only in files the map does not draw - a stylesheet, a README - + // moves nothing on it, so it says so whether replayed or just landed + const offMap = !!ev && !!S.overview && ev.status !== 'inspect' && ev.files.length > 0 && !focusRoute(ev, ev.on_disk); + badge.hidden = !ev || !(T.replay || offMap); if (!badge.hidden) { badge.replaceChildren( ...(byHand(ev) ? [person()] : []), - T.replay.live - ? `Proposed · step #${ev.seq}${why(ev) ? ' · ' + why(ev) : ' - staged, not written yet'}` - : `Replay · step #${ev.seq} ${ev.status} · ${T.side} it${why(ev) ? ' · ' + why(ev) : ''}`, + !T.replay + ? `Step #${ev.seq} ${ev.status}${why(ev) ? ' · ' + why(ev) : ''}` + : T.replay.live + ? `Proposed · step #${ev.seq}${why(ev) ? ' · ' + why(ev) : ' - staged, not written yet'}` + : `Replay · step #${ev.seq} ${ev.status} · ${T.side} it${why(ev) ? ' · ' + why(ev) : ''}`, + ...(offMap ? [h('span', 'off-map', ` · ${ev.files.map(f => base(f.path)).join(', ')} not on the map`)] : []), ); badge.title = (ev.intent || []).join('\n'); } @@ -2625,11 +3626,19 @@ function panelEvent(ev) { select(null); - const look = ev.status === 'read'; - panel((look ? 'Look #' : 'Edit step #') + ev.seq, `${ev.status} · ${ev.tool}`, body => { - const by = byHand(ev) ? 'a person, by hand' : look ? 'an agent, through ccc’s read tools' : 'an agent, through ccc’s edit tools'; + const look = ev.status === 'inspect'; + // a read an agent took with its own tool names the call, found in its transcript + const own = look && !!ev.call; + panel((look ? 'Inspect #' : 'Edit step #') + ev.seq, `${ev.status} · ${ev.tool}`, body => { + const by = byHand(ev) + ? 'a person, by hand' + : own + ? `an agent, with its own ${ev.tool} tool` + : look + ? 'an agent, through ccc’s read tools' + : 'an agent, through ccc’s edit tools'; const rows = [['status', STATUS[ev.status]], ['by', by]]; - if (ev.intent && ev.intent.length) rows.push([look ? 'asked' : 'why', ev.intent.join(' · ')]); + if (ev.intent && ev.intent.length) rows.push([own ? 'read' : look ? 'asked' : 'why', ev.intent.join(' · ')]); if (ev.part) rows.push(['part', `${ev.part.index + 1} of ${ev.part.of} - ${ev.part.kind} ${ev.part.change}`]); const g = groupOf(ev); if (g && g.ask) rows.push(['ask', g.ask.prompt]); @@ -2644,7 +3653,7 @@ row.append(h('span', 'chg-tag ' + (t.whitespace ? 'ws' : t.change), t.whitespace ? 'layout' : t.change), goBtn(`${t.owner ? t.owner + '::' : ''}${t.name} ${base(t.file)}`, route)); return row; }); - section(body, look ? 'Looked at' : 'Functions', list(rows)); + section(body, look ? 'Inspected' : 'Functions', list(rows)); } if (ev.ops.length) section(body, 'Operations', list(ev.ops.map(o => h('code', null, o)))); if (look) section(body, 'Files', list(ev.files.map(f => goBtn(f.path, { level: 'code', file: f.path })))); @@ -2653,17 +3662,20 @@ S.stepPanel = true; } - // Every ask the timeline holds, in order, under the session it was made in: - // pick one to walk through its steps, or play a whole session through. + // Every collection the timeline holds - each ask, each run of changes by + // hand - in order, under the session it was made in: pick one and the + // timeline holds its steps alone, or play a whole session through. function panelAsks() { const keep = S.asksPanel ? $('panel-body').scrollTop : 0; select(null); - const asks = new Set(A.groups.filter(g => g.ask).map(g => g.ask.id)).size; + const all = collections(); + const asks = all.filter(c => c.group.ask).length; panel('Asks', `${plural(asks, 'ask')} · ${plural(T.steps.length, 'step')}`, body => { - if (!A.groups.length) body.append(h('p', 'hint', 'No steps yet - each ask an agent works on lands here, and every change made by hand')); - const cur = A.of.get(T.steps[T.at]?.seq); + if (!all.length) body.append(h('p', 'hint', 'No steps yet - each ask an agent works on lands here, and every change made by hand')); + const cur = collectionAt(); let session; - A.groups.forEach((g, gi) => { + for (const c of all) { + const g = c.group; if (g.ask && g.ask.session !== session) { session = g.ask.session; const s = session; @@ -2675,18 +3687,18 @@ head.append(all); body.append(head); } - const row = h('div', 'ask-row' + (gi === cur ? ' at' : '')); - row.dataset.g = gi; + const row = h('div', 'ask-row' + (c.key === cur ? ' at' : '')); + row.dataset.c = c.key; const go = h('button', 'ask-go'); - go.append(groupIcon(g), h('span', 'ask-text', groupText(g)), h('span', 'ask-n', plural(g.steps.length, 'step'))); + go.append(groupIcon(g), h('span', 'ask-text', groupText(g)), h('span', 'ask-n', plural(c.steps.length, 'step'))); go.title = groupText(g); - go.addEventListener('click', () => goGroup(gi)); + go.addEventListener('click', () => goCollection(c)); const one = h('button', 'ask-play', '▶'); one.title = 'Replay this ask’s steps'; - one.addEventListener('click', () => play(indicesOf(g.steps))); + one.addEventListener('click', () => play(indicesOf(c.steps))); row.append(go, one); body.append(row); - }); + } }); S.asksPanel = true; $('panel-body').scrollTop = keep; @@ -2695,22 +3707,21 @@ // where steps sit on the timeline, by their numbers const indicesOf = seqs => seqs.map(seq => T.steps.findIndex(e => e.seq === seq)).filter(i => i >= 0); - // a group, from its first step - function goGroup(gi) { - const g = A.groups[gi]; - const [i] = g ? indicesOf(g.steps) : []; + // a collection, from its first step - the timeline then holds it alone + function goCollection(c) { + const [i] = c ? indicesOf(c.steps) : []; if (i == null) return; stopPlay(); - T.session = sessionOf(gi); replay(i, 'after'); } - // the ask before or after the one on show + // the collection before or after the one on show function groupBy(d) { - if (!A.groups.length) return; - const cur = A.of.get(T.steps[T.at]?.seq); - const from = cur == null ? (d > 0 ? -1 : A.groups.length) : cur; - goGroup(clamp(from + d, 0, A.groups.length - 1)); + const all = collections(); + if (!all.length) return; + const k = all.findIndex(c => c.key === collectionAt()); + const from = k < 0 ? (d > 0 ? -1 : all.length) : k; + goCollection(all[clamp(from + d, 0, all.length - 1)]); } // every step from a session's first ask to its last, changes by hand between them included @@ -2718,11 +3729,22 @@ const at = A.groups.map((g, i) => (g.ask && g.ask.session === session ? i : -1)).filter(i => i >= 0); if (!at.length) return; stopPlay(); - T.session = session; play(indicesOf(A.groups.slice(at[0], at[at.length - 1] + 1).flatMap(g => g.steps))); } + // how far the timeline's top sits from the window's foot, for what keeps clear of it + function clearOfTimeline() { + const bar = $('timeline'); + const set = () => { + const top = bar.hidden ? 0 : Math.ceil(window.innerHeight - bar.getBoundingClientRect().top); + document.body.style.setProperty('--tl-top', top + 'px'); + }; + new ResizeObserver(set).observe(bar); + window.addEventListener('resize', set); + } + function bindTimeline() { + clearOfTimeline(); const track = $('tl-track'); let scrubbing = false; let timer = 0; @@ -2764,7 +3786,11 @@ $('tl-prev').addEventListener('click', () => stepBy(-1)); $('tl-next').addEventListener('click', () => stepBy(1)); $('tl-play').addEventListener('click', () => play()); - $('tl-follow').addEventListener('click', () => followLive()); + $('tl-follow').addEventListener('click', () => toggleFollow()); + $('tl-unfold').addEventListener('click', () => { + T.unfolded = !T.unfolded; + drawTimeline(); + }); $('tl-speed').addEventListener('click', e => { // shift steps back up through the speeds const i = SPEEDS.indexOf(T.speed); @@ -2814,9 +3840,9 @@ } const fresh = r.events || []; T.steps.push(...fresh); - // past the cap a look goes before an edit does, as on the server + // past the cap an inspect goes before an edit does, as on the server while (T.steps.length > MAX_TIMELINE) { - const look = T.steps.slice(0, T.steps.length >> 1).findIndex(e => e.status === 'read'); + const look = T.steps.slice(0, T.steps.length >> 1).findIndex(e => e.status === 'inspect'); const cut = look < 0 ? 0 : look; T.steps.splice(cut, 1); if (T.at != null && T.at > cut) T.at--; diff --git a/docs/EXTENSION.md b/docs/EXTENSION.md index 6022aca..cb9dac5 100644 --- a/docs/EXTENSION.md +++ b/docs/EXTENSION.md @@ -63,7 +63,8 @@ steps along its foot. While an agent edits through ccc's tools, it flies to each and marks it, and any step can be replayed before and after. A staged change is drawn as **Proposed** - the code as it would be - before anything is written, beside the one-line intent the agent gave for it. Changes saved by hand land on the timeline too, marked with a person. Each edit -plays a definition at a time and an agent's reads land as looks, at the pace the speed button sets - +plays a definition at a time and an agent's reads land as inspects - through ccc or with its own +tools - one ask at a time, at the pace the speed button sets - it is the same page as in a browser, so it behaves the same there (see the [README](../README.md#architecture-visualiser)). A change that only moves whitespace or wraps lines is not marked unless ¶ is on. Its title-bar buttons open the same diff --git a/docs/MCP.md b/docs/MCP.md index 4d937ec..79baa19 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -51,6 +51,8 @@ What every write is held to: `file://` report) can read the map but never write through it. - **Attributed.** Each applied changeset is appended to `.ccc/edits.jsonl`, which is kept out of git. `ccc prompts` reads it to credit a ccc edit to the request behind it, as strongly as it credits an `Edit` or `Write`. -- **Shared.** Every step - staged, applied, reverted, and each look a read tool took - is appended to +- **Shared.** Every step - staged, applied, reverted, and each inspect an agent made - is appended to `.ccc/timeline.jsonl`, also kept out of git, so the visualiser of any ccc server on the project shows it. + An inspect is a read tool's answer, or a file the agent read with its own tools, which the server + finds in Claude Code's transcripts (subagents included) every couple of seconds. diff --git a/src/inspect.rs b/src/inspect.rs new file mode 100644 index 0000000..279ef57 --- /dev/null +++ b/src/inspect.rs @@ -0,0 +1,471 @@ +//! What agents read with their own tools - a `Read`, a `cat` or a `sed -n` in +//! a shell - taken from their transcripts as they are written. ccc's read tools +//! put their looks on the timeline as they answer; these are the rest, so the +//! visualiser shows an agent inspecting code whichever way it looked. +//! +//! Claude Code's transcripts only, a session's subagents with it: Copilot's +//! chat log keeps no record of the files its tools read. + +use crate::model::FileCache; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::{Component, Path, PathBuf}; + +// transcripts are looked for again this often - a new session, a subagent +const FOUND_EVERY: std::time::Duration = std::time::Duration::from_secs(10); +// and this often when claude keeps no directory for the project, where +// finding them means opening every other project's +const FOUND_SLOW: std::time::Duration = std::time::Duration::from_secs(600); +// the sites one read names past the file itself +const READ_SITES: usize = 59; + +// commands that read the files they name and write none +const READERS: &[&str] = &[ + "cat", "head", "tail", "sed", "awk", "nl", "less", "more", "bat", "batcat", "view", "grep", "egrep", "fgrep", "rg", "ag", + "wc", "cut", "sort", "uniq", "diff", "strings", "xxd", "od", "jq", +]; +// readers whose first word is a pattern or a script rather than a file +const PATTERNED: &[&str] = &["sed", "awk", "grep", "egrep", "fgrep", "rg", "ag", "jq"]; +// words ahead of a command that only change how it runs +const WRAPPERS: &[&str] = &["sudo", "env", "time", "command", "nohup", "exec", "nice"]; + +// One file an agent read with its own tools. +#[derive(Debug, Clone, PartialEq)] +pub struct Read { + pub at_ms: u64, + pub session: String, + // the tool call it was, so it is put down once + pub call: String, + // Read, NotebookRead, Grep, Bash + pub tool: String, + // project-relative + pub path: String, + // 1-based and inclusive, ending at `usize::MAX` for the rest of the file - + // none for the whole of it + pub lines: Option<(usize, usize)>, +} + +impl Read { + // the file and the lines read, as the timeline labels it + pub fn what(&self) -> String { + match self.lines { + None => self.path.clone(), + Some((a, b)) if a == b => format!("{}:{a}", self.path), + Some((a, usize::MAX)) => format!("{}:{a}-", self.path), + Some((a, b)) => format!("{}:{a}-{b}", self.path), + } + } + + // Where on the map it was read, as (file, line - none for the file, main): + // the one function the lines lie in, else the file and each function they + // cross. + pub fn sites(&self, caches: &[FileCache]) -> Vec<(String, usize, bool)> { + let whole = vec![(self.path.clone(), 0, true)]; + let cache = caches.iter().find(|c| crate::changes::path_str(&c.rel_path) == self.path); + let (Some((from, to)), Some(c)) = (self.lines, cache) else { + return whole; + }; + let holding = c + .funcs + .iter() + .filter(|f| f.start_line <= from && to <= f.end_line) + .min_by_key(|f| f.end_line - f.start_line); + if let Some(f) = holding { + return vec![(self.path.clone(), f.line, true)]; + } + let crossed = c.funcs.iter().filter(|f| f.start_line <= to && from <= f.end_line); + whole.into_iter().chain(crossed.take(READ_SITES).map(|f| (self.path.clone(), f.line, false))).collect() + } +} + +// The transcripts of this project's agents, each read as far as it was last. +#[derive(Default)] +pub struct Reads { + // when the transcripts were last looked for + found: Option, + // each, and the bytes of it read so far + read: BTreeMap, +} + +impl Reads { + // The reads agents made since the last call, oldest first. + pub fn fresh(&mut self, root: &Path) -> Vec { + let quick = crate::prompts::claude_root().is_some_and(|b| b.join(crate::prompts::claude_slug(root)).is_dir()); + let every = if quick { FOUND_EVERY } else { FOUND_SLOW }; + if self.found.is_none_or(|t| t.elapsed() >= every) { + let found = transcripts(root); + self.found_now(found); + } + self.tail(root) + } + + // What a transcript held when it was first found is history; one begun + // since is news from its first line. + fn found_now(&mut self, found: Vec) { + let first = self.found.is_none(); + self.read.retain(|f, _| found.contains(f)); + for f in found { + let from = if first { std::fs::metadata(&f).map_or(0, |m| m.len()) } else { 0 }; + self.read.entry(f).or_insert(from); + } + self.found = Some(std::time::Instant::now()); + } + + fn tail(&mut self, root: &Path) -> Vec { + let mut out = Vec::new(); + for (f, from) in self.read.iter_mut() { + let (records, end) = tail(f, *from); + *from = end; + out.extend(records.iter().flat_map(|r| reads_of(r, root))); + } + out.sort_by_key(|r| r.at_ms); + out + } +} + +// claude's transcripts for this project, each session's subagents beside it +fn transcripts(root: &Path) -> Vec { + let mut out = Vec::new(); + for main in crate::prompts::claude_transcripts(root).0 { + out.extend(crate::prompts::jsonl_files(&main.with_extension("").join("subagents"))); + out.push(main); + } + out +} + +// The records a transcript gained past byte `from` that hold a tool call, and +// the byte they end at. A line still being written waits for the next read; a +// transcript shorter than `from` began again, so it is read from the top. +fn tail(path: &Path, from: u64) -> (Vec, u64) { + use std::io::{Read as _, Seek, SeekFrom}; + let Ok(mut f) = std::fs::File::open(path) else { + return (Vec::new(), from); + }; + let from = if f.metadata().map_or(0, |m| m.len()) < from { 0 } else { from }; + let mut buf = Vec::new(); + if f.seek(SeekFrom::Start(from)).is_err() || f.read_to_end(&mut buf).is_err() { + return (Vec::new(), from); + } + let end = buf.iter().rposition(|&b| b == b'\n').map_or(0, |i| i + 1); + let call = b"\"tool_use\""; + let records = buf[..end] + .split(|&b| b == b'\n') + // a tool's answer can run to megabytes - only the calls are parsed + .filter(|l| l.windows(call.len()).any(|w| w == call)) + .filter_map(|l| serde_json::from_slice(l).ok()) + .collect(); + (records, from + end as u64) +} + +// the files inside the project one transcript record's tool calls read +fn reads_of(rec: &Value, root: &Path) -> Vec { + if rec["type"] != "assistant" { + return Vec::new(); + } + let at_ms = rec["timestamp"] + .as_str() + .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) + .map_or(0, |d| d.timestamp_millis().max(0) as u64); + let session = rec["sessionId"].as_str().unwrap_or_default(); + let cwd = rec["cwd"].as_str().map_or_else(|| root.to_path_buf(), PathBuf::from); + let mut out = Vec::new(); + for b in rec["message"]["content"].as_array().into_iter().flatten() { + let (Some("tool_use"), Some(tool), Some(call)) = (b["type"].as_str(), b["name"].as_str(), b["id"].as_str()) else { + continue; + }; + let input = &b["input"]; + let named: Vec<(PathBuf, Option<(usize, usize)>)> = match tool { + "Read" => input["file_path"].as_str().map(|p| (cwd.join(p), read_range(input))).into_iter().collect(), + "NotebookRead" => input["notebook_path"].as_str().map(|p| (cwd.join(p), None)).into_iter().collect(), + // a search of one file looks at it; one of a directory names no file until it answers + "Grep" => input["path"].as_str().map(|p| (cwd.join(p), None)).into_iter().collect(), + "Bash" => input["command"].as_str().map(|c| shell_reads(c, &cwd)).unwrap_or_default(), + _ => Vec::new(), + }; + out.extend(named.into_iter().filter_map(|(p, lines)| { + let path = inside(root, &p)?; + Some(Read { at_ms, session: session.to_string(), call: call.to_string(), tool: tool.to_string(), path, lines }) + })); + } + out +} + +// the lines a `Read` asked for - none when it asked for the whole file +fn read_range(input: &Value) -> Option<(usize, usize)> { + let num = |v: &Value| v.as_u64().or_else(|| v.as_str()?.trim().parse().ok()).map(|n| n as usize); + let (offset, limit) = (num(&input["offset"]), num(&input["limit"])); + if offset.is_none() && limit.is_none() { + return None; + } + let from = offset.unwrap_or(1).max(1); + Some((from, limit.map_or(usize::MAX, |l| from + l.max(1) - 1))) +} + +// an absolute path as a path inside the project, relative to it +fn inside(root: &Path, path: &Path) -> Option { + let mut abs = PathBuf::new(); + for c in path.components() { + match c { + Component::ParentDir => { + abs.pop(); + } + Component::CurDir => {} + c => abs.push(c), + } + } + let rel = abs.strip_prefix(root).ok()?; + rel.components().next().is_some().then(|| crate::changes::path_str(rel)) +} + +// The files a shell command line reads, each with the lines a `sed -n 'a,bp'` +// or a `head -n` took where it says. A word is taken for a file only where the +// command it follows reads files and writes none. +fn shell_reads(line: &str, cwd: &Path) -> Vec<(PathBuf, Option<(usize, usize)>)> { + let mut cwd = cwd.to_path_buf(); + let mut out = Vec::new(); + for (words, fed) in commands(line) { + let mut words = words.iter().map(String::as_str).skip_while(|w| assignment(w) || WRAPPERS.contains(w)); + let Some(cmd) = words.next() else { + continue; + }; + let args: Vec<&str> = words.collect(); + let name = cmd.rsplit('/').next().unwrap_or(cmd); + if name == "cd" { + if let Some(dir) = args.first() { + cwd = cwd.join(dir); + } + continue; + } + let in_place = name == "sed" && args.iter().any(|a| a.starts_with("-i") || a.starts_with("--in-place")); + if !READERS.contains(&name) || in_place { + continue; + } + let lines = match name { + "sed" => args.iter().find_map(|a| sed_range(a)), + "head" => head_count(&args).map(|n| (1, n)), + _ => None, + }; + // a search's or an edit script's first word is its pattern, unless a flag gave it + let given = args.iter().any(|a| matches!(*a, "-e" | "-f" | "--regexp" | "--expression" | "--file")); + let pattern = usize::from(PATTERNED.contains(&name) && !given); + let words = args.iter().copied().filter(|a| !a.starts_with('-') && !a.bytes().all(|b| b.is_ascii_digit())); + let files = words.skip(pattern).chain(fed.iter().map(String::as_str)); + out.extend(files.map(|f| (cwd.join(f), lines))); + } + out +} + +// `NAME=value` ahead of a command +fn assignment(word: &str) -> bool { + word.split_once('=') + .is_some_and(|(k, _)| !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) +} + +// the lines `a,bp` or `ap` prints under `sed -n` +fn sed_range(script: &str) -> Option<(usize, usize)> { + let body = script.strip_suffix('p')?; + let (a, b) = body.split_once(',').unwrap_or((body, body)); + let a: usize = a.parse().ok()?; + let b = if b == "$" { usize::MAX } else { b.parse().ok()? }; + (a >= 1 && b >= a).then_some((a, b)) +} + +// how many lines a `head` took, where it says +fn head_count(args: &[&str]) -> Option { + let mut it = args.iter(); + while let Some(a) = it.next() { + let n: Option = match *a { + "-n" | "--lines" => it.next().and_then(|n| n.parse().ok()), + a => a + .strip_prefix("--lines=") + .or_else(|| a.strip_prefix("-n")) + .or_else(|| a.strip_prefix('-')) + .and_then(|n| n.parse().ok()), + }; + if n.is_some() { + return n.filter(|&n| n > 0); + } + } + None +} + +// where the word being read goes +#[derive(Clone, Copy)] +enum Into { + Arg, + // fed to the command with `<` + Fed, + // written with `>` - no read + Written, +} + +// a command line on its way to its simple commands +struct Split { + commands: Vec<(Vec, Vec)>, + word: Option, + into: Into, +} + +impl Split { + fn push(&mut self, c: char) { + self.word.get_or_insert_with(String::new).push(c); + } + + fn word(&mut self) { + let Some(w) = self.word.take() else { + return; + }; + let Some((args, fed)) = self.commands.last_mut() else { + return; + }; + match std::mem::replace(&mut self.into, Into::Arg) { + Into::Arg => args.push(w), + Into::Fed => fed.push(w), + Into::Written => {} + } + } + + fn command(&mut self) { + self.word(); + self.commands.push(Default::default()); + } +} + +// A command line as its simple commands, each its words with the quotes taken +// off and the words a `<` feeds it apart. A heredoc's body is text, not +// commands, so the line is read no further than one. +fn commands(line: &str) -> Vec<(Vec, Vec)> { + let mut s = Split { commands: vec![Default::default()], word: None, into: Into::Arg }; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '\'' => { + s.word.get_or_insert_with(String::new); + for c in chars.by_ref().take_while(|&c| c != '\'') { + s.push(c); + } + } + '"' => { + s.word.get_or_insert_with(String::new); + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' => chars.next().into_iter().for_each(|n| s.push(n)), + c => s.push(c), + } + } + } + '\\' => chars.next().into_iter().filter(|&n| n != '\n').for_each(|n| s.push(n)), + ' ' | '\t' => s.word(), + '\n' | ';' | '|' | '&' | '(' | ')' => s.command(), + '#' if s.word.is_none() => { + chars.by_ref().take_while(|&c| c != '\n').for_each(drop); + s.command(); + } + '<' if chars.peek() == Some(&'<') => break, + '<' => { + s.word(); + s.into = Into::Fed; + } + '>' => { + // the `2` of `2>` is the stream, not a word + if s.word.as_deref().is_some_and(|w| !w.is_empty() && w.bytes().all(|b| b.is_ascii_digit())) { + s.word = None; + } + s.word(); + while chars.next_if(|&c| c == '>' || c == '&').is_some() {} + s.into = Into::Written; + } + c => s.push(c), + } + } + s.command(); + s.commands.retain(|(args, fed)| !args.is_empty() || !fed.is_empty()); + s.commands +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn files(line: &str) -> Vec<(String, Option<(usize, usize)>)> { + shell_reads(line, Path::new("/p/sub")) + .into_iter() + .map(|(p, lines)| (p.to_string_lossy().into_owned(), lines)) + .collect() + } + + // A shell line reads the files its reading commands name - with the lines + // a `sed -n` or `head` took - and nothing a command writes, runs or edits + // in place, a heredoc's text included. + #[test] + fn a_shell_line_reads_what_its_readers_name() { + assert_eq!(files("sed -n '120,180p' src/a.rs"), [("/p/sub/src/a.rs".into(), Some((120, 180)))]); + assert_eq!(files("cd .. && head -n 40 lib.rs | grep fn"), [("/p/sub/../lib.rs".into(), Some((1, 40)))]); + assert_eq!( + files("grep -n \"fn main\" a.rs b.rs 2>/dev/null; cat < c.rs"), + [("/p/sub/a.rs".into(), None), ("/p/sub/b.rs".into(), None), ("/p/sub/c.rs".into(), None)] + ); + assert_eq!(files("LANG=C sudo cat /etc/x"), [("/etc/x".into(), None)]); + assert!(files("sed -i 's/a/b/' a.rs").is_empty()); + assert!(files("cargo test --lib a.rs > out.txt").is_empty()); + assert!(files("cat > a.rs <<'EOF'\nfn x() {}\nEOF").is_empty()); + assert!(files("echo cat a.rs # cat b.rs").is_empty()); + } + + // A read is seen where it was taken: the one function its lines lie in, + // else the file and the functions they cross - the whole file when it + // asked for no lines. + #[test] + fn a_read_is_seen_at_the_function_its_lines_lie_in() { + let src = "fn a() {\n 1;\n}\n\nfn b() {\n 2;\n}\n"; + let caches = vec![crate::scan::read_one(Path::new("src/x.rs"), src).unwrap()]; + let read = |lines| Read { at_ms: 0, session: "s".into(), call: "c".into(), tool: "Read".into(), path: "src/x.rs".into(), lines }; + assert_eq!(read(Some((2, 2))).sites(&caches), [("src/x.rs".to_string(), 1, true)]); + assert_eq!( + read(Some((2, 6))).sites(&caches), + [("src/x.rs".to_string(), 0, true), ("src/x.rs".to_string(), 1, false), ("src/x.rs".to_string(), 5, false)] + ); + assert_eq!(read(None).sites(&caches), [("src/x.rs".to_string(), 0, true)]); + assert_eq!(read(Some((5, usize::MAX))).what(), "src/x.rs:5-"); + } + + // A transcript is read on from where it was last: what it held when it + // was found is history, each call after is a read of what it named inside + // the project, and a line still being written waits for the next look. + #[test] + fn a_transcripts_reads_are_found_as_they_are_written() { + let dir = std::env::temp_dir().join(format!("ccc-inspect-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let root = dir.join("proj"); + std::fs::create_dir_all(&root).unwrap(); + let log = dir.join("s1.jsonl"); + let call = |id: &str, name: &str, input: Value| { + json!({"type": "assistant", "timestamp": "2026-10-03T12:00:00.000Z", "sessionId": "s1", "cwd": root, + "message": {"content": [{"type": "tool_use", "id": id, "name": name, "input": input}]}}) + .to_string() + + "\n" + }; + let mut text = call("old", "Read", json!({"file_path": root.join("a.rs")})); + std::fs::write(&log, &text).unwrap(); + let mut reads = Reads::default(); + reads.found_now(vec![log.clone()]); + assert!(reads.tail(&root).is_empty(), "history"); + + text += &call("t1", "Read", json!({"file_path": root.join("src/a.rs"), "offset": 10, "limit": 5})); + text += &call("t2", "Bash", json!({"command": "sed -n '1,3p' src/b.rs && cat /etc/hosts"})); + text += &call("t3", "mcp__ccc__file", json!({"path": "src/c.rs"})); + text += &json!({"type": "user", "message": {"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "x"}]}}).to_string(); + text += "\n"; + let half = call("t4", "Read", json!({"file_path": root.join("src/d.rs")})); + std::fs::write(&log, format!("{text}{}", &half[..20])).unwrap(); + let got: Vec<(String, String, String)> = reads.tail(&root).into_iter().map(|r| (r.what(), r.call, r.tool)).map(|(w, c, t)| (c, t, w)).collect(); + assert_eq!(got, [("t1".into(), "Read".into(), "src/a.rs:10-14".into()), ("t2".into(), "Bash".into(), "src/b.rs:1-3".into())]); + + std::fs::write(&log, format!("{text}{half}")).unwrap(); + let got: Vec = reads.tail(&root).into_iter().map(|r| r.what()).collect(); + assert_eq!(got, ["src/d.rs"]); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9462a12..53dace2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ pub mod extract; pub mod externals; pub mod html; pub mod insights; +pub mod inspect; pub mod instance; pub mod languages; pub mod model; diff --git a/src/prompts.rs b/src/prompts.rs index 626e4cc..9854bcf 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -207,7 +207,7 @@ fn home() -> Option { // the directory claude code keeps its transcripts in // ccc:skip -fn claude_root() -> Option { +pub(crate) fn claude_root() -> Option { if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") { return Some(PathBuf::from(dir).join("projects")); } @@ -237,7 +237,7 @@ fn transcript_cwd(path: &Path) -> Option { } // ccc:skip -fn jsonl_files(dir: &Path) -> Vec { +pub(crate) fn jsonl_files(dir: &Path) -> Vec { let Ok(entries) = fs::read_dir(dir) else { return Vec::new(); }; @@ -253,7 +253,7 @@ fn jsonl_files(dir: &Path) -> Vec { // transcripts for this project: the slug first, then any other project // directory whose sessions actually ran here // ccc:skip -fn claude_transcripts(root: &Path) -> (Vec, String) { +pub(crate) fn claude_transcripts(root: &Path) -> (Vec, String) { let Some(base) = claude_root() else { return (Vec::new(), "".into()); }; @@ -415,6 +415,10 @@ fn truncate(s: &str, cap: usize) -> String { // harness metadata and slash-command expansions are not requests // ccc:skip fn user_prompt_text(rec: &Value) -> Option { + if let Some(raw) = queued_text(rec) { + let condensed = condense(&raw); + return (!condensed.is_empty()).then_some(condensed); + } if rec.get("type").and_then(|t| t.as_str()) != Some("user") { return None; } @@ -450,6 +454,32 @@ fn user_prompt_text(rec: &Value) -> Option { (!condensed.is_empty()).then_some(condensed) } +// A message the user sent while the agent worked: the harness queues it and +// hands it over mid-turn, as an attachment rather than a turn of its own - a +// request all the same, and what the agent does after it answers it. +fn queued_text(rec: &Value) -> Option { + if rec.get("type").and_then(|t| t.as_str()) != Some("attachment") { + return None; + } + let a = rec.get("attachment")?; + let is = |k: &str, want: &str| a.get(k).and_then(|v| v.as_str()).is_none_or(|v| v == want); + let human = a.get("origin").and_then(|o| o.get("kind")).and_then(|k| k.as_str()).is_none_or(|k| k == "human"); + if a.get("type").and_then(|t| t.as_str()) != Some("queued_command") || !is("commandMode", "prompt") || !human { + return None; + } + match a.get("prompt")? { + Value::String(s) => Some(s.clone()), + Value::Array(blocks) => Some( + blocks + .iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n"), + ), + _ => None, + } +} + // the longest distinctive line of what a tool wrote, for finding it again // ccc:skip fn anchor_of(text: &str) -> Option { @@ -1319,6 +1349,36 @@ mod tests { assert!(turns[0].edits[0].anchor.is_some()); } + // A message sent while the agent worked reaches it queued, mid-turn - a + // request of its own, the edits after it its own - while a task's note + // to the agent is no one's request. + #[test] + fn a_message_queued_mid_turn_is_a_request_of_its_own() { + let dir = tempdir::Dir::new("prompt-queued"); + let queued = |uuid: &str, parent: &str, ts: &str, prompt: Value, origin: &str| { + serde_json::json!({ + "type": "attachment", "uuid": uuid, "parentUuid": parent, "timestamp": ts, "cwd": "/proj", + "attachment": {"type": "queued_command", "prompt": prompt, "commandMode": "prompt", "origin": {"kind": origin}}, + }) + }; + let edit = |text: &str| tool_use("Edit", serde_json::json!({"file_path": "/proj/src/net.rs", "new_string": text})); + let file = write( + dir.path(), + "s9.jsonl", + &[ + user("u1", None, "2026-08-20T10:00:00Z", serde_json::json!("rename the port field")), + assistant("a1", "u1", "2026-08-20T10:00:01Z", serde_json::json!([edit(" pub listen_port: u16,")])), + queued("q1", "a1", "2026-08-20T10:00:02Z", serde_json::json!([{"type": "text", "text": "and the host field"}]), "human"), + assistant("a2", "q1", "2026-08-20T10:00:03Z", serde_json::json!([edit(" pub listen_host: String,")])), + queued("q2", "a2", "2026-08-20T10:00:04Z", serde_json::json!("a background task finished"), "task"), + ], + ); + let turns = parse_claude(&file, Path::new("/proj")); + let got: Vec<(&str, usize)> = turns.iter().map(|t| (t.prompt.as_str(), t.edits.len())).collect(); + assert_eq!(got, [("rename the port field", 1), ("and the host field", 1)]); + assert_eq!(turns[1].id, "claude:s9:q1"); + } + #[test] fn a_turn_carries_the_model_that_answered_it_even_with_no_edit() { let dir = tempdir::Dir::new("prompt-model"); diff --git a/src/replay.rs b/src/replay.rs index 4484cd4..0c2ea38 100644 --- a/src/replay.rs +++ b/src/replay.rs @@ -39,6 +39,11 @@ fn git_raw(root: &Path, args: &[&str]) -> Option { out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned()) } +// a text kept for a replay, by the object id `texts_of` gave it +pub(crate) fn text_of(root: &Path, id: &str) -> Option { + git_raw(root, &["cat-file", "blob", id]) +} + // git fed `input`, with `env` set - an error carries what git said fn git_in(root: &Path, args: &[&str], input: &[u8], env: &[(&str, &str)]) -> Result { let mut child = Command::new("git") @@ -105,20 +110,27 @@ pub(crate) fn by_changeset(turns: &[Turn]) -> BTreeMap<&str, Vec<&Turn>> { // The ask a step answers: the one whose calls named its changeset - the // latest made before it, as a changeset can be staged under one ask and // applied under the next - or, for a look, which names none, the ask made -// last before it. A change made by hand answers none. +// last before it, in its own session where it names one. A change made by +// hand answers none. pub(crate) fn ask_of<'t>( human: bool, changeset: Option<&str>, + session: Option<&str>, at_ms: u64, turns: &'t [Turn], named: &BTreeMap<&str, Vec<&'t Turn>>, ) -> Option<&'t Turn> { - // a transcript's clock and the server's can sit a moment apart - let made_before = |t: &&Turn| t.epoch * 1000 <= at_ms as i64 + 5000; + // a transcript's clock and the server's can sit a moment apart - a look + // read from the transcript, which names its session, keeps the transcript's own + let lead = if session.is_some() { 0 } else { 5000 }; + let made_before = |t: &&Turn| t.epoch * 1000 <= at_ms as i64 + lead; match (human, changeset) { (true, _) => None, (_, Some(c)) => named.get(c).and_then(|ts| ts.iter().rev().find(|t| made_before(t)).or(ts.first()).copied()), - (_, None) => turns.iter().filter(made_before).max_by_key(|t| t.epoch), + (_, None) => { + let latest = |s: Option<&str>| turns.iter().filter(made_before).filter(|t| s.is_none_or(|s| t.session == s)).max_by_key(|t| t.epoch); + session.and_then(|s| latest(Some(s))).or_else(|| latest(None)) + } } } @@ -294,7 +306,7 @@ pub fn save(root: &Path, opts: &SaveOptions) -> Result { // a step no ask accounts for goes with the session it falls in let mut session: Option = None; for mut v in steps { - let ask = ask_of(v["by"] == "human", v["changeset"].as_str(), v["at"].as_u64().unwrap_or(0), &turns, &named); + let ask = ask_of(v["by"] == "human", v["changeset"].as_str(), v["session"].as_str(), v["at"].as_u64().unwrap_or(0), &turns, &named); if let Some(t) = ask { session = Some(t.session.clone()); } @@ -476,6 +488,33 @@ mod tests { use super::*; use std::fs; + // A look read from a transcript keeps the transcript's clock and serves + // the ask its own session made last before it - not one made a moment + // after, nor another session's. + #[test] + fn a_look_from_a_transcript_serves_its_own_sessions_ask() { + let turn = |id: &str, session: &str, epoch| Turn { + id: id.into(), + agent: "claude".into(), + session: session.into(), + model: None, + ts: String::new(), + branch: None, + prompt: String::new(), + edits: Vec::new(), + changesets: Vec::new(), + epoch, + }; + let turns = [turn("a", "s1", 100), turn("b", "s1", 102), turn("c", "s2", 150)]; + let named = by_changeset(&turns); + let ask = |session, at_ms| ask_of(false, None, session, at_ms, &turns, &named).map(|t| t.id.as_str()); + // the server's own look allows for the two clocks + assert_eq!(ask(None, 101_000), Some("b")); + assert_eq!(ask(Some("s1"), 101_000), Some("a")); + assert_eq!(ask(Some("s1"), 160_000), Some("b")); + assert_eq!(ask(None, 160_000), Some("c")); + } + // a repository with a branch off `main`, and a bare remote both were pushed to struct Repo { dir: PathBuf, @@ -555,7 +594,7 @@ mod tests { assert!(sh(&r.work, &["config", "--get-all", "remote.origin.fetch"]).contains(FETCH_SPEC)); assert_eq!(save(&r.work, &push).unwrap().steps, 0, "nothing new"); - feed(&r.work, &[step(4, "feature", "read", json!([]))]); + feed(&r.work, &[step(4, "feature", "inspect", json!([]))]); assert_eq!(save(&r.work, &push).unwrap().steps, 1); assert_eq!(lines(&r.work, "refs/ccc/replay/feature:sessions/unattributed.jsonl"), 4); assert_eq!(sh(&r.work, &["rev-list", "--count", "refs/ccc/replay/feature"]), "2"); diff --git a/src/scan.rs b/src/scan.rs index b3ccf2e..2da76c6 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -116,7 +116,25 @@ pub fn collect_files(root: &Path) -> Result> { // parse every discovered file into a `FileCache`, sorted by path pub fn build_caches(root: &Path, files: &[PathBuf]) -> Vec { - let mut caches = parse_all(root, files); + build_caches_reusing(root, files, |_| None) +} + +// The caches for `files`, each that `held` still has for a file - one that +// has not changed since - taken from there rather than parsed again. +pub fn build_caches_reusing(root: &Path, files: &[PathBuf], held: impl Fn(&PathBuf) -> Option) -> Vec { + let mut caches = Vec::with_capacity(files.len()); + let mut fresh = Vec::new(); + for f in files { + match held(f) { + Some(mut c) => { + // named afresh below, against whatever else is here now + c.cache_name = naming::cache_name(&c.rel_path); + caches.push(c); + } + None => fresh.push(f.clone()), + } + } + caches.extend(parse_all(root, &fresh)); caches.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); disambiguate_cache_names(&mut caches); caches diff --git a/src/serve.rs b/src/serve.rs index 46c5128..e8a8f42 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -74,6 +74,12 @@ struct MapState { feed: Mutex, // the requests behind the timeline's steps, read from agents' transcripts asks: Mutex, + // what agents read with their own tools, as far as their transcripts were read + inspects: Mutex, + // held while the map is read again - see `rescan` + rescanning: Arc>, + // each mapped file's mtime and size when it was read, so one unchanged is not parsed again + stamps: Mutex, // the advisory answer for this map generation; asking osv is a network round trip audit: Mutex>, // security findings for this generation, keyed also by whether tests were included @@ -143,11 +149,20 @@ impl Timeline { if let Some(o) = v.as_object_mut() { o.remove("server"); } - tl.log(Logged { json: v, before: None, after: None }); + as_inspect(&mut v); + tl.log(Logged::bare(v)); } tl } + // Is this look on the timeline already - the step just logged, or the + // same call another server's feed put down. + fn holds(&self, look: &Value) -> bool { + let same = |l: &Logged| l.json["status"] == "inspect" && l.json["intent"] == look["intent"]; + self.steps.back().is_some_and(same) + || (look["call"].is_string() && self.steps.iter().any(|l| same(l) && l.json["call"] == look["call"])) + } + // A step onto the end, numbered, and the event as logged. Past the cap a // look goes before an edit does - the edits are what a replay is for - // and only the newest steps keep their snapshots. @@ -158,13 +173,14 @@ impl Timeline { self.steps.push_back(l); while self.steps.len() > TIMELINE_STEPS { let older = self.steps.len() / 2; - match self.steps.iter().take(older).position(|s| s.json["status"] == "read") { + match self.steps.iter().take(older).position(|s| s.json["status"] == "inspect") { Some(i) => drop(self.steps.remove(i)), None => drop(self.steps.pop_front()), } } let n = self.steps.len(); for old in self.steps.iter_mut().take(n.saturating_sub(TIMELINE_SNAPSHOTS)) { + old.drawable = None; old.before = None; old.after = None; } @@ -174,18 +190,47 @@ impl Timeline { struct Logged { json: Value, - // the step drawn at its focus level, on each side + // what the step's sides are drawn from, while it is among the newest - + // none for a look, or a step another server shared + drawable: Option>, + // each side drawn at its focus level, once a page asked for it before: Option>, after: Option>, } -// a step on its way to the timeline, read while the map still held its before side +impl Logged { + // a step with nothing to draw + fn bare(json: Value) -> Logged { + Logged { json, drawable: None, before: None, after: None } + } +} + +// a step on its way to the timeline struct Draft { step: edit::Activity, event: vis::Event, - before: Option>, - // a part of a step, or a staged call - sides no map held - unmapped: bool, +} + +// a logged step that kept the texts it is drawn from - a write, staged or +// made by hand; a discard is drawn already as what it dropped +fn drawable_json(v: &Value) -> bool { + matches!(v["status"].as_str(), Some("staged" | "applied" | "reverted" | "edited")) && v["texts"].as_array().is_some_and(|t| !t.is_empty()) +} + +// A step logged before looks were inspects, as one now. +fn as_inspect(v: &mut Value) { + if v["status"] != "read" { + return; + } + v["status"] = json!("inspect"); + for key in ["files", "functions"] { + for t in v.get_mut(key).and_then(Value::as_array_mut).into_iter().flatten() { + t["change"] = json!("inspect"); + } + } + if v["focus"]["change"] == "read" { + v["focus"]["change"] = json!("inspect"); + } } fn now_ms() -> u64 { @@ -271,6 +316,8 @@ struct StepRef { at: u64, human: bool, changeset: Option, + // the agent session a look came from, where its transcript said + session: Option, } // Steps into runs by the ask behind them. A step an agent took belongs to the @@ -283,7 +330,7 @@ fn group_by_ask(steps: &[StepRef], turns: &[crate::prompts::Turn]) -> Value { let named = crate::replay::by_changeset(turns); let mut groups: Vec<(&str, Option<&crate::prompts::Turn>, Vec)> = Vec::new(); for s in steps { - let ask = crate::replay::ask_of(s.human, s.changeset.as_deref(), s.at, turns, &named); + let ask = crate::replay::ask_of(s.human, s.changeset.as_deref(), s.session.as_deref(), s.at, turns, &named); let kind = match (s.human, ask) { (true, _) => "hand", (_, Some(_)) => "ask", @@ -359,6 +406,8 @@ fn hand_step(files: Vec) -> Vec { impl MapState { fn build(root: &Path) -> Result { let files = scan::collect_files(root)?; + // stamped before they are read, so a write meanwhile is read again + let stamps = stamps_of(&files); let caches = scan::build_caches(root, &files); let withdrawn = scan::withdrawn_files(root, &files, &caches); let seen = seen_of(root, &caches); @@ -393,6 +442,9 @@ impl MapState { // and only what the others share from here on feed: Mutex::new(Feed { read: std::fs::metadata(feed_path(root)).map_or(0, |m| m.len()), ..Default::default() }), asks: Mutex::new(AsksCache::default()), + inspects: Mutex::new(Default::default()), + rescanning: Default::default(), + stamps: Mutex::new(stamps), audit: Mutex::new(None), sast: Mutex::new(None), deps: Mutex::new((String::new(), BTreeMap::new())), @@ -449,37 +501,16 @@ impl MapState { report } - fn rescan(&mut self) -> Result<(usize, usize)> { - let before = self.caches.len(); - let files = scan::collect_files(&self.root)?; - let caches = scan::build_caches(&self.root, &files); - // read while the map still holds what a change by hand started from - let drafts = self.found_drafts(&caches); - self.caches = caches; - self.withdrawn = scan::withdrawn_files(&self.root, &files, &self.caches); - self.contracts = ContractIndex::for_root(&self.root, &self.caches); - self.externals = manifest_deps(&self.root); - self.facade = cargo_package_name(&self.root); - self.ts = render::now_ts(); - self.invalidate(); - self.timeline_finish(drafts); - Ok((before, self.caches.len())) - } - - // swap in a fresh map (built outside lock by watcher) - fn swap_in(&mut self, caches: Vec) { - let drafts = self.found_drafts(&caches); - self.caches = caches; - // the watcher hands over caches alone; the walk is cheap next to the parse - self.withdrawn = scan::collect_files(&self.root) - .map(|files| scan::withdrawn_files(&self.root, &files, &self.caches)) - .unwrap_or_default(); - self.contracts = ContractIndex::for_root(&self.root, &self.caches); - self.externals = manifest_deps(&self.root); - self.facade = cargo_package_name(&self.root); + // a map read afresh from disk put in place of this one - see `swap_in` + fn swap(&mut self, fresh: Fresh) { + *self.stamps.get_mut().unwrap_or_else(|p| p.into_inner()) = fresh.stamps; + self.caches = fresh.caches; + self.withdrawn = fresh.withdrawn; + self.contracts = fresh.contracts; + self.externals = fresh.externals; + self.facade = fresh.facade; self.ts = render::now_ts(); self.invalidate(); - self.timeline_finish(drafts); } // What changed on disk since the timeline last looked, other than through @@ -611,6 +642,7 @@ impl MapState { at: l.json["at"].as_u64().unwrap_or(0), human: l.json["by"] == "human", changeset: l.json["changeset"].as_str().map(str::to_string), + session: l.json["session"].as_str().map(str::to_string), }) .collect(); let Some(first) = steps.first() else { @@ -695,13 +727,13 @@ impl MapState { // The first half of putting edit steps on the timeline: each split into // the parts it is made of, a definition at a time, and read into an event - // while the map still holds the side it started from - and, when a - // visualiser is open, that side drawn. + // while the map still holds the side it started from. Nothing is drawn + // here - a page asks for a step's sides when it shows them, so an edit to + // a big project waits on no drawing. fn timeline_begin(&self, tool: &str, steps: Vec) -> Vec { if steps.is_empty() { return Vec::new(); } - let model = self.timeline().open().then(|| self.vis_model()); let lang_of = |p: &str| { self.caches .iter() @@ -715,54 +747,42 @@ impl MapState { .map(|(step, part)| { let mut event = vis::event(0, now_ms(), tool, &step, lang_of); event.json["part"] = part.as_ref().map_or(Value::Null, vis::Part::json); - // a staged call starts where the calls before it left the file - let unmapped = part.is_some() || step.status == "staged"; - let before = match &model { - // a discarded step is drawn already, as the step that staged it - Some(m) if step.status != "discarded" => self.side_of(m, &step, unmapped, &event.focus, false), - _ => None, - }; - Draft { step, event, before, unmapped } + Draft { step, event } }) .collect() } // One side of a step drawn at its focus. A function's logic draws from the - // step's own text through this map; a wider view of a side no map held - - // a part's, a staged call's - draws from the map that text would make. - fn side_of(&self, m: &vis::Model, step: &edit::Activity, unmapped: bool, focus: &vis::Focus, after: bool) -> Option> { + // step's own text through this map; a wider view draws from the map the + // side's text makes of this one - and a staged step's after side, which + // no map held, from the map it would leave. + fn side_of(&self, m: &vis::Model, step: &edit::Activity, focus: &vis::Focus, after: bool) -> Option> { let flow = matches!(focus, vis::Focus::Flow(_)); - if unmapped && (!flow || (after && step.status == "staged")) { + if !flow || (after && step.status == "staged") { let (caches, m) = self.proposed(step, after); return m.snapshot(&caches, step, focus, after); } m.snapshot(&self.caches, step, focus, after) } - // The second half, once any write is folded into the map: each step's - // after side drawn from the map it left, the steps logged, and this - // server's own shared on the project's feed. A staged step is drawn from - // the map it would leave, so it can be looked over before it is written. + // The second half, once any write is folded into the map: the steps + // logged, each keeping what its sides are drawn from, and this server's + // own shared on the project's feed. fn timeline_finish(&self, drafts: Vec) { if drafts.is_empty() { return; } - let model = self.timeline().open().then(|| self.vis_model()); let mut logged = Vec::with_capacity(drafts.len()); // what each side held, kept as git objects so a saved replay draws it again let mut kept = std::collections::HashMap::new(); for d in drafts { - let after = match (&model, d.step.status) { - (Some(m), "applied" | "reverted" | "edited" | "staged") => { - self.side_of(m, &d.step, d.unmapped, &d.event.focus, true) - } - _ => None, - }; let mut json = d.event.json; if self.git && json["tool"] != ELSEWHERE { json["texts"] = crate::replay::texts_of(&self.root, &d.step.files, &mut kept); } - logged.push(Logged { json, before: d.before, after }); + // a discarded step is drawn already, as the step that staged it + let drawable = (d.step.status != "discarded").then(|| Arc::new((d.step, d.event.focus))); + logged.push(Logged { json, drawable, before: None, after: None }); } let shared: Vec = { let mut tl = self.timeline(); @@ -800,7 +820,7 @@ impl MapState { .filter(|l| l.json["seq"].as_u64().is_some_and(|s| s > since)) .map(|l| { let mut v = l.json.clone(); - v["snapshot"] = json!(l.before.is_some() || l.after.is_some()); + v["snapshot"] = json!(l.drawable.is_some() || (self.git && drawable_json(&l.json))); v }) .collect(); @@ -813,31 +833,123 @@ impl MapState { }) } - // one step whole: the event and the level it happened on, drawn each side + // One step whole: the event and the level it happened on, drawn each side + // the first time a page asks - with the timeline let go meanwhile, as a + // big project's map takes a moment to draw - and kept for the next. fn timeline_step(&self, seq: u64) -> Option { - let tl = self.timeline(); - let l = tl.steps.iter().find(|l| l.json["seq"].as_u64() == Some(seq))?; + let at = |l: &&Logged| l.json["seq"].as_u64() == Some(seq); + let (json, drawable, mut before, mut after) = { + let tl = self.timeline(); + let l = tl.steps.iter().find(at)?; + (l.json.clone(), l.drawable.clone(), l.before.clone(), l.after.clone()) + }; + // one logged before this server started, or shared by another, is put + // back together from the texts kept of it + let drawable = drawable.or_else(|| self.rebuilt(&json)); + if let Some(d) = drawable.filter(|_| before.is_none() && after.is_none()) { + let (step, focus) = &*d; + let model = self.vis_model(); + before = self.side_of(&model, step, focus, false); + if matches!(step.status, "applied" | "reverted" | "edited" | "staged") { + after = self.side_of(&model, step, focus, true); + } + let mut tl = self.timeline(); + if let Some(l) = tl.steps.iter_mut().find(|l| l.json["seq"].as_u64() == Some(seq)) { + l.before = before.clone(); + l.after = after.clone(); + } + } let side = |s: &Option>| s.as_deref().cloned().unwrap_or(Value::Null); - Some(json!({"event": l.json, "before": side(&l.before), "after": side(&l.after)})) + Some(json!({"event": json, "before": side(&before), "after": side(&after)})) + } + + // A step put back together from the texts the feed kept of it, as git + // objects - one logged before this server started, or shared by another + // server - so it is drawn like a step this server took. None without git, + // without texts, or with one of them gone. + fn rebuilt(&self, json: &Value) -> Option> { + if !self.git || !drawable_json(json) { + return None; + } + let status = match json["status"].as_str()? { + "staged" => "staged", + "applied" => "applied", + "reverted" => "reverted", + _ => "edited", + }; + // a side the step had no file on is none; one it had must still be there + let text = |v: &Value| match v.as_str() { + Some(id) => crate::replay::text_of(&self.root, id).map(Some), + None => Some(None), + }; + let mut files = Vec::new(); + for t in json["texts"].as_array()? { + files.push(edit::FileText { path: t["path"].as_str()?.to_string(), before: text(&t["before"])?, after: text(&t["after"])? }); + } + let step = edit::Activity { + changeset: json["changeset"].as_str().unwrap_or_default().to_string(), + status, + ops: Vec::new(), + files, + intent: Vec::new(), + }; + let lang_of = |p: &str| { + self.caches + .iter() + .find(|c| crate::changes::path_str(&c.rel_path) == p) + .map(|c| c.language) + .or_else(|| crate::languages::Language::from_path(Path::new(p))) + }; + let focus = vis::event(0, 0, json["tool"].as_str().unwrap_or_default(), &step, lang_of).focus; + Some(Arc::new((step, focus))) } - // An agent's look at the code through a read tool, logged as a step of - // its own and shared - unless it is the look just logged. + // An agent's look at the code through a read tool, logged as an inspect + // of its own and shared - unless it is the look just logged. fn timeline_look(&self, tool: &str, what: &str, sites: &[(String, usize, bool)]) { let Some(ev) = vis::look(now_ms(), tool, what, sites, &self.caches) else { return; }; let shared = { let mut tl = self.timeline(); - let again = tl.steps.back().is_some_and(|l| l.json["status"] == "read" && l.json["intent"] == ev.json["intent"]); - if again { + if tl.holds(&ev.json) { return; } - tl.log(Logged { json: ev.json, before: None, after: None }) + tl.log(Logged::bare(ev.json)) }; self.feed_out(&[shared]); } + // What agents read with their own tools since the last time - a `Read`, + // a `cat` - each an inspect logged and shared like a read tool's look. + fn timeline_inspects(&self) { + let reads = self.inspects.lock().unwrap_or_else(|p| p.into_inner()).fresh(&self.root); + self.timeline_reads(reads); + } + + // reads onto the timeline, each once, whichever server's transcripts or feed it came by + fn timeline_reads(&self, reads: Vec) { + if reads.is_empty() { + return; + } + let shared: Vec = { + let mut tl = self.timeline(); + let mut shared = Vec::new(); + for r in reads { + let Some(mut ev) = vis::look(r.at_ms, &r.tool, &r.what(), &r.sites(&self.caches), &self.caches) else { + continue; + }; + ev.json["call"] = json!(r.call); + ev.json["session"] = json!(r.session); + if !tl.holds(&ev.json) { + shared.push(tl.log(Logged::bare(ev.json))); + } + } + shared + }; + self.feed_out(&shared); + } + // this server's own steps onto the project's shared feed fn feed_out(&self, events: &[Value]) { if events.is_empty() { @@ -888,6 +1000,11 @@ impl MapState { if v["server"].as_str() == Some(me.as_str()) || v["by"] == "human" { continue; } + as_inspect(&mut v); + // a read this server found in the same transcript first + if v["status"] == "inspect" && tl.holds(&v) { + continue; + } let write = matches!(v["status"].as_str(), Some("applied" | "reverted")); if let Some(id) = v["changeset"].as_str().filter(|_| write) { if feed.ledgered.contains(id) { @@ -898,7 +1015,7 @@ impl MapState { if let Some(o) = v.as_object_mut() { o.remove("server"); } - tl.log(Logged { json: v, before: None, after: None }); + tl.log(Logged::bare(v)); } } @@ -1004,17 +1121,83 @@ impl MapState { } +// A map read afresh from disk, everything in it worked out before any lock +// on the map is taken. +struct Fresh { + stamps: Fingerprint, + caches: Vec, + withdrawn: Vec, + contracts: ContractIndex, + externals: Vec, + facade: Option, +} + +impl Fresh { + fn of(root: &Path, stamps: Fingerprint, caches: Vec) -> Fresh { + let files: Vec = stamps.keys().cloned().collect(); + Fresh { + stamps, + withdrawn: scan::withdrawn_files(root, &files, &caches), + contracts: ContractIndex::for_root(root, &caches), + externals: manifest_deps(root), + facade: cargo_package_name(root), + caches, + } + } +} + +// The project read again and put in place of the map. The parse takes no +// lock, so a big project's rescan holds up no reader but for the swap - one +// rescan at a time, so an older read never lands after a newer one. +fn rescan(state: &RwLock) -> Result<(usize, usize, String)> { + let (root, one) = { + let map = state.read().expect("map lock poisoned"); + (map.root.clone(), Arc::clone(&map.rescanning)) + }; + let _one = one.lock().unwrap_or_else(|p| p.into_inner()); + let now = fingerprint(&root)?; + let caches = reparse(state, &root, &now); + Ok(swap_in(state, Fresh::of(&root, now, caches))) +} + +// The project's files parsed, each unchanged since the map read it taken +// from the map - an edit's rescan parses what it wrote, not the project. +fn reparse(state: &RwLock, root: &Path, now: &Fingerprint) -> Vec { + let files: Vec = now.keys().cloned().collect(); + let map = state.read().expect("map lock poisoned"); + let was = map.stamps.lock().unwrap_or_else(|p| p.into_inner()); + let held: std::collections::HashMap<&Path, &FileCache> = map.caches.iter().map(|c| (c.rel_path.as_path(), c)).collect(); + scan::build_caches_reusing(root, &files, |f| { + let same = was.get(f).is_some_and(|s| now.get(f) == Some(s)); + let c = held.get(f.strip_prefix(root).ok()?)?; + same.then(|| (*c).clone()) + }) +} + +// A fresh map in place of the one held: what changed by hand read against the +// map it started from, the write lock taken only for the swap. The files +// before and after, and the generation it made. +fn swap_in(state: &RwLock, fresh: Fresh) -> (usize, usize, String) { + let drafts = state.read().expect("map lock poisoned").found_drafts(&fresh.caches); + let done = { + let mut map = state.write().expect("map lock poisoned"); + let before = map.caches.len(); + map.swap(fresh); + (before, map.caches.len(), map.ts.clone()) + }; + state.read().expect("map lock poisoned").timeline_finish(drafts); + done +} + // comps every mapped file with its mtime (unix nanos) and size. type Fingerprint = BTreeMap; fn fingerprint(root: &Path) -> Result { - let mut fp = Fingerprint::new(); - for f in scan::collect_files(root)? { - if let Ok(md) = std::fs::metadata(&f) { - fp.insert(f, stamp(&md)); - } - } - Ok(fp) + Ok(stamps_of(&scan::collect_files(root)?)) +} + +fn stamps_of(files: &[PathBuf]) -> Fingerprint { + files.iter().filter_map(|f| std::fs::metadata(f).ok().map(|md| (f.clone(), stamp(&md)))).collect() } // a file's mtime (unix nanos) and size - it changed when either did @@ -1037,17 +1220,18 @@ fn fingerprint_delta(a: &Fingerprint, b: &Fingerprint) -> usize { changed + added } -fn check_and_rebuild( - root: &Path, - last: &Fingerprint, -) -> Result, usize)>> { +// the project read again if anything in it moved since the map read it +fn check_and_rebuild(state: &RwLock, root: &Path) -> Result, usize)>> { let now = fingerprint(root)?; - if now == *last { - return Ok(None); - } - let delta = fingerprint_delta(last, &now); - let files: Vec = now.keys().cloned().collect(); - let caches = scan::build_caches(root, &files); + let delta = { + let map = state.read().expect("map lock poisoned"); + let was = map.stamps.lock().unwrap_or_else(|p| p.into_inner()); + if now == *was { + return Ok(None); + } + fingerprint_delta(&was, &now) + }; + let caches = reparse(state, root, &now); Ok(Some((now, caches, delta))) } @@ -1055,22 +1239,18 @@ fn check_and_rebuild( // changes as they land, whoever makes them fn spawn_watcher(state: Arc>, root: PathBuf, interval: std::time::Duration, while_open: bool) { std::thread::spawn(move || { - let mut last = fingerprint(&root).unwrap_or_default(); let mut warned = false; loop { std::thread::sleep(interval); if while_open && !state.read().expect("map lock poisoned").timeline().open() { continue; } - match check_and_rebuild(&root, &last) { + let one = Arc::clone(&state.read().expect("map lock poisoned").rescanning); + let _one = one.lock().unwrap_or_else(|p| p.into_inner()); + match check_and_rebuild(&state, &root) { Ok(Some((fp, caches, delta))) => { let n = caches.len(); - let ts = { - let mut map = state.write().expect("map lock poisoned"); - map.swap_in(caches); - map.ts.clone() - }; - last = fp; + let (_, _, ts) = swap_in(&state, Fresh::of(&root, fp, caches)); warned = false; println!("map refreshed: {n} files ({delta} changed) at {ts}"); } @@ -1087,6 +1267,17 @@ fn spawn_watcher(state: Arc>, root: PathBuf, interval: std::tim }); } +// how often agents' transcripts are read for what they read with their own tools +const INSPECT_EVERY: std::time::Duration = std::time::Duration::from_secs(2); + +// what agents read with their own tools lands on the timeline as they read it +fn spawn_inspector(state: Arc>) { + std::thread::spawn(move || loop { + std::thread::sleep(INSPECT_EVERY); + state.read().unwrap_or_else(|p| p.into_inner()).timeline_inspects(); + }); +} + // `prefix` narrows the overview to one subtree; totals then describe the // filtered set, with the project-wide file count kept alongside so a filtered // answer can never be mistaken for the whole project @@ -4446,15 +4637,8 @@ fn mcp_tool_call(state: &RwLock, params: &Value, browser: bool) -> Res } if name == "refresh" { - let mut map = state.write().expect("map lock poisoned"); - return match map.rescan() { - Ok((before, after)) => Ok(mcp_md( - &format!( - "rescanned: {before} -> {after} files (generated {})", - map.ts - ), - false, - )), + return match rescan(state) { + Ok((before, after, ts)) => Ok(mcp_md(&format!("rescanned: {before} -> {after} files (generated {ts})"), false)), Err(e) => Ok(mcp_md(&format!("error: {e}"), true)), }; } @@ -4746,9 +4930,8 @@ fn edit_tool_call(state: &RwLock, name: &str, args: &Value) -> Value { }; // a write landed - fold it into the map before anyone asks again if done.wrote { - let mut map = state.write().expect("map lock poisoned"); - match map.rescan() { - Ok((_, after)) => text.push_str(&format!("map rescanned: {after} files (generated {})\n", map.ts)), + match rescan(state) { + Ok((_, after, ts)) => text.push_str(&format!("map rescanned: {after} files (generated {ts})\n")), Err(e) => text.push_str(&format!("the map did not rescan ({e}) - call `refresh`\n")), } } @@ -5322,17 +5505,14 @@ fn route_from(state: &RwLock, method: &str, url: &str, body: &[u8], or status: 204, body: ReplyBody::Empty, }, - ("POST", "/refresh") => { - let mut map = state.write().expect("map lock poisoned"); - match map.rescan() { - Ok((before, after)) => ok(json!({ - "files_before": before, - "files_after": after, - "generated": map.ts, - })), - Err(e) => bad(500, format!("rescan failed: {e:#}")), - } - } + ("POST", "/refresh") => match rescan(state) { + Ok((before, after, ts)) => ok(json!({ + "files_before": before, + "files_after": after, + "generated": ts, + })), + Err(e) => bad(500, format!("rescan failed: {e:#}")), + }, ("POST", "/mcp") => { let msg: Value = match serde_json::from_slice(body) { Ok(v) => v, @@ -5441,6 +5621,8 @@ pub fn serve(root: &Path, opts: &ServeOptions) -> Result<()> { } } + spawn_inspector(Arc::clone(&state)); + let server = Arc::new(server); let mut workers = Vec::new(); for _ in 0..4 { @@ -6613,25 +6795,28 @@ mod tests { fs::create_dir_all(&dir).unwrap(); fs::write(dir.join("a.rs"), "fn one() {}\n").unwrap(); - let fp0 = fingerprint(&dir).unwrap(); - assert!(check_and_rebuild(&dir, &fp0).unwrap().is_none()); + let state = RwLock::new(MapState::build(&dir).unwrap()); + assert!(check_and_rebuild(&state, &dir).unwrap().is_none()); + // the change found, and the map it makes swapped in + let next = || { + let (fp, caches, delta) = check_and_rebuild(&state, &dir).unwrap().expect("a change"); + let names: Vec = caches.iter().flat_map(|c| c.funcs.iter().map(|f| f.name.clone())).collect(); + swap_in(&state, Fresh::of(&dir, fp, caches)); + (names, delta) + }; // appended function shows up in the fresh map fs::write(dir.join("a.rs"), "fn one() {}\nfn two() {}\n").unwrap(); - let (fp1, caches, delta) = check_and_rebuild(&dir, &fp0).unwrap().expect("edit detected"); - assert_eq!(delta, 1); - assert!(caches[0].funcs.iter().any(|f| f.name == "two")); + assert_eq!(next(), (vec!["one".to_string(), "two".to_string()], 1)); - // add a file + // a file added is parsed, and the one beside it kept as it was read fs::write(dir.join("b.rs"), "fn three() {}\n").unwrap(); - let (fp2, caches, _) = check_and_rebuild(&dir, &fp1).unwrap().expect("add detected"); - assert_eq!(caches.len(), 2); + assert_eq!(next().0, ["one", "two", "three"]); - // delete it again q + // and deleted again fs::remove_file(dir.join("b.rs")).unwrap(); - let (_, caches, delta) = check_and_rebuild(&dir, &fp2).unwrap().expect("delete detected"); - assert_eq!(caches.len(), 1); - assert_eq!(delta, 1); + assert_eq!(next(), (vec!["one".to_string(), "two".to_string()], 1)); + assert!(check_and_rebuild(&state, &dir).unwrap().is_none()); let _ = fs::remove_dir_all(&dir); } @@ -7591,6 +7776,29 @@ mod tests { assert_eq!(who, [("agent", "staged"), ("agent", "staged"), ("agent", "applied"), ("agent", "applied")]); } + // A step logged before a restart is drawn all the same: the texts the + // feed kept of it are git objects, and the step is put back together + // from them when a page asks to see it. + #[test] + fn a_step_from_before_a_restart_is_drawn_from_its_kept_texts() { + let (dir, before) = scratch("rebuilt", &[("src/pay.rs", "pub fn charge(n: i32) -> i32 {\n n\n}\n")]); + let git = |args: &[&str]| std::process::Command::new("git").arg("-C").arg(&dir.0).args(args).output().unwrap(); + git(&["init", "-q"]); + drop(before); + let before = RwLock::new(MapState::build(&dir.0).unwrap()); + let (err, out) = tool(&before, "edit_text", json!({"path": "src/pay.rs", "old": " n\n", "new": " n + 1\n", "apply": true})); + assert!(!err, "{out}"); + drop(before); + let after = RwLock::new(MapState::build(&dir.0).unwrap()); + let all = json_of(&route(&after, "GET", "/vis/events?since=0", b"")).clone(); + let step = all["events"].as_array().unwrap().iter().find(|e| e["status"] == "applied").expect("the write").clone(); + assert_eq!(step["snapshot"], true, "{step}"); + let seq = step["seq"].as_u64().unwrap(); + let drawn = json_of(&route(&after, "GET", &format!("/vis/event?seq={seq}"), b"")).clone(); + assert_eq!(drawn["before"]["function"]["name"], "charge", "{drawn}"); + assert!(!drawn["after"].is_null(), "{drawn}"); + } + // A server started on a project picks the timeline up where the servers // before it left it, so a restart loses none of what agents did. #[test] @@ -7609,7 +7817,7 @@ mod tests { .map(|e| (e["seq"].as_u64().unwrap(), e["status"].as_str().unwrap())) .collect(); // staged and written in one call, the change is its write alone - assert_eq!(steps, [(1, "read"), (2, "applied")]); + assert_eq!(steps, [(1, "inspect"), (2, "applied")]); } // Two servers on one project share what the ledger cannot carry: one @@ -7636,7 +7844,7 @@ mod tests { .iter() .map(|e| (e["status"].as_str().unwrap(), e["tool"].as_str().unwrap(), e["focus"]["name"].as_str().unwrap_or("-"))) .collect(); - assert_eq!(steps, [("read", "references", "charge"), ("applied", "edit_text", "fee"), ("applied", "edit_text", "charge")]); + assert_eq!(steps, [("inspect", "references", "charge"), ("applied", "edit_text", "fee"), ("applied", "edit_text", "charge")]); } // An agent's reads land on the timeline as looks at what they named - the @@ -7662,12 +7870,58 @@ mod tests { .iter() .map(|e| (e["status"].as_str().unwrap(), e["intent"][0].as_str().unwrap(), e["focus"]["level"].as_str().unwrap())) .collect(); - assert_eq!(looks, [("read", "references charge", "flow"), ("read", "file src/pay.rs", "code")]); + assert_eq!(looks, [("inspect", "references charge", "flow"), ("inspect", "file src/pay.rs", "code")]); assert_eq!(all["events"][0]["focus"]["name"], "charge"); let marked: Vec<&str> = all["events"][0]["functions"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); assert_eq!(marked, ["charge", "refund"]); } + // What an agent read with its own tools lands as an inspect too - the + // function its lines lie in, the file a whole read took - each call once, + // and nothing for a file the map does not hold. + #[test] + fn an_agents_own_reads_land_on_the_timeline_as_inspects() { + let (_dir, state) = scratch( + "inspects", + &[("src/pay.rs", "pub fn charge(n: i32) -> i32 {\n n\n}\n\npub fn refund(n: i32) -> i32 {\n charge(-n)\n}\n")], + ); + let read = |call: &str, tool: &str, path: &str, lines| crate::inspect::Read { + at_ms: 1, + session: "s1".into(), + call: call.into(), + tool: tool.into(), + path: path.into(), + lines, + }; + let map = state.read().unwrap(); + map.timeline_reads(vec![read("t1", "Read", "src/pay.rs", Some((6, 6))), read("t2", "Bash", "src/pay.rs", None), read("t3", "Read", "notes.txt", None)]); + map.timeline_reads(vec![read("t1", "Read", "src/pay.rs", Some((6, 6)))]); + drop(map); + let all = json_of(&route(&state, "GET", "/vis/events?since=0", b"")).clone(); + let got: Vec<(&str, &str, &str, &str)> = all["events"] + .as_array() + .unwrap() + .iter() + .map(|e| (e["status"].as_str().unwrap(), e["intent"][0].as_str().unwrap(), e["focus"]["level"].as_str().unwrap(), e["call"].as_str().unwrap())) + .collect(); + assert_eq!(got, [("inspect", "Read src/pay.rs:6", "flow", "t1"), ("inspect", "Bash src/pay.rs", "code", "t2")]); + assert_eq!(all["events"][0]["focus"]["name"], "refund"); + assert_eq!(all["events"][0]["session"], "s1"); + let fed = fs::read_to_string(feed_path(&state.read().unwrap().root)).unwrap(); + assert_eq!(fed.lines().count(), 2, "{fed}"); + } + + // A feed written before looks were inspects reads back as inspects. + #[test] + fn a_read_from_before_reads_back_as_an_inspect() { + let mut v = json!({"status": "read", "files": [{"change": "read"}], "functions": [{"change": "read"}], "focus": {"change": "read"}}); + as_inspect(&mut v); + assert_eq!(v, json!({"status": "inspect", "files": [{"change": "inspect"}], "functions": [{"change": "inspect"}], "focus": {"change": "inspect"}})); + let mut code = json!({"status": "read", "focus": {"level": "code"}}); + as_inspect(&mut code); + assert_eq!(code, json!({"status": "inspect", "focus": {"level": "code"}})); + } + // Each step is put down to the ask whose calls named its changeset - the // latest made before it, since a changeset staged under one ask can be // applied under the next - and changes made by hand run on their own. @@ -7690,6 +7944,7 @@ mod tests { at: secs * 1000, human, changeset: changeset.map(str::to_string), + session: None, }; let turns = [turn("a", 100, &["c1"]), turn("b", 200, &["c1", "c2"])]; let steps = [ diff --git a/src/vis.rs b/src/vis.rs index 0f25aa5..a31a651 100644 --- a/src/vis.rs +++ b/src/vis.rs @@ -1314,7 +1314,7 @@ pub fn look(at_ms: u64, tool: &str, what: &str, sites: &[(String, usize, bool)], file: path.clone(), name: f.name.clone(), owner: f.owner.clone(), - change: "read", + change: "inspect", before: None, after: Some(span), lines_before: Vec::new(), @@ -1342,14 +1342,14 @@ pub fn look(at_ms: u64, tool: &str, what: &str, sites: &[(String, usize, bool)], "at": at_ms, "tool": tool, "changeset": Value::Null, - "status": "read", + "status": "inspect", "by": "agent", "intent": [asked], "on_disk": "after", "ops": [], "files": files .iter() - .map(|p| json!({"path": p, "change": "read", "added": 0, "removed": 0, "hunks": [], "diff": [], "diff_truncated": false, "whitespace": false})) + .map(|p| json!({"path": p, "change": "inspect", "added": 0, "removed": 0, "hunks": [], "diff": [], "diff_truncated": false, "whitespace": false})) .collect::>(), "files_total": files.len(), "whitespace": false, @@ -2906,8 +2906,8 @@ f :: proc(x: int) -> int { let sites = [("src/pay.rs".to_string(), 1, true), ("src/pay.rs".to_string(), 4, false)]; let ev = look(0, "references", "charge", &sites, &caches).expect("a look"); let Focus::Flow(t) = &ev.focus else { panic!("{:?}", ev.focus) }; - assert_eq!((t.name.as_str(), t.change), ("charge", "read")); - assert_eq!(ev.json["status"], "read"); + assert_eq!((t.name.as_str(), t.change), ("charge", "inspect")); + assert_eq!(ev.json["status"], "inspect"); assert_eq!(ev.json["intent"], json!(["references charge"])); let names: Vec<&str> = ev.json["functions"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); assert_eq!(names, ["charge", "refund"]); diff --git a/visual.png b/visual.png index d267e3d..068a22e 100644 Binary files a/visual.png and b/visual.png differ