@oh-my-pi/pi-tui 18.13.1 → 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 (42) hide show
  1. package/CHANGELOG.md +20 -1
  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 +9 -2
  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/running-subagent-badge.d.ts +7 -1
  12. package/dist/types/status-line/context-usage.d.ts +15 -30
  13. package/dist/types/theme/symbols.d.ts +1 -1
  14. package/dist/types/tools/ask.d.ts +2 -0
  15. package/dist/types/tools/fetch.d.ts +1 -0
  16. package/dist/types/tools/github.d.ts +3 -0
  17. package/dist/types/tools/read.d.ts +3 -0
  18. package/dist/types/tools/wait.d.ts +6 -0
  19. package/dist/types/tui.d.ts +2 -0
  20. package/package.json +9 -9
  21. package/src/chrome/context-map.ts +62 -0
  22. package/src/chrome/context-presentation.ts +26 -0
  23. package/src/chrome/context-view-model.ts +73 -0
  24. package/src/components/editor.ts +23 -0
  25. package/src/components/markdown.ts +14 -2
  26. package/src/overlays/advisor-config.ts +9 -2
  27. package/src/overlays/ask-dialog.ts +55 -15
  28. package/src/overlays/context-viewer-format.ts +145 -0
  29. package/src/overlays/context-viewer-overview.ts +381 -0
  30. package/src/overlays/context-viewer.ts +728 -0
  31. package/src/overlays/hook-input.ts +2 -0
  32. package/src/overlays/running-subagent-badge.ts +12 -1
  33. package/src/render/output-pane.ts +3 -3
  34. package/src/status-line/context-usage.ts +56 -580
  35. package/src/theme/symbols.ts +16 -0
  36. package/src/tools/ask.ts +2 -0
  37. package/src/tools/bash.ts +5 -23
  38. package/src/tools/fetch.ts +1 -0
  39. package/src/tools/github.ts +23 -3
  40. package/src/tools/read.ts +3 -1
  41. package/src/tools/wait.ts +6 -0
  42. package/src/tui.ts +23 -3
