@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.
- package/CHANGELOG.md +53 -105
- package/dist/types/app-keybindings.d.ts +5 -0
- package/dist/types/chat/assistant-message.d.ts +1 -1
- package/dist/types/chat/user-message.d.ts +3 -2
- package/dist/types/chrome/countdown-timer.d.ts +3 -2
- package/dist/types/debug-server.d.ts +2 -0
- package/dist/types/overlays/annotation-overlay.d.ts +19 -0
- package/dist/types/overlays/ask-dialog.d.ts +11 -6
- package/dist/types/overlays/btw-history-panel.d.ts +2 -6
- package/dist/types/overlays/hook-editor.d.ts +7 -2
- package/dist/types/overlays/hook-input.d.ts +12 -4
- package/dist/types/overlays/jobs-panel.d.ts +8 -7
- package/dist/types/overlays/login-dialog.d.ts +8 -3
- package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
- package/dist/types/overlays/pause-screen.d.ts +5 -0
- package/dist/types/overlays/report-panel.d.ts +5 -1
- package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
- package/dist/types/overlays/session-account-selector.d.ts +4 -0
- package/dist/types/overlays/session-info-overlay.d.ts +7 -3
- package/dist/types/overlays/usage-dashboard.d.ts +6 -6
- package/dist/types/prompt/composer.d.ts +1 -20
- package/dist/types/status-line/context-usage.d.ts +1 -1
- package/dist/types/tools/grouped-file-output.d.ts +4 -1
- package/dist/types/tui.d.ts +10 -3
- package/package.json +9 -9
- package/src/app-keybindings.ts +5 -0
- package/src/apps/autoresearch-dashboard.ts +440 -27
- package/src/chat/assistant-message.ts +41 -13
- package/src/chat/user-message.ts +32 -19
- package/src/chrome/countdown-timer.ts +3 -3
- package/src/chrome/transcript-container.ts +202 -28
- package/src/debug-server.ts +51 -69
- package/src/hotkeys-markdown.ts +1 -0
- package/src/native/reconcile.ts +24 -6
- package/src/overlays/advisor-config.ts +259 -8
- package/src/overlays/annotation-overlay.ts +420 -168
- package/src/overlays/ask-dialog.ts +249 -105
- package/src/overlays/btw-history-panel.ts +178 -124
- package/src/overlays/hook-editor.ts +90 -32
- package/src/overlays/hook-input.ts +105 -19
- package/src/overlays/jobs-panel.ts +154 -132
- package/src/overlays/login-dialog.ts +201 -91
- package/src/overlays/mcp-add-wizard.ts +559 -279
- package/src/overlays/pause-screen.ts +81 -23
- package/src/overlays/plugin-selector.ts +17 -1
- package/src/overlays/queue-mode-selector.ts +10 -0
- package/src/overlays/report-panel.ts +6 -2
- package/src/overlays/reset-usage-selector.ts +130 -21
- package/src/overlays/session-account-selector.ts +36 -0
- package/src/overlays/session-info-overlay.ts +197 -76
- package/src/overlays/show-images-selector.ts +1 -0
- package/src/overlays/theme-selector.ts +6 -1
- package/src/overlays/thinking-selector.ts +8 -4
- package/src/overlays/usage-dashboard.ts +495 -143
- package/src/prompt/composer.ts +35 -72
- package/src/status-line/context-usage.ts +154 -57
- package/src/tools/grep.ts +1 -0
- package/src/tools/grouped-file-output.ts +11 -2
- 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
|
|
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
|
|
426
|
-
|
|
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
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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(
|
|
488
|
-
//
|
|
489
|
-
const
|
|
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"
|
|
515
|
-
this.#
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
"
|
|
549
|
-
{
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
if (turn.
|
|
595
|
-
|
|
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
|
-
#
|
|
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
|
|
601
|
-
|
|
602
|
-
|
|
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
|
|
606
|
-
const
|
|
607
|
-
const
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
34
|
-
import
|
|
35
|
-
import {
|
|
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
|
-
/**
|
|
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,
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
|
204
|
-
|
|
212
|
+
} else {
|
|
213
|
+
head.push(
|
|
205
214
|
node(
|
|
206
215
|
"text",
|
|
207
|
-
{ spans: [span(plainText(
|
|
216
|
+
{ spans: [span(plainText(titleLine))], role: `${PART}.title`, wrap: "word" },
|
|
208
217
|
undefined,
|
|
209
|
-
"
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
/**
|
|
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);
|