dsh-ssh-tui 0.4.0 → 0.5.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 (50) hide show
  1. package/README.en.md +63 -38
  2. package/README.md +44 -20
  3. package/cordis.patch.yml +6 -0
  4. package/docs/screenshots/compare.png +0 -0
  5. package/docs/screenshots/headless.png +0 -0
  6. package/docs/screenshots/slow-link.gif +0 -0
  7. package/docs/screenshots/workspace.png +0 -0
  8. package/lib/approval-reviewer.js +62 -0
  9. package/lib/approval-reviewer.js.map +1 -0
  10. package/lib/auto-approval.js +124 -0
  11. package/lib/auto-approval.js.map +1 -0
  12. package/lib/display-sock.js +583 -0
  13. package/lib/display-sock.js.map +1 -0
  14. package/lib/dsh-compat.js +45 -0
  15. package/lib/dsh-compat.js.map +1 -0
  16. package/lib/i18n/en.js +108 -14
  17. package/lib/i18n/en.js.map +1 -1
  18. package/lib/i18n/index.js +2 -1
  19. package/lib/i18n/index.js.map +1 -1
  20. package/lib/i18n/zh.js +108 -14
  21. package/lib/i18n/zh.js.map +1 -1
  22. package/lib/index.js +110 -6
  23. package/lib/index.js.map +1 -1
  24. package/lib/picker.js +14 -1
  25. package/lib/picker.js.map +1 -1
  26. package/lib/provider-catalog.js +169 -0
  27. package/lib/provider-catalog.js.map +1 -0
  28. package/lib/route-memory.js +1 -1
  29. package/lib/route-memory.js.map +1 -1
  30. package/lib/session-list.js +116 -11
  31. package/lib/session-list.js.map +1 -1
  32. package/lib/session-lock.js +182 -6
  33. package/lib/session-lock.js.map +1 -1
  34. package/lib/startup.js +2 -0
  35. package/lib/startup.js.map +1 -1
  36. package/lib/subagent-model.js +1 -1
  37. package/lib/subagent-model.js.map +1 -1
  38. package/lib/tui.js +1776 -370
  39. package/lib/tui.js.map +1 -1
  40. package/lib/types/approval-reviewer.d.ts +20 -0
  41. package/lib/types/auto-approval.d.ts +42 -0
  42. package/lib/types/display-sock.d.ts +74 -0
  43. package/lib/types/dsh-compat.d.ts +30 -0
  44. package/lib/types/i18n/index.d.ts +2 -0
  45. package/lib/types/picker.d.ts +4 -0
  46. package/lib/types/provider-catalog.d.ts +35 -0
  47. package/lib/types/session-list.d.ts +8 -1
  48. package/lib/types/session-lock.d.ts +51 -0
  49. package/lib/types/tui.d.ts +208 -24
  50. package/package.json +46 -30
package/lib/tui.js CHANGED
@@ -20,8 +20,12 @@ import { StringDecoder } from 'node:string_decoder';
20
20
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
21
21
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
22
22
  import { SessionId } from '@deepseek-ai/dsh-session';
23
- import { settingsNamespace } from '@deepseek-ai/dsh-settings';
23
+ import { sessionEvents, settingsNamespace } from './dsh-compat.js';
24
+ import { classifyApproval, commandFromArgs, parseAutoApprovalMode } from './auto-approval.js';
25
+ import { buildReviewUserMessage, parseReviewOutput, REVIEW_SYSTEM_PROMPT } from './approval-reviewer.js';
26
+ import { loadProviderCatalog, mergeProviderEntries } from './provider-catalog.js';
24
27
  import { formatFooterCwd, formatSessionTime, listResumableSessions } from './session-list.js';
28
+ import { detachFromSshSession, DisplayHost, sessionSockPath } from './display-sock.js';
25
29
  import { applySavedLocale, getLocale, localeDisplayName, localeFromTag, setLocale, t, UI_LOCALE_NAMESPACE, } from './i18n/index.js';
26
30
  import { defaultReasoningEffort } from './reasoning.js';
27
31
  import { checkForPluginUpdate, installPluginLatest } from './update-check.js';
@@ -88,12 +92,29 @@ function providerTemplates() {
88
92
  },
89
93
  };
90
94
  }
95
+ /**
96
+ * The template the wizard's current step works against: the five pinned
97
+ * shapes, or the web-catalog preset chosen through option 6.
98
+ */
99
+ function onboardTemplate(state) {
100
+ if (state.providerType === 'catalog') {
101
+ return {
102
+ label: state.catalog?.name ?? state.catalog?.id ?? '',
103
+ defaultId: state.catalog?.id ?? '',
104
+ defaultBaseUrl: '',
105
+ defaultModels: state.catalog?.modelIds ?? [],
106
+ };
107
+ }
108
+ return providerTemplates()[state.providerType];
109
+ }
91
110
  const RENDER_INTERVAL_MS = 160;
92
111
  const LOCAL_PAINT_INTERVAL_MS = 80;
93
112
  const WAIT_INDICATOR_MS = 8000;
94
113
  const MIN_PAINT_INTERVAL_MS = 40;
95
114
  const MAX_PAINT_INTERVAL_MS = 1000;
96
115
  const DSR_PROBE_TIMEOUT_MS = 800;
116
+ /** Give a running turn this long to settle after cancel before we flush anyway. */
117
+ const HANGUP_CANCEL_TIMEOUT_MS = 10_000;
97
118
  /** Compact token count, matching the web stats line (517 / 12.2K / 1.2M). */
98
119
  export function formatTokens(n) {
99
120
  const scaled = (value) => value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10);
