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/README.en.md +31 -8
- package/README.md +30 -7
- package/lib/index.js +3 -1
- package/lib/index.js.map +1 -1
- package/lib/picker.js +51 -7
- package/lib/picker.js.map +1 -1
- package/lib/tui.js +672 -88
- package/lib/tui.js.map +1 -1
- package/lib/types/picker.d.ts +6 -3
- package/lib/types/tui.d.ts +83 -7
- package/package.json +30 -28
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
|
|
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: '
|
|
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
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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'
|
|
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
|
-
|
|
1737
|
-
if (
|
|
1738
|
-
addDialog(
|
|
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('
|
|
2031
|
+
addDialog(' (自由输入:在下方输入后按 Enter)');
|
|
1749
2032
|
}
|
|
1750
|
-
addDialog(` ${d.question.multiSelect === true ? '
|
|
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
|
-
|
|
1860
|
-
|
|
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.
|
|
2016
|
-
detail =
|
|
2017
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2800
|
+
appendSubagentLog(row, { kind: 'assistant', text: truncate(text, 8) });
|
|
2374
2801
|
break;
|
|
2375
2802
|
}
|
|
2376
|
-
case 'tool/call':
|
|
2377
|
-
|
|
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),
|
|
2381
|
-
const ok = event.data.error === undefined &&
|
|
2382
|
-
|
|
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
|
-
|
|
2818
|
+
appendSubagentLog(row, { kind: 'turn', text: `轮次结束(${event.data.reason.kind})` });
|
|
2387
2819
|
break;
|
|
2388
2820
|
case 'approval/asked':
|
|
2389
|
-
|
|
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:
|
|
2833
|
+
id: sessionId,
|
|
2400
2834
|
provider: info.provider,
|
|
2401
2835
|
startedAt: Date.now(),
|
|
2402
2836
|
});
|
|
2403
|
-
this.subagentSessions.add(
|
|
2837
|
+
this.subagentSessions.add(sessionId);
|
|
2404
2838
|
this.lastActivity = Date.now();
|
|
2405
|
-
|
|
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.
|
|
2416
|
-
|
|
2417
|
-
|
|
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
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
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
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
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
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
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,
|
|
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 => ({
|
|
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
|
|
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
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
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]) =>
|
|
4097
|
-
|
|
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
|
}
|