@deepseek-harness-tui/dsh-tui 0.7.2 → 0.8.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 (119) hide show
  1. package/README.md +3 -3
  2. package/bin/dsh-tui.js +24 -3
  3. package/cordis.patch.yml +24 -11
  4. package/cordis.yml +9 -1
  5. package/lib/types/commands.d.ts.map +1 -1
  6. package/lib/types/commands.js +2 -1
  7. package/lib/types/components/GoalTodoPanel.d.ts.map +1 -1
  8. package/lib/types/components/GoalTodoPanel.js +6 -1
  9. package/lib/types/components/HistorySearchDialog.d.ts.map +1 -1
  10. package/lib/types/components/HistorySearchDialog.js +5 -1
  11. package/lib/types/components/MessageList.d.ts +3 -1
  12. package/lib/types/components/MessageList.d.ts.map +1 -1
  13. package/lib/types/components/MessageList.js +9 -5
  14. package/lib/types/components/PluginSceneBoundary.d.ts +31 -0
  15. package/lib/types/components/PluginSceneBoundary.d.ts.map +1 -0
  16. package/lib/types/components/PluginSceneBoundary.js +29 -0
  17. package/lib/types/components/PromptInput.d.ts.map +1 -1
  18. package/lib/types/components/PromptInput.js +52 -74
  19. package/lib/types/components/SearchBox.d.ts +4 -0
  20. package/lib/types/components/SearchBox.d.ts.map +1 -1
  21. package/lib/types/components/SearchBox.js +101 -2
  22. package/lib/types/components/SkillsPicker.d.ts +18 -0
  23. package/lib/types/components/SkillsPicker.d.ts.map +1 -0
  24. package/lib/types/components/SkillsPicker.js +50 -0
  25. package/lib/types/components/Spinner/SpinnerAnimationRow.d.ts +4 -1
  26. package/lib/types/components/Spinner/SpinnerAnimationRow.d.ts.map +1 -1
  27. package/lib/types/components/Spinner/SpinnerAnimationRow.js +10 -5
  28. package/lib/types/components/SplitDiffView.d.ts +28 -0
  29. package/lib/types/components/SplitDiffView.d.ts.map +1 -0
  30. package/lib/types/components/SplitDiffView.js +417 -0
  31. package/lib/types/components/WorkingSpinner.d.ts +3 -1
  32. package/lib/types/components/WorkingSpinner.d.ts.map +1 -1
  33. package/lib/types/components/WorkingSpinner.js +2 -2
  34. package/lib/types/components/design-system/ThemedText.d.ts +7 -1
  35. package/lib/types/components/design-system/ThemedText.d.ts.map +1 -1
  36. package/lib/types/components/design-system/ThemedText.js +2 -2
  37. package/lib/types/components/messages/AssistantToolUseMessage.d.ts +3 -1
  38. package/lib/types/components/messages/AssistantToolUseMessage.d.ts.map +1 -1
  39. package/lib/types/components/messages/AssistantToolUseMessage.js +17 -7
  40. package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
  41. package/lib/types/components/questions/AskUserQuestionPanel.js +52 -8
  42. package/lib/types/components/questions/PlanReviewPanel.d.ts.map +1 -1
  43. package/lib/types/components/questions/PlanReviewPanel.js +12 -3
  44. package/lib/types/dsh-adapter/channel.d.ts +78 -0
  45. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  46. package/lib/types/dsh-adapter/channel.js +165 -14
  47. package/lib/types/dsh-adapter/contract.d.ts +1 -1
  48. package/lib/types/dsh-adapter/contract.d.ts.map +1 -1
  49. package/lib/types/dsh-adapter/contract.js +1 -0
  50. package/lib/types/dsh-adapter/index.d.ts +4 -0
  51. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  52. package/lib/types/dsh-adapter/index.js +1 -0
  53. package/lib/types/dsh-adapter/packaged-presets.d.ts.map +1 -1
  54. package/lib/types/dsh-adapter/packaged-presets.js +5 -2
  55. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  56. package/lib/types/dsh-adapter/plugin.js +66 -3
  57. package/lib/types/dsh-adapter/promptDebug.d.ts +10 -0
  58. package/lib/types/dsh-adapter/promptDebug.d.ts.map +1 -0
  59. package/lib/types/dsh-adapter/promptDebug.js +148 -0
  60. package/lib/types/dsh-adapter/providerWizard.d.ts.map +1 -1
  61. package/lib/types/dsh-adapter/providerWizard.js +3 -1
  62. package/lib/types/dsh-adapter/scenes.d.ts +75 -0
  63. package/lib/types/dsh-adapter/scenes.d.ts.map +1 -0
  64. package/lib/types/dsh-adapter/scenes.js +78 -0
  65. package/lib/types/dsh-adapter/settings-sections.d.ts +116 -0
  66. package/lib/types/dsh-adapter/settings-sections.d.ts.map +1 -0
  67. package/lib/types/dsh-adapter/settings-sections.js +63 -0
  68. package/lib/types/dsh-adapter/settingsEditor.d.ts +128 -0
  69. package/lib/types/dsh-adapter/settingsEditor.d.ts.map +1 -0
  70. package/lib/types/dsh-adapter/settingsEditor.js +244 -0
  71. package/lib/types/i18n.d.ts +169 -18
  72. package/lib/types/i18n.d.ts.map +1 -1
  73. package/lib/types/i18n.js +45 -6
  74. package/lib/types/ink/components/Text.d.ts +9 -1
  75. package/lib/types/ink/components/Text.d.ts.map +1 -1
  76. package/lib/types/ink/components/Text.js +7 -6
  77. package/lib/types/ink/ink.d.ts.map +1 -1
  78. package/lib/types/ink/ink.js +4 -2
  79. package/lib/types/ink/log-update.d.ts.map +1 -1
  80. package/lib/types/ink/log-update.js +76 -9
  81. package/lib/types/ink/parse-keypress.d.ts.map +1 -1
  82. package/lib/types/ink/parse-keypress.js +4 -1
  83. package/lib/types/ink/terminal.d.ts +40 -4
  84. package/lib/types/ink/terminal.d.ts.map +1 -1
  85. package/lib/types/ink/terminal.js +57 -5
  86. package/lib/types/ink/termio/tokenize.d.ts +6 -0
  87. package/lib/types/ink/termio/tokenize.d.ts.map +1 -1
  88. package/lib/types/ink/termio/tokenize.js +18 -4
  89. package/lib/types/jsx-runtime.d.ts +2 -0
  90. package/lib/types/jsx-runtime.d.ts.map +1 -0
  91. package/lib/types/jsx-runtime.js +13 -0
  92. package/lib/types/modelRoute.d.ts +10 -2
  93. package/lib/types/modelRoute.d.ts.map +1 -1
  94. package/lib/types/modelRoute.js +33 -4
  95. package/lib/types/scenes.d.ts +3 -0
  96. package/lib/types/scenes.d.ts.map +1 -0
  97. package/lib/types/scenes.js +4 -0
  98. package/lib/types/screens/Chat.d.ts.map +1 -1
  99. package/lib/types/screens/Chat.js +182 -26
  100. package/lib/types/screens/SessionBrowser.d.ts.map +1 -1
  101. package/lib/types/screens/SessionBrowser.js +30 -15
  102. package/lib/types/screens/Settings.d.ts +20 -0
  103. package/lib/types/screens/Settings.d.ts.map +1 -0
  104. package/lib/types/screens/Settings.js +333 -0
  105. package/lib/types/screens/StatusLine.js +1 -1
  106. package/lib/types/sessions/view.d.ts.map +1 -1
  107. package/lib/types/sessions/view.js +13 -1
  108. package/lib/types/settings-sections.d.ts +3 -0
  109. package/lib/types/settings-sections.d.ts.map +1 -0
  110. package/lib/types/settings-sections.js +4 -0
  111. package/lib/types/theme.d.ts +12 -0
  112. package/lib/types/theme.d.ts.map +1 -1
  113. package/lib/types/theme.js +36 -0
  114. package/lib/types/update.d.ts +21 -7
  115. package/lib/types/update.d.ts.map +1 -1
  116. package/lib/types/update.js +66 -16
  117. package/lib/types/utils/paths.d.ts.map +1 -1
  118. package/lib/types/utils/paths.js +4 -1
  119. package/package.json +49 -9
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { t, getLang, setLang, isLang, writeLangPref, subscribeLang } from '../i18n.js';
4
4
  import { AlternateScreen, Box, Text, useInput, ScrollBox, useTheme, useTerminalSize } from '../ui.js';
