@oh-my-pi/pi-tui 18.4.4 → 18.4.6

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 (63) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/types/apps/git/sidebar.d.ts +2 -2
  3. package/dist/types/chat/recap-notice.d.ts +13 -0
  4. package/dist/types/components/editor.d.ts +15 -3
  5. package/dist/types/components/input.d.ts +11 -1
  6. package/dist/types/native/blobs.d.ts +1 -1
  7. package/dist/types/native/node.d.ts +63 -1
  8. package/dist/types/overlays/agent-hub-projection.d.ts +9 -0
  9. package/dist/types/overlays/ask-dialog.d.ts +9 -5
  10. package/dist/types/overlays/copy-selector.d.ts +0 -23
  11. package/dist/types/overlays/error-banner.d.ts +5 -2
  12. package/dist/types/overlays/hook-editor.d.ts +12 -0
  13. package/dist/types/overlays/jobs-panel.d.ts +25 -1
  14. package/dist/types/overlays/model-hub.d.ts +2 -0
  15. package/dist/types/overlays/rewind-selector.d.ts +17 -4
  16. package/dist/types/prompt/custom-editor.d.ts +16 -8
  17. package/dist/types/prompt/welcome.d.ts +3 -3
  18. package/dist/types/render/render-utils.d.ts +5 -0
  19. package/dist/types/status-line/component.d.ts +2 -0
  20. package/dist/types/status-line/host.d.ts +2 -0
  21. package/dist/types/status-line/metrics.d.ts +4 -0
  22. package/dist/types/status-line/types.d.ts +13 -2
  23. package/dist/types/tools/edit.d.ts +2 -0
  24. package/dist/types/tools/task.d.ts +7 -0
  25. package/dist/types/tui.d.ts +8 -1
  26. package/package.json +9 -9
  27. package/src/apps/git/sidebar.ts +15 -2
  28. package/src/chat/assistant-message.ts +2 -2
  29. package/src/chat/recap-notice.ts +30 -0
  30. package/src/chat/tool-execution.ts +19 -3
  31. package/src/chat/user-message.ts +2 -1
  32. package/src/components/editor.ts +115 -16
  33. package/src/components/input.ts +57 -1
  34. package/src/native/backend.ts +28 -10
  35. package/src/native/blobs.ts +1 -1
  36. package/src/native/encode.ts +1 -0
  37. package/src/native/node.ts +108 -1
  38. package/src/native/reconcile.ts +5 -4
  39. package/src/overlays/agent-hub-projection.ts +25 -0
  40. package/src/overlays/agent-hub.ts +4 -15
  41. package/src/overlays/ask-dialog.ts +20 -32
  42. package/src/overlays/copy-selector.ts +6 -6
  43. package/src/overlays/error-banner.ts +14 -3
  44. package/src/overlays/hook-editor.ts +59 -6
  45. package/src/overlays/jobs-panel.ts +132 -49
  46. package/src/overlays/model-browser.ts +56 -12
  47. package/src/overlays/model-hub.ts +82 -29
  48. package/src/overlays/rewind-selector.ts +235 -332
  49. package/src/overlays/session-selector.ts +19 -20
  50. package/src/prompt/custom-editor.ts +119 -30
  51. package/src/prompt/welcome.ts +4 -33
  52. package/src/render/render-utils.ts +51 -3
  53. package/src/status-line/component.ts +61 -30
  54. package/src/status-line/host.ts +2 -0
  55. package/src/status-line/metrics.ts +36 -5
  56. package/src/status-line/segments.ts +9 -1
  57. package/src/status-line/types.ts +13 -2
  58. package/src/terminal.ts +27 -6
  59. package/src/tools/edit.ts +23 -0
  60. package/src/tools/eval.ts +43 -11
  61. package/src/tools/grouped-file-output.ts +6 -0
  62. package/src/tools/task.ts +29 -4
  63. package/src/tui.ts +9 -1
@@ -28,6 +28,11 @@ export type NativeNode = {
28
28
  * or reordered so their ids and terminal-side view state survive.
29
29
  */
30
30
  readonly key?: string;
31
+ /**
32
+ * Scroll the node into view, placed like the `reveal` op, when it is
33
+ * added. Key a node by what it points at to reveal it again on a move.
34
+ */
35
+ readonly reveal?: "start" | "end" | "nearest";
31
36
  };
