pi-ui-extend 0.1.70 → 0.1.72

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 (58) hide show
  1. package/dist/app/app.d.ts +2 -0
  2. package/dist/app/app.js +39 -1
  3. package/dist/app/commands/command-host.d.ts +7 -0
  4. package/dist/app/commands/command-host.js +10 -1
  5. package/dist/app/commands/command-model-actions.d.ts +1 -1
  6. package/dist/app/commands/command-model-actions.js +36 -2
  7. package/dist/app/commands/command-navigation-actions.d.ts +1 -1
  8. package/dist/app/commands/command-navigation-actions.js +40 -6
  9. package/dist/app/commands/command-session-actions.d.ts +1 -1
  10. package/dist/app/commands/command-session-actions.js +56 -0
  11. package/dist/app/commands/shell-controller.d.ts +9 -2
  12. package/dist/app/commands/shell-controller.js +32 -21
  13. package/dist/app/extensions/extension-actions-controller.d.ts +3 -0
  14. package/dist/app/extensions/extension-actions-controller.js +25 -7
  15. package/dist/app/extensions/extension-ui-controller.d.ts +5 -1
  16. package/dist/app/extensions/extension-ui-controller.js +104 -64
  17. package/dist/app/icons.d.ts +1 -0
  18. package/dist/app/icons.js +2 -0
  19. package/dist/app/input/input-action-controller.d.ts +4 -1
  20. package/dist/app/input/input-action-controller.js +68 -27
  21. package/dist/app/input/input-controller.d.ts +3 -0
  22. package/dist/app/input/input-controller.js +45 -1
  23. package/dist/app/input/input-paste-handler.d.ts +3 -0
  24. package/dist/app/input/input-paste-handler.js +21 -12
  25. package/dist/app/input/prompt-enhancer-controller.d.ts +1 -0
  26. package/dist/app/input/prompt-enhancer-controller.js +11 -5
  27. package/dist/app/input/voice-controller.d.ts +4 -0
  28. package/dist/app/input/voice-controller.js +76 -35
  29. package/dist/app/popup/popup-action-controller.d.ts +3 -0
  30. package/dist/app/popup/popup-action-controller.js +43 -8
  31. package/dist/app/rendering/render-controller.js +1 -0
  32. package/dist/app/rendering/status-line-renderer.d.ts +3 -1
  33. package/dist/app/rendering/status-line-renderer.js +11 -0
  34. package/dist/app/screen/mouse-controller.d.ts +5 -1
  35. package/dist/app/screen/mouse-controller.js +14 -2
  36. package/dist/app/session/queued-message-controller.d.ts +5 -4
  37. package/dist/app/session/queued-message-controller.js +32 -27
  38. package/dist/app/session/request-history.d.ts +2 -0
  39. package/dist/app/session/request-history.js +22 -15
  40. package/dist/app/session/session-event-controller.js +28 -8
  41. package/dist/app/session/session-history.js +8 -2
  42. package/dist/app/session/session-lifecycle-controller.d.ts +7 -0
  43. package/dist/app/session/session-lifecycle-controller.js +91 -19
  44. package/dist/app/session/tabs-controller.d.ts +26 -0
  45. package/dist/app/session/tabs-controller.js +469 -96
  46. package/dist/app/terminal/terminal-controller.d.ts +4 -0
  47. package/dist/app/terminal/terminal-controller.js +38 -11
  48. package/dist/app/types.d.ts +10 -0
  49. package/dist/app/workspace/workspace-actions-controller.d.ts +2 -0
  50. package/dist/app/workspace/workspace-actions-controller.js +37 -9
  51. package/dist/input-editor.d.ts +2 -0
  52. package/dist/input-editor.js +17 -0
  53. package/docs/concurrency.md +76 -0
  54. package/external/pi-tools-suite/package.json +3 -3
  55. package/external/pi-tools-suite/src/dcp/state-persistence.ts +29 -14
  56. package/external/pi-tools-suite/src/todo/index.ts +2 -2
  57. package/external/pi-tools-suite/src/tool-descriptions.ts +1 -1
  58. package/package.json +4 -4
