@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
@@ -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;
@@ -1,4 +1,6 @@
1
1
  import type { TspProps } from "@oh-my-pi/pi-wire";
2
+ import { formatTooltipKey } from "../key-hint-format";
3
+ import type { KeyId } from "../keys";
2
4
  import { elapsed, kbd, keyed, node, row, span, text } from "../native/describe";
3
5
  import { plainText } from "../native/spans";
4
6
  import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
@@ -38,22 +40,15 @@ export interface WorkingRowSpec {
38
40
  readonly variant?: WorkingRowVariant;
39
41
  /** Shimmer palette of the label (a session-accented intent). */
40
42
  readonly palette?: ShimmerPalette;
41
- /** Live tok/s, docked before the stop control. */
42
- readonly rate?: number;
43
43
  /** Key id that interrupts (`escape`); undefined hides the stop control (Esc would not cancel). */
44
- readonly interruptKey?: string;
45
- }
46
-
47
- /** A key id as tooltip keys: `escape` reads `esc`, the rest as bound. */
48
- function titleKey(key: string): string {
49
- return key === "escape" ? "esc" : key;
44
+ readonly interruptKey?: KeyId;
50
45
  }
51
46
 
52
47
  /**
53
48
  * The dock's working row: `starburst` spinner (or a retry countdown ring),
54
- * the intent shimmering, `·`, the elapsed time, a grow spacer, then the stop
55
- * control whose click sends `interrupt`. Compaction adds an indeterminate
56
- * `progress` before the stop control; a retry says "Cancel".
49
+ * the elapsed time, the `·` divider, the intent shimmering, a grow spacer,
50
+ * then the stop control whose click sends `interrupt`. Compaction adds an
51
+ * indeterminate `progress` before the stop control; a retry says "Cancel".
57
52
  */
