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