@@ -2,6 +2,7 @@ import type { AgentSessionRuntime } from "@earendil-works/pi-coding-agent";
2
2
  export type AppTerminalControllerHost = {
3
3
  isRunning(): boolean;
4
4
  setRunning(running: boolean): void;
5
+ cancelPendingTabLifecycle(): void;
5
6
  runtime(): AgentSessionRuntime | undefined;
6
7
  saveInputStateForQuit(): Promise<void>;
7
8
  disposeInactiveRuntimesForQuit(): Promise<void>;
@@ -26,8 +27,10 @@ export declare class AppTerminalController {
26
27
  private stopPromise;
27
28
  private keyboardProtocolNegotiationBuffer;
28
29
  private keyboardProtocolBufferFlushTimer;
30
+ private inputDecoder;
29
31
  private kittyProtocolActive;
30
32
  private modifyOtherKeysActive;
33
+ private shutdownDeadlineAt;
31
34
  private readonly enterInteractiveSequence;
32
35
  private readonly exitInteractiveSequence;
33
36
  constructor(host: AppTerminalControllerHost);
@@ -41,6 +44,7 @@ export declare class AppTerminalController {
41
44
  private suspendForInteractiveProcess;
42
45
  private resumeAfterInteractiveProcess;
43
46
  private stopInternal;
47
+ private runBestEffort;
44
48
  private scheduleForcedProcessExit;
45
49
  private readonly onResize;
46
50
  private readonly onInputData;
@@ -1,3 +1,4 @@
1
+ import { StringDecoder } from "node:string_decoder";
1
2
  import { ANSI_RESET } from "../../theme.js";
2
3
  import { DISABLE_BRACKETED_PASTE, DISABLE_TERMINAL_KEY_REPORTING, DISABLE_TERMINAL_WRAP, CLEAR_TERMINAL, ENABLE_BRACKETED_PASTE, ENABLE_TERMINAL_MODIFY_OTHER_KEYS, ENABLE_TERMINAL_KEY_REPORTING, ENABLE_TERMINAL_WRAP, HIDE_CURSOR, RESET_TERMINAL_VIEWPORT_STATE, RUNTIME_DISPOSE_GRACE_MS, SHOW_CURSOR, } from "../constants.js";
3
4
  export class AppTerminalController {
@@ -7,8 +8,10 @@ export class AppTerminalController {
7
8
  stopPromise;
8
9
  keyboardProtocolNegotiationBuffer = "";
9
10
  keyboardProtocolBufferFlushTimer;
11
+ inputDecoder = new StringDecoder("utf8");
10
12
  kittyProtocolActive = false;
11
13
  modifyOtherKeysActive = false;
14
+ shutdownDeadlineAt;
12
15
  enterInteractiveSequence = `${ANSI_RESET}${RESET_TERMINAL_VIEWPORT_STATE}${CLEAR_TERMINAL}\x1b[?1049h${RESET_TERMINAL_VIEWPORT_STATE}${CLEAR_TERMINAL}${ENABLE_TERMINAL_KEY_REPORTING}${ENABLE_BRACKETED_PASTE}${DISABLE_TERMINAL_WRAP}\x1b[?1002h\x1b[?1006h${HIDE_CURSOR}`;
13
16
  exitInteractiveSequence = `${ANSI_RESET}${RESET_TERMINAL_VIEWPORT_STATE}${DISABLE_TERMINAL_KEY_REPORTING}${DISABLE_BRACKETED_PASTE}${ENABLE_TERMINAL_WRAP}\x1b[?1006l\x1b[?1002l\x1b[?1049l${SHOW_CURSOR}`;
14
17
  constructor(host) {
@@ -38,10 +41,13 @@ export class AppTerminalController {
38
41
  await this.stopPromise;
39
42
  }
40
43
  async disposeRuntimeForQuit(runtime) {
44
+ const graceMs = this.shutdownDeadlineAt === undefined
45
+ ? RUNTIME_DISPOSE_GRACE_MS
46
+ : Math.max(0, this.shutdownDeadlineAt - Date.now());
41
47
  let timeout;
42
48
  const dispose = runtime.dispose().then(() => "disposed", () => "failed");
43
49
  const timeoutPromise = new Promise((resolveTimeout) => {
44
- timeout = setTimeout(() => resolveTimeout("timeout"), RUNTIME_DISPOSE_GRACE_MS);
50
+ timeout = setTimeout(() => resolveTimeout("timeout"), graceMs);
45
51
  });
46
52
  const result = await Promise.race([dispose, timeoutPromise]);
47
53
  if (timeout)
@@ -106,30 +112,42 @@ export class AppTerminalController {
106
112
  async stopInternal() {
107
113
  if (!this.host.isRunning())
108
114
  return;
109
- this.clearKeyboardProtocolNegotiationBuffer();
110
115
  this.host.setRunning(false);
116
+ this.host.cancelPendingTabLifecycle();
117
+ this.clearKeyboardProtocolNegotiationBuffer();
118
+ this.inputDecoder = new StringDecoder("utf8");
119
+ process.stdin.off("data", this.onInputData);
120
+ process.stdin.pause();
121
+ process.stdout.off("resize", this.onResize);
122
+ process.off("exit", this.restoreTerminal);
111
123
  this.host.closeSdkMenuForStop();
112
124
  this.host.clearToastTimers();
113
125
  this.host.stopBlinking();
114
126
  this.host.stopSubagentsPolling();
115
127
  this.host.stopModelUsagePolling();
116
- await this.host.stopVoiceInput();
117
128
  this.host.stopAutocomplete();
118
129
  this.host.stopShellCommand();
119
- process.stdin.off("data", this.onInputData);
120
- process.stdin.pause();
121
- process.stdout.off("resize", this.onResize);
122
- process.off("exit", this.restoreTerminal);
123
130
  this.host.unsubscribeSession();
124
131
  this.host.clearExtensionWidgets();
125
- await this.host.saveInputStateForQuit();
126
132
  this.restoreTerminal();
133
+ this.shutdownDeadlineAt = Date.now() + RUNTIME_DISPOSE_GRACE_MS;
127
134
  this.scheduleForcedProcessExit();
128
- await this.host.disposeInactiveRuntimesForQuit();
135
+ await this.runBestEffort(() => this.host.stopVoiceInput());
136
+ await this.runBestEffort(() => this.host.saveInputStateForQuit());
137
+ await this.runBestEffort(() => this.host.disposeInactiveRuntimesForQuit());
129
138
  const runtime = this.host.runtime();
130
139
  if (runtime)
131
140
  await this.disposeRuntimeForQuit(runtime);
132
141
  }
142
+ async runBestEffort(task) {
143
+ try {
144
+ await task();
145
+ }
146
+ catch {
147
+ // Shutdown continues so terminal restoration and runtime disposal are not
148
+ // skipped when one independent cleanup step fails.
149
+ }
150
+ }
133
151
  scheduleForcedProcessExit() {
134
152
  const exitCode = typeof process.exitCode === "number" ? process.exitCode : 0;
135
153
  const timer = setTimeout(() => {
@@ -138,16 +156,24 @@ export class AppTerminalController {
138
156
  timer.unref();
139
157
  }
140
158
  onResize = () => {
159
+ if (!this.terminalEnabled || this.interactiveSuspended || !this.host.isRunning())
160
+ return;
141
161
  this.host.resetRenderOutputBuffer();
142
162
  this.host.render();
143
163
  };
144
164
  onInputData = (chunk) => {
145
- const input = this.filterKeyboardProtocolNegotiationInput(chunk.toString("utf8"));
165
+ if (!this.terminalEnabled || this.interactiveSuspended || !this.host.isRunning())
166
+ return;
167
+ const decoded = this.inputDecoder.write(chunk);
168
+ if (!decoded)
169
+ return;
170
+ const input = this.filterKeyboardProtocolNegotiationInput(decoded);
146
171
  if (input)
147
172
  this.host.handleInputChunk(Buffer.from(input, "utf8"));
148
173
  };
149
174
  beginKeyboardProtocolNegotiation() {
150
175
  this.clearKeyboardProtocolNegotiationBuffer();
176
+ this.inputDecoder = new StringDecoder("utf8");
151
177
  this.kittyProtocolActive = false;
152
178
  this.modifyOtherKeysActive = false;
153
179
  }
@@ -198,8 +224,9 @@ export class AppTerminalController {
198
224
  this.keyboardProtocolBufferFlushTimer = undefined;
199
225
  const buffered = this.keyboardProtocolNegotiationBuffer;
200
226
  this.keyboardProtocolNegotiationBuffer = "";
201
- if (buffered)
227
+ if (buffered && this.terminalEnabled && !this.interactiveSuspended && this.host.isRunning()) {
202
228
  this.host.handleInputChunk(Buffer.from(buffered, "utf8"));
229
+ }
203
230
  }, 150);
204
231
  }
205
232
  clearKeyboardProtocolNegotiationBuffer() {
@@ -279,6 +279,7 @@ export type StatusLineLayout = {
279
279
  quickScrollDownWidget?: StatusQuickScrollWidgetLayout;
280
280
  userJumpWidget?: StatusUserJumpWidgetLayout;
281
281
  draftQueueWidget?: StatusDraftQueueWidgetLayout;
282
+ internalClipboardWidget?: StatusInternalClipboardWidgetLayout;
282
283
  thinkingExpandWidget?: StatusThinkingExpandWidgetLayout;
283
284
  compactToolsWidget?: StatusCompactToolsWidgetLayout;
284
285
  terminalBellSoundWidget?: StatusTerminalBellSoundWidgetLayout;
@@ -297,6 +298,10 @@ export type StatusDraftQueueWidgetLayout = {
297
298
  startColumn: number;
298
299
  endColumn: number;
299
300
  };
301
+ export type StatusInternalClipboardWidgetLayout = {
302
+ startColumn: number;
303
+ endColumn: number;
304
+ };
300
305
  export type StatusThinkingExpandWidgetLayout = {
301
306
  startColumn: number;
302
307
  endColumn: number;
@@ -594,6 +599,11 @@ export type StatusDraftQueueTarget = {
594
599
  startColumn: number;
595
600
  endColumn: number;
596
601
  };
602
+ export type StatusInternalClipboardTarget = {
603
+ row: number;
604
+ startColumn: number;
605
+ endColumn: number;
606
+ };
597
607
  export type StatusThinkingExpandTarget = {
598
608
  row: number;
599
609
  startColumn: number;
@@ -35,6 +35,8 @@ export declare class AppWorkspaceActionsController {
35
35
  forkFromUserMessage(entryId: string): Promise<void>;
36
36
  forkFromUserMessageInNewTab(entryId: string): Promise<void>;
37
37
  undoChangesFromUserMessage(entryId: string): Promise<void>;
38
+ private isRuntimeActive;
39
+ private isSessionActive;
38
40
  private workspaceMutationPlanFromSessionEntry;
39
41
  private workspaceMutationPlanForSingleEntry;
40
42
  private findUserEntryBySessionEntryId;
@@ -28,10 +28,13 @@ export class AppWorkspaceActionsController {
28
28
  this.host.touchEntry(entry);
29
29
  }
30
30
  scheduleUserSessionEntryMetadataSync() {
31
+ const runtime = this.host.runtime();
32
+ const session = runtime?.session;
31
33
  const timer = setTimeout(() => {
34
+ if (!runtime || !session || !this.isSessionActive(runtime, session))
35
+ return;
32
36
  this.syncUserSessionEntryMetadata();
33
- if (this.host.isRunning())
34
- this.host.render();
37
+ this.host.render();
35
38
  }, 0);
36
39
  timer.unref?.();
37
40
  }
@@ -48,13 +51,20 @@ export class AppWorkspaceActionsController {
48
51
  });
49
52
  if (branchUserEntries.length === 0)
50
53
  return;
54
+ const branchIndexesByEntryId = new Map(branchUserEntries.map((entry, index) => [entry.entryId, index]));
51
55
  const loadedEntries = this.host.allEntries?.() ?? this.host.entries;
52
56
  let branchIndex = 0;
53
57
  for (const entry of loadedEntries) {
54
58
  if (entry.kind !== "user")
55
59
  continue;
56
- const sessionEntry = branchUserEntries[branchIndex];
57
- branchIndex += 1;
60
+ const existingBranchIndex = entry.sessionEntryId === undefined
61
+ ? undefined
62
+ : branchIndexesByEntryId.get(entry.sessionEntryId);
63
+ const resolvedBranchIndex = existingBranchIndex !== undefined && existingBranchIndex >= branchIndex
64
+ ? existingBranchIndex
65
+ : branchIndex;
66
+ const sessionEntry = branchUserEntries[resolvedBranchIndex];
67
+ branchIndex = resolvedBranchIndex + 1;
58
68
  if (!sessionEntry)
59
69
  break;
60
70
  let changed = false;
@@ -76,12 +86,15 @@ export class AppWorkspaceActionsController {
76
86
  }
77
87
  }
78
88
  async copyUserMessage(entryId) {
89
+ const runtime = this.host.runtime();
79
90
  const entry = this.host.findUserEntry(entryId);
80
91
  if (!entry)
81
92
  throw new Error("User message is no longer available");
82
93
  await copyTextToClipboard(entry.text);
94
+ if (!runtime || !this.isRuntimeActive(runtime))
95
+ return;
83
96
  this.host.showToast("Message copied", "success");
84
- this.host.setSessionStatus(this.host.runtime()?.session);
97
+ this.host.setSessionStatus(runtime.session);
85
98
  }
86
99
  async forkFromUserMessage(entryId) {
87
100
  const runtime = this.getIdleRuntimeForAction("fork");
@@ -96,7 +109,11 @@ export class AppWorkspaceActionsController {
96
109
  this.host.setStatus("forking session");
97
110
  this.host.render();
98
111
  await this.host.awaitCurrentSessionExtensions(runtime);
112
+ if (!this.isRuntimeActive(runtime))
113
+ return;
99
114
  const result = await runtime.fork(sessionEntryId);
115
+ if (!this.isRuntimeActive(runtime))
116
+ return;
100
117
  if (result.cancelled) {
101
118
  this.host.addEntry({ id: createId("system"), kind: "system", text: "Fork cancelled." });
102
119
  this.host.setSessionStatus(runtime.session);
@@ -135,7 +152,10 @@ export class AppWorkspaceActionsController {
135
152
  const mutationPlan = this.workspaceMutationPlanFromSessionEntry(sessionEntryId);
136
153
  this.host.setStatus("truncating session");
137
154
  this.host.render();
138
- const result = await runtime.session.navigateTree(sessionEntryId);
155
+ const session = runtime.session;
156
+ const result = await session.navigateTree(sessionEntryId);
157
+ if (!this.isSessionActive(runtime, session))
158
+ return;
139
159
  if (result.aborted) {
140
160
  this.host.showToast("Undo cancelled", "info");
141
161
  this.host.setSessionStatus(runtime.session);
@@ -146,15 +166,14 @@ export class AppWorkspaceActionsController {
146
166
  this.host.setSessionStatus(runtime.session);
147
167
  return;
148
168
  }
149
- this.host.resetSessionView();
150
- this.host.loadSessionHistory();
151
- this.host.setInput(result.editorText ?? entry.text);
152
169
  let revertSummary = "No recorded file mutations were found for the removed branch.";
153
170
  let revertToastKind = "success";
154
171
  if (mutationPlan.mutations.length > 0) {
155
172
  this.host.setStatus("reverting recorded commands");
156
173
  this.host.render();
157
174
  const reverted = await revertWorkspaceMutations(runtime.cwd, mutationPlan.mutations);
175
+ if (!this.isSessionActive(runtime, session))
176
+ return;
158
177
  if (reverted.ok) {
159
178
  revertSummary = `Reverted ${reverted.revertedChanges} command${reverted.revertedChanges === 1 ? "" : "s"} across ${reverted.changedFiles} file${reverted.changedFiles === 1 ? "" : "s"}.`;
160
179
  }
@@ -167,6 +186,9 @@ export class AppWorkspaceActionsController {
167
186
  revertSummary = `No recorded file mutations were available for ${mutationPlan.messagesWithoutLogs} removed message${mutationPlan.messagesWithoutLogs === 1 ? "" : "s"}.`;
168
187
  revertToastKind = "warning";
169
188
  }
189
+ this.host.resetSessionView();
190
+ this.host.loadSessionHistory();
191
+ this.host.setInput(result.editorText ?? entry.text);
170
192
  this.host.addEntry({
171
193
  id: createId("system"),
172
194
  kind: "system",
@@ -175,6 +197,12 @@ export class AppWorkspaceActionsController {
175
197
  this.host.setSessionStatus(runtime.session);
176
198
  this.host.showToast(revertToastKind === "success" ? "Changes undone" : "Session rewound with revert warnings", revertToastKind);
177
199
  }
200
+ isRuntimeActive(runtime) {
201
+ return this.host.isRunning() && this.host.runtime() === runtime;
202
+ }
203
+ isSessionActive(runtime, session) {
204
+ return this.isRuntimeActive(runtime) && runtime.session === session;
205
+ }
178
206
  workspaceMutationPlanFromSessionEntry(entryId) {
179
207
  const runtime = this.host.runtime();
180
208
  if (!runtime)
@@ -100,6 +100,8 @@ export declare class InputEditor {
100
100
  get promptText(): string;
101
101
  setText(text: string, cursor?: number): void;
102
102
  setDraftState(state: InputEditorDraftState): void;
103
+ /** Restore a tab-owned draft without carrying undo or paste state across tabs. */
104
+ restoreDraftState(state: InputEditorDraftState): void;
103
105
  clear(): void;
104
106
  undo(): boolean;
105
107
  redo(): boolean;
@@ -114,6 +114,23 @@ export class InputEditor {
114
114
  this.clearSelection();
115
115
  });
116
116
  }
117
+ /** Restore a tab-owned draft without carrying undo or paste state across tabs. */
118
+ restoreDraftState(state) {
119
+ const before = this.captureHistorySnapshot();
120
+ this._text = state.text;
121
+ this._cursor = state.cursor;
122
+ this._attachments.length = 0;
123
+ this._attachments.push(...Array.from(state.attachments ?? [], cloneAttachment));
124
+ this._imageCounter = maxTagCounter(this._attachments, /^\[Image (\d+)/u);
125
+ this._pasteCounter = maxTagCounter(this._attachments, /^\[Pasted ~?(\d+)/u);
126
+ this._bracketedPasteDepth = 0;
127
+ this.clampCursor();
128
+ this.clearSelection();
129
+ this.clearScrollOffset();
130
+ this.clearHistory();
131
+ if (this.didContentChangeFrom(before))
132
+ this._contentVersion += 1;
133
+ }
117
134
  clear() {
118
135
  const hadContent = this._text.length > 0 || this._attachments.length > 0;
119
136
  this._text = "";
@@ -0,0 +1,76 @@
1
+ # Spec: Application concurrency and lifecycle safety
2
+
3
+ ## Type
4
+
5
+ Change
6
+
7
+ ## Goal
8
+
9
+ Keep tab, session, editor, extension UI, persistence, and shutdown work bound to
10
+ the state that started it, without blocking normal terminal interaction.
11
+
12
+ ## Scope
13
+
14
+ - Tab/session activation, replacement, close, fork, restore, and history loading.
15
+ - Input submission, queued messages, paste, voice, prompt enhancement, and editor restoration.
16
+ - Extension UI callbacks, persisted tab snapshots, terminal input decoding, and shutdown.
17
+
18
+ ## Non-goals
19
+
20
+ - Changing the persisted tabs or session JSONL formats.
21
+ - Making intentionally full-history SDK operations lazy when the SDK exposes only a synchronous API.
22
+ - Changing extension-facing UI semantics beyond preventing inactive scopes from editing the active tab.
23
+
24
+ ## Behavior
25
+
26
+ - An async continuation may mutate visible state only while its captured tab, runtime, session, and operation generation still own that state.
27
+ - A prompt captured in one tab is submitted or queued only for that tab's captured session. If ownership is lost before dispatch, the prompt is restored instead of rerouted.
28
+ - Deferred paste, voice, and extension UI completion target their originating tab or are discarded after that scope is removed.
29
+ - Restoring a tab draft does not enter another tab's undo history and cannot silently overwrite input typed after activation started.
30
+ - Tab lifecycle mutations are mutually exclusive. Failed activation rolls back or disposes newly created/displaced runtimes.
31
+ - Persisted tab snapshots are written in invocation order with unique temporary files.
32
+ - Replacement/history work is cancelled when runtime, session, tab, or generation changes.
33
+ - Terminal byte chunks preserve split UTF-8 sequences and batch printable-input rendering.
34
+ - Shutdown detaches input before awaiting cleanup and applies one bounded cleanup deadline before forced exit.
35
+
36
+ ## Invariants
37
+
38
+ - One runtime has at most one owning tab, and replacing that ownership disposes or explicitly transfers the displaced runtime.
39
+ - Only the active tab's scope can mutate the shared editor and visible conversation.
40
+ - Timers and subscriptions verify runtime ownership before mutation.
41
+ - No stale persistence snapshot can replace a newer completed snapshot.
42
+
43
+ ## Edge cases
44
+
45
+ - Switching tabs while draft persistence, session replacement, history loading, paste, voice, or custom UI is pending.
46
+ - Closing or forking while another tab lifecycle operation is pending.
47
+ - Paste terminators and Enter arriving in the same terminal chunk.
48
+ - Multibyte UTF-8 split across terminal chunks.
49
+ - Quit during startup tab restoration or stalled runtime disposal.
50
+
51
+ ## Related files
52
+
53
+ - `src/app/session/tabs-controller.ts`
54
+ - `src/app/session/session-lifecycle-controller.ts`
55
+ - `src/app/input/`
56
+ - `src/app/extensions/extension-ui-controller.ts`
57
+ - `src/app/terminal/terminal-controller.ts`
58
+ - `src/input-editor.ts`
59
+
60
+ ## Verification
61
+
62
+ - Deterministic deferred-promise tests for tab/session identity after every relevant `await`.
63
+ - Regression tests for cross-tab submit/paste/UI completion and non-history draft restoration.
64
+ - Chunked UTF-8 and large printable-input tests.
65
+ - `npm run check`, `npm run build:pix`, and `git diff --check`.
66
+
67
+ ## Risks / unknowns
68
+
69
+ - SDK session hydration and append APIs are synchronous; eliminating those stalls may require an upstream SDK API or worker/process boundary.
70
+ - Voice model inference may require a dedicated worker to become fully non-blocking.
71
+
72
+ ## Evidence
73
+
74
+ - Confirmed by the pre-change audit: shared editor/runtime state was read after asynchronous boundaries in the related files.
75
+ - Confirmed by regression tests: audit probes reproduced cross-tab submit, deferred paste, undo leakage, and render amplification before the fixes.
76
+ - Unknown: acceptable upstream design for asynchronous SDK session hydration.
@@ -43,9 +43,9 @@
43
43
  "vscode-languageserver-protocol": "^3.17.5"
44
44
  },
45
45
  "peerDependencies": {
46
- "@earendil-works/pi-ai": "0.81.0",
47
- "@earendil-works/pi-coding-agent": "0.81.0",
48
- "@earendil-works/pi-tui": "0.81.0",
46
+ "@earendil-works/pi-ai": "0.81.1",
47
+ "@earendil-works/pi-coding-agent": "0.81.1",
48
+ "@earendil-works/pi-tui": "0.81.1",
49
49
  "typebox": "*"
50
50
  },
51
51
  "devDependencies": {
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"
1
+ import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"
2
2
  import type { Dirent } from "node:fs"
3
3
  import { dirname, join } from "node:path"
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
@@ -7,6 +7,7 @@ import { hashSerializedState, serializeState, type DcpState, type SerializedDcpS
7
7
  const DCP_STATE_DIR = "dcp-state"
8
8
  const DCP_STATE_EXT = ".json"
9
9
  const DCP_STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
10
+ const MAX_SESSION_HEADER_BYTES = 64 * 1024
10
11
 
11
12
  let lastPersistedStateHash: string | undefined
12
13
  let saveQueue: Promise<void> = Promise.resolve()
@@ -15,6 +16,28 @@ function safeSessionFileName(sessionId: string): string {
15
16
  return sessionId.replace(/[^a-zA-Z0-9._-]/g, "_") + DCP_STATE_EXT
16
17
  }
17
18
 
19
+ export async function readSessionIdFromFile(sessionPath: string): Promise<string | undefined> {
20
+ const file = await open(sessionPath, "r")
21
+ try {
22
+ const buffer = Buffer.alloc(MAX_SESSION_HEADER_BYTES)
23
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0)
24
+ if (bytesRead <= 0) return undefined
25
+
26
+ const content = buffer.subarray(0, bytesRead)
27
+ const newlineIndex = content.indexOf(0x0a)
28
+ if (newlineIndex < 0 && bytesRead === buffer.length) return undefined
29
+
30
+ const firstLine = content.subarray(0, newlineIndex >= 0 ? newlineIndex : bytesRead).toString("utf8").trim()
31
+ if (!firstLine) return undefined
32
+ const parsed = JSON.parse(firstLine) as { type?: string; id?: unknown }
33
+ return parsed.type === "session" && typeof parsed.id === "string" && parsed.id.length > 0
34
+ ? parsed.id
35
+ : undefined
36
+ } finally {
37
+ await file.close()
38
+ }
39
+ }
40
+
18
41
  async function listSessionIds(sessionDir: string): Promise<string[]> {
19
42
  let entries: Dirent[]
20
43
  try {
@@ -30,12 +53,8 @@ async function listSessionIds(sessionDir: string): Promise<string[]> {
30
53
 
31
54
  const sessionPath = join(sessionDir, entry.name)
32
55
  try {
33
- const firstLine = (await readFile(sessionPath, "utf8")).split("\n", 1)[0]?.trim()
34
- if (!firstLine) continue
35
- const parsed = JSON.parse(firstLine) as { type?: string; id?: unknown }
36
- if (parsed.type === "session" && typeof parsed.id === "string" && parsed.id.length > 0) {
37
- sessionIds.add(parsed.id)
38
- }
56
+ const sessionId = await readSessionIdFromFile(sessionPath)
57
+ if (sessionId) sessionIds.add(sessionId)
39
58
  } catch {
40
59
  // Ignore malformed or transient session files during cleanup.
41
60
  }
@@ -88,14 +107,10 @@ export async function loadDcpStateFromSessionFile(
88
107
  if (!sessionFile) return undefined
89
108
 
90
109
  try {
91
- const firstLine = (await readFile(sessionFile, "utf8")).split("\n", 1)[0]?.trim()
92
- if (!firstLine) return undefined
93
- const parsed = JSON.parse(firstLine) as { type?: string; id?: unknown }
94
- if (parsed.type !== "session" || typeof parsed.id !== "string" || !parsed.id) {
95
- return undefined
96
- }
110
+ const sessionId = await readSessionIdFromFile(sessionFile)
111
+ if (!sessionId) return undefined
97
112
  const stateDir = join(dirname(sessionFile), DCP_STATE_DIR)
98
- const statePath = join(stateDir, safeSessionFileName(parsed.id))
113
+ const statePath = join(stateDir, safeSessionFileName(sessionId))
99
114
  const text = await readFile(statePath, "utf8")
100
115
  return JSON.parse(text) as SerializedDcpState
101
116
  } catch {
@@ -42,10 +42,10 @@ function buildThinkingPromptParts(model: unknown): { promptSnippet?: string; pro
42
42
  const levels = getAvailableTodoThinkingLevels(model);
43
43
  if (levels.length <= 1) return {};
44
44
  return {
45
- promptSnippet: `${DEFAULT_PROMPT_SNIPPET} Optional per-item thinking: ${levels.join("|")}.`.trim(),
45
+ promptSnippet: `${DEFAULT_PROMPT_SNIPPET} Set per-item thinking: ${levels.join("|")}.`.trim(),
46
46
  promptGuidelines: [
47
47
  ...DEFAULT_PROMPT_GUIDELINES,
48
- `If todoThinking is enabled, assign task \`thinking\` during create/batch_create or update whenever planned items differ in complexity; choose from ${levels.join(", ")}. Use higher thinking for investigation, hard debugging, risky edits, or review; use lower/off for mechanical steps and the final user-facing report. Do not leave all thinking unset for a non-trivial mixed-complexity plan.`,
48
+ `If todoThinking is enabled, set \`thinking\` on every planned task during create/batch_create (or update); choose from ${levels.join(", ")}. Use higher thinking for investigation, hard debugging, risky edits, or review; use lower/off for mechanical steps and the final report. Never leave it unset in a non-trivial plan.`,
49
49
  ],
50
50
  };
51
51
  }
@@ -235,7 +235,7 @@ export const SESSION_NAME_TOOL_DESCRIPTION: ToolDescription = {
235
235
  name: "session_name",
236
236
  label: "Session Name",
237
237
  description: "Show or set the current session name so the agent can retitle the active session without relying on slash-command parsing.",
238
- promptSnippet: "Use session_name to rename the current session directly when the task calls for updating the session title.",
238
+ promptSnippet: "Use session_name only for explicit renames, opaque first prompts (such as image-only or task links) once understood, or when the current name no longer fits the active task; ordinary first prompts are auto-named.",
239
239
  promptGuidelines: [
240
240
  "Pass a short, user-meaningful name to rename; call without a name only to read the current session name.",
241
241
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -75,9 +75,9 @@
75
75
  "prepublishOnly": "npm run check && npm run build:pix && npm run generate-schemas"
76
76
  },
77
77
  "dependencies": {
78
- "@earendil-works/pi-ai": "0.81.0",
79
- "@earendil-works/pi-coding-agent": "0.81.0",
80
- "@earendil-works/pi-tui": "0.81.0",
78
+ "@earendil-works/pi-ai": "0.81.1",
79
+ "@earendil-works/pi-coding-agent": "0.81.1",
80
+ "@earendil-works/pi-tui": "0.81.1",
81
81
  "@mariozechner/clipboard": "^0.3.9",
82
82
  "jsonc-parser": "3.3.1",
83
83
  "typebox": "1.1.38",