pi-ui-extend 0.1.32 → 0.1.34

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 (95) hide show
  1. package/README.md +1 -1
  2. package/dist/app/app.d.ts +2 -0
  3. package/dist/app/app.js +28 -0
  4. package/dist/app/commands/command-session-actions.js +29 -1
  5. package/dist/app/constants.d.ts +1 -1
  6. package/dist/app/constants.js +2 -2
  7. package/dist/app/icons.d.ts +4 -9
  8. package/dist/app/icons.js +12 -35
  9. package/dist/app/model/model-usage-status.d.ts +2 -1
  10. package/dist/app/model/model-usage-status.js +33 -25
  11. package/dist/app/rendering/conversation-entry-renderer.d.ts +1 -0
  12. package/dist/app/rendering/conversation-tool-renderer.d.ts +1 -0
  13. package/dist/app/rendering/conversation-tool-renderer.js +12 -18
  14. package/dist/app/rendering/conversation-viewport.d.ts +4 -0
  15. package/dist/app/rendering/conversation-viewport.js +144 -13
  16. package/dist/app/rendering/dcp-stats.js +42 -16
  17. package/dist/app/rendering/render-controller.js +4 -0
  18. package/dist/app/rendering/status-line-renderer.d.ts +8 -1
  19. package/dist/app/rendering/status-line-renderer.js +36 -1
  20. package/dist/app/rendering/tab-line-renderer.js +2 -2
  21. package/dist/app/rendering/tool-block-renderer.d.ts +1 -0
  22. package/dist/app/rendering/tool-block-renderer.js +37 -11
  23. package/dist/app/runtime.js +1 -1
  24. package/dist/app/screen/mouse-controller.d.ts +5 -1
  25. package/dist/app/screen/mouse-controller.js +16 -0
  26. package/dist/app/screen/scroll-controller.d.ts +20 -0
  27. package/dist/app/screen/scroll-controller.js +127 -10
  28. package/dist/app/session/lazy-session-manager.js +35 -5
  29. package/dist/app/session/pix-system-message.d.ts +1 -0
  30. package/dist/app/session/pix-system-message.js +14 -3
  31. package/dist/app/session/queued-message-controller.d.ts +11 -4
  32. package/dist/app/session/queued-message-controller.js +74 -59
  33. package/dist/app/session/queued-message-entries.d.ts +2 -1
  34. package/dist/app/session/queued-message-entries.js +12 -1
  35. package/dist/app/session/session-event-controller.d.ts +42 -1
  36. package/dist/app/session/session-event-controller.js +500 -31
  37. package/dist/app/session/session-history.js +23 -4
  38. package/dist/app/session/tabs-controller.d.ts +11 -1
  39. package/dist/app/session/tabs-controller.js +102 -21
  40. package/dist/app/types.d.ts +14 -1
  41. package/dist/bundled-extensions/question/contract.d.ts +25 -0
  42. package/dist/bundled-extensions/question/contract.js +94 -0
  43. package/dist/bundled-extensions/question/index.d.ts +7 -0
  44. package/dist/bundled-extensions/question/index.js +28 -0
  45. package/dist/bundled-extensions/question/render.d.ts +4 -0
  46. package/dist/bundled-extensions/question/render.js +27 -0
  47. package/dist/bundled-extensions/question/result.d.ts +6 -0
  48. package/dist/bundled-extensions/question/result.js +84 -0
  49. package/dist/bundled-extensions/question/tool-description.d.ts +7 -0
  50. package/dist/bundled-extensions/question/tool-description.js +11 -0
  51. package/dist/bundled-extensions/question/tui.d.ts +2 -0
  52. package/dist/bundled-extensions/question/tui.js +577 -0
  53. package/dist/bundled-extensions/question/types.d.ts +103 -0
  54. package/dist/bundled-extensions/question/types.js +1 -0
  55. package/dist/bundled-extensions/session-title/config.d.ts +17 -0
  56. package/dist/bundled-extensions/session-title/config.js +150 -0
  57. package/dist/bundled-extensions/session-title/index.d.ts +5 -0
  58. package/dist/bundled-extensions/session-title/index.js +384 -0
  59. package/dist/bundled-extensions/session-title/title-generation.d.ts +26 -0
  60. package/dist/bundled-extensions/session-title/title-generation.js +141 -0
  61. package/dist/bundled-extensions/terminal-bell/index.d.ts +14 -0
  62. package/dist/bundled-extensions/terminal-bell/index.js +491 -0
  63. package/dist/config.d.ts +1 -1
  64. package/dist/config.js +2 -1
  65. package/dist/default-pix-config.js +2 -1
  66. package/dist/icon-theme.d.ts +7 -0
  67. package/dist/icon-theme.js +36 -0
  68. package/dist/schemas/pi-tools-suite-schema.d.ts +4 -0
  69. package/dist/schemas/pi-tools-suite-schema.js +5 -0
  70. package/dist/schemas/pix-schema.d.ts +1 -0
  71. package/dist/schemas/pix-schema.js +1 -0
  72. package/external/pi-tools-suite/README.md +7 -7
  73. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +16 -16
  74. package/external/pi-tools-suite/src/async-subagents/core/state.ts +18 -4
  75. package/external/pi-tools-suite/src/async-subagents/core/types.ts +4 -0
  76. package/external/pi-tools-suite/src/async-subagents/tools/result.ts +14 -26
  77. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +0 -1
  78. package/external/pi-tools-suite/src/dcp/config.ts +14 -14
  79. package/external/pi-tools-suite/src/dcp/index.ts +31 -43
  80. package/external/pi-tools-suite/src/dcp/state-persistence.ts +151 -0
  81. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +25 -18
  82. package/external/pi-tools-suite/src/tool-descriptions.ts +34 -54
  83. package/package.json +3 -2
  84. package/schemas/pi-tools-suite.json +14 -0
  85. package/schemas/pix.json +7 -0
  86. package/extensions/question/contract.ts +0 -100
  87. package/extensions/question/index.ts +0 -34
  88. package/extensions/question/render.ts +0 -28
  89. package/extensions/question/result.ts +0 -86
  90. package/extensions/question/tool-description.ts +0 -11
  91. package/extensions/question/tui.ts +0 -629
  92. package/extensions/question/types.ts +0 -123
  93. package/extensions/session-title/config.ts +0 -164
  94. package/extensions/session-title/index.ts +0 -502
  95. package/extensions/terminal-bell/index.ts +0 -345
