dsh-ssh-tui 0.1.8 → 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;
@@ -844,6 +853,89 @@ function friendlyArgsSummary(name, args) {
844
853
  }
845
854
  const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh']);
846
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
+ }
847
939
  /** Derive the intended file change from a mutation tool's arguments. */
848
940
  function diffHunksFromArgs(name, argsRaw) {
849
941
  const args = parseJsonArgs(argsRaw);
@@ -905,6 +997,22 @@ export function presentToolCall(name, args) {
905
997
  ...diff === null || diff === undefined ? {} : { diff },
906
998
  };
907
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
+ }
908
1016
  return { title: name, summary: friendlyArgsSummary(name, args) };
909
1017
  }
910
1018
  /** Validate a tool/result meta payload's structured diff, mirroring the web card. */
@@ -1240,7 +1348,7 @@ export class SshTui {
1240
1348
  this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
1241
1349
  this.pushRow({ kind: 'brand-logo' });
1242
1350
  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' });
1351
+ this.pushRow({ kind: 'system', text: '输入 /help 查看命令 · /setup 配置提供商 · ↑/↓ 选择卡片 · Enter 展开/折叠 · Ctrl+R 全部展开/收起 · Ctrl+T 折叠输入 · Esc 取消' });
1244
1352
  }
1245
1353
  /** Enter raw mode, switch to the alternate screen, and start listening. */
1246
1354
  start() {
@@ -1264,9 +1372,12 @@ export class SshTui {
1264
1372
  const now = Date.now();
1265
1373
  if (this.agent.status === 'running')
1266
1374
  this.updateTerminalTitle();
1267
- if (this.streaming !== undefined
1268
- && this.streaming.reasoning !== ''
1269
- && 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) {
1270
1381
  this.dirty = true;
1271
1382
  }
1272
1383
  // While a turn is waiting on the provider with no new events, repaint at
@@ -1330,7 +1441,7 @@ export class SshTui {
1330
1441
  const content = await readFile(credentialFile, 'utf8');
1331
1442
  if (this.disposed)
1332
1443
  return;
1333
- stored = new RegExp(`^${envRef}\\s*:\\s*\\S`, 'm').test(content);
1444
+ stored = new RegExp(`^${escapeRegex(envRef)}\\s*:\\s*\\S`, 'm').test(content);
1334
1445
  }
1335
1446
  }
1336
1447
  catch {
@@ -1490,13 +1601,60 @@ export class SshTui {
1490
1601
  }
1491
1602
  /** The transcript rows that support per-row expand/collapse. */
1492
1603
  collapsibleRows() {
1493
- 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');
1494
1610
  if (this.streaming !== undefined && this.streaming.reasoning !== '') {
1495
1611
  this.streamingReasoning ??= { kind: 'streaming-reasoning', expanded: false };
1496
1612
  rows.push(this.streamingReasoning);
1497
1613
  }
1498
1614
  return rows;
1499
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
+ }
1500
1658
  /** Move the expand/collapse focus among reasoning and tool rows. */
1501
1659
  moveCollapsibleFocus(delta) {
1502
1660
  const rows = this.collapsibleRows();
@@ -1604,6 +1762,7 @@ export class SshTui {
1604
1762
  return this.styleLine('tool', line);
1605
1763
  return `${this.styleLine('tool', line.slice(0, dotIndex))}\x1b[${dotColor}m●${this.styleLine('tool', line.slice(dotIndex + 1))}`;
1606
1764
  };
1765
+ const spinner = running ? ` ${this.spinnerFrame()}` : '';
1607
1766
  const state = running ? 'running…' : ok ? 'ok' : 'error';
1608
1767
  const summary = row.summary === '' ? '' : ` ${row.summary}`;
1609
1768
  const exit = !running && row.command !== undefined
@@ -1615,7 +1774,7 @@ export class SshTui {
1615
1774
  : '';
1616
1775
  const focused = this.focusedRow === row;
1617
1776
  const marker = row.expanded ? '▾' : '▸';
1618
- const plainHeader = `${marker} ● ${row.title}${summary} [${state}]${exit}`;
1777
+ const plainHeader = `${marker} ● ${row.title}${summary} [${state}]${exit}${spinner}`;
1619
1778
  if (!row.expanded) {
1620
1779
  const collapsed = truncateToWidth(`${focused ? '▶ ' : ' '}${plainHeader}`, Math.max(1, width - 2));
1621
1780
  const styled = styleToolHeader(collapsed);
@@ -1632,6 +1791,116 @@ export class SshTui {
1632
1791
  }
1633
1792
  continue;
1634
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
+ }
1635
1904
  pushRow(row.kind, row.text);
1636
1905
  }
1637
1906
  if (this.streaming !== undefined) {
@@ -1733,21 +2002,35 @@ export class SshTui {
1733
2002
  }
1734
2003
  else {
1735
2004
  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));
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
+ }
1739
2020
  }
1740
2021
  const options = d.question.options ?? [];
2022
+ const approve = d.question.intent?.approve;
1741
2023
  for (const [index, option] of options.entries()) {
1742
2024
  const marker = d.selected.has(index) ? '●' : '○';
1743
2025
  const key = QUESTION_OPTION_KEYS[index] ?? '?';
2026
+ const recommended = option.label === approve ? '(推荐)' : '';
1744
2027
  const extra = option.description === undefined ? '' : ` — ${option.description}`;
1745
- addDialog(` ${key} ${marker} ${option.label}${extra}`);
2028
+ addDialog(` ${key} ${marker} ${option.label}${recommended}${extra}`);
1746
2029
  }
1747
2030
  if (options.length === 0) {
1748
- addDialog(' (free text: type below and press Enter)');
2031
+ addDialog(' (自由输入:在下方输入后按 Enter');
1749
2032
  }
1750
- 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 取消`);
1751
2034
  }
1752
2035
  }
1753
2036
  const fitLine = (text) => truncateToWidth(text, Math.max(1, width));
@@ -1856,8 +2139,24 @@ export class SshTui {
1856
2139
  if (this.pendingMessages.size > 0)
1857
2140
  statusText += ` · 排队 ${this.pendingMessages.size}`;
1858
2141
  const idleMs = Date.now() - this.lastActivity;
1859
- if (this.agent.status === 'running' && this.activeSubagents.size > 0) {
1860
- 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}`;
1861
2160
  }
