pi-ui-extend 0.1.69 → 0.1.71
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.
- package/dist/app/app.js +22 -1
- package/dist/app/commands/command-host.d.ts +7 -0
- package/dist/app/commands/command-host.js +10 -1
- package/dist/app/commands/command-model-actions.d.ts +1 -1
- package/dist/app/commands/command-model-actions.js +36 -2
- package/dist/app/commands/command-navigation-actions.d.ts +1 -1
- package/dist/app/commands/command-navigation-actions.js +40 -6
- package/dist/app/commands/command-session-actions.d.ts +1 -1
- package/dist/app/commands/command-session-actions.js +56 -0
- package/dist/app/commands/shell-controller.d.ts +9 -2
- package/dist/app/commands/shell-controller.js +32 -21
- package/dist/app/extensions/extension-actions-controller.d.ts +3 -0
- package/dist/app/extensions/extension-actions-controller.js +25 -7
- package/dist/app/extensions/extension-ui-controller.d.ts +5 -1
- package/dist/app/extensions/extension-ui-controller.js +104 -64
- package/dist/app/input/input-action-controller.d.ts +4 -1
- package/dist/app/input/input-action-controller.js +68 -27
- package/dist/app/input/input-controller.d.ts +3 -0
- package/dist/app/input/input-controller.js +45 -1
- package/dist/app/input/input-paste-handler.d.ts +3 -0
- package/dist/app/input/input-paste-handler.js +21 -12
- package/dist/app/input/prompt-enhancer-controller.d.ts +1 -0
- package/dist/app/input/prompt-enhancer-controller.js +11 -5
- package/dist/app/input/voice-controller.d.ts +4 -0
- package/dist/app/input/voice-controller.js +76 -35
- package/dist/app/popup/popup-action-controller.d.ts +3 -0
- package/dist/app/popup/popup-action-controller.js +43 -8
- package/dist/app/session/queued-message-controller.d.ts +5 -4
- package/dist/app/session/queued-message-controller.js +32 -27
- package/dist/app/session/request-history.d.ts +2 -0
- package/dist/app/session/request-history.js +22 -15
- package/dist/app/session/session-event-controller.js +28 -8
- package/dist/app/session/session-history.js +8 -2
- package/dist/app/session/session-lifecycle-controller.d.ts +7 -0
- package/dist/app/session/session-lifecycle-controller.js +91 -19
- package/dist/app/session/tabs-controller.d.ts +26 -0
- package/dist/app/session/tabs-controller.js +469 -96
- package/dist/app/terminal/terminal-controller.d.ts +4 -0
- package/dist/app/terminal/terminal-controller.js +38 -11
- package/dist/app/workspace/workspace-actions-controller.d.ts +2 -0
- package/dist/app/workspace/workspace-actions-controller.js +37 -9
- package/dist/input-editor.d.ts +2 -0
- package/dist/input-editor.js +17 -0
- package/docs/concurrency.md +76 -0
- package/external/pi-tools-suite/package.json +3 -3
- package/external/pi-tools-suite/src/dcp/state-persistence.ts +29 -14
- package/external/pi-tools-suite/src/todo/index.ts +2 -2
- package/external/pi-tools-suite/src/todo/state/state-reducer.ts +6 -0
- package/external/pi-tools-suite/src/tool-descriptions.ts +1 -1
- 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"),
|
|
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.
|
|
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
|
-
|
|
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() {
|
|
@@ -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
|
-
|
|
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
|
|
57
|
-
|
|
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(
|
|
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
|
|
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)
|
package/dist/input-editor.d.ts
CHANGED
|
@@ -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;
|
package/dist/input-editor.js
CHANGED
|
@@ -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.
|
|
47
|
-
"@earendil-works/pi-coding-agent": "0.81.
|
|
48
|
-
"@earendil-works/pi-tui": "0.81.
|
|
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
|
|
34
|
-
if (
|
|
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
|
|
92
|
-
if (!
|
|
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(
|
|
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}
|
|
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,
|
|
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
|
}
|
|
@@ -266,6 +266,12 @@ export function applyTaskMutation(state: TaskState, action: TaskAction, params:
|
|
|
266
266
|
|
|
267
267
|
case "batch_update": {
|
|
268
268
|
if (!params.items?.length) return errorResult(state, "items required for batch_update");
|
|
269
|
+
const seenIds = new Set<number>();
|
|
270
|
+
for (const item of params.items) {
|
|
271
|
+
if (item.id === undefined) continue;
|
|
272
|
+
if (seenIds.has(item.id)) return errorResult(state, `duplicate id in batch_update: #${item.id}`);
|
|
273
|
+
seenIds.add(item.id);
|
|
274
|
+
}
|
|
269
275
|
let working = state;
|
|
270
276
|
const ids: number[] = [];
|
|
271
277
|
for (let i = 0; i < params.items.length; i++) {
|
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.1.71",
|
|
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.
|
|
79
|
-
"@earendil-works/pi-coding-agent": "0.81.
|
|
80
|
-
"@earendil-works/pi-tui": "0.81.
|
|
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",
|