@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
@@ -29,13 +29,13 @@ import type { SpaceHoldHandler } from "../space-hold";
29
29
  import { sanitizeErrorLine } from "../chrome/error-block";
30
30
  import { sanitizeDisplayLine, sanitizeDisplayText } from "./extensions/display-text";
31
31
  import { editorKey, editorKeys, keyHint, rawKeyHint } from "../chrome/keybinding-hints";
32
- import { formatKeyHint } from "../app-keybindings";
32
+ import { formatKeyHint, type KeyId } from "../app-keybindings";
33
33
  import { bottomBorder, row, topBorder } from "../chrome/overlay-box";
34
34
  import { padToWidth } from "../render/utils";
35
35
  import { SplitPane } from "../components/layout/split-pane";
36
36
  import { clampSelection, contentRowWidth, padLinesToHeight, renderScrollableList } from "../chrome/selector-helpers";
37
- import type { TspSpan } from "@oh-my-pi/pi-wire";
38
- import type { NativeChild, NativeNode, NativeUiEvent } from "../native/node";
37
+ import type { TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
38
+ import type { NativeChild, NativeNode, NativeScroll, NativeUiEvent } from "../native/node";
39
39
  import { col, md, node, span, text } from "../native/describe";
40
40
  import { actionHint, hintsRow, type NativeHint, statusHintsRow } from "../native/overlay";
41
41
 
@@ -46,10 +46,17 @@ interface BtwHistoryPanelOptions {
46
46
  onCancel: (record: BtwHistoryRecord) => void;
47
47
  canFollowUp?: (record: BtwHistoryRecord) => boolean;
48
48
  onFollowUp?: (record: BtwHistoryRecord, question: string, signal: AbortSignal) => Promise<boolean>;
49
- /** Space-bar push-to-talk for a follow-up composer: dictates into `input` while Space is held. */
49
+ /** Configured push-to-talk keys for a follow-up composer. */
50
+ spaceHoldKeys?: readonly KeyId[];
50
51
  spaceHold?: (input: Input) => SpaceHoldHandler;
51
52
  requestRender: () => void;
52
53
  getHeight: () => number;
54
+ /**
55
+ * Esc closes the panel even while the selected answer runs (it keeps
56
+ * streaming in the background) and `x` cancels it, instead of Esc cancelling
57
+ * first. For a host with no inline panel to fall back to (Tern).
58
+ */
59
+ escapeHides?: boolean;
53
60
  }
54
61
 
55
62
  interface FollowUpComposer {
@@ -78,6 +85,7 @@ interface BtwHistoryNativeMemo {
78
85
  followUpPending: boolean;
79
86
  canFollowUp: boolean;
80
87
  copied: boolean;
88
+ scroll: NativeScroll | undefined;
81
89
  node: NativeNode;
82
90
  }
83
91
 
@@ -108,6 +116,8 @@ export class BtwHistoryPanel implements Component, Focusable {
108
116
  #lastCopiedId: string | undefined;
109
117
  #lastCopiedText: string | undefined;
110
118
  #native: BtwHistoryNativeMemo | undefined;
119
+ /** The last answer-pane scroll key, forwarded natively (see {@link NativeNode.scroll}). */
120
+ #scroll: NativeScroll | undefined;
111
121
  readonly #detail = new ScrollView([], {
112
122
  height: 1,
113
123
  scrollbar: "auto",
@@ -159,6 +169,7 @@ export class BtwHistoryPanel implements Component, Focusable {
159
169
  this.#options = options;
160
170
  this.#records = options.records;
161
171
  this.#selectedId = options.records[0]?.id;
172
+ if (options.records.length === 1) this.#focus = "answer";
162
173
  }
163
174
 
164
175
  get focused(): boolean {
@@ -187,6 +198,8 @@ export class BtwHistoryPanel implements Component, Focusable {
187
198
  this.#followLatest = false;
188
199
  this.#detail.scrollToTop();
189
200
  }
201
+ // One side question has nothing to pick: its answer keeps the arrow keys.
202
+ if (records.length === 1) this.#focus = "answer";
190
203
  this.#options.requestRender();
191
204
  }
192
205
 
@@ -247,9 +260,30 @@ export class BtwHistoryPanel implements Component, Focusable {
247
260
  return true;
248
261
  }
249
262
 
263
+ /** Select a record with its answer pane focused, read from the top (a just-asked /btw). */
264
+ showRecord(recordId: string): void {
265
+ const index = this.#records.findIndex(record => record.id === recordId);
266
+ if (index === -1) return;
267
+ this.#select(index);
268
+ this.#focus = "answer";
269
+ this.#options.requestRender();
270
+ }
271
+
272
+ /**
273
+ * Jump the answer pane to its bottom once (opening or sending a follow-up):
274
+ * text mode keeps following the newest row at render until a scroll key;
275
+ * natively the sheet body scrolls to its end. Tern reports no wheel scroll,
276
+ * so streamed updates never re-pin it there and a reader is never yanked.
277
+ */
278
+ #jumpToEnd(): void {
279
+ this.#followLatest = true;
280
+ this.#scroll = { by: "end", n: (this.#scroll?.n ?? 0) + 1 };
281
+ }
282
+
250
283
  #openComposer(record: BtwHistoryRecord): void {
251
284
  const input = new Input();
252
285
  input.prompt = theme.fg("accent", "Follow up: ");
286
+ if (this.#options.spaceHoldKeys !== undefined) input.spaceHold.keys = this.#options.spaceHoldKeys;
253
287
  input.spaceHold.handler = this.#options.spaceHold?.(input);
254
288
  const composer: FollowUpComposer = { recordId: record.id, input, abortController: new AbortController() };
255
289
  input.onEscape = () => {
@@ -261,6 +295,8 @@ export class BtwHistoryPanel implements Component, Focusable {
261
295
  };
262
296
  this.#composer = composer;
263
297
  this.#focus = "answer";
298
+ // Following up reads from the newest turn, right above the composer.
299
+ this.#jumpToEnd();
264
300
  this.#options.requestRender();
265
301
  }
266
302
 
@@ -286,7 +322,7 @@ export class BtwHistoryPanel implements Component, Focusable {
286
322
  this.#composer = undefined;
287
323
  this.#selectedId = composer.recordId;
288
324
  this.#focus = "answer";
289
- this.#followLatest = true;
325
+ this.#jumpToEnd();
290
326
  } else {
291
327
  composer.notice = `Follow-up was not started. Your draft is kept; ${editorKey("tui.input.submit")} to retry.`;
292
328
  }
@@ -300,6 +336,11 @@ export class BtwHistoryPanel implements Component, Focusable {
300
336
  }
301
337
  }
302
338
 
339
+ /** Only an active follow-up composer can claim its configured push-to-talk keys. */
340
+ capturesInput(data: string): boolean {
341
+ return this.#composer?.input.capturesInput(data) ?? false;
342
+ }
343
+
303
344
  /** Enhanced clipboard pastes belong only to the active composer. */
304
345
  pasteText(text: string): void {
305
346
  if (!this.#composer) return;
@@ -314,11 +355,17 @@ export class BtwHistoryPanel implements Component, Focusable {
314
355
  return;
315
356
  }
316
357
  const record = this.#selected();
358
+ const running = record !== undefined && getBtwLatestTurn(record).status === "running" ? record : undefined;
359
+ const hides = this.#options.escapeHides === true;
317
360
  if (matchesSelectCancel(data) || matchesKey(data, "escape")) {
318
- if (record && getBtwLatestTurn(record).status === "running") this.#options.onCancel(record);
361
+ if (running && !hides) this.#options.onCancel(running);
319
362
  else this.#options.onClose();
320
363
  return;
321
364
  }
365
+ if (hides && matchesKey(data, "x")) {
366
+ if (running) this.#options.onCancel(running);
367
+ return;
368
+ }
322
369
  if (matchesKey(data, "f") || matchesKey(data, "enter")) {
323
370
  if (record) this.openFollowUp(record.id);
324
371
  return;
@@ -335,11 +382,12 @@ export class BtwHistoryPanel implements Component, Focusable {
335
382
  matchesKey(data, "ctrl+_") ||
336
383
  data === String.fromCharCode(31)
337
384
  ) {
338
- this.#focus = this.#focus === "list" ? "answer" : "list";
385
+ // One side question has nothing to pick: its answer keeps the arrow keys.
386
+ if (this.#records.length > 1) this.#focus = this.#focus === "list" ? "answer" : "list";
339
387
  } else if (matchesKey(data, "right")) {
340
388
  this.#focus = "answer";
341
389
  } else if (matchesKey(data, "left")) {
342
- this.#focus = "list";
390
+ if (this.#records.length > 1) this.#focus = "list";
343
391
  } else if (this.#focus === "list") {
344
392
  const index = this.#selectedIndex();
345
393
  if (matchesSelectUp(data)) this.#select(index - 1);
@@ -350,11 +398,25 @@ export class BtwHistoryPanel implements Component, Focusable {
350
398
  else if (matchesKey(data, "end")) this.#select(this.#records.length - 1);
351
399
  else return;
352
400
  } else {
353
- if (matchesSelectUp(data)) this.#detail.scroll(-1);
354
- else if (matchesSelectDown(data)) this.#detail.scroll(1);
355
- else if (matchesSelectPageUp(data)) this.#detail.page(-1);
356
- else if (matchesSelectPageDown(data)) this.#detail.page(1);
401
+ const by: TspScrollBy | undefined = matchesSelectUp(data)
402
+ ? "line-up"
403
+ : matchesSelectDown(data)
404
+ ? "line-down"
405
+ : matchesSelectPageUp(data)
406
+ ? "page-up"
407
+ : matchesSelectPageDown(data)
408
+ ? "page-down"
409
+ : matchesKey(data, "home")
410
+ ? "start"
411
+ : matchesKey(data, "end")
412
+ ? "end"
413
+ : undefined;
414
+ if (by === "line-up") this.#detail.scroll(-1);
415
+ else if (by === "line-down") this.#detail.scroll(1);
416
+ else if (by === "page-up") this.#detail.page(-1);
417
+ else if (by === "page-down") this.#detail.page(1);
357
418
  else if (!this.#detail.handleScrollKey(data)) return;
419
+ if (by) this.#scroll = { by, n: (this.#scroll?.n ?? 0) + 1 };
358
420
  this.#followLatest = matchesKey(data, "end");
359
421
  }
360
422
  this.#options.requestRender();
@@ -378,31 +440,39 @@ export class BtwHistoryPanel implements Component, Focusable {
378
440
  memo.notice === composer?.notice &&
379
441
  memo.followUpPending === this.#followUpPending &&
380
442
  memo.canFollowUp === canFollowUp &&
381
- memo.copied === copied
443
+ memo.copied === copied &&
444
+ memo.scroll === this.#scroll
382
445
  ) {
383
446
  return memo.node;
384
447
  }
385
- const children: NativeChild[] = [
386
- node(
387
- "row",
388
- { gap: "md", align: "start" },
389
- [
448
+ // Plain columns, not `section`s: Tern draws a section as a foldable `##` heading,
449
+ // and a flex item's min-content width (a long code line in the answer) would
450
+ // crush the list pane. The answer pane may shrink to nothing; the list may not.
451
+ // With one side question the list and the pane labels are noise: the answer alone.
452
+ const showList = this.#records.length > 1;
453
+ const detail = this.#describeDetail(record, copied);
454
+ const answerPane: NativeNode = {
455
+ ...node(
456
+ "col",
457
+ { gap: "sm", grow: 1, basis: 0, min: { w: 0 } },
458
+ showList ? [text(this.#describeFocusLabel("answer"), { truncate: "end", lines: 1 }), detail] : [detail],
459
+ "details",
460
+ ),
461
+ // Natively the sheet body is the scroller; arrow/page keys on the answer pane move it.
462
+ scroll: this.#scroll,
463
+ };
464
+ const panes: NativeChild[] = showList
465
+ ? [
390
466
  node(
391
- "section",
392
- { head: this.#describeFocusLabel("list"), basis: 0.42, max: { w: "46ch" } },
393
- [this.#describeList()],
467
+ "col",
468
+ { gap: "sm", shrink: 0, basis: 0.3, max: { w: "40ch" } },
469
+ [text(this.#describeFocusLabel("list"), { truncate: "end", lines: 1 }), this.#describeList()],
394
470
  "history",
395
471
  ),
396
- node(
397
- "section",
398
- { head: this.#describeFocusLabel("answer"), grow: 1 },
399
- [this.#describeDetail(record, copied)],
400
- "details",
401
- ),
402
- ],
403
- "body",
404
- ),
405
- ];
472
+ answerPane,
473
+ ]
474
+ : [answerPane];
475
+ const children: NativeChild[] = [node("row", { gap: "lg", align: "start" }, panes, "body")];
406
476
  if (composer) {
407
477
  composer.input.focused = this.#focused;
408
478
  const lines: NativeChild[] = [
@@ -427,6 +497,7 @@ export class BtwHistoryPanel implements Component, Focusable {
427
497
  followUpPending: this.#followUpPending,
428
498
  canFollowUp,
429
499
  copied,
500
+ scroll: this.#scroll,
430
501
  node: described,
431
502
  };
432
503
  return described;
@@ -492,10 +563,21 @@ export class BtwHistoryPanel implements Component, Focusable {
492
563
  const answer = turn.answer.trim()
493
564
  ? md(sanitizeDisplayText(turn.answer), { stream: turn.status === "running" })
494
565
  : text([span(turn.status === "running" ? "Waiting for response…" : "No answer text.", "dim")]);
566
+ // Plain labels, not `section`s (Tern draws those as foldable `##` headings);
567
+ // the question stays markdown so its inline code and links render.
495
568
  const children: NativeChild[] = [
496
- text([span(`${status.label} · ${this.#dateFormat.format(turn.createdAt)}`, status.color)], { wrap: "word" }),
497
- node("section", { head: [span("Question", "accent strong")] }, [md(sanitizeDisplayText(turn.question))]),
498
- node("section", { head: [span("Answer", "accent strong")] }, [answer]),
569
+ text(
570
+ [
571
+ span("Question", "accent strong"),
572
+ span(" · ", "dim"),
573
+ span(status.label, status.color),
574
+ span(` · ${this.#dateFormat.format(turn.createdAt)}`, "dim"),
575
+ ],
576
+ { wrap: "word" },
577
+ ),
578
+ md(sanitizeDisplayText(turn.question)),
579
+ text([span("Answer", "accent strong")]),
580
+ answer,
499
581
  ];
500
582
  if (turn.error) children.push(text([span(sanitizeErrorLine(turn.error), "error")], { wrap: "word" }));
501
583
  if (turn.status === "interrupted") children.push(text([span("Not resumed in this view.", "muted")]));
@@ -510,9 +592,11 @@ export class BtwHistoryPanel implements Component, Focusable {
510
592
  ]);
511
593
  }
512
594
  const latest = record ? getBtwLatestTurn(record) : undefined;
595
+ const running = latest?.status === "running";
513
596
  const hints: (NativeHint | undefined)[] = [
514
- actionHint("tui.select.cancel", latest?.status === "running" ? "cancel" : "close"),
515
- { keys: ["tab", "ctrl+/"], label: "switch pane" },
597
+ actionHint("tui.select.cancel", running && !this.#options.escapeHides ? "cancel" : "close"),
598
+ running && this.#options.escapeHides ? { keys: ["x"], label: "cancel" } : undefined,
599
+ this.#records.length > 1 ? { keys: ["tab", "ctrl+/"], label: "switch pane" } : undefined,
516
600
  actionHint(["tui.select.up", "tui.select.down"], this.#focus === "list" ? "select" : "scroll"),
517
601
  ];
518
602
  if (canFollowUp) hints.push({ keys: ["f", "enter"], label: "follow up" });
@@ -689,14 +773,16 @@ export class BtwHistoryPanel implements Component, Focusable {
689
773
  const record = this.#selected();
690
774
  const composer = this.#composer;
691
775
  const latest = record ? getBtwLatestTurn(record) : undefined;
776
+ const running = latest?.status === "running";
692
777
  const actions = composer
693
778
  ? [
694
779
  keyHint("tui.input.submit", this.#followUpPending ? "starting…" : "send"),
695
780
  keyHint("tui.select.cancel", "cancel"),
696
781
  ]
697
782
  : [
698
- keyHint("tui.select.cancel", latest?.status === "running" ? "cancel" : "close"),
699
- rawKeyHint(["tab", "ctrl+/"], "switch pane"),
783
+ keyHint("tui.select.cancel", running && !this.#options.escapeHides ? "cancel" : "close"),
784
+ ...(running && this.#options.escapeHides ? [rawKeyHint("x", "cancel")] : []),
785
+ ...(this.#records.length > 1 ? [rawKeyHint(["tab", "ctrl+/"], "switch pane")] : []),
700
786
  ];
701
787
  if (!composer) {
702
788
  if (record && this.#canFollowUp(record)) actions.push(rawKeyHint(["f", "enter"], "follow up"));
@@ -274,6 +274,7 @@ export interface CopySelectorDeps {
274
274
  cwd: string;
275
275
  hideThinkingBlock?: () => boolean;
276
276
  proseOnlyThinking?: () => boolean;
277
+ expandThinkingBlocks?: () => boolean;
277
278
  linkTargets?: ReadonlyMap<string, string>;
278
279
  requestRender: () => void;
279
280
  /** Replaces the "Copy" header when the picker is reused for another purpose. */
@@ -379,6 +380,7 @@ export class CopySelectorComponent implements Component {
379
380
  cwd: this.deps.cwd,
380
381
  hideThinkingBlock: this.deps.hideThinkingBlock,
381
382
  proseOnlyThinking: this.deps.proseOnlyThinking,
383
+ expandThinkingBlocks: this.deps.expandThinkingBlocks,
382
384
  linkTargets: this.deps.linkTargets,
383
385
  requestRender: this.deps.requestRender,
384
386
  });
@@ -25,7 +25,7 @@ import { Memo } from "../../native/memo";
25
25
  import type { DescribeContext, NativeNode, NativeScroll, NativeUiEvent } from "../../native/node";
26
26
  import { actionBar, actionButton } from "../../native/overlay";
27
27
  import { CLOSE_ACTION, type PickerEvent, picker, pickerAction, pickerEvent } from "../../native/picker";
28
- import { matchesKey } from "../../keys";
28
+ import { type KeyId, matchesKey } from "../../keys";
29
29
  import { parseSgrMouse } from "../../mouse";
30
30
  import { SplitPane, type SplitPaneHit } from "../../components/layout/split-pane";
31
31
  import { Stack } from "../../components/layout/stack";
@@ -41,7 +41,7 @@ import {
41
41
  matchesSelectPageUp,
42
42
  } from "../../keybinding-matchers";
43
43
  import { expandKeyHint } from "../../render/render-utils";
44
- import { formatKeyHint, formatKeyHints } from "../../app-keybindings";
44
+ import { formatKeyHint, formatKeyHints, formatTooltipKey } from "../../app-keybindings";
45
45
  import { boundKeys, editorKeys, interruptKey } from "../../chrome/keybinding-hints";
46
46
  import { bottomBorder, divider, PanelRows, row, topBorder } from "../../chrome/overlay-box";
47
47
  import { ExtensionList, type ExtensionListSwitch } from "./extension-list";
@@ -694,7 +694,7 @@ export class ExtensionDashboard implements Component {
694
694
  ];
695
695
  }
696
696
 
697
- #expandKey(): string {
697
+ #expandKey(): KeyId {
698
698
  return boundKeys("app.tools.expand", ["ctrl+o"])[0] ?? "ctrl+o";
699
699
  }
700
700
 
@@ -714,7 +714,7 @@ export class ExtensionDashboard implements Component {
714
714
  node("icon", {
715
715
  name: "x",
716
716
  role: "omp.app.ibtn",
717
- title: `Close ${interruptKey()}`,
717
+ title: `Close ${formatTooltipKey(boundKeys("app.interrupt", ["escape"])[0] ?? "escape")}`,
718
718
  aria: "Close",
719
719
  actions: { click: "close" },
720
720
  }),
@@ -17,7 +17,7 @@ import { formatCoarseDuration } from "../chrome/format";
17
17
  import { centerLine } from "../utils";
18
18
  import { theme } from "../theme/theme";
19
19
  import { matchesAppInterrupt } from "../keybinding-matchers";
20
- import { formatKeyHint } from "../app-keybindings";
20
+ import { formatKeyHint, formatTooltipKey } from "../app-keybindings";
21
21
  import { boundKeys, interruptKey } from "../chrome/keybinding-hints";
22
22
  import type { NativeNode, NativeUiEvent } from "../native/node";
23
23
  import { col, node, row, span, text } from "../native/describe";
@@ -198,7 +198,7 @@ export class PauseScreenComponent implements Component, OverlayFocusOwner {
198
198
  actionButton("Resume", "resume", {
199
199
  keys: resumeKey,
200
200
  tone: "accent",
201
- title: `Resume ${resumeKey} · enter · space`,
201
+ title: `Resume ${formatTooltipKey(resumeKey)} · enter · space`,
202
202
  }),
203
203
  ]),
204
204
  );
@@ -83,6 +83,7 @@ export interface RewindSelectorDeps {
83
83
  cwd: string;
84
84
  hideThinkingBlock?: () => boolean;
85
85
  proseOnlyThinking?: () => boolean;
86
+ expandThinkingBlocks?: () => boolean;
86
87
  linkTargets?: ReadonlyMap<string, string>;
87
88
  requestRender: () => void;
88
89
  /** Sibling branch paths of `entryId`'s turn (excluding the turn itself). */
@@ -226,6 +227,7 @@ export class RewindSelectorComponent implements Component {
226
227
  cwd: this.deps.cwd,
227
228
  hideThinkingBlock: this.deps.hideThinkingBlock,
228
229
  proseOnlyThinking: this.deps.proseOnlyThinking,
230
+ expandThinkingBlocks: this.deps.expandThinkingBlocks,
229
231
  linkTargets: this.deps.linkTargets,
230
232
  requestRender: this.deps.requestRender,
231
233
  });
@@ -43,7 +43,7 @@ import { editorKey, editorKeys } from "../chrome/keybinding-hints";
43
43
  import type { TspSpan, TspTableColumn, TspText, TspTone } from "@oh-my-pi/pi-wire";
44
44
  import { col, elapsed, node, span, text } from "../native/describe";
45
45
  import { type DescribeContext, leafKey, type NativeChild, type NativeNode, type NativeUiEvent } from "../native/node";
46
- import { actionButton } from "../native/overlay";
46
+ import { actionBar, actionButton } from "../native/overlay";
47
47
 
48
48
  /** Local calendar-day activity consumed by the usage heatmap. */
49
49
  export interface DailyActivityPoint {
@@ -935,9 +935,9 @@ export class UsageDashboardComponent implements Component {
935
935
  * The sheet body (the terminal's `lg` overlay is the frame): a head row
936
936
  * (checked ago, Overview/Details tabs, Refresh), then either the provider
937
937
  * grid of frames with window meters and the activity heatmap, or the
938
- * per-provider detail tables. `meter`/`chart` fall back to
939
- * `progress`/`table` on terminals without them. Rebuilt only when its
940
- * inputs change.
938
+ * per-provider detail tables, then Close like the other report sheets.
939
+ * `meter`/`chart` fall back to `progress`/`table` on terminals without
940
+ * them. Rebuilt only when its inputs change.
941
941
  */
942
942
  describe(cx: DescribeContext): NativeNode {
943
943
  const meter = cx.supports("meter");
@@ -947,15 +947,20 @@ export class UsageDashboardComponent implements Component {
947
947
  return cache.node;
948
948
  }
949
949
  const body = this.#view === "detail" ? this.#describeDetail() : this.#describeOverview(meter, chart);
950
- const root = col([this.#describeHead(), node("col", { gap: "lg" }, body, this.#view)], { gap: "lg" });
950
+ // Esc leaves Details for Overview first, so only Overview's Close wears its keycap.
951
+ const close = actionButton("Close", "close", this.#view === "overview" ? { keys: "escape" } : {});
952
+ const root = col([this.#describeHead(), node("col", { gap: "lg" }, body, this.#view), actionBar([null, close])], {
953
+ gap: "lg",
954
+ });
951
955
  this.#nativeCache = { revision: this.#revision, meter, chart, node: root };
952
956
  return root;
953
957
  }
954
958
 
955
- /** Tab clicks switch views like Enter/Esc; the Refresh button runs `r`. */
959
+ /** Tab clicks switch views like Enter/Esc; the Refresh button runs `r`; Close closes from either view. */
956
960
  handleNativeEvent(event: NativeUiEvent): void {
957
961
  if (event.type === "action") {
958
962
  if (event.act === "refresh") void this.#refresh();
963
+ else if (event.act === "close") this.#close();
959
964
  return;
960
965
  }
961
966
  if (event.type !== "select" && event.type !== "activate") return;
@@ -1379,8 +1384,7 @@ export class UsageDashboardComponent implements Component {
1379
1384
  this.#setView("overview");
1380
1385
  return;
1381
1386
  }
1382
- this.dispose();
1383
- this.#options.onClose();
1387
+ this.#close();
1384
1388
  return;
1385
1389
  }
1386
1390
  if (matchesKey(data, "r")) {
@@ -1403,4 +1407,9 @@ export class UsageDashboardComponent implements Component {
1403
1407
  this.#options.requestRender();
1404
1408
  } else if (matchesKey(data, "end")) this.#scrollBy(Number.MAX_SAFE_INTEGER);
1405
1409
  }
1410
+
1411
+ #close(): void {
1412
+ this.dispose();
1413
+ this.#options.onClose();
1414
+ }
1406
1415
  }
@@ -5,17 +5,19 @@ import { formatDuration, formatNumber } from "@oh-my-pi/pi-utils";
5
5
  import { theme } from "../theme/theme";
6
6
  import { formatMetricRow, MetricRow, type MetricSpec } from "../components/metric";
7
7
  import { node, row, span, text } from "../native/describe";
8
- import type { NativeChild, NativeNode } from "../native/node";
8
+ import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
9
9
 
10
10
  /** Below this the rate is nonsense (cached/instant responses yield absurd tok/s). */
11
11
  const MIN_DURATION_MS = 100;
12
12
 
13
- /** Local `YYYY-MM-DD HH:mm:ss` stamp for the per-turn usage row. */
14
- function formatUsageTimestamp(ms: number): string {
13
+ /** Local ISO date with a wall-clock time; only the hour cycle follows the terminal. */
14
+ function formatUsageTimestamp(ms: number, hour12 = false): string {
15
15
  const d = new Date(ms);
16
16
  const pad = (n: number): string => String(n).padStart(2, "0");
17
17
  const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
18
- const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
18
+ const time = hour12
19
+ ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12 })
20
+ : `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
19
21
  return `${date} ${time}`;
20
22
  }
21
23
 
@@ -161,6 +163,8 @@ class UsageRowBlock extends Container {
161
163
  readonly #timestamp: number | undefined;
162
164
  readonly #turnElapsedMs: number | undefined;
163
165
  #nativeNode: NativeNode | undefined;
166
+ /** The terminal's clock {@link #nativeNode} was described with. */
167
+ #nativeHour12: boolean | undefined;
164
168
 
165
169
  constructor(usage: Usage, durationMs?: number, ttftMs?: number, timestamp?: number, turnElapsedMs?: number) {
166
170
  super();
@@ -186,13 +190,23 @@ class UsageRowBlock extends Container {
186
190
  * ttft 0.9s · 96 tok/s`): worded metrics instead of the ANSI icons, the
187
191
  * full timestamp in the tooltip, and throughput as a `rate`.
188
192
  */
189
- override describe(): NativeNode {
190
- if (this.#nativeNode) return this.#nativeNode;
193
+ override describe(cx?: DescribeContext): NativeNode {
194
+ // The terminal's clock: the process locale doesn't reliably carry the user's 12/24-hour choice.
195
+ const hour12 = cx?.hour12;
196
+ if (this.#nativeNode && this.#nativeHour12 === hour12) return this.#nativeNode;
197
+ this.#nativeHour12 = hour12;
191
198
  const usage = this.#usage;
192
199
  const parts: string[] = [];
193
200
  const timestamp = this.#timestamp;
194
201
  const stamped = timestamp !== undefined && Number.isFinite(timestamp) && timestamp > 0;
195
- if (stamped) parts.push(formatUsageTimestamp(timestamp).slice(11, 16));
202
+ // Only a 12-hour clock goes through the locale; 24-hour keeps the tooltip's own `HH:mm`.
203
+ if (stamped) {
204
+ parts.push(
205
+ hour12
206
+ ? new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12 })
207
+ : formatUsageTimestamp(timestamp).slice(11, 16),
208
+ );
209
+ }
196
210
  if (this.#turnElapsedMs !== undefined && this.#turnElapsedMs > 0) {
197
211
  parts.push(`Δ ${formatDuration(Math.round(this.#turnElapsedMs))}`);
198
212
  }
@@ -211,7 +225,7 @@ class UsageRowBlock extends Container {
211
225
  role: "omp.usage.turn",
212
226
  gap: "none",
213
227
  align: "baseline",
214
- ...(stamped ? { title: formatUsageTimestamp(timestamp) } : {}),
228
+ ...(stamped ? { title: formatUsageTimestamp(timestamp, hour12) } : {}),
215
229
  });
216
230
  return this.#nativeNode;
217
231
  }