dsh-ssh-tui 0.3.1 → 0.3.3
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 +45 -19
- package/README.md +37 -16
- package/cordis.patch.yml +1 -1
- package/docs/screenshots/slow-link.gif +0 -0
- package/lib/tui.js +276 -64
- package/lib/tui.js.map +1 -1
- package/lib/types/tui.d.ts +57 -6
- package/package.json +7 -3
- package/docs/screenshots/headless-labeled.png +0 -0
- package/docs/screenshots/workspace-labeled.png +0 -0
package/lib/tui.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* The renderer uses plain ANSI and coalesces each frame into one stdout
|
|
9
9
|
* write of dirty rows only — jump-host / proxied SSH should see one packet
|
|
10
|
-
* per paint, not one per line. Cadence is DSH_TUI_PAINT_MS
|
|
10
|
+
* per paint, not one per line. Cadence is DSH_TUI_PAINT_MS, else local 80 ms
|
|
11
|
+
* or an SSH tier from a CSI 6n round-trip (default 160 ms).
|
|
11
12
|
*/
|
|
12
13
|
import { spawn } from 'node:child_process';
|
|
13
14
|
import { existsSync } from 'node:fs';
|
|
@@ -59,19 +60,46 @@ const PROVIDER_TEMPLATES = {
|
|
|
59
60
|
defaultModels: ['deepseek-v4-flash'],
|
|
60
61
|
},
|
|
61
62
|
};
|
|
62
|
-
const RENDER_INTERVAL_MS =
|
|
63
|
+
const RENDER_INTERVAL_MS = 160;
|
|
64
|
+
const LOCAL_PAINT_INTERVAL_MS = 80;
|
|
63
65
|
const WAIT_INDICATOR_MS = 8000;
|
|
64
66
|
const MIN_PAINT_INTERVAL_MS = 40;
|
|
65
67
|
const MAX_PAINT_INTERVAL_MS = 1000;
|
|
68
|
+
const DSR_PROBE_TIMEOUT_MS = 800;
|
|
66
69
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
70
|
+
* Explicit env/config always wins. Otherwise local TTYs stay snappy and SSH
|
|
71
|
+
* sessions pick a tier from a measured round-trip (CSI 6n), falling back to
|
|
72
|
+
* 160 ms when the probe is missing.
|
|
69
73
|
*/
|
|
70
|
-
export function resolvePaintIntervalMs(configured, env = process.env) {
|
|
74
|
+
export function resolvePaintIntervalMs(configured, env = process.env, options = {}) {
|
|
71
75
|
const raw = configured ?? Number.parseInt(env.DSH_TUI_PAINT_MS ?? '', 10);
|
|
72
|
-
if (
|
|
76
|
+
if (Number.isFinite(raw) && raw > 0) {
|
|
77
|
+
return Math.min(MAX_PAINT_INTERVAL_MS, Math.max(MIN_PAINT_INTERVAL_MS, Math.floor(raw)));
|
|
78
|
+
}
|
|
79
|
+
if (options.ssh === true)
|
|
80
|
+
return paintIntervalForRtt(options.rttMs);
|
|
81
|
+
return LOCAL_PAINT_INTERVAL_MS;
|
|
82
|
+
}
|
|
83
|
+
/** True when this process is attached to an SSH session (jump host / proxy). */
|
|
84
|
+
export function detectSshSession(env = process.env) {
|
|
85
|
+
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
|
|
86
|
+
}
|
|
87
|
+
/** Map a CSI-6n round-trip to a paint cadence. Unknown RTT uses the SSH default. */
|
|
88
|
+
export function paintIntervalForRtt(rttMs) {
|
|
89
|
+
if (rttMs === undefined || !Number.isFinite(rttMs) || rttMs < 0)
|
|
73
90
|
return RENDER_INTERVAL_MS;
|
|
74
|
-
|
|
91
|
+
if (rttMs < 50)
|
|
92
|
+
return LOCAL_PAINT_INTERVAL_MS;
|
|
93
|
+
if (rttMs < 150)
|
|
94
|
+
return 160;
|
|
95
|
+
if (rttMs < 350)
|
|
96
|
+
return 250;
|
|
97
|
+
return 400;
|
|
98
|
+
}
|
|
99
|
+
export function paintLinkLabel(kind, intervalMs, probed) {
|
|
100
|
+
if (kind === 'local')
|
|
101
|
+
return `本机绘制 ${intervalMs}ms`;
|
|
102
|
+
return probed ? `SSH 绘制 ${intervalMs}ms` : `SSH 绘制 ${intervalMs}ms(未测到往返)`;
|
|
75
103
|
}
|
|
76
104
|
/** One incremental paint as a single stdout write (one SSH packet when corked). */
|
|
77
105
|
export function composePaintOutput(options) {
|
|
@@ -994,10 +1022,58 @@ export function isEscapePrefix(text) {
|
|
|
994
1022
|
return true;
|
|
995
1023
|
if (/^\x1b\[\d+~?$/u.test(text))
|
|
996
1024
|
return true;
|
|
1025
|
+
if (/^\x1b\[\d+(?:;\d+)?R?$/u.test(text))
|
|
1026
|
+
return true;
|
|
997
1027
|
if (/^\x1b\[<(?:\d*;?)*[Mm]?$/u.test(text))
|
|
998
1028
|
return true;
|
|
999
1029
|
return false;
|
|
1000
1030
|
}
|
|
1031
|
+
/** Parse a Device Status Report cursor reply (`CSI row;col R`). */
|
|
1032
|
+
export function parseCursorPositionReply(text) {
|
|
1033
|
+
const match = /^\x1b\[(\d+);(\d+)R$/u.exec(text);
|
|
1034
|
+
if (match === null)
|
|
1035
|
+
return undefined;
|
|
1036
|
+
return { row: Number(match[1]), column: Number(match[2]) };
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Round-trip to the attached terminal via CSI 6n. Returns undefined when the
|
|
1040
|
+
* reply never arrives (dumb pipe, blocked DSR). Does not interpret the
|
|
1041
|
+
* coordinates — only the elapsed milliseconds matter.
|
|
1042
|
+
*/
|
|
1043
|
+
export async function probeTerminalRttMs(stdin = process.stdin, stdout = process.stdout, timeoutMs = DSR_PROBE_TIMEOUT_MS) {
|
|
1044
|
+
if (!stdin.isTTY || !stdout.isTTY)
|
|
1045
|
+
return undefined;
|
|
1046
|
+
return await new Promise(resolve => {
|
|
1047
|
+
let buffer = '';
|
|
1048
|
+
let settled = false;
|
|
1049
|
+
const started = Date.now();
|
|
1050
|
+
const finish = (value) => {
|
|
1051
|
+
if (settled)
|
|
1052
|
+
return;
|
|
1053
|
+
settled = true;
|
|
1054
|
+
clearTimeout(timer);
|
|
1055
|
+
stdin.removeListener('data', onData);
|
|
1056
|
+
resolve(value);
|
|
1057
|
+
};
|
|
1058
|
+
const onData = (chunk) => {
|
|
1059
|
+
buffer += chunk.toString('utf8');
|
|
1060
|
+
if (parseCursorPositionReply(buffer) !== undefined) {
|
|
1061
|
+
finish(Math.max(0, Date.now() - started));
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
if (buffer.length > 32 && !buffer.includes('\x1b['))
|
|
1065
|
+
finish(undefined);
|
|
1066
|
+
};
|
|
1067
|
+
const timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
1068
|
+
stdin.on('data', onData);
|
|
1069
|
+
try {
|
|
1070
|
+
stdout.write('\x1b[6n');
|
|
1071
|
+
}
|
|
1072
|
+
catch {
|
|
1073
|
+
finish(undefined);
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1001
1077
|
/** Parse a tool call's raw arguments JSON into an object; null when unparsable. */
|
|
1002
1078
|
function parseJsonArgs(args) {
|
|
1003
1079
|
try {
|
|
@@ -1086,6 +1162,28 @@ export function planIsLive(plan) {
|
|
|
1086
1162
|
return true;
|
|
1087
1163
|
return false;
|
|
1088
1164
|
}
|
|
1165
|
+
/** Open todos left behind when a turn ends without a completing todo_write. */
|
|
1166
|
+
export function planTurnLeftOpen(plan) {
|
|
1167
|
+
return plan.todos.some(item => item.status !== 'completed');
|
|
1168
|
+
}
|
|
1169
|
+
/** Mark leftover in-progress/pending todos as display-stale after turn/end. */
|
|
1170
|
+
export function applyTurnEndToPlan(plan) {
|
|
1171
|
+
if (!planTurnLeftOpen(plan)) {
|
|
1172
|
+
plan.turnLeftOpen = false;
|
|
1173
|
+
return plan;
|
|
1174
|
+
}
|
|
1175
|
+
plan.turnLeftOpen = true;
|
|
1176
|
+
return plan;
|
|
1177
|
+
}
|
|
1178
|
+
/** Follow-up that asks the model to close leftover todos. One per open list. */
|
|
1179
|
+
export function planCloseNudgeText(plan) {
|
|
1180
|
+
const leftover = plan.todos.filter(item => item.status !== 'completed');
|
|
1181
|
+
const lines = leftover.map(item => `- [${item.status}] ${item.content}`);
|
|
1182
|
+
return [
|
|
1183
|
+
'本轮结束时计划条还有未完成待办。请立刻再调用一次 todo_write,把已经做完的标成 completed,还没做的留 pending。不要开新任务。',
|
|
1184
|
+
...lines,
|
|
1185
|
+
].join('\n');
|
|
1186
|
+
}
|
|
1089
1187
|
/** Category for jump / search. Assistant replies are not collapsible cards. */
|
|
1090
1188
|
export function cardCategoryOf(row) {
|
|
1091
1189
|
if (row.kind === 'reasoning' || row.kind === 'streaming-reasoning')
|
|
@@ -1187,6 +1285,10 @@ export function matchTranscriptRows(rows, raw) {
|
|
|
1187
1285
|
export function planDockNote(plan) {
|
|
1188
1286
|
const running = plan.todos.some(item => item.status === 'in_progress');
|
|
1189
1287
|
const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
|
|
1288
|
+
const leftover = plan.todos.filter(item => item.status !== 'completed').length;
|
|
1289
|
+
if (plan.turnLeftOpen === true && leftover > 0) {
|
|
1290
|
+
return `本轮未收尾:还剩 ${leftover} 项待办(会话日志未改)。`;
|
|
1291
|
+
}
|
|
1190
1292
|
if (plan.pending)
|
|
1191
1293
|
return '模式切换将在下一步生效。';
|
|
1192
1294
|
if (plan.active)
|
|
@@ -1813,9 +1915,13 @@ export class SshTui {
|
|
|
1813
1915
|
lastPaintWidth = 0;
|
|
1814
1916
|
lastPaintHeight = 0;
|
|
1815
1917
|
paintIntervalMs;
|
|
1918
|
+
paintLink = 'local';
|
|
1919
|
+
paintProbed = false;
|
|
1816
1920
|
searchHits = [];
|
|
1817
1921
|
searchIndex = -1;
|
|
1818
1922
|
searchQuery = '';
|
|
1923
|
+
planNudgePending = false;
|
|
1924
|
+
pendingReveal;
|
|
1819
1925
|
constructor(ctx, agent, config) {
|
|
1820
1926
|
this.ctx = ctx;
|
|
1821
1927
|
this.agent = agent;
|
|
@@ -1836,7 +1942,10 @@ export class SshTui {
|
|
|
1836
1942
|
this.presetId = config.presetId ?? 'standard';
|
|
1837
1943
|
this.presetName = config.presetName ?? this.presetId;
|
|
1838
1944
|
this.useAlternateScreen = process.env.DSH_TUI_NO_ALT_SCREEN !== '1' && process.env.DSH_TUI_NO_ALT_SCREEN !== 'true';
|
|
1839
|
-
this.
|
|
1945
|
+
this.paintLink = detectSshSession() ? 'ssh' : 'local';
|
|
1946
|
+
this.paintIntervalMs = resolvePaintIntervalMs(config.paintIntervalMs, process.env, {
|
|
1947
|
+
ssh: this.paintLink === 'ssh',
|
|
1948
|
+
});
|
|
1840
1949
|
this.pushRow({ kind: 'brand-logo' });
|
|
1841
1950
|
this.pushRow({ kind: 'system', text: 'DeepSeek Harness — SSH TUI' });
|
|
1842
1951
|
this.pushRow({ kind: 'system', text: '输入 /help 查看快捷键 · /find 搜索思考/计划/子代理/回复 · 空输入时 ↑/↓ 选卡片' });
|
|
@@ -1845,7 +1954,6 @@ export class SshTui {
|
|
|
1845
1954
|
start() {
|
|
1846
1955
|
process.stdin.setRawMode(true);
|
|
1847
1956
|
process.stdin.resume();
|
|
1848
|
-
process.stdin.on('data', this.handleData);
|
|
1849
1957
|
process.stdout.on('resize', this.markDirty);
|
|
1850
1958
|
process.on('SIGWINCH', this.markDirty);
|
|
1851
1959
|
this.disposers.push(this.ctx.on('session/event', this.handleSessionEvent), this.ctx.on('agent/status', this.handleStatus), this.ctx.on('agent/error', this.handleError), this.ctx.on('agent/disposed', this.handleDisposed), this.ctx.on('agent/inbox/claimed', this.handleInboxClaimed), this.ctx.on('agent/inbox/discarded', this.handleInboxDiscarded), this.ctx.on('agent/request', this.handleAgentRequest), this.ctx.on('subagent/start', this.handleSubagentStart), this.ctx.on('subagent/end', this.handleSubagentEnd), this.ctx.on('approval/request', this.handleApproval));
|
|
@@ -1859,6 +1967,30 @@ export class SshTui {
|
|
|
1859
1967
|
if (this.resumePicker) {
|
|
1860
1968
|
void this.runResumeCommand('', true);
|
|
1861
1969
|
}
|
|
1970
|
+
void this.calibratePaintInterval().finally(() => {
|
|
1971
|
+
if (this.disposed)
|
|
1972
|
+
return;
|
|
1973
|
+
process.stdin.on('data', this.handleData);
|
|
1974
|
+
this.startRenderTimer();
|
|
1975
|
+
});
|
|
1976
|
+
void this.maybeRunOnboarding().catch((error) => {
|
|
1977
|
+
if (this.disposed)
|
|
1978
|
+
return;
|
|
1979
|
+
this.pushRow({ kind: 'error', text: `首次配置检查失败: ${errorChain(error)}` });
|
|
1980
|
+
this.markDirty();
|
|
1981
|
+
});
|
|
1982
|
+
void this.syncSubagentToProvider(this.currentProviderId()).catch((error) => {
|
|
1983
|
+
if (this.disposed)
|
|
1984
|
+
return;
|
|
1985
|
+
this.pushRow({ kind: 'error', text: `同步子代理模型失败: ${errorChain(error)}` });
|
|
1986
|
+
this.markDirty();
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
startRenderTimer() {
|
|
1990
|
+
if (this.renderTimer !== undefined) {
|
|
1991
|
+
clearInterval(this.renderTimer);
|
|
1992
|
+
this.renderTimer = undefined;
|
|
1993
|
+
}
|
|
1862
1994
|
this.renderTimer = setInterval(() => {
|
|
1863
1995
|
const now = Date.now();
|
|
1864
1996
|
if (this.agent.status === 'running')
|
|
@@ -1871,13 +2003,9 @@ export class SshTui {
|
|
|
1871
2003
|
if (animating && now - this.lastPaintAt >= Math.max(this.paintIntervalMs, 200)) {
|
|
1872
2004
|
this.dirty = true;
|
|
1873
2005
|
}
|
|
1874
|
-
// While a turn is waiting on the provider with no new events, repaint at
|
|
1875
|
-
// most once per second so slow SSH links do not drown in redraws.
|
|
1876
2006
|
const idleWaiting = this.agent.status === 'running' && !this.dirty;
|
|
1877
2007
|
if (idleWaiting && now - this.lastPaintAt < 1000)
|
|
1878
2008
|
return;
|
|
1879
|
-
// Running with no fresh events only needs the seconds-bearing status
|
|
1880
|
-
// line refreshed; force one repaint per second instead of every tick.
|
|
1881
2009
|
if (this.agent.status === 'running' && !this.dirty && now - this.lastPaintAt >= 1000) {
|
|
1882
2010
|
this.dirty = true;
|
|
1883
2011
|
}
|
|
@@ -1887,18 +2015,33 @@ export class SshTui {
|
|
|
1887
2015
|
}
|
|
1888
2016
|
}, this.paintIntervalMs);
|
|
1889
2017
|
this.renderTimer.unref?.();
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
this.
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
2018
|
+
}
|
|
2019
|
+
async calibratePaintInterval() {
|
|
2020
|
+
const envOverride = Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '', 10);
|
|
2021
|
+
if (Number.isFinite(envOverride) && envOverride > 0) {
|
|
2022
|
+
this.paintProbed = false;
|
|
2023
|
+
this.pushRow({
|
|
2024
|
+
kind: 'system',
|
|
2025
|
+
text: `${paintLinkLabel(this.paintLink, this.paintIntervalMs, false)} · DSH_TUI_PAINT_MS`,
|
|
2026
|
+
});
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
if (this.paintLink !== 'ssh') {
|
|
2030
|
+
this.pushRow({ kind: 'system', text: paintLinkLabel('local', this.paintIntervalMs, false) });
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
const rtt = await probeTerminalRttMs();
|
|
2034
|
+
if (this.disposed)
|
|
2035
|
+
return;
|
|
2036
|
+
this.paintProbed = rtt !== undefined;
|
|
2037
|
+
this.paintIntervalMs = resolvePaintIntervalMs(undefined, {}, { ssh: true, rttMs: rtt });
|
|
2038
|
+
this.pushRow({
|
|
2039
|
+
kind: 'system',
|
|
2040
|
+
text: rtt === undefined
|
|
2041
|
+
? paintLinkLabel('ssh', this.paintIntervalMs, false)
|
|
2042
|
+
: `${paintLinkLabel('ssh', this.paintIntervalMs, true)} · 往返 ${Math.round(rtt)}ms`,
|
|
1901
2043
|
});
|
|
2044
|
+
this.markDirty();
|
|
1902
2045
|
}
|
|
1903
2046
|
/** Replay the durable session log so a resumed session renders its history. */
|
|
1904
2047
|
replayHistory() {
|
|
@@ -2166,6 +2309,10 @@ export class SshTui {
|
|
|
2166
2309
|
if (existing !== undefined && planIsLive(existing)) {
|
|
2167
2310
|
Object.assign(existing, patch);
|
|
2168
2311
|
existing.archived = false;
|
|
2312
|
+
if (patch.todos !== undefined || patch.active !== undefined || patch.pending !== undefined) {
|
|
2313
|
+
existing.turnLeftOpen = false;
|
|
2314
|
+
this.planNudgePending = false;
|
|
2315
|
+
}
|
|
2169
2316
|
if (!planIsLive(existing)) {
|
|
2170
2317
|
existing.archived = true;
|
|
2171
2318
|
existing.expanded = false;
|
|
@@ -2196,6 +2343,27 @@ export class SshTui {
|
|
|
2196
2343
|
shouldDockPlan() {
|
|
2197
2344
|
return this.findLivePlanRow() !== undefined;
|
|
2198
2345
|
}
|
|
2346
|
+
/** One follow-up per leftover list; replay and cancelled turns stay quiet. */
|
|
2347
|
+
queuePlanCloseNudge(plan) {
|
|
2348
|
+
if (this.replaying || this.agentGone || this.planNudgePending)
|
|
2349
|
+
return;
|
|
2350
|
+
if (this.agent.status === 'running')
|
|
2351
|
+
return;
|
|
2352
|
+
this.planNudgePending = true;
|
|
2353
|
+
const text = planCloseNudgeText(plan);
|
|
2354
|
+
this.pushRow({ kind: 'system', text: '已请模型补一次待办状态(本轮只问一次)。' });
|
|
2355
|
+
const message = createUserMessage({
|
|
2356
|
+
content: [{ type: 'text', text }],
|
|
2357
|
+
source: { kind: 'user' },
|
|
2358
|
+
});
|
|
2359
|
+
try {
|
|
2360
|
+
this.agent.followup(message);
|
|
2361
|
+
}
|
|
2362
|
+
catch (error) {
|
|
2363
|
+
this.planNudgePending = false;
|
|
2364
|
+
this.pushRow({ kind: 'error', text: `补待办状态失败:${errorChain(error)}` });
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2199
2367
|
/** Compact web-style plan strip pinned above the input, not in the transcript. */
|
|
2200
2368
|
paintPlanDock(width, yieldBottom) {
|
|
2201
2369
|
const plan = this.findLivePlanRow();
|
|
@@ -2204,8 +2372,14 @@ export class SshTui {
|
|
|
2204
2372
|
const inner = Math.max(1, width - 2);
|
|
2205
2373
|
const running = plan.todos.some(item => item.status === 'in_progress');
|
|
2206
2374
|
const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
|
|
2207
|
-
const
|
|
2208
|
-
const
|
|
2375
|
+
const leftOpen = plan.turnLeftOpen === true && !allDone && !plan.pending;
|
|
2376
|
+
const spinner = (plan.pending || ((plan.active || running) && !leftOpen)) ? ` ${this.spinnerFrame()}` : '';
|
|
2377
|
+
const mode = plan.pending ? '切换中'
|
|
2378
|
+
: leftOpen ? '本轮未收尾'
|
|
2379
|
+
: plan.active ? '计划模式'
|
|
2380
|
+
: running ? '计划'
|
|
2381
|
+
: allDone ? '计划完成'
|
|
2382
|
+
: '计划';
|
|
2209
2383
|
const counts = todoProgressLabel(plan.todos);
|
|
2210
2384
|
const title = planTitleFromMarkdown(plan.planMarkdown ?? '');
|
|
2211
2385
|
const summary = title ?? (counts === '' ? '还没有任务' : counts);
|
|
@@ -2304,18 +2478,27 @@ export class SshTui {
|
|
|
2304
2478
|
this.focusedRow = allExpanded ? null : rows[rows.length - 1] ?? null;
|
|
2305
2479
|
this.markDirty();
|
|
2306
2480
|
}
|
|
2307
|
-
|
|
2481
|
+
highlightSearchLine(line) {
|
|
2482
|
+
if (line.includes('\x1b[7m'))
|
|
2483
|
+
return line;
|
|
2484
|
+
return this.color ? `\x1b[7m${line}\x1b[27m` : `» ${line}`;
|
|
2485
|
+
}
|
|
2486
|
+
revealRow(row) {
|
|
2308
2487
|
if (row === undefined)
|
|
2309
2488
|
return;
|
|
2310
|
-
if (row.kind
|
|
2311
|
-
|
|
2312
|
-
}
|
|
2313
|
-
else if ('expanded' in row) {
|
|
2489
|
+
if (row.kind !== 'assistant' && 'expanded' in row) {
|
|
2490
|
+
row.expanded = true;
|
|
2314
2491
|
this.focusedRow = row;
|
|
2315
2492
|
}
|
|
2316
|
-
|
|
2493
|
+
else {
|
|
2494
|
+
this.focusedRow = null;
|
|
2495
|
+
}
|
|
2496
|
+
this.pendingReveal = row;
|
|
2317
2497
|
this.markDirty();
|
|
2318
2498
|
}
|
|
2499
|
+
focusCard(row) {
|
|
2500
|
+
this.revealRow(row);
|
|
2501
|
+
}
|
|
2319
2502
|
/** Jump to the newest card in a category (thinking / plan / subagent / reply). */
|
|
2320
2503
|
jumpToCategory(category) {
|
|
2321
2504
|
if (category === 'plan') {
|
|
@@ -2323,6 +2506,7 @@ export class SshTui {
|
|
|
2323
2506
|
if (live !== undefined) {
|
|
2324
2507
|
this.focusCard(live);
|
|
2325
2508
|
this.pushRow({ kind: 'system', text: `已跳到${CARD_CATEGORY_LABEL[category]}(底栏计划条)。` });
|
|
2509
|
+
this.revealRow(live);
|
|
2326
2510
|
return;
|
|
2327
2511
|
}
|
|
2328
2512
|
}
|
|
@@ -2332,10 +2516,8 @@ export class SshTui {
|
|
|
2332
2516
|
this.markDirty();
|
|
2333
2517
|
return;
|
|
2334
2518
|
}
|
|
2335
|
-
if ('expanded' in target)
|
|
2336
|
-
target.expanded = true;
|
|
2337
|
-
this.focusCard(target);
|
|
2338
2519
|
this.pushRow({ kind: 'system', text: `已跳到最新${CARD_CATEGORY_LABEL[category]}。` });
|
|
2520
|
+
this.revealRow(target);
|
|
2339
2521
|
}
|
|
2340
2522
|
applySearchHits(query, hits) {
|
|
2341
2523
|
this.searchQuery = query;
|
|
@@ -2348,14 +2530,12 @@ export class SshTui {
|
|
|
2348
2530
|
}
|
|
2349
2531
|
this.searchIndex = hits.length - 1;
|
|
2350
2532
|
const hit = hits[this.searchIndex];
|
|
2351
|
-
if (hit !== undefined && 'expanded' in hit)
|
|
2352
|
-
hit.expanded = true;
|
|
2353
|
-
this.focusCard(hit);
|
|
2354
2533
|
const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
|
|
2355
2534
|
this.pushRow({
|
|
2356
2535
|
kind: 'system',
|
|
2357
2536
|
text: `找到 ${hits.length} 条${query === '' ? '' : `「${query}」`} · 第 ${hits.length}/${hits.length} 条(${where})。Ctrl+G / Alt+N 下一条,Alt+P 上一条。`,
|
|
2358
2537
|
});
|
|
2538
|
+
this.revealRow(hit);
|
|
2359
2539
|
}
|
|
2360
2540
|
runFindCommand(arg) {
|
|
2361
2541
|
const parsed = parseFindQuery(arg);
|
|
@@ -2372,14 +2552,12 @@ export class SshTui {
|
|
|
2372
2552
|
const count = this.searchHits.length;
|
|
2373
2553
|
this.searchIndex = (this.searchIndex + delta + count) % count;
|
|
2374
2554
|
const hit = this.searchHits[this.searchIndex];
|
|
2375
|
-
if (hit !== undefined && 'expanded' in hit)
|
|
2376
|
-
hit.expanded = true;
|
|
2377
|
-
this.focusCard(hit);
|
|
2378
2555
|
const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
|
|
2379
2556
|
this.pushRow({
|
|
2380
2557
|
kind: 'system',
|
|
2381
2558
|
text: `搜索「${this.searchQuery}」· 第 ${this.searchIndex + 1}/${count} 条(${where})。`,
|
|
2382
2559
|
});
|
|
2560
|
+
this.revealRow(hit);
|
|
2383
2561
|
}
|
|
2384
2562
|
paint = () => {
|
|
2385
2563
|
if (this.exiting)
|
|
@@ -2388,19 +2566,21 @@ export class SshTui {
|
|
|
2388
2566
|
const height = Math.max(6, process.stdout.rows || 24);
|
|
2389
2567
|
const display = [];
|
|
2390
2568
|
const displayRefs = [];
|
|
2569
|
+
const searchHit = this.searchHits[this.searchIndex];
|
|
2391
2570
|
const addDisplay = (line, ref) => {
|
|
2392
|
-
|
|
2571
|
+
const hit = ref !== undefined && ref === searchHit;
|
|
2572
|
+
display.push(hit ? this.highlightSearchLine(line) : line);
|
|
2393
2573
|
displayRefs.push(ref);
|
|
2394
2574
|
};
|
|
2395
|
-
const pushRow = (kind, text) => {
|
|
2575
|
+
const pushRow = (kind, text, ref) => {
|
|
2396
2576
|
if (kind === 'assistant') {
|
|
2397
2577
|
for (const line of renderMarkdownLines(text, width, this.color)) {
|
|
2398
|
-
addDisplay(line);
|
|
2578
|
+
addDisplay(line, ref);
|
|
2399
2579
|
}
|
|
2400
2580
|
return;
|
|
2401
2581
|
}
|
|
2402
2582
|
for (const line of wrap(text, width)) {
|
|
2403
|
-
addDisplay(this.styleLine(kind, line));
|
|
2583
|
+
addDisplay(this.styleLine(kind, line), ref);
|
|
2404
2584
|
}
|
|
2405
2585
|
};
|
|
2406
2586
|
for (const row of this.rows) {
|
|
@@ -2426,7 +2606,7 @@ export class SshTui {
|
|
|
2426
2606
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
|
|
2427
2607
|
if (row.expanded) {
|
|
2428
2608
|
for (const wrapped of wrap(row.text, width)) {
|
|
2429
|
-
addDisplay(this.styleLine('reasoning', wrapped));
|
|
2609
|
+
addDisplay(this.styleLine('reasoning', wrapped), row);
|
|
2430
2610
|
}
|
|
2431
2611
|
}
|
|
2432
2612
|
continue;
|
|
@@ -2474,7 +2654,7 @@ export class SshTui {
|
|
|
2474
2654
|
const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
|
|
2475
2655
|
for (const wrapped of wrap(line.text, inner)) {
|
|
2476
2656
|
const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
|
|
2477
|
-
addDisplay(this.styleLine(line.kind, body));
|
|
2657
|
+
addDisplay(this.styleLine(line.kind, body), row);
|
|
2478
2658
|
}
|
|
2479
2659
|
}
|
|
2480
2660
|
continue;
|
|
@@ -2496,12 +2676,12 @@ export class SshTui {
|
|
|
2496
2676
|
const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
2497
2677
|
this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width, styleHeader);
|
|
2498
2678
|
if (row.expanded) {
|
|
2499
|
-
addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`));
|
|
2679
|
+
addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
|
|
2500
2680
|
if (row.stopReason !== undefined) {
|
|
2501
|
-
addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`));
|
|
2681
|
+
addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`), row);
|
|
2502
2682
|
}
|
|
2503
2683
|
if (row.logs.length === 0) {
|
|
2504
|
-
addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'));
|
|
2684
|
+
addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
|
|
2505
2685
|
}
|
|
2506
2686
|
else {
|
|
2507
2687
|
for (const entry of row.logs) {
|
|
@@ -2511,7 +2691,7 @@ export class SshTui {
|
|
|
2511
2691
|
? 'error'
|
|
2512
2692
|
: 'tool-result';
|
|
2513
2693
|
for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
|
|
2514
|
-
addDisplay(this.styleLine(kind, ` ${wrapped}`));
|
|
2694
|
+
addDisplay(this.styleLine(kind, ` ${wrapped}`), row);
|
|
2515
2695
|
}
|
|
2516
2696
|
}
|
|
2517
2697
|
}
|
|
@@ -2527,16 +2707,16 @@ export class SshTui {
|
|
|
2527
2707
|
const header = `计划 · ${summary}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
2528
2708
|
this.paintCollapsibleHeader(addDisplay, row, 'plan-dock', header, width);
|
|
2529
2709
|
if (row.expanded) {
|
|
2530
|
-
addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`));
|
|
2710
|
+
addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
|
|
2531
2711
|
if (row.planMarkdown !== undefined && row.planMarkdown !== '') {
|
|
2532
2712
|
for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color).slice(0, 8)) {
|
|
2533
|
-
addDisplay(` ${line}
|
|
2713
|
+
addDisplay(` ${line}`, row);
|
|
2534
2714
|
}
|
|
2535
2715
|
}
|
|
2536
2716
|
for (const item of row.todos) {
|
|
2537
2717
|
const mark = TODO_STATUS_MARK[item.status];
|
|
2538
2718
|
for (const wrapped of wrap(`${mark} ${item.content}`, Math.max(1, width - 2))) {
|
|
2539
|
-
addDisplay(this.styleLine(todoItemKind(item.status), ` ${wrapped}`));
|
|
2719
|
+
addDisplay(this.styleLine(todoItemKind(item.status), ` ${wrapped}`), row);
|
|
2540
2720
|
}
|
|
2541
2721
|
}
|
|
2542
2722
|
}
|
|
@@ -2551,25 +2731,25 @@ export class SshTui {
|
|
|
2551
2731
|
this.paintCollapsibleHeader(addDisplay, row, waiting ? 'tool' : 'system', header, width);
|
|
2552
2732
|
if (row.expanded) {
|
|
2553
2733
|
if (row.header !== undefined)
|
|
2554
|
-
addDisplay(this.styleLine('system', ` ${row.header}`));
|
|
2734
|
+
addDisplay(this.styleLine('system', ` ${row.header}`), row);
|
|
2555
2735
|
for (const wrapped of wrap(row.title, Math.max(1, width - 2))) {
|
|
2556
|
-
addDisplay(this.styleLine('assistant', ` ${wrapped}`));
|
|
2736
|
+
addDisplay(this.styleLine('assistant', ` ${wrapped}`), row);
|
|
2557
2737
|
}
|
|
2558
2738
|
if (row.detail !== undefined && row.detail !== '') {
|
|
2559
2739
|
if (row.intent === 'plan-review') {
|
|
2560
2740
|
for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color)) {
|
|
2561
|
-
addDisplay(` ${line}
|
|
2741
|
+
addDisplay(` ${line}`, row);
|
|
2562
2742
|
}
|
|
2563
2743
|
}
|
|
2564
2744
|
else {
|
|
2565
2745
|
for (const wrapped of wrap(row.detail, Math.max(1, width - 2))) {
|
|
2566
|
-
addDisplay(this.styleLine('tool-result', ` ${wrapped}`));
|
|
2746
|
+
addDisplay(this.styleLine('tool-result', ` ${wrapped}`), row);
|
|
2567
2747
|
}
|
|
2568
2748
|
}
|
|
2569
2749
|
}
|
|
2570
2750
|
addDisplay(this.styleLine('system', waiting
|
|
2571
2751
|
? ' 用下方对话框选择,数字/字母选中,Enter 提交,Esc 取消。'
|
|
2572
|
-
: ` ${row.summary}`));
|
|
2752
|
+
: ` ${row.summary}`), row);
|
|
2573
2753
|
}
|
|
2574
2754
|
continue;
|
|
2575
2755
|
}
|
|
@@ -2584,16 +2764,16 @@ export class SshTui {
|
|
|
2584
2764
|
const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
2585
2765
|
this.paintCollapsibleHeader(addDisplay, row, live ? 'tool' : 'system', header, width);
|
|
2586
2766
|
if (row.expanded) {
|
|
2587
|
-
addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'));
|
|
2767
|
+
addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'), row);
|
|
2588
2768
|
if (row.blockedReason !== undefined) {
|
|
2589
2769
|
for (const wrapped of wrap(row.blockedReason, Math.max(1, width - 2))) {
|
|
2590
|
-
addDisplay(this.styleLine('error', ` ${wrapped}`));
|
|
2770
|
+
addDisplay(this.styleLine('error', ` ${wrapped}`), row);
|
|
2591
2771
|
}
|
|
2592
2772
|
}
|
|
2593
2773
|
}
|
|
2594
2774
|
continue;
|
|
2595
2775
|
}
|
|
2596
|
-
pushRow(row.kind, row.text);
|
|
2776
|
+
pushRow(row.kind, row.text, row);
|
|
2597
2777
|
}
|
|
2598
2778
|
if (this.streaming !== undefined) {
|
|
2599
2779
|
if (this.showReasoning && this.streaming.reasoning !== '') {
|
|
@@ -2611,7 +2791,7 @@ export class SshTui {
|
|
|
2611
2791
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, block);
|
|
2612
2792
|
if (block.expanded) {
|
|
2613
2793
|
for (const wrapped of wrap(this.streaming.reasoning, width)) {
|
|
2614
|
-
addDisplay(this.styleLine('reasoning', wrapped));
|
|
2794
|
+
addDisplay(this.styleLine('reasoning', wrapped), block);
|
|
2615
2795
|
}
|
|
2616
2796
|
}
|
|
2617
2797
|
}
|
|
@@ -2805,6 +2985,18 @@ export class SshTui {
|
|
|
2805
2985
|
const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
|
|
2806
2986
|
const available = Math.max(1, height - reserved - dialogLines.length);
|
|
2807
2987
|
const maxOffset = Math.max(0, display.length - available);
|
|
2988
|
+
const reveal = this.pendingReveal;
|
|
2989
|
+
if (reveal !== undefined) {
|
|
2990
|
+
this.pendingReveal = undefined;
|
|
2991
|
+
const first = displayRefs.findIndex(ref => ref === reveal);
|
|
2992
|
+
if (first !== -1) {
|
|
2993
|
+
let last = first;
|
|
2994
|
+
while (last + 1 < displayRefs.length && displayRefs[last + 1] === reveal)
|
|
2995
|
+
last += 1;
|
|
2996
|
+
const span = last - first + 1;
|
|
2997
|
+
this.scrollOffset = Math.max(0, display.length - available - first);
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
2808
3000
|
if (this.scrollOffset > maxOffset)
|
|
2809
3001
|
this.scrollOffset = maxOffset;
|
|
2810
3002
|
const start = Math.max(0, display.length - available - this.scrollOffset);
|
|
@@ -2818,7 +3010,7 @@ export class SshTui {
|
|
|
2818
3010
|
this.clickableRows.clear();
|
|
2819
3011
|
for (let index = 0; index < visibleRefs.length; index++) {
|
|
2820
3012
|
const ref = visibleRefs[index];
|
|
2821
|
-
if (ref !== undefined)
|
|
3013
|
+
if (ref !== undefined && 'expanded' in ref)
|
|
2822
3014
|
this.clickableRows.set(headerLines.length + index + 1, ref);
|
|
2823
3015
|
}
|
|
2824
3016
|
const dockPlan = this.findLivePlanRow();
|
|
@@ -2844,6 +3036,7 @@ export class SshTui {
|
|
|
2844
3036
|
statusText += ' · Ctrl+T 折叠输入';
|
|
2845
3037
|
if (this.pendingMessages.size > 0)
|
|
2846
3038
|
statusText += ` · 排队 ${this.pendingMessages.size}`;
|
|
3039
|
+
statusText += ` · ${this.paintLink === 'ssh' ? 'ssh' : '本机'}${this.paintIntervalMs}ms`;
|
|
2847
3040
|
const idleMs = Date.now() - this.lastActivity;
|
|
2848
3041
|
const livePlan = this.findLivePlanRow();
|
|
2849
3042
|
const waitingQuestions = this.rows.some(row => row.kind === 'question' && row.status === 'waiting');
|
|
@@ -2852,6 +3045,9 @@ export class SshTui {
|
|
|
2852
3045
|
? ' · 计划待审'
|
|
2853
3046
|
: ' · 等待用户回答';
|
|
2854
3047
|
}
|
|
3048
|
+
else if (livePlan?.turnLeftOpen === true) {
|
|
3049
|
+
statusText += ' · 本轮未收尾';
|
|
3050
|
+
}
|
|
2855
3051
|
else if (livePlan?.active === true || livePlan?.pending === true) {
|
|
2856
3052
|
statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
|
|
2857
3053
|
}
|
|
@@ -3355,6 +3551,17 @@ export class SshTui {
|
|
|
3355
3551
|
if (reason.kind === 'error') {
|
|
3356
3552
|
this.pushRow({ kind: 'error', text: `Turn ${event.data.turn} failed: ${reason.error.message}` });
|
|
3357
3553
|
}
|
|
3554
|
+
const livePlan = this.findLivePlanRow();
|
|
3555
|
+
if (livePlan !== undefined && reason.kind === 'completed') {
|
|
3556
|
+
applyTurnEndToPlan(livePlan);
|
|
3557
|
+
if (livePlan.turnLeftOpen === true) {
|
|
3558
|
+
this.pushRow({
|
|
3559
|
+
kind: 'system',
|
|
3560
|
+
text: planDockNote(livePlan),
|
|
3561
|
+
});
|
|
3562
|
+
this.queuePlanCloseNudge(livePlan);
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3358
3565
|
this.markDirty();
|
|
3359
3566
|
break;
|
|
3360
3567
|
}
|
|
@@ -4641,6 +4848,8 @@ export class SshTui {
|
|
|
4641
4848
|
this.markDirty();
|
|
4642
4849
|
return;
|
|
4643
4850
|
}
|
|
4851
|
+
if (parseCursorPositionReply(combined) !== undefined)
|
|
4852
|
+
return;
|
|
4644
4853
|
if (combined === '\x1b[H' || combined === '\x1b[1~') {
|
|
4645
4854
|
this.cursor = 0;
|
|
4646
4855
|
this.markDirty();
|
|
@@ -5553,6 +5762,8 @@ export class SshTui {
|
|
|
5553
5762
|
this.searchHits = [];
|
|
5554
5763
|
this.searchIndex = -1;
|
|
5555
5764
|
this.searchQuery = '';
|
|
5765
|
+
this.planNudgePending = false;
|
|
5766
|
+
this.pendingReveal = undefined;
|
|
5556
5767
|
this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
|
|
5557
5768
|
break;
|
|
5558
5769
|
case 'status':
|
|
@@ -5571,6 +5782,7 @@ export class SshTui {
|
|
|
5571
5782
|
`preset: ${this.presetName}`,
|
|
5572
5783
|
`subagents: ${this.activeSubagents.size}`,
|
|
5573
5784
|
`plan: ${plan === undefined ? 'off' : plan.pending ? 'pending' : plan.active ? 'on' : 'off'}`,
|
|
5785
|
+
`paint: ${paintLinkLabel(this.paintLink, this.paintIntervalMs, this.paintProbed)}`,
|
|
5574
5786
|
waiting > 0 ? `questions: waiting ${waiting}` : 'questions: none',
|
|
5575
5787
|
];
|
|
5576
5788
|
this.pushRow({ kind: 'system', text: lines.join('\n') });
|