gsd-pi 2.70.1-dev.3e19108 → 2.70.1-dev.6504106

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 (101) hide show
  1. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +127 -30
  2. package/dist/resources/extensions/get-secrets-from-user.js +17 -1
  3. package/dist/resources/extensions/gsd/state.js +234 -332
  4. package/dist/web/standalone/.next/BUILD_ID +1 -1
  5. package/dist/web/standalone/.next/app-path-routes-manifest.json +13 -13
  6. package/dist/web/standalone/.next/build-manifest.json +2 -2
  7. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  8. package/dist/web/standalone/.next/required-server-files.json +1 -1
  9. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  10. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  11. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  18. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/index.html +1 -1
  26. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app-paths-manifest.json +13 -13
  33. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  34. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  35. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  36. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  37. package/dist/web/standalone/server.js +1 -1
  38. package/package.json +1 -1
  39. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +178 -1
  40. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  41. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
  42. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  43. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  44. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +19 -2
  45. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  46. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +50 -1
  47. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  48. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -0
  49. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +1 -0
  51. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
  52. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +107 -9
  54. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  55. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +1 -0
  56. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  57. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  58. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +6 -0
  59. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  60. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +58 -2
  61. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  62. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +1 -1
  63. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  64. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts +1 -0
  65. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.js.map +1 -1
  67. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +219 -1
  68. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  69. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +58 -2
  70. package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +2 -0
  71. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +119 -15
  72. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
  73. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +66 -2
  74. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +1 -1
  75. package/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts +1 -0
  76. package/packages/pi-tui/dist/components/__tests__/input.test.js +9 -0
  77. package/packages/pi-tui/dist/components/__tests__/input.test.js.map +1 -1
  78. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts +2 -0
  79. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts.map +1 -0
  80. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js +66 -0
  81. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js.map +1 -0
  82. package/packages/pi-tui/dist/components/input.d.ts +2 -0
  83. package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
  84. package/packages/pi-tui/dist/components/input.js +7 -4
  85. package/packages/pi-tui/dist/components/input.js.map +1 -1
  86. package/packages/pi-tui/dist/components/markdown.d.ts +3 -0
  87. package/packages/pi-tui/dist/components/markdown.d.ts.map +1 -1
  88. package/packages/pi-tui/dist/components/markdown.js +17 -1
  89. package/packages/pi-tui/dist/components/markdown.js.map +1 -1
  90. package/packages/pi-tui/src/components/__tests__/input.test.ts +11 -0
  91. package/packages/pi-tui/src/components/__tests__/markdown-maxlines.test.ts +75 -0
  92. package/packages/pi-tui/src/components/input.ts +7 -4
  93. package/packages/pi-tui/src/components/markdown.ts +22 -1
  94. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +164 -31
  95. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +112 -0
  96. package/src/resources/extensions/get-secrets-from-user.ts +24 -1
  97. package/src/resources/extensions/gsd/state.ts +274 -344
  98. package/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts +436 -0
  99. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +45 -0
  100. /package/dist/web/standalone/.next/static/{cHCEWiRJM5bXJa9HkP1QU → T3hWbQ-WQBDEIGoaOOfEo}/_buildManifest.js +0 -0
  101. /package/dist/web/standalone/.next/static/{cHCEWiRJM5bXJa9HkP1QU → T3hWbQ-WQBDEIGoaOOfEo}/_ssgManifest.js +0 -0
@@ -42,13 +42,27 @@ function createHost() {
42
42
  },
43
43
  };
44
44
 
