@timurproko/a1 0.2.5-dev.698 → 0.2.5

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 (38) hide show
  1. package/dist/app/session-shell/session-shell.js +9 -8
  2. package/dist/composition/owned-ui.js +2 -0
  3. package/dist/contracts/presentation/index.d.ts +6 -1
  4. package/dist/contracts/presentation/index.js +4 -2
  5. package/dist/features/owned-ui/resources/release-notes.json +4 -0
  6. package/dist/features/owned-ui/settings-app.d.ts +1 -1
  7. package/dist/features/owned-ui/settings-app.js +334 -63
  8. package/dist/integrations/pi/components/models-dialog.js +2 -2
  9. package/dist/integrations/pi/components/shell-editor-autocomplete.js +40 -2
  10. package/dist/integrations/pi/components/skills-dialog.js +2 -2
  11. package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +4 -2
  12. package/dist/integrations/pi/components/upstream/components/owned-editor.js +48 -12
  13. package/dist/integrations/pi/components/upstream/components/session-selector.js +14 -14
  14. package/dist/integrations/pi/components/upstream/components/thinking-selector.d.ts +5 -4
  15. package/dist/integrations/pi/components/upstream/components/thinking-selector.js +6 -4
  16. package/dist/integrations/pi/components/upstream/components/tree-selector.d.ts +7 -6
  17. package/dist/integrations/pi/components/upstream/components/tree-selector.js +24 -18
  18. package/dist/native/darwin-arm64/manifest.json +1 -1
  19. package/dist/native/linux-x64/manifest.json +1 -1
  20. package/dist/native/win32-x64/manifest.json +2 -2
  21. package/dist/native/win32-x64/process-guardian.exe +0 -0
  22. package/dist/ui/components/dialog-panel.d.ts +15 -0
  23. package/dist/ui/components/dialog-panel.js +50 -3
  24. package/dist/ui/components/index.d.ts +3 -3
  25. package/dist/ui/components/index.js +2 -2
  26. package/dist/ui/components/line-input.d.ts +2 -0
  27. package/dist/ui/components/line-input.js +2 -2
  28. package/dist/ui/components/prompt-input.d.ts +2 -2
  29. package/dist/ui/components/prompt-input.js +5 -5
  30. package/dist/ui/components/shortcut-hints.d.ts +8 -3
  31. package/dist/ui/components/shortcut-hints.js +25 -5
  32. package/dist/ui/components/shortcuts.d.ts +1 -1
  33. package/dist/ui/components/shortcuts.js +4 -0
  34. package/dist/ui/components/value-menu.js +3 -1
  35. package/dist/ui/settings/manager.d.ts +1 -0
  36. package/dist/ui/settings/manager.js +9 -3
  37. package/docs/releases/0.2.5.md +35 -0
  38. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
- import { DIALOG_CLOSE_SHORTCUT_HINT } from "../../contracts/presentation/index.js";
2
- import { GLOBAL_SCOPE, LineInput, PLAIN_THEME, ShortcutRegistry, assertNoShortcutConflicts, blockJumpTarget, dialogValueColumn, numericValues, RAIL_COLUMNS, renderDialogPanel, renderEmptyState, renderGroupHeader, renderInputRow, renderListRow, renderNote, renderShortcutHintsWithClose, dialogRowAt, menuRowAt, regionAt, renderValueMenu, withScrollbarRail, stepperEnds, steppedValue, truncateToWidth, valueColumnFor, valueMenuFrame, blockRowSpan, displayWidth, handleLineInputKey, humanizeLabel, humanizeTitle, indexOfKey, isThumbRow, layoutList, moveSelection, rowKey, scrollForSelection, scrollForTrackPage, scrollbarGeometry, scrollbarPresentation, ScrollbarRails, scrollbarWheelRows, selectableIndexes, } from "../../ui/components/index.js";
1
+ import { DIALOG_BACK_SHORTCUT_HINT, DIALOG_CLOSE_SHORTCUT_HINT } from "../../contracts/presentation/index.js";
2
+ import { GLOBAL_SCOPE, LineInput, PLAIN_THEME, ShortcutRegistry, assertNoShortcutConflicts, blockJumpTarget, numericValues, RAIL_COLUMNS, renderDialogPanel, renderSteppedDialogPanel, renderEmptyState, renderGroupHeader, renderInputRow, renderListRow, renderNote, renderShortcutHintsWithClose, menuRowAt, regionAt, renderValueMenu, withScrollbarRail, stepperEnds, steppedValue, truncateToWidth, valueColumnFor, valueMenuFrame, blockRowSpan, displayWidth, handleLineInputKey, humanizeLabel, humanizeTitle, indexOfKey, isThumbRow, layoutList, moveSelection, rowKey, scrollForSelection, scrollForTrackPage, scrollbarGeometry, scrollbarPresentation, ScrollbarRails, scrollbarWheelRows, selectableIndexes, } from "../../ui/components/index.js";
3
3
  import { SETTINGS_APP_ID, SETTINGS_ROUTE } from "./settings-route.js";
4
4
  export { SETTINGS_APP_ID, SETTINGS_ROUTE } from "./settings-route.js";
5
5
  const SCOPE = SETTINGS_APP_ID;
@@ -11,6 +11,8 @@ const SETTINGS_STATUS_ROWS = 1;
11
11
  const SETTINGS_CONTENT_INSET = 1;