5
+ import * as tuiKit from '../ui.js';
5
6
  import { POINTER } from '../cc/figures.js';
6
7
  import { isMod, isPlainReturnInput, modLabel } from '../utils/modifiers.js';
7
8
  import { formatTokens } from '../cc/format.js';
@@ -26,7 +27,10 @@ import { StatusLine } from './StatusLine.js';
26
27
  import { WorkingSpinner, useThinkingStatus } from '../components/WorkingSpinner.js';
27
28
  import { ActivityLine, contextPressurePct } from '../components/ActivityLine.js';
28
29
  import { ModelPicker } from '../components/ModelPicker.js';
30
+ import { PluginSceneBoundary } from '../components/PluginSceneBoundary.js';
31
+ import { SkillsPicker, SkillsPickerLoading } from '../components/SkillsPicker.js';
29
32
  import { SessionBrowser } from './SessionBrowser.js';
33
+ import { Settings } from './Settings.js';
30
34
  import { WorkspacePicker } from '../components/WorkspacePicker.js';
31
35
  import { WorkspaceFlowPicker } from '../components/WorkspaceFlowPicker.js';
32
36
  import { ActivityPicker } from '../components/ActivityPicker.js';
@@ -152,9 +156,17 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
152
156
  const [modelPickerOpen, setModelPickerOpen] = React.useState(false);
153
157
  const [models, setModels] = React.useState([]);
154
158
  const [modelIndex, setModelIndex] = React.useState(0);
159
+ /** `/skills` 技能目录(issue #204):null = 注册表快照在途。 */
160
+ const [skillsPickerOpen, setSkillsPickerOpen] = React.useState(false);
161
+ const [skillsList, setSkillsList] = React.useState(null);
162
+ const [skillsIndex, setSkillsIndex] = React.useState(0);
155
163
  /** `/resume` opens the session browser, a screen rather than a panel. It
156
164
  * owns its own selection, filters and keyboard — Chat only opens it. */
157
165
  const [browserOpen, setBrowserOpen] = React.useState(false);
166
+ /** `/settings` opens the plugin settings screen (issue #165) — like the
167
+ * browser, a screen rather than a panel: it owns its own focus, staged
168
+ * drafts and keyboard; Chat only opens it. */
169
+ const [settingsOpen, setSettingsOpen] = React.useState(false);
158
170
  const [workspacePickerOpen, setWorkspacePickerOpen] = React.useState(false);
