@oh-my-pi/pi-tui 18.9.2 → 18.10.1

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 (59) hide show
  1. package/CHANGELOG.md +53 -105
  2. package/dist/types/app-keybindings.d.ts +5 -0
  3. package/dist/types/chat/assistant-message.d.ts +1 -1
  4. package/dist/types/chat/user-message.d.ts +3 -2
  5. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  6. package/dist/types/debug-server.d.ts +2 -0
  7. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  8. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  9. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  10. package/dist/types/overlays/hook-editor.d.ts +7 -2
  11. package/dist/types/overlays/hook-input.d.ts +12 -4
  12. package/dist/types/overlays/jobs-panel.d.ts +8 -7
  13. package/dist/types/overlays/login-dialog.d.ts +8 -3
  14. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  15. package/dist/types/overlays/pause-screen.d.ts +5 -0
  16. package/dist/types/overlays/report-panel.d.ts +5 -1
  17. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  18. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  19. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  20. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  21. package/dist/types/prompt/composer.d.ts +1 -20
  22. package/dist/types/status-line/context-usage.d.ts +1 -1
  23. package/dist/types/tools/grouped-file-output.d.ts +4 -1
  24. package/dist/types/tui.d.ts +10 -3
  25. package/package.json +9 -9
  26. package/src/app-keybindings.ts +5 -0
  27. package/src/apps/autoresearch-dashboard.ts +440 -27
  28. package/src/chat/assistant-message.ts +41 -13
  29. package/src/chat/user-message.ts +32 -19
  30. package/src/chrome/countdown-timer.ts +3 -3
  31. package/src/chrome/transcript-container.ts +202 -28
  32. package/src/debug-server.ts +51 -69
  33. package/src/hotkeys-markdown.ts +1 -0
  34. package/src/native/reconcile.ts +24 -6
  35. package/src/overlays/advisor-config.ts +259 -8
  36. package/src/overlays/annotation-overlay.ts +420 -168
  37. package/src/overlays/ask-dialog.ts +249 -105
  38. package/src/overlays/btw-history-panel.ts +178 -124
  39. package/src/overlays/hook-editor.ts +90 -32
  40. package/src/overlays/hook-input.ts +105 -19
  41. package/src/overlays/jobs-panel.ts +154 -132
  42. package/src/overlays/login-dialog.ts +201 -91
  43. package/src/overlays/mcp-add-wizard.ts +559 -279
  44. package/src/overlays/pause-screen.ts +81 -23
  45. package/src/overlays/plugin-selector.ts +17 -1
  46. package/src/overlays/queue-mode-selector.ts +10 -0
  47. package/src/overlays/report-panel.ts +6 -2
  48. package/src/overlays/reset-usage-selector.ts +130 -21
  49. package/src/overlays/session-account-selector.ts +36 -0
  50. package/src/overlays/session-info-overlay.ts +197 -76
  51. package/src/overlays/show-images-selector.ts +1 -0
  52. package/src/overlays/theme-selector.ts +6 -1
  53. package/src/overlays/thinking-selector.ts +8 -4
  54. package/src/overlays/usage-dashboard.ts +495 -143
  55. package/src/prompt/composer.ts +35 -72
  56. package/src/status-line/context-usage.ts +154 -57
  57. package/src/tools/grep.ts +1 -0
  58. package/src/tools/grouped-file-output.ts +11 -2
  59. package/src/tui.ts +27 -4