32
37
  }[TspKind];
33
38
 
@@ -60,6 +65,15 @@ export interface NativeSurface {
60
65
  readonly dock: readonly NativeChild[];
61
66
  }
62
67
 
68
+ /**
69
+ * The screen surface a fullscreen overlay fills as a page of its own (see
70
+ * `Component.describeScreen`): its regions plus the surface `role`, which the
71
+ * terminal exposes to styling.
72
+ */
73
+ export interface NativeScreen extends NativeSurface {
74
+ readonly role: string;
75
+ }
76
+
63
77
  /**
64
78
  * A user action on a node a component described, routed back to that
65
79
  * component. `key` is the node's key path inside the component (`""` for the
@@ -83,7 +97,100 @@ export type NativeUiEvent =
83
97
  readonly key: string;
84
98
  readonly item: string;
85
99
  readonly value: boolean | number | string | readonly string[] | null;
86
- };
100
+ }
101
+ /** An edit over the terminal's own selection in an `editor`/`input` node (see {@link NativeTextEdit}). */
102
+ | ({ readonly type: "edit"; readonly key: string } & NativeTextEdit);
103
+
104
+ /**
105
+ * A primitive edit the terminal made over its own text selection (cut,
106
+ * typing, Backspace/Delete or paste over it, or collapsing it). Offsets are
107
+ * UTF-16 code units into the text last described (lines joined with `"\n"`),
108
+ * the coordinates of the node's `cursor`: replace `[from, to)` with `text`,
109
+ * then put the caret at `cursor` in the resulting text. `from == to` with an
110
+ * empty `text` is a pure caret move. `len` is the length of the text the
111
+ * terminal saw: when the current text differs (keys in flight changed it) the
112
+ * edit is stale and ignored.
113
+ */
114
+ export interface NativeTextEdit {
115
+ readonly from: number;
116
+ readonly to: number;
117
+ readonly text: string;
118
+ readonly cursor: number;
119
+ readonly len: number;
120
+ }
121
+
122
+ /** A {@link NativeTextEdit} checked against the current text and clamped, ready to apply. */
123
+ export interface ResolvedTextEdit {
124
+ /** Replaced range in the current text, widened by the caller's `widen`. */
125
+ readonly from: number;
126
+ readonly to: number;
127
+ /** The cleaned replacement. */
128
+ readonly insert: string;
129
+ /** The whole text after the edit. */
130
+ readonly text: string;
131
+ /** Caret in {@link text}. */
132
+ readonly cursor: number;
133
+ /** Whether the text changes (false: a pure caret move). */
134
+ readonly changed: boolean;
135
+ }
136
+
137
+ /** `value` as an integer offset in `[0, max]` (anything non-finite is 0). */
138
+ export function clampTextOffset(value: number, max: number): number {
139
+ return Number.isFinite(value) ? Math.min(Math.max(Math.trunc(value), 0), max) : 0;
140
+ }
141
+
142
+ /** Whether `offset` falls between the halves of a surrogate pair in `text`. */
143
+ function splitsSurrogatePair(text: string, offset: number): boolean {
144
+ if (offset <= 0 || offset >= text.length) return false;
145
+ const high = text.charCodeAt(offset - 1);
146
+ const low = text.charCodeAt(offset);
147
+ return high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff;
148
+ }
149
+
150
+ /**
151
+ * Check `edit` against `current` and resolve it: null when stale (`len`
152
+ * differs), else the range clamped (reversed ends swapped, never splitting a
153
+ * surrogate pair), widened by `widen` when non-empty, the replacement passed
154
+ * through `clean`, and the caret mapped from the text the terminal expects
155
+ * onto the text the edit actually produces.
156
+ */
157
+ export function resolveTextEdit(
158
+ current: string,
159
+ edit: NativeTextEdit,
160
+ clean: (text: string) => string,
161
+ widen?: (from: number, to: number) => { from: number; to: number },
162
+ ): ResolvedTextEdit | null {
163
+ if (edit.len !== current.length) return null;
164
+ const size = current.length;
165
+ let from = clampTextOffset(edit.from, size);
166
+ let to = clampTextOffset(edit.to, size);
167
+ if (to < from) [from, to] = [to, from];
168
+ if (splitsSurrogatePair(current, from)) from--;
169
+ if (splitsSurrogatePair(current, to)) to++;
170
+ const raw = typeof edit.text === "string" ? edit.text : "";
171
+ const widened = widen && from < to ? widen(from, to) : { from, to };
172
+ const insert = raw ? clean(raw) : "";
173
+ const text = current.slice(0, widened.from) + insert + current.slice(widened.to);
174
+ // Where the terminal's caret sits in its own result (`[from, to)` replaced
175
+ // by `raw`), carried over to ours: widening swallows neighbours, cleaning
176
+ // may change the replacement's length.
177
+ const expected = clampTextOffset(edit.cursor, size - (to - from) + raw.length);
178
+ let cursor: number;
179
+ if (expected <= widened.from) cursor = expected;
180
+ else if (expected <= from) cursor = widened.from;
181
+ else if (expected < from + raw.length) cursor = widened.from + Math.min(expected - from, insert.length);
182
+ else cursor = widened.from + insert.length + Math.max(0, expected - from - raw.length - (widened.to - to));
183
+ cursor = Math.min(cursor, text.length);
184
+ if (splitsSurrogatePair(text, cursor)) cursor--;
185
+ return {
186
+ from: widened.from,
187
+ to: widened.to,
188
+ insert,
189
+ text,
190
+ cursor,
191
+ changed: widened.from !== widened.to || insert !== "",
192
+ };
193
+ }
87
194
 
