@aiwayds/dsh-tui-pi 2.9.1 → 2.10.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/README.md CHANGED
@@ -105,11 +105,11 @@ dsh plugin --profile tui add @aiwayds/dsh-topics-memory
105
105
  | Key | Action |
106
106
  |---|---|
107
107
  | `Enter` | Send the prompt |
108
- | `Esc` | **Double-press to stop** (single press arms; a popup open closes it instead) |
109
- | `Ctrl+C` | Mid-turn: first press cancels, second quits; idle: clears editor / quits. Held-key auto-repeat never quits. |
108
+ | `Esc` | **Double-press to stop everything** the first press arms, the second opens a confirmation dialog naming what is running (main turn + subagent count); `Enter` there stops the main turn AND every running subagent, `Esc` keeps everything running. A popup open closes it instead. Works while only background subagents run. |
109
+ | `Ctrl+C` | Mid-task: first press stops (same everything-stop, no dialog), second quits; idle: clears editor / quits. Held-key auto-repeat never quits. |
110
110
  | `Ctrl+D` | Quit (only when the editor is empty) |
111
111
  | `Ctrl+L` | Open the model/think picker |
112
- | `Ctrl+G` | Open the subagent picker (viewer `Enter` opens steer) |
112
+ | `Ctrl+G` | Open the subagent picker (viewer `Enter` opens steer, `x ×2` stops that subagent while it runs / closes when settled) |
113
113
  | `Ctrl+O` | Pending-message queue (s steer now · d remove) |
114
114
  | `Ctrl+Shift+F` | Transcript search (`Enter`/`Ctrl+G` next · `Shift+Enter`/`Ctrl+Shift+G` previous · `Esc` close) |
115
115
  | `↑` / `↓` | Browse submitted-message history |