45
+ const pinnedMessageContainer = {
46
+ children: [] as any[],
47
+ addChild(component: any) {
48
+ this.children.push(component);
49
+ },
50
+ removeChild(component: any) {
51
+ const idx = this.children.indexOf(component);
52
+ if (idx !== -1) this.children.splice(idx, 1);
53
+ },
54
+ clear() {
55
+ this.children = [];
56
+ },
57
+ };
58
+
45
59
  const host: any = {
46
60
  isInitialized: true,
47
61
  init: async () => {},
48
62
  defaultEditor: { onEscape: undefined },
49
63
  editor: {},
50
64
  session: { retryAttempt: 0, abortCompaction: () => {}, abortRetry: () => {} },
51
- ui: { requestRender: () => {} },
65
+ ui: { requestRender: () => {}, terminal: { rows: 50 } },
52
66
  footer: { invalidate: () => {} },
53
67
  keybindings: {},
54
68
  statusContainer: { clear: () => {}, addChild: () => {} },
@@ -62,6 +76,7 @@ function createHost() {
62
76
  compactionQueuedMessages: [],
63
77
  editorContainer: {},
64
78
  pendingMessagesContainer: { clear: () => {} },
79
+ pinnedMessageContainer,
65
80
  addMessageToChat: () => {},
66
81
  getMarkdownThemeWithSettings: () => ({}),
67
82
  formatWebSearchResult: () => "",
@@ -150,3 +165,206 @@ test("chat-controller keeps tool output ahead of delayed assistant text for exte
150
165
  assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
151
166
  assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent");
152
167
  });
168
+
169
+ test("chat-controller keeps serverToolUse output ahead of assistant text when external results arrive", async () => {
170
+ (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
171
+ fg: (_key: string, text: string) => text,
172
+ bg: (_key: string, text: string) => text,
173
+ bold: (text: string) => text,
174
+ italic: (text: string) => text,
175
+ truncate: (text: string) => text,
176
+ };
177
+
178
+ const host = createHost();
179
+ const toolId = "mcp-secure-1";
180
+ const serverToolUse = {
181
+ type: "serverToolUse",
182
+ id: toolId,
183
+ name: "mcp__gsd-workflow__secure_env_collect",
184
+ input: { projectDir: "/tmp/project", keys: [{ key: "SECURE_PASSWORD" }], destination: "dotenv" },
185
+ };
186
+
187
+ await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any);
188
+
189
+ await handleAgentEvent(
190
+ host,
191
+ {
192
+ type: "message_update",
193
+ message: makeAssistant([serverToolUse]),
194
+ assistantMessageEvent: {
195
+ type: "server_tool_use",
196
+ contentIndex: 0,
197
+ partial: makeAssistant([serverToolUse]),
198
+ },
199
+ } as any,
200
+ );
201
+
202
+ assert.equal(host.streamingComponent, undefined, "assistant content should stay deferred while only tool content streams");
203
+ assert.equal(host.chatContainer.children.length, 1, "server tool block should render immediately");
204
+ assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
205
+
206
+ host.getMarkdownThemeWithSettings = () => ({});
207
+ const resultMessage = makeAssistant([
208
+ {
209
+ ...serverToolUse,
210
+ externalResult: {
211
+ content: [{ type: "text", text: "secure_env_collect was cancelled by user." }],
212
+ details: {},
213
+ isError: true,
214
+ },
215
+ },
216
+ { type: "text", text: "The secure password collection was cancelled." },
217
+ ]);
218
+
219
+ await handleAgentEvent(
220
+ host,
221
+ {
222
+ type: "message_update",
223
+ message: resultMessage,
224
+ assistantMessageEvent: {
225
+ type: "server_tool_use",
226
+ contentIndex: 0,
227
+ partial: resultMessage,
228
+ },
229
+ } as any,
230
+ );
231
+
232
+ assert.equal(host.chatContainer.children.length, 2, "assistant text should render after existing server tool output");
233
+ assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
234
+ assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent");
235
+ });
236
+
237
+ test("chat-controller pins latest assistant text above editor when tool calls are present", async () => {
238
+ (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
239
+ fg: (_key: string, text: string) => text,
240
+ bg: (_key: string, text: string) => text,
241
+ bold: (text: string) => text,
242
+ italic: (text: string) => text,
243
+ truncate: (text: string) => text,
244
+ };
245
+
246
+ const host = createHost();
247
+ const toolId = "tool-pin-1";
248
+ const toolCall = {
249
+ type: "toolCall",
250
+ id: toolId,
251
+ name: "exec_command",
252
+ arguments: { cmd: "echo hi" },
253
+ };
254
+
255
+ await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any);
256
+
257
+ assert.equal(host.pinnedMessageContainer.children.length, 0, "pinned zone should be empty at message_start");
258
+
259
+ // Send a message with text followed by a tool call
260
+ host.getMarkdownThemeWithSettings = () => ({});
261
+ await handleAgentEvent(
262
+ host,
263
+ {
264
+ type: "message_update",
265
+ message: makeAssistant([
266
+ { type: "text", text: "Looking at the files now." },
267
+ toolCall,
268
+ ]),
269
+ assistantMessageEvent: {
270
+ type: "toolcall_end",
271
+ contentIndex: 1,
272
+ toolCall: {
273
+ ...toolCall,
274
+ externalResult: {
275
+ content: [{ type: "text", text: "file contents" }],
276
+ details: {},
277
+ isError: false,
278
+ },
279
+ },
280
+ partial: makeAssistant([{ type: "text", text: "Looking at the files now." }, toolCall]),
281
+ },
282
+ } as any,
283
+ );
284
+
285
+ // Pinned zone should now have a DynamicBorder and a Markdown component
286
+ assert.equal(host.pinnedMessageContainer.children.length, 2, "pinned zone should have border + markdown");
287
+ assert.equal(host.pinnedMessageContainer.children[0]?.constructor?.name, "DynamicBorder");
288
+ assert.equal(host.pinnedMessageContainer.children[1]?.constructor?.name, "Markdown");
289
+ });
290
+
291
+ test("chat-controller clears pinned zone when a new assistant message starts", async () => {
292
+ (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
293
+ fg: (_key: string, text: string) => text,
294
+ bg: (_key: string, text: string) => text,
295
+ bold: (text: string) => text,
296
+ italic: (text: string) => text,
297
+ truncate: (text: string) => text,
298
+ };
299
+
300
+ const host = createHost();
301
+ const toolCall = {
302
+ type: "toolCall",
303
+ id: "tool-clear-1",
304
+ name: "exec_command",
305
+ arguments: { cmd: "echo hi" },
306
+ };
307
+
308
+ await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any);
309
+
310
+ // Populate the pinned zone
311
+ host.getMarkdownThemeWithSettings = () => ({});
312
+ await handleAgentEvent(
313
+ host,
314
+ {
315
+ type: "message_update",
316
+ message: makeAssistant([{ type: "text", text: "Working on it." }, toolCall]),
317
+ assistantMessageEvent: {
318
+ type: "toolcall_end",
319
+ contentIndex: 1,
320
+ toolCall: {
321
+ ...toolCall,
322
+ externalResult: {
323
+ content: [{ type: "text", text: "ok" }],
324
+ details: {},
325
+ isError: false,
326
+ },
327
+ },
328
+ partial: makeAssistant([{ type: "text", text: "Working on it." }, toolCall]),
329
+ },
330
+ } as any,
331
+ );
332
+
333
+ assert.ok(host.pinnedMessageContainer.children.length > 0, "pinned zone should be populated");
334
+
335
+ // Start a new assistant message — pinned zone should clear
336
+ await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any);
337
+
338
+ assert.equal(host.pinnedMessageContainer.children.length, 0, "pinned zone should clear on new assistant message");
339
+ });
340
+
341
+ test("chat-controller does not pin when there are no tool calls", async () => {
342
+ (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
343
+ fg: (_key: string, text: string) => text,
344
+ bg: (_key: string, text: string) => text,
345
+ bold: (text: string) => text,
346
+ italic: (text: string) => text,
347
+ truncate: (text: string) => text,
348
+ };
349
+
350
+ const host = createHost();
351
+
352
+ await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any);
353
+
354
+ host.getMarkdownThemeWithSettings = () => ({});
355
+ await handleAgentEvent(
356
+ host,
357
+ {
358
+ type: "message_update",
359
+ message: makeAssistant([{ type: "text", text: "Just some text, no tools." }]),
360
+ assistantMessageEvent: {
361
+ type: "text_delta",
362
+ contentIndex: 0,
363
+ delta: "Just some text, no tools.",
364
+ partial: makeAssistant([{ type: "text", text: "Just some text, no tools." }]),
365
+ },
366
+ } as any,
367
+ );
368
+
369
+ assert.equal(host.pinnedMessageContainer.children.length, 0, "pinned zone should stay empty without tool calls");
370
+ });
@@ -88,6 +88,8 @@ export interface ExtensionUIDialogOptions {
88
88
  timeout?: number;
89
89
  /** When true, the user can select multiple options. The return type becomes `string[]`. */
90
90
  allowMultiple?: boolean;
91
+ /** When true, text input dialogs should hide typed characters if supported by the client surface. */
92
+ secure?: boolean;
91
93
  }