159
171
  const [workspaceTargets, setWorkspaceTargets] = React.useState([]);
160
172
  const [workspaceIndex, setWorkspaceIndex] = React.useState(0);
@@ -243,6 +255,14 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
243
255
  }, []);
244
256
  /** Startup context panel: expanded by header click or Ctrl+T. */
245
257
  const [loadedContextOpen, setLoadedContextOpen] = React.useState(false);
258
+ /**
259
+ * Whether the startup context panel is on screen. Derived once and read by
260
+ * BOTH the Ctrl+T handler and the render below: the key's meaning depends on
261
+ * the panel being visible, so a second copy of this condition is a place for
262
+ * the two to disagree — which is how the key came to advertise one thing and
263
+ * do another in the first place.
264
+ */
265
+ const loadedContextVisible = channel.rows.length === 0 && channel.loadedContext !== undefined;
246
266
  /** `/` transcript search (less-style incsearch, ported from CC's REPL). */
247
267
  const [searchOpen, setSearchOpen] = React.useState(false);
248
268
  const [searchQuery, setSearchQuery] = React.useState('');
@@ -306,10 +326,17 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
306
326
  // Spinner timing refs, fed from channel state each render (the spinner
307
327
  // only mounts while working, so values are stable for the mount).
308
328
  const responseLengthRef = React.useRef(0);
329
+ const uploadTokensRef = React.useRef(0);
309
330
  const loadingStartTimeRef = React.useRef(0);
310
331
  const totalPausedMsRef = React.useRef(0);
311
332
  const pauseStartTimeRef = React.useRef(null);
312
333
  responseLengthRef.current = channel.responseChars;
334
+ // Most recent request's real upload (input + cache read/write occupy the
335
+ // wire exactly like the context window); 0 until the first usage event.
336
+ const lastUploadTokens = channel.lastUsage === undefined
337
+ ? 0
338
+ : channel.lastUsage.input + channel.lastUsage.cacheRead + channel.lastUsage.cacheWrite;
339
+ uploadTokensRef.current = lastUploadTokens;
313
340
  loadingStartTimeRef.current = channel.turnStart;
314
341
  const thinkingStatus = useThinkingStatus(channel.spinnerMode === 'thinking');
315
342
  // Terminal tab title (ported from CC's AnimatedTerminalTitle): the session
@@ -620,6 +647,23 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
620
647
  setModelIndex(index >= 0 ? index : 0);
621
648
  });
622
649
  return true;
650
+ case 'skills':
651
+ // issue #204: 列出当前 agent 的完整技能目录(名称 + 来源 + 简述),
652
+ // Enter 把可直调技能以 `/name ` 填回输入行(completion-only 分发的
653
+ // 同一路径)。注册表读取走 channel(快照 scoped 到 live agent)。
654
+ setHelpOpen(false);
655
+ setSkillsList(null);
656
+ setSkillsIndex(0);
657
+ setSkillsPickerOpen(true);
658
+ void channel.listSkills().then((list) => {
659
+ if (list === undefined) {
660
+ setSkillsPickerOpen(false);
661
+ channel.notify(t('skills-load-failed'), { color: 'error' });
662
+ return;
663
+ }
664
+ setSkillsList(list);
665
+ });
666
+ return true;
623
667
  case 'provider': {
624
668
  // Interactive add-provider wizard (/provider): drives the shared
625
669
  // question panel, persists profile + key via the channel's settings/
@@ -773,6 +817,13 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
773
817
  channel.pushLocal('/cost', lines);
774
818
  return true;
775
819
  }
820
+ case 'settings': {
821
+ // Plugin settings screen (issue #165): opens immediately; the screen
822
+ // reads sections + namespaces from the channel itself.
823
+ setHelpOpen(false);
824
+ setSettingsOpen(true);
825
+ return true;
826
+ }
776
827
  case 'config': {
777
828
  const userHome = process.env.USERPROFILE ?? '';
778
829
  const lines = [
@@ -814,14 +865,28 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
814
865
  });
815
866
  return true;
816
867
  case 'login': {
817
- const key = process.env.DEEPSEEK_API_KEY;
818
- const lines = [
819
- `${t('login-api-key', { key: key ? key.slice(0, 6) + '…' + key.slice(-4) : t('login-key-missing') })}`,
820
- `${t('login-base-url', { url: process.env.DEEPSEEK_BASE_URL ?? t('login-official-endpoint') })}`,
821
- t('login-source-hint'),
822
- ];
823
868
  setHelpOpen(false);
824
- channel.pushLocal('/login', lines);
869
+ void channel.describeCredential('DEEPSEEK_API_KEY')
870
+ .catch(() => undefined)
871
+ .then(status => {
872
+ const keyStatus = status === undefined
873
+ ? t('login-credentials-unavailable')
874
+ : status.configured
875
+ ? t('login-key-configured', { ref: 'DEEPSEEK_API_KEY' })
876
+ : t('login-key-missing');
877
+ channel.pushLocal('/login', [
878
+ t('login-api-key', { status: keyStatus }),
879
+ ...(status === undefined
880
+ ? []
881
+ : [
882
+ t('login-credential-source', { source: status.source ?? t('login-source-none') }),
883
+ t('login-credential-storage', {
884
+ mode: t(status.writable ? 'login-storage-writable' : 'login-storage-read-only'),
885
+ }),
886
+ ]),
887
+ t('login-base-url', { url: process.env.DEEPSEEK_BASE_URL ?? t('login-official-endpoint') }),
888
+ ]);
889
+ });
825
890
  return true;
826
891
  }
