@deepseek-harness-tui/dsh-tui 0.7.1 → 0.7.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 (127) hide show
  1. package/README.md +7 -5
  2. package/bin/dsh-tui.js +42 -1
  3. package/cordis.patch.yml +10 -0
  4. package/lib/types/commands.js +1 -1
  5. package/lib/types/components/GoalTodoPanel.d.ts.map +1 -1
  6. package/lib/types/components/GoalTodoPanel.js +6 -1
  7. package/lib/types/components/HistorySearchDialog.d.ts.map +1 -1
  8. package/lib/types/components/HistorySearchDialog.js +5 -1
  9. package/lib/types/components/LoadedContextPanel.d.ts.map +1 -1
  10. package/lib/types/components/LoadedContextPanel.js +1 -1
  11. package/lib/types/components/MessageList.d.ts.map +1 -1
  12. package/lib/types/components/MessageList.js +5 -1
  13. package/lib/types/components/PromptInput.d.ts.map +1 -1
  14. package/lib/types/components/PromptInput.js +52 -74
  15. package/lib/types/components/SearchBox.d.ts +4 -0
  16. package/lib/types/components/SearchBox.d.ts.map +1 -1
  17. package/lib/types/components/SearchBox.js +101 -2
  18. package/lib/types/components/Spinner/SpinnerAnimationRow.d.ts +4 -1
  19. package/lib/types/components/Spinner/SpinnerAnimationRow.d.ts.map +1 -1
  20. package/lib/types/components/Spinner/SpinnerAnimationRow.js +10 -5
  21. package/lib/types/components/WorkingSpinner.d.ts +3 -1
  22. package/lib/types/components/WorkingSpinner.d.ts.map +1 -1
  23. package/lib/types/components/WorkingSpinner.js +2 -2
  24. package/lib/types/components/design-system/ThemedText.d.ts +9 -3
  25. package/lib/types/components/design-system/ThemedText.d.ts.map +1 -1
  26. package/lib/types/components/design-system/ThemedText.js +3 -5
  27. package/lib/types/components/questions/AskUserQuestionPanel.d.ts.map +1 -1
  28. package/lib/types/components/questions/AskUserQuestionPanel.js +52 -8
  29. package/lib/types/components/questions/PlanReviewPanel.d.ts.map +1 -1
  30. package/lib/types/components/questions/PlanReviewPanel.js +12 -3
  31. package/lib/types/components/sessions/SessionListRow.d.ts +26 -0
  32. package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -0
  33. package/lib/types/components/sessions/SessionListRow.js +35 -0
  34. package/lib/types/components/sessions/SessionPreview.d.ts +25 -0
  35. package/lib/types/components/sessions/SessionPreview.d.ts.map +1 -0
  36. package/lib/types/components/sessions/SessionPreview.js +60 -0
  37. package/lib/types/dsh-adapter/channel.d.ts +19 -4
  38. package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
  39. package/lib/types/dsh-adapter/channel.js +76 -82
  40. package/lib/types/dsh-adapter/compat/index.d.ts +1 -1
  41. package/lib/types/dsh-adapter/compat/index.d.ts.map +1 -1
  42. package/lib/types/dsh-adapter/compat/index.js +1 -1
  43. package/lib/types/dsh-adapter/compat/sessionLog.d.ts +8 -0
  44. package/lib/types/dsh-adapter/compat/sessionLog.d.ts.map +1 -1
  45. package/lib/types/dsh-adapter/compat/sessionLog.js +1 -1
  46. package/lib/types/dsh-adapter/index.d.ts.map +1 -1
  47. package/lib/types/dsh-adapter/index.js +10 -1
  48. package/lib/types/dsh-adapter/packaged-presets.d.ts +22 -0
  49. package/lib/types/dsh-adapter/packaged-presets.d.ts.map +1 -0
  50. package/lib/types/dsh-adapter/packaged-presets.js +93 -0
  51. package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
  52. package/lib/types/dsh-adapter/plugin.js +53 -7
  53. package/lib/types/dsh-adapter/presets.d.ts +16 -0
  54. package/lib/types/dsh-adapter/presets.d.ts.map +1 -1
  55. package/lib/types/dsh-adapter/presets.js +23 -0
  56. package/lib/types/dsh-adapter/providerWizard.d.ts.map +1 -1
  57. package/lib/types/dsh-adapter/providerWizard.js +3 -1
  58. package/lib/types/dsh-adapter/sessions/digest.d.ts +29 -0
  59. package/lib/types/dsh-adapter/sessions/digest.d.ts.map +1 -0
  60. package/lib/types/dsh-adapter/sessions/digest.js +224 -0
  61. package/lib/types/dsh-adapter/sessions/frames.d.ts +100 -0
  62. package/lib/types/dsh-adapter/sessions/frames.d.ts.map +1 -0
  63. package/lib/types/dsh-adapter/sessions/frames.js +246 -0
  64. package/lib/types/dsh-adapter/sessions/header.d.ts +50 -0
  65. package/lib/types/dsh-adapter/sessions/header.d.ts.map +1 -0
  66. package/lib/types/dsh-adapter/sessions/header.js +62 -0
  67. package/lib/types/dsh-adapter/sessions/index.d.ts +17 -0
  68. package/lib/types/dsh-adapter/sessions/index.d.ts.map +1 -0
  69. package/lib/types/dsh-adapter/sessions/index.js +15 -0
  70. package/lib/types/dsh-adapter/sessions/list.d.ts +41 -0
  71. package/lib/types/dsh-adapter/sessions/list.d.ts.map +1 -0
  72. package/lib/types/dsh-adapter/sessions/list.js +213 -0
  73. package/lib/types/dsh-adapter/sessions/store.d.ts +48 -0
  74. package/lib/types/dsh-adapter/sessions/store.d.ts.map +1 -0
  75. package/lib/types/dsh-adapter/sessions/store.js +164 -0
  76. package/lib/types/dsh-adapter/sessions/types.d.ts +142 -0
  77. package/lib/types/dsh-adapter/sessions/types.d.ts.map +1 -0
  78. package/lib/types/dsh-adapter/sessions/types.js +14 -0
  79. package/lib/types/i18n.d.ts +150 -22
  80. package/lib/types/i18n.d.ts.map +1 -1
  81. package/lib/types/i18n.js +45 -9
  82. package/lib/types/ink/components/Text.d.ts +9 -1
  83. package/lib/types/ink/components/Text.d.ts.map +1 -1
  84. package/lib/types/ink/components/Text.js +7 -6
  85. package/lib/types/ink/ink.d.ts.map +1 -1
  86. package/lib/types/ink/ink.js +4 -2
  87. package/lib/types/ink/log-update.d.ts.map +1 -1
  88. package/lib/types/ink/log-update.js +76 -9
  89. package/lib/types/ink/parse-keypress.d.ts.map +1 -1
  90. package/lib/types/ink/parse-keypress.js +4 -1
  91. package/lib/types/ink/terminal.d.ts +40 -4
  92. package/lib/types/ink/terminal.d.ts.map +1 -1
  93. package/lib/types/ink/terminal.js +57 -5
  94. package/lib/types/ink/termio/tokenize.d.ts +6 -0
  95. package/lib/types/ink/termio/tokenize.d.ts.map +1 -1
  96. package/lib/types/ink/termio/tokenize.js +18 -4
  97. package/lib/types/screens/Chat.d.ts +3 -2
  98. package/lib/types/screens/Chat.d.ts.map +1 -1
  99. package/lib/types/screens/Chat.js +104 -160
  100. package/lib/types/screens/SessionBrowser.d.ts +37 -0
  101. package/lib/types/screens/SessionBrowser.d.ts.map +1 -0
  102. package/lib/types/screens/SessionBrowser.js +452 -0
  103. package/lib/types/sessionHistory.d.ts +0 -8
  104. package/lib/types/sessionHistory.d.ts.map +1 -1
  105. package/lib/types/sessions/format.d.ts +117 -0
  106. package/lib/types/sessions/format.d.ts.map +1 -0
  107. package/lib/types/sessions/format.js +244 -0
  108. package/lib/types/sessions/view.d.ts +125 -0
  109. package/lib/types/sessions/view.d.ts.map +1 -0
  110. package/lib/types/sessions/view.js +234 -0
  111. package/lib/types/update.d.ts +21 -7
  112. package/lib/types/update.d.ts.map +1 -1
  113. package/lib/types/update.js +66 -16
  114. package/lib/types/utils/paths.d.ts.map +1 -1
  115. package/lib/types/utils/paths.js +4 -1
  116. package/package.json +37 -9
  117. package/presets/liangshen/.dsh-tui-managed.json +5 -0
  118. package/presets/liangshen/agent.cordis.yml +389 -0
  119. package/presets/liangshen/compaction-epoch.mjs +81 -0
  120. package/presets/liangshen/custom-bash.mjs +126 -0
  121. package/presets/liangshen/instruction-hint.mjs +181 -0
  122. package/presets/liangshen/preset.yml +3 -0
  123. package/presets/liangshen/skill-search.mjs +142 -0
  124. package/presets/liangshen/tool-bootstrap.mjs +300 -0
  125. package/lib/types/components/ResumePicker.d.ts +0 -26
  126. package/lib/types/components/ResumePicker.d.ts.map +0 -1
  127. package/lib/types/components/ResumePicker.js +0 -50
