apex-code 0.5.2 → 0.6.1
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 +24 -0
- package/README.md +41 -2
- package/dist/core/agent-session.d.ts +37 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +264 -60
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/default-tool-names.d.ts +7 -0
- package/dist/core/default-tool-names.d.ts.map +1 -0
- package/dist/core/default-tool-names.js +14 -0
- package/dist/core/default-tool-names.js.map +1 -0
- package/dist/core/extensions/runner.d.ts.map +1 -1
- package/dist/core/extensions/runner.js +2 -0
- package/dist/core/extensions/runner.js.map +1 -1
- package/dist/core/extensions/types.d.ts +3 -0
- package/dist/core/extensions/types.d.ts.map +1 -1
- package/dist/core/extensions/types.js.map +1 -1
- package/dist/core/keybindings.d.ts +16 -1
- package/dist/core/keybindings.d.ts.map +1 -1
- package/dist/core/keybindings.js +7 -1
- package/dist/core/keybindings.js.map +1 -1
- package/dist/core/permissions/startup.d.ts +1 -1
- package/dist/core/permissions/startup.d.ts.map +1 -1
- package/dist/core/permissions/startup.js.map +1 -1
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +13 -9
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/settings-manager.d.ts +4 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +13 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/slash-commands.d.ts.map +1 -1
- package/dist/core/slash-commands.js +1 -0
- package/dist/core/slash-commands.js.map +1 -1
- package/dist/core/tools/contract.d.ts +1 -0
- package/dist/core/tools/contract.d.ts.map +1 -1
- package/dist/core/tools/contract.js.map +1 -1
- package/dist/core/tools/index.d.ts +1 -1
- package/dist/core/tools/index.d.ts.map +1 -1
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/plan-present.d.ts +13 -12
- package/dist/core/tools/plan-present.d.ts.map +1 -1
- package/dist/core/tools/plan-present.js +32 -19
- package/dist/core/tools/plan-present.js.map +1 -1
- package/dist/core/tools/renderers/index.d.ts +1 -1
- package/dist/core/tools/renderers/index.d.ts.map +1 -1
- package/dist/core/tools/renderers/index.js +2 -0
- package/dist/core/tools/renderers/index.js.map +1 -1
- package/dist/core/tools/renderers/todo-write.d.ts +7 -0
- package/dist/core/tools/renderers/todo-write.d.ts.map +1 -0
- package/dist/core/tools/renderers/todo-write.js +58 -0
- package/dist/core/tools/renderers/todo-write.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/modes/interactive/components/bash-execution.d.ts +13 -2
- package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/bash-execution.js +56 -1
- package/dist/modes/interactive/components/bash-execution.js.map +1 -1
- package/dist/modes/interactive/components/error-summary.d.ts +2 -0
- package/dist/modes/interactive/components/error-summary.d.ts.map +1 -1
- package/dist/modes/interactive/components/error-summary.js +10 -0
- package/dist/modes/interactive/components/error-summary.js.map +1 -1
- package/dist/modes/interactive/components/first-use-hints.d.ts +1 -1
- package/dist/modes/interactive/components/first-use-hints.d.ts.map +1 -1
- package/dist/modes/interactive/components/first-use-hints.js +1 -0
- package/dist/modes/interactive/components/first-use-hints.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts +6 -1
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +38 -11
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/settings-selector.d.ts +5 -0
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +20 -1
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/components/task-panel.d.ts +27 -0
- package/dist/modes/interactive/components/task-panel.d.ts.map +1 -0
- package/dist/modes/interactive/components/task-panel.js +92 -0
- package/dist/modes/interactive/components/task-panel.js.map +1 -0
- package/dist/modes/interactive/components/tool-execution.d.ts +9 -1
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution.js +81 -8
- package/dist/modes/interactive/components/tool-execution.js.map +1 -1
- package/dist/modes/interactive/components/tool-panel.d.ts +7 -0
- package/dist/modes/interactive/components/tool-panel.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-panel.js +35 -0
- package/dist/modes/interactive/components/tool-panel.js.map +1 -1
- package/dist/modes/interactive/components/turn-summary.d.ts +6 -0
- package/dist/modes/interactive/components/turn-summary.d.ts.map +1 -0
- package/dist/modes/interactive/components/turn-summary.js +12 -0
- package/dist/modes/interactive/components/turn-summary.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +10 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +191 -22
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/docs/keybindings.md +21 -2
- package/docs/settings.md +8 -0
- package/npm-shrinkwrap.json +5 -5
- package/package.json +2 -2
|
@@ -6,7 +6,9 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail, } from "../../../co
|
|
|
6
6
|
import { stripAnsi } from "../../../utils/ansi.js";
|
|
7
7
|
import { theme } from "../theme/theme.js";
|
|
8
8
|
import { DynamicBorder } from "./dynamic-border.js";
|
|
9
|
+
import { summarizeOperationError } from "./error-summary.js";
|
|
9
10
|
import { formatHiddenLines, keyHint, keyText, previewLineCount } from "./keybinding-hints.js";
|
|
11
|
+
import { countOutputLines, renderCompactOperationRow } from "./tool-panel.js";
|
|
10
12
|
import { truncateToVisualLines } from "./visual-truncate.js";
|
|
11
13
|
// Preview line limit when not expanded (matches tool execution behavior)
|
|
12
14
|
const PREVIEW_LINES = 20;
|
|
@@ -19,13 +21,18 @@ export class BashExecutionComponent extends Container {
|
|
|
19
21
|
truncationResult;
|
|
20
22
|
fullOutputPath;
|
|
21
23
|
expanded = false;
|
|
24
|
+
collapsedOverride = false;
|
|
25
|
+
chatDetail = "details";
|
|
26
|
+
symbolPreset;
|
|
22
27
|
contentContainer;
|
|
23
28
|
// Display rebuild cache: appendOutput and invalidate() fire far more often
|
|
24
29
|
// than the displayed state changes, and the rebuild is allocation-heavy.
|
|
25
30
|
displayState;
|
|
26
|
-
|
|
31
|
+
compactSummary = "0 output lines";
|
|
32
|
+
constructor(command, ui, excludeFromContext = false, symbolPreset = "unicode") {
|
|
27
33
|
super();
|
|
28
34
|
this.command = command;
|
|
35
|
+
this.symbolPreset = symbolPreset;
|
|
29
36
|
// Use dim border for excluded-from-context commands (!! prefix)
|
|
30
37
|
const colorKey = excludeFromContext ? "dim" : "bashMode";
|
|
31
38
|
const borderColor = (str) => theme.fg(colorKey, str);
|
|
@@ -49,9 +56,49 @@ export class BashExecutionComponent extends Container {
|
|
|
49
56
|
* Set whether the output is expanded (shows full output) or collapsed (preview only).
|
|
50
57
|
*/
|
|
51
58
|
setExpanded(expanded) {
|
|
59
|
+
this.collapsedOverride = false;
|
|
52
60
|
this.expanded = expanded;
|
|
53
61
|
this.updateDisplay();
|
|
54
62
|
}
|
|
63
|
+
setChatDetail(detail) {
|
|
64
|
+
this.chatDetail = detail;
|
|
65
|
+
this.setExpanded(detail === "all");
|
|
66
|
+
}
|
|
67
|
+
toggleExpanded() {
|
|
68
|
+
this.expanded = !this.expanded;
|
|
69
|
+
this.collapsedOverride = !this.expanded;
|
|
70
|
+
this.updateDisplay();
|
|
71
|
+
}
|
|
72
|
+
handleMouse(event) {
|
|
73
|
+
if (event.type !== "click" || event.button !== "left" || event.y < 0 || event.y >= event.height)
|
|
74
|
+
return undefined;
|
|
75
|
+
if (event.y !== (this.isCompact() ? 0 : 2))
|
|
76
|
+
return undefined;
|
|
77
|
+
this.toggleExpanded();
|
|
78
|
+
return {
|
|
79
|
+
handled: true,
|
|
80
|
+
target: {
|
|
81
|
+
component: this,
|
|
82
|
+
originX: event.screenX - event.x,
|
|
83
|
+
originY: event.screenY - event.y,
|
|
84
|
+
width: event.width,
|
|
85
|
+
height: event.height,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
isCompact() {
|
|
90
|
+
return this.collapsedOverride || (this.chatDetail === "overview" && !this.expanded);
|
|
91
|
+
}
|
|
92
|
+
render(width) {
|
|
93
|
+
if (!this.isCompact())
|
|
94
|
+
return super.render(width);
|
|
95
|
+
return renderCompactOperationRow({
|
|
96
|
+
label: `$ ${this.command}`,
|
|
97
|
+
lifecycle: this.status === "running" ? "running" : this.status === "complete" ? "done" : "error",
|
|
98
|
+
symbolPreset: this.symbolPreset,
|
|
99
|
+
summary: this.compactSummary,
|
|
100
|
+
}, width);
|
|
101
|
+
}
|
|
55
102
|
invalidate() {
|
|
56
103
|
super.invalidate();
|
|
57
104
|
this.updateDisplay();
|
|
@@ -97,6 +144,14 @@ export class BashExecutionComponent extends Container {
|
|
|
97
144
|
this.displayState = { key };
|
|
98
145
|
// Apply truncation for LLM context limits (same limits as bash tool)
|
|
99
146
|
const fullOutput = this.outputLines.join("\n");
|
|
147
|
+
const outputLineCount = countOutputLines(fullOutput);
|
|
148
|
+
const diagnostic = this.status === "error" ? summarizeOperationError(fullOutput) : undefined;
|
|
149
|
+
this.compactSummary =
|
|
150
|
+
this.status === "cancelled"
|
|
151
|
+
? "cancelled"
|
|
152
|
+
: this.status === "error"
|
|
153
|
+
? `exit ${this.exitCode}${diagnostic ? `: ${diagnostic}` : ""}`
|
|
154
|
+
: `${outputLineCount} output line${outputLineCount === 1 ? "" : "s"}`;
|
|
100
155
|
const contextTruncation = truncateTail(fullOutput, {
|
|
101
156
|
maxLines: DEFAULT_MAX_LINES,
|
|
102
157
|
maxBytes: DEFAULT_MAX_BYTES,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bash-execution.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/bash-execution.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Component for displaying bash command execution with streaming output.\n */\n\nimport { Container, Loader, Spacer, Text, type TUI } from \"@earendil-works/pi-tui\";\nimport {\n\tDEFAULT_MAX_BYTES,\n\tDEFAULT_MAX_LINES,\n\ttype TruncationResult,\n\ttruncateTail,\n} from \"../../../core/tools/truncate.ts\";\nimport { stripAnsi } from \"../../../utils/ansi.ts\";\nimport { theme } from \"../theme/theme.ts\";\nimport { DynamicBorder } from \"./dynamic-border.ts\";\nimport { formatHiddenLines, keyHint, keyText, previewLineCount } from \"./keybinding-hints.ts\";\nimport { truncateToVisualLines } from \"./visual-truncate.ts\";\n\n// Preview line limit when not expanded (matches tool execution behavior)\nconst PREVIEW_LINES = 20;\n\nexport class BashExecutionComponent extends Container {\n\tprivate command: string;\n\tprivate outputLines: string[] = [];\n\tprivate status: \"running\" | \"complete\" | \"cancelled\" | \"error\" = \"running\";\n\tprivate exitCode: number | undefined = undefined;\n\tprivate loader: Loader;\n\tprivate truncationResult?: TruncationResult;\n\tprivate fullOutputPath?: string;\n\tprivate expanded = false;\n\tprivate contentContainer: Container;\n\t// Display rebuild cache: appendOutput and invalidate() fire far more often\n\t// than the displayed state changes, and the rebuild is allocation-heavy.\n\tprivate displayState?: { key: string };\n\n\tconstructor(command: string, ui: TUI, excludeFromContext = false) {\n\t\tsuper();\n\t\tthis.command = command;\n\n\t\t// Use dim border for excluded-from-context commands (!! prefix)\n\t\tconst colorKey = excludeFromContext ? \"dim\" : \"bashMode\";\n\t\tconst borderColor = (str: string) => theme.fg(colorKey, str);\n\n\t\t// Add spacer\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Top border\n\t\tthis.addChild(new DynamicBorder(borderColor));\n\n\t\t// Content container (holds dynamic content between borders)\n\t\tthis.contentContainer = new Container();\n\t\tthis.addChild(this.contentContainer);\n\n\t\t// Command header\n\t\tconst header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);\n\t\tthis.contentContainer.addChild(header);\n\n\t\t// Loader\n\t\tthis.loader = new Loader(\n\t\t\tui,\n\t\t\t(spinner) => theme.fg(colorKey, spinner),\n\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t`Running... (${keyText(\"tui.select.cancel\")} to cancel)`, // Plain text for loader\n\t\t);\n\t\tthis.contentContainer.addChild(this.loader);\n\n\t\t// Bottom border\n\t\tthis.addChild(new DynamicBorder(borderColor));\n\t}\n\n\t/**\n\t * Set whether the output is expanded (shows full output) or collapsed (preview only).\n\t */\n\tsetExpanded(expanded: boolean): void {\n\t\tthis.expanded = expanded;\n\t\tthis.updateDisplay();\n\t}\n\n\toverride invalidate(): void {\n\t\tsuper.invalidate();\n\t\tthis.updateDisplay();\n\t}\n\n\tappendOutput(chunk: string): void {\n\t\t// Strip ANSI codes and normalize line endings\n\t\t// Note: binary data is already sanitized in tui-renderer.ts executeBashCommand\n\t\tconst clean = stripAnsi(chunk).replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n\n\t\t// Append to output lines\n\t\tconst newLines = clean.split(\"\\n\");\n\t\tif (this.outputLines.length > 0 && newLines.length > 0) {\n\t\t\t// Append first chunk to last line (incomplete line continuation)\n\t\t\tthis.outputLines[this.outputLines.length - 1] += newLines[0];\n\t\t\tthis.outputLines.push(...newLines.slice(1));\n\t\t} else {\n\t\t\tthis.outputLines.push(...newLines);\n\t\t}\n\n\t\tthis.updateDisplay();\n\t}\n\n\tsetComplete(\n\t\texitCode: number | undefined,\n\t\tcancelled: boolean,\n\t\ttruncationResult?: TruncationResult,\n\t\tfullOutputPath?: string,\n\t): void {\n\t\tthis.exitCode = exitCode;\n\t\tthis.status = cancelled\n\t\t\t? \"cancelled\"\n\t\t\t: exitCode !== 0 && exitCode !== undefined && exitCode !== null\n\t\t\t\t? \"error\"\n\t\t\t\t: \"complete\";\n\t\tthis.truncationResult = truncationResult;\n\t\tthis.fullOutputPath = fullOutputPath;\n\n\t\t// Stop loader\n\t\tthis.loader.stop();\n\n\t\tthis.updateDisplay();\n\t}\n\n\tprivate updateDisplay(): void {\n\t\t// Skip the rebuild when nothing observable changed: appendOutput runs\n\t\t// per output chunk and invalidate() runs per TUI invalidation, while\n\t\t// the display only depends on line count, expansion, and completion\n\t\t// state. The last line's length moves with intra-line appends.\n\t\tconst lastLineLength = this.outputLines[this.outputLines.length - 1]?.length ?? 0;\n\t\tconst key = `${this.outputLines.length}:${lastLineLength}:${this.expanded}:${this.status}:${this.exitCode ?? \"none\"}:${this.truncationResult !== undefined}:${this.fullOutputPath !== undefined}`;\n\t\tif (this.displayState?.key === key) return;\n\t\tthis.displayState = { key };\n\n\t\t// Apply truncation for LLM context limits (same limits as bash tool)\n\t\tconst fullOutput = this.outputLines.join(\"\\n\");\n\t\tconst contextTruncation = truncateTail(fullOutput, {\n\t\t\tmaxLines: DEFAULT_MAX_LINES,\n\t\t\tmaxBytes: DEFAULT_MAX_BYTES,\n\t\t});\n\n\t\t// Get the lines to potentially display (after context truncation)\n\t\tconst availableLines = contextTruncation.content ? contextTruncation.content.split(\"\\n\") : [];\n\n\t\t// Apply preview truncation based on expanded state\n\t\tconst previewLogicalLines = availableLines.slice(-previewLineCount(availableLines.length, PREVIEW_LINES));\n\t\tconst hiddenLineCount = availableLines.length - previewLogicalLines.length;\n\n\t\t// Rebuild content container\n\t\tthis.contentContainer.clear();\n\n\t\t// Command header\n\t\tconst header = new Text(theme.fg(\"bashMode\", theme.bold(`$ ${this.command}`)), 1, 0);\n\t\tthis.contentContainer.addChild(header);\n\n\t\t// Output\n\t\tif (availableLines.length > 0) {\n\t\t\t// A preview that hides no line shows all of it. Capping its rows as well would cut\n\t\t\t// wrapped lines with nothing to say so, since the hint counts hidden lines.\n\t\t\tif (this.expanded || hiddenLineCount === 0) {\n\t\t\t\t// Show all lines\n\t\t\t\tconst displayText = availableLines.map((line) => theme.fg(\"muted\", line)).join(\"\\n\");\n\t\t\t\tthis.contentContainer.addChild(new Text(`\\n${displayText}`, 1, 0));\n\t\t\t} else {\n\t\t\t\t// Use shared visual truncation utility with width-aware caching\n\t\t\t\tconst styledOutput = previewLogicalLines.map((line) => theme.fg(\"muted\", line)).join(\"\\n\");\n\t\t\t\tconst styledInput = `\\n${styledOutput}`;\n\t\t\t\tlet cachedWidth: number | undefined;\n\t\t\t\tlet cachedLines: string[] | undefined;\n\t\t\t\tthis.contentContainer.addChild({\n\t\t\t\t\trender: (width: number) => {\n\t\t\t\t\t\tif (cachedLines === undefined || cachedWidth !== width) {\n\t\t\t\t\t\t\tconst result = truncateToVisualLines(styledInput, PREVIEW_LINES, width, 1);\n\t\t\t\t\t\t\tcachedLines = result.visualLines;\n\t\t\t\t\t\t\tcachedWidth = width;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn cachedLines ?? [];\n\t\t\t\t\t},\n\t\t\t\t\tinvalidate: () => {\n\t\t\t\t\t\tcachedWidth = undefined;\n\t\t\t\t\t\tcachedLines = undefined;\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\t// Loader or status\n\t\tif (this.status === \"running\") {\n\t\t\tthis.contentContainer.addChild(this.loader);\n\t\t} else {\n\t\t\tconst statusParts: string[] = [];\n\n\t\t\t// Show how many lines are hidden (collapsed preview)\n\t\t\tif (hiddenLineCount > 0) {\n\t\t\t\tif (this.expanded) {\n\t\t\t\t\tstatusParts.push(\n\t\t\t\t\t\t`${theme.fg(\"muted\", \"(\")}${keyHint(\"app.tools.expand\", \"to collapse\")}${theme.fg(\"muted\", \")\")}`,\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tstatusParts.push(formatHiddenLines(hiddenLineCount, \"earlier\"));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (this.status === \"cancelled\") {\n\t\t\t\tstatusParts.push(theme.fg(\"warning\", \"(cancelled)\"));\n\t\t\t} else if (this.status === \"error\") {\n\t\t\t\tstatusParts.push(theme.fg(\"error\", `(exit ${this.exitCode})`));\n\t\t\t}\n\n\t\t\t// Add truncation warning (context truncation, not preview truncation)\n\t\t\tconst wasTruncated = this.truncationResult?.truncated || contextTruncation.truncated;\n\t\t\tif (wasTruncated && this.fullOutputPath) {\n\t\t\t\tstatusParts.push(theme.fg(\"warning\", `Output truncated. Full output: ${this.fullOutputPath}`));\n\t\t\t}\n\n\t\t\tif (statusParts.length > 0) {\n\t\t\t\tthis.contentContainer.addChild(new Text(`\\n${statusParts.join(\"\\n\")}`, 1, 0));\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Get the raw output for creating BashExecutionMessage.\n\t */\n\tgetOutput(): string {\n\t\treturn this.outputLines.join(\"\\n\");\n\t}\n\n\t/**\n\t * Get the command that was executed.\n\t */\n\tgetCommand(): string {\n\t\treturn this.command;\n\t}\n\n\t/**\n\t * Stop the animated loader. Session swaps clear containers wholesale,\n\t * which detaches children without stopping their timers; an in-flight\n\t * component left uncompleted would otherwise animate forever.\n\t */\n\tdispose(): void {\n\t\tthis.loader.stop();\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"bash-execution.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/bash-execution.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ChatDetail } from \"../../../core/settings-manager.ts\";\n/**\n * Component for displaying bash command execution with streaming output.\n */\n\nimport { Container, Loader, Spacer, Text, type TUI, type TuiMouseEvent } from \"@earendil-works/pi-tui\";\nimport {\n\tDEFAULT_MAX_BYTES,\n\tDEFAULT_MAX_LINES,\n\ttype TruncationResult,\n\ttruncateTail,\n} from \"../../../core/tools/truncate.ts\";\nimport { stripAnsi } from \"../../../utils/ansi.ts\";\nimport { theme } from \"../theme/theme.ts\";\nimport { DynamicBorder } from \"./dynamic-border.ts\";\nimport { summarizeOperationError } from \"./error-summary.ts\";\nimport { formatHiddenLines, keyHint, keyText, previewLineCount } from \"./keybinding-hints.ts\";\nimport { countOutputLines, renderCompactOperationRow, type ToolSymbolPreset } from \"./tool-panel.ts\";\nimport { truncateToVisualLines } from \"./visual-truncate.ts\";\n\n// Preview line limit when not expanded (matches tool execution behavior)\nconst PREVIEW_LINES = 20;\n\nexport class BashExecutionComponent extends Container {\n\tprivate command: string;\n\tprivate outputLines: string[] = [];\n\tprivate status: \"running\" | \"complete\" | \"cancelled\" | \"error\" = \"running\";\n\tprivate exitCode: number | undefined = undefined;\n\tprivate loader: Loader;\n\tprivate truncationResult?: TruncationResult;\n\tprivate fullOutputPath?: string;\n\tprivate expanded = false;\n\tprivate collapsedOverride = false;\n\tprivate chatDetail: ChatDetail = \"details\";\n\tprivate symbolPreset: ToolSymbolPreset;\n\tprivate contentContainer: Container;\n\t// Display rebuild cache: appendOutput and invalidate() fire far more often\n\t// than the displayed state changes, and the rebuild is allocation-heavy.\n\tprivate displayState?: { key: string };\n\tprivate compactSummary = \"0 output lines\";\n\n\tconstructor(command: string, ui: TUI, excludeFromContext = false, symbolPreset: ToolSymbolPreset = \"unicode\") {\n\t\tsuper();\n\t\tthis.command = command;\n\t\tthis.symbolPreset = symbolPreset;\n\n\t\t// Use dim border for excluded-from-context commands (!! prefix)\n\t\tconst colorKey = excludeFromContext ? \"dim\" : \"bashMode\";\n\t\tconst borderColor = (str: string) => theme.fg(colorKey, str);\n\n\t\t// Add spacer\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Top border\n\t\tthis.addChild(new DynamicBorder(borderColor));\n\n\t\t// Content container (holds dynamic content between borders)\n\t\tthis.contentContainer = new Container();\n\t\tthis.addChild(this.contentContainer);\n\n\t\t// Command header\n\t\tconst header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);\n\t\tthis.contentContainer.addChild(header);\n\n\t\t// Loader\n\t\tthis.loader = new Loader(\n\t\t\tui,\n\t\t\t(spinner) => theme.fg(colorKey, spinner),\n\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t`Running... (${keyText(\"tui.select.cancel\")} to cancel)`, // Plain text for loader\n\t\t);\n\t\tthis.contentContainer.addChild(this.loader);\n\n\t\t// Bottom border\n\t\tthis.addChild(new DynamicBorder(borderColor));\n\t}\n\n\t/**\n\t * Set whether the output is expanded (shows full output) or collapsed (preview only).\n\t */\n\tsetExpanded(expanded: boolean): void {\n\t\tthis.collapsedOverride = false;\n\t\tthis.expanded = expanded;\n\t\tthis.updateDisplay();\n\t}\n\n\tsetChatDetail(detail: ChatDetail): void {\n\t\tthis.chatDetail = detail;\n\t\tthis.setExpanded(detail === \"all\");\n\t}\n\n\ttoggleExpanded(): void {\n\t\tthis.expanded = !this.expanded;\n\t\tthis.collapsedOverride = !this.expanded;\n\t\tthis.updateDisplay();\n\t}\n\n\toverride handleMouse(event: TuiMouseEvent): ReturnType<Container[\"handleMouse\"]> {\n\t\tif (event.type !== \"click\" || event.button !== \"left\" || event.y < 0 || event.y >= event.height) return undefined;\n\t\tif (event.y !== (this.isCompact() ? 0 : 2)) return undefined;\n\t\tthis.toggleExpanded();\n\t\treturn {\n\t\t\thandled: true,\n\t\t\ttarget: {\n\t\t\t\tcomponent: this,\n\t\t\t\toriginX: event.screenX - event.x,\n\t\t\t\toriginY: event.screenY - event.y,\n\t\t\t\twidth: event.width,\n\t\t\t\theight: event.height,\n\t\t\t},\n\t\t};\n\t}\n\n\tprivate isCompact(): boolean {\n\t\treturn this.collapsedOverride || (this.chatDetail === \"overview\" && !this.expanded);\n\t}\n\n\toverride render(width: number): string[] {\n\t\tif (!this.isCompact()) return super.render(width);\n\t\treturn renderCompactOperationRow(\n\t\t\t{\n\t\t\t\tlabel: `$ ${this.command}`,\n\t\t\t\tlifecycle: this.status === \"running\" ? \"running\" : this.status === \"complete\" ? \"done\" : \"error\",\n\t\t\t\tsymbolPreset: this.symbolPreset,\n\t\t\t\tsummary: this.compactSummary,\n\t\t\t},\n\t\t\twidth,\n\t\t);\n\t}\n\n\toverride invalidate(): void {\n\t\tsuper.invalidate();\n\t\tthis.updateDisplay();\n\t}\n\n\tappendOutput(chunk: string): void {\n\t\t// Strip ANSI codes and normalize line endings\n\t\t// Note: binary data is already sanitized in tui-renderer.ts executeBashCommand\n\t\tconst clean = stripAnsi(chunk).replace(/\\r\\n/g, \"\\n\").replace(/\\r/g, \"\\n\");\n\n\t\t// Append to output lines\n\t\tconst newLines = clean.split(\"\\n\");\n\t\tif (this.outputLines.length > 0 && newLines.length > 0) {\n\t\t\t// Append first chunk to last line (incomplete line continuation)\n\t\t\tthis.outputLines[this.outputLines.length - 1] += newLines[0];\n\t\t\tthis.outputLines.push(...newLines.slice(1));\n\t\t} else {\n\t\t\tthis.outputLines.push(...newLines);\n\t\t}\n\n\t\tthis.updateDisplay();\n\t}\n\n\tsetComplete(\n\t\texitCode: number | undefined,\n\t\tcancelled: boolean,\n\t\ttruncationResult?: TruncationResult,\n\t\tfullOutputPath?: string,\n\t): void {\n\t\tthis.exitCode = exitCode;\n\t\tthis.status = cancelled\n\t\t\t? \"cancelled\"\n\t\t\t: exitCode !== 0 && exitCode !== undefined && exitCode !== null\n\t\t\t\t? \"error\"\n\t\t\t\t: \"complete\";\n\t\tthis.truncationResult = truncationResult;\n\t\tthis.fullOutputPath = fullOutputPath;\n\n\t\t// Stop loader\n\t\tthis.loader.stop();\n\n\t\tthis.updateDisplay();\n\t}\n\n\tprivate updateDisplay(): void {\n\t\t// Skip the rebuild when nothing observable changed: appendOutput runs\n\t\t// per output chunk and invalidate() runs per TUI invalidation, while\n\t\t// the display only depends on line count, expansion, and completion\n\t\t// state. The last line's length moves with intra-line appends.\n\t\tconst lastLineLength = this.outputLines[this.outputLines.length - 1]?.length ?? 0;\n\t\tconst key = `${this.outputLines.length}:${lastLineLength}:${this.expanded}:${this.status}:${this.exitCode ?? \"none\"}:${this.truncationResult !== undefined}:${this.fullOutputPath !== undefined}`;\n\t\tif (this.displayState?.key === key) return;\n\t\tthis.displayState = { key };\n\n\t\t// Apply truncation for LLM context limits (same limits as bash tool)\n\t\tconst fullOutput = this.outputLines.join(\"\\n\");\n\t\tconst outputLineCount = countOutputLines(fullOutput);\n\t\tconst diagnostic = this.status === \"error\" ? summarizeOperationError(fullOutput) : undefined;\n\t\tthis.compactSummary =\n\t\t\tthis.status === \"cancelled\"\n\t\t\t\t? \"cancelled\"\n\t\t\t\t: this.status === \"error\"\n\t\t\t\t\t? `exit ${this.exitCode}${diagnostic ? `: ${diagnostic}` : \"\"}`\n\t\t\t\t\t: `${outputLineCount} output line${outputLineCount === 1 ? \"\" : \"s\"}`;\n\t\tconst contextTruncation = truncateTail(fullOutput, {\n\t\t\tmaxLines: DEFAULT_MAX_LINES,\n\t\t\tmaxBytes: DEFAULT_MAX_BYTES,\n\t\t});\n\n\t\t// Get the lines to potentially display (after context truncation)\n\t\tconst availableLines = contextTruncation.content ? contextTruncation.content.split(\"\\n\") : [];\n\n\t\t// Apply preview truncation based on expanded state\n\t\tconst previewLogicalLines = availableLines.slice(-previewLineCount(availableLines.length, PREVIEW_LINES));\n\t\tconst hiddenLineCount = availableLines.length - previewLogicalLines.length;\n\n\t\t// Rebuild content container\n\t\tthis.contentContainer.clear();\n\n\t\t// Command header\n\t\tconst header = new Text(theme.fg(\"bashMode\", theme.bold(`$ ${this.command}`)), 1, 0);\n\t\tthis.contentContainer.addChild(header);\n\n\t\t// Output\n\t\tif (availableLines.length > 0) {\n\t\t\t// A preview that hides no line shows all of it. Capping its rows as well would cut\n\t\t\t// wrapped lines with nothing to say so, since the hint counts hidden lines.\n\t\t\tif (this.expanded || hiddenLineCount === 0) {\n\t\t\t\t// Show all lines\n\t\t\t\tconst displayText = availableLines.map((line) => theme.fg(\"muted\", line)).join(\"\\n\");\n\t\t\t\tthis.contentContainer.addChild(new Text(`\\n${displayText}`, 1, 0));\n\t\t\t} else {\n\t\t\t\t// Use shared visual truncation utility with width-aware caching\n\t\t\t\tconst styledOutput = previewLogicalLines.map((line) => theme.fg(\"muted\", line)).join(\"\\n\");\n\t\t\t\tconst styledInput = `\\n${styledOutput}`;\n\t\t\t\tlet cachedWidth: number | undefined;\n\t\t\t\tlet cachedLines: string[] | undefined;\n\t\t\t\tthis.contentContainer.addChild({\n\t\t\t\t\trender: (width: number) => {\n\t\t\t\t\t\tif (cachedLines === undefined || cachedWidth !== width) {\n\t\t\t\t\t\t\tconst result = truncateToVisualLines(styledInput, PREVIEW_LINES, width, 1);\n\t\t\t\t\t\t\tcachedLines = result.visualLines;\n\t\t\t\t\t\t\tcachedWidth = width;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn cachedLines ?? [];\n\t\t\t\t\t},\n\t\t\t\t\tinvalidate: () => {\n\t\t\t\t\t\tcachedWidth = undefined;\n\t\t\t\t\t\tcachedLines = undefined;\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\t// Loader or status\n\t\tif (this.status === \"running\") {\n\t\t\tthis.contentContainer.addChild(this.loader);\n\t\t} else {\n\t\t\tconst statusParts: string[] = [];\n\n\t\t\t// Show how many lines are hidden (collapsed preview)\n\t\t\tif (hiddenLineCount > 0) {\n\t\t\t\tif (this.expanded) {\n\t\t\t\t\tstatusParts.push(\n\t\t\t\t\t\t`${theme.fg(\"muted\", \"(\")}${keyHint(\"app.tools.expand\", \"to collapse\")}${theme.fg(\"muted\", \")\")}`,\n\t\t\t\t\t);\n\t\t\t\t} else {\n\t\t\t\t\tstatusParts.push(formatHiddenLines(hiddenLineCount, \"earlier\"));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (this.status === \"cancelled\") {\n\t\t\t\tstatusParts.push(theme.fg(\"warning\", \"(cancelled)\"));\n\t\t\t} else if (this.status === \"error\") {\n\t\t\t\tstatusParts.push(theme.fg(\"error\", `(exit ${this.exitCode})`));\n\t\t\t}\n\n\t\t\t// Add truncation warning (context truncation, not preview truncation)\n\t\t\tconst wasTruncated = this.truncationResult?.truncated || contextTruncation.truncated;\n\t\t\tif (wasTruncated && this.fullOutputPath) {\n\t\t\t\tstatusParts.push(theme.fg(\"warning\", `Output truncated. Full output: ${this.fullOutputPath}`));\n\t\t\t}\n\n\t\t\tif (statusParts.length > 0) {\n\t\t\t\tthis.contentContainer.addChild(new Text(`\\n${statusParts.join(\"\\n\")}`, 1, 0));\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Get the raw output for creating BashExecutionMessage.\n\t */\n\tgetOutput(): string {\n\t\treturn this.outputLines.join(\"\\n\");\n\t}\n\n\t/**\n\t * Get the command that was executed.\n\t */\n\tgetCommand(): string {\n\t\treturn this.command;\n\t}\n\n\t/**\n\t * Stop the animated loader. Session swaps clear containers wholesale,\n\t * which detaches children without stopping their timers; an in-flight\n\t * component left uncompleted would otherwise animate forever.\n\t */\n\tdispose(): void {\n\t\tthis.loader.stop();\n\t}\n}\n"]}
|
|
@@ -8,4 +8,6 @@
|
|
|
8
8
|
export declare function summarizeError(text: string): string | undefined;
|
|
9
9
|
/** A folded error line: the summary followed by the expand hint. */
|
|
10
10
|
export declare function collapsedErrorLine(styledSummary: string): string;
|
|
11
|
+
/** A command may print successful progress before the diagnostic that stops it. */
|
|
12
|
+
export declare function summarizeOperationError(text: string): string | undefined;
|
|
11
13
|
//# sourceMappingURL=error-summary.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error-summary.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/error-summary.ts"],"names":[],"mappings":"AASA;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAS/D;AAED,oEAAoE;AACpE,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAEhE","sourcesContent":["import { keyHint } from \"./keybinding-hints.ts\";\n\nfunction nonEmptyLines(text: string): string[] {\n\treturn text\n\t\t.replace(/\\r\\n?/g, \"\\n\")\n\t\t.split(\"\\n\")\n\t\t.filter((line) => line.trim() !== \"\");\n}\n\n/**\n * The one line that stands for a multi-line error while it is collapsed, or\n * undefined when the error is already one line and there is nothing to fold.\n *\n * A Python traceback leads with frames and ends with the raised error, so the\n * last unindented line names it; anything else leads with its headline.\n */\nexport function summarizeError(text: string): string | undefined {\n\tconst lines = nonEmptyLines(text);\n\tif (lines.length <= 1) return undefined;\n\tconst prefix = lines[0].startsWith(\"Error: Traceback \") ? \"Error: \" : \"\";\n\tif (lines[0].slice(prefix.length).startsWith(\"Traceback \")) {\n\t\tconst raised = lines.filter((line) => !/^\\s/.test(line)).at(-1);\n\t\tif (raised && raised !== lines[0]) return `${prefix}${raised.trim()}`;\n\t}\n\treturn lines[0].trim();\n}\n\n/** A folded error line: the summary followed by the expand hint. */\nexport function collapsedErrorLine(styledSummary: string): string {\n\treturn `${styledSummary} ${keyHint(\"app.tools.expand\", \"to expand\")}`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"error-summary.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/error-summary.ts"],"names":[],"mappings":"AASA;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAS/D;AAED,oEAAoE;AACpE,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAEhE;AAED,mFAAmF;AACnF,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAQxE","sourcesContent":["import { keyHint } from \"./keybinding-hints.ts\";\n\nfunction nonEmptyLines(text: string): string[] {\n\treturn text\n\t\t.replace(/\\r\\n?/g, \"\\n\")\n\t\t.split(\"\\n\")\n\t\t.filter((line) => line.trim() !== \"\");\n}\n\n/**\n * The one line that stands for a multi-line error while it is collapsed, or\n * undefined when the error is already one line and there is nothing to fold.\n *\n * A Python traceback leads with frames and ends with the raised error, so the\n * last unindented line names it; anything else leads with its headline.\n */\nexport function summarizeError(text: string): string | undefined {\n\tconst lines = nonEmptyLines(text);\n\tif (lines.length <= 1) return undefined;\n\tconst prefix = lines[0].startsWith(\"Error: Traceback \") ? \"Error: \" : \"\";\n\tif (lines[0].slice(prefix.length).startsWith(\"Traceback \")) {\n\t\tconst raised = lines.filter((line) => !/^\\s/.test(line)).at(-1);\n\t\tif (raised && raised !== lines[0]) return `${prefix}${raised.trim()}`;\n\t}\n\treturn lines[0].trim();\n}\n\n/** A folded error line: the summary followed by the expand hint. */\nexport function collapsedErrorLine(styledSummary: string): string {\n\treturn `${styledSummary} ${keyHint(\"app.tools.expand\", \"to expand\")}`;\n}\n\n/** A command may print successful progress before the diagnostic that stops it. */\nexport function summarizeOperationError(text: string): string | undefined {\n\tconst lines = nonEmptyLines(text);\n\tconst traceback = lines.findIndex((line) => line.startsWith(\"Traceback \") || line.startsWith(\"Error: Traceback \"));\n\tif (traceback >= 0) return summarizeError(lines.slice(traceback).join(\"\\n\"));\n\tconst diagnostic =\n\t\tlines.find((line) => /(?:^|\\s)(?:[\\w.]*Error|Exception|fatal):/i.test(line)) ??\n\t\tlines.find((line) => /\\b(?:error|failed|failure|fail|denied|exception|fatal)\\b/i.test(line));\n\treturn diagnostic?.trim() ?? lines.find((line) => !/^\\s*(?:PASS|passed|ok|✓)\\b/i.test(line))?.trim();\n}\n"]}
|
|
@@ -28,4 +28,14 @@ export function summarizeError(text) {
|
|
|
28
28
|
export function collapsedErrorLine(styledSummary) {
|
|
29
29
|
return `${styledSummary} ${keyHint("app.tools.expand", "to expand")}`;
|
|
30
30
|
}
|
|
31
|
+
/** A command may print successful progress before the diagnostic that stops it. */
|
|
32
|
+
export function summarizeOperationError(text) {
|
|
33
|
+
const lines = nonEmptyLines(text);
|
|
34
|
+
const traceback = lines.findIndex((line) => line.startsWith("Traceback ") || line.startsWith("Error: Traceback "));
|
|
35
|
+
if (traceback >= 0)
|
|
36
|
+
return summarizeError(lines.slice(traceback).join("\n"));
|
|
37
|
+
const diagnostic = lines.find((line) => /(?:^|\s)(?:[\w.]*Error|Exception|fatal):/i.test(line)) ??
|
|
38
|
+
lines.find((line) => /\b(?:error|failed|failure|fail|denied|exception|fatal)\b/i.test(line));
|
|
39
|
+
return diagnostic?.trim() ?? lines.find((line) => !/^\s*(?:PASS|passed|ok|✓)\b/i.test(line))?.trim();
|
|
40
|
+
}
|
|
31
41
|
//# sourceMappingURL=error-summary.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error-summary.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/error-summary.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAEhD,SAAS,aAAa,CAAC,IAAY,EAAY;IAC9C,OAAO,IAAI;SACT,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC;SACvB,KAAK,CAAC,IAAI,CAAC;SACX,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AAAA,CACvC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY,EAAsB;IAChE,MAAM,KAAK,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;IAClC,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC;QAAE,OAAO,SAAS,CAAC;IACxC,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;IACzE,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,YAAY,CAAC,EAAE,CAAC;QAC5D,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAChE,IAAI,MAAM,IAAI,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC;YAAE,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;IACvE,CAAC;IACD,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;AAAA,CACvB;AAED,oEAAoE;AACpE,MAAM,UAAU,kBAAkB,CAAC,aAAqB,EAAU;IACjE,OAAO,GAAG,aAAa,IAAI,OAAO,CAAC,kBAAkB,EAAE,WAAW,CAAC,EAAE,CAAC;AAAA,CACtE","sourcesContent":["import { keyHint } from \"./keybinding-hints.ts\";\n\nfunction nonEmptyLines(text: string): string[] {\n\treturn text\n\t\t.replace(/\\r\\n?/g, \"\\n\")\n\t\t.split(\"\\n\")\n\t\t.filter((line) => line.trim() !== \"\");\n}\n\n/**\n * The one line that stands for a multi-line error while it is collapsed, or\n * undefined when the error is already one line and there is nothing to fold.\n *\n * A Python traceback leads with frames and ends with the raised error, so the\n * last unindented line names it; anything else leads with its headline.\n */\nexport function summarizeError(text: string): string | undefined {\n\tconst lines = nonEmptyLines(text);\n\tif (lines.length <= 1) return undefined;\n\tconst prefix = lines[0].startsWith(\"Error: Traceback \") ? \"Error: \" : \"\";\n\tif (lines[0].slice(prefix.length).startsWith(\"Traceback \")) {\n\t\tconst raised = lines.filter((line) => !/^\\s/.test(line)).at(-1);\n\t\tif (raised && raised !== lines[0]) return `${prefix}${raised.trim()}`;\n\t}\n\treturn lines[0].trim();\n}\n\n/** A folded error line: the summary followed by the expand hint. */\nexport function collapsedErrorLine(styledSummary: string): string {\n\treturn `${styledSummary} ${keyHint(\"app.tools.expand\", \"to expand\")}`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"error-summary.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/error-summary.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAEhD,SAAS,aAAa,CAAC,IAAY,EAAY;IAC9C,OAAO,IAAI;SACT,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC;SACvB,KAAK,CAAC,IAAI,CAAC;SACX,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AAAA,CACvC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY,EAAsB;IAChE,MAAM,KAAK,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;IAClC,IAAI,KAAK,CAAC,MAAM,IAAI,CAAC;QAAE,OAAO,SAAS,CAAC;IACxC,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;IACzE,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,YAAY,CAAC,EAAE,CAAC;QAC5D,MAAM,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAChE,IAAI,MAAM,IAAI,MAAM,KAAK,KAAK,CAAC,CAAC,CAAC;YAAE,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;IACvE,CAAC;IACD,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;AAAA,CACvB;AAED,oEAAoE;AACpE,MAAM,UAAU,kBAAkB,CAAC,aAAqB,EAAU;IACjE,OAAO,GAAG,aAAa,IAAI,OAAO,CAAC,kBAAkB,EAAE,WAAW,CAAC,EAAE,CAAC;AAAA,CACtE;AAED,mFAAmF;AACnF,MAAM,UAAU,uBAAuB,CAAC,IAAY,EAAsB;IACzE,MAAM,KAAK,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;IAClC,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,YAAY,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,mBAAmB,CAAC,CAAC,CAAC;IACnH,IAAI,SAAS,IAAI,CAAC;QAAE,OAAO,cAAc,CAAC,KAAK,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IAC7E,MAAM,UAAU,GACf,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,2CAA2C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC5E,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,2DAA2D,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;IAC9F,OAAO,UAAU,EAAE,IAAI,EAAE,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,+BAA6B,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;AAAA,CACrG","sourcesContent":["import { keyHint } from \"./keybinding-hints.ts\";\n\nfunction nonEmptyLines(text: string): string[] {\n\treturn text\n\t\t.replace(/\\r\\n?/g, \"\\n\")\n\t\t.split(\"\\n\")\n\t\t.filter((line) => line.trim() !== \"\");\n}\n\n/**\n * The one line that stands for a multi-line error while it is collapsed, or\n * undefined when the error is already one line and there is nothing to fold.\n *\n * A Python traceback leads with frames and ends with the raised error, so the\n * last unindented line names it; anything else leads with its headline.\n */\nexport function summarizeError(text: string): string | undefined {\n\tconst lines = nonEmptyLines(text);\n\tif (lines.length <= 1) return undefined;\n\tconst prefix = lines[0].startsWith(\"Error: Traceback \") ? \"Error: \" : \"\";\n\tif (lines[0].slice(prefix.length).startsWith(\"Traceback \")) {\n\t\tconst raised = lines.filter((line) => !/^\\s/.test(line)).at(-1);\n\t\tif (raised && raised !== lines[0]) return `${prefix}${raised.trim()}`;\n\t}\n\treturn lines[0].trim();\n}\n\n/** A folded error line: the summary followed by the expand hint. */\nexport function collapsedErrorLine(styledSummary: string): string {\n\treturn `${styledSummary} ${keyHint(\"app.tools.expand\", \"to expand\")}`;\n}\n\n/** A command may print successful progress before the diagnostic that stops it. */\nexport function summarizeOperationError(text: string): string | undefined {\n\tconst lines = nonEmptyLines(text);\n\tconst traceback = lines.findIndex((line) => line.startsWith(\"Traceback \") || line.startsWith(\"Error: Traceback \"));\n\tif (traceback >= 0) return summarizeError(lines.slice(traceback).join(\"\\n\"));\n\tconst diagnostic =\n\t\tlines.find((line) => /(?:^|\\s)(?:[\\w.]*Error|Exception|fatal):/i.test(line)) ??\n\t\tlines.find((line) => /\\b(?:error|failed|failure|fail|denied|exception|fatal)\\b/i.test(line));\n\treturn diagnostic?.trim() ?? lines.find((line) => !/^\\s*(?:PASS|passed|ok|✓)\\b/i.test(line))?.trim();\n}\n"]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type FirstUseHintId = "queue" | "tool-expand" | "thinking" | "bash";
|
|
1
|
+
export type FirstUseHintId = "queue" | "tool-expand" | "thinking" | "bash" | "permission-mode-cycle";
|
|
2
2
|
export declare class FirstUseHints {
|
|
3
3
|
private readonly seen;
|
|
4
4
|
constructor(seen?: readonly string[]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"first-use-hints.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/first-use-hints.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,aAAa,GAAG,UAAU,GAAG,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"first-use-hints.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/first-use-hints.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,aAAa,GAAG,UAAU,GAAG,MAAM,GAAG,uBAAuB,CAAC;AAUrG,qBAAa,aAAa;IACzB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAsB;IAE3C,YAAY,IAAI,GAAE,SAAS,MAAM,EAAO,EAEvC;IAED,KAAK,CAAC,IAAI,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAI9C;IAED,OAAO,IAAI,cAAc,EAAE,CAE1B;CACD","sourcesContent":["export type FirstUseHintId = \"queue\" | \"tool-expand\" | \"thinking\" | \"bash\" | \"permission-mode-cycle\";\n\nconst HINTS: Record<FirstUseHintId, string> = {\n\tqueue: \"Queued messages run after the current turn.\",\n\t\"tool-expand\": \"Use the tool expansion shortcut to see full output.\",\n\tthinking: \"Use the thinking shortcut to show or hide reasoning blocks.\",\n\tbash: \"Press Escape to leave bash input mode.\",\n\t\"permission-mode-cycle\": \"Permission mode cycling now has its own shortcut.\",\n};\n\nexport class FirstUseHints {\n\tprivate readonly seen: Set<FirstUseHintId>;\n\n\tconstructor(seen: readonly string[] = []) {\n\t\tthis.seen = new Set(seen.filter((hint): hint is FirstUseHintId => hint in HINTS));\n\t}\n\n\toffer(hint: FirstUseHintId): string | undefined {\n\t\tif (this.seen.has(hint)) return undefined;\n\t\tthis.seen.add(hint);\n\t\treturn HINTS[hint];\n\t}\n\n\tgetSeen(): FirstUseHintId[] {\n\t\treturn [...this.seen];\n\t}\n}\n"]}
|
|
@@ -3,6 +3,7 @@ const HINTS = {
|
|
|
3
3
|
"tool-expand": "Use the tool expansion shortcut to see full output.",
|
|
4
4
|
thinking: "Use the thinking shortcut to show or hide reasoning blocks.",
|
|
5
5
|
bash: "Press Escape to leave bash input mode.",
|
|
6
|
+
"permission-mode-cycle": "Permission mode cycling now has its own shortcut.",
|
|
6
7
|
};
|
|
7
8
|
export class FirstUseHints {
|
|
8
9
|
seen;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"first-use-hints.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/first-use-hints.ts"],"names":[],"mappings":"AAEA,MAAM,KAAK,GAAmC;IAC7C,KAAK,EAAE,6CAA6C;IACpD,aAAa,EAAE,qDAAqD;IACpE,QAAQ,EAAE,6DAA6D;IACvE,IAAI,EAAE,wCAAwC;
|
|
1
|
+
{"version":3,"file":"first-use-hints.js","sourceRoot":"","sources":["../../../../src/modes/interactive/components/first-use-hints.ts"],"names":[],"mappings":"AAEA,MAAM,KAAK,GAAmC;IAC7C,KAAK,EAAE,6CAA6C;IACpD,aAAa,EAAE,qDAAqD;IACpE,QAAQ,EAAE,6DAA6D;IACvE,IAAI,EAAE,wCAAwC;IAC9C,uBAAuB,EAAE,mDAAmD;CAC5E,CAAC;AAEF,MAAM,OAAO,aAAa;IACR,IAAI,CAAsB;IAE3C,YAAY,IAAI,GAAsB,EAAE,EAAE;QACzC,IAAI,CAAC,IAAI,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,EAA0B,EAAE,CAAC,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC;IAAA,CAClF;IAED,KAAK,CAAC,IAAoB,EAAsB;QAC/C,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,OAAO,SAAS,CAAC;QAC1C,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACpB,OAAO,KAAK,CAAC,IAAI,CAAC,CAAC;IAAA,CACnB;IAED,OAAO,GAAqB;QAC3B,OAAO,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC;IAAA,CACtB;CACD","sourcesContent":["export type FirstUseHintId = \"queue\" | \"tool-expand\" | \"thinking\" | \"bash\" | \"permission-mode-cycle\";\n\nconst HINTS: Record<FirstUseHintId, string> = {\n\tqueue: \"Queued messages run after the current turn.\",\n\t\"tool-expand\": \"Use the tool expansion shortcut to see full output.\",\n\tthinking: \"Use the thinking shortcut to show or hide reasoning blocks.\",\n\tbash: \"Press Escape to leave bash input mode.\",\n\t\"permission-mode-cycle\": \"Permission mode cycling now has its own shortcut.\",\n};\n\nexport class FirstUseHints {\n\tprivate readonly seen: Set<FirstUseHintId>;\n\n\tconstructor(seen: readonly string[] = []) {\n\t\tthis.seen = new Set(seen.filter((hint): hint is FirstUseHintId => hint in HINTS));\n\t}\n\n\toffer(hint: FirstUseHintId): string | undefined {\n\t\tif (this.seen.has(hint)) return undefined;\n\t\tthis.seen.add(hint);\n\t\treturn HINTS[hint];\n\t}\n\n\tgetSeen(): FirstUseHintId[] {\n\t\treturn [...this.seen];\n\t}\n}\n"]}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { type Component } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { AgentSession } from "../../../core/agent-session.ts";
|
|
3
3
|
import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts";
|
|
4
|
+
import type { PermissionModeOrigin } from "../../../core/permissions/startup.ts";
|
|
4
5
|
import type { PermissionMode } from "../../../core/permissions/store.ts";
|
|
6
|
+
import type { ChatDetail } from "../../../core/settings-manager.ts";
|
|
5
7
|
/**
|
|
6
8
|
* Format token counts for compact footer display.
|
|
7
9
|
*/
|
|
@@ -32,14 +34,17 @@ export declare function formatCwdForFooter(cwd: string, home: string | undefined
|
|
|
32
34
|
export declare class FooterComponent implements Component {
|
|
33
35
|
private autoCompactEnabled;
|
|
34
36
|
private permissionMode;
|
|
37
|
+
private permissionModeOrigin;
|
|
35
38
|
private session;
|
|
36
39
|
private footerData;
|
|
37
40
|
private accessibilitySettings;
|
|
38
41
|
private activity;
|
|
39
42
|
constructor(session: AgentSession, footerData: ReadonlyFooterDataProvider, accessibilitySettings?: FooterAccessibilitySettings);
|
|
43
|
+
private chatDetail?;
|
|
44
|
+
setChatDetail(detail: ChatDetail): void;
|
|
40
45
|
setSession(session: AgentSession): void;
|
|
41
46
|
setAutoCompactEnabled(enabled: boolean): void;
|
|
42
|
-
setPermissionMode(mode: PermissionMode): void;
|
|
47
|
+
setPermissionMode(mode: PermissionMode, origin?: PermissionModeOrigin): void;
|
|
43
48
|
/**
|
|
44
49
|
* The working indicator, rendered inline in the tray instead of in its own
|
|
45
50
|
* rows above the editor.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/footer.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAAiC,MAAM,wBAAwB,CAAC;AACvF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAEnE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAC;AACxF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAgBzE;;GAEG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAMlD;AAKD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CACjC,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,YAAY,EAAE,SAAS,GAAG,OAAO,GAC/B;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAWnC;AAED,mFAAmF;AACnF,MAAM,WAAW,2BAA2B;IAC3C,eAAe,IAAI,SAAS,GAAG,OAAO,CAAC;IACvC,iBAAiB,IAAI,OAAO,CAAC;IAC7B,oBAAoB,IAAI,KAAK,GAAG,SAAS,GAAG,MAAM,CAAC;CACnD;AAQD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAYhF;AAED;;;GAGG;AACH,qBAAa,eAAgB,YAAW,SAAS;IAChD,OAAO,CAAC,kBAAkB,CAAQ;IAClC,OAAO,CAAC,cAAc,CAA6B;IACnD,OAAO,CAAC,OAAO,CAAe;IAC9B,OAAO,CAAC,UAAU,CAA6B;IAC/C,OAAO,CAAC,qBAAqB,CAA8B;IAC3D,OAAO,CAAC,QAAQ,CAAwB;IAExC,YACC,OAAO,EAAE,YAAY,EACrB,UAAU,EAAE,0BAA0B,EACtC,qBAAqB,GAAE,2BAA4D,EAKnF;IAED,UAAU,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAKtC;IAED,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAE5C;IAED,iBAAiB,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI,CAE5C;IAED;;;;;;;;OAQG;IACH,WAAW,CAAC,QAAQ,EAAE,SAAS,GAAG,SAAS,GAAG,IAAI,CAEjD;IAED;;;OAGG;IACH,UAAU,IAAI,IAAI,CAEjB;IAED;;;OAGG;IACH,OAAO,IAAI,IAAI,CAEd;IAED;;;;;OAKG;IACH,OAAO,CAAC,UAAU,CAAC,CAIjB;IAEF,OAAO,CAAC,YAAY;IAiCpB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAsU9B;CACD","sourcesContent":["import { isAbsolute, relative, resolve, sep } from \"node:path\";\nimport { type Component, truncateToWidth, visibleWidth } from \"@earendil-works/pi-tui\";\nimport type { AgentSession } from \"../../../core/agent-session.ts\";\nimport { areExperimentalFeaturesEnabled } from \"../../../core/experimental.ts\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.ts\";\nimport type { PermissionMode } from \"../../../core/permissions/store.ts\";\nimport { addUsageToTotals, createUsageTotals } from \"../../../core/usage-totals.ts\";\nimport { theme } from \"../theme/theme.ts\";\n\n/**\n * Sanitize text for display in a single-line status.\n * Removes newlines, tabs, carriage returns, and other control characters.\n */\nfunction sanitizeStatusText(text: string): string {\n\t// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces\n\treturn text\n\t\t.replace(/[\\r\\n\\t]/g, \" \")\n\t\t.replace(/ +/g, \" \")\n\t\t.trim();\n}\n\n/**\n * Format token counts for compact footer display.\n */\nexport function formatTokens(count: number): string {\n\tif (count < 1000) return count.toString();\n\tif (count < 10000) return `${(count / 1000).toFixed(1)}k`;\n\tif (count < 1000000) return `${Math.round(count / 1000)}k`;\n\tif (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;\n\treturn `${Math.round(count / 1000000)}M`;\n}\n\n/** Cells in the context gauge. Eight reads as a bar without crowding the tray. */\nconst CONTEXT_GAUGE_CELLS = 8;\n\n/**\n * A filled bar for the context window, split into its filled and empty runs so\n * the caller can colour them separately.\n *\n * This is a redundant channel and never the only one: the percentage beside it\n * states the same value as text (WCAG 1.4.1). An unknown percentage renders an\n * empty track rather than guessing a position.\n */\nexport function renderContextGauge(\n\tpercent: number | undefined,\n\tsymbolPreset: \"unicode\" | \"ascii\",\n): { filled: string; empty: string } {\n\tconst filledGlyph = symbolPreset === \"ascii\" ? \"#\" : \"█\";\n\tconst emptyGlyph = symbolPreset === \"ascii\" ? \"-\" : \"░\";\n\tif (percent === undefined) {\n\t\treturn { filled: \"\", empty: emptyGlyph.repeat(CONTEXT_GAUGE_CELLS) };\n\t}\n\tconst clamped = Math.min(100, Math.max(0, percent));\n\t// Any non-zero usage lights at least one cell, so the bar never reads empty\n\t// while the window is genuinely filling.\n\tconst cells = clamped === 0 ? 0 : Math.max(1, Math.round((clamped / 100) * CONTEXT_GAUGE_CELLS));\n\treturn { filled: filledGlyph.repeat(cells), empty: emptyGlyph.repeat(CONTEXT_GAUGE_CELLS - cells) };\n}\n\n/** Accessibility/display settings the footer reads (roadmap Phase 8, task 8.6). */\nexport interface FooterAccessibilitySettings {\n\tgetSymbolPreset(): \"unicode\" | \"ascii\";\n\tgetColorBlindMode(): boolean;\n\tgetTokenUsageDisplay(): \"off\" | \"compact\" | \"full\";\n}\n\nconst DEFAULT_ACCESSIBILITY_SETTINGS: FooterAccessibilitySettings = {\n\tgetSymbolPreset: () => \"unicode\",\n\tgetColorBlindMode: () => false,\n\tgetTokenUsageDisplay: () => \"compact\",\n};\n\nexport function formatCwdForFooter(cwd: string, home: string | undefined): string {\n\tif (!home) return cwd;\n\n\tconst resolvedCwd = resolve(cwd);\n\tconst resolvedHome = resolve(home);\n\tconst relativeToHome = relative(resolvedHome, resolvedCwd);\n\tconst isInsideHome =\n\t\trelativeToHome === \"\" ||\n\t\t(relativeToHome !== \"..\" && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));\n\n\tif (!isInsideHome) return cwd;\n\treturn relativeToHome === \"\" ? \"~\" : `~/${relativeToHome.replaceAll(\"\\\\\", \"/\")}`;\n}\n\n/**\n * Footer component that shows pwd, token stats, and context usage.\n * Computes token/context stats from session, gets git branch and extension statuses from provider.\n */\nexport class FooterComponent implements Component {\n\tprivate autoCompactEnabled = true;\n\tprivate permissionMode: PermissionMode = \"default\";\n\tprivate session: AgentSession;\n\tprivate footerData: ReadonlyFooterDataProvider;\n\tprivate accessibilitySettings: FooterAccessibilitySettings;\n\tprivate activity: Component | undefined;\n\n\tconstructor(\n\t\tsession: AgentSession,\n\t\tfooterData: ReadonlyFooterDataProvider,\n\t\taccessibilitySettings: FooterAccessibilitySettings = DEFAULT_ACCESSIBILITY_SETTINGS,\n\t) {\n\t\tthis.session = session;\n\t\tthis.footerData = footerData;\n\t\tthis.accessibilitySettings = accessibilitySettings;\n\t}\n\n\tsetSession(session: AgentSession): void {\n\t\tthis.session = session;\n\t\t// The usage totals are cached against the previous session's entry\n\t\t// version; a session switch must not reuse them.\n\t\tthis.usageCache = undefined;\n\t}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\tsetPermissionMode(mode: PermissionMode): void {\n\t\tthis.permissionMode = mode;\n\t}\n\n\t/**\n\t * The working indicator, rendered inline in the tray instead of in its own\n\t * rows above the editor.\n\t *\n\t * Taking the component rather than a string keeps the spinner animation, the\n\t * extension-supplied `WorkingIndicatorOptions`, and disposal where they already\n\t * live. This setter only moves where the line is drawn, so it cannot disturb\n\t * the usage cache.\n\t */\n\tsetActivity(activity: Component | undefined): void {\n\t\tthis.activity = activity;\n\t}\n\n\t/**\n\t * No-op: git branch caching now handled by provider.\n\t * Kept for compatibility with existing call sites in interactive-mode.\n\t */\n\tinvalidate(): void {\n\t\t// No-op: git branch is cached/invalidated by provider\n\t}\n\n\t/**\n\t * Clean up resources.\n\t * Git watcher cleanup now handled by provider.\n\t */\n\tdispose(): void {\n\t\t// Git watcher cleanup handled by provider\n\t}\n\n\t/**\n\t * Usage totals walked from all session entries. getEntries() copies the\n\t * whole entry list, so this is cached against the session manager's\n\t * entries version: without the cache the footer re-walked the entire\n\t * transcript on every rendered frame.\n\t */\n\tprivate usageCache?: {\n\t\tversion: number;\n\t\ttotals: ReturnType<typeof createUsageTotals>;\n\t\tlatestCacheHitRate?: number;\n\t};\n\n\tprivate computeUsage(): {\n\t\ttotals: ReturnType<typeof createUsageTotals>;\n\t\tlatestCacheHitRate?: number;\n\t} {\n\t\tconst version = this.session.sessionManager.getEntriesVersion();\n\t\tif (this.usageCache && this.usageCache.version === version) {\n\t\t\treturn { totals: this.usageCache.totals, latestCacheHitRate: this.usageCache.latestCacheHitRate };\n\t\t}\n\n\t\tconst usageTotals = createUsageTotals();\n\t\tlet latestCacheHitRate: number | undefined;\n\n\t\tfor (const entry of this.session.sessionManager.getEntries()) {\n\t\t\tif (entry.type === \"usage\") {\n\t\t\t\taddUsageToTotals(usageTotals, entry.usage);\n\t\t\t} else if (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\taddUsageToTotals(usageTotals, entry.message.usage);\n\n\t\t\t\tconst latestPromptTokens =\n\t\t\t\t\tentry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite;\n\t\t\t\tlatestCacheHitRate =\n\t\t\t\t\tlatestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined;\n\t\t\t} else if (entry.type === \"message\" && entry.message.role === \"toolResult\" && entry.message.usage) {\n\t\t\t\taddUsageToTotals(usageTotals, entry.message.usage);\n\t\t\t} else if ((entry.type === \"branch_summary\" || entry.type === \"compaction\") && entry.usage) {\n\t\t\t\taddUsageToTotals(usageTotals, entry.usage);\n\t\t\t}\n\t\t}\n\n\t\tthis.usageCache = { version, totals: usageTotals, latestCacheHitRate };\n\t\treturn { totals: usageTotals, latestCacheHitRate };\n\t}\n\n\trender(width: number): string[] {\n\t\tconst state = this.session.state;\n\t\tconst symbolPreset = this.accessibilitySettings.getSymbolPreset();\n\t\tconst colorBlindMode = this.accessibilitySettings.getColorBlindMode();\n\t\tconst tokenUsageDisplay = this.accessibilitySettings.getTokenUsageDisplay();\n\t\tconst upSymbol = symbolPreset === \"ascii\" ? \"^\" : \"↑\";\n\t\tconst downSymbol = symbolPreset === \"ascii\" ? \"v\" : \"↓\";\n\t\tconst bulletSymbol = symbolPreset === \"ascii\" ? \"-\" : \"•\";\n\t\t// One line, unpadded, so the fit ladder can measure it as a segment. Loader\n\t\t// prepends a blank row to stand off from the transcript, which the tray does\n\t\t// not need, so this takes the first row that actually carries the message.\n\t\tconst activityLine = this.activity\n\t\t\t?.render(width)\n\t\t\t.map((line) => line.replace(/ +$/, \"\"))\n\t\t\t.find((line) => line.length > 0);\n\n\t\t// Calculate cumulative usage from ALL session entries (not just post-compaction messages),\n\t\t// cached against the session's entry version (see computeUsage).\n\t\tconst { totals: usageTotals, latestCacheHitRate } = this.computeUsage();\n\n\t\t// Calculate context usage from session (handles compaction correctly).\n\t\t// After compaction, tokens are unknown until the next LLM response.\n\t\tconst contextUsage = this.session.getContextUsage();\n\t\tconst contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;\n\t\tconst contextPercentValue = contextUsage?.percent ?? 0;\n\t\t// The gauge lights a cell for any usage at all, so a figure that rounds to zero says so.\n\t\tconst contextPercent =\n\t\t\tcontextUsage?.percent === null\n\t\t\t\t? \"?\"\n\t\t\t\t: contextPercentValue > 0 && contextPercentValue < 0.05\n\t\t\t\t\t? \"<0.1\"\n\t\t\t\t\t: contextPercentValue.toFixed(1);\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = formatCwdForFooter(this.session.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);\n\n\t\t// Add git branch if available\n\t\tconst branch = this.footerData.getGitBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Add session name if set\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tif (sessionName) {\n\t\t\tpwd = `${pwd} ${bulletSymbol} ${sessionName}`;\n\t\t}\n\n\t\t// Build stats line. tokenUsageDisplay: \"off\" hides this whole section\n\t\t// (context% and model name are separate and always shown); \"full\" shows\n\t\t// exact counts instead of formatTokens' abbreviation.\n\t\tconst formatTokenCount = tokenUsageDisplay === \"full\" ? (n: number) => n.toLocaleString() : formatTokens;\n\t\tconst statsParts = [];\n\t\tif (tokenUsageDisplay !== \"off\") {\n\t\t\tif (usageTotals.input) statsParts.push(`${upSymbol}${formatTokenCount(usageTotals.input)}`);\n\t\t\tif (usageTotals.output) statsParts.push(`${downSymbol}${formatTokenCount(usageTotals.output)}`);\n\t\t\tif (usageTotals.cacheRead) statsParts.push(`R${formatTokenCount(usageTotals.cacheRead)}`);\n\t\t\tif (usageTotals.cacheWrite) statsParts.push(`W${formatTokenCount(usageTotals.cacheWrite)}`);\n\t\t\tif ((usageTotals.cacheRead > 0 || usageTotals.cacheWrite > 0) && latestCacheHitRate !== undefined) {\n\t\t\t\tstatsParts.push(`cache ${Math.round(latestCacheHitRate)}%`);\n\t\t\t}\n\t\t}\n\n\t\t// Kimi Coding is subscription-backed despite using API-key authentication.\n\t\tconst usingSubscription = state.model\n\t\t\t? state.model.provider === \"kimi-coding\" || this.session.modelRuntime.isUsingSubscription(state.model.provider)\n\t\t\t: false;\n\t\tif (tokenUsageDisplay !== \"off\" && (usageTotals.cost || usingSubscription)) {\n\t\t\tconst costStr = `$${usageTotals.cost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\tstatsParts.push(costStr);\n\t\t}\n\n\t\t// Context pressure is signalled through text at every setting -- never\n\t\t// color alone (WCAG 1.4.1) -- with color as an additional channel. The\n\t\t// default error/warning pair (red/amber) is already distinguishable for\n\t\t// most red-green colorblindness; colorBlindMode's palette adjustment\n\t\t// moves the critical (>90%) threshold to \"accent\" -- a hue outside that\n\t\t// red/amber family entirely -- rather than relying on a style attribute\n\t\t// like bold/underline, which several terminals (and this theme's own\n\t\t// chalk-backed style methods, suppressed outside a real TTY) silently\n\t\t// drop, making it an unreliable second channel.\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst pressureMarker = contextPercentValue > 90 ? \"!!\" : contextPercentValue > 70 ? \"!\" : \"\";\n\t\tconst contextPercentDisplay =\n\t\t\tcontextPercent === \"?\"\n\t\t\t\t? `?/${formatTokens(contextWindow)}${autoIndicator}`\n\t\t\t\t: `${contextPercent}%${pressureMarker}/${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(colorBlindMode ? \"accent\" : \"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\t\t// Only worth saying when pressure is real and nothing will act on its own.\n\t\t// With auto-compaction on, the \"(auto)\" marker above already answers it.\n\t\tconst compactPrompt =\n\t\t\tcontextPercentValue > 70 && !this.autoCompactEnabled ? theme.fg(\"warning\", \"/compact soon\") : undefined;\n\t\tstatsParts.push(contextPercentStr);\n\t\tif (compactPrompt) statsParts.push(compactPrompt);\n\t\tif (activityLine) statsParts.unshift(activityLine);\n\t\tif (areExperimentalFeaturesEnabled()) {\n\t\t\tstatsParts.push(`${theme.fg(\"dim\", bulletSymbol)} ${theme.bold(theme.fg(\"warning\", \"xp\"))}`);\n\t\t}\n\n\t\t// Permission posture is always named, including `default`. It is operational\n\t\t// state rather than routine telemetry: hiding the safe default would make the\n\t\t// tray change meaning by omission, while hiding bypass would conceal risk.\n\t\t// Text carries the signal and color remains a second channel (WCAG 1.4.1).\n\t\tconst modeColor =\n\t\t\tthis.permissionMode === \"bypassPermissions\" ? \"error\" : this.permissionMode === \"default\" ? \"dim\" : \"warning\";\n\t\tstatsParts.unshift(theme.fg(modeColor, this.permissionMode));\n\n\t\tif (tokenUsageDisplay !== \"full\") {\n\t\t\tconst separator = theme.fg(\"dim\", symbolPreset === \"ascii\" ? \" - \" : \" · \");\n\t\t\tconst compactPermissionNames: Record<PermissionMode, string> = {\n\t\t\t\tdefault: \"default\",\n\t\t\t\tplan: \"plan\",\n\t\t\t\tacceptEdits: \"accept\",\n\t\t\t\tbypassPermissions: \"bypass\",\n\t\t\t\tdontAsk: \"dontAsk\",\n\t\t\t};\n\t\t\tconst permissionColor =\n\t\t\t\tthis.permissionMode === \"bypassPermissions\"\n\t\t\t\t\t? \"error\"\n\t\t\t\t\t: this.permissionMode === \"default\"\n\t\t\t\t\t\t? \"dim\"\n\t\t\t\t\t\t: \"warning\";\n\t\t\tconst permissionFull = theme.fg(permissionColor, this.permissionMode);\n\t\t\tconst permissionCompact = theme.fg(permissionColor, compactPermissionNames[this.permissionMode]);\n\t\t\tconst compactPercent = contextPercent === \"?\" ? \"?\" : contextPercentValue.toFixed(0);\n\t\t\tconst compactPressure = contextPercentValue > 90 ? \"!!\" : contextPercentValue > 70 ? \"!\" : \"\";\n\t\t\tconst contextColor =\n\t\t\t\tcontextPercentValue > 90\n\t\t\t\t\t? colorBlindMode\n\t\t\t\t\t\t? \"accent\"\n\t\t\t\t\t\t: \"error\"\n\t\t\t\t\t: contextPercentValue > 70\n\t\t\t\t\t\t? \"warning\"\n\t\t\t\t\t\t: \"dim\";\n\t\t\t// The gauge rides along only in the roomy form. The compact form drops\n\t\t\t// it, which is what lets it keep winning the fit ladder below.\n\t\t\tconst gauge = renderContextGauge(contextPercent === \"?\" ? undefined : contextPercentValue, symbolPreset);\n\t\t\t// Below the warning threshold the text is dim, which would make the bar\n\t\t\t// invisible; the fill takes the accent there and tracks the text colour\n\t\t\t// at every level that actually signals pressure.\n\t\t\tconst gaugeColor = contextColor === \"dim\" ? \"accent\" : contextColor;\n\t\t\t// An empty run would still emit an open/close SGR pair, so skip it.\n\t\t\tconst paintRun = (color: Parameters<typeof theme.fg>[0], text: string) => (text ? theme.fg(color, text) : \"\");\n\t\t\tconst contextGauged =\n\t\t\t\ttheme.fg(contextColor, \"context \") +\n\t\t\t\tpaintRun(gaugeColor, gauge.filled) +\n\t\t\t\tpaintRun(\"borderMuted\", gauge.empty) +\n\t\t\t\ttheme.fg(contextColor, ` ${contextPercent}%${pressureMarker}`);\n\t\t\tconst contextFull = theme.fg(contextColor, `context ${contextPercent}%${pressureMarker}`);\n\t\t\tconst contextCompact = theme.fg(contextColor, `ctx ${compactPercent}%${compactPressure}`);\n\n\t\t\tconst joinSegments = (segments: string[]): string => segments.join(separator);\n\t\t\tconst fits = (segments: string[]): boolean => visibleWidth(joinSegments(segments)) <= width;\n\t\t\tconst safetyFirst = (...rest: Array<string | undefined>): string[] => [\n\t\t\t\tpermissionFull,\n\t\t\t\t...rest.filter((segment): segment is string => segment !== undefined),\n\t\t\t];\n\t\t\tlet left: string[] = safetyFirst(compactPrompt, activityLine);\n\t\t\tlet right = contextGauged;\n\n\t\t\t// The gauge is decoration over a value the text already states, so it\n\t\t\t// is the first thing to go. Dropping it must not cost the tray its\n\t\t\t// spelled-out permission mode, which is why this is its own rung.\n\t\t\tif (!fits([...left, right])) {\n\t\t\t\tright = contextFull;\n\t\t\t}\n\n\t\t\t// Activity is operational, so it outranks every optional segment below.\n\t\t\t// It still yields to the safety posture and to the pressure reading,\n\t\t\t// which are the two things the tray must never lose.\n\t\t\tif (!fits([...left, right])) {\n\t\t\t\tleft = safetyFirst(compactPrompt);\n\t\t\t}\n\n\t\t\tif (!fits([...left, right])) {\n\t\t\t\tleft = safetyFirst();\n\t\t\t}\n\n\t\t\tif (!fits([...left, right])) {\n\t\t\t\tleft = [permissionCompact];\n\t\t\t\tright = contextCompact;\n\t\t\t}\n\n\t\t\tif (!fits([...left, right])) {\n\t\t\t\tconst safetyOnly = left[0];\n\t\t\t\tconst line =\n\t\t\t\t\tvisibleWidth(safetyOnly) <= width ? safetyOnly : truncateToWidth(safetyOnly, Math.max(0, width), \"\");\n\t\t\t\treturn width > 0 ? [line] : [];\n\t\t\t}\n\n\t\t\tconst optional: string[] = [];\n\t\t\toptional.push(theme.fg(\"dim\", state.model?.id || \"no-model\"));\n\t\t\tif (state.model?.reasoning && state.thinkingLevel && state.thinkingLevel !== \"off\") {\n\t\t\t\toptional.push(theme.fg(\"dim\", state.thinkingLevel));\n\t\t\t}\n\t\t\tif (tokenUsageDisplay !== \"off\" && (usageTotals.input || usageTotals.output)) {\n\t\t\t\toptional.push(\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${upSymbol}${formatTokens(usageTotals.input)} ${downSymbol}${formatTokens(usageTotals.output)}`,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (\n\t\t\t\ttokenUsageDisplay !== \"off\" &&\n\t\t\t\t(usageTotals.cacheRead > 0 || usageTotals.cacheWrite > 0) &&\n\t\t\t\tlatestCacheHitRate !== undefined\n\t\t\t) {\n\t\t\t\toptional.push(theme.fg(\"dim\", `cache ${Math.round(latestCacheHitRate)}%`));\n\t\t\t}\n\t\t\tif (tokenUsageDisplay !== \"off\" && (usageTotals.cost || usingSubscription)) {\n\t\t\t\toptional.push(theme.fg(\"dim\", `$${usageTotals.cost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`));\n\t\t\t}\n\t\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\t\toptional.push(theme.fg(\"dim\", state.model.provider));\n\t\t\t}\n\t\t\tif (areExperimentalFeaturesEnabled()) optional.push(theme.fg(\"warning\", \"xp\"));\n\t\t\toptional.push(theme.fg(\"dim\", pwd));\n\n\t\t\tfor (const segment of optional) {\n\t\t\t\tif (fits([...left, segment, right])) left.push(segment);\n\t\t\t}\n\n\t\t\tconst leftText = joinSegments(left);\n\t\t\tconst padding = \" \".repeat(Math.max(1, width - visibleWidth(leftText) - visibleWidth(right)));\n\t\t\tconst tray = leftText ? `${leftText}${padding}${right}` : right;\n\t\t\tconst lines = [tray];\n\t\t\tconst extensionStatuses = this.footerData.getExtensionStatuses();\n\t\t\tif (extensionStatuses.size > 0) {\n\t\t\t\tconst sortedStatuses = Array.from(extensionStatuses.entries())\n\t\t\t\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t\t\t\t.map(([, text]) => sanitizeStatusText(text));\n\t\t\t\tlines.push(truncateToWidth(sortedStatuses.join(\" \"), width, theme.fg(\"dim\", \"...\")));\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tlet statsLeft = statsParts.join(\" \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = state.model?.id || \"no-model\";\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\t// Permission mode is the first segment and must remain textual at every\n\t\t\t// positive width. An ellipsis suffix can consume the entire narrow line.\n\t\t\tstatsLeft = truncateToWidth(statsLeft, width, \"\");\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\n\t\t// Add thinking level indicator if model supports reasoning\n\t\tlet rightSideWithoutProvider = modelName;\n\t\tif (state.model?.reasoning) {\n\t\t\tconst thinkingLevel = state.thinkingLevel || \"off\";\n\t\t\trightSideWithoutProvider =\n\t\t\t\tthinkingLevel === \"off\"\n\t\t\t\t\t? `${modelName} ${bulletSymbol} thinking off`\n\t\t\t\t\t: `${modelName} ${bulletSymbol} ${thinkingLevel}`;\n\t\t}\n\n\t\t// Prepend the provider in parentheses if there are multiple providers and there's enough room\n\t\tlet rightSide = rightSideWithoutProvider;\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\trightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`;\n\t\t\tif (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {\n\t\t\t\t// Too wide, fall back\n\t\t\t\trightSide = rightSideWithoutProvider;\n\t\t\t}\n\t\t}\n\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 0) {\n\t\t\t\tconst truncatedRight = truncateToWidth(rightSide, availableForRight, \"\");\n\t\t\t\tconst truncatedRightWidth = visibleWidth(truncatedRight);\n\t\t\t\tconst padding = \" \".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));\n\t\t\t\tstatsLine = statsLeft + padding + truncatedRight;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Apply dim to each part separately. statsLeft may contain color codes (for context %)\n\t\t// that end with a reset, which would clear an outer dim wrapper. So we dim the parts\n\t\t// before and after the colored section independently.\n\t\tconst dimStatsLeft = theme.fg(\"dim\", statsLeft);\n\t\tconst remainder = statsLine.slice(statsLeft.length); // padding + rightSide\n\t\tconst dimRemainder = theme.fg(\"dim\", remainder);\n\n\t\tconst pwdLine = truncateToWidth(theme.fg(\"dim\", pwd), width, theme.fg(\"dim\", \"...\"));\n\t\tconst lines = [pwdLine, dimStatsLeft + dimRemainder];\n\n\t\t// Add extension statuses on a single line, sorted by key alphabetically\n\t\tconst extensionStatuses = this.footerData.getExtensionStatuses();\n\t\tif (extensionStatuses.size > 0) {\n\t\t\tconst sortedStatuses = Array.from(extensionStatuses.entries())\n\t\t\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t\t\t.map(([, text]) => sanitizeStatusText(text));\n\t\t\tconst statusLine = sortedStatuses.join(\" \");\n\t\t\t// Truncate to terminal width with dim ellipsis for consistency with footer style\n\t\t\tlines.push(truncateToWidth(statusLine, width, theme.fg(\"dim\", \"...\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}
|
|
1
|
+
{"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../../../src/modes/interactive/components/footer.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAAiC,MAAM,wBAAwB,CAAC;AACvF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gCAAgC,CAAC;AAEnE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAC;AACxF,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,sCAAsC,CAAC;AACjF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AACzE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mCAAmC,CAAC;AAiBpE;;GAEG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAMlD;AAKD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CACjC,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,YAAY,EAAE,SAAS,GAAG,OAAO,GAC/B;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAWnC;AAED,mFAAmF;AACnF,MAAM,WAAW,2BAA2B;IAC3C,eAAe,IAAI,SAAS,GAAG,OAAO,CAAC;IACvC,iBAAiB,IAAI,OAAO,CAAC;IAC7B,oBAAoB,IAAI,KAAK,GAAG,SAAS,GAAG,MAAM,CAAC;CACnD;AAQD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAYhF;AAED;;;GAGG;AACH,qBAAa,eAAgB,YAAW,SAAS;IAChD,OAAO,CAAC,kBAAkB,CAAQ;IAClC,OAAO,CAAC,cAAc,CAA6B;IACnD,OAAO,CAAC,oBAAoB,CAAmC;IAC/D,OAAO,CAAC,OAAO,CAAe;IAC9B,OAAO,CAAC,UAAU,CAA6B;IAC/C,OAAO,CAAC,qBAAqB,CAA8B;IAC3D,OAAO,CAAC,QAAQ,CAAwB;IAExC,YACC,OAAO,EAAE,YAAY,EACrB,UAAU,EAAE,0BAA0B,EACtC,qBAAqB,GAAE,2BAA4D,EAKnF;IAED,OAAO,CAAC,UAAU,CAAC,CAAa;IAEhC,aAAa,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAEtC;IAED,UAAU,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI,CAKtC;IAED,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAE5C;IAED,iBAAiB,CAAC,IAAI,EAAE,cAAc,EAAE,MAAM,CAAC,EAAE,oBAAoB,GAAG,IAAI,CAG3E;IAED;;;;;;;;OAQG;IACH,WAAW,CAAC,QAAQ,EAAE,SAAS,GAAG,SAAS,GAAG,IAAI,CAEjD;IAED;;;OAGG;IACH,UAAU,IAAI,IAAI,CAEjB;IAED;;;OAGG;IACH,OAAO,IAAI,IAAI,CAEd;IAED;;;;;OAKG;IACH,OAAO,CAAC,UAAU,CAAC,CAIjB;IAEF,OAAO,CAAC,YAAY;IAiCpB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA2V9B;CACD","sourcesContent":["import { isAbsolute, relative, resolve, sep } from \"node:path\";\nimport { type Component, truncateToWidth, visibleWidth } from \"@earendil-works/pi-tui\";\nimport type { AgentSession } from \"../../../core/agent-session.ts\";\nimport { areExperimentalFeaturesEnabled } from \"../../../core/experimental.ts\";\nimport type { ReadonlyFooterDataProvider } from \"../../../core/footer-data-provider.ts\";\nimport type { PermissionModeOrigin } from \"../../../core/permissions/startup.ts\";\nimport type { PermissionMode } from \"../../../core/permissions/store.ts\";\nimport type { ChatDetail } from \"../../../core/settings-manager.ts\";\nimport { addUsageToTotals, createUsageTotals } from \"../../../core/usage-totals.ts\";\nimport { theme } from \"../theme/theme.ts\";\nimport { keyDisplayText } from \"./keybinding-hints.ts\";\n\n/**\n * Sanitize text for display in a single-line status.\n * Removes newlines, tabs, carriage returns, and other control characters.\n */\nfunction sanitizeStatusText(text: string): string {\n\t// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces\n\treturn text\n\t\t.replace(/[\\r\\n\\t]/g, \" \")\n\t\t.replace(/ +/g, \" \")\n\t\t.trim();\n}\n\n/**\n * Format token counts for compact footer display.\n */\nexport function formatTokens(count: number): string {\n\tif (count < 1000) return count.toString();\n\tif (count < 10000) return `${(count / 1000).toFixed(1)}k`;\n\tif (count < 1000000) return `${Math.round(count / 1000)}k`;\n\tif (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;\n\treturn `${Math.round(count / 1000000)}M`;\n}\n\n/** Cells in the context gauge. Eight reads as a bar without crowding the tray. */\nconst CONTEXT_GAUGE_CELLS = 8;\n\n/**\n * A filled bar for the context window, split into its filled and empty runs so\n * the caller can colour them separately.\n *\n * This is a redundant channel and never the only one: the percentage beside it\n * states the same value as text (WCAG 1.4.1). An unknown percentage renders an\n * empty track rather than guessing a position.\n */\nexport function renderContextGauge(\n\tpercent: number | undefined,\n\tsymbolPreset: \"unicode\" | \"ascii\",\n): { filled: string; empty: string } {\n\tconst filledGlyph = symbolPreset === \"ascii\" ? \"#\" : \"█\";\n\tconst emptyGlyph = symbolPreset === \"ascii\" ? \"-\" : \"░\";\n\tif (percent === undefined) {\n\t\treturn { filled: \"\", empty: emptyGlyph.repeat(CONTEXT_GAUGE_CELLS) };\n\t}\n\tconst clamped = Math.min(100, Math.max(0, percent));\n\t// Any non-zero usage lights at least one cell, so the bar never reads empty\n\t// while the window is genuinely filling.\n\tconst cells = clamped === 0 ? 0 : Math.max(1, Math.round((clamped / 100) * CONTEXT_GAUGE_CELLS));\n\treturn { filled: filledGlyph.repeat(cells), empty: emptyGlyph.repeat(CONTEXT_GAUGE_CELLS - cells) };\n}\n\n/** Accessibility/display settings the footer reads (roadmap Phase 8, task 8.6). */\nexport interface FooterAccessibilitySettings {\n\tgetSymbolPreset(): \"unicode\" | \"ascii\";\n\tgetColorBlindMode(): boolean;\n\tgetTokenUsageDisplay(): \"off\" | \"compact\" | \"full\";\n}\n\nconst DEFAULT_ACCESSIBILITY_SETTINGS: FooterAccessibilitySettings = {\n\tgetSymbolPreset: () => \"unicode\",\n\tgetColorBlindMode: () => false,\n\tgetTokenUsageDisplay: () => \"compact\",\n};\n\nexport function formatCwdForFooter(cwd: string, home: string | undefined): string {\n\tif (!home) return cwd;\n\n\tconst resolvedCwd = resolve(cwd);\n\tconst resolvedHome = resolve(home);\n\tconst relativeToHome = relative(resolvedHome, resolvedCwd);\n\tconst isInsideHome =\n\t\trelativeToHome === \"\" ||\n\t\t(relativeToHome !== \"..\" && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));\n\n\tif (!isInsideHome) return cwd;\n\treturn relativeToHome === \"\" ? \"~\" : `~/${relativeToHome.replaceAll(\"\\\\\", \"/\")}`;\n}\n\n/**\n * Footer component that shows pwd, token stats, and context usage.\n * Computes token/context stats from session, gets git branch and extension statuses from provider.\n */\nexport class FooterComponent implements Component {\n\tprivate autoCompactEnabled = true;\n\tprivate permissionMode: PermissionMode = \"default\";\n\tprivate permissionModeOrigin: PermissionModeOrigin | undefined;\n\tprivate session: AgentSession;\n\tprivate footerData: ReadonlyFooterDataProvider;\n\tprivate accessibilitySettings: FooterAccessibilitySettings;\n\tprivate activity: Component | undefined;\n\n\tconstructor(\n\t\tsession: AgentSession,\n\t\tfooterData: ReadonlyFooterDataProvider,\n\t\taccessibilitySettings: FooterAccessibilitySettings = DEFAULT_ACCESSIBILITY_SETTINGS,\n\t) {\n\t\tthis.session = session;\n\t\tthis.footerData = footerData;\n\t\tthis.accessibilitySettings = accessibilitySettings;\n\t}\n\n\tprivate chatDetail?: ChatDetail;\n\n\tsetChatDetail(detail: ChatDetail): void {\n\t\tthis.chatDetail = detail;\n\t}\n\n\tsetSession(session: AgentSession): void {\n\t\tthis.session = session;\n\t\t// The usage totals are cached against the previous session's entry\n\t\t// version; a session switch must not reuse them.\n\t\tthis.usageCache = undefined;\n\t}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\tsetPermissionMode(mode: PermissionMode, origin?: PermissionModeOrigin): void {\n\t\tthis.permissionMode = mode;\n\t\tthis.permissionModeOrigin = origin;\n\t}\n\n\t/**\n\t * The working indicator, rendered inline in the tray instead of in its own\n\t * rows above the editor.\n\t *\n\t * Taking the component rather than a string keeps the spinner animation, the\n\t * extension-supplied `WorkingIndicatorOptions`, and disposal where they already\n\t * live. This setter only moves where the line is drawn, so it cannot disturb\n\t * the usage cache.\n\t */\n\tsetActivity(activity: Component | undefined): void {\n\t\tthis.activity = activity;\n\t}\n\n\t/**\n\t * No-op: git branch caching now handled by provider.\n\t * Kept for compatibility with existing call sites in interactive-mode.\n\t */\n\tinvalidate(): void {\n\t\t// No-op: git branch is cached/invalidated by provider\n\t}\n\n\t/**\n\t * Clean up resources.\n\t * Git watcher cleanup now handled by provider.\n\t */\n\tdispose(): void {\n\t\t// Git watcher cleanup handled by provider\n\t}\n\n\t/**\n\t * Usage totals walked from all session entries. getEntries() copies the\n\t * whole entry list, so this is cached against the session manager's\n\t * entries version: without the cache the footer re-walked the entire\n\t * transcript on every rendered frame.\n\t */\n\tprivate usageCache?: {\n\t\tversion: number;\n\t\ttotals: ReturnType<typeof createUsageTotals>;\n\t\tlatestCacheHitRate?: number;\n\t};\n\n\tprivate computeUsage(): {\n\t\ttotals: ReturnType<typeof createUsageTotals>;\n\t\tlatestCacheHitRate?: number;\n\t} {\n\t\tconst version = this.session.sessionManager.getEntriesVersion();\n\t\tif (this.usageCache && this.usageCache.version === version) {\n\t\t\treturn { totals: this.usageCache.totals, latestCacheHitRate: this.usageCache.latestCacheHitRate };\n\t\t}\n\n\t\tconst usageTotals = createUsageTotals();\n\t\tlet latestCacheHitRate: number | undefined;\n\n\t\tfor (const entry of this.session.sessionManager.getEntries()) {\n\t\t\tif (entry.type === \"usage\") {\n\t\t\t\taddUsageToTotals(usageTotals, entry.usage);\n\t\t\t} else if (entry.type === \"message\" && entry.message.role === \"assistant\") {\n\t\t\t\taddUsageToTotals(usageTotals, entry.message.usage);\n\n\t\t\t\tconst latestPromptTokens =\n\t\t\t\t\tentry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite;\n\t\t\t\tlatestCacheHitRate =\n\t\t\t\t\tlatestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined;\n\t\t\t} else if (entry.type === \"message\" && entry.message.role === \"toolResult\" && entry.message.usage) {\n\t\t\t\taddUsageToTotals(usageTotals, entry.message.usage);\n\t\t\t} else if ((entry.type === \"branch_summary\" || entry.type === \"compaction\") && entry.usage) {\n\t\t\t\taddUsageToTotals(usageTotals, entry.usage);\n\t\t\t}\n\t\t}\n\n\t\tthis.usageCache = { version, totals: usageTotals, latestCacheHitRate };\n\t\treturn { totals: usageTotals, latestCacheHitRate };\n\t}\n\n\trender(width: number): string[] {\n\t\tconst state = this.session.state;\n\t\tconst symbolPreset = this.accessibilitySettings.getSymbolPreset();\n\t\tconst colorBlindMode = this.accessibilitySettings.getColorBlindMode();\n\t\tconst tokenUsageDisplay = this.accessibilitySettings.getTokenUsageDisplay();\n\t\tconst upSymbol = symbolPreset === \"ascii\" ? \"^\" : \"↑\";\n\t\tconst downSymbol = symbolPreset === \"ascii\" ? \"v\" : \"↓\";\n\t\tconst bulletSymbol = symbolPreset === \"ascii\" ? \"-\" : \"•\";\n\t\t// One line, unpadded, so the fit ladder can measure it as a segment. Loader\n\t\t// prepends a blank row to stand off from the transcript, which the tray does\n\t\t// not need, so this takes the first row that actually carries the message.\n\t\tconst activityLine = this.activity\n\t\t\t?.render(width)\n\t\t\t.map((line) => line.replace(/ +$/, \"\"))\n\t\t\t.find((line) => line.length > 0);\n\n\t\t// Calculate cumulative usage from ALL session entries (not just post-compaction messages),\n\t\t// cached against the session's entry version (see computeUsage).\n\t\tconst { totals: usageTotals, latestCacheHitRate } = this.computeUsage();\n\n\t\t// Calculate context usage from session (handles compaction correctly).\n\t\t// After compaction, tokens are unknown until the next LLM response.\n\t\tconst contextUsage = this.session.getContextUsage();\n\t\tconst contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;\n\t\tconst contextPercentValue = contextUsage?.percent ?? 0;\n\t\t// The gauge lights a cell for any usage at all, so a figure that rounds to zero says so.\n\t\tconst contextPercent =\n\t\t\tcontextUsage?.percent === null\n\t\t\t\t? \"?\"\n\t\t\t\t: contextPercentValue > 0 && contextPercentValue < 0.05\n\t\t\t\t\t? \"<0.1\"\n\t\t\t\t\t: contextPercentValue.toFixed(1);\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = formatCwdForFooter(this.session.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);\n\n\t\t// Add git branch if available\n\t\tconst branch = this.footerData.getGitBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Add session name if set\n\t\tconst sessionName = this.session.sessionManager.getSessionName();\n\t\tif (sessionName) {\n\t\t\tpwd = `${pwd} ${bulletSymbol} ${sessionName}`;\n\t\t}\n\n\t\t// Build stats line. tokenUsageDisplay: \"off\" hides this whole section\n\t\t// (context% and model name are separate and always shown); \"full\" shows\n\t\t// exact counts instead of formatTokens' abbreviation.\n\t\tconst formatTokenCount = tokenUsageDisplay === \"full\" ? (n: number) => n.toLocaleString() : formatTokens;\n\t\tconst statsParts = [];\n\t\tif (tokenUsageDisplay !== \"off\") {\n\t\t\tif (usageTotals.input) statsParts.push(`${upSymbol}${formatTokenCount(usageTotals.input)}`);\n\t\t\tif (usageTotals.output) statsParts.push(`${downSymbol}${formatTokenCount(usageTotals.output)}`);\n\t\t\tif (usageTotals.cacheRead) statsParts.push(`R${formatTokenCount(usageTotals.cacheRead)}`);\n\t\t\tif (usageTotals.cacheWrite) statsParts.push(`W${formatTokenCount(usageTotals.cacheWrite)}`);\n\t\t\tif ((usageTotals.cacheRead > 0 || usageTotals.cacheWrite > 0) && latestCacheHitRate !== undefined) {\n\t\t\t\tstatsParts.push(`cache ${Math.round(latestCacheHitRate)}%`);\n\t\t\t}\n\t\t}\n\n\t\t// Kimi Coding is subscription-backed despite using API-key authentication.\n\t\tconst usingSubscription = state.model\n\t\t\t? state.model.provider === \"kimi-coding\" || this.session.modelRuntime.isUsingSubscription(state.model.provider)\n\t\t\t: false;\n\t\tif (tokenUsageDisplay !== \"off\" && (usageTotals.cost || usingSubscription)) {\n\t\t\tconst costStr = `$${usageTotals.cost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\tstatsParts.push(costStr);\n\t\t}\n\n\t\t// Context pressure is signalled through text at every setting -- never\n\t\t// color alone (WCAG 1.4.1) -- with color as an additional channel. The\n\t\t// default error/warning pair (red/amber) is already distinguishable for\n\t\t// most red-green colorblindness; colorBlindMode's palette adjustment\n\t\t// moves the critical (>90%) threshold to \"accent\" -- a hue outside that\n\t\t// red/amber family entirely -- rather than relying on a style attribute\n\t\t// like bold/underline, which several terminals (and this theme's own\n\t\t// chalk-backed style methods, suppressed outside a real TTY) silently\n\t\t// drop, making it an unreliable second channel.\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst pressureMarker = contextPercentValue > 90 ? \"!!\" : contextPercentValue > 70 ? \"!\" : \"\";\n\t\tconst contextPercentDisplay =\n\t\t\tcontextPercent === \"?\"\n\t\t\t\t? `?/${formatTokens(contextWindow)}${autoIndicator}`\n\t\t\t\t: `${contextPercent}%${pressureMarker}/${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(colorBlindMode ? \"accent\" : \"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\t\t// Only worth saying when pressure is real and nothing will act on its own.\n\t\t// With auto-compaction on, the \"(auto)\" marker above already answers it.\n\t\tconst compactPrompt =\n\t\t\tcontextPercentValue > 70 && !this.autoCompactEnabled ? theme.fg(\"warning\", \"/compact soon\") : undefined;\n\t\tstatsParts.push(contextPercentStr);\n\t\tif (compactPrompt) statsParts.push(compactPrompt);\n\t\tif (activityLine) statsParts.unshift(activityLine);\n\t\tif (areExperimentalFeaturesEnabled()) {\n\t\t\tstatsParts.push(`${theme.fg(\"dim\", bulletSymbol)} ${theme.bold(theme.fg(\"warning\", \"xp\"))}`);\n\t\t}\n\n\t\t// Permission posture is always named, including `default`. It is operational\n\t\t// state rather than routine telemetry: hiding the safe default would make the\n\t\t// tray change meaning by omission, while hiding bypass would conceal risk.\n\t\t// Text carries the signal and color remains a second channel (WCAG 1.4.1).\n\t\tconst modeColor =\n\t\t\tthis.permissionMode === \"bypassPermissions\" ? \"error\" : this.permissionMode === \"default\" ? \"dim\" : \"warning\";\n\t\tconst sessionMarker = this.permissionModeOrigin === \"interactive\" ? \" (session)\" : \"\";\n\t\tconst cycleKey = this.session.hasPermissionGate ? keyDisplayText(\"app.permissionMode.cycle\") : \"\";\n\t\tconst cycleHint = cycleKey ? theme.fg(\"dim\", `${cycleKey} mode`) : undefined;\n\t\tconst compactPermissionNames: Record<PermissionMode, string> = {\n\t\t\tdefault: \"default\",\n\t\t\tplan: \"plan\",\n\t\t\tacceptEdits: \"accept\",\n\t\t\tbypassPermissions: \"bypass\",\n\t\t\tdontAsk: \"dontAsk\",\n\t\t};\n\t\tconst permissionName = this.permissionMode + sessionMarker;\n\t\tconst safetyName =\n\t\t\tsessionMarker && visibleWidth(permissionName) > width\n\t\t\t\t? compactPermissionNames[this.permissionMode] + sessionMarker\n\t\t\t\t: permissionName;\n\t\tstatsParts.unshift(theme.fg(modeColor, safetyName));\n\t\tif (cycleHint) statsParts.push(cycleHint);\n\n\t\tif (tokenUsageDisplay !== \"full\") {\n\t\t\tconst separator = theme.fg(\"dim\", symbolPreset === \"ascii\" ? \" - \" : \" · \");\n\t\t\tconst permissionColor =\n\t\t\t\tthis.permissionMode === \"bypassPermissions\"\n\t\t\t\t\t? \"error\"\n\t\t\t\t\t: this.permissionMode === \"default\"\n\t\t\t\t\t\t? \"dim\"\n\t\t\t\t\t\t: \"warning\";\n\t\t\tconst permissionFull = theme.fg(permissionColor, this.permissionMode + sessionMarker);\n\t\t\tconst permissionCompact = theme.fg(\n\t\t\t\tpermissionColor,\n\t\t\t\tcompactPermissionNames[this.permissionMode] + sessionMarker,\n\t\t\t);\n\t\t\tconst compactPercent = contextPercent === \"?\" ? \"?\" : contextPercentValue.toFixed(0);\n\t\t\tconst compactPressure = contextPercentValue > 90 ? \"!!\" : contextPercentValue > 70 ? \"!\" : \"\";\n\t\t\tconst contextColor =\n\t\t\t\tcontextPercentValue > 90\n\t\t\t\t\t? colorBlindMode\n\t\t\t\t\t\t? \"accent\"\n\t\t\t\t\t\t: \"error\"\n\t\t\t\t\t: contextPercentValue > 70\n\t\t\t\t\t\t? \"warning\"\n\t\t\t\t\t\t: \"dim\";\n\t\t\t// The gauge rides along only in the roomy form. The compact form drops\n\t\t\t// it, which is what lets it keep winning the fit ladder below.\n\t\t\tconst gauge = renderContextGauge(contextPercent === \"?\" ? undefined : contextPercentValue, symbolPreset);\n\t\t\t// Below the warning threshold the text is dim, which would make the bar\n\t\t\t// invisible; the fill takes the accent there and tracks the text colour\n\t\t\t// at every level that actually signals pressure.\n\t\t\tconst gaugeColor = contextColor === \"dim\" ? \"accent\" : contextColor;\n\t\t\t// An empty run would still emit an open/close SGR pair, so skip it.\n\t\t\tconst paintRun = (color: Parameters<typeof theme.fg>[0], text: string) => (text ? theme.fg(color, text) : \"\");\n\t\t\tconst contextGauged =\n\t\t\t\ttheme.fg(contextColor, \"context \") +\n\t\t\t\tpaintRun(gaugeColor, gauge.filled) +\n\t\t\t\tpaintRun(\"borderMuted\", gauge.empty) +\n\t\t\t\ttheme.fg(contextColor, ` ${contextPercent}%${pressureMarker}`);\n\t\t\tconst contextFull = theme.fg(contextColor, `context ${contextPercent}%${pressureMarker}`);\n\t\t\tconst contextCompact = theme.fg(contextColor, `ctx ${compactPercent}%${compactPressure}`);\n\n\t\t\tconst joinSegments = (segments: string[]): string => segments.join(separator);\n\t\t\tconst fits = (segments: string[]): boolean => visibleWidth(joinSegments(segments)) <= width;\n\t\t\tconst safetyFirst = (...rest: Array<string | undefined>): string[] => [\n\t\t\t\tpermissionFull,\n\t\t\t\t...rest.filter((segment): segment is string => segment !== undefined),\n\t\t\t];\n\t\t\tlet left: string[] = safetyFirst(compactPrompt, activityLine);\n\t\t\tlet right = contextGauged;\n\n\t\t\t// The gauge is decoration over a value the text already states, so it\n\t\t\t// is the first thing to go. Dropping it must not cost the tray its\n\t\t\t// spelled-out permission mode, which is why this is its own rung.\n\t\t\tif (!fits([...left, right])) {\n\t\t\t\tright = contextFull;\n\t\t\t}\n\n\t\t\t// Activity is operational, so it outranks every optional segment below.\n\t\t\t// It still yields to the safety posture and to the pressure reading,\n\t\t\t// which are the two things the tray must never lose.\n\t\t\tif (!fits([...left, right])) {\n\t\t\t\tleft = safetyFirst(compactPrompt);\n\t\t\t}\n\n\t\t\tif (!fits([...left, right])) {\n\t\t\t\tleft = safetyFirst();\n\t\t\t}\n\n\t\t\tif (!fits([...left, right])) {\n\t\t\t\tleft = [permissionCompact];\n\t\t\t\tright = contextCompact;\n\t\t\t}\n\n\t\t\tif (!fits([...left, right])) {\n\t\t\t\tconst safetyOnly = left[0];\n\t\t\t\tconst line =\n\t\t\t\t\tvisibleWidth(safetyOnly) <= width ? safetyOnly : truncateToWidth(safetyOnly, Math.max(0, width), \"\");\n\t\t\t\treturn width > 0 ? [line] : [];\n\t\t\t}\n\n\t\t\tconst optional: string[] = [];\n\n\t\t\tif (cycleHint) optional.push(cycleHint);\n\t\t\toptional.push(theme.fg(\"dim\", state.model?.id || \"no-model\"));\n\t\t\tconst detailHint = this.chatDetail\n\t\t\t\t? theme.fg(\"dim\", `${this.chatDetail} ${keyDisplayText(\"app.tools.expand\")}`)\n\t\t\t\t: undefined;\n\t\t\tif (detailHint) optional.push(detailHint);\n\t\t\tif (state.model?.reasoning && state.thinkingLevel && state.thinkingLevel !== \"off\") {\n\t\t\t\toptional.push(theme.fg(\"dim\", state.thinkingLevel));\n\t\t\t}\n\t\t\tif (tokenUsageDisplay !== \"off\" && (usageTotals.input || usageTotals.output)) {\n\t\t\t\toptional.push(\n\t\t\t\t\ttheme.fg(\n\t\t\t\t\t\t\"dim\",\n\t\t\t\t\t\t`${upSymbol}${formatTokens(usageTotals.input)} ${downSymbol}${formatTokens(usageTotals.output)}`,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (\n\t\t\t\ttokenUsageDisplay !== \"off\" &&\n\t\t\t\t(usageTotals.cacheRead > 0 || usageTotals.cacheWrite > 0) &&\n\t\t\t\tlatestCacheHitRate !== undefined\n\t\t\t) {\n\t\t\t\toptional.push(theme.fg(\"dim\", `cache ${Math.round(latestCacheHitRate)}%`));\n\t\t\t}\n\t\t\tif (tokenUsageDisplay !== \"off\" && (usageTotals.cost || usingSubscription)) {\n\t\t\t\toptional.push(theme.fg(\"dim\", `$${usageTotals.cost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`));\n\t\t\t}\n\t\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\t\toptional.push(theme.fg(\"dim\", state.model.provider));\n\t\t\t}\n\t\t\tif (areExperimentalFeaturesEnabled()) optional.push(theme.fg(\"warning\", \"xp\"));\n\t\t\toptional.push(theme.fg(\"dim\", pwd));\n\n\t\t\tfor (const segment of optional) {\n\t\t\t\tif (fits([...left, segment, right])) left.push(segment);\n\t\t\t\telse if (segment === detailHint && this.chatDetail && fits([...left, this.chatDetail, right]))\n\t\t\t\t\tleft.push(theme.fg(\"dim\", this.chatDetail));\n\t\t\t}\n\n\t\t\tconst leftText = joinSegments(left);\n\t\t\tconst padding = \" \".repeat(Math.max(1, width - visibleWidth(leftText) - visibleWidth(right)));\n\t\t\tconst tray = leftText ? `${leftText}${padding}${right}` : right;\n\t\t\tconst lines = [tray];\n\t\t\tconst extensionStatuses = this.footerData.getExtensionStatuses();\n\t\t\tif (extensionStatuses.size > 0) {\n\t\t\t\tconst sortedStatuses = Array.from(extensionStatuses.entries())\n\t\t\t\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t\t\t\t.map(([, text]) => sanitizeStatusText(text));\n\t\t\t\tlines.push(truncateToWidth(sortedStatuses.join(\" \"), width, theme.fg(\"dim\", \"...\")));\n\t\t\t}\n\t\t\treturn lines;\n\t\t}\n\n\t\tif (this.chatDetail) statsParts.push(theme.fg(\"dim\", `${this.chatDetail} ${keyDisplayText(\"app.tools.expand\")}`));\n\t\tlet statsLeft = statsParts.join(\" \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = state.model?.id || \"no-model\";\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\t// Permission mode is the first segment and must remain textual at every\n\t\t\t// positive width. An ellipsis suffix can consume the entire narrow line.\n\t\t\tstatsLeft = truncateToWidth(statsLeft, width, \"\");\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\n\t\t// Add thinking level indicator if model supports reasoning\n\t\tlet rightSideWithoutProvider = modelName;\n\t\tif (state.model?.reasoning) {\n\t\t\tconst thinkingLevel = state.thinkingLevel || \"off\";\n\t\t\trightSideWithoutProvider =\n\t\t\t\tthinkingLevel === \"off\"\n\t\t\t\t\t? `${modelName} ${bulletSymbol} thinking off`\n\t\t\t\t\t: `${modelName} ${bulletSymbol} ${thinkingLevel}`;\n\t\t}\n\n\t\t// Prepend the provider in parentheses if there are multiple providers and there's enough room\n\t\tlet rightSide = rightSideWithoutProvider;\n\t\tif (this.footerData.getAvailableProviderCount() > 1 && state.model) {\n\t\t\trightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`;\n\t\t\tif (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) {\n\t\t\t\t// Too wide, fall back\n\t\t\t\trightSide = rightSideWithoutProvider;\n\t\t\t}\n\t\t}\n\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 0) {\n\t\t\t\tconst truncatedRight = truncateToWidth(rightSide, availableForRight, \"\");\n\t\t\t\tconst truncatedRightWidth = visibleWidth(truncatedRight);\n\t\t\t\tconst padding = \" \".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));\n\t\t\t\tstatsLine = statsLeft + padding + truncatedRight;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Apply dim to each part separately. statsLeft may contain color codes (for context %)\n\t\t// that end with a reset, which would clear an outer dim wrapper. So we dim the parts\n\t\t// before and after the colored section independently.\n\t\tconst dimStatsLeft = theme.fg(\"dim\", statsLeft);\n\t\tconst remainder = statsLine.slice(statsLeft.length); // padding + rightSide\n\t\tconst dimRemainder = theme.fg(\"dim\", remainder);\n\n\t\tconst pwdLine = truncateToWidth(theme.fg(\"dim\", pwd), width, theme.fg(\"dim\", \"...\"));\n\t\tconst lines = [pwdLine, dimStatsLeft + dimRemainder];\n\n\t\t// Add extension statuses on a single line, sorted by key alphabetically\n\t\tconst extensionStatuses = this.footerData.getExtensionStatuses();\n\t\tif (extensionStatuses.size > 0) {\n\t\t\tconst sortedStatuses = Array.from(extensionStatuses.entries())\n\t\t\t\t.sort(([a], [b]) => a.localeCompare(b))\n\t\t\t\t.map(([, text]) => sanitizeStatusText(text));\n\t\t\tconst statusLine = sortedStatuses.join(\" \");\n\t\t\t// Truncate to terminal width with dim ellipsis for consistency with footer style\n\t\t\tlines.push(truncateToWidth(statusLine, width, theme.fg(\"dim\", \"...\")));\n\t\t}\n\n\t\treturn lines;\n\t}\n}\n"]}
|
|
@@ -3,6 +3,7 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
3
3
|
import { areExperimentalFeaturesEnabled } from "../../../core/experimental.js";
|
|
4
4
|
import { addUsageToTotals, createUsageTotals } from "../../../core/usage-totals.js";
|
|
5
5
|
import { theme } from "../theme/theme.js";
|
|
6
|
+
import { keyDisplayText } from "./keybinding-hints.js";
|
|
6
7
|
/**
|
|
7
8
|
* Sanitize text for display in a single-line status.
|
|
8
9
|
* Removes newlines, tabs, carriage returns, and other control characters.
|
|
@@ -74,6 +75,7 @@ export function formatCwdForFooter(cwd, home) {
|
|
|
74
75
|
export class FooterComponent {
|
|
75
76
|
autoCompactEnabled = true;
|
|
76
77
|
permissionMode = "default";
|
|
78
|
+
permissionModeOrigin;
|
|
77
79
|
session;
|
|
78
80
|
footerData;
|
|
79
81
|
accessibilitySettings;
|
|
@@ -83,6 +85,10 @@ export class FooterComponent {
|
|
|
83
85
|
this.footerData = footerData;
|
|
84
86
|
this.accessibilitySettings = accessibilitySettings;
|
|
85
87
|
}
|
|
88
|
+
chatDetail;
|
|
89
|
+
setChatDetail(detail) {
|
|
90
|
+
this.chatDetail = detail;
|
|
91
|
+
}
|
|
86
92
|
setSession(session) {
|
|
87
93
|
this.session = session;
|
|
88
94
|
// The usage totals are cached against the previous session's entry
|
|
@@ -92,8 +98,9 @@ export class FooterComponent {
|
|
|
92
98
|
setAutoCompactEnabled(enabled) {
|
|
93
99
|
this.autoCompactEnabled = enabled;
|
|
94
100
|
}
|
|
95
|
-
setPermissionMode(mode) {
|
|
101
|
+
setPermissionMode(mode, origin) {
|
|
96
102
|
this.permissionMode = mode;
|
|
103
|
+
this.permissionModeOrigin = origin;
|
|
97
104
|
}
|
|
98
105
|
/**
|
|
99
106
|
* The working indicator, rendered inline in the tray instead of in its own
|
|
@@ -262,23 +269,32 @@ export class FooterComponent {
|
|
|
262
269
|
// tray change meaning by omission, while hiding bypass would conceal risk.
|
|
263
270
|
// Text carries the signal and color remains a second channel (WCAG 1.4.1).
|
|
264
271
|
const modeColor = this.permissionMode === "bypassPermissions" ? "error" : this.permissionMode === "default" ? "dim" : "warning";
|
|
265
|
-
|
|
272
|
+
const sessionMarker = this.permissionModeOrigin === "interactive" ? " (session)" : "";
|
|
273
|
+
const cycleKey = this.session.hasPermissionGate ? keyDisplayText("app.permissionMode.cycle") : "";
|
|
274
|
+
const cycleHint = cycleKey ? theme.fg("dim", `${cycleKey} mode`) : undefined;
|
|
275
|
+
const compactPermissionNames = {
|
|
276
|
+
default: "default",
|
|
277
|
+
plan: "plan",
|
|
278
|
+
acceptEdits: "accept",
|
|
279
|
+
bypassPermissions: "bypass",
|
|
280
|
+
dontAsk: "dontAsk",
|
|
281
|
+
};
|
|
282
|
+
const permissionName = this.permissionMode + sessionMarker;
|
|
283
|
+
const safetyName = sessionMarker && visibleWidth(permissionName) > width
|
|
284
|
+
? compactPermissionNames[this.permissionMode] + sessionMarker
|
|
285
|
+
: permissionName;
|
|
286
|
+
statsParts.unshift(theme.fg(modeColor, safetyName));
|
|
287
|
+
if (cycleHint)
|
|
288
|
+
statsParts.push(cycleHint);
|
|
266
289
|
if (tokenUsageDisplay !== "full") {
|
|
267
290
|
const separator = theme.fg("dim", symbolPreset === "ascii" ? " - " : " · ");
|
|
268
|
-
const compactPermissionNames = {
|
|
269
|
-
default: "default",
|
|
270
|
-
plan: "plan",
|
|
271
|
-
acceptEdits: "accept",
|
|
272
|
-
bypassPermissions: "bypass",
|
|
273
|
-
dontAsk: "dontAsk",
|
|
274
|
-
};
|
|
275
291
|
const permissionColor = this.permissionMode === "bypassPermissions"
|
|
276
292
|
? "error"
|
|
277
293
|
: this.permissionMode === "default"
|
|
278
294
|
? "dim"
|
|
279
295
|
: "warning";
|
|
280
|
-
const permissionFull = theme.fg(permissionColor, this.permissionMode);
|
|
281
|
-
const permissionCompact = theme.fg(permissionColor, compactPermissionNames[this.permissionMode]);
|
|
296
|
+
const permissionFull = theme.fg(permissionColor, this.permissionMode + sessionMarker);
|
|
297
|
+
const permissionCompact = theme.fg(permissionColor, compactPermissionNames[this.permissionMode] + sessionMarker);
|
|
282
298
|
const compactPercent = contextPercent === "?" ? "?" : contextPercentValue.toFixed(0);
|
|
283
299
|
const compactPressure = contextPercentValue > 90 ? "!!" : contextPercentValue > 70 ? "!" : "";
|
|
284
300
|
const contextColor = contextPercentValue > 90
|
|
@@ -336,7 +352,14 @@ export class FooterComponent {
|
|
|
336
352
|
return width > 0 ? [line] : [];
|
|
337
353
|
}
|
|
338
354
|
const optional = [];
|
|
355
|
+
if (cycleHint)
|
|
356
|
+
optional.push(cycleHint);
|
|
339
357
|
optional.push(theme.fg("dim", state.model?.id || "no-model"));
|
|
358
|
+
const detailHint = this.chatDetail
|
|
359
|
+
? theme.fg("dim", `${this.chatDetail} ${keyDisplayText("app.tools.expand")}`)
|
|
360
|
+
: undefined;
|
|
361
|
+
if (detailHint)
|
|
362
|
+
optional.push(detailHint);
|
|
340
363
|
if (state.model?.reasoning && state.thinkingLevel && state.thinkingLevel !== "off") {
|
|
341
364
|
optional.push(theme.fg("dim", state.thinkingLevel));
|
|
342
365
|
}
|
|
@@ -360,6 +383,8 @@ export class FooterComponent {
|
|
|
360
383
|
for (const segment of optional) {
|
|
361
384
|
if (fits([...left, segment, right]))
|
|
362
385
|
left.push(segment);
|
|
386
|
+
else if (segment === detailHint && this.chatDetail && fits([...left, this.chatDetail, right]))
|
|
387
|
+
left.push(theme.fg("dim", this.chatDetail));
|
|
363
388
|
}
|
|
364
389
|
const leftText = joinSegments(left);
|
|
365
390
|
const padding = " ".repeat(Math.max(1, width - visibleWidth(leftText) - visibleWidth(right)));
|
|
@@ -374,6 +399,8 @@ export class FooterComponent {
|
|
|
374
399
|
}
|
|
375
400
|
return lines;
|
|
376
401
|
}
|
|
402
|
+
if (this.chatDetail)
|
|
403
|
+
statsParts.push(theme.fg("dim", `${this.chatDetail} ${keyDisplayText("app.tools.expand")}`));
|
|
377
404
|
let statsLeft = statsParts.join(" ");
|
|
378
405
|
// Add model name on the right side, plus thinking level if model supports it
|
|
379
406
|
const modelName = state.model?.id || "no-model";
|