827
892
  case 'logout':
@@ -859,13 +924,6 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
859
924
  setHelpOpen(false);
860
925
  channel.pushLocal('/mcp', channel.mcpStatus());
861
926
  return true;
862
- case 'memory':
863
- setHelpOpen(false);
864
- channel.pushLocal('/memory', [
865
- t('memory-none'),
866
- t('memory-hint'),
867
- ]);
868
- return true;
869
927
  case 'update':
870
928
  setHelpOpen(false);
871
929
  if (onUpdate === undefined) {
@@ -1173,6 +1231,16 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1173
1231
  // search box, which Chat would otherwise route into the prompt.
1174
1232
  if (browserOpen)
1175
1233
  return;
1234
+ // Same for the settings screen: plain letters (s save / d discard) and
1235
+ // the field draft editor belong to it alone.
1236
+ if (settingsOpen)
1237
+ return;
1238
+ // A plugin scene (dsh-tui-scenes) or the trajectory scene owns the whole
1239
+ // screen while open: every key belongs to it. Unguarded, an Esc meant to
1240
+ // CLOSE the scene also reached the chat:cancel branch below whenever a
1241
+ // turn was in flight — closing the view and killing the turn in one key.
1242
+ if (sceneOpen || channel.pluginScene !== undefined)
1243
+ return;
1176
1244
  // The questionnaire / approval panel owns the keyboard while one is
1177
1245
  // pending (the panel's own useInput handles ↑/↓/Space/Tab/Enter/Esc;
1178
1246
  // the prompt input is unmounted, so nothing else should see these keys).
@@ -1428,6 +1496,30 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1428
1496
  }
1429
1497
  return;
1430
1498
  }
1499
+ if (skillsPickerOpen) {
1500
+ const list = skillsList ?? [];
1501
+ if (key.upArrow) {
1502
+ if (list.length > 0)
1503
+ setSkillsIndex(index => (index <= 0 ? list.length - 1 : index - 1));
1504
+ }
1505
+ else if (key.downArrow) {
1506
+ if (list.length > 0)
1507
+ setSkillsIndex(index => (index >= list.length - 1 ? 0 : index + 1));
1508
+ }
1509
+ else if (plainReturn) {
1510
+ const skill = list[skillsIndex];
1511
+ setSkillsPickerOpen(false);
1512
+ // 可直调技能 Enter 填入 `/name `——与 / 菜单选中技能同一条
1513
+ // completion-only 分发路径;模型专用技能(userInvocable=false)只关闭。
1514
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty list
1515
+ if (skill?.userInvocable)
1516
+ setHistoryFill(`/${skill.name} `);
1517
+ }
1518
+ else if (key.escape) {
1519
+ setSkillsPickerOpen(false);
1520
+ }
1521
+ return;
1522
+ }
1431
1523
  if (activityPickerOpen) {
1432
1524
  if (key.upArrow) {
1433
1525
  setActivityIndex(index => (index <= 0 ? PRESET_NAMES.length - 1 : index - 1));
@@ -1538,10 +1630,22 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1538
1630
  }
1539
1631
  }
1540
1632
  else if (key.leftArrow) {
1541
- setHistoryCursor(cursor => Math.max(0, cursor - 1));
1633
+ // Step by code point, not UTF-16 unit: an emoji is two units, and
1634
+ // a mid-pair caret offset would split it in the SearchBox render.
1635
+ setHistoryCursor(cursor => {
1636
+ if (cursor <= 0)
1637
+ return 0;
1638
+ const ch = [...historyQuery.slice(0, cursor)].pop();
1639
+ return cursor - ch.length;
1640
+ });
1542
1641
  }
1543
1642
  else if (key.rightArrow) {
1544
- setHistoryCursor(cursor => Math.min(historyQuery.length, cursor + 1));
1643
+ setHistoryCursor(cursor => {
1644
+ if (cursor >= historyQuery.length)
1645
+ return historyQuery.length;
1646
+ const ch = [...historyQuery.slice(cursor)][0];
1647
+ return cursor + ch.length;
1648
+ });
1545
1649
  }
1546
1650
  else if (key.home) {
1547
1651
  setHistoryCursor(0);
@@ -1589,11 +1693,25 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1589
1693
  return;
1590
1694
  }
1591
1695
  if (isMod(key) && input === 't') {
1592
- // Ctrl+T opens the trajectory scene (issue #80 evolution). It used to
1593
- // toggle the startup loaded-context panel — which only ever renders
1594
- // before the first message (see the render below), so the binding was
1595
- // dead for the whole rest of a session. The panel keeps its header
1596
- // click-to-toggle; the key now has a meaning that always applies.
1696
+ // Ctrl+T means "expand what is in front of you", and the two things it
1697
+ // can expand never share the screen.
1698
+ //
1699
+ // The startup loaded-context panel renders only while the transcript is
1700
+ // empty (see the render below) — and that is exactly the window where
1701
+ // the trajectory has nothing in it yet, since the trajectory is folded
1702
+ // from the session's own events. So the panel wins while it is up, and
1703
+ // the scene takes over for the rest of the session.
1704
+ //
1705
+ // Claiming the key outright for the scene was wrong even though the
1706
+ // panel binding looked dead: the panel prints its own `(Ctrl+T 展开)`
1707
+ // hint, so the key still had a visible promise attached to it, and the
1708
+ // scene it opened instead was necessarily empty at that moment. That
1709
+ // left the panel reachable only by clicking its header, which is not a
1710
+ // keyboard path at all.
1711
+ if (loadedContextVisible) {
1712
+ setLoadedContextOpen(previous => !previous);
1713
+ return;
1714
+ }
1597
1715
  openScene();
1598
1716
  return;
1599
1717
  }
