dsh-ssh-tui 0.1.8 → 0.3.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.
package/lib/tui.js CHANGED
@@ -194,9 +194,29 @@ const DEEPSEEK_LOGO_VARIANTS = [
194
194
  ],
195
195
  },
196
196
  ];
197
+ /** Human-facing kind for a live LLM route. */
198
+ export function describeProviderRoute(provider) {
199
+ const id = provider.trim();
200
+ if (id === 'deepseek-official' || id === 'deepseek') {
201
+ return { kind: 'DeepSeek 官方', short: 'DeepSeek 官方' };
202
+ }
203
+ if (id === 'xai' || id === 'grok' || id.startsWith('xai-')) {
204
+ return { kind: 'SuperGrok / X Premium 订阅', short: 'SuperGrok' };
205
+ }
206
+ if (id === 'opencode-go')
207
+ return { kind: 'OpenCode Go', short: 'OpenCode Go' };
208
+ if (id === 'opencode')
209
+ return { kind: 'OpenCode Zen', short: 'OpenCode Zen' };
210
+ return { kind: '已注册提供商', short: id };
211
+ }
212
+ /** Routes that authenticate without a harness API-key credential. */
213
+ export function providerUsesLocalOAuth(provider) {
214
+ const id = provider.trim();
215
+ return id === 'xai' || id === 'grok' || id.startsWith('xai-');
216
+ }
197
217
  const LOCAL_COMMANDS = [
198
218
  { name: 'help', description: 'show all available commands' },
199
- { name: 'model', description: 'select model and reasoning effort (same provider)' },
219
+ { name: 'model', description: 'select provider, model and reasoning effort' },
200
220
  { name: 'submodel', description: `select subagent model (default ${DEFAULT_SUBAGENT_MODEL}, same provider as parent)` },
201
221
  { name: 'subeffort', description: 'select subagent reasoning effort (default follows provider)' },
202
222
  { name: 'mode', description: 'switch agent mode / preset (standard, minimal, code, cordis, routing-suite, ...)' },
@@ -206,7 +226,7 @@ const LOCAL_COMMANDS = [
206
226
  { name: 'status', description: 'show session, provider and model status' },
207
227
  { name: 'usage', description: 'show OpenCode Zen billing / Go quota usage' },
208
228
  { name: 'quota', description: 'alias of /usage for OpenCode Go quota' },
209
- { name: 'subagents', description: 'list active subagents' },
229
+ { name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
210
230
  { name: 'resume', description: 'resume a past session (empty = session picker)' },
211
231
  { name: 'setup', description: 're-open provider / API key setup' },
212
232
  { name: 'dialog-test', description: 'verify the question dialog' },
@@ -637,6 +657,15 @@ function cursorVisualPosition(text, cursor, width) {
637
657
  }
638
658
  return { row, col };
639
659
  }
660
+ /** Build per-model reasoningEfforts from a provider-level reasoning default. */
661
+ function reasoningEffortsForDefault(reasoning) {
662
+ if (typeof reasoning !== 'string')
663
+ return undefined;
664
+ const level = reasoning.trim();
665
+ if (level === '' || level === 'off')
666
+ return undefined;
667
+ return { off: null, [level]: level };
668
+ }
640
669
  const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
641
670
  const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
642
671
  /**
@@ -754,7 +783,7 @@ function openCodeApiErrorMessage(payload) {
754
783
  return '';
755
784
  }
756
785
  /** Whether `text` could still grow into a recognized escape sequence. */
757
- function isEscapePrefix(text) {
786
+ export function isEscapePrefix(text) {
758
787
  if (text === '\x1b')
759
788
  return true;
760
789
  if (!text.startsWith('\x1b'))
@@ -767,7 +796,7 @@ function isEscapePrefix(text) {
767
796
  return true;
768
797
  if (/^\x1b\[[HF]$/u.test(text))
769
798
  return true;
770
- if (/^\x1b\[\d~?$/u.test(text))
799
+ if (/^\x1b\[\d+~?$/u.test(text))
771
800
  return true;
772
801
  if (/^\x1b\[<(?:\d*;?)*[Mm]?$/u.test(text))
773
802
  return true;
@@ -844,6 +873,89 @@ function friendlyArgsSummary(name, args) {
844
873
  }
845
874
  const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
846
875
  const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
876
+ const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
877
+ const MAX_SUBAGENT_LOGS = 80;
878
+ const TODO_STATUS_MARK = {
879
+ pending: '○',
880
+ in_progress: '◉',
881
+ completed: '✓',
882
+ };
883
+ /** Parse a todo_write payload into displayable plan items. */
884
+ export function parsePlanTodos(value) {
885
+ const root = typeof value === 'string' ? parseJsonArgs(value) : value;
886
+ const todos = root !== null && typeof root === 'object' && !Array.isArray(root)
887
+ ? root.todos
888
+ : Array.isArray(root) ? root : undefined;
889
+ if (!Array.isArray(todos))
890
+ return [];
891
+ const out = [];
892
+ for (const item of todos) {
893
+ if (typeof item !== 'object' || item === null)
894
+ continue;
895
+ const content = typeof item.content === 'string'
896
+ ? item.content.trim()
897
+ : '';
898
+ if (content === '')
899
+ continue;
900
+ const status = item.status;
901
+ out.push({
902
+ content,
903
+ status: status === 'in_progress' || status === 'completed' ? status : 'pending',
904
+ });
905
+ }
906
+ return out;
907
+ }
908
+ /** Compact todo-list summary: done/total plus the first in-progress task. */
909
+ export function todoSummary(value) {
910
+ const todos = parsePlanTodos(value);
911
+ if (todos.length === 0)
912
+ return '计划列表';
913
+ const done = todos.filter(item => item.status === 'completed').length;
914
+ const active = todos.find(item => item.status === 'in_progress');
915
+ const extra = todos.filter(item => item.status === 'in_progress').length;
916
+ const head = `${done}/${todos.length} 完成`;
917
+ if (active === undefined)
918
+ return head;
919
+ return extra > 1 ? `${head} · ${active.content} +${extra - 1}` : `${head} · ${active.content}`;
920
+ }
921
+ /** Compact ask_user_question summary from tool arguments. */
922
+ export function askSummary(value) {
923
+ const root = typeof value === 'string' ? parseJsonArgs(value) : value;
924
+ const questions = root !== null && typeof root === 'object' && !Array.isArray(root)
925
+ ? root.questions
926
+ : undefined;
927
+ if (!Array.isArray(questions) || questions.length === 0)
928
+ return '等待回答';
929
+ const first = questions[0];
930
+ const text = typeof first === 'object' && first !== null && typeof first.question === 'string'
931
+ ? first.question
932
+ : '等待回答';
933
+ return questions.length > 1 ? `${text}(${questions.length} 题)` : text;
934
+ }
935
+ /** One-line subagent card header used while collapsed. */
936
+ export function subagentHeaderText(row, now = Date.now()) {
937
+ const elapsed = Math.max(0, Math.floor(((row.endedAt ?? now) - row.startedAt) / 1000));
938
+ const elapsedLabel = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${elapsed % 60}s` : `${elapsed}s`;
939
+ const state = row.status === 'running'
940
+ ? '运行中'
941
+ : row.status === 'ok'
942
+ ? '完成'
943
+ : row.status === 'aborted'
944
+ ? '已中断'
945
+ : '失败';
946
+ const activity = row.lastActivity === '' ? '' : ` · ${row.lastActivity}`;
947
+ const id = row.sessionId.slice(0, 8);
948
+ return `${row.label} [${id}] ${state} · ${elapsedLabel}${activity}`;
949
+ }
950
+ function appendSubagentLog(row, entry) {
951
+ row.logs.push(entry);
952
+ if (row.logs.length > MAX_SUBAGENT_LOGS)
953
+ row.logs.splice(0, row.logs.length - MAX_SUBAGENT_LOGS);
954
+ row.lastActivity = entry.text;
955
+ }
956
+ function planReviewOf(question) {
957
+ return question.intent?.kind === 'plan-review' && question.detail !== undefined && question.detail !== '';
958
+ }
847
959
  /** Derive the intended file change from a mutation tool's arguments. */
848
960
  function diffHunksFromArgs(name, argsRaw) {
849
961
  const args = parseJsonArgs(argsRaw);
@@ -905,6 +1017,22 @@ export function presentToolCall(name, args) {
905
1017
  ...diff === null || diff === undefined ? {} : { diff },
906
1018
  };
907
1019
  }
1020
+ if (SUBAGENT_TOOL_NAMES.has(name)) {
1021
+ const description = typeof parsed?.description === 'string' ? parsed.description.trim() : '';
1022
+ return {
1023
+ title: name === 'subagent_fork' ? '子代理 fork' : '子代理',
1024
+ summary: description === '' ? friendlyArgsSummary(name, args) : description,
1025
+ };
1026
+ }
1027
+ if (name === 'todo_write' || name === 'todo') {
1028
+ return { title: '计划', summary: todoSummary(parsed) };
1029
+ }
1030
+ if (name === 'ask_user_question') {
1031
+ return { title: '提问用户', summary: askSummary(parsed) };
1032
+ }
1033
+ if (name === 'exit_plan_mode') {
1034
+ return { title: '退出计划模式', summary: '等待确认计划' };
1035
+ }
908
1036
  return { title: name, summary: friendlyArgsSummary(name, args) };
909
1037
  }
910
1038
  /** Validate a tool/result meta payload's structured diff, mirroring the web card. */
@@ -1240,7 +1368,7 @@ export class SshTui {
1240
1368
  this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
1241
1369
  this.pushRow({ kind: 'brand-logo' });
1242
1370
  this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
1243
- this.pushRow({ kind: 'system', text: 'Type /help for commands · /setup provider & key · ↑/↓ select · Enter expand/collapse · Ctrl+T fold input · Esc cancels' });
1371
+ this.pushRow({ kind: 'system', text: '输入 /help 查看命令 · /setup 配置提供商 · ↑/↓ 选择卡片 · Enter 展开/折叠 · Ctrl+R 全部展开/收起 · Ctrl+T 折叠输入 · Esc 取消' });
1244
1372
  }
1245
1373
  /** Enter raw mode, switch to the alternate screen, and start listening. */
1246
1374
  start() {
@@ -1264,9 +1392,12 @@ export class SshTui {
1264
1392
  const now = Date.now();
1265
1393
  if (this.agent.status === 'running')
1266
1394
  this.updateTerminalTitle();
1267
- if (this.streaming !== undefined
1268
- && this.streaming.reasoning !== ''
1269
- && now - this.lastPaintAt >= 200) {
1395
+ const animating = (this.streaming !== undefined && this.streaming.reasoning !== '')
1396
+ || this.activeSubagents.size > 0
1397
+ || this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
1398
+ || (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
1399
+ || (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked')));
1400
+ if (animating && now - this.lastPaintAt >= 200) {
1270
1401
  this.dirty = true;
1271
1402
  }
1272
1403
  // While a turn is waiting on the provider with no new events, repaint at
@@ -1312,7 +1443,15 @@ export class SshTui {
1312
1443
  /** Show the first-launch provider/API-key onboarding when nothing is configured. */
1313
1444
  async maybeRunOnboarding() {
1314
1445
  const credentials = this.ctx.get('credentials');
1315
- const provider = this.providerName;
1446
+ const provider = this.currentProviderId();
1447
+ if (providerUsesLocalOAuth(provider)) {
1448
+ this.pushRow({
1449
+ kind: 'system',
1450
+ text: `当前是 ${describeProviderRoute(provider).kind}(${provider}),使用本机 OAuth token,无需 API Key。如需改回 Key 提供商,输入 /setup。`,
1451
+ });
1452
+ this.markDirty();
1453
+ return;
1454
+ }
1316
1455
  const envRef = provider === 'deepseek-official' ? 'DEEPSEEK_API_KEY' : envRefForId(provider);
1317
1456
  const envKey = process.env[envRef];
1318
1457
  let stored = false;
@@ -1330,7 +1469,7 @@ export class SshTui {
1330
1469
  const content = await readFile(credentialFile, 'utf8');
1331
1470
  if (this.disposed)
1332
1471
  return;
1333
- stored = new RegExp(`^${envRef}\\s*:\\s*\\S`, 'm').test(content);
1472
+ stored = new RegExp(`^${escapeRegex(envRef)}\\s*:\\s*\\S`, 'm').test(content);
1334
1473
  }
1335
1474
  }
1336
1475
  catch {
@@ -1490,13 +1629,60 @@ export class SshTui {
1490
1629
  }
1491
1630
  /** The transcript rows that support per-row expand/collapse. */
1492
1631
  collapsibleRows() {
1493
- const rows = this.rows.filter((row) => row.kind === 'reasoning' || row.kind === 'tool');
1632
+ const rows = this.rows.filter((row) => row.kind === 'reasoning'
1633
+ || row.kind === 'tool'
1634
+ || row.kind === 'subagent'
1635
+ || row.kind === 'plan'
1636
+ || row.kind === 'question'
1637
+ || row.kind === 'goal');
1494
1638
  if (this.streaming !== undefined && this.streaming.reasoning !== '') {
1495
1639
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
1496
1640
  rows.push(this.streamingReasoning);
1497
1641
  }
1498
1642
  return rows;
1499
1643
  }
1644
+ spinnerFrame(periodMs = 120) {
1645
+ return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
1646
+ }
1647
+ findSubagentRow(sessionId) {
1648
+ return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
1649
+ }
1650
+ findLivePlanRow() {
1651
+ return this.rows.findLast((row) => row.kind === 'plan');
1652
+ }
1653
+ upsertPlanRow(patch) {
1654
+ const existing = this.findLivePlanRow();
1655
+ if (existing !== undefined) {
1656
+ Object.assign(existing, patch);
1657
+ return existing;
1658
+ }
1659
+ const row = {
1660
+ kind: 'plan',
1661
+ active: patch.active ?? false,
1662
+ pending: patch.pending ?? false,
1663
+ todos: patch.todos ?? [],
1664
+ expanded: false,
1665
+ };
1666
+ this.pushRow(row);
1667
+ return row;
1668
+ }
1669
+ paintCollapsibleHeader(addDisplay, row, kind, header, width, colorize) {
1670
+ const focused = this.focusedRow === row;
1671
+ const marker = row.expanded ? '▾' : '▸';
1672
+ const prefix = focused ? '▶ ' : ' ';
1673
+ const plain = `${prefix}${marker} ${header}`;
1674
+ const paint = colorize ?? ((line) => this.styleLine(kind, line));
1675
+ if (!row.expanded) {
1676
+ const collapsed = truncateToWidth(plain, Math.max(1, width - 2));
1677
+ const styled = paint(collapsed);
1678
+ addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
1679
+ return;
1680
+ }
1681
+ for (const wrapped of wrap(plain, width)) {
1682
+ const styled = paint(wrapped);
1683
+ addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
1684
+ }
1685
+ }
1500
1686
  /** Move the expand/collapse focus among reasoning and tool rows. */
1501
1687
  moveCollapsibleFocus(delta) {
1502
1688
  const rows = this.collapsibleRows();
@@ -1604,6 +1790,7 @@ export class SshTui {
1604
1790
  return this.styleLine('tool', line);
1605
1791
  return `${this.styleLine('tool', line.slice(0, dotIndex))}\x1b[${dotColor}m●${this.styleLine('tool', line.slice(dotIndex + 1))}`;
1606
1792
  };
1793
+ const spinner = running ? ` ${this.spinnerFrame()}` : '';
1607
1794
  const state = running ? 'running…' : ok ? 'ok' : 'error';
1608
1795
  const summary = row.summary === '' ? '' : ` ${row.summary}`;
1609
1796
  const exit = !running && row.command !== undefined
@@ -1615,7 +1802,7 @@ export class SshTui {
1615
1802
  : '';
1616
1803
  const focused = this.focusedRow === row;
1617
1804
  const marker = row.expanded ? '▾' : '▸';
1618
- const plainHeader = `${marker} ● ${row.title}${summary} [${state}]${exit}`;
1805
+ const plainHeader = `${marker} ● ${row.title}${summary} [${state}]${exit}${spinner}`;
1619
1806
  if (!row.expanded) {
1620
1807
  const collapsed = truncateToWidth(`${focused ? '▶ ' : ' '}${plainHeader}`, Math.max(1, width - 2));
1621
1808
  const styled = styleToolHeader(collapsed);
@@ -1632,6 +1819,116 @@ export class SshTui {
1632
1819
  }
1633
1820
  continue;
1634
1821
  }
1822
+ if (row.kind === 'subagent') {
1823
+ const running = row.status === 'running';
1824
+ const ok = row.status === 'ok';
1825
+ const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
1826
+ const styleHeader = (line) => {
1827
+ const dotIndex = line.indexOf('●');
1828
+ if (dotColor === undefined || dotIndex === -1)
1829
+ return this.styleLine('tool', line);
1830
+ return `${this.styleLine('tool', line.slice(0, dotIndex))}\x1b[${dotColor}m●${this.styleLine('tool', line.slice(dotIndex + 1))}`;
1831
+ };
1832
+ const spinner = running ? ` ${this.spinnerFrame()}` : '';
1833
+ const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
1834
+ this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width, styleHeader);
1835
+ if (row.expanded) {
1836
+ addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`));
1837
+ if (row.stopReason !== undefined) {
1838
+ addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`));
1839
+ }
1840
+ if (row.logs.length === 0) {
1841
+ addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'));
1842
+ }
1843
+ else {
1844
+ for (const entry of row.logs) {
1845
+ const kind = entry.kind === 'assistant'
1846
+ ? 'assistant'
1847
+ : entry.kind === 'result' && row.status === 'error'
1848
+ ? 'error'
1849
+ : 'tool-result';
1850
+ for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
1851
+ addDisplay(this.styleLine(kind, ` ${wrapped}`));
1852
+ }
1853
+ }
1854
+ }
1855
+ }
1856
+ continue;
1857
+ }
1858
+ if (row.kind === 'plan') {
1859
+ const running = row.todos.some(item => item.status === 'in_progress');
1860
+ const spinner = (row.active || row.pending || running) ? ` ${this.spinnerFrame()}` : '';
1861
+ const mode = row.pending
1862
+ ? '切换中'
1863
+ : row.active
1864
+ ? '计划模式'
1865
+ : '计划';
1866
+ const header = `● ${mode}${spinner} · ${todoSummary(row.todos)}${row.expanded ? '' : ' · Enter 展开'}`;
1867
+ this.paintCollapsibleHeader(addDisplay, row, 'system', header, width);
1868
+ if (row.expanded) {
1869
+ addDisplay(this.styleLine('system', row.active
1870
+ ? ' 当前处于计划模式:只规划、不改代码,确认后再执行。'
1871
+ : ' 计划模式已关闭。可用 /plan 重新进入。'));
1872
+ if (row.pending)
1873
+ addDisplay(this.styleLine('system', ' 模式切换将在下一步生效。'));
1874
+ if (row.todos.length === 0) {
1875
+ addDisplay(this.styleLine('tool-result', ' 还没有任务列表'));
1876
+ }
1877
+ else {
1878
+ for (const item of row.todos) {
1879
+ const mark = TODO_STATUS_MARK[item.status];
1880
+ for (const wrapped of wrap(`${mark} ${item.content}`, Math.max(1, width - 2))) {
1881
+ addDisplay(this.styleLine(item.status === 'completed' ? 'system' : 'tool', ` ${wrapped}`));
1882
+ }
1883
+ }
1884
+ }
1885
+ }
1886
+ continue;
1887
+ }
1888
+ if (row.kind === 'question') {
1889
+ const waiting = row.status === 'waiting';
1890
+ const spinner = waiting ? ` ${this.spinnerFrame()}` : '';
1891
+ const state = waiting ? '等待回答' : row.status === 'answered' ? '已回答' : '已取消';
1892
+ const title = row.intent === 'plan-review' ? '计划待审' : '提问用户';
1893
+ const header = `● ${title}${spinner} · ${state} · ${row.summary}${row.expanded ? '' : ' · Enter 展开'}`;
1894
+ this.paintCollapsibleHeader(addDisplay, row, waiting ? 'tool' : 'system', header, width);
1895
+ if (row.expanded) {
1896
+ if (row.header !== undefined)
1897
+ addDisplay(this.styleLine('system', ` ${row.header}`));
1898
+ for (const wrapped of wrap(row.title, Math.max(1, width - 2))) {
1899
+ addDisplay(this.styleLine('assistant', ` ${wrapped}`));
1900
+ }
1901
+ if (row.detail !== undefined && row.detail !== '') {
1902
+ for (const wrapped of wrap(row.detail, Math.max(1, width - 2))) {
1903
+ addDisplay(this.styleLine('tool-result', ` ${wrapped}`));
1904
+ }
1905
+ }
1906
+ addDisplay(this.styleLine('system', waiting
1907
+ ? ' 用下方对话框选择,数字/字母选中,Enter 提交,Esc 取消。'
1908
+ : ` ${row.summary}`));
1909
+ }
1910
+ continue;
1911
+ }
1912
+ if (row.kind === 'goal') {
1913
+ const live = row.phase === 'active' || row.phase === 'blocked';
1914
+ const spinner = live ? ` ${this.spinnerFrame()}` : '';
1915
+ const phase = row.phase === 'active' ? '进行中'
1916
+ : row.phase === 'paused' ? '已暂停'
1917
+ : row.phase === 'blocked' ? '受阻'
1918
+ : row.phase === 'complete' ? '已完成'
1919
+ : '已清除';
1920
+ const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : ' · Enter 展开'}`;
1921
+ this.paintCollapsibleHeader(addDisplay, row, live ? 'tool' : 'system', header, width);
1922
+ if (row.expanded) {
1923
+ addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'));
1924
+ if (row.blockedReason !== undefined) {
1925
+ for (const wrapped of wrap(row.blockedReason, Math.max(1, width - 2))) {
1926
+ addDisplay(this.styleLine('error', ` ${wrapped}`));
1927
+ }
1928
+ }
1929
+ }
1930
+ continue;
1931
+ }
1635
1932
  pushRow(row.kind, row.text);
1636
1933
  }
1637
1934
  if (this.streaming !== undefined) {
@@ -1733,21 +2030,35 @@ export class SshTui {
1733
2030
  }
1734
2031
  else {
1735
2032
  const d = this.dialog;
1736
- addDialog(`Question ${d.index + 1}/${d.total}: ${d.question.question}`);
1737
- if (d.question.detail !== undefined && d.question.detail !== '') {
1738
- addDialog(truncate(d.question.detail, 6));
2033
+ const review = planReviewOf(d.question);
2034
+ if (review) {
2035
+ addDialog(`计划待审 ${d.index + 1}/${d.total}${d.question.header === undefined ? '' : ` · ${d.question.header}`}`);
2036
+ addDialog(d.question.question);
2037
+ if (d.question.detail !== undefined && d.question.detail !== '') {
2038
+ addDialog(truncate(d.question.detail, 12));
2039
+ }
2040
+ }
2041
+ else {
2042
+ addDialog(`提问用户 ${d.index + 1}/${d.total}: ${d.question.question}`);
2043
+ if (d.question.header !== undefined && d.question.header !== '')
2044
+ addDialog(d.question.header);
2045
+ if (d.question.detail !== undefined && d.question.detail !== '') {
2046
+ addDialog(truncate(d.question.detail, 6));
2047
+ }
1739
2048
  }
1740
2049
  const options = d.question.options ?? [];
2050
+ const approve = d.question.intent?.approve;
1741
2051
  for (const [index, option] of options.entries()) {
1742
2052
  const marker = d.selected.has(index) ? '●' : '○';
1743
2053
  const key = QUESTION_OPTION_KEYS[index] ?? '?';
2054
+ const recommended = option.label === approve ? '(推荐)' : '';
1744
2055
  const extra = option.description === undefined ? '' : ` — ${option.description}`;
1745
- addDialog(` ${key} ${marker} ${option.label}${extra}`);
2056
+ addDialog(` ${key} ${marker} ${option.label}${recommended}${extra}`);
1746
2057
  }
1747
2058
  if (options.length === 0) {
1748
- addDialog(' (free text: type below and press Enter)');
2059
+ addDialog(' (自由输入:在下方输入后按 Enter');
1749
2060
  }
1750
- addDialog(` ${d.question.multiSelect === true ? 'digits/letters toggle, Enter submit' : 'digit/letter to select, Enter submit'}, Esc to cancel`);
2061
+ addDialog(` ${d.question.multiSelect === true ? '数字/字母切换,Enter 提交' : '数字/字母选择,Enter 提交'}Esc 取消`);
1751
2062
  }
1752
2063
  }
1753
2064
  const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
@@ -1856,8 +2167,24 @@ export class SshTui {
1856
2167
  if (this.pendingMessages.size > 0)
1857
2168
  statusText += ` · 排队 ${this.pendingMessages.size}`;
1858
2169
  const idleMs = Date.now() - this.lastActivity;
1859
- if (this.agent.status === 'running' && this.activeSubagents.size > 0) {
1860
- statusText += ` · 子代理执行中 ${this.activeSubagents.size}`;
2170
+ const livePlan = this.findLivePlanRow();
2171
+ const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
2172
+ if (waitingQuestions || this.dialog?.kind === 'questions') {
2173
+ statusText += this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question)
2174
+ ? ' · 计划待审'
2175
+ : ' · 等待用户回答';
2176
+ }
2177
+ else if (livePlan?.active === true || livePlan?.pending === true) {
2178
+ statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
2179
+ }
2180
+ const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
2181
+ if (liveGoal !== undefined && (liveGoal.phase === 'active' || liveGoal.phase === 'paused' || liveGoal.phase === 'blocked')) {
2182
+ const phase = liveGoal.phase === 'active' ? '目标进行中' : liveGoal.phase === 'paused' ? '目标已暂停' : '目标受阻';
2183
+ statusText += ` · ${phase}`;
2184
+ }
2185
+ if (this.activeSubagents.size > 0) {
2186
+ const spinner = this.spinnerFrame(160);
2187
+ statusText += ` · ${spinner} 子代理 ${this.activeSubagents.size}`;
1861
2188
  }
1862
2189
  else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
1863
2190
  statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
@@ -1893,6 +2220,9 @@ export class SshTui {
1893
2220
  this.pendingMessages.size,
1894
2221
  this.commandSuggestions.length,
1895
2222
  this.suggestionIndex,
2223
+ this.activeSubagents.size,
2224
+ this.dialog?.kind ?? '',
2225
+ this.findLivePlanRow()?.active === true ? 'plan' : '',
1896
2226
  ].join('\x1f');
1897
2227
  const chromeChanged = chromeKey !== this.lastChromeKey;
1898
2228
  // Incremental repaint: rewrite only rows whose content changed, so slow
@@ -1922,7 +2252,7 @@ export class SshTui {
1922
2252
  const prefix = input.slice(1).toLowerCase();
1923
2253
  const dsh = (this.ctx.get('commands')?.list(this.agent) ?? []).map(command => ({
1924
2254
  name: command.name,
1925
- description: command.description,
2255
+ description: command.input?.images === true ? `${command.description}(可附图)` : command.description,
1926
2256
  local: false,
1927
2257
  }));
1928
2258
  const all = [
@@ -1937,12 +2267,16 @@ export class SshTui {
1937
2267
  suggestionsVisible() {
1938
2268
  return this.commandSuggestions.length > 0 && this.dialog === undefined;
1939
2269
  }
2270
+ currentProviderId() {
2271
+ return this.selectionRef?.current?.provider ?? this.agent.options.provider ?? this.providerName;
2272
+ }
1940
2273
  currentSelectionLabel() {
1941
2274
  const current = this.selectionRef?.current;
1942
- const provider = current?.provider ?? this.agent.options.provider ?? this.providerName;
2275
+ const provider = this.currentProviderId();
1943
2276
  const model = current?.model ?? this.agent.options.model ?? 'unknown';
1944
2277
  const effort = current?.reasoningEffort;
1945
- return `${provider}/${model}${effort === undefined ? '' : ` (${effort})`}`;
2278
+ const kind = describeProviderRoute(provider).short;
2279
+ return `${provider}/${model}${effort === undefined ? '' : ` (${effort})`} · ${kind}`;
1946
2280
  }
1947
2281
  /** Replace one step's usage sample so a repeated report never double counts. */
1948
2282
  recordUsage(turn, step, usage) {
@@ -2012,10 +2346,25 @@ export class SshTui {
2012
2346
  this.lastTitleUpdateAt = now;
2013
2347
  const spinner = SPINNER[Math.floor(now / 800) % SPINNER.length];
2014
2348
  let detail = '运行中';
2015
- if (this.openToolCalls.size > 0)
2016
- detail = `运行中 · 工具 ${this.openToolCalls.size}`;
2017
- else if (this.activeSubagents.size > 0)
2349
+ if (this.dialog?.kind === 'questions') {
2350
+ detail = planReviewOf(this.dialog.question) ? '计划待审' : '等待用户回答';
2351
+ }
2352
+ else if (this.activeSubagents.size > 0) {
2018
2353
  detail = `运行中 · 子代理 ${this.activeSubagents.size}`;
2354
+ }
2355
+ else if (this.openToolCalls.size > 0) {
2356
+ detail = `运行中 · 工具 ${this.openToolCalls.size}`;
2357
+ }
2358
+ else if (this.findLivePlanRow()?.active === true) {
2359
+ detail = '计划模式';
2360
+ }
2361
+ else {
2362
+ const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
2363
+ if (liveGoal?.phase === 'active')
2364
+ detail = '目标进行中';
2365
+ else if (liveGoal?.phase === 'blocked')
2366
+ detail = '目标受阻';
2367
+ }
2019
2368
  this.write(`\x1b]0;dsh ${spinner} ${detail}\x07`);
2020
2369
  return;
2021
2370
  }
@@ -2166,14 +2515,20 @@ export class SshTui {
2166
2515
  this.recordUsage(event.data.turn, event.data.step, event.data.usage);
2167
2516
  }
2168
2517
  const reasoningExpanded = this.streamingReasoning?.expanded ?? false;
2518
+ const interrupted = event.data.interrupted === true;
2169
2519
  this.streaming = undefined;
2170
2520
  this.streamingReasoning = undefined;
2171
2521
  this.thinkingStartedAt = undefined;
2522
+ const interruptedMark = interrupted ? ' ⚠ 已中断' : '';
2172
2523
  if (reasoning !== '') {
2173
- this.pushRow({ kind: 'reasoning', text: reasoning, expanded: reasoningExpanded });
2524
+ this.pushRow({ kind: 'reasoning', text: `${reasoning}${interruptedMark}`, expanded: reasoningExpanded });
2525
+ }
2526
+ if (text !== '') {
2527
+ this.pushRow({ kind: 'assistant', text: `${text}${interruptedMark}` });
2528
+ }
2529
+ else if (interrupted && reasoning === '') {
2530
+ this.pushRow({ kind: 'system', text: '本轮输出已中断,没有可见内容。' });
2174
2531
  }
2175
- if (text !== '')
2176
- this.pushRow({ kind: 'assistant', text });
2177
2532
  this.markDirty();
2178
2533
  break;
2179
2534
  }
@@ -2193,7 +2548,7 @@ export class SshTui {
2193
2548
  ...present.command === undefined ? {} : { command: present.command },
2194
2549
  ...present.cwd === undefined ? {} : { cwd: present.cwd },
2195
2550
  ...present.diff === undefined ? {} : { diff: present.diff },
2196
- expanded: DIFF_TOOL_NAMES.has(event.data.name),
2551
+ expanded: DIFF_TOOL_NAMES.has(event.data.name) && !SUBAGENT_TOOL_NAMES.has(event.data.name),
2197
2552
  };
2198
2553
  this.pushRow(row);
2199
2554
  this.streaming = undefined;
@@ -2306,7 +2661,13 @@ export class SshTui {
2306
2661
  this.markDirty();
2307
2662
  break;
2308
2663
  }
2664
+ case 'todo/write': {
2665
+ this.upsertPlanRow({ todos: parsePlanTodos(event.data.todos) });
2666
+ this.markDirty();
2667
+ break;
2668
+ }
2309
2669
  default:
2670
+ this.handleExtensionEvent(event);
2310
2671
  break;
2311
2672
  }
2312
2673
  };
@@ -2316,6 +2677,7 @@ export class SshTui {
2316
2677
  this.lastActivity = Date.now();
2317
2678
  if (status === 'running') {
2318
2679
  this.completionSignaled = false;
2680
+ this.completedAt = 0;
2319
2681
  }
2320
2682
  else if (!this.completionSignaled && this.status === 'running') {
2321
2683
  this.completionSignaled = true;
@@ -2353,56 +2715,187 @@ export class SshTui {
2353
2715
  this.status = 'disposed';
2354
2716
  this.markDirty();
2355
2717
  };
2356
- /** Render a live subagent's own session events so its progress is visible. */
2718
+ /** Plan-mode / command / team events that plugins merge into SessionEventMap. */
2719
+ handleExtensionEvent(event) {
2720
+ const type = String(event.type);
2721
+ const data = event.data;
2722
+ if (type === 'plan/mode') {
2723
+ const active = data?.active === true;
2724
+ this.upsertPlanRow({ active, pending: false });
2725
+ this.pushRow({
2726
+ kind: 'system',
2727
+ text: active
2728
+ ? '已进入计划模式:先规划、等确认后再改代码。可用 /plan off 退出。'
2729
+ : '已退出计划模式,可以继续执行改动。',
2730
+ });
2731
+ this.markDirty();
2732
+ return;
2733
+ }
2734
+ if (type === 'command/run' && data?.name === 'plan') {
2735
+ const args = String(data.args ?? '').trim();
2736
+ const wantsActive = args !== 'off';
2737
+ const current = this.findLivePlanRow();
2738
+ this.upsertPlanRow({
2739
+ pending: current !== undefined && current.active !== wantsActive,
2740
+ active: current?.active ?? false,
2741
+ });
2742
+ this.pushRow({
2743
+ kind: 'system',
2744
+ text: wantsActive ? '已请求进入计划模式。' : '已请求退出计划模式。',
2745
+ });
2746
+ this.markDirty();
2747
+ return;
2748
+ }
2749
+ if (type === 'goal/change') {
2750
+ this.handleGoalChange(data);
2751
+ return;
2752
+ }
2753
+ if (type.startsWith('team/')) {
2754
+ this.pushRow({ kind: 'system', text: `[团队] ${type}` });
2755
+ this.markDirty();
2756
+ }
2757
+ }
2758
+ handleGoalChange(data) {
2759
+ const payload = data !== null && typeof data === 'object' ? data : {};
2760
+ const existing = this.rows.findLast((row) => row.kind === 'goal');
2761
+ if (payload.operation === 'clear') {
2762
+ if (existing !== undefined) {
2763
+ existing.phase = 'cleared';
2764
+ existing.blockedReason = undefined;
2765
+ }
2766
+ else {
2767
+ this.pushRow({ kind: 'goal', objective: '(已清除)', phase: 'cleared', expanded: false });
2768
+ }
2769
+ this.pushRow({ kind: 'system', text: '当前目标已清除。' });
2770
+ this.markDirty();
2771
+ return;
2772
+ }
2773
+ const goal = payload.goal !== null && typeof payload.goal === 'object' ? payload.goal : {};
2774
+ const objective = typeof goal.objective === 'string' && goal.objective.trim() !== '' ? goal.objective.trim() : '(未命名目标)';
2775
+ const phase = goal.phase === 'paused' || goal.phase === 'blocked' || goal.phase === 'complete' ? goal.phase : 'active';
2776
+ const blocked = goal.blockedReason !== null && typeof goal.blockedReason === 'object'
2777
+ ? goal.blockedReason.message
2778
+ : undefined;
2779
+ const blockedReason = typeof blocked === 'string' ? blocked : undefined;
2780
+ if (existing !== undefined) {
2781
+ existing.objective = objective;
2782
+ existing.phase = phase;
2783
+ existing.blockedReason = blockedReason;
2784
+ }
2785
+ else {
2786
+ this.pushRow({
2787
+ kind: 'goal',
2788
+ objective,
2789
+ phase,
2790
+ ...(blockedReason === undefined ? {} : { blockedReason }),
2791
+ expanded: false,
2792
+ });
2793
+ }
2794
+ const notice = phase === 'active' ? '已设置目标'
2795
+ : phase === 'paused' ? '目标已暂停'
2796
+ : phase === 'blocked' ? '目标受阻'
2797
+ : '目标已完成';
2798
+ this.pushRow({ kind: 'system', text: `${notice}:${objective}` });
2799
+ this.markDirty();
2800
+ }
2801
+ handleSubagentExtensionEvent(row, event) {
2802
+ const type = String(event.type);
2803
+ const data = event.data;
2804
+ if (type === 'plan/mode') {
2805
+ appendSubagentLog(row, {
2806
+ kind: 'system',
2807
+ text: data?.active === true ? '进入计划模式' : '退出计划模式',
2808
+ });
2809
+ return;
2810
+ }
2811
+ if (type.startsWith('team/')) {
2812
+ appendSubagentLog(row, { kind: 'team', text: `[团队] ${type}` });
2813
+ }
2814
+ }
2815
+ /** Fold a live subagent's own session events into that child's card. */
2357
2816
  handleSubagentSessionEvent = (sessionId, event) => {
2358
- const label = `[子代理 ${String(sessionId).slice(0, 8)}]`;
2817
+ const row = this.findSubagentRow(String(sessionId));
2818
+ if (row === undefined)
2819
+ return;
2359
2820
  switch (event.type) {
2360
2821
  case 'user/message': {
2361
2822
  const text = collectText(event.data.content);
2362
2823
  if (text !== '')
2363
- this.pushRow({ kind: 'system', text: `${label} ${truncate(text, 6)}` });
2824
+ appendSubagentLog(row, { kind: 'user', text: `❯ ${truncate(text, 4)}` });
2364
2825
  break;
2365
2826
  }
2366
- case 'assistant/chunk': {
2367
- // Child chunks are coalesced into assistant/message to avoid flooding.
2827
+ case 'assistant/chunk':
2368
2828
  break;
2369
- }
2370
2829
  case 'assistant/message': {
2371
2830
  const text = collectText(event.data.message.content);
2372
2831
  if (text !== '')
2373
- this.pushRow({ kind: 'assistant', text: `${label} ${truncate(text, 12)}` });
2832
+ appendSubagentLog(row, { kind: 'assistant', text: truncate(text, 8) });
2374
2833
  break;
2375
2834
  }
2376
- case 'tool/call':
2377
- this.pushRow({ kind: 'system', text: `${label} ▶ ${event.data.name} ${sliceCodePoints(event.data.arguments, 160)}` });
2835
+ case 'tool/call': {
2836
+ const present = presentToolCall(event.data.name, event.data.arguments);
2837
+ appendSubagentLog(row, { kind: 'tool', text: `▶ ${present.title} ${present.summary}` });
2378
2838
  break;
2839
+ }
2379
2840
  case 'tool/result': {
2380
- const output = truncate(collectText(event.data.message.content), 4);
2381
- const ok = event.data.error === undefined && !event.data.message.content[0]?.isError;
2382
- this.pushRow({ kind: 'system', text: `${label} ${ok ? '✓' : '✗'} ${event.data.message.source.callId}${output === '' ? '' : `\n ${output}`}` });
2841
+ const output = truncate(collectText(event.data.message.content), 3);
2842
+ const ok = event.data.error === undefined && event.data.message.content[0]?.isError !== true;
2843
+ appendSubagentLog(row, {
2844
+ kind: 'result',
2845
+ text: `${ok ? '✓' : '✗'} ${event.data.message.source.callId}${output === '' ? '' : ` · ${output}`}`,
2846
+ });
2383
2847
  break;
2384
2848
  }
2385
2849
  case 'turn/end':
2386
- this.pushRow({ kind: 'system', text: `${label} 轮次结束(${event.data.reason.kind})` });
2850
+ appendSubagentLog(row, { kind: 'turn', text: `轮次结束(${event.data.reason.kind})` });
2387
2851
  break;
2388
2852
  case 'approval/asked':
2389
- this.pushRow({ kind: 'system', text: `${label} 等待审批:${event.data.toolName}` });
2853
+ appendSubagentLog(row, { kind: 'approval', text: `等待审批:${event.data.toolName}` });
2390
2854
  break;
2391
2855
  default:
2856
+ this.handleSubagentExtensionEvent(row, event);
2392
2857
  break;
2393
2858
  }
2394
2859
  this.lastActivity = Date.now();
2395
2860
  this.markDirty();
2396
2861
  };
2397
2862
  handleSubagentStart = (info) => {
2863
+ const sessionId = String(info.id);
2398
2864
  this.activeSubagents.set(String(info.runId), {
2399
- id: String(info.id),
2865
+ id: sessionId,
2400
2866
  provider: info.provider,
2401
2867
  startedAt: Date.now(),
2402
2868
  });
2403
- this.subagentSessions.add(String(info.id));
2869
+ this.subagentSessions.add(sessionId);
2404
2870
  this.lastActivity = Date.now();
2405
- this.pushRow({ kind: 'system', text: `▶ 子代理 ${info.id} 已启动(${info.provider}${info.local ? '' : ',外部进程'})` });
2871
+ const existing = this.findSubagentRow(sessionId);
2872
+ if (existing !== undefined) {
2873
+ existing.runId = String(info.runId);
2874
+ existing.provider = info.provider;
2875
+ existing.local = info.local;
2876
+ existing.status = 'running';
2877
+ existing.startedAt = Date.now();
2878
+ existing.endedAt = undefined;
2879
+ existing.stopReason = undefined;
2880
+ existing.lastActivity = '已启动';
2881
+ existing.expanded = false;
2882
+ appendSubagentLog(existing, { kind: 'system', text: `已启动(${info.provider}${info.local ? '' : ',外部进程'})` });
2883
+ }
2884
+ else {
2885
+ this.pushRow({
2886
+ kind: 'subagent',
2887
+ sessionId,
2888
+ runId: String(info.runId),
2889
+ provider: info.provider,
2890
+ local: info.local,
2891
+ label: `子代理 ${info.provider}`,
2892
+ status: 'running',
2893
+ startedAt: Date.now(),
2894
+ lastActivity: '已启动',
2895
+ logs: [{ kind: 'system', text: `已启动(${info.provider}${info.local ? '' : ',外部进程'})` }],
2896
+ expanded: false,
2897
+ });
2898
+ }
2406
2899
  this.markDirty();
2407
2900
  };
2408
2901
  handleSubagentEnd = (info) => {
@@ -2412,10 +2905,34 @@ export class SshTui {
2412
2905
  const output = info.lastAssistantMessage === undefined
2413
2906
  ? ''
2414
2907
  : truncate(collectText(info.lastAssistantMessage), 6);
2415
- this.pushRow({
2416
- kind: 'system',
2417
- text: `✓ 子代理 ${info.id} 结束(${info.stopReason})${output === '' ? '' : `\n ${output}`}`,
2418
- });
2908
+ const row = this.findSubagentRow(String(info.id)) ?? this.rows.findLast((candidate) => candidate.kind === 'subagent' && candidate.runId === String(info.runId));
2909
+ const failed = info.stopReason !== 'completed';
2910
+ if (row !== undefined) {
2911
+ row.status = info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok';
2912
+ row.endedAt = Date.now();
2913
+ row.stopReason = info.stopReason;
2914
+ appendSubagentLog(row, {
2915
+ kind: failed ? 'result' : 'assistant',
2916
+ text: `结束(${info.stopReason})${output === '' ? '' : ` · ${output}`}`,
2917
+ });
2918
+ }
2919
+ else {
2920
+ this.pushRow({
2921
+ kind: 'subagent',
2922
+ sessionId: String(info.id),
2923
+ runId: String(info.runId),
2924
+ provider: info.provider,
2925
+ local: info.local,
2926
+ label: `子代理 ${info.provider}`,
2927
+ status: info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok',
2928
+ startedAt: Date.now(),
2929
+ endedAt: Date.now(),
2930
+ stopReason: info.stopReason,
2931
+ lastActivity: `结束(${info.stopReason})`,
2932
+ logs: [{ kind: 'system', text: `结束(${info.stopReason})${output === '' ? '' : ` · ${output}`}` }],
2933
+ expanded: false,
2934
+ });
2935
+ }
2419
2936
  this.markDirty();
2420
2937
  };
2421
2938
  // ── approval and questions ──────────────────────────────────────────────
@@ -2446,38 +2963,77 @@ export class SshTui {
2446
2963
  const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
2447
2964
  ? undefined
2448
2965
  : `子代理 ${request.agent.id}`;
2449
- for (const [index, question] of request.questions.entries()) {
2450
- const answer = await new Promise((resolve, reject) => {
2451
- const fail = (error) => {
2452
- request.signal?.removeEventListener('abort', onAbort);
2453
- reject(error);
2454
- };
2455
- const onAbort = () => {
2456
- request.signal?.removeEventListener('abort', onAbort);
2457
- if (dialog !== undefined) {
2458
- dialog.reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
2459
- }
2460
- else {
2461
- reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
2966
+ const cards = [];
2967
+ for (const question of request.questions) {
2968
+ const card = {
2969
+ kind: 'question',
2970
+ questionId: question.id,
2971
+ title: question.question,
2972
+ ...(question.header === undefined ? {} : { header: question.header }),
2973
+ ...(question.detail === undefined ? {} : { detail: question.detail }),
2974
+ intent: planReviewOf(question) ? 'plan-review' : 'ask',
2975
+ status: 'waiting',
2976
+ summary: question.question,
2977
+ expanded: false,
2978
+ };
2979
+ cards.push(card);
2980
+ this.pushRow(card);
2981
+ }
2982
+ this.markDirty();
2983
+ const settleCards = (status, summary) => {
2984
+ for (const card of cards) {
2985
+ if (card.status === 'waiting') {
2986
+ card.status = status;
2987
+ card.summary = summary;
2988
+ }
2989
+ }
2990
+ };
2991
+ try {
2992
+ for (const [index, question] of request.questions.entries()) {
2993
+ const answer = await new Promise((resolve, reject) => {
2994
+ const fail = (error) => {
2995
+ request.signal?.removeEventListener('abort', onAbort);
2996
+ reject(error);
2997
+ };
2998
+ const onAbort = () => {
2999
+ request.signal?.removeEventListener('abort', onAbort);
3000
+ if (dialog !== undefined) {
3001
+ dialog.reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
3002
+ }
3003
+ else {
3004
+ reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
3005
+ }
3006
+ };
3007
+ let dialog;
3008
+ request.signal?.addEventListener('abort', onAbort, { once: true });
3009
+ if (request.signal?.aborted === true) {
3010
+ onAbort();
3011
+ return;
2462
3012
  }
2463
- };
2464
- let dialog;
2465
- request.signal?.addEventListener('abort', onAbort, { once: true });
2466
- if (request.signal?.aborted === true) {
2467
- onAbort();
2468
- return;
3013
+ const labeled = agentLabel === undefined
3014
+ ? question
3015
+ : { ...question, question: `[${agentLabel}] ${question.question}` };
3016
+ dialog = this.openQuestion(labeled, index, request.questions.length, (selection) => {
3017
+ request.signal?.removeEventListener('abort', onAbort);
3018
+ resolve(selection);
3019
+ }, fail);
3020
+ });
3021
+ answers.push({ id: question.id, selected: answer.selected, custom: answer.custom });
3022
+ const card = cards[index];
3023
+ if (card !== undefined) {
3024
+ card.status = 'answered';
3025
+ card.summary = answer.custom !== undefined && answer.custom !== ''
3026
+ ? answer.custom
3027
+ : answer.selected.join(', ') || '已回答';
2469
3028
  }
2470
- const labeled = agentLabel === undefined
2471
- ? question
2472
- : { ...question, question: `[${agentLabel}] ${question.question}` };
2473
- dialog = this.openQuestion(labeled, index, request.questions.length, (selection) => {
2474
- request.signal?.removeEventListener('abort', onAbort);
2475
- resolve(selection);
2476
- }, fail);
2477
- });
2478
- answers.push({ id: question.id, selected: answer.selected, custom: answer.custom });
3029
+ }
3030
+ settleCards('answered', '已回答');
3031
+ return { answers };
3032
+ }
3033
+ catch (error) {
3034
+ settleCards('cancelled', error instanceof UserQuestionError ? error.message : '已取消');
3035
+ throw error;
2479
3036
  }
2480
- return { answers };
2481
3037
  };
2482
3038
  /** Queue one dialog behind an already-open one instead of overwriting it. */
2483
3039
  openDialog(dialog) {
@@ -2631,9 +3187,13 @@ export class SshTui {
2631
3187
  }
2632
3188
  if (ids.has(modelId))
2633
3189
  return true;
3190
+ const modelEntry = { id: modelId };
3191
+ const reasoningEfforts = reasoningEffortsForDefault(profile.reasoning);
3192
+ if (reasoningEfforts !== undefined)
3193
+ modelEntry.reasoningEfforts = reasoningEfforts;
2634
3194
  try {
2635
3195
  await settings.mutate(settingsNamespace('llm-pi-ai'), [
2636
- { op: 'set', path: ['providers', provider, 'models'], value: [...models, { id: modelId }] },
3196
+ { op: 'set', path: ['providers', provider, 'models'], value: [...models, modelEntry] },
2637
3197
  ]);
2638
3198
  this.pushRow({ kind: 'system', text: `模型 ${modelId} 已加入提供商 ${provider} 的配置。` });
2639
3199
  this.markDirty();
@@ -2697,11 +3257,51 @@ export class SshTui {
2697
3257
  return page.find(option => option.label === picked.label);
2698
3258
  }
2699
3259
  }
2700
- /** /model: pick a model and reasoning effort for the current provider. */
3260
+ /** Live adapter routes the TUI can switch to, plus the current selection. */
3261
+ listSelectableProviders() {
3262
+ const llm = this.ctx.get('llm');
3263
+ const current = this.currentProviderId();
3264
+ const seen = new Set();
3265
+ const out = [];
3266
+ const add = (id, name) => {
3267
+ if (id === '' || seen.has(id))
3268
+ return;
3269
+ seen.add(id);
3270
+ const kind = describeProviderRoute(id);
3271
+ const display = name !== undefined && name !== '' && name !== id ? name : kind.short;
3272
+ out.push({ id, label: `${display} · ${id}` });
3273
+ };
3274
+ for (const info of llm?.listProviders() ?? [])
3275
+ add(info.id, info.name);
3276
+ add(current);
3277
+ add('deepseek-official', 'DeepSeek 官方');
3278
+ add('xai', 'SuperGrok');
3279
+ add('opencode-go', 'OpenCode Go');
3280
+ add('opencode', 'OpenCode Zen');
3281
+ return out;
3282
+ }
3283
+ /** /model: pick a provider, then a model and reasoning effort on that route. */
2701
3284
  async runModelCommand() {
2702
3285
  const llm = this.ctx.get('llm');
2703
3286
  const current = this.selectionRef?.current;
2704
- const provider = current?.provider ?? this.agent.options.provider ?? this.providerName;
3287
+ const providers = this.listSelectableProviders();
3288
+ let provider = this.currentProviderId();
3289
+ if (providers.length > 1) {
3290
+ const answer = await this.askQuestion({
3291
+ id: 'provider-pick',
3292
+ question: '选择提供商',
3293
+ options: providers.map(option => ({
3294
+ label: option.label,
3295
+ description: option.id === provider
3296
+ ? `${describeProviderRoute(option.id).kind} · 当前`
3297
+ : describeProviderRoute(option.id).kind,
3298
+ })),
3299
+ });
3300
+ const picked = providers.find(option => option.label === answer.selected[0]);
3301
+ if (picked === undefined)
3302
+ return;
3303
+ provider = picked.id;
3304
+ }
2705
3305
  let modelOptions = [];
2706
3306
  let modelSource = '已配置列表';
2707
3307
  // OpenCode and other third-party routes are interrogated live so the picker
@@ -2790,9 +3390,10 @@ export class SshTui {
2790
3390
  this.selectionRef.current = next;
2791
3391
  this.onSelectionChanged?.(next);
2792
3392
  await this.ctx.get('agentDefaultModel')?.saveSelection(next);
3393
+ const kind = describeProviderRoute(provider);
2793
3394
  this.pushRow({
2794
3395
  kind: 'system',
2795
- text: `模型已切换:${selected.id}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
3396
+ text: `已切换到 ${kind.kind}:${provider}/${selected.id}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
2796
3397
  });
2797
3398
  this.markDirty();
2798
3399
  }
@@ -3729,12 +4330,18 @@ export class SshTui {
3729
4330
  const defaultEffort = model !== undefined && llm !== undefined
3730
4331
  ? await defaultReasoningEffort(llm, state.providerId, model)
3731
4332
  : undefined;
4333
+ const reasoningEfforts = defaultEffort === undefined
4334
+ ? undefined
4335
+ : { off: null, [defaultEffort]: defaultEffort };
3732
4336
  const profile = {
3733
4337
  displayName: template.label,
3734
4338
  apiKeyEnv: envRef,
3735
4339
  api: template.api,
3736
4340
  baseURL: state.baseUrl === '' ? template.defaultBaseUrl : state.baseUrl,
3737
- models: state.models.map(id => ({ id })),
4341
+ models: state.models.map(id => ({
4342
+ id,
4343
+ ...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
4344
+ })),
3738
4345
  ...(defaultEffort === undefined ? {} : { reasoning: defaultEffort }),
3739
4346
  };
3740
4347
  if (settings === undefined) {
@@ -3806,7 +4413,6 @@ export class SshTui {
3806
4413
  const home = dshHomeDir();
3807
4414
  const file = join(home, IS_WINDOWS ? 'env.cmd' : 'env.sh');
3808
4415
  await mkdir(home, { recursive: true, mode: 0o700 });
3809
- const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
3810
4416
  if (IS_WINDOWS) {
3811
4417
  let previous = '';
3812
4418
  try {
@@ -3864,6 +4470,8 @@ export class SshTui {
3864
4470
  }
3865
4471
  /** Idempotently source $DSH_HOME/env.sh from the user's POSIX shell rc files. */
3866
4472
  async ensurePosixEnvHook() {
4473
+ if (process.env.DSH_TUI_NO_RC_HOOK === '1' || process.env.DSH_TUI_NO_RC_HOOK === 'true')
4474
+ return;
3867
4475
  const envFile = join(dshHomeDir(), 'env.sh');
3868
4476
  const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
3869
4477
  const sourceLine = `[ -f ${quote(envFile)} ] && . ${quote(envFile)}`;
@@ -4008,14 +4616,18 @@ export class SshTui {
4008
4616
  .filter(item => item.name !== 'help' && item.name !== 'exit')
4009
4617
  .map(item => `/${item.name.padEnd(12)} ${item.description}`);
4010
4618
  const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
4011
- .map(item => `/${item.name.padEnd(12)} ${item.description} (dsh)`);
4619
+ .map(item => `/${item.name.padEnd(12)} ${item.description}${item.input?.images === true ? '(可附图)' : ''} (dsh)`);
4012
4620
  this.pushRow({
4013
4621
  kind: 'system',
4014
4622
  text: [
4015
4623
  ...local,
4016
4624
  ...dsh,
4017
4625
  '',
4018
- 'Enter while running steers the agent; Esc or Ctrl+C cancels the turn.',
4626
+ '运行中按 Enter 可插入指示;Esc / Ctrl+C 取消当前轮次。',
4627
+ '↑/↓ 或 Ctrl+N/P 选择思考、工具、子代理、计划或提问卡片;Enter 展开/折叠;Ctrl+R 全部展开或收起。',
4628
+ '计划模式、提问用户和当前目标会显示独立卡片;多个子代理默认各自折叠,互不混排。',
4629
+ '/model 先选提供商(DeepSeek 官方 / SuperGrok 订阅 / OpenCode …),再选模型和思考强度。',
4630
+ '/status 会标明当前是 DeepSeek 官方、SuperGrok 订阅、OpenCode Go / Zen,还是其它已注册提供商。',
4019
4631
  ].join('\n'),
4020
4632
  });
4021
4633
  break;
@@ -4074,12 +4686,28 @@ export class SshTui {
4074
4686
  this.streamingReasoning = undefined;
4075
4687
  this.thinkingStartedAt = undefined;
4076
4688
  this.focusedRow = null;
4689
+ this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
4077
4690
  break;
4078
4691
  case 'status':
4079
- this.pushRow({
4080
- kind: 'system',
4081
- text: `session: ${this.agent.id}\nmodel: ${this.agent.options.model ?? 'default'}\nprovider: ${this.agent.options.provider ?? 'default'}\nstatus: ${this.agent.status}`,
4082
- });
4692
+ {
4693
+ const plan = this.findLivePlanRow();
4694
+ const waiting = this.rows.filter(row => row.kind === 'question' && row.status === 'waiting').length;
4695
+ const provider = this.currentProviderId();
4696
+ const route = describeProviderRoute(provider);
4697
+ const model = this.selectionRef?.current?.model ?? this.agent.options.model ?? 'default';
4698
+ const effort = this.selectionRef?.current?.reasoningEffort;
4699
+ const lines = [
4700
+ `session: ${this.agent.id}`,
4701
+ `route: ${provider}/${model}${effort === undefined ? '' : ` (${effort})`}`,
4702
+ `provider: ${route.kind}`,
4703
+ `status: ${this.agent.status}`,
4704
+ `preset: ${this.presetName}`,
4705
+ `subagents: ${this.activeSubagents.size}`,
4706
+ `plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
4707
+ waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
4708
+ ];
4709
+ this.pushRow({ kind: 'system', text: lines.join('\n') });
4710
+ }
4083
4711
  break;
4084
4712
  case 'usage':
4085
4713
  case 'quota':
@@ -4089,12 +4717,43 @@ export class SshTui {
4089
4717
  });
4090
4718
  break;
4091
4719
  case 'subagents': {
4720
+ const trimmed = arg.trim();
4721
+ if (trimmed !== '' && trimmed !== 'list') {
4722
+ const [action, ...ids] = trimmed.split(/\s+/u);
4723
+ if (action === 'kill' || action === 'stop') {
4724
+ if (ids.length === 0) {
4725
+ this.pushRow({ kind: 'error', text: '/subagents kill <session-id> — 缺少子代理会话 ID' });
4726
+ break;
4727
+ }
4728
+ const subagents = this.ctx.get('subagents');
4729
+ if (subagents === undefined) {
4730
+ this.pushRow({ kind: 'error', text: 'subagents service is unavailable' });
4731
+ break;
4732
+ }
4733
+ const targets = ids.map(id => SessionId(id));
4734
+ void subagents.drainContinuableChildren(this.agent, targets).then(() => {
4735
+ this.pushRow({ kind: 'system', text: `已请求释放子代理:${ids.join(', ')}` });
4736
+ this.markDirty();
4737
+ }).catch((error) => {
4738
+ this.pushRow({ kind: 'error', text: `/subagents kill failed: ${errorChain(error)}` });
4739
+ this.markDirty();
4740
+ });
4741
+ break;
4742
+ }
4743
+ this.pushRow({ kind: 'error', text: `/subagents 未知操作 "${action}"(支持 list / kill <id>)` });
4744
+ break;
4745
+ }
4092
4746
  if (this.activeSubagents.size === 0) {
4093
4747
  this.pushRow({ kind: 'system', text: '当前没有活动的子代理。' });
4094
4748
  }
4095
4749
  else {
4096
- const lines = [...this.activeSubagents.entries()].map(([runId, sub]) => `▶ ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]`);
4097
- this.pushRow({ kind: 'system', text: lines.join('\n') });
4750
+ const lines = [...this.activeSubagents.entries()].map(([runId, sub]) => {
4751
+ const card = this.findSubagentRow(sub.id);
4752
+ const label = card?.label ?? sub.id;
4753
+ const activity = card?.lastActivity ? ` · ${card.lastActivity}` : '';
4754
+ return `▶ ${label} ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]${activity}`;
4755
+ });
4756
+ this.pushRow({ kind: 'system', text: `${lines.join('\n')}\n↑/↓ 选择对应卡片,Enter 展开/折叠,Ctrl+R 全部展开或收起。` });
4098
4757
  }
4099
4758
  break;
4100
4759
  }
@@ -4144,7 +4803,7 @@ export class SshTui {
4144
4803
  this.commandAbort?.abort();
4145
4804
  const controller = new AbortController();
4146
4805
  this.commandAbort = controller;
4147
- void commands.execute(this.agent, text, controller.signal).then((execution) => {
4806
+ void commands.execute(this.agent, text, [], controller.signal).then((execution) => {
4148
4807
  if (execution === undefined) {
4149
4808
  this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
4150
4809
  return;
@@ -4267,6 +4926,10 @@ export class SshTui {
4267
4926
  function optionsLength(dialog) {
4268
4927
  return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
4269
4928
  }
4929
+ /** Escape a string for safe interpolation into a RegExp source. */
4930
+ function escapeRegex(value) {
4931
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
4932
+ }
4270
4933
  function envRefForId(providerId) {
4271
4934
  return `${providerId.replaceAll('-', '_').toUpperCase()}_API_KEY`;
4272
4935
  }