@@ -5,6 +5,8 @@ import { AlternateScreen, Box, Text, useInput, ScrollBox, useTheme, useTerminalS
5
5
  import { POINTER } from '../cc/figures.js';
6
6
  import { isMod, isPlainReturnInput, modLabel } from '../utils/modifiers.js';
7
7
  import { formatTokens } from '../cc/format.js';
8
+ import { homeDir } from '../utils/paths.js';
9
+ import { sessionCwdMatches } from '../dsh-adapter/channel.js';
8
10
  import { runProviderWizard } from '../dsh-adapter/providerWizard.js';
9
11
  import { ApprovalStore } from '../dsh-adapter/approvals.js';
10
12
  import { AskUserQuestionPanel } from '../components/questions/AskUserQuestionPanel.js';
@@ -24,7 +26,7 @@ import { StatusLine } from './StatusLine.js';
24
26
  import { WorkingSpinner, useThinkingStatus } from '../components/WorkingSpinner.js';
25
27
  import { ActivityLine, contextPressurePct } from '../components/ActivityLine.js';
26
28
  import { ModelPicker } from '../components/ModelPicker.js';
27
- import { ResumePicker } from '../components/ResumePicker.js';
29
+ import { SessionBrowser } from './SessionBrowser.js';
28
30
  import { WorkspacePicker } from '../components/WorkspacePicker.js';
29
31
  import { WorkspaceFlowPicker } from '../components/WorkspaceFlowPicker.js';
30
32
  import { ActivityPicker } from '../components/ActivityPicker.js';
@@ -150,12 +152,9 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
150
152
  const [modelPickerOpen, setModelPickerOpen] = React.useState(false);
151
153
  const [models, setModels] = React.useState([]);
152
154
  const [modelIndex, setModelIndex] = React.useState(0);
153
- const [resumePickerOpen, setResumePickerOpen] = React.useState(false);
154
- const [resumeSessions, setResumeSessions] = React.useState([]);
155
- const [resumeIndex, setResumeIndex] = React.useState(0);
156
- /** `/resume` session management (issue #112): plain selection, a delete
157
- * confirmation (ctrl+d), or the inline rename input (ctrl+r). */
158
- const [resumeMode, setResumeMode] = React.useState('list');
155
+ /** `/resume` opens the session browser, a screen rather than a panel. It
156
+ * owns its own selection, filters and keyboard — Chat only opens it. */
157
+ const [browserOpen, setBrowserOpen] = React.useState(false);
159
158
  const [workspacePickerOpen, setWorkspacePickerOpen] = React.useState(false);
160
159
  const [workspaceTargets, setWorkspaceTargets] = React.useState([]);
161
160
  const [workspaceIndex, setWorkspaceIndex] = React.useState(0);
@@ -165,7 +164,6 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
165
164
  const [workspaceFlowInput, setWorkspaceFlowInput] = React.useState(null);
166
165
  const workspaceFlowRequestRef = React.useRef(0);
167
166
  const workspaceFlowAbortRef = React.useRef(null);
168
- const [resumeRenameText, setResumeRenameText] = React.useState('');
169
167
  /** `/activity` indicator picker (pi extension's interactive select). */
170
168
  const [activityPickerOpen, setActivityPickerOpen] = React.useState(false);
171
169
  const [activityIndex, setActivityIndex] = React.useState(0);
@@ -245,6 +243,14 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
245
243
  }, []);
