dsh-ssh-tui 0.4.1 → 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 +1759 -361
  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);
@@ -3332,18 +4083,19 @@ export class SshTui {
3332
4083
  collapsibleRows() {
3333
4084
  const compact = this.isCompactView();
3334
4085
  if (compact) {
3335
- const groups = compactToolGroups(this.rows.filter((row) => row.kind === 'tool'));
3336
4086
  const rows = this.rows.filter((row) => row.kind === 'subagent'
3337
4087
  || row.kind === 'plan'
3338
4088
  || row.kind === 'question'
3339
4089
  || row.kind === 'goal'
3340
4090
  || row.kind === 'compaction');
3341
- const callAnchor = groups.calls.at(-1);
3342
- const editAnchor = groups.edits.at(-1);
3343
- if (callAnchor !== undefined)
3344
- rows.push(callAnchor);
3345
- if (editAnchor !== undefined)
3346
- rows.push(editAnchor);
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
+ }
3347
4099
  return rows;
3348
4100
  }
3349
4101
  const rows = this.rows.filter((row) => row.kind === 'reasoning'
@@ -3364,26 +4116,27 @@ export class SshTui {
3364
4116
  return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
3365
4117
  }
3366
4118
  /**
3367
- * Codex wait card: shown while the turn is running. Thinking/reply streams
3368
- * 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).
3369
4123
  */
3370
4124
  waitCardVisible() {
3371
4125
  if (this.agent.status !== 'running')
3372
4126
  return false;
4127
+ if (this.streaming?.text)
4128
+ return false;
3373
4129
  if (this.rows.some(row => row.kind === 'compaction' && row.status === 'running'))
3374
4130
  return false;
3375
4131
  if (this.dialog?.kind === 'questions' || this.dialog?.kind === 'confirm')
3376
4132
  return false;
3377
4133
  return true;
3378
4134
  }
3379
- beginWait(prompt) {
4135
+ beginWait() {
3380
4136
  this.waitStartedAt = Date.now();
3381
- const trimmed = prompt?.replace(/\s+/gu, ' ').trim();
3382
- this.waitPrompt = trimmed === undefined || trimmed === '' ? this.waitPrompt : trimmed;
3383
4137
  }
3384
4138
  endWait() {
3385
4139
  this.waitStartedAt = undefined;
3386
- this.waitPrompt = undefined;
3387
4140
  }
3388
4141
  waitCardSource() {
3389
4142
  const liveTool = this.rows.findLast((row) => row.kind === 'tool' && (row.status === undefined || row.status === 'running'));
@@ -3394,8 +4147,6 @@ export class SshTui {
3394
4147
  ? {}
3395
4148
  : { toolTitle: liveSub.label, toolSummary: liveSub.lastActivity }),
3396
4149
  ...(this.streaming?.reasoning ? { reasoning: this.streaming.reasoning } : {}),
3397
- ...(this.streaming?.text ? { reply: this.streaming.text } : {}),
3398
- ...(this.waitPrompt === undefined ? {} : { prompt: this.waitPrompt }),
3399
4150
  };
3400
4151
  }
3401
4152
  planShouldDefaultExpand(plan) {
@@ -3695,8 +4446,8 @@ export class SshTui {
3695
4446
  }
3696
4447
  toggleCard(target) {
3697
4448
  if (target.kind === 'tool' && !target.expanded) {
3698
- const width = Math.max(10, process.stdout.columns || 80);
3699
- 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());
3700
4451
  const body = toolBodyLines(target, Number.MAX_SAFE_INTEGER);
3701
4452
  const bodyRows = wrappedToolBodyLineCount(body, width);
3702
4453
  if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
@@ -3722,8 +4473,8 @@ export class SshTui {
3722
4473
  this.focusedRow = null;
3723
4474
  }
3724
4475
  else {
3725
- const width = Math.max(10, process.stdout.columns || 80);
3726
- 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());
3727
4478
  const workspace = this.workspaceRowsFor(width, height);
3728
4479
  for (const row of rows) {
3729
4480
  if (row.kind === 'tool') {
@@ -3747,8 +4498,8 @@ export class SshTui {
3747
4498
  if (row === undefined)
3748
4499
  return;
3749
4500
  if (row.kind === 'tool') {
3750
- const width = Math.max(10, process.stdout.columns || 80);
3751
- 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());
3752
4503
  const body = toolBodyLines(row, Number.MAX_SAFE_INTEGER);
3753
4504
  const bodyRows = wrappedToolBodyLineCount(body, width);
3754
4505
  if (!toolBodyFitsWorkspace(bodyRows, this.workspaceRowsFor(width, height))) {
@@ -3834,8 +4585,8 @@ export class SshTui {
3834
4585
  paint = () => {
3835
4586
  if (this.exiting)
3836
4587
  return;
3837
- const width = Math.max(10, process.stdout.columns || 80);
3838
- 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());
3839
4590
  if (this.dialog?.kind === 'inspect') {
3840
4591
  this.paintInspectOverlay(width, height);
3841
4592
  return;
@@ -3861,12 +4612,22 @@ export class SshTui {
3861
4612
  }
3862
4613
  };
3863
4614
  const compact = this.isCompactView();
3864
- const compactGroups = compact
3865
- ? compactToolGroups(this.rows.filter((row) => row.kind === 'tool'))
3866
- : undefined;
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;
3867
4622
  for (const row of this.rows) {
3868
4623
  if (compact && (row.kind === 'reasoning' || row.kind === 'prompt' || row.kind === 'tool'))
3869
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;
4630
+ }
3870
4631
  if (row.kind === 'brand-logo') {
3871
4632
  const variant = DEEPSEEK_LOGO_VARIANTS.find(candidate => candidate.width <= width - 2)
3872
4633
  ?? DEEPSEEK_LOGO_VARIANTS[DEEPSEEK_LOGO_VARIANTS.length - 1];
@@ -3901,12 +4662,16 @@ export class SshTui {
3901
4662
  focused,
3902
4663
  expanded: row.expanded,
3903
4664
  title: toolTitle(row.name) || row.title,
3904
- summary: row.summary,
4665
+ summary: this.toolCardSummary(row),
3905
4666
  status: row.status,
3906
4667
  command: row.command,
3907
4668
  signal: row.signal,
3908
4669
  exitCode: row.exitCode,
3909
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
+ : {}),
3910
4675
  });
3911
4676
  const headerSegments = this.color ? header.segments : [];
3912
4677
  if (!row.expanded) {
@@ -4074,6 +4839,13 @@ export class SshTui {
4074
4839
  continue;
4075
4840
  }
4076
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
+ }
4077
4849
  }
4078
4850
  if (this.streaming !== undefined) {
4079
4851
  if (!compact && this.showReasoning && this.streaming.reasoning !== '') {
@@ -4107,14 +4879,16 @@ export class SshTui {
4107
4879
  }
4108
4880
  }
4109
4881
  }
4110
- if (compact && compactGroups !== undefined) {
4111
- const editAnchor = compactGroups.edits.at(-1);
4112
- const callAnchor = compactGroups.calls.at(-1);
4113
- if (callAnchor !== undefined) {
4114
- this.paintCompactSummary(addDisplay, callAnchor, 'calls', compactGroups, width);
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);
4115
4888
  }
4116
- if (editAnchor !== undefined) {
4117
- this.paintCompactSummary(addDisplay, editAnchor, 'edits', compactGroups, width);
4889
+ const leading = compactBursts.find(burst => burst.after === undefined);
4890
+ if (leading !== undefined && !paintedLeadingCompact) {
4891
+ this.paintCompactBurst(addDisplay, leading.groups, width);
4118
4892
  }
4119
4893
  }
4120
4894
  if (this.waitCardVisible()) {
@@ -4127,8 +4901,8 @@ export class SshTui {
4127
4901
  ? `${spinner} ${shimmerText(copy.header, Date.now(), true)} ${this.styleLine('system', hint)}`
4128
4902
  : `${spinner} ${copy.header} ${hint}`;
4129
4903
  addDisplay(header);
4130
- if (copy.detail !== undefined && copy.detail !== '') {
4131
- addDisplay(this.styleLine('system', ` └ ${copy.detail}`));
4904
+ for (const line of wrapWaitDetails(copy.detail ?? '', width)) {
4905
+ addDisplay(this.styleLine('system', line));
4132
4906
  }
4133
4907
  }
4134
4908
  const dialogLines = [];
@@ -4145,32 +4919,47 @@ export class SshTui {
4145
4919
  else if (this.dialog.kind === 'onboarding') {
4146
4920
  const ob = this.onboarding;
4147
4921
  if (ob !== undefined) {
4148
- const template = providerTemplates()[ob.providerType];
4922
+ const template = onboardTemplate(ob);
4149
4923
  const providerLabel = `${template.label}${template.defaultBaseUrl === '' ? '' : `(${template.defaultBaseUrl})`}`;
4150
4924
  switch (ob.step) {
4151
- case 'provider':
4925
+ case 'provider': {
4926
+ const options = this.mergedProviderEntries(ob);
4152
4927
  addDialog(t('onboard.title'));
4153
- addDialog(t('onboard.opt1'));
4154
- addDialog(t('onboard.opt2'));
4155
- addDialog(t('onboard.opt3'));
4156
- addDialog(t('onboard.opt4'));
4157
- 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 }));
4158
4947
  addDialog(t('onboard.pickHint'));
4159
4948
  break;
4949
+ }
4160
4950
  case 'id':
4161
4951
  addDialog(t('onboard.providerLine', { label: providerLabel }));
4162
4952
  addDialog(t('onboard.idPrompt'));
4163
4953
  addDialog(t('onboard.default', { value: template.defaultId }));
4164
4954
  addDialog(t('onboard.enterEsc'));
4165
4955
  break;
4166
- case 'key':
4167
- addDialog(t('onboard.providerLine', { label: providerLabel }));
4168
- addDialog(t('onboard.keyPrompt'));
4169
- addDialog(t('onboard.enterEsc'));
4170
- break;
4171
4956
  case 'base-url':
4172
4957
  addDialog(t('onboard.providerLine', { label: providerLabel }));
4173
- 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
+ }));
4174
4963
  addDialog(t('onboard.enterEsc'));
4175
4964
  break;
4176
4965
  case 'models':
@@ -4181,6 +4970,8 @@ export class SshTui {
4181
4970
  : t('onboard.default', { value: template.defaultModels.join(', ') }));
4182
4971
  if (template.api !== undefined)
4183
4972
  addDialog(t('onboard.ctrlF'));
4973
+ if (ob.providerType === 'catalog')
4974
+ addDialog(t('onboard.modelsCatalogHint'));
4184
4975
  addDialog(t('onboard.enterEsc'));
4185
4976
  break;
4186
4977
  case 'confirm':
@@ -4188,7 +4979,9 @@ export class SshTui {
4188
4979
  addDialog(t('onboard.confirmProvider', { label: providerLabel }));
4189
4980
  addDialog(` Provider ID: ${ob.providerId}`);
4190
4981
  addDialog(t('onboard.confirmBase', { url: ob.baseUrl === '' ? (template.defaultBaseUrl || t('onboard.defaultParen')) : ob.baseUrl }));
4191
- 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
+ }));
4192
4985
  addDialog(t('onboard.confirmModels', { list: formatModelList(ob.models, 8) }));
4193
4986
  addDialog(t('onboard.confirmKey', {
4194
4987
  head: sliceCodePoints(ob.key, 6),
@@ -4222,17 +5015,27 @@ export class SshTui {
4222
5015
  }
4223
5016
  const options = d.question.options ?? [];
4224
5017
  const approve = d.question.intent?.approve;
4225
- 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;
4226
5026
  const marker = d.selected.has(index) ? '●' : '○';
4227
- const key = QUESTION_OPTION_KEYS[index] ?? '?';
5027
+ const key = QUESTION_OPTION_KEYS[index] ?? '';
5028
+ const focused = index === d.cursor ? '›' : ' ';
4228
5029
  const recommended = option.label === approve ? '(推荐)' : '';
4229
5030
  const extra = option.description === undefined ? '' : ` — ${option.description}`;
4230
- addDialog(` ${key} ${marker} ${option.label}${recommended}${extra}`);
5031
+ addDialog(` ${focused}${key} ${marker} ${option.label}${recommended}${extra}`);
4231
5032
  }
5033
+ if (end < options.length)
5034
+ addDialog(` ↓ 还有 ${options.length - end} 项`);
4232
5035
  if (options.length === 0) {
4233
5036
  addDialog(' (自由输入:在下方输入后按 Enter)');
4234
5037
  }
4235
- addDialog(` ${d.question.multiSelect === true ? '数字/字母切换,Enter 提交' : '数字/字母选择,Enter 提交'},Esc 取消`);
5038
+ addDialog(` ${d.question.multiSelect === true ? '↑/↓ 或数字/字母切换,Enter 提交' : '↑/↓ 或数字/字母选择,Enter 提交'},Esc 取消`);
4236
5039
  }
4237
5040
  }
4238
5041
  const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
@@ -4248,13 +5051,24 @@ export class SshTui {
4248
5051
  this.suggestionIndex = Math.max(0, this.commandSuggestions.length - 1);
4249
5052
  }
4250
5053
  const suggestionLines = [];
4251
- 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;
4252
5063
  const marker = index === this.suggestionIndex ? '›' : ' ';
4253
5064
  const line = ` ${marker} /${command.name.padEnd(14)} ${command.description}${command.local ? '' : ' (dsh)'}`;
4254
5065
  suggestionLines.push(index === this.suggestionIndex && this.color
4255
5066
  ? `\x1b[7m${fitLine(line)}\x1b[27m`
4256
5067
  : this.styleLine('system', fitLine(line)));
4257
5068
  }
5069
+ if (suggestionEnd < this.commandSuggestions.length) {
5070
+ suggestionLines.push(this.styleLine('system', fitLine(` ${t('suggest.moreBelow', { count: this.commandSuggestions.length - suggestionEnd })}`)));
5071
+ }
4258
5072
  const promptPlain = this.color ? '❯ ' : '> ';
4259
5073
  const prompt = this.color ? `\x1b[36m${promptPlain.trimEnd()}\x1b[0m ` : promptPlain;
4260
5074
  const promptWidth = displayWidth(promptPlain);
@@ -4300,7 +5114,7 @@ export class SshTui {
4300
5114
  : [];
4301
5115
  const inputDivider = this.styleLine('system', repeatToWidth('─', width));
4302
5116
  const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
4303
- const available = Math.max(1, height - reserved - dialogLines.length);
5117
+ const available = Math.max(0, height - reserved - dialogLines.length);
4304
5118
  const maxOffset = Math.max(0, display.length - available);
4305
5119
  const reveal = this.pendingReveal;
4306
5120
  if (reveal !== undefined) {
@@ -4358,11 +5172,14 @@ export class SshTui {
4358
5172
  const idleMs = Date.now() - this.lastActivity;
4359
5173
  const livePlan = this.findLivePlanRow();
4360
5174
  const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
5175
+ const current = this.selectionRef?.current;
5176
+ const provider = this.currentProviderId();
4361
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;
4362
5181
  const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
4363
5182
  const compacting = this.rows.some(row => row.kind === 'compaction' && row.status === 'running');
4364
- const current = this.selectionRef?.current;
4365
- const provider = this.currentProviderId();
4366
5183
  const parentModel = current?.model ?? this.agent.options.model ?? '';
4367
5184
  const sub = this.subagentSelection.current;
4368
5185
  const footer = {
@@ -4390,6 +5207,7 @@ export class SshTui {
4390
5207
  ...(quotaWindow === undefined || this.quotaSnapshot === undefined || this.quotaSnapshot.provider !== provider
4391
5208
  ? {}
4392
5209
  : { quotaCode: this.quotaSnapshot.plan, quotaPercent: quotaWindow.remainingPercent }),
5210
+ ...(balanceText === undefined ? {} : { balanceText }),
4393
5211
  ...(this.searchHits.length > 0 && this.searchIndex >= 0
4394
5212
  ? { search: { index: this.searchIndex, total: this.searchHits.length } }
4395
5213
  : {}),
@@ -4440,6 +5258,7 @@ export class SshTui {
4440
5258
  this.suggestionIndex,
4441
5259
  this.activeSubagents.size,
4442
5260
  this.dialog?.kind ?? '',
5261
+ this.dialog?.kind === 'questions' ? String(this.dialog.cursor) : '',
4443
5262
  planDockLines.join('\n'),
4444
5263
  String(chromeStart),
4445
5264
  ].join('\x1f');
@@ -4491,19 +5310,34 @@ export class SshTui {
4491
5310
  if (!input.startsWith('/'))
4492
5311
  return [];
4493
5312
  const prefix = input.slice(1).toLowerCase();
4494
- const dsh = (this.ctx.get('commands')?.list(this.agent) ?? []).map(command => ({
4495
- name: command.name,
4496
- description: command.input?.images === true ? `${command.description}(可附图)` : command.description,
4497
- local: false,
4498
- }));
4499
- const all = [
4500
- ...localizedCommands().map(command => ({ name: command.name, description: command.description, local: true })),
4501
- ...dsh,
4502
- ];
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];
4503
5331
  const filtered = prefix === ''
4504
5332
  ? all
4505
5333
  : all.filter(command => command.name.startsWith(prefix) || command.name.includes(prefix));
4506
- 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
+ });
4507
5341
  }
4508
5342
  suggestionsVisible() {
4509
5343
  return this.commandSuggestions.length > 0 && this.dialog === undefined;
@@ -4606,11 +5440,13 @@ export class SshTui {
4606
5440
  const disabled = process.env.DSH_TUI_NO_BELL === '1' || process.env.DSH_TUI_NO_BELL === 'true';
4607
5441
  if (disabled)
4608
5442
  return;
4609
- process.stdout.write('\x07');
5443
+ this.write('\x07');
4610
5444
  }
4611
5445
  render = () => {
4612
5446
  if (!this.dirty || this.exiting)
4613
5447
  return;
5448
+ if ((this.displayDetached || this.headlessDisplay) && this.displayHost?.attached !== true)
5449
+ return;
4614
5450
  if (this.agent.status === 'running'
4615
5451
  && Date.now() - this.lastActivity > STALL_WARNING_MS
4616
5452
  && !this.stalledWarningShown
@@ -4688,7 +5524,7 @@ export class SshTui {
4688
5524
  if (sourceKind === 'user') {
4689
5525
  this.pushRow({ kind: 'user', text: `❯ ${text}` });
4690
5526
  if (!this.replaying)
4691
- this.beginWait(text);
5527
+ this.beginWait();
4692
5528
  }
4693
5529
  else if (isPromptInjectionMessage(sourceKind, text, source.plugin)) {
4694
5530
  this.pushPromptInjection(text, source.plugin);
@@ -4784,21 +5620,34 @@ export class SshTui {
4784
5620
  this.pendingToolTimes.set(String(event.data.callId), event.time);
4785
5621
  if (!HIDDEN_TOOL_NAMES.has(event.data.name)) {
4786
5622
  const present = presentToolCall(event.data.name, event.data.arguments);
4787
- const row = {
4788
- kind: 'tool',
4789
- callId: event.data.callId,
4790
- name: event.data.name,
4791
- args: event.data.arguments,
4792
- status: 'running',
4793
- output: '',
4794
- title: present.title,
4795
- summary: present.summary,
4796
- ...present.command === undefined ? {} : { command: present.command },
4797
- ...present.cwd === undefined ? {} : { cwd: present.cwd },
4798
- ...present.diff === undefined ? {} : { diff: present.diff },
4799
- expanded: false,
4800
- };
4801
- 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
+ }
4802
5651
  }
4803
5652
  if (event.data.name === 'exit_plan_mode') {
4804
5653
  const markdown = planMarkdownFromArgs(event.data.arguments);
@@ -4821,7 +5670,9 @@ export class SshTui {
4821
5670
  if (row !== undefined) {
4822
5671
  const metaDiffs = diffMetaDiffs(event.data.meta);
4823
5672
  if (metaDiffs !== null) {
4824
- row.diff = metaDiffs;
5673
+ row.diff = (row.repeats ?? 1) > 1 && row.diff !== undefined && row.diff.length > 0
5674
+ ? [...row.diff, ...metaDiffs]
5675
+ : metaDiffs;
4825
5676
  }
4826
5677
  const isShell = SHELL_TOOL_NAMES.has(row.name);
4827
5678
  if (isShell) {
@@ -4839,6 +5690,10 @@ export class SshTui {
4839
5690
  || event.data.message.content[0]?.isError === true
4840
5691
  || (isShell && ((row.exitCode !== undefined && row.exitCode !== 0) || row.signal !== undefined));
4841
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
+ }
4842
5697
  }
4843
5698
  else {
4844
5699
  const present = presentToolCall(event.data.message.source.callId, '');
@@ -4890,6 +5745,14 @@ export class SshTui {
4890
5745
  this.markDirty();
4891
5746
  break;
4892
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;
4893
5756
  case 'turn/start':
4894
5757
  this.stalledWarningShown = false;
4895
5758
  this.llmRetry = undefined;
@@ -5169,19 +6032,48 @@ export class SshTui {
5169
6032
  });
5170
6033
  this.markDirty();
5171
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
+ }
5172
6059
  handleCommandDone(data) {
5173
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);
5174
6064
  const kind = typeof payload.kind === 'string' ? payload.kind : '';
5175
6065
  const text = typeof payload.text === 'string' ? payload.text.trim() : '';
5176
6066
  if (kind === 'error') {
5177
- this.pushRow({ kind: 'error', text: text === '' ? '命令失败。' : text });
6067
+ const errText = this.formatCommandText(text);
6068
+ this.pushRow({ kind: 'error', text: errText === '' ? t('command.failed') : errText });
5178
6069
  if (this.status.startsWith('压缩'))
5179
6070
  this.status = this.agent.status === 'running' ? 'running' : 'idle';
5180
6071
  this.markDirty();
5181
6072
  return;
5182
6073
  }
5183
- if (text !== '')
5184
- this.pushRow({ kind: 'system', text });
6074
+ if (text !== '') {
6075
+ this.pushRow({ kind: 'system', text: this.formatCommandText(text) });
6076
+ }
5185
6077
  this.markDirty();
5186
6078
  }
5187
6079
  handleGoalChange(data) {
@@ -5377,7 +6269,170 @@ export class SshTui {
5377
6269
  this.markDirty();
5378
6270
  };
5379
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
+ }
5380
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
+ }
5381
6436
  const agentLabel = request.agent.id === this.agent.id
5382
6437
  ? '当前会话'
5383
6438
  : `子代理 ${request.agent.id}`;
@@ -5400,6 +6455,8 @@ export class SshTui {
5400
6455
  });
5401
6456
  };
5402
6457
  handleUserQuestions = async (request) => {
6458
+ if (!this.hasLiveDisplay())
6459
+ await this.waitForLiveDisplay(request.signal);
5403
6460
  const answers = [];
5404
6461
  const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
5405
6462
  ? undefined
@@ -5544,6 +6601,7 @@ export class SshTui {
5544
6601
  index,
5545
6602
  total,
5546
6603
  selected: new Set(preselected !== undefined && preselected >= 0 ? [preselected] : []),
6604
+ cursor: preselected !== undefined && preselected >= 0 ? preselected : 0,
5547
6605
  resolve: (selection) => {
5548
6606
  this.settleQuestion(dialog, () => resolve(selection));
5549
6607
  },
@@ -5645,13 +6703,9 @@ export class SshTui {
5645
6703
  return false;
5646
6704
  }
5647
6705
  }
5648
- /** How many endpoint-listed models fit on one picker page alongside navigation. */
5649
- MODEL_PAGE_SIZE = 7;
5650
- MODEL_PAGE_PREV = '« 上一页';
5651
- MODEL_PAGE_NEXT = '» 下一页';
5652
6706
  /**
5653
- * One pick across a possibly long model list, paging through the digit
5654
- * 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 ↑/↓.
5655
6709
  */
5656
6710
  async pickModelOption(modelOptions, provider, sourceLabel, currentModel) {
5657
6711
  const seen = new Set();
@@ -5663,40 +6717,19 @@ export class SshTui {
5663
6717
  });
5664
6718
  if (unique.length === 0)
5665
6719
  return undefined;
5666
- let offset = 0;
5667
- for (;;) {
5668
- const page = unique.slice(offset, offset + this.MODEL_PAGE_SIZE);
5669
- const hasPrev = offset > 0;
5670
- const hasNext = offset + this.MODEL_PAGE_SIZE < unique.length;
5671
- const pageCount = Math.max(1, Math.ceil(unique.length / this.MODEL_PAGE_SIZE));
5672
- const currentPage = Math.floor(offset / this.MODEL_PAGE_SIZE) + 1;
5673
- 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 => ({
5674
6725
  label: option.label,
5675
6726
  description: option.id === currentModel ? '当前' : undefined,
5676
- }));
5677
- if (hasPrev)
5678
- options.push({ label: this.MODEL_PAGE_PREV, description: undefined });
5679
- if (hasNext)
5680
- options.push({ label: this.MODEL_PAGE_NEXT, description: undefined });
5681
- const currentIndex = page.findIndex(option => option.id === currentModel && option.id !== '__switch_provider__');
5682
- const answer = await this.askQuestion({
5683
- id: 'model-pick',
5684
- question: `选择模型(提供商 ${provider} · ${sourceLabel}${hasPrev || hasNext ? `,第 ${currentPage}/${pageCount} 页` : ''})`,
5685
- options,
5686
- }, 0, 1, currentIndex >= 0 ? currentIndex : undefined);
5687
- const picked = options.find(option => option.label === answer.selected[0]);
5688
- if (picked === undefined)
5689
- return undefined;
5690
- if (picked.label === this.MODEL_PAGE_NEXT) {
5691
- offset += this.MODEL_PAGE_SIZE;
5692
- continue;
5693
- }
5694
- if (picked.label === this.MODEL_PAGE_PREV) {
5695
- offset = Math.max(0, offset - this.MODEL_PAGE_SIZE);
5696
- continue;
5697
- }
5698
- return page.find(option => option.label === picked.label);
5699
- }
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);
5700
6733
  }
5701
6734
  /** Live adapter routes the TUI can switch to, plus the current selection. */
5702
6735
  listSelectableProviders() {
@@ -5868,38 +6901,38 @@ export class SshTui {
5868
6901
  effortOptions = [];
5869
6902
  }
5870
6903
  if (effortOptions.length === 0 && providerUsesLocalOAuth(provider)) {
5871
- effortOptions = modelId === 'grok-4.6'
5872
- ? [
5873
- { id: 'off', label: 'Off' },
5874
- { id: 'low', label: 'Low' },
5875
- { id: 'medium', label: 'Medium' },
5876
- { id: 'high', label: 'High' },
5877
- { id: 'xhigh', label: 'Extra high' },
5878
- ]
5879
- : [
5880
- { id: 'off', label: 'Off' },
5881
- { id: 'low', label: 'Low' },
5882
- { id: 'medium', label: 'Medium' },
5883
- { id: 'high', label: 'High' },
5884
- ];
5885
- }
5886
- let effort;
5887
- if (effortOptions.length > 0) {
5888
- const rememberedEffort = this.rememberedRoute(provider)?.reasoningEffort ?? preferredEffort ?? '';
5889
- const currentEffort = current?.provider === provider
5890
- ? String(current?.reasoningEffort ?? '')
5891
- : rememberedEffort;
5892
- const currentIndex = Math.max(0, effortOptions.findIndex(option => option.id === currentEffort));
5893
- const effortAnswer = await this.askQuestion({
5894
- id: 'effort-pick',
5895
- question: `选择思考强度(${modelId})`,
5896
- options: effortOptions.map(option => ({
5897
- label: option.label,
5898
- description: option.id === currentEffort ? '当前' : undefined,
5899
- })),
5900
- }, 0, 1, currentIndex);
5901
- effort = effortOptions.find(option => option.label === effortAnswer.selected[0])?.id;
6904
+ effortOptions = localOAuthEffortChoices(modelId);
5902
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;
5903
6936
  const next = {
5904
6937
  provider,
5905
6938
  model: modelId,
@@ -5911,9 +6944,11 @@ export class SshTui {
5911
6944
  await this.persistDefaultSelection(next);
5912
6945
  await this.rememberRoute(next);
5913
6946
  const kind = describeProviderRoute(provider);
6947
+ const effortText = effort ?? t('effort.defaultShort');
6948
+ const note = isUndeclared && effort !== undefined ? t('effort.manualNote') : '';
5914
6949
  this.pushRow({
5915
6950
  kind: 'system',
5916
- text: `已切换到 ${kind.kind}:${provider}/${modelId}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
6951
+ text: t('effort.switchedModel', { kind: kind.kind, provider, model: modelId, effort: effortText, note }),
5917
6952
  });
5918
6953
  const listedIds = listed.filter(id => id !== '__switch_provider__' && id !== '');
5919
6954
  const previousProvider = current?.provider ?? this.agent.options.provider ?? this.providerName;
@@ -6010,10 +7045,16 @@ export class SshTui {
6010
7045
  });
6011
7046
  }
6012
7047
  clearQuotaForProvider(provider) {
6013
- 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)
6014
7051
  return;
6015
- this.quotaSnapshot = undefined;
6016
- this.quotaAlerted.clear();
7052
+ if (!quotaSame) {
7053
+ this.quotaSnapshot = undefined;
7054
+ this.quotaAlerted.clear();
7055
+ }
7056
+ if (!balanceSame)
7057
+ this.balanceSnapshot = undefined;
6017
7058
  this.quotaStepsSinceRefresh = 0;
6018
7059
  this.markDirty();
6019
7060
  }
@@ -6101,8 +7142,98 @@ export class SshTui {
6101
7142
  });
6102
7143
  this.markDirty();
6103
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
+ }
6104
7235
  /** /subeffort: pick the reasoning effort subagent children use. */
6105
- async runSubeffortCommand() {
7236
+ async runSubeffortCommand(arg) {
6106
7237
  const provider = this.effectiveSubagentProvider();
6107
7238
  const current = this.subagentSelection.current;
6108
7239
  const llm = this.ctx.get('llm');
@@ -6114,30 +7245,58 @@ export class SshTui {
6114
7245
  catch {
6115
7246
  effortOptions = [];
6116
7247
  }
6117
- 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);
6118
7269
  this.pushRow({
6119
7270
  kind: 'system',
6120
- 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')}`,
6121
7274
  });
6122
7275
  this.markDirty();
6123
7276
  return;
6124
7277
  }
7278
+ const isUndeclared = effortOptions.length === 0;
7279
+ const available = isUndeclared ? undeclaredEffortChoices() : effortOptions;
6125
7280
  const choices = [
6126
- { id: undefined, label: SUBAGENT_DEFAULT_EFFORT_LABEL() },
6127
- ...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 })),
6128
7287
  ];
7288
+ const currentEffort = current.reasoningEffort === undefined ? undefined : String(current.reasoningEffort);
7289
+ const currentIndex = Math.max(0, choices.findIndex(c => c.id === currentEffort));
6129
7290
  const answer = await this.askQuestion({
6130
7291
  id: 'subagent-effort-pick',
6131
- question: `选择子代理思考强度(${provider}/${current.model})`,
7292
+ question: isUndeclared
7293
+ ? t('effort.pickSubUndeclared', { provider, model: current.model })
7294
+ : t('effort.pickSub', { provider, model: current.model }),
6132
7295
  options: choices.map(option => ({
6133
7296
  label: option.label,
6134
- description: option.id === undefined
6135
- ? '清空自定义强度,跟随提供商/模型默认'
6136
- : option.id === String(current.reasoningEffort)
6137
- ? '当前'
6138
- : undefined,
7297
+ description: option.id === currentEffort ? t('disconnect.current') : option.desc,
6139
7298
  })),
6140
- });
7299
+ }, 0, 1, currentIndex);
6141
7300
  const picked = choices.find(option => option.label === answer.selected[0]);
6142
7301
  if (picked === undefined)
6143
7302
  return;
@@ -6151,8 +7310,8 @@ export class SshTui {
6151
7310
  this.pushRow({
6152
7311
  kind: 'system',
6153
7312
  text: `${picked.id === undefined
6154
- ? '子代理思考强度已恢复为提供商默认。'
6155
- : `子代理思考强度已切换:${picked.id}。`}${persisted ? '' : '(仅当前会话)'}`,
7313
+ ? t('effort.subDefault')
7314
+ : t('effort.subSwitched', { effort: picked.id })}${persisted ? '' : t('effort.sessionOnly')}`,
6156
7315
  });
6157
7316
  this.markDirty();
6158
7317
  }
@@ -6219,6 +7378,37 @@ export class SshTui {
6219
7378
  this.pushRow({ kind: 'system', text: t('view.switched', { name: next === 'compact' ? t('view.compact') : t('view.detailed') }) });
6220
7379
  this.markDirty();
6221
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
+ }
6222
7412
  /** /mode: pick an agent preset (standard / minimal / ptc / cordis / routing-suite / ...). */
6223
7413
  async runModeCommand() {
6224
7414
  const agentPresets = this.ctx.get('agentPresets');
@@ -6245,7 +7435,7 @@ export class SshTui {
6245
7435
  if (selected === undefined)
6246
7436
  return;
6247
7437
  const selectedName = selected.name ?? selected.id;
6248
- 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');
6249
7439
  if (!hasWork) {
6250
7440
  await agentPresets.recompose(this.agent.ctx, selected.id);
6251
7441
  this.presetId = selected.id;
@@ -6390,9 +7580,8 @@ export class SshTui {
6390
7580
  const quota = await this.refreshQuota({ reason: 'command', announce: true });
6391
7581
  if (quota !== undefined)
6392
7582
  return;
6393
- const balance = await this.fetchAccountBalance(this.currentProviderId());
6394
- if (balance !== undefined) {
6395
- this.pushRow({ kind: 'system', text: formatAccountBalance(balance) });
7583
+ if (this.balanceSnapshot !== undefined) {
7584
+ this.pushRow({ kind: 'system', text: formatAccountBalance(this.balanceSnapshot) });
6396
7585
  return;
6397
7586
  }
6398
7587
  const provider = this.currentProviderId();
@@ -6441,14 +7630,37 @@ export class SshTui {
6441
7630
  const provider = this.currentProviderId();
6442
7631
  const snapshot = await this.fetchQuotaSnapshot(provider);
6443
7632
  if (snapshot !== undefined) {
7633
+ this.balanceSnapshot = undefined;
6444
7634
  this.applyQuotaSnapshot(snapshot, options.announce);
6445
7635
  return snapshot;
6446
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
+ }
6447
7655
  if (this.quotaSnapshot !== undefined && this.quotaSnapshot.provider !== provider) {
6448
7656
  this.quotaSnapshot = undefined;
6449
7657
  this.quotaAlerted.clear();
6450
7658
  this.markDirty();
6451
7659
  }
7660
+ if (this.balanceSnapshot !== undefined && this.balanceSnapshot.provider !== provider) {
7661
+ this.balanceSnapshot = undefined;
7662
+ this.markDirty();
7663
+ }
6452
7664
  return undefined;
6453
7665
  }
6454
7666
  finally {
@@ -6586,6 +7798,12 @@ export class SshTui {
6586
7798
  if (this.dialog?.kind === 'inspect') {
6587
7799
  this.scrollInspectOrTranscript(-1);
6588
7800
  }
7801
+ else if (this.moveQuestionCursor(-1)) {
7802
+ return;
7803
+ }
7804
+ else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(-1)) {
7805
+ return;
7806
+ }
6589
7807
  else if (this.suggestionsVisible()) {
6590
7808
  this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
6591
7809
  this.markDirty();
@@ -6601,6 +7819,12 @@ export class SshTui {
6601
7819
  if (this.dialog?.kind === 'inspect') {
6602
7820
  this.scrollInspectOrTranscript(1);
6603
7821
  }
7822
+ else if (this.moveQuestionCursor(1)) {
7823
+ return;
7824
+ }
7825
+ else if (this.dialog?.kind === 'onboarding' && this.moveProviderCursor(1)) {
7826
+ return;
7827
+ }
6604
7828
  else if (this.suggestionsVisible()) {
6605
7829
  this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
6606
7830
  this.markDirty();
@@ -6641,11 +7865,11 @@ export class SshTui {
6641
7865
  return;
6642
7866
  }
6643
7867
  if (combined === '\x1b[5~') {
6644
- this.scrollInspectOrTranscript(Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
7868
+ this.scrollInspectOrTranscript(Math.max(3, Math.floor(this.screenRows() / 2)));
6645
7869
  return;
6646
7870
  }
6647
7871
  if (combined === '\x1b[6~') {
6648
- this.scrollInspectOrTranscript(-Math.max(3, Math.floor((process.stdout.rows || 24) / 2)));
7872
+ this.scrollInspectOrTranscript(-Math.max(3, Math.floor(this.screenRows() / 2)));
6649
7873
  return;
6650
7874
  }
6651
7875
  if (parseCursorPositionReply(combined) !== undefined)
@@ -6776,9 +8000,10 @@ export class SshTui {
6776
8000
  const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
6777
8001
  if (normalized === '')
6778
8002
  return;
8003
+ this.leaveHistoryBrowse();
6779
8004
  this.input = `${this.input.slice(0, this.cursor)}${normalized}${this.input.slice(this.cursor)}`;
6780
8005
  this.cursor += normalized.length;
6781
- const cols = Math.max(10, process.stdout.columns || 80);
8006
+ const cols = Math.max(10, this.screenColumns());
6782
8007
  const lineWidth = Math.max(1, cols - 2);
6783
8008
  if (normalized.includes('\n') || displayWidth(this.input) > lineWidth)
6784
8009
  this.inputFolded = true;
@@ -6852,12 +8077,14 @@ export class SshTui {
6852
8077
  this.markDirty();
6853
8078
  return;
6854
8079
  case '\x15':
8080
+ this.leaveHistoryBrowse();
6855
8081
  this.input = '';
6856
8082
  this.cursor = 0;
6857
8083
  this.inputFolded = false;
6858
8084
  this.markDirty();
6859
8085
  return;
6860
8086
  case '\x0b':
8087
+ this.leaveHistoryBrowse();
6861
8088
  this.input = this.input.slice(0, this.cursor);
6862
8089
  this.markDirty();
6863
8090
  return;
@@ -6910,11 +8137,27 @@ export class SshTui {
6910
8137
  return;
6911
8138
  }
6912
8139
  if (char >= ' ' && char !== '\x7f') {
8140
+ this.leaveHistoryBrowse();
6913
8141
  this.input = `${this.input.slice(0, this.cursor)}${char}${this.input.slice(this.cursor)}`;
6914
8142
  this.cursor += char.length;
6915
8143
  this.markDirty();
6916
8144
  }
6917
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
+ }
6918
8161
  handleDialogChar(text) {
6919
8162
  const dialog = this.dialog;
6920
8163
  if (dialog === undefined)
@@ -6941,6 +8184,7 @@ export class SshTui {
6941
8184
  const key = text.toLowerCase();
6942
8185
  const index = QUESTION_OPTION_KEYS.indexOf(key);
6943
8186
  if (index >= 0 && index < (dialog.question.options?.length ?? 0)) {
8187
+ dialog.cursor = index;
6944
8188
  if (dialog.question.multiSelect === true) {
6945
8189
  if (dialog.selected.has(index))
6946
8190
  dialog.selected.delete(index);
@@ -6984,31 +8228,86 @@ export class SshTui {
6984
8228
  this.markDirty();
6985
8229
  }
6986
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
+ }
6987
8257
  handleOnboardingChar(text) {
6988
8258
  const state = this.onboarding;
6989
8259
  if (state === undefined)
6990
8260
  return;
6991
8261
  switch (state.step) {
6992
- case 'provider':
6993
- {
6994
- const selected = text === '1' ? 'official'
6995
- : text === '2' ? 'opencode-go'
6996
- : text === '3' ? 'openai-completions'
6997
- : text === '4' ? 'openai-responses'
6998
- : text === '5' ? 'anthropic-messages'
6999
- : undefined;
7000
- if (selected !== undefined) {
7001
- state.providerType = selected;
7002
- state.providerId = '';
7003
- state.baseUrl = '';
7004
- state.key = '';
7005
- state.models = [];
7006
- this.input = '';
7007
- 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);
7008
8284
  this.advanceOnboarding();
7009
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();
7010
8308
  }
7011
8309
  return;
8310
+ }
7012
8311
  case 'id':
7013
8312
  case 'base-url':
7014
8313
  case 'key':
@@ -7020,7 +8319,7 @@ export class SshTui {
7020
8319
  if (text === '\r' || text === '\n') {
7021
8320
  const value = this.input.trim();
7022
8321
  if (state.step === 'id') {
7023
- const template = providerTemplates()[state.providerType];
8322
+ const template = onboardTemplate(state);
7024
8323
  const id = value === '' ? template.defaultId : value;
7025
8324
  if (!/^[a-z0-9][a-z0-9-]*$/u.test(id)) {
7026
8325
  this.pushRow({ kind: 'error', text: 'Provider ID 只能包含小写字母、数字和连字符,且不能以连字符开头。' });
@@ -7030,7 +8329,7 @@ export class SshTui {
7030
8329
  state.providerId = id;
7031
8330
  }
7032
8331
  else if (state.step === 'key') {
7033
- if (value === '') {
8332
+ if (value === '' && state.providerType !== 'catalog') {
7034
8333
  this.pushRow({ kind: 'error', text: 'API Key 不能为空,请重新输入。' });
7035
8334
  this.markDirty();
7036
8335
  return;
@@ -7038,7 +8337,7 @@ export class SshTui {
7038
8337
  state.key = value;
7039
8338
  }
7040
8339
  else if (state.step === 'models') {
7041
- const template = providerTemplates()[state.providerType];
8340
+ const template = onboardTemplate(state);
7042
8341
  const parsed = value === ''
7043
8342
  ? template.defaultModels
7044
8343
  : value.split(/[\s,,]+/u).filter(Boolean);
@@ -7117,12 +8416,12 @@ export class SshTui {
7117
8416
  const state = this.onboarding;
7118
8417
  if (state === undefined || state.step !== 'models')
7119
8418
  return;
7120
- const template = providerTemplates()[state.providerType];
8419
+ const template = onboardTemplate(state);
7121
8420
  const providerType = state.providerType;
7122
8421
  const baseUrl = state.baseUrl;
7123
8422
  const key = state.key;
7124
8423
  const baseURL = baseUrl === '' ? template.defaultBaseUrl : baseUrl;
7125
- if (baseURL === '') {
8424
+ if (baseURL === '' && providerType !== 'catalog') {
7126
8425
  this.pushRow({ kind: 'error', text: '请先填写 Base URL 再获取模型列表。' });
7127
8426
  this.markDirty();
7128
8427
  return;
@@ -7135,7 +8434,10 @@ export class SshTui {
7135
8434
  if (llm === undefined)
7136
8435
  throw new Error('llm 服务不可用');
7137
8436
  const discovered = await discoverProviderModels(llm, {
7138
- baseURL,
8437
+ ...(providerType === 'catalog' && state.catalog !== undefined && baseURL === ''
8438
+ ? {}
8439
+ : { baseURL }),
8440
+ ...(providerType === 'catalog' && state.catalog !== undefined ? { provider: state.catalog.id } : {}),
7139
8441
  ...(template.api === undefined ? {} : { api: template.api }),
7140
8442
  ...(key === '' ? {} : { apiKey: key }),
7141
8443
  }, AbortSignal.timeout(15_000));
@@ -7175,7 +8477,7 @@ export class SshTui {
7175
8477
  try {
7176
8478
  const credentials = this.ctx.get('credentials');
7177
8479
  const settings = this.ctx.get('settings');
7178
- const template = providerTemplates()[state.providerType];
8480
+ const template = onboardTemplate(state);
7179
8481
  if (state.providerType === 'official') {
7180
8482
  const envRef = 'DEEPSEEK_API_KEY';
7181
8483
  await this.saveCredential(credentials, envRef, state.key);
@@ -7234,15 +8536,24 @@ export class SshTui {
7234
8536
  mergedIds.push(id);
7235
8537
  }
7236
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 === '';
7237
8544
  const profile = {
7238
8545
  displayName: typeof existing?.displayName === 'string' && existing.displayName.trim() !== ''
7239
8546
  ? existing.displayName
7240
8547
  : template.label,
7241
- apiKeyEnv: envRef,
8548
+ ...(keyless ? {} : { apiKeyEnv: envRef }),
7242
8549
  api: template.api ?? existing?.api,
7243
- baseURL: state.baseUrl === ''
7244
- ? (typeof existing?.baseURL === 'string' && existing.baseURL !== '' ? existing.baseURL : template.defaultBaseUrl)
7245
- : 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
+ }),
7246
8557
  models: mergedIds.map(id => ({
7247
8558
  id,
7248
8559
  ...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
@@ -7261,7 +8572,7 @@ export class SshTui {
7261
8572
  }
7262
8573
  // Only store the key when its provider profile actually made it to
7263
8574
  // settings; otherwise the saved key points at an unusable route.
7264
- if (saved)
8575
+ if (saved && !keyless)
7265
8576
  await this.saveCredential(credentials, envRef, state.key);
7266
8577
  if (saved) {
7267
8578
  const selection = {
@@ -7515,6 +8826,8 @@ export class SshTui {
7515
8826
  if (text === '')
7516
8827
  return;
7517
8828
  if (text.startsWith('/')) {
8829
+ this.historyIndex = this.history.length;
8830
+ this.historyDraft = '';
7518
8831
  this.runCommand(text);
7519
8832
  return;
7520
8833
  }
@@ -7522,6 +8835,7 @@ export class SshTui {
7522
8835
  return;
7523
8836
  this.history.push(text);
7524
8837
  this.historyIndex = this.history.length;
8838
+ this.historyDraft = '';
7525
8839
  this.input = '';
7526
8840
  this.cursor = 0;
7527
8841
  this.inputFolded = false;
@@ -7535,7 +8849,7 @@ export class SshTui {
7535
8849
  this.agent.steer(message);
7536
8850
  }
7537
8851
  else {
7538
- this.beginWait(text);
8852
+ this.beginWait();
7539
8853
  this.agent.followup(message);
7540
8854
  }
7541
8855
  this.markDirty();
@@ -7546,10 +8860,16 @@ export class SshTui {
7546
8860
  switch (command) {
7547
8861
  case 'help': {
7548
8862
  const local = localizedCommands()
7549
- .filter(item => item.name !== 'help' && item.name !== 'exit')
8863
+ .filter(item => item.name !== 'help' && item.aliasOf === undefined)
7550
8864
  .map(item => `/${item.name.padEnd(12)} ${item.description}`);
8865
+ const seen = new Set(localizedCommands().map(item => item.name));
7551
8866
  const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
7552
- .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
+ });
7553
8873
  this.pushRow({
7554
8874
  kind: 'system',
7555
8875
  text: [
@@ -7563,6 +8883,7 @@ export class SshTui {
7563
8883
  t('help.intro5'),
7564
8884
  t('help.intro6'),
7565
8885
  t('help.intro7'),
8886
+ t('help.intro8'),
7566
8887
  ].join('\n'),
7567
8888
  });
7568
8889
  break;
@@ -7582,6 +8903,17 @@ export class SshTui {
7582
8903
  this.markDirty();
7583
8904
  });
7584
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;
7585
8917
  case 'provider':
7586
8918
  void this.runProviderCommand().catch((error) => {
7587
8919
  if (error instanceof UserQuestionError) {
@@ -7605,7 +8937,7 @@ export class SshTui {
7605
8937
  });
7606
8938
  break;
7607
8939
  case 'subeffort':
7608
- void this.runSubeffortCommand().catch((error) => {
8940
+ void this.runSubeffortCommand(arg).catch((error) => {
7609
8941
  if (error instanceof UserQuestionError) {
7610
8942
  this.pushRow({ kind: 'system', text: t('help.subeffortCancel') });
7611
8943
  }
@@ -7649,6 +8981,17 @@ export class SshTui {
7649
8981
  this.markDirty();
7650
8982
  });
7651
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;
7652
8995
  case 'find':
7653
8996
  this.runFindCommand(arg);
7654
8997
  break;
@@ -7658,7 +9001,6 @@ export class SshTui {
7658
9001
  this.streamingReasoning = undefined;
7659
9002
  this.thinkingStartedAt = undefined;
7660
9003
  this.waitStartedAt = undefined;
7661
- this.waitPrompt = undefined;
7662
9004
  this.focusedRow = null;
7663
9005
  this.searchHits = [];
7664
9006
  this.searchIndex = -1;
@@ -7689,6 +9031,7 @@ export class SshTui {
7689
9031
  activeSubagents: this.activeSubagents.size,
7690
9032
  plan: plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off',
7691
9033
  paint: formatLinkQualityChip(this.paintLink, this.paintIntervalMs, this.paintRttMs, this.paintProbed),
9034
+ disconnect: this.disconnectPolicy,
7692
9035
  waitingQuestions: waiting,
7693
9036
  ...(quota === undefined ? {} : { quota }),
7694
9037
  parentModel: model,
@@ -7759,6 +9102,40 @@ export class SshTui {
7759
9102
  this.markDirty();
7760
9103
  });
7761
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
+ }
7762
9139
  case 'setup':
7763
9140
  void this.runOnboarding();
7764
9141
  break;
@@ -7799,14 +9176,16 @@ export class SshTui {
7799
9176
  this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
7800
9177
  return;
7801
9178
  }
7802
- const result = execution.result;
7803
- if (result.kind === 'success') {
7804
- if (result.text !== undefined && result.text !== '') {
7805
- this.pushRow({ kind: 'system', text: result.text });
7806
- }
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) });
7807
9186
  }
7808
- else {
7809
- 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) });
7810
9189
  }
7811
9190
  }).catch((error) => {
7812
9191
  this.pushRow({ kind: 'error', text: `/${command} failed: ${errorChain(error)}` });
@@ -7854,6 +9233,7 @@ export class SshTui {
7854
9233
  backspace() {
7855
9234
  if (this.cursor === 0)
7856
9235
  return;
9236
+ this.leaveHistoryBrowse();
7857
9237
  const range = this.graphemeBefore(this.cursor);
7858
9238
  this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
7859
9239
  this.cursor = range.start;
@@ -7863,6 +9243,7 @@ export class SshTui {
7863
9243
  const range = this.graphemeAfter(this.cursor);
7864
9244
  if (range === undefined)
7865
9245
  return;
9246
+ this.leaveHistoryBrowse();
7866
9247
  this.input = `${this.input.slice(0, range.start)}${this.input.slice(range.end)}`;
7867
9248
  this.cursor = range.start;
7868
9249
  this.markDirty();
@@ -7898,6 +9279,8 @@ export class SshTui {
7898
9279
  historyBack() {
7899
9280
  if (this.history.length === 0)
7900
9281
  return;
9282
+ if (this.historyIndex === this.history.length)
9283
+ this.historyDraft = this.input;
7901
9284
  if (this.historyIndex <= 0)
7902
9285
  return;
7903
9286
  this.historyIndex -= 1;
@@ -7906,13 +9289,22 @@ export class SshTui {
7906
9289
  this.markDirty();
7907
9290
  }
7908
9291
  historyForward() {
7909
- if (this.historyIndex >= this.history.length)
9292
+ if (this.historyIndex < 0 || this.historyIndex >= this.history.length)
7910
9293
  return;
7911
9294
  this.historyIndex += 1;
7912
- this.input = this.history[this.historyIndex] ?? '';
9295
+ this.input = this.historyIndex >= this.history.length
9296
+ ? this.historyDraft
9297
+ : (this.history[this.historyIndex] ?? '');
7913
9298
  this.cursor = this.input.length;
7914
9299
  this.markDirty();
7915
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
+ }
7916
9308
  }
7917
9309
  function optionsLength(dialog) {
7918
9310
  return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
@@ -7980,6 +9372,12 @@ export function mountTui(ctx, config) {
7980
9372
  stopWaiting();
7981
9373
  await controller?.dispose();
7982
9374
  },
9375
+ async handleHangup() {
9376
+ await controller?.handleHangup();
9377
+ },
9378
+ disconnectPolicy() {
9379
+ return controller?.currentDisconnectPolicy() ?? 'pause';
9380
+ },
7983
9381
  };
7984
9382
  }
7985
9383
  //# sourceMappingURL=tui.js.map