1862
2161
  else if (this.agent.status === 'running' && this.openToolCalls.size > 0) {
1863
2162
  statusText += ` · 工具执行中 ${this.openToolCalls.size}`;
@@ -1893,6 +2192,9 @@ export class SshTui {
1893
2192
  this.pendingMessages.size,
1894
2193
  this.commandSuggestions.length,
1895
2194
  this.suggestionIndex,
2195
+ this.activeSubagents.size,
2196
+ this.dialog?.kind ?? '',
2197
+ this.findLivePlanRow()?.active === true ? 'plan' : '',
1896
2198
  ].join('\x1f');
1897
2199
  const chromeChanged = chromeKey !== this.lastChromeKey;
1898
2200
  // Incremental repaint: rewrite only rows whose content changed, so slow
@@ -1922,7 +2224,7 @@ export class SshTui {
1922
2224
  const prefix = input.slice(1).toLowerCase();
1923
2225
  const dsh = (this.ctx.get('commands')?.list(this.agent) ?? []).map(command => ({
1924
2226
  name: command.name,
1925
- description: command.description,
2227
+ description: command.input?.images === true ? `${command.description}(可附图)` : command.description,
1926
2228
  local: false,
1927
2229
  }));
1928
2230
  const all = [
@@ -2012,10 +2314,25 @@ export class SshTui {
2012
2314
  this.lastTitleUpdateAt = now;
2013
2315
  const spinner = SPINNER[Math.floor(now / 800) % SPINNER.length];
2014
2316
  let detail = '运行中';
2015
- if (this.openToolCalls.size > 0)
2016
- detail = `运行中 · 工具 ${this.openToolCalls.size}`;
2017
- 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) {
2018
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
+ }
2019
2336
  this.write(`\x1b]0;dsh ${spinner} ${detail}\x07`);
2020
2337
  return;
2021
2338
  }
@@ -2166,14 +2483,20 @@ export class SshTui {
2166
2483
  this.recordUsage(event.data.turn, event.data.step, event.data.usage);
2167
2484
  }
2168
2485
  const reasoningExpanded = this.streamingReasoning?.expanded ?? false;
2486
+ const interrupted = event.data.interrupted === true;
2169
2487
  this.streaming = undefined;
2170
2488
  this.streamingReasoning = undefined;
2171
2489
  this.thinkingStartedAt = undefined;
2490
+ const interruptedMark = interrupted ? ' ⚠ 已中断' : '';
2172
2491
  if (reasoning !== '') {
2173
- 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: '本轮输出已中断,没有可见内容。' });
2174
2499
  }