246
244
  /** Startup context panel: expanded by header click or Ctrl+T. */
247
245
  const [loadedContextOpen, setLoadedContextOpen] = React.useState(false);
246
+ /**
247
+ * Whether the startup context panel is on screen. Derived once and read by
248
+ * BOTH the Ctrl+T handler and the render below: the key's meaning depends on
249
+ * the panel being visible, so a second copy of this condition is a place for
250
+ * the two to disagree — which is how the key came to advertise one thing and
251
+ * do another in the first place.
252
+ */
253
+ const loadedContextVisible = channel.rows.length === 0 && channel.loadedContext !== undefined;
248
254
  /** `/` transcript search (less-style incsearch, ported from CC's REPL). */
249
255
  const [searchOpen, setSearchOpen] = React.useState(false);
250
256
  const [searchQuery, setSearchQuery] = React.useState('');
@@ -308,10 +314,17 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
308
314
  // Spinner timing refs, fed from channel state each render (the spinner
309
315
  // only mounts while working, so values are stable for the mount).
310
316
  const responseLengthRef = React.useRef(0);
317
+ const uploadTokensRef = React.useRef(0);
311
318
  const loadingStartTimeRef = React.useRef(0);
312
319
  const totalPausedMsRef = React.useRef(0);
313
320
  const pauseStartTimeRef = React.useRef(null);
