@aiwayds/dsh-tui-pi 2.16.0 → 2.18.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.
Files changed (47) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/cordis.patch.yml +14 -0
  4. package/lib/index.js +70 -124
  5. package/lib/index.js.map +1 -1
  6. package/lib/selectors.d.ts +7 -5
  7. package/lib/selectors.js +19 -23
  8. package/lib/selectors.js.map +1 -1
  9. package/lib/theme/index.d.ts +6 -3
  10. package/lib/theme/index.js +30 -2
  11. package/lib/theme/index.js.map +1 -1
  12. package/lib/theme/palette.d.ts +2 -0
  13. package/lib/theme/palette.js +1 -1
  14. package/lib/theme/palette.js.map +1 -1
  15. package/lib/theme/registry.d.ts +49 -0
  16. package/lib/theme/registry.js +211 -0
  17. package/lib/theme/registry.js.map +1 -0
  18. package/lib/theme-preview.d.ts +98 -0
  19. package/lib/theme-preview.js +266 -0
  20. package/lib/theme-preview.js.map +1 -0
  21. package/lib/theme-settings.js +6 -5
  22. package/lib/theme-settings.js.map +1 -1
  23. package/lib/tui.d.ts +3 -0
  24. package/lib/tui.js +1 -1
  25. package/lib/tui.js.map +1 -1
  26. package/package.json +6 -4
  27. package/skills/dsh-tui-pi-config/SKILL.md +11 -4
  28. package/themes/ayu-light.json +25 -0
  29. package/themes/catppuccin-latte.json +25 -0
  30. package/themes/catppuccin-mocha.json +25 -0
  31. package/themes/dracula-light.json +25 -0
  32. package/themes/dracula.json +25 -0
  33. package/themes/github-dark.json +25 -0
  34. package/themes/github-light.json +25 -0
  35. package/themes/gruvbox-dark.json +25 -0
  36. package/themes/gruvbox-light.json +25 -0
  37. package/themes/material-light.json +25 -0
  38. package/themes/monokai.json +25 -0
  39. package/themes/nord-dark.json +25 -0
  40. package/themes/nord-light.json +25 -0
  41. package/themes/one-dark.json +25 -0
  42. package/themes/one-light.json +25 -0
  43. package/themes/solarized-dark.json +25 -0
  44. package/themes/solarized-light.json +25 -0
  45. package/themes/synthwave.json +25 -0
  46. package/themes/tokyo-night-light.json +25 -0
  47. package/themes/tokyo-night.json +25 -0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # dsh-tui-pi
4
4
 
5
- A fully-featured pi-style terminal UI for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) — a plugin suite that turns dsh into a pi-like coding-agent experience: /history look-back & fork-at-turn, guided preset switching, live subagent steering, model profiles, GitHub light/dark themes and a powerline footer.
5
+ A fully-featured pi-style terminal UI for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) — a plugin suite that turns dsh into a pi-like coding-agent experience: /history look-back & fork-at-turn, guided preset switching, live subagent steering, model profiles, 20+ built-in themes (10 light + 10 dark) with user theme directory discovery, and a powerline footer.
6
6
 
7
7
  **Requires dsh >= 0.1.5-rc.2** — this plugin targets the dsh RC/stable line only (CI and releases resolve the newest of the `latest`/`next` dist-tags at runtime). **The alpha line is no longer supported.** A startup guard logs a one-line warning and exits cleanly when the host is older than the floor (opt out with `DSH_TUI_SKIP_HOST_CHECK=1`). See [ADR 0002](docs/adr/0002-target-dsh-0.1.2-alpha.3-single-target.md) for the now-superseded alpha single-target decision.
8
8
 
@@ -25,7 +25,7 @@ https://github.com/user-attachments/assets/67a7c6ca-ff42-4005-b543-437ba61771bb
25
25
  - [**Agent preset switching**](docs/features/preset-switch.md) — `/preset` between the shipped agent compositions (`standard`, `minimal`, …); a switch is confirmed and starts a NEW session on the preset (the current one stays resumable); what a preset really gates.
26
26
  - [**Sessions & resume**](docs/features/sessions-resume.md) — sessions stay tidy automatically and resume in a few keystrokes; the host's kernel write lease keeps the log single-writer across processes.
27
27
  - [**History browser**](docs/features/history.md) — `/history` opens a fixed two-pane look-back over the session: completed turns on the left, the selected turn's replies on the right; copy a prompt back to the editor, or cold-read any stored session without resuming it (read-only).
28
- - [**Themes**](docs/features/themes.md) — GitHub light/dark palettes, hot-switchable; `auto` follows your terminal.
28
+ - [**Themes**](docs/features/themes.md) — 20 built-in palettes (10 light + 10 dark) plus user theme discovery (`~/.dsh/themes/`); hot-switchable, `auto` follows your terminal.
29
29
  - [**Search, selection & images**](docs/features/search-selection-images.md) — `Ctrl+Shift+F` over the whole transcript, drag-select copies to the OS clipboard, attachments from web/Feishu render inline, LaTeX replies draw as Unicode math.
