@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.5
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 +39 -1
- package/dist/types/cli/classify-install-target.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +13 -4
- package/dist/types/modes/components/assistant-message.d.ts +11 -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/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/image-references.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +7 -0
- package/dist/types/modes/types.d.ts +7 -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/tui/hyperlink.d.ts +12 -0
- package/dist/types/web/search/render.d.ts +1 -2
- package/package.json +9 -9
- package/src/cli/classify-install-target.ts +31 -5
- package/src/cli/plugin-cli.ts +45 -0
- package/src/cli/web-search-cli.ts +0 -1
- package/src/config/model-registry.ts +54 -4
- package/src/config/settings-schema.ts +14 -4
- package/src/eval/__tests__/agent-bridge.test.ts +72 -0
- package/src/eval/py/tool-bridge.ts +43 -5
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/main.ts +7 -1
- package/src/modes/components/assistant-message.ts +22 -0
- package/src/modes/components/custom-editor.ts +14 -2
- package/src/modes/components/error-banner.ts +33 -0
- package/src/modes/components/tool-execution.ts +44 -0
- package/src/modes/components/transcript-container.ts +93 -32
- package/src/modes/components/user-message.ts +9 -2
- package/src/modes/controllers/event-controller.ts +42 -3
- package/src/modes/controllers/input-controller.ts +33 -1
- package/src/modes/image-references.ts +111 -0
- package/src/modes/interactive-mode.ts +48 -13
- package/src/modes/types.ts +10 -1
- 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/sdk.ts +17 -9
- package/src/session/agent-session.ts +37 -12
- package/src/session/blob-store.ts +96 -9
- package/src/session/session-manager.ts +19 -10
- package/src/system-prompt.ts +4 -0
- 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/fetch.ts +29 -9
- package/src/tools/gh.ts +65 -11
- package/src/tools/index.ts +6 -8
- package/src/tools/read.ts +58 -12
- package/src/tools/search-tool-bm25.ts +4 -6
- package/src/tools/search.ts +60 -11
- package/src/tui/hyperlink.ts +42 -7
- package/src/web/search/index.ts +2 -2
- package/src/web/search/render.ts +20 -52
package/src/main.ts
CHANGED
|
@@ -285,7 +285,13 @@ async function runInteractiveMode(
|
|
|
285
285
|
})
|
|
286
286
|
.catch(() => {});
|
|
287
287
|
|
|
288
|
-
|
|
288
|
+
// Cold-launch cleanup: wipe the terminal scrollback before painting the
|
|
289
|
+
// resumed/new transcript. The TUI's initial paint deliberately preserves
|
|
290
|
+
// native scrollback (prior shell content), but on `omp`/`omp -c` that leaves
|
|
291
|
+
// the previous run's welcome + transcript stacked above the fresh one. Every
|
|
292
|
+
// in-process session load already clears via `clearTerminalHistory`; the cold
|
|
293
|
+
// launch is the lone path that did not.
|
|
294
|
+
mode.renderInitialMessages(undefined, { preserveExistingChat: true, clearTerminalHistory: true });
|
|
289
295
|
|
|
290
296
|
for (const notify of notifs) {
|
|
291
297
|
if (!notify) {
|
|
@@ -17,6 +17,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
17
17
|
#usageInfo?: Usage;
|
|
18
18
|
#convertedKittyImages = new Map<string, ImageContent>();
|
|
19
19
|
#kittyConversionsInFlight = new Set<string>();
|
|
20
|
+
#transcriptBlockFinalized: boolean;
|
|
20
21
|
|
|
21
22
|
constructor(
|
|
22
23
|
message?: AssistantMessage,
|
|
@@ -26,6 +27,7 @@ export class AssistantMessageComponent extends Container {
|
|
|
26
27
|
private readonly imageBudget?: ImageBudget,
|
|
27
28
|
) {
|
|
28
29
|
super();
|
|
30
|
+
this.#transcriptBlockFinalized = message !== undefined;
|
|
29
31
|
|
|
30
32
|
// Container for text/thinking content
|
|
31
33
|
this.#contentContainer = new Container();
|
|
@@ -47,6 +49,26 @@ export class AssistantMessageComponent extends Container {
|
|
|
47
49
|
this.hideThinkingBlock = hide;
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
isTranscriptBlockFinalized(): boolean {
|
|
53
|
+
return this.#transcriptBlockFinalized;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Assistant text/thinking streams in append-only: earlier rendered rows never
|
|
58
|
+
* re-layout, new content only grows the block at the bottom. The transcript
|
|
59
|
+
* reports this so the renderer may commit scrolled-off head rows of a long
|
|
60
|
+
* streamed reply to native scrollback instead of dropping them (see
|
|
61
|
+
* `NativeScrollbackLiveRegion#getNativeScrollbackCommitSafeEnd`). Volatile
|
|
62
|
+
* blocks (tool previews that collapse) intentionally do not implement this.
|
|
63
|
+
*/
|
|
64
|
+
isTranscriptBlockAppendOnly(): boolean {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
markTranscriptBlockFinalized(): void {
|
|
69
|
+
this.#transcriptBlockFinalized = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
setToolResultImages(toolCallId: string, images: ImageContent[]): void {
|
|
51
73
|
if (!toolCallId) return;
|
|
52
74
|
const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
|
|
2
2
|
import type { AppKeybinding } from "../../config/keybindings";
|
|
3
|
+
import { imageReferenceHyperlink, renderImageReferences } from "../image-references";
|
|
3
4
|
import { highlightMagicKeywords } from "../magic-keywords";
|
|
5
|
+
import { theme } from "../theme/theme";
|
|
4
6
|
|
|
5
7
|
type ConfigurableEditorAction = Extract<
|
|
6
8
|
AppKeybinding,
|
|
@@ -47,9 +49,19 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
|
|
|
47
49
|
* Custom editor that handles configurable app-level shortcuts for coding-agent.
|
|
48
50
|
*/
|
|
49
51
|
export class CustomEditor extends Editor {
|
|
52
|
+
imageLinks?: readonly (string | undefined)[];
|
|
53
|
+
|
|
50
54
|
/** Gradient-highlight the "ultrathink" / "orchestrate" / "workflow" keywords as the user types
|
|
51
|
-
* them, skipping any occurrence inside code spans, fenced blocks, or XML sections.
|
|
52
|
-
|
|
55
|
+
* them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make
|
|
56
|
+
* pasted image placeholders visually distinct and hyperlink them once their blob file exists. */
|
|
57
|
+
decorateText = (text: string): string =>
|
|
58
|
+
renderImageReferences(text, {
|
|
59
|
+
renderText: value => highlightMagicKeywords(value),
|
|
60
|
+
renderReference: (value, index) =>
|
|
61
|
+
imageReferenceHyperlink(value, index, this.imageLinks, label =>
|
|
62
|
+
theme.fg("accent", `\x1b[1m\x1b[4m${label}\x1b[24m\x1b[22m`),
|
|
63
|
+
),
|
|
64
|
+
});
|
|
53
65
|
onEscape?: () => void;
|
|
54
66
|
onClear?: () => void;
|
|
55
67
|
onExit?: () => void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
|
|
2
|
+
import { getPreviewLines, TRUNCATE_LENGTHS } from "../../tools/render-utils";
|
|
3
|
+
import { theme } from "../theme/theme";
|
|
4
|
+
import { DynamicBorder } from "./dynamic-border";
|
|
5
|
+
|
|
6
|
+
/** Max lines of the error message shown in the pinned banner. */
|
|
7
|
+
const MAX_BANNER_LINES = 3;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A persistent error banner pinned above the editor. Unlike the transcript
|
|
11
|
+
* "Error: …" line (which scrolls away as the conversation grows), this stays in
|
|
12
|
+
* the fixed region directly above the input so a turn that ended on a provider
|
|
13
|
+
* error — e.g. Anthropic's "Output blocked by content filtering policy" — cannot
|
|
14
|
+
* be missed. It is cleared when the next turn starts.
|
|
15
|
+
*/
|
|
16
|
+
export class ErrorBannerComponent extends Container {
|
|
17
|
+
constructor(message: string) {
|
|
18
|
+
super();
|
|
19
|
+
const lines = getPreviewLines(message, MAX_BANNER_LINES, TRUNCATE_LENGTHS.LINE);
|
|
20
|
+
if (lines.length === 0) {
|
|
21
|
+
lines.push("Unknown error");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
this.addChild(new Spacer(1));
|
|
25
|
+
this.addChild(new DynamicBorder(str => theme.fg("error", str)));
|
|
26
|
+
this.addChild(new Text(theme.bold(theme.fg("error", `${theme.status.error} ${lines[0]}`)), 1, 0));
|
|
27
|
+
for (const line of lines.slice(1)) {
|
|
28
|
+
this.addChild(new Text(theme.fg("error", ` ${line}`), 1, 0));
|
|
29
|
+
}
|
|
30
|
+
this.addChild(new Text(theme.fg("dim", "Dismissed when you send your next message."), 1, 0));
|
|
31
|
+
this.addChild(new DynamicBorder(str => theme.fg("error", str)));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -197,6 +197,11 @@ export class ToolExecutionComponent extends Container {
|
|
|
197
197
|
#todoStrikeInterval?: NodeJS.Timeout;
|
|
198
198
|
// Track if args are still being streamed (for edit/write spinner)
|
|
199
199
|
#argsComplete = false;
|
|
200
|
+
// Sealed once the tool reaches a terminal state (result delivered, or the
|
|
201
|
+
// turn abandoned it without one). Drives `isTranscriptBlockFinalized`: until
|
|
202
|
+
// sealed the block stays in the transcript's repaintable live region so a
|
|
203
|
+
// late result still repaints instead of stranding the streaming preview.
|
|
204
|
+
#sealed = false;
|
|
200
205
|
#renderState: {
|
|
201
206
|
spinnerFrame?: number;
|
|
202
207
|
expanded: boolean;
|
|
@@ -448,6 +453,13 @@ export class ToolExecutionComponent extends Container {
|
|
|
448
453
|
} else if (!needsSpinner && this.#spinnerInterval) {
|
|
449
454
|
clearInterval(this.#spinnerInterval);
|
|
450
455
|
this.#spinnerInterval = undefined;
|
|
456
|
+
// Clear the last drawn frame so a non-live renderCall (e.g. a write whose
|
|
457
|
+
// args just completed) stops showing a frozen spinner glyph. Skip when a
|
|
458
|
+
// todo strike owns the frame — it sets its own value right after this.
|
|
459
|
+
if (!this.#todoStrikeInterval) {
|
|
460
|
+
this.#spinnerFrame = undefined;
|
|
461
|
+
this.#renderState.spinnerFrame = undefined;
|
|
462
|
+
}
|
|
451
463
|
}
|
|
452
464
|
}
|
|
453
465
|
|
|
@@ -488,6 +500,37 @@ export class ToolExecutionComponent extends Container {
|
|
|
488
500
|
}
|
|
489
501
|
}
|
|
490
502
|
|
|
503
|
+
/**
|
|
504
|
+
* Whether this block has reached a terminal state for transcript freezing.
|
|
505
|
+
* Reports `false` while it can still visually change so the
|
|
506
|
+
* {@link TranscriptContainer} keeps it inside the repaintable live region:
|
|
507
|
+
* a foreground tool awaiting its result, or one streaming partial output.
|
|
508
|
+
* A final (non-partial) result, a background-async tool the agent has moved
|
|
509
|
+
* past, or an explicit {@link seal} flips it to `true`.
|
|
510
|
+
*/
|
|
511
|
+
isTranscriptBlockFinalized(): boolean {
|
|
512
|
+
if (this.#sealed) return true;
|
|
513
|
+
if (this.#result === undefined) return false;
|
|
514
|
+
if (!this.#isPartial) return true;
|
|
515
|
+
// Partial result: a background async tool is accepted to freeze (the agent
|
|
516
|
+
// continues while it runs and would otherwise pin an unbounded live region);
|
|
517
|
+
// a foreground tool streaming partial output stays live until it finishes.
|
|
518
|
+
return (this.#result.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Mark the tool terminal even though no result arrived (the turn aborted or
|
|
523
|
+
* abandoned it) and stop animating, so it can freeze and stops pinning the
|
|
524
|
+
* transcript live region.
|
|
525
|
+
*/
|
|
526
|
+
seal(): void {
|
|
527
|
+
if (this.#sealed) return;
|
|
528
|
+
this.#sealed = true;
|
|
529
|
+
this.stopAnimation();
|
|
530
|
+
this.#updateDisplay();
|
|
531
|
+
this.#ui.requestRender();
|
|
532
|
+
}
|
|
533
|
+
|
|
491
534
|
/**
|
|
492
535
|
* Stop spinner animation and cleanup resources.
|
|
493
536
|
*/
|
|
@@ -496,6 +539,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
496
539
|
clearInterval(this.#spinnerInterval);
|
|
497
540
|
this.#spinnerInterval = undefined;
|
|
498
541
|
this.#spinnerFrame = undefined;
|
|
542
|
+
this.#renderState.spinnerFrame = undefined;
|
|
499
543
|
}
|
|
500
544
|
this.#stopTodoStrikeAnimation();
|
|
501
545
|
this.#editDiffAbort?.abort();
|
|
@@ -12,6 +12,33 @@ interface SnapshotCarrier {
|
|
|
12
12
|
[kSnapshot]?: FrozenRender;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* A transcript block that is still mutating (a foreground tool awaiting its
|
|
17
|
+
* result, an assistant message mid-stream) reports `false` so the container
|
|
18
|
+
* keeps it inside the live (repaintable) region instead of freezing it. Blocks
|
|
19
|
+
* without the method are treated as finalized — the default, stable behavior.
|
|
20
|
+
*
|
|
21
|
+
* `isTranscriptBlockAppendOnly` marks a still-live block whose rendered rows
|
|
22
|
+
* only grow at the bottom and never re-layout (a streaming assistant reply).
|
|
23
|
+
* Such a block's scrolled-off head is safe to commit to native scrollback even
|
|
24
|
+
* while live; blocks that omit it (tool previews that collapse to a compact
|
|
25
|
+
* result) keep their mutable rows deferred. Default is `false`.
|
|
26
|
+
*/
|
|
27
|
+
interface FinalizableBlock {
|
|
28
|
+
isTranscriptBlockFinalized?(): boolean;
|
|
29
|
+
isTranscriptBlockAppendOnly?(): boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isBlockFinalized(child: Component): boolean {
|
|
33
|
+
const fn = (child as Component & FinalizableBlock).isTranscriptBlockFinalized;
|
|
34
|
+
return fn ? fn.call(child) : true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isBlockAppendOnly(child: Component): boolean {
|
|
38
|
+
const fn = (child as Component & FinalizableBlock).isTranscriptBlockAppendOnly;
|
|
39
|
+
return fn ? fn.call(child) : false;
|
|
40
|
+
}
|
|
41
|
+
|
|
15
42
|
/**
|
|
16
43
|
* Transcript container that freezes the rendered output of every block except
|
|
17
44
|
* the bottom-most (live) one on terminals where committed native scrollback is
|
|
@@ -38,15 +65,24 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
38
65
|
// Bumped to invalidate every block's snapshot at once; a snapshot is only
|
|
39
66
|
// honored when its stored generation still matches.
|
|
40
67
|
#generation = 0;
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
68
|
+
// Line index where the live (repaintable) region began on the previous
|
|
69
|
+
// render — the start of the earliest still-mutating block, or the bottom
|
|
70
|
+
// block when everything is finalized. A block leaves the live region only
|
|
71
|
+
// once it has finalized AND a finalized block sits below it; the frame it
|
|
72
|
+
// crosses out is recomputed so it freezes at its true final content, not the
|
|
73
|
+
// mid-stream snapshot it last rendered while live (TUI render coalescing can
|
|
74
|
+
// advance a block's content in the very frame it stops being live).
|
|
75
|
+
#prevLiveStartIndex = 0;
|
|
76
|
+
// Local line index where the current live region begins in the most recent
|
|
77
|
+
// render. TUI extends the native-scrollback pinned region from this point
|
|
78
|
+
// through the live blocks and the root chrome rendered below them.
|
|
49
79
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
80
|
+
// Local line index up to which the leading run of live blocks is append-only
|
|
81
|
+
// (a streaming assistant reply): everything in [liveRegionStart,
|
|
82
|
+
// commitSafeEnd) only grows at the bottom and never re-layouts, so its
|
|
83
|
+
// scrolled-off head is safe to commit to native scrollback. `undefined` when
|
|
84
|
+
// the first live block is volatile (a tool preview).
|
|
85
|
+
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
50
86
|
|
|
51
87
|
override invalidate(): void {
|
|
52
88
|
// A theme/global invalidation forces a full recompute on the rebuild that
|
|
@@ -64,6 +100,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
64
100
|
return this.#nativeScrollbackLiveRegionStart;
|
|
65
101
|
}
|
|
66
102
|
|
|
103
|
+
getNativeScrollbackCommitSafeEnd(): number | undefined {
|
|
104
|
+
return this.#nativeScrollbackCommitSafeEnd;
|
|
105
|
+
}
|
|
106
|
+
|
|
67
107
|
/**
|
|
68
108
|
* Retire all frozen snapshots so the next render reflects each block's current
|
|
69
109
|
* state. Call at reconciliation checkpoints (prompt submit) where the whole
|
|
@@ -77,41 +117,62 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
77
117
|
override render(width: number): string[] {
|
|
78
118
|
width = Math.max(1, width);
|
|
79
119
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
120
|
+
this.#nativeScrollbackCommitSafeEnd = undefined;
|
|
80
121
|
if (!TERMINAL.eagerEraseScrollbackRisk) return super.render(width);
|
|
81
122
|
|
|
123
|
+
const count = this.children.length;
|
|
124
|
+
// The live region spans from the earliest still-mutating block through the
|
|
125
|
+
// bottom. A block that has not finalized must stay repaintable: out-of-band
|
|
126
|
+
// inserts (TTSR/todo cards) can append a finalized block *below* a tool that
|
|
127
|
+
// is still awaiting its result, and freezing the tool there would strand its
|
|
128
|
+
// committed rows on the mid-stream preview the late result never reaches.
|
|
129
|
+
let liveStartIndex = count - 1;
|
|
130
|
+
for (let i = 0; i < count; i++) {
|
|
131
|
+
if (!isBlockFinalized(this.children[i]!)) {
|
|
132
|
+
liveStartIndex = i;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Blocks at [prevLiveStart, liveStart) just crossed out of the live region;
|
|
137
|
+
// recompute them so they freeze at their final content. Everything below
|
|
138
|
+
// the lower of the two cutoffs was already frozen last frame and replays.
|
|
139
|
+
const replayCutoff = Math.min(liveStartIndex, this.#prevLiveStartIndex);
|
|
140
|
+
this.#prevLiveStartIndex = liveStartIndex;
|
|
141
|
+
|
|
82
142
|
const lines: string[] = [];
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
for (let i = 0; i <
|
|
143
|
+
// Tracks whether we are still inside the leading run of append-only live
|
|
144
|
+
// blocks. The first non-append-only live block (or a finalized block below
|
|
145
|
+
// the live region's start, which cannot happen for a leading run) closes it.
|
|
146
|
+
let commitSafeOpen = true;
|
|
147
|
+
for (let i = 0; i < count; i++) {
|
|
88
148
|
const child = this.children[i]! as Component & SnapshotCarrier;
|
|
89
|
-
if (
|
|
90
|
-
this.#nativeScrollbackLiveRegionStart = lines.length;
|
|
149
|
+
if (i >= liveStartIndex) {
|
|
150
|
+
if (i === liveStartIndex) this.#nativeScrollbackLiveRegionStart = lines.length;
|
|
91
151
|
} else {
|
|
92
152
|
const snapshot = child[kSnapshot];
|
|
93
|
-
// Replay
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
// here: TUI render coalescing can advance its content (final streamed
|
|
98
|
-
// tokens) in the very frame that appends the block now below it, so its
|
|
99
|
-
// cached snapshot predates that final content. Recomputing on the
|
|
100
|
-
// transition seals the block at its true final state, not a mid-stream one.
|
|
101
|
-
if (
|
|
102
|
-
child !== prevLiveChild &&
|
|
103
|
-
snapshot &&
|
|
104
|
-
snapshot.generation === this.#generation &&
|
|
105
|
-
snapshot.width === width
|
|
106
|
-
) {
|
|
153
|
+
// Replay a frozen block's last live render. A stale generation
|
|
154
|
+
// (post-thaw) or width mismatch (resize, explicit rebuild) recomputes
|
|
155
|
+
// instead, as does a block that was still live last frame (i >= cutoff).
|
|
156
|
+
if (i < replayCutoff && snapshot && snapshot.generation === this.#generation && snapshot.width === width) {
|
|
107
157
|
lines.push(...snapshot.lines);
|
|
108
158
|
continue;
|
|
109
159
|
}
|
|
110
160
|
}
|
|
111
161
|
const rendered = child.render(width);
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
162
|
+
// Extend the commit-safe boundary through each leading append-only live
|
|
163
|
+
// block. `lines.length` here is this block's start offset; the boundary
|
|
164
|
+
// runs to the end of its rendered rows. The first volatile live block
|
|
165
|
+
// closes the run so its mutable rows stay deferred.
|
|
166
|
+
if (i >= liveStartIndex && commitSafeOpen) {
|
|
167
|
+
if (isBlockAppendOnly(child)) {
|
|
168
|
+
this.#nativeScrollbackCommitSafeEnd = lines.length + rendered.length;
|
|
169
|
+
} else {
|
|
170
|
+
commitSafeOpen = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Cache every block's latest render. While a block is in the live region
|
|
174
|
+
// this keeps its snapshot current; on the frame it crosses out, the
|
|
175
|
+
// recompute above refreshes it to the final state before it freezes.
|
|
115
176
|
child[kSnapshot] = { width, lines: rendered, generation: this.#generation };
|
|
116
177
|
lines.push(...rendered);
|
|
117
178
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
|
|
2
2
|
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
3
|
+
import { imageReferenceHyperlink, renderImageReferences } from "../image-references";
|
|
3
4
|
import { highlightMagicKeywords } from "../magic-keywords";
|
|
4
5
|
|
|
5
6
|
// OSC 133 shell integration: marks prompt zones for terminal multiplexers
|
|
@@ -11,7 +12,7 @@ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
|
|
|
11
12
|
* Component that renders a user message
|
|
12
13
|
*/
|
|
13
14
|
export class UserMessageComponent extends Container {
|
|
14
|
-
constructor(text: string, synthetic = false) {
|
|
15
|
+
constructor(text: string, synthetic = false, imageLinks?: readonly (string | undefined)[]) {
|
|
15
16
|
super();
|
|
16
17
|
const bgColor = (value: string) => theme.bg("userMessageBg", value);
|
|
17
18
|
// Paint the magic keywords ("ultrathink"/"orchestrate"/"workflow") inside the rendered
|
|
@@ -20,9 +21,15 @@ export class UserMessageComponent extends Container {
|
|
|
20
21
|
// `highlightMagicKeywords` additionally restores the bubble's own foreground after each
|
|
21
22
|
// painted keyword so the gradient never bleeds into the rest of the line.
|
|
22
23
|
const keywordReset = theme.getFgAnsi("userMessageText") || "\x1b[39m";
|
|
23
|
-
const
|
|
24
|
+
const baseText = synthetic
|
|
24
25
|
? (value: string) => theme.fg("dim", value)
|
|
25
26
|
: (value: string) => theme.fg("userMessageText", highlightMagicKeywords(value, keywordReset));
|
|
27
|
+
const imageLabel = (value: string) => theme.fg("accent", `\x1b[1m\x1b[4m${value}\x1b[24m\x1b[22m`);
|
|
28
|
+
const color = (value: string) =>
|
|
29
|
+
renderImageReferences(value, {
|
|
30
|
+
renderText: baseText,
|
|
31
|
+
renderReference: (label, index) => imageReferenceHyperlink(label, index, imageLinks, imageLabel),
|
|
32
|
+
});
|
|
26
33
|
this.addChild(new Spacer(1));
|
|
27
34
|
this.addChild(
|
|
28
35
|
new Markdown(text, 1, 1, getMarkdownTheme(), {
|
|
@@ -204,6 +204,7 @@ export class EventController {
|
|
|
204
204
|
this.#readToolCallAssistantComponents.clear();
|
|
205
205
|
this.#assistantMessageStreaming = false;
|
|
206
206
|
this.#lastAssistantComponent = undefined;
|
|
207
|
+
this.ctx.clearPinnedError();
|
|
207
208
|
if (this.ctx.retryEscapeHandler) {
|
|
208
209
|
this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
|
|
209
210
|
this.ctx.retryEscapeHandler = undefined;
|
|
@@ -241,16 +242,28 @@ export class EventController {
|
|
|
241
242
|
this.ctx.ui.requestRender();
|
|
242
243
|
} else if (event.message.role === "user") {
|
|
243
244
|
const textContent = this.ctx.getUserMessageText(event.message);
|
|
244
|
-
const
|
|
245
|
+
const imageBlocks =
|
|
245
246
|
typeof event.message.content === "string"
|
|
246
|
-
?
|
|
247
|
-
: event.message.content.filter(
|
|
247
|
+
? []
|
|
248
|
+
: event.message.content.filter(
|
|
249
|
+
(content): content is ImageContent =>
|
|
250
|
+
content.type === "image" &&
|
|
251
|
+
typeof content.data === "string" &&
|
|
252
|
+
typeof content.mimeType === "string",
|
|
253
|
+
);
|
|
254
|
+
const imageCount = imageBlocks.length;
|
|
248
255
|
const signature = `${textContent}\u0000${imageCount}`;
|
|
249
256
|
|
|
250
257
|
this.#resetReadGroup();
|
|
251
258
|
const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
|
|
252
259
|
const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
|
|
253
260
|
if (!wasOptimistic) {
|
|
261
|
+
// Append synchronously: #emit dispatches to this listener fire-and-forget
|
|
262
|
+
// (see AgentSession.#emit), so any await between the user message_start and
|
|
263
|
+
// addMessageToChat lets later events (assistant message_start, tool execution
|
|
264
|
+
// start/end) append their components first and scramble transcript order /
|
|
265
|
+
// live-region block boundaries. addMessageToChat materializes clickable image
|
|
266
|
+
// links via the synchronous putBlobSync fallback, so no await is needed here.
|
|
254
267
|
this.ctx.addMessageToChat(event.message);
|
|
255
268
|
}
|
|
256
269
|
if (wasOptimistic) {
|
|
@@ -462,11 +475,32 @@ export class EventController {
|
|
|
462
475
|
for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
|
|
463
476
|
component.setArgsComplete(toolCallId);
|
|
464
477
|
}
|
|
478
|
+
} else {
|
|
479
|
+
// The turn ended without running these calls (abort/error/TTSR rewind),
|
|
480
|
+
// so they will never produce a result. Seal them so they stop animating
|
|
481
|
+
// and freeze instead of pinning the transcript live region while a retry
|
|
482
|
+
// streams fresh blocks below them. Background tools keep updating.
|
|
483
|
+
for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
|
|
484
|
+
if (!this.#backgroundToolCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
|
|
485
|
+
component.seal();
|
|
486
|
+
}
|
|
487
|
+
}
|
|
465
488
|
}
|
|
466
489
|
this.#lastAssistantComponent = this.ctx.streamingComponent;
|
|
467
490
|
this.#lastAssistantComponent.setUsageInfo(event.message.usage);
|
|
491
|
+
this.#lastAssistantComponent.markTranscriptBlockFinalized();
|
|
468
492
|
this.ctx.streamingComponent = undefined;
|
|
469
493
|
this.ctx.streamingMessage = undefined;
|
|
494
|
+
// Pin a turn-ending provider error (e.g. Anthropic content-filter block)
|
|
495
|
+
// above the editor so it survives transcript scroll. Cleared at the next
|
|
496
|
+
// turn's agent_start.
|
|
497
|
+
if (
|
|
498
|
+
event.message.stopReason === "error" &&
|
|
499
|
+
event.message.errorMessage &&
|
|
500
|
+
!isSilentAbort(event.message.errorMessage)
|
|
501
|
+
) {
|
|
502
|
+
this.ctx.showPinnedError(event.message.errorMessage);
|
|
503
|
+
}
|
|
470
504
|
this.ctx.statusLine.invalidate();
|
|
471
505
|
this.ctx.updateEditorTopBorder();
|
|
472
506
|
}
|
|
@@ -626,6 +660,11 @@ export class EventController {
|
|
|
626
660
|
await this.ctx.flushPendingModelSwitch();
|
|
627
661
|
for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
|
|
628
662
|
if (!this.#backgroundToolCallIds.has(toolCallId)) {
|
|
663
|
+
// A foreground tool still pending at turn end never delivered a result;
|
|
664
|
+
// seal it so it freezes (and stops animating) rather than lingering in
|
|
665
|
+
// the transcript live region as a streaming preview until the next thaw.
|
|
666
|
+
const component = this.ctx.pendingTools.get(toolCallId);
|
|
667
|
+
if (component instanceof ToolExecutionComponent) component.seal();
|
|
629
668
|
this.ctx.pendingTools.delete(toolCallId);
|
|
630
669
|
}
|
|
631
670
|
}
|
|
@@ -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}]`;
|