From 3612969d00e28c3912fe58b65052934796562333 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 11:20:37 -0400 Subject: [PATCH 01/14] =?UTF-8?q?docs:=20calm-transcript=20spec=20?= =?UTF-8?q?=E2=80=94=20narrative=20voice=20+=20grouped=20activity=20(plan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CALM-TRANSCRIPT.md | 168 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/CALM-TRANSCRIPT.md diff --git a/docs/CALM-TRANSCRIPT.md b/docs/CALM-TRANSCRIPT.md new file mode 100644 index 0000000..c0ab31c --- /dev/null +++ b/docs/CALM-TRANSCRIPT.md @@ -0,0 +1,168 @@ +# 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 collapsed, expandable card whose +header shows the *live* step while running and a past-tense aggregate ("Ran 2 commands, read and +edited PLAN.md +56 −0") when done — instead of a flat scroll of chips and cards. + +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` effectively required. +- **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 extension host keeps posting the same events. +No event schema changes, no agent.js changes for grouping. `chat.html` gets a small router: +consecutive tool-ish entries (`agentTool` chips, `termRun` cards, `editApplied` cards, verify +cards) are appended 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. + +### 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; completed members are folded away — fixed +vertical footprint while running. When the group closes: header flips to the past-tense +**aggregate**: verb-category counts, same-file read+edit merged ("read and edited PLAN.md"), +summed diffstat, fallback "N steps" when the sentence would get awkward. A single-member group +renders the member bare — no group chrome. + +### D6 — Labels: derived for file ops, model-written for commands. +File ops derive labels from args + result ("Read PLAN.md", "Edited PLAN.md +56 −0"). For +commands, `explanation` becomes effectively mandatory: schema text specifies "5–10 words, active +voice, what it does — e.g. 'Find the insertion point in section 10'", and the voice rules require +it. UI falls back to the first command segment 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` MUST carry `explanation` (D6 wording). +Keep verbatim: decisiveness, words-are-not-edits, same-turn action, skills/plan/multi-root/ +autopilot blocks. Update `run_command`'s schema `explanation` description (D6). +*Test:* existing suites green (prompt text is data); manual acceptance below. + +**S2 — The group reducer (webview).** *(M)* +In `chat.html`: `openGroup()/routeToGroup(el, meta)/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). D4 rules (breakout + auto-expand-on-failure). CSS for header + +collapsed body. +*Test:* extraction-pattern unit tests for the routing decisions where practical; manual. + +**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 collapsed card** titled with the live step +while running ("Verifying the edit and checking repo state ⟳"), flipping on completion to +"Ran 2 commands, read and edited X.md +N −0" → narration continues → a `Done:` wrap-up — and +expanding the card shows the individual rows (semantic command 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) + +- Model-written labels for file reads/searches (derived-only is enough parity; revisit if reads + dominate groups). +- Group state across webview rebuilds / session restore. +- Per-model prompt forks for the voice. +- Collapsing `ask_user`/approvals into groups — deliberately excluded (D4). From 9971683c0b2df06a9eca96a3acaad830ff5b6386 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 11:22:40 -0400 Subject: [PATCH 02/14] =?UTF-8?q?feat(ai):=20calm-transcript=20S1=20?= =?UTF-8?q?=E2=80=94=20the=20narrative=20voice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SYSTEM_BASE gains the pair-working register: narrate briefly before a tool batch and interpret results after (always acting in the SAME turn — narration never replaces action, so the anti-stall rules keep their teeth), failures as findings with a one-line Correction: pattern, ask_user options ordered with the recommendation first and the why in its description, and a Done: line followed by a short wrap-up for substantial runs (the finish regex already accepts it). run_command's explanation becomes effectively mandatory (prompt rule + schema description): 5-10 imperative words that become the action's label in the activity view — the raw command stays behind the expandable card. --- extensions/levelcode-ai/agent.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/extensions/levelcode-ai/agent.js b/extensions/levelcode-ai/agent.js index 96bdc7e..03ea9dd 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,11 +31,12 @@ 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 MUST include "explanation": 5-10 words, active voice, imperative, saying what the command does ("Run the extension unit tests", "Find the insertion point in section 10"). It becomes this 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'); @@ -45,7 +48,7 @@ const TOOLS = [ { 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: 'ALWAYS provide: 5-10 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'] } }, { 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'] } } From a1ea8036a7c3cd4368b7c160527bf1ce27a10c7d Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Thu, 23 Jul 2026 11:37:13 -0400 Subject: [PATCH 03/14] =?UTF-8?q?feat(ai):=20calm=20transcript=20S2-S4=20?= =?UTF-8?q?=E2=80=94=20grouped=20activity=20in=20the=20chat=20timeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consecutive tool actions now fold into ONE collapsible card (docs/CALM-TRANSCRIPT.md). While the group is open its header shows the LIVE step in present-progressive ("Verifying the edit and checking repo state") with a fixed vertical footprint — completed members fold away instead of scrolling the log. When narration, an approval, questions, an error, or run-end closes the group, the header flips to a past-tense aggregate ("Ran 2 commands, read and edited PLAN.md +56 -0", counts merged per category, same-file read+edit folded, summed diffstat). The reducer lives entirely in the webview — the extension host posts the same events as ever. Safety rules (D4): approvals and ask_user questions render at top level, never inside a group; a failing member (nonzero exit, timeout, verify fail) marks the group failed and auto-expands it — collapse is for success; a user-stop is a choice, not a failure. A running member's Stop is delegated to a header button so a collapsed group never hides the control. Single-member groups unwrap to the bare card. Background commands that exit after their group closed re-finalize it in place. Labels: file ops derive ("Read PLAN.md", "Edit path" → tensed); commands use the model's explanation (imperative, per the S1 prompt) with the first command segment as fallback. tenseLabel converts verbs at the start and after and/then/ comma from a small irregular-verb map — no grammar engine. levelcode.ai.chat.groupActivity (default on) rides the config message; off restores the flat timeline exactly. test/narrativeUi.test.js pins the grammar via the shHighlight extraction pattern (19 tests, including compiling chat.html's whole script to catch syntax slips). Full extension gate: 19 suites green. --- extensions/levelcode-ai/extension.js | 7 +- extensions/levelcode-ai/media/chat.html | 236 +++++++++++++++++- extensions/levelcode-ai/package.json | 5 + .../levelcode-ai/test/narrativeUi.test.js | 122 +++++++++ 4 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 extensions/levelcode-ai/test/narrativeUi.test.js 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..9246221 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -282,6 +282,22 @@ .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; } + .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; } @@ -1369,6 +1385,7 @@ })(); function add(role, html){ clearEmpty(); + closeGroup(); // narration / user text ends the activity group (calm transcript) const d = document.createElement('div'); d.className = 'msg ' + role; d.innerHTML = '
' + (role === 'user' ? 'You' : 'LevelCode AI') + '
' + html + '
'; log.appendChild(d); scrollIfStuck(); return d.querySelector('.body'); @@ -1386,16 +1403,195 @@ 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' || s.kind === 'list').length; + const reads = of('read'), edits = of('edit'), creates = of('create'), dels = of('delete'); + const parts = []; + if (cmds){ parts.push('ran ' + cmds + (cmds === 1 ? ' command' : ' commands')); } + if (searches){ parts.push('searched the workspace'); } + 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 (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; anything unrecognized rides as a note. + function chipStep(icon, text){ + const t = String(text || ''); + if (icon === 'file' && /^read /.test(t)){ return { kind: 'read', base: 'Read ' + t.slice(5), path: t.slice(5) }; } + if (icon === 'search'){ return { kind: 'search', base: 'Search the workspace' }; } + if (icon === 'list-tree'){ return { kind: 'list', base: 'List files' }; } + return { kind: 'note', base: 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 collapsed'; + 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'), + 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; + } + // 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(); + 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){ + while (g.body.firstChild){ log.insertBefore(g.body.firstChild, g.el); } + g.el.remove(); return; + } + g.closed = true; + g.el.classList.remove('running'); + g.label.textContent = groupAggregate(g.steps); + g.counts.innerHTML = groupCountsHtml(g); + g.state.innerHTML = codicon(g.failed ? 'circle-slash' : 'check'); + g.state.className = 'cmdstate groupstate ' + (g.failed ? 'bad' : 'ok'); + g.stopBtn.hidden = true; + if (g.failed && !g.userToggled){ g.el.classList.remove('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; + if (g.el.isConnected && !g.userToggled){ g.el.classList.remove('collapsed'); } + } + 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){ clearEmpty(); clearStatus(); const d = document.createElement('div'); d.className = 'tl tl-tool'; d.innerHTML = '
' + (IC[icon] ? codicon(icon) : esc(icon || '•')) + '
' + '
' + esc(text || '') + '
'; - log.appendChild(d); scrollIfStuck(); + const st = chipStep(icon, text); st.status = 'done'; + 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 +1631,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); @@ -1562,7 +1759,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 }); }; } @@ -1588,6 +1788,10 @@ 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 +1812,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 }); }; } } @@ -1638,6 +1845,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,6 +1900,7 @@ } 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'; @@ -1747,10 +1959,18 @@ let wasOpen = true; 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; @@ -2024,6 +2244,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). @@ -2446,6 +2667,7 @@ 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 + if (typeof m.groupActivity === 'boolean'){ groupsOn = m.groupActivity; } // calm-transcript toggle renderRouting(); } else if (m.type === 'assistantStart'){ shown = ''; pending = ''; doneSignaled = false; flushAll = false; current = makeStream(add('assistant', '')); setStreaming(true); } @@ -2468,7 +2690,7 @@ 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 = ''; 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(); } @@ -2497,7 +2719,7 @@ 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(); } 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/narrativeUi.test.js b/extensions/levelcode-ai/test/narrativeUi.test.js new file mode 100644 index 0000000..412a1a8 --- /dev/null +++ b/extensions/levelcode-ai/test/narrativeUi.test.js @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * 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