{"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"]}