58
53
  export function describeWorkingRow(spec: WorkingRowSpec, cx: DescribeContext, now = Date.now()): NativeNode {
59
54
  const variant = spec.variant;
@@ -67,13 +62,12 @@ export function describeWorkingRow(spec: WorkingRowSpec, cx: DescribeContext, no
67
62
  }
68
63
  const label = describeShimmer([{ text: spec.label, palette: spec.palette }], "label");
69
64
  children.push(
70
- node(label.k, { ...label.p, role: "omp.working.label" } as TspProps, label.c, label.key),
71
- node("text", { text: "·", role: "omp.working.sep" }, undefined, "sep"),
72
65
  keyed(elapsed(now - spec.startedAt), "elapsed"),
66
+ node("text", { text: "·", role: "omp.working.sep" }, undefined, "sep"),
67
+ node(label.k, { ...label.p, role: "omp.working.label" } as TspProps, label.c, label.key),
73
68
  node("row", { grow: 1 }, undefined, "fill"),
74
69
  );
75
70
  if (variant?.kind === "compaction") children.push(node("progress", { value: null }, undefined, "progress"));
76
- if (spec.rate !== undefined) children.push(node("rate", { value: spec.rate, unit: "tok/s" }, undefined, "rate"));
77
71
  if (spec.interruptKey !== undefined) {
78
72
  const verb = variant?.kind === "retry" ? "Cancel" : "Stop";
79
73
  children.push(
@@ -83,10 +77,10 @@ export function describeWorkingRow(spec: WorkingRowSpec, cx: DescribeContext, no
83
77
  role: "omp.working.stop",
84
78
  gap: "xs",
85
79
  align: "center",
86
- title: `${verb} ${titleKey(spec.interruptKey)}`,
80
+ title: `${verb} ${formatTooltipKey(spec.interruptKey)}`,
87
81
  actions: { click: "interrupt" },
88
82
  },
89
- [kbd(titleKey(spec.interruptKey)), text(verb)],
83
+ [kbd(spec.interruptKey), text(verb)],
90
84
  "stop",
91
85
  ),
92
86
  );
@@ -285,7 +279,6 @@ export class Loader extends Text {
285
279
  variant?.kind,
286
280
  variant?.kind === "retry" ? `${variant.attempt}/${variant.max}` : "",
287
281
  countdown,
288
- spec.rate,
289
282
  spec.interruptKey,
290
283
  spec.palette?.mid,
291
284
  shimmerEnabled(),
@@ -62,6 +62,7 @@ function hotkeyGroups(bindings: HotkeysMarkdownBindings): HotkeyGroup[] {
62
62
  if (keys.length > 0) return { keys: keys.map(formatKeyHint), ids: keys, action };
63
63
  return { keys: [bindings.keybindings.getDisplayString(action) || "Disabled"], ids: [], action };
64
64
  };
65
+ const pushToTalkKeys = bindings.keybindings.getKeys("app.stt.pushToTalk");
65
66
  // CustomEditor tests the chord that was actually pressed, so exit keys split by role: a key
66
67
  // that also carries tui.editor.deleteCharForward (the readline `^D` overlap) forward-deletes
67
68
  // while the prompt holds a draft, any other exit key quits immediately. Mixed bindings such as
@@ -139,8 +140,14 @@ function hotkeyGroups(bindings: HotkeysMarkdownBindings): HotkeyGroup[] {
139
140
  { keys: [act("app.retry")], action: "Retry last failed assistant turn" },
140
141
  { keys: [act("app.clipboard.pasteImage")], action: "Paste image or text from clipboard" },
141
142
  {
142
- keys: [{ text: "Hold " }, hints(["space"])],
143
- action: "Speech-to-text (push-to-talk): hold to record, release to transcribe",
143
+ keys:
144
+ pushToTalkKeys.length > 0
145
+ ? [{ text: "Hold " }, act("app.stt.pushToTalk")]
146
+ : [act("app.stt.pushToTalk")],
147
+ action:
148
+ pushToTalkKeys.length > 0
149
+ ? "Speech-to-text (push-to-talk): hold to record, release to transcribe"
150
+ : "Speech-to-text (push-to-talk) (disabled)",
144
151
  },
145
152
  { keys: [act("app.live.toggle")], action: "Start/stop live voice mode (/live)" },
146
153
  {
@@ -7,7 +7,7 @@
7
7
  import type { KeyId } from "./keybindings";
8
8
  import type { ModifierName } from "./keys";
9
9
  import { activeThemeSymbol } from "./theme/active-symbols";
10
- import type { SymbolKey } from "./theme/symbols";
10
+ import { SYMBOL_PRESETS, type SymbolKey } from "./theme/symbols";
11
11
 
12
12
  /**
13
13
  * Key hint formatting: every key shown to the user renders through
@@ -69,9 +69,8 @@ const KEY_WORDS: Record<string, string | undefined> = {
69
69
  insert: "Ins",
70
70
  };
71
71
 
72
- function keySymbol(key: SymbolKey): string {
73
- return activeThemeSymbol(key);
74
- }
72
+ /** Where a key's glyphs come from: the active theme, or a fixed preset. */
73
+ type KeySymbols = (key: SymbolKey) => string;
75
74
 
76
75
  const HAS_LETTER = /[A-Za-z0-9]/;
77
76
 
@@ -89,6 +88,22 @@ export type KeyName = KeyId | ModifierName;
89
88
  * is capitalized. Other keys are capitalized (`f5` → `F5`).
90
89
  */
91
90
  export function formatKeyHint(key: KeyName): string {
91
+ return formatKey(key, activeThemeSymbol);
92
+ }
93
+
94
+ /**
95
+ * Format one key for a native tooltip (a TSP `title`): unicode keycap glyphs
96
+ * whatever the theme's symbol preset (`shift+tab` → `⇧⇥`), which Tern's
97
+ * tooltip splits into keycaps, and `esc` as Tern's own keycaps read. The nerd
98
+ * preset's icons are missing from Tern's UI font, and its spaced chords would
99
+ * not parse as keys.
100
+ */
101
+ export function formatTooltipKey(key: KeyName): string {
102
+ return key === "escape" ? "esc" : formatKey(key, symbol => SYMBOL_PRESETS.unicode[symbol]);
103
+ }
104
+
105
+ /** {@link formatKeyHint} with glyphs from `symbol`. */
106
+ function formatKey(key: KeyName, symbol: KeySymbols): string {
92
107
  const mac = keyHintPlatform() === "darwin";
93
108
  // A trailing `+` is the plus key itself (`+`, `ctrl++`), not a separator.
94
109
  const parts = key.endsWith("+") ? [...key.slice(0, -1).split("+").slice(0, -1), "+"] : key.split("+");
@@ -96,14 +111,14 @@ export function formatKeyHint(key: KeyName): string {
96
111
  let out = "";
97
112
  for (const modifier in MODIFIER_SYMBOLS) {
98
113
  if (!parts.includes(modifier)) continue;
99
- const label = keySymbol(MODIFIER_SYMBOLS[modifier]![mac ? 1 : 0]);
100
- out += HAS_LETTER.test(label) ? `${label}+` : label + keySymbol("key.joiner");
114
+ const label = symbol(MODIFIER_SYMBOLS[modifier]![mac ? 1 : 0]);
115
+ out += HAS_LETTER.test(label) ? `${label}+` : label + symbol("key.joiner");
101
116
  }
102
117
  const lower = base.toLowerCase();
103
118
  const modifier = MODIFIER_SYMBOLS[lower];
104
- const symbol = KEY_SYMBOLS[lower];
105
- if (modifier) out += keySymbol(modifier[mac ? 1 : 0]);
106
- else if (symbol !== undefined) out += keySymbol(symbol);
119
+ const glyph = KEY_SYMBOLS[lower];
120
+ if (modifier) out += symbol(modifier[mac ? 1 : 0]);
121
+ else if (glyph !== undefined) out += symbol(glyph);
107
122
  else if (KEY_WORDS[lower] !== undefined) out += KEY_WORDS[lower];
108
123
  else if (base.length === 1) out += parts.length === 0 ? base : base.toUpperCase();
109
124
  else out += base[0]!.toUpperCase() + base.slice(1);
@@ -118,5 +133,5 @@ export function formatKeyHints(keys: KeyName | readonly KeyName[]): string {
118
133
  /** Format a double-tap gesture: `←←` with glyphs, `Left Left` with words. */
119
134
  export function formatDoubleTap(key: KeyName): string {
120
135
  const label = formatKeyHint(key);
121
- return `${label}${HAS_LETTER.test(label) ? " " : keySymbol("key.joiner")}${label}`;
136
+ return `${label}${HAS_LETTER.test(label) ? " " : activeThemeSymbol("key.joiner")}${label}`;
122
137
  }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Reference document applier: applies TSP frames to a node tree the way the
3
- * spec says the terminal does (§5.1). Ops are validated and applied one by
4
- * one; a rejected op is reported and skipped without affecting the rest of
5
- * the frame. `settle` is recorded as a hint and never restricts later ops.
3
+ * spec says the terminal does (Tern SDK, `protocol/documents.md`, frame ops).
4
+ * Ops are validated and applied one by one; a rejected op is reported and
5
+ * skipped without affecting the rest of the frame. `settle` is recorded as a
6
+ * hint and never restricts later ops.
6
7
  *
7
8
  * Used by tests, the debug server's `doc` op, and the TSP test harness.
8
9
  */
@@ -120,6 +120,7 @@ class NativeContext implements DescribeContext {
120
120
  cols: number;
121
121
  reduceMotion: boolean;
122
122
  dark: boolean;
123
+ hour12: boolean | undefined;
123
124
  #kinds: ReadonlySet<string>;
124
125
  #features: ReadonlySet<string>;
125
126
 
@@ -127,6 +128,7 @@ class NativeContext implements DescribeContext {
127
128
  this.cols = cols;
128
129
  this.reduceMotion = hello.reduceMotion === true;
129
130
  this.dark = hello.dark !== false;
131
+ this.hour12 = hello.hour12;
130
132
  this.#kinds = new Set(hello.kinds);
131
133
  this.#features = new Set(hello.features);
132
134
  }
@@ -398,8 +400,8 @@ export class NativeBackend {
398
400
  * The terminal's real `hello` reply after an optimistic start: adopt its
399
401
  * APC limit, credits, cell size, kinds, appearance and motion preference.
400
402
  * A width the terminal already reported in a `resize` event wins over the
401
- * reply's. A different vocabulary or motion preference re-describes every
402
- * component, so kinds the terminal lacks fall back.
403
+ * reply's. A different vocabulary, motion preference or clock re-describes
404
+ * every component, so kinds the terminal lacks fall back.
403
405
  */
404
406
  confirm(hello: TspHello): void {
405
407
  const before = this.#cx;
@@ -411,7 +413,12 @@ export class NativeBackend {
411
413
  this.#sawResize = true;
412
414
  }
413
415
  if (this.#cx.dark !== before.dark) this.#host.appearanceChanged(this.#cx.dark);
414
- if (this.#cx.reduceMotion !== before.reduceMotion || !this.#cx.sameVocabulary(before)) this.#host.invalidate();
416
+ if (
417
+ this.#cx.reduceMotion !== before.reduceMotion ||
418
+ this.#cx.hour12 !== before.hour12 ||
419
+ !this.#cx.sameVocabulary(before)
420
+ )
421
+ this.#host.invalidate();
415
422
  this.#host.requestRender();
416
423
  }
417
424
 
@@ -108,9 +108,10 @@ export function encodeTspJson(verb: TspVerb, value: unknown, params?: TspParams,
108
108
 
109
109
  /**
110
110
  * The `hello` query; callers follow it with a DA1 sentinel. `features: ["edit"]`
111
- * tells the terminal that omp applies its `edit` events (TSP §8.5), so it may keep a
112
- * native selection in omp's editors; without it, every key stays omp's. `"undo"`
113
- * says omp applies `undo` events, so the terminal may turn ⌃Z in a field into one.
111
+ * tells the terminal that omp applies its `edit` events (Tern SDK,
112
+ * `protocol/input.md`), so it may keep a native selection in omp's editors;
113
+ * without it, every key stays omp's. `"undo"` says omp applies `undo` events,
114
+ * so the terminal may turn ⌃Z in a field into one.
114
115
  * `"send"` accepts an explicit prompt for a live composer without simulating keys.
115
116
  */
116
117
  export function encodeTspHelloQuery(version?: string): string {
@@ -11,7 +11,8 @@
11
11
  * `rows` fallback node rendered through `render(cx.cols)`.
12
12
  *
13
13
  * See `packages/wire/src/tsp.ts` for the wire vocabulary and
14
- * `crates/tern/SURFACE_PROTOCOL.md` (Stencil repository) for the spec.
14
+ * the Tern SDK's Surface Protocol reference (`docs/sdk/src/protocol` in the
15
+ * Stencil repository, https://docs.stencil.so/tern/protocol/) for the spec.
15
16
  */
16
17
  import type { TspEvent, TspKind, TspProps, TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
17
18
  import type { Component } from "../tui";
@@ -76,6 +77,12 @@ export interface DescribeContext {
76
77
  readonly reduceMotion: boolean;
77
78
  /** The terminal's appearance is dark. */
78
79
  readonly dark: boolean;
80
+ /**
81
+ * The user's system reads a 12-hour clock (`false`: 24-hour); `undefined`
82
+ * when the terminal doesn't say. Pass it as `hour12` when formatting times:
83
+ * the process's own default locale may not know.
84
+ */
85
+ readonly hour12?: boolean;
79
86
  /** Whether the terminal renders `kind` natively (else describe something simpler or let it fall back). */
80
87
  supports(kind: TspKind): boolean;
81
88
  /**
@@ -7,7 +7,7 @@
7
7
  * component's root is a `card` whose `head` carries the title.
8
8
  */
9
9
  import type { TspProps, TspSpan, TspText } from "@oh-my-pi/pi-wire";
10
- import type { KeyName } from "../key-hint-format";
10
+ import { formatTooltipKey, type KeyName } from "../key-hint-format";
11
11
  import { getKeybindings, type Keybinding } from "../keybindings";
12
12
  import { card, kbd, list, node, row, span, text } from "./describe";
13
13
  import type { NativeChild, NativeNode } from "./node";
@@ -86,10 +86,10 @@ export interface ActionButtonOptions {
86
86
  /** Sibling identity key (defaults to the action id); `leafKey(event.key)` reports it back. */
87
87
  readonly key?: string;
88
88
  /** Key id of the keyboard path the button mirrors, drawn as a keycap inside it (`"r"`, `"ctrl+g"`). */
89
- readonly keys?: string;
89
+ readonly keys?: KeyName;
90
90
  /** `accent` = the view's hero action, `error` = destructive. */
91
91
  readonly tone?: "accent" | "error";
92
- /** Tooltip; defaults to the label plus the keycap (`"Refresh r"`). */
92
+ /** Tooltip; defaults to the label plus the keycap (`"Refresh r"`, see {@link formatTooltipKey}). */
93
93
  readonly title?: string;
94
94
  /** Target of an `open` action (URL or `file://` path). */
95
95
  readonly href?: string;
@@ -110,7 +110,7 @@ export function actionButton(label: string, act: string, options: ActionButtonOp
110
110
  gap: "xs",
111
111
  align: "center",
112
112
  actions: { click: act },
113
- title: options.title ?? (options.keys ? `${label} ${options.keys}` : label),
113
+ title: options.title ?? (options.keys ? `${label} ${formatTooltipKey(options.keys)}` : label),
114
114
  ...(options.tone ? { tone: options.tone } : {}),
115
115
  ...(options.href ? { href: options.href } : {}),
116
116
  },
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Settling: the frame provider marks finalized transcript blocks so the
3
- * native reconciler stops keeping their description (spec §12 "Settling and
4
- * program memory"). A settled block stays editable: a later change is sent as
5
- * targeted ops by id, followed by a fresh `settle` hint.
3
+ * native reconciler stops keeping their description (Tern SDK,
4
+ * `protocol/operations.md`, "Settling"). A settled block stays editable: a
5
+ * later change is sent as targeted ops by id, followed by a fresh `settle`
6
+ * hint.
6
7
  */
7
8
  import type { Component } from "../tui";
8
9
 
@@ -300,6 +300,7 @@ export interface AgentHubDeps<TRecord extends AgentRecordLike = AgentRecordLike>
300
300
  /** Mirrors the main transcript's thinking-block visibility. */
301
301
  hideThinkingBlock?: () => boolean;
302
302
  proseOnlyThinking?: () => boolean;
303
+ expandThinkingBlocks?: () => boolean;
303
304
  /** Keys toggling tool output expansion (app.tools.expand). */
304
305
  expandKeys?: KeyId[];
305
306
  /** Focus the main view on this agent's live session (ctx.focusAgentSession). When absent (collab guest, tests), Enter opens the in-hub chat view instead. */
@@ -445,6 +446,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
445
446
  #cwd: string;
446
447
  #hideThinkingBlock: (() => boolean) | undefined;
447
448
  #proseOnlyThinking: (() => boolean) | undefined;
449
+ #expandThinkingBlocks: (() => boolean) | undefined;
448
450
  #expandKeys: KeyId[];
449
451
  #focusAgent: ((id: string) => Promise<void>) | undefined;
450
452
 
@@ -490,6 +492,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
490
492
  this.#cwd = deps.cwd ?? getProjectDir();
491
493
  this.#hideThinkingBlock = deps.hideThinkingBlock;
492
494
  this.#proseOnlyThinking = deps.proseOnlyThinking;
495
+ this.#expandThinkingBlocks = deps.expandThinkingBlocks;
493
496
  this.#expandKeys = deps.expandKeys ?? ["ctrl+o"];
494
497
  this.#focusAgent = deps.focusAgent;
495
498
 
@@ -639,6 +642,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
639
642
  cwd: this.#cwd,
640
643
  hideThinkingBlock: this.#hideThinkingBlock,
641
644
  proseOnlyThinking: this.#proseOnlyThinking,
645
+ expandThinkingBlocks: this.#expandThinkingBlocks,
642
646
  expandKeys: this.#expandKeys,
643
647
  hubKeys: this.#hubKeys,
644
648
  requestRender: this.#requestRender,
@@ -73,6 +73,7 @@ export interface AgentTranscriptViewerDeps {
73
73
  cwd: string;
74
74
  hideThinkingBlock?: () => boolean;
75
75
  proseOnlyThinking?: () => boolean;
76
+ expandThinkingBlocks?: () => boolean;
76
77
  expandKeys: KeyId[];
77
78
  /** Keys that toggle the whole hub closed (app.agents.hub + app.session.observe). */
78
79
  hubKeys: KeyId[];
@@ -199,6 +200,7 @@ export class AgentTranscriptViewer implements Component {
199
200
  cwd: deps.cwd,
200
201
  hideThinkingBlock: deps.hideThinkingBlock,
201
202
  proseOnlyThinking: deps.proseOnlyThinking,
203
+ expandThinkingBlocks: deps.expandThinkingBlocks,
202
204
  requestRender: deps.requestRender,
203
205
  });
204
206
  this.#browser = new TranscriptBrowser({