@deepseek-harness-tui/dsh-tui 0.9.2 → 0.9.3

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 (214) hide show
  1. package/README.md +4 -4
  2. package/bin/dsh-tui.js +26 -4
  3. package/dsh-ecosystem-spec/protocols/profile-definitions.d.ts +13 -13
  4. package/dsh-ecosystem-spec/protocols/profile-definitions.js +59 -59
  5. package/dsh-ecosystem-spec/protocols/tui-channel-http.d.ts +15 -15
  6. package/dsh-ecosystem-spec/protocols/tui-channel-http.js +32 -32
  7. package/dsh-ecosystem-spec/protocols/tui-channel.d.ts +59 -59
  8. package/dsh-ecosystem-spec/protocols/tui-channel.js +166 -166
  9. package/dsh-ecosystem-spec/protocols/tui-contributions.d.ts +42 -42
  10. package/dsh-ecosystem-spec/protocols/tui-contributions.js +143 -143
  11. package/dsh-ecosystem-spec/registry/README.md +14 -14
  12. package/dsh-ecosystem-spec/registry/host-descriptor.tui.example.json +56 -56
  13. package/dsh-ecosystem-spec/registry/permissions-0.1.json +13 -13
  14. package/dsh-ecosystem-spec/registry/registry-0.15.json +149 -149
  15. package/lib/types/commands.d.ts.map +1 -1
  16. package/lib/types/commands.js +7 -8
  17. package/lib/types/components/BalanceReportRow.d.ts +24 -0
  18. package/lib/types/components/BalanceReportRow.d.ts.map +1 -0
  19. package/lib/types/components/BalanceReportRow.js +87 -0
  20. package/lib/types/components/HistorySearchDialog.js +1 -1
  21. package/lib/types/components/MessageList.d.ts +3 -1
  22. package/lib/types/components/MessageList.d.ts.map +1 -1
  23. package/lib/types/components/MessageList.js +11 -7
  24. package/lib/types/components/PluginSceneBoundary.d.ts +1 -1
  25. package/lib/types/components/PluginSceneBoundary.js +1 -1
  26. package/lib/types/components/PromptInput.d.ts.map +1 -1
  27. package/lib/types/components/PromptInput.js +16 -0
  28. package/lib/types/components/Whale.d.ts +15 -4
  29. package/lib/types/components/Whale.d.ts.map +1 -1
  30. package/lib/types/components/Whale.js +9 -8
  31. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +7 -1
  32. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  33. package/lib/types/components/messages/AssistantToolUseMessage.js +34 -4
  34. package/lib/types/deepseekBalance.d.ts +53 -0
  35. package/lib/types/deepseekBalance.d.ts.map +1 -0
  36. package/lib/types/deepseekBalance.js +86 -0
  37. package/lib/types/deepseekPricing.d.ts +82 -0
  38. package/lib/types/deepseekPricing.d.ts.map +1 -0
  39. package/lib/types/deepseekPricing.js +133 -0
  40. package/lib/types/dsh-adapter/channel.d.ts +46 -1
  41. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  42. package/lib/types/dsh-adapter/channel.js +84 -10
  43. package/lib/types/dsh-adapter/index.d.ts +5 -0
  44. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  45. package/lib/types/dsh-adapter/index.js +1 -0
  46. package/lib/types/dsh-adapter/packaged-skills.d.ts +9 -0
  47. package/lib/types/dsh-adapter/packaged-skills.d.ts.map +1 -1
  48. package/lib/types/dsh-adapter/packaged-skills.js +19 -4
  49. package/lib/types/dsh-adapter/plugin.d.ts +6 -0
  50. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  51. package/lib/types/dsh-adapter/plugin.js +122 -18
  52. package/lib/types/dsh-adapter/settings-sections.d.ts +1 -0
  53. package/lib/types/dsh-adapter/settings-sections.d.ts.map +1 -1
  54. package/lib/types/dsh-adapter/settings-sections.js +35 -2
  55. package/lib/types/history.d.ts +4 -3
  56. package/lib/types/history.d.ts.map +1 -1
  57. package/lib/types/history.js +66 -18
  58. package/lib/types/i18n.d.ts +122 -55
  59. package/lib/types/i18n.d.ts.map +1 -1
  60. package/lib/types/i18n.js +39 -18
  61. package/lib/types/ink/ink.d.ts.map +1 -1
  62. package/lib/types/ink/ink.js +67 -17
  63. package/lib/types/ink/root.d.ts +16 -0
  64. package/lib/types/ink/root.d.ts.map +1 -1
  65. package/lib/types/ink/root.js +2 -0
  66. package/lib/types/ink/terminal-querier.d.ts +8 -0
  67. package/lib/types/ink/terminal-querier.d.ts.map +1 -1
  68. package/lib/types/ink/terminal-querier.js +13 -0
  69. package/lib/types/ink/terminal.d.ts +42 -0
  70. package/lib/types/ink/terminal.d.ts.map +1 -1
  71. package/lib/types/ink/terminal.js +52 -2
  72. package/lib/types/plugin-spec/registry.d.ts +1 -1
  73. package/lib/types/plugin-spec/registry.js +1 -1
  74. package/lib/types/screens/Chat.d.ts.map +1 -1
  75. package/lib/types/screens/Chat.js +72 -37
  76. package/lib/types/screens/SessionBrowser.d.ts.map +1 -1
  77. package/lib/types/screens/SessionBrowser.js +28 -13
  78. package/lib/types/screens/SessionTree.d.ts.map +1 -1
  79. package/lib/types/screens/SessionTree.js +33 -9
  80. package/lib/types/screens/Settings.d.ts +7 -5
  81. package/lib/types/screens/Settings.d.ts.map +1 -1
  82. package/lib/types/screens/Settings.js +206 -99
  83. package/lib/types/screens/StatusLine.d.ts.map +1 -1
  84. package/lib/types/screens/StatusLine.js +32 -1
  85. package/lib/types/sessionHistory.d.ts +11 -0
  86. package/lib/types/sessionHistory.d.ts.map +1 -1
  87. package/lib/types/sessionHistory.js +29 -0
  88. package/lib/types/sessions/format.d.ts.map +1 -1
  89. package/lib/types/sessions/format.js +14 -4
  90. package/lib/types/tips.js +2 -2
  91. package/lib/types/tuiDisplayPrefs.d.ts +3 -0
  92. package/lib/types/tuiDisplayPrefs.d.ts.map +1 -1
  93. package/lib/types/tuiDisplayPrefs.js +1 -0
  94. package/lib/types/update.d.ts +46 -0
  95. package/lib/types/update.d.ts.map +1 -1
  96. package/lib/types/update.js +232 -3
  97. package/node_modules/@deepseek-harness-tui/dsh-auth/LICENSE +21 -21
  98. package/node_modules/@deepseek-harness-tui/dsh-auth/dsh-plugin.json +44 -44
  99. package/node_modules/@dsh-std/command/CHANGELOG.md +8 -8
  100. package/node_modules/@dsh-std/command/LICENSE +21 -21
  101. package/node_modules/@dsh-std/command/README.md +26 -26
  102. package/node_modules/@dsh-std/command/README.zh.md +28 -28
  103. package/node_modules/@dsh-std/command/lib/index.js.map +1 -1
  104. package/node_modules/@dsh-std/command/package.json +22 -22
  105. package/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
  106. package/node_modules/@dsh-std/connection/LICENSE +21 -21
  107. package/node_modules/@dsh-std/connection/README.md +42 -42
  108. package/node_modules/@dsh-std/connection/README.zh.md +42 -42
  109. package/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
  110. package/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
  111. package/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
  112. package/node_modules/@dsh-std/connection/package.json +20 -20
  113. package/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  114. package/node_modules/@dsh-std/core/LICENSE +21 -21
  115. package/node_modules/@dsh-std/core/README.md +13 -13
  116. package/node_modules/@dsh-std/core/README.zh.md +13 -13
  117. package/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  118. package/node_modules/@dsh-std/core/package.json +21 -21
  119. package/node_modules/@dsh-std/manifest/CHANGELOG.md +9 -9
  120. package/node_modules/@dsh-std/manifest/LICENSE +21 -21
  121. package/node_modules/@dsh-std/manifest/README.md +5 -5
  122. package/node_modules/@dsh-std/manifest/README.zh.md +5 -5
  123. package/node_modules/@dsh-std/manifest/lib/index.js.map +1 -1
  124. package/node_modules/@dsh-std/manifest/package.json +20 -20
  125. package/node_modules/@dsh-std/manifest/schema/dsh-plugin-0.15.schema.json +144 -144
  126. package/node_modules/@dsh-std/messages/CHANGELOG.md +8 -8
  127. package/node_modules/@dsh-std/messages/LICENSE +21 -21
  128. package/node_modules/@dsh-std/messages/README.md +5 -5
  129. package/node_modules/@dsh-std/messages/README.zh.md +7 -7
  130. package/node_modules/@dsh-std/messages/lib/index.js.map +1 -1
  131. package/node_modules/@dsh-std/messages/package.json +20 -20
  132. package/node_modules/@dsh-std/messages/schema/message-observer-envelope-0.15.schema.json +54 -54
  133. package/node_modules/@dsh-std/presentation/CHANGELOG.md +9 -9
  134. package/node_modules/@dsh-std/presentation/LICENSE +21 -21
  135. package/node_modules/@dsh-std/presentation/README.md +27 -27
  136. package/node_modules/@dsh-std/presentation/README.zh.md +29 -29
  137. package/node_modules/@dsh-std/presentation/lib/callback.js.map +1 -1
  138. package/node_modules/@dsh-std/presentation/lib/index.js.map +1 -1
  139. package/node_modules/@dsh-std/presentation/package.json +25 -25
  140. package/node_modules/@dsh-std/storage/CHANGELOG.md +8 -8
  141. package/node_modules/@dsh-std/storage/LICENSE +21 -21
  142. package/node_modules/@dsh-std/storage/README.md +5 -5
  143. package/node_modules/@dsh-std/storage/README.zh.md +7 -7
  144. package/node_modules/@dsh-std/storage/lib/index.js.map +1 -1
  145. package/node_modules/@dsh-std/storage/package.json +20 -20
  146. package/node_modules/@dsh-std/storage/schema/local-storage-0.15.schema.json +56 -56
  147. package/package.json +95 -5
  148. package/skills/pr-comments/SKILL.md +1 -1
  149. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
  150. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/LICENSE +21 -21
  151. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/README.md +42 -42
  152. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/README.zh.md +42 -42
  153. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
  154. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
  155. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
  156. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/connection/package.json +20 -20
  157. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  158. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/LICENSE +21 -21
  159. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/README.md +13 -13
  160. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/README.zh.md +13 -13
  161. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  162. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/core/package.json +21 -21
  163. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/CHANGELOG.md +9 -9
  164. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/LICENSE +21 -21
  165. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/README.md +5 -5
  166. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/README.zh.md +5 -5
  167. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/lib/index.js.map +1 -1
  168. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/package.json +20 -20
  169. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/manifest/schema/dsh-plugin-0.15.schema.json +144 -144
  170. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/CHANGELOG.md +9 -9
  171. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/LICENSE +21 -21
  172. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/README.md +27 -27
  173. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/README.zh.md +29 -29
  174. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/lib/callback.js.map +1 -1
  175. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/lib/index.js.map +1 -1
  176. package/vendor/dsh-std/packages/command/node_modules/@dsh-std/presentation/package.json +25 -25
  177. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  178. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/LICENSE +21 -21
  179. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/README.md +13 -13
  180. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/README.zh.md +13 -13
  181. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  182. package/vendor/dsh-std/packages/connection/node_modules/@dsh-std/core/package.json +21 -21
  183. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  184. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/LICENSE +21 -21
  185. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/README.md +13 -13
  186. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/README.zh.md +13 -13
  187. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  188. package/vendor/dsh-std/packages/manifest/node_modules/@dsh-std/core/package.json +21 -21
  189. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  190. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/LICENSE +21 -21
  191. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/README.md +13 -13
  192. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/README.zh.md +13 -13
  193. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  194. package/vendor/dsh-std/packages/messages/node_modules/@dsh-std/core/package.json +21 -21
  195. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/CHANGELOG.md +8 -8
  196. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/LICENSE +21 -21
  197. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/README.md +42 -42
  198. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/README.zh.md +42 -42
  199. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/index.js.map +1 -1
  200. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/memory.js.map +1 -1
  201. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/lib/resolve-3X6ausgz.js.map +1 -1
  202. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/connection/package.json +20 -20
  203. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  204. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/LICENSE +21 -21
  205. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/README.md +13 -13
  206. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/README.zh.md +13 -13
  207. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  208. package/vendor/dsh-std/packages/presentation/node_modules/@dsh-std/core/package.json +21 -21
  209. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/CHANGELOG.md +8 -8
  210. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/LICENSE +21 -21
  211. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/README.md +13 -13
  212. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/README.zh.md +13 -13
  213. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/lib/index.js.map +1 -1
  214. package/vendor/dsh-std/packages/storage/node_modules/@dsh-std/core/package.json +21 -21
