@timurproko/a1 0.2.7-dev.1236 → 0.2.7-dev.1239

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 (41) hide show
  1. package/dist/composition/owned-settings.js +1 -1
  2. package/dist/composition/release-update-observation.js +43 -14
  3. package/dist/contracts/owned-ui/session-workflows.d.ts +1 -1
  4. package/dist/features/owned-ui/plus-menu-dialog.d.ts +13 -7
  5. package/dist/features/owned-ui/plus-menu-dialog.js +17 -12
  6. package/dist/features/owned-ui/settings-app.js +94 -17
  7. package/dist/foundation/release/automatic-update.d.ts +12 -0
  8. package/dist/foundation/release/automatic-update.js +87 -5
  9. package/dist/integrations/pi/engine/default-model-setting.d.ts +12 -0
  10. package/dist/integrations/pi/engine/default-model-setting.js +70 -0
  11. package/dist/integrations/pi/engine/diagnostics-state.js +6 -5
  12. package/dist/integrations/pi/engine/index.d.ts +1 -1
  13. package/dist/integrations/pi/engine/index.js +1 -1
  14. package/dist/integrations/pi/engine/offline-settings-port.js +1 -0
  15. package/dist/integrations/pi/engine/runtime-integration.d.ts +1 -1
  16. package/dist/integrations/pi/engine/runtime-integration.js +12 -14
  17. package/dist/integrations/pi/engine/settings-bridge.d.ts +1 -2
  18. package/dist/integrations/pi/engine/settings-bridge.js +23 -29
  19. package/dist/integrations/pi/engine/settings-port.js +2 -1
  20. package/dist/integrations/pi/engine/workflow-runner.js +2 -2
  21. package/dist/native/darwin-arm64/manifest.json +1 -1
  22. package/dist/native/darwin-arm64/terminal-host.manifest.json +3 -3
  23. package/dist/native/darwin-arm64/terminal-host.node +0 -0
  24. package/dist/native/darwin-x64/manifest.json +1 -1
  25. package/dist/native/darwin-x64/terminal-host.manifest.json +3 -3
  26. package/dist/native/darwin-x64/terminal-host.node +0 -0
  27. package/dist/native/linux-x64/manifest.json +1 -1
  28. package/dist/native/linux-x64/terminal-host.manifest.json +3 -3
  29. package/dist/native/linux-x64/terminal-host.node +0 -0
  30. package/dist/native/win32-x64/manifest.json +2 -2
  31. package/dist/native/win32-x64/process-guardian.node +0 -0
  32. package/dist/native/win32-x64/terminal-host.manifest.json +3 -3
  33. package/dist/native/win32-x64/terminal-host.node +0 -0
  34. package/dist/ui/components/dialog-panel.d.ts +6 -3
  35. package/dist/ui/components/dialog-panel.js +10 -7
  36. package/dist/ui/components/theme.d.ts +1 -1
  37. package/dist/ui/settings/declarations.d.ts +23 -2
  38. package/dist/ui/settings/declarations.js +18 -2
  39. package/dist/ui/settings/migrations.js +7 -0
  40. package/docs/architecture/ui-reference-provenance.md +3 -3
  41. package/package.json +3 -3
@@ -14,7 +14,7 @@ export function createOwnedSettingsManager(options) {
14
14
  legacyConfigDir: productPaths.configDir,
15
15
  ...(options.agent === undefined ? {} : { agent: options.agent }),
16
16
  ...(options.agentProvider === undefined ? {} : { agentProvider: options.agentProvider }),
17
- ...(options.hideTabSettings ? { hiddenOwnedSettingIds: ["tabsAutoName", "tabsPosition", "tabBarPlusMenu"] } : {}),
17
+ ...(options.hideTabSettings ? { hiddenOwnedSettingIds: ["tabsAutoName", "tabsPosition", "tabBarPlusMenu", "tabBarRightItems"] } : {}),
18
18
  hiddenAgentSettingIds: ["fullscreenWheelScrollLines"],
19
19
  agentSettingLabelOverrides: { fullscreenCopyOnSelect: "Copy on select" },
20
20
  });
