@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.67
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 +74 -1
- package/dist/types/cli/classify-install-target.d.ts +5 -1
- package/dist/types/config/keybindings.d.ts +4 -1
- package/dist/types/config/settings-schema.d.ts +24 -5
- package/dist/types/edit/file-snapshot-store.d.ts +1 -1
- package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
- package/dist/types/eval/backend.d.ts +6 -6
- package/dist/types/eval/bridge-timeout.d.ts +27 -0
- package/dist/types/eval/idle-timeout.d.ts +16 -14
- package/dist/types/eval/js/executor.d.ts +3 -3
- package/dist/types/eval/py/executor.d.ts +2 -2
- package/dist/types/eval/py/spawn-options.d.ts +58 -0
- package/dist/types/modes/components/assistant-message.d.ts +16 -0
- package/dist/types/modes/components/copy-selector.d.ts +22 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -1
- package/dist/types/modes/components/error-banner.d.ts +11 -0
- package/dist/types/modes/components/model-selector.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +15 -0
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/components/user-message.d.ts +1 -1
- package/dist/types/modes/controllers/command-controller.d.ts +0 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/image-references.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +8 -1
- package/dist/types/modes/types.d.ts +8 -1
- package/dist/types/modes/utils/copy-targets.d.ts +53 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
- package/dist/types/session/blob-store.d.ts +12 -11
- package/dist/types/session/session-manager.d.ts +5 -3
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/tiny/title-client.d.ts +16 -1
- package/dist/types/tool-discovery/mode.d.ts +8 -0
- package/dist/types/tools/archive-reader.d.ts +5 -1
- package/dist/types/tools/eval-render.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +25 -0
- package/dist/types/tui/code-cell.d.ts +6 -0
- package/dist/types/tui/hyperlink.d.ts +12 -0
- package/dist/types/tui/output-block.d.ts +11 -0
- package/dist/types/web/search/render.d.ts +1 -2
- package/package.json +9 -9
- package/src/autoresearch/dashboard.ts +11 -21
- package/src/cli/classify-install-target.ts +31 -5
- package/src/cli/claude-trace-cli.ts +13 -1
- package/src/cli/plugin-cli.ts +45 -0
- package/src/cli/web-search-cli.ts +0 -1
- package/src/config/keybindings.ts +58 -1
- package/src/config/model-registry.ts +54 -4
- package/src/config/settings-schema.ts +25 -5
- package/src/debug/raw-sse.ts +18 -4
- package/src/edit/file-snapshot-store.ts +1 -1
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +7 -7
- package/src/edit/streaming.ts +1 -1
- package/src/eval/__tests__/agent-bridge.test.ts +100 -27
- package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
- package/src/eval/__tests__/idle-timeout.test.ts +26 -12
- package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
- package/src/eval/__tests__/llm-bridge.test.ts +10 -10
- package/src/eval/__tests__/shared-executors.test.ts +2 -2
- package/src/eval/agent-bridge.ts +4 -5
- package/src/eval/backend.ts +6 -6
- package/src/eval/bridge-timeout.ts +44 -0
- package/src/eval/idle-timeout.ts +33 -15
- package/src/eval/js/executor.ts +10 -10
- package/src/eval/llm-bridge.ts +4 -5
- package/src/eval/py/executor.ts +6 -6
- package/src/eval/py/kernel.ts +11 -1
- package/src/eval/py/spawn-options.ts +126 -0
- package/src/eval/py/tool-bridge.ts +43 -5
- package/src/export/ttsr.ts +9 -0
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
- package/src/extensibility/extensions/runner.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/lsp/client.ts +80 -2
- package/src/lsp/index.ts +38 -4
- package/src/lsp/render.ts +3 -3
- package/src/main.ts +8 -2
- package/src/modes/components/agent-dashboard.ts +13 -4
- package/src/modes/components/assistant-message.ts +44 -1
- package/src/modes/components/copy-selector.ts +249 -0
- package/src/modes/components/custom-editor.ts +14 -2
- package/src/modes/components/error-banner.ts +33 -0
- package/src/modes/components/extensions/extension-list.ts +17 -8
- package/src/modes/components/history-search.ts +19 -11
- package/src/modes/components/model-selector.ts +125 -29
- package/src/modes/components/oauth-selector.ts +28 -12
- package/src/modes/components/session-observer-overlay.ts +13 -15
- package/src/modes/components/session-selector.ts +24 -13
- package/src/modes/components/tool-execution.ts +71 -13
- package/src/modes/components/transcript-container.ts +93 -32
- package/src/modes/components/tree-selector.ts +19 -7
- package/src/modes/components/user-message-selector.ts +25 -14
- package/src/modes/components/user-message.ts +9 -2
- package/src/modes/controllers/command-controller.ts +0 -116
- package/src/modes/controllers/event-controller.ts +67 -12
- package/src/modes/controllers/input-controller.ts +33 -1
- package/src/modes/controllers/selector-controller.ts +38 -1
- package/src/modes/image-references.ts +111 -0
- package/src/modes/interactive-mode.ts +52 -17
- package/src/modes/theme/theme.ts +46 -10
- package/src/modes/types.ts +11 -2
- package/src/modes/utils/copy-targets.ts +254 -0
- package/src/modes/utils/ui-helpers.ts +23 -2
- package/src/prompts/ci-green-request.md +5 -3
- package/src/prompts/system/project-prompt.md +1 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/search.md +1 -1
- package/src/sdk.ts +17 -9
- package/src/session/agent-session.ts +43 -14
- package/src/session/blob-store.ts +96 -9
- package/src/session/session-manager.ts +19 -10
- package/src/slash-commands/builtin-registry.ts +3 -11
- package/src/system-prompt.ts +4 -0
- package/src/task/render.ts +38 -11
- package/src/tiny/title-client.ts +7 -1
- package/src/tool-discovery/mode.ts +24 -0
- package/src/tools/archive-reader.ts +339 -31
- package/src/tools/bash.ts +18 -8
- package/src/tools/browser/render.ts +5 -4
- package/src/tools/debug.ts +3 -3
- package/src/tools/eval-render.ts +24 -9
- package/src/tools/eval.ts +14 -19
- package/src/tools/fetch.ts +34 -14
- package/src/tools/gh.ts +65 -11
- package/src/tools/index.ts +6 -8
- package/src/tools/read.ts +65 -19
- package/src/tools/render-utils.ts +46 -0
- package/src/tools/search-tool-bm25.ts +4 -6
- package/src/tools/search.ts +60 -11
- package/src/tools/ssh.ts +21 -8
- package/src/tools/write.ts +17 -8
- package/src/tui/code-cell.ts +19 -4
- package/src/tui/hyperlink.ts +42 -7
- package/src/tui/output-block.ts +14 -0
- package/src/web/search/index.ts +2 -2
- package/src/web/search/render.ts +23 -55
- package/dist/types/eval/heartbeat.d.ts +0 -45
- package/src/eval/__tests__/heartbeat.test.ts +0 -84
- package/src/eval/heartbeat.ts +0 -74
- /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
getEnvApiKey,
|
|
7
7
|
getProviderDetails,
|
|
8
8
|
type ProviderDetails,
|
|
9
|
-
type ToolCall,
|
|
10
9
|
type UsageLimit,
|
|
11
10
|
type UsageReport,
|
|
12
11
|
} from "@oh-my-pi/pi-ai";
|
|
@@ -239,121 +238,6 @@ export class CommandController {
|
|
|
239
238
|
}
|
|
240
239
|
}
|
|
241
240
|
|
|
242
|
-
handleCopyCommand(sub?: string) {
|
|
243
|
-
switch (sub) {
|
|
244
|
-
case "code":
|
|
245
|
-
return this.#copyCode();
|
|
246
|
-
case "all":
|
|
247
|
-
return this.#copyAllCode();
|
|
248
|
-
case "cmd":
|
|
249
|
-
return this.#copyLastCommand();
|
|
250
|
-
case "last":
|
|
251
|
-
case undefined:
|
|
252
|
-
return this.#copyLastMessage();
|
|
253
|
-
default:
|
|
254
|
-
this.ctx.showError(`Unknown subcommand: ${sub}. Use code, all, cmd, or last.`);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
#copyLastMessage() {
|
|
259
|
-
const assistantText = this.ctx.session.getLastAssistantText();
|
|
260
|
-
if (assistantText) {
|
|
261
|
-
this.#doCopy(assistantText, "Copied last agent message to clipboard");
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
if (!this.ctx.session.hasCopyCandidateAssistantMessage()) {
|
|
266
|
-
const handoffText = this.ctx.session.getLastVisibleHandoffText();
|
|
267
|
-
if (handoffText) {
|
|
268
|
-
this.#doCopy(handoffText, "Copied handoff context to clipboard");
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
this.ctx.showError("No agent messages to copy yet.");
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
#copyCode() {
|
|
277
|
-
const text = this.ctx.session.getLastAssistantText();
|
|
278
|
-
if (!text) {
|
|
279
|
-
this.ctx.showError("No agent messages to copy yet.");
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)];
|
|
283
|
-
const lastMatch = matches.at(-1);
|
|
284
|
-
if (!lastMatch) {
|
|
285
|
-
this.ctx.showWarning("No code block found in the last agent message.");
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
this.#doCopy(lastMatch[1].replace(/\n$/, ""), "Copied last code block to clipboard");
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
#copyAllCode() {
|
|
292
|
-
const text = this.ctx.session.getLastAssistantText();
|
|
293
|
-
if (!text) {
|
|
294
|
-
this.ctx.showError("No agent messages to copy yet.");
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)];
|
|
298
|
-
if (matches.length === 0) {
|
|
299
|
-
this.ctx.showWarning("No code blocks found in the last agent message.");
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
const combined = matches.map(m => m[1].replace(/\n$/, "")).join("\n\n");
|
|
303
|
-
this.#doCopy(combined, `Copied ${matches.length} code block${matches.length > 1 ? "s" : ""} to clipboard`);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
#extractEvalCode(args: unknown): string | undefined {
|
|
307
|
-
if (!args || typeof args !== "object") return undefined;
|
|
308
|
-
const cells = (args as { cells?: unknown }).cells;
|
|
309
|
-
if (!Array.isArray(cells)) return undefined;
|
|
310
|
-
|
|
311
|
-
const codeBlocks: string[] = [];
|
|
312
|
-
for (const cell of cells) {
|
|
313
|
-
if (!cell || typeof cell !== "object") continue;
|
|
314
|
-
const code = (cell as { code?: unknown }).code;
|
|
315
|
-
if (typeof code === "string" && code.length > 0) {
|
|
316
|
-
codeBlocks.push(code);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
return codeBlocks.length > 0 ? codeBlocks.join("\n\n") : undefined;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
#copyLastCommand() {
|
|
324
|
-
const messages = this.ctx.session.messages;
|
|
325
|
-
// Walk backwards to find the last bash/eval tool call
|
|
326
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
327
|
-
const msg = messages[i];
|
|
328
|
-
if (msg.role !== "assistant") continue;
|
|
329
|
-
const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
|
|
330
|
-
for (let j = toolCalls.length - 1; j >= 0; j--) {
|
|
331
|
-
const tc = toolCalls[j];
|
|
332
|
-
if (tc.name === "bash" && typeof tc.arguments.command === "string") {
|
|
333
|
-
this.#doCopy(tc.arguments.command, "Copied last bash command to clipboard");
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
if (tc.name === "eval") {
|
|
337
|
-
const code = this.#extractEvalCode(tc.arguments);
|
|
338
|
-
if (code) {
|
|
339
|
-
this.#doCopy(code, "Copied last eval code to clipboard");
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
this.ctx.showWarning("No bash or eval command found in the conversation.");
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
#doCopy(content: string, label: string) {
|
|
349
|
-
try {
|
|
350
|
-
copyToClipboard(content);
|
|
351
|
-
this.ctx.showStatus(label);
|
|
352
|
-
} catch (error) {
|
|
353
|
-
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
241
|
async handleSessionCommand(): Promise<void> {
|
|
358
242
|
const stats = this.ctx.session.getSessionStats();
|
|
359
243
|
const premiumRequests =
|
|
@@ -49,9 +49,15 @@ export class EventController {
|
|
|
49
49
|
#lastIntent: string | undefined = undefined;
|
|
50
50
|
#backgroundToolCallIds = new Set<string>();
|
|
51
51
|
#assistantMessageStreaming = false;
|
|
52
|
+
#agentTurnActive = false;
|
|
52
53
|
#readToolCallArgs = new Map<string, Record<string, unknown>>();
|
|
53
54
|
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
54
55
|
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
|
|
56
|
+
// Assistant component whose turn-ending error is currently mirrored in the
|
|
57
|
+
// pinned banner. Its inline `Error: …` line is suppressed while pinned and
|
|
58
|
+
// restored when the banner clears at the next `agent_start` (see
|
|
59
|
+
// #handleMessageEnd / #handleAgentStart).
|
|
60
|
+
#pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
|
|
55
61
|
#idleCompactionTimer?: NodeJS.Timeout;
|
|
56
62
|
#ircExpiryTimers = new Map<string, NodeJS.Timeout>();
|
|
57
63
|
#handlers: AgentSessionEventHandlers;
|
|
@@ -172,21 +178,21 @@ export class EventController {
|
|
|
172
178
|
|
|
173
179
|
const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
|
|
174
180
|
await run(event);
|
|
175
|
-
// While assistant
|
|
176
|
-
//
|
|
177
|
-
// (Markdown fences, wrapping, previews). Let the
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
// Background-running tools are excluded so late async
|
|
181
|
-
//
|
|
182
|
-
//
|
|
181
|
+
// While an assistant turn is active, visible status chrome and foreground
|
|
182
|
+
// transcript blocks can re-render after rows have entered native scrollback
|
|
183
|
+
// (idle Working loader, Markdown fences, wrapping, tool previews). Let the
|
|
184
|
+
// TUI use its foreground live-region path instead of idle deferral, which
|
|
185
|
+
// can otherwise leave the loader/status frame frozen until the next input.
|
|
186
|
+
// Background-running tools after the turn ends are excluded so late async
|
|
187
|
+
// updates keep the no-yank deferral; agent_start/agent_end bracket the
|
|
188
|
+
// foreground turn.
|
|
183
189
|
if (STREAM_RENDER_MODE_EVENTS[event.type]) {
|
|
184
190
|
this.#refreshToolRenderMode();
|
|
185
191
|
}
|
|
186
192
|
}
|
|
187
193
|
|
|
188
194
|
#refreshToolRenderMode(): void {
|
|
189
|
-
let foregroundToolActive = this.#assistantMessageStreaming;
|
|
195
|
+
let foregroundToolActive = this.#agentTurnActive || this.#assistantMessageStreaming;
|
|
190
196
|
if (!foregroundToolActive) {
|
|
191
197
|
for (const toolCallId of this.ctx.pendingTools.keys()) {
|
|
192
198
|
if (!this.#backgroundToolCallIds.has(toolCallId)) {
|
|
@@ -199,11 +205,17 @@ export class EventController {
|
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
|
|
208
|
+
this.#agentTurnActive = true;
|
|
202
209
|
this.#lastIntent = undefined;
|
|
203
210
|
this.#readToolCallArgs.clear();
|
|
204
211
|
this.#readToolCallAssistantComponents.clear();
|
|
205
212
|
this.#assistantMessageStreaming = false;
|
|
206
213
|
this.#lastAssistantComponent = undefined;
|
|
214
|
+
// Restore the previous turn's inline error in the transcript before dropping
|
|
215
|
+
// the banner, so the error stays in history once the banner is gone.
|
|
216
|
+
this.#pinnedErrorComponent?.setErrorPinned(false);
|
|
217
|
+
this.#pinnedErrorComponent = undefined;
|
|
218
|
+
this.ctx.clearPinnedError();
|
|
207
219
|
if (this.ctx.retryEscapeHandler) {
|
|
208
220
|
this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
|
|
209
221
|
this.ctx.retryEscapeHandler = undefined;
|
|
@@ -214,6 +226,7 @@ export class EventController {
|
|
|
214
226
|
this.ctx.statusContainer.clear();
|
|
215
227
|
}
|
|
216
228
|
this.#cancelIdleCompaction();
|
|
229
|
+
this.#refreshToolRenderMode();
|
|
217
230
|
this.ctx.ensureLoadingAnimation();
|
|
218
231
|
this.ctx.ui.requestRender();
|
|
219
232
|
}
|
|
@@ -241,16 +254,28 @@ export class EventController {
|
|
|
241
254
|
this.ctx.ui.requestRender();
|
|
242
255
|
} else if (event.message.role === "user") {
|
|
243
256
|
const textContent = this.ctx.getUserMessageText(event.message);
|
|
244
|
-
const
|
|
257
|
+
const imageBlocks =
|
|
245
258
|
typeof event.message.content === "string"
|
|
246
|
-
?
|
|
247
|
-
: event.message.content.filter(
|
|
259
|
+
? []
|
|
260
|
+
: event.message.content.filter(
|
|
261
|
+
(content): content is ImageContent =>
|
|
262
|
+
content.type === "image" &&
|
|
263
|
+
typeof content.data === "string" &&
|
|
264
|
+
typeof content.mimeType === "string",
|
|
265
|
+
);
|
|
266
|
+
const imageCount = imageBlocks.length;
|
|
248
267
|
const signature = `${textContent}\u0000${imageCount}`;
|
|
249
268
|
|
|
250
269
|
this.#resetReadGroup();
|
|
251
270
|
const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
|
|
252
271
|
const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
|
|
253
272
|
if (!wasOptimistic) {
|
|
273
|
+
// Append synchronously: #emit dispatches to this listener fire-and-forget
|
|
274
|
+
// (see AgentSession.#emit), so any await between the user message_start and
|
|
275
|
+
// addMessageToChat lets later events (assistant message_start, tool execution
|
|
276
|
+
// start/end) append their components first and scramble transcript order /
|
|
277
|
+
// live-region block boundaries. addMessageToChat materializes clickable image
|
|
278
|
+
// links via the synchronous putBlobSync fallback, so no await is needed here.
|
|
254
279
|
this.ctx.addMessageToChat(event.message);
|
|
255
280
|
}
|
|
256
281
|
if (wasOptimistic) {
|
|
@@ -462,11 +487,35 @@ export class EventController {
|
|
|
462
487
|
for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
|
|
463
488
|
component.setArgsComplete(toolCallId);
|
|
464
489
|
}
|
|
490
|
+
} else {
|
|
491
|
+
// The turn ended without running these calls (abort/error/TTSR rewind),
|
|
492
|
+
// so they will never produce a result. Seal them so they stop animating
|
|
493
|
+
// and freeze instead of pinning the transcript live region while a retry
|
|
494
|
+
// streams fresh blocks below them. Background tools keep updating.
|
|
495
|
+
for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
|
|
496
|
+
if (!this.#backgroundToolCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
|
|
497
|
+
component.seal();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
465
500
|
}
|
|
466
501
|
this.#lastAssistantComponent = this.ctx.streamingComponent;
|
|
467
502
|
this.#lastAssistantComponent.setUsageInfo(event.message.usage);
|
|
503
|
+
this.#lastAssistantComponent.markTranscriptBlockFinalized();
|
|
468
504
|
this.ctx.streamingComponent = undefined;
|
|
469
505
|
this.ctx.streamingMessage = undefined;
|
|
506
|
+
// Pin a turn-ending provider error (e.g. Anthropic content-filter block)
|
|
507
|
+
// above the editor so it survives transcript scroll. Cleared at the next
|
|
508
|
+
// turn's agent_start. Suppress the transcript's inline `Error: …` line for
|
|
509
|
+
// the same message while pinned so the error isn't rendered twice.
|
|
510
|
+
if (
|
|
511
|
+
event.message.stopReason === "error" &&
|
|
512
|
+
event.message.errorMessage &&
|
|
513
|
+
!isSilentAbort(event.message.errorMessage)
|
|
514
|
+
) {
|
|
515
|
+
this.#lastAssistantComponent?.setErrorPinned(true);
|
|
516
|
+
this.#pinnedErrorComponent = this.#lastAssistantComponent;
|
|
517
|
+
this.ctx.showPinnedError(event.message.errorMessage);
|
|
518
|
+
}
|
|
470
519
|
this.ctx.statusLine.invalidate();
|
|
471
520
|
this.ctx.updateEditorTopBorder();
|
|
472
521
|
}
|
|
@@ -612,6 +661,7 @@ export class EventController {
|
|
|
612
661
|
}
|
|
613
662
|
}
|
|
614
663
|
async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
|
|
664
|
+
this.#agentTurnActive = false;
|
|
615
665
|
this.#assistantMessageStreaming = false;
|
|
616
666
|
if (this.ctx.loadingAnimation) {
|
|
617
667
|
this.ctx.loadingAnimation.stop();
|
|
@@ -626,6 +676,11 @@ export class EventController {
|
|
|
626
676
|
await this.ctx.flushPendingModelSwitch();
|
|
627
677
|
for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
|
|
628
678
|
if (!this.#backgroundToolCallIds.has(toolCallId)) {
|
|
679
|
+
// A foreground tool still pending at turn end never delivered a result;
|
|
680
|
+
// seal it so it freezes (and stops animating) rather than lingering in
|
|
681
|
+
// the transcript live region as a streaming preview until the next thaw.
|
|
682
|
+
const component = this.ctx.pendingTools.get(toolCallId);
|
|
683
|
+
if (component instanceof ToolExecutionComponent) component.seal();
|
|
629
684
|
this.ctx.pendingTools.delete(toolCallId);
|
|
630
685
|
}
|
|
631
686
|
}
|
|
@@ -6,6 +6,7 @@ import { isSettingsInitialized, settings } from "../../config/settings";
|
|
|
6
6
|
import { renderSegmentTrack } from "../../modes/components/segment-track";
|
|
7
7
|
import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
|
|
8
8
|
import { expandEmoticons } from "../../modes/emoji-autocomplete";
|
|
9
|
+
import { materializeImageReferenceLinks } from "../../modes/image-references";
|
|
9
10
|
import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
|
|
10
11
|
import type { InteractiveModeContext } from "../../modes/types";
|
|
11
12
|
import type { AgentSessionEvent } from "../../session/agent-session";
|
|
@@ -253,6 +254,8 @@ export class InputController {
|
|
|
253
254
|
if (this.ctx.onInputCallback) {
|
|
254
255
|
this.ctx.editor.setText("");
|
|
255
256
|
this.ctx.pendingImages = [];
|
|
257
|
+
this.ctx.pendingImageLinks = [];
|
|
258
|
+
this.ctx.editor.imageLinks = undefined;
|
|
256
259
|
this.ctx.onInputCallback({ text: "", cancelled: false, started: true });
|
|
257
260
|
}
|
|
258
261
|
return;
|
|
@@ -260,12 +263,15 @@ export class InputController {
|
|
|
260
263
|
|
|
261
264
|
const runner = this.ctx.session.extensionRunner;
|
|
262
265
|
let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
|
|
266
|
+
let inputImageLinks = this.ctx.pendingImageLinks.length > 0 ? [...this.ctx.pendingImageLinks] : undefined;
|
|
263
267
|
|
|
264
268
|
if (runner?.hasHandlers("input")) {
|
|
265
269
|
const result = await runner.emitInput(text, inputImages, "interactive");
|
|
266
270
|
if (result?.handled) {
|
|
267
271
|
this.ctx.editor.setText("");
|
|
268
272
|
this.ctx.pendingImages = [];
|
|
273
|
+
this.ctx.pendingImageLinks = [];
|
|
274
|
+
this.ctx.editor.imageLinks = undefined;
|
|
269
275
|
return;
|
|
270
276
|
}
|
|
271
277
|
if (result?.text !== undefined) {
|
|
@@ -273,6 +279,10 @@ export class InputController {
|
|
|
273
279
|
}
|
|
274
280
|
if (result?.images !== undefined) {
|
|
275
281
|
inputImages = result.images;
|
|
282
|
+
inputImageLinks = await materializeImageReferenceLinks(
|
|
283
|
+
inputImages,
|
|
284
|
+
this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
|
|
285
|
+
);
|
|
276
286
|
}
|
|
277
287
|
}
|
|
278
288
|
|
|
@@ -356,8 +366,10 @@ export class InputController {
|
|
|
356
366
|
if (this.ctx.session.isStreaming) {
|
|
357
367
|
this.ctx.editor.addToHistory(text);
|
|
358
368
|
this.ctx.editor.setText("");
|
|
369
|
+
this.ctx.editor.imageLinks = undefined;
|
|
359
370
|
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
|
|
360
371
|
this.ctx.pendingImages = [];
|
|
372
|
+
this.ctx.pendingImageLinks = [];
|
|
361
373
|
// Record the signature so the queued message's eventual delivery
|
|
362
374
|
// (a user-role `message_start` event) leaves any draft the user has
|
|
363
375
|
// typed since queuing intact. Same protection as #783, applied to
|
|
@@ -417,11 +429,17 @@ export class InputController {
|
|
|
417
429
|
|
|
418
430
|
if (this.ctx.onInputCallback) {
|
|
419
431
|
// Include any pending images from clipboard paste
|
|
432
|
+
this.ctx.editor.imageLinks = undefined;
|
|
420
433
|
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
|
|
421
434
|
this.ctx.pendingImages = [];
|
|
435
|
+
this.ctx.pendingImageLinks = [];
|
|
422
436
|
|
|
423
437
|
// Render user message immediately, then let session events catch up
|
|
424
|
-
const submission = this.ctx.startPendingSubmission({
|
|
438
|
+
const submission = this.ctx.startPendingSubmission({
|
|
439
|
+
text,
|
|
440
|
+
images,
|
|
441
|
+
imageLinks: inputImageLinks,
|
|
442
|
+
});
|
|
425
443
|
|
|
426
444
|
this.ctx.onInputCallback(submission);
|
|
427
445
|
}
|
|
@@ -685,11 +703,25 @@ export class InputController {
|
|
|
685
703
|
}
|
|
686
704
|
}
|
|
687
705
|
|
|
706
|
+
const imageLink = (
|
|
707
|
+
await materializeImageReferenceLinks(
|
|
708
|
+
[
|
|
709
|
+
{
|
|
710
|
+
type: "image",
|
|
711
|
+
data: imageData.data,
|
|
712
|
+
mimeType: imageData.mimeType,
|
|
713
|
+
},
|
|
714
|
+
],
|
|
715
|
+
this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
|
|
716
|
+
)
|
|
717
|
+
)?.[0];
|
|
688
718
|
this.ctx.pendingImages.push({
|
|
689
719
|
type: "image",
|
|
690
720
|
data: imageData.data,
|
|
691
721
|
mimeType: imageData.mimeType,
|
|
692
722
|
});
|
|
723
|
+
this.ctx.pendingImageLinks.push(imageLink);
|
|
724
|
+
this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
|
|
693
725
|
// Insert placeholder at cursor like Claude does
|
|
694
726
|
const imageNum = this.ctx.pendingImages.length;
|
|
695
727
|
const placeholder = `[Image #${imageNum}]`;
|
|
@@ -37,9 +37,11 @@ import {
|
|
|
37
37
|
setPreferredSearchProvider,
|
|
38
38
|
} from "../../tools";
|
|
39
39
|
import { shortenPath } from "../../tools/render-utils";
|
|
40
|
+
import { copyToClipboard } from "../../utils/clipboard";
|
|
40
41
|
import { setSessionTerminalTitle } from "../../utils/title-generator";
|
|
41
42
|
import { AgentDashboard } from "../components/agent-dashboard";
|
|
42
43
|
import { AssistantMessageComponent } from "../components/assistant-message";
|
|
44
|
+
import { CopySelectorComponent } from "../components/copy-selector";
|
|
43
45
|
import { ExtensionDashboard } from "../components/extensions";
|
|
44
46
|
import { HistorySearchComponent } from "../components/history-search";
|
|
45
47
|
import { ModelSelectorComponent } from "../components/model-selector";
|
|
@@ -52,6 +54,8 @@ import { ToolExecutionComponent } from "../components/tool-execution";
|
|
|
52
54
|
import { TreeSelectorComponent } from "../components/tree-selector";
|
|
53
55
|
import { UserMessageSelectorComponent } from "../components/user-message-selector";
|
|
54
56
|
import type { SessionObserverRegistry } from "../session-observer-registry";
|
|
57
|
+
import { computeContextBreakdown } from "../utils/context-usage";
|
|
58
|
+
import { buildCopyTargets } from "../utils/copy-targets";
|
|
55
59
|
|
|
56
60
|
const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
|
|
57
61
|
"anthropic",
|
|
@@ -407,6 +411,7 @@ export class SelectorController {
|
|
|
407
411
|
}
|
|
408
412
|
|
|
409
413
|
showModelSelector(options?: { temporaryOnly?: boolean }): void {
|
|
414
|
+
const currentContextTokens = computeContextBreakdown(this.ctx.session).usedTokens;
|
|
410
415
|
this.showSelector(done => {
|
|
411
416
|
const selector = new ModelSelectorComponent(
|
|
412
417
|
this.ctx.ui,
|
|
@@ -470,7 +475,7 @@ export class SelectorController {
|
|
|
470
475
|
done();
|
|
471
476
|
this.ctx.ui.requestRender();
|
|
472
477
|
},
|
|
473
|
-
options,
|
|
478
|
+
{ ...options, currentContextTokens },
|
|
474
479
|
);
|
|
475
480
|
return { component: selector, focus: selector };
|
|
476
481
|
});
|
|
@@ -598,6 +603,38 @@ export class SelectorController {
|
|
|
598
603
|
});
|
|
599
604
|
}
|
|
600
605
|
|
|
606
|
+
showCopySelector(): void {
|
|
607
|
+
const targets = buildCopyTargets(this.ctx.session);
|
|
608
|
+
if (targets.length === 0) {
|
|
609
|
+
this.ctx.showStatus("Nothing to copy yet.");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let overlayHandle: OverlayHandle | undefined;
|
|
614
|
+
const done = () => {
|
|
615
|
+
overlayHandle?.hide();
|
|
616
|
+
this.ctx.ui.requestRender();
|
|
617
|
+
};
|
|
618
|
+
const selector = new CopySelectorComponent(targets, {
|
|
619
|
+
onPick: target => {
|
|
620
|
+
done();
|
|
621
|
+
if (target.content === undefined) return;
|
|
622
|
+
void copyToClipboard(target.content);
|
|
623
|
+
this.ctx.showStatus(target.copyMessage ?? "Copied to clipboard");
|
|
624
|
+
},
|
|
625
|
+
onCancel: done,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
overlayHandle = this.ctx.ui.showOverlay(selector, {
|
|
629
|
+
anchor: "bottom-center",
|
|
630
|
+
width: "100%",
|
|
631
|
+
maxHeight: "100%",
|
|
632
|
+
margin: 0,
|
|
633
|
+
});
|
|
634
|
+
this.ctx.ui.setFocus(selector);
|
|
635
|
+
this.ctx.ui.requestRender();
|
|
636
|
+
}
|
|
637
|
+
|
|
601
638
|
showTreeSelector(): void {
|
|
602
639
|
const tree = this.ctx.sessionManager.getTree();
|
|
603
640
|
const realLeafId = this.ctx.sessionManager.getLeafId();
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import { type BlobPutResult, blobExtensionForImageMimeType } from "../session/blob-store";
|
|
4
|
+
import { fileHyperlink } from "../tui/hyperlink";
|
|
5
|
+
|
|
6
|
+
const IMAGE_REFERENCE_REGEX = /\[Image #([1-9]\d*)\]/g;
|
|
7
|
+
|
|
8
|
+
type ImageBlobWriter = (data: Buffer, options?: { extension?: string }) => Promise<BlobPutResult>;
|
|
9
|
+
type ImageBlobWriterSync = (data: Buffer, options?: { extension?: string }) => BlobPutResult;
|
|
10
|
+
|
|
11
|
+
export interface ImageReferenceRenderers {
|
|
12
|
+
renderText: (text: string) => string;
|
|
13
|
+
renderReference: (label: string, index: number) => string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function renderImageReferences(text: string, renderers: ImageReferenceRenderers): string {
|
|
17
|
+
IMAGE_REFERENCE_REGEX.lastIndex = 0;
|
|
18
|
+
let result = "";
|
|
19
|
+
let last = 0;
|
|
20
|
+
let matched = false;
|
|
21
|
+
|
|
22
|
+
for (;;) {
|
|
23
|
+
const match = IMAGE_REFERENCE_REGEX.exec(text);
|
|
24
|
+
if (match === null) break;
|
|
25
|
+
matched = true;
|
|
26
|
+
if (match.index > last) {
|
|
27
|
+
result += renderers.renderText(text.slice(last, match.index));
|
|
28
|
+
}
|
|
29
|
+
result += renderers.renderReference(match[0], Number(match[1]));
|
|
30
|
+
last = match.index + match[0].length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!matched) {
|
|
34
|
+
return renderers.renderText(text);
|
|
35
|
+
}
|
|
36
|
+
if (last < text.length) {
|
|
37
|
+
result += renderers.renderText(text.slice(last));
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function imageReferenceHyperlink(
|
|
43
|
+
label: string,
|
|
44
|
+
index: number,
|
|
45
|
+
imageLinks: readonly (string | undefined)[] | undefined,
|
|
46
|
+
renderLabel: (text: string) => string,
|
|
47
|
+
): string {
|
|
48
|
+
const rendered = renderLabel(label);
|
|
49
|
+
const target = imageLinks?.[index - 1];
|
|
50
|
+
return target ? fileHyperlink(target, rendered) : rendered;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function materializeImageReferenceLinkAsync(
|
|
54
|
+
image: ImageContent,
|
|
55
|
+
index: number,
|
|
56
|
+
putBlob: ImageBlobWriter,
|
|
57
|
+
): Promise<string | undefined> {
|
|
58
|
+
try {
|
|
59
|
+
const result = await putBlob(Buffer.from(image.data, "base64"), {
|
|
60
|
+
extension: blobExtensionForImageMimeType(image.mimeType),
|
|
61
|
+
});
|
|
62
|
+
return result.displayPath;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
logger.warn("Failed to write image reference blob", {
|
|
65
|
+
index,
|
|
66
|
+
mimeType: image.mimeType,
|
|
67
|
+
error: error instanceof Error ? error.message : String(error),
|
|
68
|
+
});
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function materializeImageReferenceLink(
|
|
74
|
+
image: ImageContent,
|
|
75
|
+
index: number,
|
|
76
|
+
putBlob: ImageBlobWriterSync,
|
|
77
|
+
): string | undefined {
|
|
78
|
+
try {
|
|
79
|
+
const result = putBlob(Buffer.from(image.data, "base64"), {
|
|
80
|
+
extension: blobExtensionForImageMimeType(image.mimeType),
|
|
81
|
+
});
|
|
82
|
+
return result.displayPath;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
logger.warn("Failed to write image reference blob", {
|
|
85
|
+
index,
|
|
86
|
+
mimeType: image.mimeType,
|
|
87
|
+
error: error instanceof Error ? error.message : String(error),
|
|
88
|
+
});
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function materializeImageReferenceLinks(
|
|
94
|
+
images: readonly ImageContent[] | undefined,
|
|
95
|
+
putBlob: ImageBlobWriter,
|
|
96
|
+
): Promise<(string | undefined)[] | undefined> {
|
|
97
|
+
if (!images || images.length === 0) return undefined;
|
|
98
|
+
const links = await Promise.all(
|
|
99
|
+
images.map((image, index) => materializeImageReferenceLinkAsync(image, index + 1, putBlob)),
|
|
100
|
+
);
|
|
101
|
+
return links.some(link => link !== undefined) ? links : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function materializeImageReferenceLinksSync(
|
|
105
|
+
images: readonly ImageContent[] | undefined,
|
|
106
|
+
putBlob: ImageBlobWriterSync,
|
|
107
|
+
): (string | undefined)[] | undefined {
|
|
108
|
+
if (!images || images.length === 0) return undefined;
|
|
109
|
+
const links = images.map((image, index) => materializeImageReferenceLink(image, index + 1, putBlob));
|
|
110
|
+
return links.some(link => link !== undefined) ? links : undefined;
|
|
111
|
+
}
|