2175
- if (text !== '')
2176
- this.pushRow({ kind: 'assistant', text });
2177
2500
  this.markDirty();
2178
2501
  break;
2179
2502
  }
@@ -2193,7 +2516,7 @@ export class SshTui {
2193
2516
  ...present.command === undefined ? {} : { command: present.command },
2194
2517
  ...present.cwd === undefined ? {} : { cwd: present.cwd },
2195
2518
  ...present.diff === undefined ? {} : { diff: present.diff },
2196
- expanded: DIFF_TOOL_NAMES.has(event.data.name),
2519
+ expanded: DIFF_TOOL_NAMES.has(event.data.name) && !SUBAGENT_TOOL_NAMES.has(event.data.name),
2197
2520
  };
2198
2521
  this.pushRow(row);
2199
2522
  this.streaming = undefined;
@@ -2306,7 +2629,13 @@ export class SshTui {
2306
2629
  this.markDirty();
2307
2630
  break;
2308
2631
  }
2632
+ case 'todo/write': {
2633
+ this.upsertPlanRow({ todos: parsePlanTodos(event.data.todos) });
2634
+ this.markDirty();
2635
+ break;
2636
+ }
2309
2637
  default:
2638
+ this.handleExtensionEvent(event);
2310
2639
  break;
2311
2640
  }
2312
2641
  };
@@ -2316,6 +2645,7 @@ export class SshTui {
2316
2645
  this.lastActivity = Date.now();
2317
2646
  if (status === 'running') {
2318
2647
  this.completionSignaled = false;
2648
+ this.completedAt = 0;
2319
2649
  }
2320
2650
  else if (!this.completionSignaled && this.status === 'running') {
2321
2651
  this.completionSignaled = true;
@@ -2353,56 +2683,187 @@ export class SshTui {
2353
2683
  this.status = 'disposed';
2354
2684
  this.markDirty();
2355
2685
  };
2356
- /** 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. */
2357
2784
  handleSubagentSessionEvent = (sessionId, event) => {
2358
- const label = `[子代理 ${String(sessionId).slice(0, 8)}]`;
2785
+ const row = this.findSubagentRow(String(sessionId));
2786
+ if (row === undefined)
2787
+ return;
2359
2788
  switch (event.type) {
2360
2789
  case 'user/message': {
2361
2790
  const text = collectText(event.data.content);
2362
2791
  if (text !== '')
2363
- this.pushRow({ kind: 'system', text: `${label} ${truncate(text, 6)}` });
2792
+ appendSubagentLog(row, { kind: 'user', text: `❯ ${truncate(text, 4)}` });
2364
2793
  break;
2365
2794
  }
2366
- case 'assistant/chunk': {
2367
- // Child chunks are coalesced into assistant/message to avoid flooding.
2795
+ case 'assistant/chunk':
2368
2796
  break;
2369
- }
2370
2797
  case 'assistant/message': {
2371
2798
  const text = collectText(event.data.message.content);
2372
2799
  if (text !== '')
2373
- this.pushRow({ kind: 'assistant', text: `${label} ${truncate(text, 12)}` });
2800
+ appendSubagentLog(row, { kind: 'assistant', text: truncate(text, 8) });
2374
2801
  break;
2375
2802
  }
2376
- case 'tool/call':
2377
- 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}` });
2378
2806
  break;
2807
+ }
2379
2808
  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}`}` });
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
+ });
2383
2815
  break;
2384
2816
  }
2385
2817
  case 'turn/end':
2386
- this.pushRow({ kind: 'system', text: `${label} 轮次结束(${event.data.reason.kind})` });
2818
+ appendSubagentLog(row, { kind: 'turn', text: `轮次结束(${event.data.reason.kind})` });
2387
2819
  break;
2388
2820
  case 'approval/asked':
2389
- this.pushRow({ kind: 'system', text: `${label} 等待审批:${event.data.toolName}` });
2821
+ appendSubagentLog(row, { kind: 'approval', text: `等待审批:${event.data.toolName}` });
2390
2822
  break;
2391
2823
  default:
2824
+ this.handleSubagentExtensionEvent(row, event);
2392
2825
  break;
2393
2826
  }
2394
2827
  this.lastActivity = Date.now();
2395
2828
  this.markDirty();
2396
2829
  };
2397
2830
  handleSubagentStart = (info) => {
2831
+ const sessionId = String(info.id);
2398
2832
  this.activeSubagents.set(String(info.runId), {
2399
- id: String(info.id),
2833
+ id: sessionId,
2400
2834
  provider: info.provider,
2401
2835
  startedAt: Date.now(),
2402
2836
  });
2403
- this.subagentSessions.add(String(info.id));
2837
+ this.subagentSessions.add(sessionId);
2404
2838
  this.lastActivity = Date.now();
2405
- 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
+ }
2406
2867
  this.markDirty();
2407
2868
  };
2408
2869
  handleSubagentEnd = (info) => {
@@ -2412,10 +2873,34 @@ export class SshTui {
2412
2873
  const output = info.lastAssistantMessage === undefined
2413
2874
  ? ''
2414
2875
  : truncate(collectText(info.lastAssistantMessage), 6);
2415
- this.pushRow({
2416
- kind: 'system',
2417
- text: `✓ 子代理 ${info.id} 结束(${info.stopReason})${output === '' ? '' : `\n ${output}`}`,
2418
- });
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
+ }
2419
2904
  this.markDirty();
2420
2905
  };
2421
2906
  // ── approval and questions ──────────────────────────────────────────────
@@ -2446,38 +2931,77 @@ export class SshTui {
2446
2931
  const agentLabel = request.agent === undefined || request.agent.id === this.agent.id
2447
2932
  ? undefined
2448
2933
  : `子代理 ${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'));
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;
2462
2980
  }
2463
- };
2464
- let dialog;
2465
- request.signal?.addEventListener('abort', onAbort, { once: true });
2466
- if (request.signal?.aborted === true) {
2467
- onAbort();
2468
- 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(', ') || '已回答';
2469
2996
  }
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 });
2997
+ }
2998
+ settleCards('answered', '已回答');
2999
+ return { answers };
3000
+ }
3001
+ catch (error) {
3002
+ settleCards('cancelled', error instanceof UserQuestionError ? error.message : '已取消');
3003
+ throw error;
2479
3004
  }
