@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +36 -32
  2. package/dist/types/chrome/context-map.d.ts +15 -0
  3. package/dist/types/chrome/context-presentation.d.ts +48 -0
  4. package/dist/types/chrome/context-view-model.d.ts +77 -0
  5. package/dist/types/overlays/advisor-config.d.ts +6 -0
  6. package/dist/types/overlays/ask-dialog.d.ts +12 -3
  7. package/dist/types/overlays/context-viewer-format.d.ts +42 -0
  8. package/dist/types/overlays/context-viewer-overview.d.ts +70 -0
  9. package/dist/types/overlays/context-viewer.d.ts +25 -0
  10. package/dist/types/overlays/hook-input.d.ts +1 -0
  11. package/dist/types/overlays/pause-screen.d.ts +13 -3
  12. package/dist/types/overlays/running-subagent-badge.d.ts +7 -1
  13. package/dist/types/status-line/component.d.ts +2 -2
  14. package/dist/types/status-line/context-usage.d.ts +15 -30
  15. package/dist/types/theme/symbols.d.ts +1 -1
  16. package/dist/types/tools/ask.d.ts +4 -0
  17. package/dist/types/tools/fetch.d.ts +1 -0
  18. package/dist/types/tools/github.d.ts +3 -0
  19. package/dist/types/tools/read.d.ts +3 -0
  20. package/dist/types/tools/wait.d.ts +6 -0
  21. package/dist/types/tui.d.ts +2 -0
  22. package/package.json +9 -9
  23. package/src/chrome/context-map.ts +62 -0
  24. package/src/chrome/context-presentation.ts +26 -0
  25. package/src/chrome/context-view-model.ts +73 -0
  26. package/src/components/editor.ts +23 -0
  27. package/src/components/markdown.ts +14 -2
  28. package/src/overlays/advisor-config.ts +9 -2
  29. package/src/overlays/ask-dialog.ts +122 -35
  30. package/src/overlays/context-viewer-format.ts +145 -0
  31. package/src/overlays/context-viewer-overview.ts +381 -0
  32. package/src/overlays/context-viewer.ts +728 -0
  33. package/src/overlays/hook-input.ts +2 -0
  34. package/src/overlays/pause-screen.ts +166 -37
  35. package/src/overlays/running-subagent-badge.ts +12 -1
  36. package/src/render/output-pane.ts +3 -3
  37. package/src/status-line/component.ts +6 -4
  38. package/src/status-line/context-usage.ts +56 -580
  39. package/src/theme/symbols.ts +16 -0
  40. package/src/tools/ask.ts +4 -0
  41. package/src/tools/bash.ts +5 -23
  42. package/src/tools/fetch.ts +1 -0
  43. package/src/tools/github.ts +23 -3
  44. package/src/tools/read.ts +3 -1
  45. package/src/tools/wait.ts +6 -0
  46. 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
- function noteForSubmittedAnswer(question: ExtensionAskDialogQuestion, state: QuestionState): string | undefined {
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
- const inputGuard = this.options.inputGuard;
700
- if (inputGuard?.isBlocked()) {
701
- inputGuard.handleInput(keyData);
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
- inputGuard?.syncPresentation?.();
774
- const blocked = inputGuard?.isBlocked() ?? false;
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.hint), "muted")], { truncate: "end", role: "omp.ask.summary" }),
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", { keys: "n", title: "Add a note to the highlighted answer n" });
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 exactly what Enter, `n` and Esc run on the highlighted row; Hide/Show
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 || this.options.inputGuard?.isBlocked()) return;
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 || this.options.inputGuard?.isBlocked()) return;
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
- /** Enter: submit on the Review tab, else commit the highlighted row. */
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 highlighted row (questions only). */
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
- const rowItem = active ? this.#questionRows(active.question)[active.state.cursorIndex] : undefined;
1085
- if (!active || !rowItem) return;
1086
- void this.#promptForNote(active.question, active.state, rowItem);
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 = inputGuard?.isBlocked() ?? false;
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.hint);
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?.isBlocked()) return `${inputGuard.hint}${this.#expandHint()} · ${cancel}`;
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
- if (rowItem.kind === "option" || rowItem.kind === "other") {
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
- #advanceAfterQuestion(): void {
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
+ }