@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.
Files changed (109) hide show
  1. package/CHANGELOG.md +68 -2
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/models-config-schema.d.ts +3 -0
  7. package/dist/types/config/settings-schema.d.ts +13 -4
  8. package/dist/types/config/settings.d.ts +11 -0
  9. package/dist/types/discovery/helpers.d.ts +1 -0
  10. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  11. package/dist/types/hindsight/bank.d.ts +17 -9
  12. package/dist/types/hindsight/mental-models.d.ts +1 -1
  13. package/dist/types/hindsight/state.d.ts +9 -3
  14. package/dist/types/mcp/manager.d.ts +1 -1
  15. package/dist/types/modes/components/assistant-message.d.ts +11 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  17. package/dist/types/modes/components/error-banner.d.ts +11 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +4 -2
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/image-references.d.ts +17 -0
  22. package/dist/types/modes/interactive-mode.d.ts +7 -0
  23. package/dist/types/modes/types.d.ts +7 -0
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  25. package/dist/types/session/agent-session.d.ts +9 -0
  26. package/dist/types/session/auth-storage.d.ts +2 -2
  27. package/dist/types/session/blob-store.d.ts +12 -11
  28. package/dist/types/session/session-manager.d.ts +5 -3
  29. package/dist/types/system-prompt.d.ts +2 -0
  30. package/dist/types/task/types.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +16 -1
  32. package/dist/types/tool-discovery/mode.d.ts +8 -0
  33. package/dist/types/tools/archive-reader.d.ts +5 -1
  34. package/dist/types/tools/index.d.ts +16 -0
  35. package/dist/types/tools/path-utils.d.ts +11 -0
  36. package/dist/types/tui/hyperlink.d.ts +12 -0
  37. package/dist/types/web/search/render.d.ts +1 -2
  38. package/package.json +9 -9
  39. package/src/cli/classify-install-target.ts +31 -5
  40. package/src/cli/dry-balance-cli.ts +823 -0
  41. package/src/cli/plugin-cli.ts +45 -0
  42. package/src/cli/web-search-cli.ts +0 -1
  43. package/src/cli-commands.ts +1 -0
  44. package/src/commands/dry-balance.ts +43 -0
  45. package/src/config/model-registry.ts +60 -4
  46. package/src/config/models-config-schema.ts +2 -0
  47. package/src/config/settings-schema.ts +14 -4
  48. package/src/config/settings.ts +38 -0
  49. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  50. package/src/discovery/github.ts +37 -1
  51. package/src/discovery/helpers.ts +3 -1
  52. package/src/eval/__tests__/agent-bridge.test.ts +72 -0
  53. package/src/eval/py/tool-bridge.ts +43 -5
  54. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  56. package/src/hindsight/backend.ts +184 -35
  57. package/src/hindsight/bank.ts +32 -22
  58. package/src/hindsight/mental-models.ts +1 -1
  59. package/src/hindsight/state.ts +21 -7
  60. package/src/internal-urls/docs-index.generated.ts +6 -6
  61. package/src/internal-urls/omp-protocol.ts +8 -2
  62. package/src/main.ts +7 -1
  63. package/src/mcp/manager.ts +40 -21
  64. package/src/modes/components/assistant-message.ts +22 -0
  65. package/src/modes/components/custom-editor.ts +14 -2
  66. package/src/modes/components/error-banner.ts +33 -0
  67. package/src/modes/components/tool-execution.ts +44 -0
  68. package/src/modes/components/transcript-container.ts +102 -30
  69. package/src/modes/components/tree-selector.ts +29 -2
  70. package/src/modes/components/user-message.ts +9 -2
  71. package/src/modes/controllers/event-controller.ts +42 -3
  72. package/src/modes/controllers/input-controller.ts +41 -3
  73. package/src/modes/image-references.ts +111 -0
  74. package/src/modes/interactive-mode.ts +48 -13
  75. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  76. package/src/modes/types.ts +10 -1
  77. package/src/modes/utils/ui-helpers.ts +23 -2
  78. package/src/prompts/agents/explore.md +1 -0
  79. package/src/prompts/agents/librarian.md +1 -0
  80. package/src/prompts/ci-green-request.md +5 -3
  81. package/src/prompts/dry-balance-bench.md +8 -0
  82. package/src/prompts/system/project-prompt.md +1 -0
  83. package/src/sdk.ts +99 -18
  84. package/src/session/agent-session.ts +103 -19
  85. package/src/session/auth-storage.ts +4 -0
  86. package/src/session/blob-store.ts +96 -9
  87. package/src/session/session-manager.ts +19 -10
  88. package/src/system-prompt.ts +4 -0
  89. package/src/task/executor.ts +6 -2
  90. package/src/task/index.ts +8 -7
  91. package/src/task/types.ts +2 -0
  92. package/src/tiny/title-client.ts +7 -1
  93. package/src/tool-discovery/mode.ts +24 -0
  94. package/src/tools/archive-reader.ts +339 -31
  95. package/src/tools/bash.ts +3 -4
  96. package/src/tools/fetch.ts +29 -9
  97. package/src/tools/gh.ts +65 -11
  98. package/src/tools/index.ts +22 -8
  99. package/src/tools/job.ts +3 -3
  100. package/src/tools/memory-reflect.ts +2 -2
  101. package/src/tools/path-utils.ts +21 -0
  102. package/src/tools/read.ts +58 -12
  103. package/src/tools/search-tool-bm25.ts +4 -6
  104. package/src/tools/search.ts +78 -12
  105. package/src/tui/hyperlink.ts +42 -7
  106. package/src/utils/file-mentions.ts +7 -107
  107. package/src/utils/title-generator.ts +58 -37
  108. package/src/web/search/index.ts +2 -2
  109. 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 content = EMBEDDED_DOCS[normalized];
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 = normalized.replace(/\.md$/, "");
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
- mode.renderInitialMessages(undefined, { preserveExistingChat: true });
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) {
@@ -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 { TSchema } from "@oh-my-pi/pi-ai";
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
- logger.warn("MCP OAuth refresh failed, using existing token", {
1188
- credentialId,
1189
- error: refreshError,
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 (resolved.type === "http" || resolved.type === "sse") {
1195
- resolved = {
1196
- ...resolved,
1197
- headers: {
1198
- ...resolved.headers,
1199
- Authorization: `Bearer ${credential.access}`,
1200
- },
1201
- };
1202
- } else {
1203
- resolved = {
1204
- ...resolved,
1205
- env: {
1206
- ...resolved.env,
1207
- OAUTH_ACCESS_TOKEN: credential.access,
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
- decorateText = (text: string): string => highlightMagicKeywords(text);
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
- // The block that was bottom-most (live) on the previous render. When the live
42
- // position moves past it, its snapshot was last refreshed mid-stream and may
43
- // predate content that finalized in the same coalesced frame that appended the
44
- // block now below it so it must recompute once on the live→frozen transition.
45
- #prevLiveChild: Component | undefined;
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
- const liveIndex = this.children.length - 1;
75
- const liveChild = this.children[liveIndex];
76
- const prevLiveChild = this.#prevLiveChild;
77
- this.#prevLiveChild = liveChild;
78
- for (let i = 0; i < this.children.length; 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 (child !== liveChild) {
149
+ if (i >= liveStartIndex) {
150
+ if (i === liveStartIndex) this.#nativeScrollbackLiveRegionStart = lines.length;
151
+ } else {
81
152
  const snapshot = child[kSnapshot];
82
- // Replay the block's last render from while it was live. A stale
83
- // generation (post-thaw) or width mismatch (resize in flight, an
84
- // explicit rebuild that reconciles history anyway) recomputes instead.
85
- // The block that was live on the previous render is also recomputed
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
- // Cache every block's latest render. While a block is live this keeps its
102
- // snapshot current; on the frame it stops being live the recompute above
103
- // refreshes it to the final state before it freezes.
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
- lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width));
445
- lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.#getFilterLabel()}`), width));
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 color = synthetic
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 imageCount =
245
+ const imageBlocks =
245
246
  typeof event.message.content === "string"
246
- ? 0
247
- : event.message.content.filter(content => content.type === "image").length;
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
  }