@@ -132,6 +153,50 @@ export function resolvePaintIntervalMs(configured, env = process.env, options =
132
153
  export function detectSshSession(env = process.env) {
133
154
  return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
134
155
  }
156
+ /** Node errno on a write/close that means the TTY is gone (SSH drop, HUP). */
157
+ export function isHangupErrno(error) {
158
+ const code = error?.code;
159
+ return code === 'EIO' || code === 'EPIPE' || code === 'ENXIO' || code === 'ECONNRESET';
160
+ }
161
+ const HANGUP_SIGNAL_NAMES = ['SIGHUP', 'SIGTERM', 'SIGINT'];
162
+ /**
163
+ * Replace launcher SIGTERM/SIGINT/SIGHUP handlers with `handler`. SSH drop
164
+ * otherwise lets `dsh` dispose the whole tree before this plugin can detach.
165
+ */
166
+ export function captureHangupSignals(handler) {
167
+ for (const name of HANGUP_SIGNAL_NAMES) {
168
+ process.removeAllListeners(name);
169
+ process.prependListener(name, handler);
170
+ }
171
+ }
172
+ export function releaseHangupSignals(handler) {
173
+ for (const name of HANGUP_SIGNAL_NAMES) {
174
+ process.removeListener(name, handler);
175
+ }
176
+ }
177
+ /** After detach, extra HUP/TERM from sshd must not kill the leftover Host. */
178
+ export function ignoreFurtherHangupSignals() {
179
+ const ignore = () => { };
180
+ for (const name of HANGUP_SIGNAL_NAMES) {
181
+ process.removeAllListeners(name);
182
+ process.on(name, ignore);
183
+ }
184
+ }
185
+ /**
186
+ * Wait until `isIdle` is true or `timeoutMs` elapses. Used after cancel so a
187
+ * hangup can flush a settled session log instead of tearing a live write.
188
+ */
189
+ export async function waitUntilIdleOrTimeout(isIdle, timeoutMs, now = Date.now, wait = (ms) => new Promise(resolve => {
190
+ setTimeout(resolve, ms);
191
+ })) {
192
+ const deadline = now() + Math.max(0, timeoutMs);
193
+ while (!isIdle()) {
194
+ if (now() >= deadline)
195
+ return 'timeout';
196
+ await wait(Math.min(50, Math.max(0, deadline - now())));
197
+ }
198
+ return 'idle';
199
+ }
135
200
  /** Map a CSI-6n round-trip to a paint cadence. Unknown RTT uses the SSH default. */
136
201
  export function paintIntervalForRtt(rttMs) {
137
202
  if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
@@ -297,6 +362,9 @@ export function footerIdentityParts(input) {
297
362
  parts.push(model);
298
363
  if (input.subDiffers)
299
364
  parts.push(`sub:${input.subModel}`);
365
+ if (input.balanceText !== undefined && input.balanceText !== '') {
366
+ parts.push(input.balanceText);
367
+ }
300
368
  if (input.quotaPercent !== undefined) {
301
369
  parts.push(formatFooterQuota(input.quotaPercent, input.quotaCode));
302
370
  }
@@ -386,6 +454,8 @@ const PLUGIN_VERSION = (() => {
386
454
  }
387
455
  })();
388
456
  const STALL_WARNING_MS = 60000;
457
+ const DEFAULT_DETACHED_IDLE_MS = 6 * 60 * 60 * 1000;
458
+ const PICKER_WINDOW = 12;
389
459
  const CTRL_C_EXIT_WINDOW_MS = 2000;
390
460
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
391
461
  const QUESTION_OPTION_KEYS = '123456789abcdefghijklmnopqrstuvwxyz';
@@ -542,6 +612,7 @@ export function formatStatusReport(input) {
542
612
  `plan: ${input.plan}`,
543
613
  formatQuotaStatusLine(input.quota),
544
614
  `paint: ${input.paint}`,
615
+ `disconnect: ${input.disconnect ?? 'pause'}`,
545
616
  input.waitingQuestions > 0 ? `questions: waiting ${input.waitingQuestions}` : 'questions: none',
546
617
  ];
547
618
  }
@@ -566,36 +637,41 @@ export function providerUsesLocalOAuth(provider) {
566
637
  return id === 'xai' || id === 'grok' || id.startsWith('xai-');
567
638
  }
568
639
  const LOCAL_COMMANDS = [
569
- { name: 'help', description: 'show all available commands' },
570
- { name: 'model', description: 'select model and reasoning effort for the current provider' },
571
- { name: 'provider', description: 'switch provider, then model and reasoning effort' },
572
- { name: 'submodel', description: `select subagent model (default ${DEFAULT_SUBAGENT_MODEL}, same provider as parent)` },
573
- { name: 'subeffort', description: 'select subagent reasoning effort (default follows provider)' },
574
- { name: 'mode', description: 'switch agent mode / preset (standard, minimal, ptc, cordis, routing-suite, ...)' },
575
- { name: 'quit', description: 'exit the TUI' },
576
- { name: 'exit', description: 'exit the TUI' },
577
- { name: 'clear', description: 'clear the transcript view' },
578
- { name: 'status', description: 'show session, route, quota window, subagent fit, paint, and plugin version' },
579
- { name: 'usage', description: 'show remaining quota or account balance for the current provider' },
580
- { name: 'balance', description: 'alias of /usage: DeepSeek / OpenAI-compatible balance, or subscription quota' },
581
- { name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
582
- { name: 'resume', description: 'resume a past session (empty = session picker)' },
583
- { name: 'setup', description: 'add or update an API-key provider without wiping other saved routes' },
584
- { name: 'find', description: 'search thinking / plan / subagent / reply cards' },
585
- { name: 'language', description: 'switch UI language (zh / en); empty opens a picker' },
586
- { name: 'lang', description: 'alias of /language' },
587
- { name: 'view', description: 'switch workspace view (detailed / compact); empty opens a picker' },
588
- { name: 'dialog-test', description: 'verify the question dialog' },
640
+ { name: 'help', key: 'cmd.help' },
641
+ { name: 'model', key: 'cmd.model' },
642
+ { name: 'effort', key: 'cmd.effort' },
643
+ { name: 'provider', key: 'cmd.provider' },
644
+ { name: 'submodel', key: 'cmd.submodel' },
645
+ { name: 'subeffort', key: 'cmd.subeffort' },
646
+ { name: 'mode', key: 'cmd.mode' },
647
+ { name: 'quit', key: 'cmd.quit' },
648
+ { name: 'exit', key: 'cmd.quit', aliasOf: 'quit' },
649
+ { name: 'clear', key: 'cmd.clear' },
650
+ { name: 'status', key: 'cmd.status' },
651
+ { name: 'disconnect', key: 'cmd.disconnect' },
652
+ { name: 'approval', key: 'cmd.approval' },
653
+ { name: 'view', key: 'cmd.view' },
654
+ { name: 'usage', key: 'cmd.usage' },
655
+ { name: 'balance', key: 'cmd.usage', aliasOf: 'usage' },
656
+ { name: 'subagents', key: 'cmd.subagents' },
657
+ { name: 'resume', key: 'cmd.resume' },
658
+ { name: 'setup', key: 'cmd.setup' },
659
+ { name: 'find', key: 'cmd.find' },
660
+ { name: 'language', key: 'cmd.language' },
661
+ { name: 'lang', key: 'cmd.language', aliasOf: 'language' },
662
+ { name: 'dialog-test', key: 'cmd.dialog-test' },
589
663
  ];
664
+ function commandDescription(name, aliasOf) {
665
+ if (aliasOf !== undefined)
666
+ return t('cmd.aliasOf', { name: aliasOf });
667
+ return t(`cmd.${name}`);
668
+ }
590
669
  function localizedCommands() {
591
- return LOCAL_COMMANDS.map(command => {
592
- if (command.name === 'language' || command.name === 'lang') {
593
- return { name: command.name, description: t('lang.cmd') };
594
- }
595
- if (command.name === 'view')
596
- return { name: command.name, description: t('view.cmd') };
597
- return command;
598
- });
670
+ return LOCAL_COMMANDS.map(command => ({
671
+ name: command.name,
672
+ description: commandDescription(command.name, 'aliasOf' in command ? command.aliasOf : undefined),
673
+ ...('aliasOf' in command ? { aliasOf: command.aliasOf } : {}),
674
+ }));
599
675
  }
600
676
  /**
601
677
  * Terminal cell width for one string.
@@ -654,40 +730,111 @@ export function shimmerText(text, nowMs, color) {
654
730
  }
655
731
  return out;
656
732
  }
657
- const WAIT_SUMMARY_MAX = 18;
658
733
  /**
659
- * Codex-style short status from reasoning: first `**bold**` / heading, else
660
- * the first short clause. Keeps the wait card in sync with what the model is
661
- * doing instead of repeating the user's prompt.
734
+ * Codex `extract_first_bold`: the first **closed** `**bold**` in the thinking
735
+ * stream, else the first markdown heading. An unclosed `**` means the title
736
+ * has not arrived yet, so return undefined and keep the default header —
737
+ * never fall back to hard-truncated reasoning, reply, or prompt text.
662
738
  */
663
- export function waitSummaryFromReasoning(text, maxChars = WAIT_SUMMARY_MAX) {
664
- const raw = text.replace(/\r\n?/gu, '\n').trim();
665
- if (raw === '')
666
- return undefined;
667
- const bold = /\*\*([^*]{2,80})\*\*/u.exec(raw)?.[1]
668
- ?? /^#{1,6}\s+(.+)$/mu.exec(raw)?.[1];
669
- const source = (bold ?? raw.split('\n').find(line => line.trim() !== '') ?? '').replace(/\s+/gu, ' ').trim();
670
- if (source === '')
671
- return undefined;
672
- const clause = source.split(/[。!?!?\n]/u)[0]?.trim() ?? source;
673
- const chars = Array.from(clause);
674
- if (chars.length <= maxChars)
675
- return clause;
676
- return `${chars.slice(0, Math.max(2, maxChars - 1)).join('')}…`;
739
+ export function waitSummaryFromReasoning(text) {
740
+ const raw = text.replace(/\r\n?/gu, '\n');
741
+ const chars = Array.from(raw);
742
+ for (let i = 0; i + 1 < chars.length; i += 1) {
743
+ if (chars[i] !== '*' || chars[i + 1] !== '*')
744
+ continue;
745
+ let j = i + 2;
746
+ while (j + 1 < chars.length && !(chars[j] === '*' && chars[j + 1] === '*'))
747
+ j += 1;
748
+ if (j + 1 >= chars.length)
749
+ return undefined;
750
+ const inner = chars.slice(i + 2, j).join('').replace(/\s+/gu, ' ').trim();
751
+ return inner === '' ? undefined : inner;
752
+ }
753
+ const heading = /^#{1,6}\s+(.+)$/mu.exec(raw)?.[1];
754
+ const source = heading?.replace(/\s+/gu, ' ').trim() ?? '';
755
+ return source === '' ? undefined : source;
677
756
  }
678
757
  /** Wait-card header + optional detail. Header tracks model work when known. */
679
758
  export function waitCardCopy(input) {
680
759
  const toolTitle = input.toolTitle?.trim() ?? '';
681
760
  const toolSummary = input.toolSummary?.trim() ?? '';
682
- const fromReasoning = waitSummaryFromReasoning(input.reasoning ?? '');
683
- const fromReply = waitSummaryFromReasoning(input.reply ?? '');
684
- const header = fromReasoning ?? fromReply ?? t('wait.working');
761
+ const header = waitSummaryFromReasoning(input.reasoning ?? '') ?? t('wait.working');
685
762
  if (toolTitle !== '') {
686
- const extra = toolSummary === '' ? '' : ` ${Array.from(toolSummary).slice(0, 40).join('')}`;
687
- return { header, detail: `${toolTitle}${extra}` };
763
+ return { header, detail: toolSummary === '' ? toolTitle : `${toolTitle} ${toolSummary}` };
688
764
  }
689
765
  return { header };
690
766
  }
767
+ const WAIT_DETAIL_PREFIX = ' └ ';
768
+ const WAIT_DETAIL_MAX_LINES = 3;
769
+ /**
770
+ * Codex `wrapped_details_lines`: word-wrap the wait-card detail under the
771
+ * ` └ ` prefix, continue wrapped rows at the prefix width, cap at 3 rows and
772
+ * end the last one with an ellipsis when the text does not fit.
773
+ */
774
+ export function wrapWaitDetails(detail, width, maxLines = WAIT_DETAIL_MAX_LINES) {
775
+ const prefixWidth = displayWidth(WAIT_DETAIL_PREFIX);
776
+ const contentWidth = Math.max(1, width - prefixWidth);
777
+ const rows = [];
778
+ let current = '';
779
+ const flush = () => {
780
+ if (current !== '')
781
+ rows.push(current);
782
+ current = '';
783
+ };
784
+ for (const word of detail.split(/\s+/u)) {
785
+ if (word === '')
786
+ continue;
787
+ let rest = word;
788
+ while (displayWidth(rest) > contentWidth) {
789
+ flush();
790
+ let cut = 0;
791
+ let used = 0;
792
+ for (const char of rest) {
793
+ const charWidth = displayWidth(char);
794
+ if (used + charWidth > contentWidth)
795
+ break;
796
+ used += charWidth;
797
+ cut += char.length;
798
+ }
799
+ if (cut === 0)
800
+ cut = firstCodePointLength(rest);
801
+ rows.push(rest.slice(0, cut));
802
+ rest = rest.slice(cut);
803
+ }
804
+ if (rest === '')
805
+ continue;
806
+ if (current === '')
807
+ current = rest;
808
+ else if (displayWidth(current) + 1 + displayWidth(rest) <= contentWidth)
809
+ current += ` ${rest}`;
810
+ else {
811
+ flush();
812
+ current = rest;
813
+ }
814
+ }
815
+ flush();
816
+ if (rows.length === 0)
817
+ return [];
818
+ const overflow = rows.length > maxLines;
819
+ const kept = overflow ? rows.slice(0, maxLines) : rows;
820
+ if (overflow) {
821
+ // Codex rewrites the last kept row with an explicit ellipsis so it reads
822
+ // as "more below", even when the row itself still has spare room.
823
+ const last = kept[maxLines - 1] ?? '';
824
+ const limit = Math.max(1, contentWidth - 1);
825
+ let cut = 0;
826
+ let used = 0;
827
+ for (const char of last) {
828
+ const charWidth = displayWidth(char);
829
+ if (used + charWidth > limit)
830
+ break;
831
+ used += charWidth;
832
+ cut += char.length;
833
+ }
834
+ kept[maxLines - 1] = `${last.slice(0, cut)}…`;
835
+ }
836
+ return kept.map((line, index) => index === 0 ? `${WAIT_DETAIL_PREFIX}${line}` : `${' '.repeat(prefixWidth)}${line}`);
837
+ }
691
838
  export function displayWidth(text) {
692
839
  let width = 0;
693
840
  for (const char of text) {
@@ -1463,24 +1610,17 @@ export function quotaAlertText(snapshot, window) {
1463
1610
  });
1464
1611
  }
1465
1612
  /**
1466
- * How often to re-fetch quota, based on the tightest window.
1467
- * Counted in model steps (not conversation turns): a turn with several
1468
- * tool/LLM steps should refresh more often because it spends more quota.
1469
- * Hourly/5h: every 10 steps, every 4 when near a threshold.
1470
- * Weekly: every 50 steps, every 10 when near.
1471
- * Monthly: every 80 steps, every 20 when near.
1613
+ * How often to re-fetch quota or prepaid balance, counted in model steps.
1614
+ * Default is every 10 steps. Near a remaining-percent threshold, hourly
1615
+ * windows refresh every 4 steps.
1472
1616
  */
1473
1617
  export function quotaRefreshEverySteps(window) {
1474
1618
  if (window === undefined)
1475
1619
  return 10;
1476
1620
  const near = window.remainingPercent <= QUOTA_NEAR_THRESHOLD_PERCENT;
1477
- if (window.period === 'hourly')
1478
- return near ? 4 : 10;
1479
- if (window.period === 'weekly')
1480
- return near ? 10 : 50;
1481
- if (window.period === 'monthly')
1482
- return near ? 20 : 80;
1483
- return near ? 10 : 50;
1621
+ if (window.period === 'hourly' && near)
1622
+ return 4;
1623
+ return 10;
1484
1624
  }
1485
1625
  /** @deprecated Same cadence as {@link quotaRefreshEverySteps}; the name predates step accounting. */
1486
1626
  export const quotaRefreshEveryTurns = quotaRefreshEverySteps;
@@ -1694,6 +1834,18 @@ export function parseOpenAiCompatibleBalance(payload, provider, path) {
1694
1834
  return undefined;
1695
1835
  return { provider, plan: provider, lines, sourcePath: path };
1696
1836
  }
1837
+ /** Compact footer chip: `余额 86.42 CNY`. Prefers remaining/available lines. */
1838
+ export function formatFooterBalance(snapshot) {
1839
+ const preferred = snapshot.lines.find(line => /剩余|可用|余额|available|remaining|credit/iu.test(line.label))
1840
+ ?? snapshot.lines[0];
1841
+ if (preferred === undefined)
1842
+ return undefined;
1843
+ const amount = preferred.amount.trim();
1844
+ if (amount === '')
1845
+ return undefined;
1846
+ const currency = preferred.currency === undefined || preferred.currency === '' ? '' : ` ${preferred.currency}`;
1847
+ return t('footer.balance', { amount: `${amount}${currency}` });
1848
+ }
1697
1849
  export function formatAccountBalance(snapshot) {
1698
1850
  const header = [`${snapshot.plan} 余额(${snapshot.provider})`];
1699
1851
  if (snapshot.available === false)
@@ -1803,6 +1955,14 @@ function parseJsonArgs(args) {
1803
1955
  return null;
1804
1956
  }
1805
1957
  }
1958
+ function firstString(record, keys) {
1959
+ for (const key of keys) {
1960
+ const value = record[key];
1961
+ if (typeof value === 'string' && value.trim() !== '')
1962
+ return value;
1963
+ }
1964
+ return '';
1965
+ }
1806
1966
  /** A short scalar rendering of one argument value, or null for objects/arrays. */
1807
1967
  function scalarText(value) {
1808
1968
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
@@ -1862,6 +2022,46 @@ function friendlyArgsSummary(name, args) {
1862
2022
  }
1863
2023
  const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
1864
2024
  const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
2025
+ /** Sliding window of `windowSize` items that keeps `cursor` visible. */
2026
+ export function pickerWindowStart(cursor, total, windowSize = PICKER_WINDOW) {
2027
+ if (total <= windowSize)
2028
+ return 0;
2029
+ const maxStart = Math.max(0, total - windowSize);
2030
+ const start = cursor - Math.floor((windowSize - 1) / 2);
2031
+ return Math.max(0, Math.min(maxStart, start));
2032
+ }
2033
+ export function parseDisconnectPolicy(raw) {
2034
+ const id = raw.trim().toLowerCase();
2035
+ if (id === 'pause' || id === 'cancel' || id === '暂停')
2036
+ return 'pause';
2037
+ if (id === 'continue' || id === 'keep' || id === '继续')
2038
+ return 'continue';
2039
+ return undefined;
2040
+ }
2041
+ const UNDECLARED_EFFORT_IDS = ['off', 'low', 'medium', 'high', 'max', 'xhigh'];
2042
+ const DEFAULT_EFFORT_ALIASES = new Set(['default', 'none', 'auto', 'reset', '默认']);
2043
+ function effortChoices(ids) {
2044
+ return ids.map(id => ({ id, label: t(`effort.option.${id}`) }));
2045
+ }
2046
+ function undeclaredEffortChoices() {
2047
+ return effortChoices(UNDECLARED_EFFORT_IDS);
2048
+ }
2049
+ function localOAuthEffortChoices(modelId) {
2050
+ return effortChoices(modelId === 'grok-4.6'
2051
+ ? ['off', 'low', 'medium', 'high', 'xhigh']
2052
+ : ['off', 'low', 'medium', 'high']);
2053
+ }
2054
+ /** Parse `/effort high` / `/subeffort default`. Empty or unknown → undefined. */
2055
+ export function parseEffortArg(raw) {
2056
+ const id = raw.trim().toLowerCase();
2057
+ if (id === '')
2058
+ return undefined;
2059
+ if (DEFAULT_EFFORT_ALIASES.has(id))
2060
+ return { kind: 'default' };
2061
+ if (/^[a-z][a-z0-9_-]{0,31}$/u.test(id))
2062
+ return { kind: 'id', id };
2063
+ return undefined;
2064
+ }
1865
2065
  export function parseWorkspaceView(raw) {
1866
2066
  const id = raw.trim().toLowerCase();
1867
2067
  if (id === 'detailed' || id === 'detail' || id === 'full' || id === '详细')
@@ -1885,6 +2085,72 @@ export function countDiffLines(hunks) {
1885
2085
  }
1886
2086
  return total;
1887
2087
  }
2088
+ /** Added / removed line counts for a diff (`oldText: null` means a new file). */
2089
+ export function countDiffAddDel(hunks) {
2090
+ const stat = { add: 0, del: 0 };
2091
+ if (hunks === undefined)
2092
+ return stat;
2093
+ for (const hunk of hunks) {
2094
+ stat.add += hunk.newText === '' ? 0 : hunk.newText.split('\n').length;
2095
+ if (hunk.oldText !== null) {
2096
+ stat.del += hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length;
2097
+ }
2098
+ }
2099
+ return stat;
2100
+ }
2101
+ /**
2102
+ * Git diffstat token, deletions first like `-13 +24`. Zero parts drop out
2103
+ * (a new file shows only `+24`); empty when the diff has no counted lines.
2104
+ */
2105
+ export function diffStatToken(add, del) {
2106
+ const parts = [];
2107
+ if (del > 0)
2108
+ parts.push(`-${del}`);
2109
+ if (add > 0)
2110
+ parts.push(`+${add}`);
2111
+ return parts.join(' ');
2112
+ }
2113
+ const READ_TOOL_NAMES = new Set(['read']);
2114
+ const TOOL_FLIP_MS = 280;
2115
+ export function toolTargetPath(name, args, fallback = '') {
2116
+ const parsed = parseJsonArgs(args);
2117
+ if (parsed === null)
2118
+ return fallback;
2119
+ if (READ_TOOL_NAMES.has(name)) {
2120
+ return firstString(parsed, ['path', 'file_path', 'url']) || fallback;
2121
+ }
2122
+ if (DIFF_TOOL_NAMES.has(name)) {
2123
+ return firstString(parsed, ['file_path', 'path']) || fallback;
2124
+ }
2125
+ return fallback;
2126
+ }
2127
+ export function countOutputLines(text) {
2128
+ if (text === '')
2129
+ return 0;
2130
+ const body = text.endsWith('\n') ? text.slice(0, -1) : text;
2131
+ return body === '' ? 0 : body.split('\n').length;
2132
+ }
2133
+ function mergeableToolKind(name) {
2134
+ if (READ_TOOL_NAMES.has(name))
2135
+ return 'read';
2136
+ if (DIFF_TOOL_NAMES.has(name))
2137
+ return 'edit';
2138
+ return undefined;
2139
+ }
2140
+ /**
2141
+ * Consecutive same-path reads (or edits) collapse onto one card.
2142
+ * A → B → C → A becomes four cards; A ×5 stays one card with repeats=5.
2143
+ */
2144
+ export function canMergeToolCall(previous, next) {
2145
+ if (previous === undefined)
2146
+ return false;
2147
+ const kind = mergeableToolKind(next.name);
2148
+ if (kind === undefined || mergeableToolKind(previous.name) !== kind)
2149
+ return false;
2150
+ const previousPath = toolTargetPath(previous.name, previous.args, previous.summary);
2151
+ const nextPath = toolTargetPath(next.name, next.args);
2152
+ return previousPath !== '' && previousPath === nextPath;
2153
+ }
1888
2154
  export function compactToolGroups(tools) {
1889
2155
  const edits = [];
1890
2156
  const calls = [];
@@ -1897,10 +2163,30 @@ export function compactToolGroups(tools) {
1897
2163
  return {
1898
2164
  edits,
1899
2165
  calls,
1900
- editLines: edits.reduce((sum, tool) => sum + Math.max(1, countDiffLines(tool.diff)), 0),
1901
2166
  failedCalls: calls.filter(tool => tool.status === 'error').length,
1902
2167
  };
1903
2168
  }
2169
+ /**
2170
+ * Split compact-view tools into the bursts that belong with each assistant
2171
+ * reply: tools after reply N sit with that reply, until the next reply.
2172
+ */
2173
+ export function compactToolBursts(rows) {
2174
+ const bursts = [];
2175
+ let current = { after: undefined, tools: [] };
2176
+ bursts.push(current);
2177
+ for (const row of rows) {
2178
+ if (row.kind === 'assistant') {
2179
+ current = { after: row, tools: [] };
2180
+ bursts.push(current);
2181
+ continue;
2182
+ }
2183
+ if (row.kind === 'tool')
2184
+ current.tools.push(row);
2185
+ }
2186
+ return bursts
2187
+ .map(burst => ({ after: burst.after, groups: compactToolGroups(burst.tools) }))
2188
+ .filter(burst => burst.groups.calls.length > 0 || burst.groups.edits.length > 0);
2189
+ }
1904
2190
  const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
1905
2191
  /**
1906
2192
  * Tool calls that already have a dedicated transcript card (goal/change,
@@ -2432,10 +2718,10 @@ export function toolStateLabel(status) {
2432
2718
  return 'error';
2433
2719
  return 'running…';
2434
2720
  }
2435
- /** Header + SGR spans: default title, dim operand, colored and [ok]/[error]. */
2721
+ /** Header + SGR spans: default title, dim operand, colored ●. `[ok]` is omitted — the green dot is enough. */
2436
2722
  export function buildToolHeader(input) {
2437
2723
  const running = input.status === undefined || input.status === 'running';
2438
- const state = toolStateLabel(input.status);
2724
+ const stateToken = input.status === 'ok' ? '' : `[${toolStateLabel(input.status)}]`;
2439
2725
  const exit = !running && input.command !== undefined
2440
2726
  ? input.signal !== undefined
2441
2727
  ? ` [信号 ${input.signal}]`
@@ -2445,25 +2731,56 @@ export function buildToolHeader(input) {
2445
2731
  : '';
2446
2732
  const spinner = input.spinner ?? '';
2447
2733
  const prefix = input.focused ? '▶ ' : ' ';
2448
- const marker = input.expanded ? '▾' : '▸';
2734
+ const flipping = input.flipping === true;
2735
+ const marker = flipping ? '◇' : input.expanded ? '▾' : '▸';
2449
2736
  const lead = `${prefix}${marker} ● ${input.title}`;
2450
2737
  const summaryText = input.summary === '' ? '' : ` ${input.summary}`;
2451
- const stateToken = `[${state}]`;
2452
- const tail = ` ${stateToken}${exit}${spinner}`;
2453
- const plain = `${lead}${summaryText}${tail}`;
2738
+ const statToken = input.diffStat === undefined ? '' : diffStatToken(input.diffStat.add, input.diffStat.del);
2739
+ const statText = statToken === '' ? '' : ` ${statToken}`;
2740
+ const stateGap = stateToken === '' ? '' : ' ';
2741
+ const tail = `${stateGap}${stateToken}${exit}${spinner}`;
2742
+ const plain = `${lead}${summaryText}${statText}${tail}`;
2454
2743
  const stateCode = toolStateColor(input.status);
2455
2744
  const dotIndex = lead.indexOf('●');
2456
- const stateIndex = lead.length + summaryText.length + 2;
2745
+ const stateIndex = stateToken === '' ? -1 : lead.length + summaryText.length + statText.length + stateGap.length;
2457
2746
  const segments = [];
2747
+ if (flipping) {
2748
+ const markerIndex = prefix.length;
2749
+ segments.push({ start: markerIndex, end: markerIndex + marker.length, sgr: '36' });
2750
+ }
2458
2751
  if (dotIndex >= 0)
2459
2752
  segments.push({ start: dotIndex, end: dotIndex + '●'.length, sgr: stateCode });
2460
2753
  if (summaryText.length > 0) {
2461
2754
  segments.push({ start: lead.length, end: lead.length + summaryText.length, sgr: '90' });
2462
2755
  }
2463
- segments.push({ start: stateIndex, end: stateIndex + stateToken.length + exit.length, sgr: stateCode });
2756
+ if (statToken !== '') {
2757
+ // Git diffstat colors: deletions red, additions green. The token sits
2758
+ // two cells after the summary, deletions before the joining space.
2759
+ const statStart = lead.length + summaryText.length + 2;
2760
+ const delEnd = statToken.indexOf(' +');
2761
+ if (statToken.startsWith('-')) {
2762
+ segments.push({
2763
+ start: statStart,
2764
+ end: statStart + (delEnd === -1 ? statToken.length : delEnd),
2765
+ sgr: '31',
2766
+ });
2767
+ }
2768
+ if (delEnd !== -1) {
2769
+ const addStart = statStart + delEnd + 1;
2770
+ segments.push({ start: addStart, end: statStart + statToken.length, sgr: '32' });
2771
+ }
2772
+ }
2773
+ if (stateIndex >= 0) {
2774
+ segments.push({ start: stateIndex, end: stateIndex + stateToken.length + exit.length, sgr: stateCode });
2775
+ }
2776
+ else if (exit !== '') {
2777
+ const exitIndex = lead.length + summaryText.length + statText.length;
2778
+ segments.push({ start: exitIndex, end: exitIndex + exit.length, sgr: stateCode });
2779
+ }
2464
2780
  if (spinner !== '') {
2781
+ const spinnerStart = (stateIndex >= 0 ? stateIndex + stateToken.length + exit.length : lead.length + summaryText.length + statText.length + exit.length);
2465
2782
  segments.push({
2466
- start: stateIndex + stateToken.length + exit.length,
2783
+ start: spinnerStart,
2467
2784
  end: plain.length,
2468
2785
  sgr: '90',
2469
2786
  });
@@ -2657,14 +2974,6 @@ export function toolBodyLines(row, maxLines) {
2657
2974
  }
2658
2975
  return unlimited ? out : capDisplayLines(out, maxLines);
2659
2976
  }
2660
- function firstString(record, keys) {
2661
- for (const key of keys) {
2662
- const value = record[key];
2663
- if (typeof value === 'string' && value.trim() !== '')
2664
- return value;
2665
- }
2666
- return '';
2667
- }
2668
2977
  function specializedToolBody(row, maxLines = Number.MAX_SAFE_INTEGER) {
2669
2978
  const name = row.name ?? '';
2670
2979
  const args = parseJsonArgs(row.args);
@@ -2773,12 +3082,25 @@ export class SshTui {
2773
3082
  agent;
2774
3083
  rows = [];
2775
3084
  streaming;
3085
+ autoApprovalMode = 'off';
3086
+ autoAllowedCount = 0;
3087
+ autoDeniedCount = 0;
3088
+ aiReviewCount = 0;
3089
+ /** Host knobs folded from the session log: auto mode needs approval=ask to see requests. */
3090
+ hostSandboxMode;
3091
+ hostApprovalPolicy;
3092
+ approvalMismatchWarned = false;
3093
+ /** Web-aligned provider presets from the host's pi-ai catalog (undefined until loaded / when unreachable). */
3094
+ catalogPresets;
3095
+ catalogLoad;
2776
3096
  input = '';
2777
3097
  cursor = 0;
2778
3098
  inputFolded = false;
2779
3099
  inPaste = false;
2780
3100
  history = [];
2781
3101
  historyIndex = -1;
3102
+ /** Live input parked while browsing history with ↑. Restored by ↓ past the newest item. */
3103
+ historyDraft = '';
2782
3104
  status = 'idle';
2783
3105
  dialog;
2784
3106
  dialogQueue = [];
@@ -2786,6 +3108,21 @@ export class SshTui {
2786
3108
  dirty = true;
2787
3109
  disposed = false;
2788
3110
  exiting = false;
3111
+ hangingUp = false;
3112
+ onDirectResize = () => {
3113
+ this.forceFullPaint = true;
3114
+ this.dirty = true;
3115
+ this.paint();
3116
+ };
3117
+ headlessDisplay;
3118
+ disconnectPolicy;
3119
+ detachedIdleTimer;
3120
+ displayDetached = false;
3121
+ displayHost;
3122
+ relayColumns;
3123
+ relayRows;
3124
+ onHangup;
3125
+ onReattach;
2789
3126
  renderTimer;
2790
3127
  decoder = new StringDecoder('utf8');
2791
3128
  color;
@@ -2816,6 +3153,7 @@ export class SshTui {
2816
3153
  stalledWarningShown = false;
2817
3154
  lastPaintAt = 0;
2818
3155
  commandAbort;
3156
+ seenCommandDoneIds = new Set();
2819
3157
  activeSubagents = new Map();
2820
3158
  subagentSessions = new Set();
2821
3159
  openToolCalls = new Map();
@@ -2841,7 +3179,6 @@ export class SshTui {
2841
3179
  escapeTimer;
2842
3180
  thinkingStartedAt;
2843
3181
  waitStartedAt;
2844
- waitPrompt;
2845
3182
  completionSignaled = false;
2846
3183
  replaying = false;
2847
3184
  completedAt = 0;
@@ -2863,6 +3200,7 @@ export class SshTui {
2863
3200
  sessionTitle = '';
2864
3201
  llmRetry;
2865
3202
  quotaSnapshot;
3203
+ balanceSnapshot;
2866
3204
  quotaAlerted = new Set();
2867
3205
  quotaStepsSinceRefresh = 0;
2868
3206
  quotaRefreshInFlight = false;
@@ -2879,6 +3217,7 @@ export class SshTui {
2879
3217
  this.maxToolOutputLines = Math.max(1, config.maxToolOutputLines ?? 6);
2880
3218
  this.showReasoning = config.showReasoning !== false;
2881
3219
  this.workspaceView = this.readWorkspaceView();
3220
+ this.autoApprovalMode = this.readAutoApprovalMode();
2882
3221
  this.goodbye = config.goodbye
2883
3222
  ?? this.ctx.get('tuiGoodbyeMessage')
2884
3223
  ?? `To resume this session: dsh --profile tui --resume=${this.agent.id}`;
@@ -2888,6 +3227,10 @@ export class SshTui {
2888
3227
  this.subagentSelection = config.subagentSelection ?? { current: { model: DEFAULT_SUBAGENT_MODEL } };
2889
3228
  this.onSwitchSession = config.onSwitchSession;
2890
3229
  this.onSelectionChanged = config.onSelectionChanged;
3230
+ this.onHangup = config.onHangup;
3231
+ this.onReattach = config.onReattach;
3232
+ this.headlessDisplay = config.headlessDisplay === true;
3233
+ this.disconnectPolicy = config.disconnectPolicy ?? this.readDisconnectPolicy();
2891
3234
  this.resumePicker = config.resumePicker === true;
2892
3235
  this.presetId = config.presetId ?? 'standard';
2893
3236
  this.presetName = config.presetName ?? this.presetId;
@@ -2905,15 +3248,31 @@ export class SshTui {
2905
3248
  }
2906
3249
  /** Enter raw mode, switch to the alternate screen, and start listening. */
2907
3250
  start() {
3251
+ detachFromSshSession();
3252
+ captureHangupSignals(this.handleHangupSignal);
3253
+ this.bindAgentEvents();
3254
+ void this.ensureDisplayHost().catch((error) => {
3255
+ if (this.disposed)
3256
+ return;
3257
+ this.pushRow({ kind: 'error', text: `显示通道启动失败: ${errorChain(error)}` });
3258
+ this.markDirty();
3259
+ });
3260
+ if (this.headlessDisplay) {
3261
+ this.displayDetached = true;
3262
+ this.startRenderTimer();
3263
+ this.bootBackgroundTasks();
3264
+ return;
3265
+ }
2908
3266
  process.stdin.setRawMode(true);
2909
3267
  process.stdin.resume();
2910
- process.stdout.on('resize', this.markDirty);
2911
- process.on('SIGWINCH', this.markDirty);
2912
- this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
2913
- const questions = this.ctx.get('userQuestions');
2914
- if (questions !== undefined) {
2915
- this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, questions, this.handleUserQuestions);
2916
- }
3268
+ process.stdout.on('resize', this.onDirectResize);
3269
+ if (process.platform !== 'win32') {
3270
+ process.on('SIGWINCH', this.onDirectResize);
3271
+ }
3272
+ process.stdin.prependListener('end', this.handleHangupStream);
3273
+ process.stdin.prependListener('close', this.handleHangupStream);
3274
+ process.stdout.on('error', this.handleIoError);
3275
+ process.stdin.on('error', this.handleIoError);
2917
3276
  this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
2918
3277
  this.render();
2919
3278
  this.updateTerminalTitle();
@@ -2926,6 +3285,23 @@ export class SshTui {
2926
3285
  process.stdin.on('data', this.handleData);
2927
3286
  this.startRenderTimer();
2928
3287
  });
3288
+ this.bootBackgroundTasks();
3289
+ }
3290
+ bindAgentEvents() {
3291
+ this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
3292
+ const questions = this.ctx.get('userQuestions');
3293
+ if (questions !== undefined) {
3294
+ this.userQuestionDisposer = installUserQuestionAnswerer(this.ctx, questions, this.handleUserQuestions);
3295
+ }
3296
+ }
3297
+ bootBackgroundTasks() {
3298
+ // Warm the web-aligned provider catalog so /setup has it ready instantly
3299
+ // (runs on both the interactive and the detached-display path).
3300
+ this.catalogLoad = loadProviderCatalog([process.argv[1], this.ctx.baseUrl]);
3301
+ this.catalogLoad.then(presets => {
3302
+ this.catalogPresets = presets;
3303
+ this.markDirty();
3304
+ }).catch(() => { });
2929
3305
  void this.maybeRunOnboarding().catch((error) => {
2930
3306
  if (this.disposed)
2931
3307
  return;
@@ -3020,6 +3396,51 @@ export class SshTui {
3020
3396
  return 'detailed';
3021
3397
  return parseWorkspaceView(String(raw.view ?? '')) ?? 'detailed';
3022
3398
  }
3399
+ readDisconnectPolicy() {
3400
+ const env = parseDisconnectPolicy(process.env.DSH_TUI_DISCONNECT ?? '');
3401
+ if (env !== undefined)
3402
+ return env;
3403
+ const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
3404
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
3405
+ return 'pause';
3406
+ return parseDisconnectPolicy(String(raw.disconnect ?? '')) ?? 'pause';
3407
+ }
3408
+ readAutoApprovalMode() {
3409
+ const env = parseAutoApprovalMode(process.env.DSH_TUI_AUTO_APPROVAL ?? '');
3410
+ if (env !== undefined)
3411
+ return env;
3412
+ const raw = this.ctx.get('settings')?.get(UI_LOCALE_NAMESPACE);
3413
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
3414
+ return 'off';
3415
+ return parseAutoApprovalMode(String(raw.autoApproval ?? '')) ?? 'off';
3416
+ }
3417
+ detachedIdleMs() {
3418
+ const raw = Number.parseInt(process.env.DSH_TUI_DETACHED_IDLE_MS ?? '', 10);
3419
+ if (Number.isFinite(raw) && raw > 0)
3420
+ return raw;
3421
+ return DEFAULT_DETACHED_IDLE_MS;
3422
+ }
3423
+ clearDetachedIdleTimer() {
3424
+ if (this.detachedIdleTimer !== undefined)
3425
+ clearTimeout(this.detachedIdleTimer);
3426
+ this.detachedIdleTimer = undefined;
3427
+ }
3428
+ armDetachedIdleTimer() {
3429
+ this.clearDetachedIdleTimer();
3430
+ const idleMs = this.detachedIdleMs();
3431
+ this.detachedIdleTimer = setTimeout(() => {
3432
+ if (this.disposed || this.exiting)
3433
+ return;
3434
+ if (this.displayHost?.attached === true)
3435
+ return;
3436
+ if (this.agent.status === 'running') {
3437
+ this.armDetachedIdleTimer();
3438
+ return;
3439
+ }
3440
+ void this.requestExit(0);
3441
+ }, idleMs);
3442
+ this.detachedIdleTimer.unref?.();
3443
+ }
3023
3444
  isCompactView() {
3024
3445
  return this.workspaceView === 'compact';
3025
3446
  }
@@ -3027,32 +3448,75 @@ export class SshTui {
3027
3448
  setWorkspaceView(view) {
3028
3449
  this.workspaceView = view;
3029
3450
  }
3451
+ paintCompactBurst(addDisplay, groups, width) {
3452
+ const callAnchor = groups.calls.at(-1);
3453
+ const editAnchor = groups.edits.at(-1);
3454
+ if (callAnchor !== undefined)
3455
+ this.paintCompactSummary(addDisplay, callAnchor, 'calls', groups, width);
3456
+ if (editAnchor !== undefined)
3457
+ this.paintCompactSummary(addDisplay, editAnchor, 'edits', groups, width);
3458
+ }
3030
3459
  paintCompactSummary(addDisplay, anchor, kind, groups, width) {
3031
- const focused = this.focusedRow === anchor;
3032
- const marker = anchor.expanded ? '▾' : '';
3033
- const running = kind === 'edits'
3034
- ? groups.edits.some(item => item.status === undefined || item.status === 'running')
3035
- : groups.calls.some(item => item.status === undefined || item.status === 'running');
3036
- const spinner = running ? ` ${this.spinnerFrame()}` : '';
3460
+ const items = kind === 'edits' ? groups.edits : groups.calls;
3461
+ const running = items.some(item => item.status === undefined || item.status === 'running');
3462
+ const failed = items.length > 0 && items.every(item => item.status === 'error');
3463
+ const status = running ? 'running' : failed ? 'error' : 'ok';
3464
+ const addDel = { add: 0, del: 0 };
3465
+ if (kind === 'edits') {
3466
+ for (const item of groups.edits) {
3467
+ const stat = countDiffAddDel(item.diff);
3468
+ addDel.add += stat.add;
3469
+ addDel.del += stat.del;
3470
+ }
3471
+ }
3037
3472
  const title = kind === 'edits'
3038
3473
  ? (groups.edits.length > 1
3039
- ? t('compact.editsFiles', { lines: groups.editLines, files: groups.edits.length })
3040
- : t('compact.edits', { lines: groups.editLines }))
3474
+ ? t('compact.editsFiles', { files: groups.edits.length })
3475
+ : t('compact.edits'))
3041
3476
  : (groups.failedCalls > 0
3042
3477
  ? t('compact.toolsFailed', { count: groups.calls.length, failed: groups.failedCalls })
3043
3478
  : t('compact.tools', { count: groups.calls.length }));
3044
- const header = `${focused ? '▶ ' : ' '}${marker} ● ${title}${spinner}${anchor.expanded ? '' : t('card.expand')}`;
3045
- const styled = this.styleLine(groups.failedCalls > 0 && kind === 'calls' ? 'error' : 'tool', header);
3046
- addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, anchor);
3047
- if (!anchor.expanded)
3479
+ const header = buildToolHeader({
3480
+ focused: this.focusedRow === anchor,
3481
+ expanded: anchor.expanded,
3482
+ title,
3483
+ summary: '',
3484
+ status,
3485
+ spinner: running ? ` ${this.spinnerFrame()}` : '',
3486
+ flipping: items.some(item => item.flipUntil !== undefined && Date.now() < item.flipUntil),
3487
+ ...(kind === 'edits' && (addDel.add > 0 || addDel.del > 0) ? { diffStat: addDel } : {}),
3488
+ });
3489
+ const headerSegments = this.color ? header.segments : [];
3490
+ if (!anchor.expanded) {
3491
+ const collapsed = truncateToWidth(header.plain, Math.max(1, width - 2));
3492
+ const styled = headerSegments.length === 0
3493
+ ? this.styleLine('tool', collapsed)
3494
+ : paintSegmentedLine(collapsed, 0, collapsed.length, headerSegments);
3495
+ addDisplay(this.focusedRow === anchor && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, anchor);
3048
3496
  return;
3049
- const items = kind === 'edits' ? groups.edits : groups.calls;
3497
+ }
3498
+ const expandedHeaderLines = headerSegments.length === 0
3499
+ ? wrap(header.plain, width).map(line => this.styleLine('tool', line))
3500
+ : wrapSegmented(header.plain, Math.max(1, width), headerSegments);
3501
+ for (const wrapped of expandedHeaderLines) {
3502
+ addDisplay(this.focusedRow === anchor && this.color ? `\x1b[7m${wrapped}\x1b[27m` : wrapped, anchor);
3503
+ }
3050
3504
  for (const item of items) {
3051
- const state = item.status === 'error' ? 'error' : item.status === 'ok' ? 'ok' : 'running…';
3052
- const extra = kind === 'edits'
3053
- ? `${countDiffLines(item.diff) || 1} ${getLocale() === 'en' ? 'lines' : '行'}`
3054
- : item.summary;
3055
- addDisplay(this.styleLine('system', truncateToWidth(` ${item.title} ${extra} [${state}]`, width)), item);
3505
+ if (kind === 'edits') {
3506
+ const stat = countDiffAddDel(item.diff);
3507
+ const token = diffStatToken(stat.add, stat.del);
3508
+ const extra = token === ''
3509
+ ? `${countDiffLines(item.diff) || 1} ${getLocale() === 'en' ? 'lines' : '行'}`
3510
+ : token;
3511
+ addDisplay(this.styleLine('tool-result', truncateToWidth(` ${item.title} ${extra}`, width)), item);
3512
+ for (const line of toolBodyLines(item, Number.MAX_SAFE_INTEGER)) {
3513
+ this.paintToolBodyLine(addDisplay, item, line, width);
3514
+ }
3515
+ continue;
3516
+ }
3517
+ const extra = item.summary;
3518
+ const state = item.status === 'error' ? ' [error]' : item.status === 'ok' ? '' : ' [running…]';
3519
+ addDisplay(this.styleLine('tool-result', truncateToWidth(` ${item.title} ${extra}${state}`, width)), item);
3056
3520
  }
3057
3521
  }
3058
3522
  startRenderTimer() {
@@ -3067,7 +3531,8 @@ export class SshTui {
3067
3531
  const animating = (this.streaming !== undefined && this.streaming.reasoning !== '')
3068
3532
  || this.activeSubagents.size > 0
3069
3533
  || this.waitCardVisible()
3070
- || this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
3534
+ || this.rows.some(row => (row.kind === 'tool' && row.flipUntil !== undefined && now < row.flipUntil)
3535
+ || (row.kind === 'question' && row.status === 'waiting')
3071
3536
  || (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
3072
3537
  || (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked'))
3073
3538
  || (row.kind === 'compaction' && row.status === 'running'));
@@ -3110,7 +3575,7 @@ export class SshTui {
3110
3575
  replayHistory() {
3111
3576
  this.replaying = true;
3112
3577
  try {
3113
- for (const event of this.agent.session.events) {
3578
+ for (const event of sessionEvents(this.agent.session)) {
3114
3579
  this.handleSessionEvent(this.agent.session, event);
3115
3580
  }
3116
3581
  }
@@ -3192,12 +3657,25 @@ export class SshTui {
3192
3657
  baseUrl: '',
3193
3658
  key: '',
3194
3659
  models: [],
3660
+ catalogPresets: undefined,
3661
+ catalog: undefined,
3662
+ providerCursor: 0,
3195
3663
  saving: false,
3196
3664
  resolve: (saved) => {
3197
3665
  this.onboardingCompletion = undefined;
3198
3666
  resolve(saved);
3199
3667
  },
3200
3668
  };
3669
+ // Web-aligned catalog presets warm at construction; adopt whatever is
3670
+ // ready now and update the open wizard when the load settles.
3671
+ const state = this.onboarding;
3672
+ state.catalogPresets = this.catalogPresets;
3673
+ void this.catalogLoad?.then(presets => {
3674
+ if (this.onboarding === state && state.catalogPresets === undefined && presets !== undefined) {
3675
+ state.catalogPresets = presets;
3676
+ this.markDirty();
3677
+ }
3678
+ });
3201
3679
  this.input = '';
3202
3680
  this.cursor = 0;
3203
3681
  this.dialog = { kind: 'onboarding' };
@@ -3218,12 +3696,64 @@ export class SshTui {
3218
3696
  this.showNextDialog();
3219
3697
  this.markDirty();
3220
3698
  }
3221
- /** Restore the terminal, flush the session, and request process exit. */
3699
+ /**
3700
+ * Drop the TTY without disposing the agent. Safe to call when the fd is
3701
+ * already dead: DECSET restore is best-effort and never throws.
3702
+ */
3703
+ detachDisplay() {
3704
+ if (this.displayDetached)
3705
+ return;
3706
+ if (this.renderTimer !== undefined)
3707
+ clearInterval(this.renderTimer);
3708
+ this.renderTimer = undefined;
3709
+ if (this.escapeTimer !== undefined)
3710
+ clearTimeout(this.escapeTimer);
3711
+ this.escapeTimer = undefined;
3712
+ process.stdin.removeListener('data', this.handleData);
3713
+ process.stdout.removeListener('resize', this.onDirectResize);
3714
+ process.stdout.removeListener('error', this.handleIoError);
3715
+ process.stdin.removeListener('error', this.handleIoError);
3716
+ process.stdin.removeListener('end', this.handleHangupStream);
3717
+ process.stdin.removeListener('close', this.handleHangupStream);
3718
+ process.removeListener('SIGWINCH', this.onDirectResize);
3719
+ if (!this.hangingUp)
3720
+ releaseHangupSignals(this.handleHangupSignal);
3721
+ try {
3722
+ process.stdin.setRawMode(false);
3723
+ }
3724
+ catch {
3725
+ // stdin may already be closed after SIGHUP.
3726
+ }
3727
+ try {
3728
+ process.stdin.pause();
3729
+ }
3730
+ catch {
3731
+ // ignore
3732
+ }
3733
+ try {
3734
+ process.stdout.write('\x1b]0;\x07');
3735
+ process.stdout.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
3736
+ process.stdout.write(`\x1b[?1000l\x1b[?1006l\x1b[?2004l\x1b[?25h${this.useAlternateScreen ? '\x1b[?1049l' : ''}`);
3737
+ }
3738
+ catch (error) {
3739
+ if (!isHangupErrno(error)) {
3740
+ try {
3741
+ process.stderr.write(`dsh-ssh-tui: failed to restore terminal: ${errorChain(error)}\n`);
3742
+ }
3743
+ catch {
3744
+ // both pipes gone
3745
+ }
3746
+ }
3747
+ }
3748
+ this.displayDetached = true;
3749
+ }
3750
+ /** Restore the terminal and drop event wiring. Does not flush or exit. */
3222
3751
  async dispose() {
3223
3752
  if (this.disposed)
3224
3753
  return;
3225
3754
  this.disposed = true;
3226
3755
  this.exiting = true;
3756
+ this.clearDetachedIdleTimer();
3227
3757
  const dialog = this.dialog;
3228
3758
  const queued = this.dialogQueue.splice(0);
3229
3759
  this.dialog = undefined;
@@ -3246,12 +3776,6 @@ export class SshTui {
3246
3776
  pending.reject(new UserQuestionError('TUI closed before the question was answered', 'ASK_ABORTED'));
3247
3777
  }
3248
3778
  }
3249
- if (this.renderTimer !== undefined)
3250
- clearInterval(this.renderTimer);
3251
- this.renderTimer = undefined;
3252
- if (this.escapeTimer !== undefined)
3253
- clearTimeout(this.escapeTimer);
3254
- this.escapeTimer = undefined;
3255
3779
  this.commandAbort?.abort();
3256
3780
  this.commandAbort = undefined;
3257
3781
  for (const dispose of this.disposers.splice(0)) {
@@ -3259,31 +3783,123 @@ export class SshTui {
3259
3783
  }
3260
3784
  this.userQuestionDisposer?.();
3261
3785
  this.userQuestionDisposer = undefined;
3262
- process.stdin.removeListener('data', this.handleData);
3263
- process.stdout.removeListener('resize', this.markDirty);
3264
- process.removeListener('SIGWINCH', this.markDirty);
3265
- process.stdin.setRawMode(false);
3266
- process.stdin.pause();
3267
- this.write('\x1b]0;\x07');
3268
- // Clear every screen (regular + scrollback) before restoring the terminal.
3269
- // In no-alternate-screen mode this removes the last painted frame that
3270
- // would otherwise stay behind the shell prompt after exit.
3271
- this.write('\x1b[0m\x1b[2J\x1b[3J\x1b[H');
3272
- this.write(`\x1b[?1000l\x1b[?1006l\x1b[?2004l\x1b[?25h${this.useAlternateScreen ? '\x1b[?1049l' : ''}`);
3786
+ this.detachDisplay();
3787
+ const host = this.displayHost;
3788
+ this.displayHost = undefined;
3789
+ if (host !== undefined)
3790
+ await host.close();
3273
3791
  }
3274
3792
  /** Human-facing exit with goodbye and flush; called from key handling. */
3275
3793
  async requestExit(code) {
3276
- if (this.exiting)
3794
+ if (this.hangingUp)
3795
+ return;
3796
+ if (this.disposed)
3797
+ return;
3798
+ this.exiting = true;
3799
+ this.clearDetachedIdleTimer();
3800
+ this.displayHost?.sendGoodbye();
3801
+ await this.dispose();
3802
+ if (!this.headlessDisplay)
3803
+ this.writeGoodbye();
3804
+ await this.flushSession();
3805
+ this.exitProcess(code);
3806
+ }
3807
+ /**
3808
+ * True while the session is doing work the user would lose by killing the
3809
+ * Host: a running turn (thinking / reply / tools), live subagents, in-flight
3810
+ * compaction, or an LLM retry. Idle (including a waiting approval dialog
3811
+ * after the turn has already settled) is not busy — hangup then exits
3812
+ * instead of leaving a leftover process.
3813
+ */
3814
+ isBusyForHangupKeepalive() {
3815
+ if (this.agent.status === 'running')
3816
+ return true;
3817
+ if (this.activeSubagents.size > 0)
3818
+ return true;
3819
+ if (this.streaming !== undefined)
3820
+ return true;
3821
+ if (this.openToolCalls.size > 0)
3822
+ return true;
3823
+ if (this.llmRetry !== undefined)
3824
+ return true;
3825
+ if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
3826
+ return true;
3827
+ return false;
3828
+ }
3829
+ /**
3830
+ * SSH / TTY hangup: drop the local display, flush, and either keep the Host
3831
+ * (busy: thinking / reply / tools / subagents) or exit (idle).
3832
+ * When keeping the Host, `pause` cancels the turn; `continue` lets it finish.
3833
+ * Ctrl+C is not a hangup.
3834
+ */
3835
+ async handleHangup() {
3836
+ if (this.hangingUp || this.disposed)
3837
+ return;
3838
+ this.hangingUp = true;
3839
+ ignoreFurtherHangupSignals();
3840
+ this.detachDisplay();
3841
+ const busy = this.isBusyForHangupKeepalive();
3842
+ const pauseTurn = this.disconnectPolicy !== 'continue';
3843
+ if (pauseTurn && this.agent.status === 'running') {
3844
+ try {
3845
+ this.agent.cancel({ kind: 'user' });
3846
+ }
3847
+ catch {
3848
+ // cancel is best-effort; we still flush below.
3849
+ }
3850
+ await waitUntilIdleOrTimeout(() => this.agent.status !== 'running', HANGUP_CANCEL_TIMEOUT_MS);
3851
+ }
3852
+ await this.flushSession();
3853
+ const keepHost = this.displayHost !== undefined && busy;
3854
+ if (keepHost) {
3855
+ this.hangingUp = false;
3856
+ this.armDetachedIdleTimer();
3857
+ await this.onHangup?.();
3277
3858
  return;
3859
+ }
3278
3860
  this.exiting = true;
3279
3861
  await this.dispose();
3280
- process.stdout.write(`\n${sanitizeTerminalText(this.goodbye)}\n`);
3862
+ this.exitProcess(129);
3863
+ }
3864
+ handleHangupSignal = () => {
3865
+ void this.handleHangup();
3866
+ };
3867
+ handleHangupStream = () => {
3868
+ void this.handleHangup();
3869
+ };
3870
+ handleIoError = (error) => {
3871
+ if (isHangupErrno(error))
3872
+ void this.handleHangup();
3873
+ };
3874
+ writeGoodbye() {
3875
+ try {
3876
+ process.stdout.write(`\n${sanitizeTerminalText(this.goodbye)}\n`);
3877
+ }
3878
+ catch (error) {
3879
+ if (!isHangupErrno(error)) {
3880
+ try {
3881
+ process.stderr.write(`dsh-ssh-tui: failed to write goodbye: ${errorChain(error)}\n`);
3882
+ }
3883
+ catch {
3884
+ // both pipes gone
3885
+ }
3886
+ }
3887
+ }
3888
+ }
3889
+ async flushSession() {
3281
3890
  try {
3282
3891
  await this.ctx.get('sessions')?.flush(this.agent.session);
3283
3892
  }
3284
3893
  catch (error) {
3285
- process.stdout.write(`dsh-ssh-tui: failed to flush session: ${errorChain(error)}\n`);
3894
+ try {
3895
+ process.stderr.write(`dsh-ssh-tui: failed to flush session: ${errorChain(error)}\n`);
3896
+ }
3897
+ catch {
3898
+ // stderr may be gone after hangup
3899
+ }
3286
3900
  }
3901
+ }
3902
+ exitProcess(code) {
3287
3903
  const exit = this.ctx.get('appExit');
3288
3904
  if (exit !== undefined)
3289
3905
  exit(code);
@@ -3309,12 +3925,147 @@ export class SshTui {
3309
3925
  process.stdout.rows = previousRows;
3310
3926
  }
3311
3927
  }
3312
- write(chunk) {
3313
- process.stdout.write(chunk);
3928
+ screenColumns() {
3929
+ if (this.headlessDisplay || this.displayDetached) {
3930
+ return this.relayColumns ?? process.stdout.columns ?? 80;
3931
+ }
3932
+ return process.stdout.columns ?? 80;
3314
3933
  }
3315
- markDirty = () => {
3316
- this.dirty = true;
3934
+ screenRows() {
3935
+ if (this.headlessDisplay || this.displayDetached) {
3936
+ return this.relayRows ?? process.stdout.rows ?? 24;
3937
+ }
3938
+ return process.stdout.rows ?? 24;
3939
+ }
3940
+ write(chunk) {
3941
+ const host = this.displayHost;
3942
+ if (host?.attached === true) {
3943
+ host.sendStdout(chunk);
3944
+ if (this.displayDetached)
3945
+ return;
3946
+ }
3947
+ else if (this.displayDetached) {
3948
+ return;
3949
+ }
3950
+ try {
3951
+ process.stdout.write(chunk);
3952
+ }
3953
+ catch (error) {
3954
+ if (isHangupErrno(error)) {
3955
+ void this.handleHangup();
3956
+ return;
3957
+ }
3958
+ throw error;
3959
+ }
3960
+ }
3961
+ async ensureDisplayHost() {
3962
+ if (this.displayHost !== undefined || this.disposed)
3963
+ return;
3964
+ const host = new DisplayHost(sessionSockPath(String(this.agent.id)), {
3965
+ onStdin: (bytes) => {
3966
+ if (this.disposed)
3967
+ return;
3968
+ this.handleData(bytes);
3969
+ },
3970
+ onResize: (columns, rows) => {
3971
+ const changed = this.relayColumns !== columns || this.relayRows !== rows;
3972
+ this.relayColumns = columns;
3973
+ this.relayRows = rows;
3974
+ if (this.displayHost?.attached === true && this.displayDetached) {
3975
+ this.attachRelayDisplay();
3976
+ return;
3977
+ }
3978
+ if (changed) {
3979
+ this.forceFullPaint = true;
3980
+ this.dirty = true;
3981
+ this.paint();
3982
+ }
3983
+ },
3984
+ onRtt: (rttMs) => {
3985
+ this.applyProbedRtt(rttMs);
3986
+ },
3987
+ onDetach: () => {
3988
+ if (this.disposed || this.hangingUp)
3989
+ return;
3990
+ void this.handleHangup();
3991
+ },
3992
+ onAttach: () => {
3993
+ if (this.disposed)
3994
+ return;
3995
+ if (this.relayColumns !== undefined && this.relayRows !== undefined) {
3996
+ this.attachRelayDisplay();
3997
+ }
3998
+ },
3999
+ });
4000
+ await host.listen();
4001
+ if (this.disposed) {
4002
+ await host.close();
4003
+ return;
4004
+ }
4005
+ this.displayHost = host;
4006
+ }
4007
+ /** Re-open DECSET and start painting to an attached Display relay. */
4008
+ attachRelayDisplay() {
4009
+ this.displayDetached = false;
4010
+ this.hangingUp = false;
4011
+ this.clearDetachedIdleTimer();
4012
+ this.lastActivity = Date.now();
4013
+ this.stalledWarningShown = false;
4014
+ this.lastPaintRows = [];
4015
+ this.lastChromeKey = '';
4016
+ this.lastTranscriptStart = -1;
4017
+ this.write(`${this.useAlternateScreen ? '\x1b[?1049h' : ''}\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l`);
4018
+ this.forceFullPaint = true;
4019
+ this.dirty = true;
4020
+ this.paint();
4021
+ this.startRenderTimer();
4022
+ void this.onReattach?.();
4023
+ }
4024
+ currentDisconnectPolicy() {
4025
+ return this.disconnectPolicy;
4026
+ }
4027
+ applyProbedRtt(rttMs) {
4028
+ const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
4029
+ this.paintLink = 'ssh';
4030
+ this.paintProbed = rttMs !== undefined;
4031
+ this.paintRttMs = rttMs;
4032
+ if (!(Number.isFinite(envOverride) && envOverride > 0)) {
4033
+ this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs });
4034
+ this.startRenderTimer();
4035
+ }
4036
+ this.markDirty();
4037
+ }
4038
+ markDirty = () => {
4039
+ this.dirty = true;
3317
4040
  };
4041
+ toolCardSummary(row) {
4042
+ const repeats = row.repeats ?? 1;
4043
+ const parts = [];
4044
+ if (row.summary !== '')
4045
+ parts.push(row.summary);
4046
+ if (repeats > 1)
4047
+ parts.push(t('tool.repeatCount', { count: repeats }));
4048
+ if (READ_TOOL_NAMES.has(row.name) && (row.totalChars !== undefined || row.totalLines !== undefined)) {
4049
+ const chars = row.totalChars ?? 0;
4050
+ const lines = row.totalLines ?? 0;
4051
+ parts.push(t('tool.readStats', { chars: formatTokens(chars), lines: String(lines) }));
4052
+ }
4053
+ return parts.join(' · ');
4054
+ }
4055
+ mergeIntoToolCard(previous, next) {
4056
+ previous.callId = next.callId;
4057
+ previous.name = next.name;
4058
+ previous.args = next.args;
4059
+ previous.title = next.title;
4060
+ previous.summary = next.summary;
4061
+ previous.status = 'running';
4062
+ previous.output = '';
4063
+ previous.exitCode = undefined;
4064
+ previous.signal = undefined;
4065
+ previous.repeats = (previous.repeats ?? 1) + 1;
4066
+ if (!this.replaying)
4067
+ previous.flipUntil = Date.now() + TOOL_FLIP_MS;
4068
+ }
3318
4069
  /** Append one transcript row, bounding memory on long sessions. */
3319
4070
  pushRow(row) {
3320
4071
  this.rows.push(row);
@@ -3331,19 +4082,31 @@ export class SshTui {
3331
4082
  /** The transcript rows that support per-row expand/collapse. */
3332
4083
  collapsibleRows() {
3333
4084
  const compact = this.isCompactView();
3334
- const rows = this.rows.filter((row) => {
3335
- if (compact && (row.kind === 'reasoning' || row.kind === 'prompt'))
3336
- return false;
3337
- return row.kind === 'reasoning'
3338
- || row.kind === 'tool'
3339
- || row.kind === 'subagent'
4085
+ if (compact) {
4086
+ const rows = this.rows.filter((row) => row.kind === 'subagent'
3340
4087
  || row.kind === 'plan'
3341
4088
  || row.kind === 'question'
3342
4089
  || row.kind === 'goal'
3343
- || row.kind === 'compaction'
3344
- || row.kind === 'prompt';
3345
- });
3346
- if (!compact && this.streaming !== undefined && this.streaming.reasoning !== '') {
4090
+ || row.kind === 'compaction');
4091
+ for (const burst of compactToolBursts(this.rows)) {
4092
+ const callAnchor = burst.groups.calls.at(-1);
4093
+ const editAnchor = burst.groups.edits.at(-1);
4094
+ if (callAnchor !== undefined)
4095
+ rows.push(callAnchor);
4096
+ if (editAnchor !== undefined)
4097
+ rows.push(editAnchor);
4098
+ }
4099
+ return rows;
4100
+ }
4101
+ const rows = this.rows.filter((row) => row.kind === 'reasoning'
4102
+ || row.kind === 'tool'
4103
+ || row.kind === 'subagent'
4104
+ || row.kind === 'plan'
4105
+ || row.kind === 'question'
4106
+ || row.kind === 'goal'
4107
+ || row.kind === 'compaction'
4108
+ || row.kind === 'prompt');
4109
+ if (this.streaming !== undefined && this.streaming.reasoning !== '') {
3347
4110
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
3348
4111
  rows.push(this.streamingReasoning);
3349
4112
  }
@@ -3353,26 +4116,27 @@ export class SshTui {
3353
4116
  return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
3354
4117
  }
3355
4118
  /**
3356
- * Codex wait card: shown while the turn is running. Thinking/reply streams
3357
- * feed the shimmer header; a live tool becomes the detail line.
4119
+ * Codex wait card: shown while the turn is running. The live thinking
4120
+ * stream feeds the shimmer header; a live tool becomes the detail rows.
4121
+ * While the reply itself is streaming, the transcript paints those tokens
4122
+ * and the card yields (Codex hides the status row once output commits).
3358
4123
  */
3359
4124
  waitCardVisible() {
3360
4125
  if (this.agent.status !== 'running')
3361
4126
  return false;
4127
+ if (this.streaming?.text)
4128
+ return false;
3362
4129
  if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
3363
4130
  return false;
3364
4131
  if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
3365
4132
  return false;
3366
4133
  return true;
3367
4134
  }
3368
- beginWait(prompt) {
4135
+ beginWait() {
3369
4136
  this.waitStartedAt = Date.now();
3370
- const trimmed = prompt?.replace(/\s+/gu, ' ').trim();
3371
- this.waitPrompt = trimmed === undefined || trimmed === '' ? this.waitPrompt : trimmed;
3372
4137
  }
3373
4138
  endWait() {
3374
4139
  this.waitStartedAt = undefined;
3375
- this.waitPrompt = undefined;
3376
4140
  }
3377
4141
  waitCardSource() {
3378
4142
  const liveTool = this.rows.findLast((row) => row.kind === 'tool' && (row.status === undefined || row.status === 'running'));
@@ -3383,8 +4147,6 @@ export class SshTui {
3383
4147
  ? {}
3384
4148
  : { toolTitle: liveSub.label, toolSummary: liveSub.lastActivity }),
3385
4149
  ...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
3386
- ...(this.streaming?.text ? { reply: this.streaming.text } : {}),
3387
- ...(this.waitPrompt === undefined ? {} : { prompt: this.waitPrompt }),
3388
4150
  };
3389
4151
  }
3390
4152
  planShouldDefaultExpand(plan) {
@@ -3684,8 +4446,8 @@ export class SshTui {
3684
4446
  }
3685
4447
  toggleCard(target) {
3686
4448
  if (target.kind === 'tool' && !target.expanded) {
3687
- const width = Math.max(10, process.stdout.columns || 80);
3688
- const height = Math.max(6, process.stdout.rows || 24);
4449
+ const width = Math.max(10, this.screenColumns());
4450
+ const height = Math.max(6, this.screenRows());
3689
4451
  const body = toolBodyLines(target, Number.MAX_SAFE_INTEGER);
3690
4452
  const bodyRows = wrappedToolBodyLineCount(body, width);
3691
4453
  if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
@@ -3711,8 +4473,8 @@ export class SshTui {
3711
4473
  this.focusedRow = null;
3712
4474
  }
3713
4475
  else {
3714
- const width = Math.max(10, process.stdout.columns || 80);
3715
- const height = Math.max(6, process.stdout.rows || 24);
4476
+ const width = Math.max(10, this.screenColumns());
4477
+ const height = Math.max(6, this.screenRows());
3716
4478
  const workspace = this.workspaceRowsFor(width, height);
3717
4479
  for (const row of rows) {
3718
4480
  if (row.kind === 'tool') {
@@ -3736,8 +4498,8 @@ export class SshTui {
3736
4498
  if (row === undefined)
3737
4499
  return;
3738
4500
  if (row.kind === 'tool') {
3739
- const width = Math.max(10, process.stdout.columns || 80);
3740
- const height = Math.max(6, process.stdout.rows || 24);
4501
+ const width = Math.max(10, this.screenColumns());
4502
+ const height = Math.max(6, this.screenRows());
3741
4503
  const body = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
3742
4504
  const bodyRows = wrappedToolBodyLineCount(body, width);
3743
4505
  if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
@@ -3823,8 +4585,8 @@ export class SshTui {
3823
4585
  paint = () => {
3824
4586
  if (this.exiting)
3825
4587
  return;
3826
- const width = Math.max(10, process.stdout.columns || 80);
3827
- const height = Math.max(6, process.stdout.rows || 24);
4588
+ const width = Math.max(10, this.screenColumns());
4589
+ const height = Math.max(6, this.screenRows());
3828
4590
  if (this.dialog?.kind === 'inspect') {
3829
4591
  this.paintInspectOverlay(width, height);
3830
4592
  return;
@@ -3850,24 +4612,21 @@ export class SshTui {
3850
4612
  }
3851
4613
  };
3852
4614
  const compact = this.isCompactView();
3853
- const compactGroups = compact
3854
- ? compactToolGroups(this.rows.filter((row) => row.kind === 'tool'))
3855
- : undefined;
3856
- const compactEditCard = compactGroups?.edits[0];
3857
- const compactCallCard = compactGroups?.calls[0];
4615
+ const compactBursts = compact ? compactToolBursts(this.rows) : [];
4616
+ const compactBurstByReply = new Map();
4617
+ for (const burst of compactBursts) {
4618
+ if (burst.after !== undefined)
4619
+ compactBurstByReply.set(burst.after, burst);
4620
+ }
4621
+ let paintedLeadingCompact = false;
3858
4622
  for (const row of this.rows) {
3859
- if (compact && (row.kind === 'reasoning' || row.kind === 'prompt'))
3860
- continue;
3861
- if (compact && compactGroups !== undefined && row.kind === 'tool') {
3862
- if (row === compactEditCard) {
3863
- this.paintCompactSummary(addDisplay, row, 'edits', compactGroups, width);
3864
- continue;
3865
- }
3866
- if (row === compactCallCard) {
3867
- this.paintCompactSummary(addDisplay, row, 'calls', compactGroups, width);
3868
- continue;
3869
- }
4623
+ if (compact && (row.kind === 'reasoning' || row.kind === 'prompt' || row.kind === 'tool'))
3870
4624
  continue;
4625
+ if (compact && !paintedLeadingCompact && (row.kind === 'assistant' || row.kind === 'user')) {
4626
+ const leading = compactBursts.find(burst => burst.after === undefined);
4627
+ if (leading !== undefined)
4628
+ this.paintCompactBurst(addDisplay, leading.groups, width);
4629
+ paintedLeadingCompact = true;
3871
4630
  }
3872
4631
  if (row.kind === 'brand-logo') {
3873
4632
  const variant = DEEPSEEK_LOGO_VARIANTS.find(candidate => candidate.width <= width - 2)
@@ -3903,12 +4662,16 @@ export class SshTui {
3903
4662
  focused,
3904
4663
  expanded: row.expanded,
3905
4664
  title: toolTitle(row.name) || row.title,
3906
- summary: row.summary,
4665
+ summary: this.toolCardSummary(row),
3907
4666
  status: row.status,
3908
4667
  command: row.command,
3909
4668
  signal: row.signal,
3910
4669
  exitCode: row.exitCode,
3911
4670
  spinner: running ? ` ${this.spinnerFrame()}` : '',
4671
+ flipping: row.flipUntil !== undefined && Date.now() < row.flipUntil,
4672
+ ...(row.diff !== undefined && row.diff.length > 0
4673
+ ? { diffStat: countDiffAddDel(row.diff) }
4674
+ : {}),
3912
4675
  });
3913
4676
  const headerSegments = this.color ? header.segments : [];
3914
4677
  if (!row.expanded) {
@@ -4076,6 +4839,13 @@ export class SshTui {
4076
4839
  continue;
4077
4840
  }
4078
4841
  pushRow(row.kind, row.text, row);
4842
+ if (compact && row.kind === 'assistant') {
4843
+ const burst = compactBurstByReply.get(row);
4844
+ const lastAssistant = this.rows.findLast((item) => item.kind === 'assistant');
4845
+ if (burst !== undefined && (row !== lastAssistant || this.streaming === undefined)) {
4846
+ this.paintCompactBurst(addDisplay, burst.groups, width);
4847
+ }
4848
+ }
4079
4849
  }
4080
4850
  if (this.streaming !== undefined) {
4081
4851
  if (!compact && this.showReasoning && this.streaming.reasoning !== '') {
@@ -4109,6 +4879,18 @@ export class SshTui {
4109
4879
  }
4110
4880
  }
4111
4881
  }
4882
+ if (compact) {
4883
+ const lastAssistant = this.rows.findLast((row) => row.kind === 'assistant');
4884
+ if (this.streaming !== undefined) {
4885
+ const openBurst = compactBursts.find(burst => burst.after === lastAssistant);
4886
+ if (openBurst !== undefined)
4887
+ this.paintCompactBurst(addDisplay, openBurst.groups, width);
4888
+ }
4889
+ const leading = compactBursts.find(burst => burst.after === undefined);
4890
+ if (leading !== undefined && !paintedLeadingCompact) {
4891
+ this.paintCompactBurst(addDisplay, leading.groups, width);
4892
+ }
4893
+ }
4112
4894
  if (this.waitCardVisible()) {
4113
4895
  const copy = waitCardCopy(this.waitCardSource());
4114
4896
  const started = this.waitStartedAt ?? Date.now();
@@ -4119,8 +4901,8 @@ export class SshTui {
4119
4901
  ? `${spinner} ${shimmerText(copy.header, Date.now(), true)} ${this.styleLine('system', hint)}`
4120
4902
  : `${spinner} ${copy.header} ${hint}`;
4121
4903
  addDisplay(header);
4122
- if (copy.detail !== undefined && copy.detail !== '') {
4123
- addDisplay(this.styleLine('system', ` └ ${copy.detail}`));
4904
+ for (const line of wrapWaitDetails(copy.detail ?? '', width)) {
4905
+ addDisplay(this.styleLine('system', line));
4124
4906
  }
4125
4907
  }
4126
4908
  const dialogLines = [];
@@ -4137,32 +4919,47 @@ export class SshTui {
4137
4919
  else if (this.dialog.kind === 'onboarding') {
4138
4920
  const ob = this.onboarding;
4139
4921
  if (ob !== undefined) {
4140
- const template = providerTemplates()[ob.providerType];
4922
+ const template = onboardTemplate(ob);
4141
4923
  const providerLabel = `${template.label}${template.defaultBaseUrl === '' ? '' : `(${template.defaultBaseUrl})`}`;
4142
4924
  switch (ob.step) {
4143
- case 'provider':
4925
+ case 'provider': {
4926
+ const options = this.mergedProviderEntries(ob);
4144
4927
  addDialog(t('onboard.title'));
4145
- addDialog(t('onboard.opt1'));
4146
- addDialog(t('onboard.opt2'));
4147
- addDialog(t('onboard.opt3'));
4148
- addDialog(t('onboard.opt4'));
4149
- addDialog(t('onboard.opt5'));
4928
+ if (options.length === 0) {
4929
+ addDialog(t('onboard.catalogEmpty'));
4930
+ break;
4931
+ }
4932
+ const start = pickerWindowStart(ob.providerCursor, options.length);
4933
+ const end = Math.min(options.length, start + PICKER_WINDOW);
4934
+ if (start > 0)
4935
+ addDialog(` ↑ 还有 ${start} 项`);
4936
+ for (let index = start; index < end; index += 1) {
4937
+ const option = options[index];
4938
+ if (option === undefined)
4939
+ continue;
4940
+ const focused = index === ob.providerCursor ? '›' : ' ';
4941
+ addDialog(` ${focused} ○ ${option.label}${option.detail === '' ? '' : ` — ${option.detail}`}`);
4942
+ }
4943
+ if (end < options.length)
4944
+ addDialog(` ↓ 还有 ${options.length - end} 项`);
4945
+ if (this.input.trim() !== '')
4946
+ addDialog(t('onboard.catalogHint', { count: options.length }));
4150
4947
  addDialog(t('onboard.pickHint'));
4151
4948
  break;
4949
+ }
4152
4950
  case 'id':
4153
4951
  addDialog(t('onboard.providerLine', { label: providerLabel }));
4154
4952
  addDialog(t('onboard.idPrompt'));
4155
4953
  addDialog(t('onboard.default', { value: template.defaultId }));
4156
4954
  addDialog(t('onboard.enterEsc'));
4157
4955
  break;
4158
- case 'key':
4159
- addDialog(t('onboard.providerLine', { label: providerLabel }));
4160
- addDialog(t('onboard.keyPrompt'));
4161
- addDialog(t('onboard.enterEsc'));
4162
- break;
4163
4956
  case 'base-url':
4164
4957
  addDialog(t('onboard.providerLine', { label: providerLabel }));
4165
- addDialog(t('onboard.basePrompt', { fallback: template.defaultBaseUrl || t('onboard.baseFallback') }));
4958
+ addDialog(t('onboard.basePrompt', {
4959
+ fallback: template.defaultBaseUrl !== ''
4960
+ ? template.defaultBaseUrl
4961
+ : ob.providerType === 'catalog' ? t('onboard.baseFallbackCatalog') : t('onboard.baseFallback'),
4962
+ }));
4166
4963
  addDialog(t('onboard.enterEsc'));
4167
4964
  break;
4168
4965
  case 'models':
@@ -4173,6 +4970,8 @@ export class SshTui {
4173
4970
  : t('onboard.default', { value: template.defaultModels.join(', ') }));
4174
4971
  if (template.api !== undefined)
4175
4972
  addDialog(t('onboard.ctrlF'));
4973
+ if (ob.providerType === 'catalog')
4974
+ addDialog(t('onboard.modelsCatalogHint'));
4176
4975
  addDialog(t('onboard.enterEsc'));
4177
4976
  break;
4178
4977
  case 'confirm':
@@ -4180,7 +4979,9 @@ export class SshTui {
4180
4979
  addDialog(t('onboard.confirmProvider', { label: providerLabel }));
4181
4980
  addDialog(` Provider ID: ${ob.providerId}`);
4182
4981
  addDialog(t('onboard.confirmBase', { url: ob.baseUrl === '' ? (template.defaultBaseUrl || t('onboard.defaultParen')) : ob.baseUrl }));
4183
- addDialog(t('onboard.confirmApi', { api: template.api ?? 'deepseek-official' }));
4982
+ addDialog(t('onboard.confirmApi', {
4983
+ api: template.api ?? (ob.providerType === 'catalog' ? t('onboard.apiCatalog') : 'deepseek-official'),
4984
+ }));
4184
4985
  addDialog(t('onboard.confirmModels', { list: formatModelList(ob.models, 8) }));
4185
4986
  addDialog(t('onboard.confirmKey', {
4186
4987
  head: sliceCodePoints(ob.key, 6),
@@ -4214,17 +5015,27 @@ export class SshTui {
4214
5015
  }
4215
5016
  const options = d.question.options ?? [];
4216
5017
  const approve = d.question.intent?.approve;
4217
- for (const [index, option] of options.entries()) {
5018
+ const start = pickerWindowStart(d.cursor, options.length);
5019
+ const end = Math.min(options.length, start + PICKER_WINDOW);
5020
+ if (start > 0)
5021
+ addDialog(` ↑ 还有 ${start} 项`);
5022
+ for (let index = start; index < end; index += 1) {
5023
+ const option = options[index];
5024
+ if (option === undefined)
5025
+ continue;
4218
5026
  const marker = d.selected.has(index) ? '●' : '○';
4219
- const key = QUESTION_OPTION_KEYS[index] ?? '?';
5027
+ const key = QUESTION_OPTION_KEYS[index] ?? '';
5028
+ const focused = index === d.cursor ? '›' : ' ';
4220
5029
  const recommended = option.label === approve ? '(推荐)' : '';
4221
5030
  const extra = option.description === undefined ? '' : ` — ${option.description}`;
4222
- addDialog(` ${key} ${marker} ${option.label}${recommended}${extra}`);
5031
+ addDialog(` ${focused}${key} ${marker} ${option.label}${recommended}${extra}`);
4223
5032
  }
5033
+ if (end < options.length)
5034
+ addDialog(` ↓ 还有 ${options.length - end} 项`);
4224
5035
  if (options.length === 0) {
4225
5036
  addDialog(' (自由输入:在下方输入后按 Enter)');
4226
5037
  }
4227
- addDialog(` ${d.question.multiSelect === true ? '数字/字母切换,Enter 提交' : '数字/字母选择,Enter 提交'},Esc 取消`);
5038
+ addDialog(` ${d.question.multiSelect === true ? '↑/↓ 或数字/字母切换,Enter 提交' : '↑/↓ 或数字/字母选择,Enter 提交'},Esc 取消`);
4228
5039
  }
4229
5040
  }
4230
5041
  const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
@@ -4240,13 +5051,24 @@ export class SshTui {
4240
5051
  this.suggestionIndex = Math.max(0, this.commandSuggestions.length - 1);
4241
5052
  }
4242
5053
  const suggestionLines = [];
4243
- for (const [index, command] of this.commandSuggestions.entries()) {
5054
+ const suggestionStart = pickerWindowStart(this.suggestionIndex, this.commandSuggestions.length);
5055
+ const suggestionEnd = Math.min(this.commandSuggestions.length, suggestionStart + PICKER_WINDOW);
5056
+ if (suggestionStart > 0) {
5057
+ suggestionLines.push(this.styleLine('system', fitLine(` ${t('suggest.moreAbove', { count: suggestionStart })}`)));
5058
+ }
5059
+ for (let index = suggestionStart; index < suggestionEnd; index += 1) {
5060
+ const command = this.commandSuggestions[index];
5061
+ if (command === undefined)
5062
+ continue;
4244
5063
  const marker = index === this.suggestionIndex ? '›' : ' ';
4245
5064
  const line = ` ${marker} /${command.name.padEnd(14)} ${command.description}${command.local ? '' : ' (dsh)'}`;
4246
5065
  suggestionLines.push(index === this.suggestionIndex && this.color
4247
5066
  ? `\x1b[7m${fitLine(line)}\x1b[27m`
4248
5067
  : this.styleLine('system', fitLine(line)));
4249
5068
  }
5069
+ if (suggestionEnd < this.commandSuggestions.length) {
5070
+ suggestionLines.push(this.styleLine('system', fitLine(` ${t('suggest.moreBelow', { count: this.commandSuggestions.length - suggestionEnd })}`)));
5071
+ }
4250
5072
  const promptPlain = this.color ? '❯ ' : '> ';
4251
5073
  const prompt = this.color ? `\x1b[36m${promptPlain.trimEnd()}\x1b[0m ` : promptPlain;
4252
5074
  const promptWidth = displayWidth(promptPlain);
@@ -4292,7 +5114,7 @@ export class SshTui {
4292
5114
  : [];
4293
5115
  const inputDivider = this.styleLine('system', repeatToWidth('─', width));
4294
5116
  const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
4295
- const available = Math.max(1, height - reserved - dialogLines.length);
5117
+ const available = Math.max(0, height - reserved - dialogLines.length);
4296
5118
  const maxOffset = Math.max(0, display.length - available);
4297
5119
  const reveal = this.pendingReveal;
4298
5120
  if (reveal !== undefined) {
@@ -4350,11 +5172,14 @@ export class SshTui {
4350
5172
  const idleMs = Date.now() - this.lastActivity;
4351
5173
  const livePlan = this.findLivePlanRow();
4352
5174
  const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
5175
+ const current = this.selectionRef?.current;
5176
+ const provider = this.currentProviderId();
4353
5177
  const quotaWindow = this.quotaSnapshot === undefined ? undefined : tightestQuotaWindow(this.quotaSnapshot);
5178
+ const balanceText = this.balanceSnapshot !== undefined && this.balanceSnapshot.provider === provider
5179
+ ? formatFooterBalance(this.balanceSnapshot)
5180
+ : undefined;
4354
5181
  const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
4355
5182
  const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
4356
- const current = this.selectionRef?.current;
4357
- const provider = this.currentProviderId();
4358
5183
  const parentModel = current?.model ?? this.agent.options.model ?? '';
4359
5184
  const sub = this.subagentSelection.current;
4360
5185
  const footer = {
@@ -4382,6 +5207,7 @@ export class SshTui {
4382
5207
  ...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
4383
5208
  ? {}
4384
5209
  : { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
5210
+ ...(balanceText === undefined ? {} : { balanceText }),
4385
5211
  ...(this.searchHits.length > 0 && this.searchIndex >= 0
4386
5212
  ? { search: { index: this.searchIndex, total: this.searchHits.length } }
4387
5213
  : {}),
@@ -4432,6 +5258,7 @@ export class SshTui {
4432
5258
  this.suggestionIndex,
4433
5259
  this.activeSubagents.size,
4434
5260
  this.dialog?.kind ?? '',
5261
+ this.dialog?.kind === 'questions' ? String(this.dialog.cursor) : '',
4435
5262
  planDockLines.join('\n'),
4436
5263
  String(chromeStart),
4437
5264
  ].join('\x1f');
@@ -4483,19 +5310,34 @@ export class SshTui {
4483
5310
  if (!input.startsWith('/'))
4484
5311
  return [];
4485
5312
  const prefix = input.slice(1).toLowerCase();
4486
- const dsh = (this.ctx.get('commands')?.list(this.agent) ?? []).map(command => ({
4487
- name: command.name,
4488
- description: command.input?.images === true ? `${command.description}(可附图)` : command.description,
4489
- local: false,
4490
- }));
4491
- const all = [
4492
- ...localizedCommands().map(command => ({ name: command.name, description: command.description, local: true })),
4493
- ...dsh,
4494
- ];
5313
+ const local = localizedCommands()
5314
+ .filter(command => command.name !== 'dialog-test' && (prefix !== '' || command.aliasOf === undefined))
5315
+ .map(command => ({ name: command.name, description: command.description, local: true }));
5316
+ const seen = new Set(local.map(command => command.name));
5317
+ const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
5318
+ .filter(command => !seen.has(command.name))
5319
+ .map(command => {
5320
+ const descKey = `cmd.${command.name}`;
5321
+ const desc = t(descKey, undefined, command.description);
5322
+ return {
5323
+ name: command.name,
5324
+ description: command.input?.images === true
5325
+ ? t('cmd.withImagesSuffix', { desc })
5326
+ : desc,
5327
+ local: false,
5328
+ };
5329
+ });
5330
+ const all = [...local, ...dsh];
4495
5331
  const filtered = prefix === ''
4496
5332
  ? all
4497
5333
  : all.filter(command => command.name.startsWith(prefix) || command.name.includes(prefix));
4498
- return filtered.slice(0, 12);
5334
+ if (prefix === '')
5335
+ return filtered;
5336
+ return filtered.sort((a, b) => {
5337
+ const aStart = a.name.startsWith(prefix) ? 0 : 1;
5338
+ const bStart = b.name.startsWith(prefix) ? 0 : 1;
5339
+ return aStart - bStart;
5340
+ });
4499
5341
  }
4500
5342
  suggestionsVisible() {
4501
5343
  return this.commandSuggestions.length > 0 && this.dialog === undefined;
@@ -4598,11 +5440,13 @@ export class SshTui {
4598
5440
  const disabled = process.env.DSH_TUI_NO_BELL === '1' || process.env.DSH_TUI_NO_BELL === 'true';
4599
5441
  if (disabled)
4600
5442
  return;
4601
- process.stdout.write('\x07');
5443
+ this.write('\x07');
4602
5444
  }
4603
5445
  render = () => {
4604
5446
  if (!this.dirty || this.exiting)
4605
5447
  return;
5448
+ if ((this.displayDetached || this.headlessDisplay) && this.displayHost?.attached !== true)
5449
+ return;
4606
5450
  if (this.agent.status === 'running'
4607
5451
  && Date.now() - this.lastActivity > STALL_WARNING_MS
4608
5452
  && !this.stalledWarningShown
@@ -4680,7 +5524,7 @@ export class SshTui {
4680
5524
  if (sourceKind === 'user') {
4681
5525
  this.pushRow({ kind: 'user', text: `❯ ${text}` });
4682
5526
  if (!this.replaying)
4683
- this.beginWait(text);
5527
+ this.beginWait();
4684
5528
  }
4685
5529
  else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
4686
5530
  this.pushPromptInjection(text, source.plugin);
@@ -4776,21 +5620,34 @@ export class SshTui {
4776
5620
  this.pendingToolTimes.set(String(event.data.callId), event.time);
4777
5621
  if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
4778
5622
  const present = presentToolCall(event.data.name, event.data.arguments);
4779
- const row = {
4780
- kind: 'tool',
4781
- callId: event.data.callId,
4782
- name: event.data.name,
4783
- args: event.data.arguments,
4784
- status: 'running',
4785
- output: '',
4786
- title: present.title,
4787
- summary: present.summary,
4788
- ...present.command === undefined ? {} : { command: present.command },
4789
- ...present.cwd === undefined ? {} : { cwd: present.cwd },
4790
- ...present.diff === undefined ? {} : { diff: present.diff },
4791
- expanded: false,
4792
- };
4793
- this.pushRow(row);
5623
+ const previous = this.rows.findLast((candidate) => candidate.kind === 'tool');
5624
+ if (canMergeToolCall(previous, { name: event.data.name, args: event.data.arguments })) {
5625
+ this.mergeIntoToolCard(previous, {
5626
+ callId: event.data.callId,
5627
+ name: event.data.name,
5628
+ args: event.data.arguments,
5629
+ title: present.title,
5630
+ summary: present.summary,
5631
+ ...present.diff === undefined ? {} : { diff: present.diff },
5632
+ });
5633
+ }
5634
+ else {
5635
+ const row = {
5636
+ kind: 'tool',
5637
+ callId: event.data.callId,
5638
+ name: event.data.name,
5639
+ args: event.data.arguments,
5640
+ status: 'running',
5641
+ output: '',
5642
+ title: present.title,
5643
+ summary: present.summary,
5644
+ ...present.command === undefined ? {} : { command: present.command },
5645
+ ...present.cwd === undefined ? {} : { cwd: present.cwd },
5646
+ ...present.diff === undefined ? {} : { diff: present.diff },
5647
+ expanded: false,
5648
+ };
5649
+ this.pushRow(row);
5650
+ }
4794
5651
  }
4795
5652
  if (event.data.name === 'exit_plan_mode') {
4796
5653
  const markdown = planMarkdownFromArgs(event.data.arguments);
@@ -4813,7 +5670,9 @@ export class SshTui {
4813
5670
  if (row !== undefined) {
4814
5671
  const metaDiffs = diffMetaDiffs(event.data.meta);
4815
5672
  if (metaDiffs !== null) {
4816
- row.diff = metaDiffs;
5673
+ row.diff = (row.repeats ?? 1) > 1 && row.diff !== undefined && row.diff.length > 0
5674
+ ? [...row.diff, ...metaDiffs]
5675
+ : metaDiffs;
4817
5676
  }
4818
5677
  const isShell = SHELL_TOOL_NAMES.has(row.name);
4819
5678
  if (isShell) {
@@ -4831,6 +5690,10 @@ export class SshTui {
4831
5690
  || event.data.message.content[0]?.isError === true
4832
5691
  || (isShell && ((row.exitCode !== undefined && row.exitCode !== 0) || row.signal !== undefined));
4833
5692
  row.status = failed ? 'error' : 'ok';
5693
+ if (READ_TOOL_NAMES.has(row.name)) {
5694
+ row.totalChars = (row.totalChars ?? 0) + row.output.length;
5695
+ row.totalLines = (row.totalLines ?? 0) + countOutputLines(row.output);
5696
+ }
4834
5697
  }
4835
5698
  else {
4836
5699
  const present = presentToolCall(event.data.message.source.callId, '');
@@ -4882,6 +5745,14 @@ export class SshTui {
4882
5745
  this.markDirty();
4883
5746
  break;
4884
5747
  }
5748
+ case 'sandbox/mode':
5749
+ this.hostSandboxMode = String(event.data.mode ?? '');
5750
+ break;
5751
+ case 'approval/policy':
5752
+ this.hostApprovalPolicy = String(event.data.policy ?? '');
5753
+ if (this.hostApprovalPolicy === 'never')
5754
+ this.warnApprovalMismatch();
5755
+ break;
4885
5756
  case 'turn/start':
4886
5757
  this.stalledWarningShown = false;
4887
5758
  this.llmRetry = undefined;
@@ -5161,19 +6032,48 @@ export class SshTui {
5161
6032
  });
5162
6033
  this.markDirty();
5163
6034
  }
6035
+ formatCommandText(text) {
6036
+ const permMatch = text.match(/^current preset (\S+) \(available: (.+)\)$/);
6037
+ if (permMatch) {
6038
+ const current = permMatch[1] ?? '';
6039
+ const avail = permMatch[2] ?? '';
6040
+ const localize = (name) => {
6041
+ const key = `preset.${name}`;
6042
+ const trans = t(key);
6043
+ return trans !== key ? t('preset.named', { name, label: trans }) : name;
6044
+ };
6045
+ const currentLabel = localize(current);
6046
+ const availLabel = avail.split(', ').map(s => localize(s.trim())).join(t('list.sep'));
6047
+ return t('permission.currentInfo', { current: currentLabel, available: availLabel });
6048
+ }
6049
+ const permSwitched = text.match(/^preset (\S+)$/);
6050
+ if (permSwitched) {
6051
+ const name = permSwitched[1] ?? '';
6052
+ const key = `preset.${name}`;
6053
+ const trans = t(key);
6054
+ const label = trans !== key ? t('preset.named', { name, label: trans }) : name;
6055
+ return t('permission.switched', { preset: label });
6056
+ }
6057
+ return text;
6058
+ }
5164
6059
  handleCommandDone(data) {
5165
6060
  const payload = data !== null && typeof data === 'object' ? data : {};
6061
+ const commandId = typeof payload.commandId === 'string' ? payload.commandId : '';
6062
+ if (commandId !== '')
6063
+ this.seenCommandDoneIds.add(commandId);
5166
6064
  const kind = typeof payload.kind === 'string' ? payload.kind : '';
5167
6065
  const text = typeof payload.text === 'string' ? payload.text.trim() : '';
5168
6066
  if (kind === 'error') {
5169
- this.pushRow({ kind: 'error', text: text === '' ? '命令失败。' : text });
6067
+ const errText = this.formatCommandText(text);
6068
+ this.pushRow({ kind: 'error', text: errText === '' ? t('command.failed') : errText });
5170
6069
  if (this.status.startsWith('压缩'))
5171
6070
  this.status = this.agent.status === 'running' ? 'running' : 'idle';
5172
6071
  this.markDirty();
5173
6072
  return;
5174
6073
  }
5175
- if (text !== '')
5176
- this.pushRow({ kind: 'system', text });
6074
+ if (text !== '') {
6075
+ this.pushRow({ kind: 'system', text: this.formatCommandText(text) });
6076
+ }
5177
6077
  this.markDirty();
5178
6078
  }
5179
6079
  handleGoalChange(data) {
@@ -5369,7 +6269,170 @@ export class SshTui {
5369
6269
  this.markDirty();
5370
6270
  };
5371
6271
  // ── approval and questions ──────────────────────────────────────────────
6272
+ hasLiveDisplay() {
6273
+ if (this.disposed || this.exiting)
6274
+ return false;
6275
+ if (this.headlessDisplay || this.displayDetached)
6276
+ return this.displayHost?.attached === true;
6277
+ return true;
6278
+ }
6279
+ waitForLiveDisplay(signal) {
6280
+ if (this.hasLiveDisplay())
6281
+ return Promise.resolve();
6282
+ return new Promise((resolve, reject) => {
6283
+ let settled = false;
6284
+ const finish = (ok) => {
6285
+ if (settled)
6286
+ return;
6287
+ settled = true;
6288
+ signal?.removeEventListener('abort', onAbort);
6289
+ clearInterval(timer);
6290
+ if (ok)
6291
+ resolve();
6292
+ else
6293
+ reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
6294
+ };
6295
+ const onAbort = () => { finish(false); };
6296
+ signal?.addEventListener('abort', onAbort, { once: true });
6297
+ const timer = setInterval(() => {
6298
+ if (this.disposed || this.exiting) {
6299
+ finish(false);
6300
+ return;
6301
+ }
6302
+ if (this.hasLiveDisplay())
6303
+ finish(true);
6304
+ }, 200);
6305
+ timer.unref?.();
6306
+ });
6307
+ }
6308
+ /**
6309
+ * Auto mode rides the approval waterfall: when the host approval policy is
6310
+ * `never` no request is ever produced, so the classifier silently does
6311
+ * nothing. Say so instead of letting the user believe a guard is active.
6312
+ */
6313
+ warnApprovalMismatch() {
6314
+ if (this.autoApprovalMode !== 'auto' || this.approvalMismatchWarned)
6315
+ return;
6316
+ if (this.hostApprovalPolicy !== 'never')
6317
+ return;
6318
+ this.approvalMismatchWarned = true;
6319
+ this.pushRow({ kind: 'system', text: t('approval.mismatchNever') });
6320
+ this.markDirty();
6321
+ }
6322
+ /**
6323
+ * AI review for rule-table `ask` outcomes: one shot at the subagent
6324
+ * model route with a compact, injection-fenced context. Returns
6325
+ * 'allow' | 'deny', or undefined when the reviewer is unavailable or its
6326
+ * output was unusable (caller falls back to prompt/reject).
6327
+ */
6328
+ async reviewUnknownWithModel(request, command) {
6329
+ const llm = this.ctx.get('llm');
6330
+ if (llm === undefined)
6331
+ return undefined;
6332
+ const selection = this.subagentSelection.current;
6333
+ const parentProvider = this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
6334
+ const provider = selection.provider ?? parentProvider;
6335
+ const model = subagentModelMatchesProvider(provider, selection.model)
6336
+ ? selection.model
6337
+ : defaultSubagentModelForProvider(provider, [], this.selectionRef?.current?.model);
6338
+ // 最近模型输出/思考(≤2 段)与最新用户消息(≤400 字)
6339
+ const segments = [];
6340
+ for (let i = this.rows.length - 1; i >= 0 && segments.length < 2; i -= 1) {
6341
+ const row = this.rows[i];
6342
+ if (row !== undefined && (row.kind === 'assistant' || row.kind === 'reasoning') && row.text.trim() !== '') {
6343
+ segments.unshift(row.text);
6344
+ }
6345
+ }
6346
+ let userText = '';
6347
+ for (let i = this.rows.length - 1; i >= 0; i -= 1) {
6348
+ const row = this.rows[i];
6349
+ if (row !== undefined && row.kind === 'user') {
6350
+ userText = row.text.replace(/^❯\s*/u, '');
6351
+ break;
6352
+ }
6353
+ }
6354
+ const signals = [request.signal, AbortSignal.timeout(15_000)].filter(s => s !== undefined);
6355
+ const signal = signals.length > 0 ? AbortSignal.any(signals) : undefined;
6356
+ const options = {
6357
+ provider,
6358
+ model,
6359
+ messages: [createUserMessage({
6360
+ content: [{ type: 'text', text: buildReviewUserMessage({
6361
+ userText,
6362
+ segments,
6363
+ toolName: request.toolName,
6364
+ command: command ?? `(无命令参数,工具:${request.toolName})`,
6365
+ }) }],
6366
+ source: { kind: 'plugin', plugin: 'dsh-ssh-tui' },
6367
+ })],
6368
+ system: REVIEW_SYSTEM_PROMPT,
6369
+ maxTokens: 200,
6370
+ sessionId: this.agent.session.id,
6371
+ signal,
6372
+ };
6373
+ this.aiReviewCount += 1;
6374
+ let text = '';
6375
+ for await (const chunk of llm.stream(options)) {
6376
+ if (chunk.type === 'text-delta')
6377
+ text += chunk.text;
6378
+ }
6379
+ const verdict = parseReviewOutput(text);
6380
+ if (verdict === undefined)
6381
+ return undefined;
6382
+ this.pushRow({
6383
+ kind: 'system',
6384
+ text: t('approval.reviewRow', {
6385
+ verdict: verdict.approved ? t('approval.reviewApproved') : t('approval.reviewRejected'),
6386
+ risk: verdict.risk,
6387
+ authorization: verdict.authorization,
6388
+ reason: verdict.reason,
6389
+ }),
6390
+ });
6391
+ this.markDirty();
6392
+ return verdict.approved ? 'allow' : 'deny';
6393
+ }
5372
6394
  handleApproval = async (request, _next) => {
6395
+ // Auto mode classifies BEFORE waiting for a display, Codex-style: allow
6396
+ // shapes approve, danger shapes REJECT (the model reads the rejection and
6397
+ // adapts instead of paging the human), unknown shapes ask only while a
6398
+ // human is attached — detached turns reject so they complete instead of
6399
+ // stalling toward the idle kill.
6400
+ if (this.autoApprovalMode === 'auto') {
6401
+ const row = request.callId === undefined
6402
+ ? undefined
6403
+ : this.rows.findLast((candidate) => candidate.kind === 'tool' && candidate.callId === request.callId);
6404
+ const command = row === undefined ? undefined : commandFromArgs(row.name, row.args);
6405
+ const decision = classifyApproval(request.toolName, command);
6406
+ if (decision === 'allow') {
6407
+ this.autoAllowedCount += 1;
6408
+ return 'allowed-once';
6409
+ }
6410
+ if (decision === 'deny') {
6411
+ this.autoDeniedCount += 1;
6412
+ return 'rejected';
6413
+ }
6414
+ // Unknown shape: the rule table cannot judge it — hand it to the
6415
+ // subagent-configured model with compact context (AI review). Without
6416
+ // a display there is nobody to fall back on, so unreviewable asks
6417
+ // reject and the turn completes instead of stalling.
6418
+ const reviewed = await this.reviewUnknownWithModel(request, command);
6419
+ if (reviewed === 'allow') {
6420
+ this.autoAllowedCount += 1;
6421
+ return 'allowed-once';
6422
+ }
6423
+ if (reviewed === 'deny' || !this.hasLiveDisplay()) {
6424
+ this.autoDeniedCount += 1;
6425
+ return 'rejected';
6426
+ }
6427
+ }
6428
+ if (!this.hasLiveDisplay()) {
6429
+ try {
6430
+ await this.waitForLiveDisplay(request.signal);
6431
+ }
6432
+ catch {
6433
+ return 'cancelled';
6434
+ }
6435
+ }
5373
6436
  const agentLabel = request.agent.id === this.agent.id
5374
6437
  ? '当前会话'
5375
6438
  : `子代理 ${request.agent.id}`;
@@ -5392,6 +6455,8 @@ export class SshTui {
5392
6455
  });
5393
6456
  };
5394
6457
  handleUserQuestions = async (request) => {
6458
+ if (!this.hasLiveDisplay())
6459
+ await this.waitForLiveDisplay(request.signal);
5395
6460
  const answers = [];
5396
6461
  const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
5397
6462
  ? undefined
@@ -5536,6 +6601,7 @@ export class SshTui {
5536
6601
  index,
5537
6602
  total,
5538
6603
  selected: new Set(preselected !== undefined && preselected >= 0 ? [preselected] : []),
6604
+ cursor: preselected !== undefined && preselected >= 0 ? preselected : 0,
5539
6605
  resolve: (selection) => {
5540
6606
  this.settleQuestion(dialog, () => resolve(selection));
5541
6607
  },
@@ -5637,13 +6703,9 @@ export class SshTui {
5637
6703
  return false;
5638
6704
  }
5639
6705
  }
5640
- /** How many endpoint-listed models fit on one picker page alongside navigation. */
5641
- MODEL_PAGE_SIZE = 7;
5642
- MODEL_PAGE_PREV = '« 上一页';
5643
- MODEL_PAGE_NEXT = '» 下一页';
5644
6706
  /**
5645
- * One pick across a possibly long model list, paging through the digit
5646
- * dialog so an endpoint with dozens of models stays selectable.
6707
+ * One pick across a possibly long model list. The question dialog keeps a
6708
+ * 12-row sliding window so dozens of models stay selectable with ↑/↓.
5647
6709
  */
5648
6710
  async pickModelOption(modelOptions, provider, sourceLabel, currentModel) {
5649
6711
  const seen = new Set();
@@ -5655,40 +6717,19 @@ export class SshTui {
5655
6717
  });
5656
6718
  if (unique.length === 0)
5657
6719
  return undefined;
5658
- let offset = 0;
5659
- for (;;) {
5660
- const page = unique.slice(offset, offset + this.MODEL_PAGE_SIZE);
5661
- const hasPrev = offset > 0;
5662
- const hasNext = offset + this.MODEL_PAGE_SIZE < unique.length;
5663
- const pageCount = Math.max(1, Math.ceil(unique.length / this.MODEL_PAGE_SIZE));
5664
- const currentPage = Math.floor(offset / this.MODEL_PAGE_SIZE) + 1;
5665
- const options = page.map(option => ({
6720
+ const currentIndex = unique.findIndex(option => option.id === currentModel && option.id !== '__switch_provider__');
6721
+ const answer = await this.askQuestion({
6722
+ id: 'model-pick',
6723
+ question: `选择模型(提供商 ${provider} · ${sourceLabel}${unique.length > PICKER_WINDOW ? ` · ${unique.length} 个,↑/↓ 翻看` : ''})`,
6724
+ options: unique.map(option => ({
5666
6725
  label: option.label,
5667
6726
  description: option.id === currentModel ? '当前' : undefined,
5668
- }));
5669
- if (hasPrev)
5670
- options.push({ label: this.MODEL_PAGE_PREV, description: undefined });
5671
- if (hasNext)
5672
- options.push({ label: this.MODEL_PAGE_NEXT, description: undefined });
5673
- const currentIndex = page.findIndex(option => option.id === currentModel && option.id !== '__switch_provider__');
5674
- const answer = await this.askQuestion({
5675
- id: 'model-pick',
5676
- question: `选择模型(提供商 ${provider} · ${sourceLabel}${hasPrev || hasNext ? `,第 ${currentPage}/${pageCount} 页` : ''})`,
5677
- options,
5678
- }, 0, 1, currentIndex >= 0 ? currentIndex : undefined);
5679
- const picked = options.find(option => option.label === answer.selected[0]);
5680
- if (picked === undefined)
5681
- return undefined;
5682
- if (picked.label === this.MODEL_PAGE_NEXT) {
5683
- offset += this.MODEL_PAGE_SIZE;
5684
- continue;
5685
- }
5686
- if (picked.label === this.MODEL_PAGE_PREV) {
5687
- offset = Math.max(0, offset - this.MODEL_PAGE_SIZE);
5688
- continue;
5689
- }
5690
- return page.find(option => option.label === picked.label);
5691
- }
6727
+ })),
6728
+ }, 0, 1, currentIndex >= 0 ? currentIndex : undefined);
6729
+ const picked = answer.selected[0];
6730
+ if (picked === undefined)
6731
+ return undefined;
6732
+ return unique.find(option => option.label === picked);
5692
6733
  }
5693
6734
  /** Live adapter routes the TUI can switch to, plus the current selection. */
5694
6735
  listSelectableProviders() {
@@ -5860,38 +6901,38 @@ export class SshTui {
5860
6901
  effortOptions = [];
5861
6902
  }
5862
6903
  if (effortOptions.length === 0 && providerUsesLocalOAuth(provider)) {
5863
- effortOptions = modelId === 'grok-4.6'
5864
- ? [
5865
- { id: 'off', label: 'Off' },
5866
- { id: 'low', label: 'Low' },
5867
- { id: 'medium', label: 'Medium' },
5868
- { id: 'high', label: 'High' },
5869
- { id: 'xhigh', label: 'Extra high' },
5870
- ]
5871
- : [
5872
- { id: 'off', label: 'Off' },
5873
- { id: 'low', label: 'Low' },
5874
- { id: 'medium', label: 'Medium' },
5875
- { id: 'high', label: 'High' },
5876
- ];
5877
- }
5878
- let effort;
5879
- if (effortOptions.length > 0) {
5880
- const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
5881
- const currentEffort = current?.provider === provider
5882
- ? String(current?.reasoningEffort ?? '')
5883
- : rememberedEffort;
5884
- const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
5885
- const effortAnswer = await this.askQuestion({
5886
- id: 'effort-pick',
5887
- question: `选择思考强度(${modelId})`,
5888
- options: effortOptions.map(option => ({
5889
- label: option.label,
5890
- description: option.id === currentEffort ? '当前' : undefined,
5891
- })),
5892
- }, 0, 1, currentIndex);
5893
- effort = effortOptions.find(option => option.label === effortAnswer.selected[0])?.id;
6904
+ effortOptions = localOAuthEffortChoices(modelId);
5894
6905
  }
6906
+ const isUndeclared = effortOptions.length === 0;
6907
+ const available = isUndeclared ? undeclaredEffortChoices() : effortOptions;
6908
+ const choices = [
6909
+ {
6910
+ id: undefined,
6911
+ label: t('footer.effortDefault'),
6912
+ desc: isUndeclared ? t('effort.descUndeclared') : t('effort.descFollow'),
6913
+ },
6914
+ ...available.map(opt => ({
6915
+ id: opt.id,
6916
+ label: opt.label,
6917
+ desc: undefined,
6918
+ })),
6919
+ ];
6920
+ const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
6921
+ const currentEffort = current?.provider === provider
6922
+ ? String(current?.reasoningEffort ?? '')
6923
+ : rememberedEffort;
6924
+ const currentIndex = Math.max(0, choices.findIndex(option => option.id === (currentEffort === '' ? undefined : currentEffort)));
6925
+ const effortAnswer = await this.askQuestion({
6926
+ id: 'effort-pick',
6927
+ question: isUndeclared
6928
+ ? t('effort.pickUndeclared', { model: modelId })
6929
+ : t('effort.pick', { model: modelId }),
6930
+ options: choices.map(option => ({
6931
+ label: option.label,
6932
+ description: option.id === (currentEffort === '' ? undefined : currentEffort) ? t('disconnect.current') : option.desc,
6933
+ })),
6934
+ }, 0, 1, currentIndex);
6935
+ const effort = choices.find(option => option.label === effortAnswer.selected[0])?.id;
5895
6936
  const next = {
5896
6937
  provider,
5897
6938
  model: modelId,
@@ -5903,9 +6944,11 @@ export class SshTui {
5903
6944
  await this.persistDefaultSelection(next);
5904
6945
  await this.rememberRoute(next);
5905
6946
  const kind = describeProviderRoute(provider);
6947
+ const effortText = effort ?? t('effort.defaultShort');
6948
+ const note = isUndeclared && effort !== undefined ? t('effort.manualNote') : '';
5906
6949
  this.pushRow({
5907
6950
  kind: 'system',
5908
- text: `已切换到 ${kind.kind}:${provider}/${modelId}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
6951
+ text: t('effort.switchedModel', { kind: kind.kind, provider, model: modelId, effort: effortText, note }),
5909
6952
  });
5910
6953
  const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
5911
6954
  const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
@@ -6002,10 +7045,16 @@ export class SshTui {
6002
7045
  });
6003
7046
  }
6004
7047
  clearQuotaForProvider(provider) {
6005
- if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider)
7048
+ const quotaSame = this.quotaSnapshot !== undefined && this.quotaSnapshot.provider === provider;
7049
+ const balanceSame = this.balanceSnapshot !== undefined && this.balanceSnapshot.provider === provider;
7050
+ if (quotaSame && balanceSame)
6006
7051
  return;
6007
- this.quotaSnapshot = undefined;
6008
- this.quotaAlerted.clear();
7052
+ if (!quotaSame) {
7053
+ this.quotaSnapshot = undefined;
7054
+ this.quotaAlerted.clear();
7055
+ }
7056
+ if (!balanceSame)
7057
+ this.balanceSnapshot = undefined;
6009
7058
  this.quotaStepsSinceRefresh = 0;
6010
7059
  this.markDirty();
6011
7060
  }
@@ -6093,8 +7142,98 @@ export class SshTui {
6093
7142
  });
6094
7143
  this.markDirty();
6095
7144
  }
7145
+ /** /effort: pick or set the reasoning effort for the current model. */
7146
+ async runEffortCommand(arg) {
7147
+ const provider = this.currentProviderId();
7148
+ const current = this.selectionRef?.current;
7149
+ if (current === undefined || current.model === undefined) {
7150
+ this.pushRow({ kind: 'error', text: t('effort.noModel') });
7151
+ this.markDirty();
7152
+ return;
7153
+ }
7154
+ const modelId = current.model;
7155
+ const llm = this.ctx.get('llm');
7156
+ let declaredOptions = [];
7157
+ try {
7158
+ const info = await llm?.resolveModelInfo(provider, modelId);
7159
+ declaredOptions = (info?.reasoning?.efforts ?? []).map(e => ({ id: String(e.id), label: e.name }));
7160
+ }
7161
+ catch {
7162
+ declaredOptions = [];
7163
+ }
7164
+ if (declaredOptions.length === 0 && providerUsesLocalOAuth(provider)) {
7165
+ declaredOptions = localOAuthEffortChoices(modelId);
7166
+ }
7167
+ const parsed = parseEffortArg(arg ?? '');
7168
+ if ((arg ?? '').trim() !== '') {
7169
+ if (parsed === undefined) {
7170
+ this.pushRow({ kind: 'error', text: t('effort.unknown', { id: (arg ?? '').trim() }) });
7171
+ this.markDirty();
7172
+ return;
7173
+ }
7174
+ const allowed = declaredOptions.length === 0
7175
+ ? UNDECLARED_EFFORT_IDS
7176
+ : declaredOptions.map(option => option.id);
7177
+ if (parsed.kind === 'id' && !allowed.includes(parsed.id)) {
7178
+ this.pushRow({ kind: 'error', text: t('effort.unknown', { id: parsed.id }) });
7179
+ this.markDirty();
7180
+ return;
7181
+ }
7182
+ await this.setReasoningEffort(provider, modelId, parsed.kind === 'default' ? undefined : parsed.id, declaredOptions.length === 0);
7183
+ return;
7184
+ }
7185
+ const isUndeclared = declaredOptions.length === 0;
7186
+ const available = isUndeclared ? undeclaredEffortChoices() : declaredOptions;
7187
+ const currentEffort = current.reasoningEffort === undefined ? undefined : String(current.reasoningEffort);
7188
+ const choices = [
7189
+ {
7190
+ id: undefined,
7191
+ label: t('footer.effortDefault'),
7192
+ desc: isUndeclared ? t('effort.descUndeclared') : t('effort.descFollow'),
7193
+ },
7194
+ ...available.map(opt => ({
7195
+ id: opt.id,
7196
+ label: opt.label,
7197
+ desc: undefined,
7198
+ })),
7199
+ ];
7200
+ const currentIndex = Math.max(0, choices.findIndex(c => c.id === currentEffort));
7201
+ const answer = await this.askQuestion({
7202
+ id: 'effort-pick',
7203
+ question: isUndeclared
7204
+ ? t('effort.pickCurrentUndeclared', { provider, model: modelId })
7205
+ : t('effort.pickCurrent', { provider, model: modelId }),
7206
+ options: choices.map(c => ({
7207
+ label: c.label,
7208
+ description: c.id === currentEffort ? t('disconnect.current') : c.desc,
7209
+ })),
7210
+ }, 0, 1, currentIndex);
7211
+ const picked = choices.find(c => c.label === answer.selected[0]);
7212
+ if (picked === undefined)
7213
+ return;
7214
+ await this.setReasoningEffort(provider, modelId, picked.id, isUndeclared);
7215
+ }
7216
+ async setReasoningEffort(provider, modelId, effort, isUndeclared) {
7217
+ const next = {
7218
+ provider,
7219
+ model: modelId,
7220
+ ...(effort === undefined ? {} : { reasoningEffort: ReasoningEffortId(effort) }),
7221
+ };
7222
+ if (this.selectionRef !== undefined)
7223
+ this.selectionRef.current = next;
7224
+ this.onSelectionChanged?.(next);
7225
+ await this.persistDefaultSelection(next);
7226
+ await this.rememberRoute(next);
7227
+ const effortText = effort ?? t('effort.defaultExplicit');
7228
+ const note = isUndeclared && effort !== undefined ? t('effort.manualNote') : '';
7229
+ this.pushRow({
7230
+ kind: 'system',
7231
+ text: t('effort.updated', { provider, model: modelId, effort: effortText, note }),
7232
+ });
7233
+ this.markDirty();
7234
+ }
6096
7235
  /** /subeffort: pick the reasoning effort subagent children use. */
6097
- async runSubeffortCommand() {
7236
+ async runSubeffortCommand(arg) {
6098
7237
  const provider = this.effectiveSubagentProvider();
6099
7238
  const current = this.subagentSelection.current;
6100
7239
  const llm = this.ctx.get('llm');
@@ -6106,30 +7245,58 @@ export class SshTui {
6106
7245
  catch {
6107
7246
  effortOptions = [];
6108
7247
  }
6109
- if (effortOptions.length === 0 && current.reasoningEffort === undefined) {
7248
+ const parsed = parseEffortArg(arg ?? '');
7249
+ if ((arg ?? '').trim() !== '') {
7250
+ if (parsed === undefined) {
7251
+ this.pushRow({ kind: 'error', text: t('effort.unknown', { id: (arg ?? '').trim() }) });
7252
+ this.markDirty();
7253
+ return;
7254
+ }
7255
+ const allowed = effortOptions.length === 0
7256
+ ? UNDECLARED_EFFORT_IDS
7257
+ : effortOptions.map(option => option.id);
7258
+ if (parsed.kind === 'id' && !allowed.includes(parsed.id)) {
7259
+ this.pushRow({ kind: 'error', text: t('effort.unknown', { id: parsed.id }) });
7260
+ this.markDirty();
7261
+ return;
7262
+ }
7263
+ const targetEffort = parsed.kind === 'default' ? undefined : parsed.id;
7264
+ const next = {
7265
+ ...current,
7266
+ ...(targetEffort === undefined ? { reasoningEffort: undefined } : { reasoningEffort: ReasoningEffortId(targetEffort) }),
7267
+ };
7268
+ const persisted = await this.saveSubagentSelection(next);
6110
7269
  this.pushRow({
6111
7270
  kind: 'system',
6112
- text: `模型 ${provider}/${current.model} 未声明可选 reasoning effort,已保持提供商默认;请勿手动设置 high/max。`,
7271
+ text: `${targetEffort === undefined
7272
+ ? t('effort.subDefault')
7273
+ : t('effort.subSwitched', { effort: targetEffort })}${persisted ? '' : t('effort.sessionOnly')}`,
6113
7274
  });
6114
7275
  this.markDirty();
6115
7276
  return;
6116
7277
  }
7278
+ const isUndeclared = effortOptions.length === 0;
7279
+ const available = isUndeclared ? undeclaredEffortChoices() : effortOptions;
6117
7280
  const choices = [
6118
- { id: undefined, label: SUBAGENT_DEFAULT_EFFORT_LABEL() },
6119
- ...effortOptions.map(option => ({ id: option.id, label: option.label })),
7281
+ {
7282
+ id: undefined,
7283
+ label: SUBAGENT_DEFAULT_EFFORT_LABEL(),
7284
+ desc: isUndeclared ? t('effort.subDescUndeclared') : t('effort.subDescFollow'),
7285
+ },
7286
+ ...available.map(option => ({ id: option.id, label: option.label, desc: undefined })),
6120
7287
  ];
7288
+ const currentEffort = current.reasoningEffort === undefined ? undefined : String(current.reasoningEffort);
7289
+ const currentIndex = Math.max(0, choices.findIndex(c => c.id === currentEffort));
6121
7290
  const answer = await this.askQuestion({
6122
7291
  id: 'subagent-effort-pick',
6123
- question: `选择子代理思考强度(${provider}/${current.model})`,
7292
+ question: isUndeclared
7293
+ ? t('effort.pickSubUndeclared', { provider, model: current.model })
7294
+ : t('effort.pickSub', { provider, model: current.model }),
6124
7295
  options: choices.map(option => ({
6125
7296
  label: option.label,
6126
- description: option.id === undefined
6127
- ? '清空自定义强度,跟随提供商/模型默认'
6128
- : option.id === String(current.reasoningEffort)
6129
- ? '当前'
6130
- : undefined,
7297
+ description: option.id === currentEffort ? t('disconnect.current') : option.desc,
6131
7298
  })),
6132
- });
7299
+ }, 0, 1, currentIndex);
6133
7300
  const picked = choices.find(option => option.label === answer.selected[0]);
6134
7301
  if (picked === undefined)
6135
7302
  return;
@@ -6143,8 +7310,8 @@ export class SshTui {
6143
7310
  this.pushRow({
6144
7311
  kind: 'system',
6145
7312
  text: `${picked.id === undefined
6146
- ? '子代理思考强度已恢复为提供商默认。'
6147
- : `子代理思考强度已切换:${picked.id}。`}${persisted ? '' : '(仅当前会话)'}`,
7313
+ ? t('effort.subDefault')
7314
+ : t('effort.subSwitched', { effort: picked.id })}${persisted ? '' : t('effort.sessionOnly')}`,
6148
7315
  });
6149
7316
  this.markDirty();
6150
7317
  }
@@ -6211,6 +7378,37 @@ export class SshTui {
6211
7378
  this.pushRow({ kind: 'system', text: t('view.switched', { name: next === 'compact' ? t('view.compact') : t('view.detailed') }) });
6212
7379
  this.markDirty();
6213
7380
  }
7381
+ /** /disconnect: pause (default) or continue the turn after SSH drop. */
7382
+ async runDisconnectCommand(arg) {
7383
+ const direct = parseDisconnectPolicy(arg);
7384
+ let next = direct;
7385
+ if (next === undefined && arg.trim() !== '') {
7386
+ this.pushRow({ kind: 'error', text: t('disconnect.unknown', { id: arg.trim() }) });
7387
+ this.markDirty();
7388
+ return;
7389
+ }
7390
+ if (next === undefined) {
7391
+ const current = this.disconnectPolicy;
7392
+ const answer = await this.askQuestion({
7393
+ id: 'disconnect-pick',
7394
+ question: t('disconnect.pick'),
7395
+ options: [
7396
+ { label: t('disconnect.pause'), description: current === 'pause' ? t('disconnect.current') : t('disconnect.pauseDesc') },
7397
+ { label: t('disconnect.continue'), description: current === 'continue' ? t('disconnect.current') : t('disconnect.continueDesc') },
7398
+ ],
7399
+ }, 0, 1, current === 'continue' ? 1 : 0);
7400
+ const picked = answer.selected[0];
7401
+ next = picked === t('disconnect.continue') ? 'continue' : 'pause';
7402
+ }
7403
+ this.disconnectPolicy = next;
7404
+ await this.mergeUiSettings({ disconnect: next });
7405
+ this.forceFullPaint = true;
7406
+ this.pushRow({
7407
+ kind: 'system',
7408
+ text: t('disconnect.switched', { name: next === 'continue' ? t('disconnect.continue') : t('disconnect.pause') }),
7409
+ });
7410
+ this.markDirty();
7411
+ }
6214
7412
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
6215
7413
  async runModeCommand() {
6216
7414
  const agentPresets = this.ctx.get('agentPresets');
@@ -6237,7 +7435,7 @@ export class SshTui {
6237
7435
  if (selected === undefined)
6238
7436
  return;
6239
7437
  const selectedName = selected.name ?? selected.id;
6240
- const hasWork = this.agent.session.events.some(event => event.type === 'turn/start');
7438
+ const hasWork = sessionEvents(this.agent.session).some(event => event.type === 'turn/start');
6241
7439
  if (!hasWork) {
6242
7440
  await agentPresets.recompose(this.agent.ctx, selected.id);
6243
7441
  this.presetId = selected.id;
@@ -6382,9 +7580,8 @@ export class SshTui {
6382
7580
  const quota = await this.refreshQuota({ reason: 'command', announce: true });
6383
7581
  if (quota !== undefined)
6384
7582
  return;
6385
- const balance = await this.fetchAccountBalance(this.currentProviderId());
6386
- if (balance !== undefined) {
6387
- this.pushRow({ kind: 'system', text: formatAccountBalance(balance) });
7583
+ if (this.balanceSnapshot !== undefined) {
7584
+ this.pushRow({ kind: 'system', text: formatAccountBalance(this.balanceSnapshot) });
6388
7585
  return;
6389
7586
  }
6390
7587
  const provider = this.currentProviderId();
@@ -6433,14 +7630,37 @@ export class SshTui {
6433
7630
  const provider = this.currentProviderId();
6434
7631
  const snapshot = await this.fetchQuotaSnapshot(provider);
6435
7632
  if (snapshot !== undefined) {
7633
+ this.balanceSnapshot = undefined;
6436
7634
  this.applyQuotaSnapshot(snapshot, options.announce);
6437
7635
  return snapshot;
6438
7636
  }
7637
+ try {
7638
+ const balance = await this.fetchAccountBalance(provider);
7639
+ if (balance !== undefined) {
7640
+ this.balanceSnapshot = balance;
7641
+ this.quotaSnapshot = undefined;
7642
+ this.quotaAlerted.clear();
7643
+ if (options.announce)
7644
+ this.pushRow({ kind: 'system', text: formatAccountBalance(balance) });
7645
+ this.markDirty();
7646
+ return undefined;
7647
+ }
7648
+ }
7649
+ catch (error) {
7650
+ if (options.reason === 'command')
7651
+ throw error;
7652
+ this.markDirty();
7653
+ return this.quotaSnapshot;
7654
+ }
6439
7655
  if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider !== provider) {
6440
7656
  this.quotaSnapshot = undefined;
6441
7657
  this.quotaAlerted.clear();
6442
7658
  this.markDirty();
6443
7659
  }
7660
+ if (this.balanceSnapshot !== undefined && this.balanceSnapshot.provider !== provider) {
7661
+ this.balanceSnapshot = undefined;
7662
+ this.markDirty();
7663
+ }
6444
7664
  return undefined;
6445
7665
  }
6446
7666
  finally {
@@ -6578,6 +7798,12 @@ export class SshTui {
6578
7798
  if (this.dialog?.kind === 'inspect') {
6579
7799
  this.scrollInspectOrTranscript(-1);
6580
7800
  }
7801
+ else if (this.moveQuestionCursor(-1)) {
7802
+ return;
7803
+ }
7804
+ else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(-1)) {
7805
+ return;
7806
+ }
6581
7807
  else if (this.suggestionsVisible()) {
6582
7808
  this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
6583
7809
  this.markDirty();
@@ -6593,6 +7819,12 @@ export class SshTui {
6593
7819
  if (this.dialog?.kind === 'inspect') {
6594
7820
  this.scrollInspectOrTranscript(1);
6595
7821
  }
7822
+ else if (this.moveQuestionCursor(1)) {
7823
+ return;
7824
+ }
7825
+ else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(1)) {
7826
+ return;
7827
+ }
6596
7828
  else if (this.suggestionsVisible()) {
6597
7829
  this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
6598
7830
  this.markDirty();
@@ -6633,11 +7865,11 @@ export class SshTui {
6633
7865
  return;
6634
7866
  }
6635
7867
  if (combined === '\x1b[5~') {
6636
- this.scrollInspectOrTranscript(Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
7868
+ this.scrollInspectOrTranscript(Math.max(3, Math.floor(this.screenRows() / 2)));
6637
7869
  return;
6638
7870
  }
6639
7871
  if (combined === '\x1b[6~') {
6640
- this.scrollInspectOrTranscript(-Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
7872
+ this.scrollInspectOrTranscript(-Math.max(3, Math.floor(this.screenRows() / 2)));
6641
7873
  return;
6642
7874
  }
6643
7875
  if (parseCursorPositionReply(combined) !== undefined)
@@ -6768,9 +8000,10 @@ export class SshTui {
6768
8000
  const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
6769
8001
  if (normalized === '')
6770
8002
  return;
8003
+ this.leaveHistoryBrowse();
6771
8004
  this.input = `${this.input.slice(0, this.cursor)}${normalized}${this.input.slice(this.cursor)}`;
6772
8005
  this.cursor += normalized.length;
6773
- const cols = Math.max(10, process.stdout.columns || 80);
8006
+ const cols = Math.max(10, this.screenColumns());
6774
8007
  const lineWidth = Math.max(1, cols - 2);
6775
8008
  if (normalized.includes('\n') || displayWidth(this.input) > lineWidth)
6776
8009
  this.inputFolded = true;
@@ -6844,12 +8077,14 @@ export class SshTui {
6844
8077
  this.markDirty();
6845
8078
  return;
6846
8079
  case '\x15':
8080
+ this.leaveHistoryBrowse();
6847
8081
  this.input = '';
6848
8082
  this.cursor = 0;
6849
8083
  this.inputFolded = false;
6850
8084
  this.markDirty();
6851
8085
  return;
6852
8086
  case '\x0b':
8087
+ this.leaveHistoryBrowse();
6853
8088
  this.input = this.input.slice(0, this.cursor);
6854
8089
  this.markDirty();
6855
8090
  return;
@@ -6902,11 +8137,27 @@ export class SshTui {
6902
8137
  return;
6903
8138
  }
6904
8139
  if (char >= ' ' && char !== '\x7f') {
8140
+ this.leaveHistoryBrowse();
6905
8141
  this.input = `${this.input.slice(0, this.cursor)}${char}${this.input.slice(this.cursor)}`;
6906
8142
  this.cursor += char.length;
6907
8143
  this.markDirty();
6908
8144
  }
6909
8145
  }
8146
+ moveQuestionCursor(delta) {
8147
+ const dialog = this.dialog;
8148
+ if (dialog === undefined || dialog.kind !== 'questions')
8149
+ return false;
8150
+ const count = dialog.question.options?.length ?? 0;
8151
+ if (count === 0)
8152
+ return false;
8153
+ dialog.cursor = Math.max(0, Math.min(count - 1, dialog.cursor + delta));
8154
+ if (dialog.question.multiSelect !== true) {
8155
+ dialog.selected.clear();
8156
+ dialog.selected.add(dialog.cursor);
8157
+ }
8158
+ this.markDirty();
8159
+ return true;
8160
+ }
6910
8161
  handleDialogChar(text) {
6911
8162
  const dialog = this.dialog;
6912
8163
  if (dialog === undefined)
@@ -6933,6 +8184,7 @@ export class SshTui {
6933
8184
  const key = text.toLowerCase();
6934
8185
  const index = QUESTION_OPTION_KEYS.indexOf(key);
6935
8186
  if (index >= 0 && index < (dialog.question.options?.length ?? 0)) {
8187
+ dialog.cursor = index;
6936
8188
  if (dialog.question.multiSelect === true) {
6937
8189
  if (dialog.selected.has(index))
6938
8190
  dialog.selected.delete(index);
@@ -6976,31 +8228,86 @@ export class SshTui {
6976
8228
  this.markDirty();
6977
8229
  }
6978
8230
  }
8231
+ /**
8232
+ * The wizard's first-step list: the pinned templates plus the web-catalog
8233
+ * presets (deduped), filtered by the search box text.
8234
+ */
8235
+ mergedProviderEntries(state) {
8236
+ const templates = providerTemplates();
8237
+ const templateEntries = [
8238
+ { key: 'template:official', label: templates.official.label, detail: 'api.deepseek.com' },
8239
+ { key: 'template:opencode-go', label: templates['opencode-go'].label, detail: 'opencode.ai/zen/go · Responses' },
8240
+ { key: 'template:openai-completions', label: templates['openai-completions'].label, detail: 'openai-completions' },
8241
+ { key: 'template:openai-responses', label: templates['openai-responses'].label, detail: 'openai-responses' },
8242
+ { key: 'template:anthropic-messages', label: templates['anthropic-messages'].label, detail: 'anthropic-messages' },
8243
+ ];
8244
+ return mergeProviderEntries(templateEntries, state.catalogPresets ?? [], ['deepseek', 'opencode-go'], this.input);
8245
+ }
8246
+ moveProviderCursor(delta) {
8247
+ const state = this.onboarding;
8248
+ if (state === undefined || state.step !== 'provider')
8249
+ return false;
8250
+ const total = this.mergedProviderEntries(state).length;
8251
+ if (total === 0)
8252
+ return false;
8253
+ state.providerCursor = Math.max(0, Math.min(total - 1, state.providerCursor + delta));
8254
+ this.markDirty();
8255
+ return true;
8256
+ }
6979
8257
  handleOnboardingChar(text) {
6980
8258
  const state = this.onboarding;
6981
8259
  if (state === undefined)
6982
8260
  return;
6983
8261
  switch (state.step) {
6984
- case 'provider':
6985
- {
6986
- const selected = text === '1' ? 'official'
6987
- : text === '2' ? 'opencode-go'
6988
- : text === '3' ? 'openai-completions'
6989
- : text === '4' ? 'openai-responses'
6990
- : text === '5' ? 'anthropic-messages'
6991
- : undefined;
6992
- if (selected !== undefined) {
6993
- state.providerType = selected;
6994
- state.providerId = '';
6995
- state.baseUrl = '';
6996
- state.key = '';
6997
- state.models = [];
6998
- this.input = '';
6999
- this.cursor = 0;
8262
+ case 'provider': {
8263
+ const options = this.mergedProviderEntries(state);
8264
+ if (text === '\r' || text === '\n') {
8265
+ const index = Math.min(state.providerCursor, options.length - 1);
8266
+ const entry = options[index];
8267
+ if (entry === undefined) {
8268
+ this.markDirty();
8269
+ return;
8270
+ }
8271
+ state.providerId = '';
8272
+ state.baseUrl = '';
8273
+ state.key = '';
8274
+ state.models = [];
8275
+ this.input = '';
8276
+ this.cursor = 0;
8277
+ if (entry.catalog !== undefined) {
8278
+ state.providerType = 'catalog';
8279
+ state.catalog = entry.catalog;
8280
+ state.step = 'id';
8281
+ }
8282
+ else {
8283
+ state.providerType = entry.key.slice('template:'.length);
7000
8284
  this.advanceOnboarding();
7001
8285
  }
8286
+ this.markDirty();
8287
+ return;
8288
+ }
8289
+ if (text === '\x7f') {
8290
+ if (this.input !== '') {
8291
+ this.input = this.input.slice(0, -1);
8292
+ state.providerCursor = 0;
8293
+ }
8294
+ this.markDirty();
8295
+ return;
8296
+ }
8297
+ let changed = false;
8298
+ for (const char of text) {
8299
+ if (char >= ' ' && char !== '\x7f') {
8300
+ this.input = `${this.input.slice(0, this.cursor)}${char}${this.input.slice(this.cursor)}`;
8301
+ this.cursor += char.length;
8302
+ changed = true;
8303
+ }
8304
+ }
8305
+ if (changed) {
8306
+ state.providerCursor = 0;
8307
+ this.markDirty();
7002
8308
  }
7003
8309
  return;
8310
+ }
7004
8311
  case 'id':
7005
8312
  case 'base-url':
7006
8313
  case 'key':
@@ -7012,7 +8319,7 @@ export class SshTui {
7012
8319
  if (text === '\r' || text === '\n') {
7013
8320
  const value = this.input.trim();
7014
8321
  if (state.step === 'id') {
7015
- const template = providerTemplates()[state.providerType];
8322
+ const template = onboardTemplate(state);
7016
8323
  const id = value === '' ? template.defaultId : value;
7017
8324
  if (!/^[a-z0-9][a-z0-9-]*$/u.test(id)) {
7018
8325
  this.pushRow({ kind: 'error', text: 'Provider ID 只能包含小写字母、数字和连字符,且不能以连字符开头。' });
@@ -7022,7 +8329,7 @@ export class SshTui {
7022
8329
  state.providerId = id;
7023
8330
  }
7024
8331
  else if (state.step === 'key') {
7025
- if (value === '') {
8332
+ if (value === '' && state.providerType !== 'catalog') {
7026
8333
  this.pushRow({ kind: 'error', text: 'API Key 不能为空,请重新输入。' });
7027
8334
  this.markDirty();
7028
8335
  return;
@@ -7030,7 +8337,7 @@ export class SshTui {
7030
8337
  state.key = value;
7031
8338
  }
7032
8339
  else if (state.step === 'models') {
7033
- const template = providerTemplates()[state.providerType];
8340
+ const template = onboardTemplate(state);
7034
8341
  const parsed = value === ''
7035
8342
  ? template.defaultModels
7036
8343
  : value.split(/[\s,,]+/u).filter(Boolean);
@@ -7109,12 +8416,12 @@ export class SshTui {
7109
8416
  const state = this.onboarding;
7110
8417
  if (state === undefined || state.step !== 'models')
7111
8418
  return;
7112
- const template = providerTemplates()[state.providerType];
8419
+ const template = onboardTemplate(state);
7113
8420
  const providerType = state.providerType;
7114
8421
  const baseUrl = state.baseUrl;
7115
8422
  const key = state.key;
7116
8423
  const baseURL = baseUrl === '' ? template.defaultBaseUrl : baseUrl;
7117
- if (baseURL === '') {
8424
+ if (baseURL === '' && providerType !== 'catalog') {
7118
8425
  this.pushRow({ kind: 'error', text: '请先填写 Base URL 再获取模型列表。' });
7119
8426
  this.markDirty();
7120
8427
  return;
@@ -7127,7 +8434,10 @@ export class SshTui {
7127
8434
  if (llm === undefined)
7128
8435
  throw new Error('llm 服务不可用');
7129
8436
  const discovered = await discoverProviderModels(llm, {
7130
- baseURL,
8437
+ ...(providerType === 'catalog' && state.catalog !== undefined && baseURL === ''
8438
+ ? {}
8439
+ : { baseURL }),
8440
+ ...(providerType === 'catalog' && state.catalog !== undefined ? { provider: state.catalog.id } : {}),
7131
8441
  ...(template.api === undefined ? {} : { api: template.api }),
7132
8442
  ...(key === '' ? {} : { apiKey: key }),
7133
8443
  }, AbortSignal.timeout(15_000));
@@ -7167,7 +8477,7 @@ export class SshTui {
7167
8477
  try {
7168
8478
  const credentials = this.ctx.get('credentials');
7169
8479
  const settings = this.ctx.get('settings');
7170
- const template = providerTemplates()[state.providerType];
8480
+ const template = onboardTemplate(state);
7171
8481
  if (state.providerType === 'official') {
7172
8482
  const envRef = 'DEEPSEEK_API_KEY';
7173
8483
  await this.saveCredential(credentials, envRef, state.key);
@@ -7226,15 +8536,24 @@ export class SshTui {
7226
8536
  mergedIds.push(id);
7227
8537
  }
7228
8538
  }
8539
+ // Catalog routes mirror the web's model settings: an empty profile is
8540
+ // valid — the installed catalog serves endpoint, protocol, and models,
8541
+ // and an absent key defers to the provider's own environment auth.
8542
+ const catalogRoute = state.providerType === 'catalog' && state.catalog !== undefined;
8543
+ const keyless = catalogRoute && state.key === '';
7229
8544
  const profile = {
7230
8545
  displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
7231
8546
  ? existing.displayName
7232
8547
  : template.label,
7233
- apiKeyEnv: envRef,
8548
+ ...(keyless ? {} : { apiKeyEnv: envRef }),
7234
8549
  api: template.api ?? existing?.api,
7235
- baseURL: state.baseUrl === ''
7236
- ? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
7237
- : state.baseUrl,
8550
+ ...(catalogRoute && state.baseUrl === ''
8551
+ ? {}
8552
+ : {
8553
+ baseURL: state.baseUrl === ''
8554
+ ? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
8555
+ : state.baseUrl,
8556
+ }),
7238
8557
  models: mergedIds.map(id => ({
7239
8558
  id,
7240
8559
  ...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
@@ -7253,7 +8572,7 @@ export class SshTui {
7253
8572
  }
7254
8573
  // Only store the key when its provider profile actually made it to
7255
8574
  // settings; otherwise the saved key points at an unusable route.
7256
- if (saved)
8575
+ if (saved && !keyless)
7257
8576
  await this.saveCredential(credentials, envRef, state.key);
7258
8577
  if (saved) {
7259
8578
  const selection = {
@@ -7507,6 +8826,8 @@ export class SshTui {
7507
8826
  if (text === '')
7508
8827
  return;
7509
8828
  if (text.startsWith('/')) {
8829
+ this.historyIndex = this.history.length;
8830
+ this.historyDraft = '';
7510
8831
  this.runCommand(text);
7511
8832
  return;
7512
8833
  }
@@ -7514,6 +8835,7 @@ export class SshTui {
7514
8835
  return;
7515
8836
  this.history.push(text);
7516
8837
  this.historyIndex = this.history.length;
8838
+ this.historyDraft = '';
7517
8839
  this.input = '';
7518
8840
  this.cursor = 0;
7519
8841
  this.inputFolded = false;
@@ -7527,7 +8849,7 @@ export class SshTui {
7527
8849
  this.agent.steer(message);
7528
8850
  }
7529
8851
  else {
7530
- this.beginWait(text);
8852
+ this.beginWait();
7531
8853
  this.agent.followup(message);
7532
8854
  }
7533
8855
  this.markDirty();
@@ -7538,10 +8860,16 @@ export class SshTui {
7538
8860
  switch (command) {
7539
8861
  case 'help': {
7540
8862
  const local = localizedCommands()
7541
- .filter(item => item.name !== 'help' && item.name !== 'exit')
8863
+ .filter(item => item.name !== 'help' && item.aliasOf === undefined)
7542
8864
  .map(item => `/${item.name.padEnd(12)} ${item.description}`);
8865
+ const seen = new Set(localizedCommands().map(item => item.name));
7543
8866
  const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
7544
- .map(item => `/${item.name.padEnd(12)} ${item.description}${item.input?.images === true ? '(可附图)' : ''} (dsh)`);
8867
+ .filter(item => !seen.has(item.name))
8868
+ .map(item => {
8869
+ const descKey = `cmd.${item.name}`;
8870
+ const desc = t(descKey, undefined, item.description);
8871
+ return `/${item.name.padEnd(12)} ${item.input?.images === true ? t('cmd.withImagesSuffix', { desc }) : desc} (dsh)`;
8872
+ });
7545
8873
  this.pushRow({
7546
8874
  kind: 'system',
7547
8875
  text: [
@@ -7555,6 +8883,7 @@ export class SshTui {
7555
8883
  t('help.intro5'),
7556
8884
  t('help.intro6'),
7557
8885
  t('help.intro7'),
8886
+ t('help.intro8'),
7558
8887
  ].join('\n'),
7559
8888
  });
7560
8889
  break;
@@ -7574,6 +8903,17 @@ export class SshTui {
7574
8903
  this.markDirty();
7575
8904
  });
7576
8905
  break;
8906
+ case 'effort':
8907
+ void this.runEffortCommand(arg).catch((error) => {
8908
+ if (error instanceof UserQuestionError) {
8909
+ this.pushRow({ kind: 'system', text: t('help.effortCancel') });
8910
+ }
8911
+ else {
8912
+ this.pushRow({ kind: 'error', text: `/effort failed: ${errorChain(error)}` });
8913
+ }
8914
+ this.markDirty();
8915
+ });
8916
+ break;
7577
8917
  case 'provider':
7578
8918
  void this.runProviderCommand().catch((error) => {
7579
8919
  if (error instanceof UserQuestionError) {
@@ -7597,7 +8937,7 @@ export class SshTui {
7597
8937
  });
7598
8938
  break;
7599
8939
  case 'subeffort':
7600
- void this.runSubeffortCommand().catch((error) => {
8940
+ void this.runSubeffortCommand(arg).catch((error) => {
7601
8941
  if (error instanceof UserQuestionError) {
7602
8942
  this.pushRow({ kind: 'system', text: t('help.subeffortCancel') });
7603
8943
  }
@@ -7641,6 +8981,17 @@ export class SshTui {
7641
8981
  this.markDirty();
7642
8982
  });
7643
8983
  break;
8984
+ case 'disconnect':
8985
+ void this.runDisconnectCommand(arg).catch((error) => {
8986
+ if (error instanceof UserQuestionError) {
8987
+ this.pushRow({ kind: 'system', text: t('help.modeCancel') });
8988
+ }
8989
+ else {
8990
+ this.pushRow({ kind: 'error', text: `/disconnect failed: ${errorChain(error)}` });
8991
+ }
8992
+ this.markDirty();
8993
+ });
8994
+ break;
7644
8995
  case 'find':
7645
8996
  this.runFindCommand(arg);
7646
8997
  break;
@@ -7650,7 +9001,6 @@ export class SshTui {
7650
9001
  this.streamingReasoning = undefined;
7651
9002
  this.thinkingStartedAt = undefined;
7652
9003
  this.waitStartedAt = undefined;
7653
- this.waitPrompt = undefined;
7654
9004
  this.focusedRow = null;
7655
9005
  this.searchHits = [];
7656
9006
  this.searchIndex = -1;
@@ -7681,6 +9031,7 @@ export class SshTui {
7681
9031
  activeSubagents: this.activeSubagents.size,
7682
9032
  plan: plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off',
7683
9033
  paint: formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed),
9034
+ disconnect: this.disconnectPolicy,
7684
9035
  waitingQuestions: waiting,
7685
9036
  ...(quota === undefined ? {} : { quota }),
7686
9037
  parentModel: model,
@@ -7751,6 +9102,40 @@ export class SshTui {
7751
9102
  this.markDirty();
7752
9103
  });
7753
9104
  break;
9105
+ case 'approval': {
9106
+ const requested = arg === '' ? 'toggle' : arg;
9107
+ if (requested === 'status') {
9108
+ this.pushRow({
9109
+ kind: 'system',
9110
+ text: this.autoApprovalMode === 'auto'
9111
+ ? t('approval.statusAuto', { allowed: this.autoAllowedCount, denied: this.autoDeniedCount })
9112
+ : t('approval.statusOff'),
9113
+ });
9114
+ this.markDirty();
9115
+ break;
9116
+ }
9117
+ const next = requested === 'toggle'
9118
+ ? this.autoApprovalMode === 'auto' ? 'off' : 'auto'
9119
+ : parseAutoApprovalMode(requested);
9120
+ if (next === undefined) {
9121
+ this.pushRow({ kind: 'error', text: t('approval.unknown', { arg }) });
9122
+ this.markDirty();
9123
+ break;
9124
+ }
9125
+ this.autoApprovalMode = next;
9126
+ void this.mergeUiSettings({ autoApproval: next }).catch((error) => {
9127
+ this.pushRow({ kind: 'error', text: `/approval failed: ${errorChain(error)}` });
9128
+ this.markDirty();
9129
+ });
9130
+ this.pushRow({
9131
+ kind: 'system',
9132
+ text: next === 'auto' ? t('approval.autoOn') : t('approval.autoOff'),
9133
+ });
9134
+ if (next === 'auto')
9135
+ this.warnApprovalMismatch();
9136
+ this.markDirty();
9137
+ break;
9138
+ }
7754
9139
  case 'setup':
7755
9140
  void this.runOnboarding();
7756
9141
  break;
@@ -7791,14 +9176,16 @@ export class SshTui {
7791
9176
  this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
7792
9177
  return;
7793
9178
  }
7794
- const result = execution.result;
7795
- if (result.kind === 'success') {
7796
- if (result.text !== undefined && result.text !== '') {
7797
- this.pushRow({ kind: 'system', text: result.text });
7798
- }
9179
+ // command/run + command/done already paint via handleCommandDone
9180
+ // when the session log is live. Fall back if those events never
9181
+ // arrived (no persistence, or a handler that skipped the log).
9182
+ if (this.seenCommandDoneIds.has(String(execution.commandId)))
9183
+ return;
9184
+ if (execution.result.kind === 'error') {
9185
+ this.pushRow({ kind: 'error', text: this.formatCommandText(execution.result.text) });
7799
9186
  }
7800
- else {
7801
- this.pushRow({ kind: 'error', text: result.text });
9187
+ else if (execution.result.text !== undefined && execution.result.text !== '') {
9188
+ this.pushRow({ kind: 'system', text: this.formatCommandText(execution.result.text) });
7802
9189
  }
7803
9190
  }).catch((error) => {
7804
9191
  this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
@@ -7846,6 +9233,7 @@ export class SshTui {
7846
9233
  backspace() {
7847
9234
  if (this.cursor === 0)
7848
9235
  return;
9236
+ this.leaveHistoryBrowse();
7849
9237
  const range = this.graphemeBefore(this.cursor);
7850
9238
  this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
7851
9239
  this.cursor = range.start;
@@ -7855,6 +9243,7 @@ export class SshTui {
7855
9243
  const range = this.graphemeAfter(this.cursor);
7856
9244
  if (range === undefined)
7857
9245
  return;
9246
+ this.leaveHistoryBrowse();
7858
9247
  this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
7859
9248
  this.cursor = range.start;
7860
9249
  this.markDirty();
@@ -7890,6 +9279,8 @@ export class SshTui {
7890
9279
  historyBack() {
7891
9280
  if (this.history.length === 0)
7892
9281
  return;
9282
+ if (this.historyIndex === this.history.length)
9283
+ this.historyDraft = this.input;
7893
9284
  if (this.historyIndex <= 0)
7894
9285
  return;
7895
9286
  this.historyIndex -= 1;
@@ -7898,13 +9289,22 @@ export class SshTui {
7898
9289
  this.markDirty();
7899
9290
  }
7900
9291
  historyForward() {
7901
- if (this.historyIndex >= this.history.length)
9292
+ if (this.historyIndex < 0 || this.historyIndex >= this.history.length)
7902
9293
  return;
7903
9294
  this.historyIndex += 1;
7904
- this.input = this.history[this.historyIndex] ?? '';
9295
+ this.input = this.historyIndex >= this.history.length
9296
+ ? this.historyDraft
9297
+ : (this.history[this.historyIndex] ?? '');
7905
9298
  this.cursor = this.input.length;
7906
9299
  this.markDirty();
7907
9300
  }
9301
+ /** Typing while browsing history detaches from the saved item. */
9302
+ leaveHistoryBrowse() {
9303
+ if (this.historyIndex >= 0 && this.historyIndex < this.history.length) {
9304
+ this.historyIndex = this.history.length;
9305
+ this.historyDraft = this.input;
9306
+ }
9307
+ }
7908
9308
  }
7909
9309
  function optionsLength(dialog) {
7910
9310
  return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
@@ -7972,6 +9372,12 @@ export function mountTui(ctx, config) {
7972
9372
  stopWaiting();
7973
9373
  await controller?.dispose();
7974
9374
  },
9375
+ async handleHangup() {
9376
+ await controller?.handleHangup();
9377
+ },
9378
+ disconnectPolicy() {
9379
+ return controller?.currentDisconnectPolicy() ?? 'pause';
9380
+ },
7975
9381
  };
7976
9382
  }
7977
9383
  //# sourceMappingURL=tui.js.map