@oh-my-pi/pi-tui 18.13.0 → 18.13.2
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 +36 -32
- package/dist/types/chrome/context-map.d.ts +15 -0
- package/dist/types/chrome/context-presentation.d.ts +48 -0
- package/dist/types/chrome/context-view-model.d.ts +77 -0
- package/dist/types/overlays/advisor-config.d.ts +6 -0
- package/dist/types/overlays/ask-dialog.d.ts +12 -3
- package/dist/types/overlays/context-viewer-format.d.ts +42 -0
- package/dist/types/overlays/context-viewer-overview.d.ts +70 -0
- package/dist/types/overlays/context-viewer.d.ts +25 -0
- package/dist/types/overlays/hook-input.d.ts +1 -0
- package/dist/types/overlays/pause-screen.d.ts +13 -3
- package/dist/types/overlays/running-subagent-badge.d.ts +7 -1
- package/dist/types/status-line/component.d.ts +2 -2
- package/dist/types/status-line/context-usage.d.ts +15 -30
- package/dist/types/theme/symbols.d.ts +1 -1
- package/dist/types/tools/ask.d.ts +4 -0
- package/dist/types/tools/fetch.d.ts +1 -0
- package/dist/types/tools/github.d.ts +3 -0
- package/dist/types/tools/read.d.ts +3 -0
- package/dist/types/tools/wait.d.ts +6 -0
- package/dist/types/tui.d.ts +2 -0
- package/package.json +9 -9
- package/src/chrome/context-map.ts +62 -0
- package/src/chrome/context-presentation.ts +26 -0
- package/src/chrome/context-view-model.ts +73 -0
- package/src/components/editor.ts +23 -0
- package/src/components/markdown.ts +14 -2
- package/src/overlays/advisor-config.ts +9 -2
- package/src/overlays/ask-dialog.ts +122 -35
- package/src/overlays/context-viewer-format.ts +145 -0
- package/src/overlays/context-viewer-overview.ts +381 -0
- package/src/overlays/context-viewer.ts +728 -0
- package/src/overlays/hook-input.ts +2 -0
- package/src/overlays/pause-screen.ts +166 -37
- package/src/overlays/running-subagent-badge.ts +12 -1
- package/src/render/output-pane.ts +3 -3
- package/src/status-line/component.ts +6 -4
- package/src/status-line/context-usage.ts +56 -580
- package/src/theme/symbols.ts +16 -0
- package/src/tools/ask.ts +4 -0
- package/src/tools/bash.ts +5 -23
- package/src/tools/fetch.ts +1 -0
- package/src/tools/github.ts +23 -3
- package/src/tools/read.ts +3 -1
- package/src/tools/wait.ts +6 -0
- package/src/tui.ts +23 -3
|
@@ -51,6 +51,8 @@ export interface ExtensionAskDialogResultItem {
|
|
|
51
51
|
customInputImages?: ImageContent[];
|
|
52
52
|
note?: string;
|
|
53
53
|
noteImages?: ImageContent[];
|
|
54
|
+
/** Label of the option the note is on; unset when it is on the custom answer. */
|
|
55
|
+
noteOption?: string;
|
|
54
56
|
timedOut?: boolean;
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -145,14 +147,21 @@ interface AskDialogCallbacks {
|
|
|
145
147
|
): Promise<AskDialogPromptValue | undefined>;
|
|
146
148
|
}
|
|
147
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Routes typing to a composer draft that was in progress when the dialog
|
|
152
|
+
* opened, so in-flight keystrokes never answer by accident. The up/down select
|
|
153
|
+
* keys are the explicit hand-off: they release the guard for the rest of the
|
|
154
|
+
* dialog's life and the draft stays parked (intact) beneath it.
|
|
155
|
+
*/
|
|
148
156
|
interface AskDialogInputGuard {
|
|
149
157
|
isBlocked(): boolean;
|
|
150
158
|
handleInput(keyData: string): void;
|
|
159
|
+
/** Shown after the release keys while the guard holds input. */
|
|
151
160
|
hint: string;
|
|
152
161
|
/** Mirror the guard's blocked state onto the proxied draft surface each
|
|
153
162
|
* render, so a draft that owns input shows a visible insertion cursor even
|
|
154
|
-
* though this dialog holds TUI focus. */
|
|
155
|
-
syncPresentation?(): void;
|
|
163
|
+
* though this dialog holds TUI focus, and drops it once the dialog takes over. */
|
|
164
|
+
syncPresentation?(blocked: boolean): void;
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
interface AskDialogOptions {
|
|
@@ -404,7 +413,7 @@ function describeAnswer(question: ExtensionAskDialogQuestion, state: QuestionSta
|
|
|
404
413
|
push(optionLabelSpans(question, index, labels[index] ?? option.label));
|
|
405
414
|
});
|
|
406
415
|
if (state.customInput !== undefined) push([span(`“${normalizedInlineInput(state.customInput)}”`)]);
|
|
407
|
-
const note = noteForSubmittedAnswer(question, state)?.trim();
|
|
416
|
+
const note = noteForSubmittedAnswer(question, state)?.text.trim();
|
|
408
417
|
if (note) spans.push(span(" ✎ ", "dim"), span(normalizedInlineInput(note), "em"));
|
|
409
418
|
return spans;
|
|
410
419
|
}
|
|
@@ -448,13 +457,17 @@ function clearNoteUnlessRow(state: QuestionState, rowKey: string): void {
|
|
|
448
457
|
if (state.noteRowKey !== undefined && state.noteRowKey !== rowKey) clearNote(state);
|
|
449
458
|
}
|
|
450
459
|
|
|
451
|
-
|
|
460
|
+
/** The note that goes out with the answer (its row is part of it), and the option it is on (unset for Other). */
|
|
461
|
+
function noteForSubmittedAnswer(
|
|
462
|
+
question: ExtensionAskDialogQuestion,
|
|
463
|
+
state: QuestionState,
|
|
464
|
+
): { text: string; option?: string } | undefined {
|
|
452
465
|
if (state.note === undefined || state.noteRowKey === undefined) return undefined;
|
|
453
|
-
if (state.noteRowKey === "other") return state.customInput !== undefined ? state.note : undefined;
|
|
466
|
+
if (state.noteRowKey === "other") return state.customInput !== undefined ? { text: state.note } : undefined;
|
|
454
467
|
const match = /^option:(\d+)$/.exec(state.noteRowKey);
|
|
455
468
|
const optionIndex = match?.[1] === undefined ? Number.NaN : Number.parseInt(match[1], 10);
|
|
456
469
|
const option = Number.isInteger(optionIndex) ? question.options[optionIndex] : undefined;
|
|
457
|
-
return option && state.selectedOptions.has(option.label) ? state.note : undefined;
|
|
470
|
+
return option && state.selectedOptions.has(option.label) ? { text: state.note, option: option.label } : undefined;
|
|
458
471
|
}
|
|
459
472
|
|
|
460
473
|
function renderRowLabel(
|
|
@@ -581,10 +594,18 @@ export class AskDialogComponent implements Component {
|
|
|
581
594
|
#native: NativeNode | undefined;
|
|
582
595
|
/** Input-guard blocked state {@link #native} was described with (the guard flips outside this dialog). */
|
|
583
596
|
#nativeBlocked = false;
|
|
597
|
+
/** Set once the user explicitly takes the dialog (up/down, or a pointer action) while a draft held input. */
|
|
598
|
+
#guardReleased = false;
|
|
584
599
|
/** When the countdown runs out (epoch ms), for the native ring. */
|
|
585
600
|
#countdownDeadline = 0;
|
|
586
601
|
/** Once-a-second re-describe while the native countdown ring is shown. */
|
|
587
602
|
#ringTick: NodeJS.Timeout | undefined;
|
|
603
|
+
/**
|
|
604
|
+
* The answer whose pick just moved the dialog on to the next question. Note annotates it, not the
|
|
605
|
+
* next question's highlighted row, until that question is answered or any other input arrives:
|
|
606
|
+
* a Tern click commits a single-choice row, so picking then pressing Note is the mouse flow.
|
|
607
|
+
*/
|
|
608
|
+
#justAnswered: { questionIndex: number; rowKey: string } | undefined;
|
|
588
609
|
readonly #panel: OverlayPanel;
|
|
589
610
|
readonly #headerRegion: PanelRows;
|
|
590
611
|
readonly #bodyRegion: PanelRows;
|
|
@@ -682,6 +703,7 @@ export class AskDialogComponent implements Component {
|
|
|
682
703
|
// closed/prompt guards, matching HookSelector/HookInput semantics.
|
|
683
704
|
this.#countdown?.reset();
|
|
684
705
|
this.#native = undefined;
|
|
706
|
+
if (keyData !== "n" && keyData !== "N") this.#justAnswered = undefined;
|
|
685
707
|
if (matchesSelectCancel(keyData)) {
|
|
686
708
|
this.#finishCancel();
|
|
687
709
|
return;
|
|
@@ -696,9 +718,15 @@ export class AskDialogComponent implements Component {
|
|
|
696
718
|
this.toggleQuestionExpansion();
|
|
697
719
|
return;
|
|
698
720
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
721
|
+
if (this.#guardBlocks()) {
|
|
722
|
+
if (
|
|
723
|
+
getKeybindings().matches(keyData, "tui.select.up") ||
|
|
724
|
+
getKeybindings().matches(keyData, "tui.select.down")
|
|
725
|
+
) {
|
|
726
|
+
this.#releaseGuard();
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
this.options.inputGuard?.handleInput(keyData);
|
|
702
730
|
this.#requestRender();
|
|
703
731
|
return;
|
|
704
732
|
}
|
|
@@ -723,7 +751,7 @@ export class AskDialogComponent implements Component {
|
|
|
723
751
|
// Keep the proxied draft's cursor visible while it owns input (the editor
|
|
724
752
|
// renders as the next sibling in the same container, so this lands in the
|
|
725
753
|
// same frame).
|
|
726
|
-
this.options.inputGuard?.syncPresentation?.();
|
|
754
|
+
this.options.inputGuard?.syncPresentation?.(this.#guardBlocks());
|
|
727
755
|
if (this.#hidden) return this.#renderHidden(width);
|
|
728
756
|
const innerWidth = Math.max(1, width - 4);
|
|
729
757
|
this.#contentWidth = innerWidth;
|
|
@@ -768,10 +796,9 @@ export class AskDialogComponent implements Component {
|
|
|
768
796
|
* countdown, Hide, then Note, Skip and the primary Submit/Next).
|
|
769
797
|
*/
|
|
770
798
|
describe(cx: DescribeContext): NativeNode {
|
|
771
|
-
const inputGuard = this.options.inputGuard;
|
|
772
799
|
// Same draft-cursor mirroring as render(): the proxied draft describes itself next to this dialog.
|
|
773
|
-
|
|
774
|
-
|
|
800
|
+
const blocked = this.#guardBlocks();
|
|
801
|
+
this.options.inputGuard?.syncPresentation?.(blocked);
|
|
775
802
|
const ring = this.#countdown !== undefined && cx.supports("meter");
|
|
776
803
|
if (ring) this.#startRingTick();
|
|
777
804
|
if (this.#native && this.#nativeBlocked === blocked) return this.#native;
|
|
@@ -985,7 +1012,7 @@ export class AskDialogComponent implements Component {
|
|
|
985
1012
|
if (blocked && inputGuard) {
|
|
986
1013
|
return actionBar([
|
|
987
1014
|
...lead,
|
|
988
|
-
text([span(plainText(inputGuard
|
|
1015
|
+
text([span(plainText(this.#guardHint(inputGuard)), "muted")], { truncate: "end", role: "omp.ask.summary" }),
|
|
989
1016
|
skip,
|
|
990
1017
|
]);
|
|
991
1018
|
}
|
|
@@ -1006,7 +1033,10 @@ export class AskDialogComponent implements Component {
|
|
|
1006
1033
|
tone: "accent",
|
|
1007
1034
|
title: question?.multi ? `${enterLabel} enter · Toggle space` : `${enterLabel} enter`,
|
|
1008
1035
|
});
|
|
1009
|
-
const note = actionButton("Note", "note", {
|
|
1036
|
+
const note = actionButton("Note", "note", {
|
|
1037
|
+
keys: "n",
|
|
1038
|
+
title: "Add a note to the highlighted answer, or to the one just picked n",
|
|
1039
|
+
});
|
|
1010
1040
|
return actionBar([...lead, null, note, skip, submit]);
|
|
1011
1041
|
}
|
|
1012
1042
|
|
|
@@ -1014,11 +1044,12 @@ export class AskDialogComponent implements Component {
|
|
|
1014
1044
|
* Pointer actions: a tab click switches to it; a click on an answer row is
|
|
1015
1045
|
* Enter on it (Space for a multi-select option, so it toggles; its
|
|
1016
1046
|
* double-click `activate` is Enter); the action bar's Submit, Note and Skip
|
|
1017
|
-
* run
|
|
1047
|
+
* run what Enter (see {@link #pressEnter} for Other), `n` and Esc run on the highlighted row; Hide/Show
|
|
1018
1048
|
* collapse and restore the dialog.
|
|
1019
1049
|
*/
|
|
1020
1050
|
handleNativeEvent(event: NativeUiEvent): void {
|
|
1021
1051
|
if (this.#closed || this.#promptActive) return;
|
|
1052
|
+
if (event.type !== "action" || event.act !== "note") this.#justAnswered = undefined;
|
|
1022
1053
|
if (event.type === "action") {
|
|
1023
1054
|
if (event.act === "cancel") {
|
|
1024
1055
|
this.#finishCancel();
|
|
@@ -1028,13 +1059,16 @@ export class AskDialogComponent implements Component {
|
|
|
1028
1059
|
this.#setHidden(event.act === "hide");
|
|
1029
1060
|
return;
|
|
1030
1061
|
}
|
|
1031
|
-
if (this.#hidden
|
|
1062
|
+
if (this.#hidden) return;
|
|
1063
|
+
// A pointer action on the dialog is an explicit hand-off from the draft.
|
|
1064
|
+
this.#releaseGuardForPointer();
|
|
1032
1065
|
if (event.act === "submit") this.#pressEnter();
|
|
1033
1066
|
else if (event.act === "note") this.#pressNote();
|
|
1034
1067
|
return;
|
|
1035
1068
|
}
|
|
1036
1069
|
if (event.type !== "select" && event.type !== "activate") return;
|
|
1037
|
-
if (this.#hidden
|
|
1070
|
+
if (this.#hidden) return;
|
|
1071
|
+
this.#releaseGuardForPointer();
|
|
1038
1072
|
const key = leafKey(event.key);
|
|
1039
1073
|
if (key === "tabs") {
|
|
1040
1074
|
if (!this.#hasSubmitTab()) return;
|
|
@@ -1062,7 +1096,13 @@ export class AskDialogComponent implements Component {
|
|
|
1062
1096
|
this.#requestRender();
|
|
1063
1097
|
}
|
|
1064
1098
|
|
|
1065
|
-
/**
|
|
1099
|
+
/**
|
|
1100
|
+
* The action bar's Submit/Next: submit on the Review tab, else commit the highlighted row —
|
|
1101
|
+
* except Other, whose row (click or Enter) already opens the editor. Reopening it here trapped a
|
|
1102
|
+
* cancelled Other: Next kept bringing the editor back instead of moving on. With a Review tab to
|
|
1103
|
+
* land on, Next keeps any custom answer and moves on; a lone single-select question has nowhere
|
|
1104
|
+
* to go but submitting nothing, so it still asks.
|
|
1105
|
+
*/
|
|
1066
1106
|
#pressEnter(): void {
|
|
1067
1107
|
this.#countdown?.reset();
|
|
1068
1108
|
if (this.#isSubmitTab()) {
|
|
@@ -1072,18 +1112,38 @@ export class AskDialogComponent implements Component {
|
|
|
1072
1112
|
const active = this.#activeQuestionState();
|
|
1073
1113
|
const rowItem = active ? this.#questionRows(active.question)[active.state.cursorIndex] : undefined;
|
|
1074
1114
|
if (!active || !rowItem) return;
|
|
1115
|
+
if (rowItem.kind === "other" && this.#hasSubmitTab()) {
|
|
1116
|
+
this.#advanceAfterQuestion(active.state.customInput === undefined ? undefined : rowItem.key);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1075
1119
|
this.#commitRow(active.question, active.state, rowItem, true);
|
|
1076
1120
|
this.#requestRender();
|
|
1077
1121
|
}
|
|
1078
1122
|
|
|
1079
|
-
/** `n`: a note on the
|
|
1123
|
+
/** `n`: a note on the {@link #noteTarget} row (questions only). */
|
|
1080
1124
|
#pressNote(): void {
|
|
1081
1125
|
if (this.#isSubmitTab()) return;
|
|
1082
1126
|
this.#countdown?.reset();
|
|
1127
|
+
const target = this.#noteTarget();
|
|
1128
|
+
if (target) void this.#promptForNote(target.question, target.state, target.rowItem);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* The row a note goes to: the answer just picked while the question it moved on to is still
|
|
1133
|
+
* unanswered, else the highlighted row.
|
|
1134
|
+
*/
|
|
1135
|
+
#noteTarget(): { question: ExtensionAskDialogQuestion; state: QuestionState; rowItem: QuestionRow } | undefined {
|
|
1083
1136
|
const active = this.#activeQuestionState();
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1137
|
+
if (!active) return undefined;
|
|
1138
|
+
const last = this.#justAnswered;
|
|
1139
|
+
if (last && last.questionIndex !== this.#currentQuestionIndex() && !isAnswered(active.state)) {
|
|
1140
|
+
const question = this.#questions[last.questionIndex];
|
|
1141
|
+
const state = this.#states[last.questionIndex];
|
|
1142
|
+
const rowItem = question ? this.#questionRows(question).find(row => row.key === last.rowKey) : undefined;
|
|
1143
|
+
if (question && state && rowItem) return { question, state, rowItem };
|
|
1144
|
+
}
|
|
1145
|
+
const rowItem = this.#questionRows(active.question)[active.state.cursorIndex];
|
|
1146
|
+
return rowItem ? { question: active.question, state: active.state, rowItem } : undefined;
|
|
1087
1147
|
}
|
|
1088
1148
|
|
|
1089
1149
|
/** Re-describe once a second while a countdown ring is shown (the ring's value is data, not a terminal clock). */
|
|
@@ -1169,16 +1229,39 @@ export class AskDialogComponent implements Component {
|
|
|
1169
1229
|
this.#requestRender();
|
|
1170
1230
|
}
|
|
1171
1231
|
|
|
1232
|
+
/** True while a draft that predates the dialog still owns typing. */
|
|
1233
|
+
#guardBlocks(): boolean {
|
|
1234
|
+
return !this.#guardReleased && (this.options.inputGuard?.isBlocked() ?? false);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** Up/down hand the keyboard to the dialog for good; the draft stays untouched beneath it. */
|
|
1238
|
+
#releaseGuard(): void {
|
|
1239
|
+
this.#guardReleased = true;
|
|
1240
|
+
this.#native = undefined;
|
|
1241
|
+
this.#hidden = false;
|
|
1242
|
+
this.#requestRender();
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
#releaseGuardForPointer(): void {
|
|
1246
|
+
if (!this.#guardBlocks()) return;
|
|
1247
|
+
this.#guardReleased = true;
|
|
1248
|
+
this.#native = undefined;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
#guardHint(inputGuard: AskDialogInputGuard): string {
|
|
1252
|
+
return `${editorKeys("tui.select.up", "tui.select.down")} answer · ${inputGuard.hint}`;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1172
1255
|
/** Hidden: one line in the composer's place — title (with countdown), the question clipped to fit, and how to bring it back. */
|
|
1173
1256
|
#renderHidden(width: number): readonly string[] {
|
|
1174
1257
|
const hideKey = editorKey("app.ask.hide");
|
|
1175
1258
|
const enter = formatKeyHint("enter");
|
|
1176
1259
|
const inputGuard = this.options.inputGuard;
|
|
1177
|
-
const blocked =
|
|
1260
|
+
const blocked = this.#guardBlocks();
|
|
1178
1261
|
// While a draft owns input, Enter goes to the draft, so only the hide key restores.
|
|
1179
1262
|
const show = blocked ? hideKey : hideKey ? `${hideKey}/${enter}` : enter;
|
|
1180
1263
|
const hints: string[] = [];
|
|
1181
|
-
if (blocked && inputGuard) hints.push(inputGuard
|
|
1264
|
+
if (blocked && inputGuard) hints.push(this.#guardHint(inputGuard));
|
|
1182
1265
|
if (show) hints.push(`${show} show`);
|
|
1183
1266
|
hints.push(`${editorKey("tui.select.cancel")} cancel`);
|
|
1184
1267
|
const hint = theme.fg("dim", ` · ${hints.join(" · ")}`);
|
|
@@ -1262,7 +1345,7 @@ export class AskDialogComponent implements Component {
|
|
|
1262
1345
|
const enter = formatKeyHint("enter");
|
|
1263
1346
|
const upDown = editorKeys("tui.select.up", "tui.select.down");
|
|
1264
1347
|
const inputGuard = this.options.inputGuard;
|
|
1265
|
-
if (inputGuard
|
|
1348
|
+
if (inputGuard && this.#guardBlocks()) return `${this.#guardHint(inputGuard)}${this.#expandHint()} · ${cancel}`;
|
|
1266
1349
|
if (this.#isSubmitTab()) {
|
|
1267
1350
|
const scroll = indicator ? ` ${indicator} scroll ·` : "";
|
|
1268
1351
|
return `${enter} submit · ${upDown} scroll ·${scroll} ${cancel}`;
|
|
@@ -1334,9 +1417,7 @@ export class AskDialogComponent implements Component {
|
|
|
1334
1417
|
const rowItem = rows[state.cursorIndex];
|
|
1335
1418
|
if (!rowItem) return;
|
|
1336
1419
|
if (keyData === "n" || keyData === "N") {
|
|
1337
|
-
|
|
1338
|
-
void this.#promptForNote(question, state, rowItem);
|
|
1339
|
-
}
|
|
1420
|
+
this.#pressNote();
|
|
1340
1421
|
return;
|
|
1341
1422
|
}
|
|
1342
1423
|
const isEnter = matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n";
|
|
@@ -1364,7 +1445,7 @@ export class AskDialogComponent implements Component {
|
|
|
1364
1445
|
// focused option; Space toggles. Advances to the next question
|
|
1365
1446
|
// (submitting only for a single-question dialog), matching
|
|
1366
1447
|
// single-select Enter (#8252).
|
|
1367
|
-
this.#advanceAfterQuestion();
|
|
1448
|
+
this.#advanceAfterQuestion(state.selectedOptions.has(option.label) ? rowItem.key : undefined);
|
|
1368
1449
|
return;
|
|
1369
1450
|
}
|
|
1370
1451
|
if (state.selectedOptions.has(option.label)) {
|
|
@@ -1380,7 +1461,7 @@ export class AskDialogComponent implements Component {
|
|
|
1380
1461
|
state.customInput = undefined;
|
|
1381
1462
|
state.customInputImages = undefined;
|
|
1382
1463
|
clearNoteUnlessRow(state, rowItem.key);
|
|
1383
|
-
this.#advanceAfterQuestion();
|
|
1464
|
+
this.#advanceAfterQuestion(rowItem.key);
|
|
1384
1465
|
}
|
|
1385
1466
|
|
|
1386
1467
|
#handleSubmitTabInput(keyData: string): void {
|
|
@@ -1405,8 +1486,13 @@ export class AskDialogComponent implements Component {
|
|
|
1405
1486
|
this.#submitScrollOffset = 0;
|
|
1406
1487
|
}
|
|
1407
1488
|
|
|
1408
|
-
|
|
1489
|
+
/** Move past the current question; `answeredRowKey` is the row whose pick did it, for {@link #noteTarget}. */
|
|
1490
|
+
#advanceAfterQuestion(answeredRowKey?: string): void {
|
|
1409
1491
|
const current = this.#currentQuestionIndex();
|
|
1492
|
+
this.#justAnswered =
|
|
1493
|
+
answeredRowKey !== undefined && this.#questions.length > 1
|
|
1494
|
+
? { questionIndex: current, rowKey: answeredRowKey }
|
|
1495
|
+
: undefined;
|
|
1410
1496
|
if (this.#questions.length === 1) {
|
|
1411
1497
|
this.#finishSubmit();
|
|
1412
1498
|
return;
|
|
@@ -1458,7 +1544,7 @@ export class AskDialogComponent implements Component {
|
|
|
1458
1544
|
this.#activeTabIndex = this.#submitTabIndex();
|
|
1459
1545
|
this.#submitScrollOffset = 0;
|
|
1460
1546
|
} else {
|
|
1461
|
-
this.#advanceAfterQuestion();
|
|
1547
|
+
this.#advanceAfterQuestion(rowItem.key);
|
|
1462
1548
|
}
|
|
1463
1549
|
} finally {
|
|
1464
1550
|
this.#promptActive = false;
|
|
@@ -1590,7 +1676,7 @@ export class AskDialogComponent implements Component {
|
|
|
1590
1676
|
const label = questionTabLabel(question, index);
|
|
1591
1677
|
const answer = renderAnswerSummary(question, state);
|
|
1592
1678
|
allLines.push(`${theme.fg("dim", `${index + 1}. ${label}:`)} ${answer}`);
|
|
1593
|
-
const submittedNote = noteForSubmittedAnswer(question, state);
|
|
1679
|
+
const submittedNote = noteForSubmittedAnswer(question, state)?.text;
|
|
1594
1680
|
if (submittedNote?.trim()) {
|
|
1595
1681
|
const note = normalizedInlineInput(submittedNote);
|
|
1596
1682
|
allLines.push(
|
|
@@ -1724,8 +1810,9 @@ export class AskDialogComponent implements Component {
|
|
|
1724
1810
|
selectedOptions,
|
|
1725
1811
|
customInput: state.customInput,
|
|
1726
1812
|
customInputImages: state.customInputImages,
|
|
1727
|
-
note,
|
|
1813
|
+
note: note?.text,
|
|
1728
1814
|
noteImages: note === undefined ? undefined : state.noteImages,
|
|
1815
|
+
noteOption: note?.option,
|
|
1729
1816
|
timedOut: state.timedOut || undefined,
|
|
1730
1817
|
});
|
|
1731
1818
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display text for context entries. Each header has an ANSI-string form (`render`)
|
|
3
|
+
* and a span form (`describe`); keep the twins visually identical.
|
|
4
|
+
*/
|
|
5
|
+
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import type { TspSpan } from "@oh-my-pi/pi-wire";
|
|
7
|
+
import { CONTEXT_PRESENTATION } from "../chrome/context-presentation";
|
|
8
|
+
import type { ContextCategoryId, ContextThinkingGroup, ContextViewEntry } from "../chrome/context-view-model";
|
|
9
|
+
import { editorKey } from "../chrome/keybinding-hints";
|
|
10
|
+
import { span } from "../native/describe";
|
|
11
|
+
import { PREVIEW_LIMITS, replaceTabs, shortenEmbeddedPaths, shortenPath } from "../render/render-utils";
|
|
12
|
+
import { theme } from "../theme/theme";
|
|
13
|
+
import { wrapTextWithAnsi } from "../utils";
|
|
14
|
+
|
|
15
|
+
const TIMESTAMP_FORMAT = {
|
|
16
|
+
year: "numeric",
|
|
17
|
+
month: "2-digit",
|
|
18
|
+
day: "2-digit",
|
|
19
|
+
hour: "2-digit",
|
|
20
|
+
minute: "2-digit",
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
/** Normalized newlines, control characters removed, tabs expanded. */
|
|
24
|
+
export function safeContent(value: string): string {
|
|
25
|
+
return replaceTabs(sanitizeText(value.replace(/\r\n?/g, "\n")));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** {@link safeContent} with embedded home paths shortened, for labels and metadata. */
|
|
29
|
+
export function safeText(value: string): string {
|
|
30
|
+
return shortenEmbeddedPaths(safeContent(value));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** {@link safeText} collapsed to one row. */
|
|
34
|
+
export function safeLine(value: string): string {
|
|
35
|
+
return safeText(value).replace(/\n/g, " ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Token label of an entry or group: `—` for a grouped entry, `N/A · Interrupted`, else `N token(s)` (unscaled estimate). */
|
|
39
|
+
export function tokenEstimate(item: ContextViewEntry | ContextThinkingGroup): string {
|
|
40
|
+
// A grouped entry shares its group's estimate, which the group heading shows.
|
|
41
|
+
if ("thinkingGroup" in item && item.thinkingGroup) return "—";
|
|
42
|
+
if (item.interrupted) return "N/A · Interrupted";
|
|
43
|
+
return `${item.tokens.toLocaleString("en-US")} ${item.tokens === 1 ? "token" : "tokens"}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `[label] N tokens` as ANSI. */
|
|
47
|
+
export function entryHeaderLine(entry: ContextViewEntry, category: ContextCategoryId): string {
|
|
48
|
+
const color = CONTEXT_PRESENTATION[category].color;
|
|
49
|
+
return (
|
|
50
|
+
theme.fg("dim", "[") +
|
|
51
|
+
theme.fg(color, theme.bold(safeLine(entry.label))) +
|
|
52
|
+
theme.fg("dim", `] ${tokenEstimate(entry)}`)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `[label] N tokens` as spans. */
|
|
57
|
+
export function entryHeaderSpans(entry: ContextViewEntry, category: ContextCategoryId): TspSpan[] {
|
|
58
|
+
const color = CONTEXT_PRESENTATION[category].color;
|
|
59
|
+
return [span("[", "dim"), span(safeLine(entry.label), `${color} strong`), span(`] ${tokenEstimate(entry)}`, "dim")];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Heading for a shared thinking group: label plus its token estimate. */
|
|
63
|
+
export function groupHeaderLine(group: ContextThinkingGroup, category: ContextCategoryId): string {
|
|
64
|
+
const color = CONTEXT_PRESENTATION[category].color;
|
|
65
|
+
return theme.fg(color, theme.bold(safeLine(group.label))) + theme.fg("dim", ` · ${tokenEstimate(group)}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Truncation marker under a preview; the selected preview also advertises the open key. */
|
|
69
|
+
export function previewHint(selected: boolean): string {
|
|
70
|
+
const confirm = selected ? editorKey("tui.select.confirm") : "";
|
|
71
|
+
return (
|
|
72
|
+
theme.fg("dim", " … more") +
|
|
73
|
+
(confirm ? theme.fg("dim", " · ") + theme.fg("accent", `${confirm} View Content`) : "")
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Metadata rows: info and path, call id, timestamp. The full-content view also
|
|
79
|
+
* lists the thinking group, which previews show as a shared heading instead.
|
|
80
|
+
*/
|
|
81
|
+
export function entryMetadata(
|
|
82
|
+
entry: ContextViewEntry,
|
|
83
|
+
options: { hour12: boolean | undefined; withGroup: boolean },
|
|
84
|
+
): string[] {
|
|
85
|
+
const lines: string[] = [];
|
|
86
|
+
const group = entry.thinkingGroup;
|
|
87
|
+
if (group && options.withGroup) lines.push(`${safeLine(group.label)} · ${tokenEstimate(group)}`);
|
|
88
|
+
const info = safeText([entry.info, entry.path ? shortenPath(entry.path) : undefined].filter(Boolean).join(" · "));
|
|
89
|
+
if (info) lines.push(info);
|
|
90
|
+
const callId = entry.callId !== undefined ? safeText(entry.callId) : "";
|
|
91
|
+
if (callId) lines.push(callId);
|
|
92
|
+
if (entry.timestamp !== undefined)
|
|
93
|
+
lines.push(new Date(entry.timestamp).toLocaleString([], { ...TIMESTAMP_FORMAT, hour12: options.hour12 }));
|
|
94
|
+
return lines;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Preview row budget for a list of `height` rows: room for the entry heading, hint, and group heading. */
|
|
98
|
+
export function previewRows(entry: ContextViewEntry, height: number): number {
|
|
99
|
+
const reserved = entry.thinkingGroup ? 3 : 2;
|
|
100
|
+
return Math.max(1, Math.min(PREVIEW_LIMITS.OUTPUT_EXPANDED, height - reserved));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface EntryPreviewOptions {
|
|
104
|
+
/** Wrap width of preview rows. */
|
|
105
|
+
readonly width: number;
|
|
106
|
+
/** Maximum preview rows. */
|
|
107
|
+
readonly rows: number;
|
|
108
|
+
/** Viewport width; bounds the source read to `sourceWidth × rows × 2` characters before sanitizing and wrapping. */
|
|
109
|
+
readonly sourceWidth: number;
|
|
110
|
+
readonly hour12: boolean | undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Dim metadata rows, then the indented body, cut to `rows`; `complete` is false when anything was omitted. */
|
|
114
|
+
export function entryPreview(
|
|
115
|
+
entry: ContextViewEntry,
|
|
116
|
+
options: EntryPreviewOptions,
|
|
117
|
+
): { lines: string[]; complete: boolean } {
|
|
118
|
+
const { width, rows } = options;
|
|
119
|
+
const maxChars = Math.max(1, options.sourceWidth) * rows * 2;
|
|
120
|
+
const metadata = entryMetadata(entry, { hour12: options.hour12, withGroup: false }).join("\n");
|
|
121
|
+
const metadataLength = Math.min(metadata.length, maxChars);
|
|
122
|
+
// Providers often end thinking and tool output with newlines; they render as empty rows.
|
|
123
|
+
const body = entry.text.trimEnd();
|
|
124
|
+
const bodyLength = Math.min(body.length, Math.max(0, maxChars - metadataLength - (metadataLength ? 1 : 0)));
|
|
125
|
+
const parts = [
|
|
126
|
+
{
|
|
127
|
+
value: safeContent(metadata.slice(0, metadataLength)),
|
|
128
|
+
prefix: "",
|
|
129
|
+
style: (line: string) => theme.fg("dim", line),
|
|
130
|
+
},
|
|
131
|
+
{ value: safeContent(body.slice(0, bodyLength)), prefix: " ", style: (line: string) => line },
|
|
132
|
+
];
|
|
133
|
+
const lines: string[] = [];
|
|
134
|
+
for (const { value, prefix, style } of parts) {
|
|
135
|
+
if (!value) continue;
|
|
136
|
+
for (const paragraph of value.split("\n")) {
|
|
137
|
+
const remaining = rows - lines.length;
|
|
138
|
+
if (remaining === 0) return { lines, complete: false };
|
|
139
|
+
const wrapped = paragraph ? wrapTextWithAnsi(style(paragraph), Math.max(1, width - prefix.length)) : [""];
|
|
140
|
+
lines.push(...wrapped.slice(0, remaining).map(line => `${prefix}${line}`));
|
|
141
|
+
if (wrapped.length > remaining) return { lines, complete: false };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { lines, complete: metadataLength === metadata.length && bodyLength === body.length };
|
|
145
|
+
}
|