@oh-my-pi/pi-tui 18.6.0 → 18.6.3

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 (68) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +6 -2
  3. package/dist/types/app-keybindings.d.ts +6 -1
  4. package/dist/types/apps/git/git-tui.d.ts +3 -2
  5. package/dist/types/chat/assistant-message.d.ts +6 -1
  6. package/dist/types/chat/chat-transcript-builder.d.ts +1 -0
  7. package/dist/types/chat/user-message.d.ts +2 -2
  8. package/dist/types/chrome/status-notice.d.ts +15 -3
  9. package/dist/types/components/editor.d.ts +4 -0
  10. package/dist/types/components/image.d.ts +14 -0
  11. package/dist/types/components/input.d.ts +1 -0
  12. package/dist/types/components/loader.d.ts +5 -6
  13. package/dist/types/key-hint-format.d.ts +8 -0
  14. package/dist/types/native/apply.d.ts +4 -3
  15. package/dist/types/native/backend.d.ts +2 -2
  16. package/dist/types/native/encode.d.ts +4 -3
  17. package/dist/types/native/node.d.ts +8 -1
  18. package/dist/types/native/overlay.d.ts +3 -3
  19. package/dist/types/native/settle.d.ts +4 -3
  20. package/dist/types/overlays/agent-hub.d.ts +1 -0
  21. package/dist/types/overlays/agent-transcript-viewer.d.ts +1 -0
  22. package/dist/types/overlays/btw-history-panel.d.ts +13 -1
  23. package/dist/types/overlays/copy-selector.d.ts +1 -0
  24. package/dist/types/overlays/rewind-selector.d.ts +1 -0
  25. package/dist/types/overlays/usage-dashboard.d.ts +4 -4
  26. package/dist/types/prompt/custom-editor.d.ts +10 -4
  27. package/dist/types/prompt/interactive-context-helpers.d.ts +1 -0
  28. package/dist/types/space-hold.d.ts +40 -25
  29. package/dist/types/terminal-capabilities.d.ts +13 -0
  30. package/dist/types/tui.d.ts +5 -0
  31. package/package.json +9 -9
  32. package/src/app-keybindings.ts +6 -1
  33. package/src/apps/git/diff-pane.ts +70 -18
  34. package/src/apps/git/git-tui.ts +21 -14
  35. package/src/apps/git/sidebar.ts +6 -1
  36. package/src/chat/assistant-message.ts +26 -4
  37. package/src/chat/chat-transcript-builder.ts +4 -0
  38. package/src/chat/tool-execution.ts +6 -1
  39. package/src/chat/user-message.ts +16 -5
  40. package/src/chrome/status-notice.ts +25 -7
  41. package/src/chrome/transcript-container.ts +6 -3
  42. package/src/components/editor.ts +14 -0
  43. package/src/components/image.ts +68 -1
  44. package/src/components/input.ts +14 -6
  45. package/src/components/loader.ts +10 -17
  46. package/src/hotkeys-markdown.ts +9 -2
  47. package/src/key-hint-format.ts +25 -10
  48. package/src/native/apply.ts +4 -3
  49. package/src/native/backend.ts +10 -3
  50. package/src/native/encode.ts +4 -3
  51. package/src/native/node.ts +8 -1
  52. package/src/native/overlay.ts +4 -4
  53. package/src/native/settle.ts +4 -3
  54. package/src/overlays/agent-hub.ts +4 -0
  55. package/src/overlays/agent-transcript-viewer.ts +2 -0
  56. package/src/overlays/btw-history-panel.ts +124 -38
  57. package/src/overlays/copy-selector.ts +2 -0
  58. package/src/overlays/extensions/extension-dashboard.ts +4 -4
  59. package/src/overlays/pause-screen.ts +2 -2
  60. package/src/overlays/rewind-selector.ts +2 -0
  61. package/src/overlays/usage-dashboard.ts +17 -8
  62. package/src/overlays/usage-row.ts +22 -8
  63. package/src/prompt/custom-editor.ts +188 -67
  64. package/src/prompt/interactive-context-helpers.ts +2 -0
  65. package/src/prompt/queued-messages.ts +6 -4
  66. package/src/space-hold.ts +156 -52
  67. package/src/terminal-capabilities.ts +42 -23
  68. package/src/tui.ts +36 -14
@@ -284,6 +284,13 @@ export interface ImageRenderOptions {
284
284
  placementId?: number;
285
285
  /** When true (Kitty + {@link imageId}), also return the one-time transmit sequence. */
286
286
  includeTransmit?: boolean;
287
+ /**
288
+ * SIXEL sequence for the target pixel size renderImage computes, so the
289
+ * caller can encode off the JS thread: answer `undefined` while the encode
290
+ * is pending (the image's rows stay reserved) and `null` once it failed.
291
+ * Without a provider, renderImage encodes synchronously.
292
+ */
293
+ sixel?: (widthPx: number, heightPx: number) => string | null | undefined;
287
294
  }
288
295
  export declare function getCellDimensions(): CellDimensions;