92
94
 
93
95
  /** Placement for extension widgets. */
@@ -1,8 +1,10 @@
1
- import type { Component } from "@gsd/pi-tui";
1
+ import type { Component, TUI } from "@gsd/pi-tui";
2
+ import { visibleWidth } from "@gsd/pi-tui";
2
3
  import { theme } from "../theme/theme.js";
3
4
 
4
5
  /**
5
6
  * Dynamic border component that adjusts to viewport width.
7
+ * Supports an optional animated spinner in the label area.
6
8
  *
7
9
  * Note: When used from extensions loaded via jiti, the global `theme` may be undefined
8
10
  * because jiti creates a separate module cache. Always pass an explicit color
@@ -10,11 +12,51 @@ import { theme } from "../theme/theme.js";
10
12
  */
11
13
  export class DynamicBorder implements Component {
12
14
  private color: (str: string) => string;
15
+ private label?: string;
16
+ private spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
17
+ private spinnerIndex = 0;
18
+ private spinnerInterval: NodeJS.Timeout | null = null;
19
+ private spinnerColorFn?: (str: string) => string;
13
20
 
14
21
  constructor(color: (str: string) => string = (str) => {
15
22
  try { return theme.fg("border", str); } catch { return str; }
16
- }) {
23
+ }, label?: string) {
17
24
  this.color = color;
25
+ this.label = label;
26
+ }
27
+
28
+ setLabel(label: string | undefined): void {
29
+ this.label = label;
30
+ }
31
+
32
+ /**
33
+ * Start an animated spinner that prepends to the label.
34
+ * The spinner rotates every 80ms and triggers a re-render via the TUI.
35
+ */
36
+ startSpinner(ui: TUI, colorFn: (str: string) => string): void {
37
+ this.stopSpinner();
38
+ this.spinnerColorFn = colorFn;
39
+ this.spinnerIndex = 0;
40
+ this.spinnerInterval = setInterval(() => {
41
+ this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
42
+ ui.requestRender();
43
+ }, 80);
44
+ ui.requestRender();
45
+ }
46
+
47
+ /**
48
+ * Stop the spinner animation. The border reverts to a static label.
49
+ */
50
+ stopSpinner(): void {
51
+ if (this.spinnerInterval) {
52
+ clearInterval(this.spinnerInterval);
53
+ this.spinnerInterval = null;
54
+ }
55
+ this.spinnerColorFn = undefined;
56
+ }
57
+
58
+ get isSpinning(): boolean {
59
+ return this.spinnerInterval !== null;
18
60
  }
