dsh-ssh-tui 0.1.7 → 0.2.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
@@ -206,7 +206,7 @@ const LOCAL_COMMANDS = [
206
206
  { name: 'status', description: 'show session, provider and model status' },
207
207
  { name: 'usage', description: 'show OpenCode Zen billing / Go quota usage' },
208
208
  { name: 'quota', description: 'alias of /usage for OpenCode Go quota' },
209
- { name: 'subagents', description: 'list active subagents' },
209
+ { name: 'subagents', description: 'list active subagents; kill <id> to stop one' },
210
210
  { name: 'resume', description: 'resume a past session (empty = session picker)' },
211
211
  { name: 'setup', description: 're-open provider / API key setup' },
212
212
  { name: 'dialog-test', description: 'verify the question dialog' },
@@ -637,6 +637,15 @@ function cursorVisualPosition(text, cursor, width) {
637
637
  }
638
638
  return { row, col };
639
639
  }
640
+ /** Build per-model reasoningEfforts from a provider-level reasoning default. */
641
+ function reasoningEffortsForDefault(reasoning) {
642
+ if (typeof reasoning !== 'string')
643
+ return undefined;
644
+ const level = reasoning.trim();
645
+ if (level === '' || level === 'off')
646
+ return undefined;
647
+ return { off: null, [level]: level };
648
+ }
640
649
  const OPENCODE_GO_USAGE_URL = 'https://opencode.ai/zen/go/v1/usage';
641
650
  const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1';
642
651
  /**
@@ -754,7 +763,7 @@ function openCodeApiErrorMessage(payload) {
754
763
  return '';
755
764
  }
756
765
  /** Whether `text` could still grow into a recognized escape sequence. */
