lsd-pi 1.3.2 → 1.3.6

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.
Files changed (169) hide show
  1. package/dist/resources/extensions/browser-tools/tools/codegen.js +5 -5
  2. package/dist/resources/extensions/browser-tools/tools/navigation.js +107 -178
  3. package/dist/resources/extensions/browser-tools/tools/network-mock.js +112 -167
  4. package/dist/resources/extensions/browser-tools/tools/pages.js +182 -234
  5. package/dist/resources/extensions/browser-tools/tools/refs.js +202 -461
  6. package/dist/resources/extensions/browser-tools/tools/session.js +176 -323
  7. package/dist/resources/extensions/browser-tools/tools/state-persistence.js +91 -154
  8. package/dist/resources/extensions/browser-tools/utils.js +1 -1
  9. package/dist/resources/extensions/slash-commands/extension-manifest.json +2 -2
  10. package/dist/resources/extensions/slash-commands/fast.js +73 -0
  11. package/dist/resources/extensions/slash-commands/index.js +2 -0
  12. package/dist/resources/extensions/slash-commands/plan.js +37 -12
  13. package/dist/resources/extensions/subagent/background-job-manager.js +13 -0
  14. package/dist/resources/extensions/subagent/in-process-runner.js +387 -0
  15. package/dist/resources/extensions/subagent/index.js +278 -626
  16. package/dist/resources/extensions/subagent/legacy-runner.js +503 -0
  17. package/dist/resources/extensions/voice/index.js +96 -36
  18. package/dist/resources/extensions/voice/push-to-talk.js +26 -0
  19. package/package.json +1 -1
  20. package/packages/pi-agent-core/dist/agent.d.ts +19 -0
  21. package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
  22. package/packages/pi-agent-core/dist/agent.js +16 -0
  23. package/packages/pi-agent-core/dist/agent.js.map +1 -1
  24. package/packages/pi-agent-core/src/agent.ts +32 -2
  25. package/packages/pi-ai/dist/providers/openai-codex-responses.d.ts +34 -1
  26. package/packages/pi-ai/dist/providers/openai-codex-responses.d.ts.map +1 -1
  27. package/packages/pi-ai/dist/providers/openai-codex-responses.js +32 -4
  28. package/packages/pi-ai/dist/providers/openai-codex-responses.js.map +1 -1
  29. package/packages/pi-ai/dist/providers/openai-codex-responses.test.js +127 -16
  30. package/packages/pi-ai/dist/providers/openai-codex-responses.test.js.map +1 -1
  31. package/packages/pi-ai/dist/providers/openai-responses.d.ts +8 -1
  32. package/packages/pi-ai/dist/providers/openai-responses.d.ts.map +1 -1
  33. package/packages/pi-ai/dist/providers/openai-responses.fast-mode.test.d.ts +2 -0
  34. package/packages/pi-ai/dist/providers/openai-responses.fast-mode.test.d.ts.map +1 -0
  35. package/packages/pi-ai/dist/providers/openai-responses.fast-mode.test.js +67 -0
  36. package/packages/pi-ai/dist/providers/openai-responses.fast-mode.test.js.map +1 -0
  37. package/packages/pi-ai/dist/providers/openai-responses.js +21 -3
  38. package/packages/pi-ai/dist/providers/openai-responses.js.map +1 -1
  39. package/packages/pi-ai/dist/providers/simple-options.d.ts.map +1 -1
  40. package/packages/pi-ai/dist/providers/simple-options.js +2 -0
  41. package/packages/pi-ai/dist/providers/simple-options.js.map +1 -1
  42. package/packages/pi-ai/dist/types.d.ts +5 -0
  43. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  44. package/packages/pi-ai/dist/types.js.map +1 -1
  45. package/packages/pi-ai/src/providers/openai-codex-responses.test.ts +143 -20
  46. package/packages/pi-ai/src/providers/openai-codex-responses.ts +47 -4
  47. package/packages/pi-ai/src/providers/openai-responses.fast-mode.test.ts +73 -0
  48. package/packages/pi-ai/src/providers/openai-responses.ts +26 -3
  49. package/packages/pi-ai/src/providers/simple-options.ts +2 -0
  50. package/packages/pi-ai/src/types.ts +5 -0
  51. package/packages/pi-coding-agent/dist/core/keybindings.d.ts +1 -1
  52. package/packages/pi-coding-agent/dist/core/keybindings.d.ts.map +1 -1
  53. package/packages/pi-coding-agent/dist/core/keybindings.js +2 -0
  54. package/packages/pi-coding-agent/dist/core/keybindings.js.map +1 -1
  55. package/packages/pi-coding-agent/dist/core/sdk.d.ts.map +1 -1
  56. package/packages/pi-coding-agent/dist/core/sdk.js +4 -2
  57. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  58. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +6 -0
  59. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  60. package/packages/pi-coding-agent/dist/core/settings-manager.fast-mode.test.d.ts +2 -0
  61. package/packages/pi-coding-agent/dist/core/settings-manager.fast-mode.test.d.ts.map +1 -0
  62. package/packages/pi-coding-agent/dist/core/settings-manager.fast-mode.test.js +35 -0
  63. package/packages/pi-coding-agent/dist/core/settings-manager.fast-mode.test.js.map +1 -0
  64. package/packages/pi-coding-agent/dist/core/settings-manager.js +12 -0
  65. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  66. package/packages/pi-coding-agent/dist/core/slash-commands.d.ts.map +1 -1
  67. package/packages/pi-coding-agent/dist/core/slash-commands.js +1 -0
  68. package/packages/pi-coding-agent/dist/core/slash-commands.js.map +1 -1
  69. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -1
  71. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  72. package/packages/pi-coding-agent/dist/core/tool-priority.d.ts +4 -0
  73. package/packages/pi-coding-agent/dist/core/tool-priority.d.ts.map +1 -0
  74. package/packages/pi-coding-agent/dist/core/tool-priority.js +18 -0
  75. package/packages/pi-coding-agent/dist/core/tool-priority.js.map +1 -0
  76. package/packages/pi-coding-agent/dist/core/tool-priority.test.d.ts +2 -0
  77. package/packages/pi-coding-agent/dist/core/tool-priority.test.d.ts.map +1 -0
  78. package/packages/pi-coding-agent/dist/core/tool-priority.test.js +27 -0
  79. package/packages/pi-coding-agent/dist/core/tool-priority.test.js.map +1 -0
  80. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.d.ts +2 -0
  81. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.d.ts.map +1 -0
  82. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js +26 -0
  83. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-summary-line.test.js.map +1 -0
  84. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.d.ts +45 -0
  85. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.d.ts.map +1 -0
  86. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.js +314 -0
  87. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.js.map +1 -0
  88. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.test.d.ts +2 -0
  89. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.test.d.ts.map +1 -0
  90. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.test.js +122 -0
  91. package/packages/pi-coding-agent/dist/modes/interactive/components/btw-overlay.test.js.map +1 -0
  92. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts +2 -0
  93. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.d.ts.map +1 -1
  94. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js +7 -0
  95. package/packages/pi-coding-agent/dist/modes/interactive/components/footer.js.map +1 -1
  96. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.d.ts +4 -0
  97. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  98. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.js +26 -2
  99. package/packages/pi-coding-agent/dist/modes/interactive/components/settings-selector.js.map +1 -1
  100. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts +6 -0
  101. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  102. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +18 -4
  103. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  104. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts +13 -0
  105. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.d.ts.map +1 -0
  106. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js +49 -0
  107. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-summary-line.js.map +1 -0
  108. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.d.ts +2 -0
  109. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.d.ts.map +1 -0
  110. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js +197 -0
  111. package/packages/pi-coding-agent/dist/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.js.map +1 -0
  112. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  113. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +97 -0
  114. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  115. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.js +7 -0
  117. package/packages/pi-coding-agent/dist/modes/interactive/controllers/input-controller.js.map +1 -1
  118. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +3 -0
  119. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  120. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  121. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +4 -0
  122. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  123. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +35 -0
  124. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  125. package/packages/pi-coding-agent/dist/modes/interactive/slash-command-handlers.d.ts.map +1 -1
  126. package/packages/pi-coding-agent/dist/modes/interactive/slash-command-handlers.js +41 -0
  127. package/packages/pi-coding-agent/dist/modes/interactive/slash-command-handlers.js.map +1 -1
  128. package/packages/pi-coding-agent/package.json +1 -1
  129. package/packages/pi-coding-agent/src/core/keybindings.ts +4 -1
  130. package/packages/pi-coding-agent/src/core/sdk.ts +4 -2
  131. package/packages/pi-coding-agent/src/core/settings-manager.fast-mode.test.ts +46 -0
  132. package/packages/pi-coding-agent/src/core/settings-manager.ts +18 -0
  133. package/packages/pi-coding-agent/src/core/slash-commands.ts +1 -0
  134. package/packages/pi-coding-agent/src/core/system-prompt.ts +6 -1
  135. package/packages/pi-coding-agent/src/core/tool-priority.test.ts +30 -0
  136. package/packages/pi-coding-agent/src/core/tool-priority.ts +17 -0
  137. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-summary-line.test.ts +31 -0
  138. package/packages/pi-coding-agent/src/modes/interactive/components/btw-overlay.test.ts +172 -0
  139. package/packages/pi-coding-agent/src/modes/interactive/components/btw-overlay.ts +402 -0
  140. package/packages/pi-coding-agent/src/modes/interactive/components/footer.ts +8 -0
  141. package/packages/pi-coding-agent/src/modes/interactive/components/settings-selector.ts +32 -2
  142. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +1154 -1136
  143. package/packages/pi-coding-agent/src/modes/interactive/components/tool-summary-line.ts +64 -0
  144. package/packages/pi-coding-agent/src/modes/interactive/controllers/__tests__/chat-controller.collapsed-tool-summary.test.ts +228 -0
  145. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +494 -398
  146. package/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts +7 -0
  147. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +3 -0
  148. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +38 -0
  149. package/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts +60 -1
  150. package/pkg/package.json +1 -1
  151. package/src/resources/extensions/browser-tools/tools/codegen.ts +5 -5
  152. package/src/resources/extensions/browser-tools/tools/navigation.ts +118 -196
  153. package/src/resources/extensions/browser-tools/tools/network-mock.ts +114 -205
  154. package/src/resources/extensions/browser-tools/tools/pages.ts +183 -237
  155. package/src/resources/extensions/browser-tools/tools/refs.ts +193 -507
  156. package/src/resources/extensions/browser-tools/tools/session.ts +182 -321
  157. package/src/resources/extensions/browser-tools/tools/state-persistence.ts +94 -172
  158. package/src/resources/extensions/browser-tools/utils.ts +1 -1
  159. package/src/resources/extensions/slash-commands/extension-manifest.json +2 -2
  160. package/src/resources/extensions/slash-commands/fast.ts +89 -0
  161. package/src/resources/extensions/slash-commands/index.ts +2 -0
  162. package/src/resources/extensions/slash-commands/plan.ts +42 -12
  163. package/src/resources/extensions/subagent/background-job-manager.ts +28 -0
  164. package/src/resources/extensions/subagent/in-process-runner.ts +534 -0
  165. package/src/resources/extensions/subagent/index.ts +489 -799
  166. package/src/resources/extensions/subagent/legacy-runner.ts +607 -0
  167. package/src/resources/extensions/voice/index.ts +308 -238
  168. package/src/resources/extensions/voice/push-to-talk.ts +42 -0
  169. package/src/resources/extensions/voice/tests/push-to-talk.test.ts +109 -0