@@ -4,13 +4,41 @@ class ReleaseUpdateObservation {
4
4
  #controller = new AbortController();
5
5
  #timer;
6
6
  #announced = null;
7
+ #installedAnnounced = false;
7
8
  #eligible;
8
9
  #cadence = null;
9
10
  #nextDelay = null;
10
11
  #started = false;
11
12
  #running = false;
12
13
  #refresh = false;
14
+ #stopActiveReleaseObservation = () => { };
13
15
  constructor(input) { this.#input = input; this.#eligible = input.mode === "auto"; }
16
+ #announce(status, version) {
17
+ const key = `${status}:${version}`;
18
+ if (key === this.#announced || (status === "installed" && this.#installedAnnounced) || this.#controller.signal.aborted)
19
+ return;
20
+ this.#announced = key;
21
+ if (status === "installed") {
22
+ this.#installedAnnounced = true;
23
+ this.#eligible = false;
24
+ if (this.#timer !== undefined)
25
+ clearTimeout(this.#timer);
26
+ }
27
+ this.#input.backend.extensions.announceReleaseUpdate({ status, version });
28
+ }
29
+ async #startActiveReleaseObservation() {
30
+ const runningVersion = this.#input.packageVersion ?? await this.#input.readPackageVersion();
31
+ if (this.#controller.signal.aborted)
32
+ return;
33
+ const module = await import("../foundation/release/automatic-update.js");
34
+ if (this.#controller.signal.aborted)
35
+ return;
36
+ this.#stopActiveReleaseObservation = module.observeNewerActiveRelease({
37
+ runningVersion,
38
+ signal: this.#controller.signal,
39
+ onNewer: version => this.#announce("installed", version),
40
+ });
41
+ }
14
42
  async #observe() {
15
43
  // Performance: registry modules and their locks stay outside the first-frame startup graph.
16
44
  const releases = await import("../foundation/release/latest-release.js");
@@ -41,22 +69,18 @@ class ReleaseUpdateObservation {
41
69
  }
42
70
  if (input.mode !== "auto")
43
71
  return;
44
- const announce = (status, version) => {
45
- const actual = input.clientVersion?.();
46
- const key = `${status}:${version}`;
47
- if (key === this.#announced || this.#controller.signal.aborted
48
- || status !== "blocked-cleared" && actual && !releases.isNewerRelease(version, actual))
49
- return;
50
- this.#announced = key;
51
- input.backend.extensions.announceReleaseUpdate({ status, version });
52
- };
53
72
  const installed = await import("../foundation/release/automatic-update.js")
54
73
  .then(module => module.requestAutomaticUpdate({
55
74
  release, runningVersion, signal: this.#controller.signal,
56
- onActivationBlocked: version => announce(version === null ? "blocked-cleared" : "blocked", version ?? release.version),
75
+ onActivationBlocked: version => {
76
+ const clientVersion = input.clientVersion?.();
77
+ if (version !== null && clientVersion && !releases.isNewerRelease(version, clientVersion))
78
+ return;
79
+ this.#announce(version === null ? "blocked-cleared" : "blocked", version ?? release.version);
80
+ },
57
81
  }));
58
82
  if (installed !== null)
59
- announce("installed", installed);
83
+ this.#announce("installed", installed);
60
84
  }
61
85
  async #run() {
62
86
  if (this.#running) {
@@ -83,13 +107,18 @@ class ReleaseUpdateObservation {
83
107
  if (this.#started || this.#controller.signal.aborted)
84
108
  return;
85
109
  this.#started = true;
110
+ void this.#startActiveReleaseObservation().catch(() => undefined);
86
111
  void this.#run();
87
112
  }
88
- stop() { this.#controller.abort(); if (this.#timer !== undefined)
89
- clearTimeout(this.#timer); }
113
+ stop() {
114
+ this.#controller.abort();
115
+ this.#stopActiveReleaseObservation();
116
+ if (this.#timer !== undefined)
117
+ clearTimeout(this.#timer);
118
+ }
90
119
  clientChanged() {
91
120
  this.#announced = null;
92
- if (!this.#started || this.#controller.signal.aborted || this.#input.mode === "no")
121
+ if (!this.#started || this.#controller.signal.aborted || this.#installedAnnounced || this.#input.mode === "no")
93
122
  return;
94
123
  if (this.#timer !== undefined)
95
124
  clearTimeout(this.#timer);
@@ -292,7 +292,7 @@ export type OwnedUiJsonValue = null | boolean | number | string | readonly Owned
292
292
  /** The layer that installs a setting's live effect. */
293
293
  export type OwnedUiSettingOwner = "agent" | "shell" | "terminal" | "startup" | "shutdown" | "installation";
294
294
  /** Engine settings whose live effect an owner may bind. */
295
- export type OwnedUiSessionSettingKey = "autoCompact" | "showImages" | "imageWidthCells" | "autoResizeImages" | "blockImages" | "enableSkillCommands" | "steeringMode" | "followUpMode" | "transport" | "httpIdleTimeoutMs" | "cacheWarmingMode" | "modelThinkingLevels" | "theme" | "hideThinkingBlock" | "mermaidRenderingMode" | "showCacheMissNotices" | "collapseChangelog" | "enableInstallTelemetry" | "quietStartup" | "defaultProjectTrust" | "sessionShareDestination" | "doubleEscapeAction" | "treeFilterMode" | "showHardwareCursor" | "editorPaddingX" | "outputPad" | "autocompleteMaxVisible" | "clearOnShrink" | "showTerminalProgress" | "tuiMode" | "fullscreenExitOutput" | "fullscreenScrollbar" | "fullscreenCopyOnSelect" | "fullscreenWheelScrollLines" | "warnings";
295
+ export type OwnedUiSessionSettingKey = "autoCompact" | "showImages" | "imageWidthCells" | "autoResizeImages" | "blockImages" | "enableSkillCommands" | "steeringMode" | "followUpMode" | "transport" | "httpIdleTimeoutMs" | "cacheWarmingMode" | "defaultModel" | "modelThinkingLevels" | "theme" | "hideThinkingBlock" | "mermaidRenderingMode" | "showCacheMissNotices" | "collapseChangelog" | "enableInstallTelemetry" | "quietStartup" | "defaultProjectTrust" | "sessionShareDestination" | "doubleEscapeAction" | "treeFilterMode" | "showHardwareCursor" | "editorPaddingX" | "outputPad" | "autocompleteMaxVisible" | "clearOnShrink" | "showTerminalProgress" | "tuiMode" | "fullscreenExitOutput" | "fullscreenScrollbar" | "fullscreenCopyOnSelect" | "fullscreenWheelScrollLines" | "warnings";
296
296
  export interface OwnedUiSettingEffectHandler {
297
297
  /** Install one value in the active owner. Handlers must be idempotent and reversible. */
298
298
  apply(value: OwnedUiJsonValue): void | Promise<void>;
@@ -6,17 +6,23 @@ export interface PlusMenuKind {
6
6
  readonly key: string;
7
7
  readonly label: string;
8
8
  }
9
- /**
10
- * The plus-menu dialog: every tab kind with `●` when `+` offers it and `○` when not, filtered by typed
11
- * text. It owns only its filter and the row in hand; the enabled record and its saving stay with the
12
- * Settings screen that opened it.
13
- */
14
- export declare class PlusMenuDialog {
9
+ /** Presentation copy for a searchable ordered flag multi-select dialog. */
10
+ export interface FlagToggleDialogPresentation {
11
+ readonly title: string;
12
+ readonly description: string;
13
+ readonly empty: string;
14
+ }
15
+ /** Owns only filtering and selection; the Settings screen owns enabled state and persistence. */
16
+ export declare class FlagToggleDialog {
15
17
  #private;
16
- constructor(kinds: readonly PlusMenuKind[]);
18
+ constructor(kinds: readonly PlusMenuKind[], presentation: FlagToggleDialogPresentation);
17
19
  /** The kinds the filter keeps, in kind order. */
18
20
  shown(): readonly PlusMenuKind[];
19
21
  /** Handles one key by its resolved name; `toggle` flips a kind. Returns whether the dialog closed. */
20
22
  handle(name: string, data: string, toggle: (key: string) => void): "close" | "handled";
21
23
  lines(enabled: Readonly<Record<string, boolean | string>>, width: number, theme: UiTheme): readonly string[];
22
24
  }
25
+ /** The tab bar's specialized flag dialog, retained as the public Plus Menu surface. */
26
+ export declare class PlusMenuDialog extends FlagToggleDialog {
27
+ constructor(kinds: readonly PlusMenuKind[]);
28
+ }
@@ -1,8 +1,11 @@
1
1
  import { LineInput, handleLineInputKey, padToWidth, renderInputRow, renderShortcutHintsWithClose, truncateToWidth, } from "../../ui/components/index.js";
2
2
  /** The A1 setting holding the tab kinds the strip's `+` offers. */
3
3
  export const PLUS_MENU_SETTING_ID = "tabBarPlusMenu";
4
- const TITLE = "Plus Menu";
5
- const DESCRIPTION = "Choose what the + button in the tab bar can add.";
4
+ const PLUS_MENU_PRESENTATION = Object.freeze({
5
+ title: "Plus Menu",
6
+ description: "Choose what the + button in the tab bar can add.",
7
+ empty: "No tabs match",
8
+ });
6
9
  const HINTS = [
7
10
  { key: "Type", action: "search" },
8
11
  { key: "↑↓", action: "navigate" },
@@ -10,17 +13,15 @@ const HINTS = [
10
13
  { key: "esc", action: "close" },
11
14
  ];
12
15
  const PADDING_X = 2;
13
- /**
14
- * The plus-menu dialog: every tab kind with `●` when `+` offers it and `○` when not, filtered by typed
15
- * text. It owns only its filter and the row in hand; the enabled record and its saving stay with the
16
- * Settings screen that opened it.
17
- */
18
- export class PlusMenuDialog {
16
+ /** Owns only filtering and selection; the Settings screen owns enabled state and persistence. */
17
+ export class FlagToggleDialog {
19
18
  #kinds;
19
+ #presentation;
20
20
  #filter = new LineInput("");
21
21
  #index = 0;
22
- constructor(kinds) {
22
+ constructor(kinds, presentation) {
23
23
  this.#kinds = kinds;
24
+ this.#presentation = presentation;
24
25
  }
25
26
  /** The kinds the filter keeps, in kind order. */
26
27
  shown() {
@@ -60,7 +61,7 @@ export class PlusMenuDialog {
60
61
  const shown = this.shown();
61
62
  const index = Math.min(this.#index, Math.max(0, shown.length - 1));
62
63
  const rows = shown.length === 0
63
- ? [row(theme.fg("dim", "No tabs match"))]
64
+ ? [row(theme.fg("dim", this.#presentation.empty))]
64
65
  : shown.map((kind, position) => {
65
66
  const selected = position === index;
66
67
  const cursor = selected ? "→ " : " ";
@@ -70,8 +71,8 @@ export class PlusMenuDialog {
70
71
  });
71
72
  return [
72
73
  blank,
73
- row(theme.bold(theme.fg("accent", TITLE))),
74
- row(theme.fg("dim", DESCRIPTION)),
74
+ row(theme.bold(theme.fg("accent", this.#presentation.title))),
75
+ row(theme.fg("dim", this.#presentation.description)),
75
76
  blank,
76
77
  ...renderInputRow(this.#filter, contentWidth, { ruled: false, promptGlyph: "> ", theme }).lines.map(line => row(line)),
77
78
  blank,
@@ -82,3 +83,7 @@ export class PlusMenuDialog {
82
83
  ];
83
84
  }
84
85
  }
86
+ /** The tab bar's specialized flag dialog, retained as the public Plus Menu surface. */
87
+ export class PlusMenuDialog extends FlagToggleDialog {
88
+ constructor(kinds) { super(kinds, PLUS_MENU_PRESENTATION); }
89
+ }
@@ -1,6 +1,6 @@
1
1
  import { DIALOG_BACK_SHORTCUT_HINT, DIALOG_CLOSE_SHORTCUT_HINT } from "../../contracts/presentation/index.js";
2
2
  import { GLOBAL_SCOPE, LineInput, PLAIN_THEME, ShortcutRegistry, assertNoShortcutConflicts, blockJumpTarget, configureDialogChoiceCapacity, configureDialogTextRows, configureDialogWidth, numericValues, RAIL_COLUMNS, renderCenteredDialogOverlay, renderDialogPanel, renderSteppedDialogPanel, renderEmptyState, renderGroupHeader, renderListRow, renderNote, renderShortcutHintsWithClose, menuRowAt, regionAt, renderValueMenu, withScrollbarRail, stepperEnds, steppedValue, truncateToWidth, valueColumnFor, valueMenuFrame, blockRowSpan, displayWidth, formatLocalTime, handleLineInputKey, humanizeLabel, humanizeTitle, indexOfKey, layoutList, moveSelection, rowKey, scrollForSelection, scrollForTrackPage, scrollbarGeometry, scrollbarPresentation, ScrollbarRails, scrollbarWheelRows, selectableIndexes, } from "../../ui/components/index.js";
3
- import { PLUS_MENU_SETTING_ID, PlusMenuDialog } from "./plus-menu-dialog.js";
3
+ import { FlagToggleDialog, PLUS_MENU_SETTING_ID, PlusMenuDialog } from "./plus-menu-dialog.js";
4
4
  import { SETTINGS_APP_ID } from "./settings-route.js";
5
5
  export { SETTINGS_APP_ID, SETTINGS_ROUTE } from "./settings-route.js";
6
6
  const SCOPE = SETTINGS_APP_ID;
@@ -22,6 +22,7 @@ const SCROLL_LINGER_MS = 900;
22
22
  const SCROLL_LINGER_REPAINT_MS = 925;
23
23
  /** What a structured value offers instead of printing itself. */
24
24
  const CONFIGURE = "configure";
25
+ const TAB_BAR_RIGHT_ITEMS_SETTING_ID = "tabBarRightItems";
25
26
  export const SETTINGS_SHORTCUTS = new ShortcutRegistry();
26
27
  SETTINGS_SHORTCUTS.declare({ key: "edit", scope: SCOPE, description: "Search settings", section: "Change", hint: { keys: "Type", does: "search" } }, "edit-filter");
27
28
  SETTINGS_SHORTCUTS.declare({ key: "up", scope: SCOPE, description: "Previous setting", section: "Navigate", hint: { keys: "↑↓", does: "navigate" } }, "move-up");
@@ -281,7 +282,7 @@ export class SettingsApp {
281
282
  return { consumed: false };
282
283
  case "activate": {
283
284
  const row = rows[selected];
284
- if (row?.kind === "element" && row.value.structured)
285
+ if (row?.kind === "element" && (row.value.structured || isDefaultModelEntry(row.value)))
285
286
  this.#openStructured(row.value);
286
287
  else if (row?.kind === "element" && typeof this.#shownValue(row.value) === "number")
287
288
  this.#cycle(rows, selected, 1);
@@ -469,7 +470,7 @@ export class SettingsApp {
469
470
  if (row === undefined || row.kind !== "element")
470
471
  return;
471
472
  const entry = row.value;
472
- if (entry.structured) {
473
+ if (entry.structured || isDefaultModelEntry(entry)) {
473
474
  this.#openStructured(entry);
474
475
  return;
475
476
  }
@@ -508,9 +509,13 @@ export class SettingsApp {
508
509
  }
509
510
  // Rationale: a structured setting opens as its own flag list rather than a value menu.
510
511
  #openStructured(entry) {
512
+ const modelDialog = entry.backend === "agent" && entry.id === "modelThinkingLevels"
513
+ ? "thinking"
514
+ : isDefaultModelEntry(entry) ? "default" : null;
511
515
  // Invariant: the flags come from the declaration, not from the stored value: an unset
512
- // flag still has a row, showing the default the source would apply.
513
- if (entry.flags.length === 0)
516
+ // flag still has a row, showing the default the source would apply. The default-model
517
+ // selector may open empty so it can truthfully report that no authenticated models exist.
518
+ if (entry.flags.length === 0 && modelDialog !== "default")
514
519
  return;
515
520
  // Rationale: beside a tab bar, its own Plus Menu dialog configures `+`, so both ways look the same.
516
521
  if (entry.backend === "a1" && entry.id === PLUS_MENU_SETTING_ID && this.#options.configurePlusMenu?.() === true)
@@ -530,17 +535,24 @@ export class SettingsApp {
530
535
  this.#hoverKey = null;
531
536
  this.#hoverRegion = "label";
532
537
  this.#rails.clear();
533
- const modelDialog = entry.backend === "agent" && entry.id === "modelThinkingLevels";
538
+ const currentDefault = typeof entry.rawValue === "string" ? entry.rawValue : null;
534
539
  this.#structured = {
535
540
  entry,
536
541
  flags: entry.flags.map(flag => flag.key),
537
542
  modelDialog,
538
- plusMenu: entry.backend === "a1" && entry.id === PLUS_MENU_SETTING_ID ? new PlusMenuDialog(entry.flags) : null,
539
- filter: modelDialog ? new LineInput("") : null,
543
+ flagDialog: entry.backend !== "a1" ? null
544
+ : entry.id === PLUS_MENU_SETTING_ID ? new PlusMenuDialog(entry.flags)
545
+ : entry.id === TAB_BAR_RIGHT_ITEMS_SETTING_ID ? new FlagToggleDialog(entry.flags, {
546
+ title: "Tab Bar Right Side",
547
+ description: "Choose what the right side of the tab bar shows.",
548
+ empty: "No items match",
549
+ }) : null,
550
+ filter: modelDialog === null ? null : new LineInput(""),
540
551
  step: "model",
541
552
  modelKey: null,
542
553
  modelIndex: 0,
543
- index: 0,
554
+ index: modelDialog === "default" ? Math.max(0, entry.flags.findIndex(flag => flag.key === currentDefault)) : 0,
555
+ error: null,
544
556
  record,
545
557
  };
546
558
  }
@@ -548,10 +560,10 @@ export class SettingsApp {
548
560
  const open = this.#structured;
549
561
  if (open === null)
550
562
  return { consumed: false };
551
- if (open.modelDialog)
563
+ if (open.modelDialog !== null)
552
564
  return this.#modelDialogKey(open, data);
553
- if (open.plusMenu !== null) {
554
- const outcome = open.plusMenu.handle(KEYS[data] ?? data, data, key => this.#toggleFlag(open.flags.indexOf(key)));
565
+ if (open.flagDialog !== null) {
566
+ const outcome = open.flagDialog.handle(KEYS[data] ?? data, data, key => this.#toggleFlag(open.flags.indexOf(key)));
555
567
  if (outcome === "close")
556
568
  this.#structured = null;
557
569
  return { consumed: true };
@@ -578,6 +590,38 @@ export class SettingsApp {
578
590
  }
579
591
  #modelDialogKey(open, data) {
580
592
  const key = KEYS[data] ?? data;
593
+ if (open.modelDialog === "default") {
594
+ const flags = this.#filteredModelFlags(open);
595
+ const action = MODEL_SHORTCUTS.resolve(key, MODEL_SCOPE) ?? "edit-filter";
596
+ if (action === "close") {
597
+ this.#structured = null;
598
+ return { consumed: true };
599
+ }
600
+ if (action === "previous" || action === "next") {
601
+ open.index = Math.min(Math.max(0, flags.length - 1), Math.max(0, open.index + (action === "next" ? 1 : -1)));
602
+ return { consumed: true };
603
+ }
604
+ if (action === "select") {
605
+ const selected = flags[open.index];
606
+ if (selected === undefined)
607
+ return { consumed: true, render: false };
608
+ open.error = null;
609
+ this.#structured = null;
610
+ void this.#session.change(open.entry.backend, open.entry.id, selected.key).then(outcome => {
611
+ const failure = changeFailure(outcome);
612
+ if (failure !== null) {
613
+ open.error = `Could not save: ${failure}`;
614
+ this.#structured = open;
615
+ }
616
+ });
617
+ return { consumed: true };
618
+ }
619
+ if (open.filter === null)
620
+ return { consumed: true, render: false };
621
+ handleLineInputKey(open.filter, data);
622
+ open.index = 0;
623
+ return { consumed: true };
624
+ }
581
625
  if (open.step === "model") {
582
626
  const flags = this.#filteredModelFlags(open);
583
627
  const action = MODEL_SHORTCUTS.resolve(key, MODEL_SCOPE) ?? "edit-filter";
@@ -640,6 +684,7 @@ export class SettingsApp {
640
684
  const needle = open.filter?.value.trim().toLowerCase() ?? "";
641
685
  return open.entry.flags.filter(flag => needle.length === 0
642
686
  || (flag.label ?? humanizeLabel(flag.key)).toLowerCase().includes(needle)
687
+ || flag.description.toLowerCase().includes(needle)
643
688
  || flag.key.toLowerCase().includes(needle));
644
689
  }
645
690
  #modelLevelOptions(open, selected) {
@@ -897,7 +942,8 @@ export class SettingsApp {
897
942
  // Rationale: a section named by the search is what the reader asked for, so it arrives
898
943
  // whole rather than narrowed to the entries that happen to repeat its name.
899
944
  const named = needle.length > 0 && section.title.toLowerCase().includes(needle);
900
- const entries = named ? section.entries : section.entries.filter(matches);
945
+ const matching = named ? section.entries : section.entries.filter(matches);
946
+ const entries = orderDefaultModelBeforeThinking(matching, section.id);
901
947
  if (needle.length > 0 && entries.length === 0)
902
948
  continue;
903
949
  if (rows.length > 0)
@@ -947,7 +993,7 @@ export class SettingsApp {
947
993
  #viewRow(entry) {
948
994
  const key = `${entry.backend}:${entry.id}`;
949
995
  const shown = this.#shownValue(entry);
950
- const value = entry.structured
996
+ const value = entry.structured || isDefaultModelEntry(entry)
951
997
  ? CONFIGURE
952
998
  : shown === null
953
999
  ? describeRaw(entry.rawValue)
@@ -988,10 +1034,10 @@ export class SettingsApp {
988
1034
  return renderValueMenu(lines, state, frame, theme);
989
1035
  }
990
1036
  #dialogLines(open, width, height, theme) {
991
- if (open.modelDialog)
1037
+ if (open.modelDialog !== null)
992
1038
  return this.#modelDialogLines(open, width, height, theme);
993
- if (open.plusMenu !== null)
994
- return open.plusMenu.lines(open.record, width, theme);
1039
+ if (open.flagDialog !== null)
1040
+ return open.flagDialog.lines(open.record, width, theme);
995
1041
  const rows = open.flags.map(key => {
996
1042
  const declared = open.entry.flags.find(flag => flag.key === key);
997
1043
  return {
@@ -1005,6 +1051,23 @@ export class SettingsApp {
1005
1051
  return renderDialogPanel({ title: labelOf(open.entry), rows: visible.rows, index: visible.index, hint: SETTINGS_SHORTCUTS.hintEntries(DIALOG_SCOPE) }, width, theme);
1006
1052
  }
1007
1053
  #modelDialogLines(open, width, height, theme) {
1054
+ if (open.modelDialog === "default") {
1055
+ const flags = this.#filteredModelFlags(open);
1056
+ const current = typeof open.entry.rawValue === "string" ? open.entry.rawValue : null;
1057
+ const rows = flags.length === 0
1058
+ ? [{ label: "No models found", value: "" }]
1059
+ : flags.map(flag => ({
1060
+ ...modelLabelParts(flag.label ?? humanizeLabel(flag.key)),
1061
+ ...(flag.key === current ? { successSuffix: "✓" } : {}),
1062
+ value: "",
1063
+ }));
1064
+ const description = open.error ?? "Select the model used for new sessions";
1065
+ const visible = dialogWindow(rows, open.index, configureDialogChoiceCapacity(height, true, configureDialogTextRows(description, width)));
1066
+ return renderSteppedDialogPanel({
1067
+ title: "Default Model", description, input: open.filter ?? new LineInput(""),
1068
+ rows: visible.rows, index: visible.index, hint: MODEL_SHORTCUTS.hintEntries(MODEL_SCOPE),
1069
+ }, width, theme);
1070
+ }
1008
1071
  if (open.step === "model") {
1009
1072
  const flags = this.#filteredModelFlags(open);
1010
1073
  const stored = structuredValue(open.entry, open.record);
@@ -1086,6 +1149,17 @@ function handleSettingsSearchPointer(state, event, panelTop) {
1086
1149
  const handled = input.handlePointer({ kind: event.kind, button: event.button, column: event.column, row });
1087
1150
  return { consumed: handled || overInput, render: handled };
1088
1151
  }
1152
+ function orderDefaultModelBeforeThinking(entries, sectionId) {
1153
+ if (sectionId !== "agent")
1154
+ return entries;
1155
+ const defaultModel = entries.find(isDefaultModelEntry);
1156
+ if (defaultModel === undefined)
1157
+ return entries;
1158
+ const ordered = entries.filter(entry => entry !== defaultModel);
1159
+ const thinking = ordered.findIndex(entry => entry.backend === "agent" && entry.id === "modelThinkingLevels");
1160
+ ordered.splice(thinking < 0 ? ordered.length : thinking, 0, defaultModel);
1161
+ return ordered;
1162
+ }
1089
1163
  function modelLabelParts(label) {
1090
1164
  const match = /^(.*?)(\s+\[[^\]]+\])$/u.exec(label);
1091
1165
  return match === null ? { label } : { label: match[1] ?? label, labelSuffix: match[2] ?? "" };
@@ -1139,6 +1213,9 @@ function changeFailure(outcome) {
1139
1213
  return outcome.limitationReason ?? "the setting is unavailable";
1140
1214
  return outcome.limitationReason;
1141
1215
  }
1216
+ function isDefaultModelEntry(entry) {
1217
+ return entry.backend === "agent" && entry.id === "defaultModel";
1218
+ }
1142
1219
  /** The source's own wording when it has one, otherwise the id made readable. */
1143
1220
  function labelOf(entry) {
1144
1221
  return entry.label ?? humanizeLabel(entry.id);
@@ -2,6 +2,7 @@ import { type AvailableRelease, type ReleaseChannel } from "./latest-release.js"
2
2
  export declare const AUTOMATIC_UPDATE_STATE_SCHEMA = 1;
3
3
  export declare const AUTOMATIC_UPDATE_RETRY_MS: number;
4
4
  export declare const AUTOMATIC_UPDATE_ACTIVATION_RETRY_MS: number;
5
+ export declare const ACTIVE_RELEASE_FALLBACK_MS = 5000;
5
6
  export interface AutomaticUpdateState {
6
7
  readonly schema: typeof AUTOMATIC_UPDATE_STATE_SCHEMA;
7
8
  readonly channel: ReleaseChannel;
@@ -18,6 +19,17 @@ export declare class AutomaticUpdateStateStore {
18
19
  read(): Promise<AutomaticUpdateState | null>;
19
20
  write(value: AutomaticUpdateState): Promise<void>;
20
21
  }
22
+ export interface ActiveReleaseObservationOptions {
23
+ readonly runningVersion: string;
24
+ readonly environment?: NodeJS.ProcessEnv;
25
+ readonly signal?: AbortSignal;
26
+ readonly onNewer: (version: string) => void;
27
+ readonly fallbackMs?: number;
28
+ }
29
+ /** Read the approved active release only when this process executes an older release on that channel. */
30
+ export declare function readNewerActiveRelease(runningVersion: string, environment?: NodeJS.ProcessEnv): Promise<string | null>;
31
+ /** Observe local release activation promptly, with a bounded fallback for missed filesystem events. */
32
+ export declare function observeNewerActiveRelease(options: ActiveReleaseObservationOptions): () => void;
21
33
  export interface AutomaticUpdateRequestOptions {
22
34
  readonly release: AvailableRelease;
23
35
  readonly runningVersion: string;
@@ -1,7 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
4
- import { dirname, resolve } from "node:path";
3
+ import { watch } from "node:fs";
4
+ import { mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
5
+ import { basename, dirname, resolve } from "node:path";
5
6
  import { valid as validSemver } from "semver";
6
7
  import { readLaunchContext } from "../launch-context/index.js";
7
8
  import { resolveProductPaths } from "../lifecycle/index.js";
@@ -19,6 +20,7 @@ const AUTOMATIC_UPDATE_STATE_FILENAME = "automatic-update.json";
19
20
  const MAX_STATE_BYTES = 16_384;
20
21
  const OBSERVER_POLL_MS = 250;
21
22
  const WORKER_START_GRACE_MS = 5_000;
23
+ export const ACTIVE_RELEASE_FALLBACK_MS = 5_000;
22
24
  export class AutomaticUpdateStateStore {
23
25
  path;
24
26
  constructor(dataDir) { this.path = resolve(dataDir, AUTOMATIC_UPDATE_STATE_FILENAME); }
@@ -59,6 +61,82 @@ export class AutomaticUpdateStateStore {
59
61
  }
60
62
  }
61
63
  }
64
+ /** Read the approved active release only when this process executes an older release on that channel. */
65
+ export async function readNewerActiveRelease(runningVersion, environment = process.env) {
66
+ const channel = releaseChannelOf(runningVersion);
67
+ if (channel === null)
68
+ return null;
69
+ try {
70
+ const paths = resolveProductPaths(environment);
71
+ const active = await activeRelease(paths.dataDir);
72
+ return activeNewerPackageVersion(active, runningVersion, channel) ?? null;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ /** Observe local release activation promptly, with a bounded fallback for missed filesystem events. */
79
+ export function observeNewerActiveRelease(options) {
80
+ if (releaseChannelOf(options.runningVersion) === null || options.signal?.aborted)
81
+ return () => { };
82
+ const paths = resolveProductPaths(options.environment ?? process.env);
83
+ const store = new CohortStateStore(paths.dataDir);
84
+ let closed = false;
85
+ let checking = false;
86
+ let requested = false;
87
+ let watcher = null;
88
+ let signature;
89
+ const stop = () => {
90
+ if (closed)
91
+ return;
92
+ closed = true;
93
+ clearInterval(fallback);
94
+ watcher?.close();
95
+ options.signal?.removeEventListener("abort", stop);
96
+ };
97
+ const check = async () => {
98
+ if (closed)
99
+ return;
100
+ if (checking) {
101
+ requested = true;
102
+ return;
103
+ }
104
+ checking = true;
105
+ try {
106
+ const version = await readNewerActiveRelease(options.runningVersion, options.environment);
107
+ if (version !== null && !closed) {
108
+ stop();
109
+ options.onNewer(version);
110
+ }
111
+ }
112
+ finally {
113
+ checking = false;
114
+ if (requested && !closed) {
115
+ requested = false;
116
+ void check();
117
+ }
118
+ }
119
+ };
120
+ const checkChanged = async (force) => {
121
+ const next = await stat(store.path).then(value => `${value.dev}:${value.ino}:${value.ctimeMs}:${value.mtimeMs}:${value.size}`, () => null);
122
+ if (!force && next === signature)
123
+ return;
124
+ signature = next;
125
+ await check();
126
+ };
127
+ const fallback = setInterval(() => { void checkChanged(false); }, options.fallbackMs ?? ACTIVE_RELEASE_FALLBACK_MS);
128
+ fallback.unref?.();
129
+ try {
130
+ watcher = watch(dirname(store.path), { persistent: false }, (_event, filename) => {
131
+ if (filename === null || filename.toString() === basename(store.path))
132
+ void checkChanged(false);
133
+ });
134
+ }
135
+ catch { /* Rationale: the bounded metadata fallback remains authoritative when watching is unavailable. */ }
136
+ options.signal?.addEventListener("abort", stop, { once: true });
137
+ void checkChanged(true);
138
+ return stop;
139
+ }
62
140
  /** Start or observe one silent update and resolve with the active newer version once restart is useful. */
63
141
  export async function requestAutomaticUpdate(options) {
64
142
  if (options.signal?.aborted)
@@ -292,9 +370,13 @@ async function spawnAutomaticUpdateWorker(entry, arguments_, environment) {
292
370
  }
293
371
  async function activeNewerVersion(dataDir, runningVersion, target) {
294
372
  const active = await activeRelease(dataDir);
295
- return active !== null && releaseChannelOf(active.packageVersion) === target.channel
296
- && isNewerRelease(active.packageVersion, runningVersion)
297
- && (active.packageVersion === target.version || isNewerRelease(active.packageVersion, target.version))
373
+ return activeNewerPackageVersion(active, runningVersion, target.channel, target.version);
374
+ }
375
+ function activeNewerPackageVersion(active, runningVersion, channel, minimum) {
376
+ if (active === null || releaseChannelOf(active.packageVersion) !== channel
377
+ || !isNewerRelease(active.packageVersion, runningVersion))
378
+ return null;
379
+ return minimum === undefined || active.packageVersion === minimum || isNewerRelease(active.packageVersion, minimum)
298
380
  ? active.packageVersion : null;
299
381
  }
300
382
  async function activeRelease(dataDir) {
@@ -0,0 +1,12 @@
1
+ import type { SettingsManager } from "../startup-public.js";
2
+ import type { AgentSettingChangeOutcome, AgentSettingDescriptor } from "../../../contracts/agent-engine/index.js";
3
+ interface DefaultModelChoice {
4
+ readonly key: string;
5
+ readonly label: string;
6
+ readonly name: string;
7
+ }
8
+ export declare const DEFAULT_MODEL_SETTING_KEY = "defaultModel";
9
+ export declare const SUPPLEMENTAL_SETTING_KEYS: readonly ["defaultModel"];
10
+ export declare function defaultModelDescriptor(settings: SettingsManager, models: readonly DefaultModelChoice[]): AgentSettingDescriptor;
11
+ export declare function writeDefaultModel(settings: SettingsManager, models: readonly DefaultModelChoice[], value: unknown): Promise<AgentSettingChangeOutcome>;
12
+ export {};