@@ -1689,6 +1807,32 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1689
1807
  // Working-activity line (spinner slot): context-pressure prefix shares the
1690
1808
  // StatusLine thresholds (amber ≥ 80, red ≥ 95).
1691
1809
  const activityWarnPct = contextPressurePct(channel.lastUsage, channel.contextWindow);
1810
+ // A plugin scene (dsh-tui-scenes) takes the whole terminal the same way
1811
+ // the trajectory scene does, and sits at the TOP of this return chain:
1812
+ // an open() landing while the session browser or the trajectory scene is
1813
+ // up must still take the screen (and the keyboard, via the useInput guard
1814
+ // above), not queue silently behind them. Closing the plugin scene lands
1815
+ // back on whatever screen was up before, so these early returns read as a
1816
+ // stack. The component comes from the registry, so its identity is stable
1817
+ // across renders and its hook state survives re-renders; it receives the
1818
+ // TUI's own React + ui kit because a plugin importing its own React copy
1819
+ // would die on the first hook call under this reconciler.
1820
+ // The scene is third-party code, so it renders inside a boundary: a render
1821
+ // crash reports to the transcript and closes the scene instead of taking
1822
+ // the whole TUI down through ink's app-level boundary.
1823
+ const pluginScene = channel.pluginScene;
1824
+ if (pluginScene !== undefined) {
1825
+ const node = (_jsx(PluginSceneBoundary, { id: pluginScene.id, onError: (id, error) => {
1826
+ channel.notify(t('plugin-scene-crashed', { id, err: error.message }), { color: 'error' });
1827
+ channel.closePluginScene();
1828
+ }, children: React.createElement(pluginScene.component, {
1829
+ React,
1830
+ ui: tuiKit,
1831
+ channel,
1832
+ close: () => channel.closePluginScene(),
1833
+ }) }));
1834
+ return fullscreen ? node : _jsx(AlternateScreen, { children: node });
1835
+ }
1692
1836
  // The browser is a screen, not an overlay: it REPLACES the conversation
1693
1837
  // rather than floating above it. Rendering it as an early return (after
1694
1838
  // every hook above has run) is what makes that literal — there is no
@@ -1699,8 +1843,15 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1699
1843
  // hosts are already in it and must not nest a second one.
1700
1844
  return fullscreen ? browser : _jsx(AlternateScreen, { children: browser });
1701
1845
  }
1846
+ // The settings screen follows the browser's rule exactly: it REPLACES the
1847
+ // conversation (an early return after every hook above has run), so there
1848
+ // is no transcript underneath to be repainted or bled through.
1849
+ if (settingsOpen) {
1850
+ const screen = _jsx(Settings, { channel: channel, onClose: () => setSettingsOpen(false) });
1851
+ return fullscreen ? screen : _jsx(AlternateScreen, { children: screen });
1852
+ }
1702
1853
  /** Prompt input is inert while a modal dialog owns the keyboard. */
1703
- const promptSelectionActive = selectionActive || modelPickerOpen || workspacePickerOpen || workspaceFlow !== null || activityPickerOpen ||
1854
+ const promptSelectionActive = selectionActive || modelPickerOpen || skillsPickerOpen || workspacePickerOpen || workspaceFlow !== null || activityPickerOpen ||
1704
1855
  effortSliderOpen || presetPickerOpen || themePickerOpen || thinkingOpen || historyOpen || rewindOpen || searchOpen ||
1705
1856
  btw !== null;
1706
1857
  // The trajectory scene replaces the conversation for as long as it is open.
@@ -1721,7 +1872,7 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1721
1872
  // 常驻、只移除其普通子节点,blit 解毒不触发,被覆盖的转录行会在
1722
1873
  // blit-skip 后留空(Esc 关 picker 一片空白的根因)。
1723
1874
  const dialogOverlayOpen = thinkingOpen || (workspacePickerOpen && workspaceTargets.length > 0) || workspaceFlow !== null ||
1724
- modelPickerOpen ||
1875
+ modelPickerOpen || skillsPickerOpen ||
1725
1876
  activityPickerOpen || (effortSliderOpen && effortOptions.length > 1) ||
1726
1877
  (presetPickerOpen && presetOptions.length > 0) || themePickerOpen || historyOpen ||
1727
1878
  rewindOpen || searchOpen;
@@ -1732,11 +1883,16 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1732
1883
  seekRow(lastUser.id);
1733
1884
  else
1734
1885
  handle?.scrollToBottom();