314
321
  responseLengthRef.current = channel.responseChars;
322
+ // Most recent request's real upload (input + cache read/write occupy the
323
+ // wire exactly like the context window); 0 until the first usage event.
324
+ const lastUploadTokens = channel.lastUsage === undefined
325
+ ? 0
326
+ : channel.lastUsage.input + channel.lastUsage.cacheRead + channel.lastUsage.cacheWrite;
327
+ uploadTokensRef.current = lastUploadTokens;
315
328
  loadingStartTimeRef.current = channel.turnStart;
316
329
  const thinkingStatus = useThinkingStatus(channel.spinnerMode === 'thinking');
317
330
  // Terminal tab title (ported from CC's AnimatedTerminalTitle): the session
@@ -663,22 +676,10 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
663
676
  }
664
677
  case 'resume': {
665
678
  setHelpOpen(false);
666
- void (async () => {
667
- const sessions = await channel.listSessions();
668
- // The current session cannot be resumed into itself (agents.resume
669
- // rejects a live session), so it is excluded from the picker —
670
- // otherwise the fresh empty session of this launch always tops the
671
- // list as an unopenable row.
672
- const pickable = sessions.filter(session => session.id !== channel.agentId);
673
- setResumeSessions(pickable);
674
- if (pickable.length === 0) {
675
- channel.notify(t('resume-none-in-cwd'));
676
- return;
677
- }
678
- setResumeMode('list');
679
- setResumePickerOpen(true);
680
- setResumeIndex(0);
681
- })();
679
+ // The browser opens immediately and loads its own list. Waiting for
680
+ // the listing here would make `/resume` feel slower the more history
681
+ // a project has, which is exactly backwards.
682
+ setBrowserOpen(true);
682
683
  return true;
683
684
  }
684
685
  case 'workspace': {
@@ -828,14 +829,28 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
828
829
  });
829
830
  return true;
