@aiwayds/dsh-tui-pi 2.9.1 → 2.11.0

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.
package/lib/index.js CHANGED
@@ -27,8 +27,10 @@ import { AGENT_TICK_MS, LiveWidgets } from "./live-widgets.js";
27
27
  import { displayPermissionPreset } from "./permission.js";
28
28
  import { pickEffort, pickModel, pickPermission, pickPreset, pickTheme } from "./selectors.js";
29
29
  import { DshSessionBridge, persistDefaultModel, stashSessionIdForReload, takeStashedSessionId } from "./session.js";
30
- import { currentCacheHitMode, currentThemePreference, readFooterHintsPreference, readIconSetPreference, readPanelHeightPreference, readSessionManagementExplicit, readSubagentLimits, readThemePreference, registerThemeSettings, writeThemePreference, } from "./theme-settings.js";
30
+ import { currentCacheHitMode, currentRememberPreset, currentThemePreference, readFooterHintsPreference, readIconSetPreference, readPanelHeightPreference, readRememberPreset, readSessionManagementExplicit, readSubagentLimits, readThemePreference, registerThemeSettings, writeThemePreference, } from "./theme-settings.js";
31
+ import { loadWorkspacePresets, rememberedPresetFor, saveWorkspacePresets, withRememberedPreset, workspacePresetsPath, } from "./workspace-presets.js";
31
32
  import { applyIconSet, resolveIconSet, stopIcon } from "./icons.js";
33
+ import { readAgentMaxRounds } from "./agent-runtime.js";
32
34
  import { detectNerdFontAvailable } from "./font-detect.js";
33
35
  import { openAgentManager } from "./agents.js";
34
36
  import { openProfileManager, openProfileSwitcher } from "./profile.js";
@@ -42,7 +44,7 @@ import { collectStartupSummary, formatResumeCommand, parseResumeArg, resolveProf
42
44
  import { inspectPersistedSession, pickPersistedSession, sessionLogRoot, showSessionInfo } from "./sessions.js";
43
45
  import { isCorruptLogError, locateSessionLog, repairFailureNotice, repairSessionLog, } from "./log-repair.js";
44
46
  import { openRepairConfirmDialog } from "./repair-dialog.js";
45
- import { WriterLockedError } from "./writer-lock.js";
47
+ import { WriterLockedError, projectKeyFor } from "./writer-lock.js";
46
48
  import { emitNotice } from "./notice-bridge.js";
47
49
  import { applySubagentPolicy } from "./subagent-policy.js";
48
50
  import { openSubagentViewer } from "./subagent-viewer.js";
@@ -62,6 +64,7 @@ import { keybindingsPath, loadKeyBindings, openHotkeysManager } from "./hotkeys.
62
64
  import { startTui } from "./tui.js";
63
65
  import { currentPreset, fetchPresetRoster, findPresetByName, formatPresetLabel, initialPresetIndex, peekNextPreset } from "./preset.js";
64
66
  import { completedTurnSeed, openPresetConfirmDialog, performPresetSwitch } from "./preset-dialog.js";
67
+ import { openStopConfirmDialog } from "./stop-dialog.js";
65
68
  import { registerAskUserProvider } from "./ask-user.js";
66
69
  import { checkHostSupport } from "./host-version.js";
67
70
  export const name = 'dsh-tui-pi';
@@ -72,15 +75,6 @@ export const name = 'dsh-tui-pi';
72
75
  * ~30-50ms) aborts the quit before this timer ever fires.
73
76
  */
74
77
  const QUIT_CONFIRM_MS = 200;