19
61
 
20
62
  invalidate(): void {
@@ -22,6 +64,20 @@ export class DynamicBorder implements Component {
22
64
  }
23
65
 
24
66
  render(width: number): string[] {
67
+ const spinnerPrefix = this.spinnerInterval && this.spinnerColorFn
68
+ ? this.spinnerColorFn(this.spinnerFrames[this.spinnerIndex]) + " "
69
+ : "";
70
+
71
+ if (this.label) {
72
+ const labelText = ` ${spinnerPrefix}${this.label} `;
73
+ const labelVisible = visibleWidth(labelText);
74
+ const leading = "── ";
75
+ const remaining = Math.max(0, width - labelVisible - leading.length);
76
+ const trailing = "─".repeat(Math.max(1, remaining));
77
+ // Color leading and trailing separately so embedded ANSI in the
78
+ // spinner/label doesn't bleed into the trailing dashes.
79
+ return [this.color(leading) + labelText + this.color(trailing)];
80
+ }
25
81
  return [this.color("─".repeat(Math.max(1, width)))];
26
82
  }
27
83
  }
@@ -11,6 +11,7 @@ import { keyHint } from "./keybinding-hints.js";
11
11
  export interface ExtensionInputOptions {
12
12
  tui?: TUI;
13
13
  timeout?: number;
14
+ secure?: boolean;
14
15
  }
15
16
 