88
195
  /** The expanded state a `toggle` on the component's root node asks for, else undefined. */
89
196
  export function rootToggleExpanded(event: NativeUiEvent): boolean | undefined {
@@ -211,8 +211,8 @@ export class Reconciler {
211
211
  #ops: TspOp[] = [];
212
212
  #dels: Entry[] = [];
213
213
  #settles: string[] = [];
214
- /** Selected list items sent this frame, revealed once the frame's adds have landed. */
215
- #reveals: string[] = [];
214
+ /** Selected list items and added `reveal` nodes sent this frame, revealed once the frame's adds have landed. */
215
+ #reveals: [id: string, at: "start" | "end" | "nearest"][] = [];
216
216
  /** Wire ids `list.selected` keys resolved to (an unresolved key names no node to reveal). */
217
217
  #selectedIds = new Set<string>();
218
218
  #moves: PendingMove[] = [];
@@ -292,7 +292,7 @@ export class Reconciler {
292
292
  }
293
293
  this.#parked = [];
294
294
  for (const id of this.#settles) this.#ops.push(["settle", id]);
295
- for (const id of this.#reveals) this.#ops.push(["reveal", id, "nearest"]);
295
+ for (const [id, at] of this.#reveals) this.#ops.push(["reveal", id, at]);
296
296
  const ops = this.#ops;
297
297
  this.#ops = [];
298
298
  return this.#gone.size === 0 ? ops : ops.filter(op => !this.#touchesGone(op));
@@ -716,6 +716,7 @@ export class Reconciler {
716
716
 
717
717
  /** A full wire subtree for an entry; nested components get states (or pending moves when mounted elsewhere). */
718
718
  #materialize(entry: NodeEntry, walk: Walk): TspNode {
719
+ if (entry.node.reveal) this.#reveals.push([entry.id, entry.node.reveal]);
719
720
  const entries = this.#entries(entry.node.c, entry.keypath, entry.owner, entry.hoist, walk);
720
721
  const children: TspNode[] = [];
721
722
  for (let i = 0; i < entries.length; i++) {
@@ -779,7 +780,7 @@ export class Reconciler {
779
780
 
780
781
  /** A list's selection went out (on add or change): scroll it into view at frame end. */
781
782
  #noteSelected(selected: unknown): void {
782
- if (typeof selected === "string" && this.#selectedIds.has(selected)) this.#reveals.push(selected);
783
+ if (typeof selected === "string" && this.#selectedIds.has(selected)) this.#reveals.push([selected, "nearest"]);
783
784
  }
784
785
 
785
786
  /** Move components an `add` left out into their new parent, last first so every `before` exists. */
@@ -110,6 +110,31 @@ function readSessionMetrics(session: NonNullable<AgentRecordLike["session"]>): A
110
110
  }
111
111
  }
112
112
 
113
+ /** Live session whose own messages back a row's metrics when no observer progress exists. */
114
+ export function hubFallbackStatsSession<TRecord extends AgentRecordLike>(
115
+ ref: TRecord,
116
+ observed: ObservableSession | undefined,
117
+ ): NonNullable<TRecord["session"]> | undefined {
118
+ if (observed?.progress) return undefined;
119
+ const session = ref.session;
120
+ return session && typeof session.getSessionStats === "function" ? session : undefined;
121
+ }
122
+
123
+ /**
124
+ * One roster row's usage: live observer progress, then persisted history, then
125
+ * the cached fallback read of a live session (populated by {@link aggregateMetrics}).
126
+ */
127
+ export function hubRowMetrics<TRecord extends AgentRecordLike>(
128
+ ref: TRecord,
129
+ observed: ObservableSession | undefined,
130
+ sessionMetrics: WeakMap<object, { metrics: AgentMetrics | undefined }>,
131
+ ): AgentMetrics | undefined {
132
+ if (observed?.progress) return progressMetrics(observed);
133
+ if (ref.history?.metrics) return ref.history.metrics;
134
+ const session = hubFallbackStatsSession(ref, observed);
135
+ return session ? sessionMetrics.get(session)?.metrics : undefined;
136
+ }
137
+
113
138
  export function aggregateMetrics<TRecord extends AgentRecordLike>(args: {
114
139
  rows: readonly TRecord[];
115
140
  observedById: ReadonlyMap<string, ObservableSession>;
@@ -49,7 +49,8 @@ import {
49
49
  type AgentMetrics,
50
50
  type AggregateMetrics,
51
51
  aggregateMetrics,
52
- progressMetrics,
52
+ hubFallbackStatsSession,
53
+ hubRowMetrics,
53
54
  projectAgentTree,
54
55
  STATUS_ORDER,
55
56
  } from "./agent-hub-projection";
@@ -1661,19 +1662,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
1661
1662
  }
1662
1663
 
1663
1664
  #metricsFor(ref: TRecord, observed: ObservableSession | undefined): AgentMetrics | undefined {
1664
- if (observed?.progress) return progressMetrics(observed);
1665
- if (ref.history?.metrics) return ref.history.metrics;
1666
- const session = this.#fallbackStatsSession(ref, observed);
1667
- return session ? this.#sessionMetrics.get(session)?.metrics : undefined;
1668
- }
1669
-
1670
- #fallbackStatsSession(
1671
- ref: TRecord,
1672
- observed: ObservableSession | undefined,
1673
- ): NonNullable<TRecord["session"]> | undefined {
1674
- if (observed?.progress) return undefined;
1675
- const session = ref.session;
1676
- return session && typeof session.getSessionStats === "function" ? session : undefined;
1665
+ return hubRowMetrics(ref, observed, this.#sessionMetrics);
1677
1666
  }
1678
1667
 
1679
1668
  // ========================================================================
@@ -2009,7 +1998,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
2009
1998
  rows: this.#rows,
2010
1999
  observedById: this.#observedById,
2011
2000
  metricsFor: (ref, observed) => this.#metricsFor(ref, observed),
2012
- fallbackStatsSession: (ref, observed) => this.#fallbackStatsSession(ref, observed),
2001
+ fallbackStatsSession: hubFallbackStatsSession,
2013
2002
  sessionMetrics: this.#sessionMetrics,
2014
2003
  refreshFallback,
2015
2004
  });
@@ -117,45 +117,32 @@ const DIALOG_HEIGHT_RATIO = 0.7;
117
117
  const MIN_DIALOG_ROWS = 12;
118
118
  const MIN_BODY_ROWS = 5;
119
119
  const MAX_HEADER_CHIP_WIDTH = 16;
120
- /** Maximum number of title lines shown in the prompt editor overlay, so a
121
- * long or multi-line question cannot push the input row off-screen. Mirrors
122
- * the bounded-title pattern from the legacy ask path without its option-window
123
- * coupling. */
124
- const MAX_PROMPT_TITLE_ROWS = 3;
125
- /** Border (2) + padX (2) columns consumed by the HookEditor chrome. */
126
- const PROMPT_TITLE_CHROME_COLUMNS = 4;
127
120
  /** Maximum number of wrapped lines for an in-body question header, so a long
128
121
  * or multi-line question cannot push the option list off-screen. Mirrors the
129
- * row-cap pattern used by boundPromptTitle for the prompt editor overlay. */
122
+ * row-cap pattern of the prompt editor's title (`boundPromptTitle`). */
130
123
  const MAX_HEADER_ROWS = 4;
131
124
  /** Maximum number of wrapped lines shown for an option description before
132
125
  * Ctrl+O expansion. Mirrors the header row-cap above so long descriptions
133
126
  * cannot push later options off-screen. */
134
127
  const MAX_DESC_ROWS = 2;
135
128
 
136
- function promptTitleContentWidth(): number {
137
- const cols = process.stdout.columns ?? 80;
138
- return Math.max(1, cols - PROMPT_TITLE_CHROME_COLUMNS);
139
- }
140
-
141
- /** Bound a prompt editor title to a fixed row/width budget so long or
142
- * multi-line questions stay usable inside the small prompt overlay. */
143
- export function boundPromptTitle(prefix: string, question: string): string {
144
- const width = promptTitleContentWidth();
145
- const flat = normalizedInlineInput(`${prefix}${question}`);
146
- const wrapped = wrapTextWithAnsi(flat, width);
147
- if (wrapped.length <= MAX_PROMPT_TITLE_ROWS) return wrapped.join("\n");
148
- const kept = wrapped.slice(0, MAX_PROMPT_TITLE_ROWS - 1);
149
- const last = truncateToWidth(wrapped[MAX_PROMPT_TITLE_ROWS - 1] ?? "", width, Ellipsis.Unicode);
150
- return [...kept, last].join("\n");
129
+ /** What a custom-answer or note prompt asks: the host shows `title` over `question` (`HookEditorOptions.question`). */
130
+ export interface AskDialogPrompt {
131
+ /** `Custom answer`, `Note for <option>`. */
132
+ title: string;
133
+ /** The question being answered, verbatim. */
134
+ question: string;
151
135
  }
152
136
 
153
137
  interface AskDialogCallbacks {
154
138
  onSubmit(result: ExtensionAskDialogSubmitResult): void;
155
139
  onCancel(): void;
156
- onPrompt(title: string, prefill?: string): Promise<string | undefined>;
140
+ onPrompt(prompt: AskDialogPrompt, prefill?: string): Promise<string | undefined>;
157
141
  /** Prompt that accepts pasted images; without it, prompts use `onPrompt`. */
158
- onImagePrompt?(title: string, prefill: AskDialogPromptValue | undefined): Promise<AskDialogPromptValue | undefined>;
142
+ onImagePrompt?(
143
+ prompt: AskDialogPrompt,
144
+ prefill: AskDialogPromptValue | undefined,
145
+ ): Promise<AskDialogPromptValue | undefined>;
159
146
  }
160
147
 
161
148
  interface AskDialogInputGuard {
@@ -1296,12 +1283,12 @@ export class AskDialogComponent implements Component {
1296
1283
  * caller keeps one `await` before clearing `#promptActive`, which the host's restore relies on.
1297
1284
  */
1298
1285
  #openPrompt(
1299
- title: string,
1286
+ prompt: AskDialogPrompt,
1300
1287
  prefill: AskDialogPromptValue | undefined,
1301
1288
  ): Promise<string | AskDialogPromptValue | undefined> {
1302
1289
  return this.callbacks.onImagePrompt
1303
- ? this.callbacks.onImagePrompt(title, prefill)
1304
- : this.callbacks.onPrompt(title, prefill?.text);
1290
+ ? this.callbacks.onImagePrompt(prompt, prefill)
1291
+ : this.callbacks.onPrompt(prompt, prefill?.text);
1305
1292
  }
1306
1293
 
1307
1294
  async #promptForCustomInput(
@@ -1311,10 +1298,9 @@ export class AskDialogComponent implements Component {
1311
1298
  ): Promise<void> {
1312
1299
  this.#promptActive = true;
1313
1300
  try {
1314
- const title = boundPromptTitle("Custom answer: ", question.question);
1315
1301
  const prefill =
1316
1302
  state.customInput === undefined ? undefined : { text: state.customInput, images: state.customInputImages };
1317
- const result = await this.#openPrompt(title, prefill);
1303
+ const result = await this.#openPrompt({ title: "Custom answer", question: question.question }, prefill);
1318
1304
  if (result === undefined || this.#closed) return;
1319
1305
  const input = splitPromptInput(result);
1320
1306
  if (input.text.trim() === "") {
@@ -1350,11 +1336,13 @@ export class AskDialogComponent implements Component {
1350
1336
  ): Promise<void> {
1351
1337
  this.#promptActive = true;
1352
1338
  try {
1353
- const title = boundPromptTitle(`Note for ${rowItem.label}: `, question.question);
1354
1339
  const isReedit = state.noteRowKey === rowItem.key;
1355
1340
  const prefill =
1356
1341
  isReedit && state.note !== undefined ? { text: state.note, images: state.noteImages } : undefined;
1357
- const result = await this.#openPrompt(title, prefill);
1342
+ const result = await this.#openPrompt(
1343
+ { title: `Note for ${rowItem.label}`, question: question.question },
1344
+ prefill,
1345
+ );
1358
1346
  if (result === undefined || this.#closed) return;
1359
1347
  const note = splitPromptInput(result);
1360
1348
  state.note = note.text;
@@ -48,10 +48,10 @@ import { actionHint, hintsRow, type NativeHint, overlayCard } from "../native/ov
48
48
  import { CLOSE_ACTION, type PickerEvent, picker, pickerAction, pickerEvent } from "../native/picker";
49
49
 
50
50
  /** Key of the leading list item that replays the history older than the startup tail. */
51
- export const EARLIER_TURNS_KEY = "earlier";
51
+ const EARLIER_TURNS_KEY = "earlier";
52
52
 
53
53
  /** Leading item of a truncated transcript list; selecting it loads the older turns. */
54
- export const earlierTurnsItem: NativeNode = node(
54
+ const earlierTurnsItem: NativeNode = node(
55
55
  "item",
56
56
  { label: [span("Earlier turns…", "muted")], hint: ["a"] },
57
57
  undefined,
@@ -112,7 +112,7 @@ function entryTime(entry: TranscriptEntry): string {
112
112
  * One list item per outline target, keyed by the turn's opening entry id:
113
113
  * the plain turn summary as label, `detail` (or the entry time) on the right.
114
114
  */
115
- export function turnItem(target: OutlineTarget, detail?: TspText): NativeNode {
115
+ function turnItem(target: OutlineTarget, detail?: TspText): NativeNode {
116
116
  const entry = target.entries[0]!;
117
117
  const { label, role } = turnSummary(entry);
118
118
  const time = entryTime(entry);
@@ -164,14 +164,14 @@ function toolCallLabel(name: string, args: Record<string, unknown> | undefined):
164
164
  }
165
165
 
166
166
  /** The timeline pickers' one fact column: a user turn's clock time. */
167
- export const TIMELINE_COLUMNS: readonly TspPickerColumn[] = [{ id: "at", format: "dim", priority: 1 }];
167
+ const TIMELINE_COLUMNS: readonly TspPickerColumn[] = [{ id: "at", format: "dim", priority: 1 }];
168
168
 
169
169
  /**
170
170
  * The `timeline` picker item of one outline target (rewind, copy), keyed by
171
171
  * the turn's opening entry id: user turns carry their `HH:MM`, pure tool turns
172
172
  * the tool's role and a `name target` label.
173
173
  */
174
- export function timelineItem(target: OutlineTarget): TspPickerItem {
174
+ function timelineItem(target: OutlineTarget): TspPickerItem {
175
175
  const entry = target.entries[0]!;
176
176
  const id = target.turnId;
177
177
  const summary = turnSummary(entry);
@@ -203,7 +203,7 @@ export function timelineItem(target: OutlineTarget): TspPickerItem {
203
203
  }
204
204
 
205
205
  /** Timeline picker items for `targets`, reused while `targets` is the same array. */
206
- export class TimelineItems {
206
+ class TimelineItems {
207
207
  #memo: { targets: readonly OutlineTarget[]; items: TspPickerItem[] } | undefined;
208
208
 
209
209
  of(targets: readonly OutlineTarget[]): TspPickerItem[] {
@@ -19,16 +19,21 @@ const MAX_BANNER_ROWS = 4;
19
19
  * be missed. It is cleared when the next turn starts. The message wraps to the
20
20
  * render width and keeps {@link MAX_BANNER_ROWS} rows; the expand hint on the
21
21
  * overflow row reveals the full body inline in the transcript.
22
+ *
23
+ * Natively the transcript's error frame always shows the full message, so the
24
+ * strip is only a reminder and offers a Dismiss button that calls `onDismiss`.
22
25
  */
23
26
  export class ErrorBannerComponent extends Container {
24
27
  readonly #message: string;
28
+ readonly #onDismiss: (() => void) | undefined;
25
29
  /** Natively the strip shows the headline; Details unfolds the whole message in place. */
26
30
  #expanded = false;
27
31
  #native: NativeNode | undefined;
28
32
 
29
- constructor(message: string) {
33
+ constructor(message: string, onDismiss?: () => void) {
30
34
  super();
31
35
  this.#message = plainText(message).trim();
36
+ this.#onDismiss = onDismiss;
32
37
  this.addChild(new Spacer(1));
33
38
  this.addChild(new DynamicBorder(str => theme.fg("error", str)));
34
39
  this.addChild(
@@ -50,7 +55,7 @@ export class ErrorBannerComponent extends Container {
50
55
  /**
51
56
  * An error-tinted strip pinned above the composer: the message's first line
52
57
  * (the transcript's error frame keeps the full message and its actions)
53
- * and a Details button that unfolds the rest here.
58
+ * and a Details button that unfolds the rest here, then Dismiss.
54
59
  */
55
60
  override describe(): NativeNode {
56
61
  if (this.#native) return this.#native;
@@ -67,6 +72,7 @@ export class ErrorBannerComponent extends Container {
67
72
  if (multiline || headline.length > 80) {
68
73
  children.push(actionButton(this.#expanded ? "Less" : "Details", "details"));
69
74
  }
75
+ if (this.#onDismiss) children.push(actionButton("Dismiss", "dismiss"));
70
76
  this.#native = node(
71
77
  "row",
72
78
  { role: "omp.errorBanner", tone: "error", gap: "sm", align: this.#expanded ? "start" : "center" },
@@ -76,7 +82,12 @@ export class ErrorBannerComponent extends Container {
76
82
  }
77
83
 
78
84
  handleNativeEvent(event: NativeUiEvent): void {
79
- if (event.type !== "action" || event.act !== "details") return;
85
+ if (event.type !== "action") return;
86
+ if (event.act === "dismiss") {
87
+ this.#onDismiss?.();
88
+ return;
89
+ }
90
+ if (event.act !== "details") return;
80
91
  this.#expanded = !this.#expanded;
81
92
  this.#native = undefined;
82
93
  }
@@ -10,7 +10,18 @@
10
10
  import type { ImageContent } from "@oh-my-pi/pi-ai";
11
11
  import { compactImageMarkers, formatVisionMarker, PLACEHOLDER_REGEX } from "../prompt/composer-attachments";
12
12
  import { extractImagePastePathsFromText } from "../prompt/custom-editor";
13
- import { Editor, type Focusable, matchesKey, Spacer, Text, type TUI } from "../index";
13
+ import {
14
+ Editor,
15
+ Ellipsis,
16
+ type Focusable,
17
+ matchesKey,
18
+ replaceTabs,
19
+ Spacer,
20
+ Text,
21
+ type TUI,
22
+ truncateToWidth,
23
+ wrapTextWithAnsi,
24
+ } from "../index";
14
25
  import { BracketedPasteHandler } from "../bracketed-paste";
15
26
  import { getEditorTheme, theme } from "../theme/theme";
16
27
  import { matchesAppExternalEditor, matchesAppFollowUp, matchesAppInterrupt } from "../keybinding-matchers";
@@ -23,12 +34,39 @@ import { node, span } from "../native/describe";
23
34
  import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
24
35
  import { hintsRow, type NativeHint, overlayCard } from "../native/overlay";
25
36
  import { plainText } from "../native/spans";
37
+ import { sanitizeCarriageReturns } from "../render/render-utils";
38
+
39
+ /** Most title rows the terminal prompt shows, so a long question cannot push the input row off-screen. */
40
+ const MAX_PROMPT_TITLE_ROWS = 3;
41
+ /** Border (2) + padX (2) columns consumed by the panel chrome. */
42
+ const PROMPT_TITLE_CHROME_COLUMNS = 4;
43
+
44
+ /**
45
+ * `prefix` and `question` flattened onto one line, wrapped to the terminal's width and cut to
46
+ * {@link MAX_PROMPT_TITLE_ROWS} rows (the last ends in `…` when cut). For terminal rendering only:
47
+ * native hosts get the question whole ({@link HookEditorOptions.question}).
48
+ */
49
+ export function boundPromptTitle(prefix: string, question: string): string {
50
+ const width = Math.max(1, (process.stdout.columns ?? 80) - PROMPT_TITLE_CHROME_COLUMNS);
51
+ const flat = replaceTabs(`${prefix}${question}`).replace(/\s+/g, " ").trim();
52
+ const wrapped = wrapTextWithAnsi(flat, width);
53
+ if (wrapped.length <= MAX_PROMPT_TITLE_ROWS) return wrapped.join("\n");
54
+ const kept = wrapped.slice(0, MAX_PROMPT_TITLE_ROWS - 1);
55
+ const last = truncateToWidth(wrapped.slice(MAX_PROMPT_TITLE_ROWS - 1).join(" "), width, Ellipsis.Unicode);
56
+ return [...kept, last].join("\n");
57
+ }
26
58
 
27
59
  export interface HookEditorOptions {
28
60
  /** Edit text with the host's configured external editor. */
29
61
  externalEditor?: (text: string) => Promise<string | null>;
30
62
  /** When true, use prompt-style keybindings with the legacy ask prompt chrome. */
31
63
  promptStyle?: boolean;
64
+ /**
65
+ * The question the text answers (the ask tool's custom answers and notes). The terminal
66
+ * shows it after `<title>: `, wrapped and cut to three rows; native hosts put it whole,
67
+ * as markdown, under the title.
68
+ */
69
+ question?: string;
32
70
  /** Allow clipboard images to be attached to this prompt. */
33
71
  acceptImages?: boolean;
34
72
  /** Images already represented by markers in the prefilled text. */
@@ -85,9 +123,11 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
85
123
  options?: HookEditorOptions,
86
124
  ) {
87
125
  // First title line insets into the panel border; remaining lines (e.g. the
88
- // bounded ask question under "◆ Other (type your own)") stay as body rows
89
- // so they are never truncated into the one-row border.
90
- const [titleLine = "", ...detailLines] = title.split("\n");
126
+ // bounded ask question) stay as body rows so they are never truncated into
127
+ // the one-row border.
128
+ const question = options?.question;
129
+ const terminalTitle = question === undefined ? title : boundPromptTitle(`${title}: `, question);
130
+ const [titleLine = "", ...detailLines] = terminalTitle.split("\n");
91
131
  super(titleLine, "omp.overlay.hook-editor");
92
132
 
93
133
  this.#tui = tui;
@@ -151,7 +191,16 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
151
191
  externalHint,
152
192
  ];
153
193
  const nativeChildren: NativeChild[] = [];
154
- if (detailLines.length > 0) {
194
+ if (question !== undefined) {
195
+ nativeChildren.push(
196
+ node(
197
+ "md",
198
+ { text: replaceTabs(sanitizeCarriageReturns(question)), role: "omp.ask.question" },
199
+ undefined,
200
+ "question",
201
+ ),
202
+ );
203
+ } else if (detailLines.length > 0) {
155
204
  nativeChildren.push(
156
205
  node(
157
206
  "text",
@@ -162,7 +211,11 @@ export class HookEditorComponent extends OverlayPanel implements Focusable {
162
211
  );
163
212
  }
164
213
  nativeChildren.push(this.#editor, hintsRow(nativeHints));
165
- this.#nativeRoot = overlayCard(this.nativeRole, plainText(titleLine), nativeChildren);
214
+ this.#nativeRoot = overlayCard(
215
+ this.nativeRole,
216
+ plainText(question === undefined ? titleLine : title),
217
+ nativeChildren,
218
+ );
166
219
  }
167
220
 
168
221
  /** The editor (which describes itself) under the title and detail lines, with the key hints below. */