@oh-my-pi/pi-coding-agent 15.11.8 → 15.12.1
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 +46 -2
- package/dist/cli.js +8095 -7704
- package/dist/types/collab/crypto.d.ts +1 -6
- package/dist/types/collab/guest.d.ts +2 -0
- package/dist/types/collab/host.d.ts +16 -0
- package/dist/types/collab/protocol.d.ts +14 -1
- package/dist/types/config/settings-schema.d.ts +52 -6
- 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/main.d.ts +2 -0
- package/dist/types/modes/components/agent-hub.d.ts +19 -1
- package/dist/types/modes/components/status-line/component.d.ts +6 -1
- package/dist/types/modes/components/status-line/types.d.ts +2 -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 +9 -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 +12 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/codex-auto-reset.d.ts +8 -4
- package/dist/types/task/executor.d.ts +7 -0
- package/dist/types/task/types.d.ts +9 -0
- package/dist/types/tools/tool-result.d.ts +2 -0
- package/package.json +13 -14
- package/scripts/build-binary.ts +4 -0
- package/scripts/bundle-dist.ts +4 -0
- package/scripts/generate-share-viewer.ts +34 -0
- package/src/collab/crypto.ts +10 -4
- package/src/collab/guest.ts +31 -2
- package/src/collab/host.ts +73 -11
- package/src/collab/protocol.ts +48 -7
- package/src/commands/join.ts +1 -1
- package/src/config/settings-schema.ts +54 -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/internal-urls/docs-index.generated.ts +73 -73
- package/src/lsp/index.ts +11 -0
- package/src/main.ts +22 -9
- package/src/modes/components/agent-hub.ts +541 -410
- package/src/modes/components/status-line/component.ts +38 -5
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/tips.txt +3 -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 +104 -4
- package/src/modes/controllers/selector-controller.ts +11 -15
- package/src/modes/controllers/session-focus-controller.ts +112 -0
- package/src/modes/interactive-mode.ts +44 -2
- package/src/modes/session-observer-registry.ts +11 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/modes/types.ts +12 -0
- package/src/modes/utils/ui-helpers.ts +16 -13
- package/src/prompts/tools/job.md +1 -1
- package/src/session/agent-session.ts +87 -19
- package/src/session/codex-auto-reset.ts +23 -11
- package/src/slash-commands/builtin-registry.ts +62 -35
- 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/ast-grep.ts +3 -1
- package/src/tools/find.ts +3 -1
- package/src/tools/gh.ts +20 -6
- package/src/tools/irc.ts +4 -0
- package/src/tools/job.ts +18 -13
- package/src/tools/memory-recall.ts +2 -0
- package/src/tools/search.ts +3 -1
- package/src/tools/tool-result.ts +8 -0
- 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
|
@@ -154,6 +154,7 @@ export class StatusLineComponent implements Component {
|
|
|
154
154
|
#loopModeStatus: { enabled: boolean } | null = null;
|
|
155
155
|
#goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
|
|
156
156
|
#collabStatus: CollabStatus | null = null;
|
|
157
|
+
#focusedAgentId: string | undefined;
|
|
157
158
|
|
|
158
159
|
// Git status caching (1s TTL)
|
|
159
160
|
#cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
|
|
@@ -189,7 +190,7 @@ export class StatusLineComponent implements Component {
|
|
|
189
190
|
#nonMessageInputsKey: string | undefined;
|
|
190
191
|
#messageTokenTotalsCache: MessageTokenTotalsCache | undefined;
|
|
191
192
|
|
|
192
|
-
constructor(private
|
|
193
|
+
constructor(private session: AgentSession) {
|
|
193
194
|
this.#settings = {
|
|
194
195
|
preset: settings.get("statusLine.preset"),
|
|
195
196
|
leftSegments: settings.get("statusLine.leftSegments"),
|
|
@@ -202,6 +203,19 @@ export class StatusLineComponent implements Component {
|
|
|
202
203
|
};
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Re-point the status line at another session (focus proxy). Invalidate: model/context/usage all derive
|
|
208
|
+
* from it. `focusedAgentId` is the focused subagent id while the view is proxied, undefined for main.
|
|
209
|
+
*/
|
|
210
|
+
setSession(session: AgentSession, focusedAgentId?: string): void {
|
|
211
|
+
const sessionChanged = this.session !== session;
|
|
212
|
+
if (!sessionChanged && this.#focusedAgentId === focusedAgentId) return;
|
|
213
|
+
this.session = session;
|
|
214
|
+
this.#focusedAgentId = focusedAgentId;
|
|
215
|
+
if (sessionChanged) this.#invalidateSessionCaches();
|
|
216
|
+
this.invalidate();
|
|
217
|
+
}
|
|
218
|
+
|
|
205
219
|
updateSettings(settings: StatusLineSettings): void {
|
|
206
220
|
this.#settings = settings;
|
|
207
221
|
this.#effectiveSettings = undefined;
|
|
@@ -292,6 +306,16 @@ export class StatusLineComponent implements Component {
|
|
|
292
306
|
invalidate(): void {
|
|
293
307
|
this.#invalidateGitCaches();
|
|
294
308
|
}
|
|
309
|
+
#invalidateSessionCaches(): void {
|
|
310
|
+
this.#cachedUsage = null;
|
|
311
|
+
this.#usageFetchedAt = 0;
|
|
312
|
+
this.#usageInFlight = false;
|
|
313
|
+
this.#nonMessageTokensCache = undefined;
|
|
314
|
+
this.#nonMessageInputsKey = undefined;
|
|
315
|
+
this.#messageTokenTotalsCache = undefined;
|
|
316
|
+
this.#lastTokensPerSecond = null;
|
|
317
|
+
this.#lastTokensPerSecondTimestamp = null;
|
|
318
|
+
}
|
|
295
319
|
|
|
296
320
|
#invalidateGitCaches(): void {
|
|
297
321
|
this.#cachedBranch = undefined;
|
|
@@ -452,16 +476,19 @@ export class StatusLineComponent implements Component {
|
|
|
452
476
|
const now = Date.now();
|
|
453
477
|
if (this.#usageInFlight) return;
|
|
454
478
|
if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
|
|
455
|
-
const
|
|
479
|
+
const session = this.session;
|
|
480
|
+
const fetcher = (session as { fetchUsageReports?: () => Promise<unknown> }).fetchUsageReports;
|
|
456
481
|
if (typeof fetcher !== "function") return;
|
|
457
482
|
this.#usageInFlight = true;
|
|
458
483
|
void fetcher
|
|
459
|
-
.call(
|
|
484
|
+
.call(session)
|
|
460
485
|
.then(reports => {
|
|
486
|
+
if (this.session !== session) return;
|
|
461
487
|
this.#cachedUsage = this.#normalizeUsageReports(reports);
|
|
462
488
|
this.#usageFetchedAt = Date.now();
|
|
463
489
|
})
|
|
464
490
|
.catch(() => {
|
|
491
|
+
if (this.session !== session) return;
|
|
465
492
|
// Backoff on error: stamp the fetch time so the 5-min TTL guard
|
|
466
493
|
// also acts as an error budget. Without this, every render
|
|
467
494
|
// kicks off another fetch (gated only by #usageInFlight),
|
|
@@ -469,7 +496,7 @@ export class StatusLineComponent implements Component {
|
|
|
469
496
|
this.#usageFetchedAt = Date.now();
|
|
470
497
|
})
|
|
471
498
|
.finally(() => {
|
|
472
|
-
this.#usageInFlight = false;
|
|
499
|
+
if (this.session === session) this.#usageInFlight = false;
|
|
473
500
|
});
|
|
474
501
|
}
|
|
475
502
|
|
|
@@ -665,6 +692,7 @@ export class StatusLineComponent implements Component {
|
|
|
665
692
|
|
|
666
693
|
return {
|
|
667
694
|
session: this.session,
|
|
695
|
+
focusedAgentId: this.#focusedAgentId,
|
|
668
696
|
width,
|
|
669
697
|
options: segmentOptions ?? {},
|
|
670
698
|
planMode: this.#planModeStatus,
|
|
@@ -878,7 +906,12 @@ export class StatusLineComponent implements Component {
|
|
|
878
906
|
}
|
|
879
907
|
|
|
880
908
|
getTopBorder(width: number): { content: string; width: number } {
|
|
881
|
-
|
|
909
|
+
let content = this.#buildStatusLine(width);
|
|
910
|
+
if (this.#focusedAgentId && content) {
|
|
911
|
+
// Dim the whole bar while focus-proxied. Group/cap terminators emit full
|
|
912
|
+
// `\x1b[0m` resets that would cancel faint mid-bar, so re-open it after each.
|
|
913
|
+
content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`;
|
|
914
|
+
}
|
|
882
915
|
return {
|
|
883
916
|
content,
|
|
884
917
|
width: visibleWidth(content),
|
|
@@ -65,7 +65,11 @@ function classifyProjectDir(pwd: string): { scratch: boolean; relative: string |
|
|
|
65
65
|
|
|
66
66
|
const piSegment: StatusLineSegment = {
|
|
67
67
|
id: "pi",
|
|
68
|
-
render(
|
|
68
|
+
render(ctx) {
|
|
69
|
+
if (ctx.focusedAgentId) {
|
|
70
|
+
const icon = theme.icon.ghost ? `${theme.icon.ghost} ` : "";
|
|
71
|
+
return { content: theme.fg("warning", `${icon}${ctx.focusedAgentId} `), visible: true };
|
|
72
|
+
}
|
|
69
73
|
const content = theme.icon.pi ? `${theme.icon.pi} ` : "";
|
|
70
74
|
return { content: theme.fg("accent", content), visible: true };
|
|
71
75
|
},
|
|
@@ -45,6 +45,8 @@ export type RGB = readonly [number, number, number];
|
|
|
45
45
|
|
|
46
46
|
export interface SegmentContext {
|
|
47
47
|
session: AgentSession;
|
|
48
|
+
/** Focused subagent id while the view is proxied at its session, undefined otherwise. */
|
|
49
|
+
focusedAgentId?: string | undefined;
|
|
48
50
|
width: number;
|
|
49
51
|
options: StatusLineSegmentOptions;
|
|
50
52
|
planMode: {
|
|
@@ -17,4 +17,6 @@ Press ctrl+r to search your prompt history and reuse a past message
|
|
|
17
17
|
`/force read` pins the next turn to one specific tool when the model keeps reaching for the wrong one
|
|
18
18
|
`/copy code` grabs the last code block to your clipboard — `/copy cmd` grabs the last shell/python command
|
|
19
19
|
`/shake` rips heavy tool results out of context to reclaim tokens without a full /compact — `/shake images` drops just images
|
|
20
|
-
Pair up live: `/collab` shares your session through an end-to-end encrypted relay link — a teammate runs `/join <link>` to watch tool calls stream and prompt the agent from their own omp
|
|
20
|
+
Pair up live: `/collab` shares your session through an end-to-end encrypted relay link — a teammate runs `/join <link>` to watch tool calls stream and prompt the agent from their own omp
|
|
21
|
+
Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript
|
|
22
|
+
Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
3
|
-
import type
|
|
3
|
+
import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@oh-my-pi/pi-tui";
|
|
4
4
|
import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import { isSettingsInitialized, settings } from "../../config/settings";
|
|
6
6
|
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
|
|
@@ -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,12 +136,24 @@ 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
|
+
}
|
|
128
151
|
if (this.ctx.collabGuest) {
|
|
129
152
|
// Guest Esc: ask the host to interrupt its agent; the local replica
|
|
130
153
|
// session is never streaming, so the native abort path below would
|
|
131
154
|
// no-op.
|
|
132
155
|
if (this.ctx.collabGuest.state?.isStreaming || this.ctx.loadingAnimation) {
|
|
133
|
-
this.ctx.notifyInterrupting();
|
|
156
|
+
if (!this.ctx.collabGuest.readOnly) this.ctx.notifyInterrupting();
|
|
134
157
|
this.ctx.collabGuest.sendAbort();
|
|
135
158
|
}
|
|
136
159
|
return;
|
|
@@ -261,9 +284,14 @@ export class InputController {
|
|
|
261
284
|
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showAgentHub());
|
|
262
285
|
}
|
|
263
286
|
|
|
264
|
-
// Double-tap left arrow on an empty editor opens the agent hub
|
|
265
|
-
//
|
|
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.
|
|
266
290
|
this.ctx.editor.onLeftAtStart = () => {
|
|
291
|
+
if (this.ctx.focusedAgentId) {
|
|
292
|
+
this.#handleFocusedLeftTap();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
267
295
|
const now = Date.now();
|
|
268
296
|
if (now - this.ctx.lastLeftTapTime < 500) {
|
|
269
297
|
this.ctx.lastLeftTapTime = 0;
|
|
@@ -287,6 +315,16 @@ export class InputController {
|
|
|
287
315
|
};
|
|
288
316
|
}
|
|
289
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
|
+
|
|
290
328
|
#setupEnhancedPaste(): void {
|
|
291
329
|
if (this.#enhancedPaste) return;
|
|
292
330
|
|
|
@@ -324,6 +362,14 @@ export class InputController {
|
|
|
324
362
|
text = text.trim();
|
|
325
363
|
if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
|
|
326
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
|
+
|
|
327
373
|
// Empty submit while streaming with queued steering: interrupt now and
|
|
328
374
|
// immediately resume so the visible `Steer:` entry is sent without
|
|
329
375
|
// waiting for the current tool/model boundary.
|
|
@@ -416,6 +462,11 @@ export class InputController {
|
|
|
416
462
|
this.ctx.editor.setText("");
|
|
417
463
|
return;
|
|
418
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
|
+
}
|
|
419
470
|
this.ctx.editor.addToHistory(text);
|
|
420
471
|
this.ctx.editor.setText("");
|
|
421
472
|
this.ctx.editor.imageLinks = undefined;
|
|
@@ -602,6 +653,41 @@ export class InputController {
|
|
|
602
653
|
};
|
|
603
654
|
}
|
|
604
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
|
+
|
|
605
691
|
handleCtrlC(): void {
|
|
606
692
|
const now = Date.now();
|
|
607
693
|
if (now - this.ctx.lastSigintTime < 500) {
|
|
@@ -740,6 +826,12 @@ export class InputController {
|
|
|
740
826
|
let text = this.ctx.editor.getText().trim();
|
|
741
827
|
if (!text) return;
|
|
742
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
|
+
|
|
743
835
|
// Compaction first: while compacting, free text gets queued via
|
|
744
836
|
// `queueCompactionMessage`, and `/skill:*` rides the same queue so a
|
|
745
837
|
// skill typed during compaction is not lost or short-circuited through
|
|
@@ -1049,6 +1141,10 @@ export class InputController {
|
|
|
1049
1141
|
}
|
|
1050
1142
|
|
|
1051
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
|
+
}
|
|
1052
1148
|
const newLevel = this.ctx.session.cycleThinkingLevel();
|
|
1053
1149
|
if (newLevel === undefined) {
|
|
1054
1150
|
this.ctx.showStatus("Current model does not support thinking");
|
|
@@ -1059,6 +1155,10 @@ export class InputController {
|
|
|
1059
1155
|
}
|
|
1060
1156
|
|
|
1061
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
|
+
}
|
|
1062
1162
|
try {
|
|
1063
1163
|
const cycleOrder = settings.get("cycleOrder");
|
|
1064
1164
|
const result = await this.ctx.session.cycleRoleModels(cycleOrder, direction);
|