75
- /**
76
- * Delay (ms) before the double-Esc stop actually fires — the same held-key
77
- * defence as the Ctrl+C quit: the OS repeat delay (~183ms-2s) puts the first
78
- * auto-repeat of a held Esc exactly at the 500ms stop-window boundary; the
79
- * stop is confirmed for this long and a follow-up `key-repeat` (betraying
80
- * the held key) aborts it. A second HUMAN-speed press during the window
81
- * fires the stop immediately (see `interrupt-cancel`).
82
- */
83
- const STOP_CONFIRM_MS = 200;
84
78
  /**
85
79
  * The TUI drives the agent factory and registers slash commands. The render
86
80
  * effect also touches `ctx.systemPrompt` (ask-user guidance section) and the
@@ -103,7 +97,7 @@ const SKILL_RESOURCE_BASE = {
103
97
  };
104
98
  const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true };
105
99
  /** Routing description; must stay identical to the SKILL.md frontmatter (asserted in tests). */
106
- const SKILL_DESCRIPTION = 'dsh TUI 增强套件(@aiwayds/dsh-tui-pi)使用与配置指南。凡涉及 TUI 主题/面板/footer、子代理并发与轮数限制、模型收藏与隐藏、会话保留清理与 /resume 过滤,或要配置 dsh-tui 段时先读本指南:settings.yaml 顶层 `dsh-tui:` 段 12 键(theme/panelHeight/maxAgents/maxRounds/disableSubagent/footerHints/cacheHitMode/iconSet/favoriteModels/hiddenModels/retention/resume)、DSH_TUI_* 环境变量、ask_user_question 快速上手向导、keybindings.json 与 /hotkeys。触发词:tui、主题、theme、面板、footer、收藏模型、隐藏模型、保留策略、panelHeight、resume。';
100
+ const SKILL_DESCRIPTION = 'dsh TUI 增强套件(@aiwayds/dsh-tui-pi)使用与配置指南。凡涉及 TUI 主题/面板/footer、子代理并发与轮数限制、模型收藏与隐藏、会话保留清理与 /resume 过滤、preset 记忆,或要配置 dsh-tui 段时先读本指南:settings.yaml 顶层 `dsh-tui:` 段 14 键(theme/panelHeight/maxAgents/maxRounds/maxRoundsGrace/disableSubagent/footerHints/cacheHitMode/iconSet/rememberPreset/favoriteModels/hiddenModels/retention/resume)、DSH_TUI_* 环境变量、ask_user_question 快速上手向导、keybindings.json 与 /hotkeys。触发词:tui、主题、theme、面板、footer、收藏模型、隐藏模型、保留策略、panelHeight、resume、preset。';
107
101
  const SKILL_CANDIDATE = {
108
102
  name: SKILL_PROVIDER_NAME,
109
103
  description: SKILL_DESCRIPTION,
@@ -290,6 +284,17 @@ export function apply(ctx) {
290
284
  const nerdfontAvailable = await detectNerdFontAvailable();
291
285
  applyIconSet(resolveIconSet(iconSetPreference, nerdfontAvailable));
292
286
  const presetRoster = await fetchPresetRoster();
287
+ // Preset memory (`dsh-tui.rememberPreset`, default ON): the last /preset
288
+ // selection committed in THIS workspace (keyed by the session backend's
289
+ // project key of the cwd) becomes the launch selection — runTui seeds it
290
+ // into both the footer state AND `bridge.setAgentPreset`, so the FIRST
291
+ // session of the day composes under the remembered preset instead of the
292
+ // server default. A stale id (preset renamed/removed upstream) degrades
293
+ // to the stock default-selection behavior; memory off skips the read
294
+ // entirely.
295
+ const rememberedPresetId = (await readRememberPreset(ctx))
296
+ ? rememberedPresetFor(loadWorkspacePresets(workspacePresetsPath()), projectKeyFor(process.cwd()))
297
+ : undefined;
293
298
  // Startup configuration snapshot (mcp/skills/plugins readout under the
294
299
  // welcome banner): best-effort by contract — no loader service or a
295
300
  // throwing entry walk degrades to undefined and the banner renders
@@ -302,7 +307,7 @@ export function apply(ctx) {
302
307
  const cmdlineArgs = ctx.get('cmdlineArgs')?.get();
303
308
  let disposer;
304
309
  try {
305
- disposer = runTui(themePreference, panelHeight, footerHints, nerdfontAvailable, presetRoster, startupInfo, cmdlineArgs);
310
+ disposer = runTui(themePreference, panelHeight, footerHints, nerdfontAvailable, presetRoster, startupInfo, cmdlineArgs, rememberedPresetId);
306
311
  }
307
312
  catch (error) {
308
313
  // An async-effect failure after startTui would otherwise orphan the
@@ -323,7 +328,7 @@ export function apply(ctx) {
323
328
  * reaches cordis. Returns the effect disposer handed back to cordis on
324
329
  * teardown.
325
330
  */
326
- function runTui(themePreference, panelHeight, footerHints, nerdfontAvailable, presetRoster, startupInfo, cmdlineArgs) {
331
+ function runTui(themePreference, panelHeight, footerHints, nerdfontAvailable, presetRoster, startupInfo, cmdlineArgs, rememberedPresetId) {
327
332
  // User keybindings (`~/.dsh/keybindings.json`): a partial map of the app
328
333
  // keys, read once per TUI start — `/reload` re-runs apply() and re-reads
329
334
  // it. Broken entries surface as notices instead of breaking startup.
@@ -333,16 +338,23 @@ export function apply(ctx) {
333
338
  // switching is an explicit, confirmed action). The roster is fetched once
334
339
  // at startup from the api-proxy service; an empty roster (no service / no
335
340
  // presets) disables the feature gracefully (/preset errors, footer shows
336
- // plain "dsh"). Out of the box the selection starts on the `standard`
337
- // preset when the roster supplies one; otherwise it falls back to the
338
- // first-scanned entry (initialPresetIndex). This is a local selection
339
- // only: before the user switches via /preset, NO `meta.agentPreset` is
340
- // sent at session create, so the server-side default
341
- // (`agent-presets.default` settings / deployment config) governs; the
342
- // footer reflects the local selection only. We deliberately do NOT seed
343
- // `bridge.setAgentPreset(DEFAULT_PRESET_ID)` at init that would override
344
- // the server-side default with a client-side assumption.
345
- const presetState = { roster: presetRoster, index: initialPresetIndex(presetRoster) };
341
+ // plain "dsh"). The initial selection is the workspace's REMEMBERED
342
+ // preset when preset memory is on and the id is still on the roster
343
+ // (initialPresetIndex prefers it), else the `standard` entry, else the
344
+ // first-scanned one. Without memory the launch selection is a local
345
+ // display only: before the user switches via /preset, NO
346
+ // `meta.agentPreset` is sent at session create, so the server-side
347
+ // default (`agent-presets.default` settings / deployment config)
348
+ // governs. WITH memory the remembered id is ALSO seeded into the bridge
349
+ // below (bridge.setAgentPreset), which is the point: the first session
350
+ // of the launch composes under the remembered preset, not the server
351
+ // default. We deliberately seed ONLY a remembered id, never
352
+ // DEFAULT_PRESET_ID — that would override the server-side default with
353
+ // a client-side assumption.
354
+ const rememberedPresetEntry = rememberedPresetId !== undefined
355
+ ? presetRoster.find(preset => preset.id === rememberedPresetId)
356
+ : undefined;
357
+ const presetState = { roster: presetRoster, index: initialPresetIndex(presetRoster, rememberedPresetEntry?.id) };
346
358
  // Docked-modal liveness (the ask-user questions panel): while a question
347
359
  // is pending the panel owns the keyboard — the keymap treats it like an
348
360
  // open overlay (see tui.ts), and refocusEditor must not steal focus from
@@ -353,51 +365,44 @@ export function apply(ctx) {
353
365
  void submit(text);
354
366
  },
355
367
  keyBindings: keyBindings.bindings,
356
- // App-level keys (pi interrupt chain): Esc on a running task is a
368
+ // App-level keys (pi interrupt chain): Esc while LLM work runs is a
357
369
  // deliberate double-press — the first press arms the window (with
358
- // feedback), the second within 500ms stops the whole task (parent +
359
- // subagents); popup/autocomplete first, and an idle Esc (any editor)
360
- // is an anti-misfire no-op.
361
- // Ctrl+C cancels a running turn or clears the editor a second press
370
+ // feedback), the second within 500ms opens the stop-everything
371
+ // CONFIRMATION dialog (main turn + every running subagent); stopping
372
+ // everything is a read-before-confirm action, not a timer race.
373
+ // popup/autocomplete first, and an idle Esc (any editor) is an
374
+ // anti-misfire no-op. "Running" covers background subagents too —
375
+ // children keep working after the parent turn ends, and the stop must
376
+ // still reach them (bridge.hasRunningWork).
377
+ // Ctrl+C cancels a running task or clears the editor — a second press
362
378
  // within 500ms quits — and Ctrl+D quits only on an empty editor.
363
379
  // Ctrl+G opens the subagent picker while children run. All decisions
364
380
  // live in keymap.ts.
365
- isRunning: () => bridge.isRunning(),
381
+ isRunning: () => bridge.hasRunningWork(),
366
382
  getRunningAgents: () => bridge.getLiveChildren().length,
367
383
  hasSession: () => bridge.getSessionId() !== undefined,
368
384
  dockedModalActive: () => askUserActive,
369
385
  onKeyAction: (action) => {
370
386
  switch (action.kind) {
371
387
  case 'interrupt-arm-stop':
372
- // First Esc while the task runs: the stop is ARMED, not fired —
373
- // a second Esc within the window confirms it, so a stray Esc can
374
- // never kill a running turn. The notice keeps the armed first
375
- // press from feeling dead and states the contract explicitly.
376
- renderer.renderNotice('Press Esc again to stop the current task', 'info');
388
+ // First Esc while LLM work runs: the stop is ARMED, not fired —
389
+ // a second Esc within the window opens the confirmation dialog,
390
+ // so a stray Esc can never kill a running task. The notice keeps
391
+ // the armed first press from feeling dead and states the
392
+ // contract explicitly.
393
+ renderer.renderNotice('Press Esc again to stop all LLM work', 'info');
377
394
  break;
378
395
  case 'interrupt-cancel': {
379
- // Second Esc while the task runs (parent + subagents). Like the
380
- // Ctrl+C quit, the stop is CONFIRMED, not immediate: a held
381
- // Esc's FIRST auto-repeat lands at the OS repeat delay (right at
382
- // the 500ms window boundary) looking exactly like a deliberate
383
- // second press. The stop fires after STOP_CONFIRM_MS unless a
384
- // follow-up `key-repeat` aborts it (a repeat betrays a held key),
385
- // while another HUMAN-speed press during the window stops right
386
- // away. The '⏹ stopping…' notice renders now, so the visible
387
- // feedback is immediate even though the cancellation (and its
388
- // '⏹ canceling current turn…' notice inside `stopTask`) is
389
- // deferred by the confirm window.
390
- if (stopConfirmTimer !== undefined) {
391
- clearTimeout(stopConfirmTimer);
392
- stopConfirmTimer = undefined;
393
- void stopTask();
394
- break;
395
- }
396
- renderer.renderNotice(`${stopIcon()} stopping — Esc was double-pressed`, 'info');
397
- stopConfirmTimer = setTimeout(() => {
398
- stopConfirmTimer = undefined;
399
- void stopTask();
400
- }, STOP_CONFIRM_MS);
396
+ // Second Esc while LLM work runs (main turn and/or subagents):
397
+ // open the stop-everything CONFIRMATION dialog. The old flow
398
+ // auto-fired the stop after a 200ms window that confirmed the
399
+ // press timing, never the intent, and the gesture's reach had
400
+ // grown to every running subagent. The dialog states the blast
401
+ // radius (what is running right now) and waits for Enter; Esc
402
+ // inside it cancels and keeps everything running. While the
403
+ // overlay is up the keymap yields it every app key, so further
404
+ // Esc presses land in the dialog, never straight at the task.
405
+ void confirmAndStop();
401
406
  break;
402
407
  }
403
408
  case 'ctrl-c-cancel': {
@@ -437,18 +442,14 @@ export function apply(ctx) {
437
442
  }
438
443
  case 'key-repeat':
439
444
  // Auto-repeat of a held key — betrays a hold, never a double:
440
- // abort a pending Ctrl+C quit confirmation and/or a pending
441
- // double-Esc stop confirmation.
445
+ // abort a pending Ctrl+C quit confirmation. (The double-Esc stop
446
+ // has no timer anymore — its confirmation is the dialog, which
447
+ // ignores held keys through the keymap's repeat filter.)
442
448
  if (action.key === 'ctrl-c' && quitConfirmTimer !== undefined) {
443
449
  clearTimeout(quitConfirmTimer);
444
450
  quitConfirmTimer = undefined;
445
451
  renderer.renderNotice('quit aborted — Ctrl+C was held, not double-pressed', 'info');
446
452
  }
447
- if (action.key === 'escape' && stopConfirmTimer !== undefined) {
448
- clearTimeout(stopConfirmTimer);
449
- stopConfirmTimer = undefined;
450
- renderer.renderNotice('stop aborted — Esc was held, not double-pressed', 'info');
451
- }
452
453
  break;
453
454
  case 'ctrl-d-quit':
454
455
  void disposeAndExit(0);
@@ -459,7 +460,8 @@ export function apply(ctx) {
459
460
  break;
460
461
  case 'subagent-viewer':
461
462
  // Ctrl+G: open the subagent picker → transcript viewer while
462
- // children run (modal overlay; Esc / double-x closes).
463
+ // children run (modal overlay; Esc closes, x ×2 stops the child
464
+ // while it runs / closes when settled).
463
465
  void openSubagentViewer(ctx, ui.tui, ui.theme, bridge, refocusEditor);
464
466
  break;
465
467
  case 'queue-panel': {
@@ -551,27 +553,35 @@ export function apply(ctx) {
551
553
  */
552
554
  let quitConfirmTimer;
553
555
  /**
554
- * Pending double-Esc stop confirmation (see the `interrupt-cancel` case):
555
- * armed by the second Esc while running, fired after STOP_CONFIRM_MS,
556
- * aborted by a `key-repeat` (held key) or torn down with the TUI. The
557
- * Ctrl+C quit and the Esc stop never share a timer distinct keys, both
558
- * could theoretically be in flight.
556
+ * The double-Esc stop flow: the confirmation dialog first (it states
557
+ * what is running and waits for Enter), then the everything-stop. The
558
+ * dialog is modal, so a mashing user cannot double-commit every key
559
+ * while it is up goes to the dialog, and a cancel simply returns.
559
560
  */
560
- let stopConfirmTimer;
561
+ const confirmAndStop = async () => {
562
+ const outcome = await openStopConfirmDialog(ui.tui, ui.theme, bridge.isRunning(), bridge.getLiveChildren().length, refocusEditor);
563
+ if (outcome === 'stop')
564
+ await stopTask();
565
+ };
561
566
  /**
562
- * Stop the whole task (parent turn + subagents), mirroring the web
563
- * client's stop button: `agent.cancel({ kind: 'user' }, { keepInbox:
564
- * true })` via the bridge. Deferred by the confirm windows above (the
565
- * ESC/Ctrl+C double-press guards); the notice renders immediately.
567
+ * Stop the whole task the everything-stop: the main turn (mirroring
568
+ * the web client's stop button, `agent.cancel({ kind: 'user' }, {
569
+ * keepInbox: true })` via the bridge), btw side calls, AND every live
570
+ * subagent. The children leg is what makes this "everything": cancelling
571
+ * the parent only kills foreground children (they ride the tool call's
572
+ * abort signal) — background/continuable children survive it and would
573
+ * keep burning LLM rounds (bridge.cancelAllChildren). Fired by the
574
+ * dialog's Stop choice (double-Esc) and the first Ctrl+C mid-task.
566
575
  */
567
576
  const stopTask = async () => {
568
- renderer.renderNotice(`${stopIcon()} canceling current turn…`, 'info');
577
+ renderer.renderNotice(`${stopIcon()} stopping all LLM work…`, 'info');
569
578
  // The stop gesture is the everything-stop: side calls die with the turn.
570
579
  btwController.cancelAll();
571
580
  const cancelled = await bridge.cancelActiveTurn();
572
- // State raced idle between the decision and the cancel call — nothing
573
- // to cancel (e.g. the turn settled inside the confirm window).
574
- if (!cancelled)
581
+ const stoppedChildren = bridge.cancelAllChildren();
582
+ // State raced idle between the decision and the cancel calls — nothing
583
+ // to cancel (e.g. the task settled while the dialog was up).
584
+ if (!cancelled && stoppedChildren === 0)
575
585
  renderer.renderNotice('Nothing running to cancel.', 'info');
576
586
  };
577
587
  // Arm the settings watch sink now that the renderer exists (see apply()).
@@ -715,19 +725,33 @@ export function apply(ctx) {
715
725
  };
716
726
  const bridge = new DshSessionBridge(ctx, bridgeCallbacksWithTakeover);
717
727
  bridgeRef = bridge;
728
+ // Preset memory's creation-time leg: seed the remembered selection so the
729
+ // FIRST session creation (the lazy create on the first submitted prompt)
730
+ // records `meta.agentPreset` — without this the launch selection would be
731
+ // footer cosmetics while the server default composed the session. Only a
732
+ // roster-validated id is ever seeded (see presetState above).
733
+ if (rememberedPresetEntry !== undefined)
734
+ bridge.setAgentPreset(rememberedPresetEntry.id);
718
735
  // Subagent fine-grained control, all in-process (see subagent-policy.ts):
719
736
  // a tools.guard denies spawn-tool calls once `maxAgents` children run
720
737
  // (workflow fan-out, which bypasses the tool pipeline, is pruned on
721
- // `subagent/start`), and when a child's assistant-message count reaches
722
- // `maxRounds` the policy queues one wrap-up message into its next turn.
723
- // Limits are read live from the `dsh-tui` settings namespace
724
- // (/agents l limits).
738
+ // `subagent/start`), and the round ladder injects one wrap-up at a
739
+ // child's cap and hard-stops it after the grace window the per-agent
740
+ // tier reads the agent file's frontmatter `maxRounds` through the
741
+ // registry contract. Limits are read live from the `dsh-tui` settings
742
+ // namespace (/agents → l limits).
725
743
  const subagentPolicy = applySubagentPolicy(ctx, {
726
744
  getLive: () => bridge.getLiveChildren(),
727
745
  getRoundCount: childId => bridge.getRoundCount(childId),
728
746
  isSettled: childId => bridge.isChildSettled(childId),
729
- });
747
+ cancelChild: childId => bridge.cancelChild(childId),
748
+ }, readAgentMaxRounds);
730
749
  bridgeCallbacks.onRoundCount = (childId, count) => subagentPolicy.onRoundCount(childId, count);
750
+ // The `⏻` marker fold: a force-stopped child is policy enforcement, and
751
+ // every render surface shows it as such.
752
+ if (subagentPolicy.onHardStop !== undefined) {
753
+ subagentPolicy.onHardStop = ({ childId, round, cap }) => bridge.markChildHardStopped(childId, round, cap);
754
+ }
731
755
  // Ask-user-question provider: the upstream `dsh-tool-ask-user` tool calls
732
756
  // `ctx.userQuestions.ask()` while its tool call is pending, and the
733
757
  // provider returns a canonical `{ answers: [{ id, selected, custom? }] }`
@@ -998,6 +1022,29 @@ export function apply(ctx) {
998
1022
  description: 'Switch the current model\'s think (reasoning) level',
999
1023
  handler: invocation => thinkHandler(invocation.rawInput, invocation.signal),
1000
1024
  }), 'dsh-tui-pi: /think');
1025
+ /**
1026
+ * Record one committed preset as THIS workspace's remembered selection
1027
+ * (`$DSH_HOME/workspace-presets.json`, keyed by the session backend's
1028
+ * project key of the cwd — the same grouping /resume and the writer lock
1029
+ * use). Honors the LIVE `dsh-tui.rememberPreset` toggle (off = no read at
1030
+ * startup, no write here; the file is kept so flipping back restores the
1031
+ * last choices). Never throws: memory is best-effort, a failed save is a
1032
+ * notice.
1033
+ */
1034
+ const persistRememberedPreset = (presetId) => {
1035
+ try {
1036
+ if (!currentRememberPreset(ctx))
1037
+ return;
1038
+ const path = workspacePresetsPath();
1039
+ const doc = withRememberedPreset(loadWorkspacePresets(path), projectKeyFor(process.cwd()), presetId);
1040
+ const error = saveWorkspacePresets(path, doc);
1041
+ if (error !== undefined)
1042
+ renderer.renderNotice(`preset memory not saved: ${error}`, 'error');
1043
+ }
1044
+ catch {
1045
+ // A broken store must never fail the switch itself.
1046
+ }
1047
+ };
1001
1048
  // /preset: switch the agent preset — an EXPLICIT action that takes effect
1002
1049
  // immediately by starting a NEW session on the chosen preset. Every entry
1003
1050
  // path (picker Enter, `/preset <name>`, `/preset next`) funnels into
@@ -1030,8 +1077,7 @@ export function apply(ctx) {
1030
1077
  }
1031
1078
  if (target === undefined)
1032
1079
  return { kind: 'success', text: 'Preset unchanged.' };
1033
- const outcome = await performPresetSwitch(presetState, target, {
1034
- hasLiveSession: () => bridge.getAgent() !== undefined,
1080
+ const outcome = await performPresetSwitch(presetState, target, { hasLiveSession: () => bridge.getAgent() !== undefined,
1035
1081
  confirmSwitch: (name, restart) => openPresetConfirmDialog(ui.tui, ui.theme, name, restart, refocusEditor)
1036
1082
  .then(outcome => (outcome === 'cancelled' ? 'cancel' : outcome)),
1037
1083
  commit: async (presetId) => {
@@ -1102,6 +1148,14 @@ export function apply(ctx) {
1102
1148
  }
1103
1149
  },
1104
1150
  });
1151
+ // Preset memory's recording leg: a COMMITTED switch (fresh, fork, or a
1152
+ // degraded fork — every switched path above seeds the same id) is the
1153
+ // moment the workspace's memory is written. Lives AFTER the switch so
1154
+ // a failed commit never records; the toggle is read LIVE, so a
1155
+ // /settings change applies without a restart. Best-effort: a failed
1156
+ // save is a notice, never a failed switch.
1157
+ if (outcome.switched)
1158
+ persistRememberedPreset(target.id);
1105
1159
  return { kind: 'success', text: outcome.message };
1106
1160
  };
1107
1161
  commands.registerLocal('preset', presetHandler);
@@ -2102,8 +2156,6 @@ export function apply(ctx) {
2102
2156
  stashSessionIdForReload(bridge.getSessionId());
2103
2157
  if (quitConfirmTimer !== undefined)
2104
2158
  clearTimeout(quitConfirmTimer);
2105
- if (stopConfirmTimer !== undefined)
2106
- clearTimeout(stopConfirmTimer);
2107
2159
  subagentPolicy.dispose();
2108
2160
  try {
2109
2161
  await bridge.dispose();