@@ -132,6 +132,7 @@ Every knob lives under the `dsh-tui` settings namespace in `~/.dsh/settings.yaml
132
132
  | `footerHints` | all `true` | Per-segment footer hint toggles: `send`/`stop`/`quit`/`quitEmpty`/`subagents`/`search`/`history` |
133
133
  | `cacheHitMode` | `lastMessage` | Footer CH segment scope: `lastMessage` — the latest assistant message's cache-hit rate, matching the pi-tui footer (default); `session` — cumulative over the whole session |
134
134
  | `iconSet` | `auto` | `auto`/`nerdfont`/`plain` — powerline glyphs adapt to your font; install a Nerd Font with `node scripts/install-font.mjs` |
135
+ | `rememberPreset` | `true` | Remember the last `/preset` selection **per workspace** (keyed by directory) and start the next launch there — the first session composes under it instead of the server-side default. `false` always starts on the server default. The memory itself lives in `$DSH_HOME/workspace-presets.json` |
135
136
  | `favoriteModels` | `[]` | Favorite models (`provider/id`), pinned to the top of the `/model` picker |
136
137
  | `hiddenModels` | `[]` | Hidden models (`provider/id`), moved to the picker's Hidden section (`f` favorites / `h` hides inside the picker — both persist to these keys) |
137
138
 
package/README.zh-CN.md CHANGED
@@ -102,11 +102,11 @@ dsh plugin --profile tui add @aiwayds/dsh-topics-memory
102
102
  | 按键 | 功能 |
103
103
  |---|---|
104
104
  | `Enter` | 发送 prompt |
105
- | `Esc` | **双击停止**(单击进入待发状态;有 popup 打开时改为关闭它) |
106
- | `Ctrl+C` | 对话中:第一次取消当前轮次,第二次退出;空闲时:清空编辑器 / 退出。长按自动重复绝不会触发退出。 |
105
+ | `Esc` | **双击停止一切 LLM 工作**——第一击进入待发状态,第二击弹出确认框(列明正在运行的主 turn 与子代理数);`Enter` 确认后停止主 turn **和**所有运行中的子代理,`Esc` 维持运行。仅有后台子代理在跑时同样有效。有 popup 打开时双击改为关闭它 |
106
+ | `Ctrl+C` | 任务中:第一次停止(同一"停止一切",无确认框),第二次退出;空闲时:清空编辑器 / 退出。长按自动重复绝不会触发退出。 |
107
107
  | `Ctrl+D` | 退出(仅在编辑器为空时) |
108
108
  | `Ctrl+L` | 打开 model/think 选择器 |
109
- | `Ctrl+G` | 打开 subagent 选择器(查看器内 `Enter` 打开 steer |
109
+ | `Ctrl+G` | 打开 subagent 选择器(查看器内 `Enter` 打开 steer;子代理运行中 `x ×2` 停止它,已结束后 `x ×2` 关闭面板) |
110
110
  | `Ctrl+O` | 待发消息队列(`s` 立即 steer · `d` 移除) |
111
111
  | `Ctrl+Shift+F` | 全文搜索(`Enter`/`Ctrl+G` 下一个 · `Shift+Enter`/`Ctrl+Shift+G` 上一个 · `Esc` 关闭) |
112
112
  | `↑` / `↓` | 浏览已提交消息历史 |
@@ -129,6 +129,7 @@ dsh plugin --profile tui add @aiwayds/dsh-topics-memory
129
129
  | `footerHints` | 全 `true` | footer 快捷键提示分段开关:`send`/`stop`/`quit`/`quitEmpty`/`subagents`/`search`/`history` |
130
130
  | `cacheHitMode` | `lastMessage` | footer CH 段口径:`lastMessage`——最新一条 assistant 消息的缓存命中率(与 pi-tui footer 一致,默认);`session`——全会话累计 |
131
131
  | `iconSet` | `auto` | `auto`/`nerdfont`/`plain`——powerline 字形自适应你的字体;用 `node scripts/install-font.mjs` 安装 Nerd Font |
132
+ | `rememberPreset` | `true` | 按**工作区**(目录)记住最后一次 `/preset` 的选择,下次同目录启动直接用它——首个会话按它组合,而不是服务端默认。`false` 则始终用服务端默认。记忆本体存于 `$DSH_HOME/workspace-presets.json` |
132
133
  | `favoriteModels` | `[]` | 收藏模型(`provider/id`),钉在 `/model` 选择器顶部 |
133
134
  | `hiddenModels` | `[]` | 隐藏模型(`provider/id`),移入 `/model` 的 Hidden 区(选择器内 `f` 收藏 / `h` 隐藏,即写这两个键) |
134
135
 
package/lib/index.js CHANGED
@@ -27,7 +27,8 @@ 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";
32
33
  import { detectNerdFontAvailable } from "./font-detect.js";
33
34
  import { openAgentManager } from "./agents.js";
@@ -42,7 +43,7 @@ import { collectStartupSummary, formatResumeCommand, parseResumeArg, resolveProf
42
43
  import { inspectPersistedSession, pickPersistedSession, sessionLogRoot, showSessionInfo } from "./sessions.js";
43
44
  import { isCorruptLogError, locateSessionLog, repairFailureNotice, repairSessionLog, } from "./log-repair.js";
44
45
  import { openRepairConfirmDialog } from "./repair-dialog.js";
45
- import { WriterLockedError } from "./writer-lock.js";
46
+ import { WriterLockedError, projectKeyFor } from "./writer-lock.js";
46
47
  import { emitNotice } from "./notice-bridge.js";
47
48
  import { applySubagentPolicy } from "./subagent-policy.js";
48
49
  import { openSubagentViewer } from "./subagent-viewer.js";
@@ -62,6 +63,7 @@ import { keybindingsPath, loadKeyBindings, openHotkeysManager } from "./hotkeys.
62
63
  import { startTui } from "./tui.js";
63
64
  import { currentPreset, fetchPresetRoster, findPresetByName, formatPresetLabel, initialPresetIndex, peekNextPreset } from "./preset.js";
64
65
  import { completedTurnSeed, openPresetConfirmDialog, performPresetSwitch } from "./preset-dialog.js";
66
+ import { openStopConfirmDialog } from "./stop-dialog.js";
65
67
  import { registerAskUserProvider } from "./ask-user.js";
66
68
  import { checkHostSupport } from "./host-version.js";
67
69
  export const name = 'dsh-tui-pi';
@@ -72,15 +74,6 @@ export const name = 'dsh-tui-pi';
72
74
  * ~30-50ms) aborts the quit before this timer ever fires.
73
75
  */
74
76
  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
77
  /**
85
78
  * The TUI drives the agent factory and registers slash commands. The render
86
79
  * effect also touches `ctx.systemPrompt` (ask-user guidance section) and the
@@ -103,7 +96,7 @@ const SKILL_RESOURCE_BASE = {
103
96
  };
104
97
  const SKILL_INVOCATION = { modelInvocable: true, userInvocable: true };
105
98
  /** 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。';
99
+ const SKILL_DESCRIPTION = 'dsh TUI 增强套件(@aiwayds/dsh-tui-pi)使用与配置指南。凡涉及 TUI 主题/面板/footer、子代理并发与轮数限制、模型收藏与隐藏、会话保留清理与 /resume 过滤、preset 记忆,或要配置 dsh-tui 段时先读本指南:settings.yaml 顶层 `dsh-tui:` 段 13 键(theme/panelHeight/maxAgents/maxRounds/disableSubagent/footerHints/cacheHitMode/iconSet/rememberPreset/favoriteModels/hiddenModels/retention/resume)、DSH_TUI_* 环境变量、ask_user_question 快速上手向导、keybindings.json 与 /hotkeys。触发词:tui、主题、theme、面板、footer、收藏模型、隐藏模型、保留策略、panelHeight、resume、preset。';
107
100
  const SKILL_CANDIDATE = {
108
101
  name: SKILL_PROVIDER_NAME,
109
102
  description: SKILL_DESCRIPTION,
@@ -290,6 +283,17 @@ export function apply(ctx) {
290
283
  const nerdfontAvailable = await detectNerdFontAvailable();
291
284
  applyIconSet(resolveIconSet(iconSetPreference, nerdfontAvailable));
292
285
  const presetRoster = await fetchPresetRoster();
286
+ // Preset memory (`dsh-tui.rememberPreset`, default ON): the last /preset
287
+ // selection committed in THIS workspace (keyed by the session backend's
288
+ // project key of the cwd) becomes the launch selection — runTui seeds it
289
+ // into both the footer state AND `bridge.setAgentPreset`, so the FIRST
290
+ // session of the day composes under the remembered preset instead of the
291
+ // server default. A stale id (preset renamed/removed upstream) degrades
292
+ // to the stock default-selection behavior; memory off skips the read
293
+ // entirely.
294
+ const rememberedPresetId = (await readRememberPreset(ctx))
295
+ ? rememberedPresetFor(loadWorkspacePresets(workspacePresetsPath()), projectKeyFor(process.cwd()))
296
+ : undefined;
293
297
  // Startup configuration snapshot (mcp/skills/plugins readout under the
294
298
  // welcome banner): best-effort by contract — no loader service or a
295
299
  // throwing entry walk degrades to undefined and the banner renders
@@ -302,7 +306,7 @@ export function apply(ctx) {
302
306
  const cmdlineArgs = ctx.get('cmdlineArgs')?.get();
303
307
  let disposer;
304
308
  try {
305
- disposer = runTui(themePreference, panelHeight, footerHints, nerdfontAvailable, presetRoster, startupInfo, cmdlineArgs);
309
+ disposer = runTui(themePreference, panelHeight, footerHints, nerdfontAvailable, presetRoster, startupInfo, cmdlineArgs, rememberedPresetId);
306
310
  }
307
311
  catch (error) {
308
312
  // An async-effect failure after startTui would otherwise orphan the
@@ -323,7 +327,7 @@ export function apply(ctx) {
323
327
  * reaches cordis. Returns the effect disposer handed back to cordis on
324
328
  * teardown.
325
329
  */
326
- function runTui(themePreference, panelHeight, footerHints, nerdfontAvailable, presetRoster, startupInfo, cmdlineArgs) {
330
+ function runTui(themePreference, panelHeight, footerHints, nerdfontAvailable, presetRoster, startupInfo, cmdlineArgs, rememberedPresetId) {
327
331
  // User keybindings (`~/.dsh/keybindings.json`): a partial map of the app
328
332
  // keys, read once per TUI start — `/reload` re-runs apply() and re-reads
329
333
  // it. Broken entries surface as notices instead of breaking startup.
@@ -333,16 +337,23 @@ export function apply(ctx) {
333
337
  // switching is an explicit, confirmed action). The roster is fetched once
334
338
  // at startup from the api-proxy service; an empty roster (no service / no
335
339
  // 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) };
340
+ // plain "dsh"). The initial selection is the workspace's REMEMBERED
341
+ // preset when preset memory is on and the id is still on the roster
342
+ // (initialPresetIndex prefers it), else the `standard` entry, else the
343
+ // first-scanned one. Without memory the launch selection is a local
344
+ // display only: before the user switches via /preset, NO
345
+ // `meta.agentPreset` is sent at session create, so the server-side
346
+ // default (`agent-presets.default` settings / deployment config)
347
+ // governs. WITH memory the remembered id is ALSO seeded into the bridge
348
+ // below (bridge.setAgentPreset), which is the point: the first session
349
+ // of the launch composes under the remembered preset, not the server
350
+ // default. We deliberately seed ONLY a remembered id, never
351
+ // DEFAULT_PRESET_ID — that would override the server-side default with
352
+ // a client-side assumption.
353
+ const rememberedPresetEntry = rememberedPresetId !== undefined
354
+ ? presetRoster.find(preset => preset.id === rememberedPresetId)
355
+ : undefined;
356
+ const presetState = { roster: presetRoster, index: initialPresetIndex(presetRoster, rememberedPresetEntry?.id) };
346
357
  // Docked-modal liveness (the ask-user questions panel): while a question
347
358
  // is pending the panel owns the keyboard — the keymap treats it like an
348
359
  // open overlay (see tui.ts), and refocusEditor must not steal focus from
@@ -353,51 +364,44 @@ export function apply(ctx) {
353
364
  void submit(text);
354
365
  },
355
366
  keyBindings: keyBindings.bindings,
356
- // App-level keys (pi interrupt chain): Esc on a running task is a
367
+ // App-level keys (pi interrupt chain): Esc while LLM work runs is a
357
368
  // 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
369
+ // feedback), the second within 500ms opens the stop-everything
370
+ // CONFIRMATION dialog (main turn + every running subagent); stopping
371
+ // everything is a read-before-confirm action, not a timer race.
372
+ // popup/autocomplete first, and an idle Esc (any editor) is an
373
+ // anti-misfire no-op. "Running" covers background subagents too —
374
+ // children keep working after the parent turn ends, and the stop must
375
+ // still reach them (bridge.hasRunningWork).
376
+ // Ctrl+C cancels a running task or clears the editor — a second press
362
377
  // within 500ms quits — and Ctrl+D quits only on an empty editor.
363
378
  // Ctrl+G opens the subagent picker while children run. All decisions
364
379
  // live in keymap.ts.
365
- isRunning: () => bridge.isRunning(),
380
+ isRunning: () => bridge.hasRunningWork(),
366
381
  getRunningAgents: () => bridge.getLiveChildren().length,
367
382
  hasSession: () => bridge.getSessionId() !== undefined,
368
383
  dockedModalActive: () => askUserActive,
369
384
  onKeyAction: (action) => {
370
385
  switch (action.kind) {
371
386
  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');
387
+ // First Esc while LLM work runs: the stop is ARMED, not fired —
388
+ // a second Esc within the window opens the confirmation dialog,
389
+ // so a stray Esc can never kill a running task. The notice keeps
390
+ // the armed first press from feeling dead and states the
391
+ // contract explicitly.
392
+ renderer.renderNotice('Press Esc again to stop all LLM work', 'info');
377
393
  break;
378
394
  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);
395
+ // Second Esc while LLM work runs (main turn and/or subagents):
396
+ // open the stop-everything CONFIRMATION dialog. The old flow
397
+ // auto-fired the stop after a 200ms window that confirmed the
398
+ // press timing, never the intent, and the gesture's reach had
399
+ // grown to every running subagent. The dialog states the blast
400
+ // radius (what is running right now) and waits for Enter; Esc
401
+ // inside it cancels and keeps everything running. While the
402
+ // overlay is up the keymap yields it every app key, so further
403
+ // Esc presses land in the dialog, never straight at the task.
404
+ void confirmAndStop();
401
405
  break;
402
406
  }
403
407
  case 'ctrl-c-cancel': {
@@ -437,18 +441,14 @@ export function apply(ctx) {
437
441
  }
438
442
  case 'key-repeat':
439
443
  // 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.
444
+ // abort a pending Ctrl+C quit confirmation. (The double-Esc stop
445
+ // has no timer anymore — its confirmation is the dialog, which
446
+ // ignores held keys through the keymap's repeat filter.)
442
447
  if (action.key === 'ctrl-c' && quitConfirmTimer !== undefined) {
443
448
  clearTimeout(quitConfirmTimer);
444
449
  quitConfirmTimer = undefined;
445
450
  renderer.renderNotice('quit aborted — Ctrl+C was held, not double-pressed', 'info');
446
451
  }
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
452
  break;
453
453
  case 'ctrl-d-quit':
454
454
  void disposeAndExit(0);
@@ -459,7 +459,8 @@ export function apply(ctx) {
459
459
  break;
460
460
  case 'subagent-viewer':
461
461
  // Ctrl+G: open the subagent picker → transcript viewer while
462
- // children run (modal overlay; Esc / double-x closes).
462
+ // children run (modal overlay; Esc closes, x ×2 stops the child
463
+ // while it runs / closes when settled).
463
464
  void openSubagentViewer(ctx, ui.tui, ui.theme, bridge, refocusEditor);
464
465
  break;
465
466
  case 'queue-panel': {
@@ -551,27 +552,35 @@ export function apply(ctx) {
551
552
  */
552
553
  let quitConfirmTimer;
553
554
  /**
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.
555
+ * The double-Esc stop flow: the confirmation dialog first (it states
556
+ * what is running and waits for Enter), then the everything-stop. The
557
+ * dialog is modal, so a mashing user cannot double-commit every key
558
+ * while it is up goes to the dialog, and a cancel simply returns.
559
559
  */
560
- let stopConfirmTimer;
560
+ const confirmAndStop = async () => {
561
+ const outcome = await openStopConfirmDialog(ui.tui, ui.theme, bridge.isRunning(), bridge.getLiveChildren().length, refocusEditor);
562
+ if (outcome === 'stop')
563
+ await stopTask();
564
+ };
561
565
  /**
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.
566
+ * Stop the whole task the everything-stop: the main turn (mirroring
567
+ * the web client's stop button, `agent.cancel({ kind: 'user' }, {
568
+ * keepInbox: true })` via the bridge), btw side calls, AND every live
569
+ * subagent. The children leg is what makes this "everything": cancelling
570
+ * the parent only kills foreground children (they ride the tool call's
571
+ * abort signal) — background/continuable children survive it and would
572
+ * keep burning LLM rounds (bridge.cancelAllChildren). Fired by the
573
+ * dialog's Stop choice (double-Esc) and the first Ctrl+C mid-task.
566
574
  */
567
575
  const stopTask = async () => {
568
- renderer.renderNotice(`${stopIcon()} canceling current turn…`, 'info');
576
+ renderer.renderNotice(`${stopIcon()} stopping all LLM work…`, 'info');
569
577
  // The stop gesture is the everything-stop: side calls die with the turn.
570
578
  btwController.cancelAll();
571
579
  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)
580
+ const stoppedChildren = bridge.cancelAllChildren();
581
+ // State raced idle between the decision and the cancel calls — nothing
582
+ // to cancel (e.g. the task settled while the dialog was up).
583
+ if (!cancelled && stoppedChildren === 0)
575
584
  renderer.renderNotice('Nothing running to cancel.', 'info');
576
585
  };
577
586
  // Arm the settings watch sink now that the renderer exists (see apply()).
@@ -715,6 +724,13 @@ export function apply(ctx) {
715
724
  };
716
725
  const bridge = new DshSessionBridge(ctx, bridgeCallbacksWithTakeover);
717
726
  bridgeRef = bridge;
727
+ // Preset memory's creation-time leg: seed the remembered selection so the
728
+ // FIRST session creation (the lazy create on the first submitted prompt)
729
+ // records `meta.agentPreset` — without this the launch selection would be
730
+ // footer cosmetics while the server default composed the session. Only a
731
+ // roster-validated id is ever seeded (see presetState above).
732
+ if (rememberedPresetEntry !== undefined)
733
+ bridge.setAgentPreset(rememberedPresetEntry.id);
718
734
  // Subagent fine-grained control, all in-process (see subagent-policy.ts):
719
735
  // a tools.guard denies spawn-tool calls once `maxAgents` children run
720
736
  // (workflow fan-out, which bypasses the tool pipeline, is pruned on
@@ -998,6 +1014,29 @@ export function apply(ctx) {
998
1014
  description: 'Switch the current model\'s think (reasoning) level',
999
1015
  handler: invocation => thinkHandler(invocation.rawInput, invocation.signal),
1000
1016
  }), 'dsh-tui-pi: /think');
1017
+ /**
1018
+ * Record one committed preset as THIS workspace's remembered selection
1019
+ * (`$DSH_HOME/workspace-presets.json`, keyed by the session backend's
1020
+ * project key of the cwd — the same grouping /resume and the writer lock
1021
+ * use). Honors the LIVE `dsh-tui.rememberPreset` toggle (off = no read at
1022
+ * startup, no write here; the file is kept so flipping back restores the
1023
+ * last choices). Never throws: memory is best-effort, a failed save is a
1024
+ * notice.
1025
+ */
1026
+ const persistRememberedPreset = (presetId) => {
1027
+ try {
1028
+ if (!currentRememberPreset(ctx))
1029
+ return;
1030
+ const path = workspacePresetsPath();
1031
+ const doc = withRememberedPreset(loadWorkspacePresets(path), projectKeyFor(process.cwd()), presetId);
1032
+ const error = saveWorkspacePresets(path, doc);
1033
+ if (error !== undefined)
1034
+ renderer.renderNotice(`preset memory not saved: ${error}`, 'error');
1035
+ }
1036
+ catch {
1037
+ // A broken store must never fail the switch itself.
1038
+ }
1039
+ };
1001
1040
  // /preset: switch the agent preset — an EXPLICIT action that takes effect
1002
1041
  // immediately by starting a NEW session on the chosen preset. Every entry
1003
1042
  // path (picker Enter, `/preset <name>`, `/preset next`) funnels into
@@ -1030,8 +1069,7 @@ export function apply(ctx) {
1030
1069
  }
1031
1070
  if (target === undefined)
1032
1071
  return { kind: 'success', text: 'Preset unchanged.' };
1033
- const outcome = await performPresetSwitch(presetState, target, {
1034
- hasLiveSession: () => bridge.getAgent() !== undefined,
1072
+ const outcome = await performPresetSwitch(presetState, target, { hasLiveSession: () => bridge.getAgent() !== undefined,
1035
1073
  confirmSwitch: (name, restart) => openPresetConfirmDialog(ui.tui, ui.theme, name, restart, refocusEditor)
1036
1074
  .then(outcome => (outcome === 'cancelled' ? 'cancel' : outcome)),
1037
1075
  commit: async (presetId) => {
@@ -1102,6 +1140,14 @@ export function apply(ctx) {
1102
1140
  }
1103
1141
  },
1104
1142
  });
1143
+ // Preset memory's recording leg: a COMMITTED switch (fresh, fork, or a
1144
+ // degraded fork — every switched path above seeds the same id) is the
1145
+ // moment the workspace's memory is written. Lives AFTER the switch so
1146
+ // a failed commit never records; the toggle is read LIVE, so a
1147
+ // /settings change applies without a restart. Best-effort: a failed
1148
+ // save is a notice, never a failed switch.
1149
+ if (outcome.switched)
1150
+ persistRememberedPreset(target.id);
1105
1151
  return { kind: 'success', text: outcome.message };
1106
1152
  };
1107
1153
  commands.registerLocal('preset', presetHandler);
@@ -2102,8 +2148,6 @@ export function apply(ctx) {
2102
2148
  stashSessionIdForReload(bridge.getSessionId());
2103
2149
  if (quitConfirmTimer !== undefined)
2104
2150
  clearTimeout(quitConfirmTimer);
2105
- if (stopConfirmTimer !== undefined)
2106
- clearTimeout(stopConfirmTimer);
2107
2151
  subagentPolicy.dispose();
2108
2152
  try {
2109
2153
  await bridge.dispose();