dsh-ssh-tui 0.3.1 → 0.3.2
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 +25 -11
- package/README.md +26 -10
- package/cordis.patch.yml +1 -1
- package/lib/tui.js +135 -42
- package/lib/tui.js.map +1 -1
- package/lib/types/tui.d.ts +27 -2
- package/package.json +4 -2
- package/docs/screenshots/headless-labeled.png +0 -0
- package/docs/screenshots/workspace-labeled.png +0 -0
package/README.en.md
CHANGED
|
@@ -35,6 +35,26 @@ Singles: [headless stdout](docs/screenshots/headless.png) · [dsh-ssh-tui](docs/
|
|
|
35
35
|
|
|
36
36
|
## Install
|
|
37
37
|
|
|
38
|
+
On a remote box with `dsh` and pnpm already installed (no clone):
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
dsh plugin --profile tui add dsh-ssh-tui
|
|
42
|
+
# optional: reuse a SuperGrok / grok-bridge token already on the machine
|
|
43
|
+
dsh plugin --profile tui add github:cyjyyd/dsh-llm-xai-oauth
|
|
44
|
+
dsh plugin --profile headless add github:cyjyyd/dsh-llm-xai-oauth
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Smoke the route before opening the TUI. The TUI exits immediately without a TTY:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
dsh --profile headless "Reply with exactly: tui-install-ok. Do not use tools."
|
|
51
|
+
dsh --profile tui
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
From a checkout: `bash scripts/smoke-headless.sh` (prints an outcome summary, never a token).
|
|
55
|
+
|
|
56
|
+
From git:
|
|
57
|
+
|
|
38
58
|
```sh
|
|
39
59
|
git clone https://github.com/cyjyyd/dsh-ssh-tui.git
|
|
40
60
|
cd dsh-ssh-tui
|
|
@@ -49,14 +69,6 @@ npm run build
|
|
|
49
69
|
dsh plugin --profile tui add "link:$(pwd)"
|
|
50
70
|
```
|
|
51
71
|
|
|
52
|
-
Or install the published npm package directly (requires `@deepseek-ai/dsh` and
|
|
53
|
-
`pnpm`, no local clone needed):
|
|
54
|
-
|
|
55
|
-
```sh
|
|
56
|
-
dsh plugin --profile tui add dsh-ssh-tui
|
|
57
|
-
# or from a repo checkout: bash scripts/install-npm.sh
|
|
58
|
-
```
|
|
59
|
-
|
|
60
72
|
For the optional **智能路由模式 (routing-suite)** mode, also run:
|
|
61
73
|
|
|
62
74
|
```sh
|
|
@@ -151,7 +163,7 @@ The plugin runs on Linux, macOS, and Windows (Node ≥ 22.19):
|
|
|
151
163
|
| `Ctrl+R` | expand or collapse all cards |
|
|
152
164
|
| `Ctrl+T` | fold the input box (display-only) |
|
|
153
165
|
| `Alt+1` / `2` / `3` / `4` | jump to latest thinking / plan / subagent / reply |
|
|
154
|
-
| `/find [kind] query` | search
|
|
166
|
+
| `/find [kind] query` | search and jump to the full matching message (`thinking` `plan` `subagent` `reply`). `Ctrl+/` or `Alt+/` opens it |
|
|
155
167
|
| `Ctrl+G` / `Alt+N` | next search hit; `Alt+P` previous |
|
|
156
168
|
| `Esc` | drop selection → scroll to bottom → cancel the running turn |
|
|
157
169
|
| `Ctrl+C` | cancel the running turn; exit when idle |
|
|
@@ -195,7 +207,9 @@ into the parent stream.
|
|
|
195
207
|
The plan strip pins only the **latest incomplete plan**. When the model
|
|
196
208
|
opens a new plan in the same turn, the previous one archives into the
|
|
197
209
|
scrolling transcript and the dock shows the new one. A finished plan says
|
|
198
|
-
「计划任务已全部完成」, not 「计划模式已关闭」.
|
|
210
|
+
「计划任务已全部完成」, not 「计划模式已关闭」. If a turn ends with open todos, the strip says 「本轮未收尾」, stops
|
|
211
|
+
spinning, and sends one follow-up asking the model to `todo_write` the
|
|
212
|
+
real statuses. The `/` menu and
|
|
199
213
|
approval/question dialogs yield that bottom space. `exit_plan_mode` is
|
|
200
214
|
markdown. `ask_user_question` still opens a dialog and leaves a collapsed
|
|
201
215
|
`提问用户` card. `/goal` is a collapsed `目标` card. `/find thinking foo`
|
|
@@ -310,7 +324,7 @@ switching while running.
|
|
|
310
324
|
|
|
311
325
|
Each paint is one `stdout.write` of dirty rows only, so a jump host or
|
|
312
326
|
corporate proxy does not see one SSH packet per line. Default cadence is
|
|
313
|
-
about
|
|
327
|
+
about 160 ms, enough for most jump hosts. On a very slow path:
|
|
314
328
|
|
|
315
329
|
```sh
|
|
316
330
|
export DSH_TUI_PAINT_MS=250 # 40–1000; higher = fewer packets, choppier stream
|
package/README.md
CHANGED
|
@@ -53,7 +53,25 @@ SuperGrok / X Premium 订阅走配套插件 [dsh-llm-xai-oauth](https://github.c
|
|
|
53
53
|
|
|
54
54
|
## 部署指南
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
远程机器最短路径(已有 `dsh` 和 pnpm,无需 clone):
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
dsh plugin --profile tui add dsh-ssh-tui
|
|
60
|
+
# 可选:本机已有 SuperGrok / grok-bridge token 时
|
|
61
|
+
dsh plugin --profile tui add github:cyjyyd/dsh-llm-xai-oauth
|
|
62
|
+
dsh plugin --profile headless add github:cyjyyd/dsh-llm-xai-oauth
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
先确认链路再开 TUI(无 TTY 时 TUI 会直接退出):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
dsh --profile headless "Reply with exactly: tui-install-ok. Do not use tools."
|
|
69
|
+
dsh --profile tui # 必须在真实终端 / SSH 会话里
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
仓库内也可:`bash scripts/smoke-headless.sh`(记录出口摘要,不打印 token)。
|
|
73
|
+
|
|
74
|
+
### 方式一:从 git clone 安装
|
|
57
75
|
|
|
58
76
|
```bash
|
|
59
77
|
git clone https://github.com/cyjyyd/dsh-ssh-tui.git
|
|
@@ -79,14 +97,10 @@ npm run build
|
|
|
79
97
|
dsh plugin --profile tui add "link:$(pwd)"
|
|
80
98
|
```
|
|
81
99
|
|
|
82
|
-
###
|
|
83
|
-
|
|
84
|
-
前置要求:已全局安装 `@deepseek-ai/dsh`(`npm i -g @deepseek-ai/dsh`)且有 pnpm。
|
|
100
|
+
### 方式三:指定其它 profile 的 npm 安装
|
|
85
101
|
|
|
86
102
|
```bash
|
|
87
|
-
|
|
88
|
-
# 或在仓库内快捷执行:bash scripts/install-npm.sh
|
|
89
|
-
# 指定其它 profile:bash scripts/install-npm.sh work
|
|
103
|
+
bash scripts/install-npm.sh work
|
|
90
104
|
```
|
|
91
105
|
|
|
92
106
|
`dsh plugin add` 会从 npm 拉取包、写入 profile 依赖,并自动把 `dsh-ssh-tui`
|
|
@@ -132,7 +146,7 @@ dsh --profile tui --no-color
|
|
|
132
146
|
| `Ctrl+R` | 全部展开或全部收起 |
|
|
133
147
|
| `Ctrl+T` | 折叠输入框(只影响显示) |
|
|
134
148
|
| `Alt+1` / `2` / `3` / `4` | 跳到最新思考 / 计划 / 子代理 / 回复 |
|
|
135
|
-
| `/find [类] 关键字` |
|
|
149
|
+
| `/find [类] 关键字` | 搜索并跳到该条完整消息(反色高亮)。类:`思考` `计划` `子代理` `回复`。`Ctrl+/` 或 `Alt+/` 打开 |
|
|
136
150
|
| `Ctrl+G` / `Alt+N` | 下一条搜索结果;`Alt+P` 上一条 |
|
|
137
151
|
| 鼠标左键 | 点击卡片标题展开/收起 |
|
|
138
152
|
| `PgUp` / `PgDn`、滚轮 | 转录回看 |
|
|
@@ -171,7 +185,9 @@ SuperGrok / X Premium 走本机 OAuth,不需要填 Key。`/status` 和底栏
|
|
|
171
185
|
|
|
172
186
|
计划条只钉**最新一条未完成的计划**。同一轮次里模型再开新计划时,旧计划归档进
|
|
173
187
|
工作区随转录上滚,底栏换成新计划。任务全部完成后计划条会说「计划任务已全部完成」,
|
|
174
|
-
|
|
188
|
+
不再误报「计划模式已关闭」。一轮结束时若待办仍是进行中/待处理,计划条改成「本轮未收尾」并停止转圈,
|
|
189
|
+
同时自动追问模型补一次 `todo_write`(同一列表只问一次,不改会话里的旧状态)。
|
|
190
|
+
打开 `/` 命令选单或批准/提问对话框时,计划条让出底栏。
|
|
175
191
|
`exit_plan_mode` 按 markdown 渲染。`ask_user_question` 仍弹对话框,并留下折叠的
|
|
176
192
|
`提问用户` 卡片。`/goal` 是折叠的 `目标` 卡片。`/find 思考 padAnsi` 或 `Alt+1..4`
|
|
177
193
|
可跳到对应类别的最新卡片。
|
|
@@ -274,7 +290,7 @@ npm run build
|
|
|
274
290
|
- **标题栏或铃声不生效**:确认终端支持 OSC 0 与 BEL;铃声可用
|
|
275
291
|
`DSH_TUI_NO_BELL=1` 关闭。
|
|
276
292
|
- **滚轮误触取消**:已加入转义序列缓冲,网络拆包也不会把 `ESC` 当取消。
|
|
277
|
-
- **跳板机 / 多层代理 SSH 发画**:每一帧只发脏行,并且拼成一次 `stdout.write`,避免一行一个 SSH 包。默认约
|
|
293
|
+
- **跳板机 / 多层代理 SSH 发画**:每一帧只发脏行,并且拼成一次 `stdout.write`,避免一行一个 SSH 包。默认约 160 ms 合一帧,多数跳板机不用改。特别慢时:
|
|
278
294
|
```bash
|
|
279
295
|
export DSH_TUI_PAINT_MS=250 # 40–1000;越大越省链路,流式字越顿
|
|
280
296
|
```
|
package/cordis.patch.yml
CHANGED
|
@@ -81,4 +81,4 @@
|
|
|
81
81
|
maxToolOutputLines: 6
|
|
82
82
|
color: !!js ctx.sshTuiStartup.noColor !== true
|
|
83
83
|
# Jump-host SSH: coalesce token ticks into one stdout write per frame.
|
|
84
|
-
paintIntervalMs: !!js Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '
|
|
84
|
+
paintIntervalMs: !!js Number.parseInt(process.env.DSH_TUI_PAINT_MS ?? '160', 10) || 160
|
package/lib/tui.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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 (default
|
|
10
|
+
* per paint, not one per line. Cadence is DSH_TUI_PAINT_MS (default 160).
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
@@ -59,7 +59,7 @@ const PROVIDER_TEMPLATES = {
|
|
|
59
59
|
defaultModels: ['deepseek-v4-flash'],
|
|
60
60
|
},
|
|
61
61
|
};
|
|
62
|
-
const RENDER_INTERVAL_MS =
|
|
62
|
+
const RENDER_INTERVAL_MS = 160;
|
|
63
63
|
const WAIT_INDICATOR_MS = 8000;
|
|
64
64
|
const MIN_PAINT_INTERVAL_MS = 40;
|
|
65
65
|
const MAX_PAINT_INTERVAL_MS = 1000;
|
|
@@ -1086,6 +1086,28 @@ export function planIsLive(plan) {
|
|
|
1086
1086
|
return true;
|
|
1087
1087
|
return false;
|
|
1088
1088
|
}
|
|
1089
|
+
/** Open todos left behind when a turn ends without a completing todo_write. */
|
|
1090
|
+
export function planTurnLeftOpen(plan) {
|
|
1091
|
+
return plan.todos.some(item => item.status !== 'completed');
|
|
1092
|
+
}
|
|
1093
|
+
/** Mark leftover in-progress/pending todos as display-stale after turn/end. */
|
|
1094
|
+
export function applyTurnEndToPlan(plan) {
|
|
1095
|
+
if (!planTurnLeftOpen(plan)) {
|
|
1096
|
+
plan.turnLeftOpen = false;
|
|
1097
|
+
return plan;
|
|
1098
|
+
}
|
|
1099
|
+
plan.turnLeftOpen = true;
|
|
1100
|
+
return plan;
|
|
1101
|
+
}
|
|
1102
|
+
/** Follow-up that asks the model to close leftover todos. One per open list. */
|
|
1103
|
+
export function planCloseNudgeText(plan) {
|
|
1104
|
+
const leftover = plan.todos.filter(item => item.status !== 'completed');
|
|
1105
|
+
const lines = leftover.map(item => `- [${item.status}] ${item.content}`);
|
|
1106
|
+
return [
|
|
1107
|
+
'本轮结束时计划条还有未完成待办。请立刻再调用一次 todo_write,把已经做完的标成 completed,还没做的留 pending。不要开新任务。',
|
|
1108
|
+
...lines,
|
|
1109
|
+
].join('\n');
|
|
1110
|
+
}
|
|
1089
1111
|
/** Category for jump / search. Assistant replies are not collapsible cards. */
|
|
1090
1112
|
export function cardCategoryOf(row) {
|
|
1091
1113
|
if (row.kind === 'reasoning' || row.kind === 'streaming-reasoning')
|
|
@@ -1187,6 +1209,10 @@ export function matchTranscriptRows(rows, raw) {
|
|
|
1187
1209
|
export function planDockNote(plan) {
|
|
1188
1210
|
const running = plan.todos.some(item => item.status === 'in_progress');
|
|
1189
1211
|
const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
|
|
1212
|
+
const leftover = plan.todos.filter(item => item.status !== 'completed').length;
|
|
1213
|
+
if (plan.turnLeftOpen === true && leftover > 0) {
|
|
1214
|
+
return `本轮未收尾:还剩 ${leftover} 项待办(会话日志未改)。`;
|
|
1215
|
+
}
|
|
1190
1216
|
if (plan.pending)
|
|
1191
1217
|
return '模式切换将在下一步生效。';
|
|
1192
1218
|
if (plan.active)
|
|
@@ -1816,6 +1842,8 @@ export class SshTui {
|
|
|
1816
1842
|
searchHits = [];
|
|
1817
1843
|
searchIndex = -1;
|
|
1818
1844
|
searchQuery = '';
|
|
1845
|
+
planNudgePending = false;
|
|
1846
|
+
pendingReveal;
|
|
1819
1847
|
constructor(ctx, agent, config) {
|
|
1820
1848
|
this.ctx = ctx;
|
|
1821
1849
|
this.agent = agent;
|
|
@@ -2166,6 +2194,10 @@ export class SshTui {
|
|
|
2166
2194
|
if (existing !== undefined && planIsLive(existing)) {
|
|
2167
2195
|
Object.assign(existing, patch);
|
|
2168
2196
|
existing.archived = false;
|
|
2197
|
+
if (patch.todos !== undefined || patch.active !== undefined || patch.pending !== undefined) {
|
|
2198
|
+
existing.turnLeftOpen = false;
|
|
2199
|
+
this.planNudgePending = false;
|
|
2200
|
+
}
|
|
2169
2201
|
if (!planIsLive(existing)) {
|
|
2170
2202
|
existing.archived = true;
|
|
2171
2203
|
existing.expanded = false;
|
|
@@ -2196,6 +2228,27 @@ export class SshTui {
|
|
|
2196
2228
|
shouldDockPlan() {
|
|
2197
2229
|
return this.findLivePlanRow() !== undefined;
|
|
2198
2230
|
}
|
|
2231
|
+
/** One follow-up per leftover list; replay and cancelled turns stay quiet. */
|
|
2232
|
+
queuePlanCloseNudge(plan) {
|
|
2233
|
+
if (this.replaying || this.agentGone || this.planNudgePending)
|
|
2234
|
+
return;
|
|
2235
|
+
if (this.agent.status === 'running')
|
|
2236
|
+
return;
|
|
2237
|
+
this.planNudgePending = true;
|
|
2238
|
+
const text = planCloseNudgeText(plan);
|
|
2239
|
+
this.pushRow({ kind: 'system', text: '已请模型补一次待办状态(本轮只问一次)。' });
|
|
2240
|
+
const message = createUserMessage({
|
|
2241
|
+
content: [{ type: 'text', text }],
|
|
2242
|
+
source: { kind: 'user' },
|
|
2243
|
+
});
|
|
2244
|
+
try {
|
|
2245
|
+
this.agent.followup(message);
|
|
2246
|
+
}
|
|
2247
|
+
catch (error) {
|
|
2248
|
+
this.planNudgePending = false;
|
|
2249
|
+
this.pushRow({ kind: 'error', text: `补待办状态失败:${errorChain(error)}` });
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2199
2252
|
/** Compact web-style plan strip pinned above the input, not in the transcript. */
|
|
2200
2253
|
paintPlanDock(width, yieldBottom) {
|
|
2201
2254
|
const plan = this.findLivePlanRow();
|
|
@@ -2204,8 +2257,14 @@ export class SshTui {
|
|
|
2204
2257
|
const inner = Math.max(1, width - 2);
|
|
2205
2258
|
const running = plan.todos.some(item => item.status === 'in_progress');
|
|
2206
2259
|
const allDone = plan.todos.length > 0 && plan.todos.every(item => item.status === 'completed');
|
|
2207
|
-
const
|
|
2208
|
-
const
|
|
2260
|
+
const leftOpen = plan.turnLeftOpen === true && !allDone && !plan.pending;
|
|
2261
|
+
const spinner = (plan.pending || ((plan.active || running) && !leftOpen)) ? ` ${this.spinnerFrame()}` : '';
|
|
2262
|
+
const mode = plan.pending ? '切换中'
|
|
2263
|
+
: leftOpen ? '本轮未收尾'
|
|
2264
|
+
: plan.active ? '计划模式'
|
|
2265
|
+
: running ? '计划'
|
|
2266
|
+
: allDone ? '计划完成'
|
|
2267
|
+
: '计划';
|
|
2209
2268
|
const counts = todoProgressLabel(plan.todos);
|
|
2210
2269
|
const title = planTitleFromMarkdown(plan.planMarkdown ?? '');
|
|
2211
2270
|
const summary = title ?? (counts === '' ? '还没有任务' : counts);
|
|
@@ -2304,18 +2363,27 @@ export class SshTui {
|
|
|
2304
2363
|
this.focusedRow = allExpanded ? null : rows[rows.length - 1] ?? null;
|
|
2305
2364
|
this.markDirty();
|
|
2306
2365
|
}
|
|
2307
|
-
|
|
2366
|
+
highlightSearchLine(line) {
|
|
2367
|
+
if (line.includes('\x1b[7m'))
|
|
2368
|
+
return line;
|
|
2369
|
+
return this.color ? `\x1b[7m${line}\x1b[27m` : `» ${line}`;
|
|
2370
|
+
}
|
|
2371
|
+
revealRow(row) {
|
|
2308
2372
|
if (row === undefined)
|
|
2309
2373
|
return;
|
|
2310
|
-
if (row.kind
|
|
2311
|
-
|
|
2312
|
-
}
|
|
2313
|
-
else if ('expanded' in row) {
|
|
2374
|
+
if (row.kind !== 'assistant' && 'expanded' in row) {
|
|
2375
|
+
row.expanded = true;
|
|
2314
2376
|
this.focusedRow = row;
|
|
2315
2377
|
}
|
|
2316
|
-
|
|
2378
|
+
else {
|
|
2379
|
+
this.focusedRow = null;
|
|
2380
|
+
}
|
|
2381
|
+
this.pendingReveal = row;
|
|
2317
2382
|
this.markDirty();
|
|
2318
2383
|
}
|
|
2384
|
+
focusCard(row) {
|
|
2385
|
+
this.revealRow(row);
|
|
2386
|
+
}
|
|
2319
2387
|
/** Jump to the newest card in a category (thinking / plan / subagent / reply). */
|
|
2320
2388
|
jumpToCategory(category) {
|
|
2321
2389
|
if (category === 'plan') {
|
|
@@ -2323,6 +2391,7 @@ export class SshTui {
|
|
|
2323
2391
|
if (live !== undefined) {
|
|
2324
2392
|
this.focusCard(live);
|
|
2325
2393
|
this.pushRow({ kind: 'system', text: `已跳到${CARD_CATEGORY_LABEL[category]}(底栏计划条)。` });
|
|
2394
|
+
this.revealRow(live);
|
|
2326
2395
|
return;
|
|
2327
2396
|
}
|
|
2328
2397
|
}
|
|
@@ -2332,10 +2401,8 @@ export class SshTui {
|
|
|
2332
2401
|
this.markDirty();
|
|
2333
2402
|
return;
|
|
2334
2403
|
}
|
|
2335
|
-
if ('expanded' in target)
|
|
2336
|
-
target.expanded = true;
|
|
2337
|
-
this.focusCard(target);
|
|
2338
2404
|
this.pushRow({ kind: 'system', text: `已跳到最新${CARD_CATEGORY_LABEL[category]}。` });
|
|
2405
|
+
this.revealRow(target);
|
|
2339
2406
|
}
|
|
2340
2407
|
applySearchHits(query, hits) {
|
|
2341
2408
|
this.searchQuery = query;
|
|
@@ -2348,14 +2415,12 @@ export class SshTui {
|
|
|
2348
2415
|
}
|
|
2349
2416
|
this.searchIndex = hits.length - 1;
|
|
2350
2417
|
const hit = hits[this.searchIndex];
|
|
2351
|
-
if (hit !== undefined && 'expanded' in hit)
|
|
2352
|
-
hit.expanded = true;
|
|
2353
|
-
this.focusCard(hit);
|
|
2354
2418
|
const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
|
|
2355
2419
|
this.pushRow({
|
|
2356
2420
|
kind: 'system',
|
|
2357
2421
|
text: `找到 ${hits.length} 条${query === '' ? '' : `「${query}」`} · 第 ${hits.length}/${hits.length} 条(${where})。Ctrl+G / Alt+N 下一条,Alt+P 上一条。`,
|
|
2358
2422
|
});
|
|
2423
|
+
this.revealRow(hit);
|
|
2359
2424
|
}
|
|
2360
2425
|
runFindCommand(arg) {
|
|
2361
2426
|
const parsed = parseFindQuery(arg);
|
|
@@ -2372,14 +2437,12 @@ export class SshTui {
|
|
|
2372
2437
|
const count = this.searchHits.length;
|
|
2373
2438
|
this.searchIndex = (this.searchIndex + delta + count) % count;
|
|
2374
2439
|
const hit = this.searchHits[this.searchIndex];
|
|
2375
|
-
if (hit !== undefined && 'expanded' in hit)
|
|
2376
|
-
hit.expanded = true;
|
|
2377
|
-
this.focusCard(hit);
|
|
2378
2440
|
const where = hit === undefined ? '' : CARD_CATEGORY_LABEL[cardCategoryOf(hit) ?? 'reply'];
|
|
2379
2441
|
this.pushRow({
|
|
2380
2442
|
kind: 'system',
|
|
2381
2443
|
text: `搜索「${this.searchQuery}」· 第 ${this.searchIndex + 1}/${count} 条(${where})。`,
|
|
2382
2444
|
});
|
|
2445
|
+
this.revealRow(hit);
|
|
2383
2446
|
}
|
|
2384
2447
|
paint = () => {
|
|
2385
2448
|
if (this.exiting)
|
|
@@ -2388,19 +2451,21 @@ export class SshTui {
|
|
|
2388
2451
|
const height = Math.max(6, process.stdout.rows || 24);
|
|
2389
2452
|
const display = [];
|
|
2390
2453
|
const displayRefs = [];
|
|
2454
|
+
const searchHit = this.searchHits[this.searchIndex];
|
|
2391
2455
|
const addDisplay = (line, ref) => {
|
|
2392
|
-
|
|
2456
|
+
const hit = ref !== undefined && ref === searchHit;
|
|
2457
|
+
display.push(hit ? this.highlightSearchLine(line) : line);
|
|
2393
2458
|
displayRefs.push(ref);
|
|
2394
2459
|
};
|
|
2395
|
-
const pushRow = (kind, text) => {
|
|
2460
|
+
const pushRow = (kind, text, ref) => {
|
|
2396
2461
|
if (kind === 'assistant') {
|
|
2397
2462
|
for (const line of renderMarkdownLines(text, width, this.color)) {
|
|
2398
|
-
addDisplay(line);
|
|
2463
|
+
addDisplay(line, ref);
|
|
2399
2464
|
}
|
|
2400
2465
|
return;
|
|
2401
2466
|
}
|
|
2402
2467
|
for (const line of wrap(text, width)) {
|
|
2403
|
-
addDisplay(this.styleLine(kind, line));
|
|
2468
|
+
addDisplay(this.styleLine(kind, line), ref);
|
|
2404
2469
|
}
|
|
2405
2470
|
};
|
|
2406
2471
|
for (const row of this.rows) {
|
|
@@ -2426,7 +2491,7 @@ export class SshTui {
|
|
|
2426
2491
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, row);
|
|
2427
2492
|
if (row.expanded) {
|
|
2428
2493
|
for (const wrapped of wrap(row.text, width)) {
|
|
2429
|
-
addDisplay(this.styleLine('reasoning', wrapped));
|
|
2494
|
+
addDisplay(this.styleLine('reasoning', wrapped), row);
|
|
2430
2495
|
}
|
|
2431
2496
|
}
|
|
2432
2497
|
continue;
|
|
@@ -2474,7 +2539,7 @@ export class SshTui {
|
|
|
2474
2539
|
const fillRow = line.kind === 'diff-add' || line.kind === 'diff-del';
|
|
2475
2540
|
for (const wrapped of wrap(line.text, inner)) {
|
|
2476
2541
|
const body = fillRow ? padToWidth(` ${wrapped}`, width) : ` ${wrapped}`;
|
|
2477
|
-
addDisplay(this.styleLine(line.kind, body));
|
|
2542
|
+
addDisplay(this.styleLine(line.kind, body), row);
|
|
2478
2543
|
}
|
|
2479
2544
|
}
|
|
2480
2545
|
continue;
|
|
@@ -2496,12 +2561,12 @@ export class SshTui {
|
|
|
2496
2561
|
const header = `● ${subagentHeaderText(row)}${spinner}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
2497
2562
|
this.paintCollapsibleHeader(addDisplay, row, 'tool', header, width, styleHeader);
|
|
2498
2563
|
if (row.expanded) {
|
|
2499
|
-
addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`));
|
|
2564
|
+
addDisplay(this.styleLine('tool-result', ` 会话 ${row.sessionId} · ${row.provider}${row.local ? '' : ' · 外部进程'}`), row);
|
|
2500
2565
|
if (row.stopReason !== undefined) {
|
|
2501
|
-
addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`));
|
|
2566
|
+
addDisplay(this.styleLine('tool-result', ` 结束原因:${row.stopReason}`), row);
|
|
2502
2567
|
}
|
|
2503
2568
|
if (row.logs.length === 0) {
|
|
2504
|
-
addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'));
|
|
2569
|
+
addDisplay(this.styleLine('tool-result', running ? ' 等待子代理输出…' : ' 没有可见输出'), row);
|
|
2505
2570
|
}
|
|
2506
2571
|
else {
|
|
2507
2572
|
for (const entry of row.logs) {
|
|
@@ -2511,7 +2576,7 @@ export class SshTui {
|
|
|
2511
2576
|
? 'error'
|
|
2512
2577
|
: 'tool-result';
|
|
2513
2578
|
for (const wrapped of wrap(entry.text, Math.max(1, width - 2))) {
|
|
2514
|
-
addDisplay(this.styleLine(kind, ` ${wrapped}`));
|
|
2579
|
+
addDisplay(this.styleLine(kind, ` ${wrapped}`), row);
|
|
2515
2580
|
}
|
|
2516
2581
|
}
|
|
2517
2582
|
}
|
|
@@ -2527,16 +2592,16 @@ export class SshTui {
|
|
|
2527
2592
|
const header = `计划 · ${summary}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
2528
2593
|
this.paintCollapsibleHeader(addDisplay, row, 'plan-dock', header, width);
|
|
2529
2594
|
if (row.expanded) {
|
|
2530
|
-
addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`));
|
|
2595
|
+
addDisplay(this.styleLine('plan-dock', ` ${planDockNote({ ...row, active: false, pending: false })}`), row);
|
|
2531
2596
|
if (row.planMarkdown !== undefined && row.planMarkdown !== '') {
|
|
2532
2597
|
for (const line of renderMarkdownLines(row.planMarkdown, Math.max(1, width - 2), this.color).slice(0, 8)) {
|
|
2533
|
-
addDisplay(` ${line}
|
|
2598
|
+
addDisplay(` ${line}`, row);
|
|
2534
2599
|
}
|
|
2535
2600
|
}
|
|
2536
2601
|
for (const item of row.todos) {
|
|
2537
2602
|
const mark = TODO_STATUS_MARK[item.status];
|
|
2538
2603
|
for (const wrapped of wrap(`${mark} ${item.content}`, Math.max(1, width - 2))) {
|
|
2539
|
-
addDisplay(this.styleLine(todoItemKind(item.status), ` ${wrapped}`));
|
|
2604
|
+
addDisplay(this.styleLine(todoItemKind(item.status), ` ${wrapped}`), row);
|
|
2540
2605
|
}
|
|
2541
2606
|
}
|
|
2542
2607
|
}
|
|
@@ -2551,25 +2616,25 @@ export class SshTui {
|
|
|
2551
2616
|
this.paintCollapsibleHeader(addDisplay, row, waiting ? 'tool' : 'system', header, width);
|
|
2552
2617
|
if (row.expanded) {
|
|
2553
2618
|
if (row.header !== undefined)
|
|
2554
|
-
addDisplay(this.styleLine('system', ` ${row.header}`));
|
|
2619
|
+
addDisplay(this.styleLine('system', ` ${row.header}`), row);
|
|
2555
2620
|
for (const wrapped of wrap(row.title, Math.max(1, width - 2))) {
|
|
2556
|
-
addDisplay(this.styleLine('assistant', ` ${wrapped}`));
|
|
2621
|
+
addDisplay(this.styleLine('assistant', ` ${wrapped}`), row);
|
|
2557
2622
|
}
|
|
2558
2623
|
if (row.detail !== undefined && row.detail !== '') {
|
|
2559
2624
|
if (row.intent === 'plan-review') {
|
|
2560
2625
|
for (const line of renderMarkdownLines(row.detail, Math.max(1, width - 2), this.color)) {
|
|
2561
|
-
addDisplay(` ${line}
|
|
2626
|
+
addDisplay(` ${line}`, row);
|
|
2562
2627
|
}
|
|
2563
2628
|
}
|
|
2564
2629
|
else {
|
|
2565
2630
|
for (const wrapped of wrap(row.detail, Math.max(1, width - 2))) {
|
|
2566
|
-
addDisplay(this.styleLine('tool-result', ` ${wrapped}`));
|
|
2631
|
+
addDisplay(this.styleLine('tool-result', ` ${wrapped}`), row);
|
|
2567
2632
|
}
|
|
2568
2633
|
}
|
|
2569
2634
|
}
|
|
2570
2635
|
addDisplay(this.styleLine('system', waiting
|
|
2571
2636
|
? ' 用下方对话框选择,数字/字母选中,Enter 提交,Esc 取消。'
|
|
2572
|
-
: ` ${row.summary}`));
|
|
2637
|
+
: ` ${row.summary}`), row);
|
|
2573
2638
|
}
|
|
2574
2639
|
continue;
|
|
2575
2640
|
}
|
|
@@ -2584,16 +2649,16 @@ export class SshTui {
|
|
|
2584
2649
|
const header = `● 目标${spinner} · ${phase} · ${row.objective}${row.expanded ? '' : ' · Enter 展开'}`;
|
|
2585
2650
|
this.paintCollapsibleHeader(addDisplay, row, live ? 'tool' : 'system', header, width);
|
|
2586
2651
|
if (row.expanded) {
|
|
2587
|
-
addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'));
|
|
2652
|
+
addDisplay(this.styleLine('system', ' 用 /goal 查看、暂停、恢复或清除当前目标。'), row);
|
|
2588
2653
|
if (row.blockedReason !== undefined) {
|
|
2589
2654
|
for (const wrapped of wrap(row.blockedReason, Math.max(1, width - 2))) {
|
|
2590
|
-
addDisplay(this.styleLine('error', ` ${wrapped}`));
|
|
2655
|
+
addDisplay(this.styleLine('error', ` ${wrapped}`), row);
|
|
2591
2656
|
}
|
|
2592
2657
|
}
|
|
2593
2658
|
}
|
|
2594
2659
|
continue;
|
|
2595
2660
|
}
|
|
2596
|
-
pushRow(row.kind, row.text);
|
|
2661
|
+
pushRow(row.kind, row.text, row);
|
|
2597
2662
|
}
|
|
2598
2663
|
if (this.streaming !== undefined) {
|
|
2599
2664
|
if (this.showReasoning && this.streaming.reasoning !== '') {
|
|
@@ -2611,7 +2676,7 @@ export class SshTui {
|
|
|
2611
2676
|
addDisplay(focused && this.color ? `\x1b[7m${styled}\x1b[27m` : styled, block);
|
|
2612
2677
|
if (block.expanded) {
|
|
2613
2678
|
for (const wrapped of wrap(this.streaming.reasoning, width)) {
|
|
2614
|
-
addDisplay(this.styleLine('reasoning', wrapped));
|
|
2679
|
+
addDisplay(this.styleLine('reasoning', wrapped), block);
|
|
2615
2680
|
}
|
|
2616
2681
|
}
|
|
2617
2682
|
}
|
|
@@ -2805,6 +2870,18 @@ export class SshTui {
|
|
|
2805
2870
|
const reserved = RESERVED_BOTTOM_LINES + (inputRows - 1) + headerLines.length + suggestionLines.length + planDockLines.length + 1;
|
|
2806
2871
|
const available = Math.max(1, height - reserved - dialogLines.length);
|
|
2807
2872
|
const maxOffset = Math.max(0, display.length - available);
|
|
2873
|
+
const reveal = this.pendingReveal;
|
|
2874
|
+
if (reveal !== undefined) {
|
|
2875
|
+
this.pendingReveal = undefined;
|
|
2876
|
+
const first = displayRefs.findIndex(ref => ref === reveal);
|
|
2877
|
+
if (first !== -1) {
|
|
2878
|
+
let last = first;
|
|
2879
|
+
while (last + 1 < displayRefs.length && displayRefs[last + 1] === reveal)
|
|
2880
|
+
last += 1;
|
|
2881
|
+
const span = last - first + 1;
|
|
2882
|
+
this.scrollOffset = Math.max(0, display.length - available - first);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2808
2885
|
if (this.scrollOffset > maxOffset)
|
|
2809
2886
|
this.scrollOffset = maxOffset;
|
|
2810
2887
|
const start = Math.max(0, display.length - available - this.scrollOffset);
|
|
@@ -2818,7 +2895,7 @@ export class SshTui {
|
|
|
2818
2895
|
this.clickableRows.clear();
|
|
2819
2896
|
for (let index = 0; index < visibleRefs.length; index++) {
|
|
2820
2897
|
const ref = visibleRefs[index];
|
|
2821
|
-
if (ref !== undefined)
|
|
2898
|
+
if (ref !== undefined && 'expanded' in ref)
|
|
2822
2899
|
this.clickableRows.set(headerLines.length + index + 1, ref);
|
|
2823
2900
|
}
|
|
2824
2901
|
const dockPlan = this.findLivePlanRow();
|
|
@@ -2852,6 +2929,9 @@ export class SshTui {
|
|
|
2852
2929
|
? ' · 计划待审'
|
|
2853
2930
|
: ' · 等待用户回答';
|
|
2854
2931
|
}
|
|
2932
|
+
else if (livePlan?.turnLeftOpen === true) {
|
|
2933
|
+
statusText += ' · 本轮未收尾';
|
|
2934
|
+
}
|
|
2855
2935
|
else if (livePlan?.active === true || livePlan?.pending === true) {
|
|
2856
2936
|
statusText += livePlan.pending ? ' · 计划模式切换中' : ' · 计划模式';
|
|
2857
2937
|
}
|
|
@@ -3355,6 +3435,17 @@ export class SshTui {
|
|
|
3355
3435
|
if (reason.kind === 'error') {
|
|
3356
3436
|
this.pushRow({ kind: 'error', text: `Turn ${event.data.turn} failed: ${reason.error.message}` });
|
|
3357
3437
|
}
|
|
3438
|
+
const livePlan = this.findLivePlanRow();
|
|
3439
|
+
if (livePlan !== undefined && reason.kind === 'completed') {
|
|
3440
|
+
applyTurnEndToPlan(livePlan);
|
|
3441
|
+
if (livePlan.turnLeftOpen === true) {
|
|
3442
|
+
this.pushRow({
|
|
3443
|
+
kind: 'system',
|
|
3444
|
+
text: planDockNote(livePlan),
|
|
3445
|
+
});
|
|
3446
|
+
this.queuePlanCloseNudge(livePlan);
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3358
3449
|
this.markDirty();
|
|
3359
3450
|
break;
|
|
3360
3451
|
}
|
|
@@ -5553,6 +5644,8 @@ export class SshTui {
|
|
|
5553
5644
|
this.searchHits = [];
|
|
5554
5645
|
this.searchIndex = -1;
|
|
5555
5646
|
this.searchQuery = '';
|
|
5647
|
+
this.planNudgePending = false;
|
|
5648
|
+
this.pendingReveal = undefined;
|
|
5556
5649
|
this.pushRow({ kind: 'system', text: '转录已清空。子代理、计划与提问卡片会在新事件到达时重新出现。' });
|
|
5557
5650
|
break;
|
|
5558
5651
|
case 'status':
|