1735
- } })), _jsxs(ScrollBox, { ref: setHandle, flexDirection: "column", flexGrow: 1, flexShrink: 1, stickyScroll: true, children: [_jsx(LogoHeader, { model: channel.model, effort: channel.reasoningEffort, cwd: channel.displayCwd }), channel.rows.length === 0 && channel.loadedContext !== undefined && (_jsx(LoadedContextPanel, { context: channel.loadedContext, open: loadedContextOpen, onToggle: () => { setLoadedContextOpen(previous => !previous); } })), _jsx(MessageList, { rows: channel.rows, failureHintRowId: failureHintRowId, failureHint: t('traj-hint-failure', { key: `${modLabel}t` }), expanded: expanded, expandedRows: expandedRows, selectedId: selectionActive ? selectedId : null, onToggleRow: toggleRowExpanded, model: channel.model, showAll: showAllMessages, thinkingVisible: thinkingVisible, onToggleAll: () => { setShowAllMessages(previous => !previous); }, onLoadOlder: () => channel.loadOlder(), registerRowRef: registerRowRef, scrollHandle: handle, forceMountRowId: forceMountRowId, newSinceRowId: isSticky ? null : lastSeenRowIdRef.current, onUnseenCount: setUnseenCount })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [showPill && (_jsx(NewMessagesPill, { count: unseenCount, onClick: () => handle?.scrollToBottom() })), channel.working &&
1886
+ } })), _jsxs(ScrollBox, { ref: setHandle, flexDirection: "column", flexGrow: 1, flexShrink: 1, stickyScroll: true, children: [_jsx(LogoHeader, { model: channel.model, effort: channel.reasoningEffort, cwd: channel.displayCwd }), loadedContextVisible && (_jsx(LoadedContextPanel, { context: channel.loadedContext, open: loadedContextOpen, onToggle: () => { setLoadedContextOpen(previous => !previous); } })), _jsx(MessageList, { rows: channel.rows, failureHintRowId: failureHintRowId, failureHint: t('traj-hint-failure', { key: `${modLabel}t` }), expanded: expanded, expandedRows: expandedRows, selectedId: selectionActive ? selectedId : null, onToggleRow: toggleRowExpanded, model: channel.model, diffLayout: channel.diffLayout, showAll: showAllMessages, thinkingVisible: thinkingVisible, onToggleAll: () => { setShowAllMessages(previous => !previous); }, onLoadOlder: () => channel.loadOlder(), registerRowRef: registerRowRef, scrollHandle: handle, forceMountRowId: forceMountRowId, newSinceRowId: isSticky ? null : lastSeenRowIdRef.current, onUnseenCount: setUnseenCount })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [showPill && (_jsx(NewMessagesPill, { count: unseenCount, onClick: () => handle?.scrollToBottom() })), channel.working &&
1736
1887
  (channel.activityEnabled &&
1737
1888
  channel.workingActivity !== undefined &&
1738
1889
  channel.workingActivity.line !== '' &&
1739
- channel.workingActivity.phase !== 'idle' ? (_jsx(Box, { marginTop: 1, children: _jsx(ActivityLine, { activity: channel.workingActivity, activityFrames: channel.activityFrames, warnPct: activityWarnPct, warnDanger: activityWarnPct !== undefined && activityWarnPct >= 95, suffix: ` · ↓ ${channel.responseChars} tokens` }) })) : (_jsx(WorkingSpinner, { mode: channel.spinnerMode, hasActiveTools: channel.activeToolCount > 0, responseLengthRef: responseLengthRef, loadingStartTimeRef: loadingStartTimeRef, totalPausedMsRef: totalPausedMsRef, pauseStartTimeRef: pauseStartTimeRef, thinkingStatus: thinkingStatus }))), _jsx(GoalTodoPanel, { channel: channel }), approvalSnapshot !== null ? (_jsx(ApprovalPanel, { approval: approvalSnapshot, onDecide: outcome => approvals.decide(outcome) }, approvalSnapshot.key)) : btw !== null ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(BtwPanel, { question: btw.question, answer: btw.answer, error: btw.error, streaming: !btw.done, onClose: closeBtw, onCopy: () => {
1890
+ channel.workingActivity.phase !== 'idle' ? (_jsx(Box, { marginTop: 1, children: _jsx(ActivityLine, { activity: channel.workingActivity, activityFrames: channel.activityFrames, warnPct: activityWarnPct, warnDanger: activityWarnPct !== undefined && activityWarnPct >= 95,
1891
+ // Upload = real tokens of the last request; download =
1892
+ // the animated chars/4 estimate, matching the classic
1893
+ // spinner's counter (the suffix used raw chars before,
1894
+ // inflating the reading next to a real upload number).
1895
+ suffix: `${lastUploadTokens > 0 ? ` · ↑ ${formatTokens(lastUploadTokens)}` : ''} · ↓ ${formatTokens(Math.round(channel.responseChars / 4))} tokens` }) })) : (_jsx(WorkingSpinner, { mode: channel.spinnerMode, hasActiveTools: channel.activeToolCount > 0, responseLengthRef: responseLengthRef, uploadTokensRef: uploadTokensRef, loadingStartTimeRef: loadingStartTimeRef, totalPausedMsRef: totalPausedMsRef, pauseStartTimeRef: pauseStartTimeRef, thinkingStatus: thinkingStatus }))), _jsx(GoalTodoPanel, { channel: channel }), approvalSnapshot !== null ? (_jsx(ApprovalPanel, { approval: approvalSnapshot, onDecide: outcome => approvals.decide(outcome) }, approvalSnapshot.key)) : btw !== null ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(BtwPanel, { question: btw.question, answer: btw.answer, error: btw.error, streaming: !btw.done, onClose: closeBtw, onCopy: () => {
1740
1896
  void setClipboard(btw.answer ?? '').then(raw => { if (raw)
1741
1897
  process.stdout.write(raw); });
1742
1898
  channel.notify(t('copied-chars', { n: (btw.answer ?? '').length }), { timeoutMs: 1500 });
@@ -1746,7 +1902,7 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1746
1902
  band: wakeBand,
1747
1903
  hint: trajectorySeen ? undefined : `${modLabel}t`,
1748
1904
  tick: Math.floor(wakeTime / 120),
1749
- } }), dialogOverlayOpen && (_jsxs(OverlayAbove, { maxHeight: Math.max(terminalRows - 8, 8), children: [thinkingOpen && (_jsx(ThinkingToggle, { currentValue: thinkingVisible, focusIndex: thinkingFocus, confirmationPending: thinkingConfirm })), workspacePickerOpen && workspaceTargets.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(WorkspacePicker, { targets: workspaceTargets, focusIndex: workspaceIndex, currentCwd: channel.cwd }) })), workspaceFlow !== null && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(WorkspaceFlowPicker, { title: workspaceFlow.title, choices: workspaceFlow.choices, focusIndex: workspaceFlowIndex, busy: workspaceFlowBusy, input: workspaceFlowInput }) })), modelPickerOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: models.length === 0 ? (_jsx(ModelPickerLoading, {})) : (_jsx(ModelPicker, { models: models, focusIndex: modelIndex, currentModel: `${channel.provider}/${channel.model}` })) })), activityPickerOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ActivityPicker, { focusIndex: activityIndex, currentPreset: channel.activityFrames }) })), effortSliderOpen && effortOptions.length > 1 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(EffortSlider, { options: effortOptions, focusIndex: effortIndex, currentId: channel.reasoningEffort }) })), presetPickerOpen && presetOptions.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(PresetPicker, { presets: presetOptions, focusIndex: presetIndex, currentPreset: channel.agentPreset }) })), themePickerOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ThemePicker, { focusIndex: themeIndex, currentTheme: themeName }) })), historyOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(HistorySearchDialog, { query: historyQuery, cursorOffset: historyCursor, matches: historyMatches, focusIndex: historyFocus }) })), rewindOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(RewindPicker, { rows: rewindRows, focusIndex: rewindIndex, confirmRow: rewindConfirm }) })), searchOpen && _jsx(TranscriptSearchBar, { query: searchQuery, cursorOffset: searchCursor, count: searchCount, current: searchCurrent })] }))] })] }));
1905
+ } }), dialogOverlayOpen && (_jsxs(OverlayAbove, { maxHeight: Math.max(terminalRows - 8, 8), children: [thinkingOpen && (_jsx(ThinkingToggle, { currentValue: thinkingVisible, focusIndex: thinkingFocus, confirmationPending: thinkingConfirm })), workspacePickerOpen && workspaceTargets.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(WorkspacePicker, { targets: workspaceTargets, focusIndex: workspaceIndex, currentCwd: channel.cwd }) })), workspaceFlow !== null && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(WorkspaceFlowPicker, { title: workspaceFlow.title, choices: workspaceFlow.choices, focusIndex: workspaceFlowIndex, busy: workspaceFlowBusy, input: workspaceFlowInput }) })), modelPickerOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: models.length === 0 ? (_jsx(ModelPickerLoading, {})) : (_jsx(ModelPicker, { models: models, focusIndex: modelIndex, currentModel: `${channel.provider}/${channel.model}` })) })), skillsPickerOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: skillsList === null ? (_jsx(SkillsPickerLoading, {})) : (_jsx(SkillsPicker, { skills: skillsList, focusIndex: skillsIndex })) })), activityPickerOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ActivityPicker, { focusIndex: activityIndex, currentPreset: channel.activityFrames }) })), effortSliderOpen && effortOptions.length > 1 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(EffortSlider, { options: effortOptions, focusIndex: effortIndex, currentId: channel.reasoningEffort }) })), presetPickerOpen && presetOptions.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(PresetPicker, { presets: presetOptions, focusIndex: presetIndex, currentPreset: channel.agentPreset }) })), themePickerOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ThemePicker, { focusIndex: themeIndex, currentTheme: themeName }) })), historyOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(HistorySearchDialog, { query: historyQuery, cursorOffset: historyCursor, matches: historyMatches, focusIndex: historyFocus }) })), rewindOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(RewindPicker, { rows: rewindRows, focusIndex: rewindIndex, confirmRow: rewindConfirm }) })), searchOpen && _jsx(TranscriptSearchBar, { query: searchQuery, cursorOffset: searchCursor, count: searchCount, current: searchCurrent })] }))] })] }));
1750
1906
  }