16
17
  export class ExtensionInputComponent extends Container implements Focusable {
@@ -61,6 +62,7 @@ export class ExtensionInputComponent extends Container implements Focusable {
61
62
  }
62
63
 
63
64
  this.input = new Input();
65
+ this.input.secure = opts?.secure === true;
64
66
  if (placeholder) {
65
67
  this.input.placeholder = placeholder;
66
68
  }
@@ -1,9 +1,10 @@
1
- import { Loader, Spacer, Text } from "@gsd/pi-tui";
1
+ import { Loader, Markdown, Spacer, Text } from "@gsd/pi-tui";
2
2
 
3
3
  import type { InteractiveModeEvent, InteractiveModeStateHost } from "../interactive-mode-state.js";
4
4
  import { theme } from "../theme/theme.js";
5
5
  import { AssistantMessageComponent } from "../components/assistant-message.js";
6
6
  import { ToolExecutionComponent } from "../components/tool-execution.js";
7
+ import { DynamicBorder } from "../components/dynamic-border.js";
7
8
  import { appKey } from "../components/keybinding-hints.js";
8
9
 
9
10
  // Tracks the last processed content index to avoid re-scanning all blocks on every message_update
@@ -21,6 +22,15 @@ function hasAssistantToolBlocks(message: { content: Array<any> }): boolean {
21
22
  return message.content.some((c) => c.type === "toolCall" || c.type === "serverToolUse");
22
23
  }
23
24
 
25
+ // Tracks the latest assistant text for the pinned message zone
26
+ let lastPinnedText = "";
27
+ // Whether any tool execution has been added in this assistant turn (triggers pinned display)
28
+ let hasToolsInTurn = false;
29
+ // Reference to the pinned border so we can toggle its label between working/idle
30
+ let pinnedBorder: DynamicBorder | undefined;
31
+ // Reference to the pinned markdown component below the border
32
+ let pinnedTextComponent: Markdown | undefined;
33
+
24
34
  export async function handleAgentEvent(host: InteractiveModeStateHost & {
25
35
  init: () => Promise<void>;
26
36
  getMarkdownThemeWithSettings: () => any;
@@ -43,9 +53,15 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
43
53
 
44
54
  host.footer.invalidate();
45
55
 
46
- // Reset content index tracker when a new assistant message starts
56
+ // Reset content index tracker and pinned state when a new assistant message starts
47
57
  if (event.type === "message_start" && event.message.role === "assistant") {
48
58
  lastProcessedContentIndex = 0;
59
+ lastPinnedText = "";
60
+ hasToolsInTurn = false;
61
+ if (pinnedBorder) pinnedBorder.stopSpinner();
62
+ pinnedBorder = undefined;
63
+ pinnedTextComponent = undefined;
64
+ host.pinnedMessageContainer.clear();
49
65
  }
50
66
 
51
67
  switch (event.type) {
@@ -58,6 +74,12 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
58
74
  host.streamingMessage = undefined;
59
75
  host.pendingTools.clear();
60
76
  host.pendingMessagesContainer.clear();
77
+ host.pinnedMessageContainer.clear();
78
+ lastPinnedText = "";
79
+ hasToolsInTurn = false;
80
+ if (pinnedBorder) pinnedBorder.stopSpinner();
81
+ pinnedBorder = undefined;
82
+ pinnedTextComponent = undefined;
61
83
  host.compactionQueuedMessages = [];
62
84
  host.rebuildChatFromMessages();
63
85
  host.updatePendingMessagesDisplay();
@@ -129,19 +151,6 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
129
151
  host.streamingMessage = event.message;
130
152
  const innerEvent = event.assistantMessageEvent;
131
153
 
132
- if (!host.streamingComponent && hasVisibleAssistantContent(host.streamingMessage)) {
133
- host.streamingComponent = new AssistantMessageComponent(
134
- undefined,
135
- host.hideThinkingBlock,
136
- host.getMarkdownThemeWithSettings(),
137
- host.settingsManager.getTimestampFormat(),
138
- );
139
- host.chatContainer.addChild(host.streamingComponent);
140
- }
141
- if (host.streamingComponent) {
142
- host.streamingComponent.updateContent(host.streamingMessage);
143
- }
144
-
145
154
  let externalToolResult:
146
155
  | { toolCallId: string; content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; details: Record<string, unknown>; isError: boolean }
147
156
  | undefined;
@@ -156,6 +165,18 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
156
165
  isError: ext.isError ?? false,
157
166
  };
158
167
  }
168
+ } else if (innerEvent.type === "server_tool_use") {
169
+ const idx = typeof innerEvent.contentIndex === "number" ? innerEvent.contentIndex : -1;
170
+ const block = idx >= 0 ? (host.streamingMessage.content[idx] as any) : undefined;
171
+ const ext = block?.externalResult;
172
+ if (block?.id && ext) {
173
+ externalToolResult = {
174
+ toolCallId: block.id,
175
+ content: ext.content ?? [{ type: "text", text: "" }],
176
+ details: ext.details ?? {},
177
+ isError: ext.isError ?? false,
178
+ };
179
+ }
159
180
  }
160
181
 
161
182
  const contentBlocks = host.streamingMessage.content;
@@ -230,12 +251,85 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
230
251
  }
