@oh-my-pi/pi-coding-agent 15.11.7 → 15.12.0
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/CHANGELOG.md +63 -1
- package/dist/cli.js +8106 -7708
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/collab/crypto.d.ts +7 -0
- package/dist/types/collab/guest.d.ts +23 -0
- package/dist/types/collab/host.d.ts +29 -0
- package/dist/types/collab/protocol.d.ts +113 -0
- package/dist/types/collab/relay-client.d.ts +22 -0
- package/dist/types/commands/join.d.ts +12 -0
- package/dist/types/config/settings-schema.d.ts +60 -5
- package/dist/types/export/custom-share.d.ts +1 -2
- package/dist/types/export/html/index.d.ts +39 -1
- package/dist/types/export/share.d.ts +43 -0
- package/dist/types/extensibility/slash-commands.d.ts +1 -11
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/agent-hub.d.ts +32 -1
- package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
- package/dist/types/modes/components/hook-selector.d.ts +4 -6
- package/dist/types/modes/components/segment-track.d.ts +11 -6
- package/dist/types/modes/components/status-line/component.d.ts +10 -2
- package/dist/types/modes/components/status-line/types.d.ts +11 -0
- package/dist/types/modes/controllers/event-controller.d.ts +7 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -1
- package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
- package/dist/types/modes/interactive-mode.d.ts +16 -0
- package/dist/types/modes/session-observer-registry.d.ts +7 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +20 -0
- package/dist/types/session/agent-session.d.ts +13 -0
- package/dist/types/session/codex-auto-reset.d.ts +8 -4
- package/dist/types/session/session-manager.d.ts +21 -0
- package/dist/types/session/snapcompact-inline.d.ts +6 -3
- package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
- package/dist/types/task/executor.d.ts +7 -0
- package/dist/types/task/types.d.ts +9 -0
- package/package.json +14 -13
- package/scripts/bench-guard.ts +71 -0
- package/scripts/build-binary.ts +4 -0
- package/scripts/bundle-dist.ts +4 -0
- package/scripts/generate-share-viewer.ts +34 -0
- package/src/cli/args.ts +2 -0
- package/src/cli-commands.ts +1 -0
- package/src/collab/crypto.ts +63 -0
- package/src/collab/guest.ts +450 -0
- package/src/collab/host.ts +556 -0
- package/src/collab/protocol.ts +232 -0
- package/src/collab/relay-client.ts +216 -0
- package/src/commands/join.ts +39 -0
- package/src/config/model-registry.ts +22 -14
- package/src/config/settings-schema.ts +67 -5
- package/src/config/settings.ts +12 -0
- package/src/export/custom-share.ts +1 -1
- package/src/export/html/index.ts +122 -17
- package/src/export/html/share-loader.js +102 -0
- package/src/export/html/template.css +745 -459
- package/src/export/html/template.html +6 -3
- package/src/export/html/template.js +240 -915
- package/src/export/html/tool-views.generated.js +38 -0
- package/src/export/share.ts +268 -0
- package/src/extensibility/slash-commands.ts +1 -97
- package/src/internal-urls/docs-index.generated.ts +74 -73
- package/src/main.ts +33 -11
- package/src/modes/components/agent-hub.ts +659 -431
- package/src/modes/components/assistant-message.ts +126 -6
- package/src/modes/components/collab-prompt-message.ts +30 -0
- package/src/modes/components/hook-selector.ts +4 -5
- package/src/modes/components/segment-track.ts +44 -7
- package/src/modes/components/status-line/component.ts +59 -6
- package/src/modes/components/status-line/presets.ts +1 -1
- package/src/modes/components/status-line/segments.ts +18 -1
- package/src/modes/components/status-line/types.ts +12 -0
- package/src/modes/components/tips.txt +4 -1
- package/src/modes/controllers/command-controller.ts +55 -96
- package/src/modes/controllers/event-controller.ts +45 -16
- package/src/modes/controllers/input-controller.ts +175 -9
- package/src/modes/controllers/selector-controller.ts +13 -15
- package/src/modes/controllers/session-focus-controller.ts +112 -0
- package/src/modes/controllers/streaming-reveal.ts +7 -0
- package/src/modes/interactive-mode.ts +56 -6
- package/src/modes/session-observer-registry.ts +11 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/modes/types.ts +20 -0
- package/src/modes/utils/ui-helpers.ts +23 -13
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk.ts +239 -36
- package/src/session/agent-session.ts +82 -7
- package/src/session/codex-auto-reset.ts +23 -11
- package/src/session/session-manager.ts +44 -0
- package/src/session/snapcompact-inline.ts +9 -3
- package/src/slash-commands/builtin-registry.ts +261 -24
- package/src/task/executor.ts +14 -0
- package/src/task/index.ts +5 -1
- package/src/task/render.ts +76 -5
- package/src/task/types.ts +9 -0
- package/src/tiny/worker.ts +17 -95
- package/src/tools/job.ts +6 -9
- package/src/tools/read.ts +38 -5
- package/src/tools/write.ts +13 -42
- package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
- package/dist/types/export/html/template.generated.d.ts +0 -1
- package/dist/types/export/html/template.macro.d.ts +0 -5
- package/dist/types/tiny/compiled-runtime.d.ts +0 -35
- package/scripts/generate-template.ts +0 -33
- package/src/bun-imports.d.ts +0 -28
- package/src/export/html/template.generated.ts +0 -2
- package/src/export/html/template.macro.ts +0 -25
- package/src/tiny/compiled-runtime.ts +0 -179
|
@@ -11,8 +11,9 @@ import {
|
|
|
11
11
|
} from "@oh-my-pi/pi-ai";
|
|
12
12
|
import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
13
13
|
import { formatDuration, Snowflake } from "@oh-my-pi/pi-utils";
|
|
14
|
-
import { $ } from "bun";
|
|
15
14
|
import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
|
|
15
|
+
import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share";
|
|
16
|
+
import { shareSession } from "../../export/share";
|
|
16
17
|
import type { CompactOptions } from "../../extensibility/extensions/types";
|
|
17
18
|
import {
|
|
18
19
|
diffMentalModelContent,
|
|
@@ -117,126 +118,84 @@ export class CommandController {
|
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
async handleShareCommand(): Promise<void> {
|
|
120
|
-
|
|
121
|
-
const cleanupTempFile = async () => {
|
|
122
|
-
try {
|
|
123
|
-
await fs.rm(tmpFile, { force: true });
|
|
124
|
-
} catch {
|
|
125
|
-
// Ignore cleanup errors
|
|
126
|
-
}
|
|
127
|
-
};
|
|
121
|
+
let customShare: LoadedCustomShare | null;
|
|
128
122
|
try {
|
|
129
|
-
await
|
|
130
|
-
} catch (error: unknown) {
|
|
131
|
-
this.ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
const { loadCustomShare } = await import("../../export/custom-share");
|
|
137
|
-
const customShare = await loadCustomShare();
|
|
138
|
-
if (customShare) {
|
|
139
|
-
const loader = new BorderedLoader(this.ctx.ui, theme, "Sharing...");
|
|
140
|
-
this.ctx.editorContainer.clear();
|
|
141
|
-
this.ctx.editorContainer.addChild(loader);
|
|
142
|
-
this.ctx.ui.setFocus(loader);
|
|
143
|
-
this.ctx.ui.requestRender();
|
|
144
|
-
|
|
145
|
-
const restoreEditor = async () => {
|
|
146
|
-
loader.dispose();
|
|
147
|
-
this.ctx.editorContainer.clear();
|
|
148
|
-
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
149
|
-
this.ctx.ui.setFocus(this.ctx.editor);
|
|
150
|
-
await cleanupTempFile();
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
const result = await customShare.fn(tmpFile);
|
|
155
|
-
await restoreEditor();
|
|
156
|
-
|
|
157
|
-
if (typeof result === "string") {
|
|
158
|
-
this.ctx.showStatus(`Share URL: ${result}`);
|
|
159
|
-
this.openInBrowser(result);
|
|
160
|
-
} else if (result) {
|
|
161
|
-
const parts: string[] = [];
|
|
162
|
-
if (result.url) parts.push(`Share URL: ${result.url}`);
|
|
163
|
-
if (result.message) parts.push(result.message);
|
|
164
|
-
if (parts.length > 0) this.ctx.showStatus(parts.join("\n"));
|
|
165
|
-
if (result.url) this.openInBrowser(result.url);
|
|
166
|
-
} else {
|
|
167
|
-
this.ctx.showStatus("Session shared");
|
|
168
|
-
}
|
|
169
|
-
return;
|
|
170
|
-
} catch (err) {
|
|
171
|
-
await restoreEditor();
|
|
172
|
-
this.ctx.showError(`Custom share failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
123
|
+
customShare = await loadCustomShare();
|
|
176
124
|
} catch (err) {
|
|
177
|
-
await cleanupTempFile();
|
|
178
125
|
this.ctx.showError(err instanceof Error ? err.message : String(err));
|
|
179
126
|
return;
|
|
180
127
|
}
|
|
181
128
|
|
|
182
|
-
|
|
183
|
-
const authResult = await $`gh auth status`.quiet().nothrow();
|
|
184
|
-
if (authResult.exitCode !== 0) {
|
|
185
|
-
await cleanupTempFile();
|
|
186
|
-
this.ctx.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
} catch {
|
|
190
|
-
await cleanupTempFile();
|
|
191
|
-
this.ctx.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const loader = new BorderedLoader(this.ctx.ui, theme, "Creating gist...");
|
|
129
|
+
const loader = new BorderedLoader(this.ctx.ui, theme, "Sharing session...");
|
|
196
130
|
this.ctx.editorContainer.clear();
|
|
197
131
|
this.ctx.editorContainer.addChild(loader);
|
|
198
132
|
this.ctx.ui.setFocus(loader);
|
|
199
133
|
this.ctx.ui.requestRender();
|
|
200
134
|
|
|
201
|
-
const restoreEditor =
|
|
135
|
+
const restoreEditor = () => {
|
|
202
136
|
loader.dispose();
|
|
203
137
|
this.ctx.editorContainer.clear();
|
|
204
138
|
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
205
139
|
this.ctx.ui.setFocus(this.ctx.editor);
|
|
206
|
-
await cleanupTempFile();
|
|
207
140
|
};
|
|
208
|
-
|
|
209
141
|
loader.onAbort = () => {
|
|
210
|
-
|
|
142
|
+
restoreEditor();
|
|
211
143
|
this.ctx.showStatus("Share cancelled");
|
|
212
144
|
};
|
|
213
145
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
146
|
+
// Custom share scripts keep their legacy contract: they receive a path
|
|
147
|
+
// to a standalone HTML export. No fallback to the default flow on error.
|
|
148
|
+
if (customShare) {
|
|
149
|
+
const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`);
|
|
150
|
+
try {
|
|
151
|
+
await this.ctx.session.exportToHtml(tmpFile);
|
|
152
|
+
const result = await customShare.fn(tmpFile);
|
|
153
|
+
if (loader.signal.aborted) return;
|
|
154
|
+
restoreEditor();
|
|
155
|
+
|
|
156
|
+
if (typeof result === "string") {
|
|
157
|
+
this.ctx.showStatus(`Share URL: ${result}`);
|
|
158
|
+
this.openInBrowser(result);
|
|
159
|
+
} else if (result) {
|
|
160
|
+
const parts: string[] = [];
|
|
161
|
+
if (result.url) parts.push(`Share URL: ${result.url}`);
|
|
162
|
+
if (result.message) parts.push(result.message);
|
|
163
|
+
if (parts.length > 0) this.ctx.showStatus(parts.join("\n"));
|
|
164
|
+
if (result.url) this.openInBrowser(result.url);
|
|
165
|
+
} else {
|
|
166
|
+
this.ctx.showStatus("Session shared");
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (!loader.signal.aborted) {
|
|
170
|
+
restoreEditor();
|
|
171
|
+
this.ctx.showError(`Custom share failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
await fs.rm(tmpFile, { force: true }).catch(() => {});
|
|
224
175
|
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
225
178
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
179
|
+
// Default: encrypted snapshot to a secret gist (preferred) or the share
|
|
180
|
+
// server; the key rides in the link fragment and never leaves the client.
|
|
181
|
+
try {
|
|
182
|
+
const result = await shareSession(this.ctx.session.sessionManager, {
|
|
183
|
+
serverUrl: this.ctx.settings.get("share.serverUrl"),
|
|
184
|
+
state: this.ctx.session.state,
|
|
185
|
+
obfuscator: this.ctx.settings.get("share.redactSecrets") ? this.ctx.session.obfuscator : undefined,
|
|
186
|
+
});
|
|
187
|
+
if (loader.signal.aborted) return;
|
|
188
|
+
restoreEditor();
|
|
232
189
|
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
190
|
+
const lines = [`Share URL: ${result.url}`];
|
|
191
|
+
if (result.gistUrl) lines.push(`Gist: ${result.gistUrl}`);
|
|
192
|
+
if (result.truncated) lines.push("Note: large content was trimmed to fit the share size limit.");
|
|
193
|
+
this.ctx.showStatus(lines.join("\n"));
|
|
194
|
+
this.openInBrowser(result.url);
|
|
236
195
|
} catch (error: unknown) {
|
|
237
196
|
if (!loader.signal.aborted) {
|
|
238
|
-
|
|
239
|
-
this.ctx.showError(`Failed to
|
|
197
|
+
restoreEditor();
|
|
198
|
+
this.ctx.showError(`Failed to share session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
240
199
|
}
|
|
241
200
|
}
|
|
242
201
|
}
|
|
@@ -221,6 +221,35 @@ export class EventController {
|
|
|
221
221
|
await this.handleEvent(event);
|
|
222
222
|
});
|
|
223
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* Clear every transcript-anchored/turn-scoped piece of state. Used by the
|
|
226
|
+
* session focus proxy when re-pointing the transcript at another session:
|
|
227
|
+
* components, timers, and stream-reveal state all reference the previous
|
|
228
|
+
* session's transcript and must not bleed into the new one.
|
|
229
|
+
*/
|
|
230
|
+
resetTranscriptAnchors(): void {
|
|
231
|
+
this.#resetReadGroup();
|
|
232
|
+
this.#lastVisibleBlockCount = 0;
|
|
233
|
+
this.#renderedCustomMessages.clear();
|
|
234
|
+
this.#lastIntent = undefined;
|
|
235
|
+
this.#backgroundToolCallIds.clear();
|
|
236
|
+
this.#agentTurnActive = false;
|
|
237
|
+
this.#interrupting = false;
|
|
238
|
+
this.#readToolCallArgs.clear();
|
|
239
|
+
this.#readToolCallAssistantComponents.clear();
|
|
240
|
+
this.#lastAssistantComponent = undefined;
|
|
241
|
+
this.#pinnedErrorComponent = undefined;
|
|
242
|
+
this.#cancelIdleCompaction();
|
|
243
|
+
for (const timer of this.#ircExpiryTimers.values()) {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
}
|
|
246
|
+
this.#ircExpiryTimers.clear();
|
|
247
|
+
this.#liveIrcCards.clear();
|
|
248
|
+
this.#displaceablePollComponent = undefined;
|
|
249
|
+
this.#lastTtsrNotification = undefined;
|
|
250
|
+
this.#streamingReveal.stop();
|
|
251
|
+
this.#toolArgsReveal.stop();
|
|
252
|
+
}
|
|
224
253
|
|
|
225
254
|
async handleEvent(event: AgentSessionEvent): Promise<void> {
|
|
226
255
|
if (!this.ctx.isInitialized) {
|
|
@@ -335,7 +364,7 @@ export class EventController {
|
|
|
335
364
|
undefined,
|
|
336
365
|
this.ctx.hideThinkingBlock,
|
|
337
366
|
() => this.ctx.ui.requestRender(),
|
|
338
|
-
this.ctx.
|
|
367
|
+
this.ctx.viewSession.extensionRunner?.getAssistantThinkingRenderers(),
|
|
339
368
|
this.ctx.ui.imageBudget,
|
|
340
369
|
);
|
|
341
370
|
this.ctx.streamingMessage = event.message;
|
|
@@ -519,12 +548,12 @@ export class EventController {
|
|
|
519
548
|
if (!this.ctx.pendingTools.has(content.id)) {
|
|
520
549
|
this.#resolveDisplaceablePoll(content.name);
|
|
521
550
|
this.#resetReadGroup();
|
|
522
|
-
const tool = this.ctx.
|
|
551
|
+
const tool = this.ctx.viewSession.getToolByName(content.name);
|
|
523
552
|
const component = new ToolExecutionComponent(
|
|
524
553
|
content.name,
|
|
525
554
|
renderArgs,
|
|
526
555
|
{
|
|
527
|
-
snapshots: getFileSnapshotStore(this.ctx.
|
|
556
|
+
snapshots: getFileSnapshotStore(this.ctx.viewSession),
|
|
528
557
|
showImages: settings.get("terminal.showImages"),
|
|
529
558
|
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
|
|
530
559
|
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
|
|
@@ -556,7 +585,7 @@ export class EventController {
|
|
|
556
585
|
this.#updateWorkingMessageFromIntent(args[INTENT_FIELD]);
|
|
557
586
|
continue;
|
|
558
587
|
}
|
|
559
|
-
const tool = this.ctx.
|
|
588
|
+
const tool = this.ctx.viewSession.getToolByName(content.name);
|
|
560
589
|
if (typeof tool?.intent !== "function") continue;
|
|
561
590
|
try {
|
|
562
591
|
const derived = tool.intent(args as never)?.trim();
|
|
@@ -581,7 +610,7 @@ export class EventController {
|
|
|
581
610
|
let errorMessage: string | undefined;
|
|
582
611
|
const aborted = this.ctx.streamingMessage.stopReason === "aborted";
|
|
583
612
|
const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage.errorMessage);
|
|
584
|
-
const ttsrSilenced = aborted && this.ctx.
|
|
613
|
+
const ttsrSilenced = aborted && this.ctx.viewSession.isTtsrAbortPending;
|
|
585
614
|
if (aborted && !silentlyAborted && !ttsrSilenced) {
|
|
586
615
|
// Resolve the operator-facing label: a user-interrupt (Esc) abort
|
|
587
616
|
// carries USER_INTERRUPT_LABEL on errorMessage (threaded through the
|
|
@@ -590,7 +619,7 @@ export class EventController {
|
|
|
590
619
|
// AgentSession.#handleAgentEvent already stamped SILENT_ABORT_MARKER for
|
|
591
620
|
// the plan-compact transition before this controller ran, so reaching
|
|
592
621
|
// this branch implies the abort was NOT a silent internal transition.
|
|
593
|
-
errorMessage = resolveAbortLabel(this.ctx.streamingMessage.errorMessage, this.ctx.
|
|
622
|
+
errorMessage = resolveAbortLabel(this.ctx.streamingMessage.errorMessage, this.ctx.viewSession.retryAttempt);
|
|
594
623
|
this.ctx.streamingMessage.errorMessage = errorMessage;
|
|
595
624
|
}
|
|
596
625
|
if (silentlyAborted || ttsrSilenced) {
|
|
@@ -664,12 +693,12 @@ export class EventController {
|
|
|
664
693
|
}
|
|
665
694
|
|
|
666
695
|
this.#resetReadGroup();
|
|
667
|
-
const tool = this.ctx.
|
|
696
|
+
const tool = this.ctx.viewSession.getToolByName(event.toolName);
|
|
668
697
|
const component = new ToolExecutionComponent(
|
|
669
698
|
event.toolName,
|
|
670
699
|
event.args,
|
|
671
700
|
{
|
|
672
|
-
snapshots: getFileSnapshotStore(this.ctx.
|
|
701
|
+
snapshots: getFileSnapshotStore(this.ctx.viewSession),
|
|
673
702
|
showImages: settings.get("terminal.showImages"),
|
|
674
703
|
editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
|
|
675
704
|
editAllowFuzzy: settings.get("edit.fuzzyMatch"),
|
|
@@ -844,7 +873,7 @@ export class EventController {
|
|
|
844
873
|
this.#cancelIdleCompaction();
|
|
845
874
|
this.ctx.autoCompactionEscapeHandler = this.ctx.editor.onEscape;
|
|
846
875
|
this.ctx.editor.onEscape = () => {
|
|
847
|
-
this.ctx.
|
|
876
|
+
this.ctx.viewSession.abortCompaction();
|
|
848
877
|
};
|
|
849
878
|
this.ctx.statusContainer.clear();
|
|
850
879
|
const reasonText =
|
|
@@ -937,7 +966,7 @@ export class EventController {
|
|
|
937
966
|
async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
|
|
938
967
|
this.ctx.retryEscapeHandler = this.ctx.editor.onEscape;
|
|
939
968
|
this.ctx.editor.onEscape = () => {
|
|
940
|
-
this.ctx.
|
|
969
|
+
this.ctx.viewSession.abortRetry();
|
|
941
970
|
};
|
|
942
971
|
this.ctx.statusContainer.clear();
|
|
943
972
|
const delaySeconds = Math.round(event.delayMs / 1000);
|
|
@@ -1023,7 +1052,7 @@ export class EventController {
|
|
|
1023
1052
|
this.#cancelIdleCompaction();
|
|
1024
1053
|
// Don't schedule idle work while context maintenance is already running; the
|
|
1025
1054
|
// maintenance flow may reset the session before this timer fires.
|
|
1026
|
-
if (this.ctx.
|
|
1055
|
+
if (this.ctx.viewSession.isCompacting) return;
|
|
1027
1056
|
|
|
1028
1057
|
const idleSettings = settings.getGroup("compaction");
|
|
1029
1058
|
if (!idleSettings.idleEnabled) return;
|
|
@@ -1040,17 +1069,17 @@ export class EventController {
|
|
|
1040
1069
|
this.#idleCompactionTimer = undefined;
|
|
1041
1070
|
// Re-check conditions before firing. Pruning may have run between arming
|
|
1042
1071
|
// the timer and now, dropping usage back below the idle threshold.
|
|
1043
|
-
if (this.ctx.
|
|
1044
|
-
if (this.ctx.
|
|
1072
|
+
if (this.ctx.viewSession.isStreaming) return;
|
|
1073
|
+
if (this.ctx.viewSession.isCompacting) return;
|
|
1045
1074
|
if (this.ctx.editor.getText().trim()) return;
|
|
1046
1075
|
if (this.#currentContextTokens() < threshold) return;
|
|
1047
|
-
void this.ctx.
|
|
1076
|
+
void this.ctx.viewSession.runIdleCompaction();
|
|
1048
1077
|
}, timeoutMs);
|
|
1049
1078
|
this.#idleCompactionTimer.unref?.();
|
|
1050
1079
|
}
|
|
1051
1080
|
|
|
1052
1081
|
#currentContextTokens(): number {
|
|
1053
|
-
const lastAssistant = this.ctx.
|
|
1082
|
+
const lastAssistant = this.ctx.viewSession.agent.state.messages
|
|
1054
1083
|
.slice()
|
|
1055
1084
|
.reverse()
|
|
1056
1085
|
.find((m): m is AssistantMessage => m.role === "assistant" && m.stopReason !== "aborted");
|
|
@@ -1065,7 +1094,7 @@ export class EventController {
|
|
|
1065
1094
|
// errored — those are not "Task complete" events. Mirrors the gate
|
|
1066
1095
|
// already used by #currentContextTokens, #handleMessageEnd, and the
|
|
1067
1096
|
// retry / TTSR / compaction skip paths across agent-session.ts.
|
|
1068
|
-
const last = this.ctx.
|
|
1097
|
+
const last = this.ctx.viewSession.getLastAssistantMessage?.();
|
|
1069
1098
|
if (last?.stopReason === "aborted" || last?.stopReason === "error") return;
|
|
1070
1099
|
|
|
1071
1100
|
const sessionName = this.ctx.sessionManager.getSessionName();
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
3
|
-
import type
|
|
4
|
-
import { $env, logger, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
5
|
-
import { getRoleInfo } from "../../config/model-roles";
|
|
3
|
+
import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@oh-my-pi/pi-tui";
|
|
4
|
+
import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
6
5
|
import { isSettingsInitialized, settings } from "../../config/settings";
|
|
7
6
|
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
|
|
8
7
|
import { renderSegmentTrack } from "../../modes/components/segment-track";
|
|
@@ -18,6 +17,7 @@ import { isTinyTitleLocalModelKey } from "../../tiny/models";
|
|
|
18
17
|
import { isLowSignalTitleInput } from "../../tiny/text";
|
|
19
18
|
import { tinyTitleClient } from "../../tiny/title-client";
|
|
20
19
|
import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
|
|
20
|
+
import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
|
|
21
21
|
import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
|
|
22
22
|
import { EnhancedPasteController } from "../../utils/enhanced-paste";
|
|
23
23
|
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
|
@@ -60,6 +60,7 @@ export class InputController {
|
|
|
60
60
|
) {}
|
|
61
61
|
|
|
62
62
|
#enhancedPaste?: EnhancedPasteController;
|
|
63
|
+
#focusedLeftTapListenerInstalled = false;
|
|
63
64
|
|
|
64
65
|
#showTinyTitleDownloadProgress(modelKey: string): void {
|
|
65
66
|
if (!isTinyTitleLocalModelKey(modelKey)) return;
|
|
@@ -108,6 +109,16 @@ export class InputController {
|
|
|
108
109
|
|
|
109
110
|
setupKeyHandlers(): void {
|
|
110
111
|
this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
|
|
112
|
+
if (!this.#focusedLeftTapListenerInstalled) {
|
|
113
|
+
this.#focusedLeftTapListenerInstalled = true;
|
|
114
|
+
this.ctx.ui.addInputListener(data => {
|
|
115
|
+
if (!this.ctx.focusedAgentId) return undefined;
|
|
116
|
+
if (!matchesKey(data, "left")) return undefined;
|
|
117
|
+
if (this.ctx.editor.getText().trim()) return undefined;
|
|
118
|
+
this.#handleFocusedLeftTap();
|
|
119
|
+
return { consume: true };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
111
122
|
this.ctx.editor.onEscape = () => {
|
|
112
123
|
if (this.ctx.loopModeEnabled) {
|
|
113
124
|
this.ctx.pauseLoop();
|
|
@@ -125,6 +136,28 @@ export class InputController {
|
|
|
125
136
|
if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
|
|
126
137
|
return;
|
|
127
138
|
}
|
|
139
|
+
if (this.ctx.focusedAgentId) {
|
|
140
|
+
// Esc never interrupts the focused agent's turn: clear typed text,
|
|
141
|
+
// else return the view to the main session. Interrupt via empty
|
|
142
|
+
// steer-flush submit if needed.
|
|
143
|
+
if (this.ctx.editor.getText().trim()) {
|
|
144
|
+
this.ctx.editor.setText("");
|
|
145
|
+
this.ctx.ui.requestRender();
|
|
146
|
+
} else {
|
|
147
|
+
void this.ctx.unfocusSession();
|
|
148
|
+
}
|
|
149
|
+
return; // double-escape backtrack (/tree, /branch) stays main-only
|
|
150
|
+
}
|
|
151
|
+
if (this.ctx.collabGuest) {
|
|
152
|
+
// Guest Esc: ask the host to interrupt its agent; the local replica
|
|
153
|
+
// session is never streaming, so the native abort path below would
|
|
154
|
+
// no-op.
|
|
155
|
+
if (this.ctx.collabGuest.state?.isStreaming || this.ctx.loadingAnimation) {
|
|
156
|
+
if (!this.ctx.collabGuest.readOnly) this.ctx.notifyInterrupting();
|
|
157
|
+
this.ctx.collabGuest.sendAbort();
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
128
161
|
if (this.ctx.loadingAnimation) {
|
|
129
162
|
if (this.ctx.cancelPendingSubmission()) {
|
|
130
163
|
return;
|
|
@@ -251,9 +284,14 @@ export class InputController {
|
|
|
251
284
|
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showAgentHub());
|
|
252
285
|
}
|
|
253
286
|
|
|
254
|
-
// Double-tap left arrow on an empty editor opens the agent hub
|
|
255
|
-
//
|
|
287
|
+
// Double-tap left arrow on an empty editor: opens the agent hub from the
|
|
288
|
+
// main session, or returns the focused subagent view to the main session.
|
|
289
|
+
// Focused ←← intentionally matches Esc.
|
|
256
290
|
this.ctx.editor.onLeftAtStart = () => {
|
|
291
|
+
if (this.ctx.focusedAgentId) {
|
|
292
|
+
this.#handleFocusedLeftTap();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
257
295
|
const now = Date.now();
|
|
258
296
|
if (now - this.ctx.lastLeftTapTime < 500) {
|
|
259
297
|
this.ctx.lastLeftTapTime = 0;
|
|
@@ -277,6 +315,16 @@ export class InputController {
|
|
|
277
315
|
};
|
|
278
316
|
}
|
|
279
317
|
|
|
318
|
+
#handleFocusedLeftTap(): void {
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
if (now - this.ctx.lastLeftTapTime < 500) {
|
|
321
|
+
this.ctx.lastLeftTapTime = 0;
|
|
322
|
+
void this.ctx.unfocusSession();
|
|
323
|
+
} else {
|
|
324
|
+
this.ctx.lastLeftTapTime = now;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
280
328
|
#setupEnhancedPaste(): void {
|
|
281
329
|
if (this.#enhancedPaste) return;
|
|
282
330
|
|
|
@@ -314,6 +362,14 @@ export class InputController {
|
|
|
314
362
|
text = text.trim();
|
|
315
363
|
if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
|
|
316
364
|
|
|
365
|
+
// Focused subagent session: the editor is a plain chat box for it.
|
|
366
|
+
// Everything below (continue shortcuts, slash/bash/python, loop,
|
|
367
|
+
// compaction queueing) is main-session-only.
|
|
368
|
+
if (this.ctx.focusedAgentId) {
|
|
369
|
+
await this.#submitToFocusedSession(text, "steer");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
317
373
|
// Empty submit while streaming with queued steering: interrupt now and
|
|
318
374
|
// immediately resume so the visible `Steer:` entry is sent without
|
|
319
375
|
// waiting for the current tool/model boundary.
|
|
@@ -392,6 +448,37 @@ export class InputController {
|
|
|
392
448
|
text = slashResult;
|
|
393
449
|
}
|
|
394
450
|
|
|
451
|
+
// Collab guest: prompts execute on the host; local slash/skill/bash/
|
|
452
|
+
// python execution is host-only (builtins are gated inside
|
|
453
|
+
// executeBuiltinSlashCommand, which already consumed allowed ones).
|
|
454
|
+
if (this.ctx.collabGuest) {
|
|
455
|
+
if (text.startsWith("/")) {
|
|
456
|
+
this.ctx.showStatus(`${text.split(/\s+/, 1)[0]} is host-only during a collab session`);
|
|
457
|
+
this.ctx.editor.setText("");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (text.startsWith("!") || text.startsWith("$")) {
|
|
461
|
+
this.ctx.showStatus("Local execution is host-only during a collab session");
|
|
462
|
+
this.ctx.editor.setText("");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (this.ctx.collabGuest.readOnly) {
|
|
466
|
+
// Keep the typed text: the prompt was not consumed.
|
|
467
|
+
this.ctx.showStatus("This collab link is read-only — prompting is disabled");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
this.ctx.editor.addToHistory(text);
|
|
471
|
+
this.ctx.editor.setText("");
|
|
472
|
+
this.ctx.editor.imageLinks = undefined;
|
|
473
|
+
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
|
|
474
|
+
this.ctx.pendingImages = [];
|
|
475
|
+
this.ctx.pendingImageLinks = [];
|
|
476
|
+
// No local render: the prompt comes back from the host as a
|
|
477
|
+
// collab-prompt event/entry and renders with the author badge.
|
|
478
|
+
this.ctx.collabGuest.sendPrompt(text, images);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
395
482
|
// Handle skill commands (/skill:name [args]). Enter ⇒ steer (matches the
|
|
396
483
|
// free-text Enter semantics applied a few lines below at the streaming
|
|
397
484
|
// branch). Ctrl+Enter routes through `handleFollowUp` and dispatches the
|
|
@@ -566,6 +653,41 @@ export class InputController {
|
|
|
566
653
|
};
|
|
567
654
|
}
|
|
568
655
|
|
|
656
|
+
/** Submit editor text to the focused subagent session (chat-only focus policy). */
|
|
657
|
+
async #submitToFocusedSession(text: string, streamingBehavior: "steer" | "followUp"): Promise<void> {
|
|
658
|
+
const target = this.ctx.viewSession;
|
|
659
|
+
if (!text) {
|
|
660
|
+
// Mirror the empty-submit steer flush against the focused session.
|
|
661
|
+
if (target.isStreaming && target.getQueuedMessages().steering.length > 0) {
|
|
662
|
+
await target.interruptAndFlushQueuedMessages({ reason: USER_INTERRUPT_LABEL });
|
|
663
|
+
this.ctx.updatePendingMessagesDisplay();
|
|
664
|
+
this.ctx.ui.requestRender();
|
|
665
|
+
}
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (text.startsWith("/") || text.startsWith("!") || text.startsWith("$")) {
|
|
669
|
+
this.ctx.showStatus("Commands run in the main session — press ←← to return first");
|
|
670
|
+
return; // editor text not cleared: Editor does not auto-clear on submit
|
|
671
|
+
}
|
|
672
|
+
const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
|
|
673
|
+
this.ctx.editor.addToHistory(text);
|
|
674
|
+
this.ctx.editor.setText("");
|
|
675
|
+
this.ctx.editor.imageLinks = undefined;
|
|
676
|
+
this.ctx.pendingImages = [];
|
|
677
|
+
this.ctx.pendingImageLinks = [];
|
|
678
|
+
try {
|
|
679
|
+
// prompt() handles idle (new turn) and streaming (queues per streamingBehavior).
|
|
680
|
+
await this.ctx.withLocalSubmission(text, () => target.prompt(text, { streamingBehavior, images }), {
|
|
681
|
+
imageCount: images?.length ?? 0,
|
|
682
|
+
});
|
|
683
|
+
} catch (error) {
|
|
684
|
+
this.ctx.editor.setText(text); // hand the message back, mirroring the main submit error path
|
|
685
|
+
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
686
|
+
}
|
|
687
|
+
this.ctx.updatePendingMessagesDisplay();
|
|
688
|
+
this.ctx.ui.requestRender();
|
|
689
|
+
}
|
|
690
|
+
|
|
569
691
|
handleCtrlC(): void {
|
|
570
692
|
const now = Date.now();
|
|
571
693
|
if (now - this.ctx.lastSigintTime < 500) {
|
|
@@ -704,6 +826,12 @@ export class InputController {
|
|
|
704
826
|
let text = this.ctx.editor.getText().trim();
|
|
705
827
|
if (!text) return;
|
|
706
828
|
|
|
829
|
+
// Focused subagent session: follow-ups go to it; non-chat input is gated.
|
|
830
|
+
if (this.ctx.focusedAgentId) {
|
|
831
|
+
await this.#submitToFocusedSession(text, "followUp");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
707
835
|
// Compaction first: while compacting, free text gets queued via
|
|
708
836
|
// `queueCompactionMessage`, and `/skill:*` rides the same queue so a
|
|
709
837
|
// skill typed during compaction is not lost or short-circuited through
|
|
@@ -874,11 +1002,41 @@ export class InputController {
|
|
|
874
1002
|
`Unsupported pasted image format: ${image.mimeType}`,
|
|
875
1003
|
);
|
|
876
1004
|
} catch (error) {
|
|
1005
|
+
if (error instanceof ImageInputTooLargeError) {
|
|
1006
|
+
this.ctx.editor.pasteText(path);
|
|
1007
|
+
this.ctx.ui.requestRender();
|
|
1008
|
+
this.ctx.showStatus(error.message);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (isEnoent(error)) {
|
|
1012
|
+
// #2375: the bracketed paste forwarded by a local terminal carries a
|
|
1013
|
+
// path on the *local* filesystem. When omp itself runs over SSH, that
|
|
1014
|
+
// path is unreachable here; pasting it as text would look like the
|
|
1015
|
+
// image was attached when in fact nothing was sent. Refuse the silent
|
|
1016
|
+
// degrade and tell the user how to send the bytes for real. The
|
|
1017
|
+
// pasted path is untrusted terminal input — strip control/ANSI/
|
|
1018
|
+
// newlines, collapse home to `~`, and bound the displayed length
|
|
1019
|
+
// before splicing it into the status string.
|
|
1020
|
+
const displayPath = truncateToWidth(
|
|
1021
|
+
shortenPath(
|
|
1022
|
+
sanitizeText(path)
|
|
1023
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
1024
|
+
.trim(),
|
|
1025
|
+
),
|
|
1026
|
+
TRUNCATE_LENGTHS.CONTENT,
|
|
1027
|
+
);
|
|
1028
|
+
const env = process.env;
|
|
1029
|
+
const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
|
|
1030
|
+
this.ctx.showStatus(
|
|
1031
|
+
overSsh
|
|
1032
|
+
? `Image not found at ${displayPath}. Over SSH this path is local to your terminal — paste the image directly (clipboard image-paste shortcut) to send its bytes.`
|
|
1033
|
+
: `Image not found at ${displayPath}`,
|
|
1034
|
+
);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
877
1037
|
this.ctx.editor.pasteText(path);
|
|
878
1038
|
this.ctx.ui.requestRender();
|
|
879
|
-
this.ctx.showStatus(
|
|
880
|
-
error instanceof ImageInputTooLargeError ? error.message : "Failed to read pasted image path",
|
|
881
|
-
);
|
|
1039
|
+
this.ctx.showStatus("Failed to read pasted image path");
|
|
882
1040
|
}
|
|
883
1041
|
}
|
|
884
1042
|
|
|
@@ -983,6 +1141,10 @@ export class InputController {
|
|
|
983
1141
|
}
|
|
984
1142
|
|
|
985
1143
|
cycleThinkingLevel(): void {
|
|
1144
|
+
if (this.ctx.focusedAgentId) {
|
|
1145
|
+
this.ctx.showStatus("Model/thinking apply to the main session — press ←← to return first");
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
986
1148
|
const newLevel = this.ctx.session.cycleThinkingLevel();
|
|
987
1149
|
if (newLevel === undefined) {
|
|
988
1150
|
this.ctx.showStatus("Current model does not support thinking");
|
|
@@ -993,6 +1155,10 @@ export class InputController {
|
|
|
993
1155
|
}
|
|
994
1156
|
|
|
995
1157
|
async cycleRoleModel(direction: "forward" | "backward" = "forward"): Promise<void> {
|
|
1158
|
+
if (this.ctx.focusedAgentId) {
|
|
1159
|
+
this.ctx.showStatus("Model/thinking apply to the main session — press ←← to return first");
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
996
1162
|
try {
|
|
997
1163
|
const cycleOrder = settings.get("cycleOrder");
|
|
998
1164
|
const result = await this.ctx.session.cycleRoleModels(cycleOrder, direction);
|
|
@@ -1007,7 +1173,7 @@ export class InputController {
|
|
|
1007
1173
|
// the cycle status is just a status-line-style chip track (active role
|
|
1008
1174
|
// filled), matching the plan-approval model slider.
|
|
1009
1175
|
const track = renderSegmentTrack(
|
|
1010
|
-
cycleOrder.map(role => ({ label: role
|
|
1176
|
+
cycleOrder.map(role => ({ label: role })),
|
|
1011
1177
|
cycleOrder.indexOf(result.role),
|
|
1012
1178
|
);
|
|
1013
1179
|
this.ctx.showStatus(track, { dim: false });
|