1751
1907
  /**
1752
1908
  * The pinned prompt header shown above the ScrollBox while the user has
@@ -1 +1 @@
1
- {"version":3,"file":"SessionBrowser.d.ts","sourceRoot":"","sources":["../../../src/screens/SessionBrowser.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAsBzB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AAoDxD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,IAAI,EACJ,WAAW,EACX,OAAO,GACR,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAA;IACZ,4EAA4E;IAC5E,WAAW,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,OAAO,CAAA;IAC9C,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,GAAG,KAAK,CAAC,SAAS,CAydlB"}
1
+ {"version":3,"file":"SessionBrowser.d.ts","sourceRoot":"","sources":["../../../src/screens/SessionBrowser.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAsBzB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AAoDxD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,cAAc,CAAC,EAC7B,OAAO,EACP,IAAI,EACJ,WAAW,EACX,OAAO,GACR,EAAE;IACD,OAAO,EAAE,OAAO,CAAA;IAChB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAA;IACZ,4EAA4E;IAC5E,WAAW,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,OAAO,CAAA;IAC9C,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,GAAG,KAAK,CAAC,SAAS,CAselB"}
@@ -112,6 +112,10 @@ export function SessionBrowser({ channel, home, sameProject, onClose, }) {
112
112
  const [previewOpen, setPreviewOpen] = React.useState(false);
113
113
  const [preview, setPreview] = React.useState([]);
114
114
  const [previewLoading, setPreviewLoading] = React.useState(false);
115
+ // React batches every parsed key from one stdin chunk. Lock actions in a
116
+ // ref so a repeated Enter cannot start the same async operation twice
117
+ // before the mode change is rendered.
118
+ const actionPendingRef = React.useRef(false);
115
119
  // One clock per render pass: every relative time on screen must agree, and
116
120
  // re-reading it per row would let two rows a millisecond apart round to
117
121
  // different minutes.
@@ -237,9 +241,17 @@ export function SessionBrowser({ channel, home, sameProject, onClose, }) {
237
241
  setFilters(current => ({ ...current, ...update(current) }));
238
242
  topRef.current = 0;
239
243
  };
244
+ const runAction = (action) => {
245
+ if (actionPendingRef.current)
246
+ return;
247
+ actionPendingRef.current = true;
248
+ void action().finally(() => {
249
+ actionPendingRef.current = false;
250
+ });
251
+ };
240
252
  /** Run one mutation, report it, and re-list — reporting a failure either way. */