12
12
  /** The panel a setting with parts opens: its own keys, its own hint. */
13
13
  const DIALOG_SCOPE = `${SETTINGS_APP_ID}-parts`;
14
+ const MODEL_SCOPE = `${SETTINGS_APP_ID}-models`;
15
+ const MODEL_LEVEL_SCOPE = `${SETTINGS_APP_ID}-model-levels`;
14
16
  const SCROLLBAR_TOP_INSET = 0;
15
17
  /** Identity of the settings list rail in the shared rail state. */
16
18
  const RAIL_KEY = "settings";
@@ -21,32 +23,43 @@ const SEARCH_PLACEHOLDER = "search settings";
21
23
  /** What a structured value offers instead of printing itself. */
22
24
  const CONFIGURE = "configure";
23
25
  export const SETTINGS_SHORTCUTS = new ShortcutRegistry();
24
- SETTINGS_SHORTCUTS.declare({ key: "/", scope: SCOPE, description: "Search settings", section: "Change", hint: { keys: "/", does: "to search" } }, "open-filter");
25
- SETTINGS_SHORTCUTS.declare({ key: "up", scope: SCOPE, description: "Previous setting", section: "Navigate", hint: { keys: "↑↓", does: "to navigate" } }, "move-up");
26
- SETTINGS_SHORTCUTS.declare({ key: "down", scope: SCOPE, description: "Next setting", section: "Navigate", hint: { keys: "↑↓", does: "to navigate" } }, "move-down");
27
- SETTINGS_SHORTCUTS.declare({ key: "shift+up", scope: SCOPE, description: "Previous section", section: "Navigate", hint: { keys: "Shift+↑↓", does: "to jump" } }, "block-up");
28
- SETTINGS_SHORTCUTS.declare({ key: "shift+down", scope: SCOPE, description: "Next section", section: "Navigate", hint: { keys: "Shift+↑↓", does: "to jump" } }, "block-down");
26
+ SETTINGS_SHORTCUTS.declare({ key: "/", scope: SCOPE, description: "Search settings", section: "Change", hint: { keys: "/", does: "search" } }, "open-filter");
27
+ SETTINGS_SHORTCUTS.declare({ key: "up", scope: SCOPE, description: "Previous setting", section: "Navigate", hint: { keys: "↑↓", does: "navigate" } }, "move-up");
28
+ SETTINGS_SHORTCUTS.declare({ key: "down", scope: SCOPE, description: "Next setting", section: "Navigate", hint: { keys: "↑↓", does: "navigate" } }, "move-down");
29
+ SETTINGS_SHORTCUTS.declare({ key: "shift+up", scope: SCOPE, description: "Previous section", section: "Navigate", hint: { keys: "Shift+↑↓", does: "jump" } }, "block-up");
30
+ SETTINGS_SHORTCUTS.declare({ key: "shift+down", scope: SCOPE, description: "Next section", section: "Navigate", hint: { keys: "Shift+↑↓", does: "jump" } }, "block-down");
29
31
  SETTINGS_SHORTCUTS.declare({ key: "pageUp", scope: SCOPE, description: "Up a page", section: "Navigate" }, "page-up");
30
32
  SETTINGS_SHORTCUTS.declare({ key: "pageDown", scope: SCOPE, description: "Down a page", section: "Navigate" }, "page-down");
31
33
  // Compatibility: the same chords the transcript uses for its content boundaries; plain
32
34
  // Home and End stay with the search input's cursor.
33
35
  SETTINGS_SHORTCUTS.declare({ key: "ctrl+home", scope: SCOPE, description: "First setting", section: "Navigate" }, "first");
34
36
  SETTINGS_SHORTCUTS.declare({ key: "ctrl+end", scope: SCOPE, description: "Last setting", section: "Navigate" }, "last");
35
- SETTINGS_SHORTCUTS.declare({ key: "enter", scope: SCOPE, description: "Change value", section: "Change", hint: { keys: "Enter/Space", does: "to change" } }, "activate");
36
- SETTINGS_SHORTCUTS.declare({ key: "space", scope: SCOPE, description: "Change value", section: "Change", hint: { keys: "Enter/Space", does: "to change" } }, "activate");
37
- SETTINGS_SHORTCUTS.declare({ key: "left", scope: SCOPE, description: "Previous value", section: "Change", hint: { keys: "←→", does: "to adjust" } }, "previous-value");
38
- SETTINGS_SHORTCUTS.declare({ key: "right", scope: SCOPE, description: "Next value", section: "Change", hint: { keys: "←→", does: "to adjust" } }, "next-value");
39
- SETTINGS_SHORTCUTS.declare({ key: "enter", scope: DIALOG_SCOPE, description: "Change this part", section: "Parts", hint: { keys: "Enter/Space", does: "to change" } }, "part-change");
40
- SETTINGS_SHORTCUTS.declare({ key: "space", scope: DIALOG_SCOPE, description: "Change this part", section: "Parts", hint: { keys: "Enter/Space", does: "to change" } }, "part-change");
37
+ SETTINGS_SHORTCUTS.declare({ key: "enter", scope: SCOPE, description: "Change value", section: "Change", hint: { keys: "Enter", does: "change" } }, "activate");
38
+ SETTINGS_SHORTCUTS.declare({ key: "left", scope: SCOPE, description: "Previous value", section: "Change", hint: { keys: "←→", does: "adjust" } }, "previous-value");
39
+ SETTINGS_SHORTCUTS.declare({ key: "right", scope: SCOPE, description: "Next value", section: "Change", hint: { keys: "←→", does: "adjust" } }, "next-value");
40
+ SETTINGS_SHORTCUTS.declare({ key: "ctrl+z", scope: SCOPE, description: "Undo setting change", section: "Change", hint: { keys: "Ctrl+Z", does: "undo" } }, "undo");
41
+ SETTINGS_SHORTCUTS.declare({ key: "enter", scope: DIALOG_SCOPE, description: "Change this part", section: "Parts", hint: { keys: "Enter", does: "change" } }, "part-change");
41
42
  SETTINGS_SHORTCUTS.declare({ key: "left", scope: DIALOG_SCOPE, description: "Change this part", section: "Parts" }, "part-change");
