@kolisachint/hoocode-agent 0.5.32 → 0.5.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/dist/modes/interactive/components/assistant-message.d.ts +16 -3
- package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/assistant-message.js +18 -10
- package/dist/modes/interactive/components/assistant-message.js.map +1 -1
- package/dist/modes/interactive/components/tool-chain-summary.d.ts +9 -2
- package/dist/modes/interactive/components/tool-chain-summary.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-chain-summary.js +132 -29
- package/dist/modes/interactive/components/tool-chain-summary.js.map +1 -1
- package/dist/modes/interactive/components/tool-chain.d.ts +12 -1
- package/dist/modes/interactive/components/tool-chain.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-chain.js +15 -2
- package/dist/modes/interactive/components/tool-chain.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +19 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +45 -5
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/docs/keybindings.md +5 -0
- package/docs/terminal-setup.md +51 -0
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package.json +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,70 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.34] - 2026-08-23
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Radar hides thinking traces outright rather than folding them to their
|
|
8
|
+
one-line label. Folding is right when the label sits between things you can
|
|
9
|
+
see, but a message that only thought and called tools has nothing left on
|
|
10
|
+
screen once its calls join the chain — so the label stood alone under the
|
|
11
|
+
summary and a six-call chain rendered as one row plus six `Thinking...` lines,
|
|
12
|
+
which is the noise radar exists to remove. One row per chain now means one row.
|
|
13
|
+
|
|
14
|
+
- A settled radar chain never reads a shared command prefix as a location. A
|
|
15
|
+
command is not a path however much it looks like one: several calls beginning
|
|
16
|
+
`cd /Users/me/repo && …` share a long slash-separated prefix, and naming it
|
|
17
|
+
produced `Ran cd /Users/me/repo` — a headline for the one part of the command
|
|
18
|
+
that did nothing. Only families whose subject is a path reduce to a shared
|
|
19
|
+
location now; a command's leading `cd … &&` is stripped so the phrase names the
|
|
20
|
+
act, and a run that did the same thing every time reports that thing
|
|
21
|
+
(`Ran bun run check`) rather than a count.
|
|
22
|
+
|
|
23
|
+
## [0.5.33] - 2026-08-23
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Radar keeps a running chain on screen. Thinking traces now fold to their
|
|
28
|
+
one-line label in radar, the way tool bodies already do. A chain stays open
|
|
29
|
+
across a thinking block, so an unfolded trace streaming in below it pushed the
|
|
30
|
+
chain's own summary line above the viewport — where every subsequent call
|
|
31
|
+
rewrote a line the terminal had already scrolled away, forcing the full redraw
|
|
32
|
+
that clears scrollback.
|
|
33
|
+
|
|
34
|
+
- A settled radar chain no longer takes its name from an act incidental to it.
|
|
35
|
+
From 10 calls up, a tool family covering less than a tenth of the run cannot be
|
|
36
|
+
the headline: 28 searches and 37 reads ending in a single edit now reads as
|
|
37
|
+
`Read packages/coding-agent/src` rather than `Edited subagent.ts`, which
|
|
38
|
+
described 1 call out of 66. Chains below 10 calls are unchanged.
|
|
39
|
+
|
|
40
|
+
- A settled chain's location keeps the leading `/` on absolute paths.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- A long radar chain whose headline family is a minority of its calls now names
|
|
45
|
+
the largest thing it left out — `Edited packages/coding-agent · 76 commands`.
|
|
46
|
+
Capped at one extra clause: a chain reporting every family it touched has said
|
|
47
|
+
only that it was busy.
|
|
48
|
+
|
|
49
|
+
- Across enough files a shared path prefix collapses to something as broad as
|
|
50
|
+
`packages`, which is worse than the count it displaced; from 10 calls up such a
|
|
51
|
+
location is dropped in favour of the file count. Short chains keep theirs, so
|
|
52
|
+
`Edited docs` still reads as before.
|
|
53
|
+
|
|
54
|
+
- A radar chain of a single call keeps its signal row instead of collapsing to a
|
|
55
|
+
phrase. `Ran npm run check` dropped both the tool and how much came back;
|
|
56
|
+
`bash npm run check 12 lines` does not.
|
|
57
|
+
|
|
58
|
+
### Documentation
|
|
59
|
+
|
|
60
|
+
- `terminal-setup.md` documents what `alt` costs. 37 actions are reachable only
|
|
61
|
+
through it, and outside the Kitty keyboard protocol macOS treats `Option` as a
|
|
62
|
+
compose key — `Option+M` types `µ`, and `Option+E`/`U`/`N` are dead keys that
|
|
63
|
+
send nothing — while some Linux terminals claim `Alt+<letter>` for menu
|
|
64
|
+
mnemonics. Added the per-terminal setting for Terminal.app, iTerm2, Ghostty,
|
|
65
|
+
Kitty and VS Code, and a note that interrupt, clear, exit and expand are kept
|
|
66
|
+
off `alt` so a misconfigured terminal stays recoverable.
|
|
67
|
+
|
|
3
68
|
## [0.5.32] - 2026-08-23
|
|
4
69
|
|
|
5
70
|
### Breaking Changes
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import type { AssistantMessage } from "@kolisachint/hoocode-ai";
|
|
2
2
|
import { Container, type MarkdownTheme } from "@kolisachint/hoocode-tui";
|
|
3
|
+
/**
|
|
4
|
+
* How a thinking block renders.
|
|
5
|
+
*
|
|
6
|
+
* `omit` exists for radar. Folding a trace to its one-line label is right when
|
|
7
|
+
* the label sits between things you can see; under a chain summary it is not,
|
|
8
|
+
* because a message whose only content is thinking plus tool calls has nothing
|
|
9
|
+
* left on screen once its calls collapse into the chain's row. A stack of
|
|
10
|
+
* `Thinking...` lines under a one-line summary is precisely the noise radar
|
|
11
|
+
* exists to remove.
|
|
12
|
+
*/
|
|
13
|
+
export type ThinkingDisplay = "full" | "label" | "omit";
|
|
3
14
|
/**
|
|
4
15
|
* Split markdown into chunks at blank-line boundaries where each chunk lexes
|
|
5
16
|
* independently to the same blocks the whole text would. Prefix-stable:
|
|
@@ -14,13 +25,13 @@ export declare function segmentStreamingMarkdown(text: string): string[];
|
|
|
14
25
|
*/
|
|
15
26
|
export declare class AssistantMessageComponent extends Container {
|
|
16
27
|
private contentContainer;
|
|
17
|
-
private
|
|
28
|
+
private thinkingDisplay;
|
|
18
29
|
private markdownTheme;
|
|
19
30
|
private hiddenThinkingLabel;
|
|
20
31
|
private lastMessage?;
|
|
21
32
|
private hasToolCalls;
|
|
22
33
|
private markdownCache;
|
|
23
|
-
constructor(message?: AssistantMessage,
|
|
34
|
+
constructor(message?: AssistantMessage, thinkingDisplay?: ThinkingDisplay, markdownTheme?: MarkdownTheme, hiddenThinkingLabel?: string);
|
|
24
35
|
invalidate(): void;
|
|
25
36
|
private reuseMarkdown;
|
|
26
37
|
/** Set when the current children include streaming segments; the next
|
|
@@ -34,7 +45,9 @@ export declare class AssistantMessageComponent extends Container {
|
|
|
34
45
|
* segmentation artifact is transient by construction.
|
|
35
46
|
*/
|
|
36
47
|
private addMarkdownBlock;
|
|
37
|
-
|
|
48
|
+
setThinkingDisplay(display: ThinkingDisplay): void;
|
|
49
|
+
/** Whether a content block puts anything on screen under the current settings. */
|
|
50
|
+
private isVisibleBlock;
|
|
38
51
|
setHiddenThinkingLabel(label: string): void;
|
|
39
52
|
/** OSC-zone wrap memo: Container.render returns the same array across
|
|
40
53
|
* frames when nothing changed, so it must not be mutated — the wrapped
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"assistant-message.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/assistant-message.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,EAAE,SAAS,EAAmC,KAAK,aAAa,EAAgB,MAAM,0BAA0B,CAAC;AAkCxH;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAiC/D;AAED;;GAEG;AACH,qBAAa,yBAA0B,SAAQ,SAAS;IACvD,OAAO,CAAC,gBAAgB,CAAY;IACpC,OAAO,CAAC,iBAAiB,CAAU;IACnC,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,mBAAmB,CAAS;IACpC,OAAO,CAAC,WAAW,CAAC,CAAmB;IACvC,OAAO,CAAC,YAAY,CAAS;IAM7B,OAAO,CAAC,aAAa,CAAqD;IAE1E,YACC,OAAO,CAAC,EAAE,gBAAgB,EAC1B,iBAAiB,UAAQ,EACzB,aAAa,GAAE,aAAkC,EACjD,mBAAmB,SAAgB,EAenC;IAEQ,UAAU,IAAI,IAAI,CAW1B;IAED,OAAO,CAAC,aAAa;IAcrB;0EACsE;IACtE,OAAO,CAAC,kBAAkB,CAAS;IAEnC;;;;;;OAMG;IACH,OAAO,CAAC,gBAAgB;IAexB,oBAAoB,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,CAKxC;IAED,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAK1C;IAED;;8DAE0D;IAC1D,OAAO,CAAC,QAAQ,CAAC,CAAmC;IAE3C,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAcvC;IAED,aAAa,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,UAAQ,GAAG,IAAI,CAgFhE;CACD","sourcesContent":["import type { AssistantMessage } from \"@kolisachint/hoocode-ai\";\nimport { Container, type DefaultTextStyle, Markdown, type MarkdownTheme, Spacer, Text } from \"@kolisachint/hoocode-tui\";\nimport { getMarkdownTheme, theme } from \"../theme/theme.js\";\n\nconst OSC133_ZONE_START = \"\\x1b]133;A\\x07\";\nconst OSC133_ZONE_END = \"\\x1b]133;B\\x07\";\nconst OSC133_ZONE_FINAL = \"\\x1b]133;C\\x07\";\n\n// Streaming messages below this size render as a single Markdown; above it the\n// text is segmented at stable block boundaries so each throttle tick re-parses\n// only the growing tail instead of the whole accumulated message.\nconst SEGMENT_MIN_CHARS = 2048;\n\nconst LIST_ITEM_RE = /^ {0,3}(?:[-*+] |\\d{1,9}[.)] )/;\nconst FENCE_RE = /^ {0,3}(?:```|~~~)/;\nconst LINK_DEF_RE = /^ {0,3}\\[[^\\]]+\\]: /m;\nconst SETEXT_UNDERLINE_RE = /^ {0,3}(?:=+|-+)\\s*$/;\n\n/** True when a blank-line gap between `prev` and `next` (both non-blank) is a\n * safe place to cut the markdown into independently-parseable chunks. */\nfunction isSafeBoundary(prev: string, next: string): boolean {\n\t// Indented continuation (loose list item body, indented code) binds to the\n\t// block above the gap.\n\tif (/^\\s/.test(next)) return false;\n\t// A blank line between two list items is a loose list, not two lists.\n\tif (LIST_ITEM_RE.test(prev) && LIST_ITEM_RE.test(next)) return false;\n\t// Tables and raw HTML blocks can span blank lines in surprising ways.\n\tif (next.startsWith(\"|\") || prev.trimStart().startsWith(\"|\")) return false;\n\tif (next.startsWith(\"<\") || prev.trimStart().startsWith(\"<\")) return false;\n\t// A bare ===/--- line after the gap could lex as setext underline or hr\n\t// differently without its preceding text; keep it attached.\n\tif (SETEXT_UNDERLINE_RE.test(next)) return false;\n\treturn true;\n}\n\n/**\n * Split markdown into chunks at blank-line boundaries where each chunk lexes\n * independently to the same blocks the whole text would. Prefix-stable:\n * appending text never changes earlier boundaries (decisions depend only on\n * preceding fence parity and the lines adjacent to each gap), so during\n * streaming every chunk except the last is byte-identical across updates and\n * its Markdown render cache keeps hitting. Exported for tests.\n */\nexport function segmentStreamingMarkdown(text: string): string[] {\n\t// Reference-style link/footnote definitions resolve across the whole\n\t// document; segmenting would break lookups from other chunks.\n\tif (LINK_DEF_RE.test(text)) return [text];\n\tconst lines = text.split(\"\\n\");\n\tconst chunks: string[] = [];\n\tlet chunkStart = 0;\n\tlet fenceOpen = false;\n\tlet prevNonblank = \"\";\n\tlet i = 0;\n\twhile (i < lines.length) {\n\t\tconst line = lines[i];\n\t\tif (FENCE_RE.test(line)) fenceOpen = !fenceOpen;\n\t\tif (line.trim() === \"\" && !fenceOpen) {\n\t\t\tlet j = i + 1;\n\t\t\twhile (j < lines.length && lines[j].trim() === \"\") j++;\n\t\t\t// Only cut when the gap has content on both sides; a trailing blank\n\t\t\t// run stays attached so the decision never has to be revisited.\n\t\t\tif (j < lines.length && chunkStart < i && isSafeBoundary(prevNonblank, lines[j])) {\n\t\t\t\tchunks.push(lines.slice(chunkStart, i).join(\"\\n\"));\n\t\t\t\tchunkStart = j;\n\t\t\t}\n\t\t\ti = j;\n\t\t\tcontinue;\n\t\t}\n\t\tif (line.trim() !== \"\") prevNonblank = line;\n\t\ti++;\n\t}\n\tconst last = lines.slice(chunkStart).join(\"\\n\");\n\tif (last.trim() !== \"\") {\n\t\tchunks.push(last);\n\t}\n\treturn chunks.length > 0 ? chunks : [text];\n}\n\n/**\n * Component that renders a complete assistant message\n */\nexport class AssistantMessageComponent extends Container {\n\tprivate contentContainer: Container;\n\tprivate hideThinkingBlock: boolean;\n\tprivate markdownTheme: MarkdownTheme;\n\tprivate hiddenThinkingLabel: string;\n\tprivate lastMessage?: AssistantMessage;\n\tprivate hasToolCalls = false;\n\t// Markdown children reused across streaming updates, keyed by content index +\n\t// kind. Markdown caches its rendered lines by (text, width); recreating the\n\t// instances on every streamed delta discarded those caches and re-parsed the\n\t// entire message (thinking trace included) per frame. Reuse keeps finished\n\t// blocks cached so only the block whose text actually changed re-parses.\n\tprivate markdownCache = new Map<string, { md: Markdown; text: string }>();\n\n\tconstructor(\n\t\tmessage?: AssistantMessage,\n\t\thideThinkingBlock = false,\n\t\tmarkdownTheme: MarkdownTheme = getMarkdownTheme(),\n\t\thiddenThinkingLabel = \"Thinking...\",\n\t) {\n\t\tsuper();\n\n\t\tthis.hideThinkingBlock = hideThinkingBlock;\n\t\tthis.markdownTheme = markdownTheme;\n\t\tthis.hiddenThinkingLabel = hiddenThinkingLabel;\n\n\t\t// Container for text/thinking content\n\t\tthis.contentContainer = new Container();\n\t\tthis.addChild(this.contentContainer);\n\n\t\tif (message) {\n\t\t\tthis.updateContent(message);\n\t\t}\n\t}\n\n\toverride invalidate(): void {\n\t\t// Cached Markdown blocks may be detached right now (e.g. hidden thinking);\n\t\t// drop their render caches too so a theme/width change can't resurface\n\t\t// stale styling when they re-attach.\n\t\tfor (const { md } of this.markdownCache.values()) {\n\t\t\tmd.invalidate();\n\t\t}\n\t\tsuper.invalidate();\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\tprivate reuseMarkdown(key: string, text: string, style?: DefaultTextStyle): Markdown {\n\t\tconst entry = this.markdownCache.get(key);\n\t\tif (entry) {\n\t\t\tif (entry.text !== text) {\n\t\t\t\tentry.md.setText(text);\n\t\t\t\tentry.text = text;\n\t\t\t}\n\t\t\treturn entry.md;\n\t\t}\n\t\tconst md = new Markdown(text, 1, 0, this.markdownTheme, style);\n\t\tthis.markdownCache.set(key, { md, text });\n\t\treturn md;\n\t}\n\n\t/** Set when the current children include streaming segments; the next\n\t * non-streaming (final/rebuild) render purges their cache entries. */\n\tprivate hasSegmentedBlocks = false;\n\n\t/**\n\t * Add one markdown content block. Large blocks still being streamed are\n\t * segmented at stable boundaries so only the tail chunk re-parses per\n\t * update; every earlier chunk is byte-stable and stays cached. The final\n\t * (non-streaming) render collapses back to one canonical Markdown, so any\n\t * segmentation artifact is transient by construction.\n\t */\n\tprivate addMarkdownBlock(keyBase: string, text: string, streaming: boolean, style?: DefaultTextStyle): void {\n\t\tif (streaming && text.length >= SEGMENT_MIN_CHARS) {\n\t\t\tconst chunks = segmentStreamingMarkdown(text);\n\t\t\tif (chunks.length > 1) {\n\t\t\t\tfor (let k = 0; k < chunks.length; k++) {\n\t\t\t\t\tif (k > 0) this.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\tthis.contentContainer.addChild(this.reuseMarkdown(`${keyBase}:seg:${k}`, chunks[k], style));\n\t\t\t\t}\n\t\t\t\tthis.hasSegmentedBlocks = true;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tthis.contentContainer.addChild(this.reuseMarkdown(keyBase, text, style));\n\t}\n\n\tsetHideThinkingBlock(hide: boolean): void {\n\t\tthis.hideThinkingBlock = hide;\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\tsetHiddenThinkingLabel(label: string): void {\n\t\tthis.hiddenThinkingLabel = label;\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\t/** OSC-zone wrap memo: Container.render returns the same array across\n\t * frames when nothing changed, so it must not be mutated — the wrapped\n\t * copy is cached keyed on the source array's identity. */\n\tprivate zoneMemo?: { src: string[]; out: string[] };\n\n\toverride render(width: number): string[] {\n\t\tconst lines = super.render(width);\n\t\tif (this.hasToolCalls || lines.length === 0) {\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.zoneMemo?.src === lines) {\n\t\t\treturn this.zoneMemo.out;\n\t\t}\n\t\tconst out = lines.slice();\n\t\tout[0] = OSC133_ZONE_START + out[0];\n\t\tout[out.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + out[out.length - 1];\n\t\tthis.zoneMemo = { src: lines, out };\n\t\treturn out;\n\t}\n\n\tupdateContent(message: AssistantMessage, streaming = false): void {\n\t\tthis.lastMessage = message;\n\n\t\t// A final/rebuild render replaces streaming segments with the canonical\n\t\t// single-Markdown form; drop the segment cache entries they used.\n\t\tif (!streaming && this.hasSegmentedBlocks) {\n\t\t\tfor (const key of this.markdownCache.keys()) {\n\t\t\t\tif (key.includes(\":seg:\")) this.markdownCache.delete(key);\n\t\t\t}\n\t\t\tthis.hasSegmentedBlocks = false;\n\t\t}\n\n\t\t// Clear content container\n\t\tthis.contentContainer.clear();\n\n\t\tconst hasVisibleContent = message.content.some(\n\t\t\t(c) => (c.type === \"text\" && c.text.trim()) || (c.type === \"thinking\" && c.thinking.trim()),\n\t\t);\n\n\t\tif (hasVisibleContent) {\n\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t}\n\n\t\t// Render content in order\n\t\tfor (let i = 0; i < message.content.length; i++) {\n\t\t\tconst content = message.content[i];\n\t\t\tif (content.type === \"text\" && content.text.trim()) {\n\t\t\t\t// Assistant text messages with no background - trim the text\n\t\t\t\t// Set paddingY=0 to avoid extra spacing before tool executions\n\t\t\t\tthis.addMarkdownBlock(`${i}:text`, content.text.trim(), streaming);\n\t\t\t} else if (content.type === \"thinking\" && content.thinking.trim()) {\n\t\t\t\t// Add spacing only when another visible assistant content block follows.\n\t\t\t\t// This avoids a superfluous blank line before separately-rendered tool execution blocks.\n\t\t\t\tconst hasVisibleContentAfter = message.content\n\t\t\t\t\t.slice(i + 1)\n\t\t\t\t\t.some((c) => (c.type === \"text\" && c.text.trim()) || (c.type === \"thinking\" && c.thinking.trim()));\n\n\t\t\t\tif (this.hideThinkingBlock) {\n\t\t\t\t\t// Show static thinking label when hidden\n\t\t\t\t\tthis.contentContainer.addChild(\n\t\t\t\t\t\tnew Text(theme.italic(theme.fg(\"thinkingText\", this.hiddenThinkingLabel)), 1, 0),\n\t\t\t\t\t);\n\t\t\t\t\tif (hasVisibleContentAfter) {\n\t\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Thinking traces in thinkingText color, italic, with ✻ prefix\n\t\t\t\t\tthis.addMarkdownBlock(`${i}:thinking`, `✻ ${content.thinking.trim()}`, streaming, {\n\t\t\t\t\t\tcolor: (text: string) => theme.fg(\"thinkingText\", text),\n\t\t\t\t\t\titalic: true,\n\t\t\t\t\t});\n\t\t\t\t\tif (hasVisibleContentAfter) {\n\t\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Check if aborted - show after partial content\n\t\t// But only if there are no tool calls (tool execution components will show the error)\n\t\tconst hasToolCalls = message.content.some((c) => c.type === \"toolCall\");\n\t\tthis.hasToolCalls = hasToolCalls;\n\t\tif (!hasToolCalls) {\n\t\t\tif (message.stopReason === \"aborted\") {\n\t\t\t\tconst abortMessage =\n\t\t\t\t\tmessage.errorMessage && message.errorMessage !== \"Request was aborted\"\n\t\t\t\t\t\t? message.errorMessage\n\t\t\t\t\t\t: \"Operation aborted\";\n\t\t\t\tif (hasVisibleContent) {\n\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t} else {\n\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t}\n\t\t\t\tthis.contentContainer.addChild(new Text(theme.fg(\"error\", abortMessage), 1, 0));\n\t\t\t} else if (message.stopReason === \"error\") {\n\t\t\t\tconst errorMsg = message.errorMessage || \"Unknown error\";\n\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\tthis.contentContainer.addChild(new Text(theme.fg(\"error\", `Error: ${errorMsg}`), 1, 0));\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"assistant-message.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/assistant-message.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,EAAE,SAAS,EAAmC,KAAK,aAAa,EAAgB,MAAM,0BAA0B,CAAC;AAGxH;;;;;;;;;GASG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;AAiCxD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAiC/D;AAED;;GAEG;AACH,qBAAa,yBAA0B,SAAQ,SAAS;IACvD,OAAO,CAAC,gBAAgB,CAAY;IACpC,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,mBAAmB,CAAS;IACpC,OAAO,CAAC,WAAW,CAAC,CAAmB;IACvC,OAAO,CAAC,YAAY,CAAS;IAM7B,OAAO,CAAC,aAAa,CAAqD;IAE1E,YACC,OAAO,CAAC,EAAE,gBAAgB,EAC1B,eAAe,GAAE,eAAwB,EACzC,aAAa,GAAE,aAAkC,EACjD,mBAAmB,SAAgB,EAenC;IAEQ,UAAU,IAAI,IAAI,CAW1B;IAED,OAAO,CAAC,aAAa;IAcrB;0EACsE;IACtE,OAAO,CAAC,kBAAkB,CAAS;IAEnC;;;;;;OAMG;IACH,OAAO,CAAC,gBAAgB;IAexB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI,CAKjD;IAED,kFAAkF;IAClF,OAAO,CAAC,cAAc;IAMtB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAK1C;IAED;;8DAE0D;IAC1D,OAAO,CAAC,QAAQ,CAAC,CAAmC;IAE3C,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAcvC;IAED,aAAa,CAAC,OAAO,EAAE,gBAAgB,EAAE,SAAS,UAAQ,GAAG,IAAI,CA8EhE;CACD","sourcesContent":["import type { AssistantMessage } from \"@kolisachint/hoocode-ai\";\nimport { Container, type DefaultTextStyle, Markdown, type MarkdownTheme, Spacer, Text } from \"@kolisachint/hoocode-tui\";\nimport { getMarkdownTheme, theme } from \"../theme/theme.js\";\n\n/**\n * How a thinking block renders.\n *\n * `omit` exists for radar. Folding a trace to its one-line label is right when\n * the label sits between things you can see; under a chain summary it is not,\n * because a message whose only content is thinking plus tool calls has nothing\n * left on screen once its calls collapse into the chain's row. A stack of\n * `Thinking...` lines under a one-line summary is precisely the noise radar\n * exists to remove.\n */\nexport type ThinkingDisplay = \"full\" | \"label\" | \"omit\";\n\nconst OSC133_ZONE_START = \"\\x1b]133;A\\x07\";\nconst OSC133_ZONE_END = \"\\x1b]133;B\\x07\";\nconst OSC133_ZONE_FINAL = \"\\x1b]133;C\\x07\";\n\n// Streaming messages below this size render as a single Markdown; above it the\n// text is segmented at stable block boundaries so each throttle tick re-parses\n// only the growing tail instead of the whole accumulated message.\nconst SEGMENT_MIN_CHARS = 2048;\n\nconst LIST_ITEM_RE = /^ {0,3}(?:[-*+] |\\d{1,9}[.)] )/;\nconst FENCE_RE = /^ {0,3}(?:```|~~~)/;\nconst LINK_DEF_RE = /^ {0,3}\\[[^\\]]+\\]: /m;\nconst SETEXT_UNDERLINE_RE = /^ {0,3}(?:=+|-+)\\s*$/;\n\n/** True when a blank-line gap between `prev` and `next` (both non-blank) is a\n * safe place to cut the markdown into independently-parseable chunks. */\nfunction isSafeBoundary(prev: string, next: string): boolean {\n\t// Indented continuation (loose list item body, indented code) binds to the\n\t// block above the gap.\n\tif (/^\\s/.test(next)) return false;\n\t// A blank line between two list items is a loose list, not two lists.\n\tif (LIST_ITEM_RE.test(prev) && LIST_ITEM_RE.test(next)) return false;\n\t// Tables and raw HTML blocks can span blank lines in surprising ways.\n\tif (next.startsWith(\"|\") || prev.trimStart().startsWith(\"|\")) return false;\n\tif (next.startsWith(\"<\") || prev.trimStart().startsWith(\"<\")) return false;\n\t// A bare ===/--- line after the gap could lex as setext underline or hr\n\t// differently without its preceding text; keep it attached.\n\tif (SETEXT_UNDERLINE_RE.test(next)) return false;\n\treturn true;\n}\n\n/**\n * Split markdown into chunks at blank-line boundaries where each chunk lexes\n * independently to the same blocks the whole text would. Prefix-stable:\n * appending text never changes earlier boundaries (decisions depend only on\n * preceding fence parity and the lines adjacent to each gap), so during\n * streaming every chunk except the last is byte-identical across updates and\n * its Markdown render cache keeps hitting. Exported for tests.\n */\nexport function segmentStreamingMarkdown(text: string): string[] {\n\t// Reference-style link/footnote definitions resolve across the whole\n\t// document; segmenting would break lookups from other chunks.\n\tif (LINK_DEF_RE.test(text)) return [text];\n\tconst lines = text.split(\"\\n\");\n\tconst chunks: string[] = [];\n\tlet chunkStart = 0;\n\tlet fenceOpen = false;\n\tlet prevNonblank = \"\";\n\tlet i = 0;\n\twhile (i < lines.length) {\n\t\tconst line = lines[i];\n\t\tif (FENCE_RE.test(line)) fenceOpen = !fenceOpen;\n\t\tif (line.trim() === \"\" && !fenceOpen) {\n\t\t\tlet j = i + 1;\n\t\t\twhile (j < lines.length && lines[j].trim() === \"\") j++;\n\t\t\t// Only cut when the gap has content on both sides; a trailing blank\n\t\t\t// run stays attached so the decision never has to be revisited.\n\t\t\tif (j < lines.length && chunkStart < i && isSafeBoundary(prevNonblank, lines[j])) {\n\t\t\t\tchunks.push(lines.slice(chunkStart, i).join(\"\\n\"));\n\t\t\t\tchunkStart = j;\n\t\t\t}\n\t\t\ti = j;\n\t\t\tcontinue;\n\t\t}\n\t\tif (line.trim() !== \"\") prevNonblank = line;\n\t\ti++;\n\t}\n\tconst last = lines.slice(chunkStart).join(\"\\n\");\n\tif (last.trim() !== \"\") {\n\t\tchunks.push(last);\n\t}\n\treturn chunks.length > 0 ? chunks : [text];\n}\n\n/**\n * Component that renders a complete assistant message\n */\nexport class AssistantMessageComponent extends Container {\n\tprivate contentContainer: Container;\n\tprivate thinkingDisplay: ThinkingDisplay;\n\tprivate markdownTheme: MarkdownTheme;\n\tprivate hiddenThinkingLabel: string;\n\tprivate lastMessage?: AssistantMessage;\n\tprivate hasToolCalls = false;\n\t// Markdown children reused across streaming updates, keyed by content index +\n\t// kind. Markdown caches its rendered lines by (text, width); recreating the\n\t// instances on every streamed delta discarded those caches and re-parsed the\n\t// entire message (thinking trace included) per frame. Reuse keeps finished\n\t// blocks cached so only the block whose text actually changed re-parses.\n\tprivate markdownCache = new Map<string, { md: Markdown; text: string }>();\n\n\tconstructor(\n\t\tmessage?: AssistantMessage,\n\t\tthinkingDisplay: ThinkingDisplay = \"full\",\n\t\tmarkdownTheme: MarkdownTheme = getMarkdownTheme(),\n\t\thiddenThinkingLabel = \"Thinking...\",\n\t) {\n\t\tsuper();\n\n\t\tthis.thinkingDisplay = thinkingDisplay;\n\t\tthis.markdownTheme = markdownTheme;\n\t\tthis.hiddenThinkingLabel = hiddenThinkingLabel;\n\n\t\t// Container for text/thinking content\n\t\tthis.contentContainer = new Container();\n\t\tthis.addChild(this.contentContainer);\n\n\t\tif (message) {\n\t\t\tthis.updateContent(message);\n\t\t}\n\t}\n\n\toverride invalidate(): void {\n\t\t// Cached Markdown blocks may be detached right now (e.g. hidden thinking);\n\t\t// drop their render caches too so a theme/width change can't resurface\n\t\t// stale styling when they re-attach.\n\t\tfor (const { md } of this.markdownCache.values()) {\n\t\t\tmd.invalidate();\n\t\t}\n\t\tsuper.invalidate();\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\tprivate reuseMarkdown(key: string, text: string, style?: DefaultTextStyle): Markdown {\n\t\tconst entry = this.markdownCache.get(key);\n\t\tif (entry) {\n\t\t\tif (entry.text !== text) {\n\t\t\t\tentry.md.setText(text);\n\t\t\t\tentry.text = text;\n\t\t\t}\n\t\t\treturn entry.md;\n\t\t}\n\t\tconst md = new Markdown(text, 1, 0, this.markdownTheme, style);\n\t\tthis.markdownCache.set(key, { md, text });\n\t\treturn md;\n\t}\n\n\t/** Set when the current children include streaming segments; the next\n\t * non-streaming (final/rebuild) render purges their cache entries. */\n\tprivate hasSegmentedBlocks = false;\n\n\t/**\n\t * Add one markdown content block. Large blocks still being streamed are\n\t * segmented at stable boundaries so only the tail chunk re-parses per\n\t * update; every earlier chunk is byte-stable and stays cached. The final\n\t * (non-streaming) render collapses back to one canonical Markdown, so any\n\t * segmentation artifact is transient by construction.\n\t */\n\tprivate addMarkdownBlock(keyBase: string, text: string, streaming: boolean, style?: DefaultTextStyle): void {\n\t\tif (streaming && text.length >= SEGMENT_MIN_CHARS) {\n\t\t\tconst chunks = segmentStreamingMarkdown(text);\n\t\t\tif (chunks.length > 1) {\n\t\t\t\tfor (let k = 0; k < chunks.length; k++) {\n\t\t\t\t\tif (k > 0) this.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\tthis.contentContainer.addChild(this.reuseMarkdown(`${keyBase}:seg:${k}`, chunks[k], style));\n\t\t\t\t}\n\t\t\t\tthis.hasSegmentedBlocks = true;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tthis.contentContainer.addChild(this.reuseMarkdown(keyBase, text, style));\n\t}\n\n\tsetThinkingDisplay(display: ThinkingDisplay): void {\n\t\tthis.thinkingDisplay = display;\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\t/** Whether a content block puts anything on screen under the current settings. */\n\tprivate isVisibleBlock(content: AssistantMessage[\"content\"][number]): boolean {\n\t\tif (content.type === \"text\") return content.text.trim() !== \"\";\n\t\tif (content.type === \"thinking\") return this.thinkingDisplay !== \"omit\" && content.thinking.trim() !== \"\";\n\t\treturn false;\n\t}\n\n\tsetHiddenThinkingLabel(label: string): void {\n\t\tthis.hiddenThinkingLabel = label;\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\t/** OSC-zone wrap memo: Container.render returns the same array across\n\t * frames when nothing changed, so it must not be mutated — the wrapped\n\t * copy is cached keyed on the source array's identity. */\n\tprivate zoneMemo?: { src: string[]; out: string[] };\n\n\toverride render(width: number): string[] {\n\t\tconst lines = super.render(width);\n\t\tif (this.hasToolCalls || lines.length === 0) {\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.zoneMemo?.src === lines) {\n\t\t\treturn this.zoneMemo.out;\n\t\t}\n\t\tconst out = lines.slice();\n\t\tout[0] = OSC133_ZONE_START + out[0];\n\t\tout[out.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + out[out.length - 1];\n\t\tthis.zoneMemo = { src: lines, out };\n\t\treturn out;\n\t}\n\n\tupdateContent(message: AssistantMessage, streaming = false): void {\n\t\tthis.lastMessage = message;\n\n\t\t// A final/rebuild render replaces streaming segments with the canonical\n\t\t// single-Markdown form; drop the segment cache entries they used.\n\t\tif (!streaming && this.hasSegmentedBlocks) {\n\t\t\tfor (const key of this.markdownCache.keys()) {\n\t\t\t\tif (key.includes(\":seg:\")) this.markdownCache.delete(key);\n\t\t\t}\n\t\t\tthis.hasSegmentedBlocks = false;\n\t\t}\n\n\t\t// Clear content container\n\t\tthis.contentContainer.clear();\n\n\t\tconst hasVisibleContent = message.content.some((c) => this.isVisibleBlock(c));\n\n\t\tif (hasVisibleContent) {\n\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t}\n\n\t\t// Render content in order\n\t\tfor (let i = 0; i < message.content.length; i++) {\n\t\t\tconst content = message.content[i];\n\t\t\tif (content.type === \"text\" && content.text.trim()) {\n\t\t\t\t// Assistant text messages with no background - trim the text\n\t\t\t\t// Set paddingY=0 to avoid extra spacing before tool executions\n\t\t\t\tthis.addMarkdownBlock(`${i}:text`, content.text.trim(), streaming);\n\t\t\t} else if (content.type === \"thinking\" && content.thinking.trim()) {\n\t\t\t\tif (this.thinkingDisplay === \"omit\") continue;\n\n\t\t\t\t// Add spacing only when another visible assistant content block follows.\n\t\t\t\t// This avoids a superfluous blank line before separately-rendered tool execution blocks.\n\t\t\t\tconst hasVisibleContentAfter = message.content.slice(i + 1).some((c) => this.isVisibleBlock(c));\n\n\t\t\t\tif (this.thinkingDisplay === \"label\") {\n\t\t\t\t\t// Show static thinking label when hidden\n\t\t\t\t\tthis.contentContainer.addChild(\n\t\t\t\t\t\tnew Text(theme.italic(theme.fg(\"thinkingText\", this.hiddenThinkingLabel)), 1, 0),\n\t\t\t\t\t);\n\t\t\t\t\tif (hasVisibleContentAfter) {\n\t\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Thinking traces in thinkingText color, italic, with ✻ prefix\n\t\t\t\t\tthis.addMarkdownBlock(`${i}:thinking`, `✻ ${content.thinking.trim()}`, streaming, {\n\t\t\t\t\t\tcolor: (text: string) => theme.fg(\"thinkingText\", text),\n\t\t\t\t\t\titalic: true,\n\t\t\t\t\t});\n\t\t\t\t\tif (hasVisibleContentAfter) {\n\t\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Check if aborted - show after partial content\n\t\t// But only if there are no tool calls (tool execution components will show the error)\n\t\tconst hasToolCalls = message.content.some((c) => c.type === \"toolCall\");\n\t\tthis.hasToolCalls = hasToolCalls;\n\t\tif (!hasToolCalls) {\n\t\t\tif (message.stopReason === \"aborted\") {\n\t\t\t\tconst abortMessage =\n\t\t\t\t\tmessage.errorMessage && message.errorMessage !== \"Request was aborted\"\n\t\t\t\t\t\t? message.errorMessage\n\t\t\t\t\t\t: \"Operation aborted\";\n\t\t\t\tif (hasVisibleContent) {\n\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t} else {\n\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t}\n\t\t\t\tthis.contentContainer.addChild(new Text(theme.fg(\"error\", abortMessage), 1, 0));\n\t\t\t} else if (message.stopReason === \"error\") {\n\t\t\t\tconst errorMsg = message.errorMessage || \"Unknown error\";\n\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\tthis.contentContainer.addChild(new Text(theme.fg(\"error\", `Error: ${errorMsg}`), 1, 0));\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
|
|
@@ -83,7 +83,7 @@ export function segmentStreamingMarkdown(text) {
|
|
|
83
83
|
*/
|
|
84
84
|
export class AssistantMessageComponent extends Container {
|
|
85
85
|
contentContainer;
|
|
86
|
-
|
|
86
|
+
thinkingDisplay;
|
|
87
87
|
markdownTheme;
|
|
88
88
|
hiddenThinkingLabel;
|
|
89
89
|
lastMessage;
|
|
@@ -94,9 +94,9 @@ export class AssistantMessageComponent extends Container {
|
|
|
94
94
|
// entire message (thinking trace included) per frame. Reuse keeps finished
|
|
95
95
|
// blocks cached so only the block whose text actually changed re-parses.
|
|
96
96
|
markdownCache = new Map();
|
|
97
|
-
constructor(message,
|
|
97
|
+
constructor(message, thinkingDisplay = "full", markdownTheme = getMarkdownTheme(), hiddenThinkingLabel = "Thinking...") {
|
|
98
98
|
super();
|
|
99
|
-
this.
|
|
99
|
+
this.thinkingDisplay = thinkingDisplay;
|
|
100
100
|
this.markdownTheme = markdownTheme;
|
|
101
101
|
this.hiddenThinkingLabel = hiddenThinkingLabel;
|
|
102
102
|
// Container for text/thinking content
|
|
@@ -156,12 +156,20 @@ export class AssistantMessageComponent extends Container {
|
|
|
156
156
|
}
|
|
157
157
|
this.contentContainer.addChild(this.reuseMarkdown(keyBase, text, style));
|
|
158
158
|
}
|
|
159
|
-
|
|
160
|
-
this.
|
|
159
|
+
setThinkingDisplay(display) {
|
|
160
|
+
this.thinkingDisplay = display;
|
|
161
161
|
if (this.lastMessage) {
|
|
162
162
|
this.updateContent(this.lastMessage);
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
|
+
/** Whether a content block puts anything on screen under the current settings. */
|
|
166
|
+
isVisibleBlock(content) {
|
|
167
|
+
if (content.type === "text")
|
|
168
|
+
return content.text.trim() !== "";
|
|
169
|
+
if (content.type === "thinking")
|
|
170
|
+
return this.thinkingDisplay !== "omit" && content.thinking.trim() !== "";
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
165
173
|
setHiddenThinkingLabel(label) {
|
|
166
174
|
this.hiddenThinkingLabel = label;
|
|
167
175
|
if (this.lastMessage) {
|
|
@@ -199,7 +207,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
199
207
|
}
|
|
200
208
|
// Clear content container
|
|
201
209
|
this.contentContainer.clear();
|
|
202
|
-
const hasVisibleContent = message.content.some((c) =>
|
|
210
|
+
const hasVisibleContent = message.content.some((c) => this.isVisibleBlock(c));
|
|
203
211
|
if (hasVisibleContent) {
|
|
204
212
|
this.contentContainer.addChild(new Spacer(1));
|
|
205
213
|
}
|
|
@@ -212,12 +220,12 @@ export class AssistantMessageComponent extends Container {
|
|
|
212
220
|
this.addMarkdownBlock(`${i}:text`, content.text.trim(), streaming);
|
|
213
221
|
}
|
|
214
222
|
else if (content.type === "thinking" && content.thinking.trim()) {
|
|
223
|
+
if (this.thinkingDisplay === "omit")
|
|
224
|
+
continue;
|
|
215
225
|
// Add spacing only when another visible assistant content block follows.
|
|
216
226
|
// This avoids a superfluous blank line before separately-rendered tool execution blocks.
|
|
217
|
-
const hasVisibleContentAfter = message.content
|
|
218
|
-
|
|
219
|
-
.some((c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
|
|
220
|
-
if (this.hideThinkingBlock) {
|
|
227
|
+
const hasVisibleContentAfter = message.content.slice(i + 1).some((c) => this.isVisibleBlock(c));
|
|
228
|
+
if (this.thinkingDisplay === "label") {
|
|
221
229
|
// Show static thinking label when hidden
|
|
222
230
|
this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), 1, 0));
|
|
223
231
|
if (hasVisibleContentAfter) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"assistant-message.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/assistant-message.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AssistantMessage } from \"@kolisachint/hoocode-ai\";\nimport { Container, type DefaultTextStyle, Markdown, type MarkdownTheme, Spacer, Text } from \"@kolisachint/hoocode-tui\";\nimport { getMarkdownTheme, theme } from \"../theme/theme.js\";\n\nconst OSC133_ZONE_START = \"\\x1b]133;A\\x07\";\nconst OSC133_ZONE_END = \"\\x1b]133;B\\x07\";\nconst OSC133_ZONE_FINAL = \"\\x1b]133;C\\x07\";\n\n// Streaming messages below this size render as a single Markdown; above it the\n// text is segmented at stable block boundaries so each throttle tick re-parses\n// only the growing tail instead of the whole accumulated message.\nconst SEGMENT_MIN_CHARS = 2048;\n\nconst LIST_ITEM_RE = /^ {0,3}(?:[-*+] |\\d{1,9}[.)] )/;\nconst FENCE_RE = /^ {0,3}(?:```|~~~)/;\nconst LINK_DEF_RE = /^ {0,3}\\[[^\\]]+\\]: /m;\nconst SETEXT_UNDERLINE_RE = /^ {0,3}(?:=+|-+)\\s*$/;\n\n/** True when a blank-line gap between `prev` and `next` (both non-blank) is a\n * safe place to cut the markdown into independently-parseable chunks. */\nfunction isSafeBoundary(prev: string, next: string): boolean {\n\t// Indented continuation (loose list item body, indented code) binds to the\n\t// block above the gap.\n\tif (/^\\s/.test(next)) return false;\n\t// A blank line between two list items is a loose list, not two lists.\n\tif (LIST_ITEM_RE.test(prev) && LIST_ITEM_RE.test(next)) return false;\n\t// Tables and raw HTML blocks can span blank lines in surprising ways.\n\tif (next.startsWith(\"|\") || prev.trimStart().startsWith(\"|\")) return false;\n\tif (next.startsWith(\"<\") || prev.trimStart().startsWith(\"<\")) return false;\n\t// A bare ===/--- line after the gap could lex as setext underline or hr\n\t// differently without its preceding text; keep it attached.\n\tif (SETEXT_UNDERLINE_RE.test(next)) return false;\n\treturn true;\n}\n\n/**\n * Split markdown into chunks at blank-line boundaries where each chunk lexes\n * independently to the same blocks the whole text would. Prefix-stable:\n * appending text never changes earlier boundaries (decisions depend only on\n * preceding fence parity and the lines adjacent to each gap), so during\n * streaming every chunk except the last is byte-identical across updates and\n * its Markdown render cache keeps hitting. Exported for tests.\n */\nexport function segmentStreamingMarkdown(text: string): string[] {\n\t// Reference-style link/footnote definitions resolve across the whole\n\t// document; segmenting would break lookups from other chunks.\n\tif (LINK_DEF_RE.test(text)) return [text];\n\tconst lines = text.split(\"\\n\");\n\tconst chunks: string[] = [];\n\tlet chunkStart = 0;\n\tlet fenceOpen = false;\n\tlet prevNonblank = \"\";\n\tlet i = 0;\n\twhile (i < lines.length) {\n\t\tconst line = lines[i];\n\t\tif (FENCE_RE.test(line)) fenceOpen = !fenceOpen;\n\t\tif (line.trim() === \"\" && !fenceOpen) {\n\t\t\tlet j = i + 1;\n\t\t\twhile (j < lines.length && lines[j].trim() === \"\") j++;\n\t\t\t// Only cut when the gap has content on both sides; a trailing blank\n\t\t\t// run stays attached so the decision never has to be revisited.\n\t\t\tif (j < lines.length && chunkStart < i && isSafeBoundary(prevNonblank, lines[j])) {\n\t\t\t\tchunks.push(lines.slice(chunkStart, i).join(\"\\n\"));\n\t\t\t\tchunkStart = j;\n\t\t\t}\n\t\t\ti = j;\n\t\t\tcontinue;\n\t\t}\n\t\tif (line.trim() !== \"\") prevNonblank = line;\n\t\ti++;\n\t}\n\tconst last = lines.slice(chunkStart).join(\"\\n\");\n\tif (last.trim() !== \"\") {\n\t\tchunks.push(last);\n\t}\n\treturn chunks.length > 0 ? chunks : [text];\n}\n\n/**\n * Component that renders a complete assistant message\n */\nexport class AssistantMessageComponent extends Container {\n\tprivate contentContainer: Container;\n\tprivate hideThinkingBlock: boolean;\n\tprivate markdownTheme: MarkdownTheme;\n\tprivate hiddenThinkingLabel: string;\n\tprivate lastMessage?: AssistantMessage;\n\tprivate hasToolCalls = false;\n\t// Markdown children reused across streaming updates, keyed by content index +\n\t// kind. Markdown caches its rendered lines by (text, width); recreating the\n\t// instances on every streamed delta discarded those caches and re-parsed the\n\t// entire message (thinking trace included) per frame. Reuse keeps finished\n\t// blocks cached so only the block whose text actually changed re-parses.\n\tprivate markdownCache = new Map<string, { md: Markdown; text: string }>();\n\n\tconstructor(\n\t\tmessage?: AssistantMessage,\n\t\thideThinkingBlock = false,\n\t\tmarkdownTheme: MarkdownTheme = getMarkdownTheme(),\n\t\thiddenThinkingLabel = \"Thinking...\",\n\t) {\n\t\tsuper();\n\n\t\tthis.hideThinkingBlock = hideThinkingBlock;\n\t\tthis.markdownTheme = markdownTheme;\n\t\tthis.hiddenThinkingLabel = hiddenThinkingLabel;\n\n\t\t// Container for text/thinking content\n\t\tthis.contentContainer = new Container();\n\t\tthis.addChild(this.contentContainer);\n\n\t\tif (message) {\n\t\t\tthis.updateContent(message);\n\t\t}\n\t}\n\n\toverride invalidate(): void {\n\t\t// Cached Markdown blocks may be detached right now (e.g. hidden thinking);\n\t\t// drop their render caches too so a theme/width change can't resurface\n\t\t// stale styling when they re-attach.\n\t\tfor (const { md } of this.markdownCache.values()) {\n\t\t\tmd.invalidate();\n\t\t}\n\t\tsuper.invalidate();\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\tprivate reuseMarkdown(key: string, text: string, style?: DefaultTextStyle): Markdown {\n\t\tconst entry = this.markdownCache.get(key);\n\t\tif (entry) {\n\t\t\tif (entry.text !== text) {\n\t\t\t\tentry.md.setText(text);\n\t\t\t\tentry.text = text;\n\t\t\t}\n\t\t\treturn entry.md;\n\t\t}\n\t\tconst md = new Markdown(text, 1, 0, this.markdownTheme, style);\n\t\tthis.markdownCache.set(key, { md, text });\n\t\treturn md;\n\t}\n\n\t/** Set when the current children include streaming segments; the next\n\t * non-streaming (final/rebuild) render purges their cache entries. */\n\tprivate hasSegmentedBlocks = false;\n\n\t/**\n\t * Add one markdown content block. Large blocks still being streamed are\n\t * segmented at stable boundaries so only the tail chunk re-parses per\n\t * update; every earlier chunk is byte-stable and stays cached. The final\n\t * (non-streaming) render collapses back to one canonical Markdown, so any\n\t * segmentation artifact is transient by construction.\n\t */\n\tprivate addMarkdownBlock(keyBase: string, text: string, streaming: boolean, style?: DefaultTextStyle): void {\n\t\tif (streaming && text.length >= SEGMENT_MIN_CHARS) {\n\t\t\tconst chunks = segmentStreamingMarkdown(text);\n\t\t\tif (chunks.length > 1) {\n\t\t\t\tfor (let k = 0; k < chunks.length; k++) {\n\t\t\t\t\tif (k > 0) this.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\tthis.contentContainer.addChild(this.reuseMarkdown(`${keyBase}:seg:${k}`, chunks[k], style));\n\t\t\t\t}\n\t\t\t\tthis.hasSegmentedBlocks = true;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tthis.contentContainer.addChild(this.reuseMarkdown(keyBase, text, style));\n\t}\n\n\tsetHideThinkingBlock(hide: boolean): void {\n\t\tthis.hideThinkingBlock = hide;\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\tsetHiddenThinkingLabel(label: string): void {\n\t\tthis.hiddenThinkingLabel = label;\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\t/** OSC-zone wrap memo: Container.render returns the same array across\n\t * frames when nothing changed, so it must not be mutated — the wrapped\n\t * copy is cached keyed on the source array's identity. */\n\tprivate zoneMemo?: { src: string[]; out: string[] };\n\n\toverride render(width: number): string[] {\n\t\tconst lines = super.render(width);\n\t\tif (this.hasToolCalls || lines.length === 0) {\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.zoneMemo?.src === lines) {\n\t\t\treturn this.zoneMemo.out;\n\t\t}\n\t\tconst out = lines.slice();\n\t\tout[0] = OSC133_ZONE_START + out[0];\n\t\tout[out.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + out[out.length - 1];\n\t\tthis.zoneMemo = { src: lines, out };\n\t\treturn out;\n\t}\n\n\tupdateContent(message: AssistantMessage, streaming = false): void {\n\t\tthis.lastMessage = message;\n\n\t\t// A final/rebuild render replaces streaming segments with the canonical\n\t\t// single-Markdown form; drop the segment cache entries they used.\n\t\tif (!streaming && this.hasSegmentedBlocks) {\n\t\t\tfor (const key of this.markdownCache.keys()) {\n\t\t\t\tif (key.includes(\":seg:\")) this.markdownCache.delete(key);\n\t\t\t}\n\t\t\tthis.hasSegmentedBlocks = false;\n\t\t}\n\n\t\t// Clear content container\n\t\tthis.contentContainer.clear();\n\n\t\tconst hasVisibleContent = message.content.some(\n\t\t\t(c) => (c.type === \"text\" && c.text.trim()) || (c.type === \"thinking\" && c.thinking.trim()),\n\t\t);\n\n\t\tif (hasVisibleContent) {\n\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t}\n\n\t\t// Render content in order\n\t\tfor (let i = 0; i < message.content.length; i++) {\n\t\t\tconst content = message.content[i];\n\t\t\tif (content.type === \"text\" && content.text.trim()) {\n\t\t\t\t// Assistant text messages with no background - trim the text\n\t\t\t\t// Set paddingY=0 to avoid extra spacing before tool executions\n\t\t\t\tthis.addMarkdownBlock(`${i}:text`, content.text.trim(), streaming);\n\t\t\t} else if (content.type === \"thinking\" && content.thinking.trim()) {\n\t\t\t\t// Add spacing only when another visible assistant content block follows.\n\t\t\t\t// This avoids a superfluous blank line before separately-rendered tool execution blocks.\n\t\t\t\tconst hasVisibleContentAfter = message.content\n\t\t\t\t\t.slice(i + 1)\n\t\t\t\t\t.some((c) => (c.type === \"text\" && c.text.trim()) || (c.type === \"thinking\" && c.thinking.trim()));\n\n\t\t\t\tif (this.hideThinkingBlock) {\n\t\t\t\t\t// Show static thinking label when hidden\n\t\t\t\t\tthis.contentContainer.addChild(\n\t\t\t\t\t\tnew Text(theme.italic(theme.fg(\"thinkingText\", this.hiddenThinkingLabel)), 1, 0),\n\t\t\t\t\t);\n\t\t\t\t\tif (hasVisibleContentAfter) {\n\t\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Thinking traces in thinkingText color, italic, with ✻ prefix\n\t\t\t\t\tthis.addMarkdownBlock(`${i}:thinking`, `✻ ${content.thinking.trim()}`, streaming, {\n\t\t\t\t\t\tcolor: (text: string) => theme.fg(\"thinkingText\", text),\n\t\t\t\t\t\titalic: true,\n\t\t\t\t\t});\n\t\t\t\t\tif (hasVisibleContentAfter) {\n\t\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Check if aborted - show after partial content\n\t\t// But only if there are no tool calls (tool execution components will show the error)\n\t\tconst hasToolCalls = message.content.some((c) => c.type === \"toolCall\");\n\t\tthis.hasToolCalls = hasToolCalls;\n\t\tif (!hasToolCalls) {\n\t\t\tif (message.stopReason === \"aborted\") {\n\t\t\t\tconst abortMessage =\n\t\t\t\t\tmessage.errorMessage && message.errorMessage !== \"Request was aborted\"\n\t\t\t\t\t\t? message.errorMessage\n\t\t\t\t\t\t: \"Operation aborted\";\n\t\t\t\tif (hasVisibleContent) {\n\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t} else {\n\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t}\n\t\t\t\tthis.contentContainer.addChild(new Text(theme.fg(\"error\", abortMessage), 1, 0));\n\t\t\t} else if (message.stopReason === \"error\") {\n\t\t\t\tconst errorMsg = message.errorMessage || \"Unknown error\";\n\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\tthis.contentContainer.addChild(new Text(theme.fg(\"error\", `Error: ${errorMsg}`), 1, 0));\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"assistant-message.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/assistant-message.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AssistantMessage } from \"@kolisachint/hoocode-ai\";\nimport { Container, type DefaultTextStyle, Markdown, type MarkdownTheme, Spacer, Text } from \"@kolisachint/hoocode-tui\";\nimport { getMarkdownTheme, theme } from \"../theme/theme.js\";\n\n/**\n * How a thinking block renders.\n *\n * `omit` exists for radar. Folding a trace to its one-line label is right when\n * the label sits between things you can see; under a chain summary it is not,\n * because a message whose only content is thinking plus tool calls has nothing\n * left on screen once its calls collapse into the chain's row. A stack of\n * `Thinking...` lines under a one-line summary is precisely the noise radar\n * exists to remove.\n */\nexport type ThinkingDisplay = \"full\" | \"label\" | \"omit\";\n\nconst OSC133_ZONE_START = \"\\x1b]133;A\\x07\";\nconst OSC133_ZONE_END = \"\\x1b]133;B\\x07\";\nconst OSC133_ZONE_FINAL = \"\\x1b]133;C\\x07\";\n\n// Streaming messages below this size render as a single Markdown; above it the\n// text is segmented at stable block boundaries so each throttle tick re-parses\n// only the growing tail instead of the whole accumulated message.\nconst SEGMENT_MIN_CHARS = 2048;\n\nconst LIST_ITEM_RE = /^ {0,3}(?:[-*+] |\\d{1,9}[.)] )/;\nconst FENCE_RE = /^ {0,3}(?:```|~~~)/;\nconst LINK_DEF_RE = /^ {0,3}\\[[^\\]]+\\]: /m;\nconst SETEXT_UNDERLINE_RE = /^ {0,3}(?:=+|-+)\\s*$/;\n\n/** True when a blank-line gap between `prev` and `next` (both non-blank) is a\n * safe place to cut the markdown into independently-parseable chunks. */\nfunction isSafeBoundary(prev: string, next: string): boolean {\n\t// Indented continuation (loose list item body, indented code) binds to the\n\t// block above the gap.\n\tif (/^\\s/.test(next)) return false;\n\t// A blank line between two list items is a loose list, not two lists.\n\tif (LIST_ITEM_RE.test(prev) && LIST_ITEM_RE.test(next)) return false;\n\t// Tables and raw HTML blocks can span blank lines in surprising ways.\n\tif (next.startsWith(\"|\") || prev.trimStart().startsWith(\"|\")) return false;\n\tif (next.startsWith(\"<\") || prev.trimStart().startsWith(\"<\")) return false;\n\t// A bare ===/--- line after the gap could lex as setext underline or hr\n\t// differently without its preceding text; keep it attached.\n\tif (SETEXT_UNDERLINE_RE.test(next)) return false;\n\treturn true;\n}\n\n/**\n * Split markdown into chunks at blank-line boundaries where each chunk lexes\n * independently to the same blocks the whole text would. Prefix-stable:\n * appending text never changes earlier boundaries (decisions depend only on\n * preceding fence parity and the lines adjacent to each gap), so during\n * streaming every chunk except the last is byte-identical across updates and\n * its Markdown render cache keeps hitting. Exported for tests.\n */\nexport function segmentStreamingMarkdown(text: string): string[] {\n\t// Reference-style link/footnote definitions resolve across the whole\n\t// document; segmenting would break lookups from other chunks.\n\tif (LINK_DEF_RE.test(text)) return [text];\n\tconst lines = text.split(\"\\n\");\n\tconst chunks: string[] = [];\n\tlet chunkStart = 0;\n\tlet fenceOpen = false;\n\tlet prevNonblank = \"\";\n\tlet i = 0;\n\twhile (i < lines.length) {\n\t\tconst line = lines[i];\n\t\tif (FENCE_RE.test(line)) fenceOpen = !fenceOpen;\n\t\tif (line.trim() === \"\" && !fenceOpen) {\n\t\t\tlet j = i + 1;\n\t\t\twhile (j < lines.length && lines[j].trim() === \"\") j++;\n\t\t\t// Only cut when the gap has content on both sides; a trailing blank\n\t\t\t// run stays attached so the decision never has to be revisited.\n\t\t\tif (j < lines.length && chunkStart < i && isSafeBoundary(prevNonblank, lines[j])) {\n\t\t\t\tchunks.push(lines.slice(chunkStart, i).join(\"\\n\"));\n\t\t\t\tchunkStart = j;\n\t\t\t}\n\t\t\ti = j;\n\t\t\tcontinue;\n\t\t}\n\t\tif (line.trim() !== \"\") prevNonblank = line;\n\t\ti++;\n\t}\n\tconst last = lines.slice(chunkStart).join(\"\\n\");\n\tif (last.trim() !== \"\") {\n\t\tchunks.push(last);\n\t}\n\treturn chunks.length > 0 ? chunks : [text];\n}\n\n/**\n * Component that renders a complete assistant message\n */\nexport class AssistantMessageComponent extends Container {\n\tprivate contentContainer: Container;\n\tprivate thinkingDisplay: ThinkingDisplay;\n\tprivate markdownTheme: MarkdownTheme;\n\tprivate hiddenThinkingLabel: string;\n\tprivate lastMessage?: AssistantMessage;\n\tprivate hasToolCalls = false;\n\t// Markdown children reused across streaming updates, keyed by content index +\n\t// kind. Markdown caches its rendered lines by (text, width); recreating the\n\t// instances on every streamed delta discarded those caches and re-parsed the\n\t// entire message (thinking trace included) per frame. Reuse keeps finished\n\t// blocks cached so only the block whose text actually changed re-parses.\n\tprivate markdownCache = new Map<string, { md: Markdown; text: string }>();\n\n\tconstructor(\n\t\tmessage?: AssistantMessage,\n\t\tthinkingDisplay: ThinkingDisplay = \"full\",\n\t\tmarkdownTheme: MarkdownTheme = getMarkdownTheme(),\n\t\thiddenThinkingLabel = \"Thinking...\",\n\t) {\n\t\tsuper();\n\n\t\tthis.thinkingDisplay = thinkingDisplay;\n\t\tthis.markdownTheme = markdownTheme;\n\t\tthis.hiddenThinkingLabel = hiddenThinkingLabel;\n\n\t\t// Container for text/thinking content\n\t\tthis.contentContainer = new Container();\n\t\tthis.addChild(this.contentContainer);\n\n\t\tif (message) {\n\t\t\tthis.updateContent(message);\n\t\t}\n\t}\n\n\toverride invalidate(): void {\n\t\t// Cached Markdown blocks may be detached right now (e.g. hidden thinking);\n\t\t// drop their render caches too so a theme/width change can't resurface\n\t\t// stale styling when they re-attach.\n\t\tfor (const { md } of this.markdownCache.values()) {\n\t\t\tmd.invalidate();\n\t\t}\n\t\tsuper.invalidate();\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\tprivate reuseMarkdown(key: string, text: string, style?: DefaultTextStyle): Markdown {\n\t\tconst entry = this.markdownCache.get(key);\n\t\tif (entry) {\n\t\t\tif (entry.text !== text) {\n\t\t\t\tentry.md.setText(text);\n\t\t\t\tentry.text = text;\n\t\t\t}\n\t\t\treturn entry.md;\n\t\t}\n\t\tconst md = new Markdown(text, 1, 0, this.markdownTheme, style);\n\t\tthis.markdownCache.set(key, { md, text });\n\t\treturn md;\n\t}\n\n\t/** Set when the current children include streaming segments; the next\n\t * non-streaming (final/rebuild) render purges their cache entries. */\n\tprivate hasSegmentedBlocks = false;\n\n\t/**\n\t * Add one markdown content block. Large blocks still being streamed are\n\t * segmented at stable boundaries so only the tail chunk re-parses per\n\t * update; every earlier chunk is byte-stable and stays cached. The final\n\t * (non-streaming) render collapses back to one canonical Markdown, so any\n\t * segmentation artifact is transient by construction.\n\t */\n\tprivate addMarkdownBlock(keyBase: string, text: string, streaming: boolean, style?: DefaultTextStyle): void {\n\t\tif (streaming && text.length >= SEGMENT_MIN_CHARS) {\n\t\t\tconst chunks = segmentStreamingMarkdown(text);\n\t\t\tif (chunks.length > 1) {\n\t\t\t\tfor (let k = 0; k < chunks.length; k++) {\n\t\t\t\t\tif (k > 0) this.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\tthis.contentContainer.addChild(this.reuseMarkdown(`${keyBase}:seg:${k}`, chunks[k], style));\n\t\t\t\t}\n\t\t\t\tthis.hasSegmentedBlocks = true;\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tthis.contentContainer.addChild(this.reuseMarkdown(keyBase, text, style));\n\t}\n\n\tsetThinkingDisplay(display: ThinkingDisplay): void {\n\t\tthis.thinkingDisplay = display;\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\t/** Whether a content block puts anything on screen under the current settings. */\n\tprivate isVisibleBlock(content: AssistantMessage[\"content\"][number]): boolean {\n\t\tif (content.type === \"text\") return content.text.trim() !== \"\";\n\t\tif (content.type === \"thinking\") return this.thinkingDisplay !== \"omit\" && content.thinking.trim() !== \"\";\n\t\treturn false;\n\t}\n\n\tsetHiddenThinkingLabel(label: string): void {\n\t\tthis.hiddenThinkingLabel = label;\n\t\tif (this.lastMessage) {\n\t\t\tthis.updateContent(this.lastMessage);\n\t\t}\n\t}\n\n\t/** OSC-zone wrap memo: Container.render returns the same array across\n\t * frames when nothing changed, so it must not be mutated — the wrapped\n\t * copy is cached keyed on the source array's identity. */\n\tprivate zoneMemo?: { src: string[]; out: string[] };\n\n\toverride render(width: number): string[] {\n\t\tconst lines = super.render(width);\n\t\tif (this.hasToolCalls || lines.length === 0) {\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.zoneMemo?.src === lines) {\n\t\t\treturn this.zoneMemo.out;\n\t\t}\n\t\tconst out = lines.slice();\n\t\tout[0] = OSC133_ZONE_START + out[0];\n\t\tout[out.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + out[out.length - 1];\n\t\tthis.zoneMemo = { src: lines, out };\n\t\treturn out;\n\t}\n\n\tupdateContent(message: AssistantMessage, streaming = false): void {\n\t\tthis.lastMessage = message;\n\n\t\t// A final/rebuild render replaces streaming segments with the canonical\n\t\t// single-Markdown form; drop the segment cache entries they used.\n\t\tif (!streaming && this.hasSegmentedBlocks) {\n\t\t\tfor (const key of this.markdownCache.keys()) {\n\t\t\t\tif (key.includes(\":seg:\")) this.markdownCache.delete(key);\n\t\t\t}\n\t\t\tthis.hasSegmentedBlocks = false;\n\t\t}\n\n\t\t// Clear content container\n\t\tthis.contentContainer.clear();\n\n\t\tconst hasVisibleContent = message.content.some((c) => this.isVisibleBlock(c));\n\n\t\tif (hasVisibleContent) {\n\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t}\n\n\t\t// Render content in order\n\t\tfor (let i = 0; i < message.content.length; i++) {\n\t\t\tconst content = message.content[i];\n\t\t\tif (content.type === \"text\" && content.text.trim()) {\n\t\t\t\t// Assistant text messages with no background - trim the text\n\t\t\t\t// Set paddingY=0 to avoid extra spacing before tool executions\n\t\t\t\tthis.addMarkdownBlock(`${i}:text`, content.text.trim(), streaming);\n\t\t\t} else if (content.type === \"thinking\" && content.thinking.trim()) {\n\t\t\t\tif (this.thinkingDisplay === \"omit\") continue;\n\n\t\t\t\t// Add spacing only when another visible assistant content block follows.\n\t\t\t\t// This avoids a superfluous blank line before separately-rendered tool execution blocks.\n\t\t\t\tconst hasVisibleContentAfter = message.content.slice(i + 1).some((c) => this.isVisibleBlock(c));\n\n\t\t\t\tif (this.thinkingDisplay === \"label\") {\n\t\t\t\t\t// Show static thinking label when hidden\n\t\t\t\t\tthis.contentContainer.addChild(\n\t\t\t\t\t\tnew Text(theme.italic(theme.fg(\"thinkingText\", this.hiddenThinkingLabel)), 1, 0),\n\t\t\t\t\t);\n\t\t\t\t\tif (hasVisibleContentAfter) {\n\t\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Thinking traces in thinkingText color, italic, with ✻ prefix\n\t\t\t\t\tthis.addMarkdownBlock(`${i}:thinking`, `✻ ${content.thinking.trim()}`, streaming, {\n\t\t\t\t\t\tcolor: (text: string) => theme.fg(\"thinkingText\", text),\n\t\t\t\t\t\titalic: true,\n\t\t\t\t\t});\n\t\t\t\t\tif (hasVisibleContentAfter) {\n\t\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Check if aborted - show after partial content\n\t\t// But only if there are no tool calls (tool execution components will show the error)\n\t\tconst hasToolCalls = message.content.some((c) => c.type === \"toolCall\");\n\t\tthis.hasToolCalls = hasToolCalls;\n\t\tif (!hasToolCalls) {\n\t\t\tif (message.stopReason === \"aborted\") {\n\t\t\t\tconst abortMessage =\n\t\t\t\t\tmessage.errorMessage && message.errorMessage !== \"Request was aborted\"\n\t\t\t\t\t\t? message.errorMessage\n\t\t\t\t\t\t: \"Operation aborted\";\n\t\t\t\tif (hasVisibleContent) {\n\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t} else {\n\t\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\t}\n\t\t\t\tthis.contentContainer.addChild(new Text(theme.fg(\"error\", abortMessage), 1, 0));\n\t\t\t} else if (message.stopReason === \"error\") {\n\t\t\t\tconst errorMsg = message.errorMessage || \"Unknown error\";\n\t\t\t\tthis.contentContainer.addChild(new Spacer(1));\n\t\t\t\tthis.contentContainer.addChild(new Text(theme.fg(\"error\", `Error: ${errorMsg}`), 1, 0));\n\t\t\t}\n\t\t}\n\t}\n}\n"]}
|
|
@@ -53,10 +53,17 @@ export declare function chainStats(entries: ChainEntry[], state: ChainState): st
|
|
|
53
53
|
/**
|
|
54
54
|
* The settled chain's phrase: what this run amounted to.
|
|
55
55
|
*
|
|
56
|
-
* Deliberately one clause, not an inventory. "Edited packages/tui, read
|
|
57
|
-
* files, searched packages" is a worse line than "Edited packages/tui" — the
|
|
56
|
+
* Deliberately close to one clause, not an inventory. "Edited packages/tui, read
|
|
57
|
+
* 6 files, searched packages" is a worse line than "Edited packages/tui" — the
|
|
58
58
|
* count is already in the stats, and the per-call detail is one `alt+u` away.
|
|
59
59
|
*
|
|
60
|
+
* A long chain earns at most one more clause, and only when its headline family
|
|
61
|
+
* is a minority of the calls. That is the case where a single clause stops being
|
|
62
|
+
* terse and starts being false: 31 edits among 182 calls is worth naming, but so
|
|
63
|
+
* is the fact that 76 of the rest were commands. Listing every family it touched
|
|
64
|
+
* is not the alternative — a chain that reports "(ran, delegated, read,
|
|
65
|
+
* searched)" has said only that it was busy.
|
|
66
|
+
*
|
|
60
67
|
* A location is only named when the calls actually share one. Naming a single
|
|
61
68
|
* arbitrary file out of five would read as a claim the chain never made, so
|
|
62
69
|
* several unrelated targets collapse to a count instead.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-chain-summary.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/tool-chain-summary.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,iDAAiD;AACjD,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,2DAAyD;IACzD,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,uBAAuB;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,8CAA8C;IAC9C,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,UAAU;AACrB,6CAA6C;AAC3C,SAAS;AACX,yDAAyD;GACvD,MAAM;AACR,0EAA0E;GACxE,aAAa,CAAC;AAEjB,MAAM,MAAM,WAAW,GAAG,IAAI,GAAG,OAAO,GAAG,SAAS,CAAC;AAErD,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,WAAW,CAAC;CAClB;AAgFD,yEAAkE;AAClE,wBAAgB,aAAa,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,YAAY,EAAE,CAEnE;AAMD,kDAAkD;AAClD,wBAAgB,UAAU,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,KAAK,EAAE,UAAU,GAAG,MAAM,CAoB3E;AA2CD;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,MAAM,CA2BzD","sourcesContent":["/**\n * The radar view's chain line: one line for a run of consecutive tool calls.\n *\n * Two renderings of the same chain, because a run that is still going and a run\n * that is over answer different questions.\n *\n * While it runs you want the shape — what it did, in order, and where it broke:\n *\n * ◐ grep › read › bash✗ › edit › bash… 4 done · 1 failed · running\n *\n * Once it is over the order has stopped mattering and what is left is what it\n * amounted to:\n *\n * ● Edited packages/tui/src/keys.ts 5 calls · 1 failed\n *\n * A chain that did *not* finish keeps the running form plus a marker. The\n * settled form is a claim about what was accomplished, and a chain cut off\n * partway through has no such claim to make — the same reason\n * `settleDanglingMainTasks` refuses to mark an aborted turn's plan items done.\n *\n * Everything here is pure and deterministic. A model-written phase label would\n * read better than the inferred one, but it would cost tokens on every turn and\n * this agent's whole premise is that nothing happens you did not pay for on\n * purpose.\n */\n\n/** One tool call's contribution to its chain. */\nexport interface ChainEntry {\n\ttool: string;\n\t/** The call's subject — a path, a command, a pattern. */\n\tsubject: string;\n\tisError: boolean;\n\t/** Still executing. */\n\tisPartial: boolean;\n\t/** Lines of text output the call returned. */\n\toutputLines: number;\n}\n\nexport type ChainState =\n\t/** Calls are still arriving or executing. */\n\t| \"running\"\n\t/** Every call finished and the turn moved on cleanly. */\n\t| \"done\"\n\t/** The turn was aborted, errored, or hit a length cap partway through. */\n\t| \"interrupted\";\n\nexport type SegmentTone = \"ok\" | \"error\" | \"running\";\n\nexport interface ChainSegment {\n\tlabel: string;\n\ttone: SegmentTone;\n}\n\n/** Segments beyond this many are elided in the middle rather than wrapped. */\nconst MAX_SEGMENTS = 8;\n/** Kept at the head when eliding. */\nconst HEAD_SEGMENTS = 3;\n/** Kept at the tail when eliding. */\nconst TAIL_SEGMENTS = 2;\n\nfunction tone(entry: ChainEntry): SegmentTone {\n\tif (entry.isError) return \"error\";\n\tif (entry.isPartial) return \"running\";\n\treturn \"ok\";\n}\n\n/**\n * Collapse consecutive calls to the same tool into one segment.\n *\n * A run of five reads is one act, not five, and spelling it out crowds out the\n * calls either side of it. A failure never merges: `read ×4` hiding one broken\n * read would defeat the point of the line.\n */\nfunction collapseRepeats(entries: ChainEntry[]): ChainSegment[] {\n\tconst segments: ChainSegment[] = [];\n\tlet index = 0;\n\twhile (index < entries.length) {\n\t\tconst entry = entries[index];\n\t\tconst entryTone = tone(entry);\n\t\tlet run = 1;\n\t\twhile (\n\t\t\tindex + run < entries.length &&\n\t\t\tentries[index + run].tool === entry.tool &&\n\t\t\ttone(entries[index + run]) === entryTone &&\n\t\t\tentryTone === \"ok\"\n\t\t) {\n\t\t\trun++;\n\t\t}\n\t\tconst marker = entryTone === \"error\" ? \"✗\" : \"\";\n\t\tsegments.push({ label: run > 1 ? `${entry.tool} ×${run}` : `${entry.tool}${marker}`, tone: entryTone });\n\t\tindex += run;\n\t}\n\treturn segments;\n}\n\n/**\n * Elide the middle of a long chain, keeping both ends legible.\n *\n * Failures are never elided. Where a run broke is the one thing the line exists\n * to show, and a long chain is exactly when you most need it — hiding the ✗\n * inside \"… 22 more …\" would leave the stats claiming a failure the line cannot\n * point at.\n */\nfunction capSegments(segments: ChainSegment[]): ChainSegment[] {\n\tif (segments.length <= MAX_SEGMENTS) return segments;\n\n\tconst keep = new Set<number>();\n\tfor (let i = 0; i < HEAD_SEGMENTS; i++) keep.add(i);\n\tfor (let i = segments.length - TAIL_SEGMENTS; i < segments.length; i++) keep.add(i);\n\tsegments.forEach((segment, i) => {\n\t\tif (segment.tone === \"error\") keep.add(i);\n\t});\n\n\tconst out: ChainSegment[] = [];\n\tlet gap = 0;\n\tconst flushGap = () => {\n\t\tif (gap > 0) out.push({ label: `… ${gap} more …`, tone: \"ok\" });\n\t\tgap = 0;\n\t};\n\tsegments.forEach((segment, i) => {\n\t\tif (keep.has(i)) {\n\t\t\tflushGap();\n\t\t\tout.push(segment);\n\t\t} else {\n\t\t\tgap++;\n\t\t}\n\t});\n\tflushGap();\n\treturn out;\n}\n\n/** The `grep › read × 3 › bash✗` part of a running chain line. */\nexport function chainSegments(entries: ChainEntry[]): ChainSegment[] {\n\treturn capSegments(collapseRepeats(entries));\n}\n\nfunction plural(n: number, one: string, many = `${one}s`): string {\n\treturn `${n} ${n === 1 ? one : many}`;\n}\n\n/** The flush-right stats for either rendering. */\nexport function chainStats(entries: ChainEntry[], state: ChainState): string {\n\tconst failed = entries.filter((e) => e.isError).length;\n\tconst parts: string[] = [];\n\n\tif (state === \"running\") {\n\t\tparts.push(`${entries.filter((e) => !e.isPartial).length} done`);\n\t} else {\n\t\tparts.push(plural(entries.length, \"call\"));\n\t}\n\tif (failed > 0) parts.push(`${failed} failed`);\n\n\tif (state === \"running\") {\n\t\tparts.push(\"running\");\n\t} else if (state === \"interrupted\") {\n\t\tparts.push(\"interrupted\");\n\t} else {\n\t\tconst lines = entries.reduce((sum, e) => sum + e.outputLines, 0);\n\t\tif (lines > 0) parts.push(plural(lines, \"line\"));\n\t}\n\treturn parts.join(\" · \");\n}\n\n/**\n * Tool families, in the order a settled chain reports them.\n *\n * The ordering is the whole trick: a chain that read six files and then changed\n * one is remembered as the edit. Mutation outranks execution outranks reading\n * outranks searching, so the phrase names the most consequential thing the\n * chain did rather than the most frequent.\n */\nconst FAMILIES: Array<{ verb: string; noun: string; tools: string[] }> = [\n\t{ verb: \"Edited\", noun: \"file\", tools: [\"edit\", \"write\", \"MultiEdit\", \"NotebookEdit\"] },\n\t{ verb: \"Ran\", noun: \"command\", tools: [\"bash\"] },\n\t{ verb: \"Delegated\", noun: \"task\", tools: [\"Task\", \"TaskOutput\"] },\n\t{ verb: \"Fetched\", noun: \"page\", tools: [\"webfetch\", \"websearch\"] },\n\t{ verb: \"Read\", noun: \"file\", tools: [\"read\"] },\n\t{ verb: \"Searched\", noun: \"search\", tools: [\"grep\", \"find\", \"search\", \"ls\"] },\n];\n\n/** Longest common directory prefix of the given paths, or \"\" when they share none. */\nfunction commonPathPrefix(paths: string[]): string {\n\tif (paths.length === 0) return \"\";\n\tconst split = paths.map((p) => p.split(\"/\").filter(Boolean));\n\tconst [first, ...rest] = split;\n\tconst prefix: string[] = [];\n\tfor (let i = 0; i < first.length; i++) {\n\t\tif (rest.every((parts) => parts[i] === first[i])) prefix.push(first[i]);\n\t\telse break;\n\t}\n\treturn prefix.join(\"/\");\n}\n\n/**\n * Whether a subject can stand in for a location.\n *\n * Globs are excluded even though they look pathlike: \"Searched *.test.ts\" reads\n * as a place the chain worked in, which is exactly what it is not.\n */\nfunction usableAsTarget(subject: string): boolean {\n\tif (/[*?]/.test(subject)) return false;\n\treturn subject.includes(\"/\") || /\\.[a-zA-Z0-9]+$/.test(subject);\n}\n\n/**\n * The settled chain's phrase: what this run amounted to.\n *\n * Deliberately one clause, not an inventory. \"Edited packages/tui, read 6\n * files, searched packages\" is a worse line than \"Edited packages/tui\" — the\n * count is already in the stats, and the per-call detail is one `alt+u` away.\n *\n * A location is only named when the calls actually share one. Naming a single\n * arbitrary file out of five would read as a claim the chain never made, so\n * several unrelated targets collapse to a count instead.\n */\nexport function chainPhrase(entries: ChainEntry[]): string {\n\tif (entries.length === 0) return \"No calls\";\n\n\tfor (const family of FAMILIES) {\n\t\tconst matched = entries.filter((e) => family.tools.includes(e.tool));\n\t\tif (matched.length === 0) continue;\n\n\t\tconst subjects = matched.map((e) => e.subject).filter(Boolean);\n\n\t\t// A lone call is fully described by its own subject, whatever shape it is:\n\t\t// a command, a pattern, a URL, a path.\n\t\tif (matched.length === 1 && subjects.length === 1) {\n\t\t\treturn `${family.verb} ${subjects[0]}`;\n\t\t}\n\n\t\t// Several calls: name the place they share, or fall back to a count.\n\t\tconst target = commonPathPrefix(subjects.filter(usableAsTarget));\n\t\tif (target) return `${family.verb} ${target}`;\n\t\t// Searching has no natural plural noun (\"3 searches\" says nothing the\n\t\t// stats do not), so the bare verb carries it.\n\t\tif (family.noun === \"search\") return \"Explored\";\n\t\treturn `${family.verb} ${plural(matched.length, family.noun)}`;\n\t}\n\n\t// An unrecognised tool set still gets a line rather than a blank.\n\tconst names = [...new Set(entries.map((e) => e.tool))];\n\treturn names.length === 1 ? `Called ${names[0]}` : `Called ${plural(names.length, \"tool\")}`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"tool-chain-summary.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/tool-chain-summary.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,iDAAiD;AACjD,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,2DAAyD;IACzD,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,uBAAuB;IACvB,SAAS,EAAE,OAAO,CAAC;IACnB,8CAA8C;IAC9C,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,UAAU;AACrB,6CAA6C;AAC3C,SAAS;AACX,yDAAyD;GACvD,MAAM;AACR,0EAA0E;GACxE,aAAa,CAAC;AAEjB,MAAM,MAAM,WAAW,GAAG,IAAI,GAAG,OAAO,GAAG,SAAS,CAAC;AAErD,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,WAAW,CAAC;CAClB;AAgFD,yEAAkE;AAClE,wBAAgB,aAAa,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,YAAY,EAAE,CAEnE;AAMD,kDAAkD;AAClD,wBAAgB,UAAU,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,KAAK,EAAE,UAAU,GAAG,MAAM,CAoB3E;AA2JD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,MAAM,CAqCzD","sourcesContent":["/**\n * The radar view's chain line: one line for a run of consecutive tool calls.\n *\n * Two renderings of the same chain, because a run that is still going and a run\n * that is over answer different questions.\n *\n * While it runs you want the shape — what it did, in order, and where it broke:\n *\n * ◐ grep › read › bash✗ › edit › bash… 4 done · 1 failed · running\n *\n * Once it is over the order has stopped mattering and what is left is what it\n * amounted to:\n *\n * ● Edited packages/tui/src/keys.ts 5 calls · 1 failed\n *\n * A chain that did *not* finish keeps the running form plus a marker. The\n * settled form is a claim about what was accomplished, and a chain cut off\n * partway through has no such claim to make — the same reason\n * `settleDanglingMainTasks` refuses to mark an aborted turn's plan items done.\n *\n * Everything here is pure and deterministic. A model-written phase label would\n * read better than the inferred one, but it would cost tokens on every turn and\n * this agent's whole premise is that nothing happens you did not pay for on\n * purpose.\n */\n\n/** One tool call's contribution to its chain. */\nexport interface ChainEntry {\n\ttool: string;\n\t/** The call's subject — a path, a command, a pattern. */\n\tsubject: string;\n\tisError: boolean;\n\t/** Still executing. */\n\tisPartial: boolean;\n\t/** Lines of text output the call returned. */\n\toutputLines: number;\n}\n\nexport type ChainState =\n\t/** Calls are still arriving or executing. */\n\t| \"running\"\n\t/** Every call finished and the turn moved on cleanly. */\n\t| \"done\"\n\t/** The turn was aborted, errored, or hit a length cap partway through. */\n\t| \"interrupted\";\n\nexport type SegmentTone = \"ok\" | \"error\" | \"running\";\n\nexport interface ChainSegment {\n\tlabel: string;\n\ttone: SegmentTone;\n}\n\n/** Segments beyond this many are elided in the middle rather than wrapped. */\nconst MAX_SEGMENTS = 8;\n/** Kept at the head when eliding. */\nconst HEAD_SEGMENTS = 3;\n/** Kept at the tail when eliding. */\nconst TAIL_SEGMENTS = 2;\n\nfunction tone(entry: ChainEntry): SegmentTone {\n\tif (entry.isError) return \"error\";\n\tif (entry.isPartial) return \"running\";\n\treturn \"ok\";\n}\n\n/**\n * Collapse consecutive calls to the same tool into one segment.\n *\n * A run of five reads is one act, not five, and spelling it out crowds out the\n * calls either side of it. A failure never merges: `read ×4` hiding one broken\n * read would defeat the point of the line.\n */\nfunction collapseRepeats(entries: ChainEntry[]): ChainSegment[] {\n\tconst segments: ChainSegment[] = [];\n\tlet index = 0;\n\twhile (index < entries.length) {\n\t\tconst entry = entries[index];\n\t\tconst entryTone = tone(entry);\n\t\tlet run = 1;\n\t\twhile (\n\t\t\tindex + run < entries.length &&\n\t\t\tentries[index + run].tool === entry.tool &&\n\t\t\ttone(entries[index + run]) === entryTone &&\n\t\t\tentryTone === \"ok\"\n\t\t) {\n\t\t\trun++;\n\t\t}\n\t\tconst marker = entryTone === \"error\" ? \"✗\" : \"\";\n\t\tsegments.push({ label: run > 1 ? `${entry.tool} ×${run}` : `${entry.tool}${marker}`, tone: entryTone });\n\t\tindex += run;\n\t}\n\treturn segments;\n}\n\n/**\n * Elide the middle of a long chain, keeping both ends legible.\n *\n * Failures are never elided. Where a run broke is the one thing the line exists\n * to show, and a long chain is exactly when you most need it — hiding the ✗\n * inside \"… 22 more …\" would leave the stats claiming a failure the line cannot\n * point at.\n */\nfunction capSegments(segments: ChainSegment[]): ChainSegment[] {\n\tif (segments.length <= MAX_SEGMENTS) return segments;\n\n\tconst keep = new Set<number>();\n\tfor (let i = 0; i < HEAD_SEGMENTS; i++) keep.add(i);\n\tfor (let i = segments.length - TAIL_SEGMENTS; i < segments.length; i++) keep.add(i);\n\tsegments.forEach((segment, i) => {\n\t\tif (segment.tone === \"error\") keep.add(i);\n\t});\n\n\tconst out: ChainSegment[] = [];\n\tlet gap = 0;\n\tconst flushGap = () => {\n\t\tif (gap > 0) out.push({ label: `… ${gap} more …`, tone: \"ok\" });\n\t\tgap = 0;\n\t};\n\tsegments.forEach((segment, i) => {\n\t\tif (keep.has(i)) {\n\t\t\tflushGap();\n\t\t\tout.push(segment);\n\t\t} else {\n\t\t\tgap++;\n\t\t}\n\t});\n\tflushGap();\n\treturn out;\n}\n\n/** The `grep › read × 3 › bash✗` part of a running chain line. */\nexport function chainSegments(entries: ChainEntry[]): ChainSegment[] {\n\treturn capSegments(collapseRepeats(entries));\n}\n\nfunction plural(n: number, one: string, many = `${one}s`): string {\n\treturn `${n} ${n === 1 ? one : many}`;\n}\n\n/** The flush-right stats for either rendering. */\nexport function chainStats(entries: ChainEntry[], state: ChainState): string {\n\tconst failed = entries.filter((e) => e.isError).length;\n\tconst parts: string[] = [];\n\n\tif (state === \"running\") {\n\t\tparts.push(`${entries.filter((e) => !e.isPartial).length} done`);\n\t} else {\n\t\tparts.push(plural(entries.length, \"call\"));\n\t}\n\tif (failed > 0) parts.push(`${failed} failed`);\n\n\tif (state === \"running\") {\n\t\tparts.push(\"running\");\n\t} else if (state === \"interrupted\") {\n\t\tparts.push(\"interrupted\");\n\t} else {\n\t\tconst lines = entries.reduce((sum, e) => sum + e.outputLines, 0);\n\t\tif (lines > 0) parts.push(plural(lines, \"line\"));\n\t}\n\treturn parts.join(\" · \");\n}\n\ninterface ToolFamily {\n\tverb: string;\n\t/** Noun for the headline's count form: \"Edited 3 files\". */\n\tnoun: string;\n\t/** Noun for the secondary clause, which has no verb to lean on: \"· 38 reads\". */\n\ttail: string;\n\ttools: string[];\n\t/**\n\t * Whether this family's subjects name a place on disk.\n\t *\n\t * Only these may be reduced to a shared location. A command is not a path\n\t * however much it looks like one: three calls that all begin\n\t * `cd /Users/me/repo && …` share a long slash-separated prefix, and treating\n\t * it as a location produced `Ran cd /Users/me/repo` — a headline naming the\n\t * one part of the command that did nothing.\n\t */\n\tsubjectIsPath: boolean;\n\t/** Reduce a subject to the act it performed, when the raw form carries noise. */\n\tact?: (subject: string) => string;\n}\n\n/** Leading `cd <dir> &&` runs, however many are chained. */\nconst CD_PREFIX_RE = /^(?:cd\\s+(?:\"[^\"]*\"|'[^']*'|[^\\s;&|]+)\\s*&&\\s*)+/;\n\n/**\n * A command's act, with the navigation stripped off the front.\n *\n * `cd packages/tui && bun run check` is one act performed somewhere, not two,\n * and the somewhere is not what the chain did.\n */\nfunction commandAct(subject: string): string {\n\treturn subject.replace(CD_PREFIX_RE, \"\").trim() || subject;\n}\n\n/**\n * Tool families, in the order a settled chain reports them.\n *\n * The ordering is the whole trick: a chain that read six files and then changed\n * one is remembered as the edit. Mutation outranks execution outranks reading\n * outranks searching, so the phrase names the most consequential thing the\n * chain did rather than the most frequent.\n *\n * Priority alone is only sound while the chain is small enough for every call\n * to plausibly serve the headline act. See `INCIDENTAL_SHARE`.\n */\nconst FAMILIES: ToolFamily[] = [\n\t{\n\t\tverb: \"Edited\",\n\t\tnoun: \"file\",\n\t\ttail: \"edit\",\n\t\ttools: [\"edit\", \"write\", \"MultiEdit\", \"NotebookEdit\"],\n\t\tsubjectIsPath: true,\n\t},\n\t{ verb: \"Ran\", noun: \"command\", tail: \"command\", tools: [\"bash\"], subjectIsPath: false, act: commandAct },\n\t{ verb: \"Delegated\", noun: \"task\", tail: \"task\", tools: [\"Task\", \"TaskOutput\"], subjectIsPath: false },\n\t{ verb: \"Fetched\", noun: \"page\", tail: \"fetch\", tools: [\"webfetch\", \"websearch\"], subjectIsPath: false },\n\t{ verb: \"Read\", noun: \"file\", tail: \"read\", tools: [\"read\"], subjectIsPath: true },\n\t{ verb: \"Searched\", noun: \"search\", tail: \"search\", tools: [\"grep\", \"find\", \"search\", \"ls\"], subjectIsPath: true },\n];\n\n/**\n * Calls from here up, a chain is long enough that priority order alone starts\n * lying, and the three rules below switch on.\n *\n * Below it the existing behaviour is left exactly as it was. Measured over this\n * repo's own sessions, 87% of chains are three calls or fewer and 75% touch a\n * single family; the failure these rules address does not exist down there, so\n * neither should the rules.\n */\nconst LONG_CHAIN_CALLS = 10;\n\n/**\n * A family this far below the chain's own size did not characterise it.\n *\n * Priority order says a chain that read six files and changed one is the edit,\n * and at seven calls that is true. At seventy-three it is not: 28 greps, 37\n * reads and one edit is an investigation that ended in a small change, and\n * calling it \"Edited subagent.ts\" describes 1 call out of 73.\n */\nconst INCIDENTAL_SHARE = 0.1;\n\n/** Below this, a secondary family is a footnote and not worth the width. */\nconst MIN_SECONDARY_CALLS = 3;\n\n/**\n * Longest common directory prefix of the given paths, or \"\" when they share none.\n *\n * `long` chains additionally reject a single-segment prefix: across forty files\n * the shared root collapses to something like `packages`, which names a location\n * so broad it is worse than the count it displaced. Short chains keep theirs —\n * `Edited docs` is a genuine location, and three files rarely share a vague one.\n */\nfunction commonPathPrefix(paths: string[], long: boolean): string {\n\tif (paths.length === 0) return \"\";\n\tconst split = paths.map((p) => p.split(\"/\").filter(Boolean));\n\tconst [first, ...rest] = split;\n\tconst prefix: string[] = [];\n\tfor (let i = 0; i < first.length; i++) {\n\t\tif (rest.every((parts) => parts[i] === first[i])) prefix.push(first[i]);\n\t\telse break;\n\t}\n\tif (prefix.length === 0) return \"\";\n\tif (long && prefix.length < 2) return \"\";\n\tconst joined = prefix.join(\"/\");\n\t// `filter(Boolean)` drops the empty leading segment of an absolute path.\n\treturn paths[0].startsWith(\"/\") ? `/${joined}` : joined;\n}\n\n/**\n * Whether a subject can stand in for a location.\n *\n * Globs are excluded even though they look pathlike: \"Searched *.test.ts\" reads\n * as a place the chain worked in, which is exactly what it is not.\n */\nfunction usableAsTarget(subject: string): boolean {\n\tif (/[*?]/.test(subject)) return false;\n\treturn subject.includes(\"/\") || /\\.[a-zA-Z0-9]+$/.test(subject);\n}\n\n/** The headline clause for one family's calls. */\nfunction familyPhrase(family: ToolFamily, matched: ChainEntry[], long: boolean): string {\n\tconst subjects = matched.map((e) => (family.act ? family.act(e.subject) : e.subject)).filter(Boolean);\n\n\t// A lone call is fully described by its own subject, whatever shape it is:\n\t// a command, a pattern, a URL, a path. So is a run that did the same thing\n\t// every time — `bun run check` twice is that command, not \"2 commands\".\n\tif (subjects.length === matched.length && new Set(subjects).size === 1) {\n\t\treturn `${family.verb} ${subjects[0]}`;\n\t}\n\n\t// Several calls: name the place they share, or fall back to a count.\n\tconst target = family.subjectIsPath ? commonPathPrefix(subjects.filter(usableAsTarget), long) : \"\";\n\tif (target) return `${family.verb} ${target}`;\n\t// Searching has no natural plural noun (\"3 searches\" says nothing the\n\t// stats do not), so the bare verb carries it.\n\tif (family.noun === \"search\") return \"Explored\";\n\treturn `${family.verb} ${plural(matched.length, family.noun)}`;\n}\n\n/** The family with the most calls other than `chosen`, if any reaches the floor. */\nfunction largestOtherFamily(\n\tentries: ChainEntry[],\n\tchosen: ToolFamily,\n): { family: ToolFamily; count: number } | undefined {\n\tlet best: { family: ToolFamily; count: number } | undefined;\n\tfor (const family of FAMILIES) {\n\t\tif (family === chosen) continue;\n\t\tconst count = entries.filter((e) => family.tools.includes(e.tool)).length;\n\t\tif (count > (best?.count ?? 0)) best = { family, count };\n\t}\n\treturn best && best.count >= MIN_SECONDARY_CALLS ? best : undefined;\n}\n\n/**\n * The settled chain's phrase: what this run amounted to.\n *\n * Deliberately close to one clause, not an inventory. \"Edited packages/tui, read\n * 6 files, searched packages\" is a worse line than \"Edited packages/tui\" — the\n * count is already in the stats, and the per-call detail is one `alt+u` away.\n *\n * A long chain earns at most one more clause, and only when its headline family\n * is a minority of the calls. That is the case where a single clause stops being\n * terse and starts being false: 31 edits among 182 calls is worth naming, but so\n * is the fact that 76 of the rest were commands. Listing every family it touched\n * is not the alternative — a chain that reports \"(ran, delegated, read,\n * searched)\" has said only that it was busy.\n *\n * A location is only named when the calls actually share one. Naming a single\n * arbitrary file out of five would read as a claim the chain never made, so\n * several unrelated targets collapse to a count instead.\n */\nexport function chainPhrase(entries: ChainEntry[]): string {\n\tif (entries.length === 0) return \"No calls\";\n\tconst long = entries.length >= LONG_CHAIN_CALLS;\n\n\tlet chosen: ToolFamily | undefined;\n\tlet matched: ChainEntry[] = [];\n\tfor (const family of FAMILIES) {\n\t\tconst m = entries.filter((e) => family.tools.includes(e.tool));\n\t\tif (m.length === 0) continue;\n\t\tif (long && m.length / entries.length < INCIDENTAL_SHARE) continue;\n\t\tchosen = family;\n\t\tmatched = m;\n\t\tbreak;\n\t}\n\n\t// Every family was incidental — a long chain spread thin across many tools.\n\t// The largest still describes it better than the priority order's first hit.\n\tif (!chosen) {\n\t\tfor (const family of FAMILIES) {\n\t\t\tconst m = entries.filter((e) => family.tools.includes(e.tool));\n\t\t\tif (m.length > matched.length) {\n\t\t\t\tchosen = family;\n\t\t\t\tmatched = m;\n\t\t\t}\n\t\t}\n\t}\n\n\t// An unrecognised tool set still gets a line rather than a blank.\n\tif (!chosen) {\n\t\tconst names = [...new Set(entries.map((e) => e.tool))];\n\t\treturn names.length === 1 ? `Called ${names[0]}` : `Called ${plural(names.length, \"tool\")}`;\n\t}\n\n\tconst head = familyPhrase(chosen, matched, long);\n\tif (!long || matched.length / entries.length >= 0.5) return head;\n\tconst secondary = largestOtherFamily(entries, chosen);\n\treturn secondary ? `${head} · ${plural(secondary.count, secondary.family.tail)}` : head;\n}\n"]}
|