830
831
  case 'login': {
831
- const key = process.env.DEEPSEEK_API_KEY;
832
- const lines = [
833
- `${t('login-api-key', { key: key ? key.slice(0, 6) + '…' + key.slice(-4) : t('login-key-missing') })}`,
834
- `${t('login-base-url', { url: process.env.DEEPSEEK_BASE_URL ?? t('login-official-endpoint') })}`,
835
- t('login-source-hint'),
836
- ];
837
832
  setHelpOpen(false);
838
- channel.pushLocal('/login', lines);
833
+ void channel.describeCredential('DEEPSEEK_API_KEY')
834
+ .catch(() => undefined)
835
+ .then(status => {
836
+ const keyStatus = status === undefined
837
+ ? t('login-credentials-unavailable')
838
+ : status.configured
839
+ ? t('login-key-configured', { ref: 'DEEPSEEK_API_KEY' })
840
+ : t('login-key-missing');
841
+ channel.pushLocal('/login', [
842
+ t('login-api-key', { status: keyStatus }),
843
+ ...(status === undefined
844
+ ? []
845
+ : [
846
+ t('login-credential-source', { source: status.source ?? t('login-source-none') }),
847
+ t('login-credential-storage', {
848
+ mode: t(status.writable ? 'login-storage-writable' : 'login-storage-read-only'),
849
+ }),
850
+ ]),
851
+ t('login-base-url', { url: process.env.DEEPSEEK_BASE_URL ?? t('login-official-endpoint') }),
852
+ ]);
853
+ });
839
854
  return true;
840
855
  }
841
856
  case 'logout':
@@ -1182,6 +1197,11 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1182
1197
  // not block the event from reaching the panel.
1183
1198
  if (btw !== null)
1184
1199
  return;
1200
+ // Same for the session browser: it renders instead of the conversation,
1201
+ // so every key belongs to it — including the plain letters that drive its
1202
+ // search box, which Chat would otherwise route into the prompt.
1203
+ if (browserOpen)
1204
+ return;
1185
1205
  // The questionnaire / approval panel owns the keyboard while one is
1186
1206
  // pending (the panel's own useInput handles ↑/↓/Space/Tab/Enter/Esc;
1187
1207
  // the prompt input is unmounted, so nothing else should see these keys).
@@ -1407,123 +1427,6 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1407
1427
  }
1408
1428
  return;
1409
1429
  }
