@oh-my-pi/pi-coding-agent 15.9.1 → 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 +68 -2
- package/dist/types/cli/classify-install-target.d.ts +5 -1
- package/dist/types/cli/dry-balance-cli.d.ts +104 -0
- package/dist/types/commands/dry-balance.d.ts +31 -0
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +13 -4
- package/dist/types/config/settings.d.ts +11 -0
- package/dist/types/discovery/helpers.d.ts +1 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
- package/dist/types/hindsight/bank.d.ts +17 -9
- package/dist/types/hindsight/mental-models.d.ts +1 -1
- package/dist/types/hindsight/state.d.ts +9 -3
- package/dist/types/mcp/manager.d.ts +1 -1
- 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 +4 -2
- 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/agent-session.d.ts +9 -0
- package/dist/types/session/auth-storage.d.ts +2 -2
- 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/task/types.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/index.d.ts +16 -0
- package/dist/types/tools/path-utils.d.ts +11 -0
- 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/dry-balance-cli.ts +823 -0
- package/src/cli/plugin-cli.ts +45 -0
- package/src/cli/web-search-cli.ts +0 -1
- package/src/cli-commands.ts +1 -0
- package/src/commands/dry-balance.ts +43 -0
- package/src/config/model-registry.ts +60 -4
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +14 -4
- package/src/config/settings.ts +38 -0
- package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
- package/src/discovery/github.ts +37 -1
- package/src/discovery/helpers.ts +3 -1
- 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/extensibility/plugins/legacy-pi-compat.ts +245 -25
- package/src/hindsight/backend.ts +184 -35
- package/src/hindsight/bank.ts +32 -22
- package/src/hindsight/mental-models.ts +1 -1
- package/src/hindsight/state.ts +21 -7
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/internal-urls/omp-protocol.ts +8 -2
- package/src/main.ts +7 -1
- package/src/mcp/manager.ts +40 -21
- 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 +102 -30
- package/src/modes/components/tree-selector.ts +29 -2
- 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 +41 -3
- package/src/modes/image-references.ts +111 -0
- package/src/modes/interactive-mode.ts +48 -13
- package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
- package/src/modes/types.ts +10 -1
- package/src/modes/utils/ui-helpers.ts +23 -2
- package/src/prompts/agents/explore.md +1 -0
- package/src/prompts/agents/librarian.md +1 -0
- package/src/prompts/ci-green-request.md +5 -3
- package/src/prompts/dry-balance-bench.md +8 -0
- package/src/prompts/system/project-prompt.md +1 -0
- package/src/sdk.ts +99 -18
- package/src/session/agent-session.ts +103 -19
- package/src/session/auth-storage.ts +4 -0
- 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/task/executor.ts +6 -2
- package/src/task/index.ts +8 -7
- package/src/task/types.ts +2 -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/bash.ts +3 -4
- package/src/tools/fetch.ts +29 -9
- package/src/tools/gh.ts +65 -11
- package/src/tools/index.ts +22 -8
- package/src/tools/job.ts +3 -3
- package/src/tools/memory-reflect.ts +2 -2
- package/src/tools/path-utils.ts +21 -0
- package/src/tools/read.ts +58 -12
- package/src/tools/search-tool-bm25.ts +4 -6
- package/src/tools/search.ts +78 -12
- package/src/tui/hyperlink.ts +42 -7
- package/src/utils/file-mentions.ts +7 -107
- package/src/utils/title-generator.ts +58 -37
- package/src/web/search/index.ts +2 -2
- package/src/web/search/render.ts +20 -52
|
@@ -64,9 +64,15 @@ export class OmpProtocolHandler implements ProtocolHandler {
|
|
|
64
64
|
throw new Error("Path traversal (..) is not allowed in omp:// URLs");
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
const
|
|
67
|
+
const docPath =
|
|
68
|
+
normalized === "docs" ? "" : normalized.startsWith("docs/") ? normalized.slice("docs/".length) : normalized;
|
|
69
|
+
if (!docPath) {
|
|
70
|
+
return this.#listDocs(url);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const content = EMBEDDED_DOCS[docPath];
|
|
68
74
|
if (content === undefined) {
|
|
69
|
-
const lookup =
|
|
75
|
+
const lookup = docPath.replace(/\.md$/, "");
|
|
70
76
|
const suggestions = EMBEDDED_DOC_FILENAMES.filter(
|
|
71
77
|
f => f.includes(lookup) || lookup.includes(f.replace(/\.md$/, "")),
|
|
72
78
|
).slice(0, 5);
|
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) {
|
package/src/mcp/manager.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import * as url from "node:url";
|
|
9
|
-
import type
|
|
9
|
+
import { isDefinitiveOAuthFailure, type TSchema } from "@oh-my-pi/pi-ai";
|
|
10
10
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
11
11
|
import type { SourceMeta } from "../capability/types";
|
|
12
12
|
import { resolveConfigValue } from "../config/resolve-config-value";
|
|
@@ -1184,29 +1184,48 @@ export class MCPManager {
|
|
|
1184
1184
|
await this.#authStorage.set(credentialId, refreshedCredential);
|
|
1185
1185
|
credential = refreshedCredential;
|
|
1186
1186
|
} catch (refreshError) {
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1187
|
+
const errorMsg = refreshError instanceof Error ? refreshError.message : String(refreshError);
|
|
1188
|
+
if (isDefinitiveOAuthFailure(errorMsg)) {
|
|
1189
|
+
// `invalid_grant` / `invalid_token` / 401 from the token endpoint means
|
|
1190
|
+
// the server has retired this credential — keeping the stale access
|
|
1191
|
+
// token would just re-fail with 401 on every MCP request and leave a
|
|
1192
|
+
// poisoned row in agent.db that survives restarts. Drop it now so the
|
|
1193
|
+
// next connect attempt surfaces a clean "needs reauth" failure and
|
|
1194
|
+
// the user can recover with `/mcp reauth <server>` (or `/mcp unauth`
|
|
1195
|
+
// to forget the server entirely).
|
|
1196
|
+
logger.warn("MCP OAuth refresh failed definitively; cleared credential", {
|
|
1197
|
+
credentialId,
|
|
1198
|
+
error: errorMsg,
|
|
1199
|
+
});
|
|
1200
|
+
await this.#authStorage.remove(credentialId);
|
|
1201
|
+
credential = undefined;
|
|
1202
|
+
} else {
|
|
1203
|
+
logger.warn("MCP OAuth refresh failed, using existing token", {
|
|
1204
|
+
credentialId,
|
|
1205
|
+
error: refreshError,
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1191
1208
|
}
|
|
1192
1209
|
}
|
|
1193
1210
|
|
|
1194
|
-
if (
|
|
1195
|
-
resolved
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1211
|
+
if (credential?.type === "oauth") {
|
|
1212
|
+
if (resolved.type === "http" || resolved.type === "sse") {
|
|
1213
|
+
resolved = {
|
|
1214
|
+
...resolved,
|
|
1215
|
+
headers: {
|
|
1216
|
+
...resolved.headers,
|
|
1217
|
+
Authorization: `Bearer ${credential.access}`,
|
|
1218
|
+
},
|
|
1219
|
+
};
|
|
1220
|
+
} else {
|
|
1221
|
+
resolved = {
|
|
1222
|
+
...resolved,
|
|
1223
|
+
env: {
|
|
1224
|
+
...resolved.env,
|
|
1225
|
+
OAUTH_ACCESS_TOKEN: credential.access,
|
|
1226
|
+
},
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1210
1229
|
}
|
|
1211
1230
|
}
|
|
1212
1231
|
} catch (error) {
|
|
@@ -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();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Component, Container, TERMINAL } from "@oh-my-pi/pi-tui";
|
|
1
|
+
import { type Component, Container, type NativeScrollbackLiveRegion, TERMINAL } from "@oh-my-pi/pi-tui";
|
|
2
2
|
|
|
3
3
|
const kSnapshot = Symbol("transcript.frozenRender");
|
|
4
4
|
|
|
@@ -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
|
|
@@ -34,15 +61,28 @@ interface SnapshotCarrier {
|
|
|
34
61
|
* and any drift reconciles safely. On terminals that can rebuild history this
|
|
35
62
|
* freezing is unnecessary, so it renders every block live for full fidelity.
|
|
36
63
|
*/
|
|
37
|
-
export class TranscriptContainer extends Container {
|
|
64
|
+
export class TranscriptContainer extends Container implements NativeScrollbackLiveRegion {
|
|
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
|
-
|
|
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.
|
|
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;
|
|
46
86
|
|
|
47
87
|
override invalidate(): void {
|
|
48
88
|
// A theme/global invalidation forces a full recompute on the rebuild that
|
|
@@ -56,6 +96,14 @@ export class TranscriptContainer extends Container {
|
|
|
56
96
|
super.clear();
|
|
57
97
|
}
|
|
58
98
|
|
|
99
|
+
getNativeScrollbackLiveRegionStart(): number | undefined {
|
|
100
|
+
return this.#nativeScrollbackLiveRegionStart;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
getNativeScrollbackCommitSafeEnd(): number | undefined {
|
|
104
|
+
return this.#nativeScrollbackCommitSafeEnd;
|
|
105
|
+
}
|
|
106
|
+
|
|
59
107
|
/**
|
|
60
108
|
* Retire all frozen snapshots so the next render reflects each block's current
|
|
61
109
|
* state. Call at reconciliation checkpoints (prompt submit) where the whole
|
|
@@ -68,39 +116,63 @@ export class TranscriptContainer extends Container {
|
|
|
68
116
|
|
|
69
117
|
override render(width: number): string[] {
|
|
70
118
|
width = Math.max(1, width);
|
|
119
|
+
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
120
|
+
this.#nativeScrollbackCommitSafeEnd = undefined;
|
|
71
121
|
if (!TERMINAL.eagerEraseScrollbackRisk) return super.render(width);
|
|
72
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
|
+
|
|
73
142
|
const lines: string[] = [];
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
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++) {
|
|
79
148
|
const child = this.children[i]! as Component & SnapshotCarrier;
|
|
80
|
-
if (
|
|
149
|
+
if (i >= liveStartIndex) {
|
|
150
|
+
if (i === liveStartIndex) this.#nativeScrollbackLiveRegionStart = lines.length;
|
|
151
|
+
} else {
|
|
81
152
|
const snapshot = child[kSnapshot];
|
|
82
|
-
// Replay
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
// here: TUI render coalescing can advance its content (final streamed
|
|
87
|
-
// tokens) in the very frame that appends the block now below it, so its
|
|
88
|
-
// cached snapshot predates that final content. Recomputing on the
|
|
89
|
-
// transition seals the block at its true final state, not a mid-stream one.
|
|
90
|
-
if (
|
|
91
|
-
child !== prevLiveChild &&
|
|
92
|
-
snapshot &&
|
|
93
|
-
snapshot.generation === this.#generation &&
|
|
94
|
-
snapshot.width === width
|
|
95
|
-
) {
|
|
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) {
|
|
96
157
|
lines.push(...snapshot.lines);
|
|
97
158
|
continue;
|
|
98
159
|
}
|
|
99
160
|
}
|
|
100
161
|
const rendered = child.render(width);
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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.
|
|
104
176
|
child[kSnapshot] = { width, lines: rendered, generation: this.#generation };
|
|
105
177
|
lines.push(...rendered);
|
|
106
178
|
}
|
|
@@ -441,8 +441,35 @@ class TreeList implements Component {
|
|
|
441
441
|
const lines: string[] = [];
|
|
442
442
|
|
|
443
443
|
if (this.#filteredNodes.length === 0) {
|
|
444
|
-
|
|
445
|
-
|
|
444
|
+
// Three empty-state shapes:
|
|
445
|
+
// - flatNodes empty → no entries at all (truly fresh session).
|
|
446
|
+
// - search query rejects everything → tell the user the search is the cause.
|
|
447
|
+
// - filter mode rejects everything → tell the user the filter is the cause and
|
|
448
|
+
// how to widen it. Otherwise fresh sessions whose only persisted entries are
|
|
449
|
+
// `model_change` + `thinking_level_change` (both hidden by the default filter)
|
|
450
|
+
// read as "broken /tree" — see #1909.
|
|
451
|
+
if (this.#flatNodes.length === 0) {
|
|
452
|
+
lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width));
|
|
453
|
+
lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.#getFilterLabel()}`), width));
|
|
454
|
+
} else if (this.#searchQuery.length > 0) {
|
|
455
|
+
lines.push(truncateToWidth(theme.fg("muted", ` No entries match search "${this.#searchQuery}"`), width));
|
|
456
|
+
lines.push(truncateToWidth(theme.fg("muted", " Press Backspace to clear the search"), width));
|
|
457
|
+
lines.push(
|
|
458
|
+
truncateToWidth(theme.fg("muted", ` (0/${this.#flatNodes.length})${this.#getFilterLabel()}`), width),
|
|
459
|
+
);
|
|
460
|
+
} else {
|
|
461
|
+
const filterLabel = this.#getFilterLabel().trim() || "[default]";
|
|
462
|
+
lines.push(
|
|
463
|
+
truncateToWidth(
|
|
464
|
+
theme.fg("muted", ` ${this.#flatNodes.length} entries hidden by the current filter ${filterLabel}`),
|
|
465
|
+
width,
|
|
466
|
+
),
|
|
467
|
+
);
|
|
468
|
+
lines.push(truncateToWidth(theme.fg("muted", " Press Alt+A to show all, Alt+D for default"), width));
|
|
469
|
+
lines.push(
|
|
470
|
+
truncateToWidth(theme.fg("muted", ` (0/${this.#flatNodes.length})${this.#getFilterLabel()}`), width),
|
|
471
|
+
);
|
|
472
|
+
}
|
|
446
473
|
return lines;
|
|
447
474
|
}
|
|
448
475
|
|
|
@@ -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
|
}
|