diff --git a/docs/CALM-TRANSCRIPT.md b/docs/CALM-TRANSCRIPT.md new file mode 100644 index 0000000..7e43a42 --- /dev/null +++ b/docs/CALM-TRANSCRIPT.md @@ -0,0 +1,183 @@ +# LevelCode — Calm narrative transcript (voice + grouped activity) — scope & plan + +The goal, in one sentence: make an agent run read like a colleague narrating their work — short +prose between actions, and consecutive actions folded into one expandable card whose header shows +the *live* step while running and a past-tense aggregate ("Read and edited PLAN.md +56 −0, ran 2 +commands") when done — instead of a flat scroll of chips and cards. + +The group card itself is never collapsed while it runs: its *members* collapse to one-line rows as +they finish, so the live step's output stays readable at a fixed vertical footprint. Clause order +in the aggregate is files first, commands last, matching the reference transcript. + +Reference behavior: Claude Code's transcript (screenshots in the design discussion, 2026-07-23). +Two halves, shipped as separate slices: the **voice** (system prompt) and the **grouping** (webview). + +## 1. Verified facts about our own code (read 2026-07-23, not recalled) + +- **Interleaving already works end-to-end.** `providers.streamAgentTurn` returns Anthropic-shaped + `turn.content` (text + tool_use blocks in order); `agent.js` pushes it wholesale into `messages`, + so narration between tool calls is persisted and re-sent. In the webview, `agentDelta` streams + text into a bubble and `agentTool`/`termRun`/`editApplied` each close the bubble + (`finishAgentBubble`) and append a timeline entry. Text → chip → text already renders; it is the + *prompt* that suppresses the text and the *flat appending* that makes it noisy. +- **The current prompt actively fights narration.** `SYSTEM_BASE` (agent.js:21): "Do NOT write long + multi-point plans — at most ONE short sentence before each tool call", "Be DECISIVE and FAST", + and a one-line `Done:` ending. +- **Three loop mechanics are load-bearing and must survive the voice change** (agent.js): + 1. The finish sentinel: `/(^|\n)\s*done\s*:/i` (line ~702) triggers auto-verify + finish. It + matches a `Done:` **line anywhere** in the final text — so a structured summary is fine as + long as one line still starts with `Done:`. + 2. The anti-stall nudge (line ~708): fires only on a **tool-less** turn that "promises action" + (ends with `:`/`…`, or "let me… edit/check/run"). Narration followed by a tool call in the + SAME turn never triggers it. The voice rules must keep the "narrate then CALL the tool in the + same turn" discipline or runs will start eating nudges. + 3. `max_tokens` continuation + the `` stripping for Kimi — unaffected, but more prose per + turn slightly raises the odds of hitting the per-turn cap; the machinery already handles it. +- **Labels**: `run_command` already has an optional `explanation` input (rendered as the approval + subtitle and the run card's italic `cmdwhy`), but the schema text doesn't say what to write and + the system prompt never asks for it — in practice it's often missing. File ops post derived + chips ("read X", `search "q"`). There is nothing like Claude Code's semantic command label + ("Found exact insertion point in section 10") unless we make `explanation` required — which D6/S1 + now do (schema-required on all three tools). +- **Diffstat data exists everywhere we need it**: `editApplied` carries per-file `add`/`del` + (rendered `+N −M` on the edit card); `reviewState` carries the run aggregate. A group header sum + is arithmetic on data already in the webview. +- **Rendering**: every entry is a `.tl` div appended flat to `log`. `termRun` cards already + collapse via a chevron. Edit cards are keyed (`editCards[id]`) and re-rendered in place; + `resolveEditCard` collapses them when reviewed. `addApproval` is a blocking card with keyboard + handling. `addQuestions` (ask_user) likewise interactive. +- **Testing pattern for webview logic exists**: `test/shHighlight.test.js` tests a function that + lives inside `chat.html` (extraction pattern) — the CI gate runs the node suites before builds + (release.yml "cheap gate"). New pure webview functions get tested the same way. + +## 2. Decisions (with the reasoning, so they can be re-litigated) + +### D1 — The voice is a system-prompt change, not a new mechanism. +The transport (interleaved blocks) and persistence already exist (§1). We rewrite the +communication rules in `SYSTEM_BASE`; no provider/loop code changes for the voice itself. The +`Done:` sentinel stays — the final message becomes "a line starting with `Done:` followed by a +short structured wrap-up", which the existing regex already accepts. + +### D2 — Narration must never replace action in a turn. (The anti-stall covenant.) +The current rules that make the agent act ("words are not edits", act-in-the-same-turn, the nudge) +stay verbatim. The voice rules are additive: *narrate briefly, then call the tool in the same +turn*. A narration-only turn that promises action remains a stall and still gets nudged. This is +the difference between "calm" and "chatty but idle". + +### D3 — Grouping is a webview-only reducer; the host keeps posting the same event *types*. +No new event types and no host-side grouping logic. The `agentTool` payload does gain optional +metadata — `label` (the model's `explanation`), `kind`, and `path` — so the webview can title and +categorise rows; `agent.js` fills those fields, but the grouping decisions all live in `chat.html`. +The router appends consecutive tool-ish entries (`agentTool` chips, `termRun` cards, `editApplied` +cards, verify cards) into the open group's body instead of `log`; any assistant text bubble, +`ask_user` card, `agentError`, or `agentDone` **closes** the group. This keeps the diff small, +rebase-safe, and revertable (one function + CSS). + +### D4 — Interactive and alarming things never hide inside a collapsed group. +- `addApproval` and `addQuestions` (blocking, keyboard-owning) **close the current group** and + render at top level, exactly as today. A hidden pending gate looks like a hang. +- A member that *fails* (termExit nonzero / timeout / stopped, `editApplied` error-free but + verify-fail cards) **auto-expands** the group and highlights the row. Collapse is for success. +- Edit cards DO collapse into groups (their Keep/Undo stays usable when expanded; the global + review bar and editor-side review remain the primary review paths). Header shows the summed + `+a −d` so the information isn't lost while collapsed. +- `ask_user` asks **one question at a time** (`n of N`, Back, advance button). Stacked questions in + a single card invited answering the first, missing the rest and sending — the agent then acted on + defaults nobody chose. Nothing posts before the last question, and passing one over is deliberate: + with nothing picked the button reads "Skip this one" and the answer is recorded as empty. On send + the card collapses to `Answers recorded`, unfolding to the full question/answer record. A single + question keeps the plain card — no step chrome. + +### D5 — Two header states, exactly like the reference. +While the group is open (agent still acting, no closing event yet): header = the **live** step's +label in present-progressive + the working spinner. The *members* fold: each finished step +collapses to a one-line row while the running one keeps its body open, so the footprint stays +fixed without hiding the output the user is watching. The group card itself is never born +collapsed. When the group closes: header flips to the past-tense **aggregate** — file clauses +first, then searches, then commands ("Read and edited PLAN.md, ran 2 commands"), same-file +read+edit merged, summed diffstat, fallback "N steps" when the sentence would get awkward. A +single-member group unwraps and hands the card back **expanded** — no group chrome. + +### D6 — Labels: model-written, for every tool that has a story to tell. +`run_command`, `read_file` and `search` all take a required `explanation`: "3–8 words, active +voice, what it does — e.g. 'Find the insertion point in section 10'" (the same range in the prompt +and all three schemas), and the voice rules require it. That sentence titles the row; the raw tool text (`read src/agent.js`) becomes the tooltip. UI +falls back to deriving a label from the arguments when absent (older transcripts, weaker models). +Tense: a small verb map (Run/Ran/Running, Read, Edit, Verify, Search, Create, Delete, Install, +Check ~a dozen) converts imperative → progressive/past; unknown verbs render as-is. No grammar +engine. + +### D7 — Kimi degradation is accepted, not engineered around. +Claude models hold this register natively; Kimi K2.7 via the gateway will narrate less reliably. +The prompt carries the style; the UI (grouping, labels, aggregates) works identically either way — +that's where most of the calm comes from. No per-model prompt forks in v1. + +## 3. Slices + +**S1 — The voice (system prompt).** *(S)* +Rewrite `SYSTEM_BASE`'s communication rules in `agent.js`: +- Before a tool batch: 1–2 sentences, what + why ("PLAN.md already had uncommitted edits — let me + confirm I added on top rather than clobbering:") then the tool call **in the same turn**. +- After results: interpret, don't restate ("the hash values differ but slice(0,6) truncates from + the left — that's the bug"). +- Corrections: once, plainly ("Correction —"), then continue. +- Decision points: `ask_user` with the tradeoff, a recommendation, and the trigger that would + change it — never a decision buried in prose. +- Finish: a `Done:` line followed by a short structured wrap-up (what landed / how verified / + what's next) for substantial runs; one line stays enough for trivial ones. +- `run_command`, `read_file` and `search` MUST carry `explanation` (D6 wording). +Keep verbatim: decisiveness, words-are-not-edits, same-turn action, skills/plan/multi-root/ +autopilot blocks. Update those three schemas' `explanation` descriptions (D6). +*Test:* existing suites green (prompt text is data); manual acceptance below. + +**S2 — The group reducer (webview).** *(M)* +In `chat.html`: `openGroup()/groupAppend(el, step)/closeGroup()`; route `addAgentLine`, +`addTermRun`, `addEditCard`, verify cards through it; close on text bubble / approval / questions +/ error / `agentDone`. Group DOM: `.tl.tl-group` → header (chevron · label · Σdiffstat · state) + +body (existing cards unchanged, each collapsed to a row by `collapseMember` once finished). +D4 rules (breakout + auto-expand-on-failure). CSS for header + collapsed member rows. +*Test:* `test/groupReducer.test.js` drives the real reducer functions against a zero-dep fake DOM — +header states, member collapse, failure re-open, unwrap, late background exit, grouping-off. + +**S3 — Labels, aggregate, tense (pure functions + wiring).** *(M)* +`stepLabel(meta)`, `groupAggregate(steps)`, `verbForms(label)` as extractable pure functions in +`chat.html`; header live-updates on member start/finish (progressive label while running → +aggregate on close). Wire per-edit `add/del` into the sum. +*Test:* `test/narrativeUi.test.js` via the shHighlight extraction pattern — aggregate sentences +(counts, same-file merge, fallback), tense map, diffstat sums. + +**S4 — Polish.** *(S)* +Auto-expand rules exercised (failed command, verify-fail); scroll behavior (`scrollIfStuck`) +inside collapsed groups; status-bar interplay (global working bar stays; group header is local +detail); single-member degenerate case; a settings escape hatch +(`levelcode.ai.chat.groupActivity`, default on) so the flat timeline remains one toggle away. + +## 4. Risks + +- **Prompt regression on the anti-stall machinery** — the voice invites prose; if the model starts + ending turns on narration, nudges fire and runs slow down. Mitigation: D2's covenant wording + + watch `dbg('nudge')` frequency before/after on the same tasks. +- **Output-token cost** rises modestly (narration is small next to tool payloads); the per-run cost + line in the response bar keeps it honest. +- **Webview lifecycle**: if the chat webview is rebuilt mid-run, group state resets (entries after + rebuild render flat until the next group opens). Same class of behavior as today's transcript; + not worse. Not engineering session-restore for groups in v1. +- **Kimi adherence** (D7) — accepted. + +## 5. Exit test + +Run the acceptance task on a real repo (Claude model, agent mode, autopilot on): +"add a section to docs/X.md, verify the edit landed cleanly, and check repo state". +Pass when the transcript reads: short narration → **one card** titled with the live step while +running ("Verifying the edit and checking repo state ⟳"), its finished members folded to one-line +rows beneath it while the running step's output stays visible, flipping on completion to +"Read and edited X.md +N −0, ran 2 commands" → narration continues → a `Done:` wrap-up — and +expanding a folded row shows its full output (semantic labels, per-file diffstat), with +any approval card rendered outside the group and any failing step auto-expanded. +Then re-run with grouping toggled off and confirm the flat timeline is unchanged. + +## 6. Not doing (yet) + +- Group state across webview rebuilds / session restore. +- Per-model prompt forks for the voice. +- Collapsing `ask_user`/approvals into groups — deliberately excluded (D4). diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index 96bdc7e..7c216d9 100644 --- a/extensions/levelcode-ai/agent.js +++ b/extensions/levelcode-ai/agent.js @@ -21,7 +21,9 @@ const { loadProjectRules } = require('./projectRules'); const SYSTEM_BASE = [ "You are LevelCode's built-in autonomous coding agent. You accomplish the user's goal in their", 'workspace using the provided tools. Rules:', - '- Be DECISIVE and FAST. Read only the file you are changing (plus at most 1 other if truly needed), then ACT. Do NOT write long multi-point plans — at most ONE short sentence before each tool call.', + '- Be DECISIVE and FAST. Read only the file you are changing (plus at most 1 other if truly needed), then ACT.', + '- NARRATE while you work — you are pair-working, not executing silently. Before a tool call (or a burst of related calls), write ONE or TWO short sentences: what you are about to do and why it is the right next step — then CALL THE TOOL in that SAME turn (narration is never a substitute for acting). After results, one sentence on what they MEAN — never restate output. Calm, senior, specific: no filler, no cheerleading, numbers over adjectives.', + '- A failure is a finding, not an apology: say plainly what broke, diagnose it, fix it, and say how you verified the fix. If you change your mind, say so once ("Correction: …") with the reason, then continue — no re-litigating.', '- Start acting within your first 1-2 turns. Use read_file / list_files / search to understand code before editing — never guess file contents.', '- CRITICAL: never end your turn by merely describing an edit ("now let me refactor…"). If you intend to change a file, you MUST call edit_file or write_file in that SAME turn. Words are not edits.', '- To change an EXISTING file, use edit_file with an exact, unique snippet (old_str) and its replacement (new_str). This is preferred — small and reliable. Read the file first so old_str matches exactly. Prefer SEVERAL small edit_file calls over one huge one (smaller edits are faster and easier to review).', @@ -29,23 +31,24 @@ const SYSTEM_BASE = [ '- Use delete_file to remove an existing file (e.g. during a refactor). To RENAME/move a file, write_file the new path then delete_file the old one. Deletions are reviewable (Keep/Undo) and restorable from the per-turn checkpoint.', '- Your file edits are APPLIED IMMEDIATELY and the user reviews them afterward in the editor with Keep/Undo — do NOT wait for approval, and do NOT re-edit a file you just edited. Only run_command still needs approval; if the user skips a command, adapt or stop.', '- Commands that do NOT exit on their own (dev servers, file watchers, tail -f) MUST be run with run_command background:true — it returns immediately so you keep working instead of hanging. After starting one, call read_command_output with the returned id to watch for a readiness/port line (e.g. "listening on :3000") before you test against it. Use a normal foreground run_command for things that finish (builds, installs, tests, git, curl). This pairs with verification: bring the app up in the background, confirm it serves, fix, repeat.', + '- EVERY run_command, read_file and search MUST include "explanation": 3-8 words, active voice, imperative, saying what you are doing and why ("Run the extension unit tests", "Find the insertion point in section 10", "Read the runAgent call site"). It becomes that action\'s label in the user\'s activity view — never omit it.', '- Paths are relative to the workspace root. In a MULTI-ROOT workspace (several top-level folders), paths from list_files/search are prefixed with the folder name (e.g. "thin.ly/app/models/link.rb") — use them exactly as shown; an unprefixed path resolves against the first folder. To create a file in a specific folder, prefix its name. run_command accepts an optional "folder" to pick which folder it runs in.', '- For a multi-step goal, call update_plan FIRST with a short checklist (3-8 short items, all "pending"), then call it again to set an item "in_progress" when you start it and "done" when finished. Skip the plan for trivial single-step goals.', - '- If the goal truly depends on a decision only the user can make (tech stack, scope, where to create files, must-have features), call ask_user ONCE with concise multiple-choice questions (a short header + 2-4 concrete options each) INSTEAD of writing the questions as prose. Then act on their answers and do not ask again. Do NOT ask about things you can reasonably decide yourself — prefer a sensible default and proceed.', + '- If the goal truly depends on a decision only the user can make (tech stack, scope, where to create files, must-have features), call ask_user ONCE with concise multiple-choice questions (a short header + 2-4 concrete options each) INSTEAD of writing the questions as prose. Put your RECOMMENDED option FIRST and use its description to say why — and, when it matters, what would change your mind. Then act on their answers and do not ask again. Do NOT ask about things you can reasonably decide yourself — prefer a sensible default and proceed.', '- You have SKILLS — short expert playbooks for common task types, listed under "Available skills" below with a name and a one-line description. When the user\'s goal clearly matches a skill\'s description (e.g. reviewing a diff, fixing one reported bug, writing tests), call use_skill with that exact name FIRST, before other tools, and follow the steps it returns. Pick at most one; if nothing clearly fits, just proceed normally. Do not mention skills to the user.', - '- When the goal is finished, end with a one-line summary starting with "Done:" and STOP (no more tools).', + '- When the goal is finished, end with a line starting with "Done:" — one crisp sentence of what was accomplished. For a substantial run (several files, a tricky diagnosis, or a decision the user should know about), follow that line with a SHORT wrap-up in plain lines: what landed, how you verified it, anything left open or worth pushing on. For a trivial goal the "Done:" line alone is right. Then STOP (no more tools).', '- After you finish, your work is verified automatically: editor diagnostics for the files you changed (plus a verify command, if the user configured one) are checked. If problems are found you will get them back as a follow-up message — fix the ones you introduced, then finish again with "Done:". If a reported problem is clearly pre-existing and unrelated to the goal, do not chase it: note it in one line and finish.' ].join('\n'); const TOOLS = [ { name: 'list_files', description: 'List workspace files (optional glob like "**/*.js"). Excludes node_modules/.git/build dirs.', input_schema: { type: 'object', properties: { glob: { type: 'string' } } } }, - { name: 'read_file', description: 'Read a workspace file (path relative to the workspace root; in a multi-root workspace use the folder-name prefix exactly as list_files shows it).', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } }, - { name: 'search', description: 'Search file contents for a literal string. Returns file:line snippets.', input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } }, + { name: 'read_file', description: 'Read a workspace file (path relative to the workspace root; in a multi-root workspace use the folder-name prefix exactly as list_files shows it).', input_schema: { type: 'object', properties: { path: { type: 'string' }, explanation: { type: 'string', description: 'REQUIRED: 3-8 words, active voice, imperative — WHY you are reading this ("Read the runAgent call site", "Read agent.js tool definitions"). Shown to the user as this action\'s label.' } }, required: ['path', 'explanation'] } }, + { name: 'search', description: 'Search file contents for a literal string. Returns file:line snippets.', input_schema: { type: 'object', properties: { query: { type: 'string' }, explanation: { type: 'string', description: 'REQUIRED: 3-8 words, active voice, imperative — what you are looking for ("Find every postMessage call site"). Shown to the user as this action\'s label.' } }, required: ['query', 'explanation'] } }, { name: 'update_plan', description: 'Declare or update your task checklist for a multi-step goal. Pass the FULL list each time, each item with a status. Call it once up front (all pending), then again to mark an item in_progress when you start it and done when finished. Skip for trivial single-step goals.', input_schema: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { title: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'done'] } }, required: ['title', 'status'] } } }, required: ['todos'] } }, { name: 'edit_file', description: 'Make a targeted edit to an EXISTING file: replace an exact, unique snippet (old_str) with new_str. Applied immediately; the user reviews it with Keep/Undo. old_str must appear exactly once — include enough surrounding context to be unique.', input_schema: { type: 'object', properties: { path: { type: 'string' }, old_str: { type: 'string' }, new_str: { type: 'string' } }, required: ['path', 'old_str', 'new_str'] } }, { name: 'write_file', description: 'Create a new file (or fully overwrite a short one) with the COMPLETE content. For edits to existing files, prefer edit_file. Applied immediately; the user reviews it with Keep/Undo.', input_schema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } }, { name: 'delete_file', description: 'Delete an EXISTING workspace file (e.g. removing a file during a refactor). Applied immediately; the user reviews it with Keep/Undo, and the per-turn checkpoint can restore it. To RENAME or move a file: write_file the new path, then delete_file the old one.', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } }, - { name: 'run_command', description: 'Run a shell command in the workspace root (or a named workspace folder via "folder" in multi-root workspaces). Requires approval. Pass background:true for commands that do not exit on their own (servers, watchers) so the agent is not blocked — it returns immediately and you read progress later with read_command_output.', input_schema: { type: 'object', properties: { command: { type: 'string' }, explanation: { type: 'string' }, folder: { type: 'string', description: 'multi-root workspaces only: the workspace folder NAME to run in; defaults to the first folder' }, background: { type: 'boolean', description: 'true = start it and keep working without waiting (dev servers, watchers, tail -f). Returns immediately with an id; poll read_command_output for its output/status.' } }, required: ['command'] } }, + { name: 'run_command', description: 'Run a shell command in the workspace root (or a named workspace folder via "folder" in multi-root workspaces). Requires approval. Pass background:true for commands that do not exit on their own (servers, watchers) so the agent is not blocked — it returns immediately and you read progress later with read_command_output.', input_schema: { type: 'object', properties: { command: { type: 'string' }, explanation: { type: 'string', description: 'REQUIRED: 3-8 words, active voice, imperative — what this command does ("Run the extension unit tests", "Find the insertion point in section 10"). Shown to the user as this action\'s label.' }, folder: { type: 'string', description: 'multi-root workspaces only: the workspace folder NAME to run in; defaults to the first folder' }, background: { type: 'boolean', description: 'true = start it and keep working without waiting (dev servers, watchers, tail -f). Returns immediately with an id; poll read_command_output for its output/status.' } }, required: ['command', 'explanation'] } }, { name: 'read_command_output', description: 'Read recent output + status of a command started with run_command background:true. Returns a status header ([running on :3000] / [exited 0] / [stopped]) followed by the latest output lines. Poll this to wait for a server to become ready before testing against it.', input_schema: { type: 'object', properties: { id: { type: 'string', description: 'the id returned by a background run_command' }, lines: { type: 'number', description: 'max recent output lines to return (default 80, max 400)' } }, required: ['id'] } }, { name: 'ask_user', description: 'Ask the user one or more multiple-choice questions when the goal genuinely depends on a decision only they can make (tech stack, scope, where to put files, must-have features). The user picks by CLICKING — do NOT write questions as prose. Ask ONCE up front with all your questions, then proceed with the answers and never re-ask. Prefer sensible defaults over asking; only ask when a wrong guess would waste real work.', input_schema: { type: 'object', properties: { questions: { type: 'array', items: { type: 'object', properties: { header: { type: 'string', description: 'a 1-3 word tag for the question' }, question: { type: 'string' }, multiSelect: { type: 'boolean', description: 'true if several options can be picked at once' }, options: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, description: { type: 'string' } }, required: ['label'] } } }, required: ['question', 'options'] } } }, required: ['questions'] } }, { name: 'use_skill', description: 'Load an expert playbook (SKILL.md) for a task type, chosen from the "Available skills" list in your system prompt. Returns the skill\'s step-by-step instructions as the tool result — then follow them. Read-only and instant (no approval). Call it once, early, when the goal matches a skill\'s description.', input_schema: { type: 'object', properties: { name: { type: 'string', description: 'the exact skill name from the Available skills list' } }, required: ['name'] } } @@ -273,7 +276,9 @@ async function runTool(tu, ctx) { return uris.map((u) => vscode.workspace.asRelativePath(u)).join('\n') || '(no files)'; } if (tu.name === 'read_file') { - ctx.post({ type: 'agentTool', icon: 'file', text: 'read ' + input.path }); + // label/path ride alongside the legacy `text` so the chat can title this action with the + // model's own words ("Read the runAgent call site") and still attribute it to a file. + ctx.post({ type: 'agentTool', icon: 'file', text: 'read ' + input.path, label: input.explanation || '', path: input.path, kind: 'read' }); const abs = resolveWorkspacePath(input.path || '', { mustExist: true }); if (!abs) { return 'ERROR: file not found: ' + input.path + whereHint(); } if (isBinaryFile(abs)) { return 'ERROR: ' + input.path + ' looks like a binary file — not reading it as text.'; } @@ -282,7 +287,7 @@ async function runTool(tu, ctx) { return body; } if (tu.name === 'search') { - ctx.post({ type: 'agentTool', icon: 'search', text: 'search "' + input.query + '"' }); + ctx.post({ type: 'agentTool', icon: 'search', text: 'search "' + input.query + '"', label: input.explanation || '', kind: 'search' }); // Multi-root: search EVERY workspace folder, prefixing hits with the folder name so the // model can hand the paths straight back to read_file/edit_file. const folders = workspaceFolderList(); diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 0416144..1fa9441 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -1254,19 +1254,22 @@ function sendConfigToWebview() { // Gateway mode (signed in — the gateway only routes when authenticated): the footer reflects the // LevelCode Cloud plan model (free → gpt-oss, paid → Kimi), not the BYOK provider — with a `paid` flag // so the webview can show the free-tier Upgrade CTA. + // Calm transcript: whether the webview folds consecutive agent actions into one collapsible group. + const groupActivity = cfg.get('chat.groupActivity', true) !== false; if (providerMode() === 'gateway' && cloudSignedIn) { const model = gatewayModel(); post({ type: 'config', provider: 'gateway', model: gatewayModelLabel(model), modelId: model, providerLabel: 'LevelCode Cloud', contextLimit: contextLimitFor('openai', capsModel(model)), - gateway: true, plan: cloudPlanName() || 'Free', paid: isPaidCloudPlan(cloudPlanName()) + gateway: true, plan: cloudPlanName() || 'Free', paid: isPaidCloudPlan(cloudPlanName()), + groupActivity: groupActivity }); return; } const providerId = currentProviderId(); const p = providers.getProvider(providerId) || providers.getProvider('claude'); // Carry the model's context window so the footer meter updates the moment the model changes. - post({ type: 'config', provider: providerId, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit() }); + post({ type: 'config', provider: providerId, model: activeModel(cfg, providerId), providerLabel: p.label, contextLimit: currentContextLimit(), groupActivity: groupActivity }); } class ChatViewProvider { diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index 1f0ada5..f5154bc 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -76,6 +76,14 @@ #jumpLatest:hover { opacity: 1; background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.25)); } .msg { white-space: normal; word-wrap: break-word; line-height: 1.5; } .msg .role { font-size: 11px; opacity: .55; margin-bottom: 4px; text-transform: uppercase; letter-spacing: .05em; } + /* a quiet remind-me line: a soft accent rule, small label, the words in a calm serif italic */ + .rme { margin: 0 4px; padding: 10px 0 10px 14px; border-left: 2px solid color-mix(in srgb, var(--accent) 45%, transparent); } + .rme .rme-tag { display: flex; align-items: center; gap: 6px; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin-bottom: 5px; } + .rme .rme-tag .ci { width: 12px; height: 12px; } + .rme .rme-body { font-family: Georgia, 'Times New Roman', ui-serif, serif; font-style: italic; font-size: 14.5px; line-height: 1.6; color: var(--vscode-foreground); } + /* continuation narration: no second speaker label, and pulled up against the 12px log gap so the + prose reads as one voice continuing rather than a fresh announcement */ + .msg.cont { margin-top: -5px; } /* rendered-markdown block elements inside a message body */ .msg .body p { margin: 0 0 8px; } .msg .body ul, .msg .body ol { margin: 0 0 8px; padding-left: 22px; } @@ -93,12 +101,14 @@ .msg .body th { background: var(--vscode-editorWidget-background, rgba(127,127,127,.1)); font-weight: 600; } .msg .body del { opacity: .65; } /* clickable file chips (mentions of real project files) */ - .filechip { display: inline-flex; align-items: center; gap: 4px; vertical-align: -2px; margin: 0 1px; padding: 0 6px 0 3px; border: 1px solid var(--border); border-radius: 5px; background: var(--vscode-editorWidget-background, rgba(127,127,127,.1)); cursor: pointer; font-size: .92em; line-height: 1.7; } + /* vertical-align: middle centres the pill on the text's midline; the old -2px + line-height 1.7 + dropped it below the baseline and inflated the line box, so it read as sitting too low. */ + .filechip { display: inline-flex; align-items: center; gap: 4px; vertical-align: middle; margin: 0 1px; padding: 0 6px 0 3px; border: 1px solid var(--border); border-radius: 5px; background: var(--vscode-editorWidget-background, rgba(127,127,127,.1)); cursor: pointer; font-size: .92em; line-height: 1.2; } .filechip:hover { border-color: var(--accent); background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.2)); } .filechip .fcname { color: var(--accent); } .filechip:hover .fcname { text-decoration: underline; } .filechip:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; } - .ficon { font-size: 8.5px; font-weight: 700; line-height: 1; padding: 2px 3px; border-radius: 3px; letter-spacing: .02em; color: #1f1f1f; background: #9aa0a6; } + .ficon { font-size: 8.5px; font-weight: 700; line-height: 1; padding: 1px 3px; border-radius: 3px; letter-spacing: .02em; color: #1f1f1f; background: #9aa0a6; } .ficon-js, .ficon-mjs, .ficon-cjs, .ficon-jsx { background: #f0db4f; color: #323330; } .ficon-ts, .ficon-tsx { background: #3178c6; color: #fff; } .ficon-css, .ficon-scss { background: #2965f1; color: #fff; } @@ -107,6 +117,9 @@ .ficon-json, .ficon-yml, .ficon-yaml { background: #cbcb41; color: #323330; } .ficon-py { background: #3572a5; color: #fff; } .ficon-rb { background: #cc342d; color: #fff; } + /* HTML files wear the HTML5 shield itself, not a text pill — so no pill chrome, just the mark */ + .ficon.fhtml5 { background: none; padding: 0; border-radius: 0; display: inline-flex; } + .ficon.fhtml5 svg { display: block; width: 14px; height: 14px; } .msg .body > div > :last-child, .msg .body > p:last-child, .msg .body > ul:last-child, .msg .body > ol:last-child { margin-bottom: 0; } .msg.user .body { background: var(--field-bg); border: 1px solid var(--border); border-radius: 10px; padding: 9px 11px; } .msg.assistant .body { padding: 1px 2px; } @@ -261,11 +274,23 @@ .tl-body { flex: 1 1 auto; min-width: 0; padding: 3px 0 8px; } /* light tool action (read / edit / search …) — a one-liner node */ .tl-tool .tl-body { padding: 4px 0 6px; } - .tl-tool .toolt { display: block; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11.5px; color: var(--muted); line-height: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + /* UI font, not the editor font: these rows carry the model's own sentence ("Understand how levels + are authored"), and monospace made prose read like a command line. */ + .tl-tool .toolt { display: block; font-size: 12px; color: var(--muted); line-height: 17px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* heavier command action — collapsible boxed card: header (chevron · verb · chips · state) → highlighted command → streamed output → status foot → italic why */ .tl-cmd .tl-node { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 55%, transparent); background: color-mix(in srgb, var(--accent) 12%, var(--vscode-editor-background, var(--field-bg))); } .tl-cmd.running .tl-node { animation: nodepulse 2s ease-out infinite; } + /* A FINISHED group's node stops shouting: the accent fill was the "running" identity. Success + speaks the same quiet green as every other done tick (Kept, verified, command-ok); failure + takes the error colour. The ring goes neutral either way — no coloured circle competing with + the check inside it. */ + /* the finished node's glyph (check-circle / circle-slash) brings its OWN ring, so drop the node's + border to avoid a double ring. The background stays to mask the rail line behind the node, and + the glyph grows to fill it. */ + .tl-group.gok .tl-node { color: var(--vscode-terminal-ansiBrightGreen, #4ec97a); border-color: transparent; background: var(--vscode-editor-background, var(--field-bg)); } + .tl-group.gfailed .tl-node { color: var(--vscode-errorForeground, #f14c4c); border-color: transparent; background: var(--vscode-editor-background, var(--field-bg)); } + .tl-group.gok .tl-node .ci, .tl-group.gfailed .tl-node .ci { width: 18px; height: 18px; } @keyframes nodepulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 40%, transparent); } 70%, 100% { box-shadow: 0 0 0 6px transparent; } } .tl-cmd .cmdhead { display: flex; align-items: center; gap: 7px; min-width: 0; cursor: pointer; user-select: none; padding: 2px 0; } .tl-cmd .cmdchev { flex: 0 0 auto; display: inline-flex; color: var(--muted); } @@ -282,6 +307,26 @@ .tl-cmd .cmdstate .ci { width: 12px; height: 12px; } .tl-cmd .cmdstate.ok { color: var(--vscode-terminal-ansiBrightGreen, #4ec97a); } .tl-cmd .cmdstate.bad { color: var(--vscode-errorForeground, #f14c4c); } + /* ── Grouped activity (calm transcript) — consecutive tool actions fold into ONE collapsible card. + Open: the header shows the LIVE step (present-progressive) with a fixed vertical footprint; on + close it flips to a past-tense aggregate ("Ran 2 commands, read and edited chat.html +56 -0"). + Members keep their own cards inside .groupbody; their rails hide so expanded rows read flat. */ + .tl-group .grouplabel { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--vscode-foreground); } + .tl-group .grouphead:hover .grouplabel { text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--muted); } + .tl-group .groupcounts { flex: 0 0 auto; font-family: var(--vscode-editor-font-family, ui-monospace, monospace); font-size: 11px; } + .tl-group .groupcounts .a { color: var(--vscode-gitDecoration-addedResourceForeground, #4ec97a); } + .tl-group .groupcounts .d { color: var(--vscode-gitDecoration-deletedResourceForeground, #e06c75); } + .tl-group .groupstop { flex: 0 0 auto; display: inline-flex; align-items: center; border: none; background: none; color: var(--muted); cursor: pointer; padding: 2px; } + /* an explicit display beats the UA's [hidden] rule, so .hidden = true is inert without this — + same escape as #workbar[hidden] / .modemenu[hidden] above. A stop button that outlives its + command is worse than no button: it offers to stop something that already finished. */ + .tl-group .groupstop[hidden] { display: none; } + .tl-group .groupstop:hover { color: var(--vscode-errorForeground, #f14c4c); } + .tl-group .groupstop .ci { width: 12px; height: 12px; } + .tl-group .groupbody { padding: 2px 0 0 4px; } + .tl-group.collapsed .groupbody { display: none; } + .tl-group .groupbody > .tl > .tl-rail { display: none; } + .tl-group .groupbody > .tl { margin: 0; } /* Copilot-style falling-dots progress — a 2×3 dot grid cascading downward while a command runs */ .lcdots { display: inline-grid; grid-template-columns: repeat(2, 3px); gap: 2px; color: var(--accent); align-self: center; } .lcdots i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: lcdotfall 1.1s ease-in-out infinite; } @@ -397,6 +442,28 @@ .questions .qsend:hover { background: var(--vscode-button-hoverBackground); } .questions .qverdict { font-size: 12px; font-weight: 600; color: var(--vscode-gitDecoration-addedResourceForeground, #73c991); } .questions.answered { opacity: .92; } + /* ---- one question at a time ---- + Several questions stacked in one card made it far too easy to answer the first, miss the rest + and hit send. In wizard mode exactly one block is on screen and the send button only appears on + the last one, so skipping a question has to be deliberate (the button says so). On submit the + wizard class comes off and every question + answer is revealed as a permanent record. */ + .questions.wizard .qblock { display: none; } + .questions.wizard .qblock.cur { display: block; } + .questions.wizard .qnotes { display: none; } + .questions.wizard.laststep .qnotes { display: block; } + .questions .qstep { flex: 0 0 auto; font-size: 11px; font-weight: 600; opacity: .6; font-variant-numeric: tabular-nums; } + .questions .qback { cursor: pointer; border: 1px solid var(--border); border-radius: 6px; padding: 5px 11px; font-size: 12px; + background: transparent; color: var(--vscode-foreground); } + .questions .qback:hover { background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.18)); } + .questions .qback[hidden] { display: none; } /* explicit display above would defeat [hidden] */ + /* nothing picked yet: the primary button steps down to a quiet "skip", so moving on is a choice */ + .questions .qsend.ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); } + .questions .qsend.ghost:hover { background: var(--vscode-toolbar-hoverBackground, rgba(127,127,127,.18)); color: var(--vscode-foreground); } + .questions .qspacer { flex: 1 1 auto; } + /* Collapsing wins over the two rules above. `.questions.collapsed .qblock` is three classes and + would lose to `.questions.wizard .qblock.cur`, so the current question would survive a collapse. + These match at equal weight and come later, which settles it. */ + .questions.collapsed .qblock.cur, .questions.collapsed.laststep .qnotes { display: none; } /* ---- applied-edit review (apply-then-review) ---- */ /* Sits between the message log and the composer — sticky review summary for applied edits. */ @@ -916,12 +983,12 @@ @@ -948,12 +1015,12 @@ @@ -1089,6 +1156,10 @@ 'warning': { vb: '0 0 16 16', p: '' }, 'edit': { vb: '0 0 16 16', p: '' }, 'check': { vb: '0 0 16 16', p: '' }, + // check-in-a-ring, drawn as strokes (fill:none overrides the svg-level fill=currentColor) — the + // outline look from the design: a thin ring with a rounded, slightly bolder tick. currentColor, + // so it inherits green in "done" contexts and the accent in the menu. + 'check-circle': { vb: '0 0 16 16', p: '' }, 'circle-slash': { vb: '0 0 16 16', p: '' }, 'stop-circle': { vb: '0 0 16 16', p: '' }, 'shield': { vb: '0 0 16 16', p: '' }, @@ -1186,9 +1257,17 @@ if (t.indexOf('/') >= 0){ const hit = list.find(function(r){ return r === t || r.endsWith('/' + t); }); return hit || null; } return list[0]; // bare basename → first known path (click still resolves the real one) } + // The HTML5 shield — the two-tone orange badge with the white "5". Its own colours, so it doesn't + // sit inside a coloured pill; used for .html/.htm in place of the "HTML" text badge. + const HTML5_SHIELD = ''; function fileIcon(rel){ const ext = (rel.split('.').pop() || '').toLowerCase(); - const map = { js:'JS', mjs:'JS', cjs:'JS', jsx:'JS', ts:'TS', tsx:'TS', css:'CSS', scss:'CSS', html:'HTML', htm:'HTML', md:'MD', json:'JSON', py:'PY', rb:'RB', sh:'SH', go:'GO', rs:'RS', yml:'YML', yaml:'YML' }; + if (ext === 'html' || ext === 'htm'){ return '' + HTML5_SHIELD + ''; } + const map = { js:'JS', mjs:'JS', cjs:'JS', jsx:'JS', ts:'TS', tsx:'TS', css:'CSS', scss:'CSS', md:'MD', json:'JSON', py:'PY', rb:'RB', sh:'SH', go:'GO', rs:'RS', yml:'YML', yaml:'YML' }; const lbl = map[ext] || (ext ? ext.toUpperCase().slice(0, 4) : 'FILE'); return '' + esc(lbl) + ''; } @@ -1348,7 +1427,7 @@ btn.title = 'Copy this response'; btn.innerHTML = codicon('copy') + ' Copy'; btn.onclick = () => { vscode.postMessage({ type: 'copy', text: raw }); - btn.innerHTML = codicon('check') + ' Copied'; btn.classList.add('done'); + btn.innerHTML = codicon('check-circle') + ' Copied'; btn.classList.add('done'); try { clearTimeout(btn._t); } catch(e){} btn._t = setTimeout(() => { btn.innerHTML = codicon('copy') + ' Copy'; btn.classList.remove('done'); }, 1200); }; @@ -1367,12 +1446,36 @@ b.onclick = () => { const inp = document.getElementById('input'); if (!inp) { return; } inp.value = b.dataset.q || ''; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.focus(); }; }); })(); + // ONE speaker label per turn. A run narrates several times around its tool cards; stamping + // "LEVELCODE AI" over each block chopped one train of thought into four announcements. The + // continuation blocks flow as prose (tighter gap, no label) — the way a person keeps talking. + let turnLabeled = false; function add(role, html){ clearEmpty(); - const d = document.createElement('div'); d.className = 'msg ' + role; - d.innerHTML = '
' + (role === 'user' ? 'You' : 'LevelCode AI') + '
' + html + '
'; + closeGroup(); // narration / user text ends the activity group (calm transcript) + if (role === 'user'){ turnLabeled = false; } + const cont = role !== 'user' && turnLabeled; + if (role !== 'user'){ turnLabeled = true; } + const d = document.createElement('div'); d.className = 'msg ' + role + (cont ? ' cont' : ''); + d.innerHTML = (cont ? '' : '
' + (role === 'user' ? 'You' : 'LevelCode AI') + '
') + + '
' + html + '
'; log.appendChild(d); scrollIfStuck(); return d.querySelector('.body'); } + // A remind-me line. The words are kept out of the source as base64 and decoded only here, so + // they surface only when someone runs /rme — not to anyone scanning the file. UTF-8 aware decode + // (the text is Cyrillic), so atob's byte string is run back through TextDecoder. + var RME_B64 = '0KHQsNC80L7QtNC+0YHRgtCw0YLQvdGW0YHRgtGMLCDQt9Cx0YPQtNC+0LLQsNC90LAsINGJ0L7QsSDRg9C90LjQutC90YPRgtC4INC/0L7QutC40L3Rg9GC0L7RgdGC0ZYsIOKAlCDRhtC1INGB0LDQvNC1INGC0LUsINGJ0L4g0YLRgNC40LzQsNGUINC70Y7QtNC10Lkg0L3QsCDQstGW0LTRgdGC0LDQvdGWLg=='; + function rmeText(){ + try { return new TextDecoder().decode(Uint8Array.from(atob(RME_B64), function(c){ return c.charCodeAt(0); })); } + catch (e) { return ''; } + } + function addReminder(){ + clearEmpty(); + var d = document.createElement('div'); d.className = 'rme'; + d.innerHTML = '
' + codicon('sparkle') + ' Remind me
' + + '
' + esc(rmeText()) + '
'; + log.appendChild(d); scrollIfStuck(); + } function addAutoCtx(names){ if (!names || !names.length) { return; } clearEmpty(); @@ -1386,16 +1489,251 @@ let data = ''; if (m.data != null){ try { data = ' ' + JSON.stringify(m.data); } catch(e){ data = ' [unserializable]'; } } d.innerHTML = codicon('bug') + esc(' DEBUG [' + ts + '] ' + (m.label || '') + data); - log.appendChild(d); scrollIfStuck(); + (groupsOn && curGroup ? curGroup.body : log).appendChild(d); // ride inside an open activity group + scrollIfStuck(); } function clearStatus(){ if (agentStatusEl){ agentStatusEl.remove(); agentStatusEl = null; } } + + // ── Calm transcript (docs/CALM-TRANSCRIPT.md): consecutive tool activity folds into ONE + // collapsible group. The reducer lives entirely in the webview — the extension host posts the + // same events as ever; assistant/user text, approvals, questions, errors and run-end CLOSE the + // open group. While open the header shows the LIVE step; on close, a past-tense aggregate. + let groupsOn = true; // levelcode.ai.chat.groupActivity (arrives on the config message) + let curGroup = null; // open group state, or null + const termSteps = {}; // run id → step (a command's exit updates its group later) + const editSteps = {}; // edit id → step (re-renders update the counts in place) + const verifySteps = {}; // verify id → step + + // Convert an imperative action label to present-progressive (form 0: "Verifying the edit and + // checking repo state") or past (form 1: "Verified the edit and checked repo state"). Verbs + // convert at the start and after "and"/"then"/a comma; unknown words pass through. Pure — + // unit-tested from the shipped file (test/narrativeUi.test.js), like shHighlight. + function tenseLabel(label, form){ + const VERBS = { + run: ['Running', 'Ran'], read: ['Reading', 'Read'], edit: ['Editing', 'Edited'], + write: ['Writing', 'Wrote'], create: ['Creating', 'Created'], delete: ['Deleting', 'Deleted'], + search: ['Searching', 'Searched'], list: ['Listing', 'Listed'], verify: ['Verifying', 'Verified'], + check: ['Checking', 'Checked'], find: ['Finding', 'Found'], install: ['Installing', 'Installed'], + build: ['Building', 'Built'], test: ['Testing', 'Tested'], fix: ['Fixing', 'Fixed'], + update: ['Updating', 'Updated'], add: ['Adding', 'Added'], remove: ['Removing', 'Removed'], + start: ['Starting', 'Started'], stop: ['Stopping', 'Stopped'], apply: ['Applying', 'Applied'], + generate: ['Generating', 'Generated'], confirm: ['Confirming', 'Confirmed'], commit: ['Committing', 'Committed'], + push: ['Pushing', 'Pushed'], open: ['Opening', 'Opened'], compile: ['Compiling', 'Compiled'], + rename: ['Renaming', 'Renamed'], move: ['Moving', 'Moved'], count: ['Counting', 'Counted'], + grep: ['Grepping', 'Grepped'], clean: ['Cleaning', 'Cleaned'], download: ['Downloading', 'Downloaded'] + }; + const out = []; + let slot = true; + for (const w of String(label || '').split(' ')){ + const bare = w.toLowerCase().replace(/[^a-z]/g, ''); + const rep = slot && VERBS[bare] ? VERBS[bare][form] : null; + if (rep){ out.push(/^[a-z]/.test(w) ? rep.toLowerCase() : rep); slot = false; continue; } + out.push(w); + slot = bare === 'and' || bare === 'then' || /,$/.test(w); + } + return out.join(' '); + } + + // One past-tense sentence for a finished group: "Ran 2 commands, read and edited PLAN.md". + // Verb-category counts, same-file read+edit folded, >2 files become "N files". Pure. + function groupAggregate(steps){ + const uniq = (list) => list.filter((p, i) => list.indexOf(p) === i); + const short = (p) => String(p).split('/').pop(); + const files = (list) => list.length > 2 ? (list.length + ' files') : list.map(short).join(', '); + const of = (kind) => uniq(steps.filter((s) => s.kind === kind && s.path).map((s) => s.path)); + const cmds = steps.filter((s) => s.kind === 'cmd').length; + const searches = steps.filter((s) => s.kind === 'search').length; + const lists = steps.filter((s) => s.kind === 'list').length; // listing files ≠ searching content + const reads = of('read'), edits = of('edit'), creates = of('create'), dels = of('delete'); + // File work leads, commands trail ("Read and edited extension.js, ran a command") — the reference + // ordering: what changed matters more than how many shells it took. + const parts = []; + const readOnly = reads.filter((p) => edits.indexOf(p) < 0 && creates.indexOf(p) < 0); + const readEdited = edits.filter((p) => reads.indexOf(p) >= 0); + if (readEdited.length && !readOnly.length && readEdited.length === edits.length){ + parts.push('read and edited ' + files(readEdited)); + } else { + if (readOnly.length){ parts.push('read ' + files(readOnly)); } + if (edits.length){ parts.push('edited ' + files(edits)); } + } + if (creates.length){ parts.push('created ' + files(creates)); } + if (dels.length){ parts.push('deleted ' + files(dels)); } + if (searches){ parts.push('searched the workspace'); } + if (lists){ parts.push('listed files'); } + if (cmds){ parts.push('ran ' + (cmds === 1 ? 'a command' : cmds + ' commands')); } + if (steps.some((s) => s.kind === 'verify')){ parts.push('verified the edits'); } + if (!parts.length){ return steps.length === 1 ? '1 step' : steps.length + ' steps'; } + const sentence = parts.join(', '); + return sentence.charAt(0).toUpperCase() + sentence.slice(1); + } + + // Derive a step {kind, base, path} from an agentTool chip. The host now sends `kind`/`path` and the + // model's own `label` (read_file/search explanation) — prefer that; fall back to parsing the legacy + // `text` so an older transcript (or a model that skipped the field) still groups sensibly. + function chipStep(icon, text, label, kind, path){ + const t = String(text || ''); + const said = String(label || '').trim().replace(/\.+$/, ''); + const titled = said ? said.charAt(0).toUpperCase() + said.slice(1) : ''; + if (kind === 'read' || (icon === 'file' && /^read /.test(t))){ + const p = path || t.slice(5); + return { kind: 'read', base: titled || ('Read ' + p), path: p }; + } + if (kind === 'search' || icon === 'search'){ return { kind: 'search', base: titled || 'Search the workspace' }; } + if (icon === 'list-tree'){ return { kind: 'list', base: titled || 'List files' }; } + return { kind: 'note', base: titled || t }; + } + // The label for a command step: the model's explanation (imperative, per the system prompt), or + // the command's first segment as a fallback for older transcripts / weaker models. + function cmdBase(m){ + const ex = String(m.explanation || '').trim().replace(/\.+$/, ''); + if (ex){ return ex.charAt(0).toUpperCase() + ex.slice(1); } + const seg = String(m.command || '').split(/\n+|\s*&&\s*|\s*;\s*/).map((s) => s.trim()).filter(Boolean)[0] || 'command'; + return 'Run `' + (seg.length > 48 ? seg.slice(0, 48) + '…' : seg) + '`'; + } + function groupCountsHtml(g){ + let a = 0, d = 0; + for (const s of g.steps){ a += s.add || 0; d += s.del || 0; } + return (a || d) ? '+' + a + ' -' + d + '' : ''; + } + function openGroup(){ + const wrap = document.createElement('div'); wrap.className = 'tl tl-cmd tl-group running'; + wrap.innerHTML = + '
' + codicon('sync') + '
' + + '
' + + '
' + + '' + codicon('chevron-down') + '' + + '' + + '' + + '' + + '' + LC_DOTS + '' + + '
' + + '
' + + '
'; + log.appendChild(wrap); + const g = { + el: wrap, body: wrap.querySelector('.groupbody'), label: wrap.querySelector('.grouplabel'), + counts: wrap.querySelector('.groupcounts'), state: wrap.querySelector('.groupstate'), + node: wrap.querySelector('.tl-node'), + stopBtn: wrap.querySelector('.groupstop'), steps: [], failed: false, closed: false, userToggled: false + }; + wrap.querySelector('.grouphead').onclick = (e) => { + if (e.target && e.target.closest && e.target.closest('.groupstop')) { return; } + g.userToggled = true; wrap.classList.toggle('collapsed'); + }; + curGroup = g; + return g; + } + // Inside a group every member renders as a ONE-LINE row — its output/diff sits behind its own + // chevron, exactly like the reference. Ungrouped cards keep today's expanded-by-default behavior. + function collapseMember(el){ + if (!el || !el.classList){ return; } + if (el.classList.contains('tl-cmd')){ el.classList.add('collapsed'); return; } + const det = el.querySelector && el.querySelector('details.diffwrap'); + if (det){ det.open = false; } + } + // Append a timeline element into the open group (opening one if needed). step=null rides along + // without affecting the header (debug lines). + function groupAppend(el, step){ + if (!groupsOn){ log.appendChild(el); return; } + const g = curGroup || openGroup(); + // A RUNNING member stays expanded so its live output is readable — that is the whole point of + // the fixed-footprint header. Finished/instantaneous members (chips, an already-exited command) + // fold to a one-line row now; a running one folds later, in groupStepDone, once it completes. + if (!step || step.status !== 'running'){ collapseMember(el); } + g.body.appendChild(el); + if (step){ step.group = g; g.steps.push(step); refreshGroupHead(); } + } + function refreshGroupHead(){ + const g = curGroup; if (!g){ return; } + let live = null; + for (let i = g.steps.length - 1; i >= 0; i--){ if (g.steps[i].status === 'running'){ live = g.steps[i]; break; } } + const s = live || g.steps[g.steps.length - 1]; + if (s){ g.label.textContent = tenseLabel(s.base, s.status === 'running' ? 0 : 1); } + g.counts.innerHTML = groupCountsHtml(g); + const stopEl = live && live.card && live.card.querySelector('.termstop'); + g.stopBtn.hidden = !stopEl; + g.stopBtn.onclick = stopEl ? ((e) => { e.stopPropagation(); stopEl.click(); }) : null; + } + // Finalize a group's header (aggregate label, ok/bad state). A single-member group unwraps — + // the member rejoins the timeline bare, no group chrome. + function finalizeGroup(g){ + if (!g.el.isConnected){ return; } + if (!g.steps.length){ g.el.remove(); return; } + if (g.steps.length === 1){ + // Degenerate group: hand the member back to the timeline bare — and un-collapse it, since a + // standalone card is expanded by default (it was only a one-line row for the group's sake). + while (g.body.firstChild){ + const child = g.body.firstChild; + if (child.classList){ child.classList.remove('collapsed'); } + log.insertBefore(child, g.el); + } + g.el.remove(); return; + } + g.closed = true; + // A member may still be running when narration closes the group (a background command). Don't + // claim the group is finished or hide the Stop control — keep the live chrome and let that + // member's own groupStepDone re-enter here to complete the group once it actually exits. + if (g.steps.some((s) => s.status === 'running')){ + g.el.classList.remove('collapsed'); // never hide a command that is still going + refreshGroupHead(); + return; + } + g.el.classList.remove('running'); + g.el.classList.add(g.failed ? 'gfailed' : 'gok'); // recolours the rail node — see .tl-group.gok + g.label.textContent = groupAggregate(g.steps); + g.counts.innerHTML = groupCountsHtml(g); + // ONE outcome glyph, in the rail. While running the rail carried a spinner and the head carried + // dots; a finished group must not go on wearing either. The rail states the outcome, so the + // trailing state clears on success and speaks up only when something failed. + if (g.node){ g.node.innerHTML = codicon(g.failed ? 'circle-slash' : 'check-circle'); } + g.state.innerHTML = g.failed ? codicon('circle-slash') : ''; + g.state.className = 'cmdstate groupstate ' + (g.failed ? 'bad' : 'ok'); + g.stopBtn.hidden = true; + // A finished, successful group folds to its one-line summary — that fold IS the calm payoff. + // It was born expanded so the running step stayed readable; nobody needs the finished rows in + // the way afterwards. Failures stay open, and a group the user opened by hand keeps their choice. + if (g.failed){ g.el.classList.remove('collapsed'); } + else if (!g.userToggled){ g.el.classList.add('collapsed'); } + } + // Narration / user text / an interactive card ends the activity group. + function closeGroup(){ const g = curGroup; if (!g){ return; } curGroup = null; finalizeGroup(g); } + // A member finished after registration (command exit, verify done) — possibly after its group + // closed (background commands). Failures auto-expand: collapse is for success (D4). + function groupStepDone(step, failed){ + if (!step){ return; } + step.status = failed ? 'fail' : 'done'; + const g = step.group; if (!g){ return; } + if (failed){ + g.failed = true; + // Collapse is for success (D4): surface the failing row's own output, and re-open the group + // if the user had collapsed it — a hidden failure is the one thing this UI must never do. + if (step.card && step.card.classList){ step.card.classList.remove('collapsed'); } + if (g.el.isConnected){ g.el.classList.remove('collapsed'); } + } else { + // Success: the step is done, so it folds to a one-line row (it stayed expanded while running). + collapseMember(step.card); + } + if (g.closed){ finalizeGroup(g); } else if (g === curGroup){ refreshGroupHead(); } + } + function groupStepCounts(step, a, d){ + if (!step){ return; } + step.add = a; step.del = d; + const g = step.group; if (!g){ return; } + if (g.closed){ finalizeGroup(g); } else if (g === curGroup){ refreshGroupHead(); } + } + // A tool action (read / edit / search …) — rendered as a light node on the activity timeline rail. - function addAgentLine(icon, text){ + function addAgentLine(icon, text, label, kind, path){ clearEmpty(); clearStatus(); + const st = chipStep(icon, text, label, kind, path); st.status = 'done'; const d = document.createElement('div'); d.className = 'tl tl-tool'; + // Inside a group the row reads as the model's sentence ("Read the runAgent call site"); the raw + // tool text stays as the tooltip so the underlying path/query is never lost. + const shown = (groupsOn && st.base) ? st.base : String(text || ''); d.innerHTML = '
' + (IC[icon] ? codicon(icon) : esc(icon || '•')) + '
' - + '
' + esc(text || '') + '
'; - log.appendChild(d); scrollIfStuck(); + + '
' + esc(shown) + '
'; + groupAppend(d, st); + scrollIfStuck(); } // Drives the persistent working bar (not an inline log element), so the indicator can't be cleared into // a gap by an intervening tool card or bubble. The bar's visibility is owned by setStreaming. @@ -1435,6 +1773,7 @@ let pendingApproval = null; // { done } while a decision is awaited — Enter approves, Esc skips function addApproval(m){ clearEmpty(); clearStatus(); agentBubble = null; + closeGroup(); // a blocking gate never hides inside a collapsed group (D4) const card = document.createElement('div'); card.className = 'tl tl-cmd tl-ask asking'; const isDelete = m.kind === 'delete'; const segs = String(m.command || '').split(/\n+|\s*&&\s*|\s*;\s*/).map(s => s.trim()).filter(Boolean); @@ -1473,7 +1812,7 @@ card.querySelector('.tl-body').innerHTML = '
' + (approved ? 'Approved' : 'Skipped') + '' + '' + chips + '' - + '' + codicon(approved ? 'check' : 'circle-slash') + '
'; + + '' + codicon(approved ? 'check-circle' : 'circle-slash') + ''; // Deciding is user intent to continue (like send), so re-engage auto-scroll for the run output // that follows — by here `stuck` is usually false, since reading the command scrolls the log up. // Must run after the collapse above, not before: that shrink clamps scrollTop down on its own, @@ -1562,7 +1901,10 @@ + '' + (m.explanation ? '
' + esc(m.explanation) + '
' : '') + ''; - log.appendChild(card); scrollIfStuck(); + const st = { kind: 'cmd', base: cmdBase(m), status: 'running', card: card }; + termSteps[m.id] = st; + groupAppend(card, st); + scrollIfStuck(); termCards[m.id] = card; card.querySelector('.cmdhead').onclick = () => card.classList.toggle('collapsed'); const sb = card.querySelector('.termstop'); if (sb) { sb.onclick = () => { sb.disabled = true; sb.textContent = 'stopping…'; vscode.postMessage({ type: 'stopCommand', id: m.id }); }; } @@ -1575,19 +1917,30 @@ scrollIfStuck(); } function termExitFinish(m){ - const card = termCards[m.id]; if (!card) { return; } - const st = card.querySelector('.termstatus'); if (!st) { return; } + // Even if the card is gone (checkpoint restore / prune), still settle the group step and drop + // the step map entry — otherwise termSteps leaks and holds a stale group reference all session. + const card = termCards[m.id]; + if (!card || !card.querySelector('.termstatus')){ + if (termSteps[m.id]){ groupStepDone(termSteps[m.id], m.how === 'timeout' || (m.how !== 'stopped' && m.code !== 0)); delete termSteps[m.id]; } + delete termCards[m.id]; + return; + } + const st = card.querySelector('.termstatus'); const dur = (typeof m.ms === 'number') ? (m.ms >= 1000 ? (m.ms / 1000).toFixed(1) + 's' : m.ms + 'ms') : ''; let icon, text, cls; if (m.how === 'stopped'){ icon = 'circle-slash'; text = 'stopped'; cls = 'bad'; } else if (m.how === 'timeout'){ icon = 'warning'; text = 'timed out'; cls = 'bad'; } - else { const ok = m.code === 0; icon = ok ? 'check' : 'circle-slash'; text = 'exit ' + m.code; cls = ok ? 'ok' : 'bad'; } + else { const ok = m.code === 0; icon = ok ? 'check-circle' : 'circle-slash'; text = 'exit ' + m.code; cls = ok ? 'ok' : 'bad'; } st.innerHTML = codicon(icon) + ' ' + text + (dur ? ' · ' + dur : ''); st.className = 'termstatus ' + cls; card.classList.remove('running'); // stops the node pulse const verb = card.querySelector('.cmdverb'); if (verb) { verb.textContent = 'Ran'; } const state = card.querySelector('.cmdstate'); if (state) { state.innerHTML = codicon(icon); state.className = 'cmdstate ' + cls; } const sb = card.querySelector('.termstop'); if (sb) { sb.remove(); } + // Group bookkeeping: a user-stop is a choice, not a failure — only timeouts and nonzero exits + // mark the group failed (and auto-expand it). + groupStepDone(termSteps[m.id], m.how === 'timeout' || (m.how !== 'stopped' && m.code !== 0)); + delete termSteps[m.id]; delete termCards[m.id]; scrollIfStuck(); } @@ -1608,7 +1961,10 @@ + (m.command ? '' : '') + '' + ''; - log.appendChild(card); scrollIfStuck(); + const st = { kind: 'verify', base: 'Verify the edits', status: 'running', card: card }; + verifySteps[m.id] = st; + groupAppend(card, st); + scrollIfStuck(); verifyCards[m.id] = card; const sb = card.querySelector('.termstop'); if (sb) { sb.onclick = () => { sb.disabled = true; sb.textContent = 'stopping…'; vscode.postMessage({ type: 'stopCommand', id: m.id }); }; } } @@ -1626,7 +1982,7 @@ st.innerHTML = codicon('warning') + ' verify command couldn’t run — check settings'; st.className = 'termstatus'; // neutral: it’s a config issue, not a code failure } else if (m.ok){ - st.innerHTML = codicon('check') + ' verified — no problems'; + st.innerHTML = codicon('check-circle') + ' verified — no problems'; st.className = 'termstatus ok'; } else if (m.exhausted){ st.innerHTML = codicon('warning') + ' still failing — left for you to review'; @@ -1638,6 +1994,10 @@ st.innerHTML = codicon('circle-slash') + ' ' + (bits.join(' · ') || 'failed') + ' — fixing…'; st.className = 'termstatus bad'; } + // Group bookkeeping: ok and unrunnable (a config issue, not the agent's code) close clean; + // a real verify failure marks the group failed and auto-expands it. + groupStepDone(verifySteps[m.id], !(m.ok || m.unrunnable)); + delete verifySteps[m.id]; delete verifyCards[m.id]; scrollIfStuck(); } @@ -1689,13 +2049,18 @@ } function addQuestions(m){ clearEmpty(); clearStatus(); agentBubble = null; + closeGroup(); // interactive questions render at top level, never inside a group (D4) const qs = m.questions || []; const state = qs.map((q) => ({ multi: !!q.multiSelect, sel: new Set() })); - const card = document.createElement('div'); card.className = 'questions'; + // One question on screen at a time. Stacked, they invited answering the first and sending with + // the rest untouched. A single question needs no step chrome, so it keeps the plain card. + const wizard = qs.length > 1; + let step = 0; + const card = document.createElement('div'); card.className = 'questions' + (wizard ? ' wizard' : ''); let h = '
' + codicon('chevron-down') + '' + '' + codicon('question') + ' A few quick decisions — click to choose
'; qs.forEach((q, qi) => { - h += '
'; + h += '
'; if (q.header){ h += '
' + esc(q.header) + (q.multiSelect ? ' · pick any' : '') + '
'; } h += '
' + esc(q.question || '') + '
'; (q.options || []).forEach((o, oi) => { @@ -1708,7 +2073,8 @@ h += '
'; }); h += ''; - h += '
'; + h += '
' + + '
'; card.innerHTML = h; log.appendChild(card); scrollIfStuck(); @@ -1716,6 +2082,23 @@ const qhead = card.querySelector('.qhead'); if (qhead) { qhead.onclick = () => card.classList.toggle('collapsed'); } + const sendBtn = card.querySelector('.qsend'); + const backBtn = card.querySelector('.qback'); + const stepEl = card.querySelector('.qstep'); + // Only the last step sends. Before that the primary button advances — and when nothing is + // picked it says so plainly, so a question is never passed over by accident. + function render(){ + if (!wizard){ return; } + card.querySelectorAll('.qblock').forEach((b) => { b.classList.toggle('cur', +b.dataset.qi === step); }); + const last = step === qs.length - 1; + card.classList.toggle('laststep', last); + backBtn.hidden = step === 0; + stepEl.textContent = (step + 1) + ' of ' + qs.length; + const picked = state[step].sel.size > 0; + sendBtn.textContent = last ? 'Send answers' : (picked ? 'Next question' : 'Skip this one'); + sendBtn.classList.toggle('ghost', !last && !picked); + } + card.querySelectorAll('.qopt').forEach((btn) => { btn.onclick = () => { const qi = +btn.dataset.q, oi = +btn.dataset.o, st = state[qi]; @@ -1724,33 +2107,54 @@ card.querySelectorAll('.qopt[data-q="' + qi + '"]').forEach((b) => { b.classList.toggle('sel', st.sel.has(+b.dataset.o)); }); + render(); }; }); - card.querySelector('.qsend').onclick = () => { + backBtn.onclick = () => { if (step > 0){ step--; render(); } }; + sendBtn.onclick = () => { + if (wizard && step < qs.length - 1){ step++; render(); scrollIfStuck(); return; } const answers = qs.map((q, qi) => ({ header: q.header || '', question: q.question || '', selected: [...state[qi].sel].sort((a, b) => a - b).map((oi) => ((q.options || [])[oi] || {}).label || '') })); const notes = card.querySelector('.qnotes').value.trim(); vscode.postMessage({ type: 'questionsResponse', id: m.id, answers, notes }); - card.classList.add('answered'); // freeze as a visible record + // Answered and out of the way: the card folds to a single "Answers recorded" line so the + // transcript moves on, and unfolds to the full record — every question with what was picked. + card.classList.add('answered', 'collapsed'); + card.classList.remove('wizard'); card.querySelectorAll('.qopt').forEach((b) => { b.disabled = true; }); card.querySelector('.qnotes').disabled = true; - card.querySelector('.qbtns').innerHTML = '' + codicon('check') + ' Answers recorded'; + const title = card.querySelector('.qtitle'); + if (title){ title.innerHTML = codicon('check-circle') + ' Answers recorded — click to review'; } + card.querySelector('.qbtns').innerHTML = '' + codicon('check-circle') + ' Answers recorded'; }; + render(); } // Non-blocking applied-edit card (the agent already changed the file; you review after). function addEditCard(m){ clearEmpty(); clearStatus(); agentBubble = null; let card = editCards[m.id]; - let wasOpen = true; + // A NEW edit card inside a group starts as a one-line row (diff closed); ungrouped it keeps + // today's open-diff default. (collapseMember can't close the diff at groupAppend time because the + // card's innerHTML isn't built yet, so the default has to be right here.) A re-render preserves + // whatever open/closed state the user last left it in. + let wasOpen = !groupsOn; if (!card){ card = document.createElement('div'); card.className = 'editcard'; - editCards[m.id] = card; log.appendChild(card); + editCards[m.id] = card; + const st = { + kind: m.deleted ? 'delete' : (m.exists ? 'edit' : 'create'), + base: (m.deleted ? 'Delete ' : (m.exists ? 'Edit ' : 'Create ')) + m.path, + path: m.path, status: 'done' + }; + editSteps[m.id] = st; + groupAppend(card, st); } else { const prev = card.querySelector('details.diffwrap'); if (prev) { wasOpen = prev.open; } // preserve user's collapse state } + groupStepCounts(editSteps[m.id], m.deleted ? 0 : (m.add || 0), m.del || 0); if (m.deleted){ // A delete_file shows as its own card — not an all-red diff. Keep = confirm, Recreate = undo. const dn = m.del || 0; @@ -1791,12 +2195,13 @@ const isDel = card.classList.contains('deletedcard'); const map = isDel ? { kept: codicon('trash') + ' Deleted', undone: codicon('circle-slash') + ' Recreated' } - : { kept: codicon('check') + ' Kept', undone: codicon('circle-slash') + ' Undid', 'kept-user-edited': codicon('edit') + ' Kept (you edited it)' }; + : { kept: codicon('check-circle') + ' Kept', undone: codicon('circle-slash') + ' Undid', 'kept-user-edited': codicon('edit') + ' Kept (you edited it)' }; const btns = card.querySelector('.abtns'); - if (btns){ btns.innerHTML = '' + (map[m.verdict] || (isDel ? codicon('trash') + ' Deleted' : codicon('check') + ' Kept')) + ''; } + if (btns){ btns.innerHTML = '' + (map[m.verdict] || (isDel ? codicon('trash') + ' Deleted' : codicon('check-circle') + ' Kept')) + ''; } const det = card.querySelector('details.diffwrap'); if (det) { det.open = false; } // collapse so resolved cards don't stack tall card.classList.add('resolved'); delete editCards[m.id]; + delete editSteps[m.id]; // release the group step ref alongside the card (no unbounded growth) } // ---- context-window usage: always-visible footer stat + a warning bar when it fills ---- const ctxBar = document.getElementById('ctxBar'); @@ -1980,7 +2385,7 @@ let items = ''; for (const t of todos){ const st = t.status === 'done' ? 'done' : t.status === 'in_progress' ? 'doing' : 'todo'; - const ic = st === 'done' ? codicon('check') : st === 'doing' ? codicon('sync') : codicon('circle'); + const ic = st === 'done' ? codicon('check-circle') : st === 'doing' ? codicon('sync') : codicon('circle'); items += '
' + ic + '' + esc(t.title) + '
'; } const head = '
' + codicon('chevron-down') + '' @@ -2024,6 +2429,7 @@ // Response action bar: (Retry) (Copy) (Helpful) (Unhelpful) … model · $cost · $left. function addAgentDone(reason, edits, credits, maxSteps, costMicros){ + closeGroup(); // the run is over — finalize any open activity group const bar = document.createElement('div'); bar.className = 'agentbar'; // Stamp the model that produced THIS turn, so a later reaction is attributed to it // (not whatever the plan is entitled to at click time — models can change mid-session). @@ -2122,6 +2528,7 @@ histIdx = -1; histDraft = ''; // Slash commands — handled locally (deterministic, no LLM call). if (/^\/skills\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); vscode.postMessage({ type: 'listSkills' }); return; } + if (/^\/rme\b/i.test(t)){ add('user', esc(t)); forceStick(); input.value = ''; auto(); addReminder(); return; } add('user', render(t)); forceStick(); input.value = ''; auto(); vscode.postMessage({ type: 'send', text: t }); } @@ -2137,8 +2544,9 @@ const approvalsLabel = document.getElementById('approvalsLabel'); const approvalsIco = document.getElementById('approvalsIco'); let autopilotOn = false; - // Paint the menu option glyphs from the codicon set once (shield / rocket). - approvalsMenu.querySelectorAll('.moico[data-ico]').forEach((s) => { s.innerHTML = codicon(s.getAttribute('data-ico')); }); + // Paint every menu glyph from the codicon set once — option icons (shield / rocket) and the + // selected-tick (check-circle) in both the approvals and mode menus. + document.querySelectorAll('[data-ico]').forEach((s) => { s.innerHTML = codicon(s.getAttribute('data-ico')); }); function syncApprovalsMenu(){ approvalsMenu.querySelectorAll('.modeopt').forEach((b) => { b.classList.toggle('active', (b.getAttribute('data-autopilot') === '1') === autopilotOn); @@ -2267,7 +2675,7 @@ + upgradeBtn + '' + '' - + '
' + codicon('check') + ' Synced: settings · skills · keymaps
' + + '
' + codicon('check-circle') + ' Synced: settings · skills · keymaps
' + privacyNote(gateway); } else { acctCard.classList.add('lc-modal'); // signed-out → the calm LevelCode sign-in modal @@ -2406,12 +2814,16 @@ let wrap = u.querySelector('.ckwrap'); if (!wrap){ wrap = document.createElement('div'); wrap.className = 'ckwrap'; u.appendChild(wrap); } renderRestoreIdle(wrap, turnId, fileCount); } - function pruneDetached(map){ for (const k in map){ const el = map[k]; if (el && !log.contains(el)){ delete map[k]; } } } + // Drop entries whose DOM is gone. Works for card maps (value IS the element) and step maps (value + // is a {card} object) — a checkpoint restore removes the cards, so their step state must go too. + function pruneDetached(map){ for (const k in map){ const v = map[k]; const el = (v && v.nodeType) ? v : (v && v.card); if (!el || !log.contains(el)){ delete map[k]; } } } function applyCheckpointRestored(m){ const u = ckBubble(m.turnId); if (!u){ return; } let n = u.nextSibling; while (n){ const next = n.nextSibling; n.remove(); n = next; } // drop this turn's output + every later turn const wrap = u.querySelector('.ckwrap'); if (wrap){ wrap.innerHTML = '' + REVERT + ' Restored to here · ' + (m.filesRestored || 0) + ' file' + (m.filesRestored === 1 ? '' : 's') + ' reverted'; } pruneDetached(editCards); pruneDetached(termCards); pruneDetached(verifyCards); + pruneDetached(editSteps); pruneDetached(termSteps); pruneDetached(verifySteps); + if (curGroup && !log.contains(curGroup.el)){ curGroup = null; } // don't append into a removed group agentBubble = null; agentRaw = ''; setStreaming(false); forceStick(); } function checkpointBusyNotice(m){ @@ -2446,6 +2858,9 @@ if (typeof m.contextLimit === 'number'){ renderContext({ limit: m.contextLimit }); } lastProvider = m.provider || ''; lastModelId = m.modelId || m.model || ''; // gateway sends modelId (the real id); BYOK model IS the id + // calm-transcript toggle. Turning it OFF mid-run closes any open group first, so the timeline + // returns to fully flat immediately instead of leaving one stray group behind. + if (typeof m.groupActivity === 'boolean'){ if (!m.groupActivity){ closeGroup(); } groupsOn = m.groupActivity; } renderRouting(); } else if (m.type === 'assistantStart'){ shown = ''; pending = ''; doneSignaled = false; flushAll = false; current = makeStream(add('assistant', '')); setStreaming(true); } @@ -2468,11 +2883,11 @@ else if (m.type === 'autoContext'){ addAutoCtx(m.names); } else if (m.type === 'mode'){ applyMode(!!m.agent); } else if (m.type === 'autopilot'){ applyAutopilot(!!m.on); } - else if (m.type === 'agentStart'){ setStreaming(true); agentBubble = null; agentRaw = ''; renderPlan([]); setAgentStatus('thinking…'); } + else if (m.type === 'agentStart'){ closeGroup(); setStreaming(true); agentBubble = null; agentRaw = ''; turnLabeled = false; renderPlan([]); setAgentStatus('thinking…'); } else if (m.type === 'agentStatus'){ finishAgentBubble(); setAgentStatus(m.text || 'working…'); } else if (m.type === 'agentDelta'){ clearStatus(); setWork('Responding…'); if (!agentBubble){ agentBubble = makeStream(add('assistant', '')); agentRaw = ''; } agentRaw += m.text; streamFeed(agentBubble, agentRaw); } else if (m.type === 'agentTurnEnd'){ finishAgentBubble(); } - else if (m.type === 'agentTool'){ finishAgentBubble(); addAgentLine(m.icon, m.text); } + else if (m.type === 'agentTool'){ finishAgentBubble(); addAgentLine(m.icon, m.text, m.label, m.kind, m.path); } else if (m.type === 'agentApproval'){ addApproval(m); } else if (m.type === 'agentQuestions'){ addQuestions(m); } else if (m.type === 'termRun'){ addTermRun(m); } @@ -2497,14 +2912,20 @@ else if (m.type === 'debug'){ addDebug(m); } else if (m.type === 'account'){ renderAccount(m); if (m.open) openAccount(); } else if (m.type === 'fileIndex'){ setFileIndex(m.files || []); } - else if (m.type === 'agentError'){ clearStatus(); finishAgentBubble(); const cap = capReachedInfo(m.message); if (cap){ addUpgradeCard(cap); } else if (isServiceIssue(m)){ addServiceCard(m); } else { add('assistant', '' + esc(m.message) + ''); } } + else if (m.type === 'agentError'){ clearStatus(); finishAgentBubble(); closeGroup(); const cap = capReachedInfo(m.message); if (cap){ addUpgradeCard(cap); } else if (isServiceIssue(m)){ addServiceCard(m); } else { add('assistant', '' + esc(m.message) + ''); } } else if (m.type === 'agentDone'){ clearStatus(); finishAgentBubble(); addAgentDone(m.reason, m.edits, m.credits, m.maxSteps, m.costMicros); setStreaming(false); } else if (m.type === 'context'){ selLabel = m.label; renderChips(); } else if (m.type === 'clearContext'){ selLabel = null; renderChips(); } else if (m.type === 'reset'){ log.innerHTML = '
New chat
Ask about your code, or describe what to build.
'; + // Wiping the log detaches any open group; drop the dangling refs so the next run starts clean — + // otherwise groupAppend() appends into a disconnected .groupbody (nothing shows), and a stale + // turnLabeled makes the first assistant line render as an unlabeled continuation. + curGroup = null; turnLabeled = false; selLabel = null; ctxFiles = []; renderChips(); - for (const k in editCards) { delete editCards[k]; } for (const k in termCards) { delete termCards[k]; } for (const k in verifyCards) { delete verifyCards[k]; } for (const k in bgTasks) { delete bgTasks[k]; } renderBgTasks(); updateReviewBar(0); renderPlan([]); renderContext({ input: 0 }); + for (const k in editCards) { delete editCards[k]; } for (const k in termCards) { delete termCards[k]; } for (const k in verifyCards) { delete verifyCards[k]; } for (const k in bgTasks) { delete bgTasks[k]; } + for (const k in editSteps) { delete editSteps[k]; } for (const k in termSteps) { delete termSteps[k]; } for (const k in verifySteps) { delete verifySteps[k]; } // step maps ride with their cards + renderBgTasks(); updateReviewBar(0); renderPlan([]); renderContext({ input: 0 }); pending = ''; shown = ''; doneSignaled = false; flushAll = false; current = null; setStreaming(false); forceStick(); } diff --git a/extensions/levelcode-ai/package.json b/extensions/levelcode-ai/package.json index 866365b..a05d8e8 100644 --- a/extensions/levelcode-ai/package.json +++ b/extensions/levelcode-ai/package.json @@ -349,6 +349,11 @@ "default": false, "description": "Include a list of all project file paths with each chat message, so the AI knows the repo structure. Uses more tokens." }, + "levelcode.ai.chat.groupActivity": { + "type": "boolean", + "default": true, + "description": "Fold consecutive agent actions (reads, edits, commands) into one collapsible activity card in the chat — the header shows the live step while running and a summary when done. Turn off for the flat, ungrouped timeline." + }, "levelcode.cloud.endpoint": { "type": "string", "default": "https://levelcode.ai", diff --git a/extensions/levelcode-ai/test/askWizard.test.js b/extensions/levelcode-ai/test/askWizard.test.js new file mode 100644 index 0000000..98bb414 --- /dev/null +++ b/extensions/levelcode-ai/test/askWizard.test.js @@ -0,0 +1,238 @@ +/*--------------------------------------------------------------------------------------------- + * Behavioural tests for the ask_user card — run: node test/askWizard.test.js + * + * Several questions used to render stacked in one card with a single "Send answers" button. It + * was easy to answer the first, not notice the rest, and send a half-filled form — the agent then + * acted on defaults the user never chose. The card now shows ONE question at a time. + * + * The invariants worth protecting, all of them clicked through here against the real function + * extracted from media/chat.html: + * - nothing is posted until the LAST question — the button advances before that, + * - passing over a question is deliberate: with nothing picked the button reads "Skip this one", + * - Back returns to a previous question with its selection intact, + * - the posted payload keeps every question in order, skipped ones included as empty, + * - a single question keeps the plain card (no step chrome, sends straight away). + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'media', 'chat.html'), 'utf8'); + +function extract(name) { + const start = html.indexOf('function ' + name + '('); + assert.ok(start >= 0, 'chat.html no longer defines ' + name + '()'); + const end = html.indexOf('\n }', start); + assert.ok(end >= 0, 'no closing brace found for ' + name + '()'); + return html.slice(start, end + 4); +} + +// ── a fake DOM with just enough attribute support to drive the card ──────────────────────────── +// The card is built as one innerHTML string and then wired by class, so the parser records every +// tag's classes and data-* attributes and serves them back through querySelector(All). +class El { + constructor(tag) { + this.tagName = (tag || 'div').toUpperCase(); + this.children = []; + this._html = ''; + this._nodes = []; + this.dataset = {}; + this.hidden = false; + this.disabled = false; + this.value = ''; + this.textContent = ''; + this.onclick = null; + this.classList = { + _s: new Set(), + add: (...c) => c.forEach((x) => this.classList._s.add(x)), + remove: (...c) => c.forEach((x) => this.classList._s.delete(x)), + contains: (c) => this.classList._s.has(c), + toggle: (c, force) => { + const on = force === undefined ? !this.classList._s.has(c) : !!force; + if (on) { this.classList._s.add(c); } else { this.classList._s.delete(c); } + return on; + } + }; + } + set className(v) { this.classList._s = new Set(String(v).split(/\s+/).filter(Boolean)); } + get className() { return [...this.classList._s].join(' '); } + set innerHTML(v) { + this._html = String(v); + this._nodes = []; + for (const t of this._html.matchAll(/<(\w+)([^>]*)>/g)) { + const attrs = t[2]; + if (!/class="/.test(attrs)) { continue; } + const el = new El(t[1]); + el.className = /class="([^"]*)"/.exec(attrs)[1]; + for (const d of attrs.matchAll(/data-([\w-]+)="([^"]*)"/g)) { el.dataset[d[1]] = d[2]; } + if (/\shidden(?=[\s>/])/.test(attrs + ' ')) { el.hidden = true; } + // text up to the next tag — enough for the labels the card ships in its markup + el.textContent = (/^([^<]*)/.exec(this._html.slice(t.index + t[0].length)) || ['', ''])[1].trim(); + this._nodes.push(el); + } + } + get innerHTML() { return this._html; } + appendChild(c) { this.children.push(c); return c; } + _match(sel) { + const m = /^\.([\w-]+)(?:\[([\w-]+)="([^"]*)"\])?$/.exec(String(sel).trim()); + assert.ok(m, 'fake DOM cannot parse selector: ' + sel); + const [, cls, attr, val] = m; + return this._nodes.filter((n) => n.classList.contains(cls) + && (!attr || n.dataset[attr.replace(/^data-/, '')] === val)); + } + querySelector(sel) { return this._match(sel)[0] || null; } + querySelectorAll(sel) { return this._match(sel); } +} + +function newCard(questions) { + const log = new El('div'); + const posted = []; + const sandbox = {}; + const preamble = 'const codicon = (n) => "";\n' + + 'const esc = (s) => String(s == null ? "" : s);\n' + + 'const clearEmpty = () => {}; const clearStatus = () => {}; const closeGroup = () => {};\n' + + 'const scrollIfStuck = () => {}; let agentBubble = null;\n'; + new Function('document', 'log', 'vscode', preamble + extract('addQuestions') + '\nthis.addQuestions = addQuestions;') + .call(sandbox, { createElement: (t) => new El(t) }, log, { postMessage: (msg) => posted.push(msg) }); + /** @type {any} */ (sandbox).addQuestions({ id: 'q1', questions }); + const card = log.children[0]; + return { + card, posted, + send: card.querySelector('.qsend'), + back: card.querySelector('.qback'), + step: card.querySelector('.qstep'), + notes: card.querySelector('.qnotes'), + pick: (qi, oi) => card.querySelectorAll('.qopt[data-q="' + qi + '"]')[oi].onclick(), + curIndex: () => card.querySelectorAll('.qblock').findIndex((b) => b.classList.contains('cur')) + }; +} + +const Q = (header, ...labels) => ({ header, question: header + '?', options: labels.map((l) => ({ label: l })) }); +const THREE = [Q('Target', 'A', 'B'), Q('Content', 'C', 'D'), Q('Style', 'E', 'F')]; + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +// ── 1. one at a time ─────────────────────────────────────────────────────────────────────────── +test('several questions open on the first one alone', () => { + const c = newCard(THREE); + assert.ok(c.card.classList.contains('wizard')); + assert.strictEqual(c.curIndex(), 0, 'only the first block is current'); + assert.strictEqual(c.step.textContent, '1 of 3'); + assert.ok(c.back.hidden, 'nothing to go back to yet'); +}); + +test('with nothing picked the button offers to SKIP, not to send', () => { + const c = newCard(THREE); + assert.strictEqual(c.send.textContent, 'Skip this one'); + assert.ok(c.send.classList.contains('ghost'), 'and it steps down to a quiet style'); +}); + +test('picking an option turns it into the advance button', () => { + const c = newCard(THREE); + c.pick(0, 1); + assert.strictEqual(c.send.textContent, 'Next question'); + assert.ok(!c.send.classList.contains('ghost')); +}); + +// ── 2. nothing escapes early ─────────────────────────────────────────────────────────────────── +test('the button ADVANCES on every question but the last — no early post', () => { + const c = newCard(THREE); + c.pick(0, 0); c.send.onclick(); + assert.deepStrictEqual(c.posted, [], 'nothing sent after question 1'); + assert.strictEqual(c.curIndex(), 1); + assert.strictEqual(c.step.textContent, '2 of 3'); + assert.ok(!c.back.hidden, 'Back appears once there is somewhere to go back to'); + + c.pick(1, 0); c.send.onclick(); + assert.deepStrictEqual(c.posted, [], 'nothing sent after question 2'); + assert.strictEqual(c.send.textContent, 'Send answers', 'only the last step sends'); + assert.ok(c.card.classList.contains('laststep'), 'and the free-text box appears with it'); +}); + +test('the final step posts every answer, in order', () => { + const c = newCard(THREE); + c.pick(0, 1); c.send.onclick(); + c.pick(1, 0); c.send.onclick(); + c.pick(2, 1); c.send.onclick(); + assert.strictEqual(c.posted.length, 1); + assert.strictEqual(c.posted[0].type, 'questionsResponse'); + assert.deepStrictEqual(c.posted[0].answers.map((a) => a.selected), [['B'], ['C'], ['F']]); + assert.deepStrictEqual(c.posted[0].answers.map((a) => a.header), ['Target', 'Content', 'Style']); +}); + +test('a skipped question posts as empty — recorded, not silently defaulted', () => { + const c = newCard(THREE); + c.send.onclick(); // "Skip this one" + c.pick(1, 1); c.send.onclick(); + c.pick(2, 0); c.send.onclick(); + assert.deepStrictEqual(c.posted[0].answers.map((a) => a.selected), [[], ['D'], ['E']]); +}); + +// ── 3. going back ────────────────────────────────────────────────────────────────────────────── +test('Back returns to the previous question with its answer still selected', () => { + const c = newCard(THREE); + c.pick(0, 1); c.send.onclick(); + c.back.onclick(); + assert.strictEqual(c.curIndex(), 0); + assert.strictEqual(c.step.textContent, '1 of 3'); + assert.strictEqual(c.send.textContent, 'Next question', 'the earlier pick is still held'); + assert.ok(c.back.hidden, 'and Back retires at the start again'); +}); + +test('changing a mind on the way back is what gets posted', () => { + const c = newCard(THREE); + c.pick(0, 0); c.send.onclick(); + c.back.onclick(); c.pick(0, 1); // switch A → B + c.send.onclick(); + c.pick(1, 0); c.send.onclick(); + c.pick(2, 0); c.send.onclick(); + assert.deepStrictEqual(c.posted[0].answers[0].selected, ['B']); +}); + +// ── 4. the shapes that must not gain step chrome ─────────────────────────────────────────────── +test('a single question keeps the plain card and sends straight away', () => { + const c = newCard([Q('Target', 'A', 'B')]); + assert.ok(!c.card.classList.contains('wizard'), 'no step chrome for one question'); + assert.strictEqual(c.send.textContent, 'Send answers'); + c.pick(0, 0); c.send.onclick(); + assert.strictEqual(c.posted.length, 1); + assert.deepStrictEqual(c.posted[0].answers[0].selected, ['A']); +}); + +test('multiSelect collects several picks before advancing', () => { + const c = newCard([{ header: 'Pick', question: 'Which?', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] }, + Q('Then', 'X', 'Y')]); + c.pick(0, 0); c.pick(0, 2); + assert.strictEqual(c.send.textContent, 'Next question'); + c.send.onclick(); + c.pick(1, 0); c.send.onclick(); + assert.deepStrictEqual(c.posted[0].answers[0].selected, ['A', 'C']); +}); + +// ── 5. the record left behind ────────────────────────────────────────────────────────────────── +test('once sent, the card folds itself away and says so', () => { + const c = newCard(THREE); + c.pick(0, 0); c.send.onclick(); + c.pick(1, 0); c.send.onclick(); + c.pick(2, 0); c.send.onclick(); + assert.ok(c.card.classList.contains('collapsed'), 'the finished card gets out of the way'); + assert.ok(c.card.classList.contains('answered')); + assert.ok(/Answers recorded/.test(c.card.querySelector('.qtitle').innerHTML), + 'and the header — the only thing still on screen — states the outcome'); +}); + +test('unfolding it shows every question as a frozen record', () => { + const c = newCard(THREE); + c.pick(0, 0); c.send.onclick(); + c.pick(1, 0); c.send.onclick(); + c.pick(2, 0); c.send.onclick(); + assert.ok(!c.card.classList.contains('wizard'), 'all questions revealed, not just the last one'); + assert.ok(c.card.querySelectorAll('.qopt').every((b) => b.disabled), 'answers are no longer editable'); + assert.ok(/Answers recorded/.test(c.card.querySelector('.qbtns').innerHTML)); +}); + +console.log('askWizard: ' + n + ' tests passed'); diff --git a/extensions/levelcode-ai/test/groupReducer.test.js b/extensions/levelcode-ai/test/groupReducer.test.js new file mode 100644 index 0000000..4843999 --- /dev/null +++ b/extensions/levelcode-ai/test/groupReducer.test.js @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * Behavioural tests for the calm-transcript GROUP REDUCER — run: node test/groupReducer.test.js + * + * narrativeUi.test.js covers the grammar (pure strings). This file covers the part that actually + * bit: the DOM behaviour. It drives the REAL functions extracted from media/chat.html against a + * tiny fake DOM, so the invariants a user would notice are checked without launching the editor: + * - a group's header shows the LIVE step while running, the aggregate once closed, + * - members render as one-line rows (collapsed) and a FAILING one re-opens (collapse is for + * success — a hidden failure is the one thing this UI must never do), + * - a single-member group unwraps and hands the card back expanded, + * - a background command that exits AFTER its group closed still re-finalizes it, + * - grouping off ⇒ everything lands flat in the log, exactly as before. + * + * The fake DOM is deliberately minimal: innerHTML is scanned for class="…" to create queryable + * child stubs (the reducer only ever queries by class), and appendChild/insertBefore/remove keep + * a real child list. It is not a browser — it is just enough to exercise the reducer honestly. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'media', 'chat.html'), 'utf8'); + +function extract(name) { + const start = html.indexOf('function ' + name + '('); + assert.ok(start >= 0, 'chat.html no longer defines ' + name + '()'); + const end = html.indexOf('\n }', start); + assert.ok(end >= 0, 'no closing brace found for ' + name + '()'); + return html.slice(start, end + 4); +} + +// ── the fake DOM ─────────────────────────────────────────────────────────────────────────────── +class El { + constructor(tag) { + this.tagName = (tag || 'div').toUpperCase(); + this.children = []; + this.parent = null; + this._html = ''; + this._text = ''; + this._stubs = []; // elements discovered in innerHTML, queryable by class + this.hidden = false; + this.onclick = null; + this.classList = { + _s: new Set(), + add: (...c) => c.forEach((x) => this.classList._s.add(x)), + remove: (...c) => c.forEach((x) => this.classList._s.delete(x)), + contains: (c) => this.classList._s.has(c), + toggle: (c) => (this.classList._s.has(c) ? (this.classList._s.delete(c), false) : (this.classList._s.add(c), true)) + }; + } + set className(v) { this.classList._s = new Set(String(v).split(/\s+/).filter(Boolean)); } + get className() { return [...this.classList._s].join(' '); } + set innerHTML(v) { + this._html = String(v); + this._stubs = []; + const re = /class="([^"]+)"/g; + let m; + while ((m = re.exec(this._html))) { const s = new El('div'); s.className = m[1]; this._stubs.push(s); } + } + get innerHTML() { return this._html; } + set textContent(v) { this._text = String(v); } + get textContent() { return this._text; } + get isConnected() { let n = this; while (n.parent) { n = n.parent; } return n.__root === true; } + get firstChild() { return this.children[0] || null; } + appendChild(c) { if (c.parent) { c.parent.removeChild(c); } c.parent = this; this.children.push(c); return c; } + insertBefore(c, ref) { + if (c.parent) { c.parent.removeChild(c); } + c.parent = this; + const i = this.children.indexOf(ref); + this.children.splice(i < 0 ? this.children.length : i, 0, c); + return c; + } + removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) { this.children.splice(i, 1); } c.parent = null; } + remove() { if (this.parent) { this.parent.removeChild(this); } } + querySelector(sel) { + const want = String(sel).replace(/^\./, '').split('.')[0]; + for (const s of this._stubs) { if (s.classList.contains(want)) { return s; } } + for (const c of this.children) { if (c.classList.contains(want)) { return c; } const d = c.querySelector(sel); if (d) { return d; } } + return null; + } +} + +function newHarness(groupsOn) { + const log = new El('div'); + log.__root = true; + const sandbox = {}; + const preamble = 'let curGroup = null; let groupsOn = ' + (groupsOn === false ? 'false' : 'true') + ';\n' + + 'let turnLabeled = false;\n' + + 'const LC_DOTS = "";\n' + + 'const codicon = (n) => "";\n' + + 'const esc = (s) => String(s == null ? "" : s);\n' + + 'const escAttr = (s) => esc(s).replace(/"/g, """);\n' + + 'const IC = { file: 1, search: 1, "list-tree": 1 };\n' + + 'const scrollIfStuck = () => {}; const clearEmpty = () => {}; const clearStatus = () => {};\n'; + const src = preamble + + [ + 'tenseLabel', 'groupAggregate', 'chipStep', 'cmdBase', 'groupCountsHtml', 'openGroup', + 'collapseMember', 'groupAppend', 'refreshGroupHead', 'finalizeGroup', 'closeGroup', + 'groupStepDone', 'groupStepCounts', 'addAgentLine', 'add' + ].map(extract).join('\n') + + '\nthis.api = { openGroup, groupAppend, closeGroup, groupStepDone, groupStepCounts, addAgentLine, add, get curGroup(){ return curGroup; } };'; + new Function('document', 'log', src).call(sandbox, { createElement: (t) => new El(t) }, log); + return { log, api: /** @type {any} */ (sandbox).api }; +} + +// A stand-in for a term/edit card the reducer receives from the existing add* functions. +const card = (cls) => { const e = new El('div'); e.className = cls; return e; }; + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +// ── 1. header states: live step while running → aggregate once closed ────────────────────────── +test('header shows the LIVE step (progressive) while the group runs', () => { + const h = newHarness(); + h.api.addAgentLine('file', 'read a.js', 'Read the runAgent call site', 'read', 'a.js'); + const cmd = card('tl tl-cmd'); + h.api.groupAppend(cmd, { kind: 'cmd', base: 'Verify the reap wiring', status: 'running', card: cmd }); + assert.strictEqual(h.api.curGroup.label.textContent, 'Verifying the reap wiring'); +}); + +test('header flips to the past-tense aggregate when the group closes', () => { + const h = newHarness(); + h.api.addAgentLine('file', 'read extension.js', '', 'read', 'extension.js'); + const ed = card('editcard'); + h.api.groupAppend(ed, { kind: 'edit', base: 'Edit extension.js', path: 'extension.js', status: 'done' }); + const cmd = card('tl tl-cmd'); + h.api.groupAppend(cmd, { kind: 'cmd', base: 'Verify the reap wiring', status: 'running', card: cmd }); + const g = h.api.curGroup; + h.api.groupStepCounts(g.steps[1], 3, 1); + h.api.groupStepDone(g.steps[2], false); + assert.ok(!g.el.classList.contains('collapsed'), 'stays open while it runs'); + h.api.closeGroup(); + assert.strictEqual(g.label.textContent, 'Read and edited extension.js, ran a command'); + assert.ok(/\+3/.test(g.counts.innerHTML) && /-1/.test(g.counts.innerHTML), 'summed diffstat in the header'); + // The fold IS the payoff: a finished group is ONE line, not a header plus every finished row. + assert.ok(g.el.classList.contains('collapsed'), 'a successful group folds to its summary line'); + assert.ok(/i:check-circle/.test(g.node.innerHTML), 'rail states the outcome with the ring-check'); + assert.ok(!/i:sync/.test(g.node.innerHTML), 'and stops wearing the running spinner'); + assert.ok(g.el.classList.contains('gok') && !g.el.classList.contains('gfailed'), + 'success recolours the node to the quiet-green done state, not the accent running one'); +}); + +test('a group the user opened by hand keeps their choice when it closes', () => { + const h = newHarness(); + const a = card('tl tl-cmd'), b = card('tl tl-cmd'); + h.api.groupAppend(a, { kind: 'cmd', base: 'Run one', status: 'done', card: a }); + h.api.groupAppend(b, { kind: 'cmd', base: 'Run two', status: 'done', card: b }); + const g = h.api.curGroup; + g.userToggled = true; // they clicked the header mid-run + h.api.closeGroup(); + assert.ok(!g.el.classList.contains('collapsed'), 'auto-fold never overrides a deliberate click'); +}); + +test('a FAILED group closes open, so the problem is on screen', () => { + const h = newHarness(); + const a = card('tl tl-cmd'), b = card('tl tl-cmd'); + h.api.groupAppend(a, { kind: 'cmd', base: 'Run one', status: 'done', card: a }); + const step = { kind: 'cmd', base: 'Run two', status: 'running', card: b }; + h.api.groupAppend(b, step); + const g = h.api.curGroup; + h.api.groupStepDone(step, true); + h.api.closeGroup(); + assert.ok(!g.el.classList.contains('collapsed'), 'failures never fold away'); + assert.ok(/circle-slash/.test(g.node.innerHTML), 'rail marks the failure'); + assert.ok(g.el.classList.contains('gfailed') && !g.el.classList.contains('gok'), + 'failure recolours the node to the error state'); +}); + +// ── 2. members are one-line rows; a failure re-opens its row ─────────────────────────────────── +test('members render collapsed (one-line rows) inside a group', () => { + const h = newHarness(); + const a = card('tl tl-cmd'), b = card('tl tl-cmd'); + h.api.groupAppend(a, { kind: 'cmd', base: 'Run the tests', status: 'done', card: a }); + h.api.groupAppend(b, { kind: 'cmd', base: 'Run the linter', status: 'done', card: b }); + assert.ok(a.classList.contains('collapsed') && b.classList.contains('collapsed')); +}); + +test('a FAILING member re-opens its own row and the group (collapse is for success)', () => { + const h = newHarness(); + const ok = card('tl tl-cmd'), bad = card('tl tl-cmd'); + h.api.groupAppend(ok, { kind: 'cmd', base: 'Run the tests', status: 'done', card: ok }); + const step = { kind: 'cmd', base: 'Run the linter', status: 'running', card: bad }; + h.api.groupAppend(bad, step); + const g = h.api.curGroup; + g.el.classList.add('collapsed'); // user had collapsed the whole group + h.api.groupStepDone(step, true); // …then it failed + assert.ok(!bad.classList.contains('collapsed'), 'failing row expands'); + assert.ok(!g.el.classList.contains('collapsed'), 'group re-opens'); + h.api.closeGroup(); + assert.ok(/circle-slash/.test(g.state.innerHTML), 'group state marks failure'); +}); + +test('a user-stopped command is NOT a failure', () => { + const h = newHarness(); + const a = card('tl tl-cmd'), b = card('tl tl-cmd'); + h.api.groupAppend(a, { kind: 'cmd', base: 'Run one', status: 'done', card: a }); + const step = { kind: 'cmd', base: 'Run two', status: 'running', card: b }; + h.api.groupAppend(b, step); + const g = h.api.curGroup; + h.api.groupStepDone(step, false); // termExitFinish passes false for how === 'stopped' + h.api.closeGroup(); + assert.ok(/i:check/.test(g.node.innerHTML), 'group still closes clean'); + assert.strictEqual(g.state.innerHTML, '', 'no second glyph trailing the header'); +}); + +// ── 3. degenerate + late-exit lifecycles ─────────────────────────────────────────────────────── +test('a single-member group unwraps and hands the card back EXPANDED', () => { + const h = newHarness(); + const only = card('tl tl-cmd'); + h.api.groupAppend(only, { kind: 'cmd', base: 'Run the tests', status: 'done', card: only }); + const g = h.api.curGroup; + h.api.closeGroup(); + assert.ok(!g.el.isConnected, 'group wrapper removed'); + assert.ok(h.log.children.indexOf(only) >= 0, 'member rejoined the log'); + assert.ok(!only.classList.contains('collapsed'), 'standalone card is expanded again'); +}); + +test('a still-running member keeps the group live when narration closes it; it settles on exit', () => { + const h = newHarness(); + const a = card('tl tl-cmd'), bg = card('tl tl-cmd'); + h.api.groupAppend(a, { kind: 'read', base: 'Read a.js', path: 'a.js', status: 'done' }); + const step = { kind: 'cmd', base: 'Start the dev server', status: 'running', card: bg }; + h.api.groupAppend(bg, step); + const g = h.api.curGroup; + h.api.closeGroup(); // narration arrived while the server still ran + // It must NOT claim to be finished: the header still tracks the live step and no outcome is shown, + // so the group can't misrepresent a running background command as done. + assert.ok(/Starting the dev server/.test(g.label.textContent), 'header keeps the live step while a member runs'); + assert.ok(!g.el.classList.contains('gok') && !g.el.classList.contains('gfailed'), 'not settled while still running'); + h.api.groupStepDone(step, true); // server later exits nonzero + assert.ok(/circle-slash/.test(g.node.innerHTML), 'late exit settles the group: the node marks failure'); + assert.ok(g.el.classList.contains('gfailed'), 'and it is now recorded as failed'); + assert.ok(!g.el.classList.contains('collapsed'), 'a failure stays open'); +}); + +test('a running member stays expanded on insert and folds only when it finishes', () => { + const h = newHarness(); + const done = card('tl tl-cmd'), running = card('tl tl-cmd'); + h.api.groupAppend(done, { kind: 'cmd', base: 'Run one', status: 'done', card: done }); + assert.ok(done.classList.contains('collapsed'), 'an already-finished member folds to a row on insert'); + const step = { kind: 'cmd', base: 'Run the tests', status: 'running', card: running }; + h.api.groupAppend(running, step); + assert.ok(!running.classList.contains('collapsed'), 'a RUNNING member stays expanded so its live output shows'); + h.api.groupStepDone(step, false); + assert.ok(running.classList.contains('collapsed'), 'and folds once it completes successfully'); +}); + +test('closing twice is a no-op (idempotent)', () => { + const h = newHarness(); + const a = card('tl tl-cmd'), b = card('tl tl-cmd'); + h.api.groupAppend(a, { kind: 'cmd', base: 'Run one', status: 'done', card: a }); + h.api.groupAppend(b, { kind: 'cmd', base: 'Run two', status: 'done', card: b }); + h.api.closeGroup(); + const before = h.log.children.length; + h.api.closeGroup(); + assert.strictEqual(h.log.children.length, before); +}); + +// ── 4. the escape hatch ──────────────────────────────────────────────────────────────────────── +test('grouping OFF puts every card straight in the log, uncollapsed', () => { + const h = newHarness(false); + const a = card('tl tl-cmd'), b = card('tl tl-cmd'); + h.api.groupAppend(a, { kind: 'cmd', base: 'Run one', status: 'done', card: a }); + h.api.groupAppend(b, { kind: 'cmd', base: 'Run two', status: 'done', card: b }); + assert.strictEqual(h.api.curGroup, null, 'no group is ever opened'); + assert.strictEqual(h.log.children.length, 2); + assert.ok(!a.classList.contains('collapsed'), 'cards keep their normal expanded form'); +}); + +test('the model label titles the row; the raw tool text stays as the tooltip', () => { + const h = newHarness(); + h.api.addAgentLine('file', 'read src/agent.js', 'Read the runAgent call site', 'read', 'src/agent.js'); + const row = h.api.curGroup.body.children[0]; + assert.ok(/Read the runAgent call site/.test(row.innerHTML), 'row shows the model sentence'); + assert.ok(/title="read src\/agent\.js"/.test(row.innerHTML), 'raw text preserved as the tooltip'); +}); + +test('a search chip\'s quotes are escaped in the title so they cannot break the attribute', () => { + const h = newHarness(); + // agent.js sends search text as: search "postMessage" — the raw quotes must not close title="…" + h.api.addAgentLine('search', 'search "postMessage"', 'Find every postMessage call site', 'search'); + const row = h.api.curGroup.body.children[0]; + assert.ok(/title="search "postMessage""/.test(row.innerHTML), 'double quotes are entity-escaped'); + assert.ok(!/title="search "postMessage""/.test(row.innerHTML), 'no raw quote leaks into the attribute'); +}); + +// ── 5. one speaker label per turn ────────────────────────────────────────────────────────────── +// A run narrates repeatedly around its tool cards. Re-stamping "LEVELCODE AI" over every block is +// what made the transcript read as four announcements instead of one person thinking out loud. +const roleOf = (el) => (/class="role">([^<]*) { + const h = newHarness(); + h.api.add('user', 'do the thing'); + h.api.add('assistant', 'Let me look at the repo first.'); + h.api.add('assistant', 'I have enough context now.'); + h.api.add('assistant', 'Done: created the file.'); + const [u, first, second, third] = h.log.children; + assert.strictEqual(roleOf(u), 'You'); + assert.strictEqual(roleOf(first), 'LevelCode AI', 'the turn announces itself once'); + assert.strictEqual(roleOf(second), null, 'continuation carries no second label'); + assert.strictEqual(roleOf(third), null); + assert.ok(!first.classList.contains('cont') && second.classList.contains('cont'), + 'continuations are marked so CSS can tighten the gap'); +}); + +test('a new user message re-arms the label', () => { + const h = newHarness(); + h.api.add('user', 'first ask'); + h.api.add('assistant', 'working…'); + h.api.add('assistant', 'still working…'); + h.api.add('user', 'second ask'); + h.api.add('assistant', 'on it'); + assert.strictEqual(roleOf(h.log.children[4]), 'LevelCode AI', 'the next turn is labelled again'); +}); + +console.log('groupReducer: ' + n + ' tests passed'); diff --git a/extensions/levelcode-ai/test/narrativeUi.test.js b/extensions/levelcode-ai/test/narrativeUi.test.js new file mode 100644 index 0000000..87f2b7a --- /dev/null +++ b/extensions/levelcode-ai/test/narrativeUi.test.js @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Unit tests for the calm-transcript webview logic — run: node test/narrativeUi.test.js + * (docs/CALM-TRANSCRIPT.md S3). The group header is the ONLY thing the user reads while a + * collapsed group works, so its grammar functions are pinned here: tense conversion for the + * live label, the past-tense aggregate sentence, chip→step derivation, and the command label. + * Like shHighlight.test.js, the functions are extracted from the shipped chat.html — these + * tests exercise the real code, not a copy that can drift from it. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'media', 'chat.html'), 'utf8'); + +// Same slicing convention as shHighlight.test.js: functions sit at 2 spaces and close with " }". +function extract(name) { + const start = html.indexOf('function ' + name + '('); + assert.ok(start >= 0, 'chat.html no longer defines ' + name + '()'); + const end = html.indexOf('\n }', start); + assert.ok(end >= 0, 'no closing brace found for ' + name + '()'); + return html.slice(start, end + 4); +} + +const sandbox = {}; +new Function( + extract('tenseLabel') + '\n' + extract('groupAggregate') + '\n' + extract('chipStep') + '\n' + + extract('cmdBase') + '\n' + + 'this.tenseLabel = tenseLabel; this.groupAggregate = groupAggregate; this.chipStep = chipStep; this.cmdBase = cmdBase;' +).call(sandbox); +const { tenseLabel, groupAggregate, chipStep, cmdBase } = /** @type {any} */ (sandbox); + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +// ── 0. The whole webview script COMPILES — catches a syntax slip anywhere in chat.html's JS. +test('chat.html script compiles', () => { + const open = html.lastIndexOf('', open) + 1; + const end = html.indexOf('', start); + assert.ok(open >= 0 && end > start, 'no