1410
- if (resumePickerOpen) {
1411
- const resumeSession = resumeSessions[resumeIndex];
1412
- // Session management modes (issue #112): the list keys stay untouched
1413
- // until ctrl+d/ctrl+r switch into a sub-mode, each with Enter/Esc.
1414
- if (resumeMode === 'confirm-delete') {
1415
- if (plainReturn) {
1416
- setResumeMode('list');
1417
- // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty list
1418
- if (resumeSession) {
1419
- const target = resumeSession;
1420
- void (async () => {
1421
- const ok = await channel.deleteSession(target.id);
1422
- if (!ok) {
1423
- channel.notify(t('resume-delete-failed', { name: target.title || target.id }), { color: 'error' });
1424
- return;
1425
- }
1426
- channel.notify(t('resume-deleted', { name: target.title || target.id }));
1427
- // Refresh right away so the row disappears in place; closing
1428
- // the picker when nothing resumable remains.
1429
- const sessions = await channel.listSessions();
1430
- const pickable = sessions.filter(session => session.id !== channel.agentId);
1431
- setResumeSessions(pickable);
1432
- if (pickable.length === 0) {
1433
- setResumePickerOpen(false);
1434
- }
1435
- else {
1436
- setResumeIndex(index => Math.min(index, pickable.length - 1));
1437
- }
1438
- })();
1439
- }
1440
- }
1441
- else if (key.escape) {
1442
- setResumeMode('list');
1443
- }
1444
- return;
1445
- }
1446
- if (resumeMode === 'rename') {
1447
- if (plainReturn) {
1448
- setResumeMode('list');
1449
- const title = resumeRenameText.trim();
1450
- // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty list
1451
- if (resumeSession && title.length > 0) {
1452
- const target = resumeSession;
1453
- void (async () => {
1454
- const ok = await channel.renameSessionTo(target.id, title);
1455
- if (!ok) {
1456
- channel.notify(t('resume-rename-failed', { name: target.title || target.id }), { color: 'error' });
1457
- return;
1458
- }
1459
- channel.notify(t('rename-done', { title }));
1460
- // Re-list so the row reflects the persisted state, but patch
1461
- // the renamed row's title explicitly: listSessions resolves
1462
- // persisted titles only within the MRU top SESSION_TITLE_DEPTH
1463
- // window, and a freshly renamed row must never snap back to
1464
- // the basename fallback in between.
1465
- const sessions = await channel.listSessions();
1466
- const next = sessions
1467
- .filter(session => session.id !== channel.agentId)
1468
- .map(session => (session.id === target.id ? { ...session, title } : session));
1469
- setResumeSessions(next);
1470
- // Re-anchor focus on the renamed row: renameSessionTo touches
1471
- // MRU, so the re-listed order shifts — a kept index would
1472
- // silently point at a DIFFERENT session, and a following
1473
- // Enter/ctrl+d would act on the wrong one (review leftover).
1474
- const anchored = next.findIndex(session => session.id === target.id);
1475
- if (anchored >= 0)
1476
- setResumeIndex(anchored);
1477
- })();
1478
- }
1479
- }
1480
- else if (key.escape) {
1481
- setResumeMode('list');
1482
- }
1483
- else if (key.backspace) {
1484
- setResumeRenameText(text => text.slice(0, -1));
1485
- }
1486
- else if (!key.ctrl && !key.meta && !key.super && input) {
1487
- // Single-line title: pasted newlines collapse to spaces.
1488
- setResumeRenameText(text => text + input.replace(/[\r\n]+/g, ' '));
1489
- }
1490
- return;
1491
- }
1492
- if (key.upArrow) {
1493
- setResumeIndex(index => (index <= 0 ? resumeSessions.length - 1 : index - 1));
1494
- }
1495
- else if (key.downArrow) {
1496
- setResumeIndex(index => (index >= resumeSessions.length - 1 ? 0 : index + 1));
1497
- }
1498
- else if (plainReturn) {
1499
- // oxlint-disable-next-line typescript/no-unnecessary-condition -- runtime guard: out-of-range index on an empty list
1500
- if (resumeSession) {
1501
- // Enter switches the live agent to the persisted session right
1502
- // away (the history replays into the transcript); the resume.txt
1503
- // launcher marker is refreshed by resumeTo so `--resume` on the
1504
- // next launch opens the same session.
1505
- setResumePickerOpen(false);
1506
- void channel.resumeTo(resumeSession.id).then((ok) => {
1507
- if (ok)
1508
- channel.notify(t('resume-resumed'));
1509
- });
1510
- }
1511
- else {
1512
- setResumePickerOpen(false);
1513
- }
1514
- }
1515
- else if (key.escape) {
1516
- setResumePickerOpen(false);
1517
- }
1518
- else if (isMod(key) && input === 'd' && resumeSession) {
1519
- setResumeMode('confirm-delete');
1520
- }
1521
- else if (isMod(key) && input === 'r' && resumeSession) {
1522
- setResumeRenameText(resumeSession.title || '');
1523
- setResumeMode('rename');
1524
- }
1525
- return;
1526
- }
1527
1430
  if (modelPickerOpen) {
1528
1431
  if (key.upArrow) {
1529
1432
  setModelIndex(index => (index <= 0 ? models.length - 1 : index - 1));
@@ -1664,10 +1567,22 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1664
1567
  }
1665
1568
  }
1666
1569
  else if (key.leftArrow) {
1667
- setHistoryCursor(cursor => Math.max(0, cursor - 1));
1570
+ // Step by code point, not UTF-16 unit: an emoji is two units, and
1571
+ // a mid-pair caret offset would split it in the SearchBox render.
1572
+ setHistoryCursor(cursor => {
1573
+ if (cursor <= 0)
1574
+ return 0;
1575
+ const ch = [...historyQuery.slice(0, cursor)].pop();
1576
+ return cursor - ch.length;
1577
+ });
1668
1578
  }
1669
1579
  else if (key.rightArrow) {
1670
- setHistoryCursor(cursor => Math.min(historyQuery.length, cursor + 1));
1580
+ setHistoryCursor(cursor => {
1581
+ if (cursor >= historyQuery.length)
1582
+ return historyQuery.length;
1583
+ const ch = [...historyQuery.slice(cursor)][0];
1584
+ return cursor + ch.length;
1585
+ });
1671
1586
  }