@@ -0,0 +1,17 @@
1
+ export type ToolPriority = "always" | "on-error" | "collapse";
2
+
3
+ const ALWAYS_VISIBLE = new Set(["edit", "write"]);
4
+ const ON_ERROR = new Set(["bash", "bg_shell"]);
5
+
6
+ export function getToolPriority(toolName: string): ToolPriority {
7
+ if (ALWAYS_VISIBLE.has(toolName)) return "always";
8
+ if (ON_ERROR.has(toolName)) return "on-error";
9
+ return "collapse";
10
+ }
11
+
12
+ export function shouldCollapse(toolName: string, isError: boolean): boolean {
13
+ const priority = getToolPriority(toolName);
14
+ if (priority === "always") return false;
15
+ if (priority === "on-error") return !isError;
16
+ return true;
17
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import stripAnsi from "strip-ansi";
4
+
5
+ import { ToolSummaryLine } from "../tool-summary-line.js";
6
+ import { initTheme } from "../../theme/theme.js";
7
+
8
+ initTheme("dark");
9
+
10
+ describe("ToolSummaryLine", () => {
11
+ it("aggregates repeated tools with tool-row style formatting", () => {
12
+ const summary = new ToolSummaryLine();
13
+ summary.addTool("read", 600);
14
+ summary.addTool("lsp", 250);
15
+ summary.addTool("read", 150);
16
+
17
+ const rendered = stripAnsi(summary.render(160).join("\n"));
18
+ assert.match(rendered, /^ ● collapsed tools /);
19
+ assert.ok(rendered.includes("read ×2 · lsp · 1.0s"));
20
+ assert.equal(rendered.includes("⎯"), false);
21
+ });
22
+
23
+ it("renders nothing when empty or hidden", () => {
24
+ const summary = new ToolSummaryLine();
25
+ assert.deepEqual(summary.render(80), []);
26
+
27
+ summary.addTool("grep", 100);
28
+ summary.setHidden(true);
29
+ assert.deepEqual(summary.render(80), []);
30
+ });
31
+ });
@@ -0,0 +1,172 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import stripAnsi from "strip-ansi";
4
+ import type { Context, Model, SimpleStreamOptions, Message } from "@gsd/pi-ai";
5
+ import type { TUI } from "@gsd/pi-tui";
6
+ import { BtwOverlayComponent } from "./btw-overlay.js";
7
+ import { getMarkdownTheme, initTheme } from "../theme/theme.js";
8
+
9
+ initTheme();
10
+
11
+ function makeUi(rows = 40): TUI {
12
+ return { terminal: { rows } } as unknown as TUI;
13
+ }
14
+
15
+ function makeModel(): Model<any> {
16
+ return {
17
+ id: "test-model",
18
+ name: "Test Model",
19
+ api: "openai-completions",
20
+ provider: "test",
21
+ baseUrl: "",
22
+ reasoning: false,
23
+ input: ["text"],
24
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
25
+ contextWindow: 4096,
26
+ maxTokens: 1024,
27
+ } as unknown as Model<any>;
28
+ }
29
+
30
+ function makeMessages(): Message[] {
31
+ return [
32
+ {
33
+ role: "user",
34
+ content: [{ type: "text", text: "hello" }],
35
+ timestamp: Date.now(),
36
+ },
37
+ ];
38
+ }
39
+
40
+ function renderText(component: BtwOverlayComponent): string {
41
+ return stripAnsi(component.render(80).join("\n"));
42
+ }
43
+
44
+ async function flushTurns(count = 3): Promise<void> {
45
+ for (let i = 0; i < count; i++) {
46
+ await Promise.resolve();
47
+ await new Promise((resolve) => setTimeout(resolve, 0));
48
+ }
49
+ }
50
+
51
+ test("BtwOverlayComponent renders initial question and placeholder before response", async () => {
52
+ const component = new BtwOverlayComponent(
53
+ "What is btw?",
54
+ makeModel(),
55
+ "system",
56
+ makeMessages(),
57
+ getMarkdownTheme(),
58
+ makeUi(),
59
+ () => undefined,
60
+ () => undefined,
61
+ () => ({
62
+ async *[Symbol.asyncIterator]() {
63
+ await new Promise(() => undefined);
64
+ },
65
+ }) as any,
66
+ );
67
+
68
+ await flushTurns();
69
+ const text = renderText(component);
70
+ assert.match(text, /btw/);
71
+ assert.match(text, /You/);
72
+ assert.match(text, /What is btw\?/);
73
+ assert.match(text, /Awaiting response/);
74
+ assert.match(text, /Ask follow-up/);
75
+ component.dispose();
76
+ });
77
+
78
+ test("BtwOverlayComponent supports follow-up turns and reuses overlay-local history", async () => {
79
+ const calls: Array<{ context: Context; options?: SimpleStreamOptions }> = [];
80
+ const responses = [
81
+ [
82
+ { type: "text_delta", delta: "First answer" },
83
+ { type: "done" },
84
+ ],
85
+ [
86
+ { type: "text_delta", delta: "Second answer" },
87
+ { type: "done" },
88
+ ],
89
+ ];
90
+
91
+ const streamFn = ((_: Model<any>, context: Context, options?: SimpleStreamOptions) => {
92
+ calls.push({ context, options });
93
+ const response = responses[calls.length - 1] ?? [];
94
+ return {
95
+ async *[Symbol.asyncIterator]() {
96
+ for (const event of response) {
97
+ yield event;
98
+ }
99
+ },
100
+ };
101
+ }) as any;
102
+
103
+ const component = new BtwOverlayComponent(
104
+ "What is btw?",
105
+ makeModel(),
106
+ "system",
107
+ makeMessages(),
108
+ getMarkdownTheme(),
109
+ makeUi(),
110
+ () => undefined,
111
+ () => undefined,
112
+ streamFn,
113
+ { apiKey: "test-key", sessionId: "session-1" },
114
+ );
115
+
116
+ await flushTurns();
117
+ for (const ch of "next") {
118
+ component.handleInput(ch);
119
+ }
120
+ component.handleInput("\n");
121
+ await flushTurns();
122
+
123
+ assert.equal(calls.length, 2);
124
+ assert.equal(calls[0]?.options?.apiKey, "test-key");
125
+ assert.equal(calls[1]?.options?.sessionId, "session-1");
126
+ assert.equal(calls[0]?.context.messages.length, 2);
127
+ assert.equal(calls[1]?.context.messages.length, 4);
128
+ assert.equal(calls[1]?.context.messages[1]?.role, "user");
129
+ assert.equal(calls[1]?.context.messages[2]?.role, "assistant");
130
+ assert.deepEqual(calls[1]?.context.messages[3]?.content, [{ type: "text", text: "next" }]);
131
+
132
+ const text = renderText(component);
133
+ assert.match(text, /First answer/);
134
+ assert.match(text, /Second answer/);
135
+ component.dispose();
136
+ });
137
+
138
+ test("BtwOverlayComponent aborts active stream on Escape", async () => {
139
+ let capturedSignal: AbortSignal | undefined;
140
+ let dismissed = false;
141
+ const streamFn = ((_model: any, _context: any, options?: { signal?: AbortSignal }) => {
142
+ capturedSignal = options?.signal;
143
+ return {
144
+ async *[Symbol.asyncIterator]() {
145
+ await new Promise<void>((resolve) => {
146
+ capturedSignal?.addEventListener("abort", () => resolve(), { once: true });
147
+ });
148
+ },
149
+ };
150
+ }) as any;
151
+
152
+ const component = new BtwOverlayComponent(
153
+ "What is btw?",
154
+ makeModel(),
155
+ "system",
156
+ makeMessages(),
157
+ getMarkdownTheme(),
158
+ makeUi(),
159
+ () => {
160
+ dismissed = true;
161
+ },
162
+ () => undefined,
163
+ streamFn,
164
+ );
165
+
166
+ await flushTurns();
167
+ component.handleInput("\u001b");
168
+ await flushTurns();
169
+
170
+ assert.equal(dismissed, true);
171
+ assert.equal(capturedSignal?.aborted, true);
172
+ });
@@ -0,0 +1,402 @@
1
+ import type { AssistantMessage, Context, Message, Model, SimpleStreamOptions, StopReason, Usage } from "@gsd/pi-ai";
2
+ import { streamSimple } from "@gsd/pi-ai";
3
+ import {
4
+ Input,
5
+ Markdown,
6
+ type MarkdownTheme,
7
+ parseKey,
8
+ truncateToWidth,
9
+ type Component,
10
+ type Focusable,
11
+ type TUI,
12
+ visibleWidth,
13
+ } from "@gsd/pi-tui";
14
+ import { theme } from "../theme/theme.js";
15
+
16
+ const EMPTY_USAGE: Usage = {
17
+ input: 0,
18
+ output: 0,
19
+ cacheRead: 0,
20
+ cacheWrite: 0,
21
+ totalTokens: 0,
22
+ cost: {
23
+ input: 0,
24
+ output: 0,
25
+ cacheRead: 0,
26
+ cacheWrite: 0,
27
+ total: 0,
28
+ },
29
+ };
30
+
31
+ interface BtwTurn {
32
+ role: "user" | "assistant";
33
+ text: string;
34
+ isError?: boolean;
35
+ isStreaming?: boolean;
36
+ }
37
+
38
+ function clamp(value: number, min: number, max: number): number {
39
+ return Math.max(min, Math.min(max, value));
40
+ }
41
+
42
+ function fitWidth(text: string, width: number): string {
43
+ if (width <= 0) return "";
44
+ const visible = visibleWidth(text);
45
+ if (visible === width) return text;
46
+ if (visible < width) return text + " ".repeat(width - visible);
47
+ return truncateToWidth(text, width, "");
48
+ }
49
+
50
+ function formatAssistantError(message: AssistantMessage): string {
51
+ if (message.errorMessage?.trim()) {
52
+ return message.errorMessage.trim();
53
+ }
54
+
55
+ const text = message.content
56
+ .map((part) => (part.type === "text" ? part.text.trim() : ""))
57
+ .filter(Boolean)
58
+ .join("\n")
59
+ .trim();
60
+
61
+ return text || `Request failed (${message.stopReason})`;
62
+ }
63
+
64
+ export class BtwOverlayComponent implements Component, Focusable {
65
+ private _focused = false;
66
+
67
+ private readonly markdownTheme: MarkdownTheme;
68
+ private readonly model: Model<any>;
69
+ private readonly systemPrompt: string | undefined;
70
+ private readonly baseMessages: Message[];
71
+ private readonly ui: TUI;
72
+ private readonly input = new Input();
73
+ private readonly onDismiss: () => void;
74
+ private readonly requestRender: () => void;
75
+ private readonly streamFn: typeof streamSimple;
76
+ private readonly streamOptions: Pick<SimpleStreamOptions, "apiKey" | "sessionId">;
77
+
78
+ private readonly btwHistory: Message[] = [];
79
+ private readonly turns: BtwTurn[] = [];
80
+ private currentAbortController: AbortController | undefined;
81
+ private disposed = false;
82
+ private isStreaming = false;
83
+ private scrollOffset = 0;
84
+ private followTail = true;
85
+ private lastWidth = 0;
86
+
87
+ get focused(): boolean {
88
+ return this._focused;
89
+ }
90
+
91
+ set focused(value: boolean) {
92
+ this._focused = value;
93
+ this.input.focused = value;
94
+ }
95
+
96
+ constructor(
97
+ question: string,
98
+ model: Model<any>,
99
+ systemPrompt: string | undefined,
100
+ messages: Message[],
101
+ markdownTheme: MarkdownTheme,
102
+ ui: TUI,
103
+ onDismiss: () => void,
104
+ requestRender: () => void,
105
+ streamer: typeof streamSimple = streamSimple,
106
+ streamOptions: Pick<SimpleStreamOptions, "apiKey" | "sessionId"> = {},
107
+ ) {
108
+ this.model = model;
109
+ this.systemPrompt = systemPrompt;
110
+ this.baseMessages = messages;
111
+ this.markdownTheme = markdownTheme;
112
+ this.ui = ui;
113
+ this.onDismiss = onDismiss;
114
+ this.requestRender = requestRender;
115
+ this.streamFn = streamer;
116
+ this.streamOptions = streamOptions;
117
+
118
+ this.input.placeholder = "Ask follow-up...";
119
+ this.input.onEscape = () => this.dismiss();
120
+ this.input.onSubmit = (value) => {
121
+ void this.submitTurn(value);
122
+ };
123
+
124
+ void this.submitTurn(question);
125
+ }
126
+
127
+ handleInput(data: string): void {
128
+ const key = parseKey(data);
129
+ switch (key) {
130
+ case "up":
131
+ this.scrollBy(-1);
132
+ return;
133
+ case "down":
134
+ this.scrollBy(1);
135
+ return;
136
+ case "pageUp":
137
+ this.scrollBy(-(this.getBodyHeight() - 1));
138
+ return;
139
+ case "pageDown":
140
+ this.scrollBy(this.getBodyHeight() - 1);
141
+ return;
142
+ }
143
+
144
+ this.input.handleInput(data);
145
+ if (!this.disposed) {
146
+ this.requestRender();
147
+ }
148
+ }
149
+
150
+ invalidate(): void {
151
+ // No cached subtree state to invalidate.
152
+ }
153
+
154
+ dispose(): void {
155
+ if (this.disposed) return;
156
+ this.disposed = true;
157
+ this.currentAbortController?.abort();
158
+ }
159
+
160
+ render(width: number): string[] {
161
+ this.lastWidth = width;
162
+
163
+ const totalHeight = this.getTotalHeight();
164
+ const bodyHeight = Math.max(1, totalHeight - 4);
165
+ const contentWidth = Math.max(1, width - 4);
166
+ const bodyLines = this.getBodyLines(contentWidth);
167
+ const maxOffset = Math.max(0, bodyLines.length - bodyHeight);
168
+ if (this.followTail) {
169
+ this.scrollOffset = maxOffset;
170
+ } else {
171
+ this.scrollOffset = clamp(this.scrollOffset, 0, maxOffset);
172
+ }
173
+
174
+ const visibleBody = bodyLines.slice(this.scrollOffset, this.scrollOffset + bodyHeight);
175
+ while (visibleBody.length < bodyHeight) {
176
+ visibleBody.push("");
177
+ }
178
+
179
+ const inputLines = this.input.render(contentWidth);
180
+ const inputLine = inputLines[0] ?? "";
181
+
182
+ return [
183
+ this.renderTopBorder(width),
184
+ ...visibleBody.map((line) => this.renderBodyLine(line, width)),
185
+ this.renderBodyLine(inputLine, width),
186
+ this.renderFooterLine(width, bodyLines.length > bodyHeight),
187
+ this.renderBottomBorder(width),
188
+ ];
189
+ }
190
+
191
+ private dismiss(): void {
192
+ this.dispose();
193
+ this.onDismiss();
194
+ }
195
+
196
+ private async submitTurn(rawQuestion: string): Promise<void> {
197
+ const question = rawQuestion.trim();
198
+ if (!question || this.disposed || this.isStreaming) {
199
+ return;
200
+ }
201
+
202
+ const userMessage = this.createUserMessage(question);
203
+ const context: Context = {
204
+ systemPrompt: this.systemPrompt,
205
+ messages: [...this.baseMessages, ...this.btwHistory, userMessage],
206
+ };
207
+
208
+ this.btwHistory.push(userMessage);
209
+ this.turns.push({ role: "user", text: question });
210
+ const assistantTurn: BtwTurn = { role: "assistant", text: "", isStreaming: true };
211
+ this.turns.push(assistantTurn);
212
+ this.input.setValue("");
213
+ this.isStreaming = true;
214
+ this.followTail = true;
215
+ this.requestRender();
216
+
217
+ const abortController = new AbortController();
218
+ this.currentAbortController = abortController;
219
+
220
+ try {
221
+ const eventStream = this.streamFn(this.model, context, {
222
+ signal: abortController.signal,
223
+ apiKey: this.streamOptions.apiKey,
224
+ sessionId: this.streamOptions.sessionId,
225
+ });
226
+
227
+ for await (const event of eventStream) {
228
+ if (this.disposed || this.currentAbortController !== abortController) {
229
+ break;
230
+ }
231
+
232
+ if (event.type === "text_delta") {
233
+ assistantTurn.text += event.delta;
234
+ this.requestRender();
235
+ continue;
236
+ }
237
+
238
+ if (event.type === "done") {
239
+ this.finishAssistantTurn(assistantTurn, "stop");
240
+ this.requestRender();
241
+ break;
242
+ }
243
+
244
+ if (event.type === "error") {
245
+ if (event.reason === "aborted") {
246
+ continue;
247
+ }
248
+
249
+ assistantTurn.isError = true;
250
+ if (!assistantTurn.text.trim()) {
251
+ assistantTurn.text = formatAssistantError(event.error);
252
+ }
253
+ this.finishAssistantTurn(assistantTurn, "error");
254
+ this.requestRender();
255
+ break;
256
+ }
257
+ }
258
+ } catch (error: unknown) {
259
+ if (this.disposed || abortController.signal.aborted || this.currentAbortController !== abortController) {
260
+ return;
261
+ }
262
+
263
+ assistantTurn.isError = true;
264
+ assistantTurn.text = error instanceof Error ? error.message : "Unknown error";
265
+ this.finishAssistantTurn(assistantTurn, "error");
266
+ this.requestRender();
267
+ } finally {
268
+ if (this.currentAbortController === abortController) {
269
+ this.currentAbortController = undefined;
270
+ this.isStreaming = false;
271
+ assistantTurn.isStreaming = false;
272
+ this.requestRender();
273
+ }
274
+ }
275
+ }
276
+
277
+ private finishAssistantTurn(turn: BtwTurn, stopReason: StopReason): void {
278
+ turn.isStreaming = false;
279
+ if (!turn.text.trim()) {
280
+ return;
281
+ }
282
+
283
+ this.btwHistory.push({
284
+ role: "assistant",
285
+ content: [{ type: "text", text: turn.text }],
286
+ api: this.model.api,
287
+ provider: this.model.provider,
288
+ model: this.model.id,
289
+ usage: EMPTY_USAGE,
290
+ stopReason,
291
+ errorMessage: turn.isError ? turn.text : undefined,
292
+ timestamp: Date.now(),
293
+ });
294
+ }
295
+
296
+ private createUserMessage(question: string): Message {
297
+ return {
298
+ role: "user",
299
+ content: [{ type: "text", text: question }],
300
+ timestamp: Date.now(),
301
+ };
302
+ }
303
+
304
+ private getTotalHeight(): number {
305
+ return Math.max(6, Math.floor(this.ui.terminal.rows * 0.7));
306
+ }
307
+
308
+ private getBodyHeight(): number {
309
+ return Math.max(1, this.getTotalHeight() - 4);
310
+ }
311
+
312
+ private scrollBy(delta: number): void {
313
+ const bodyHeight = this.getBodyHeight();
314
+ const bodyLines = this.getBodyLines(Math.max(1, this.lastWidth - 4));
315
+ const maxOffset = Math.max(0, bodyLines.length - bodyHeight);
316
+ this.scrollOffset = clamp(this.scrollOffset + delta, 0, maxOffset);
317
+ this.followTail = this.scrollOffset >= maxOffset;
318
+ this.requestRender();
319
+ }
320
+
321
+ private getBodyLines(contentWidth: number): string[] {
322
+ const lines: string[] = [];
323
+
324
+ for (const [index, turn] of this.turns.entries()) {
325
+ if (index > 0) {
326
+ lines.push("");
327
+ }
328
+ lines.push(turn.role === "user" ? theme.fg("accent", "You") : theme.fg("muted", "btw"));
329
+ lines.push(...this.renderTurnContent(turn, Math.max(1, contentWidth - 2)).map((line) => ` ${line}`));
330
+ }
331
+
332
+ if (lines.length === 0) {
333
+ lines.push(theme.fg("dim", "Awaiting response…"));
334
+ }
335
+
336
+ return lines;
337
+ }
338
+
339
+ private renderTurnContent(turn: BtwTurn, contentWidth: number): string[] {
340
+ if (!turn.text.trim()) {
341
+ return [theme.fg("dim", turn.isStreaming ? "Awaiting response…" : "No response received.")];
342
+ }
343
+
344
+ if (turn.isError) {
345
+ return [theme.fg("error", `Error: ${turn.text}`)];
346
+ }
347
+
348
+ const markdown = new Markdown(turn.text, 0, 0, this.markdownTheme, {
349
+ color: (text: string) => theme.fg("text", text),
350
+ });
351
+ return markdown.render(contentWidth);
352
+ }
353
+
354
+ private renderTopBorder(width: number): string {
355
+ if (width <= 2) {
356
+ return "┌┐".slice(0, width);
357
+ }
358
+
359
+ const innerWidth = width - 2;
360
+ const title = theme.fg("accent", " btw ");
361
+ const titleWidth = visibleWidth(title);
362
+
363
+ if (innerWidth <= titleWidth) {
364
+ return fitWidth(`┌${truncateToWidth(title, innerWidth, "")}┐`, width);
365
+ }
366
+
367
+ const dashCount = innerWidth - titleWidth;
368
+ const leftDashes = Math.floor(dashCount / 2);
369
+ const rightDashes = dashCount - leftDashes;
370
+ const border = theme.fg("border", "─");
371
+ return `┌${border.repeat(leftDashes)}${title}${border.repeat(rightDashes)}┐`;
372
+ }
373
+
374
+ private renderBottomBorder(width: number): string {
375
+ if (width <= 2) {
376
+ return "└┘".slice(0, width);
377
+ }
378
+ return `└${theme.fg("border", "─").repeat(width - 2)}┘`;
379
+ }
380
+
381
+ private renderBodyLine(text: string, width: number): string {
382
+ const innerWidth = Math.max(1, width - 4);
383
+ const fitted = fitWidth(truncateToWidth(text, innerWidth, ""), innerWidth);
384
+ return fitWidth(`│ ${fitted} │`, width);
385
+ }
386
+
387
+ private renderFooterLine(width: number, canScroll: boolean): string {
388
+ const innerWidth = Math.max(1, width - 4);
389
+ const parts: string[] = ["Enter send", "Esc dismiss"];
390
+ if (canScroll) {
391
+ parts.push("↑↓/PgUp/PgDn scroll");
392
+ }
393
+
394
+ const status = this.isStreaming
395
+ ? theme.fg("muted", "Streaming...")
396
+ : theme.fg("success", "Ready");
397
+
398
+ const footer = `${theme.fg("muted", parts.join(" • "))} ${status}`;
399
+ const fitted = fitWidth(truncateToWidth(footer, innerWidth, ""), innerWidth);
400
+ return fitWidth(`│ ${fitted} │`, width);
401
+ }
402
+ }
@@ -46,6 +46,7 @@ export function formatPromptCost(cost: number): string {
46
46
  export class FooterComponent implements Component {
47
47
  private autoCompactEnabled = true;
48
48
  private permissionMode: PermissionMode = "danger-full-access";
49
+ private notificationSoundEnabled = false;
49
50
 
50
51
  constructor(
51
52
  private session: AgentSession,
@@ -60,6 +61,10 @@ export class FooterComponent implements Component {
60
61
  this.permissionMode = mode;
61
62
  }
62
63
 
64
+ setNotificationSoundEnabled(enabled: boolean): void {
65
+ this.notificationSoundEnabled = enabled;
66
+ }
67
+
63
68
  /**
64
69
  * No-op: git branch caching now handled by provider.
65
70
  * Kept for compatibility with existing call sites in interactive-mode.
@@ -123,6 +128,9 @@ export class FooterComponent implements Component {
123
128
  const hotkeysHints = ["Ctrl+K • /hotkeys", "/hotkeys", "Ctrl+K"];
124
129
  const firstLineMinPadding = 2;
125
130
  const firstLineRightParts = cacheTimerStatus ? [cacheTimerStatus] : [];
131
+ if (this.notificationSoundEnabled) {
132
+ firstLineRightParts.push(theme.fg("success", "🔔"));
133
+ }
126
134
  const firstLineRightBase = firstLineRightParts.join(" ");
127
135
  const hotkeysHint = hotkeysHints.find((hint) => {
128
136
  const candidate = firstLineRightBase ? `${firstLineRightBase} ${hint}` : hint;