@@ -8,6 +8,7 @@ import { useTerminalFocus } from '../ink/hooks/use-terminal-focus.js';
8
8
  import { isMod, isPlainReturn, modLabel } from '../utils/modifiers.js';
9
9
  import { spreadRow, tailWidth, truncateWidth, wrapWidth } from '../sessions/format.js';
10
10
  import { stringWidth } from '../ink/stringWidth.js';
11
+ import { TICK, MULTIPLICATION_X } from '../cc/figures.js';
11
12
  import { t } from '../i18n.js';
12
13
  import { droppedTurnInfo, filterTree, flattenTree, nearestVisibleIndex, TREE_FILTERS, } from '../dsh-adapter/sessionTree.js';
13
14
  /** Local HH:MM (or MM/DD HH:MM when older than this year). */
@@ -95,10 +96,19 @@ function clampPrefix(prefix, budget) {
95
96
  return '…';
96
97
  return `…${tailWidth(prefix, budget - 1)}`;
97
98
  }
98
- /** Rows the layout cannot do without: header, search box, hints. */
99
- const MANDATORY_LINES = 3;
100
- /** Both dividers, in the order they earn their row (header rule first). */
101
- const RULE_PRIORITY = [0, 1];
99
+ /**
100
+ * Rows the layout cannot do without: the header, the bordered search card
101
+ * (three rows), the notice slot, and the hints. Everything else — the rule,
102
+ * the tree itself — yields before these do.
103
+ */
104
+ const MANDATORY_LINES = 6;
105
+ /**
106
+ * The one horizontal rule, above the hints. The search card's own border
107
+ * separates it from the header and the tree (the two rules that used to
108
+ * flank it are gone); the rule that remains lifts the hints off the content —
109
+ * decoration, and the first thing a short terminal drops.
110
+ */
111
+ const RULE_PRIORITY = [2];
102
112
  /** Width from which the preview pane sits beside the list instead of below. */