289
296
  export declare function setCellDimensions(dims: CellDimensions): void;
@@ -381,6 +388,12 @@ export declare function getJpegDimensions(base64Data: string): ImageDimensions |
381
388
  export declare function getGifDimensions(base64Data: string): ImageDimensions | null;
382
389
  export declare function getWebpDimensions(base64Data: string): ImageDimensions | null;
383
390
  export declare function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null;
391
+ /**
392
+ * SIXEL sequence for `base64Data` at the given pixel size, encoded on the JS
393
+ * thread; `null` when the encode fails. For output that cannot wait for an
394
+ * off-thread encode.
395
+ */
396
+ export declare function encodeSixelNow(base64Data: string, widthPx: number, heightPx: number): string | null;
384
397
  export declare function renderImage(base64Data: string, imageDimensions: ImageDimensions, options?: ImageRenderOptions): {
385
398
  sequence?: string;
386
399
  lines?: string[];
@@ -150,6 +150,11 @@ export interface Component {
150
150
  describeScreen?(cx: DescribeContext): NativeScreen;
151
151
  /** User actions on nodes this component described (toggle, select, activate, custom). */
152
152
  handleNativeEvent?(event: NativeUiEvent): void;
153
+ /**
154
+ * Pure preflight for focused input that must precede TUI-wide input listeners
155
+ * and debug shortcuts. A true result routes the event to this component first.
156
+ */
157
+ capturesInput?(data: string): boolean;
153
158
  /**
154
159
  * Optional handler for keyboard input when component has focus
155
160
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.6.0",
4
+ "version": "18.6.3",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.6.0",
45
- "@oh-my-pi/pi-agent-core": "18.6.0",
46
- "@oh-my-pi/pi-ai": "18.6.0",
47
- "@oh-my-pi/pi-catalog": "18.6.0",
48
- "@oh-my-pi/pi-natives": "18.6.0",
49
- "@oh-my-pi/pi-utils": "18.6.0",
50
- "@oh-my-pi/pi-wire": "18.6.0",
51
- "@oh-my-pi/snapcompact": "18.6.0"
44
+ "@oh-my-pi/omptype": "18.6.3",
45
+ "@oh-my-pi/pi-agent-core": "18.6.3",
46
+ "@oh-my-pi/pi-ai": "18.6.3",
47
+ "@oh-my-pi/pi-catalog": "18.6.3",
48
+ "@oh-my-pi/pi-natives": "18.6.3",
49
+ "@oh-my-pi/pi-utils": "18.6.3",
50
+ "@oh-my-pi/pi-wire": "18.6.3",
51
+ "@oh-my-pi/snapcompact": "18.6.3"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -60,6 +60,7 @@ interface AppKeybindings {
60
60
  "app.tree.unfoldOrDown": true;
61
61
  "app.plan.toggle": true;
62
62
  "app.history.search": true;
63
+ "app.stt.pushToTalk": true;
63
64
  "app.stt.toggle": true;
64
65
  "app.live.toggle": true;
65
66
  }
@@ -237,9 +238,13 @@ export const KEYBINDINGS = {
237
238
  defaultKeys: "ctrl+r",
238
239
  description: "Search history",
239
240
  },
241
+ "app.stt.pushToTalk": {
242
+ defaultKeys: "space",
243
+ description: "Hold to record speech-to-text",
244
+ },
240
245
  "app.stt.toggle": {
241
246
  defaultKeys: [],
242
- description: "Toggle speech-to-text (default gesture: hold Space)",
247
+ description: "Toggle speech-to-text recording",
243
248
  },
244
249
  "app.live.toggle": {
245
250
  defaultKeys: "ctrl+l",
@@ -184,6 +184,10 @@ export interface DiffBuildOptions {
184
184
  streamResult?: DiffStreamResult;
185
185
  }
186
186
 
187
+ function filePatchHeader(filePath: string): string {
188
+ return `diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n`;
189
+ }
190
+
187
191
  /** Build the aligned document for one file from its raw old/new texts. */
188
192
  export function buildDiffDocument(
189
193
  oldRaw: string,
@@ -376,10 +380,11 @@ export function buildDiffDocument(
376
380
  // cannot use the raw streamed result because its equality basis differs.
377
381
  const canPatch = !ignoreWs;
378
382
  const tightHunks = streamed?.hunks ?? structuredPatchHunks(oldBasis, newBasis, DIFF_CONTEXT_LINES);
383
+ const patchHeader = canPatch && tightHunks.length > 0 ? filePatchHeader(filePath) : "";
379
384
  const allHunks: HunkBlock[] = tightHunks.map(hunk => ({
380
385
  header: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
381
386
  patch: canPatch
382
- ? `--- a/${filePath}\n+++ b/${filePath}\n@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join("\n")}\n`
387
+ ? `${patchHeader}@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join("\n")}\n`
383
388
  : "",
384
389
  rows: walkHunk(hunk, false),
385
390
  }));
@@ -476,7 +481,7 @@ export function buildLineSelectionPatch(
476
481
  `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join("\n")}`,
477
482
  )
478
483
  .join("\n");
479
- return `--- a/${doc.filePath}\n+++ b/${doc.filePath}\n${body}\n`;
484
+ return `${filePatchHeader(doc.filePath)}${body}\n`;
480
485
  }
481
486
 
482
487
  // ── palette ──────────────────────────────────────────────────────────────────
@@ -596,6 +601,12 @@ export class DiffPane {
596
601
  #layoutCache: { key: string; visuals: Visual[] } | undefined;
597
602
  /** Per visible row: clickable hunk-button ranges recorded during render. */
598
603
  #hits: ({ hunk: number; primary?: [number, number]; discard?: [number, number] } | undefined)[] = [];
604
+ /**
605
+ * Asset images from the last render, by placement and size. Reusing them
606
+ * lets a SIXEL image show the sequence its off-thread encode delivers on a
607
+ * later render; a fresh `Image` per render would restart the encode forever.
608
+ */
609
+ #assetImages = new Map<string, Image>();
599
610
  constructor(imageBudget?: ImageBudget) {
600
611
  this.#imageBudget = imageBudget;
601
612
  }
@@ -721,6 +732,7 @@ export class DiffPane {
721
732
  this.scrollLeft = 0;
722
733
  this.cursor = 0;
723
734
  this.anchor = null;
735
+ if (mode === "hunk") this.selectedHunk = 0;
724
736
  }
725
737
 
726
738
  cycleMode(): void {
@@ -771,6 +783,7 @@ export class DiffPane {
771
783
  this.anchor = null;
772
784
  }
773
785
  this.cursor = Math.max(0, Math.min(total - 1, this.cursor + delta));
786
+ this.#syncHunkToCursor();
774
787
  this.scrollTop = scrollOffsetForRow(this.scrollTop, this.cursor, total, this.#lastHeight);
775
788
  this.#clampScroll();
776
789
  }
@@ -854,6 +867,7 @@ export class DiffPane {
854
867
  if (total === 0) return;
855
868
  this.anchor = null;
856
869
  this.cursor = edge === "start" ? 0 : total - 1;
870
+ this.#syncHunkToCursor();
857
871
  this.scrollTop = scrollOffsetForRow(this.scrollTop, this.cursor, total, this.#lastHeight);
858
872
  this.#clampScroll();
859
873
  }
@@ -870,6 +884,19 @@ export class DiffPane {
870
884
  }
871
885
  }
872
886
 
887
+ /** Keep the hunk action and highlighted header on the cursor's hunk, including blank separators. */
888
+ #syncHunkToCursor(): void {
889
+ if (this.mode !== "hunk") return;
890
+ const visuals = this.#layout(this.#lastWidth || 80);
891
+ for (let row = this.cursor; row >= 0; row--) {
892
+ const visual = visuals[row];
893
+ if (visual?.t === "header") {
894
+ this.selectedHunk = visual.hunk;
895
+ return;
896
+ }
897
+ }
898
+ }
899
+
873
900
  /** First visual row of every change block, in document order. */
874
901
  #changeStarts(visuals: Visual[]): number[] {
875
902
  const starts: number[] = [];
@@ -917,6 +944,10 @@ export class DiffPane {
917
944
  const hit = this.#hits[row];
918
945
  if (hit && this.#doc) {
919
946
  this.selectedHunk = hit.hunk;
947
+ if (this.mode === "hunk") {
948
+ this.cursor = this.scrollTop + row;
949
+ this.anchor = null;
950
+ }
920
951
  if (hit.primary && col >= hit.primary[0] && col < hit.primary[1] && this.patchTarget) {
921
952
  return { type: "hunk-action", hunk: this.#doc.hunks[hit.hunk], action: this.patchTarget };
922
953
  }
@@ -933,6 +964,7 @@ export class DiffPane {
933
964
  this.anchor = null;
934
965
  }
935
966
  this.cursor = visual;
967
+ this.#syncHunkToCursor();
936
968
  return { type: "handled" };
937
969
  }
938
970
  return null;
@@ -1077,8 +1109,16 @@ export class DiffPane {
1077
1109
  const leftWidth = Math.max(1, Math.floor((width - 1) / 2));
1078
1110
  const rightWidth = Math.max(1, width - leftWidth - 1);
1079
1111
  const bodyHeight = Math.max(0, height - 1);
1080
- const oldLines = this.#renderAssetSide(asset.old, leftWidth, bodyHeight, `old:${asset.filePath}`);
1081
- const newLines = this.#renderAssetSide(asset.new, rightWidth, bodyHeight, `new:${asset.filePath}`);
1112
+ const previousImages = this.#assetImages;
1113
+ this.#assetImages = new Map();
1114
+ const oldLines = this.#renderAssetSide(asset.old, leftWidth, bodyHeight, `old:${asset.filePath}`, previousImages);
1115
+ const newLines = this.#renderAssetSide(
1116
+ asset.new,
1117
+ rightWidth,
1118
+ bodyHeight,
1119
+ `new:${asset.filePath}`,
1120
+ previousImages,
1121
+ );
1082
1122
  const border = theme.fg("borderMuted", "│");
1083
1123
  const lines: string[] = [];
1084
1124
  for (let index = 0; index < height; index++) {
@@ -1099,24 +1139,36 @@ export class DiffPane {
1099
1139
  return lines;
1100
1140
  }
1101
1141
 
1102
- #renderAssetSide(side: FileAssetSide, width: number, height: number, placementKey: string): readonly string[] {
1142
+ #renderAssetSide(
1143
+ side: FileAssetSide,
1144
+ width: number,
1145
+ height: number,
1146
+ placementKey: string,
1147
+ previousImages: ReadonlyMap<string, Image>,
1148
+ ): readonly string[] {
1103
1149
  if (height <= 0) return [];
1104
1150
  let content: readonly string[];
1105
1151
  if (side.kind === "image") {
1106
1152
  const image = side.image;
1107
- content = new Image(
1108
- image.data,
1109
- image.mimeType,
1110
- { fallbackColor: text => theme.fg("dim", text) },
1111
- {
1112
- maxWidthCells: Math.max(1, width - 2),
1113
- maxHeightCells: height,
1114
- filename: this.#asset?.filePath,
1115
- budget: this.#imageBudget,
1116
- imageKey: `git-review:${placementKey}:${image.key}`,
1117
- },
1118
- { widthPx: image.widthPx, heightPx: image.heightPx },
1119
- ).render(width);
1153
+ const imageKey = `git-review:${placementKey}:${image.key}`;
1154
+ const cacheKey = `${imageKey}:${width}x${height}`;
1155
+ const component =
1156
+ previousImages.get(cacheKey) ??
1157
+ new Image(
1158
+ image.data,
1159
+ image.mimeType,
1160
+ { fallbackColor: text => theme.fg("dim", text) },
1161
+ {
1162
+ maxWidthCells: Math.max(1, width - 2),
1163
+ maxHeightCells: height,
1164
+ filename: this.#asset?.filePath,
1165
+ budget: this.#imageBudget,
1166
+ imageKey,
1167
+ },
1168
+ { widthPx: image.widthPx, heightPx: image.heightPx },
1169
+ );
1170
+ this.#assetImages.set(cacheKey, component);
1171
+ content = component.render(width);
1120
1172
  } else {
1121
1173
  let details: string[];
1122
1174
  switch (side.kind) {
@@ -13,8 +13,9 @@
13
13
  * arrows/PgUp/PgDn, vim motions (`j`/`k`/`h`/`l`/`g`/`G`), and mouse
14
14
  * clicks/wheel. All toolbar buttons are clickable and mirrored by keys:
15
15
  * `v` cycles the view (`1`–`4` pick one), `alt+↓`/`alt+↑` jump hunks and roll
16
- * into the adjacent file at the edges, `]`/`[` switch files, `s`/`u`
17
- * stage/unstage (hunk-aware), `x` discards a hunk, `delete` discards the
16
+ * into the adjacent file at the edges, `space` stages/unstages the focused
17
+ * diff row (the focused hunk in hunk view), `s`/`u` stage/unstage
18
+ * (hunk-aware), `x` discards a hunk, `delete` discards the
18
19
  * whole file (press twice to confirm), `w` wraps, `b` cycles
19
20
  * whitespace handling (exact → ignore whitespace → ignore
20
21
  * formatting/import-only changes), `c` jumps to the commit form, `r`
@@ -25,7 +26,7 @@
25
26
  */
26
27
 
27
28
  import type { TspSpan } from "@oh-my-pi/pi-wire";
28
- import { formatKeyHint, formatKeyHints } from "../../app-keybindings";
29
+ import { formatKeyHint, formatKeyHints, formatTooltipKey } from "../../app-keybindings";
29
30
  import { SplitPane } from "../../components/layout/split-pane";
30
31
  import { Stack } from "../../components/layout/stack";
31
32
  import { matchesKey } from "../../keys";
@@ -669,14 +670,20 @@ class GitTuiComponent implements Component {
669
670
  else if (matchesKey(data, "up") || data === "k") this.#pane.moveCursor(-1, false);
670
671
  else if (matchesKey(data, "down") || data === "j") this.#pane.moveCursor(1, false);
671
672
  else if (matchesKey(data, "pageUp")) this.#pane.moveCursor(-Math.max(1, this.#contentHeight - 2), false);
672
- else if (matchesKey(data, "pageDown") || data === " ")
673
- this.#pane.moveCursor(Math.max(1, this.#contentHeight - 2), false);
673
+ else if (matchesKey(data, "pageDown")) this.#pane.moveCursor(Math.max(1, this.#contentHeight - 2), false);
674
674
  else if (matchesKey(data, "left") || data === "h") this.#pane.scrollLeftBy(-8);
675
675
  else if (matchesKey(data, "right") || data === "l") this.#pane.scrollLeftBy(8);
676
676
  else if (matchesKey(data, "home") || data === "g") this.#pane.cursorToEdge("start");
677
677
  else if (matchesKey(data, "end") || data === "G") this.#pane.cursorToEdge("end");
678
678
  else if (matchesKey(data, "enter")) return this.#jumpHunkOrFile(1);
679
- else if (data === "s" || data === "u") {
679
+ else if (matchesKey(data, "space")) {
680
+ // Hunk-view rows do not map back to document rows, so Space acts on the focused hunk there.
681
+ const target = this.#pane.patchTarget;
682
+ const hunk = this.#pane.mode === "hunk" ? this.#pane.currentHunk : null;
683
+ if (target && hunk) void this.#hunkAction(hunk, target);
684
+ else if (target && this.#pane.selection) void this.#lineAction(target);
685
+ return;
686
+ } else if (data === "s" || data === "u") {
680
687
  if (this.#pane.selection?.explicit && this.#pane.patchTarget) {
681
688
  void this.#lineAction(this.#pane.patchTarget);
682
689
  return;
@@ -1023,8 +1030,8 @@ class GitTuiComponent implements Component {
1023
1030
  const right: NativeNode[] = [
1024
1031
  row(
1025
1032
  [
1026
- iconButton("chev-up", "prev-hunk", `Previous change (${formatKeyHint("alt+up")})`),
1027
- iconButton("chev", "next-hunk", `Next change (${formatKeyHint("alt+down")})`),
1033
+ iconButton("chev-up", "prev-hunk", `Previous change (${formatTooltipKey("alt+up")})`),
1034
+ iconButton("chev", "next-hunk", `Next change (${formatTooltipKey("alt+down")})`),
1028
1035
  ],
1029
1036
  { gap: "none", align: "center" },
1030
1037
  ),
@@ -1045,13 +1052,13 @@ class GitTuiComponent implements Component {
1045
1052
  "type",
1046
1053
  "whitespace",
1047
1054
  this.#whitespace === "off"
1048
- ? `Ignore whitespace (${formatKeyHint("b")})`
1055
+ ? `Ignore whitespace (${formatTooltipKey("b")})`
1049
1056
  : this.#whitespace === "whitespace"
1050
- ? `Ignoring whitespace; also ignore formatting (${formatKeyHint("b")})`
1051
- : `Ignoring formatting and imports; show all (${formatKeyHint("b")})`,
1057
+ ? `Ignoring whitespace; also ignore formatting (${formatTooltipKey("b")})`
1058
+ : `Ignoring formatting and imports; show all (${formatTooltipKey("b")})`,
1052
1059
  this.#whitespace !== "off",
1053
1060
  ),
1054
- iconButton("corner-down-right", "wrap", `Wrap lines (${formatKeyHint("w")})`, this.#pane.wrap),
1061
+ iconButton("corner-down-right", "wrap", `Wrap lines (${formatTooltipKey("w")})`, this.#pane.wrap),
1055
1062
  );
1056
1063
  }
1057
1064
  if (file?.area === "unstaged" || file?.area === "staged") {
@@ -1063,8 +1070,8 @@ class GitTuiComponent implements Component {
1063
1070
  );
1064
1071
  }
1065
1072
  right.push(
1066
- iconButton("keyboard", "help", `Keyboard shortcuts (${formatKeyHint("?")})`),
1067
- iconButton("x", "close", `Close (${formatKeyHint("q")})`),
1073
+ iconButton("keyboard", "help", `Keyboard shortcuts (${formatTooltipKey("?")})`),
1074
+ iconButton("x", "close", `Close (${formatTooltipKey("q")})`),
1068
1075
  );
1069
1076
  return keyed(
1070
1077
  row(
@@ -1699,7 +1699,12 @@ export class Sidebar {
1699
1699
  png,
1700
1700
  "image/png",
1701
1701
  { fallbackColor: text => theme.fg("dim", text) },
1702
- { maxHeightCells: 3, budget: this.#imageBudget, imageKey: `git-avatar:${email}` },
1702
+ {
1703
+ maxHeightCells: 3,
1704
+ budget: this.#imageBudget,
1705
+ imageKey: `git-avatar:${email}`,
1706
+ requestRender: this.#requestRender,
1707
+ },
1703
1708
  ),
1704
1709
  };
1705
1710
  }
@@ -456,6 +456,7 @@ export class AssistantMessageComponent extends Container {
456
456
  readonly #thinkingRenderers: readonly AssistantThinkingRenderer[];
457
457
  readonly #imageBudget?: ImageBudget;
458
458
  #proseOnlyThinking: boolean;
459
+ #expandThinkingBlocks: boolean;
459
460
 
460
461
  constructor(
461
462
  message?: AssistantMessage,
@@ -465,6 +466,7 @@ export class AssistantMessageComponent extends Container {
465
466
  imageBudget?: ImageBudget,
466
467
  proseOnlyThinking = true,
467
468
  linkTargets?: ReadonlyMap<string, string>,
469
+ expandThinkingBlocks = false,
468
470
  ) {
469
471
  super();
470
472
  this.#hideThinkingBlock = hideThinkingBlock;
@@ -472,6 +474,7 @@ export class AssistantMessageComponent extends Container {
472
474
  this.#thinkingRenderers = thinkingRenderers;
473
475
  this.#imageBudget = imageBudget;
474
476
  this.#proseOnlyThinking = proseOnlyThinking;
477
+ this.#expandThinkingBlocks = expandThinkingBlocks;
475
478
 
476
479
  ensureThemeSync();
477
480
  this.#transcriptBlockFinalized = message !== undefined;
@@ -560,6 +563,16 @@ export class AssistantMessageComponent extends Container {
560
563
  this.#proseOnlyThinking = proseOnly;
561
564
  }
562
565
 
566
+ /**
567
+ * Keep finished thinking sections expanded instead of folding them to "Thought for 12s".
568
+ * Sections the user folded or unfolded by hand keep that choice.
569
+ */
570
+ setExpandThinkingBlocks(expand: boolean): void {
571
+ if (this.#expandThinkingBlocks === expand) return;
572
+ this.#expandThinkingBlocks = expand;
573
+ this.#nativeViewVersion++;
574
+ }
575
+
563
576
  override dispose(): void {
564
577
  this.#stopThinkingAnimation();
565
578
  super.dispose();
@@ -831,8 +844,10 @@ export class AssistantMessageComponent extends Container {
831
844
  // `.live` while streaming: the body clamps to its tail under a fade.
832
845
  role: thinkingLive ? "omp.thinking.live" : "omp.thinking",
833
846
  collapsible: true,
834
- // Open while it streams; a finished thought folds to its "Thought for 12s" line.
835
- collapsed: this.#thinkingCollapsed.get(index) ?? (this.#hideThinkingBlock || !thinkingLive),
847
+ // Open while it streams; a finished thought folds to its "Thought for 12s" line unless the user keeps thinking expanded.
848
+ collapsed:
849
+ this.#thinkingCollapsed.get(index) ??
850
+ (this.#hideThinkingBlock || (!thinkingLive && !this.#expandThinkingBlocks)),
836
851
  // Tern's fold head sums it into "Worked for 12s".
837
852
  took: clock?.end === undefined ? undefined : Math.max(0, Math.round(clock.end - clock.start)),
838
853
  },
@@ -1373,7 +1388,12 @@ export class AssistantMessageComponent extends Container {
1373
1388
  displayImage.data,
1374
1389
  displayImage.mimeType,
1375
1390
  { fallbackColor: (text: string) => theme.fg("toolOutput", text) },
1376
- { ...resolveImageOptions(), budget: this.#imageBudget, imageKey: key },
1391
+ {
1392
+ ...resolveImageOptions(),
1393
+ budget: this.#imageBudget,
1394
+ imageKey: key,
1395
+ requestRender: this.#onImageUpdate,
1396
+ },
1377
1397
  ),
1378
1398
  );
1379
1399
  continue;
@@ -1415,7 +1435,9 @@ export class AssistantMessageComponent extends Container {
1415
1435
  }
1416
1436
 
1417
1437
  #computeShapeKey(message: AssistantMessage): string {
1418
- const parts: string[] = [`htb:${this.#hideThinkingBlock ? 1 : 0}|pot:${this.#proseOnlyThinking ? 1 : 0}`];
1438
+ const parts: string[] = [
1439
+ `htb:${this.#hideThinkingBlock ? 1 : 0}|pot:${this.#proseOnlyThinking ? 1 : 0}|etb:${this.#expandThinkingBlocks ? 1 : 0}`,
1440
+ ];
1419
1441
  for (const content of message.content) {
1420
1442
  if (content.type === "text") {
1421
1443
  parts.push(canonicalizeMessage(content.text) ? "T1" : "T0");
@@ -80,6 +80,7 @@ export interface ChatTranscriptBuilderDeps {
80
80
  cwd: string;
81
81
  hideThinkingBlock?: () => boolean;
82
82
  proseOnlyThinking?: () => boolean;
83
+ expandThinkingBlocks?: () => boolean;
83
84
  /** Session-scoped resolved destinations for model-authored Markdown links. */
84
85
  linkTargets?: ReadonlyMap<string, string>;
85
86
  requestRender: () => void;
@@ -404,6 +405,7 @@ export class ChatTranscriptBuilder {
404
405
  #appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
405
406
  const hideThinkingBlock = this.#deps.hideThinkingBlock?.() ?? false;
406
407
  const proseOnlyThinking = this.#deps.proseOnlyThinking ? this.#deps.proseOnlyThinking() : true;
408
+ const expandThinkingBlocks = this.#deps.expandThinkingBlocks?.() ?? false;
407
409
  const timeline = splitAssistantMessageToolTimeline(message);
408
410
  const assistantComponent = new AssistantMessageComponent(
409
411
  timeline.beforeTools,
@@ -413,6 +415,7 @@ export class ChatTranscriptBuilder {
413
415
  this.#deps.ui.imageBudget,
414
416
  proseOnlyThinking,
415
417
  this.#deps.linkTargets,
418
+ expandThinkingBlocks,
416
419
  );
417
420
  assistantComponent.setImagesVisible(displayPreferences.showImages);
418
421
  assistantComponent.setToolResultImagesVisible(!displayPreferences.hideToolActivity);
@@ -450,6 +453,7 @@ export class ChatTranscriptBuilder {
450
453
  undefined,
451
454
  proseOnlyThinking,
452
455
  this.#deps.linkTargets,
456
+ expandThinkingBlocks,
453
457
  );
454
458
  component.setImagesVisible(displayPreferences.showImages);
455
459
  component.setToolResultImagesVisible(!displayPreferences.hideToolActivity);
@@ -1639,7 +1639,12 @@ export class ToolExecutionComponent extends Container {
1639
1639
  imageData,
1640
1640
  imageMimeType,
1641
1641
  { fallbackColor: (s: string) => theme.fg("toolOutput", s) },
1642
- { ...resolveImageOptions(), budget: this.#ui.imageBudget, imageKey: `te${this.#instanceId}:${i}` },
1642
+ {
1643
+ ...resolveImageOptions(),
1644
+ budget: this.#ui.imageBudget,
1645
+ imageKey: `te${this.#instanceId}:${i}`,
1646
+ requestRender: () => this.#ui.requestRender(),
1647
+ },
1643
1648
  );
1644
1649
  this.#imageComponents.push(imageComponent);
1645
1650
  this.addChild(imageComponent);
@@ -24,7 +24,13 @@ import type { ReactionTarget } from "./reaction";
24
24
  import { card, md, node, row, span, text } from "../native/describe";
25
25
  import { base64ImageNode } from "../native/blobs";
26
26
  import { hasTranscriptActions, runTranscriptAction } from "./transcript-actions";
27
- import { type NativeChild, type NativeNode, type NativeUiEvent, rootToggleExpanded } from "../native/node";
27
+ import {
28
+ type DescribeContext,
29
+ type NativeChild,
30
+ type NativeNode,
31
+ type NativeUiEvent,
32
+ rootToggleExpanded,
33
+ } from "../native/node";
28
34
  import { Memo } from "../native/memo";
29
35
 
30
36
  // OSC 133 shell integration: marks prompt zones for terminal multiplexers.
@@ -137,6 +143,8 @@ export class UserMessageComponent extends Container implements ReactionTarget {
137
143
  readonly #tokens: RegExp;
138
144
  #reaction: string | undefined;
139
145
  #native: NativeNode | undefined;
146
+ /** The terminal's clock {@link #native} was described with. */
147
+ #nativeHour12: boolean | undefined;
140
148
 
141
149
  constructor(text: string, options: UserBubbleOptions = {}) {
142
150
  super();
@@ -184,17 +192,20 @@ export class UserMessageComponent extends Container implements ReactionTarget {
184
192
  * agent's reaction are chips at the bottom-right, so a reaction landing
185
193
  * later updates the frame in place, even deep in scrollback.
186
194
  */
187
- override describe(): NativeNode {
188
- if (this.#native) return this.#native;
195
+ override describe(cx?: DescribeContext): NativeNode {
196
+ // The terminal's clock: Bun's own default locale ignores the user's.
197
+ const hour12 = cx?.hour12;
198
+ if (this.#native && this.#nativeHour12 === hour12) return this.#native;
199
+ this.#nativeHour12 = hour12;
189
200
  const children: NativeChild[] = [];
190
201
  if (!this.#synthetic && hasTranscriptActions()) {
191
202
  const tools: NativeChild[] = [];
192
203
  if (this.#timestamp !== undefined) {
193
204
  const at = new Date(this.#timestamp);
194
205
  tools.push(
195
- text([span(at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), "dim mono")], {
206
+ text([span(at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12 }), "dim mono")], {
196
207
  role: "omp.user.time",
197
- title: at.toLocaleString(),
208
+ title: at.toLocaleString([], { hour12 }),
198
209
  }),
199
210
  );
200
211
  }
@@ -1,6 +1,6 @@
1
1
  import { Spacer } from "../components/spacer";
2
2
  import { Text } from "../components/text";
3
- import { node } from "../native/describe";
3
+ import { EMPTY_NODE, node } from "../native/describe";
4
4
  import type { NativeNode } from "../native/node";
5
5
  import { styledSpans } from "../native/spans";
6
6
  import { Container } from "../tui";
@@ -8,33 +8,51 @@ import { Container } from "../tui";
8
8
  /** How long a native status toast stays up (ms). */
9
9
  const TOAST_TTL = 2400;
10
10
 
11
+ /** How a {@link StatusNotice} shows. */
12
+ export interface StatusNoticeOptions {
13
+ /** Styles the ANSI line; resolved at render so a theme change re-shapes it. */
14
+ styleFn?: (text: string) => string;
15
+ /**
16
+ * Whether a native terminal toasts it (default). False keeps the notice to
17
+ * the ANSI transcript: background chatter such as MCP connection progress,
18
+ * which would otherwise re-toast on every server that connects or fails.
19
+ */
20
+ toast?: boolean;
21
+ }
22
+
11
23
  /**
12
24
  * A transient status notice (`showStatus`: "Thinking blocks: hidden", "Copied
13
25
  * to clipboard"). The ANSI render appends a blank row and the dim line to the
14
26
  * transcript; a native terminal shows it as a toast instead, so notices never
15
- * pile up in the history. Updating the text re-shows the toast.
27
+ * pile up in the history. Updating the text re-shows the toast; a notice
28
+ * with `toast: false` describes nothing natively.
16
29
  */
17
30
  export class StatusNotice extends Container {
18
31
  readonly #text: Text;
19
32
  #message: string;
33
+ #toast: boolean;
20
34
  #native: NativeNode | undefined;
21
35
 
22
- constructor(message: string, styleFn?: (text: string) => string) {
36
+ constructor(message: string, options: StatusNoticeOptions = {}) {
23
37
  super();
24
38
  this.#message = message;
25
- this.#text = new Text(message, 1, 0).setStyleFn(styleFn);
39
+ this.#toast = options.toast ?? true;
40
+ this.#text = new Text(message, 1, 0).setStyleFn(options.styleFn);
26
41
  this.addChild(new Spacer(1));
27
42
  this.addChild(this.#text);
28
43
  }
29
44
 
30
- setMessage(message: string, styleFn?: (text: string) => string): void {
31
- this.#text.setStyleFn(styleFn);
45
+ setMessage(message: string, options: StatusNoticeOptions = {}): void {
46
+ const toast = options.toast ?? true;
47
+ this.#text.setStyleFn(options.styleFn);
32
48
  this.#text.setText(message);
33
- if (message !== this.#message) this.#native = undefined;
49
+ if (message !== this.#message || toast !== this.#toast) this.#native = undefined;
34
50
  this.#message = message;
51
+ this.#toast = toast;
35
52
  }
36
53
 
37
54
  override describe(): NativeNode {
55
+ if (!this.#toast) return EMPTY_NODE;
38
56
  if (this.#native) return this.#native;
39
57
  // Callers may pass pre-styled text; a toast takes plain text.
40
58
  const plain = styledSpans(this.#message)
@@ -1,6 +1,7 @@
1
1
  import { type Component, Container, type HistoryBatch } from "../tui";
2
2
  import * as logger from "@oh-my-pi/pi-utils/logger";
3
3
  import { popLoopPhase, pushLoopPhase } from "@oh-my-pi/pi-utils";
4
+ import { renderForScrollback } from "../components/image";
4
5
  import { col } from "../native/describe";
5
6
  import type { NativeNode } from "../native/node";
6
7
  import { isNativeSettled, settleNative } from "../native/settle";
@@ -1000,9 +1001,11 @@ export class TranscriptContainer extends Container {
1000
1001
  // Only the range head is sliced by its emitted stable prefix; every other
1001
1002
  // entry renders whole, so the append-only verification pass (a second
1002
1003
  // full render of the block's stable prefix) is skipped for them. This
1003
- // keeps a complete-ledger replay at one render per block.
1004
- const rendered =
1005
- index === start ? this.#renderEntry(entry, width) : trimBlankEdges(entry.component.render(width));
1004
+ // keeps a complete-ledger replay at one render per block. These rows go
1005
+ // to native scrollback, where nothing can repaint them.
1006
+ const rendered = renderForScrollback(() =>
1007
+ index === start ? this.#renderEntry(entry, width) : trimBlankEdges(entry.component.render(width)),
1008
+ );
1006
1009
  const emittedRows = index === start ? this.#renderStablePrefix(entry, entry.emitted, width).length : 0;
1007
1010
  const block = rendered.slice(emittedRows);
1008
1011
  reached = index + 1;
@@ -3042,6 +3042,20 @@ export class Editor implements Component, Focusable {
3042
3042
  return true;
3043
3043
  }
3044
3044
 
3045
+ /** Whether a character-jump hotkey is waiting for its target character. */
3046
+ get isJumpPending(): boolean {
3047
+ return this.#jumpMode !== null;
3048
+ }
3049
+
3050
+ /** Type literal text through the character-editing pipeline without keybinding dispatch. */
3051
+ typeCharacter(text: string): void {
3052
+ // Same as #handleInputChunk: typed input wins over a stale in-flight provider lookup.
3053
+ if (this.#autocompleteRequestRunning && this.#autocompleteState === null) {
3054
+ this.#invalidateAutocompleteRequests();
3055
+ }
3056
+ this.#insertCharacter(text);
3057
+ }
3058
+
3045
3059
  #insertCharacter(char: string): void {
3046
3060
  this.#exitHistoryForEditing();
3047
3061
  // Undo coalescing: consecutive word typing collapses into one undo unit