241
253
  const mutate = (action, done, failed) => {
242
- void (async () => {
254
+ runAction(async () => {
243
255
  let ok = false;
244
256
  let reason;
245
257
  try {
@@ -250,7 +262,7 @@ export function SessionBrowser({ channel, home, sameProject, onClose, }) {
250
262
  }
251
263
  report(ok ? done : reason === undefined ? failed : `${failed} · ${reason}`, ok ? 'info' : 'error');
252
264
  await reload();
253
- })();
265
+ });
254
266
  };
255
267
  const runDelete = (target) => mutate(() => channel.deleteSession(target.id), t('resume-deleted', { name: target.title.text }), t('resume-delete-failed', { name: target.title.text }));
256
268
  const runRename = (target, title) => mutate(() => channel.renameSessionTo(target.id, title), t('rename-done', { title }), t('resume-rename-failed', { name: target.title.text }));
@@ -259,7 +271,7 @@ export function SessionBrowser({ channel, home, sameProject, onClose, }) {
259
271
  // below, and deleting from a list that moved under us would be a
260
272
  // destructive action aimed at whatever happens to be there now.
261
273
  const ids = [...view.emptyIds];
262
- void (async () => {
274
+ runAction(async () => {
263
275
  let removed = 0;
264
276
  for (const id of ids) {
265
277
  try {
@@ -272,9 +284,11 @@ export function SessionBrowser({ channel, home, sameProject, onClose, }) {
272
284
  }
273
285
  report(t('session-cleaned', { n: removed }), 'info');
274
286
  await reload();
275
- })();
287
+ });
276
288
  };
277
289
  useInput((input, key) => {
290
+ if (actionPendingRef.current)
291
+ return;
278
292
  // A notice describes what the LAST action did; the next keystroke makes it
279
293
  // stale, so it goes as soon as the user acts again.
280
294
  if (notice !== undefined)
@@ -338,19 +352,20 @@ export function SessionBrowser({ channel, home, sameProject, onClose, }) {
338
352
  // that explanation to the conversation the user is not looking at, and
339
353
  // leave them staring at an unchanged transcript wondering what Enter
340
354
  // did. `resumeTo` reports its own reasons; this only has to stay put.
341
- void channel
342
- .resumeTo(target.id)
343
- .then((ok) => {
344
- if (ok) {
345
- channel.notify(t('resume-resumed'));
346
- onClose();
355
+ runAction(async () => {
356
+ try {
357
+ const ok = await channel.resumeTo(target.id);
358
+ if (ok) {
359
+ channel.notify(t('resume-resumed'));
360
+ onClose();
361
+ }
362
+ else {
363
+ setNotice({ text: t('session-resume-refused'), tone: 'error' });
364
+ }
347
365
  }
348
- else {
349
- setNotice({ text: t('session-resume-refused'), tone: 'error' });
366
+ catch (error) {
367
+ setNotice({ text: t('session-resume-failed', { err: message(error) }), tone: 'error' });
350
368
  }
351
- })
352
- .catch((error) => {
353
- setNotice({ text: t('session-resume-failed', { err: message(error) }), tone: 'error' });
354
369
  });
355
370
  }
356
371
  else if (key.escape) {
@@ -0,0 +1,20 @@
1
+ import React from 'react';
2
+ import type { Channel } from '../dsh-adapter/channel.js';
3
+ /**
4
+ * The settings screen — `/settings` as a screen of its own (issue #165).
5
+ *
6
+ * The TUI owns only presentation here: plugin-declared sections from the
7
+ * `tuiSettingsSections` seam render as editable forms; every write goes back
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.
12
+ *
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).
15
+ */
16
+ export declare function Settings({ channel, onClose, }: {
17
+ channel: Channel;
18
+ onClose: () => void;
19
+ }): React.ReactNode;
20
+ //# sourceMappingURL=Settings.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Settings.d.ts","sourceRoot":"","sources":["../../../src/screens/Settings.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAUzB,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,2BAA2B,CAAA;AA2CxD;;;;;;;;;;;;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,CA0VlB"}