231
252
  }
232
253
 
254
+ // Render assistant text/thinking after tool components so mixed
255
+ // streams keep chronological ordering in the chat container.
256
+ const hasToolBlocks = hasAssistantToolBlocks(host.streamingMessage);
257
+ if (!host.streamingComponent && hasVisibleAssistantContent(host.streamingMessage)) {
258
+ host.streamingComponent = new AssistantMessageComponent(
259
+ undefined,
260
+ host.hideThinkingBlock,
261
+ host.getMarkdownThemeWithSettings(),
262
+ host.settingsManager.getTimestampFormat(),
263
+ );
264
+ host.chatContainer.addChild(host.streamingComponent);
265
+ }
266
+ if (host.streamingComponent) {
267
+ if (hasToolBlocks) {
268
+ host.chatContainer.removeChild(host.streamingComponent);
269
+ host.chatContainer.addChild(host.streamingComponent);
270
+ }
271
+ host.streamingComponent.updateContent(host.streamingMessage);
272
+ }
273
+
233
274
  // Update index: fully processed blocks won't need re-scanning.
234
275
  // Keep the last block's index (it may still be accumulating data),
235
276
  // so we re-check it next time but skip all earlier ones.
236
277
  if (contentBlocks.length > 0) {
237
278
  lastProcessedContentIndex = Math.max(0, contentBlocks.length - 1);
238
279
  }
280
+
281
+ // Pinned message: mirror the latest assistant text above the editor
282
+ // when tool executions push it out of the viewport.
283
+ const hasTools = contentBlocks.some(
284
+ (c: any) => c.type === "toolCall" || c.type === "serverToolUse",
285
+ );
286
+ if (hasTools) hasToolsInTurn = true;
287
+
288
+ if (hasToolsInTurn) {
289
+ // Collect the latest text block(s) from the assistant message
290
+ let latestText = "";
291
+ for (let i = contentBlocks.length - 1; i >= 0; i--) {
292
+ const c = contentBlocks[i] as any;
293
+ if (c.type === "text" && c.text?.trim()) {
294
+ latestText = c.text.trim();
295
+ break;
296
+ }
297
+ }
298
+
299
+ if (latestText && latestText !== lastPinnedText) {
300
+ lastPinnedText = latestText;
301
+
302
+ if (!pinnedBorder) {
303
+ // First time: create border + text component
304
+ host.pinnedMessageContainer.clear();
305
+ pinnedBorder = new DynamicBorder(
306
+ (str: string) => theme.fg("dim", str),
307
+ "Working · Latest Output",
308
+ );
309
+ pinnedBorder.startSpinner(host.ui, (str: string) => theme.fg("accent", str));
310
+ host.pinnedMessageContainer.addChild(pinnedBorder);
311
+ pinnedTextComponent = new Markdown(latestText, 1, 0, host.getMarkdownThemeWithSettings());
312
+ // Cap pinned content to ~40% of terminal height so tall output
313
+ // doesn't exceed the viewport and cause render flashing.
314
+ pinnedTextComponent.maxLines = Math.max(3, Math.floor(host.ui.terminal.rows * 0.4));
315
+ host.pinnedMessageContainer.addChild(pinnedTextComponent);
316
+ // Hide the separate status loader — the pinned zone replaces it
317
+ if (host.loadingAnimation) {
318
+ host.loadingAnimation.stop();
319
+ host.loadingAnimation = undefined;
320
+ }
321
+ host.statusContainer.clear();
322
+ } else {
323
+ // Update existing markdown component in-place
324
+ pinnedTextComponent?.setText(latestText);
325
+ // Refresh maxLines in case terminal was resized
326
+ if (pinnedTextComponent) {
327
+ pinnedTextComponent.maxLines = Math.max(3, Math.floor(host.ui.terminal.rows * 0.4));
328
+ }
329
+ }
330
+ }
331
+ }
332
+
239
333
  host.ui.requestRender();
