gsd-pi 2.70.1-dev.bef631a → 2.70.1-dev.ec24142

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 (89) 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/web/standalone/.next/BUILD_ID +1 -1
  4. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  5. package/dist/web/standalone/.next/build-manifest.json +2 -2
  6. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  7. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  8. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  9. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  11. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  12. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  16. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/index.html +1 -1
  24. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  31. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  32. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  33. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  34. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  35. package/package.json +1 -1
  36. package/packages/mcp-server/dist/env-writer.d.ts +39 -0
  37. package/packages/mcp-server/dist/env-writer.d.ts.map +1 -0
  38. package/packages/mcp-server/dist/env-writer.js +158 -0
  39. package/packages/mcp-server/dist/env-writer.js.map +1 -0
  40. package/packages/mcp-server/dist/server.d.ts +11 -2
  41. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  42. package/packages/mcp-server/dist/server.js +102 -2
  43. package/packages/mcp-server/dist/server.js.map +1 -1
  44. package/packages/mcp-server/src/env-writer.test.ts +280 -0
  45. package/packages/mcp-server/src/env-writer.ts +183 -0
  46. package/packages/mcp-server/src/secure-env-collect.test.ts +265 -0
  47. package/packages/mcp-server/src/server.ts +137 -3
  48. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.d.ts +2 -0
  49. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.d.ts.map +1 -0
  50. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +187 -0
  51. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -0
  52. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
  53. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  54. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  55. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -0
  56. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
  57. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +1 -0
  58. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
  59. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  60. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +78 -21
  61. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  62. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  63. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  64. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +1 -1
  65. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  66. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts +1 -0
  67. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  68. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.js.map +1 -1
  69. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +220 -0
  70. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  71. package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +2 -0
  72. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +102 -27
  73. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  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/input.d.ts +2 -0
  79. package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
  80. package/packages/pi-tui/dist/components/input.js +7 -4
  81. package/packages/pi-tui/dist/components/input.js.map +1 -1
  82. package/packages/pi-tui/src/components/__tests__/input.test.ts +11 -0
  83. package/packages/pi-tui/src/components/input.ts +7 -4
  84. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +164 -31
  85. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +112 -0
  86. package/src/resources/extensions/get-secrets-from-user.ts +24 -1
  87. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +45 -0
  88. /package/dist/web/standalone/.next/static/{UlX0WGGZ8aBPN0uSZ5Ki4 → 20e8bFnNjxQJflHNodEve}/_buildManifest.js +0 -0
  89. /package/dist/web/standalone/.next/static/{UlX0WGGZ8aBPN0uSZ5Ki4 → 20e8bFnNjxQJflHNodEve}/_ssgManifest.js +0 -0