30
30
  - [**Slash commands**](docs/features/slash-commands.md) — `/model`, `/resume`, `/btw`, … plus everything dsh-native.
31
31
  - [**Startup plugin tree**](docs/features/startup-tree.md) — every profile plugin with its installed npm version, printed at launch.
@@ -125,7 +125,7 @@ Every knob lives under the `dsh-tui` settings namespace in `~/.dsh/settings.yaml
125
125
 
126
126
  | Key | Default | Meaning |
127
127
  |---|---|---|
128
- | `theme` | `auto` | Color scheme: `auto` (follow the terminal) / `light` / `dark`; `/theme` writes back to the same key |
128
+ | `theme` | `auto` | Color scheme: `auto` (follow the terminal) / `light` / `dark` / any registered theme name (`/theme` writes back to the same key) |
129
129
  | `panelHeight` | `'1'` | Think/tool panel height: `'1'` / `'5'` / `'7'` / `'10'` / `'all'` (full content) |
130
130
  | `maxAgents` | `4` | Max concurrently running subagents, `0` = unlimited (hot-tunable in `/agents → l` limits) |
131
131
  | `maxRounds` | `75` | Max assistant messages per subagent before a wrap-up request is injected; `0` = unlimited |
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # dsh-tui-pi
4
4
 
5
- 面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)的功能齐全的 pi 风格终端 UI——一套把 dsh 变成 pi 式编码代理体验的插件套件:/history 回看与任意轮分叉、确认式 preset 切换、subagent 实时操控、模型档案、GitHub 明/暗主题和 powerline 状态栏。
5
+ 面向 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)的功能齐全的 pi 风格终端 UI——一套把 dsh 变成 pi 式编码代理体验的插件套件:/history 回看与任意轮分叉、确认式 preset 切换、subagent 实时操控、模型档案、20+ 内置主题(10 亮 + 10 暗,支持用户主题目录自动发现)和 powerline 状态栏。
6
6
 
7
7
  **要求 dsh >= 0.1.5-rc.2** —— 本插件只跟随 dsh RC/stable 线(CI 与发版在运行时解析 latest/next 中更新的 dist-tag)。**不再支持 alpha 线。**宿主低于下限时,启动守卫会打一行 warning 并干净退出(可用 `DSH_TUI_SKIP_HOST_CHECK=1` 跳过)。alpha 单目标决策的历史见 [ADR 0002](docs/adr/0002-target-dsh-0.1.2-alpha.3-single-target.md)(已被取代)。
8
8
 
@@ -24,7 +24,7 @@ https://github.com/user-attachments/assets/67a7c6ca-ff42-4005-b543-437ba61771bb
24
24
  - [**模型 profile 与收藏**](docs/features/model-profiles.md) —— 按项目切换整套模型配置,选择器保持精简。
25
25
  - [**Agent preset 切换**](docs/features/preset-switch.md) —— `/preset` 在内置 agent 组合(`standard`、`minimal`……)间切换;切换需确认并会开启新会话(当前会话仍可 /resume 恢复);preset 到底管什么。
26
26
  - [**Sessions 会话与恢复**](docs/features/sessions-resume.md) —— 会话自动保持整洁、几次按键恢复;跨进程写者守卫保证日志单写者。
27
- - [**Themes 主题**](docs/features/themes.md) —— GitHub 明/暗配色热切换;`auto` 跟随终端。
27
+ - [**Themes 主题**](docs/features/themes.md) —— 20 个内置配色(10 亮 + 10 暗)+ 用户主题目录发现(`~/.dsh/themes/`);热切换,`auto` 跟随终端。
28
28
  - [**搜索、选择与图片**](docs/features/search-selection-images.md) —— `Ctrl+Shift+F` 全文搜索、划选复制到系统剪贴板、web/飞书附件内联渲染、LaTeX 转 Unicode 数学。
29
29
  - [**斜杠命令**](docs/features/slash-commands.md) —— `/model`、`/resume`、`/btw`、`/profile-switch`……外加全部 dsh 原生命令。
30
30
  - [**启动插件树**](docs/features/startup-tree.md) —— 启动即打印每个 profile 插件及其安装的 npm 版本。
@@ -121,7 +121,7 @@ dsh plugin --profile tui add @aiwayds/dsh-topics-memory
121
121
 
122
122
  | 键 | 默认 | 作用 |
123
123
  |---|---|---|
124
- | `theme` | `auto` | 配色:`auto`(跟随终端)/`light`/`dark`;`/theme` 写回同一段 |
124
+ | `theme` | `auto` | 配色:`auto`(跟随终端)/`light`/`dark`/任意已注册主题名;`/theme` 写回同一段 |
125
125
  | `panelHeight` | `'1'` | think/tool 面板高度:`'1'`/`'5'`/`'7'`/`'10'`/`'all'`(完整内容) |
126
126
  | `maxAgents` | `4` | 并发子代理上限,`0` = 不限(`/agents → l` limits 面板可热调) |
127
127
  | `maxRounds` | `75` | 每个子代理的 assistant 消息数上限,到达后注入收尾请求;`0` = 不限 |
package/cordis.patch.yml CHANGED
@@ -9,6 +9,18 @@
9
9
  # the profile's bundles mounts it; mounting it here too would duplicate the
10
10
  # entry id and crash the loader.
11
11
  #
12
+ # dsh-profile-switch IS mounted here — the one deliberate exception to the
13
+ # rule above: its commands replace the panels this suite used to carry
14
+ # (src/profile.ts, removed in 2.16.0), so an upgrade must reactivate them
15
+ # with zero user action or /profile-switch silently vanishes. It ships as a
16
+ # default dependency of this package and this insert mounts it from the
17
+ # profile root (every dsh-managed profile boots with nodeLinker: hoisted).
18
+ # A profile that ALSO lists @aiwayds/dsh-profile-switch in bundles is
19
+ # tolerated: the plugin's apply is idempotent across mounts (0.1.1), so the
20
+ # second entry no-ops instead of crashing on the duplicate command
21
+ # registration. (On surfaces without tui-pi — a web-only profile —
22
+ # activation stays manual as usual: add it to bundles.)
23
+ #
12
24
  # dsh-tool-ask-user is mounted here: the upstream tool is a soft-linked
13
25
  # closure dependency (not a regular npm dep) and needs an explicit entry in
14
26
  # the bundle to be wired into the model-facing tool catalog. Without this
@@ -37,6 +49,8 @@
37
49
  - insert:
38
50
  - id: tui-pi
39
51
  name: '@aiwayds/dsh-tui-pi'
52
+ - id: profile-switch
53
+ name: '@aiwayds/dsh-profile-switch'
40
54
  - id: tool-ask-user
41
55
  name: '@deepseek-ai/dsh-tool-ask-user'
42
56
  # Config mirrors the stock row's values (the plugin's Config schema
package/lib/index.js CHANGED
@@ -57,8 +57,9 @@ import { openSubmitRouteDialog } from "./route-dialog.js";
57
57
  import { BtwController, BTW_IDLE_NOTICE, BTW_USAGE, buildBtwSnapshot, parseBtwInput, resolveSnapshotLimit, } from "./btw.js";
58
58
  import { BtwOverlayWire } from "./btw-overlay.js";
59
59
  import { openPendingQueuePanel } from "./queue-panel.js";
60
- import { ansiFg, BOLD, darkTheme, lightTheme, RESET, resolveTheme } from "./theme/index.js";
60
+ import { ansiFg, BOLD, RESET, resolveTheme } from "./theme/index.js";
61
61
  import { rgbIsLight } from "./theme/palette.js";
62
+ import { discoverThemes, scanThemeDir, userThemesDir } from "./theme/registry.js";
62
63
  import { clipToWidth } from "./text.js";
63
64
  import {} from "./keymap.js";
64
65
  import { keybindingsPath, loadKeyBindings, openHotkeysManager } from "./hotkeys.js";
@@ -174,6 +175,15 @@ export function apply(ctx) {
174
175
  // silently dropping the bundled usage/config guide.
175
176
  ctx.skills.registerProvider(() => skillProvider);
176
177
  let handle;
178
+ // Theme registry: bundled themes/ plus $DSH_HOME/themes (user overrides
179
+ // bundled by name). Built ONCE per apply() and handed to every resolveTheme
180
+ // / pickTheme call site (startup, the settings watch sink, the /theme
181
+ // handler), so a custom theme named by DSH_TUI_THEME or by the persisted
182
+ // preference resolves through the same snapshot. `themeUserNames` marks
183
+ // which registry ids are user-owned (for the picker's "user theme" /
184
+ // "built-in" labels).
185
+ const themeRegistry = discoverThemes({ home: dshHome() });
186
+ const themeUserNames = new Set(scanThemeDir(userThemesDir(dshHome())).keys());
177
187
  // Live-session handle for the startup janitor (assigned inside the
178
188
  // render effect below, where the bridge is constructed): retention
179
189
  // polls the CURRENT session id at selection time and again right
@@ -525,6 +535,7 @@ export function apply(ctx) {
525
535
  }
526
536
  },
527
537
  themePreference,
538
+ themeRegistry,
528
539
  });
529
540
  handle = ui;
530
541
  /**
@@ -591,7 +602,7 @@ export function apply(ctx) {
591
602
  // 'auto' subscribes to the terminal's live color-scheme pushes; an
592
603
  // explicit pin opts out. resolveTheme guards env-pinned displays.
593
604
  ui.tui.setTerminalColorSchemeNotifications(pref === 'auto');
594
- applyTheme(resolveTheme(process.env, pref));
605
+ applyTheme(resolveTheme(process.env, pref, themeRegistry));
595
606
  };
596
607
  const renderer = new TranscriptRenderer(ui.transcript, ui.theme, () => ui.requestRender(), startupInfo);
597
608
  // Startup notices for a broken/misleading keybindings file — the TUI keeps
@@ -851,6 +862,20 @@ export function apply(ctx) {
851
862
  // Wiring through the handle: the editor is rebuilt on theme hot-swap, and
852
863
  // these providers are re-applied to the replacement instance.
853
864
  ui.setEditorAutocompleteProvider(commands.autocompleteProvider());
865
+ // One registration for both dispatch surfaces: CommandService.registerLocal
866
+ // (direct dispatch when no agent exists — no throwaway session) plus
867
+ // ctx.commands.register (discovery + lifecycle events against a live
868
+ // agent). Agentless bodies go through both, so autocomplete matches web.
869
+ const registerLocalCommand = (name, description, handler) => {
870
+ commands.registerLocal(name, handler);
871
+ ctx.effect(() => ctx.commands.register({
872
+ name,
873
+ description,
874
+ handler: invocation => handler(invocation.rawInput, invocation.signal),
875
+ }), `dsh-tui-pi: /${name}`);
876
+ };
877
+ /** Error message for notices — unknown catch values stringify safely. */
878
+ const messageOf = (e) => e instanceof Error ? e.message : String(e);
854
879
  // ------------------------------------------------------------ /btw (TUI-owned; ADR 0001) --
855
880
  // By-the-way side questions: one tool-less one-shot model call over a
856
881
  // read-only recent-conversation snapshot, streaming into a framed
@@ -928,12 +953,7 @@ export function apply(ctx) {
928
953
  return { kind: 'error', text: `btw rejected — ${result.reason}.` };
929
954
  }
930
955
  };
931
- commands.registerLocal('btw', btwHandler);
932
- ctx.effect(() => ctx.commands.register({
933
- name: 'btw',
934
- description: 'Ask a side question while the main task runs (temp overlay, not kept)',
935
- handler: invocation => btwHandler(invocation.rawInput, invocation.signal),
936
- }), 'dsh-tui-pi: /btw');
956
+ registerLocalCommand('btw', 'Ask a side question while the main task runs (temp overlay, not kept)', btwHandler);
937
957
  // ------------------------------------------- TUI-owned slash commands --
938
958
  // Web-surface parity: `model` is a browser client contribution there and
939
959
  // `export` a web-only download plugin — the terminal gets native
@@ -960,12 +980,7 @@ export function apply(ctx) {
960
980
  text: persistError === undefined ? modelText : `${modelText} · ⚠ not persisted: ${persistError}`,
961
981
  };
962
982
  };
963
- commands.registerLocal('model', modelHandler);
964
- ctx.effect(() => ctx.commands.register({
965
- name: 'model',
966
- description: 'Select the model (and think level) for this conversation',
967
- handler: invocation => modelHandler(invocation.rawInput, invocation.signal),
968
- }), 'dsh-tui-pi: /model');
983
+ registerLocalCommand('model', 'Select the model (and think level) for this conversation', modelHandler);
969
984
  // /agents: manage agent definition markdown files (model, think level,
970
985
  // spawn depth) — the terminal counterpart of pi's /fun-agent-cfg. An
971
986
  // optional argument preselects one agent by name.
@@ -976,12 +991,7 @@ export function apply(ctx) {
976
991
  return { kind: 'success', text: 'Agents unchanged.' };
977
992
  return { kind: 'success', text: result };
978
993
  };
979
- commands.registerLocal('agents', agentsHandler);
980
- ctx.effect(() => ctx.commands.register({
981
- name: 'agents',
982
- description: 'Manage agent definitions (model, think level, spawn depth) from markdown files',
983
- handler: invocation => agentsHandler(invocation.rawInput, invocation.signal),
984
- }), 'dsh-tui-pi: /agents');
994
+ registerLocalCommand('agents', 'Manage agent definitions (model, think level, spawn depth) from markdown files', agentsHandler);
985
995
  // /subagents: the command twin of Ctrl+G — pick a running (or recently
986
996
  // settled) subagent and inspect its live transcript in the 80% viewer.
987
997
  // Same flow as the key path; empty board closes immediately.
@@ -989,12 +999,7 @@ export function apply(ctx) {
989
999
  await openSubagentViewer(ctx, ui.tui, ui.theme, bridge, refocusEditor);
990
1000
  return { kind: 'success', text: 'Subagent viewer closed.' };
991
1001
  };
992
- commands.registerLocal('subagents', subagentsHandler);
993
- ctx.effect(() => ctx.commands.register({
994
- name: 'subagents',
995
- description: 'Browse subagents and inspect their live transcript',
996
- handler: invocation => subagentsHandler(invocation.rawInput, invocation.signal),
997
- }), 'dsh-tui-pi: /subagents');
1002
+ registerLocalCommand('subagents', 'Browse subagents and inspect their live transcript', subagentsHandler);
998
1003
  // /think: cycle the current model's reasoning effort without re-picking
999
1004
  // the model. A no-session /think still lands in the selection ref and
1000
1005
  // survives the lazy session creation (bridge seeds only an empty ref).
@@ -1037,12 +1042,7 @@ export function apply(ctx) {
1037
1042
  text: persistError === undefined ? thinkText : `${thinkText} · ⚠ not persisted: ${persistError}`,
1038
1043
  };
1039
1044
  };
1040
- commands.registerLocal('think', thinkHandler);
1041
- ctx.effect(() => ctx.commands.register({
1042
- name: 'think',
1043
- description: 'Switch the current model\'s think (reasoning) level',
1044
- handler: invocation => thinkHandler(invocation.rawInput, invocation.signal),
1045
- }), 'dsh-tui-pi: /think');
1045
+ registerLocalCommand('think', 'Switch the current model\'s think (reasoning) level', thinkHandler);
1046
1046
  /**
1047
1047
  * Record one committed preset as THIS workspace's remembered selection
1048
1048
  * (`$DSH_HOME/workspace-presets.json`, keyed by the session backend's
@@ -1179,12 +1179,7 @@ export function apply(ctx) {
1179
1179
  persistRememberedPreset(target.id);
1180
1180
  return { kind: 'success', text: outcome.message };
1181
1181
  };
1182
- commands.registerLocal('preset', presetHandler);
1183
- ctx.effect(() => ctx.commands.register({
1184
- name: 'preset',
1185
- description: 'Switch the agent preset (starts a new session on it; /preset next cycles)',
1186
- handler: invocation => presetHandler(invocation.rawInput, invocation.signal),
1187
- }), 'dsh-tui-pi: /preset');
1182
+ registerLocalCommand('preset', 'Switch the agent preset (starts a new session on it; /preset next cycles)', presetHandler);
1188
1183
  // Model profiles moved to the dsh-profile-switch plugin (ask-user based,
1189
1184
  // surface-agnostic). What stays HERE is the live-selection bridge it
1190
1185
  // consumes: the TUI's /model + /think ride the same selection ref, and
@@ -1226,12 +1221,7 @@ export function apply(ctx) {
1226
1221
  }, refocusEditor);
1227
1222
  return { kind: 'success', text: agent === undefined ? 'No active session.' : 'Session info shown.' };
1228
1223
  };
1229
- commands.registerLocal('session', sessionHandler);
1230
- ctx.effect(() => ctx.commands.register({
1231
- name: 'session',
1232
- description: 'Show the current session\'s info (id, model, stats)',
1233
- handler: invocation => sessionHandler(invocation.rawInput, invocation.signal),
1234
- }), 'dsh-tui-pi: /session');
1224
+ registerLocalCommand('session', 'Show the current session\'s info (id, model, stats)', sessionHandler);
1235
1225
  // /resume: pick a persisted session, validate its log, swap the live
1236
1226
  // agent for it, and rebuild transcript + stats from the stored events.
1237
1227
  const resumeHandler = async () => {
@@ -1245,7 +1235,7 @@ export function apply(ctx) {
1245
1235
  picked = await pickPersistedSession(ctx, ui.tui, ui.theme, currentId === undefined ? undefined : String(currentId), refocusEditor, resumeSettings);
1246
1236
  }
1247
1237
  catch (error) {
1248
- const message = error instanceof Error ? error.message : String(error);
1238
+ const message = messageOf(error);
1249
1239
  return { kind: 'error', text: message };
1250
1240
  }
1251
1241
  if (picked.kind === 'empty') {
@@ -1281,7 +1271,7 @@ export function apply(ctx) {
1281
1271
  resumed = await bridge.resume(target.id);
1282
1272
  }
1283
1273
  catch (error) {
1284
- const message = error instanceof Error ? error.message : String(error);
1274
+ const message = messageOf(error);
1285
1275
  if (error instanceof SessionAlreadyOwnedError) {
1286
1276
  // The host's kernel write lease refused the cold resume: another
1287
1277
  // process drives this session. Rather than a dead end, degrade to
@@ -1302,7 +1292,7 @@ export function apply(ctx) {
1302
1292
  catch (watchError) {
1303
1293
  return {
1304
1294
  kind: 'error',
1305
- text: `Cannot watch ${clipToWidth(String(target.id), 8)} read-only: ${watchError instanceof Error ? watchError.message : String(watchError)}`,
1295
+ text: `Cannot watch ${clipToWidth(String(target.id), 8)} read-only: ${messageOf(watchError)}`,
1306
1296
  };
1307
1297
  }
1308
1298
  }
@@ -1366,7 +1356,7 @@ export function apply(ctx) {
1366
1356
  notice = repairFailureNotice(await repairSessionLog(logPath));
1367
1357
  }
1368
1358
  catch (error) {
1369
- const detail = error instanceof Error ? error.message : String(error);
1359
+ const detail = messageOf(error);
1370
1360
  notice = `repair failed: ${detail} — log untouched`;
1371
1361
  }
1372
1362
  if (notice !== undefined)
@@ -1381,7 +1371,7 @@ export function apply(ctx) {
1381
1371
  await inspectPersistedSession(ctx, picked.id);
1382
1372
  }
1383
1373
  catch (error) {
1384
- const message = error instanceof Error ? error.message : String(error);
1374
+ const message = messageOf(error);
1385
1375
  if (isCorruptLogError(message)) {
1386
1376
  return offerCorruptedLogRepair(message);
1387
1377
  }
@@ -1389,12 +1379,7 @@ export function apply(ctx) {
1389
1379
  }
1390
1380
  return resumeAndReplay();
1391
1381
  };
1392
- commands.registerLocal('resume', resumeHandler);
1393
- ctx.effect(() => ctx.commands.register({
1394
- name: 'resume',
1395
- description: 'Resume a persisted session',
1396
- handler: invocation => resumeHandler(invocation.rawInput, invocation.signal),
1397
- }), 'dsh-tui-pi: /resume');
1382
+ registerLocalCommand('resume', 'Resume a persisted session', resumeHandler);
1398
1383
  // /history: the read-only two-pane look-back (ADR 0003) — left pane lists
1399
1384
  // the browsed session's completed turns, right pane shows the selected
1400
1385
  // turn's replies; Enter/c refills the editor with the turn's prompt (a
@@ -1460,12 +1445,7 @@ export function apply(ctx) {
1460
1445
  }, arg === '' ? undefined : arg);
1461
1446
  return { kind: result.error ? 'error' : 'success', text: result.text };
1462
1447
  };
1463
- commands.registerLocal('history', historyHandler);
1464
- ctx.effect(() => ctx.commands.register({
1465
- name: 'history',
1466
- description: 'Browse past turns of a session (read-only; /history <id> for a stored one)',
1467
- handler: invocation => historyHandler(invocation.rawInput, invocation.signal),
1468
- }), 'dsh-tui-pi: /history');
1448
+ registerLocalCommand('history', 'Browse past turns of a session (read-only; /history <id> for a stored one)', historyHandler);
1469
1449
  // Auto-resume after a hot-reload: a `/reload` stashes the previously
1470
1450
  // current session id before this fiber's teardown, and the freshly
1471
1451
  // re-imported module consumes it best-effort here so the next prompt
@@ -1513,7 +1493,7 @@ export function apply(ctx) {
1513
1493
  ui.requestRender();
1514
1494
  }
1515
1495
  catch (error) {
1516
- const message = error instanceof Error ? error.message : String(error);
1496
+ const message = messageOf(error);
1517
1497
  renderer.renderNotice(`--resume ${clipToWidth(bootResumeId, 8)}: ${message} — starting fresh.`, 'error');
1518
1498
  }
1519
1499
  })();
@@ -1585,7 +1565,7 @@ export function apply(ctx) {
1585
1565
  bridge.replay(session.snapshotEvents().filter(event => event.seq < session.firstLiveSeq));
1586
1566
  }
1587
1567
  catch (error) {
1588
- const detail = error instanceof Error ? error.message : String(error);
1568
+ const detail = messageOf(error);
1589
1569
  renderer.renderNotice('could not re-attach the previous session — it stays resumable via /resume;'
1590
1570
  + ` the next prompt starts a fresh one (${detail})`, 'error');
1591
1571
  }
@@ -1595,12 +1575,7 @@ export function apply(ctx) {
1595
1575
  await startNewSession();
1596
1576
  return { kind: 'success', text: 'New session started.' };
1597
1577
  };
1598
- commands.registerLocal('new', newHandler);
1599
- ctx.effect(() => ctx.commands.register({
1600
- name: 'new',
1601
- description: 'Start a new session',
1602
- handler: invocation => newHandler(invocation.rawInput, invocation.signal),
1603
- }), 'dsh-tui-pi: /new');
1578
+ registerLocalCommand('new', 'Start a new session', newHandler);
1604
1579
  // /settings: text-based configuration browser — the terminal counterpart
1605
1580
  // of the web GUI's settings surface. Enumerates ctx.settings.describe()
1606
1581
  // and walks each namespace's schema (drill-ins, cycle rows, inline editors,
@@ -1629,12 +1604,7 @@ export function apply(ctx) {
1629
1604
  : `Settings: ${changes} change${changes === 1 ? '' : 's'} applied.`,
1630
1605
  };
1631
1606
  };
1632
- commands.registerLocal('settings', settingsHandler);
1633
- ctx.effect(() => ctx.commands.register({
1634
- name: 'settings',
1635
- description: 'Browse and edit configuration (namespaces, values, resets)',
1636
- handler: invocation => settingsHandler(invocation.rawInput, invocation.signal),
1637
- }), 'dsh-tui-pi: /settings');
1607
+ registerLocalCommand('settings', 'Browse and edit configuration (namespaces, values, resets)', settingsHandler);
1638
1608
  // /skills: standalone skill browser with Installed/Available dual mode.
1639
1609
  // Enter/Space toggles or symlinks; Tab switches views; Esc exits.
1640
1610
  const skillsHandler = async () => {
@@ -1642,12 +1612,7 @@ export function apply(ctx) {
1642
1612
  openSkillsManagerPanel(ctx, ui.tui, ui.theme, refocusEditor, agent ?? undefined, () => { });
1643
1613
  return { kind: 'success', text: 'Skills manager opened.' };
1644
1614
  };
1645
- commands.registerLocal('skills', skillsHandler);
1646
- ctx.effect(() => ctx.commands.register({
1647
- name: 'skills',
1648
- description: 'Manage user skills (installed and available)',
1649
- handler: invocation => skillsHandler(invocation.rawInput, invocation.signal),
1650
- }), 'dsh-tui-pi: /skills');
1615
+ registerLocalCommand('skills', 'Manage user skills (installed and available)', skillsHandler);
1651
1616
  // /theme: pick a color scheme and apply it immediately — the choice is
1652
1617
  // persisted to the dsh-tui settings namespace (`applies: 'live'`, so the
1653
1618
  // watch hook would re-apply the same change anyway) and hot-swapped into
@@ -1659,20 +1624,26 @@ export function apply(ctx) {
1659
1624
  return { kind: 'error', text: 'Settings service is not available.' };
1660
1625
  }
1661
1626
  // Preselect from the live settings value (which may have changed since
1662
- // startup via the /settings browser), not the startup snapshot.
1663
- const picked = await pickTheme(ui.tui, ui.theme, currentThemePreference(ctx), refocusEditor);
1627
+ // startup via the /settings browser), not the startup snapshot. The
1628
+ // picker lists the registry themes (user + built-in) after the
1629
+ // constant auto/light/dark rows.
1630
+ const picked = await pickTheme(ui.tui, ui.theme, currentThemePreference(ctx), refocusEditor, {
1631
+ themes: themeRegistry,
1632
+ userNames: themeUserNames,
1633
+ });
1664
1634
  if (picked === undefined)
1665
1635
  return { kind: 'success', text: 'Theme unchanged.' };
1666
1636
  const writeError = await writeThemePreference(ctx, picked);
1667
1637
  if (writeError !== undefined)
1668
1638
  return { kind: 'error', text: writeError };
1669
1639
  // DSH_TUI_THEME pins the display regardless of the preference — don't
1670
- // claim the pick was applied when it wasn't. The preference is still
1671
- // persisted (and shown once the env override is dropped); the notice
1672
- // goes through the buffered command echo, so no replay issue.
1673
- const applied = resolveTheme(process.env, picked);
1640
+ // claim the pick was applied when it wasn't. Compare against the
1641
+ // env-free resolution of the same pick (the bundle cache makes the
1642
+ // reference comparison exact); 'auto' is skipped it was never
1643
+ // env-pin-sensitive (its display follows the terminal either way).
1644
+ const applied = resolveTheme(process.env, picked, themeRegistry);
1674
1645
  applyTheme(applied);
1675
- const expected = picked === 'light' ? lightTheme : picked === 'dark' ? darkTheme : undefined;
1646
+ const expected = picked === 'auto' ? undefined : resolveTheme({}, picked, themeRegistry);
1676
1647
  if (expected !== undefined && applied !== expected) {
1677
1648
  return {
1678
1649
  kind: 'success',
@@ -1681,12 +1652,7 @@ export function apply(ctx) {
1681
1652
  }
1682
1653
  return { kind: 'success', text: `Theme: ${picked} — applied.` };
1683
1654
  };
1684
- commands.registerLocal('theme', themeHandler);
1685
- ctx.effect(() => ctx.commands.register({
1686
- name: 'theme',
1687
- description: 'Set the terminal color scheme (applies immediately)',
1688
- handler: invocation => themeHandler(invocation.rawInput, invocation.signal),
1689
- }), 'dsh-tui-pi: /theme');
1655
+ registerLocalCommand('theme', 'Set the terminal color scheme (applies immediately)', themeHandler);
1690
1656
  // /reload: hot-reload this plugin from the current source — re-imports the
1691
1657
  // module and its dependencies (picking up src changes after `pnpm build`)
1692
1658
  // and swaps the plugin runtime. The old fiber is disposed, so the TUI and
@@ -1696,12 +1662,7 @@ export function apply(ctx) {
1696
1662
  kind: 'success',
1697
1663
  text: await reloadPlugin(ctx, import.meta.url),
1698
1664
  });
1699
- commands.registerLocal('reload', reloadHandler);
1700
- ctx.effect(() => ctx.commands.register({
1701
- name: 'reload',
1702
- description: 'Reload the TUI from the current source (apply code changes without restarting dsh)',
1703
- handler: invocation => reloadHandler(invocation.rawInput, invocation.signal),
1704
- }), 'dsh-tui-pi: /reload');
1665
+ registerLocalCommand('reload', 'Reload the TUI from the current source (apply code changes without restarting dsh)', reloadHandler);
1705
1666
  // /hotkeys — pi's keybinding browser, in the /agents select-panel style:
1706
1667
  // a field list of the app keys, Enter opens an editor that writes
1707
1668
  // ~/.dsh/keybindings.json and live-applies the change (no /reload).
@@ -1713,12 +1674,7 @@ export function apply(ctx) {
1713
1674
  });
1714
1675
  return { kind: 'success', text: summary ?? 'Keybindings unchanged.' };
1715
1676
  };
1716
- commands.registerLocal('hotkeys', hotkeysHandler);
1717
- ctx.effect(() => ctx.commands.register({
1718
- name: 'hotkeys',
1719
- description: 'Show the current keybindings (custom file: ~/.dsh/keybindings.json)',
1720
- handler: invocation => hotkeysHandler(invocation.rawInput, invocation.signal),
1721
- }), 'dsh-tui-pi: /hotkeys');
1677
+ registerLocalCommand('hotkeys', 'Show the current keybindings (custom file: ~/.dsh/keybindings.json)', hotkeysHandler);
1722
1678
  // /login: register provider credentials — the terminal counterpart of
1723
1679
  // pi-agent's /login, on the Models category's add-provider flow. Opens a
1724
1680
  // searchable provider directory (including already-configured routes, so
@@ -1749,12 +1705,7 @@ export function apply(ctx) {
1749
1705
  return { kind: 'success', text: 'Login cancelled.' };
1750
1706
  return { kind: 'success', text: `Provider ${result.name} configured.` };
1751
1707
  };
1752
- commands.registerLocal('login', loginHandler);
1753
- ctx.effect(() => ctx.commands.register({
1754
- name: 'login',
1755
- description: 'Configure a provider API key (register or replace credentials)',
1756
- handler: invocation => loginHandler(invocation.rawInput, invocation.signal),
1757
- }), 'dsh-tui-pi: /login');
1708
+ registerLocalCommand('login', 'Register provider credentials (API key) through the provider directory', loginHandler);
1758
1709
  // /logout: unsubscribe a provider — pi-agent's /logout on the dsh side.
1759
1710
  // Lists the providers with a stored credential; on selection removes the
1760
1711
  // key AND the settings.yaml provider entry, so the provider's models
@@ -1800,12 +1751,7 @@ export function apply(ctx) {
1800
1751
  text: `Logged out ${result.name} — API key and provider configuration removed.`,
1801
1752
  };
1802
1753
  };
1803
- commands.registerLocal('logout', logoutHandler);
1804
- ctx.effect(() => ctx.commands.register({
1805
- name: 'logout',
1806
- description: 'Log out a provider (removes its API key and provider configuration)',
1807
- handler: invocation => logoutHandler(invocation.rawInput, invocation.signal),
1808
- }), 'dsh-tui-pi: /logout');
1754
+ registerLocalCommand('logout', 'Log out a provider (removes its API key and provider configuration)', logoutHandler);
1809
1755
  // ------------------------------------------------- powerline footer + git --
1810
1756
  const git = new GitBranchWatcher(process.cwd());
1811
1757
  git.onChange = () => ui.requestRender();
@@ -1915,12 +1861,12 @@ export function apply(ctx) {
1915
1861
  void (async () => {
1916
1862
  const scheme = await ui.tui.queryTerminalColorScheme({ timeoutMs: 200 }).catch(() => undefined);
1917
1863
  if (scheme === 'light' || scheme === 'dark') {
1918
- applyTheme(resolveTheme(process.env, scheme));
1864
+ applyTheme(resolveTheme(process.env, scheme, themeRegistry));
1919
1865
  return;
1920
1866
  }
1921
1867
  const background = await ui.tui.queryTerminalBackgroundColor({ timeoutMs: 200 }).catch(() => undefined);
1922
1868
  if (background !== undefined) {
1923
- applyTheme(resolveTheme(process.env, rgbIsLight(background) ? 'light' : 'dark'));
1869
+ applyTheme(resolveTheme(process.env, rgbIsLight(background) ? 'light' : 'dark', themeRegistry));
1924
1870
  }
1925
1871
  })();
1926
1872
  }
@@ -1930,7 +1876,7 @@ export function apply(ctx) {
1930
1876
  const stopTerminalFollow = ui.tui.onTerminalColorSchemeChange(scheme => {
1931
1877
  if (themePreferenceRef !== 'auto')
1932
1878
  return;
1933
- applyTheme(resolveTheme(process.env, scheme));
1879
+ applyTheme(resolveTheme(process.env, scheme, themeRegistry));
1934
1880
  });
1935
1881
  // Live clock: the footer is the only thing that changes each second.
1936
1882
  const clockTimer = setInterval(() => ui.requestRender(), 1000);
@@ -1982,7 +1928,7 @@ export function apply(ctx) {
1982
1928
  // again; surface the failure in the transcript like every other
1983
1929
  // dispatch error. Buffered notice: this line is the only record of
1984
1930
  // the failure and must survive a theme-switch rebuild (doc.clear()).
1985
- const message = error instanceof Error ? error.message : String(error);
1931
+ const message = messageOf(error);
1986
1932
  renderer.renderNotice(message, 'error');
1987
1933
  return;
1988
1934
  }
@@ -2005,7 +1951,7 @@ export function apply(ctx) {
2005
1951
  // the transcript instead of an unhandled rejection killing the TUI.
2006
1952
  // Buffered notice: this line is the only record of the failure and
2007
1953
  // must survive a theme-switch rebuild (doc.clear()).
2008
- const message = error instanceof Error ? error.message : String(error);
1954
+ const message = messageOf(error);
2009
1955
  renderer.renderNotice(message, 'error');
2010
1956
  return;
2011
1957
  }
@@ -2045,7 +1991,7 @@ export function apply(ctx) {
2045
1991
  catch (error) {
2046
1992
  // Buffered notice: the failure line is the only on-screen record and
2047
1993
  // must survive a theme-switch rebuild (doc.clear()).
2048
- const message = error instanceof Error ? error.message : String(error);
1994
+ const message = messageOf(error);
2049
1995
  renderer.renderNotice(message, 'error');
2050
1996
  }
2051
1997
  // Session (re)created by the prompt: seed the badge cache. The initial