103
113
  const SPLIT_MIN_COLUMNS = 100;
104
114
  /** Lines the bottom preview block may take on narrow terminals. */
@@ -347,6 +357,16 @@ export function SessionTree({ channel, currentSessionId, onClose, onRestoreText,
347
357
  focusIndexRef.current = at;
348
358
  setSelectedId(list[at]?.node.id ?? null);
349
359
  };
360
+ /**
361
+ * Mouse wheel over the tree walks the cursor (cursor-centered window, so
362
+ * rolling IS scrolling; the hover-driven preview rides along). The menu /
363
+ * confirm seats keep the keyboard as sole owner.
364
+ */
365
+ const handleWheel = (event) => {
366
+ if (seat !== 'tree')
367
+ return;
368
+ step(event.deltaY >= 0 ? 1 : -1, 1, false);
369
+ };
350
370
  useInput((input, key) => {
351
371
  // While an action is in flight the seat swallows every key — closing now
352
372
  // would drop the user back into a half-swapped session.
@@ -437,7 +457,9 @@ export function SessionTree({ channel, currentSessionId, onClose, onRestoreText,
437
457
  const previewBlock = seat === 'tree' && previewEntry !== null && columns < SPLIT_MIN_COLUMNS && hoverId !== null
438
458
  ? PREVIEW_BLOCK_LINES
439
459
  : 0;
440
- const extraLines = (notice === undefined ? 0 : 1) + menuLines + confirmLines + previewBlock;
460
+ // The notice slot is mandatory (permanent): a rewind/fork report must never
461
+ // shift the tree by arriving.
462
+ const extraLines = menuLines + confirmLines + previewBlock;
441
463
  const ruleBudget = Math.max(0, Math.min(RULE_PRIORITY.length, rows - MANDATORY_LINES - extraLines));
442
464
  const rules = new Set(RULE_PRIORITY.slice(0, ruleBudget));
443
465
  const listHeight = Math.max(0, rows - MANDATORY_LINES - extraLines - rules.size);
@@ -465,17 +487,19 @@ export function SessionTree({ channel, currentSessionId, onClose, onRestoreText,
465
487
  : fitHint([t('tree-hint', { mod: modLabel }), t('tree-hint-short')], inputBudget);
466
488
  // ── Loading / busy seats ────────────────────────────────────────────────
467
489
  if (loading || busy) {
468
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: "remember", bold: true, children: truncateWidth(` ${t('tree-title')}`, inputBudget) }) }), rules.has(0) && (_jsx(Box, { flexShrink: 0, children: _jsx(Divider, { width: columns }) })), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, italic: true, children: ` ${truncateWidth(busy ? t('tree-rewinding') : t('tree-loading'), inputBudget)}` }) })] }));
490
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: "remember", bold: true, children: truncateWidth(` ${t('tree-title')}`, inputBudget) }) }), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, italic: true, children: ` ${truncateWidth(busy ? t('tree-rewinding') : t('tree-loading'), inputBudget)}` }) })] }));
469
491
  }
470
492
  // ── Render ──────────────────────────────────────────────────────────────
471
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { color: "remember", bold: true, children: header.left }), _jsx(Text, { dimColor: true, children: `${' '.repeat(header.gap)}${header.right}` })] }), rules.has(0) && (_jsx(Box, { flexShrink: 0, children: _jsx(Divider, { width: columns }) })), _jsx(Box, { flexShrink: 0, children: _jsx(SearchBox, { query: tailWidth(query, inputBudget), isFocused: seat === 'tree', isTerminalFocused: isTerminalFocused, placeholder: truncateWidth(t('tree-search'), inputBudget), borderless: true }) }), _jsxs(Box, { flexGrow: 1, flexShrink: 1, children: [_jsxs(Box, { flexDirection: "column", width: listWidth, height: listHeight, flexShrink: 0, children: [visible.length === 0 && (_jsx(Text, { dimColor: true, italic: true, children: ` ${truncateWidth(t('tree-empty'), Math.max(0, listWidth - 2))}` })), windowed.map((flatNode, index) => (_jsx(TreeRow, { flatNode: flatNode, meta: data?.sessions.get(flatNode.node.sessionId), liveSessionId: currentSessionId, width: listWidth, focused: windowStart + index === focusIndex, onActivePath: data?.activePath.has(flatNode.node.id) === true, onHover: setHoverId, onClick: () => {
493
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { color: "remember", bold: true, children: header.left }), _jsx(Text, { dimColor: true, children: `${' '.repeat(header.gap)}${header.right}` })] }), _jsx(Box, { flexShrink: 0, children: _jsx(SearchBox, { query: tailWidth(query, inputBudget), isFocused: seat === 'tree', isTerminalFocused: isTerminalFocused, placeholder: truncateWidth(t('tree-search'), inputBudget) }) }), _jsxs("ink-box", { style: { flexDirection: 'row', flexGrow: 1, flexShrink: 1, overflow: 'hidden' }, onWheel: handleWheel, children: [_jsxs(Box, { flexDirection: "column", width: listWidth, height: listHeight, flexShrink: 0, children: [visible.length === 0 && (_jsx(Text, { dimColor: true, italic: true, children: ` ${truncateWidth(t('tree-empty'), Math.max(0, listWidth - 2))}` })), windowed.map((flatNode, index) => (_jsx(TreeRow, { flatNode: flatNode, meta: data?.sessions.get(flatNode.node.sessionId), liveSessionId: currentSessionId, width: listWidth, focused: windowStart + index === focusIndex, onActivePath: data?.activePath.has(flatNode.node.id) === true, onHover: setHoverId, onClick: () => {
472
494
  // Click = focus + open the action menu (the mouse path to
473
495
  // every action this screen offers); the menu's own rows
474
496
  // execute.
475
497
  setSelectedId(flatNode.node.id);
476
498
  setMenuNodeId(flatNode.node.id);
477
499
  setMenuIndex(0);
478
- } }, flatNode.node.id)))] }), splitPreview && previewEntry !== null && (_jsx(PreviewPane, { entry: previewEntry, meta: data?.sessions.get(previewEntry.sessionId), width: previewWidth, height: listHeight, liveSessionId: currentSessionId, now: Date.now() }))] }), previewBlock > 0 && previewEntry !== null && (_jsx(PreviewPane, { entry: previewEntry, meta: data?.sessions.get(previewEntry.sessionId), width: columns, height: previewBlock, liveSessionId: currentSessionId, now: Date.now(), compact: true })), notice !== undefined && (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: notice.tone === 'error' ? 'error' : 'success', children: ` ${truncateWidth(notice.text, inputBudget)}` }) })), seat === 'menu' && menuEntry !== null && (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: "remember", children: ` ${truncateWidth(`❰ ${menuEntry.text}`, inputBudget)}` }) }), options.map((option, index) => (_jsx(MenuOptionRow, { option: option, highlighted: index === menuIndex, width: inputBudget, onClick: () => {
500
+ } }, flatNode.node.id)))] }), splitPreview && previewEntry !== null && (_jsx(PreviewPane, { entry: previewEntry, meta: data?.sessions.get(previewEntry.sessionId), width: previewWidth, height: listHeight, liveSessionId: currentSessionId, now: Date.now() }))] }), previewBlock > 0 && previewEntry !== null && (_jsx(PreviewPane, { entry: previewEntry, meta: data?.sessions.get(previewEntry.sessionId), width: columns, height: previewBlock, liveSessionId: currentSessionId, now: Date.now(), compact: true })), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: notice?.tone === 'error' ? 'error' : 'success', children: notice === undefined
501
+ ? ' '
502
+ : ` ${notice.tone === 'error' ? MULTIPLICATION_X : TICK} ${truncateWidth(notice.text, inputBudget - 2)}` }) }), seat === 'menu' && menuEntry !== null && (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: "remember", children: ` ${truncateWidth(`❰ ${menuEntry.text}`, inputBudget)}` }) }), options.map((option, index) => (_jsx(MenuOptionRow, { option: option, highlighted: index === menuIndex, width: inputBudget, onClick: () => {
479
503
  setMenuIndex(index);
480
504
  runMenuOption(option);
481
505
  }, onHover: () => setMenuIndex(index) }, option.id)))] })), seat === 'confirm' && confirmState !== null && (_jsxs(Box, { flexShrink: 0, flexDirection: "column",
@@ -484,7 +508,7 @@ export function SessionTree({ channel, currentSessionId, onClose, onRestoreText,
484
508
  ? t('tree-confirm-adopt', { text: confirmState.entry.text })
485
509
  : confirmState.droppedEntries !== undefined && confirmState.droppedEntries > 0
486
510
  ? t('tree-confirm-drop', { text: confirmState.entry.text, n: confirmState.droppedEntries })
487
- : t('tree-confirm-rewind', { text: confirmState.entry.text }), inputBudget)}` }), confirmState.dropsBranch === true && (_jsx(Text, { color: "error", children: ` ${truncateWidth(t('tree-confirm-drops-branch'), inputBudget)}` }))] })), rules.has(1) && (_jsx(Box, { flexShrink: 0, children: _jsx(Divider, { width: columns }) })), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, italic: true, children: _jsx(HintLine, { text: hint }) }) })] }));
511
+ : t('tree-confirm-rewind', { text: confirmState.entry.text }), inputBudget)}` }), confirmState.dropsBranch === true && (_jsx(Text, { color: "error", children: ` ${truncateWidth(t('tree-confirm-drops-branch'), inputBudget)}` }))] })), rules.has(2) && (_jsx(Box, { flexShrink: 0, children: _jsx(Divider, { width: columns }) })), _jsx(Box, { flexShrink: 0, children: _jsx(Text, { dimColor: true, italic: true, children: _jsx(HintLine, { text: hint }) }) })] }));
488
512
  }