@@ -0,0 +1,220 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { handleAgentEvent } from "../modes/interactive/controllers/chat-controller.js";
5
+
6
+ function makeUsage() {
7
+ return {
8
+ input: 0,
9
+ output: 0,
10
+ cacheRead: 0,
11
+ cacheWrite: 0,
12
+ totalTokens: 0,
13
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
14
+ };
15
+ }
16
+
17
+ function makeAssistant(content: any[]) {
18
+ return {
19
+ role: "assistant",
20
+ content,
21
+ api: "anthropic-messages",
22
+ provider: "claude-code",
23
+ model: "claude-sonnet-4",
24
+ usage: makeUsage(),
25
+ stopReason: "stop",
26
+ timestamp: Date.now(),
27
+ };
28
+ }
29
+
30
+ function createHost() {
31
+ const chatContainer = {
32
+ children: [] as any[],
33
+ addChild(component: any) {
34
+ this.children.push(component);
35
+ },
36
+ removeChild(component: any) {
37
+ const idx = this.children.indexOf(component);
38
+ if (idx !== -1) this.children.splice(idx, 1);
39
+ },
40
+ clear() {
41
+ this.children = [];
42
+ },
43
+ };
44
+
45
+ const host: any = {
46
+ isInitialized: true,
47
+ init: async () => {},
48
+ defaultEditor: { onEscape: undefined },
49
+ editor: {},
50
+ session: { retryAttempt: 0, abortCompaction: () => {}, abortRetry: () => {} },
51
+ ui: { requestRender: () => {} },
52
+ footer: { invalidate: () => {} },
53
+ keybindings: {},
54
+ statusContainer: { clear: () => {}, addChild: () => {} },
55
+ chatContainer,
56
+ settingsManager: { getTimestampFormat: () => "date-time-iso", getShowImages: () => false },
57
+ pendingTools: new Map(),
58
+ toolOutputExpanded: false,
59
+ hideThinkingBlock: false,
60
+ isBashMode: false,
61
+ defaultWorkingMessage: "Working...",
62
+ compactionQueuedMessages: [],
63
+ editorContainer: {},
64
+ pendingMessagesContainer: { clear: () => {} },
65
+ addMessageToChat: () => {},
66
+ getMarkdownThemeWithSettings: () => ({}),
67
+ formatWebSearchResult: () => "",
68
+ getRegisteredToolDefinition: () => undefined,
69
+ checkShutdownRequested: async () => {},
70
+ rebuildChatFromMessages: () => {},
71
+ flushCompactionQueue: async () => {},
72
+ showStatus: () => {},
73
+ showError: () => {},
74
+ updatePendingMessagesDisplay: () => {},
75
+ updateTerminalTitle: () => {},
76
+ updateEditorBorderColor: () => {},
77
+ };
78
+
79
+ return host;
80
+ }
81
+
82
+ test("chat-controller keeps tool output ahead of delayed assistant text for external tool streams", async () => {
83
+ // ToolExecutionComponent uses the global theme singleton.
84
+ // Install a minimal no-op theme implementation for this unit test.
85
+ (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
86
+ fg: (_key: string, text: string) => text,
87
+ bg: (_key: string, text: string) => text,
88
+ bold: (text: string) => text,
89
+ italic: (text: string) => text,
90
+ truncate: (text: string) => text,
91
+ };
92
+
93
+ const host = createHost();
94
+ const toolId = "mcp-tool-1";
95
+ const toolCall = {
96
+ type: "toolCall",
97
+ id: toolId,
98
+ name: "exec_command",
99
+ arguments: { cmd: "echo hi" },
100
+ };
101
+
102
+ await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any);
103
+
104
+ assert.equal(host.streamingComponent, undefined, "assistant component should be deferred at message_start");
105
+ assert.equal(host.chatContainer.children.length, 0, "nothing should render before content arrives");
106
+
107
+ await handleAgentEvent(
108
+ host,
109
+ {
110
+ type: "message_update",
111
+ message: makeAssistant([toolCall]),
112
+ assistantMessageEvent: {
113
+ type: "toolcall_end",
114
+ contentIndex: 0,
115
+ toolCall: {
116
+ ...toolCall,
117
+ externalResult: {
118
+ content: [{ type: "text", text: "tool output" }],
119
+ details: {},
120
+ isError: false,
121
+ },
122
+ },
123
+ partial: makeAssistant([toolCall]),
124
+ },
125
+ } as any,
126
+ );
127
+
128
+ assert.equal(host.streamingComponent, undefined, "assistant text container should remain deferred for tool-only updates");
129
+ assert.equal(host.chatContainer.children.length, 1, "tool execution block should render immediately");
130
+ assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
131
+
132
+ // Re-assert required host method before the text-bearing update path.
133
+ host.getMarkdownThemeWithSettings = () => ({});
134
+
135
+ await handleAgentEvent(
136
+ host,
137
+ {
138
+ type: "message_update",
139
+ message: makeAssistant([toolCall, { type: "text", text: "done" }]),
140
+ assistantMessageEvent: {
141
+ type: "text_delta",
142
+ contentIndex: 1,
143
+ delta: "done",
144
+ partial: makeAssistant([toolCall, { type: "text", text: "done" }]),
145
+ },
146
+ } as any,
147
+ );
148
+
149
+ assert.equal(host.chatContainer.children.length, 2, "assistant content should render after existing tool output");
150
+ assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
151
+ assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent");
152
+ });
153
+
154
+ test("chat-controller keeps serverToolUse output ahead of assistant text when external results arrive", async () => {
155
+ (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
156
+ fg: (_key: string, text: string) => text,
157
+ bg: (_key: string, text: string) => text,
158
+ bold: (text: string) => text,
159
+ italic: (text: string) => text,
160
+ truncate: (text: string) => text,
161
+ };
162
+
163
+ const host = createHost();
164
+ const toolId = "mcp-secure-1";
165
+ const serverToolUse = {
166
+ type: "serverToolUse",
167
+ id: toolId,
168
+ name: "mcp__gsd-workflow__secure_env_collect",
169
+ input: { projectDir: "/tmp/project", keys: [{ key: "SECURE_PASSWORD" }], destination: "dotenv" },
170
+ };
171
+
172
+ await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any);
173
+
174
+ await handleAgentEvent(
175
+ host,
176
+ {
177
+ type: "message_update",
178
+ message: makeAssistant([serverToolUse]),
179
+ assistantMessageEvent: {
180
+ type: "server_tool_use",
181
+ contentIndex: 0,
182
+ partial: makeAssistant([serverToolUse]),
183
+ },
184
+ } as any,
185
+ );
186
+
187
+ assert.equal(host.streamingComponent, undefined, "assistant content should stay deferred while only tool content streams");
188
+ assert.equal(host.chatContainer.children.length, 1, "server tool block should render immediately");
189
+ assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
190
+
191
+ host.getMarkdownThemeWithSettings = () => ({});
192
+ const resultMessage = makeAssistant([
193
+ {
194
+ ...serverToolUse,
195
+ externalResult: {
196
+ content: [{ type: "text", text: "secure_env_collect was cancelled by user." }],
197
+ details: {},
198
+ isError: true,
199
+ },
200
+ },
201
+ { type: "text", text: "The secure password collection was cancelled." },
202
+ ]);
203
+
204
+ await handleAgentEvent(
205
+ host,
206
+ {
207
+ type: "message_update",
208
+ message: resultMessage,
209
+ assistantMessageEvent: {
210
+ type: "server_tool_use",
211
+ contentIndex: 0,
212
+ partial: resultMessage,
213
+ },
214
+ } as any,
215
+ );
216
+
217
+ assert.equal(host.chatContainer.children.length, 2, "assistant text should render after existing server tool output");
218
+ assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent");
219
+ assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent");
220
+ });
@@ -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. */
@@ -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
  }