240
334
  }
241
335
  break;
@@ -338,6 +432,16 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
338
432
  host.streamingMessage = undefined;
339
433
  }
340
434
  host.pendingTools.clear();
435
+ // Stop spinner on pinned border and switch label from "Working · Latest Output" to "Latest Output"
436
+ if (pinnedBorder) {
437
+ pinnedBorder.stopSpinner();
438
+ pinnedBorder.setLabel("Latest Output");
439
+ }
440
+ // Keep pinned message visible until the next assistant turn starts.
441
+ lastPinnedText = "";
442
+ hasToolsInTurn = false;
443
+ pinnedBorder = undefined;
444
+ pinnedTextComponent = undefined;
341
445
  await host.checkShutdownRequested();
342
446
  host.ui.requestRender();
343
447
  break;
@@ -9,6 +9,7 @@ export interface InteractiveModeStateHost {
9
9
  keybindings: any;
10
10
  statusContainer: any;
11
11
  chatContainer: any;
12
+ pinnedMessageContainer: any;
12
13
  settingsManager: any;
13
14
  pendingTools: Map<string, any>;
14
15
  toolOutputExpanded: boolean;
@@ -168,6 +168,7 @@ export class InteractiveMode {
168
168
  private chatContainer: Container;
169
169
  private pendingMessagesContainer: Container;
170
170
  private statusContainer: Container;
171
+ private pinnedMessageContainer: Container;
171
172
  private defaultEditor: CustomEditor;
172
173
  private editor: EditorComponent;
173
174
  private autocompleteProvider: CombinedAutocompleteProvider | undefined;
@@ -285,6 +286,7 @@ export class InteractiveMode {
285
286
  this.chatContainer = new Container();
286
287
  this.pendingMessagesContainer = new Container();
287
288
  this.statusContainer = new Container();
289
+ this.pinnedMessageContainer = new Container();
288
290
  this.widgetContainerAbove = new Container();
289
291
  this.widgetContainerBelow = new Container();
290
292
  this.keybindings = KeybindingsManager.create();
@@ -490,6 +492,7 @@ export class InteractiveMode {
490
492
  this.ui.addChild(this.chatContainer);
491
493
  this.ui.addChild(this.pendingMessagesContainer);
492
494
  this.ui.addChild(this.statusContainer);
495
+ this.ui.addChild(this.pinnedMessageContainer);
493
496
  this.renderWidgets(); // Initialize with default spacer
494
497
  this.ui.addChild(this.widgetContainerAbove);
495
498
  this.ui.addChild(this.editorContainer);
@@ -1396,7 +1399,19 @@ export class InteractiveMode {
1396
1399
  */
1397
1400
  private renderWidgets(): void {
1398
1401
  if (!this.widgetContainerAbove || !this.widgetContainerBelow) return;
1399
- this.renderWidgetContainer(this.widgetContainerAbove, this.extensionWidgetsAbove, true, true);
1402
+
1403
+ // widgetContainerAbove: spacer collapses when pinned content is visible
1404
+ // so there's no extra blank line between pinned output and the editor border.
1405
+ this.widgetContainerAbove.clear();
1406
+ const pinned = this.pinnedMessageContainer;
1407
+ this.widgetContainerAbove.addChild({
1408
+ render: () => pinned.children.length > 0 ? [] : [""],
1409
+ invalidate: () => {},
1410
+ });
1411
+ for (const component of this.extensionWidgetsAbove.values()) {
1412
+ this.widgetContainerAbove.addChild(component);
1413
+ }
1414
+
1400
1415
  this.renderWidgetContainer(this.widgetContainerBelow, this.extensionWidgetsBelow, false, false);
1401
1416
  this.ui.requestRender();
1402
1417
  }
@@ -1631,7 +1646,7 @@ export class InteractiveMode {
1631
1646
  this.hideExtensionInput();
1632
1647
  resolve(undefined);
1633
1648
  },
1634
- { tui: this.ui, timeout: opts?.timeout },
1649
+ { tui: this.ui, timeout: opts?.timeout, secure: opts?.secure },
1635
1650
  );
1636
1651
 
1637
1652
  this.editorContainer.clear();
@@ -2264,6 +2279,7 @@ export class InteractiveMode {
2264
2279
  updateFooter: true,
2265
2280
  populateHistory: true,
2266
2281
  });
2282
+ this.populatePinnedFromMessages(context.messages);
2267
2283
 
2268
2284
  // Show compaction info if session was compacted
2269
2285
  const allEntries = this.sessionManager.getEntries();
@@ -2287,6 +2303,54 @@ export class InteractiveMode {
2287
2303
  this.chatContainer.clear();
2288
2304
  const context = this.sessionManager.buildSessionContext();
2289
2305
  this.renderSessionContext(context);
2306
+ this.populatePinnedFromMessages(context.messages);
2307
+ }
2308
+
2309
+ /**
2310
+ * After rebuilding chat from messages, pin the last assistant text above the
2311
+ * editor if tool results would otherwise push it out of the viewport.
2312
+ */
2313
+ private populatePinnedFromMessages(messages: AgentMessage[]): void {
2314
+ this.pinnedMessageContainer.clear();
2315
+
2316
+ // Walk backwards to find the last assistant message
2317
+ let lastAssistant: AssistantMessage | undefined;
2318
+ for (let i = messages.length - 1; i >= 0; i--) {
2319
+ const msg = messages[i];
2320
+ if (msg && "role" in msg && msg.role === "assistant") {
2321
+ lastAssistant = msg as AssistantMessage;
2322
+ break;
2323
+ }
2324
+ }
2325
+ if (!lastAssistant) return;
2326
+
2327
+ // Check if any tool calls follow the last text block
2328
+ const content = lastAssistant.content;
2329
+ let lastTextIndex = -1;
2330
+ let hasToolAfterText = false;
2331
+ for (let i = 0; i < content.length; i++) {
2332
+ if (content[i].type === "text") lastTextIndex = i;
2333
+ }
2334
+ if (lastTextIndex >= 0) {
2335
+ for (let i = lastTextIndex + 1; i < content.length; i++) {
2336
+ if (content[i].type === "toolCall" || content[i].type === "serverToolUse") {
2337
+ hasToolAfterText = true;
2338
+ break;
2339
+ }
2340
+ }
2341
+ }
2342
+ if (!hasToolAfterText || lastTextIndex < 0) return;
2343
+
2344
+ const textBlock = content[lastTextIndex] as { type: "text"; text: string };
2345
+ const text = textBlock.text?.trim();
2346
+ if (!text) return;
2347
+
2348
+ this.pinnedMessageContainer.addChild(
2349
+ new DynamicBorder((str: string) => theme.fg("dim", str), "Latest Output"),
2350
+ );
2351
+ this.pinnedMessageContainer.addChild(
2352
+ new Markdown(text, 1, 0, this.getMarkdownThemeWithSettings()),
2353
+ );
2290
2354
  }
2291
2355
 
2292
2356
  // =========================================================================
@@ -224,7 +224,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
224
224
  ),
225
225
 
226
226
  input: (title, placeholder, opts) =>
227
- createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout }, (r) =>
227
+ createDialogPromise(opts, undefined, { method: "input", title, placeholder, timeout: opts?.timeout, secure: opts?.secure }, (r) =>
228
228
  "cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
229
229
  ),
230
230
 
@@ -291,6 +291,7 @@ export type RpcExtensionUIRequest =
291
291
  title: string;
292
292
  placeholder?: string;
293
293
  timeout?: number;
294
+ secure?: boolean;
294
295
  }
295
296
  | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
296
297
  | {