@@ -0,0 +1,62 @@
1
+ /** Pure occupancy calculations for contiguous token segments, independent of themes and renderers. */
2
+ export interface ContextMapSegment<T> {
3
+ readonly id: T;
4
+ readonly tokens: number;
5
+ }
6
+
7
+ export interface ContextMapCell<T> {
8
+ readonly id: T;
9
+ readonly full: boolean;
10
+ }
11
+
12
+ /**
13
+ * Lays `segments` out in order over a `columns` × `rows` grid representing `windowTokens`; each cell takes the
14
+ * segment covering most of it and is `full` at 70% coverage or more. Returns `[]` when the window or grid
15
+ * size is unusable, and omits cells no segment touches.
16
+ */
17
+ export function planContextMap<T>(
18
+ segments: readonly ContextMapSegment<T>[],
19
+ windowTokens: number,
20
+ columns: number,
21
+ rows: number,
22
+ ): ContextMapCell<T>[] {
23
+ if (
24
+ !Number.isFinite(windowTokens) ||
25
+ windowTokens <= 0 ||
26
+ !Number.isInteger(columns) ||
27
+ !Number.isInteger(rows) ||
28
+ columns <= 0 ||
29
+ rows <= 0
30
+ )
31
+ return [];
32
+ const cellCount = columns * rows;
33
+ const tokensPerCell = windowTokens / cellCount;
34
+ const ranges: { id: T; start: number; end: number }[] = [];
35
+ let cursor = 0;
36
+ for (const segment of segments) {
37
+ if (!Number.isFinite(segment.tokens) || segment.tokens <= 0) continue;
38
+ const end = Math.min(windowTokens, cursor + segment.tokens);
39
+ if (end > cursor) ranges.push({ id: segment.id, start: cursor, end });
40
+ cursor = end;
41
+ if (cursor >= windowTokens) break;
42
+ }
43
+ const cells: ContextMapCell<T>[] = [];
44
+ let first = 0;
45
+ for (let index = 0; index < cellCount; index++) {
46
+ const start = index * tokensPerCell;
47
+ const end = (index + 1) * tokensPerCell;
48
+ while (first < ranges.length && ranges[first]!.end <= start) first++;
49
+ let winner: T | undefined;
50
+ let overlap = 0;
51
+ for (let part = first; part < ranges.length && ranges[part]!.start < end; part++) {
52
+ const range = ranges[part]!;
53
+ const covered = Math.min(end, range.end) - Math.max(start, range.start);
54
+ if (covered > overlap) {
55
+ overlap = covered;
56
+ winner = range.id;
57
+ }
58
+ }
59
+ if (winner !== undefined) cells.push({ id: winner, full: overlap / tokensPerCell >= 0.7 });
60
+ }
61
+ return cells;
62
+ }
@@ -0,0 +1,26 @@
1
+ import type { ThemeColor } from "../theme";
2
+ import type { SymbolKey } from "../theme/symbols";
3
+ import type { ContextCategoryId } from "./context-view-model";
4
+
5
+ /** Shared category styles for maps and interactive legends; resolve glyphs at render time. */
6
+ const SYSTEM = { color: "accent", symbol: "context.content" } as const;
7
+ const DETAILS = { color: "customMessageLabel", symbol: "context.content" } as const;
8
+ const TOOLS = { color: "warning", symbol: "context.content" } as const;
9
+ const SKILLS = { color: "success", symbol: "context.content" } as const;
10
+ const MESSAGES = { color: "userMessageText", symbol: "context.messages" } as const;
11
+ const OUTPUT = { color: "muted", symbol: "context.messages" } as const;
12
+ export const CONTEXT_PRESENTATION = {
13
+ "system-prompt": SYSTEM,
14
+ skills: SKILLS,
15
+ "built-in-tools": TOOLS,
16
+ "custom-tools": DETAILS,
17
+ // Shares the System Prompt color; the message glyph tells them apart in the map.
18
+ "user-messages": { color: "accent", symbol: "context.messages" },
19
+ "assistant-messages": MESSAGES,
20
+ "assistant-thinking": DETAILS,
21
+ "tool-calls": TOOLS,
22
+ "tool-output": OUTPUT,
23
+ buffer: { color: "warning", symbol: "context.buffer" },
24
+ free: { color: "dim", symbol: "context.free" },
25
+ } as const satisfies Record<ContextPresentationId, { color: ThemeColor; symbol: SymbolKey }>;
26
+ export type ContextPresentationId = ContextCategoryId | "buffer" | "free";
@@ -0,0 +1,73 @@
1
+ /** Context snapshot shapes shared by the session snapshot builder and the context viewer. */
2
+ import type { ContextBreakdown } from "../status-line/context-usage";
3
+
4
+ interface ContextCategoryDescriptor {
5
+ readonly id: string;
6
+ readonly label: string;
7
+ /** Overview row label when the Overview weight differs from the category's content. */
8
+ readonly overviewLabel?: string;
9
+ /** Listed with its weight but never opened. */
10
+ readonly openable?: false;
11
+ }
12
+
13
+ /** Context categories in display order. */
14
+ export const CONTEXT_CATEGORIES = [
15
+ // The Overview weight excludes the Skills listing; content views keep complete system blocks.
16
+ { id: "system-prompt", label: "System Prompt", overviewLabel: "System Prompt (excluding Skills)" },
17
+ // An estimate of the prompt's skill listing, without entries of its own.
18
+ { id: "skills", label: "Skills", openable: false },
19
+ { id: "built-in-tools", label: "Built-in Tools" },
20
+ { id: "custom-tools", label: "Custom Tools" },
21
+ { id: "user-messages", label: "User / Developer Messages" },
22
+ { id: "assistant-messages", label: "Assistant Messages" },
23
+ { id: "assistant-thinking", label: "Assistant Thinking" },
24
+ { id: "tool-calls", label: "Tool Calls" },
25
+ { id: "tool-output", label: "Tool Output" },
26
+ ] as const satisfies readonly ContextCategoryDescriptor[];
27
+
28
+ export type ContextCategoryId = (typeof CONTEXT_CATEGORIES)[number]["id"];
29
+
30
+ const DESCRIPTORS_BY_ID = new Map<ContextCategoryId, ContextCategoryDescriptor>(
31
+ CONTEXT_CATEGORIES.map(descriptor => [descriptor.id, descriptor]),
32
+ );
33
+
34
+ /** Descriptor of a category id: its label, optional Overview label, and whether it can be opened. */
35
+ export function contextCategoryDescriptor(id: ContextCategoryId): ContextCategoryDescriptor {
36
+ return DESCRIPTORS_BY_ID.get(id)!;
37
+ }
38
+
39
+ export interface ContextThinkingGroup {
40
+ readonly id: string;
41
+ readonly label: string;
42
+ readonly tokens: number;
43
+ readonly interrupted?: boolean;
44
+ }
45
+
46
+ export interface ContextViewEntry {
47
+ readonly id: string;
48
+ readonly label: string;
49
+ readonly text: string;
50
+ /** Aggregation weight for the unscaled replay estimate; shared blocks contribute through their group. */
51
+ readonly tokens: number;
52
+ readonly interrupted?: boolean;
53
+ readonly thinkingGroup?: ContextThinkingGroup;
54
+ readonly path?: string;
55
+ readonly info?: string;
56
+ readonly timestamp?: number;
57
+ readonly callId?: string;
58
+ }
59
+
60
+ export interface ContextViewCategory {
61
+ id: ContextCategoryId;
62
+ key?: string;
63
+ label: string;
64
+ /** Overview attribution weight, not reported usage; may differ from the entry sum (e.g. Skills subtraction). */
65
+ tokens: number;
66
+ entries: ContextViewEntry[];
67
+ children?: ContextViewCategory[];
68
+ }
69
+
70
+ export interface ContextViewData {
71
+ readonly breakdown: ContextBreakdown;
72
+ readonly categories: readonly ContextViewCategory[];
73
+ }
@@ -2431,10 +2431,12 @@ export class Editor implements Component, Focusable {
2431
2431
  // Word left
2432
2432
  this.#resetKillSequence();
2433
2433
  this.#moveWordBackwards();
2434
+ this.#dismissAutocomplete();
2434
2435
  } else if (kb.matchesCanonical(canonical, "tui.editor.cursorWordRight")) {
2435
2436
  // Word right
2436
2437
  this.#resetKillSequence();
2437
2438
  this.#moveWordForwards();
2439
+ this.#dismissAutocomplete();
2438
2440
  }