1672
1587
  else if (key.home) {
1673
1588
  setHistoryCursor(0);
@@ -1715,11 +1630,25 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1715
1630
  return;
1716
1631
  }
1717
1632
  if (isMod(key) && input === 't') {
1718
- // Ctrl+T opens the trajectory scene (issue #80 evolution). It used to
1719
- // toggle the startup loaded-context panel — which only ever renders
1720
- // before the first message (see the render below), so the binding was
1721
- // dead for the whole rest of a session. The panel keeps its header
1722
- // click-to-toggle; the key now has a meaning that always applies.
1633
+ // Ctrl+T means "expand what is in front of you", and the two things it
1634
+ // can expand never share the screen.
1635
+ //
1636
+ // The startup loaded-context panel renders only while the transcript is
1637
+ // empty (see the render below) — and that is exactly the window where
1638
+ // the trajectory has nothing in it yet, since the trajectory is folded
1639
+ // from the session's own events. So the panel wins while it is up, and
1640
+ // the scene takes over for the rest of the session.
1641
+ //
1642
+ // Claiming the key outright for the scene was wrong even though the
1643
+ // panel binding looked dead: the panel prints its own `(Ctrl+T 展开)`
1644
+ // hint, so the key still had a visible promise attached to it, and the
1645
+ // scene it opened instead was necessarily empty at that moment. That
1646
+ // left the panel reachable only by clicking its header, which is not a
1647
+ // keyboard path at all.
1648
+ if (loadedContextVisible) {
1649
+ setLoadedContextOpen(previous => !previous);
1650
+ return;
1651
+ }
1723
1652
  openScene();
1724
1653
  return;
1725
1654
  }
@@ -1815,8 +1744,18 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1815
1744
  // Working-activity line (spinner slot): context-pressure prefix shares the
1816
1745
  // StatusLine thresholds (amber ≥ 80, red ≥ 95).
1817
1746
  const activityWarnPct = contextPressurePct(channel.lastUsage, channel.contextWindow);
1747
+ // The browser is a screen, not an overlay: it REPLACES the conversation
1748
+ // rather than floating above it. Rendering it as an early return (after
1749
+ // every hook above has run) is what makes that literal — there is no
1750
+ // transcript underneath to be repainted, scrolled, or bled through.
1751
+ if (browserOpen) {
1752
+ const browser = (_jsx(SessionBrowser, { channel: channel, home: homeDir(), sameProject: sessionCwdMatches, onClose: () => setBrowserOpen(false) }));
1753
+ // Inline hosts enter the alternate screen for the duration; full-screen
1754
+ // hosts are already in it and must not nest a second one.
1755
+ return fullscreen ? browser : _jsx(AlternateScreen, { children: browser });
1756
+ }
1818
1757
  /** Prompt input is inert while a modal dialog owns the keyboard. */
1819
- const promptSelectionActive = selectionActive || modelPickerOpen || resumePickerOpen || workspacePickerOpen || workspaceFlow !== null || activityPickerOpen ||
1758
+ const promptSelectionActive = selectionActive || modelPickerOpen || workspacePickerOpen || workspaceFlow !== null || activityPickerOpen ||
1820
1759
  effortSliderOpen || presetPickerOpen || themePickerOpen || thinkingOpen || historyOpen || rewindOpen || searchOpen ||
1821
1760
  btw !== null;
1822
1761
  // The trajectory scene replaces the conversation for as long as it is open.
@@ -1837,7 +1776,7 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1837
1776
  // 常驻、只移除其普通子节点,blit 解毒不触发,被覆盖的转录行会在
1838
1777
  // blit-skip 后留空(Esc 关 picker 一片空白的根因)。
1839
1778
  const dialogOverlayOpen = thinkingOpen || (workspacePickerOpen && workspaceTargets.length > 0) || workspaceFlow !== null ||
1840
- (resumePickerOpen && resumeSessions.length > 0) || modelPickerOpen ||
1779
+ modelPickerOpen ||
1841
1780
  activityPickerOpen || (effortSliderOpen && effortOptions.length > 1) ||