@@ -9,6 +9,18 @@ import { appKey } from "../components/keybinding-hints.js";
9
9
  // Tracks the last processed content index to avoid re-scanning all blocks on every message_update
10
10
  let lastProcessedContentIndex = 0;
11
11
 
12
+ function hasVisibleAssistantContent(message: { content: Array<any> }): boolean {
13
+ return message.content.some(
14
+ (c) =>
15
+ (c.type === "text" && typeof c.text === "string" && c.text.trim().length > 0)
16
+ || (c.type === "thinking" && typeof c.thinking === "string" && c.thinking.trim().length > 0),
17
+ );
18
+ }
19
+
20
+ function hasAssistantToolBlocks(message: { content: Array<any> }): boolean {
21
+ return message.content.some((c) => c.type === "toolCall" || c.type === "serverToolUse");
22
+ }
23
+
12
24
  export async function handleAgentEvent(host: InteractiveModeStateHost & {
13
25
  init: () => Promise<void>;
14
26
  getMarkdownThemeWithSettings: () => any;
@@ -104,45 +116,54 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
104
116
  host.updatePendingMessagesDisplay();
105
117
  host.ui.requestRender();
106
118
  } else if (event.message.role === "assistant") {
107
- host.streamingComponent = new AssistantMessageComponent(
108
- undefined,
109
- host.hideThinkingBlock,
110
- host.getMarkdownThemeWithSettings(),
111
- host.settingsManager.getTimestampFormat(),
112
- );
113
119
  host.streamingMessage = event.message;
114
- host.chatContainer.addChild(host.streamingComponent);
115
- host.streamingComponent.updateContent(host.streamingMessage);
120
+ // External-tool providers can stream multiple assistant turns through
121
+ // one response. Delay component creation until visible assistant text
122
+ // arrives so tool outputs keep chronological ordering.
116
123
  host.ui.requestRender();
117
124
  }
118
125
  break;
119
126
 
120
127
  case "message_update":
121
- if (host.streamingComponent && event.message.role === "assistant") {
128
+ if (event.message.role === "assistant") {
122
129
  host.streamingMessage = event.message;
123
- host.streamingComponent.updateContent(host.streamingMessage);
124
-
125
- // When the stream adapter signals a completed tool call with an
126
- // external result (from Claude Code SDK), update the pending
127
- // ToolExecutionComponent immediately so output is visible in
128
- // real-time instead of waiting for the session to end.
129
130
  const innerEvent = event.assistantMessageEvent;
131
+
132
+ let externalToolResult:
133
+ | { toolCallId: string; content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; details: Record<string, unknown>; isError: boolean }
134
+ | undefined;
130
135
  if (innerEvent.type === "toolcall_end" && innerEvent.toolCall) {
131
136
  const tc = innerEvent.toolCall as any;
132
- const externalResult = tc.externalResult;
133
- if (externalResult) {
134
- const component = host.pendingTools.get(tc.id);
135
- if (component) {
136
- component.updateResult({
137
- content: externalResult.content ?? [{ type: "text", text: "" }],
138
- details: externalResult.details ?? {},
139
- isError: externalResult.isError ?? false,
140
- });
141
- }
137
+ const ext = tc.externalResult;
138
+ if (ext) {
139
+ externalToolResult = {
140
+ toolCallId: tc.id,
141
+ content: ext.content ?? [{ type: "text", text: "" }],
142
+ details: ext.details ?? {},
143
+ isError: ext.isError ?? false,
144
+ };
145
+ }
146
+ } else if (innerEvent.type === "server_tool_use") {
147
+ const idx = typeof innerEvent.contentIndex === "number" ? innerEvent.contentIndex : -1;
148
+ const block = idx >= 0 ? (host.streamingMessage.content[idx] as any) : undefined;
149
+ const ext = block?.externalResult;
150
+ if (block?.id && ext) {
151
+ externalToolResult = {
152
+ toolCallId: block.id,
153
+ content: ext.content ?? [{ type: "text", text: "" }],
154
+ details: ext.details ?? {},
155
+ isError: ext.isError ?? false,
156
+ };
142
157
  }
143
158
  }
144
159
 
145
160
  const contentBlocks = host.streamingMessage.content;
161
+ // Some adapters reuse a single assistant lifecycle while internally
162
+ // spanning multiple provider turns. When a new turn starts, content
163
+ // length can shrink back to 0/1; reset scan index to avoid skipping.
164
+ if (lastProcessedContentIndex >= contentBlocks.length) {
165
+ lastProcessedContentIndex = 0;
166
+ }
146
167
  for (let i = lastProcessedContentIndex; i < contentBlocks.length; i++) {
147
168
  const content = contentBlocks[i];
148
169
  if (content.type === "toolCall") {
@@ -192,6 +213,42 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
192
213
  }
193
214
  }
194
215
  }
216
+
217
+ // When the stream adapter signals a completed tool call with an
218
+ // external result (from Claude Code SDK), update the pending
219
+ // ToolExecutionComponent immediately so output is visible in
220
+ // real-time instead of waiting for the session to end.
221
+ if (externalToolResult) {
222
+ const component = host.pendingTools.get(externalToolResult.toolCallId);
223
+ if (component) {
224
+ component.updateResult({
225
+ content: externalToolResult.content,
226
+ details: externalToolResult.details,
227
+ isError: externalToolResult.isError,
228
+ });
229
+ }
230
+ }
231
+
232
+ // Render assistant text/thinking after tool components so mixed
233
+ // streams keep chronological ordering in the chat container.
234
+ const hasToolBlocks = hasAssistantToolBlocks(host.streamingMessage);
235
+ if (!host.streamingComponent && hasVisibleAssistantContent(host.streamingMessage)) {
236
+ host.streamingComponent = new AssistantMessageComponent(
237
+ undefined,
238
+ host.hideThinkingBlock,
239
+ host.getMarkdownThemeWithSettings(),
240
+ host.settingsManager.getTimestampFormat(),
241
+ );
242
+ host.chatContainer.addChild(host.streamingComponent);
243
+ }
244
+ if (host.streamingComponent) {
245
+ if (hasToolBlocks) {
246
+ host.chatContainer.removeChild(host.streamingComponent);
247
+ host.chatContainer.addChild(host.streamingComponent);
248
+ }
249
+ host.streamingComponent.updateContent(host.streamingMessage);
250
+ }
251
+
195
252
  // Update index: fully processed blocks won't need re-scanning.
196
253
  // Keep the last block's index (it may still be accumulating data),
197
254
  // so we re-check it next time but skip all earlier ones.
@@ -204,7 +261,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
204
261
 
205
262
  case "message_end":
206
263
  if (event.message.role === "user") break;
207
- if (host.streamingComponent && event.message.role === "assistant") {
264
+ if (event.message.role === "assistant") {
208
265
  host.streamingMessage = event.message;
209
266
  let errorMessage: string | undefined;
210
267
  if (host.streamingMessage.stopReason === "aborted") {
@@ -214,7 +271,25 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
214
271
  : "Operation aborted";
215
272
  host.streamingMessage.errorMessage = errorMessage;
216
273
  }
217
- host.streamingComponent.updateContent(host.streamingMessage);
274
+
275
+ const shouldRenderAssistant = hasVisibleAssistantContent(host.streamingMessage)
276
+ || (
277
+ (host.streamingMessage.stopReason === "aborted" || host.streamingMessage.stopReason === "error")
278
+ && !hasAssistantToolBlocks(host.streamingMessage)
279
+ );
280
+ if (!host.streamingComponent && shouldRenderAssistant) {
281
+ host.streamingComponent = new AssistantMessageComponent(
282
+ undefined,
283
+ host.hideThinkingBlock,
284
+ host.getMarkdownThemeWithSettings(),
285
+ host.settingsManager.getTimestampFormat(),
286
+ );
287
+ host.chatContainer.addChild(host.streamingComponent);
288
+ }
289
+ if (host.streamingComponent) {
290
+ host.streamingComponent.updateContent(host.streamingMessage);
291
+ }
292
+
218
293
  if (host.streamingMessage.stopReason === "aborted" || host.streamingMessage.stopReason === "error") {
219
294
  if (!errorMessage) {
220
295
  errorMessage = host.streamingMessage.errorMessage || "Error";
@@ -1631,7 +1631,7 @@ export class InteractiveMode {
1631
1631
  this.hideExtensionInput();
1632
1632
  resolve(undefined);
1633
1633
  },
1634
- { tui: this.ui, timeout: opts?.timeout },
1634
+ { tui: this.ui, timeout: opts?.timeout, secure: opts?.secure },
1635
1635
  );
1636
1636
 
1637
1637
  this.editorContainer.clear();
@@ -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
  | {
@@ -25,5 +25,14 @@ describe("Input", () => {
25
25
  input.focused = false;
26
26
  assert.equal(input.focused, false);
27
27
  });
28
+ it("secure mode obscures typed characters in render output", () => {
29
+ const input = new Input();
30
+ input.secure = true;
31
+ input.focused = true;
32
+ input.handleInput("secret123");
33
+ const line = input.render(40)[0] ?? "";
34
+ assert.ok(!line.includes("secret123"), "rendered line must not expose raw secret text");
35
+ assert.ok(line.includes("*********"), "rendered line should include masked characters");
36
+ });
28
37
  });
29
38
  //# sourceMappingURL=input.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"input.test.js","sourceRoot":"","sources":["../../../src/components/__tests__/input.test.ts"],"names":[],"mappings":"AAAA,0CAA0C;AAC1C,4DAA4D;AAE5D,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAEpC,QAAQ,CAAC,OAAO,EAAE,GAAG,EAAE;IACtB,EAAE,CAAC,4CAA4C,EAAE,GAAG,EAAE;QACrD,MAAM,KAAK,GAAG,IAAI,KAAK,EAAE,CAAC;QAC1B,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC;QAErB,yDAAyD;QACzD,KAAK,CAAC,WAAW,CAAC,kBAAkB,CAAC,CAAC;QAEtC,2BAA2B;QAC3B,KAAK,CAAC,OAAO,GAAG,KAAK,CAAC;QAEtB,mDAAmD;QACnD,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC;QAErB,iEAAiE;QACjE,KAAK,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;QAC3B,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,EAAE,EAAE,OAAO,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uCAAuC,EAAE,GAAG,EAAE;QAChD,MAAM,KAAK,GAAG,IAAI,KAAK,EAAE,CAAC;QAC1B,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACnC,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC;QACrB,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QAClC,KAAK,CAAC,OAAO,GAAG,KAAK,CAAC;QACtB,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;AACJ,CAAC,CAAC,CAAC","sourcesContent":["// pi-tui Input component regression tests\n// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>\n\nimport { describe, it } from \"node:test\";\nimport assert from \"node:assert/strict\";\nimport { Input } from \"../input.js\";\n\ndescribe(\"Input\", () => {\n\tit(\"paste buffer is cleared when focus is lost\", () => {\n\t\tconst input = new Input();\n\t\tinput.focused = true;\n\n\t\t// Simulate starting a paste (bracket paste start marker)\n\t\tinput.handleInput(\"\\x1b[200~partial\");\n\n\t\t// Now lose focus mid-paste\n\t\tinput.focused = false;\n\n\t\t// Regain focus — should not have stale paste state\n\t\tinput.focused = true;\n\n\t\t// Typing normal text should work without paste buffer corruption\n\t\tinput.handleInput(\"hello\");\n\t\tassert.equal(input.getValue(), \"hello\");\n\t});\n\n\tit(\"focused getter/setter works correctly\", () => {\n\t\tconst input = new Input();\n\t\tassert.equal(input.focused, false);\n\t\tinput.focused = true;\n\t\tassert.equal(input.focused, true);\n\t\tinput.focused = false;\n\t\tassert.equal(input.focused, false);\n\t});\n});\n"]}
1
+ {"version":3,"file":"input.test.js","sourceRoot":"","sources":["../../../src/components/__tests__/input.test.ts"],"names":[],"mappings":"AAAA,0CAA0C;AAC1C,4DAA4D;AAE5D,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAEpC,QAAQ,CAAC,OAAO,EAAE,GAAG,EAAE;IACtB,EAAE,CAAC,4CAA4C,EAAE,GAAG,EAAE;QACrD,MAAM,KAAK,GAAG,IAAI,KAAK,EAAE,CAAC;QAC1B,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC;QAErB,yDAAyD;QACzD,KAAK,CAAC,WAAW,CAAC,kBAAkB,CAAC,CAAC;QAEtC,2BAA2B;QAC3B,KAAK,CAAC,OAAO,GAAG,KAAK,CAAC;QAEtB,mDAAmD;QACnD,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC;QAErB,iEAAiE;QACjE,KAAK,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;QAC3B,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,EAAE,EAAE,OAAO,CAAC,CAAC;IACzC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uCAAuC,EAAE,GAAG,EAAE;QAChD,MAAM,KAAK,GAAG,IAAI,KAAK,EAAE,CAAC;QAC1B,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACnC,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC;QACrB,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;QAClC,KAAK,CAAC,OAAO,GAAG,KAAK,CAAC;QACtB,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,wDAAwD,EAAE,GAAG,EAAE;QACjE,MAAM,KAAK,GAAG,IAAI,KAAK,EAAE,CAAC;QAC1B,KAAK,CAAC,MAAM,GAAG,IAAI,CAAC;QACpB,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC;QACrB,KAAK,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC;QAE/B,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACvC,MAAM,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,+CAA+C,CAAC,CAAC;QACxF,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,gDAAgD,CAAC,CAAC;IACzF,CAAC,CAAC,CAAC;AACJ,CAAC,CAAC,CAAC","sourcesContent":["// pi-tui Input component regression tests\n// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>\n\nimport { describe, it } from \"node:test\";\nimport assert from \"node:assert/strict\";\nimport { Input } from \"../input.js\";\n\ndescribe(\"Input\", () => {\n\tit(\"paste buffer is cleared when focus is lost\", () => {\n\t\tconst input = new Input();\n\t\tinput.focused = true;\n\n\t\t// Simulate starting a paste (bracket paste start marker)\n\t\tinput.handleInput(\"\\x1b[200~partial\");\n\n\t\t// Now lose focus mid-paste\n\t\tinput.focused = false;\n\n\t\t// Regain focus — should not have stale paste state\n\t\tinput.focused = true;\n\n\t\t// Typing normal text should work without paste buffer corruption\n\t\tinput.handleInput(\"hello\");\n\t\tassert.equal(input.getValue(), \"hello\");\n\t});\n\n\tit(\"focused getter/setter works correctly\", () => {\n\t\tconst input = new Input();\n\t\tassert.equal(input.focused, false);\n\t\tinput.focused = true;\n\t\tassert.equal(input.focused, true);\n\t\tinput.focused = false;\n\t\tassert.equal(input.focused, false);\n\t});\n\n\tit(\"secure mode obscures typed characters in render output\", () => {\n\t\tconst input = new Input();\n\t\tinput.secure = true;\n\t\tinput.focused = true;\n\t\tinput.handleInput(\"secret123\");\n\n\t\tconst line = input.render(40)[0] ?? \"\";\n\t\tassert.ok(!line.includes(\"secret123\"), \"rendered line must not expose raw secret text\");\n\t\tassert.ok(line.includes(\"*********\"), \"rendered line should include masked characters\");\n\t});\n});\n"]}
@@ -8,6 +8,8 @@ export declare class Input implements Component, Focusable {
8
8
  onSubmit?: (value: string) => void;
9
9
  onEscape?: () => void;
10
10
  placeholder: string;
11
+ /** When true, render obscured characters instead of the actual value. */
12
+ secure: boolean;
11
13
  /** Focusable interface - set by TUI when focus changes */
12
14
  private _focused;
13
15
  get focused(): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../../src/components/input.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,SAAS,EAAiB,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAW1E;;GAEG;AACH,qBAAa,KAAM,YAAW,SAAS,EAAE,SAAS;IACjD,OAAO,CAAC,KAAK,CAAc;IAC3B,OAAO,CAAC,MAAM,CAAa;IACpB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,WAAW,EAAE,MAAM,CAAM;IAEhC,0DAA0D;IAC1D,OAAO,CAAC,QAAQ,CAAkB;IAClC,IAAI,OAAO,IAAI,OAAO,CAErB;IACD,IAAI,OAAO,CAAC,KAAK,EAAE,OAAO,EAMzB;IAGD,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,SAAS,CAAkB;IAGnC,OAAO,CAAC,QAAQ,CAAkB;IAClC,OAAO,CAAC,UAAU,CAA8C;IAGhE,OAAO,CAAC,SAAS,CAA+B;IAEhD,QAAQ,IAAI,MAAM;IAIlB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAK7B,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAqK/B,OAAO,CAAC,eAAe;IAWvB,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,mBAAmB;IAY3B,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,mBAAmB;IAqB3B,OAAO,CAAC,iBAAiB;IAoBzB,OAAO,CAAC,IAAI;IAWZ,OAAO,CAAC,OAAO;IAkBf,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,IAAI;IAQZ,OAAO,CAAC,iBAAiB;IAkCzB,OAAO,CAAC,gBAAgB;IAmCxB,OAAO,CAAC,WAAW;IAYnB,UAAU,IAAI,IAAI;IAIlB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE;CAiG/B"}
1
+ {"version":3,"file":"input.d.ts","sourceRoot":"","sources":["../../src/components/input.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,SAAS,EAAiB,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAW1E;;GAEG;AACH,qBAAa,KAAM,YAAW,SAAS,EAAE,SAAS;IACjD,OAAO,CAAC,KAAK,CAAc;IAC3B,OAAO,CAAC,MAAM,CAAa;IACpB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,WAAW,EAAE,MAAM,CAAM;IAChC,yEAAyE;IAClE,MAAM,EAAE,OAAO,CAAS;IAE/B,0DAA0D;IAC1D,OAAO,CAAC,QAAQ,CAAkB;IAClC,IAAI,OAAO,IAAI,OAAO,CAErB;IACD,IAAI,OAAO,CAAC,KAAK,EAAE,OAAO,EAMzB;IAGD,OAAO,CAAC,WAAW,CAAc;IACjC,OAAO,CAAC,SAAS,CAAkB;IAGnC,OAAO,CAAC,QAAQ,CAAkB;IAClC,OAAO,CAAC,UAAU,CAA8C;IAGhE,OAAO,CAAC,SAAS,CAA+B;IAEhD,QAAQ,IAAI,MAAM;IAIlB,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAK7B,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAqK/B,OAAO,CAAC,eAAe;IAWvB,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,mBAAmB;IAY3B,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,mBAAmB;IAqB3B,OAAO,CAAC,iBAAiB;IAoBzB,OAAO,CAAC,IAAI;IAWZ,OAAO,CAAC,OAAO;IAkBf,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,IAAI;IAQZ,OAAO,CAAC,iBAAiB;IAkCzB,OAAO,CAAC,gBAAgB;IAmCxB,OAAO,CAAC,WAAW;IAYnB,UAAU,IAAI,IAAI;IAIlB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE;CAkG/B"}
@@ -13,6 +13,8 @@ export class Input {
13
13
  this.value = "";
14
14
  this.cursor = 0; // Cursor position in the value
15
15
  this.placeholder = "";
16
+ /** When true, render obscured characters instead of the actual value. */
17
+ this.secure = false;
16
18
  /** Focusable interface - set by TUI when focus changes */
17
19
  this._focused = false;
18
20
  // Bracketed paste mode buffering
@@ -376,6 +378,7 @@ export class Input {
376
378
  // Calculate visible window
377
379
  const prompt = "> ";
378
380
  const availableWidth = width - prompt.length;
381
+ const renderValue = this.secure ? "*".repeat(this.value.length) : this.value;
379
382
  if (availableWidth <= 0) {
380
383
  return [prompt];
381
384
  }
@@ -392,7 +395,7 @@ export class Input {
392
395
  let cursorDisplay = this.cursor;
393
396
  if (this.value.length < availableWidth) {
394
397
  // Everything fits (leave room for cursor at end)
395
- visibleText = this.value;
398
+ visibleText = renderValue;
396
399
  }
397
400
  else {
398
401
  // Need horizontal scrolling
@@ -425,19 +428,19 @@ export class Input {
425
428
  };
426
429
  if (this.cursor < halfWidth) {
427
430
  // Cursor near start
428
- visibleText = this.value.slice(0, findValidEnd(scrollWidth));
431
+ visibleText = renderValue.slice(0, findValidEnd(scrollWidth));
429
432
  cursorDisplay = this.cursor;
430
433
  }
431
434
  else if (this.cursor > this.value.length - halfWidth) {
432
435
  // Cursor near end
433
436
  const start = findValidStart(this.value.length - scrollWidth);
434
- visibleText = this.value.slice(start);
437
+ visibleText = renderValue.slice(start);
435
438
  cursorDisplay = this.cursor - start;
436
439
  }
437
440
  else {
438
441
  // Cursor in middle
439
442
  const start = findValidStart(this.cursor - halfWidth);
440
- visibleText = this.value.slice(start, findValidEnd(start + scrollWidth));
443
+ visibleText = renderValue.slice(start, findValidEnd(start + scrollWidth));
441
444
  cursorDisplay = halfWidth;
442
445
  }
443
446
  }