2480
- return { answers };
2481
3005
  };
2482
3006
  /** Queue one dialog behind an already-open one instead of overwriting it. */
2483
3007
  openDialog(dialog) {
@@ -2631,9 +3155,13 @@ export class SshTui {
2631
3155
  }
2632
3156
  if (ids.has(modelId))
2633
3157
  return true;
3158
+ const modelEntry = { id: modelId };
3159
+ const reasoningEfforts = reasoningEffortsForDefault(profile.reasoning);
3160
+ if (reasoningEfforts !== undefined)
3161
+ modelEntry.reasoningEfforts = reasoningEfforts;
2634
3162
  try {
2635
3163
  await settings.mutate(settingsNamespace('llm-pi-ai'), [
2636
- { op: 'set', path: ['providers', provider, 'models'], value: [...models, { id: modelId }] },
3164
+ { op: 'set', path: ['providers', provider, 'models'], value: [...models, modelEntry] },
2637
3165
  ]);
2638
3166
  this.pushRow({ kind: 'system', text: `模型 ${modelId} 已加入提供商 ${provider} 的配置。` });
2639
3167
  this.markDirty();
@@ -3729,12 +4257,18 @@ export class SshTui {
3729
4257
  const defaultEffort = model !== undefined && llm !== undefined
3730
4258
  ? await defaultReasoningEffort(llm, state.providerId, model)
3731
4259
  : undefined;
4260
+ const reasoningEfforts = defaultEffort === undefined
4261
+ ? undefined
4262
+ : { off: null, [defaultEffort]: defaultEffort };
3732
4263
  const profile = {
3733
4264
  displayName: template.label,
3734
4265
  apiKeyEnv: envRef,
3735
4266
  api: template.api,
3736
4267
  baseURL: state.baseUrl === '' ? template.defaultBaseUrl : state.baseUrl,
3737
- models: state.models.map(id => ({ id })),
4268
+ models: state.models.map(id => ({
4269
+ id,
4270
+ ...(reasoningEfforts === undefined ? {} : { reasoningEfforts }),
4271
+ })),
3738
4272
  ...(defaultEffort === undefined ? {} : { reasoning: defaultEffort }),
3739
4273
  };
3740
4274
  if (settings === undefined) {
@@ -3806,7 +4340,6 @@ export class SshTui {
3806
4340
  const home = dshHomeDir();
3807
4341
  const file = join(home, IS_WINDOWS ? 'env.cmd' : 'env.sh');
3808
4342
  await mkdir(home, { recursive: true, mode: 0o700 });
3809
- const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
3810
4343
  if (IS_WINDOWS) {
3811
4344
  let previous = '';
3812
4345
  try {
@@ -3864,6 +4397,8 @@ export class SshTui {
3864
4397
  }
3865
4398
  /** Idempotently source $DSH_HOME/env.sh from the user's POSIX shell rc files. */
3866
4399
  async ensurePosixEnvHook() {
4400
+ if (process.env.DSH_TUI_NO_RC_HOOK === '1' || process.env.DSH_TUI_NO_RC_HOOK === 'true')
4401
+ return;
3867
4402
  const envFile = join(dshHomeDir(), 'env.sh');
3868
4403
  const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
3869
4404
  const sourceLine = `[ -f ${quote(envFile)} ] && . ${quote(envFile)}`;
@@ -4008,14 +4543,16 @@ export class SshTui {
4008
4543
  .filter(item => item.name !== 'help' && item.name !== 'exit')
4009
4544
  .map(item => `/${item.name.padEnd(12)} ${item.description}`);
4010
4545
  const dsh = (this.ctx.get('commands')?.list(this.agent) ?? [])
4011
- .map(item => `/${item.name.padEnd(12)} ${item.description} (dsh)`);
4546
+ .map(item => `/${item.name.padEnd(12)} ${item.description}${item.input?.images === true ? '(可附图)' : ''} (dsh)`);
4012
4547
  this.pushRow({
4013
4548
  kind: 'system',
4014
4549
  text: [
4015
4550
  ...local,
4016
4551
  ...dsh,
4017
4552
  '',
4018
- '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
+ '计划模式、提问用户和当前目标会显示独立卡片;多个子代理默认各自折叠,互不混排。',
4019
4556
  ].join('\n'),
4020
4557
  });
4021
4558
  break;
@@ -4074,12 +4611,24 @@ export class SshTui {
4074
4611
  this.streamingReasoning = undefined;
4075
4612
  this.thinkingStartedAt = undefined;
4076
4613
  this.focusedRow = null;
4614
+ this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
4077
4615
  break;
4078
4616
  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
- });
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
+ }
4083
4632
  break;
4084
4633
  case 'usage':
4085
4634
  case 'quota':
@@ -4089,12 +4638,43 @@ export class SshTui {
4089
4638
  });
4090
4639
  break;
4091
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
+ }
4092
4667
  if (this.activeSubagents.size === 0) {
4093
4668
  this.pushRow({ kind: 'system', text: '当前没有活动的子代理。' });
4094
4669
  }
4095
4670
  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') });
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 全部展开或收起。` });
4098
4678
  }
4099
4679
  break;
4100
4680
  }
@@ -4144,7 +4724,7 @@ export class SshTui {
4144
4724
  this.commandAbort?.abort();
4145
4725
  const controller = new AbortController();
4146
4726
  this.commandAbort = controller;
4147
- void commands.execute(this.agent, text, controller.signal).then((execution) => {
4727
+ void commands.execute(this.agent, text, [], controller.signal).then((execution) => {
4148
4728
  if (execution === undefined) {
4149
4729
  this.pushRow({ kind: 'error', text: `Unknown command: /${command} (try /help)` });
4150
4730
  return;
@@ -4267,6 +4847,10 @@ export class SshTui {
4267
4847
  function optionsLength(dialog) {
4268
4848
  return dialog.kind === 'questions' ? dialog.question.options?.length ?? 0 : 0;
4269
4849
  }
4850
+ /** Escape a string for safe interpolation into a RegExp source. */
4851
+ function escapeRegex(value) {
4852
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
4853
+ }
4270
4854
  function envRefForId(providerId) {
4271
4855
  return `${providerId.replaceAll('-', '_').toUpperCase()}_API_KEY`;
4272
4856
  }