42
43
  SETTINGS_SHORTCUTS.declare({ key: "right", scope: DIALOG_SCOPE, description: "Change this part", section: "Parts" }, "part-change");
43
44
  SETTINGS_SHORTCUTS.declare({ key: "up", scope: DIALOG_SCOPE, description: "Previous part", section: "Parts" }, "part-previous");
44
45
  SETTINGS_SHORTCUTS.declare({ key: "down", scope: DIALOG_SCOPE, description: "Next part", section: "Parts" }, "part-next");
46
+ SETTINGS_SHORTCUTS.declare({ key: "ctrl+z", scope: DIALOG_SCOPE, description: "Undo setting change", section: "Parts", hint: { keys: "Ctrl+Z", does: "undo" } }, "undo");
45
47
  SETTINGS_SHORTCUTS.declare({
46
48
  key: "escape", scope: GLOBAL_SCOPE, description: "Close", section: "Screen",
47
49
  hint: { keys: DIALOG_CLOSE_SHORTCUT_HINT.key, does: DIALOG_CLOSE_SHORTCUT_HINT.action },
48
50
  }, "close");
49
51
  assertNoShortcutConflicts(SETTINGS_SHORTCUTS.assemble());
52
+ const MODEL_SHORTCUTS = new ShortcutRegistry();
53
+ MODEL_SHORTCUTS.declare({ key: "edit", scope: MODEL_SCOPE, description: "Search models", section: "Choose", hint: { keys: "Type", does: "search" } }, "edit-filter");
54
+ MODEL_SHORTCUTS.declare({ key: "up", scope: MODEL_SCOPE, description: "Previous model", section: "Choose" }, "previous");
55
+ MODEL_SHORTCUTS.declare({ key: "down", scope: MODEL_SCOPE, description: "Next model", section: "Choose" }, "next");
56
+ MODEL_SHORTCUTS.declare({ key: "enter", scope: MODEL_SCOPE, description: "Select model", section: "Choose", hint: { keys: "Enter", does: "select" } }, "select");
57
+ MODEL_SHORTCUTS.declare({ key: "escape", scope: MODEL_SCOPE, description: "Close selector", section: "Choose", hint: { keys: DIALOG_CLOSE_SHORTCUT_HINT.key, does: DIALOG_CLOSE_SHORTCUT_HINT.action } }, "close");
58
+ MODEL_SHORTCUTS.declare({ key: "up", scope: MODEL_LEVEL_SCOPE, description: "Previous level", section: "Choose", hint: { keys: "↑↓", does: "navigate" } }, "previous");
59
+ MODEL_SHORTCUTS.declare({ key: "down", scope: MODEL_LEVEL_SCOPE, description: "Next level", section: "Choose", hint: { keys: "↑↓", does: "navigate" } }, "next");
60
+ MODEL_SHORTCUTS.declare({ key: "enter", scope: MODEL_LEVEL_SCOPE, description: "Select level", section: "Choose", hint: { keys: "Enter", does: "select" } }, "select");
61
+ MODEL_SHORTCUTS.declare({ key: "escape", scope: MODEL_LEVEL_SCOPE, description: "Return to models", section: "Choose", hint: { keys: DIALOG_BACK_SHORTCUT_HINT.key, does: DIALOG_BACK_SHORTCUT_HINT.action } }, "back");
62
+ assertNoShortcutConflicts(MODEL_SHORTCUTS.assemble());
50
63
  const KEYS = {
51
64
  "\u001b[A": "up",
52
65
  "\u001b[B": "down",
@@ -66,6 +79,7 @@ const KEYS = {
66
79
  "\n": "enter",
67
80
  " ": "space",
68
81
  "/": "/",
82
+ "\u001a": "ctrl+z",
69
83
  };
70
84
  /**
71
85
  * The settings screen. Rows, sticky headers, scrolling, and the keymap come from
@@ -88,10 +102,13 @@ export class SettingsApp {
88
102
  #interruptArmed = false;
89
103
  // Invariant: pending values remain visible until the source reflects them.
90
104
  #pending = new Map();
91
- #dialogValueColumn = 0;
105
+ #undoHistory = [];
106
+ #editSequence = 0;
107
+ #undoRequests = 0;
108
+ #undoActive = false;
109
+ #closed = false;
92
110
  #bodyTopForFrame = SETTINGS_TOP_RULE_ROWS + SETTINGS_TITLE_ROWS;
93
111
  #bodyHeightForFrame = 0;
94
- #panelTop = 0;
95
112
  #panelTopForFrame = 0;
96
113
  #hoverKey = null;
97
114
  #hoverRegion = "label";
@@ -109,9 +126,13 @@ export class SettingsApp {
109
126
  this.#session = session;
110
127
  }
111
128
  onActivate(host) {
129
+ this.#closed = false;
112
130
  void this.#session.load().then(() => host.requestRender());
113
131
  }
114
132
  onClose(_host) {
133
+ this.#closed = true;
134
+ this.#undoHistory.length = 0;
135
+ this.#undoRequests = 0;
115
136
  this.#clearActivityTimer();
116
137
  this.#rails.clear();
117
138
  }
@@ -121,7 +142,9 @@ export class SettingsApp {
121
142
  const rows = this.#rows();
122
143
  const selected = indexOfKey(rows, this.#selectedKey);
123
144
  const footer = this.#footerLines(rect.width, theme);
124
- const dividerRows = this.#filter === null ? SETTINGS_FOOTER_DIVIDER_ROWS : 0;
145
+ // Invariant: the shared search input and structured panel each begin with their own standard rule.
146
+ // That rule replaces the ordinary content/footer divider instead of stacking beneath it.
147
+ const dividerRows = this.#filter === null && this.#structured === null ? SETTINGS_FOOTER_DIVIDER_ROWS : 0;
125
148
  const topRows = Math.min(SETTINGS_TOP_RULE_ROWS, rect.height);
126
149
  const contentHeight = Math.max(0, rect.height - topRows - dividerRows - footer.length);
127
150
  let titleRows = this.#scroll <= 0 ? Math.min(SETTINGS_TITLE_ROWS, contentHeight) : 0;
@@ -144,7 +167,6 @@ export class SettingsApp {
144
167
  this.#bodyTopForFrame = topRows + titleRows;
145
168
  this.#bodyHeightForFrame = bodyHeight;
146
169
  this.#panelTopForFrame = topRows + contentHeight + dividerRows;
147
- this.#panelTop = this.#panelTopForFrame;
148
170
  this.#scroll = layout.scroll;
149
171
  const now = Date.now();
150
172
  // Rationale: every way of scrolling ends in this frame, so a moved list is noticed here
@@ -278,6 +300,9 @@ export class SettingsApp {
278
300
  case "next-value":
279
301
  this.#cycle(rows, selected, action === "next-value" ? 1 : -1);
280
302
  return { consumed: true };
303
+ case "undo":
304
+ this.#undo();
305
+ return { consumed: true };
281
306
  default:
282
307
  return { consumed: false };
283
308
  }
@@ -318,30 +343,9 @@ export class SettingsApp {
318
343
  this.#apply(menu.entry, value);
319
344
  return { consumed: true };
320
345
  }
321
- const open = this.#structured;
322
- if (open !== null) {
323
- // Invariant: the panel owns the pointer while it is open; its flag rows are the targets.
324
- // The panel's rows begin one line below its rule.
325
- const panel = { firstRow: this.#panelTop + 1, rows: open.flags.length, valueColumn: this.#dialogValueColumn };
326
- const row = event.row - 1 - panel.firstRow;
327
- if (row < 0 || row >= panel.rows)
328
- return { consumed: true, render: false };
329
- if (event.kind === "motion") {
330
- if (open.index === row)
331
- return { consumed: true, render: false };
332
- open.index = row;
333
- return { consumed: true, render: true };
334
- }
335
- if (event.kind === "press") {
336
- open.index = row;
337
- // Rationale: pointing at the label picks the row; the value is what changes it,
338
- // exactly as in the list behind the dialog.
339
- const key = open.flags[row] ?? "";
340
- const width = displayWidth((open.record[key] ?? false) ? "true" : "false");
341
- if (dialogRowAt(panel, event.row - 1, event.column, width) !== null)
342
- this.#toggleFlag(row);
343
- return { consumed: true };
344
- }
346
+ if (this.#structured !== null) {
347
+ // Invariant: the structured surface is keyboard-only. It owns every pointer report so
348
+ // neither its selection nor the Settings list behind it can react.
345
349
  return { consumed: true, render: false };
346
350
  }
347
351
  if (event.kind === "wheel-up" || event.kind === "wheel-down") {
@@ -482,12 +486,25 @@ export class SettingsApp {
482
486
  this.#hoverKey = null;
483
487
  this.#hoverRegion = "label";
484
488
  this.#rails.clear();
485
- this.#structured = { entry, flags: entry.flags.map(flag => flag.key), index: 0, record };
489
+ const modelDialog = entry.backend === "agent" && entry.id === "modelThinkingLevels";
490
+ this.#structured = {
491
+ entry,
492
+ flags: entry.flags.map(flag => flag.key),
493
+ modelDialog,
494
+ filter: modelDialog ? new LineInput("") : null,
495
+ step: "model",
496
+ modelKey: null,
497
+ modelIndex: 0,
498
+ index: 0,
499
+ record,
500
+ };
486
501
  }
487
502
  #structuredKey(data) {
488
503
  const open = this.#structured;
489
504
  if (open === null)
490
505
  return { consumed: false };
506
+ if (open.modelDialog)
507
+ return this.#modelDialogKey(open, data);
491
508
  switch (SETTINGS_SHORTCUTS.resolve(KEYS[data] ?? data, DIALOG_SCOPE)) {
492
509
  case "close":
493
510
  this.#structured = null;
@@ -501,15 +518,94 @@ export class SettingsApp {
501
518
  case "part-change":
502
519
  this.#toggleFlag(open.index);
503
520
  return { consumed: true };
521
+ case "undo":
522
+ this.#undo();
523
+ return { consumed: true };
504
524
  default:
505
525
  return { consumed: true, render: false };
506
526
  }
507
527
  }
528
+ #modelDialogKey(open, data) {
529
+ const key = KEYS[data] ?? data;
530
+ if (open.step === "model") {
531
+ const flags = this.#filteredModelFlags(open);
532
+ const action = MODEL_SHORTCUTS.resolve(key, MODEL_SCOPE) ?? "edit-filter";
533
+ if (action === "close") {
534
+ this.#structured = null;
535
+ return { consumed: true };
536
+ }
537
+ if (action === "previous" || action === "next") {
538
+ open.index = Math.min(Math.max(0, flags.length - 1), Math.max(0, open.index + (action === "next" ? 1 : -1)));
539
+ return { consumed: true };
540
+ }
541
+ if (action === "select") {
542
+ const selected = flags[open.index];
543
+ if (selected === undefined)
544
+ return { consumed: true, render: false };
545
+ open.modelIndex = open.index;
546
+ open.modelKey = selected.key;
547
+ open.step = "level";
548
+ const options = this.#modelLevelOptions(open, selected);
549
+ const current = structuredValue(open.entry, open.record)[selected.key];
550
+ open.index = Math.max(0, options.findIndex(option => option.value === current));
551
+ return { consumed: true };
552
+ }
553
+ const input = open.filter;
554
+ if (input === null)
555
+ return { consumed: true, render: false };
556
+ handleLineInputKey(input, data);
557
+ open.index = 0;
558
+ return { consumed: true };
559
+ }
560
+ const selected = open.entry.flags.find(flag => flag.key === open.modelKey);
561
+ const choices = selected === undefined ? [] : this.#modelLevelOptions(open, selected);
562
+ const action = MODEL_SHORTCUTS.resolve(key, MODEL_LEVEL_SCOPE);
563
+ if (action === "back") {
564
+ open.step = "model";
565
+ open.modelKey = null;
566
+ open.index = open.modelIndex;
567
+ return { consumed: true };
568
+ }
569
+ if (action === "previous" || action === "next") {
570
+ open.index = Math.min(Math.max(0, choices.length - 1), Math.max(0, open.index + (action === "next" ? 1 : -1)));
571
+ return { consumed: true };
572
+ }
573
+ if (action === "select") {
574
+ const choice = choices[open.index];
575
+ if (selected === undefined || choice === undefined)
576
+ return { consumed: true, render: false };
577
+ const previous = { ...open.record };
578
+ open.record[selected.key] = choice.value;
579
+ const forward = { ...open.record };
580
+ this.#saveStructured(open, previous, forward);
581
+ open.step = "model";
582
+ open.modelKey = null;
583
+ open.index = open.modelIndex;
584
+ return { consumed: true };
585
+ }
586
+ return { consumed: true, render: false };
587
+ }
588
+ #filteredModelFlags(open) {
589
+ const needle = open.filter?.value.trim().toLowerCase() ?? "";
590
+ return open.entry.flags.filter(flag => needle.length === 0
591
+ || (flag.label ?? humanizeLabel(flag.key)).toLowerCase().includes(needle)
592
+ || flag.key.toLowerCase().includes(needle));
593
+ }
594
+ #modelLevelOptions(open, selected) {
595
+ const fallback = String(selected.fallback);
596
+ const supported = (selected.choices ?? [])
597
+ .filter(choice => choice !== fallback)
598
+ .map(choice => ({ value: choice, label: choice, description: thinkingLevelDescription(choice) }));
599
+ return structuredValue(open.entry, open.record)[selected.key] === undefined
600
+ ? supported
601
+ : [...supported, { value: fallback, label: "(clear override)", description: "Revert to global default" }];
602
+ }
508
603
  #toggleFlag(index) {
509
604
  const open = this.#structured;
510
605
  const flag = open?.flags[index];
511
606
  if (open === null || open === undefined || flag === undefined)
512
607
  return;
608
+ const previous = { ...open.record };
513
609
  const declared = open.entry.flags.find(candidate => candidate.key === flag);
514
610
  if (declared?.choices === undefined)
515
611
  open.record[flag] = !(open.record[flag] ?? false);
@@ -518,13 +614,24 @@ export class SettingsApp {
518
614
  const at = declared.choices.indexOf(String(open.record[flag] ?? declared.fallback));
519
615
  open.record[flag] = declared.choices[(at + 1) % declared.choices.length] ?? declared.fallback;
520
616
  }
521
- // Invariant: a choice part at its fallback is unset: it is not written, so the engine's default applies.
522
- const next = Object.fromEntries(Object.entries(open.record).filter(([key, value]) => {
523
- const part = open.entry.flags.find(candidate => candidate.key === key);
524
- return part?.choices === undefined || value !== part.fallback;
525
- }));
526
- void this.#session.changeStructured(open.entry.backend, open.entry.id, next).then(outcome => {
527
- this.#notice = outcome.failure === null ? null : `Could not save ${labelOf(open.entry)}: ${outcome.failure}`;
617
+ const forward = { ...open.record };
618
+ this.#saveStructured(open, previous, forward);
619
+ }
620
+ #saveStructured(open, previous, forward) {
621
+ const sequence = ++this.#editSequence;
622
+ void this.#session.changeStructured(open.entry.backend, open.entry.id, structuredValue(open.entry, forward)).then(outcome => {
623
+ const failure = changeFailure(outcome);
624
+ if (failure === null) {
625
+ this.#recordUndo({ kind: "structured", sequence, entry: open.entry, previous, forward });
626
+ this.#notice = outcome.status === "deferred" && outcome.application !== null
627
+ ? `${labelOf(open.entry)} is stored and applies ${applicationLabel(outcome.application)}`
628
+ : null;
629
+ return;
630
+ }
631
+ // Concurrency: only roll back the dialog if no later press has moved the record on.
632
+ if (this.#structured === open && sameRecord(open.record, forward))
633
+ replaceRecord(open.record, previous);
634
+ this.#notice = `Could not save ${labelOf(open.entry)}: ${failure}`;
528
635
  });
529
636
  }
530
637
  #menuKey(data) {
@@ -532,6 +639,11 @@ export class SettingsApp {
532
639
  if (menu === null)
533
640
  return { consumed: false };
534
641
  const key = KEYS[data] ?? data;
642
+ if (SETTINGS_SHORTCUTS.resolve(key, SCOPE) === "undo") {
643
+ this.#menu = null;
644
+ this.#undo();
645
+ return { consumed: true };
646
+ }
535
647
  if (key === "escape") {
536
648
  this.#menu = null;
537
649
  return { consumed: true };
@@ -555,6 +667,10 @@ export class SettingsApp {
555
667
  if (input === null)
556
668
  return { consumed: false };
557
669
  const key = KEYS[data];
670
+ if (SETTINGS_SHORTCUTS.resolve(key ?? data, SCOPE) === "undo") {
671
+ this.#undo();
672
+ return { consumed: true };
673
+ }
558
674
  // Rationale: the boundary chords jump through the results; plain Home and End stay with
559
675
  // the search cursor, which the shared line input moves below.
560
676
  if (key === "ctrl+home" || key === "ctrl+end") {
@@ -632,20 +748,21 @@ export class SettingsApp {
632
748
  }
633
749
  #apply(entry, value) {
634
750
  const key = `${entry.backend}:${entry.id}`;
751
+ const previous = this.#shownValue(entry);
752
+ const sequence = ++this.#editSequence;
635
753
  // Invariant: shown immediately so the row never lags a keypress, and so the next press
636
754
  // steps from here rather than from a value the source has not caught up to.
637
755
  this.#pending.set(key, value);
638
756
  void this.#session.change(entry.backend, entry.id, value).then(outcome => {
639
- if (outcome.failure !== null || outcome.status === "failed") {
640
- this.#pending.delete(key);
641
- this.#notice = `Could not save ${labelOf(entry)}: ${outcome.failure ?? "the effect failed"}`;
642
- return;
643
- }
644
- if (outcome.status === "unavailable" || outcome.limitationReason !== null) {
645
- this.#pending.delete(key);
646
- this.#notice = outcome.limitationReason ?? `${labelOf(entry)} is unavailable`;
757
+ const failure = changeFailure(outcome);
758
+ if (failure !== null) {
759
+ if (this.#pending.get(key) === value)
760
+ this.#pending.delete(key);
761
+ this.#notice = `Could not save ${labelOf(entry)}: ${failure}`;
647
762
  return;
648
763
  }
764
+ if (previous !== null && previous !== value)
765
+ this.#recordUndo({ kind: "scalar", sequence, entry, previous });
649
766
  // Concurrency: a later press may have moved on; only the last request clears itself.
650
767
  if (this.#pending.get(key) === value)
651
768
  this.#pending.delete(key);
@@ -657,6 +774,64 @@ export class SettingsApp {
657
774
  #shownValue(entry) {
658
775
  return this.#pending.get(`${entry.backend}:${entry.id}`) ?? entry.value;
659
776
  }
777
+ #recordUndo(record) {
778
+ if (this.#closed)
779
+ return;
780
+ const after = this.#undoHistory.findIndex(candidate => candidate.sequence > record.sequence);
781
+ if (after < 0)
782
+ this.#undoHistory.push(record);
783
+ else
784
+ this.#undoHistory.splice(after, 0, record);
785
+ }
786
+ #undo() {
787
+ this.#undoRequests++;
788
+ this.#drainUndo();
789
+ }
790
+ #drainUndo() {
791
+ if (this.#undoActive || this.#undoRequests === 0)
792
+ return;
793
+ const record = this.#undoHistory.pop();
794
+ if (record === undefined) {
795
+ this.#undoRequests = 0;
796
+ return;
797
+ }
798
+ this.#undoRequests--;
799
+ this.#undoActive = true;
800
+ if (record.kind === "scalar") {
801
+ const key = `${record.entry.backend}:${record.entry.id}`;
802
+ this.#pending.set(key, record.previous);
803
+ void this.#session.change(record.entry.backend, record.entry.id, record.previous).then(outcome => {
804
+ if (this.#pending.get(key) === record.previous)
805
+ this.#pending.delete(key);
806
+ this.#finishUndo(record, outcome);
807
+ });
808
+ return;
809
+ }
810
+ const open = this.#structured;
811
+ if (open !== null && sameEntry(open.entry, record.entry))
812
+ replaceRecord(open.record, record.previous);
813
+ void this.#session.changeStructured(record.entry.backend, record.entry.id, structuredValue(record.entry, record.previous)).then(outcome => {
814
+ const failure = changeFailure(outcome);
815
+ if (failure !== null && this.#structured === open && open !== null && sameRecord(open.record, record.previous)) {
816
+ replaceRecord(open.record, record.forward);
817
+ }
818
+ this.#finishUndo(record, outcome);
819
+ });
820
+ }
821
+ #finishUndo(record, outcome) {
822
+ const failure = changeFailure(outcome);
823
+ if (failure !== null) {
824
+ this.#recordUndo(record);
825
+ this.#notice = `Could not restore ${labelOf(record.entry)}: ${failure}`;
826
+ }
827
+ else {
828
+ this.#notice = outcome.status === "deferred" && outcome.application !== null
829
+ ? `${labelOf(record.entry)} is restored and applies ${applicationLabel(outcome.application)}`
830
+ : null;
831
+ }
832
+ this.#undoActive = false;
833
+ this.#drainUndo();
834
+ }
660
835
  #scrollbarSpeed() {
661
836
  const value = this.#scrollSetting("scrollbarSpeed");
662
837
  return isScrollbarSpeed(value) ? value : "normal";
@@ -744,15 +919,16 @@ export class SettingsApp {
744
919
  return ` ${renderListRow(this.#viewRow(entry), { selected, hovered, region: this.#hoverRegion }, valueColumn, innerWidth, theme)}`;
745
920
  }
746
921
  #viewRow(entry) {
922
+ const key = `${entry.backend}:${entry.id}`;
747
923
  const shown = this.#shownValue(entry);
748
924
  const value = entry.structured
749
925
  ? CONFIGURE
750
926
  : shown === null
751
927
  ? describeRaw(entry.rawValue)
752
- : effectiveDisplay(entry, shown);
928
+ : this.#pending.has(key) ? displayValue(shown) : effectiveDisplay(entry, shown);
753
929
  const range = rangeOf(entry);
754
930
  return {
755
- key: `${entry.backend}:${entry.id}`,
931
+ key,
756
932
  label: labelOf(entry),
757
933
  value,
758
934
  ...(typeof shown === "number" && entry.editable ? { stepper: stepperEnds(range, shown) } : {}),
@@ -770,7 +946,10 @@ export class SettingsApp {
770
946
  current: this.#shownValue(menu.entry) === null ? null : displayValue(this.#shownValue(menu.entry)),
771
947
  index: menu.index,
772
948
  };
773
- const frame = valueMenuFrame(state, { screenRow: anchor.screenRow, valueColumn }, {
949
+ const frame = valueMenuFrame(state, {
950
+ screenRow: anchor.screenRow,
951
+ valueColumn: valueColumn + SETTINGS_CONTENT_INSET,
952
+ }, {
774
953
  bodyTop: this.#bodyTopForFrame,
775
954
  bodyHeight: this.#bodyHeightForFrame,
776
955
  surfaceWidth: rect.width,
@@ -780,6 +959,8 @@ export class SettingsApp {
780
959
  return renderValueMenu(lines, state, frame, theme);
781
960
  }
782
961
  #dialogLines(open, width, theme) {
962
+ if (open.modelDialog)
963
+ return this.#modelDialogLines(open, width, theme);
783
964
  const rows = open.flags.map(key => {
784
965
  const declared = open.entry.flags.find(flag => flag.key === key);
785
966
  return {
@@ -789,9 +970,46 @@ export class SettingsApp {
789
970
  ...(declared?.description === undefined ? {} : { description: declared.description }),
790
971
  };
791
972
  });
792
- this.#dialogValueColumn = dialogValueColumn(rows);
793
- this.#panelTop = this.#panelTopForFrame;
794
- return renderDialogPanel({ rows, index: open.index, hint: SETTINGS_SHORTCUTS.hintEntries(DIALOG_SCOPE) }, width, theme);
973
+ return renderDialogPanel({ title: labelOf(open.entry), rows, index: open.index, hint: SETTINGS_SHORTCUTS.hintEntries(DIALOG_SCOPE) }, width, theme);
974
+ }
975
+ #modelDialogLines(open, width, theme) {
976
+ if (open.step === "model") {
977
+ const flags = this.#filteredModelFlags(open);
978
+ const stored = structuredValue(open.entry, open.record);
979
+ const allRows = flags.length === 0
980
+ ? [{ label: "No models found", value: "" }]
981
+ : flags.map(flag => ({
982
+ ...modelLabelParts(flag.label ?? humanizeLabel(flag.key)),
983
+ value: stored[flag.key] === undefined ? "" : String(stored[flag.key]),
984
+ }));
985
+ const visible = dialogWindow(allRows, open.index, 10);
986
+ return renderSteppedDialogPanel({
987
+ title: "Thinking Level",
988
+ step: 1,
989
+ steps: 2,
990
+ description: "Select a model to configure",
991
+ ...(open.filter === null ? {} : { input: open.filter }),
992
+ rows: visible.rows,
993
+ index: visible.index,
994
+ hint: MODEL_SHORTCUTS.hintEntries(MODEL_SCOPE),
995
+ }, width, theme);
996
+ }
997
+ const selected = open.entry.flags.find(flag => flag.key === open.modelKey);
998
+ const current = selected === undefined ? undefined : structuredValue(open.entry, open.record)[selected.key];
999
+ const allRows = (selected === undefined ? [] : this.#modelLevelOptions(open, selected)).map(choice => ({
1000
+ label: choice.value === current ? `✓ ${choice.label}` : choice.label,
1001
+ value: choice.description,
1002
+ }));
1003
+ const visible = dialogWindow(allRows, open.index, 10);
1004
+ return renderSteppedDialogPanel({
1005
+ title: "Thinking Level",
1006
+ step: 2,
1007
+ steps: 2,
1008
+ description: `Select default thinking level for ${selected?.label ?? open.modelKey ?? "model"}`,
1009
+ rows: visible.rows,
1010
+ index: visible.index,
1011
+ hint: MODEL_SHORTCUTS.hintEntries(MODEL_LEVEL_SCOPE),
1012
+ }, width, theme);
795
1013
  }
796
1014
  #footerLines(width, theme) {
797
1015
  const open = this.#structured;
@@ -810,6 +1028,59 @@ export class SettingsApp {
810
1028
  return [...renderInputRow(input, width, { placeholder: SEARCH_PLACEHOLDER, theme }).lines, status];
811
1029
  }
812
1030
  }
1031
+ function modelLabelParts(label) {
1032
+ const match = /^(.*?)(\s+\[[^\]]+\])$/u.exec(label);
1033
+ return match === null ? { label } : { label: match[1] ?? label, labelSuffix: match[2] ?? "" };
1034
+ }
1035
+ function dialogWindow(rows, index, maximum) {
1036
+ if (rows.length <= maximum)
1037
+ return { rows, index: Math.min(Math.max(0, rows.length - 1), Math.max(0, index)) };
1038
+ const start = Math.min(rows.length - maximum, Math.max(0, index - Math.floor(maximum / 2)));
1039
+ return { rows: rows.slice(start, start + maximum), index: index - start };
1040
+ }
1041
+ function thinkingLevelDescription(level) {
1042
+ switch (level) {
1043
+ case "default": return "global default";
1044
+ case "off": return "No reasoning";
1045
+ case "minimal": return "Very brief reasoning (~1k tokens)";
1046
+ case "low": return "Light reasoning (~2k tokens)";
1047
+ case "medium": return "Moderate reasoning (~8k tokens)";
1048
+ case "high": return "Deep reasoning (~16k tokens)";
1049
+ case "xhigh": return "Extra-high reasoning (~32k tokens)";
1050
+ case "max": return "Maximum reasoning";
1051
+ default: return "";
1052
+ }
1053
+ }
1054
+ function structuredValue(entry, record) {
1055
+ // Invariant: a choice part at its fallback is unset, so the engine's default applies.
1056
+ return Object.fromEntries(Object.entries(record).filter(([key, value]) => {
1057
+ const part = entry.flags.find(candidate => candidate.key === key);
1058
+ return part?.choices === undefined || value !== part.fallback;
1059
+ }));
1060
+ }
1061
+ function sameEntry(left, right) {
1062
+ return left.backend === right.backend && left.id === right.id;
1063
+ }
1064
+ function sameRecord(left, right) {
1065
+ const leftEntries = Object.entries(left);
1066
+ const rightEntries = Object.entries(right);
1067
+ return leftEntries.length === rightEntries.length
1068
+ && leftEntries.every(([key, value]) => right[key] === value);
1069
+ }
1070
+ function replaceRecord(target, source) {
1071
+ for (const key of Object.keys(target))
1072
+ delete target[key];
1073
+ Object.assign(target, source);
1074
+ }
1075
+ function changeFailure(outcome) {
1076
+ if (outcome.failure !== null)
1077
+ return outcome.failure;
1078
+ if (outcome.status === "failed")
1079
+ return "the effect failed";
1080
+ if (outcome.status === "unavailable")
1081
+ return outcome.limitationReason ?? "the setting is unavailable";
1082
+ return outcome.limitationReason;
1083
+ }
813
1084
  /** The source's own wording when it has one, otherwise the id made readable. */
814
1085
  function labelOf(entry) {
815
1086
  return entry.label ?? humanizeLabel(entry.id);
@@ -303,7 +303,7 @@ export class ModelsDialogComponent {
303
303
  const row = rows[index];
304
304
  const selected = index === this.#selectedIndex;
305
305
  const scoped = this.#scopeIds.includes(row.fullId);
306
- // Invariant: arrow, scope marker, model id, [provider], then the active checkmark, in that order.
306
+ // Invariant: arrow, scope marker, model id, [provider], then the active checkmark.
307
307
  const prefix = selected ? theme.fg("accent", "→ ") : " ";
308
308
  const marker = scoped ? theme.fg("accent", "●") : theme.fg("dim", "○");
309
309
  const label = selected ? theme.fg("text", row.model.id) : row.model.id;
@@ -330,7 +330,7 @@ export class ModelsDialogComponent {
330
330
  const confirm = keyLabel("tui.select.confirm");
331
331
  const save = keyLabel("app.models.save");
332
332
  return [
333
- { action: "type to search" },
333
+ { key: "Type", action: "search" },
334
334
  { key: "↑↓", action: "navigate" },
335
335
  ...(tab.length === 0 ? [] : [{ key: tab, action: "filter" }]),
336
336
  ...(confirm.length === 0 ? [] : [{ key: confirm, action: "switch" }]),