@oh-my-pi/pi-tui 18.6.1 → 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 (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -2
  3. package/dist/types/app-keybindings.d.ts +6 -1
  4. package/dist/types/chat/assistant-message.d.ts +6 -1
  5. package/dist/types/chat/chat-transcript-builder.d.ts +1 -0
  6. package/dist/types/chat/user-message.d.ts +2 -2
  7. package/dist/types/chrome/status-notice.d.ts +15 -3
  8. package/dist/types/components/editor.d.ts +4 -0
  9. package/dist/types/components/image.d.ts +14 -0
  10. package/dist/types/components/input.d.ts +1 -0
  11. package/dist/types/components/loader.d.ts +5 -6
  12. package/dist/types/key-hint-format.d.ts +8 -0
  13. package/dist/types/native/backend.d.ts +2 -2
  14. package/dist/types/native/node.d.ts +6 -0
  15. package/dist/types/native/overlay.d.ts +3 -3
  16. package/dist/types/overlays/agent-hub.d.ts +1 -0
  17. package/dist/types/overlays/agent-transcript-viewer.d.ts +1 -0
  18. package/dist/types/overlays/btw-history-panel.d.ts +5 -1
  19. package/dist/types/overlays/copy-selector.d.ts +1 -0
  20. package/dist/types/overlays/rewind-selector.d.ts +1 -0
  21. package/dist/types/overlays/usage-dashboard.d.ts +4 -4
  22. package/dist/types/prompt/custom-editor.d.ts +10 -4
  23. package/dist/types/prompt/interactive-context-helpers.d.ts +1 -0
  24. package/dist/types/space-hold.d.ts +40 -25
  25. package/dist/types/terminal-capabilities.d.ts +13 -0
  26. package/dist/types/tui.d.ts +5 -0
  27. package/package.json +9 -9
  28. package/src/app-keybindings.ts +6 -1
  29. package/src/apps/git/diff-pane.ts +42 -16
  30. package/src/apps/git/git-tui.ts +9 -9
  31. package/src/apps/git/sidebar.ts +6 -1
  32. package/src/chat/assistant-message.ts +26 -4
  33. package/src/chat/chat-transcript-builder.ts +4 -0
  34. package/src/chat/tool-execution.ts +6 -1
  35. package/src/chat/user-message.ts +16 -5
  36. package/src/chrome/status-notice.ts +25 -7
  37. package/src/chrome/transcript-container.ts +6 -3
  38. package/src/components/editor.ts +14 -0
  39. package/src/components/image.ts +68 -1
  40. package/src/components/input.ts +14 -6
  41. package/src/components/loader.ts +10 -17
  42. package/src/hotkeys-markdown.ts +9 -2
  43. package/src/key-hint-format.ts +25 -10
  44. package/src/native/backend.ts +10 -3
  45. package/src/native/node.ts +6 -0
  46. package/src/native/overlay.ts +4 -4
  47. package/src/overlays/agent-hub.ts +4 -0
  48. package/src/overlays/agent-transcript-viewer.ts +2 -0
  49. package/src/overlays/btw-history-panel.ts +9 -2
  50. package/src/overlays/copy-selector.ts +2 -0
  51. package/src/overlays/extensions/extension-dashboard.ts +4 -4
  52. package/src/overlays/pause-screen.ts +2 -2
  53. package/src/overlays/rewind-selector.ts +2 -0
  54. package/src/overlays/usage-dashboard.ts +17 -8
  55. package/src/overlays/usage-row.ts +22 -8
  56. package/src/prompt/custom-editor.ts +188 -67
  57. package/src/prompt/interactive-context-helpers.ts +2 -0
  58. package/src/prompt/queued-messages.ts +6 -4
  59. package/src/space-hold.ts +156 -52
  60. package/src/terminal-capabilities.ts +42 -23
  61. package/src/tui.ts +36 -14
@@ -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",
@@ -601,6 +601,12 @@ export class DiffPane {
601
601
  #layoutCache: { key: string; visuals: Visual[] } | undefined;
602
602
  /** Per visible row: clickable hunk-button ranges recorded during render. */
603
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>();
604
610
  constructor(imageBudget?: ImageBudget) {
605
611
  this.#imageBudget = imageBudget;
606
612
  }
@@ -1103,8 +1109,16 @@ export class DiffPane {
1103
1109
  const leftWidth = Math.max(1, Math.floor((width - 1) / 2));
1104
1110
  const rightWidth = Math.max(1, width - leftWidth - 1);
1105
1111
  const bodyHeight = Math.max(0, height - 1);
1106
- const oldLines = this.#renderAssetSide(asset.old, leftWidth, bodyHeight, `old:${asset.filePath}`);
1107
- 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
+ );
1108
1122
  const border = theme.fg("borderMuted", "│");
1109
1123
  const lines: string[] = [];
1110
1124
  for (let index = 0; index < height; index++) {
@@ -1125,24 +1139,36 @@ export class DiffPane {
1125
1139
  return lines;
1126
1140
  }
1127
1141
 
1128
- #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[] {
1129
1149
  if (height <= 0) return [];
1130
1150
  let content: readonly string[];
1131
1151
  if (side.kind === "image") {
1132
1152
  const image = side.image;
1133
- content = new Image(
1134
- image.data,
1135
- image.mimeType,
1136
- { fallbackColor: text => theme.fg("dim", text) },
1137
- {
1138
- maxWidthCells: Math.max(1, width - 2),
1139
- maxHeightCells: height,
1140
- filename: this.#asset?.filePath,
1141
- budget: this.#imageBudget,
1142
- imageKey: `git-review:${placementKey}:${image.key}`,
1143
- },
1144
- { widthPx: image.widthPx, heightPx: image.heightPx },
1145
- ).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);
1146
1172
  } else {
1147
1173
  let details: string[];
1148
1174
  switch (side.kind) {
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import type { TspSpan } from "@oh-my-pi/pi-wire";
29
- import { formatKeyHint, formatKeyHints } from "../../app-keybindings";
29
+ import { formatKeyHint, formatKeyHints, formatTooltipKey } from "../../app-keybindings";
30
30
  import { SplitPane } from "../../components/layout/split-pane";
31
31
  import { Stack } from "../../components/layout/stack";
32
32
  import { matchesKey } from "../../keys";
@@ -1030,8 +1030,8 @@ class GitTuiComponent implements Component {
1030
1030
  const right: NativeNode[] = [
1031
1031
  row(
1032
1032
  [
1033
- iconButton("chev-up", "prev-hunk", `Previous change (${formatKeyHint("alt+up")})`),
1034
- 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")})`),
1035
1035
  ],
1036
1036
  { gap: "none", align: "center" },
1037
1037
  ),
@@ -1052,13 +1052,13 @@ class GitTuiComponent implements Component {
1052
1052
  "type",
1053
1053
  "whitespace",
1054
1054
  this.#whitespace === "off"
1055
- ? `Ignore whitespace (${formatKeyHint("b")})`
1055
+ ? `Ignore whitespace (${formatTooltipKey("b")})`
1056
1056
  : this.#whitespace === "whitespace"
1057
- ? `Ignoring whitespace; also ignore formatting (${formatKeyHint("b")})`
1058
- : `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")})`,
1059
1059
  this.#whitespace !== "off",
1060
1060
  ),
1061
- 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),
1062
1062
  );
1063
1063
  }
1064
1064
  if (file?.area === "unstaged" || file?.area === "staged") {
@@ -1070,8 +1070,8 @@ class GitTuiComponent implements Component {
1070
1070
  );
1071
1071
  }
1072
1072
  right.push(
1073
- iconButton("keyboard", "help", `Keyboard shortcuts (${formatKeyHint("?")})`),
1074
- iconButton("x", "close", `Close (${formatKeyHint("q")})`),
1073
+ iconButton("keyboard", "help", `Keyboard shortcuts (${formatTooltipKey("?")})`),
1074
+ iconButton("x", "close", `Close (${formatTooltipKey("q")})`),
1075
1075
  );
1076
1076
  return keyed(
1077
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
@@ -1,5 +1,7 @@
1
+ import { encodeSixelAsync } from "@oh-my-pi/pi-natives";
1
2
  import { getKittyGraphics } from "../kitty-graphics";
2
3
  import {
4
+ encodeSixelNow,
3
5
  getCellDimensions,
4
6
  getImageDimensions,
5
7
  type ImageDimensions,
@@ -29,6 +31,29 @@ export interface ImageOptions {
29
31
  * repaint replaces the placement instead of stacking a duplicate.
30
32
  */
31
33
  imageKey?: string;
34
+ /**
35
+ * Schedules a repaint once this image's off-thread SIXEL encode lands.
36
+ * Defaults to the budget's repaint; a host without a budget must pass one,
37
+ * or the image's reserved rows stay blank until an unrelated repaint.
38
+ */
39
+ requestRender?: () => void;
40
+ }
41
+
42
+ /** Renders in progress whose rows are bound for native scrollback. */
43
+ let scrollbackRenderDepth = 0;
44
+
45
+ /**
46
+ * Run `render` for rows bound for native scrollback. Those rows are never
47
+ * repainted, so an image whose off-thread SIXEL encode has not landed encodes
48
+ * synchronously instead of committing its reserved blank rows.
49
+ */
50
+ export function renderForScrollback<T>(render: () => T): T {
51
+ scrollbackRenderDepth++;
52
+ try {
53
+ return render();
54
+ } finally {
55
+ scrollbackRenderDepth--;
56
+ }
32
57
  }
33
58
 
34
59
  const EMPTY_IDS: readonly number[] = [];
@@ -227,6 +252,11 @@ export class ImageBudget {
227
252
  this.#requestRender = requestRender;
228
253
  }
229
254
 
255
+ /** Ask for a repaint, e.g. once an image's off-thread encode settles. */
256
+ requestRender(): void {
257
+ this.#requestRender();
258
+ }
259
+
230
260
  setCap(cap: number): void {
231
261
  const next = normalizeCap(cap);
232
262
  if (next === this.#cap) return;
@@ -735,6 +765,8 @@ export class Image implements Component {
735
765
  // (its rows may already be committed to native scrollback).
736
766
  #renderedGraphicRows = 0;
737
767
  #native?: NativeNode;
768
+ /** Newest SIXEL encode: its target size and, once settled, the sequence (`null` on failure). */
769
+ #sixel?: { widthPx: number; heightPx: number; sequence?: string | null };
738
770
 
739
771
  constructor(
740
772
  base64Data: string,
@@ -768,6 +800,38 @@ export class Image implements Component {
768
800
  this.#cachedWidth = undefined;
769
801
  }
770
802
 
803
+ /**
804
+ * SIXEL sequence for a target size. A new size starts the encode off the JS
805
+ * thread and answers `undefined` until it settles; the settled encode
806
+ * invalidates the cached lines and requests a repaint. A render bound for
807
+ * scrollback cannot wait, so it encodes synchronously.
808
+ */
809
+ #sixelSequence(widthPx: number, heightPx: number): string | null | undefined {
810
+ const current = this.#sixel;
811
+ const sameSize = current !== undefined && current.widthPx === widthPx && current.heightPx === heightPx;
812
+ if (sameSize && current.sequence !== undefined) return current.sequence;
813
+ if (scrollbackRenderDepth > 0) {
814
+ // Replacing the entry makes a still-pending async encode settle as stale.
815
+ const sequence = encodeSixelNow(this.#base64Data, widthPx, heightPx);
816
+ this.#sixel = { widthPx, heightPx, sequence };
817
+ return sequence;
818
+ }
819
+ if (sameSize) return undefined;
820
+ const request: { widthPx: number; heightPx: number; sequence?: string | null } = { widthPx, heightPx };
821
+ this.#sixel = request;
822
+ const settle = (sequence: string | null): void => {
823
+ request.sequence = sequence;
824
+ if (this.#sixel !== request) return;
825
+ this.invalidate();
826
+ if (this.#options.requestRender) this.#options.requestRender();
827
+ else this.#budget?.requestRender();
828
+ };
829
+ encodeSixelAsync(new Uint8Array(Buffer.from(this.#base64Data, "base64")), widthPx, heightPx).then(settle, () =>
830
+ settle(null),
831
+ );
832
+ return undefined;
833
+ }
834
+
771
835
  /**
772
836
  * A native `image` backed by a content-addressed blob; the terminal fits
773
837
  * it. Cell caps become `ch`/`lines` bounds. The inline-image budget and
@@ -820,7 +884,9 @@ export class Image implements Component {
820
884
  this.#cachedCellWidthPx === cellDimensions.widthPx &&
821
885
  this.#cachedCellHeightPx === cellDimensions.heightPx &&
822
886
  this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders &&
823
- (!transmitsSeparately || this.#budget?.shouldTransmit(imageId) !== true)
887
+ (!transmitsSeparately || this.#budget?.shouldTransmit(imageId) !== true) &&
888
+ // Cached rows reserved for a pending SIXEL encode must not reach scrollback.
889
+ !(scrollbackRenderDepth > 0 && this.#sixel !== undefined && this.#sixel.sequence === undefined)
824
890
  ) {
825
891
  return this.#cachedLines;
826
892
  }
@@ -839,6 +905,7 @@ export class Image implements Component {
839
905
  maxHeightCells: this.#options.maxHeightCells,
840
906
  imageId: this.#imageId,
841
907
  includeTransmit: needsTransmit,
908
+ sixel: (widthPx, heightPx) => this.#sixelSequence(widthPx, heightPx),
842
909
  });
843
910
 
844
911
  if (result?.transmit && this.#imageId != null && this.#budget !== undefined) {
@@ -1,6 +1,6 @@
1
1
  import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
2
- import { getKeybindings } from "../keybindings";
3
- import { extractPrintableText, matchesKey } from "../keys";
2
+ import { canonicalKeyId, getKeybindings } from "../keybindings";
3
+ import { extractPrintableText, parseKey } from "../keys";
4
4
  import { KillRing } from "../kill-ring";
5
5
  import type { TspInputProps } from "@oh-my-pi/pi-wire";
6
6
  import { node } from "../native/describe";
@@ -14,7 +14,7 @@ import {
14
14
  type NativeUiEvent,
15
15
  resolveTextEdit,
16
16
  } from "../native/node";
17
- import { SpaceHoldGesture } from "../space-hold";
17
+ import { getSpaceHoldText, SpaceHoldGesture } from "../space-hold";
18
18
  import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
19
19
  import { cursorColumnWindow } from "./scroll-viewport";
20
20
  import {
@@ -134,6 +134,10 @@ export class Input implements Component, Focusable {
134
134
  return this.#useTerminalCursor;
135
135
  }
136
136
 
137
+ capturesInput(data: string): boolean {
138
+ return this.spaceHold.shouldRoute(data);
139
+ }
140
+
137
141
  /**
138
142
  * Apply one key: the editor's text bindings (motion, deletion, kill ring,
139
143
  * undo), pastes and printable text. Returns whether the key was the
@@ -154,10 +158,14 @@ export class Input implements Component, Focusable {
154
158
  return true;
155
159
  }
156
160
 
157
- // Space-hold push-to-talk: a sustained space bar starts/stops STT instead of typing spaces.
158
- switch (this.spaceHold.process(matchesKey(data, "space"))) {
161
+ // Reserve configured keys before text bindings or submit/delete handlers can consume them.
162
+ const parsedKey = parseKey(data);
163
+ const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined;
164
+ const spaceHoldText = getSpaceHoldText(data, canonical);
165
+ switch (this.spaceHold.process(canonical, spaceHoldText?.length ?? 0)) {
159
166
  case "type":
160
- this.#insertCharacter(" ");
167
+ this.#insertCharacter(spaceHoldText!);
168
+ this.spaceHold.recordTyped(spaceHoldText!.length);
161
169
  return true;
162
170
  case "swallow":
163
171
  return true;