@@ -1,21 +1,32 @@
1
1
  import { createId } from "../id.js";
2
2
  import { extractImageContents, renderContent, renderUserMessageContent, stringifyUnknown } from "../rendering/message-content.js";
3
3
  import { customMessageEntry, loadSessionHistoryEntries, loadSessionHistoryEntriesAsync } from "./session-history.js";
4
- import { sessionHistoryDisplayMessages, sessionHistoryOlderMessagesReader } from "./pix-system-message.js";
4
+ import { sessionHistoryDisplayMessages, sessionHistoryDisplayMessagesFromEntries, sessionHistoryFullBranchEntries } from "./pix-system-message.js";
5
+ import { THINKING_TOOL_NAME } from "../constants.js";
5
6
  import { isRecord } from "../guards.js";
6
7
  const DCP_MESSAGE_REFERENCE_PREFIX = "[dcp-id]: # (m";
7
8
  const DCP_BLOCK_REFERENCE_PREFIX = "[dcp-block-id]: # (b";
8
9
  const MAX_HISTORY_WINDOW_ENTRIES = 360;
9
10
  const HISTORY_WINDOW_TARGET_ENTRIES = 300;
11
+ const HISTORY_WINDOW_SHIFT_ENTRIES = 50;
10
12
  export class AppSessionEventController {
11
13
  host;
12
14
  entryRenderVersions = new Map();
13
15
  toolEntryIdsByCallId = new Map();
16
+ pendingToolCallIdsByContentIndex = new Map();
14
17
  toolMutationPreparationsByCallId = new Map();
15
18
  olderHistoryLoader;
16
19
  currentUserEntryId;
17
20
  currentAssistantEntryId;
21
+ currentAssistantTextBlockEntryId;
22
+ currentAssistantTextBlockStartLength;
23
+ currentAssistantTextBlockContentIndex;
24
+ assistantTextBlocksByContentIndex = new Map();
25
+ finalizedToolCallContentIndexes = new Set();
26
+ historyEntries = [];
27
+ historyWindowStart = 0;
18
28
  currentThinkingEntryId;
29
+ assistantMessageClosed = false;
19
30
  assistantTextBuffer = "";
20
31
  constructor(host) {
21
32
  this.host = host;
@@ -23,38 +34,67 @@ export class AppSessionEventController {
23
34
  snapshotState() {
24
35
  return {
25
36
  toolEntryIdsByCallId: new Map(this.toolEntryIdsByCallId),
37
+ pendingToolCallIdsByContentIndex: new Map(this.pendingToolCallIdsByContentIndex),
26
38
  toolMutationPreparationsByCallId: new Map(this.toolMutationPreparationsByCallId),
27
39
  olderHistoryLoader: this.olderHistoryLoader,
28
40
  currentUserEntryId: this.currentUserEntryId,
29
41
  currentAssistantEntryId: this.currentAssistantEntryId,
42
+ currentAssistantTextBlockEntryId: this.currentAssistantTextBlockEntryId,
43
+ currentAssistantTextBlockStartLength: this.currentAssistantTextBlockStartLength,
44
+ currentAssistantTextBlockContentIndex: this.currentAssistantTextBlockContentIndex,
45
+ assistantTextBlocksByContentIndex: new Map(this.assistantTextBlocksByContentIndex),
30
46
  currentThinkingEntryId: this.currentThinkingEntryId,
47
+ assistantMessageClosed: this.assistantMessageClosed,
31
48
  assistantTextBuffer: this.assistantTextBuffer,
32
49
  entryRenderVersions: new Map(this.entryRenderVersions),
50
+ historyEntries: [...this.historyEntries],
51
+ historyWindowStart: this.historyWindowStart,
33
52
  };
34
53
  }
35
54
  restoreState(state) {
36
55
  this.toolEntryIdsByCallId.clear();
37
56
  for (const [key, value] of state.toolEntryIdsByCallId)
38
57
  this.toolEntryIdsByCallId.set(key, value);
58
+ this.pendingToolCallIdsByContentIndex.clear();
59
+ for (const [key, value] of state.pendingToolCallIdsByContentIndex)
60
+ this.pendingToolCallIdsByContentIndex.set(key, value);
39
61
  this.toolMutationPreparationsByCallId.clear();
40
62
  for (const [key, value] of state.toolMutationPreparationsByCallId)
41
63
  this.toolMutationPreparationsByCallId.set(key, value);
42
64
  this.olderHistoryLoader = state.olderHistoryLoader;
43
65
  this.currentUserEntryId = state.currentUserEntryId;
44
66
  this.currentAssistantEntryId = state.currentAssistantEntryId;
67
+ this.currentAssistantTextBlockEntryId = state.currentAssistantTextBlockEntryId;
68
+ this.currentAssistantTextBlockStartLength = state.currentAssistantTextBlockStartLength;
69
+ this.currentAssistantTextBlockContentIndex = state.currentAssistantTextBlockContentIndex;
70
+ this.assistantTextBlocksByContentIndex.clear();
71
+ for (const [key, value] of state.assistantTextBlocksByContentIndex)
72
+ this.assistantTextBlocksByContentIndex.set(key, value);
45
73
  this.currentThinkingEntryId = state.currentThinkingEntryId;
74
+ this.assistantMessageClosed = state.assistantMessageClosed;
46
75
  this.assistantTextBuffer = state.assistantTextBuffer;
47
76
  this.entryRenderVersions.clear();
48
77
  for (const [key, value] of state.entryRenderVersions)
49
78
  this.entryRenderVersions.set(key, value);
79
+ this.historyEntries = [...state.historyEntries];
80
+ this.historyWindowStart = state.historyWindowStart;
50
81
  }
51
82
  reset() {
52
83
  this.toolEntryIdsByCallId.clear();
84
+ this.pendingToolCallIdsByContentIndex.clear();
53
85
  this.toolMutationPreparationsByCallId.clear();
54
86
  this.currentUserEntryId = undefined;
55
87
  this.entryRenderVersions.clear();
88
+ this.historyEntries = [];
89
+ this.historyWindowStart = 0;
56
90
  this.currentAssistantEntryId = undefined;
91
+ this.currentAssistantTextBlockEntryId = undefined;
92
+ this.currentAssistantTextBlockStartLength = undefined;
93
+ this.currentAssistantTextBlockContentIndex = undefined;
94
+ this.assistantTextBlocksByContentIndex.clear();
95
+ this.finalizedToolCallContentIndexes.clear();
57
96
  this.currentThinkingEntryId = undefined;
97
+ this.assistantMessageClosed = false;
58
98
  this.assistantTextBuffer = "";
59
99
  this.olderHistoryLoader = undefined;
60
100
  }
@@ -76,32 +116,53 @@ export class AppSessionEventController {
76
116
  if (!runtime)
77
117
  return !options.isCancelled();
78
118
  this.olderHistoryLoader = undefined;
79
- return loadSessionHistoryEntriesAsync({
80
- messages: sessionHistoryDisplayMessages(runtime.session),
81
- olderMessagesReader: sessionHistoryOlderMessagesReader(runtime.session),
82
- addEntry: (entry) => this.addEntry(entry),
83
- prependEntries: (entries) => this.prependEntries(entries),
119
+ this.historyEntries = [];
120
+ this.historyWindowStart = 0;
121
+ const branchEntries = await sessionHistoryFullBranchEntries(runtime.session);
122
+ if (options.isCancelled())
123
+ return false;
124
+ const historyEntries = [];
125
+ const loaded = await loadSessionHistoryEntriesAsync({
126
+ messages: sessionHistoryDisplayMessagesFromEntries(branchEntries),
127
+ addEntry: (entry) => historyEntries.push(entry),
128
+ prependEntries: (entries) => historyEntries.unshift(...entries),
84
129
  setToolEntryId: (toolCallId, entryId) => this.toolEntryIdsByCallId.set(toolCallId, entryId),
85
130
  toolDefaultExpanded: (toolName) => this.host.toolDefaultExpanded(toolName),
86
131
  observeSubagentsToolResult: (toolName, details, options) => this.host.observeSubagentsToolResult(toolName, details, options),
87
132
  observeTodoToolResult: (toolName, details, isError) => this.host.observeTodoToolResult(toolName, details, isError),
88
133
  isCancelled: options.isCancelled,
89
- render: options.render,
90
- lazyOlderHistory: options.lazyOlderHistory === true,
91
- onOlderLoaderReady: (loader) => {
92
- this.olderHistoryLoader = loader;
93
- },
134
+ render: () => { },
135
+ lazyOlderHistory: false,
94
136
  });
137
+ if (!loaded)
138
+ return false;
139
+ this.historyEntries = historyEntries;
140
+ this.setHistoryWindowStart(this.maxHistoryWindowStart());
141
+ options.render();
142
+ return !options.isCancelled();
95
143
  }
96
144
  hasOlderSessionHistory() {
145
+ if (this.historyEntries.length > 0)
146
+ return this.historyWindowStart > 0;
97
147
  return this.olderHistoryLoader?.hasOlder() === true;
98
148
  }
99
149
  isLoadingOlderSessionHistory() {
100
150
  return this.olderHistoryLoader?.isLoading() === true;
101
151
  }
102
152
  async loadOlderSessionHistory(options = {}) {
153
+ if (this.historyEntries.length > 0)
154
+ return this.shiftHistoryWindow(-HISTORY_WINDOW_SHIFT_ENTRIES, options);
103
155
  return this.olderHistoryLoader?.loadOlder(options) ?? false;
104
156
  }
157
+ hasNewerSessionHistory() {
158
+ return this.historyEntries.length > 0 && this.historyWindowStart < this.maxHistoryWindowStart();
159
+ }
160
+ isLoadingNewerSessionHistory() {
161
+ return false;
162
+ }
163
+ async loadNewerSessionHistory(options = {}) {
164
+ return this.shiftHistoryWindow(HISTORY_WINDOW_SHIFT_ENTRIES, options);
165
+ }
105
166
  handleSessionEvent(event) {
106
167
  switch (event.type) {
107
168
  case "session_info_changed":
@@ -114,6 +175,7 @@ export class AppSessionEventController {
114
175
  this.handleMessageEnd(event.message);
115
176
  break;
116
177
  case "agent_start":
178
+ this.assistantMessageClosed = false;
117
179
  this.host.setSessionActivity("running");
118
180
  this.host.setSessionStatus(this.host.runtime()?.session);
119
181
  break;
@@ -123,19 +185,22 @@ export class AppSessionEventController {
123
185
  case "agent_end":
124
186
  this.finishCurrentThinkingEntry();
125
187
  this.clearCurrentAssistantState();
188
+ this.finalizeAbandonedToolEntries();
126
189
  this.currentUserEntryId = undefined;
127
190
  this.host.setSessionActivity("idle");
128
191
  this.host.setSessionStatus(this.host.runtime()?.session);
192
+ this.host.flushAutoUserMessages();
129
193
  break;
130
194
  case "queue_update":
131
195
  this.host.updateQueuedMessageStatus();
132
196
  break;
133
197
  case "message_update":
134
- this.handleMessageUpdate(event.assistantMessageEvent);
198
+ this.handleMessageUpdate(event);
135
199
  break;
136
200
  case "tool_execution_start":
137
201
  this.finishCurrentThinkingEntry();
138
202
  this.flushAssistantTextBuffer(true);
203
+ this.finishCurrentAssistantTextBlock();
139
204
  this.currentAssistantEntryId = undefined;
140
205
  this.host.setSessionActivity("running");
141
206
  this.prepareToolWorkspaceMutation(event.toolCallId, event.toolName, event.args);
@@ -181,6 +246,7 @@ export class AppSessionEventController {
181
246
  case "compaction_end": {
182
247
  this.host.setSessionActivity(this.host.runtime()?.session.isStreaming ? "running" : "idle");
183
248
  this.host.restoreSessionStatus();
249
+ this.host.flushAutoUserMessages();
184
250
  const message = event.result
185
251
  ? `Compacted ${event.result.tokensBefore} tokens`
186
252
  : event.aborted
@@ -196,6 +262,7 @@ export class AppSessionEventController {
196
262
  case "auto_retry_end":
197
263
  this.host.setSessionActivity(this.host.runtime()?.session.isStreaming ? "running" : "idle");
198
264
  this.host.restoreSessionStatus();
265
+ this.host.flushAutoUserMessages();
199
266
  this.host.showToast(event.success ? "Retry succeeded" : `Retry failed: ${event.finalError}`, event.success ? "success" : "error");
200
267
  break;
201
268
  default:
@@ -209,7 +276,7 @@ export class AppSessionEventController {
209
276
  this.addEntry(entry);
210
277
  }
211
278
  findEntry(id) {
212
- return this.host.entries.find((entry) => entry.id === id);
279
+ return this.host.entries.find((entry) => entry.id === id) ?? this.historyEntries.find((entry) => entry.id === id);
213
280
  }
214
281
  findUserEntry(id) {
215
282
  const entry = this.findEntry(id);
@@ -220,28 +287,71 @@ export class AppSessionEventController {
220
287
  this.host.conversationViewport().deleteEntry(entry.id);
221
288
  }
222
289
  addEntry(entry) {
290
+ if (this.historyEntries.length > 0) {
291
+ const wasAtBottom = !this.hasNewerSessionHistory();
292
+ this.historyEntries.push(entry);
293
+ this.registerEntry(entry);
294
+ if (wasAtBottom)
295
+ this.setHistoryWindowStart(this.maxHistoryWindowStart());
296
+ return;
297
+ }
223
298
  this.host.entries.push(entry);
224
- this.entryRenderVersions.set(entry.id, 1);
225
- this.host.conversationViewport().deleteEntry(entry.id);
299
+ this.registerEntry(entry);
226
300
  this.pruneHistoryWindow("top");
227
301
  }
228
302
  prependEntries(entries) {
229
303
  this.host.entries.unshift(...entries);
230
- for (const entry of entries) {
231
- this.entryRenderVersions.set(entry.id, 1);
232
- this.host.conversationViewport().deleteEntry(entry.id);
233
- }
304
+ for (const entry of entries)
305
+ this.registerEntry(entry);
234
306
  this.pruneHistoryWindow("bottom");
235
307
  }
308
+ shiftHistoryWindow(delta, options = {}) {
309
+ if (this.historyEntries.length === 0)
310
+ return false;
311
+ const previousStart = this.historyWindowStart;
312
+ const nextStart = Math.max(0, Math.min(this.maxHistoryWindowStart(), previousStart + delta));
313
+ if (nextStart === previousStart)
314
+ return true;
315
+ if (nextStart < previousStart)
316
+ options.onPrependedEntries?.(this.historyEntries.slice(nextStart, previousStart));
317
+ this.setHistoryWindowStart(nextStart);
318
+ if (options.render !== false)
319
+ this.host.render();
320
+ return true;
321
+ }
322
+ setHistoryWindowStart(start) {
323
+ const nextStart = Math.max(0, Math.min(this.maxHistoryWindowStart(), start));
324
+ this.historyWindowStart = nextStart;
325
+ const nextEntries = this.historyEntries.slice(nextStart, nextStart + this.historyWindowSize());
326
+ const nextEntryIds = new Set(nextEntries.map((entry) => entry.id));
327
+ for (const entry of this.host.entries) {
328
+ if (!nextEntryIds.has(entry.id))
329
+ this.host.conversationViewport().deleteEntry(entry.id);
330
+ }
331
+ this.host.entries.splice(0, this.host.entries.length, ...nextEntries);
332
+ for (const entry of nextEntries)
333
+ this.registerEntry(entry);
334
+ }
335
+ historyWindowSize() {
336
+ return Math.min(HISTORY_WINDOW_TARGET_ENTRIES, this.historyEntries.length);
337
+ }
338
+ maxHistoryWindowStart() {
339
+ return Math.max(0, this.historyEntries.length - this.historyWindowSize());
340
+ }
341
+ registerEntry(entry) {
342
+ this.entryRenderVersions.set(entry.id, this.entryRenderVersions.get(entry.id) ?? 1);
343
+ if (entry.kind === "tool")
344
+ this.toolEntryIdsByCallId.set(entry.toolCallId, entry.id);
345
+ this.host.conversationViewport().deleteEntry(entry.id);
346
+ }
236
347
  pruneHistoryWindow(edge) {
237
348
  const removeCount = this.host.entries.length - MAX_HISTORY_WINDOW_ENTRIES;
238
349
  if (removeCount <= 0)
239
350
  return;
240
351
  const targetRemoveCount = Math.max(removeCount, this.host.entries.length - HISTORY_WINDOW_TARGET_ENTRIES);
241
- const removedEntryIds = edge === "top"
242
- ? this.host.entries.slice(0, targetRemoveCount).map((entry) => entry.id)
243
- : this.host.entries.slice(Math.max(0, this.host.entries.length - targetRemoveCount)).map((entry) => entry.id);
244
- const removedLineCount = this.measuredLineCountForEntries(removedEntryIds);
352
+ const removedLineCount = edge === "top"
353
+ ? this.measuredLineCountForEntries(this.host.entries.slice(0, targetRemoveCount).map((entry) => entry.id))
354
+ : 0;
245
355
  const removed = edge === "top"
246
356
  ? this.host.entries.splice(0, targetRemoveCount)
247
357
  : this.host.entries.splice(Math.max(0, this.host.entries.length - targetRemoveCount), targetRemoveCount);
@@ -266,18 +376,50 @@ export class AppSessionEventController {
266
376
  if (entryId === entry.id)
267
377
  this.toolEntryIdsByCallId.delete(toolCallId);
268
378
  }
379
+ for (const [contentIndex, toolCallId] of this.pendingToolCallIdsByContentIndex) {
380
+ if (this.toolEntryIdsByCallId.get(toolCallId) === undefined)
381
+ this.pendingToolCallIdsByContentIndex.delete(contentIndex);
382
+ }
383
+ }
384
+ removeToolEntryOrphan(entryId) {
385
+ const index = this.host.entries.findIndex((entry) => entry.id === entryId);
386
+ if (index === -1)
387
+ return;
388
+ const removed = this.host.entries.splice(index, 1)[0];
389
+ if (removed === undefined)
390
+ return;
391
+ this.forgetEntry(removed);
392
+ this.host.scheduleRender();
269
393
  }
270
394
  addSessionAbortedEntry() {
271
395
  this.finishCurrentThinkingEntry();
272
396
  this.clearCurrentAssistantState();
397
+ this.finalizeAbandonedToolEntries();
273
398
  this.addEntry({ id: createId("session-aborted"), kind: "session-aborted", text: "Session aborted." });
274
399
  }
400
+ // D.16: when a turn aborts (agent_end, error, or session abort) without the
401
+ // SDK emitting tool_execution_end for a tool that already started executing,
402
+ // mark any running tool entries as done so they don't render a perpetual
403
+ // spinner. The output is left as whatever partial was last observed.
404
+ finalizeAbandonedToolEntries() {
405
+ let touched = false;
406
+ for (const entry of this.host.entries) {
407
+ if (entry.kind === "tool" && entry.status === "running") {
408
+ entry.status = "done";
409
+ this.touchEntry(entry);
410
+ touched = true;
411
+ }
412
+ }
413
+ if (touched)
414
+ this.host.scheduleRender();
415
+ }
275
416
  handleMessageStart(message) {
276
417
  if (isRecord(message) && message.role === "custom") {
277
418
  this.addCustomMessageEntry(message);
278
419
  return;
279
420
  }
280
421
  if (isRecord(message) && message.role === "user") {
422
+ this.assistantMessageClosed = false;
281
423
  const text = renderUserMessageContent(message.content);
282
424
  if (!text)
283
425
  return;
@@ -293,17 +435,25 @@ export class AppSessionEventController {
293
435
  this.currentUserEntryId = entryId;
294
436
  return;
295
437
  }
296
- if (isRecord(message) && message.role === "assistant")
438
+ if (isRecord(message) && message.role === "assistant") {
439
+ this.assistantMessageClosed = false;
297
440
  this.clearCurrentAssistantState();
441
+ }
298
442
  }
299
443
  handleMessageEnd(message) {
300
444
  if (isRecord(message) && message.role === "user") {
301
445
  this.host.scheduleUserSessionEntryMetadataSync();
302
446
  }
447
+ if (isRecord(message) && message.role === "toolResult") {
448
+ this.finalizeToolResultMessage(message);
449
+ return;
450
+ }
303
451
  if (isRecord(message) && message.role === "assistant") {
452
+ this.renderAssistantToolCallsFromMessage(message);
304
453
  this.finishCurrentThinkingEntry();
305
454
  this.flushAssistantTextBuffer(true);
306
455
  this.clearCurrentAssistantState();
456
+ this.assistantMessageClosed = true;
307
457
  }
308
458
  }
309
459
  prepareToolWorkspaceMutation(toolCallId, toolName, args) {
@@ -317,6 +467,29 @@ export class AppSessionEventController {
317
467
  ...(preparation === undefined ? {} : { preparation }),
318
468
  });
319
469
  }
470
+ finalizeToolResultMessage(message) {
471
+ const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
472
+ const toolName = typeof message.toolName === "string" ? message.toolName : undefined;
473
+ const content = Array.isArray(message.content) ? message.content : [];
474
+ const details = message.details;
475
+ const isError = Boolean(message.isError);
476
+ if (toolCallId && toolName) {
477
+ this.recordToolWorkspaceMutation(toolCallId, toolName, details, isError);
478
+ if (this.currentUserEntryId)
479
+ this.host.scheduleUserSessionEntryMetadataSync();
480
+ this.host.observeSubagentsToolResult(toolName, details);
481
+ this.host.observeTodoToolResult(toolName, details, isError);
482
+ this.upsertToolEntry(toolCallId, {
483
+ toolName,
484
+ output: renderContent(content),
485
+ images: extractImageContents(content),
486
+ details,
487
+ isError,
488
+ status: "done",
489
+ });
490
+ }
491
+ this.host.setSessionActivity(this.host.runtime()?.session.isStreaming ? "running" : "idle");
492
+ }
320
493
  recordToolWorkspaceMutation(toolCallId, toolName, details, isError) {
321
494
  const prepared = this.toolMutationPreparationsByCallId.get(toolCallId);
322
495
  if (!prepared)
@@ -333,21 +506,68 @@ export class AppSessionEventController {
333
506
  return;
334
507
  this.host.recordWorkspaceMutationForUserEntry(prepared.userEntryId, mutation);
335
508
  }
336
- handleMessageUpdate(assistantEvent) {
509
+ handleMessageUpdate(event) {
510
+ const assistantEvent = event.assistantMessageEvent;
511
+ if (this.assistantMessageClosed && assistantEvent.type !== "done")
512
+ return;
513
+ this.assistantMessageClosed = false;
337
514
  switch (assistantEvent.type) {
515
+ case "text_start":
516
+ this.finishCurrentThinkingEntry();
517
+ this.flushAssistantTextBuffer(true);
518
+ this.finishCurrentAssistantTextBlock();
519
+ this.currentAssistantTextBlockEntryId = undefined;
520
+ this.currentAssistantTextBlockStartLength = undefined;
521
+ this.currentAssistantTextBlockContentIndex = assistantEvent.contentIndex;
522
+ break;
338
523
  case "text_delta":
339
524
  this.finishCurrentThinkingEntry();
340
525
  this.host.setSessionActivity("running");
341
- this.appendAssistantText(assistantEvent.delta);
526
+ {
527
+ const snapshotText = assistantTextSnapshotForContentIndex(event.message, assistantEvent.partial, assistantEvent.contentIndex);
528
+ if (snapshotText === undefined)
529
+ this.appendAssistantText(assistantEvent.delta, assistantEvent.contentIndex);
530
+ else
531
+ this.reconcileAssistantTextBlock(snapshotText, assistantEvent.contentIndex, { keepOpen: true });
532
+ }
533
+ break;
534
+ case "text_end":
535
+ this.finishCurrentThinkingEntry();
536
+ this.host.setSessionActivity("running");
537
+ this.reconcileAssistantTextBlock(assistantEvent.content, assistantEvent.contentIndex);
342
538
  break;
343
539
  case "thinking_delta":
540
+ if (assistantEvent.delta.length === 0)
541
+ return;
542
+ if (this.currentThinkingEntryId === undefined && this.hasStartedCurrentAssistantText())
543
+ return;
344
544
  this.host.setSessionActivity("thinking");
345
545
  this.appendThinkingText(assistantEvent.delta);
346
546
  break;
547
+ case "thinking_end":
548
+ if (this.currentThinkingEntryId === undefined && this.hasStartedCurrentAssistantText())
549
+ return;
550
+ if (assistantEvent.content.length === 0) {
551
+ this.finishCurrentThinkingEntry();
552
+ return;
553
+ }
554
+ this.host.setSessionActivity("thinking");
555
+ this.reconcileThinkingText(assistantEvent.content);
556
+ this.finishCurrentThinkingEntry();
557
+ break;
558
+ case "toolcall_start":
559
+ case "toolcall_delta":
560
+ this.handleToolCallStreamUpdate(assistantEvent.contentIndex, assistantEvent.partial);
561
+ break;
562
+ case "toolcall_end":
563
+ this.handleToolCallStreamUpdate(assistantEvent.contentIndex, assistantEvent.partial, assistantEvent.toolCall);
564
+ break;
347
565
  case "done":
566
+ this.renderAssistantToolCallsFromMessage(assistantEvent.message);
348
567
  this.finishCurrentThinkingEntry();
349
568
  this.flushAssistantTextBuffer(true);
350
569
  this.clearCurrentAssistantState();
570
+ this.assistantMessageClosed = true;
351
571
  this.host.setSessionActivity(this.host.runtime()?.session.isStreaming ? "running" : "idle");
352
572
  break;
353
573
  case "error":
@@ -360,8 +580,33 @@ export class AppSessionEventController {
360
580
  break;
361
581
  }
362
582
  }
363
- appendAssistantText(delta) {
364
- this.assistantTextBuffer += delta;
583
+ handleToolCallStreamUpdate(contentIndex, partial, finalToolCall) {
584
+ // B.6: once a tool call block has been finalised by toolcall_end, a later
585
+ // toolcall_delta for the same contentIndex is a stale re-stream of the
586
+ // partial args. Applying it would clobber the final, complete args with
587
+ // an incomplete version. Ignore it.
588
+ if (finalToolCall === undefined && contentIndex !== undefined && this.finalizedToolCallContentIndexes.has(contentIndex)) {
589
+ return;
590
+ }
591
+ this.finishCurrentThinkingEntry();
592
+ this.flushAssistantTextBuffer(true);
593
+ this.finishCurrentAssistantTextBlock();
594
+ this.currentAssistantEntryId = undefined;
595
+ this.currentAssistantTextBlockEntryId = undefined;
596
+ this.currentAssistantTextBlockStartLength = undefined;
597
+ this.currentAssistantTextBlockContentIndex = undefined;
598
+ this.host.setSessionActivity("running");
599
+ this.upsertPendingToolCall(finalToolCall ?? partialToolCallAt(partial, contentIndex), contentIndex);
600
+ if (finalToolCall !== undefined && contentIndex !== undefined)
601
+ this.finalizedToolCallContentIndexes.add(contentIndex);
602
+ }
603
+ appendAssistantText(delta, contentIndex) {
604
+ if (contentIndex !== undefined)
605
+ this.currentAssistantTextBlockContentIndex = contentIndex;
606
+ // C.11: providers may emit Windows-style CRLF (\r\n) line endings. The
607
+ // drain logic splits on "\n", which would leave a dangling "\r" at the
608
+ // end of each line and corrupt width/layout calculations. Normalise to LF.
609
+ this.assistantTextBuffer += delta.includes("\r") ? delta.replace(/\r\n?/gu, "\n") : delta;
365
610
  this.flushAssistantTextBuffer(false);
366
611
  }
367
612
  flushAssistantTextBuffer(final) {
@@ -374,9 +619,97 @@ export class AppSessionEventController {
374
619
  this.addEntry(entry);
375
620
  this.currentAssistantEntryId = entry.id;
376
621
  }
622
+ this.ensureAssistantTextBlockStarted(entry);
377
623
  entry.text += visibleText;
378
624
  this.touchEntry(entry);
379
625
  }
626
+ reconcileAssistantTextBlock(content, contentIndex, options = {}) {
627
+ this.flushAssistantTextBuffer(true);
628
+ const hasVisibleTextBeforeBlock = this.hasVisibleTextBeforeCurrentAssistantBlock();
629
+ // C.11: normalise CRLF in the final block content (see appendAssistantText).
630
+ const normalisedContent = content.includes("\r") ? content.replace(/\r\n?/gu, "\n") : content;
631
+ const visibleText = assistantStreamVisibleTextForCompleteBlock(normalisedContent, hasVisibleTextBeforeBlock);
632
+ // B.5: a late text_end for a block that was already finished (it is not the
633
+ // currently-streaming block and is already recorded) must not clobber the
634
+ // current block or duplicate the early block's text.
635
+ if (contentIndex !== undefined
636
+ && this.currentAssistantTextBlockContentIndex !== undefined
637
+ && contentIndex !== this.currentAssistantTextBlockContentIndex
638
+ && this.assistantTextBlocksByContentIndex.has(contentIndex))
639
+ return;
640
+ if (this.currentAssistantTextBlockEntryId === undefined
641
+ && contentIndex !== undefined
642
+ && this.assistantTextBlocksByContentIndex.get(contentIndex) === visibleText)
643
+ return;
644
+ // A.1: a text_end carrying empty content (provider content-filtering /
645
+ // truncation quirk) must not wipe assistant text already rendered to the
646
+ // user. Only finalize the block state, leaving any committed text intact.
647
+ if (!visibleText)
648
+ return;
649
+ let entry = this.currentAssistantTextBlockEntryId ? this.findEntry(this.currentAssistantTextBlockEntryId) : undefined;
650
+ if (!entry || entry.kind !== "assistant") {
651
+ entry = this.currentAssistantEntryId ? this.findEntry(this.currentAssistantEntryId) : undefined;
652
+ }
653
+ if (!entry || entry.kind !== "assistant") {
654
+ if (!visibleText)
655
+ return;
656
+ entry = { id: createId("assistant"), kind: "assistant", text: "" };
657
+ this.addEntry(entry);
658
+ this.currentAssistantEntryId = entry.id;
659
+ }
660
+ const startLength = this.currentAssistantTextBlockEntryId === entry.id
661
+ ? Math.min(this.currentAssistantTextBlockStartLength ?? entry.text.length, entry.text.length)
662
+ : entry.text.length;
663
+ const currentBlockText = entry.text.slice(startLength);
664
+ // C.10: when the final block content is a strict prefix of what was
665
+ // already streamed to the user (provider truncation / retroactive
666
+ // content filtering), preserve the longer streamed text rather than
667
+ // silently retracting already-rendered content. Same principle as A.1.
668
+ const preserveStreamedText = visibleText.length < currentBlockText.length
669
+ && currentBlockText.startsWith(visibleText);
670
+ if (!preserveStreamedText && currentBlockText !== visibleText) {
671
+ entry.text = `${entry.text.slice(0, startLength)}${visibleText}`;
672
+ this.touchEntry(entry);
673
+ }
674
+ this.currentAssistantEntryId = entry.id;
675
+ if (contentIndex !== undefined)
676
+ this.assistantTextBlocksByContentIndex.set(contentIndex, visibleText);
677
+ if (options.keepOpen) {
678
+ this.currentAssistantTextBlockEntryId = entry.id;
679
+ this.currentAssistantTextBlockStartLength = startLength;
680
+ this.currentAssistantTextBlockContentIndex = contentIndex;
681
+ }
682
+ else {
683
+ this.currentAssistantTextBlockEntryId = undefined;
684
+ this.currentAssistantTextBlockStartLength = undefined;
685
+ this.currentAssistantTextBlockContentIndex = undefined;
686
+ }
687
+ this.assistantTextBuffer = "";
688
+ }
689
+ ensureAssistantTextBlockStarted(entry) {
690
+ if (this.currentAssistantTextBlockEntryId === entry.id && this.currentAssistantTextBlockStartLength !== undefined)
691
+ return;
692
+ this.currentAssistantTextBlockEntryId = entry.id;
693
+ this.currentAssistantTextBlockStartLength = entry.text.length;
694
+ }
695
+ finishCurrentAssistantTextBlock() {
696
+ if (this.currentAssistantTextBlockContentIndex !== undefined && this.currentAssistantTextBlockEntryId !== undefined) {
697
+ const entry = this.findEntry(this.currentAssistantTextBlockEntryId);
698
+ if (entry?.kind === "assistant") {
699
+ const startLength = Math.min(this.currentAssistantTextBlockStartLength ?? entry.text.length, entry.text.length);
700
+ this.assistantTextBlocksByContentIndex.set(this.currentAssistantTextBlockContentIndex, entry.text.slice(startLength));
701
+ }
702
+ }
703
+ this.currentAssistantTextBlockEntryId = undefined;
704
+ this.currentAssistantTextBlockStartLength = undefined;
705
+ this.currentAssistantTextBlockContentIndex = undefined;
706
+ }
707
+ hasVisibleTextBeforeCurrentAssistantBlock() {
708
+ const entry = this.currentAssistantTextBlockEntryId ? this.findEntry(this.currentAssistantTextBlockEntryId) : undefined;
709
+ if (entry?.kind !== "assistant")
710
+ return this.hasVisibleAssistantText("");
711
+ return (this.currentAssistantTextBlockStartLength ?? 0) > 0;
712
+ }
380
713
  drainAssistantTextBuffer(final) {
381
714
  let visibleText = "";
382
715
  for (;;) {
@@ -409,13 +742,34 @@ export class AppSessionEventController {
409
742
  const entry = this.currentAssistantEntryId ? this.findEntry(this.currentAssistantEntryId) : undefined;
410
743
  return entry?.kind === "assistant" && entry.text.length > 0;
411
744
  }
745
+ hasStartedCurrentAssistantText() {
746
+ if (this.assistantTextBuffer.length > 0)
747
+ return true;
748
+ const entry = this.currentAssistantEntryId ? this.findEntry(this.currentAssistantEntryId) : undefined;
749
+ return entry?.kind === "assistant" && entry.text.length > 0;
750
+ }
412
751
  appendThinkingText(delta) {
413
- let entry = this.currentThinkingEntryId ? this.findEntry(this.currentThinkingEntryId) : undefined;
752
+ let entry = this.currentThinkingEntryId
753
+ ? this.findEntry(this.currentThinkingEntryId)
754
+ : undefined;
414
755
  if (!entry || entry.kind !== "thinking") {
415
- entry = { id: createId("thinking"), kind: "thinking", text: "", expanded: false, status: "running" };
756
+ const level = this.currentThinkingLevel();
757
+ entry = {
758
+ id: createId("thinking"),
759
+ kind: "thinking",
760
+ text: "",
761
+ expanded: this.host.toolDefaultExpanded(THINKING_TOOL_NAME),
762
+ ...(level === undefined ? {} : { level }),
763
+ status: "running",
764
+ };
416
765
  this.addEntry(entry);
417
766
  this.currentThinkingEntryId = entry.id;
418
767
  }
768
+ const level = this.currentThinkingLevel();
769
+ if (level === undefined)
770
+ delete entry.level;
771
+ else
772
+ entry.level = level;
419
773
  entry.status = "running";
420
774
  entry.text += delta;
421
775
  this.touchEntry(entry);
@@ -428,13 +782,86 @@ export class AppSessionEventController {
428
782
  }
429
783
  this.currentThinkingEntryId = undefined;
430
784
  }
785
+ reconcileThinkingText(content) {
786
+ let entry = this.currentThinkingEntryId
787
+ ? this.findEntry(this.currentThinkingEntryId)
788
+ : undefined;
789
+ if (!entry || entry.kind !== "thinking") {
790
+ const level = this.currentThinkingLevel();
791
+ entry = {
792
+ id: createId("thinking"),
793
+ kind: "thinking",
794
+ text: "",
795
+ expanded: this.host.toolDefaultExpanded(THINKING_TOOL_NAME),
796
+ ...(level === undefined ? {} : { level }),
797
+ status: "running",
798
+ };
799
+ this.addEntry(entry);
800
+ this.currentThinkingEntryId = entry.id;
801
+ }
802
+ const level = this.currentThinkingLevel();
803
+ if (entry.text !== content || entry.status !== "running" || entry.level !== level) {
804
+ entry.text = content;
805
+ if (level === undefined)
806
+ delete entry.level;
807
+ else
808
+ entry.level = level;
809
+ entry.status = "running";
810
+ this.touchEntry(entry);
811
+ }
812
+ }
813
+ currentThinkingLevel() {
814
+ return this.host.runtime()?.session.thinkingLevel;
815
+ }
816
+ renderAssistantToolCallsFromMessage(message) {
817
+ if (!isRecord(message) || !Array.isArray(message.content))
818
+ return;
819
+ for (let contentIndex = 0; contentIndex < message.content.length; contentIndex += 1) {
820
+ const block = message.content[contentIndex];
821
+ if (isRecord(block) && block.type === "toolCall")
822
+ this.upsertPendingToolCall(block, contentIndex);
823
+ }
824
+ }
825
+ upsertPendingToolCall(toolCall, contentIndex) {
826
+ const existingCallId = contentIndex === undefined ? undefined : this.pendingToolCallIdsByContentIndex.get(contentIndex);
827
+ const existingEntryId = existingCallId === undefined ? undefined : this.toolEntryIdsByCallId.get(existingCallId);
828
+ const toolCallId = isRecord(toolCall) && typeof toolCall.id === "string" ? toolCall.id : existingCallId ?? createId("tool-call");
829
+ if (contentIndex !== undefined)
830
+ this.pendingToolCallIdsByContentIndex.set(contentIndex, toolCallId);
831
+ if (existingCallId !== undefined && existingCallId !== toolCallId && existingEntryId !== undefined) {
832
+ this.toolEntryIdsByCallId.delete(existingCallId);
833
+ // B.7: if tool_execution_start arrived before toolcall_end finalised the
834
+ // call id, an orphan tool entry may already be registered under
835
+ // toolCallId. Reuse the pending entry and drop the orphan so a single
836
+ // logical tool call renders as a single tool entry.
837
+ const orphanEntryId = this.toolEntryIdsByCallId.get(toolCallId);
838
+ this.toolEntryIdsByCallId.set(toolCallId, existingEntryId);
839
+ const existing = this.findEntry(existingEntryId);
840
+ if (existing?.kind === "tool")
841
+ existing.toolCallId = toolCallId;
842
+ if (orphanEntryId !== undefined && orphanEntryId !== existingEntryId) {
843
+ this.removeToolEntryOrphan(orphanEntryId);
844
+ }
845
+ }
846
+ const toolName = isRecord(toolCall) && typeof toolCall.name === "string" ? toolCall.name : undefined;
847
+ const argsText = isRecord(toolCall) && "arguments" in toolCall ? stringifyUnknown(toolCall.arguments) : undefined;
848
+ this.upsertToolEntry(toolCallId, {
849
+ ...(toolName === undefined ? {} : { toolName }),
850
+ ...(argsText === undefined ? {} : { argsText }),
851
+ status: "running",
852
+ });
853
+ }
431
854
  upsertToolEntry(toolCallId, update) {
432
855
  const existingId = this.toolEntryIdsByCallId.get(toolCallId);
433
856
  const existing = existingId ? this.findEntry(existingId) : undefined;
434
857
  if (existing?.kind === "tool") {
435
858
  existing.toolName = update.toolName ?? existing.toolName;
436
859
  existing.argsText = update.argsText ?? existing.argsText;
437
- existing.output = update.output ?? existing.output;
860
+ // A.2: an update carrying empty output (e.g. a partial
861
+ // tool_execution_update with content:[]) must not erase output already
862
+ // shown to the user; only non-empty output replaces the existing value.
863
+ const nextOutput = update.output ?? existing.output;
864
+ existing.output = nextOutput.length > 0 ? nextOutput : (existing.output ?? "");
438
865
  if ("images" in update)
439
866
  existing.images = update.images;
440
867
  if ("details" in update)
@@ -462,10 +889,52 @@ export class AppSessionEventController {
462
889
  }
463
890
  clearCurrentAssistantState() {
464
891
  this.currentAssistantEntryId = undefined;
892
+ this.currentAssistantTextBlockEntryId = undefined;
893
+ this.currentAssistantTextBlockStartLength = undefined;
894
+ this.currentAssistantTextBlockContentIndex = undefined;
465
895
  this.currentThinkingEntryId = undefined;
466
896
  this.assistantTextBuffer = "";
897
+ this.assistantTextBlocksByContentIndex.clear();
898
+ this.finalizedToolCallContentIndexes.clear();
899
+ this.pendingToolCallIdsByContentIndex.clear();
467
900
  }
468
901
  }
902
+ function partialToolCallAt(partial, contentIndex) {
903
+ if (!isRecord(partial) || !Array.isArray(partial.content))
904
+ return undefined;
905
+ const block = partial.content[contentIndex];
906
+ return isRecord(block) && block.type === "toolCall" ? block : undefined;
907
+ }
908
+ function assistantTextSnapshotForContentIndex(message, partial, contentIndex) {
909
+ if (contentIndex === undefined)
910
+ return undefined;
911
+ return assistantTextContentAt(message, contentIndex) ?? assistantTextContentAt(partial, contentIndex);
912
+ }
913
+ function assistantTextContentAt(value, contentIndex) {
914
+ if (!isRecord(value) || !Array.isArray(value.content))
915
+ return undefined;
916
+ const block = value.content[contentIndex];
917
+ return isRecord(block) && block.type === "text" && typeof block.text === "string" ? block.text : undefined;
918
+ }
919
+ function assistantStreamVisibleTextForCompleteBlock(text, hasVisibleTextBeforeBlock) {
920
+ let buffer = text;
921
+ let visibleText = "";
922
+ for (;;) {
923
+ const newlineIndex = buffer.indexOf("\n");
924
+ if (newlineIndex === -1)
925
+ break;
926
+ const line = buffer.slice(0, newlineIndex);
927
+ buffer = buffer.slice(newlineIndex + 1);
928
+ if (shouldDropAssistantStreamLine(line, hasVisibleTextBeforeBlock || visibleText.length > 0))
929
+ continue;
930
+ visibleText += `${line}\n`;
931
+ }
932
+ if (!buffer)
933
+ return visibleText;
934
+ if (shouldHoldAssistantStreamTail(buffer, hasVisibleTextBeforeBlock || visibleText.length > 0))
935
+ return visibleText;
936
+ return visibleText + buffer;
937
+ }
469
938
  function shouldDropAssistantStreamLine(line, hasVisibleText) {
470
939
  if (line.trim().length === 0 && !hasVisibleText)
471
940
  return true;