@oh-my-pi/pi-tui 18.9.2 → 18.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +53 -105
  2. package/dist/types/app-keybindings.d.ts +5 -0
  3. package/dist/types/chat/assistant-message.d.ts +1 -1
  4. package/dist/types/chat/user-message.d.ts +3 -2
  5. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  6. package/dist/types/debug-server.d.ts +2 -0
  7. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  8. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  9. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  10. package/dist/types/overlays/hook-editor.d.ts +7 -2
  11. package/dist/types/overlays/hook-input.d.ts +12 -4
  12. package/dist/types/overlays/jobs-panel.d.ts +8 -7
  13. package/dist/types/overlays/login-dialog.d.ts +8 -3
  14. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  15. package/dist/types/overlays/pause-screen.d.ts +5 -0
  16. package/dist/types/overlays/report-panel.d.ts +5 -1
  17. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  18. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  19. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  20. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  21. package/dist/types/prompt/composer.d.ts +1 -20
  22. package/dist/types/status-line/context-usage.d.ts +1 -1
  23. package/dist/types/tools/grouped-file-output.d.ts +4 -1
  24. package/dist/types/tui.d.ts +10 -3
  25. package/package.json +9 -9
  26. package/src/app-keybindings.ts +5 -0
  27. package/src/apps/autoresearch-dashboard.ts +440 -27
  28. package/src/chat/assistant-message.ts +41 -13
  29. package/src/chat/user-message.ts +32 -19
  30. package/src/chrome/countdown-timer.ts +3 -3
  31. package/src/chrome/transcript-container.ts +202 -28
  32. package/src/debug-server.ts +51 -69
  33. package/src/hotkeys-markdown.ts +1 -0
  34. package/src/native/reconcile.ts +24 -6
  35. package/src/overlays/advisor-config.ts +259 -8
  36. package/src/overlays/annotation-overlay.ts +420 -168
  37. package/src/overlays/ask-dialog.ts +249 -105
  38. package/src/overlays/btw-history-panel.ts +178 -124
  39. package/src/overlays/hook-editor.ts +90 -32
  40. package/src/overlays/hook-input.ts +105 -19
  41. package/src/overlays/jobs-panel.ts +154 -132
  42. package/src/overlays/login-dialog.ts +201 -91
  43. package/src/overlays/mcp-add-wizard.ts +559 -279
  44. package/src/overlays/pause-screen.ts +81 -23
  45. package/src/overlays/plugin-selector.ts +17 -1
  46. package/src/overlays/queue-mode-selector.ts +10 -0
  47. package/src/overlays/report-panel.ts +6 -2
  48. package/src/overlays/reset-usage-selector.ts +130 -21
  49. package/src/overlays/session-account-selector.ts +36 -0
  50. package/src/overlays/session-info-overlay.ts +197 -76
  51. package/src/overlays/show-images-selector.ts +1 -0
  52. package/src/overlays/theme-selector.ts +6 -1
  53. package/src/overlays/thinking-selector.ts +8 -4
  54. package/src/overlays/usage-dashboard.ts +495 -143
  55. package/src/prompt/composer.ts +35 -72
  56. package/src/status-line/context-usage.ts +154 -57
  57. package/src/tools/grep.ts +1 -0
  58. package/src/tools/grouped-file-output.ts +11 -2
  59. package/src/tui.ts +27 -4
@@ -1,5 +1,4 @@
1
- import { existsSync, unlinkSync } from "node:fs";
2
- import { createServer, type Server, type Socket } from "node:net";
1
+ import { type IpcConnection, IpcServer } from "@oh-my-pi/pi-natives";
3
2
  import { type Component, isFocusable, type OverlayOptions, type TUI } from "./tui";
4
3
  import { plainText } from "./native/spans";
5
4
 