757
- function isEscapePrefix(text) {
766
+ export function isEscapePrefix(text) {
758
767
  if (text === '\x1b')
759
768
  return true;
760
769
  if (!text.startsWith('\x1b'))
@@ -767,7 +776,7 @@ function isEscapePrefix(text) {
767
776
  return true;
768
777
  if (/^\x1b\[[HF]$/u.test(text))
769
778
  return true;
770
- if (/^\x1b\[\d~?$/u.test(text))
779
+ if (/^\x1b\[\d+~?$/u.test(text))
771
780
  return true;
772
781
  if (/^\x1b\[<(?:\d*;?)*[Mm]?$/u.test(text))
773
782
  return true;
@@ -804,6 +813,12 @@ function lastCodePoints(text, max) {
804
813
  return '';
805
814
  return Array.from(text).slice(-max).join('');
806
815
  }
816
+ /** Format a model list compactly: show the first few entries and an ellipsis. */
817
+ function formatModelList(models, max = 5) {
818
+ const shown = models.slice(0, max);
819
+ const text = shown.join(', ');
820
+ return models.length > max ? `${text}…(共 ${models.length} 个)` : text;
821
+ }
807
822
  /** Prefer the fields a human scans for; fall back to the first scalar pairs. */
808
823
  function friendlyArgsSummary(name, args) {
809
824
  const parsed = parseJsonArgs(args);
@@ -838,6 +853,89 @@ function friendlyArgsSummary(name, args) {
838
853
  }
839
854
  const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
840
855
  const DIFF_TOOL_NAMES = new Set(['edit', 'write', 'str_replace_editor']);
856
+ const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_fork', 'task']);
857
+ const MAX_SUBAGENT_LOGS = 80;
858
+ const TODO_STATUS_MARK = {
859
+ pending: '○',
860
+ in_progress: '◉',
861
+ completed: '✓',
862
+ };
863
+ /** Parse a todo_write payload into displayable plan items. */
864
+ export function parsePlanTodos(value) {
865
+ const root = typeof value === 'string' ? parseJsonArgs(value) : value;
866
+ const todos = root !== null && typeof root === 'object' && !Array.isArray(root)
867
+ ? root.todos
868
+ : Array.isArray(root) ? root : undefined;
869
+ if (!Array.isArray(todos))
870
+ return [];
871
+ const out = [];
872
+ for (const item of todos) {
873
+ if (typeof item !== 'object' || item === null)
874
+ continue;
875
+ const content = typeof item.content === 'string'
876
+ ? item.content.trim()
877
+ : '';
878
+ if (content === '')
879
+ continue;
880
+ const status = item.status;
881
+ out.push({
882
+ content,
883
+ status: status === 'in_progress' || status === 'completed' ? status : 'pending',
884
+ });
885
+ }
886
+ return out;
887
+ }
888
+ /** Compact todo-list summary: done/total plus the first in-progress task. */
889
+ export function todoSummary(value) {
890
+ const todos = parsePlanTodos(value);
891
+ if (todos.length === 0)
892
+ return '计划列表';
893
+ const done = todos.filter(item => item.status === 'completed').length;
894
+ const active = todos.find(item => item.status === 'in_progress');
895
+ const extra = todos.filter(item => item.status === 'in_progress').length;
896
+ const head = `${done}/${todos.length} 完成`;
897
+ if (active === undefined)
898
+ return head;
899
+ return extra > 1 ? `${head} · ${active.content} +${extra - 1}` : `${head} · ${active.content}`;
900
+ }
901
+ /** Compact ask_user_question summary from tool arguments. */
902
+ export function askSummary(value) {
903
+ const root = typeof value === 'string' ? parseJsonArgs(value) : value;
904
+ const questions = root !== null && typeof root === 'object' && !Array.isArray(root)
905
+ ? root.questions
906
+ : undefined;
907
+ if (!Array.isArray(questions) || questions.length === 0)
908
+ return '等待回答';
909
+ const first = questions[0];
910
+ const text = typeof first === 'object' && first !== null && typeof first.question === 'string'
911
+ ? first.question
912
+ : '等待回答';
913
+ return questions.length > 1 ? `${text}(${questions.length} 题)` : text;
914
+ }
915
+ /** One-line subagent card header used while collapsed. */
916
+ export function subagentHeaderText(row, now = Date.now()) {
917
+ const elapsed = Math.max(0, Math.floor(((row.endedAt ?? now) - row.startedAt) / 1000));
918
+ const elapsedLabel = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${elapsed % 60}s` : `${elapsed}s`;
919
+ const state = row.status === 'running'
920
+ ? '运行中'
921
+ : row.status === 'ok'
922
+ ? '完成'
923
+ : row.status === 'aborted'
924
+ ? '已中断'
925
+ : '失败';
926
+ const activity = row.lastActivity === '' ? '' : ` · ${row.lastActivity}`;
927
+ const id = row.sessionId.slice(0, 8);
928
+ return `${row.label} [${id}] ${state} · ${elapsedLabel}${activity}`;
929
+ }
930
+ function appendSubagentLog(row, entry) {
931
+ row.logs.push(entry);
932
+ if (row.logs.length > MAX_SUBAGENT_LOGS)
933
+ row.logs.splice(0, row.logs.length - MAX_SUBAGENT_LOGS);
934
+ row.lastActivity = entry.text;
935
+ }
936
+ function planReviewOf(question) {
937
+ return question.intent?.kind === 'plan-review' && question.detail !== undefined && question.detail !== '';
938
+ }
841
939
  /** Derive the intended file change from a mutation tool's arguments. */
842
940
  function diffHunksFromArgs(name, argsRaw) {
843
941
  const args = parseJsonArgs(argsRaw);
@@ -899,6 +997,22 @@ export function presentToolCall(name, args) {
899
997
  ...diff === null || diff === undefined ? {} : { diff },
900
998
  };
901
999
  }
1000
+ if (SUBAGENT_TOOL_NAMES.has(name)) {
1001
+ const description = typeof parsed?.description === 'string' ? parsed.description.trim() : '';
1002
+ return {
1003
+ title: name === 'subagent_fork' ? '子代理 fork' : '子代理',
1004
+ summary: description === '' ? friendlyArgsSummary(name, args) : description,
1005
+ };
1006
+ }
1007
+ if (name === 'todo_write' || name === 'todo') {
1008
+ return { title: '计划', summary: todoSummary(parsed) };
1009
+ }
1010
+ if (name === 'ask_user_question') {
1011
+ return { title: '提问用户', summary: askSummary(parsed) };
1012
+ }
1013
+ if (name === 'exit_plan_mode') {
1014
+ return { title: '退出计划模式', summary: '等待确认计划' };
1015
+ }
902
1016
  return { title: name, summary: friendlyArgsSummary(name, args) };
903
1017
  }
904
1018
  /** Validate a tool/result meta payload's structured diff, mirroring the web card. */
@@ -1234,7 +1348,7 @@ export class SshTui {
1234
1348
  this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
1235
1349
  this.pushRow({ kind: 'brand-logo' });
1236
1350
  this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
1237
- this.pushRow({ kind: 'system', text: 'Type /help for commands · /setup provider & key · ↑/↓ select · Enter expand/collapse · Ctrl+T fold input · Esc cancels' });
1351
+ this.pushRow({ kind: 'system', text: '输入 /help 查看命令 · /setup 配置提供商 · ↑/↓ 选择卡片 · Enter 展开/折叠 · Ctrl+R 全部展开/收起 · Ctrl+T 折叠输入 · Esc 取消' });
1238
1352
  }
1239
1353
  /** Enter raw mode, switch to the alternate screen, and start listening. */
1240
1354
  start() {
@@ -1258,9 +1372,12 @@ export class SshTui {
1258
1372
  const now = Date.now();
1259
1373
  if (this.agent.status === 'running')
1260
1374
  this.updateTerminalTitle();
1261
- if (this.streaming !== undefined
1262
- && this.streaming.reasoning !== ''
1263
- && now - this.lastPaintAt >= 200) {
1375
+ const animating = (this.streaming !== undefined && this.streaming.reasoning !== '')
1376
+ || this.activeSubagents.size > 0
1377
+ || this.rows.some(row => (row.kind === 'question' && row.status === 'waiting')
1378
+ || (row.kind === 'plan' && (row.active || row.pending || row.todos.some(item => item.status === 'in_progress')))
1379
+ || (row.kind === 'goal' && (row.phase === 'active' || row.phase === 'blocked')));
1380
+ if (animating && now - this.lastPaintAt >= 200) {
1264
1381
  this.dirty = true;
1265
1382
  }
1266
1383
  // While a turn is waiting on the provider with no new events, repaint at
@@ -1324,7 +1441,7 @@ export class SshTui {
1324
1441
  const content = await readFile(credentialFile, 'utf8');
1325
1442
  if (this.disposed)
1326
1443
  return;
1327
- stored = new RegExp(`^${envRef}\\s*:\\s*\\S`, 'm').test(content);
1444
+ stored = new RegExp(`^${escapeRegex(envRef)}\\s*:\\s*\\S`, 'm').test(content);
1328
1445
  }
1329
1446
  }
1330
1447
  catch {
@@ -1484,13 +1601,60 @@ export class SshTui {
1484
1601
  }
1485
1602
  /** The transcript rows that support per-row expand/collapse. */
1486
1603
  collapsibleRows() {
1487
- const rows = this.rows.filter((row) => row.kind === 'reasoning' || row.kind === 'tool');
1604
+ const rows = this.rows.filter((row) => row.kind === 'reasoning'
1605
+ || row.kind === 'tool'
1606
+ || row.kind === 'subagent'
1607
+ || row.kind === 'plan'
1608
+ || row.kind === 'question'
1609
+ || row.kind === 'goal');
1488
1610
  if (this.streaming !== undefined && this.streaming.reasoning !== '') {
1489
1611
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
1490
1612
  rows.push(this.streamingReasoning);
1491
1613
  }
1492
1614
  return rows;
1493
1615
  }
1616
+ spinnerFrame(periodMs = 120) {
1617
+ return SPINNER[Math.floor(Date.now() / periodMs) % SPINNER.length] ?? '⠋';
1618
+ }
1619
+ findSubagentRow(sessionId) {
1620
+ return this.rows.findLast((row) => row.kind === 'subagent' && row.sessionId === sessionId);
1621
+ }
1622
+ findLivePlanRow() {
1623
+ return this.rows.findLast((row) => row.kind === 'plan');
1624
+ }
1625
+ upsertPlanRow(patch) {
1626
+ const existing = this.findLivePlanRow();
1627
+ if (existing !== undefined) {
1628
+ Object.assign(existing, patch);
1629
+ return existing;
1630
+ }
1631
+ const row = {
1632
+ kind: 'plan',
1633
+ active: patch.active ?? false,
1634
+ pending: patch.pending ?? false,
1635
+ todos: patch.todos ?? [],
1636
+ expanded: false,
1637
+ };
1638
+ this.pushRow(row);
1639
+ return row;
1640
+ }
1641
+ paintCollapsibleHeader(addDisplay, row, kind, header, width, colorize) {
1642
+ const focused = this.focusedRow === row;
1643
+ const marker = row.expanded ? '▾' : '▸';
1644
+ const prefix = focused ? '▶ ' : ' ';
1645
+ const plain = `${prefix}${marker} ${header}`;
1646
+ const paint = colorize ?? ((line) => this.styleLine(kind, line));
1647
+ if (!row.expanded) {
1648
+ const collapsed = truncateToWidth(plain, Math.max(1, width - 2));
1649
+ const styled = paint(collapsed);
1650
+ addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
1651
+ return;
1652
+ }
1653
+ for (const wrapped of wrap(plain, width)) {
1654
+ const styled = paint(wrapped);
1655
+ addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
1656
+ }
1657
+ }
1494
1658
  /** Move the expand/collapse focus among reasoning and tool rows. */
1495
1659
  moveCollapsibleFocus(delta) {
1496
1660
  const rows = this.collapsibleRows();
@@ -1598,6 +1762,7 @@ export class SshTui {
1598
1762
  return this.styleLine('tool', line);
1599
1763
  return `${this.styleLine('tool', line.slice(0, dotIndex))}\x1b[${dotColor}m●${this.styleLine('tool', line.slice(dotIndex + 1))}`;
1600
1764
  };
1765
+ const spinner = running ? ` ${this.spinnerFrame()}` : '';
1601
1766
  const state = running ? 'running…' : ok ? 'ok' : 'error';
1602
1767
  const summary = row.summary === '' ? '' : ` ${row.summary}`;
1603
1768
  const exit = !running && row.command !== undefined
@@ -1609,7 +1774,7 @@ export class SshTui {
1609
1774
  : '';
1610
1775
  const focused = this.focusedRow === row;
1611
1776
  const marker = row.expanded ? '▾' : '▸';
1612
- const plainHeader = `${marker} ● ${row.title}${summary} [${state}]${exit}`;
1777
+ const plainHeader = `${marker} ● ${row.title}${summary} [${state}]${exit}${spinner}`;
1613
1778
  if (!row.expanded) {
1614
1779
  const collapsed = truncateToWidth(`${focused ? '▶ ' : ' '}${plainHeader}`, Math.max(1, width - 2));
1615
1780
  const styled = styleToolHeader(collapsed);
@@ -1626,6 +1791,116 @@ export class SshTui {
1626
1791
  }
1627
1792
  continue;
1628
1793
  }
1794
+ if (row.kind === 'subagent') {
1795
+ const running = row.status === 'running';
1796
+ const ok = row.status === 'ok';
1797
+ const dotColor = !this.color ? undefined : running ? '33' : ok ? '32' : '31';
1798
+ const styleHeader = (line) => {
1799
+ const dotIndex = line.indexOf('●');
1800
+ if (dotColor === undefined || dotIndex === -1)
1801
+ return this.styleLine('tool', line);
1802
+ return `${this.styleLine('tool', line.slice(0, dotIndex))}\x1b[${dotColor}m●${this.styleLine('tool', line.slice(dotIndex + 1))}`;
1803
+ };
1804
+ const spinner = running ? ` ${this.spinnerFrame()}` : '';
1805
+ const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
1806
+ this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width, styleHeader);
1807
+ if (row.expanded) {
1808
+ addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`));
1809
+ if (row.stopReason !== undefined) {
1810
+ addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`));
1811
+ }
1812
+ if (row.logs.length === 0) {
1813
+ addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'));
1814
+ }
1815
+ else {
1816
+ for (const entry of row.logs) {
1817
+ const kind = entry.kind === 'assistant'
1818
+ ? 'assistant'
1819
+ : entry.kind === 'result' && row.status === 'error'
1820
+ ? 'error'
1821
+ : 'tool-result';
1822
+ for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
1823
+ addDisplay(this.styleLine(kind, ` ${wrapped}`));
1824
+ }
1825
+ }
1826
+ }
1827
+ }
1828
+ continue;
1829
+ }
1830
+ if (row.kind === 'plan') {
1831
+ const running = row.todos.some(item => item.status === 'in_progress');
1832
+ const spinner = (row.active || row.pending || running) ? ` ${this.spinnerFrame()}` : '';
1833
+ const mode = row.pending
1834
+ ? '切换中'
1835
+ : row.active
1836
+ ? '计划模式'
1837
+ : '计划';
1838
+ const header = `● ${mode}${spinner} · ${todoSummary(row.todos)}${row.expanded ? '' : ' · Enter 展开'}`;
1839
+ this.paintCollapsibleHeader(addDisplay, row, 'system', header, width);
1840
+ if (row.expanded) {
1841
+ addDisplay(this.styleLine('system', row.active
1842
+ ? ' 当前处于计划模式:只规划、不改代码,确认后再执行。'
1843
+ : ' 计划模式已关闭。可用 /plan 重新进入。'));
1844
+ if (row.pending)
1845
+ addDisplay(this.styleLine('system', ' 模式切换将在下一步生效。'));
1846
+ if (row.todos.length === 0) {
1847
+ addDisplay(this.styleLine('tool-result', ' 还没有任务列表'));
1848
+ }
1849
+ else {
1850
+ for (const item of row.todos) {
1851
+ const mark = TODO_STATUS_MARK[item.status];
1852
+ for (const wrapped of wrap(`${mark} ${item.content}`, Math.max(1, width - 2))) {
1853
+ addDisplay(this.styleLine(item.status === 'completed' ? 'system' : 'tool', ` ${wrapped}`));
1854
+ }
1855
+ }
1856
+ }
1857
+ }
1858
+ continue;
1859
+ }
1860
+ if (row.kind === 'question') {
1861
+ const waiting = row.status === 'waiting';
1862
+ const spinner = waiting ? ` ${this.spinnerFrame()}` : '';
1863
+ const state = waiting ? '等待回答' : row.status === 'answered' ? '已回答' : '已取消';
1864
+ const title = row.intent === 'plan-review' ? '计划待审' : '提问用户';
1865
+ const header = `● ${title}${spinner} · ${state} · ${row.summary}${row.expanded ? '' : ' · Enter 展开'}`;
1866
+ this.paintCollapsibleHeader(addDisplay, row, waiting ? 'tool' : 'system', header, width);
1867
+ if (row.expanded) {
1868
+ if (row.header !== undefined)
1869
+ addDisplay(this.styleLine('system', ` ${row.header}`));
1870
+ for (const wrapped of wrap(row.title, Math.max(1, width - 2))) {
1871
+ addDisplay(this.styleLine('assistant', ` ${wrapped}`));
1872
+ }
1873
+ if (row.detail !== undefined && row.detail !== '') {
1874
+ for (const wrapped of wrap(row.detail, Math.max(1, width - 2))) {
1875
+ addDisplay(this.styleLine('tool-result', ` ${wrapped}`));
1876
+ }
1877
+ }
1878
+ addDisplay(this.styleLine('system', waiting
1879
+ ? ' 用下方对话框选择,数字/字母选中,Enter 提交,Esc 取消。'
1880
+ : ` ${row.summary}`));
1881
+ }
1882
+ continue;
1883
+ }
1884
+ if (row.kind === 'goal') {
1885
+ const live = row.phase === 'active' || row.phase === 'blocked';
1886
+ const spinner = live ? ` ${this.spinnerFrame()}` : '';
1887
+ const phase = row.phase === 'active' ? '进行中'
1888
+ : row.phase === 'paused' ? '已暂停'
1889
+ : row.phase === 'blocked' ? '受阻'
1890
+ : row.phase === 'complete' ? '已完成'
1891
+ : '已清除';
1892
+ const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : ' · Enter 展开'}`;
1893
+ this.paintCollapsibleHeader(addDisplay, row, live ? 'tool' : 'system', header, width);
1894
+ if (row.expanded) {
1895
+ addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'));
1896
+ if (row.blockedReason !== undefined) {
1897
+ for (const wrapped of wrap(row.blockedReason, Math.max(1, width - 2))) {
1898
+ addDisplay(this.styleLine('error', ` ${wrapped}`));
1899
+ }
1900
+ }
1901
+ }
1902
+ continue;
1903
+ }
1629
1904
  pushRow(row.kind, row.text);
1630
1905
  }
1631
1906
  if (this.streaming !== undefined) {
@@ -1705,7 +1980,9 @@ export class SshTui {
1705
1980
  case 'models':
1706
1981
  addDialog(`提供商:${providerLabel}`);
1707
1982
  addDialog('模型 ID(多个用逗号或空格分隔):');
1708
- addDialog(` 默认:${template.defaultModels.join(', ')}`);
1983
+ addDialog(ob.models.length > 0
1984
+ ? ` 已获取(${ob.models.length}):${formatModelList(ob.models, 6)}`
1985
+ : ` 默认:${template.defaultModels.join(', ')}`);
1709
1986
  if (template.api !== undefined)
1710
1987
  addDialog(' Ctrl+F = 从端点获取模型列表');
1711
1988
  addDialog(' Enter 确认,Esc 取消');
@@ -1716,7 +1993,7 @@ export class SshTui {
1716
1993
  addDialog(` Provider ID: ${ob.providerId}`);
1717
1994
  addDialog(` Base URL: ${ob.baseUrl === '' ? (template.defaultBaseUrl || '(默认)') : ob.baseUrl}`);
1718
1995
  addDialog(` API 协议: ${template.api ?? 'deepseek-official'}`);
1719
- addDialog(` 模型: ${ob.models.join(', ')}`);
1996
+ addDialog(` 模型: ${formatModelList(ob.models, 8)}`);
1720
1997
  addDialog(` API Key: ${sliceCodePoints(ob.key, 6)}…${lastCodePoints(ob.key, 4)}(长度 ${ob.key.length})`);
1721
1998
  addDialog(' y = 保存, n = 重填, Esc = 取消');
1722
1999
  break;
@@ -1725,21 +2002,35 @@ export class SshTui {
1725
2002
  }
1726
2003
  else {
1727
2004
  const d = this.dialog;
1728
- addDialog(`Question ${d.index + 1}/${d.total}: ${d.question.question}`);
1729
- if (d.question.detail !== undefined && d.question.detail !== '') {
1730
- addDialog(truncate(d.question.detail, 6));
2005
+ const review = planReviewOf(d.question);
2006
+ if (review) {
2007
+ addDialog(`计划待审 ${d.index + 1}/${d.total}${d.question.header === undefined ? '' : ` · ${d.question.header}`}`);
2008
+ addDialog(d.question.question);
2009
+ if (d.question.detail !== undefined && d.question.detail !== '') {
2010
+ addDialog(truncate(d.question.detail, 12));
2011
+ }
2012
+ }
2013
+ else {
2014
+ addDialog(`提问用户 ${d.index + 1}/${d.total}: ${d.question.question}`);
2015
+ if (d.question.header !== undefined && d.question.header !== '')
2016
+ addDialog(d.question.header);
2017
+ if (d.question.detail !== undefined && d.question.detail !== '') {
2018
+ addDialog(truncate(d.question.detail, 6));
2019
+ }
1731
2020
  }
1732
2021
  const options = d.question.options ?? [];
2022
+ const approve = d.question.intent?.approve;
1733
2023
  for (const [index, option] of options.entries()) {
1734
2024
  const marker = d.selected.has(index) ? '●' : '○';
1735
2025
  const key = QUESTION_OPTION_KEYS[index] ?? '?';
2026
+ const recommended = option.label === approve ? '(推荐)' : '';
1736
2027
  const extra = option.description === undefined ? '' : ` — ${option.description}`;
1737
- addDialog(` ${key} ${marker} ${option.label}${extra}`);
2028
+ addDialog(` ${key} ${marker} ${option.label}${recommended}${extra}`);
1738
2029
  }
1739
2030
  if (options.length === 0) {
1740
- addDialog(' (free text: type below and press Enter)');
2031
+ addDialog(' (自由输入:在下方输入后按 Enter');
1741
2032
  }
1742
- addDialog(` ${d.question.multiSelect === true ? 'digits/letters toggle, Enter submit' : 'digit/letter to select, Enter submit'}, Esc to cancel`);
2033
+ addDialog(` ${d.question.multiSelect === true ? '数字/字母切换,Enter 提交' : '数字/字母选择,Enter 提交'}Esc 取消`);
1743
2034
  }
1744
2035
  }
1745
2036
  const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
@@ -1848,8 +2139,24 @@ export class SshTui {
1848
2139
  if (this.pendingMessages.size > 0)
1849
2140
  statusText += ` · 排队 ${this.pendingMessages.size}`;
1850
2141
  const idleMs = Date.now() - this.lastActivity;
1851
- if (this.agent.status === 'running' && this.activeSubagents.size > 0) {
1852
- statusText += ` · 子代理执行中 ${this.activeSubagents.size}`;
2142
+ const livePlan = this.findLivePlanRow();
2143
+ const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
2144
+ if (waitingQuestions || this.dialog?.kind === 'questions') {
2145
+ statusText += this.dialog?.kind === 'questions' && planReviewOf(this.dialog.question)
2146
+ ? ' · 计划待审'
2147
+ : ' · 等待用户回答';
2148
+ }
2149
+ else if (livePlan?.active === true || livePlan?.pending === true) {
2150
+ statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
2151
+ }
2152
+ const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
2153
+ if (liveGoal !== undefined && (liveGoal.phase === 'active' || liveGoal.phase === 'paused' || liveGoal.phase === 'blocked')) {
2154
+ const phase = liveGoal.phase === 'active' ? '目标进行中' : liveGoal.phase === 'paused' ? '目标已暂停' : '目标受阻';
2155
+ statusText += ` · ${phase}`;
2156
+ }
2157
+ if (this.activeSubagents.size > 0) {
2158
+ const spinner = this.spinnerFrame(160);
2159
+ statusText += ` · ${spinner} 子代理 ${this.activeSubagents.size}`;
1853
2160
  }
1854
2161
  else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
1855
2162
  statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
@@ -1885,6 +2192,9 @@ export class SshTui {
1885
2192
  this.pendingMessages.size,
1886
2193
  this.commandSuggestions.length,
1887
2194
  this.suggestionIndex,
2195
+ this.activeSubagents.size,
2196
+ this.dialog?.kind ?? '',
2197
+ this.findLivePlanRow()?.active === true ? 'plan' : '',
1888
2198
  ].join('\x1f');
1889
2199
  const chromeChanged = chromeKey !== this.lastChromeKey;
1890
2200
  // Incremental repaint: rewrite only rows whose content changed, so slow
@@ -1914,7 +2224,7 @@ export class SshTui {
1914
2224
  const prefix = input.slice(1).toLowerCase();
1915
2225
  const dsh = (this.ctx.get('commands')?.list(this.agent) ?? []).map(command => ({
1916
2226
  name: command.name,
1917
- description: command.description,
2227
+ description: command.input?.images === true ? `${command.description}(可附图)` : command.description,
1918
2228
  local: false,
1919
2229
  }));
1920
2230
  const all = [
@@ -2004,10 +2314,25 @@ export class SshTui {
2004
2314
  this.lastTitleUpdateAt = now;
2005
2315
  const spinner = SPINNER[Math.floor(now / 800) % SPINNER.length];
2006
2316
  let detail = '运行中';
2007
- if (this.openToolCalls.size > 0)
2008
- detail = `运行中 · 工具 ${this.openToolCalls.size}`;
2009
- else if (this.activeSubagents.size > 0)
2317
+ if (this.dialog?.kind === 'questions') {
2318
+ detail = planReviewOf(this.dialog.question) ? '计划待审' : '等待用户回答';
2319
+ }
2320
+ else if (this.activeSubagents.size > 0) {
2010
2321
  detail = `运行中 · 子代理 ${this.activeSubagents.size}`;
2322
+ }
2323
+ else if (this.openToolCalls.size > 0) {
2324
+ detail = `运行中 · 工具 ${this.openToolCalls.size}`;
2325
+ }
2326
+ else if (this.findLivePlanRow()?.active === true) {
2327
+ detail = '计划模式';
2328
+ }
2329
+ else {
2330
+ const liveGoal = this.rows.findLast((row) => row.kind === 'goal');
2331
+ if (liveGoal?.phase === 'active')
2332
+ detail = '目标进行中';
2333
+ else if (liveGoal?.phase === 'blocked')
2334
+ detail = '目标受阻';
2335
+ }
2011
2336
  this.write(`\x1b]0;dsh ${spinner} ${detail}\x07`);
2012
2337
  return;
2013
2338
  }
@@ -2158,14 +2483,20 @@ export class SshTui {
2158
2483
  this.recordUsage(event.data.turn, event.data.step, event.data.usage);
2159
2484
  }
2160
2485
  const reasoningExpanded = this.streamingReasoning?.expanded ?? false;
2486
+ const interrupted = event.data.interrupted === true;
2161
2487
  this.streaming = undefined;
2162
2488
  this.streamingReasoning = undefined;
2163
2489
  this.thinkingStartedAt = undefined;
2490
+ const interruptedMark = interrupted ? ' ⚠ 已中断' : '';
2164
2491
  if (reasoning !== '') {
2165
- this.pushRow({ kind: 'reasoning', text: reasoning, expanded: reasoningExpanded });
2492
+ this.pushRow({ kind: 'reasoning', text: `${reasoning}${interruptedMark}`, expanded: reasoningExpanded });
2493
+ }
2494
+ if (text !== '') {
2495
+ this.pushRow({ kind: 'assistant', text: `${text}${interruptedMark}` });
2496
+ }
2497
+ else if (interrupted && reasoning === '') {
2498
+ this.pushRow({ kind: 'system', text: '本轮输出已中断,没有可见内容。' });
2166
2499
  }
2167
- if (text !== '')
2168
- this.pushRow({ kind: 'assistant', text });
2169
2500
  this.markDirty();
2170
2501
  break;
2171
2502
  }
@@ -2185,7 +2516,7 @@ export class SshTui {
2185
2516
  ...present.command === undefined ? {} : { command: present.command },
2186
2517
  ...present.cwd === undefined ? {} : { cwd: present.cwd },
2187
2518
  ...present.diff === undefined ? {} : { diff: present.diff },
2188
- expanded: DIFF_TOOL_NAMES.has(event.data.name),
2519
+ expanded: DIFF_TOOL_NAMES.has(event.data.name) && !SUBAGENT_TOOL_NAMES.has(event.data.name),
2189
2520
  };
2190
2521
  this.pushRow(row);
2191
2522
  this.streaming = undefined;
@@ -2298,7 +2629,13 @@ export class SshTui {
2298
2629
  this.markDirty();
2299
2630
  break;
2300
2631
  }
2632
+ case 'todo/write': {
2633
+ this.upsertPlanRow({ todos: parsePlanTodos(event.data.todos) });
2634
+ this.markDirty();
2635
+ break;
2636
+ }
2301
2637
  default:
2638
+ this.handleExtensionEvent(event);
2302
2639
  break;
2303
2640
  }
2304
2641
  };
@@ -2308,6 +2645,7 @@ export class SshTui {
2308
2645
  this.lastActivity = Date.now();
2309
2646
  if (status === 'running') {
2310
2647
  this.completionSignaled = false;
2648
+ this.completedAt = 0;
2311
2649
  }
2312
2650
  else if (!this.completionSignaled && this.status === 'running') {
2313
2651
  this.completionSignaled = true;
@@ -2345,56 +2683,187 @@ export class SshTui {
2345
2683
  this.status = 'disposed';
2346
2684
  this.markDirty();
2347
2685
  };
2348
- /** Render a live subagent's own session events so its progress is visible. */
2686
+ /** Plan-mode / command / team events that plugins merge into SessionEventMap. */
2687
+ handleExtensionEvent(event) {
2688
+ const type = String(event.type);
2689
+ const data = event.data;
2690
+ if (type === 'plan/mode') {
2691
+ const active = data?.active === true;
2692
+ this.upsertPlanRow({ active, pending: false });
2693
+ this.pushRow({
2694
+ kind: 'system',
2695
+ text: active
2696
+ ? '已进入计划模式:先规划、等确认后再改代码。可用 /plan off 退出。'
2697
+ : '已退出计划模式,可以继续执行改动。',
2698
+ });
2699
+ this.markDirty();
2700
+ return;
2701
+ }
2702
+ if (type === 'command/run' && data?.name === 'plan') {
2703
+ const args = String(data.args ?? '').trim();
2704
+ const wantsActive = args !== 'off';
2705
+ const current = this.findLivePlanRow();
2706
+ this.upsertPlanRow({
2707
+ pending: current !== undefined && current.active !== wantsActive,
2708
+ active: current?.active ?? false,
2709
+ });
2710
+ this.pushRow({
2711
+ kind: 'system',
2712
+ text: wantsActive ? '已请求进入计划模式。' : '已请求退出计划模式。',
2713
+ });
2714
+ this.markDirty();
2715
+ return;
2716
+ }
2717
+ if (type === 'goal/change') {
2718
+ this.handleGoalChange(data);
2719
+ return;
2720
+ }
2721
+ if (type.startsWith('team/')) {
2722
+ this.pushRow({ kind: 'system', text: `[团队] ${type}` });
2723
+ this.markDirty();
2724
+ }
2725
+ }
2726
+ handleGoalChange(data) {
2727
+ const payload = data !== null && typeof data === 'object' ? data : {};
2728
+ const existing = this.rows.findLast((row) => row.kind === 'goal');
2729
+ if (payload.operation === 'clear') {
2730
+ if (existing !== undefined) {
2731
+ existing.phase = 'cleared';
2732
+ existing.blockedReason = undefined;
2733
+ }
2734
+ else {
2735
+ this.pushRow({ kind: 'goal', objective: '(已清除)', phase: 'cleared', expanded: false });
2736
+ }
2737
+ this.pushRow({ kind: 'system', text: '当前目标已清除。' });
2738
+ this.markDirty();
2739
+ return;
2740
+ }
2741
+ const goal = payload.goal !== null && typeof payload.goal === 'object' ? payload.goal : {};
2742
+ const objective = typeof goal.objective === 'string' && goal.objective.trim() !== '' ? goal.objective.trim() : '(未命名目标)';
2743
+ const phase = goal.phase === 'paused' || goal.phase === 'blocked' || goal.phase === 'complete' ? goal.phase : 'active';
2744
+ const blocked = goal.blockedReason !== null && typeof goal.blockedReason === 'object'
2745
+ ? goal.blockedReason.message
2746
+ : undefined;
2747
+ const blockedReason = typeof blocked === 'string' ? blocked : undefined;
2748
+ if (existing !== undefined) {
2749
+ existing.objective = objective;
2750
+ existing.phase = phase;
2751
+ existing.blockedReason = blockedReason;
2752
+ }
2753
+ else {
2754
+ this.pushRow({
2755
+ kind: 'goal',
2756
+ objective,
2757
+ phase,
2758
+ ...(blockedReason === undefined ? {} : { blockedReason }),
2759
+ expanded: false,
2760
+ });
2761
+ }
2762
+ const notice = phase === 'active' ? '已设置目标'
2763
+ : phase === 'paused' ? '目标已暂停'
2764
+ : phase === 'blocked' ? '目标受阻'
2765
+ : '目标已完成';
2766
+ this.pushRow({ kind: 'system', text: `${notice}:${objective}` });
2767
+ this.markDirty();
2768
+ }
2769
+ handleSubagentExtensionEvent(row, event) {
2770
+ const type = String(event.type);
2771
+ const data = event.data;
2772
+ if (type === 'plan/mode') {
2773
+ appendSubagentLog(row, {
2774
+ kind: 'system',
2775
+ text: data?.active === true ? '进入计划模式' : '退出计划模式',
2776
+ });
2777
+ return;
2778
+ }
2779
+ if (type.startsWith('team/')) {
2780
+ appendSubagentLog(row, { kind: 'team', text: `[团队] ${type}` });
2781
+ }
2782
+ }
2783
+ /** Fold a live subagent's own session events into that child's card. */
2349
2784
  handleSubagentSessionEvent = (sessionId, event) => {
2350
- const label = `[子代理 ${String(sessionId).slice(0, 8)}]`;
2785
+ const row = this.findSubagentRow(String(sessionId));
2786
+ if (row === undefined)
2787
+ return;
2351
2788
  switch (event.type) {
2352
2789
  case 'user/message': {
2353
2790
  const text = collectText(event.data.content);
2354
2791
  if (text !== '')
2355
- this.pushRow({ kind: 'system', text: `${label} ${truncate(text, 6)}` });
2792
+ appendSubagentLog(row, { kind: 'user', text: `❯ ${truncate(text, 4)}` });
2356
2793
  break;
2357
2794
  }
2358
- case 'assistant/chunk': {
2359
- // Child chunks are coalesced into assistant/message to avoid flooding.
2795
+ case 'assistant/chunk':
2360
2796
  break;
2361
- }
2362
2797
  case 'assistant/message': {
2363
2798
  const text = collectText(event.data.message.content);
2364
2799
  if (text !== '')
2365
- this.pushRow({ kind: 'assistant', text: `${label} ${truncate(text, 12)}` });
2800
+ appendSubagentLog(row, { kind: 'assistant', text: truncate(text, 8) });
2366
2801
  break;
2367
2802
  }
2368
- case 'tool/call':
2369
- this.pushRow({ kind: 'system', text: `${label} ▶ ${event.data.name} ${sliceCodePoints(event.data.arguments, 160)}` });
2803
+ case 'tool/call': {
2804
+ const present = presentToolCall(event.data.name, event.data.arguments);
2805
+ appendSubagentLog(row, { kind: 'tool', text: `▶ ${present.title} ${present.summary}` });
2370
2806
  break;
2807
+ }
2371
2808
  case 'tool/result': {
2372
- const output = truncate(collectText(event.data.message.content), 4);
2373
- const ok = event.data.error === undefined && !event.data.message.content[0]?.isError;
2374
- this.pushRow({ kind: 'system', text: `${label} ${ok ? '✓' : '✗'} ${event.data.message.source.callId}${output === '' ? '' : `\n ${output}`}` });
2809
+ const output = truncate(collectText(event.data.message.content), 3);
2810
+ const ok = event.data.error === undefined && event.data.message.content[0]?.isError !== true;
2811
+ appendSubagentLog(row, {
2812
+ kind: 'result',
2813
+ text: `${ok ? '✓' : '✗'} ${event.data.message.source.callId}${output === '' ? '' : ` · ${output}`}`,
2814
+ });
2375
2815
  break;
2376
2816
  }
2377
2817
  case 'turn/end':
2378
- this.pushRow({ kind: 'system', text: `${label} 轮次结束(${event.data.reason.kind})` });
2818
+ appendSubagentLog(row, { kind: 'turn', text: `轮次结束(${event.data.reason.kind})` });
2379
2819
  break;
2380
2820
  case 'approval/asked':
2381
- this.pushRow({ kind: 'system', text: `${label} 等待审批:${event.data.toolName}` });
2821
+ appendSubagentLog(row, { kind: 'approval', text: `等待审批:${event.data.toolName}` });
2382
2822
  break;
2383
2823
  default:
2824
+ this.handleSubagentExtensionEvent(row, event);
2384
2825
  break;
2385
2826
  }
2386
2827
  this.lastActivity = Date.now();
2387
2828
  this.markDirty();
2388
2829
  };
2389
2830
  handleSubagentStart = (info) => {
2831
+ const sessionId = String(info.id);
2390
2832
  this.activeSubagents.set(String(info.runId), {
2391
- id: String(info.id),
2833
+ id: sessionId,
2392
2834
  provider: info.provider,
2393
2835
  startedAt: Date.now(),
2394
2836
  });
2395
- this.subagentSessions.add(String(info.id));
2837
+ this.subagentSessions.add(sessionId);
2396
2838
  this.lastActivity = Date.now();
2397
- this.pushRow({ kind: 'system', text: `▶ 子代理 ${info.id} 已启动(${info.provider}${info.local ? '' : ',外部进程'})` });
2839
+ const existing = this.findSubagentRow(sessionId);
2840
+ if (existing !== undefined) {
2841
+ existing.runId = String(info.runId);
2842
+ existing.provider = info.provider;
2843
+ existing.local = info.local;
2844
+ existing.status = 'running';
2845
+ existing.startedAt = Date.now();
2846
+ existing.endedAt = undefined;
2847
+ existing.stopReason = undefined;
2848
+ existing.lastActivity = '已启动';
2849
+ existing.expanded = false;
2850
+ appendSubagentLog(existing, { kind: 'system', text: `已启动(${info.provider}${info.local ? '' : ',外部进程'})` });
2851
+ }
2852
+ else {
2853
+ this.pushRow({
2854
+ kind: 'subagent',
2855
+ sessionId,
2856
+ runId: String(info.runId),
2857
+ provider: info.provider,
2858
+ local: info.local,
2859
+ label: `子代理 ${info.provider}`,
2860
+ status: 'running',
2861
+ startedAt: Date.now(),
2862
+ lastActivity: '已启动',
2863
+ logs: [{ kind: 'system', text: `已启动(${info.provider}${info.local ? '' : ',外部进程'})` }],
2864
+ expanded: false,
2865
+ });
2866
+ }
2398
2867
  this.markDirty();
2399
2868
  };
2400
2869
  handleSubagentEnd = (info) => {
@@ -2404,10 +2873,34 @@ export class SshTui {
2404
2873
  const output = info.lastAssistantMessage === undefined
2405
2874
  ? ''
2406
2875
  : truncate(collectText(info.lastAssistantMessage), 6);
2407
- this.pushRow({
2408
- kind: 'system',
2409
- text: `✓ 子代理 ${info.id} 结束(${info.stopReason})${output === '' ? '' : `\n ${output}`}`,
2410
- });
2876
+ const row = this.findSubagentRow(String(info.id)) ?? this.rows.findLast((candidate) => candidate.kind === 'subagent' && candidate.runId === String(info.runId));
2877
+ const failed = info.stopReason !== 'completed';
2878
+ if (row !== undefined) {
2879
+ row.status = info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok';
2880
+ row.endedAt = Date.now();
2881
+ row.stopReason = info.stopReason;
2882
+ appendSubagentLog(row, {
2883
+ kind: failed ? 'result' : 'assistant',
2884
+ text: `结束(${info.stopReason})${output === '' ? '' : ` · ${output}`}`,
2885
+ });
2886
+ }
2887
+ else {
2888
+ this.pushRow({
2889
+ kind: 'subagent',
2890
+ sessionId: String(info.id),
2891
+ runId: String(info.runId),
2892
+ provider: info.provider,
2893
+ local: info.local,
2894
+ label: `子代理 ${info.provider}`,
2895
+ status: info.stopReason === 'aborted' ? 'aborted' : failed ? 'error' : 'ok',
2896
+ startedAt: Date.now(),
2897
+ endedAt: Date.now(),
2898
+ stopReason: info.stopReason,
2899
+ lastActivity: `结束(${info.stopReason})`,
2900
+ logs: [{ kind: 'system', text: `结束(${info.stopReason})${output === '' ? '' : ` · ${output}`}` }],
2901
+ expanded: false,
2902
+ });
2903
+ }
2411
2904
  this.markDirty();
2412
2905
  };
2413
2906
  // ── approval and questions ──────────────────────────────────────────────
@@ -2438,38 +2931,77 @@ export class SshTui {
2438
2931
  const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
2439
2932
  ? undefined
2440
2933
  : `子代理 ${request.agent.id}`;
2441
- for (const [index, question] of request.questions.entries()) {
2442
- const answer = await new Promise((resolve, reject) => {
2443
- const fail = (error) => {
2444
- request.signal?.removeEventListener('abort', onAbort);
2445
- reject(error);
2446
- };
2447
- const onAbort = () => {
2448
- request.signal?.removeEventListener('abort', onAbort);
2449
- if (dialog !== undefined) {
2450
- dialog.reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
2451
- }
2452
- else {
2453
- reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
2934
+ const cards = [];
2935
+ for (const question of request.questions) {
2936
+ const card = {
2937
+ kind: 'question',
2938
+ questionId: question.id,
2939
+ title: question.question,
2940
+ ...(question.header === undefined ? {} : { header: question.header }),
2941
+ ...(question.detail === undefined ? {} : { detail: question.detail }),
2942
+ intent: planReviewOf(question) ? 'plan-review' : 'ask',
2943
+ status: 'waiting',
2944
+ summary: question.question,
2945
+ expanded: false,
2946
+ };
2947
+ cards.push(card);
2948
+ this.pushRow(card);
2949
+ }
2950
+ this.markDirty();
2951
+ const settleCards = (status, summary) => {
2952
+ for (const card of cards) {
2953
+ if (card.status === 'waiting') {
2954
+ card.status = status;
2955
+ card.summary = summary;
2956
+ }
2957
+ }
2958
+ };
2959
+ try {
2960
+ for (const [index, question] of request.questions.entries()) {
2961
+ const answer = await new Promise((resolve, reject) => {
2962
+ const fail = (error) => {
2963
+ request.signal?.removeEventListener('abort', onAbort);
2964
+ reject(error);
2965
+ };
2966
+ const onAbort = () => {
2967
+ request.signal?.removeEventListener('abort', onAbort);
2968
+ if (dialog !== undefined) {
2969
+ dialog.reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
2970
+ }
2971
+ else {
2972
+ reject(new UserQuestionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'));
2973
+ }
2974
+ };
2975
+ let dialog;
2976
+ request.signal?.addEventListener('abort', onAbort, { once: true });
2977
+ if (request.signal?.aborted === true) {
2978
+ onAbort();
2979
+ return;
2454
2980
  }
2455
- };
2456
- let dialog;
2457
- request.signal?.addEventListener('abort', onAbort, { once: true });
2458
- if (request.signal?.aborted === true) {
2459
- onAbort();
2460
- return;
2981
+ const labeled = agentLabel === undefined
2982
+ ? question
2983
+ : { ...question, question: `[${agentLabel}] ${question.question}` };
2984
+ dialog = this.openQuestion(labeled, index, request.questions.length, (selection) => {
2985
+ request.signal?.removeEventListener('abort', onAbort);
2986
+ resolve(selection);
2987
+ }, fail);
2988
+ });
2989
+ answers.push({ id: question.id, selected: answer.selected, custom: answer.custom });
2990
+ const card = cards[index];
2991
+ if (card !== undefined) {
2992
+ card.status = 'answered';
2993
+ card.summary = answer.custom !== undefined && answer.custom !== ''
2994
+ ? answer.custom
2995
+ : answer.selected.join(', ') || '已回答';
2461
2996
  }
2462
- const labeled = agentLabel === undefined
2463
- ? question
2464
- : { ...question, question: `[${agentLabel}] ${question.question}` };
2465
- dialog = this.openQuestion(labeled, index, request.questions.length, (selection) => {
2466
- request.signal?.removeEventListener('abort', onAbort);
2467
- resolve(selection);
2468
- }, fail);
2469
- });
2470
- answers.push({ id: question.id, selected: answer.selected, custom: answer.custom });
2997
+ }
2998
+ settleCards('answered', '已回答');
2999
+ return { answers };
3000
+ }
3001
+ catch (error) {
3002
+ settleCards('cancelled', error instanceof UserQuestionError ? error.message : '已取消');
3003
+ throw error;
2471
3004
  }
2472
- return { answers };
2473
3005
  };
2474
3006
  /** Queue one dialog behind an already-open one instead of overwriting it. */
2475
3007
  openDialog(dialog) {
@@ -2623,9 +3155,13 @@ export class SshTui {
2623
3155
  }
2624
3156
  if (ids.has(modelId))
2625
3157
  return true;
3158
+ const modelEntry = { id: modelId };
3159
+ const reasoningEfforts = reasoningEffortsForDefault(profile.reasoning);
3160
+ if (reasoningEfforts !== undefined)
3161
+ modelEntry.reasoningEfforts = reasoningEfforts;
2626
3162
  try {
2627
3163
  await settings.mutate(settingsNamespace('llm-pi-ai'), [
2628
- { op: 'set', path: ['providers', provider, 'models'], value: [...models, { id: modelId }] },
3164
+ { op: 'set', path: ['providers', provider, 'models'], value: [...models, modelEntry] },
2629
3165
  ]);
2630
3166
  this.pushRow({ kind: 'system', text: `模型 ${modelId} 已加入提供商 ${provider} 的配置。` });
2631
3167
  this.markDirty();
@@ -2758,17 +3294,21 @@ export class SshTui {
2758
3294
  effortOptions = [];
2759
3295
  }
2760
3296
  if (effortOptions.length === 0) {
2761
- effortOptions = ['off', 'high', 'max'].map(id => ({ id, label: id }));
3297
+ // No selectable reasoning effort for this model: do not invent
3298
+ // `off/high/max`, which the adapter may reject for the exact model.
3299
+ }
3300
+ let effort;
3301
+ if (effortOptions.length > 0) {
3302
+ const effortAnswer = await this.askQuestion({
3303
+ id: 'effort-pick',
3304
+ question: `选择思考强度(${selected.id})`,
3305
+ options: effortOptions.map(option => ({
3306
+ label: option.label,
3307
+ description: option.id === String(current?.reasoningEffort) ? '当前' : undefined,
3308
+ })),
3309
+ });
3310
+ effort = effortOptions.find(option => option.label === effortAnswer.selected[0])?.id;
2762
3311
  }
2763
- const effortAnswer = await this.askQuestion({
2764
- id: 'effort-pick',
2765
- question: `选择思考强度(${selected.id})`,
2766
- options: effortOptions.map(option => ({
2767
- label: option.label,
2768
- description: option.id === String(current?.reasoningEffort) ? '当前' : undefined,
2769
- })),
2770
- });
2771
- const effort = effortOptions.find(option => option.label === effortAnswer.selected[0])?.id;
2772
3312
  const next = {
2773
3313
  provider,
2774
3314
  model: selected.id,
@@ -2778,7 +3318,10 @@ export class SshTui {
2778
3318
  this.selectionRef.current = next;
2779
3319
  this.onSelectionChanged?.(next);
2780
3320
  await this.ctx.get('agentDefaultModel')?.saveSelection(next);
2781
- this.pushRow({ kind: 'system', text: `模型已切换:${selected.id}(思考强度 ${effort ?? '默认'});下一步请求生效。` });
3321
+ this.pushRow({
3322
+ kind: 'system',
3323
+ text: `模型已切换:${selected.id}(思考强度 ${effort ?? '默认'}${effortOptions.length === 0 ? ',该模型未声明可选强度' : ''});下一步请求生效。`,
3324
+ });
2782
3325
  this.markDirty();
2783
3326
  }
2784
3327
  /** Provider route the next subagent request should use. */
@@ -2885,8 +3428,13 @@ export class SshTui {
2885
3428
  catch {
2886
3429
  effortOptions = [];
2887
3430
  }
2888
- if (effortOptions.length === 0) {
2889
- effortOptions = ['off', 'high', 'max'].map(id => ({ id, label: id }));
3431
+ if (effortOptions.length === 0 && current.reasoningEffort === undefined) {
3432
+ this.pushRow({
3433
+ kind: 'system',
3434
+ text: `模型 ${provider}/${current.model} 未声明可选 reasoning effort,已保持提供商默认;请勿手动设置 high/max。`,
3435
+ });
3436
+ this.markDirty();
3437
+ return;
2890
3438
  }
2891
3439
  const choices = [
2892
3440
  { id: undefined, label: SUBAGENT_DEFAULT_EFFORT_LABEL },
@@ -3657,7 +4205,7 @@ export class SshTui {
3657
4205
  state.models = ids;
3658
4206
  this.input = '';
3659
4207
  this.cursor = 0;
3660
- this.pushRow({ kind: 'system', text: `已从端点获取 ${ids.length} 个模型(Enter 确认,也可继续修改)。` });
4208
+ this.pushRow({ kind: 'system', text: `已从端点获取 ${ids.length} 个模型:${formatModelList(ids, 6)}(Enter 确认,也可继续修改)。` });
3661
4209
  }
3662
4210
  }
3663
4211
  catch (error) {
@@ -3709,12 +4257,18 @@ export class SshTui {
3709
4257
  const defaultEffort = model !== undefined && llm !== undefined
3710
4258
  ? await defaultReasoningEffort(llm, state.providerId, model)
3711
4259
  : undefined;
4260
+ const reasoningEfforts = defaultEffort === undefined
4261
+ ? undefined
4262
+ : { off: null, [defaultEffort]: defaultEffort };
3712
4263
  const profile = {
3713
4264
  displayName: template.label,
3714
4265
  apiKeyEnv: envRef,
3715
4266
  api: template.api,
3716
4267
  baseURL: state.baseUrl === '' ? template.defaultBaseUrl : state.baseUrl,
3717
- models: state.models.map(id => ({ id })),
4268
+ models: state.models.map(id => ({
4269
+ id,
4270
+ ...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
4271
+ })),
3718
4272
  ...(defaultEffort === undefined ? {} : { reasoning: defaultEffort }),
3719
4273
  };
3720
4274
  if (settings === undefined) {
@@ -3786,7 +4340,6 @@ export class SshTui {
3786
4340
  const home = dshHomeDir();
3787
4341
  const file = join(home, IS_WINDOWS ? 'env.cmd' : 'env.sh');
3788
4342
  await mkdir(home, { recursive: true, mode: 0o700 });
3789
- const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
3790
4343
  if (IS_WINDOWS) {
3791
4344
  let previous = '';
3792
4345
  try {
@@ -3844,6 +4397,8 @@ export class SshTui {
3844
4397
  }
3845
4398
  /** Idempotently source $DSH_HOME/env.sh from the user's POSIX shell rc files. */
3846
4399
  async ensurePosixEnvHook() {
4400
+ if (process.env.DSH_TUI_NO_RC_HOOK === '1' || process.env.DSH_TUI_NO_RC_HOOK === 'true')
4401
+ return;
3847
4402
  const envFile = join(dshHomeDir(), 'env.sh');
3848
4403
  const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
3849
4404
  const sourceLine = `[ -f ${quote(envFile)} ] && . ${quote(envFile)}`;
@@ -3988,14 +4543,16 @@ export class SshTui {
3988
4543
  .filter(item => item.name !== 'help' && item.name !== 'exit')
3989
4544
  .map(item => `/${item.name.padEnd(12)} ${item.description}`);
3990
4545
  const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
3991
- .map(item => `/${item.name.padEnd(12)} ${item.description} (dsh)`);
4546
+ .map(item => `/${item.name.padEnd(12)} ${item.description}${item.input?.images === true ? '(可附图)' : ''} (dsh)`);
3992
4547
  this.pushRow({
3993
4548
  kind: 'system',
3994
4549
  text: [
3995
4550
  ...local,
3996
4551
  ...dsh,
3997
4552
  '',
3998
- 'Enter while running steers the agent; Esc or Ctrl+C cancels the turn.',
4553
+ '运行中按 Enter 可插入指示;Esc / Ctrl+C 取消当前轮次。',
4554
+ '↑/↓ 或 Ctrl+N/P 选择思考、工具、子代理、计划或提问卡片;Enter 展开/折叠;Ctrl+R 全部展开或收起。',
4555
+ '计划模式、提问用户和当前目标会显示独立卡片;多个子代理默认各自折叠,互不混排。',
3999
4556
  ].join('\n'),
4000
4557
  });
4001
4558
  break;
@@ -4054,12 +4611,24 @@ export class SshTui {
4054
4611
  this.streamingReasoning = undefined;
4055
4612
  this.thinkingStartedAt = undefined;
4056
4613
  this.focusedRow = null;
4614
+ this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
4057
4615
  break;
4058
4616
  case 'status':
4059
- this.pushRow({
4060
- kind: 'system',
4061
- text: `session: ${this.agent.id}\nmodel: ${this.agent.options.model ?? 'default'}\nprovider: ${this.agent.options.provider ?? 'default'}\nstatus: ${this.agent.status}`,
4062
- });
4617
+ {
4618
+ const plan = this.findLivePlanRow();
4619
+ const waiting = this.rows.filter(row => row.kind === 'question' && row.status === 'waiting').length;
4620
+ const lines = [
4621
+ `session: ${this.agent.id}`,
4622
+ `model: ${this.agent.options.model ?? 'default'}`,
4623
+ `provider: ${this.agent.options.provider ?? 'default'}`,
4624
+ `status: ${this.agent.status}`,
4625
+ `preset: ${this.presetName}`,
4626
+ `subagents: ${this.activeSubagents.size}`,
4627
+ `plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
4628
+ waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
4629
+ ];
4630
+ this.pushRow({ kind: 'system', text: lines.join('\n') });
4631
+ }
4063
4632
  break;
4064
4633
  case 'usage':
4065
4634
  case 'quota':
@@ -4069,12 +4638,43 @@ export class SshTui {
4069
4638
  });
4070
4639
  break;
4071
4640
  case 'subagents': {
4641
+ const trimmed = arg.trim();
4642
+ if (trimmed !== '' && trimmed !== 'list') {
4643
+ const [action, ...ids] = trimmed.split(/\s+/u);
4644
+ if (action === 'kill' || action === 'stop') {
4645
+ if (ids.length === 0) {
4646
+ this.pushRow({ kind: 'error', text: '/subagents kill <session-id> — 缺少子代理会话 ID' });
4647
+ break;
4648
+ }
4649
+ const subagents = this.ctx.get('subagents');
4650
+ if (subagents === undefined) {
4651
+ this.pushRow({ kind: 'error', text: 'subagents service is unavailable' });
4652
+ break;
4653
+ }
4654
+ const targets = ids.map(id => SessionId(id));
4655
+ void subagents.drainContinuableChildren(this.agent, targets).then(() => {
4656
+ this.pushRow({ kind: 'system', text: `已请求释放子代理:${ids.join(', ')}` });
4657
+ this.markDirty();
4658
+ }).catch((error) => {
4659
+ this.pushRow({ kind: 'error', text: `/subagents kill failed: ${errorChain(error)}` });
4660
+ this.markDirty();
4661
+ });
4662
+ break;
4663
+ }
4664
+ this.pushRow({ kind: 'error', text: `/subagents 未知操作 "${action}"(支持 list / kill <id>)` });
4665
+ break;
4666
+ }
4072
4667
  if (this.activeSubagents.size === 0) {
4073
4668
  this.pushRow({ kind: 'system', text: '当前没有活动的子代理。' });
4074
4669
  }
4075
4670
  else {
4076
- const lines = [...this.activeSubagents.entries()].map(([runId, sub]) => `▶ ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]`);
4077
- this.pushRow({ kind: 'system', text: lines.join('\n') });
4671
+ const lines = [...this.activeSubagents.entries()].map(([runId, sub]) => {
4672
+ const card = this.findSubagentRow(sub.id);
4673
+ const label = card?.label ?? sub.id;
4674
+ const activity = card?.lastActivity ? ` · ${card.lastActivity}` : '';
4675
+ return `▶ ${label} ${sub.id}(${sub.provider})运行 ${Math.floor((Date.now() - sub.startedAt) / 1000)}s [${runId.slice(0, 8)}]${activity}`;
4676
+ });
4677
+ this.pushRow({ kind: 'system', text: `${lines.join('\n')}\n↑/↓ 选择对应卡片,Enter 展开/折叠,Ctrl+R 全部展开或收起。` });
4078
4678
  }
4079
4679
  break;
4080
4680
  }
@@ -4124,7 +4724,7 @@ export class SshTui {
4124
4724
  this.commandAbort?.abort();
4125
4725
  const controller = new AbortController();
4126
4726
  this.commandAbort = controller;
4127
- void commands.execute(this.agent, text, controller.signal).then((execution) => {
4727
+ void commands.execute(this.agent, text, [], controller.signal).then((execution) => {
4128
4728
  if (execution === undefined) {
4129
4729
  this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
4130
4730
  return;
@@ -4247,6 +4847,10 @@ export class SshTui {
4247
4847
  function optionsLength(dialog) {
4248
4848
  return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
4249
4849
  }
4850
+ /** Escape a string for safe interpolation into a RegExp source. */
4851
+ function escapeRegex(value) {
4852
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
4853
+ }
4250
4854
  function envRefForId(providerId) {
4251
4855
  return `${providerId.replaceAll('-', '_').toUpperCase()}_API_KEY`;
4252
4856
  }