2439
2441
  // Arrow keys
2440
2442
  else if (kb.matchesCanonical(canonical, "tui.editor.cursorUp")) {
@@ -3900,6 +3902,9 @@ export class Editor implements Component, Focusable {
3900
3902
  const dropSpace = this.#hasProvisionalSpace();
3901
3903
  this.#historyIndex = -1; // Exit history browsing mode
3902
3904
  this.#resetKillSequence();
3905
+ // The cursor leaves the trigger token's line, so any open popup is stale:
3906
+ // keeping it would float on the new line and Enter would submit past it.
3907
+ this.#dismissAutocomplete();
3903
3908
  this.#recordUndoState();
3904
3909
 
3905
3910
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
@@ -4201,23 +4206,27 @@ export class Editor implements Component, Focusable {
4201
4206
 
4202
4207
  #moveToLineStart(): void {
4203
4208
  this.#resetKillSequence();
4209
+ this.#dismissAutocomplete();
4204
4210
  this.#setCursorCol(0);
4205
4211
  }
4206
4212
 
4207
4213
  #moveToLineEnd(): void {
4208
4214
  this.#resetKillSequence();
4215
+ this.#dismissAutocomplete();
4209
4216
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
4210
4217
  this.#setCursorCol(currentLine.length);
4211
4218
  }
4212
4219
 
4213
4220
  #moveToMessageStart(): void {
4214
4221
  this.#resetKillSequence();
4222
+ this.#dismissAutocomplete();
4215
4223
  this.#state.cursorLine = 0;
4216
4224
  this.#setCursorCol(0);
4217
4225
  }
4218
4226
 
4219
4227
  #moveToMessageEnd(): void {
4220
4228
  this.#resetKillSequence();
4229
+ this.#dismissAutocomplete();
4221
4230
  this.#state.cursorLine = this.#state.lines.length - 1;
4222
4231
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
4223
4232
  this.#setCursorCol(currentLine.length);