@@ -209,72 +208,60 @@ function mouseSequence(x: number, y: number, action: string): string {
209
208
  export class TuiDebugServer {
210
209
  readonly #tui: TUI;
211
210
  readonly #path: string;
212
- #server: Server | undefined;
213
- #sockets = new Set<Socket>();
211
+ #server: IpcServer | undefined;
212
+ #listening: Promise<boolean> | undefined;
213
+ #stopped = false;
214
+ #connections = new Set<IpcConnection>();
214
215
 
215
216
  constructor(tui: TUI, path: string) {
216
217
  this.#tui = tui;
217
218
  this.#path = path;
218
219
  }
219
220
 
221
+ /** Resolves true once the socket accepts connections, false if listen failed or the server stopped first. */
222
+ get ready(): Promise<boolean> {
223
+ return this.#listening ?? Promise.resolve(false);
224
+ }
225
+
220
226
  start(): void {
221
- if (this.#server !== undefined) return;
222
- try {
223
- if (existsSync(this.#path)) unlinkSync(this.#path);
224
- } catch {
225
- // The listen error below is isolated from the host application.
226
- }
227
- const server = createServer(socket => this.#accept(socket));
228
- this.#server = server;
229
- server.on("error", error => {
230
- void error;
231
- });
232
- try {
233
- server.listen(this.#path);
234
- } catch {
235
- this.#server = undefined;
236
- server.close();
237
- }
227
+ if (this.#listening !== undefined) return;
228
+ this.#stopped = false;
229
+ this.#listening = IpcServer.listen(this.#path, { framing: "lines" }, connection => this.#accept(connection)).then(
230
+ server => {
231
+ if (this.#stopped) {
232
+ void server.close().catch(() => {});
233
+ return false;
234
+ }
235
+ this.#server = server;
236
+ return true;
237
+ },
238
+ () => false, // A live peer or unusable path disables the debug socket; the host application is unaffected.
239
+ );
238
240
  }
239
241
 
240
242
  stop(): void {
243
+ this.#stopped = true;
241
244
  const server = this.#server;
242
245
  this.#server = undefined;
243
- for (const socket of this.#sockets) socket.destroy();
244
- this.#sockets.clear();
245
- try {
246
- server?.close();
247
- } catch {
248
- // A failed or already-closed listener still needs its stale path removed.
249
- }
250
- try {
251
- if (existsSync(this.#path)) unlinkSync(this.#path);
252
- } catch {
253
- // Teardown must not affect the host application.
254
- }
246
+ this.#listening = undefined;
247
+ for (const connection of this.#connections) connection.destroy();
248
+ this.#connections.clear();
249
+ void server?.close().catch(() => {});
255
250
  }
256
251
 
257
- #accept(socket: Socket): void {
258
- this.#sockets.add(socket);
259
- let buffer = "";
260
- socket.setEncoding("utf8");
261
- socket.on("data", chunk => {
262
- buffer += chunk;
263
- let newline = buffer.indexOf("\n");
264
- while (newline !== -1) {
265
- const line = buffer.slice(0, newline).replace(/\r$/, "");
266
- buffer = buffer.slice(newline + 1);
267
- if (line.length > 0) this.#handleLine(socket, line);
268
- newline = buffer.indexOf("\n");
269
- }
270
- });
271
- socket.on("error", error => {
272
- void error;
273
- });
274
- socket.on("close", () => this.#sockets.delete(socket));
252
+ #accept(connection: IpcConnection): void {
253
+ if (this.#stopped) {
254
+ connection.destroy();
255
+ return;
256
+ }
257
+ this.#connections.add(connection);
258
+ connection.start(
259
+ line => void this.#handleLine(connection, line),
260
+ () => this.#connections.delete(connection),
261
+ );
275
262
  }
276
263
 
277
- #handleLine(socket: Socket, line: string): void {
264
+ async #handleLine(connection: IpcConnection, line: string): Promise<void> {
278
265
  let request: DebugRequest;
279
266
  try {
280
267
  const parsed: unknown = JSON.parse(line);
@@ -282,36 +269,31 @@ export class TuiDebugServer {
282
269
  throw new Error("request must be an object");
283
270
  request = parsed as DebugRequest;
284
271
  } catch (error) {
285
- this.#write(socket, { ok: false, error: `malformed JSON: ${errorMessage(error)}` });
272
+ this.#write(connection, { ok: false, error: `malformed JSON: ${errorMessage(error)}` });
286
273
  return;
287
274
  }
275
+ let response: DebugResponse;
288
276
  try {
289
- const response = this.#dispatch(request);
290
- if (request.op === "quit" && response.ok) {
291
- this.#write(socket, response, () => {
292
- this.#tui.stop();
293
- process.exit(0);
294
- });
295
- return;
296
- }
297
- this.#write(socket, response);
277
+ response = this.#dispatch(request);
298
278
  } catch (error) {
299
- this.#write(socket, { ok: false, error: errorMessage(error) });
279
+ response = { ok: false, error: errorMessage(error) };
280
+ }
281
+ this.#write(connection, response);
282
+ if (request.op === "quit" && response.ok) {
283
+ await connection.drained();
284
+ this.#tui.stop();
285
+ process.exit(0);
300
286
  }
301
287
  }
302
288
 
303
- #write(socket: Socket, response: DebugResponse, flushed?: () => void): void {
289
+ #write(connection: IpcConnection, response: DebugResponse): void {
304
290
  let line: string;
305
291
  try {
306
292
  line = JSON.stringify(response);
307
293
  } catch (error) {
308
294
  line = JSON.stringify({ ok: false, error: errorMessage(error) });
309
295
  }
310
- try {
311
- socket.write(`${line}\n`, flushed);
312
- } catch {
313
- // Broken clients cannot affect the TUI.
314
- }
296
+ connection.send(line);
315
297
  }
316
298
 
317
299
  #dispatch(request: DebugRequest): DebugResponse {
@@ -135,6 +135,7 @@ function hotkeyGroups(bindings: HotkeysMarkdownBindings): HotkeyGroup[] {
135
135
  { keys: [act("app.history.search")], action: "Search prompt history" },
136
136
  { keys: [act("app.tools.expand")], action: "Toggle tool output expansion" },
137
137
  { keys: [act("app.tools.toggleVisibility")], action: "Toggle tool activity visibility" },
138
+ { keys: [act("app.ask.hide")], action: "Hide/show a pending ask question" },
138
139
  { keys: [act("app.thinking.toggle")], action: "Toggle thinking block visibility" },
139
140
  { keys: [act("app.editor.external")], action: "Edit message in external editor" },
140
141
  { keys: [act("app.retry")], action: "Retry last failed assistant turn" },
@@ -405,13 +405,31 @@ export class Reconciler {
405
405
  return this.#findEditorIn(node, state.id, "", state.owner, depth);
406
406
  }
407
407
 
408
- #findEditorIn(node: NativeNode, id: string, keypath: string, owner: Owner, depth: number): string | null {
408
+ /**
409
+ * The first editor under `node`. An `overlay`/`picker` child outside a hoisted
410
+ * sheet is hoisted into `layer` under its `^` keypath (see {@link #entries}), so
411
+ * a field in a sheet a dock component describes is found under that id.
412
+ */
413
+ #findEditorIn(
414
+ node: NativeNode,
415
+ id: string,
416
+ keypath: string,
417
+ owner: Owner,
418
+ depth: number,
419
+ hoist = true,
420
+ ): string | null {
409
421
  if (node.k === "editor" || node.k === "input") return id;
410
- for (const entry of this.#entries(node.c, keypath, owner, true, null)) {
411
- const found =
412
- entry.comp === undefined
413
- ? this.#findEditorIn(entry.node, entry.id, entry.keypath, owner, depth)
414
- : this.#findEditorOf(entry.comp, depth + 1);
422
+ for (const entry of this.#entries(node.c, keypath, owner, false, null)) {
423
+ let found: string | null;
424
+ if (entry.comp !== undefined) {
425
+ found = this.#findEditorOf(entry.comp, depth + 1);
426
+ } else if (hoist && (entry.node.k === "overlay" || entry.node.k === "picker")) {
427
+ const path = entry.keypath;
428
+ const portalPath = keypath === "" ? `^${path}` : `${keypath}/^${path.slice(keypath.length + 1)}`;
429
+ found = this.#findEditorIn(entry.node, nodeId(owner.base, portalPath), portalPath, owner, depth, false);
430
+ } else {
431
+ found = this.#findEditorIn(entry.node, entry.id, entry.keypath, owner, depth, hoist);
432
+ }
415
433
  if (found !== null) return found;
416
434
  }
417
435
  return null;
@@ -34,7 +34,16 @@ import { sanitizeDisplayWarnings } from "../render/render-utils";
34
34
  import { formatKeyHint } from "../app-keybindings";
35
35
  import { editorKey, editorKeys } from "../chrome/keybinding-hints";
36
36
  import { HookEditorComponent } from "./hook-editor";
37
- import { buildBrowserItems, ModelBrowser, type ModelBrowserSource, sortModelItems } from "./model-browser";
37
+ import {
38
+ buildBrowserItems,
39
+ MODEL_PICKER_COLUMNS,
40
+ ModelBrowser,
41
+ type ModelBrowserItem,
42
+ type ModelBrowserSource,
43
+ type ModelPickerCatalogue,
44
+ type ModelPickerOrder,
45
+ sortModelItems,
46
+ } from "./model-browser";
38
47
  import { bottomBorder, divider, dividerSplit, PanelRows, row, topBorder, topBorderSplit } from "../chrome/overlay-box";
39
48
  import { isLayoutMouseRoutable } from "../components/layout/geometry";
40
49
  import { SplitPane } from "../components/layout/split-pane";
@@ -42,7 +51,16 @@ import { Stack } from "../components/layout/stack";
42
51
  import type { TspPrefsProps, TspPrefsRow, TspSpan } from "@oh-my-pi/pi-wire";
43
52
  import { col, node, span, text } from "../native/describe";
44
53
  import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
45
- import { actionHint, hintsRow, type NativeHint, overlayCard } from "../native/overlay";
54
+ import { actionBar, actionButton, actionHint, hintsRow, type NativeHint, overlayCard } from "../native/overlay";
55
+ import {
56
+ CLOSE_ACTION,
57
+ pickerAction,
58
+ pickerEvent,
59
+ routeSelectListPicker,
60
+ type SelectPickerOptions,
61
+ selectListPicker,
62
+ } from "../native/picker";
63
+ import { getThinkingLevelMetadata, parseThinkingLevel } from "../thinking";
46
64
 
47
65
  export const ADVISOR_REVIEW_MODES = ["turn", "agent-end"] as const;
48
66
  export const ADVISOR_SYNC_BACKLOG_MODES = ["off", "1", "3", "5", "strict"] as const;
@@ -205,6 +223,40 @@ function formatAdvisorTools(tools: readonly string[] | undefined, emptyLabel: st
205
223
  /** Choice value for "no per-advisor override"; matches no {@link ADVISOR_SYNC_BACKLOG_MODES} entry. */
206
224
  const SYNC_BACKLOG_INHERIT = "inherit";
207
225
 
226
+ /** The tools list's terminal-only "Done" row; the picker applies with its own action instead. */
227
+ const TOOLS_DONE = "__done";
228
+
229
+ /**
230
+ * A picker editor as a component of its own: a component's root picker stays
231
+ * where it is described, the prefs page's editor slot, where a picker
232
+ * described inside the page would be hoisted into `layer` under the sheet.
233
+ */
234
+ class EditorSheet implements Component {
235
+ node: NativeNode | undefined;
236
+ readonly #route: (event: NativeUiEvent) => void;
237
+
238
+ constructor(route: (event: NativeUiEvent) => void) {
239
+ this.#route = route;
240
+ }
241
+
242
+ render(): readonly string[] {
243
+ return [];
244
+ }
245
+
246
+ describe(): NativeNode | null {
247
+ return this.node ?? null;
248
+ }
249
+
250
+ handleNativeEvent(event: NativeUiEvent): void {
251
+ this.#route(event);
252
+ }
253
+ }
254
+
255
+ /** Whether two memo dependency lists hold the same values by identity. */
256
+ function sameDeps(a: readonly unknown[], b: readonly unknown[]): boolean {
257
+ return a.length === b.length && a.every((value, i) => value === b[i]);
258
+ }
259
+
208
260
  /** One-line cadence label: mode plus interval. */
209
261
  function formatReviewCadence(advisor: AdvisorConfig): string {
210
262
  const reviewInterval = advisor.reviewInterval ?? 1;
@@ -297,7 +349,15 @@ export class AdvisorConfigOverlayComponent implements Component {
297
349
 
298
350
  /** The advisor the detail screen (and its editors) edits; -1 on the roster or the shared instructions. */
299
351
  #detailIndex = -1;
300
- #nativePrefs: { signature: string; editor: Component | undefined; node: NativeNode } | undefined;
352
+ #nativePrefs: { signature: string; editor: NativeNode | undefined; node: NativeNode } | undefined;
353
+ /** The editor over the prefs page, rebuilt only when one of `deps` changes identity. */
354
+ #nativeEditor: { deps: readonly unknown[]; node: NativeNode } | undefined;
355
+ /** The model screen's catalogue and the advisor's current model among it (picker `current`). */
356
+ #modelItems: ModelBrowserItem[] = [];
357
+ #modelCurrent: string | undefined;
358
+ /** Picker sheet options of the current list screen (thinking, review mode, sync backlog, tools). */
359
+ #sheetOptions: SelectPickerOptions | undefined;
360
+ readonly #editorSheet = new EditorSheet(event => this.#routeEditorPicker(event));
301
361
  #screen: Screen = "list";
302
362
  /** The interactive element for the current screen. */
303
363
  #active: Component = new SelectList([], 1, getSelectListTheme());
@@ -587,7 +647,7 @@ export class AdvisorConfigOverlayComponent implements Component {
587
647
  this.#screen === "sync-backlog" ||
588
648
  this.#screen === "tools" ||
589
649
  this.#screen === "instructions";
590
- const editor = overlay ? this.#active : undefined;
650
+ const editor = overlay ? this.#prefsEditor() : undefined;
591
651
  const focus =
592
652
  this.#screen === "detail" && this.#active instanceof SelectList
593
653
  ? (this.#active.getSelectedItem()?.value ?? null)
@@ -606,12 +666,137 @@ export class AdvisorConfigOverlayComponent implements Component {
606
666
  const signature = JSON.stringify(props);
607
667
  const prev = this.#nativePrefs;
608
668
  if (prev && prev.signature === signature && prev.editor === editor) return prev.node;
609
- const children: NativeChild[] = editor ? [col([editor], { role: "omp.prefs.editor" })] : [];
669
+ let children: NativeChild[] = [];
670
+ if (editor?.k === "picker") {
671
+ this.#editorSheet.node = editor;
672
+ children = [this.#editorSheet];
673
+ } else if (editor) {
674
+ children = [editor];
675
+ }
610
676
  const root = node("prefs", props, children);
611
677
  this.#nativePrefs = { signature, editor, node: root };
612
678
  return root;
613
679
  }
614
680
 
681
+ /**
682
+ * The editor over the prefs page. Choices are picker sheets on `/model`'s
683
+ * anatomy (search, rows, the highlighted model's facts, one primary
684
+ * action); the instructions and review-interval editors sit in the page's
685
+ * editor card.
686
+ */
687
+ #prefsEditor(): NativeNode {
688
+ const active = this.#active;
689
+ const memo = this.#nativeEditor;
690
+ if (active instanceof ModelBrowser) {
691
+ const order = active.pickerOrder({ providers: false, rest: active.query.trim() ? "Matches" : "All" });
692
+ const catalogue = active.pickerItems(this.#modelItems);
693
+ const preview = active.pickerPreview("compact", this.#modelCurrent);
694
+ const deps = [active, order, catalogue, preview, active.query, active.cursor, active.pickerSelected];
695
+ if (memo && sameDeps(memo.deps, deps)) return memo.node;
696
+ const sheet = this.#modelSheet(active, order, catalogue, preview);
697
+ this.#nativeEditor = { deps, node: sheet };
698
+ return sheet;
699
+ }
700
+ const options = this.#sheetOptions;
701
+ if (active instanceof SelectList && options) {
702
+ const view = active.pickerView();
703
+ const deps = [active, view.items, view.selected, view.pending, view.query, view.cursor];
704
+ if (memo && sameDeps(memo.deps, deps)) return memo.node;
705
+ const props = selectListPicker(active, options);
706
+ const sheet = node(
707
+ "picker",
708
+ this.#screen === "tools"
709
+ ? {
710
+ ...props,
711
+ items: props.items?.filter(item => item.id !== TOOLS_DONE),
712
+ actions: [
713
+ pickerAction("confirm", "Toggle", "enter"),
714
+ pickerAction("apply", "Apply", "escape", { primary: true, end: true }),
715
+ ],
716
+ }
717
+ : props,
718
+ );
719
+ this.#nativeEditor = { deps, node: sheet };
720
+ return sheet;
721
+ }
722
+ if (memo && sameDeps(memo.deps, [active])) return memo.node;
723
+ const content = this.#screen === "review-interval" ? this.#intervalCard(active) : active;
724
+ const card = col([content], { role: "omp.prefs.editor" });
725
+ this.#nativeEditor = { deps: [active], node: card };
726
+ return card;
727
+ }
728
+
729
+ #modelSheet(
730
+ browser: ModelBrowser,
731
+ order: ModelPickerOrder,
732
+ catalogue: ModelPickerCatalogue,
733
+ preview: readonly NativeChild[],
734
+ ): NativeNode {
735
+ const name = this.#doc.advisors[this.#detailIndex]?.name || "This advisor";
736
+ const fallback = this.#defaultModelLabel ?? "the advisor role default";
737
+ return node(
738
+ "picker",
739
+ {
740
+ title: "Advisor model",
741
+ subtitle: `${name} reviews with this model. Unset, it uses ${fallback}.`,
742
+ icon: "model",
743
+ noun: "models",
744
+ size: "md",
745
+ layout: "rows",
746
+ preview: "below",
747
+ query: browser.query,
748
+ cursor: browser.cursor,
749
+ placeholder: "Search models…",
750
+ columns: MODEL_PICKER_COLUMNS,
751
+ ...catalogue,
752
+ order: order.order,
753
+ ...(order.hits ? { hits: order.hits } : {}),
754
+ selected: browser.pickerSelected,
755
+ ...(this.#modelCurrent ? { current: [this.#modelCurrent] } : {}),
756
+ total: browser.baseCount,
757
+ empty: "No models available",
758
+ actions: [
759
+ pickerAction("use", "Use for advisor", "enter", { primary: true }),
760
+ browser.query.length > 0 ? { ...CLOSE_ACTION, label: "Clear search" } : CLOSE_ACTION,
761
+ ],
762
+ },
763
+ preview,
764
+ );
765
+ }
766
+
767
+ /** The review-interval field as a titled card: what the number means, the field, Cancel and Save. */
768
+ #intervalCard(input: Component): NativeNode {
769
+ const name = this.#doc.advisors[this.#detailIndex]?.name || "this advisor";
770
+ return overlayCard("omp.overlay.advisor.interval", `Review interval · ${name}`, [
771
+ text([span("Review every Nth eligible update; skipped updates join the next review.", "muted")], {
772
+ wrap: "word",
773
+ }),
774
+ input,
775
+ actionBar([
776
+ null,
777
+ actionButton("Cancel", "field-cancel", { keys: "escape" }),
778
+ actionButton("Save", "field-save", { keys: "enter", tone: "accent" }),
779
+ ]),
780
+ ]);
781
+ }
782
+
783
+ /** A pointer event on the editor's picker sheet, on the path of the key it stands for. */
784
+ #routeEditorPicker(event: NativeUiEvent): void {
785
+ const picked = pickerEvent(event);
786
+ if (!picked) return;
787
+ const active = this.#active;
788
+ if (active instanceof ModelBrowser) {
789
+ if (picked.kind !== "action") active.routePickerItem(picked.item, picked.kind === "activate");
790
+ else if (picked.act === "close") active.handleCancel();
791
+ else if (picked.act === "use" && active.pickerSelected) active.routePickerItem(active.pickerSelected, true);
792
+ } else if (active instanceof SelectList) {
793
+ // The tools list applies on cancel, like its Esc.
794
+ if (picked.kind === "action" && picked.act === "apply") active.onCancel?.();
795
+ else routeSelectListPicker(active, event);
796
+ }
797
+ this.#cb.requestRender();
798
+ }
799
+
615
800
  /**
616
801
  * Native page events, each through the roster/detail selections the keys
617
802
  * make: a page opens that advisor (or the shared instructions), a field
@@ -619,6 +804,14 @@ export class AdvisorConfigOverlayComponent implements Component {
619
804
  * or the page cancels like Esc.
620
805
  */
621
806
  handleNativeEvent(event: NativeUiEvent): void {
807
+ // The review-interval card's buttons, on its Enter and Esc paths.
808
+ if (event.type === "action" && (event.act === "field-save" || event.act === "field-cancel")) {
809
+ const input = this.#active;
810
+ if (!(input instanceof Input)) return;
811
+ if (event.act === "field-save") input.onSubmit?.(input.getValue());
812
+ else input.onEscape?.();
813
+ return;
814
+ }
622
815
  if (event.key !== "") return;
623
816
  const index = this.#pageIndex();
624
817
  switch (event.type) {
@@ -979,9 +1172,11 @@ export class AdvisorConfigOverlayComponent implements Component {
979
1172
 
980
1173
  // ───────────────────────────── screens ───────────────────────────
981
1174
 
982
- #setScreen(screen: Screen, active: Component, footerHint: string): void {
1175
+ /** `sheet`: how a list screen describes as a picker over the prefs page. */
1176
+ #setScreen(screen: Screen, active: Component, footerHint: string, sheet?: SelectPickerOptions): void {
983
1177
  this.#screen = screen;
984
1178
  this.#active = active;
1179
+ this.#sheetOptions = sheet;
985
1180
  this.#footerHint = footerHint;
986
1181
  this.#previewScroll = 0;
987
1182
  this.#cb.requestRender();
@@ -1155,6 +1350,8 @@ export class AdvisorConfigOverlayComponent implements Component {
1155
1350
  }
1156
1351
 
1157
1352
  #onDetailSelect(index: number, field: string): void {
1353
+ // Editors opened straight from the prefs page edit this advisor too.
1354
+ this.#detailIndex = index;
1158
1355
  switch (field) {
1159
1356
  case "toggleEnabled": {
1160
1357
  const a = this.#doc.advisors[index];
@@ -1232,11 +1429,19 @@ export class AdvisorConfigOverlayComponent implements Component {
1232
1429
  }
1233
1430
  const items = buildBrowserItems(models);
1234
1431
  sortModelItems(items, { mruOrder });
1432
+ // The configured model, whatever thinking suffix it carries, is the picker's current row.
1433
+ const model = this.#doc.advisors[index]?.model?.trim();
1434
+ const current = model
1435
+ ? items.find(item => model === item.selector || model.startsWith(`${item.selector}:`))?.selector
1436
+ : undefined;
1437
+ this.#modelItems = items;
1438
+ this.#modelCurrent = current;
1235
1439
 
1236
1440
  const picker = new ModelBrowser(this.#deps.browserSource, {});
1237
1441
  picker.setMruOrder(mruOrder);
1238
1442
  picker.setPerfStats(this.#deps.browserSource.modelPerf);
1239
1443
  picker.setItems(items);
1444
+ if (current) picker.selectSelector(current);
1240
1445
  picker.onActivate = item => {
1241
1446
  const efforts = getSupportedEfforts(item.model);
1242
1447
  if (efforts.length === 0) {
@@ -1259,6 +1464,11 @@ export class AdvisorConfigOverlayComponent implements Component {
1259
1464
  const items: SelectItem[] = [{ value: "", label: "(model default thinking)" }];
1260
1465
  for (const effort of efforts) items.push({ value: effort, label: effort });
1261
1466
  const list = new SelectList(items, Math.max(1, items.length), getSelectListTheme());
1467
+ // The level the advisor already runs this model at, when it is this model.
1468
+ const model = this.#doc.advisors[index]?.model?.trim();
1469
+ const current =
1470
+ model === selector ? "" : model?.startsWith(`${selector}:`) ? model.slice(selector.length + 1) : undefined;
1471
+ if (current !== undefined) list.setSelectedValue(current);
1262
1472
  list.onSelect = item => {
1263
1473
  // Values are supported efforts or the empty model-default choice.
1264
1474
  this.#doc.advisors[index].model = item.value ? `${selector}:${item.value}` : selector;
@@ -1270,6 +1480,23 @@ export class AdvisorConfigOverlayComponent implements Component {
1270
1480
  "thinking",
1271
1481
  list,
1272
1482
  `Thinking effort for ${selector} · ${editorKey("tui.select.confirm")} / click pick · ${editorKey("tui.select.cancel")} back`,
1483
+ {
1484
+ title: "Thinking level",
1485
+ subtitle: `How hard ${this.#doc.advisors[index]?.name || "the advisor"} reasons on ${selector}.`,
1486
+ icon: "thinking",
1487
+ noun: "levels",
1488
+ current: current === undefined ? [] : [current],
1489
+ confirm: "Use level",
1490
+ decorate: item => {
1491
+ if (!item.value) return { label: "Model default", detail: "No level; the provider decides." };
1492
+ const level = parseThinkingLevel(item.value);
1493
+ const dot = `thinking${item.value.charAt(0).toUpperCase()}${item.value.slice(1)}`;
1494
+ return {
1495
+ detail: level ? getThinkingLevelMetadata(level).description : undefined,
1496
+ chips: [{ text: "", dot }],
1497
+ };
1498
+ },
1499
+ },
1273
1500
  );
1274
1501
  }
1275
1502
  #showReviewModePicker(index: number): void {
@@ -1299,6 +1526,14 @@ export class AdvisorConfigOverlayComponent implements Component {
1299
1526
  "review-mode",
1300
1527
  list,
1301
1528
  `${editorKey("tui.select.confirm")} / click choose review mode · ${editorKey("tui.select.cancel")} back`,
1529
+ {
1530
+ title: "Review mode",
1531
+ subtitle: `When ${advisor.name || "the advisor"} reviews the session.`,
1532
+ icon: "review",
1533
+ noun: "modes",
1534
+ current: [current],
1535
+ decorate: item => ({ label: item.value }),
1536
+ },
1302
1537
  );
1303
1538
  }
1304
1539
 
@@ -1361,6 +1596,14 @@ export class AdvisorConfigOverlayComponent implements Component {
1361
1596
  "sync-backlog",
1362
1597
  list,
1363
1598
  `${editorKey("tui.select.confirm")} / click choose catch-up policy · ${editorKey("tui.select.cancel")} back`,
1599
+ {
1600
+ title: "Sync backlog",
1601
+ subtitle: `How long the session waits for ${advisor.name || "the advisor"}'s pending reviews.`,
1602
+ icon: "time",
1603
+ noun: "policies",
1604
+ current: [current ?? SYNC_BACKLOG_INHERIT],
1605
+ decorate: item => ({ label: item.value }),
1606
+ },
1364
1607
  );
1365
1608
  }
1366
1609
 
@@ -1370,7 +1613,7 @@ export class AdvisorConfigOverlayComponent implements Component {
1370
1613
  value: name,
1371
1614
  label: `${selected.has(name) ? "[x]" : "[ ]"} ${name}`,
1372
1615
  }));
1373
- items.push({ value: "__done", label: "Done" });
1616
+ items.push({ value: TOOLS_DONE, label: "Done" });
1374
1617
  const list = new SelectList(items, Math.max(1, items.length), getSelectListTheme());
1375
1618
  list.setSelectedIndex(cursor);
1376
1619
  let cursorIndex = cursor;
@@ -1378,7 +1621,7 @@ export class AdvisorConfigOverlayComponent implements Component {
1378
1621
  cursorIndex = items.findIndex(i => i.value === item.value);
1379
1622
  };
1380
1623
  list.onSelect = item => {
1381
- if (item.value === "__done") {
1624
+ if (item.value === TOOLS_DONE) {
1382
1625
  this.#doc.advisors[index].tools = commitTools(selected, all, this.#deps.defaultToolNames);
1383
1626
  this.#dirty = true;
1384
1627
  this.#showDetail(index);
@@ -1397,6 +1640,14 @@ export class AdvisorConfigOverlayComponent implements Component {
1397
1640
  "tools",
1398
1641
  list,
1399
1642
  `${editorKey("tui.select.confirm")} / click toggle · select Done or ${editorKey("tui.select.cancel")} to apply (empty = no tools; read/grep/glob = default)`,
1643
+ {
1644
+ title: "Tools",
1645
+ subtitle: `What ${this.#doc.advisors[index]?.name || "the advisor"} may call. None grants no tools; read, grep and glob are the default.`,
1646
+ icon: "tools",
1647
+ noun: "tools",
1648
+ current: [...selected],
1649
+ decorate: item => ({ label: item.value }),
1650
+ },
1400
1651
  );
1401
1652
  }
1402
1653