@@ -37,7 +37,7 @@ import { clampSelection, contentRowWidth, padLinesToHeight, renderScrollableList
37
37
  import type { TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
38
38
  import type { NativeChild, NativeNode, NativeScroll, NativeUiEvent } from "../native/node";
39
39
  import { col, md, node, span, text } from "../native/describe";
40
- import { actionBar, actionButton, actionHint, hintsRow, type NativeHint, statusHintsRow } from "../native/overlay";
40
+ import { actionBar, actionButton } from "../native/overlay";
41
41
 
42
42
  interface BtwHistoryPanelOptions {
43
43
  records: readonly BtwHistoryRecord[];
@@ -97,6 +97,25 @@ const STATUS: Record<BtwHistoryRecord["status"], { label: string; color: ThemeCo
97
97
  interrupted: { label: "Interrupted", color: "warning" },
98
98
  };
99
99
 
100
+ const SHEET = { role: "omp.overlay.btwHistory", anchor: "center", size: "lg", head: "BTW history" } as const;
101
+ const EMPTY_SHEET = { role: "omp.overlay.btwHistory", anchor: "center", size: "md", head: "BTW history" } as const;
102
+
103
+ /** A status worth naming next to an answer natively; an answered question needs none. */
104
+ function describeStatus(status: BtwHistoryTurn["status"]): TspSpan[] | undefined {
105
+ switch (status) {
106
+ case "running":
107
+ return [span("●", "accent", { fx: "pulse" }), span(" Answering", "accent")];
108
+ case "cancelled":
109
+ return [span("Cancelled", "warning")];
110
+ case "error":
111
+ return [span("Failed", "error")];
112
+ case "interrupted":
113
+ return [span("Interrupted", "warning")];
114
+ case "complete":
115
+ return undefined;
116
+ }
117
+ }
118
+
100
119
  /** Session-local side questions. Selecting or copying never promotes them into chat. */
101
120
  export class BtwHistoryPanel implements Component, Focusable {
102
121
  readonly #options: BtwHistoryPanelOptions;
@@ -283,6 +302,7 @@ export class BtwHistoryPanel implements Component, Focusable {
283
302
  #openComposer(record: BtwHistoryRecord): void {
284
303
  const input = new Input();
285
304
  input.prompt = theme.fg("accent", "Follow up: ");
305
+ input.placeholder = "Ask about this answer…";
286
306
  if (this.#options.spaceHoldKeys !== undefined) input.spaceHold.keys = this.#options.spaceHoldKeys;
287
307
  input.spaceHold.handler = this.#options.spaceHold?.(input);
288
308
  const composer: FollowUpComposer = { recordId: record.id, input, abortController: new AbortController() };
@@ -422,8 +442,10 @@ export class BtwHistoryPanel implements Component, Focusable {
422
442
  this.#options.requestRender();
423
443
  }
424
444
 
425
- /** A large glass sheet titled "BTW history". */
426
- readonly nativeOverlay = { role: "omp.overlay.btwHistory", size: "lg", head: "BTW history" } as const;
445
+ /** A large glass sheet (the list beside the reader), palette-sized while there is nothing to list. */
446
+ get nativeOverlay(): NonNullable<Component["nativeOverlay"]> {
447
+ return this.#records.length > 0 ? SHEET : EMPTY_SHEET;
448
+ }
427
449
 
428
450
  describe(): NativeNode {
429
451
  const record = this.#selected();
@@ -445,55 +467,30 @@ export class BtwHistoryPanel implements Component, Focusable {
445
467
  ) {
446
468
  return memo.node;
447
469
  }
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
- ? [
466
- node(
467
- "col",
468
- { gap: "sm", shrink: 0, basis: 0.3, max: { w: "40ch" } },
469
- [text(this.#describeFocusLabel("list"), { truncate: "end", lines: 1 }), this.#describeList()],
470
- "history",
471
- ),
472
- answerPane,
473
- ]
474
- : [answerPane];
475
- const children: NativeChild[] = [node("row", { gap: "lg", align: "start" }, panes, "body")];
476
- if (composer) {
477
- composer.input.focused = this.#focused;
478
- const lines: NativeChild[] = [
479
- text([span(`Topic: ${sanitizeDisplayLine(record?.question ?? "")}`, "dim")], { truncate: "end" }),
480
- ];
481
- if (composer.notice) {
482
- lines.push(text([span(composer.notice, this.#followUpPending ? "dim" : "warning")], { wrap: "word" }));
483
- }
484
- lines.push(composer.input);
485
- children.push(node("col", undefined, lines, "composer"));
470
+ // The body row fills the sheet: the list and the reader scroll on their own,
471
+ // so the list stays put while a long answer scrolls. Plain columns, not
472
+ // `section`s (Tern draws those as foldable `##` headings). With one side
473
+ // question the list is noise: the reader alone.
474
+ const children: NativeChild[] = [];
475
+ if (record) {
476
+ const reader: NativeNode = {
477
+ ...node(
478
+ "col",
479
+ { role: "omp.btw.reader", grow: 1, basis: 0, min: { w: 0 } },
480
+ [this.#describeThread(record)],
481
+ "details",
482
+ ),
483
+ // Arrow/page keys on the answer pane move its scroller.
484
+ scroll: this.#scroll,
485
+ };
486
+ const panes = this.#records.length > 1 ? [this.#describeList(), reader] : [reader];
487
+ children.push(node("row", { role: "omp.btw.body" }, panes, "body"));
488
+ } else {
489
+ children.push(this.#describeEmpty());
486
490
  }
487
- children.push({ ...this.#describeFooter(record, canFollowUp, copied), key: "footer" });
488
- // Clicking Close always dismisses the sheet; the Esc keycap only shows where Esc does too.
489
- const running = record !== undefined && getBtwLatestTurn(record).status === "running";
490
- const escapeCloses = !composer && !(running && !this.#options.escapeHides);
491
- children.push({
492
- ...actionBar([null, actionButton("Close", "close", escapeCloses ? { keys: "escape" } : undefined)]),
493
- key: "actions",
494
- });
495
- // The sheet is the frame (`nativeOverlay`): a borderless column.
496
- const described = col(children, { gap: "md" });
491
+ children.push(this.#describeActions(record, canFollowUp, copied));
492
+ // The sheet is the frame (`nativeOverlay`); its last row is the pinned footer.
493
+ const described = col(children, { gap: "none" });
497
494
  this.#native = {
498
495
  records: this.#records,
499
496
  selectedId: this.#selectedId,
@@ -511,8 +508,31 @@ export class BtwHistoryPanel implements Component, Focusable {
511
508
  }
512
509
 
513
510
  handleNativeEvent(event: NativeUiEvent): void {
514
- if (event.type === "action" && event.act === "close") {
515
- this.#options.onClose();
511
+ if (event.type === "action") {
512
+ const record = this.#selected();
513
+ switch (event.act) {
514
+ case "close":
515
+ this.#options.onClose();
516
+ return;
517
+ case "copy":
518
+ if (record && getBtwCopyText(record) !== undefined) this.#options.onCopy(record);
519
+ break;
520
+ case "follow-up":
521
+ if (record) this.openFollowUp(record.id);
522
+ break;
523
+ case "stop":
524
+ if (record && getBtwLatestTurn(record).status === "running") this.#options.onCancel(record);
525
+ break;
526
+ case "send":
527
+ this.#composer?.input.submit();
528
+ break;
529
+ case "discard":
530
+ this.#composer?.input.onEscape?.();
531
+ break;
532
+ default:
533
+ return;
534
+ }
535
+ this.#options.requestRender();
516
536
  return;
517
537
  }
518
538
  if ((event.type !== "select" && event.type !== "activate") || this.#composer) return;
@@ -525,96 +545,130 @@ export class BtwHistoryPanel implements Component, Focusable {
525
545
  this.#options.requestRender();
526
546
  }
527
547
 
528
- #describeFocusLabel(focus: "list" | "answer"): TspSpan[] {
529
- const label = focus === "list" ? `History (${this.#records.length})` : "Details";
530
- return this.#focus === focus ? [span(`${theme.nav.cursor} ${label}`, "accent strong")] : [span(label, "muted")];
531
- }
532
-
548
+ /**
549
+ * The questions, newest first, as two-line palette rows: the question, then
550
+ * its time, follow-up count and any status other than answered. While the
551
+ * reader has the keys the selection rests (`omp.btw.list.idle`).
552
+ */
533
553
  #describeList(): NativeNode {
534
554
  const items = this.#records.map(record => {
535
- const status = STATUS[getBtwLatestTurn(record).status];
536
- return node(
537
- "item",
538
- {
539
- label: sanitizeDisplayLine(record.question),
540
- detail: [span(this.#timeFormat.format(record.createdAt), "dim")],
541
- value: [span(status.label, status.color)],
542
- },
543
- undefined,
544
- record.id,
545
- );
555
+ const followUps = record.followUps?.length ?? 0;
556
+ const detail: TspSpan[] = [span(this.#timeFormat.format(record.createdAt), "mono")];
557
+ if (followUps > 0) detail.push(span(` · ${followUps} follow-up${followUps === 1 ? "" : "s"}`));
558
+ const status = describeStatus(getBtwLatestTurn(record).status);
559
+ // The question is markdown: its `code` reads as mono, without the backticks.
560
+ const label = sanitizeDisplayLine(record.question)
561
+ .split(/`([^`]+)`/)
562
+ .flatMap((part, index) => (part ? [span(part, index % 2 === 1 ? "mono" : undefined)] : []));
563
+ return node("item", { label, detail, ...(status ? { value: status } : {}) }, undefined, record.id);
546
564
  });
565
+ const role = this.#focus === "list" ? "omp.btw.list" : "omp.btw.list.idle";
547
566
  return node(
548
- "list",
549
- {
550
- selected: this.#selectedId ?? null,
551
- empty: [span("No side questions yet. Use /btw QUESTION to start one.", "muted")],
552
- },
553
- items,
554
- "records",
567
+ "col",
568
+ { role: "omp.btw.history", shrink: 0 },
569
+ [node("list", { role, selected: this.#selectedId ?? null }, items, "records")],
570
+ "history",
555
571
  );
556
572
  }
557
573
 
558
- #describeDetail(record: BtwHistoryRecord | undefined, copied: boolean): NativeNode {
559
- if (!record)
560
- return text([span("No side questions yet. Use /btw QUESTION to start one.", "muted")], { wrap: "word" });
561
- const children: NativeChild[] = [];
562
- if (copied)
563
- children.push(node("text", { spans: [span("✓ Copied to clipboard", "success")] }, undefined, "copied"));
574
+ /** The selected side question as a short document: the asked question titles each answer. */
575
+ #describeThread(record: BtwHistoryRecord): NativeNode {
564
576
  const turns = getBtwTurns(record);
565
- for (let index = 0; index < turns.length; index++) {
566
- if (index > 0) children.push(node("rule", undefined, undefined, `rule-${index}`));
567
- children.push(this.#describeTurn(turns[index]!, `turn-${index}`));
568
- }
569
- return col(children, { gap: "md" });
577
+ const children: NativeChild[] = turns.map((turn, index) => this.#describeTurn(turn, index, `turn-${index}`));
578
+ if (this.#composer) children.push(this.#describeComposer(this.#composer));
579
+ return node("col", { role: "omp.btw.thread" }, children, "thread");
570
580
  }
571
581
 
572
- #describeTurn(turn: BtwHistoryTurn, key: string): NativeNode {
573
- const status = STATUS[turn.status];
574
- const answer = turn.answer.trim()
575
- ? md(sanitizeDisplayText(turn.answer), { stream: turn.status === "running" })
576
- : text([span(turn.status === "running" ? "Waiting for response…" : "No answer text.", "dim")]);
577
- // Plain labels, not `section`s (Tern draws those as foldable `##` headings);
578
- // the question stays markdown so its inline code and links render.
582
+ #describeTurn(turn: BtwHistoryTurn, index: number, key: string): NativeNode {
583
+ const meta: TspSpan[] =
584
+ index === 0
585
+ ? [span(this.#dateFormat.format(turn.createdAt))]
586
+ : [span("Follow-up · "), span(this.#timeFormat.format(turn.createdAt))];
587
+ const status = describeStatus(turn.status);
588
+ if (status) meta.push(span(" · "), ...status);
589
+ // The question stays markdown so its inline code and links render.
579
590
  const children: NativeChild[] = [
580
- text(
581
- [
582
- span("Question", "accent strong"),
583
- span(" · ", "dim"),
584
- span(status.label, status.color),
585
- span(` · ${this.#dateFormat.format(turn.createdAt)}`, "dim"),
586
- ],
587
- { wrap: "word" },
588
- ),
589
- md(sanitizeDisplayText(turn.question)),
590
- text([span("Answer", "accent strong")]),
591
- answer,
591
+ md(sanitizeDisplayText(turn.question), { role: index === 0 ? "omp.btw.question" : "omp.btw.followup" }),
592
+ text(meta, { role: "omp.btw.meta", truncate: "end", lines: 1 }),
592
593
  ];
593
- if (turn.error) children.push(text([span(sanitizeErrorLine(turn.error), "error")], { wrap: "word" }));
594
- if (turn.status === "interrupted") children.push(text([span("Not resumed in this view.", "muted")]));
595
- return node("col", { gap: "sm" }, children, key);
594
+ // A failed turn with no text says only why it failed.
595
+ if (turn.answer.trim() || !turn.error) {
596
+ const answer = turn.answer.trim()
597
+ ? md(sanitizeDisplayText(turn.answer), { stream: turn.status === "running" })
598
+ : text([span(turn.status === "running" ? "Thinking…" : "No answer text.", "dim")], {
599
+ role: "omp.btw.pending",
600
+ });
601
+ children.push(node("col", { role: "omp.btw.answer" }, [answer], "answer"));
602
+ }
603
+ if (turn.error) {
604
+ children.push(text([span(sanitizeErrorLine(turn.error), "error")], { role: "omp.btw.error", wrap: "word" }));
605
+ }
606
+ if (turn.status === "interrupted") {
607
+ children.push(text([span("Not resumed in this view.", "muted")], { role: "omp.btw.note" }));
608
+ }
609
+ return node("col", { role: "omp.btw.turn" }, children, key);
610
+ }
611
+
612
+ /** The follow-up field under the newest answer, where the conversation continues. */
613
+ #describeComposer(composer: FollowUpComposer): NativeNode {
614
+ composer.input.focused = this.#focused;
615
+ const children: NativeChild[] = [composer.input];
616
+ if (composer.notice) {
617
+ children.push(
618
+ text([span(composer.notice, this.#followUpPending ? "dim" : "warning")], {
619
+ role: "omp.btw.notice",
620
+ wrap: "word",
621
+ }),
622
+ );
623
+ }
624
+ return node("col", { role: "omp.btw.composer", gap: "sm" }, children, "composer");
596
625
  }
597
626
 
598
- #describeFooter(record: BtwHistoryRecord | undefined, canFollowUp: boolean, copied: boolean): NativeNode {
627
+ #describeEmpty(): NativeNode {
628
+ return node(
629
+ "col",
630
+ { role: "omp.btw.empty", align: "center", gap: "sm" },
631
+ [
632
+ text("No side questions yet", { role: "omp.btw.empty.title" }),
633
+ text(
634
+ [
635
+ span("Ask one with "),
636
+ span("/btw <question>", "code"),
637
+ span(". The answer stays out of the main conversation."),
638
+ ],
639
+ { role: "omp.btw.empty.hint", wrap: "word" },
640
+ ),
641
+ ],
642
+ "empty",
643
+ );
644
+ }
645
+
646
+ /**
647
+ * The footer: what the selected answer allows on the left, Close and the one
648
+ * primary action (Follow up, or Send while composing) in the corner. Keys
649
+ * live in the buttons' tooltips.
650
+ */
651
+ #describeActions(record: BtwHistoryRecord | undefined, canFollowUp: boolean, copied: boolean): NativeNode {
599
652
  if (this.#composer) {
600
- return hintsRow([
601
- actionHint("tui.input.submit", this.#followUpPending ? "starting…" : "send"),
602
- actionHint("tui.select.cancel", "cancel"),
653
+ return actionBar([
654
+ null,
655
+ actionButton("Cancel", "discard", { keys: "escape" }),
656
+ actionButton(this.#followUpPending ? "Sending…" : "Send", "send", { keys: "enter", tone: "accent" }),
603
657
  ]);
604
658
  }
605
- const latest = record ? getBtwLatestTurn(record) : undefined;
606
- const running = latest?.status === "running";
607
- const hints: (NativeHint | undefined)[] = [
608
- actionHint("tui.select.cancel", running && !this.#options.escapeHides ? "cancel" : "close"),
609
- running && this.#options.escapeHides ? { keys: ["x"], label: "cancel" } : undefined,
610
- this.#records.length > 1 ? { keys: ["tab", "ctrl+/"], label: "switch pane" } : undefined,
611
- actionHint(["tui.select.up", "tui.select.down"], this.#focus === "list" ? "select" : "scroll"),
612
- ];
613
- if (canFollowUp) hints.push({ keys: ["f", "enter"], label: "follow up" });
659
+ const running = record !== undefined && getBtwLatestTurn(record).status === "running";
660
+ const hides = this.#options.escapeHides === true;
661
+ const buttons: (NativeNode | null)[] = [];
662
+ if (running) buttons.push(actionButton("Stop answer", "stop", { keys: hides ? "x" : "escape" }));
614
663
  if (record && getBtwCopyText(record) !== undefined) {
615
- hints.push({ keys: ["c"], label: copied ? "copy again" : "copy answer" });
664
+ buttons.push(actionButton(copied ? "Copy again" : "Copy answer", "copy", { keys: "c" }));
665
+ if (copied) buttons.push(text([span("✓ Copied", "success")], { role: "omp.btw.copied" }));
616
666
  }
617
- return copied ? statusHintsRow([span("✓ copied", "success")], hints) : hintsRow(hints);
667
+ buttons.push(null);
668
+ // Clicking Close always dismisses the sheet; its tooltip names Esc only where Esc does too.
669
+ buttons.push(actionButton("Close", "close", running && !hides ? undefined : { keys: "escape" }));
670
+ if (canFollowUp) buttons.push(actionButton("Follow up", "follow-up", { keys: "f", tone: "accent" }));
671
+ return actionBar(buttons);
618
672
  }
619
673
 
620
674
  #renderList(width: number, height: number): readonly string[] {
@@ -30,12 +30,21 @@ import { OverlayPanel } from "../chrome/overlay-box";
30
30
  import { FormField } from "../components/form";
31
31
  import { formatKeyHint, formatKeyHints } from "../app-keybindings";
32
32
  import { boundKeys, editorKey, interruptKey } from "../chrome/keybinding-hints";
33
- import { node, span } from "../native/describe";
34
- import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
35
- import { hintsRow, type NativeHint, overlayCard } from "../native/overlay";
33
+ import { formatTooltipKey, type KeyName } from "../key-hint-format";
34
+ import { col, node, span } from "../native/describe";
35
+ import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
36
+ import { actionBar, actionButton } from "../native/overlay";
36
37
  import { plainText } from "../native/spans";
37
38
  import { sanitizeCarriageReturns } from "../render/render-utils";
38
39
 
40
+ /** Root role of the native sheet. */
41
+ const ROLE = "omp.overlay.hook-editor";
42
+ /**
43
+ * Role prefix of its parts (`.head`, `.title`, `.detail`, `.eyebrow`, `.field`/`.line`); outside
44
+ * `omp.overlay.*`, which Tern draws as a sheet of its own.
45
+ */
46
+ const PART = "omp.hook-editor";
47
+
39
48
  /** Most title rows the terminal prompt shows, so a long question cannot push the input row off-screen. */
40
49
  const MAX_PROMPT_TITLE_ROWS = 3;
41
50
  /** Border (2) + padX (2) columns consumed by the panel chrome. */
@@ -109,7 +118,7 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
109
118
  #pendingPastes: { settled: boolean; text: string | undefined }[] = [];
110
119
  #submitQueued = false;
111
120
  #disposed = false;
112
- /** Detail lines, hints and the editor under the title; every part is fixed, so it is built once. */
121
+ /** Head, field and action bar; every part but the editor (which describes itself) is fixed, so it is built once. */
113
122
  #nativeRoot: NativeNode;
114
123
  /** Focus state mirrored to the nested editor during rendering. */
115
124
  focused = false;
@@ -128,7 +137,7 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
128
137
  const question = options?.question;
129
138
  const terminalTitle = question === undefined ? title : boundPromptTitle(`${title}: `, question);
130
139
  const [titleLine = "", ...detailLines] = terminalTitle.split("\n");
131
- super(titleLine, "omp.overlay.hook-editor");
140
+ super(titleLine, ROLE);
132
141
 
133
142
  this.#tui = tui;
134
143
  this.#onSubmitCallback = onSubmit;
@@ -175,24 +184,24 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
175
184
  this.addChild(this.#field);
176
185
  this.addChild(new Spacer(1));
177
186
 
178
- const externalHint: NativeHint = {
179
- keys: boundKeys("app.editor.external", ["ctrl+g"]).slice(0, 1),
180
- label: "external editor",
181
- };
182
- const nativeHints: NativeHint[] = this.#promptStyle
183
- ? [
184
- { keys: ["enter", primaryFollowUpKey], label: "submit" },
185
- { keys: ["escape"], label: "cancel" },
186
- externalHint,
187
- ]
188
- : [
189
- { keys: followUpKeys, label: "submit" },
190
- { keys: boundKeys("app.interrupt", ["escape"]).slice(0, 1), label: "cancel" },
191
- externalHint,
192
- ];
193
- const nativeChildren: NativeChild[] = [];
187
+ // Native: the field drops its terminal frame for the sheet's, and says what to type while empty.
188
+ const fieldRole = this.#promptStyle ? `${PART}.line` : `${PART}.field`;
189
+ this.#editor.describeLayout = input => ({ role: fieldRole, children: [input], caret: "input" });
190
+ const placeholder = [span(this.#promptStyle ? "Type your answer" : "Type here")];
191
+ this.#editor.describePlaceholder = () => placeholder;
192
+
193
+ const head: NativeChild[] = [];
194
194
  if (question !== undefined) {
195
- nativeChildren.push(
195
+ // The question is the heading, as on the ask sheet; the prompt's own title names the answer above it.
196
+ head.push(
197
+ node(
198
+ "text",
199
+ { spans: [span(plainText(title))], role: `${PART}.eyebrow`, wrap: "none", truncate: "end" },
200
+ undefined,
201
+ "eyebrow",
202
+ ),
203
+ );
204
+ head.push(
196
205
  node(
197
206
  "md",
198
207
  { text: replaceTabs(sanitizeCarriageReturns(question)), role: "omp.ask.question" },
@@ -200,30 +209,79 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
200
209
  "question",
201
210
  ),
202
211
  );
203
- } else if (detailLines.length > 0) {
204
- nativeChildren.push(
212
+ } else {
213
+ head.push(
205
214
  node(
206
215
  "text",
207
- { spans: [span(plainText(detailLines.join("\n")), "accent")], wrap: "word" },
216
+ { spans: [span(plainText(titleLine))], role: `${PART}.title`, wrap: "word" },
208
217
  undefined,
209
- "detail",
218
+ "title",
210
219
  ),
211
220
  );
221
+ if (detailLines.length > 0) {
222
+ head.push(
223
+ node(
224
+ "text",
225
+ { spans: [span(plainText(detailLines.join("\n")))], wrap: "word", role: `${PART}.detail` },
226
+ undefined,
227
+ "detail",
228
+ ),
229
+ );
230
+ }
212
231
  }
213
- nativeChildren.push(this.#editor, hintsRow(nativeHints));
214
- this.#nativeRoot = overlayCard(
215
- this.nativeRole,
216
- plainText(question === undefined ? titleLine : title),
217
- nativeChildren,
232
+
233
+ // Buttons say what they do; their keys live in the tooltips.
234
+ const tip = (clauses: readonly (readonly [string, KeyName | undefined])[]): string =>
235
+ clauses.map(([label, key]) => (key ? `${label} ${formatTooltipKey(key)}` : label)).join(" · ");
236
+ const submitKey: KeyName = this.#promptStyle ? "enter" : primaryFollowUpKey;
237
+ const newlineKey: KeyName = this.#promptStyle ? "shift+enter" : "enter";
238
+ const cancelKey = this.#promptStyle ? "escape" : (boundKeys("app.interrupt", ["escape"])[0] ?? "escape");
239
+ const buttons: (NativeNode | null)[] = [];
240
+ if (this.#externalEditor) {
241
+ const [externalKey] = boundKeys("app.editor.external", ["ctrl+g"]);
242
+ buttons.push(
243
+ actionButton("Open in editor", "external", {
244
+ ...(externalKey ? { keys: externalKey } : {}),
245
+ title: tip([["Edit in your external editor", externalKey]]),
246
+ }),
247
+ );
248
+ }
249
+ buttons.push(
250
+ null,
251
+ actionButton("Cancel", "cancel", { keys: cancelKey, title: tip([["Cancel", cancelKey]]) }),
252
+ actionButton("Submit", "submit", {
253
+ keys: submitKey,
254
+ tone: "accent",
255
+ title: tip([
256
+ ["Submit", submitKey],
257
+ ["New line", newlineKey],
258
+ ]),
259
+ }),
260
+ );
261
+
262
+ this.#nativeRoot = col(
263
+ [node("col", { role: `${PART}.head`, gap: "xs" }, head, "head"), this.#editor, actionBar(buttons)],
264
+ { role: ROLE },
218
265
  );
219
266
  }
220
267
 
221
- /** The editor (which describes itself) under the title and detail lines, with the key hints below. */
268
+ /**
269
+ * A sheet in the composer's place: the title (or, answering a question, the question under the
270
+ * prompt's name) over a hairline, the editor, and Open in editor · Cancel · Submit.
271
+ */
222
272
  override describe(_cx: DescribeContext): NativeNode {
223
273
  this.#field.focused = this.focused;
224
274
  return this.#nativeRoot;
225
275
  }
226
276
 
277
+ /** The action bar's buttons run what their keys run. */
278
+ handleNativeEvent(event: NativeUiEvent): void {
279
+ if (this.#disposed || event.type !== "action") return;
280
+ if (event.act === "submit") this.#submitCurrentText();
281
+ else if (event.act === "cancel") this.#cancel();
282
+ else if (event.act === "external") void this.#openExternalEditor();
283
+ }
284
+
227
285
  /** Keep the nested editor's software/hardware cursor mode aligned with the dialog focus target. */
228
286
  setUseTerminalCursor(useTerminalCursor: boolean): void {
229
287
  this.#field.setUseTerminalCursor(useTerminalCursor);