@@ -4697,6 +4706,7 @@ export class Editor implements Component, Focusable {
4697
4706
 
4698
4707
  #moveCursor(deltaLine: number, deltaCol: number): void {
4699
4708
  this.#resetKillSequence();
4709
+ this.#dismissAutocomplete();
4700
4710
  const visualLines = this.#buildVisualLineMap(this.#caretRowWidth);
4701
4711
  const currentVisualLine = this.#findCurrentVisualLine(visualLines);
4702
4712
 
@@ -4749,6 +4759,7 @@ export class Editor implements Component, Focusable {
4749
4759
 
4750
4760
  #pageScroll(direction: -1 | 1): void {
4751
4761
  this.#resetKillSequence();
4762
+ this.#dismissAutocomplete();
4752
4763
  const visualLines = this.#buildVisualLineMap(this.#caretRowWidth);
4753
4764
  const currentVisualLine = this.#findCurrentVisualLine(visualLines);
4754
4765
  const step = this.#getPageScrollStep(visualLines.length);
@@ -4779,6 +4790,7 @@ export class Editor implements Component, Focusable {
4779
4790
  */
4780
4791
  #jumpToChar(char: string, direction: "forward" | "backward"): void {
4781
4792
  this.#resetKillSequence();
4793
+ this.#dismissAutocomplete();
4782
4794
  const isForward = direction === "forward";
4783
4795
  const lines = this.#state.lines;
4784
4796
 
@@ -5195,6 +5207,17 @@ export class Editor implements Component, Focusable {
5195
5207
  }
5196
5208
  }
5197
5209
 
5210
+ /**
5211
+ * Close the popup when the cursor leaves the token it was opened for (newline,
5212
+ * pure cursor motion). Its prefix no longer matches the text before the cursor,
5213
+ * so it could neither accept nor refresh and would linger stale.
5214
+ */
5215
+ #dismissAutocomplete(): void {
5216
+ if (this.#autocompleteState === null) return;
5217
+ this.#cancelAutocomplete();
5218
+ this.onAutocompleteUpdate?.();
5219
+ }
5220
+
5198
5221
  /**
5199
5222
  * Whether an autocomplete popup is on screen. An `@` list narrowed to no match
5200
5223
  * while its refresh is pending stays open internally but is hidden, so it does
@@ -4346,7 +4346,13 @@ export class Markdown implements Component {
4346
4346
 
4347
4347
  // Process item tokens; nested-list lines arrive structurally tagged and
4348
4348
  // already carry their own full indent.
4349
- const itemLines = this.#renderListItem(item.tokens || [], depth, width, styleContext);
4349
+ const itemLines = this.#renderListItem(
4350
+ item.tokens || [],
4351
+ depth,
4352
+ width,
4353
+ Math.max(1, width - visibleWidth(continuationIndent)),
4354
+ styleContext,
4355
+ );
4350
4356
 
4351
4357
  if (itemLines.length > 0) {
4352
4358
  const firstLine = itemLines[0]!;
@@ -4398,11 +4404,13 @@ export class Markdown implements Component {
4398
4404
  tokens: Token[],
4399
4405
  parentDepth: number,
4400
4406
  width: number,
4407
+ contentWidth: number,
4401
4408
  styleContext?: InlineStyleContext,
4402
4409
  ): RenderedListItemLine[] {
4403
4410
  const lines: RenderedListItemLine[] = [];
4404
4411
 
4405
- for (const token of tokens) {
4412
+ for (let i = 0; i < tokens.length; i++) {
4413
+ const token = tokens[i]!;
4406
4414
  if (token.type === "list") {
4407
4415
  // Nested list - render with one additional indent level
4408
4416
  // These lines carry their own indent, so tag them for pass-through
@@ -4442,6 +4450,10 @@ export class Markdown implements Component {
4442
4450
  lines.push({ ...bodyLine, nested: false });
4443
4451
  }
4444
4452
  lines.push({ text: this.#theme.codeBlockBorder("```"), nested: false });
4453
+ } else if (token.type === "table") {
4454
+ for (const row of this.#renderTable(token as TableToken, contentWidth, tokens[i + 1]?.type, styleContext)) {
4455
+ lines.push({ text: row, nested: false });
4456
+ }
4445
4457
  } else if (isMathToken(token)) {
4446
4458
  // Display math block inside a list item: stack fractions / matrix rows.
4447
4459
  const apply = styleContext?.applyText ?? ((t: string) => this.#applyDefaultStyle(t));
@@ -64,10 +64,11 @@ import { getThinkingLevelMetadata, parseThinkingLevel } from "../thinking";
64
64
 
65
65
  export const ADVISOR_REVIEW_MODES = ["turn", "agent-end"] as const;
66
66
  export const ADVISOR_SYNC_BACKLOG_MODES = ["off", "1", "3", "5", "strict"] as const;
67
+ export const ADVISOR_SESSION_SCOPES = ["main", "subagent", "all"] as const;
67
68
 
68
69
  export type AdvisorReviewMode = (typeof ADVISOR_REVIEW_MODES)[number];
69
70
  export type AdvisorSyncBacklog = (typeof ADVISOR_SYNC_BACKLOG_MODES)[number];
70
-
71
+ export type AdvisorSessionScope = (typeof ADVISOR_SESSION_SCOPES)[number];
71
72
  /** One advisor declared in `WATCHDOG.yml`; its instructions specialize the shared baseline. */
72
73
  export interface AdvisorConfig {
73
74
  name: string;
@@ -83,6 +84,10 @@ export interface AdvisorConfig {
83
84
  reviewMode?: AdvisorReviewMode;
84
85
  reviewInterval?: number;
85
86
  syncBacklog?: AdvisorSyncBacklog;
87
+ /** Which session kinds this advisor attaches to (`main`, `subagent`, or `all`; defaults to `all`). */
88
+ scope?: AdvisorSessionScope;
89
+ /** Whether primary assistant thinking blocks are included in updates sent to this advisor (defaults to `true`). */
90
+ includeReasoning?: boolean;
86
91
  }
87
92
 
88
93
  /** Which level a `WATCHDOG.yml` lives at: the project root or the user agent dir. */
@@ -1202,7 +1207,9 @@ export class AdvisorConfigOverlayComponent implements Component {
1202
1207
  advisor.syncBacklog === undefined &&
1203
1208
  !advisor.instructions?.trim() &&
1204
1209
  advisor.enabled !== false &&
1205
- advisor.maxNotesPerUpdate === undefined
1210
+ advisor.maxNotesPerUpdate === undefined &&
1211
+ advisor.scope === undefined &&
1212
+ advisor.includeReasoning === undefined
1206
1213
  );
1207
1214
  }
1208
1215
 
@@ -147,14 +147,21 @@ interface AskDialogCallbacks {
147
147
  ): Promise<AskDialogPromptValue | undefined>;
148
148
  }
149
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
+ */
150
156
  interface AskDialogInputGuard {
151
157
  isBlocked(): boolean;
152
158
  handleInput(keyData: string): void;
159
+ /** Shown after the release keys while the guard holds input. */
153
160
  hint: string;
154
161
  /** Mirror the guard's blocked state onto the proxied draft surface each
155
162
  * render, so a draft that owns input shows a visible insertion cursor even
156
- * though this dialog holds TUI focus. */
157
- syncPresentation?(): void;
163
+ * though this dialog holds TUI focus, and drops it once the dialog takes over. */
164
+ syncPresentation?(blocked: boolean): void;
158
165
  }
159
166
 
160
167
  interface AskDialogOptions {
@@ -587,6 +594,8 @@ export class AskDialogComponent implements Component {
587
594
  #native: NativeNode | undefined;
588
595
  /** Input-guard blocked state {@link #native} was described with (the guard flips outside this dialog). */
589
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;
590
599
  /** When the countdown runs out (epoch ms), for the native ring. */
591
600
  #countdownDeadline = 0;
592
601
  /** Once-a-second re-describe while the native countdown ring is shown. */
@@ -709,9 +718,15 @@ export class AskDialogComponent implements Component {
709
718
  this.toggleQuestionExpansion();
710
719
  return;
711
720
  }
712
- const inputGuard = this.options.inputGuard;
713
- if (inputGuard?.isBlocked()) {
714
- 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);
715
730
  this.#requestRender();
716
731
  return;
717
732
  }
@@ -736,7 +751,7 @@ export class AskDialogComponent implements Component {
736
751
  // Keep the proxied draft's cursor visible while it owns input (the editor
737
752
  // renders as the next sibling in the same container, so this lands in the
738
753
  // same frame).
739
- this.options.inputGuard?.syncPresentation?.();
754
+ this.options.inputGuard?.syncPresentation?.(this.#guardBlocks());
740
755
  if (this.#hidden) return this.#renderHidden(width);
741
756
  const innerWidth = Math.max(1, width - 4);
742
757
  this.#contentWidth = innerWidth;
@@ -781,10 +796,9 @@ export class AskDialogComponent implements Component {
781
796
  * countdown, Hide, then Note, Skip and the primary Submit/Next).
782
797
  */
783
798
  describe(cx: DescribeContext): NativeNode {
784
- const inputGuard = this.options.inputGuard;
785
799
  // Same draft-cursor mirroring as render(): the proxied draft describes itself next to this dialog.
786
- inputGuard?.syncPresentation?.();
787
- const blocked = inputGuard?.isBlocked() ?? false;
800
+ const blocked = this.#guardBlocks();
801
+ this.options.inputGuard?.syncPresentation?.(blocked);
788
802
  const ring = this.#countdown !== undefined && cx.supports("meter");
789
803
  if (ring) this.#startRingTick();
790
804
  if (this.#native && this.#nativeBlocked === blocked) return this.#native;
@@ -998,7 +1012,7 @@ export class AskDialogComponent implements Component {
998
1012
  if (blocked && inputGuard) {
999
1013
  return actionBar([
1000
1014
  ...lead,
1001
- 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" }),
1002
1016
  skip,
1003
1017
  ]);
1004
1018
  }
@@ -1045,13 +1059,16 @@ export class AskDialogComponent implements Component {
1045
1059
  this.#setHidden(event.act === "hide");
1046
1060
  return;
1047
1061
  }
1048
- 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();
1049
1065
  if (event.act === "submit") this.#pressEnter();
1050
1066
  else if (event.act === "note") this.#pressNote();
1051
1067
  return;
1052
1068
  }
1053
1069
  if (event.type !== "select" && event.type !== "activate") return;
1054
- if (this.#hidden || this.options.inputGuard?.isBlocked()) return;
1070
+ if (this.#hidden) return;
1071
+ this.#releaseGuardForPointer();
1055
1072
  const key = leafKey(event.key);
1056
1073
  if (key === "tabs") {
1057
1074
  if (!this.#hasSubmitTab()) return;
@@ -1212,16 +1229,39 @@ export class AskDialogComponent implements Component {
1212
1229
  this.#requestRender();
1213
1230
  }
1214
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
+
1215
1255
  /** Hidden: one line in the composer's place — title (with countdown), the question clipped to fit, and how to bring it back. */
1216
1256
  #renderHidden(width: number): readonly string[] {
1217
1257
  const hideKey = editorKey("app.ask.hide");
1218
1258
  const enter = formatKeyHint("enter");
1219
1259
  const inputGuard = this.options.inputGuard;
1220
- const blocked = inputGuard?.isBlocked() ?? false;
1260
+ const blocked = this.#guardBlocks();
1221
1261
  // While a draft owns input, Enter goes to the draft, so only the hide key restores.
1222
1262
  const show = blocked ? hideKey : hideKey ? `${hideKey}/${enter}` : enter;
1223
1263
  const hints: string[] = [];
1224
- if (blocked && inputGuard) hints.push(inputGuard.hint);
1264
+ if (blocked && inputGuard) hints.push(this.#guardHint(inputGuard));
1225
1265
  if (show) hints.push(`${show} show`);
1226
1266
  hints.push(`${editorKey("tui.select.cancel")} cancel`);
1227
1267
  const hint = theme.fg("dim", ` · ${hints.join(" · ")}`);
@@ -1305,7 +1345,7 @@ export class AskDialogComponent implements Component {
1305
1345
  const enter = formatKeyHint("enter");
1306
1346
  const upDown = editorKeys("tui.select.up", "tui.select.down");
1307
1347
  const inputGuard = this.options.inputGuard;
1308
- if (inputGuard?.isBlocked()) return `${inputGuard.hint}${this.#expandHint()} · ${cancel}`;
1348
+ if (inputGuard && this.#guardBlocks()) return `${this.#guardHint(inputGuard)}${this.#expandHint()} · ${cancel}`;
1309
1349
  if (this.#isSubmitTab()) {
1310
1350
  const scroll = indicator ? ` ${indicator} scroll ·` : "";
1311
1351
  return `${enter} submit · ${upDown} scroll ·${scroll} ${cancel}`;
@@ -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
+ }