489
513
  /** Index wrap helper for the menu cursor. */
490
514
  function wrapIndex(current, by, count) {
@@ -6,12 +6,14 @@ import type { Channel } from '../dsh-adapter/channel.js';
6
6
  * The TUI owns only presentation here: plugin-declared sections from the
7
7
  * `tuiSettingsSections` seam render as editable forms; every write goes back
8
8
  * through the dsh settings service (revision-fenced `mutate` path ops) or the
9
- * credentials seam (secret fields, blank-until-typed). Namespaces no plugin
10
- * declared a section for stay read-only with a YAML hint — the same fallback
11
- * the web front door gives namespaces without a card.
9
+ * credentials seam (secret fields, blank-until-typed).
12
10
  *
13
- * Edits are staged, never settled live: typing changes a draft, and only the
14
- * explicit save writes the durable document (see settingsEditor.ts for why).
11
+ * Edits auto-save: toggling a boolean/select writes on the spot, confirming a
12
+ * text draft writes on Enter, and Esc just leaves — there is no save key and
13
+ * nothing to discard. The SettingsForm still stages drafts under the hood (its
14
+ * snapshot save keeps rapid successive edits serialized; see
15
+ * settingsEditor.ts), so the brief in-flight window and a failed save keep
16
+ * their `*` / badge feedback.
15
17
  */
16
18
  export declare function Settings({ channel, onClose, }: {
17
19
  channel: Channel;
@@ -1 +1 @@
1
- {"version":3,"file":"Settings.d.ts","sourceRoot":"","sources":["../../../src/screens/Settings.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAWzB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AA+CxD;;;;;;;;;;;;GAYG;AACH,wBAAgB,QAAQ,CAAC,EACvB,OAAO,EACP,OAAO,GACR,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,GAAG,KAAK,CAAC,SAAS,CAkdlB"}
1
+ {"version":3,"file":"Settings.d.ts","sourceRoot":"","sources":["../../../src/screens/Settings.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAczB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AAuPxD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,QAAQ,CAAC,EACvB,OAAO,EACP,OAAO,GACR,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,GAAG,KAAK,CAAC,SAAS,CAkgBlB"}
@@ -1,26 +1,84 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { Box, Text, useInput, useTerminalSize } from '../ui.js';
4
4
  import { Divider } from '../components/design-system/Divider.js';
5
5
  import { HintLine } from '../components/design-system/HintLine.js';
6
6
  import { isMod, isPlainReturn } from '../utils/modifiers.js';
7
7
  import { truncateWidth } from '../sessions/format.js';
8
+ import { stringWidth } from '../ink/stringWidth.js';
9
+ import { POINTER, TICK, MULTIPLICATION_X } from '../cc/figures.js';
8
10
  import { getLang, t } from '../i18n.js';
9
11
  import { SettingsForm } from '../dsh-adapter/settingsEditor.js';
10
12
  /** Pick the provider-owned translation for the active language. */
11
13
  function pick(text, descriptions) {
12
14
  return descriptions?.[getLang()] ?? text;
13
15
  }
14
- /** Compact one-line preview of a read-only namespace's resolved value. */
15
- function valuePreview(value, budget) {
16
- let raw;
17
- try {
18
- raw = JSON.stringify(value) ?? 'undefined';
19
- }
20
- catch {
21
- raw = String(value);
16
+ /**
17
+ * Pad a glyph to a fixed display width.
18
+ *
19
+ * CJK terminal fonts commonly render symbols like `❯`/`✓` (U+276F/U+2713)
20
+ * a full cell wide where Latin fonts render them narrow — a marker column
21
+ * built on raw characters then shifts every row's content by one cell when
22
+ * the focused glyph swaps in. Padding with `stringWidth` (display columns,
23
+ * not string length) pins the column to `cols` cells on either font, so
24
+ * rows never move when focus changes.
25
+ */
26
+ function padTo(glyph, cols) {
27
+ const width = stringWidth(glyph);
28
+ return width >= cols ? glyph : glyph + ' '.repeat(cols - width);
29
+ }
30
+ /**
31
+ * One editable field row. Always exactly one line tall — the value column
32
+ * stays flush right (badges attach to its left), and the field's hint lives
33
+ * in the bottom help bar instead of a second row, so moving the focus never
34
+ * reflows the list. 行高亮背景由外层 CardRow 负责,本组件只管文字与 chip。
35
+ */
36
+ function FieldRow({ label, kind, value, selectLabel, focused, editing, invalid, staged, onClick, onMouseEnter, }) {
37
+ const booleanChip = kind === 'boolean' && (value === 'true' || value === 'false');
38
+ return (_jsxs(Box, { flexDirection: "row", height: 1, flexShrink: 0, overflow: "hidden", onClick: onClick, onMouseEnter: onMouseEnter, children: [_jsx(Text, { color: focused ? 'suggestion' : undefined, children: focused ? padTo(POINTER, 2) : ' ' }), _jsx(Text, { bold: focused, children: label }), _jsx(Box, { flexGrow: 1 }), invalid && _jsxs(Text, { color: "error", children: [t('settings-field-invalid'), " "] }), staged && !editing && _jsx(Text, { color: "suggestion", children: "* " }), booleanChip ? (_jsx(Text, { color: value === 'true' ? 'success' : 'inactive', children: value === 'true' ? `[${padTo(TICK, 2)}]` : '[ ]' })) : selectLabel !== undefined ? (_jsxs(Text, { children: [_jsx(Text, { color: focused ? 'suggestion' : 'subtle', children: '‹ ' }), _jsx(Text, { color: focused ? 'suggestion' : undefined, children: selectLabel }), _jsx(Text, { color: focused ? 'suggestion' : 'subtle', children: ' ›' })] })) : (_jsx(Text, { color: editing || focused ? 'suggestion' : undefined, dimColor: !focused && !editing && value === '', children: value }))] }));
39
+ }
40
+ /** A group navigation row — opens the group's subpage on Enter/click. */
41
+ function GroupRow({ title, focused, onClick, onMouseEnter, }) {
42
+ return (_jsxs(Box, { flexDirection: "row", height: 1, flexShrink: 0, overflow: "hidden", onClick: onClick, onMouseEnter: onMouseEnter, children: [_jsx(Text, { color: focused ? 'suggestion' : undefined, children: focused ? padTo(POINTER, 2) : ' ' }), _jsx(Text, { bold: focused, children: title }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: focused ? 'suggestion' : 'subtle', children: '›' })] }));
43
+ }
44
+ /** 区块卡片的边框色(沿用输入框的 panel 边框 token)。 */
45
+ const CARD_BORDER = 'promptBorder';
46
+ /**
47
+ * 区块卡片的顶边:`╭─ 标题 (副标题) ────── [徽章] ╮`。
48
+ *
49
+ * 标题用区块主题色加粗、徽章保留各自的语义色(未保存/重启/失败),其余
50
+ * 边框线用卡片边框色。宽度按 stringWidth 精确计算(CJK 标题占 2 格),
51
+ * 极窄终端依次退化:先丢副标题、再丢徽章、最后截断标题。
52
+ */
53
+ function CardTop({ title, subtitle, badges, columns, color = CARD_BORDER, }) {
54
+ const subtitleText = subtitle === undefined ? '' : ` (${subtitle})`;
55
+ let showSubtitle = subtitleText !== '';
56
+ let showBadges = badges.length > 0;
57
+ let titleText = title;
58
+ const badgesWidth = badges.reduce((sum, badge) => sum + stringWidth(`[${badge.text}]`), 0) + Math.max(0, badges.length - 1);
59
+ // 布局:'╭─ ' + 标题 + 副标题 + ' ' + 虚线 + [' ' + 徽章 + ' '] + '╮'
60
+ const used = (titleWidth, withSubtitle, withBadges) => 5 + titleWidth + (withSubtitle ? stringWidth(subtitleText) : 0) + (withBadges ? 2 + badgesWidth : 0);
61
+ if (used(stringWidth(titleText), showSubtitle, showBadges) + 1 > columns && showSubtitle)
62
+ showSubtitle = false;
63
+ if (used(stringWidth(titleText), showSubtitle, showBadges) + 1 > columns && showBadges)
64
+ showBadges = false;
65
+ if (used(stringWidth(titleText), showSubtitle, showBadges) + 1 > columns) {
66
+ titleText = truncateWidth(titleText, Math.max(4, columns - 6 - (showSubtitle ? stringWidth(subtitleText) : 0) - (showBadges ? 2 + badgesWidth : 0)));
22
67
  }
23
- return truncateWidth(raw, Math.max(8, budget));
68
+ const dashes = Math.max(0, columns - used(stringWidth(titleText), showSubtitle, showBadges));
69
+ return (_jsxs(Box, { flexDirection: "row", height: 1, flexShrink: 0, overflow: "hidden", children: [_jsx(Text, { color: color, children: '╭─ ' }), _jsx(Text, { bold: true, color: "permission", children: titleText }), showSubtitle && _jsx(Text, { dimColor: true, children: subtitleText }), _jsx(Text, { color: color, children: ` ${'─'.repeat(dashes)}` }), showBadges && (_jsxs(_Fragment, { children: [_jsx(Text, { color: color, children: ' ' }), badges.map((badge, index) => (_jsxs(React.Fragment, { children: [index > 0 && _jsx(Text, { color: color, children: ' ' }), _jsx(Text, { color: badge.color, children: `[${badge.text}]` })] }, index))), _jsx(Text, { color: color, children: ' ' })] })), _jsx(Text, { color: color, children: '╮' })] }));
70
+ }
71
+ /** 区块卡片的底边:`╰──────╯`。 */
72
+ function CardBottom({ columns, color = CARD_BORDER }) {
73
+ return (_jsx(Text, { color: color, wrap: "truncate-end", children: `╰${'─'.repeat(Math.max(0, columns - 2))}╯` }));
74
+ }
75
+ /**
76
+ * 卡片内一行:`│` 左右边框 + 内容区。聚焦行的高亮背景涂在内容区上
77
+ * (padding 也在背景内),边框线本身不吃高亮——高亮条正好嵌在两道
78
+ * 竖线之间。行内容恒 1 行,与窗口化滚动逐行兼容(SuggestionCard 同族)。
79
+ */
80
+ function CardRow({ children, highlight, color = CARD_BORDER, }) {
81
+ return (_jsxs(Box, { flexDirection: "row", height: 1, flexShrink: 0, overflow: "hidden", children: [_jsx(Text, { color: color, children: "\u2502" }), _jsx(Box, { flexDirection: "column", flexGrow: 1, minWidth: 0, paddingX: 1, backgroundColor: highlight === true ? 'selectionBg' : undefined, children: children }), _jsx(Text, { color: color, children: "\u2502" })] }));
24
82
  }
25
83
  /**
26
84
  * The settings screen — `/settings` as a screen of its own (issue #165).
@@ -28,12 +86,14 @@ function valuePreview(value, budget) {
28
86
  * The TUI owns only presentation here: plugin-declared sections from the
29
87
  * `tuiSettingsSections` seam render as editable forms; every write goes back
30
88
  * through the dsh settings service (revision-fenced `mutate` path ops) or the
31
- * credentials seam (secret fields, blank-until-typed). Namespaces no plugin
32
- * declared a section for stay read-only with a YAML hint — the same fallback
33
- * the web front door gives namespaces without a card.
89
+ * credentials seam (secret fields, blank-until-typed).
34
90
  *
35
- * Edits are staged, never settled live: typing changes a draft, and only the
36
- * explicit save writes the durable document (see settingsEditor.ts for why).
91
+ * Edits auto-save: toggling a boolean/select writes on the spot, confirming a
92
+ * text draft writes on Enter, and Esc just leaves — there is no save key and
93
+ * nothing to discard. The SettingsForm still stages drafts under the hood (its
94
+ * snapshot save keeps rapid successive edits serialized; see
95
+ * settingsEditor.ts), so the brief in-flight window and a failed save keep
96
+ * their `*` / badge feedback.
37
97
  */
38
98
  export function Settings({ channel, onClose, }) {
39
99
  // Explicit terminal size (not flexGrow) — the same rule SessionBrowser's
@@ -123,11 +183,22 @@ export function Settings({ channel, onClose, }) {
123
183
  ]);
124
184
  const effFocus = Math.min(focusIndex, Math.max(0, focusable.length - 1));
125
185
  const focused = focusable.length === 0 ? undefined : focusable[effFocus];
126
- const focusedForm = focused === undefined ? undefined : forms.get(focused.ns);
127
- const commitSave = (ns) => {
128
- const form = forms.get(ns);
129
- if (form === undefined || form.saving || !form.shell().dirty)
186
+ /**
187
+ * Save a section's staged edits right now (auto-save: every confirmed edit
188
+ * writes immediately — no manual save key, Esc just leaves). A save already
189
+ * in flight chains: the pending marker re-runs the save once it settles, and
190
+ * the editor's snapshot semantics keep drafts staged mid-flight alive, so
191
+ * rapid toggles serialize instead of racing the revision fence.
192
+ */
193
+ const pendingSaveRef = React.useRef(new Set());
194
+ const saveSoon = (ns) => {
195
+ const form = formsRef.current.get(ns);
196
+ if (form === undefined || !form.available || form.shell().invalid || !form.shell().dirty)
130
197
  return;
198
+ if (form.saving) {
199
+ pendingSaveRef.current.add(ns);
200
+ return;
201
+ }
131
202
  void form.save().then(ok => {
132
203
  if (!mountedRef.current)
133
204
  return;
@@ -139,12 +210,17 @@ export function Settings({ channel, onClose, }) {
139
210
  else {
140
211
  setNotice({ text: t('settings-save-failed', { ns }), tone: 'error' });
141
212
  }
213
+ if (pendingSaveRef.current.delete(ns)) {
214
+ const next = formsRef.current.get(ns);
215
+ if (next !== undefined && next.shell().dirty)
216
+ saveSoon(ns);
217
+ }
142
218
  });
143
219
  };
144
- /** Stage the next choice for a boolean/select field (Enter cycles). */
220
+ /** Cycle a boolean/select field and save the change immediately. */
145
221
  const cycleField = (ns, field) => {
146
222
  const form = forms.get(ns);
147
- if (form === undefined || !form.available || form.saving)
223
+ if (form === undefined || !form.available)
148
224
  return;
149
225
  const current = form.field(field).text;
150
226
  if (field.kind === 'boolean') {
@@ -157,8 +233,8 @@ export function Settings({ channel, onClose, }) {
157
233
  const index = options.findIndex(option => option.value === current);
158
234
  form.edit(field, options[(index + 1) % options.length]?.value ?? options[0]?.value ?? '');
159
235
  }
160
- setNotice(undefined);
161
236
  bump();
237
+ saveSoon(ns);
162
238
  };
163
239
  /**
164
240
  * Activate one focusable entry — the keyboard Enter path, shared with the
@@ -172,10 +248,8 @@ export function Settings({ channel, onClose, }) {
172
248
  setWindowStart(0);
173
249
  return;
174
250
  }
175
- // A save in flight owns the section's drafts; starting an edit mid-write
176
- // is exactly the lost-draft race the staged model exists to prevent.
177
251
  const form = forms.get(entry.ns);
178
- if (form === undefined || !form.available || form.saving)
252
+ if (form === undefined || !form.available)
179
253
  return;
180
254
  if (entry.field.kind === 'boolean' || entry.field.kind === 'select') {
181
255
  cycleField(entry.ns, entry.field);
@@ -196,11 +270,21 @@ export function Settings({ channel, onClose, }) {
196
270
  useInput((input, key) => {
197
271
  if (mode === 'edit' && editing !== null) {
198
272
  if (isPlainReturn(key)) {
273
+ // Confirm the draft: an invalid value keeps the editor open with the
274
+ // error badge and a toast; a valid one saves immediately (auto-save).
199
275
  const form = forms.get(editing.ns);
200
- form?.edit(editing.field, editing.draft);
201
- bump();
202
- setMode('list');
203
- setEditing(null);
276
+ if (form !== undefined) {
277
+ form.edit(editing.field, editing.draft);
278
+ if (form.field(editing.field).invalid) {
279
+ setNotice({ text: t('settings-field-invalid'), tone: 'error' });
280
+ bump();
281
+ return;
282
+ }
283
+ bump();
284
+ setMode('list');
285
+ setEditing(null);
286
+ saveSoon(editing.ns);
287
+ }
204
288
  }
205
289
  else if (key.escape) {
206
290
  setMode('list');
@@ -227,40 +311,17 @@ export function Settings({ channel, onClose, }) {
227
311
  else if (isPlainReturn(key) && focused !== undefined) {
228
312
  activateEntry(focused);
229
313
  }
230
- else if (input === 's' && focused !== undefined) {
231
- commitSave(focused.ns);
232
- }
233
- else if (input === 'd' && focused !== undefined) {
234
- if (focusedForm?.saving)
235
- return;
236
- focusedForm?.discard();
237
- setNotice(undefined);
238
- bump();
239
- }
240
314
  else if (key.escape) {
241
315
  if (activeGroupSpec !== undefined) {
242
- // Group navigation never settles drafts; the root page owns discard/exit.
316
+ // Group navigation only unwinds the page; edits save as they land.
243
317
  setActiveGroup(null);
244
318
  setFocusIndex(0);
245
319
  setWindowStart(0);
246
320
  return;
247
321
  }
248
- // At the root, Esc backs out one layer at a time: staged drafts first —
249
- // ANY dirty section, not just the focused one — then the screen itself.
250
- // A save in flight cannot be undone from here; let it settle instead of
251
- // discarding around it.
252
- const dirty = [...forms.values()].filter(form => form.shell().dirty);
253
- if (dirty.length > 0) {
254
- if (dirty.some(form => form.saving))
255
- return;
256
- for (const form of dirty)
257
- form.discard();
258
- setNotice({ text: t('settings-discarded'), tone: 'success' });
259
- bump();
260
- }
261
- else {
262
- onClose();
263
- }
322
+ // Auto-save means there is nothing left to discard: Esc leaves. A save
323
+ // still in flight was already dispatched — it settles on its own.
324
+ onClose();
264
325
  }
265
326
  });
266
327
  const renderField = (section, field, focus) => {
@@ -270,7 +331,6 @@ export function Settings({ channel, onClose, }) {
270
331
  const isFocused = focused?.kind === 'field' && focused.ns === ns && focused.field === field;
271
332
  const isEditing = isFocused && mode === 'edit' && editing !== null;
272
333
  const label = pick(field.label, field.descriptions);
273
- const hint = field.hint !== undefined ? pick(field.hint, field.hintDescriptions) : undefined;
274
334
  // 鼠标:编辑态整屏不响应(草稿由键盘独占,防误触抢焦点);列表态
275
335
  // 悬停即移动焦点(lazygit 语义——焦点就是选中),点击 = 焦点 + 该行
276
336
  // 的 Enter 动作(boolean/select 循环值,文本/secret 进编辑态)。
@@ -307,40 +367,75 @@ export function Settings({ channel, onClose, }) {
307
367
  else {
308
368
  value = state.text;
309
369
  }
310
- return (_jsxs(Box, { ...rowEvents, children: [_jsx(Text, { color: isFocused ? 'suggestion' : undefined, children: isFocused ? '❯ ' : ' ' }), _jsx(Text, { bold: isFocused, children: label }), _jsx(Box, { flexGrow: 1 }), state.invalid && _jsxs(Text, { color: "error", children: [t('settings-field-invalid'), " "] }), state.overridden && _jsxs(Text, { dimColor: true, children: ["[", t('settings-badge-override'), "] "] }), form?.isStaged(field) === true && !isEditing && _jsx(Text, { color: "suggestion", children: "* " }), _jsx(Text, { color: isEditing || isFocused ? 'suggestion' : undefined, dimColor: !isFocused && !isEditing && state.text === '', children: value }), hint !== undefined && isFocused && (_jsxs(Text, { dimColor: true, children: ['\n ', hint] }))] }));
370
+ // select 的值渲染成按钮 chip:用选项的本地化文案('zh' → '中文')而不是
371
+ // 存储的原始值;没命中选项时退回原始值。仅在非编辑态、值非空时提供。
372
+ const selectLabel = field.kind === 'select' && !isEditing && state.text !== ''
373
+ ? (() => {
374
+ const option = field.options?.find(entry => entry.value === state.text);
375
+ return option === undefined ? state.text : pick(option.label, option.descriptions);
376
+ })()
377
+ : undefined;
378
+ return (_jsx(FieldRow, { label: label, kind: field.kind, value: value, selectLabel: selectLabel, focused: isFocused, editing: isEditing, invalid: state.invalid, staged: form?.isStaged(field) === true, onClick: rowEvents?.onClick, onMouseEnter: rowEvents?.onMouseEnter }));
311
379
  };
312
380
  // ── Layout: a flat entry list with accounted line heights, windowed so the
313
- // focused row is always on screen no matter how long the current page gets. ─
381
+ // focused row is always on screen no matter how long the current page gets.
382
+ // Each section renders as a bordered card (╭─ title ─╮ / │ rows │ / ╰──╯);
383
+ // borders are their own entries so windowing can cut a long card anywhere. ─
314
384
  const entries = [];
315
385
  let focusCursor = 0;
386
+ /** Badges for a section's form, rendered on the card's top border. */
387
+ const sectionBadges = (section) => {
388
+ const form = forms.get(section.ns);
389
+ const view = form?.namespace;
390
+ const shell = form?.shell();
391
+ const badges = [];
392
+ if (view === undefined)
393
+ badges.push({ text: t('settings-section-unavailable'), color: 'warning' });
394
+ if (view?.applies === 'restart')
395
+ badges.push({ text: t('settings-badge-restart'), color: 'warning' });
396
+ if (shell?.dirty === true)
397
+ badges.push({ text: t('settings-badge-dirty'), color: 'suggestion' });
398
+ if (shell?.saving === true)
399
+ badges.push({ text: t('settings-badge-saving'), color: 'inactive' });
400
+ if (shell?.failed === true)
401
+ badges.push({ text: t('settings-badge-failed'), color: 'error' });
402
+ return badges;
403
+ };
316
404
  const addField = (section, field) => {
317
- const isFocused = focused?.kind === 'field' && focused.ns === section.ns && focused.field === field;
318
405
  const index = focusCursor;
319
406
  focusCursor += 1;
407
+ const isFocused = focused?.kind === 'field' && focused.ns === section.ns && focused.field === field;
408
+ // Field rows are always exactly one line: the hint lives in the bottom
409
+ // help bar, so focusing a field never reflows the list below it.
320
410
  entries.push({
321
411
  key: `field:${section.ns}:${field.path.join('.')}`,
322
- lines: field.hint !== undefined && isFocused ? 2 : 1,
412
+ lines: 1,
323
413
  focus: index,
324
- node: renderField(section, field, index),
414
+ node: _jsx(CardRow, { highlight: isFocused, children: renderField(section, field, index) }),
325
415
  });
326
416
  };
327
417
  if (activeSection !== undefined && activeGroupSpec !== undefined) {
328
418
  const groupFields = activeSection.fields.filter(field => field.group === activeGroupSpec.id);
419
+ entries.push({
420
+ key: 'card:group:top',
421
+ lines: 1,
422
+ node: (_jsx(CardTop, { title: pick(activeGroupSpec.title, activeGroupSpec.descriptions), subtitle: pick(activeSection.title, activeSection.descriptions), badges: sectionBadges(activeSection), columns: columns })),
423
+ });
329
424
  for (const field of groupFields)
330
425
  addField(activeSection, field);
331
426
  if (groupFields.length === 0) {
332
- entries.push({ key: 'group:empty', lines: 1, node: _jsx(Text, { dimColor: true, children: t('settings-group-empty') }) });
427
+ entries.push({ key: 'group:empty', lines: 1, node: _jsx(CardRow, { children: _jsx(Text, { dimColor: true, children: t('settings-group-empty') }) }) });
333
428
  }
429
+ entries.push({ key: 'card:group:bottom', lines: 1, node: _jsx(CardBottom, { columns: columns }) });
334
430
  }
335
431
  else {
336
432
  sections.forEach((section, sectionIndex) => {
337
- const form = forms.get(section.ns);
338
- const view = form?.namespace;
339
- const shell = form?.shell();
433
+ if (sectionIndex > 0)
434
+ entries.push({ key: `gap:${section.ns}`, lines: 1, node: _jsx(Text, { children: " " }) });
340
435
  entries.push({
341
- key: `section:${section.ns}`,
342
- lines: sectionIndex === 0 ? 1 : 2,
343
- node: (_jsxs(Box, { flexDirection: "column", children: [sectionIndex > 0 && _jsx(Text, { children: " " }), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: "permission", children: pick(section.title, section.descriptions) }), _jsxs(Text, { dimColor: true, children: [" (", section.ns, ")"] }), view?.applies === 'restart' && _jsxs(Text, { color: "warning", children: [" [", t('settings-badge-restart'), "]"] }), view === undefined && _jsxs(Text, { color: "warning", children: [" [", t('settings-section-unavailable'), "]"] }), shell?.dirty === true && _jsxs(Text, { color: "suggestion", children: [" [", t('settings-badge-dirty'), "]"] }), shell?.saving === true && _jsxs(Text, { dimColor: true, children: [" [", t('settings-badge-saving'), "]"] }), shell?.failed === true && _jsxs(Text, { color: "error", children: [" [", t('settings-badge-failed'), "]"] })] })] })),
436
+ key: `card:${section.ns}:top`,
437
+ lines: 1,
438
+ node: (_jsx(CardTop, { title: pick(section.title, section.descriptions), subtitle: section.ns, badges: sectionBadges(section), columns: columns })),
344
439
  });
345
440
  for (const field of section.fields) {
346
441
  if (field.group === undefined)
@@ -365,32 +460,14 @@ export function Settings({ channel, onClose, }) {
365
460
  key: `group:${section.ns}:${group.id}`,
366
461
  lines: 1,
367
462
  focus: index,
368
- node: (_jsxs(Box, { ...groupRowEvents, children: [_jsx(Text, { color: isFocused ? 'suggestion' : undefined, children: isFocused ? '❯ ' : ' ' }), _jsx(Text, { bold: isFocused, children: pick(group.title, group.descriptions) }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: isFocused ? 'suggestion' : undefined, children: "\u203A" })] })),
463
+ node: (_jsx(CardRow, { highlight: isFocused, children: _jsx(GroupRow, { title: pick(group.title, group.descriptions), focused: isFocused, onClick: groupRowEvents?.onClick, onMouseEnter: groupRowEvents?.onMouseEnter }) })),
369
464
  });
370
465
  }
466
+ entries.push({ key: `card:${section.ns}:bottom`, lines: 1, node: _jsx(CardBottom, { columns: columns }) });
371
467
  });
372
- const registeredNs = new Set(sections.map(section => section.ns));
373
- const readonlyNamespaces = namespaces.filter(entry => !registeredNs.has(entry.ns));
374
- if (readonlyNamespaces.length > 0) {
375
- entries.push({
376
- key: 'readonly:heading',
377
- lines: 2,
378
- node: (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsx(Text, { bold: true, dimColor: true, children: t('settings-readonly-heading') })] })),
379
- });
380
- for (const entry of readonlyNamespaces) {
381
- entries.push({
382
- key: `readonly:${entry.ns}`,
383
- lines: 1,
384
- node: (_jsxs(Box, { children: [_jsxs(Text, { children: [' ', entry.ns] }), entry.applies === 'restart' && _jsxs(Text, { color: "warning", children: [" [", t('settings-badge-restart'), "]"] }), _jsxs(Text, { dimColor: true, children: [' ', valuePreview(entry.value, 60)] })] })),
385
- });
386
- }
387
- entries.push({
388
- key: 'readonly:hint',
389
- lines: 1,
390
- node: _jsxs(Text, { dimColor: true, children: [' ', t('settings-readonly-hint', { path: '~/.dsh/settings.yaml' })] }),
391
- });
392
- }
393
- if (sections.length === 0 && readonlyNamespaces.length === 0) {
468
+ // Namespaces without a plugin-declared section are deliberately NOT
469
+ // listed (they used to get a read-only YAML hint; user feedback: noise).
470
+ if (sections.length === 0) {
394
471
  entries.push({
395
472
  key: 'empty',
396
473
  lines: 1,
@@ -409,8 +486,10 @@ export function Settings({ channel, onClose, }) {
409
486
  }
410
487
  totalLines += entry.lines;
411
488
  }
412
- // Chrome: title row, top rule, bottom rule, hint row, plus the notice.
413
- const viewport = Math.max(1, rows - 4 - (notice === undefined ? 0 : 1));
489
+ // Chrome: title row, footer rule, notice slot, help row. The notice slot is
490
+ // permanent (blank while quiet) so a save/discard toast never shifts the
491
+ // list above it.
492
+ const viewport = Math.max(1, rows - 4);
414
493
  React.useEffect(() => {
415
494
  setWindowStart(start => {
416
495
  if (focusedOffset < start)
@@ -426,9 +505,37 @@ export function Settings({ channel, onClose, }) {
426
505
  entryOffset += entry.lines;
427
506
  return start >= windowStart && start + entry.lines <= windowStart + viewport;
428
507
  });
429
- const title = activeSection !== undefined && activeGroupSpec !== undefined
430
- ? `${t('settings-title')} › ${pick(activeSection.title, activeSection.descriptions)} › ${pick(activeGroupSpec.title, activeGroupSpec.descriptions)}`
431
- : t('settings-title');
432
- const navigationHint = activeGroupSpec === undefined ? t('settings-hint-list') : t('settings-hint-group');
433
- return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: title }), _jsx(Box, { flexGrow: 1 }), host === undefined && _jsx(Text, { color: "warning", children: t('settings-unavailable') })] }), _jsx(Divider, {}), _jsx("ink-box", { style: { flexDirection: 'column', flexGrow: 1, flexShrink: 1, overflow: 'hidden' }, onWheel: handleWheel, children: visible.map(entry => (_jsx(React.Fragment, { children: entry.node }, entry.key))) }), _jsx(Box, { flexGrow: 1 }), notice !== undefined && (_jsx(Text, { color: notice.tone === 'error' ? 'error' : 'success', children: notice.text })), _jsx(Divider, {}), _jsx(Text, { dimColor: true, italic: true, children: _jsx(HintLine, { text: mode === 'edit' ? t('settings-hint-edit') : navigationHint }) })] }));
508
+ const inGroup = activeSection !== undefined && activeGroupSpec !== undefined;
509
+ const navigationHint = inGroup ? t('settings-hint-group') : t('settings-hint-list');
510
+ // The bottom help bar: the focused field's hint on the left (truncated
511
+ // first), the navigation keys pinned to the right — a truncated hint still
512
+ // reads, a truncated shortcut hint hides the keys nobody can guess.
513
+ const focusedHint = focused?.kind === 'field' && focused.field.hint !== undefined
514
+ ? pick(focused.field.hint, focused.field.hintDescriptions)
515
+ : undefined;
516
+ // A field whose user layer carries a value (settings.yaml) is "customized"
517
+ // — worth knowing, too noisy to badge every row with. It rides the help
518
+ // bar instead: focus the field and the suffix appears next to its hint.
519
+ const focusedCustomized = focused?.kind === 'field'
520
+ ? forms.get(focused.ns)?.field(focused.field).overridden === true
521
+ : false;
522
+ const keysLine = mode === 'edit' ? t('settings-hint-edit') : navigationHint;
523
+ const keysWidth = stringWidth(keysLine.replace(/\*\*/gu, ''));
524
+ const hintBudget = Math.max(0, columns - keysWidth - 2);
525
+ // The customized suffix is the whole point of this line — reserve its
526
+ // width first and truncate the hint instead, so it never becomes '已自…'.
527
+ const customText = focusedCustomized ? t('settings-field-customized') : undefined;
528
+ const customWidth = customText !== undefined ? stringWidth(customText) + 3 : 0; // ' · '
529
+ let hintText = '';
530
+ if (focusedHint !== undefined && hintBudget >= 12) {
531
+ hintText = truncateWidth(focusedHint, Math.max(0, hintBudget - customWidth));
532
+ if (customText !== undefined)
533
+ hintText += ` · ${customText}`;
534
+ }
535
+ else if (customText !== undefined && hintBudget >= 12) {
536
+ hintText = truncateWidth(customText, hintBudget);
537
+ }
538
+ // Header stats: where the focus sits in the current page's focusable list.
539
+ // (Auto-save means there is no unsaved-draft count to surface here.)
540
+ return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: t('settings-title') }), inGroup && (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: [' › ', pick(activeSection.title, activeSection.descriptions)] }), _jsx(Text, { dimColor: true, children: ' › ' }), _jsx(Text, { bold: true, children: pick(activeGroupSpec.title, activeGroupSpec.descriptions) })] })), _jsx(Box, { flexGrow: 1 }), host === undefined && _jsx(Text, { color: "warning", children: `${t('settings-unavailable')} ` }), focusable.length > 0 && _jsx(Text, { dimColor: true, children: `${effFocus + 1}/${focusable.length}` })] }), _jsx("ink-box", { style: { flexDirection: 'column', flexGrow: 1, flexShrink: 1, overflow: 'hidden' }, onWheel: handleWheel, children: visible.map(entry => (_jsx(React.Fragment, { children: entry.node }, entry.key))) }), _jsx(Box, { flexGrow: 1 }), _jsx(Divider, {}), _jsx(Text, { color: notice?.tone === 'error' ? 'error' : 'success', children: notice === undefined ? ' ' : `${notice.tone === 'error' ? MULTIPLICATION_X : TICK} ${notice.text}` }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, italic: true, children: hintText }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { dimColor: true, italic: true, children: _jsx(HintLine, { text: keysLine }) })] })] }));
434
541
  }
@@ -1 +1 @@
1
- {"version":3,"file":"StatusLine.d.ts","sourceRoot":"","sources":["../../../src/screens/StatusLine.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAQzB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AAYxD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AA6EvD,wBAAgB,UAAU,CAAC,EACzB,OAAO,EACP,eAAuB,EACvB,QAAgB,EAChB,IAAI,GACL,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;;;;OAQG;IACH,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,QAAQ,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;CACvD,qBA8VA;AAED,KAAK,aAAa,GAAG;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AAyHD,8EAA8E;AAC9E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,aAAa,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAKvF"}
1
+ {"version":3,"file":"StatusLine.d.ts","sourceRoot":"","sources":["../../../src/screens/StatusLine.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AASzB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AAcxD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AA8EvD,wBAAgB,UAAU,CAAC,EACzB,OAAO,EACP,eAAuB,EACvB,QAAgB,EAChB,IAAI,GACL,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;;;;OAQG;IACH,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,QAAQ,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;CACvD,qBAuXA;AAED,KAAK,aAAa,GAAG;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,UAAU,EAAE,MAAM,CAAA;CACnB,CAAA;AAsID,8EAA8E;AAC9E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,aAAa,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAKvF"}