1842
1781
  (presetPickerOpen && presetOptions.length > 0) || themePickerOpen || historyOpen ||
1843
1782
  rewindOpen || searchOpen;
@@ -1848,11 +1787,16 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1848
1787
  seekRow(lastUser.id);
1849
1788
  else
1850
1789
  handle?.scrollToBottom();
1851
- } })), _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 &&
1790
+ } })), _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, 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 &&
1852
1791
  (channel.activityEnabled &&
1853
1792
  channel.workingActivity !== undefined &&
1854
1793
  channel.workingActivity.line !== '' &&
1855
- 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: () => {
1794
+ channel.workingActivity.phase !== 'idle' ? (_jsx(Box, { marginTop: 1, children: _jsx(ActivityLine, { activity: channel.workingActivity, activityFrames: channel.activityFrames, warnPct: activityWarnPct, warnDanger: activityWarnPct !== undefined && activityWarnPct >= 95,
1795
+ // Upload = real tokens of the last request; download =
1796
+ // the animated chars/4 estimate, matching the classic
1797
+ // spinner's counter (the suffix used raw chars before,
1798
+ // inflating the reading next to a real upload number).
1799
+ 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: () => {
1856
1800
  void setClipboard(btw.answer ?? '').then(raw => { if (raw)
1857
1801
  process.stdout.write(raw); });
1858
1802
  channel.notify(t('copied-chars', { n: (btw.answer ?? '').length }), { timeoutMs: 1500 });
@@ -1862,7 +1806,7 @@ export function Chat({ channel, questionStore, approvalStore, onExit, onUpdate,
1862
1806
  band: wakeBand,
1863
1807
  hint: trajectorySeen ? undefined : `${modLabel}t`,
1864
1808
  tick: Math.floor(wakeTime / 120),
1865
- } }), 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 }) })), resumePickerOpen && resumeSessions.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(ResumePicker, { sessions: resumeSessions, focusIndex: resumeIndex, currentSessionId: channel.agentId, mode: resumeMode, renameText: resumeRenameText }) })), 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 })] }))] })] }));
1809
+ } }), 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 })] }))] })] }));
1866
1810
  }
1867
1811
  /**
1868
1812
  * The pinned prompt header shown above the ScrollBox while the user has
@@ -0,0 +1,37 @@
1
+ import React from 'react';
2
+ import type { Channel } from '../dsh-adapter/channel.js';
3
+ /**
4
+ * The session browser — `/resume` as a screen of its own.
5
+ *
6
+ * The old picker was a panel of eight titles and a timestamp, and the reason
7
+ * it could not be more than that was never layout: the data behind it was five
8
+ * fields wide. With every session now arriving classified, sized, dated and
9
+ * attributed, the surface that shows them can do the job a person actually
10
+ * came for — find one conversation among many.
11
+ *
12
+ * What that means concretely:
13
+ *
14
+ * - Search is always live. There is no mode to enter; typing filters, because
15
+ * the list is the search results.
16
+ * - Delegated sub-agent runs are folded away by default and revealed under
17
+ * their parents on demand. They are not noise to be deleted — they are the
18
+ * model's own work, and it is worth being able to open one — but they are
19
+ * not what "resume a conversation" means, and there are five of them for
20
+ * every conversation.
21
+ * - Sessions that hold no conversation are never listed, only counted, with
22
+ * one action to clear them.
23
+ * - The preview shows the end of a session, so "is this the one I was in the
24
+ * middle of" is answerable without resuming it.
25
+ *
26
+ * Every one of those reads bounded data, so the screen behaves the same on a
27
+ * fifty-session history as on a five-session one.
28
+ */
29
+ export declare function SessionBrowser({ channel, home, sameProject, onClose, }: {
30
+ channel: Channel;
31
+ /** Home directory, for collapsing project paths to `~`. */
32
+ home: string;
33
+ /** Whether a stored cwd belongs to the same project as the live session. */
34
+ sameProject: (a: string, b: string) => boolean;
35
+ onClose: () => void;
36
+ }): React.ReactNode;
37
+ //# sourceMappingURL=SessionBrowser.d.ts.map
@@ -0,0 +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,CAselB"}