@oh-my-pi/pi-coding-agent 16.1.20 → 16.1.22
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 +17 -0
- package/dist/cli.js +2284 -2272
- package/dist/types/advisor/advise-tool.d.ts +3 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
- package/dist/types/mcp/oauth-flow.d.ts +42 -5
- package/dist/types/mcp/transports/stdio.test.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +32 -0
- package/dist/types/modes/components/tool-execution.d.ts +5 -5
- package/dist/types/modes/controllers/event-controller.d.ts +10 -0
- package/dist/types/modes/controllers/input-controller.d.ts +2 -1
- package/dist/types/utils/clipboard.d.ts +10 -0
- package/package.json +12 -12
- package/scripts/generate-legacy-pi-bundled-registry.ts +10 -0
- package/src/advisor/__tests__/advisor.test.ts +44 -0
- package/src/advisor/advise-tool.ts +33 -0
- package/src/autolearn/controller.ts +17 -2
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +1 -1
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +4 -4
- package/src/extensibility/plugins/legacy-pi-compat.ts +193 -5
- package/src/mcp/manager.ts +12 -3
- package/src/mcp/oauth-discovery.ts +48 -1
- package/src/mcp/oauth-flow.ts +121 -7
- package/src/mcp/transports/stdio.test.ts +28 -0
- package/src/mcp/transports/stdio.ts +5 -2
- package/src/modes/components/chat-transcript-builder.ts +31 -0
- package/src/modes/components/custom-editor.test.ts +80 -0
- package/src/modes/components/custom-editor.ts +86 -6
- package/src/modes/components/tool-execution.ts +50 -25
- package/src/modes/controllers/event-controller.ts +57 -8
- package/src/modes/controllers/input-controller.ts +70 -27
- package/src/modes/controllers/mcp-command-controller.ts +18 -2
- package/src/modes/utils/ui-helpers.ts +40 -0
- package/src/prompts/system/autolearn-nudge-autocontinue.md +5 -0
- package/src/prompts/system/autolearn-nudge.md +4 -2
- package/src/prompts/tools/todo.md +1 -1
- package/src/session/agent-session.ts +4 -0
- package/src/tools/todo.ts +20 -10
- package/src/utils/clipboard.ts +57 -0
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
CustomEditor,
|
|
6
6
|
extractBracketedImagePastePaths,
|
|
7
7
|
extractBracketedPastePaths,
|
|
8
|
+
extractImagePathFromText,
|
|
9
|
+
extractPastePathsFromText,
|
|
8
10
|
SPACE_HOLD_MECHANICAL_RUN,
|
|
9
11
|
SPACE_HOLD_RELEASE_MS,
|
|
10
12
|
SPACE_REPEAT_MAX_GAP_MS,
|
|
@@ -88,6 +90,22 @@ describe("CustomEditor bracketed path paste", () => {
|
|
|
88
90
|
]);
|
|
89
91
|
});
|
|
90
92
|
|
|
93
|
+
it("strips `file://` URLs to the local filesystem path before loading the image", () => {
|
|
94
|
+
// macOS / Ghostty / iTerm2 sometimes forward the pasteboard's
|
|
95
|
+
// `public.file-url` representation when the user does Finder→Copy
|
|
96
|
+
// then Cmd+V. Without decoding, `loadImageInput` would try to read a
|
|
97
|
+
// literal `file:///…` path and fail.
|
|
98
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/Pictures/photo.png"))).toEqual([
|
|
99
|
+
"/Users/me/Pictures/photo.png",
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("percent-decodes spaces inside `file://` URLs", () => {
|
|
104
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/My%20Pictures/photo.png"))).toEqual([
|
|
105
|
+
"/Users/me/My Pictures/photo.png",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
91
109
|
it("extracts explicit non-image paths without classifying them as image paths", () => {
|
|
92
110
|
expect(extractBracketedPastePaths(bracketedPaste("/tmp/report.csv"))).toEqual(["/tmp/report.csv"]);
|
|
93
111
|
expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
|
|
@@ -107,6 +125,68 @@ describe("CustomEditor bracketed path paste", () => {
|
|
|
107
125
|
});
|
|
108
126
|
});
|
|
109
127
|
|
|
128
|
+
describe("extractImagePathFromText (issue #3506)", () => {
|
|
129
|
+
it("returns the path when the text is a single image file path", () => {
|
|
130
|
+
expect(extractImagePathFromText("/tmp/screenshot.png")).toBe("/tmp/screenshot.png");
|
|
131
|
+
expect(extractImagePathFromText("/Users/me/Pictures/photo.jpeg")).toBe("/Users/me/Pictures/photo.jpeg");
|
|
132
|
+
expect(extractImagePathFromText("C:\\Users\\me\\img.gif")).toBe("C:\\Users\\me\\img.gif");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("ignores surrounding whitespace from a clipboard read", () => {
|
|
136
|
+
expect(extractImagePathFromText(" /tmp/photo.webp\n")).toBe("/tmp/photo.webp");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns undefined for a bare filename (no explicit directory)", () => {
|
|
140
|
+
// Mirrors the bracketed-paste contract: a bare `.png` filename is
|
|
141
|
+
// almost always a project-relative reference the user wants as text,
|
|
142
|
+
// not a clipboard-anchored attachment.
|
|
143
|
+
expect(extractImagePathFromText("icon.png")).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("returns undefined for non-image extensions", () => {
|
|
147
|
+
expect(extractImagePathFromText("/tmp/report.csv")).toBeUndefined();
|
|
148
|
+
expect(extractImagePathFromText("/tmp/notes.txt")).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns undefined when the text contains anything beyond a single path", () => {
|
|
152
|
+
expect(extractImagePathFromText("see /tmp/screenshot.png")).toBeUndefined();
|
|
153
|
+
expect(extractImagePathFromText("/tmp/a.png /tmp/b.png")).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns undefined for empty/whitespace-only input", () => {
|
|
157
|
+
expect(extractImagePathFromText("")).toBeUndefined();
|
|
158
|
+
expect(extractImagePathFromText(" ")).toBeUndefined();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("decodes a `file://` URL to its filesystem path", () => {
|
|
162
|
+
expect(extractImagePathFromText("file:///Users/me/Pictures/photo.png")).toBe("/Users/me/Pictures/photo.png");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("recovers a single anchored image path containing unescaped spaces (macOS screenshot name)", () => {
|
|
166
|
+
const macScreenshot = "/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45 PM.png";
|
|
167
|
+
expect(extractImagePathFromText(macScreenshot)).toBe(macScreenshot);
|
|
168
|
+
expect(extractImagePathFromText("~/Pictures/Cleanshot 2026-06-25 at 12.00.png")).toBe(
|
|
169
|
+
"~/Pictures/Cleanshot 2026-06-25 at 12.00.png",
|
|
170
|
+
);
|
|
171
|
+
expect(extractImagePathFromText("C:\\Users\\me\\My Pictures\\img with space.jpg")).toBe(
|
|
172
|
+
"C:\\Users\\me\\My Pictures\\img with space.jpg",
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("does not hijack prose that happens to contain a path-shaped fragment", () => {
|
|
177
|
+
// The whole-text branch is gated on ABSOLUTE_PATH_PREFIX_REGEX, so a
|
|
178
|
+
// non-anchored prefix ("see ...") never triggers it.
|
|
179
|
+
expect(extractImagePathFromText("see /Users/me/Desktop/Screenshot 1.png")).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("extractPastePathsFromText", () => {
|
|
184
|
+
it("delegates to the same logic the bracketed variant uses for path detection", () => {
|
|
185
|
+
expect(extractPastePathsFromText("/tmp/a.png /tmp/b.png")).toEqual(["/tmp/a.png", "/tmp/b.png"]);
|
|
186
|
+
expect(extractPastePathsFromText("just text")).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
110
190
|
describe("CustomEditor space-hold push-to-talk", () => {
|
|
111
191
|
beforeAll(async () => {
|
|
112
192
|
await initTheme();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
1
2
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
3
|
import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui";
|
|
3
4
|
import type { AppKeybinding } from "../../config/keybindings";
|
|
@@ -66,6 +67,14 @@ const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
|
|
|
66
67
|
const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
|
|
67
68
|
const FILE_URI_REGEX = /^file:\/\//i;
|
|
68
69
|
const WINDOWS_DRIVE_PATH_REGEX = /^[a-z]:[\\/]/i;
|
|
70
|
+
/**
|
|
71
|
+
* Whole-string anchor for paths that are unambiguously absolute. Restricts the
|
|
72
|
+
* "treat the entire clipboard text as one path" branch of
|
|
73
|
+
* {@link extractImagePathFromText} to inputs that start with a clearly-anchored
|
|
74
|
+
* filesystem prefix, so prose containing a path-shaped fragment (e.g.
|
|
75
|
+
* "see /tmp/x.png") never hijacks the smart fallback.
|
|
76
|
+
*/
|
|
77
|
+
const ABSOLUTE_PATH_PREFIX_REGEX = /^(?:\/|~\/|file:\/\/|\\\\|[A-Za-z]:[\\/])/;
|
|
69
78
|
|
|
70
79
|
/** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
|
|
71
80
|
* deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
|
|
@@ -105,6 +114,20 @@ function normalizePastedPath(path: string): string {
|
|
|
105
114
|
const last = trimmed[trimmed.length - 1];
|
|
106
115
|
const unquoted =
|
|
107
116
|
trimmed.length > 1 && (first === '"' || first === "'") && last === first ? trimmed.slice(1, -1) : trimmed;
|
|
117
|
+
// `file://` URL → local filesystem path. Mirrors Codex's
|
|
118
|
+
// `normalize_pasted_path` (codex-rs/tui/src/clipboard_paste.rs) so a
|
|
119
|
+
// pasteboard whose text representation is a `file:///Users/…/img.png`
|
|
120
|
+
// URL — common when terminals forward the macOS pasteboard's
|
|
121
|
+
// `public.file-url` representation — loads as the file itself rather
|
|
122
|
+
// than failing in `loadImageInput` with a literal-`file://` path.
|
|
123
|
+
if (FILE_URI_REGEX.test(unquoted)) {
|
|
124
|
+
try {
|
|
125
|
+
return fileURLToPath(unquoted);
|
|
126
|
+
} catch {
|
|
127
|
+
// Malformed file URL: drop through to the shell-unescape branch
|
|
128
|
+
// so the caller can still reject it as a non-explicit path.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
108
131
|
return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
|
|
109
132
|
}
|
|
110
133
|
|
|
@@ -161,12 +184,15 @@ function splitPastedPathSegments(payload: string): string[] | undefined {
|
|
|
161
184
|
return segments.length > 0 ? segments : undefined;
|
|
162
185
|
}
|
|
163
186
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Extract whitespace/quoted-separated path-like segments from `payload`.
|
|
189
|
+
* Shared backend of {@link extractBracketedPastePaths} and {@link extractPastePathsFromText}.
|
|
190
|
+
* Returns the segments only when EVERY segment looks like an explicit path
|
|
191
|
+
* (`/`, `\`, drive letter, or `file://`); otherwise undefined so the caller
|
|
192
|
+
* falls back to a plain text paste.
|
|
193
|
+
*/
|
|
194
|
+
function extractExplicitPathSegments(payload: string): string[] | undefined {
|
|
195
|
+
const pasted = payload.trim();
|
|
170
196
|
if (!pasted) return undefined;
|
|
171
197
|
|
|
172
198
|
const segments = splitPastedPathSegments(pasted);
|
|
@@ -181,6 +207,23 @@ export function extractBracketedPastePaths(data: string): string[] | undefined {
|
|
|
181
207
|
return paths;
|
|
182
208
|
}
|
|
183
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Extract image-or-other file paths from plain (un-bracketed) clipboard text.
|
|
212
|
+
* Mirrors {@link extractBracketedPastePaths} for terminals/handlers that
|
|
213
|
+
* already stripped the `\x1b[200~`…`\x1b[201~` markers (e.g. clipboard text
|
|
214
|
+
* read directly via `pbpaste`/PowerShell).
|
|
215
|
+
*/
|
|
216
|
+
export function extractPastePathsFromText(text: string): string[] | undefined {
|
|
217
|
+
return extractExplicitPathSegments(text);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function extractBracketedPastePaths(data: string): string[] | undefined {
|
|
221
|
+
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
|
|
222
|
+
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
|
|
223
|
+
if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
|
|
224
|
+
return extractExplicitPathSegments(data.slice(BRACKETED_PASTE_START.length, endIndex));
|
|
225
|
+
}
|
|
226
|
+
|
|
184
227
|
export function extractBracketedImagePastePaths(data: string): string[] | undefined {
|
|
185
228
|
const paths = extractBracketedPastePaths(data);
|
|
186
229
|
return paths?.every(isImagePath) ? paths : undefined;
|
|
@@ -191,6 +234,43 @@ export function extractBracketedImagePastePath(data: string): string | undefined
|
|
|
191
234
|
return paths?.length === 1 ? paths[0] : undefined;
|
|
192
235
|
}
|
|
193
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Return a single image file path when `text` is exactly one explicit path
|
|
239
|
+
* pointing at a supported image extension (`.png`, `.jpg`/`.jpeg`, `.gif`,
|
|
240
|
+
* `.webp`). Used by the keybind-driven clipboard image paste path so a
|
|
241
|
+
* clipboard whose only payload is an image file (e.g. Finder `Cmd+C` on
|
|
242
|
+
* macOS) attaches the image instead of pasting the path as literal text.
|
|
243
|
+
*
|
|
244
|
+
* Two-stage detection:
|
|
245
|
+
*
|
|
246
|
+
* 1. Splitter pass (shared with the bracketed-paste handler) — handles
|
|
247
|
+
* quoted paths, shell-escaped spaces, and unambiguous single tokens.
|
|
248
|
+
* Returns the single image path when it parses cleanly; explicitly
|
|
249
|
+
* returns `undefined` when the splitter found multiple segments (so
|
|
250
|
+
* ambiguous multi-path clipboard text like `/tmp/a.png /tmp/b.png`
|
|
251
|
+
* still falls through to the text fallback instead of being mis-loaded
|
|
252
|
+
* as one giant path).
|
|
253
|
+
* 2. Whole-text-as-path pass — only reached when the splitter failed
|
|
254
|
+
* (every segment must look like an explicit path; an unescaped space in
|
|
255
|
+
* a real path breaks that). Restricted to inputs anchored by
|
|
256
|
+
* {@link ABSOLUTE_PATH_PREFIX_REGEX} so prose containing a path-shaped
|
|
257
|
+
* fragment ("see /tmp/x.png") never hijacks the smart fallback. This
|
|
258
|
+
* is what recovers macOS screenshot filenames like
|
|
259
|
+
* `/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45 PM.png`.
|
|
260
|
+
*/
|
|
261
|
+
export function extractImagePathFromText(text: string): string | undefined {
|
|
262
|
+
const paths = extractPastePathsFromText(text);
|
|
263
|
+
if (paths?.length === 1 && isImagePath(paths[0])) return paths[0];
|
|
264
|
+
if (paths !== undefined) return undefined;
|
|
265
|
+
const trimmed = text.trim();
|
|
266
|
+
if (!trimmed || /[\r\n]/.test(trimmed) || !ABSOLUTE_PATH_PREFIX_REGEX.test(trimmed)) return undefined;
|
|
267
|
+
const wholePath = normalizePastedPath(trimmed);
|
|
268
|
+
if (wholePath && isExplicitPastedPath(wholePath) && isImagePath(wholePath)) {
|
|
269
|
+
return wholePath;
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
194
274
|
/**
|
|
195
275
|
* Custom editor that handles configurable app-level shortcuts for coding-agent.
|
|
196
276
|
*/
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
truncateToWidth,
|
|
40
40
|
} from "../../tools/render-utils";
|
|
41
41
|
import { toolRenderers } from "../../tools/renderers";
|
|
42
|
-
import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
|
|
42
|
+
import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
|
|
43
43
|
import { isFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
|
|
44
44
|
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
|
45
45
|
import { renderDiff } from "./diff";
|
|
@@ -75,6 +75,28 @@ function stripTrailingUnbalancedRemoval(diff: string | undefined): string | unde
|
|
|
75
75
|
return lines.slice(0, lastAddIdx + 1).join("\n");
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
type DisplaceableToolName = "job" | "todo";
|
|
79
|
+
|
|
80
|
+
function isTodoToolDetails(details: unknown): details is TodoToolDetails {
|
|
81
|
+
return (
|
|
82
|
+
typeof details === "object" &&
|
|
83
|
+
details !== null &&
|
|
84
|
+
"phases" in details &&
|
|
85
|
+
Array.isArray((details as { phases?: unknown }).phases)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function displaceableToolName(
|
|
90
|
+
toolName: string,
|
|
91
|
+
result: { details?: unknown; isError?: boolean },
|
|
92
|
+
isPartial: boolean,
|
|
93
|
+
): DisplaceableToolName | undefined {
|
|
94
|
+
if (result.isError === true) return undefined;
|
|
95
|
+
if (toolName === "job" && isWaitingPollDetails(result.details)) return "job";
|
|
96
|
+
if (toolName === "todo" && !isPartial && isTodoToolDetails(result.details)) return "todo";
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
function stabilizeStreamingPreviews(previews: PerFileDiffPreview[]): PerFileDiffPreview[] {
|
|
79
101
|
let changed = false;
|
|
80
102
|
const next = previews.map(preview => {
|
|
@@ -234,11 +256,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
234
256
|
// sealed the block stays in the transcript's repaintable live region so a
|
|
235
257
|
// late result still repaints instead of stranding the streaming preview.
|
|
236
258
|
#sealed = false;
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
#
|
|
259
|
+
// Tool result snapshots that may be superseded by a later same-tool call
|
|
260
|
+
// while still in the transcript live region. `job` uses this for repeated
|
|
261
|
+
// all-running polls; `todo` uses it for per-turn state snapshots so only the
|
|
262
|
+
// latest list remains visible.
|
|
263
|
+
#displaceableByToolName: DisplaceableToolName | undefined;
|
|
242
264
|
// Probe into the owning transcript (absent outside the interactive
|
|
243
265
|
// transcript, e.g. in tests): whether this block is still repaintable.
|
|
244
266
|
#liveRegion?: TranscriptLiveRegionProbe;
|
|
@@ -427,11 +449,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
427
449
|
this.#result = result;
|
|
428
450
|
this.#resultVersion++;
|
|
429
451
|
this.#isPartial = isPartial;
|
|
430
|
-
|
|
431
|
-
// "still waiting" chrome; keep the block displaceable so the next `job`
|
|
432
|
-
// call replaces it instead of stacking another waiting frame (see the
|
|
433
|
-
// event controller's displaceable-poll bookkeeping).
|
|
434
|
-
this.#displaceable = this.#toolName === "job" && result.isError !== true && isWaitingPollDetails(result.details);
|
|
452
|
+
this.#displaceableByToolName = displaceableToolName(this.#toolName, result, isPartial);
|
|
435
453
|
// When tool is complete, ensure args are marked complete so spinner stops
|
|
436
454
|
if (!isPartial) {
|
|
437
455
|
this.#argsComplete = true;
|
|
@@ -490,10 +508,12 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
490
508
|
}
|
|
491
509
|
|
|
492
510
|
/**
|
|
493
|
-
* Start or stop spinner animation
|
|
511
|
+
* Start or stop spinner animation for result states that visibly tick.
|
|
494
512
|
*/
|
|
495
513
|
#updateSpinnerAnimation(): void {
|
|
496
|
-
// Spinner for: task tool with partial result,
|
|
514
|
+
// Spinner for: task tool with partial result, edit/write while args
|
|
515
|
+
// streaming, or a still-running job poll. Todo snapshots stay live for
|
|
516
|
+
// displacement but should remain visually static.
|
|
497
517
|
const isStreamingArgs = !this.#argsComplete && (isEditLikeToolName(this.#toolName) || this.#toolName === "write");
|
|
498
518
|
const isBackgroundAsyncRunning =
|
|
499
519
|
(this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
|
|
@@ -502,7 +522,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
502
522
|
// Detached async task progress rows are static now; progress snapshots
|
|
503
523
|
// still call #maybeFreezeBackgroundTask before applying so rows settle
|
|
504
524
|
// once the block leaves the live region.
|
|
505
|
-
const needsSpinner = isStreamingArgs || isPartialTask || this
|
|
525
|
+
const needsSpinner = isStreamingArgs || isPartialTask || this.#displaceableByToolName === "job";
|
|
506
526
|
if (needsSpinner && !this.#spinnerInterval) {
|
|
507
527
|
const frameCount = theme.spinnerFrames.length;
|
|
508
528
|
const frame = sharedSpinnerFrame(frameCount);
|
|
@@ -610,9 +630,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
610
630
|
isTranscriptBlockFinalized(): boolean {
|
|
611
631
|
if (this.#sealed) return true;
|
|
612
632
|
if (this.#result === undefined) return false;
|
|
613
|
-
// A displaceable
|
|
614
|
-
//
|
|
615
|
-
if (this.#
|
|
633
|
+
// A displaceable snapshot stays live: its rows are kept out of native
|
|
634
|
+
// scrollback so a follow-up tool call can remove the block.
|
|
635
|
+
if (this.#displaceableByToolName) return false;
|
|
616
636
|
if (!this.#isPartial) return true;
|
|
617
637
|
// Partial result: a background async tool is accepted to freeze (the agent
|
|
618
638
|
// continues while it runs and would otherwise pin an unbounded live region);
|
|
@@ -630,7 +650,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
630
650
|
* committed prefix survives the remaining transitions.
|
|
631
651
|
*/
|
|
632
652
|
isTranscriptBlockCommitStable(): boolean {
|
|
633
|
-
if (this.#
|
|
653
|
+
if (this.#displaceableByToolName) return false;
|
|
634
654
|
if (this.isTranscriptBlockFinalized()) return true;
|
|
635
655
|
// `provisionalPendingPreview` describes only the PENDING call preview
|
|
636
656
|
// (`renderCall`, before any result): the result render may re-anchor it
|
|
@@ -658,7 +678,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
658
678
|
seal(): void {
|
|
659
679
|
if (this.#sealed) return;
|
|
660
680
|
this.#sealed = true;
|
|
661
|
-
this.#
|
|
681
|
+
this.#displaceableByToolName = undefined;
|
|
662
682
|
// A sealed detached task is abandoned history: settle its progress rows
|
|
663
683
|
// on static gray.
|
|
664
684
|
this.#backgroundTaskFrozen = true;
|
|
@@ -668,14 +688,19 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
|
|
668
688
|
}
|
|
669
689
|
|
|
670
690
|
/**
|
|
671
|
-
* Whether this block is a
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
*
|
|
675
|
-
* follow-up `job` call supersedes it.
|
|
691
|
+
* Whether this block is a supersedable result snapshot that has not been
|
|
692
|
+
* sealed. Such a block never finalized, so none of its rows entered native
|
|
693
|
+
* scrollback and the whole block can be removed when a follow-up matching
|
|
694
|
+
* tool call supersedes it.
|
|
676
695
|
*/
|
|
677
696
|
isDisplaceableBlock(): boolean {
|
|
678
|
-
return this.#
|
|
697
|
+
return this.#displaceableByToolName !== undefined && !this.#sealed;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
canBeDisplacedBy(nextToolName: string | undefined): boolean {
|
|
701
|
+
return (
|
|
702
|
+
this.#displaceableByToolName !== undefined && this.#displaceableByToolName === nextToolName && !this.#sealed
|
|
703
|
+
);
|
|
679
704
|
}
|
|
680
705
|
|
|
681
706
|
/**
|
|
@@ -77,6 +77,10 @@ export class EventController {
|
|
|
77
77
|
// one persistent poll instead of a stack of "waiting on N jobs" frames —
|
|
78
78
|
// and sealed in place the moment anything else lands below it.
|
|
79
79
|
#displaceablePollComponent: ToolExecutionComponent | undefined = undefined;
|
|
80
|
+
// Most recent successful `todo` snapshot in the active turn. It stays live
|
|
81
|
+
// across intervening tool output so a later `todo` update can replace the
|
|
82
|
+
// old full list; the turn boundary seals the final snapshot as history.
|
|
83
|
+
#displaceableTodoComponent: ToolExecutionComponent | undefined = undefined;
|
|
80
84
|
// Most recent TTSR notification block. A new ttsr_triggered event merges its
|
|
81
85
|
// rules into this block while it is still the (live-region) transcript tail.
|
|
82
86
|
#lastTtsrNotification: TtsrNotificationComponent | undefined = undefined;
|
|
@@ -226,6 +230,7 @@ export class EventController {
|
|
|
226
230
|
this.#ircExpiryTimers.clear();
|
|
227
231
|
this.#liveIrcCards.clear();
|
|
228
232
|
this.#displaceablePollComponent = undefined;
|
|
233
|
+
this.#displaceableTodoComponent = undefined;
|
|
229
234
|
this.#lastTtsrNotification = undefined;
|
|
230
235
|
this.#streamingReveal.stop();
|
|
231
236
|
this.#toolArgsReveal.stop();
|
|
@@ -248,6 +253,7 @@ export class EventController {
|
|
|
248
253
|
this.#readToolCallArgs.clear();
|
|
249
254
|
this.#readToolCallAssistantComponents.clear();
|
|
250
255
|
this.#resetReadGroup();
|
|
256
|
+
this.#resolveDisplaceableTodo();
|
|
251
257
|
this.#lastAssistantComponent = undefined;
|
|
252
258
|
// Restore the previous turn's inline error in the transcript before dropping
|
|
253
259
|
// the banner, so the error stays in history once the banner is gone.
|
|
@@ -296,6 +302,7 @@ export class EventController {
|
|
|
296
302
|
|
|
297
303
|
this.#resetReadGroup();
|
|
298
304
|
this.#resolveDisplaceablePoll();
|
|
305
|
+
this.#resolveDisplaceableTodo();
|
|
299
306
|
const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
|
|
300
307
|
const matchedLocalSubmission = this.ctx.locallySubmittedUserSignatures.delete(signature);
|
|
301
308
|
const replacesOptimistic =
|
|
@@ -423,6 +430,38 @@ export class EventController {
|
|
|
423
430
|
this.ctx.ui.requestRender();
|
|
424
431
|
}
|
|
425
432
|
|
|
433
|
+
#resolveDisplaceableTodo(nextToolName?: string): void {
|
|
434
|
+
const previous = this.#displaceableTodoComponent;
|
|
435
|
+
if (!previous) return;
|
|
436
|
+
if (!previous.isDisplaceableBlock()) {
|
|
437
|
+
this.#displaceableTodoComponent = undefined;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (previous.canBeDisplacedBy(nextToolName)) {
|
|
441
|
+
this.#displaceableTodoComponent = undefined;
|
|
442
|
+
this.ctx.chatContainer.removeChild(previous);
|
|
443
|
+
previous.seal();
|
|
444
|
+
this.ctx.ui.requestRender();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (nextToolName !== undefined) return;
|
|
448
|
+
this.#displaceableTodoComponent = undefined;
|
|
449
|
+
previous.seal();
|
|
450
|
+
this.ctx.ui.requestRender();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Adopt a rebuilt-tail todo snapshot as the controller's tracked live
|
|
455
|
+
* snapshot. Used by rebuild paths (settings/extensions overlay close, focus
|
|
456
|
+
* attach, /resume) to preserve displacement continuity when a turn is still
|
|
457
|
+
* active — without this, the next same-turn `todo` update would stack
|
|
458
|
+
* another panel because the controller's tracker was reset before rebuild.
|
|
459
|
+
* Drops the candidate when it is no longer a displaceable todo.
|
|
460
|
+
*/
|
|
461
|
+
inheritDisplaceableTodo(component: ToolExecutionComponent | null | undefined): void {
|
|
462
|
+
this.#displaceableTodoComponent = component?.canBeDisplacedBy("todo") ? component : undefined;
|
|
463
|
+
}
|
|
464
|
+
|
|
426
465
|
async #handleNotice(event: Extract<AgentSessionEvent, { type: "notice" }>): Promise<void> {
|
|
427
466
|
const message = event.source ? `${event.source}: ${event.message}` : event.message;
|
|
428
467
|
if (event.level === "error") {
|
|
@@ -699,8 +738,8 @@ export class EventController {
|
|
|
699
738
|
|
|
700
739
|
async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
|
|
701
740
|
this.#updateWorkingMessageFromIntent(event.intent);
|
|
741
|
+
this.#resolveDisplaceablePoll(event.toolName);
|
|
702
742
|
if (!this.ctx.pendingTools.has(event.toolCallId)) {
|
|
703
|
-
this.#resolveDisplaceablePoll(event.toolName);
|
|
704
743
|
if (event.toolName === "read" && readArgsHaveTarget(event.args) && !readArgsTargetInternalUrl(event.args)) {
|
|
705
744
|
this.#trackReadToolCall(event.toolCallId, event.args);
|
|
706
745
|
const component = this.ctx.pendingTools.get(event.toolCallId);
|
|
@@ -830,13 +869,22 @@ export class EventController {
|
|
|
830
869
|
this.ctx.pendingTools.delete(event.toolCallId);
|
|
831
870
|
this.#backgroundToolCallIds.delete(event.toolCallId);
|
|
832
871
|
}
|
|
833
|
-
if (
|
|
834
|
-
event.toolName === "job" &&
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
872
|
+
if (component instanceof ToolExecutionComponent && component.isDisplaceableBlock()) {
|
|
873
|
+
if (event.toolName === "job" && component.canBeDisplacedBy("job")) {
|
|
874
|
+
// Remember the waiting poll so the next `job` call can displace it.
|
|
875
|
+
this.#displaceablePollComponent = component;
|
|
876
|
+
} else if (event.toolName === "todo" && component.canBeDisplacedBy("todo")) {
|
|
877
|
+
// Successful todo update supersedes the prior live snapshot. A failed
|
|
878
|
+
// follow-up never reaches this branch (canBeDisplacedBy("todo") returns
|
|
879
|
+
// false for errored results), so the last-good panel stays on screen.
|
|
880
|
+
const previous = this.#displaceableTodoComponent;
|
|
881
|
+
if (previous && previous !== component && previous.isDisplaceableBlock()) {
|
|
882
|
+
this.#displaceableTodoComponent = undefined;
|
|
883
|
+
this.ctx.chatContainer.removeChild(previous);
|
|
884
|
+
previous.seal();
|
|
885
|
+
}
|
|
886
|
+
this.#displaceableTodoComponent = component;
|
|
887
|
+
}
|
|
840
888
|
}
|
|
841
889
|
this.ctx.ui.requestRender();
|
|
842
890
|
}
|
|
@@ -918,6 +966,7 @@ export class EventController {
|
|
|
918
966
|
// The turn is over: nothing else lands this turn, so the waiting poll is
|
|
919
967
|
// final history — seal it instead of letting its spinner tick while idle.
|
|
920
968
|
this.#resolveDisplaceablePoll();
|
|
969
|
+
this.#resolveDisplaceableTodo();
|
|
921
970
|
this.#lastAssistantComponent = undefined;
|
|
922
971
|
this.ctx.ui.requestRender();
|
|
923
972
|
this.#scheduleIdleCompaction();
|
|
@@ -6,6 +6,7 @@ import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
|
6
6
|
import { isSettingsInitialized, settings } from "../../config/settings";
|
|
7
7
|
import { resolveLocalRoot } from "../../internal-urls";
|
|
8
8
|
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
|
|
9
|
+
import { extractImagePathFromText } from "../../modes/components/custom-editor";
|
|
9
10
|
import { renderSegmentTrack } from "../../modes/components/segment-track";
|
|
10
11
|
import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
|
|
11
12
|
import { expandEmoticons } from "../../modes/emoji-autocomplete";
|
|
@@ -20,7 +21,12 @@ import { isLowSignalTitleInput } from "../../tiny/text";
|
|
|
20
21
|
import { tinyTitleClient } from "../../tiny/title-client";
|
|
21
22
|
import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
|
|
22
23
|
import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
copyToClipboard,
|
|
26
|
+
readImageFromClipboard,
|
|
27
|
+
readMacFileUrlsFromClipboard,
|
|
28
|
+
readTextFromClipboard,
|
|
29
|
+
} from "../../utils/clipboard";
|
|
24
30
|
import { EnhancedPasteController } from "../../utils/enhanced-paste";
|
|
25
31
|
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
|
26
32
|
import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
|
|
@@ -126,7 +132,12 @@ export class InputController {
|
|
|
126
132
|
private clipboard: {
|
|
127
133
|
readImage: typeof readImageFromClipboard;
|
|
128
134
|
readText: typeof readTextFromClipboard;
|
|
129
|
-
|
|
135
|
+
readMacFileUrls?: typeof readMacFileUrlsFromClipboard;
|
|
136
|
+
} = {
|
|
137
|
+
readImage: readImageFromClipboard,
|
|
138
|
+
readText: readTextFromClipboard,
|
|
139
|
+
readMacFileUrls: readMacFileUrlsFromClipboard,
|
|
140
|
+
},
|
|
130
141
|
) {}
|
|
131
142
|
|
|
132
143
|
#enhancedPaste?: EnhancedPasteController;
|
|
@@ -1386,33 +1397,65 @@ export class InputController {
|
|
|
1386
1397
|
async handleImagePaste(): Promise<boolean> {
|
|
1387
1398
|
try {
|
|
1388
1399
|
const image = await this.clipboard.readImage();
|
|
1389
|
-
if (
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1400
|
+
if (image) {
|
|
1401
|
+
return await this.#normalizeAndInsertPastedImage(
|
|
1402
|
+
{
|
|
1403
|
+
type: "image",
|
|
1404
|
+
data: image.data.toBase64(),
|
|
1405
|
+
mimeType: image.mimeType,
|
|
1406
|
+
},
|
|
1407
|
+
`Unsupported clipboard image format: ${image.mimeType}`,
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
// #3506: macOS Finder `Cmd+C` puts only a `public.file-url`
|
|
1411
|
+
// representation on the pasteboard. `pbpaste` (the backing call
|
|
1412
|
+
// for `readText` on Darwin) only surfaces plain text / RTF / EPS,
|
|
1413
|
+
// so it returns empty for file-url-only pasteboards — the smart
|
|
1414
|
+
// text fallback below would dead-end with "Clipboard is empty".
|
|
1415
|
+
// Reach the file URL directly via AppleScript and route every
|
|
1416
|
+
// image-shaped path through {@link handleImagePathPaste}, matching
|
|
1417
|
+
// the bracketed-paste handler in `CustomEditor.handleInput` which
|
|
1418
|
+
// iterates every extracted image path. Multi-image Finder
|
|
1419
|
+
// selections must not silently drop after the first attach.
|
|
1420
|
+
// `readMacFileUrls` returns an empty list off Darwin, so the
|
|
1421
|
+
// check is free on every other platform.
|
|
1422
|
+
const fileUrls = (await this.clipboard.readMacFileUrls?.()) ?? [];
|
|
1423
|
+
let attachedFromFileUrls = false;
|
|
1424
|
+
for (const url of fileUrls) {
|
|
1425
|
+
const candidate = extractImagePathFromText(url);
|
|
1426
|
+
if (!candidate) continue;
|
|
1427
|
+
await this.handleImagePathPaste(candidate);
|
|
1428
|
+
attachedFromFileUrls = true;
|
|
1429
|
+
}
|
|
1430
|
+
if (attachedFromFileUrls) return true;
|
|
1431
|
+
// Smart paste (#1628): no image on the clipboard — fall back to
|
|
1432
|
+
// pasting its text so the same chord covers both payload kinds.
|
|
1433
|
+
// Hosts that pre-empt the terminal's own paste (VS Code's
|
|
1434
|
+
// integrated terminal, Win+V clipboard history) deliver only
|
|
1435
|
+
// this keypress, so a miss here must not dead-end.
|
|
1436
|
+
const text = await this.clipboard.readText();
|
|
1437
|
+
if (!text) {
|
|
1438
|
+
this.ctx.showStatus("Clipboard is empty");
|
|
1439
|
+
return false;
|
|
1440
|
+
}
|
|
1441
|
+
// #3506: when the clipboard text is an explicit image file path,
|
|
1442
|
+
// route through {@link handleImagePathPaste} so the image is
|
|
1443
|
+
// loaded and attached instead of pasting the path as literal
|
|
1444
|
+
// text. Covers terminals that paste the Finder file path as
|
|
1445
|
+
// plain text rather than as a `public.file-url` (most macOS
|
|
1446
|
+
// terminals do this for image clipboards).
|
|
1447
|
+
const imagePath = extractImagePathFromText(text);
|
|
1448
|
+
if (imagePath) {
|
|
1449
|
+
await this.handleImagePathPaste(imagePath);
|
|
1406
1450
|
return true;
|
|
1407
1451
|
}
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
);
|
|
1452
|
+
// Route to the focused component when it accepts pastes (modal
|
|
1453
|
+
// Input prompts), matching the enhanced-paste text path (#2127).
|
|
1454
|
+
const focused = this.ctx.ui.getFocused();
|
|
1455
|
+
const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
|
|
1456
|
+
target.pasteText(text);
|
|
1457
|
+
this.ctx.ui.requestRender();
|
|
1458
|
+
return true;
|
|
1416
1459
|
} catch {
|
|
1417
1460
|
this.ctx.showStatus("Failed to read clipboard");
|
|
1418
1461
|
return false;
|