pi-web-ui 0.58.0 → 0.59.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.md +19 -8
- package/README.zh-CN.md +19 -8
- package/dist/server/agent-service.js +15 -4
- package/dist/server/dsh/dsh-agent-service.js +2806 -0
- package/dist/server/dsh/dsh-client.js +518 -0
- package/dist/server/dsh/dsh-serialize.js +253 -0
- package/dist/server/dsh/dsh-sessions.js +207 -0
- package/dist/server/dsh/runtime/cordis.yml +1 -0
- package/dist/server/dsh/runtime/goal-rpc.mjs +662 -0
- package/dist/server/dsh/runtime/launcher.mjs +174 -0
- package/dist/server/dsh/runtime/override.patch.yml +71 -0
- package/dist/server/dsh/runtime/runtime-root.mjs +90 -0
- package/dist/server/files-service.js +1 -1
- package/dist/server/index.js +21 -4
- package/dist/server/terminals.js +275 -43
- package/dist/server/webui-context.js +0 -2
- package/package.json +5 -2
- package/web/dist/assets/TerminalPanel-_VfntAyG.js +2 -0
- package/web/dist/assets/index-DRsP4BO2.css +10 -0
- package/web/dist/assets/index-MeifpZzi.js +321 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/TerminalPanel-BWjl4gpk.js +0 -2
- package/web/dist/assets/index-2pBtToy6.js +0 -321
- package/web/dist/assets/index-D9m2sxDj.css +0 -10
package/dist/server/terminals.js
CHANGED
|
@@ -124,12 +124,21 @@ export function terminalIdleNotifyMs() {
|
|
|
124
124
|
const raw = Number(process.env.PI_WEB_TERMINAL_IDLE_MS);
|
|
125
125
|
return Number.isFinite(raw) && raw >= 0 ? raw : 15_000;
|
|
126
126
|
}
|
|
127
|
+
/** 静默反馈附带的最新输出行数(PI_WEB_TERMINAL_IDLE_LINES 覆盖;默认 10)。
|
|
128
|
+
* 每次调用时读取(测试可注入)。 */
|
|
129
|
+
export function terminalIdleNotifyLines() {
|
|
130
|
+
const raw = Number(process.env.PI_WEB_TERMINAL_IDLE_LINES);
|
|
131
|
+
const n = Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 10;
|
|
132
|
+
return Math.max(1, Math.min(n, 500));
|
|
133
|
+
}
|
|
127
134
|
// ---------------------------------------------------------------------------
|
|
128
135
|
// 终端接管 bash(terminal-backed bash tool)
|
|
129
136
|
// ---------------------------------------------------------------------------
|
|
130
137
|
/** 哨兵行:命令执行完后由 shell 打印,携带真实退出码。正则只匹配数字,
|
|
131
138
|
* 因此不会误匹配回显里的 printf 格式串 `[pi-exit:%s]`。 */
|
|
132
139
|
const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
|
|
140
|
+
/** 一次性 bash 终端的 id 计数器(全局单调递增,跨会话不重号)。 */
|
|
141
|
+
let bashCommandSeq = 0;
|
|
133
142
|
/**
|
|
134
143
|
* 把任意命令(含多行脚本)构造成「一行」交互 shell 命令:执行 + 捕获退出码。
|
|
135
144
|
*
|
|
@@ -137,7 +146,7 @@ const BASH_SENTINEL_RE = /\[pi-exit:(\d+)\]/g;
|
|
|
137
146
|
* 后续哨兵;也避开交互 shell 的 bracketed-paste 对多行输入的特殊处理。
|
|
138
147
|
* 多行脚本用 `$'...'` ANSI-C 引号转义后交给 eval(bash/zsh/busybox ash 都支持)。
|
|
139
148
|
*/
|
|
140
|
-
export function buildTerminalBashLine(command) {
|
|
149
|
+
export function buildTerminalBashLine(command, tailFile) {
|
|
141
150
|
const trimmed = command.replace(/\s+$/, "");
|
|
142
151
|
let body = trimmed;
|
|
143
152
|
if (trimmed.includes("\n")) {
|
|
@@ -148,7 +157,156 @@ export function buildTerminalBashLine(command) {
|
|
|
148
157
|
.replace(/\n/g, "\\n")
|
|
149
158
|
.replace(/\t/g, "\\t")}'`;
|
|
150
159
|
}
|
|
151
|
-
|
|
160
|
+
// 退出码取【第一个】命令(真正干活的那个)而非管道末尾命令:`head`/`grep`/`tail`
|
|
161
|
+
// 在管道末尾会把退出码吞成自己的(head 恒 0、grep 无命恒 1)。`${PIPESTATUS:-$?}`
|
|
162
|
+
// 在 bash 里取 PIPESTATUS[0](首命令),busybox ash/dash 无 PIPESTATUS 时退化为 `$?`
|
|
163
|
+
// (不崩溃、只回到末尾命令)。
|
|
164
|
+
// SIGPIPE(141) 归 0:`cmd | head` 里 head 读到 N 行就主动关管道,把首命令“截断杀掉”
|
|
165
|
+
// 留下的 141 不是真失败,而是“按要求截断=成功”。只有恰好 141 才转 0,真失败(1/2/127…)照报。
|
|
166
|
+
// tailFile:`cmd > log 2>&1 | tail -N` —— 拆掉 tail 后 stdout 进文件、终端为空;
|
|
167
|
+
// 在哨兵前补一个 `tail -N log` 让模型看到日志尾部,退出码仍是底层命令的。
|
|
168
|
+
const rcGuard = `__pi_rc=\${PIPESTATUS:-\$?}; [ "$__pi_rc" -eq 141 ] && __pi_rc=0`;
|
|
169
|
+
const tailPart = tailFile
|
|
170
|
+
? `; tail -n ${Math.max(1, Math.floor(tailFile.lines))} -- '${tailFile.file.replace(/'/g, `'\\''`)}'`
|
|
171
|
+
: "";
|
|
172
|
+
return `${body}; ${rcGuard}${tailPart}; printf '\\n[pi-exit:%s]\\n' "$__pi_rc"`;
|
|
173
|
+
}
|
|
174
|
+
/** 顶层(引号/反引号/转义外)按 `|` 拆分的管道元素。 */
|
|
175
|
+
export function splitTopLevelPipes(cmd) {
|
|
176
|
+
const parts = [];
|
|
177
|
+
let cur = "";
|
|
178
|
+
let quote = null;
|
|
179
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
180
|
+
const ch = cmd[i];
|
|
181
|
+
if (quote) {
|
|
182
|
+
cur += ch;
|
|
183
|
+
if (ch === "\\" && quote !== "'" && quote !== "`" && i + 1 < cmd.length)
|
|
184
|
+
cur += cmd[++i];
|
|
185
|
+
else if (ch === quote)
|
|
186
|
+
quote = null;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
190
|
+
quote = ch;
|
|
191
|
+
cur += ch;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (ch === "|") {
|
|
195
|
+
parts.push(cur.trimEnd());
|
|
196
|
+
cur = "";
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
cur += ch;
|
|
200
|
+
}
|
|
201
|
+
parts.push(cur.trimEnd());
|
|
202
|
+
return parts;
|
|
203
|
+
}
|
|
204
|
+
/** 从 tail 参数里解析行数:`-n 15` / `-n15` / `-15` / `--lines=15` / `--lines 15`。 */
|
|
205
|
+
function parseTailLines(rest) {
|
|
206
|
+
const m = rest.match(/--lines(?:=|\s+)(\d+)|-n\s*(\d+)|(?:^|\s)-(\d+)/);
|
|
207
|
+
if (!m)
|
|
208
|
+
return null;
|
|
209
|
+
return Number(m[1] ?? m[2] ?? m[3]);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* 识别命令**末尾**用来「限制输出量」的管道:`| tail [-n N|-N]`(不含 -f)、`| less`、
|
|
213
|
+
* `| more`、`| cat`。这类管道会 ①缓冲输出(可见终端全程哑火、无法感知实时进度)②把
|
|
214
|
+
* 退出码变成**管道最后一个命令**(tail 恒 0)——掩盖底层真实失败 ③需 stdin 的管道
|
|
215
|
+
* (尤其长驻命令)出错后可能一直挂到超时。
|
|
216
|
+
*
|
|
217
|
+
* 识别后由调用方拆掉它:底层命令直跑(实时可见 + 真实退出码),只在返回给模型时取
|
|
218
|
+
* 末尾 N 行(tail)或全部输出。
|
|
219
|
+
*
|
|
220
|
+
* - **只拆末尾单个纯“限输出/透传”**;`| grep`/`| head`/`| awk`/`| sed`/`| sort`/`| uniq`
|
|
221
|
+
* (真过滤/变换,拆掉会丢语义或崩出海量未过滤输出)与 `| tee`(写文件副作用)都**不拆**,
|
|
222
|
+
* 交给 prompt 引导模型不用。`| head` 也因 `yes | head -5` 靠 SIGPIPE 早停而**不拆**。
|
|
223
|
+
* - `tail -f` / `tail --follow`(长驻观察)也不拆。
|
|
224
|
+
*/
|
|
225
|
+
export function detectTrailingLimiter(command) {
|
|
226
|
+
const parts = splitTopLevelPipes(command);
|
|
227
|
+
if (parts.length < 2)
|
|
228
|
+
return null;
|
|
229
|
+
// 管道分隔处可能在 `|` 后留前导空白(`| tail`),trim 掉再匹配。
|
|
230
|
+
const last = parts[parts.length - 1].trim();
|
|
231
|
+
const m = last.match(/^(tail|less|more|cat)\b(.*)$/i);
|
|
232
|
+
if (!m)
|
|
233
|
+
return null;
|
|
234
|
+
const kind = m[1].toLowerCase();
|
|
235
|
+
const rest = m[2].trim();
|
|
236
|
+
if (kind === "tail") {
|
|
237
|
+
// 长驻观察:tail -f / tail --follow —— 不拆。
|
|
238
|
+
if (/(?:^|\s)(?:-f|--follow(?:=|\b))/.test(rest))
|
|
239
|
+
return null;
|
|
240
|
+
const lines = parseTailLines(rest) ?? 10; // 裸 `| tail` 默认 10 行
|
|
241
|
+
return { base: parts.slice(0, -1).map((s) => s.trim()).join(" | "), kind, lines, segment: last };
|
|
242
|
+
}
|
|
243
|
+
// less / more / cat:纯透传,拆掉只为修正退出码 + 避免交互分页器在 PTY 里挂起。
|
|
244
|
+
return { base: parts.slice(0, -1).map((s) => s.trim()).join(" | "), kind, lines: null, segment: last };
|
|
245
|
+
}
|
|
246
|
+
/** 识别命令里把 stdout 重定向到文件的 `> file` / `>> file`(忽略 2>N / >&N / /dev/null)。
|
|
247
|
+
* 用于 `cmd > log 2>&1 | tail -N`:拆掉 tail 后输出进文件,终端为空——补一个 tail 文件
|
|
248
|
+
* 让模型能看到日志尾部与真实退出码。返回文件路径;复杂/带引号目标暂不处理。 */
|
|
249
|
+
export function detectStdoutRedirect(command) {
|
|
250
|
+
const all = [...command.matchAll(/(?:^|[\s;&|])(>>|>)\s*([^\s;|&]+)/g)];
|
|
251
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
252
|
+
const file = all[i][2];
|
|
253
|
+
if (/^(?:&[0-9]+|[0-9]+)$/.test(file) || file === "/dev/null" || /["'`]/.test(file))
|
|
254
|
+
return null;
|
|
255
|
+
return { file };
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
/** 对终端输出缓冲做快照查询:head / tail / search(+context)。返回带 1-based 行号的
|
|
260
|
+
* 人类可读文本,便于 AI 定位后继续按行查看/搜索。纯函数(单测可覆盖)。 */
|
|
261
|
+
export function queryTerminalOutput(output, q, running = false, exitCode = null) {
|
|
262
|
+
const lines = stripAnsi(output)
|
|
263
|
+
.replace(/\r(?!\n)/g, "")
|
|
264
|
+
.split("\n")
|
|
265
|
+
.filter((l) => {
|
|
266
|
+
const t = l.trim();
|
|
267
|
+
return !/^\[pi-exit:\d+\]$/.test(t) && !/^\[进程已退出/.test(t);
|
|
268
|
+
});
|
|
269
|
+
while (lines.length > 0 && !lines[lines.length - 1].trim())
|
|
270
|
+
lines.pop();
|
|
271
|
+
const numbered = (arr, start) => arr.map((l, i) => `${start + i + 1}: ${l}`).join("\n");
|
|
272
|
+
if (q.search) {
|
|
273
|
+
const ctx = Math.max(0, q.context ?? 3);
|
|
274
|
+
const needle = q.search.toLowerCase();
|
|
275
|
+
const matches = [];
|
|
276
|
+
const out = [];
|
|
277
|
+
let i = 0;
|
|
278
|
+
while (i < lines.length) {
|
|
279
|
+
if (lines[i].toLowerCase().includes(needle)) {
|
|
280
|
+
matches.push({ line: i + 1, text: lines[i] });
|
|
281
|
+
const s = Math.max(0, i - ctx);
|
|
282
|
+
const e = Math.min(lines.length - 1, i + ctx);
|
|
283
|
+
out.push(`── 匹配行 ${i + 1} ──`);
|
|
284
|
+
for (let j = s; j <= e; j++)
|
|
285
|
+
out.push(`${j + 1}: ${lines[j]}`);
|
|
286
|
+
out.push("");
|
|
287
|
+
i = e + 1;
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
i++;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
text: out.join("\n").trim() || "(无匹配)",
|
|
295
|
+
running,
|
|
296
|
+
exitCode,
|
|
297
|
+
matches: matches.length ? matches : undefined,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
if (q.head !== undefined && q.head >= 0) {
|
|
301
|
+
const arr = lines.slice(0, q.head);
|
|
302
|
+
return { text: numbered(arr, 0), running, exitCode };
|
|
303
|
+
}
|
|
304
|
+
if (q.tail !== undefined && q.tail >= 0) {
|
|
305
|
+
const n = Math.max(1, q.tail);
|
|
306
|
+
const arr = lines.slice(-n);
|
|
307
|
+
return { text: numbered(arr, lines.length - arr.length), running, exitCode };
|
|
308
|
+
}
|
|
309
|
+
return { text: numbered(lines, 0), running, exitCode };
|
|
152
310
|
}
|
|
153
311
|
/** 去掉 ANSI 转义序列(OSC/CSI/其余 ESC 序列)与孤立 CR(进度条重绘),
|
|
154
312
|
* 让 PTY 回显变成 bash 工具风格的纯文本。 */
|
|
@@ -249,6 +407,10 @@ function shellEnv() {
|
|
|
249
407
|
const env = {
|
|
250
408
|
...process.env,
|
|
251
409
|
TERM: "xterm-256color",
|
|
410
|
+
// 禁用分页器:stdout 是 tty 时 git log / git diff / man / … 会开 less 并挂住等按键。
|
|
411
|
+
// agent 工具需要完整输出而非分页,故强制 cat 透传(防挂死)。
|
|
412
|
+
GIT_PAGER: process.env.GIT_PAGER || "cat",
|
|
413
|
+
PAGER: process.env.PAGER || "cat",
|
|
252
414
|
};
|
|
253
415
|
if (!env.LANG && !env.LC_ALL)
|
|
254
416
|
env.LANG = "en_US.UTF-8";
|
|
@@ -465,7 +627,7 @@ export class TerminalManager {
|
|
|
465
627
|
this.fail(id, "终端工作目录必须位于当前工作区内");
|
|
466
628
|
return null;
|
|
467
629
|
}
|
|
468
|
-
if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash)) {
|
|
630
|
+
if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.runLine)) {
|
|
469
631
|
this.maybeEmitTccHint(id);
|
|
470
632
|
this.emitList();
|
|
471
633
|
return this.info(this.terms.get(id));
|
|
@@ -544,7 +706,7 @@ export class TerminalManager {
|
|
|
544
706
|
this.input(id, command + "\r");
|
|
545
707
|
}
|
|
546
708
|
/** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
|
|
547
|
-
spawnShell(id, cwd, cols, rows, title, command, forceBash) {
|
|
709
|
+
spawnShell(id, cwd, cols, rows, title, command, forceBash, runLine) {
|
|
548
710
|
let abs = cwd;
|
|
549
711
|
if (!abs)
|
|
550
712
|
abs = homedir();
|
|
@@ -566,7 +728,12 @@ export class TerminalManager {
|
|
|
566
728
|
let pty;
|
|
567
729
|
try {
|
|
568
730
|
const { shell, args } = forceBash ? resolveBashShell() : resolveShell();
|
|
569
|
-
|
|
731
|
+
// runLine != null → 以 `bash -c <runLine>` 非交互启动:无命令回显、无提示符,
|
|
732
|
+
// 退出后缓冲即干净输出(供一次性 bash 终端的事后查询/搜索)。
|
|
733
|
+
const spawnArgs = runLine !== undefined
|
|
734
|
+
? ["-c", runLine]
|
|
735
|
+
: args;
|
|
736
|
+
pty = spawn(shell, spawnArgs, {
|
|
570
737
|
name: "xterm-256color",
|
|
571
738
|
cols: Math.max(2, Math.floor(cols) || 80),
|
|
572
739
|
rows: Math.max(2, Math.floor(rows) || 24),
|
|
@@ -657,7 +824,9 @@ export class TerminalManager {
|
|
|
657
824
|
return;
|
|
658
825
|
// 一次性:触发后解除武装,直到下次 agent 触碰。
|
|
659
826
|
entry.agentTouched = false;
|
|
660
|
-
|
|
827
|
+
// 附带最近 N 行输出(可配),便于 AI 直接看到终端当前状态。
|
|
828
|
+
const lastLines = this.query(entry.id, { tail: terminalIdleNotifyLines() })?.text ?? "";
|
|
829
|
+
this.onAgentIdle?.(entry.id, Date.now() - entry.lastActivityAt, entry.title, lastLines);
|
|
661
830
|
}, delay);
|
|
662
831
|
entry.idleTimer.unref?.();
|
|
663
832
|
}
|
|
@@ -797,6 +966,13 @@ export class TerminalManager {
|
|
|
797
966
|
const end = Math.min(start + Math.max(1, Math.floor(maxBytes) || 20_000), entry.outputOffset + entry.output.length);
|
|
798
967
|
return { data: entry.output.slice(start - entry.outputOffset, end - entry.outputOffset), cursor: end, running: !entry.exited, exitCode: entry.exitCode };
|
|
799
968
|
}
|
|
969
|
+
/** 对终端(含已退出的 history 项)的输出缓冲做快照查询:head/tail/search+context。 */
|
|
970
|
+
query(id, q) {
|
|
971
|
+
const entry = this.find(id);
|
|
972
|
+
if (!entry)
|
|
973
|
+
return null;
|
|
974
|
+
return queryTerminalOutput(entry.output, q, !entry.exited, entry.exitCode);
|
|
975
|
+
}
|
|
800
976
|
async waitForOutput(id, cursor, timeoutMs, signal) {
|
|
801
977
|
const current = this.read(id, cursor, 1);
|
|
802
978
|
if (!current || current.cursor > cursor || !current.running)
|
|
@@ -1096,12 +1272,11 @@ function cleanBashOutput(raw) {
|
|
|
1096
1272
|
* 工具做不到的。
|
|
1097
1273
|
*/
|
|
1098
1274
|
export function makeTerminalBashTool(terminals, opts) {
|
|
1099
|
-
const TERM_ID = "ai-bash";
|
|
1100
1275
|
return defineTool({
|
|
1101
1276
|
name: "bash",
|
|
1102
1277
|
label: "Run bash command",
|
|
1103
|
-
description: "Run a shell command and return its full output plus exit code. Commands
|
|
1104
|
-
promptSnippet: "run commands in
|
|
1278
|
+
description: "Run a shell command and return its full output plus exit code. Commands run in a VISIBLE terminal (its id is the first line of the result). By default the terminal is one-shot: a fresh terminal is torn down after the command so shell state is NOT shared between calls; its output stays queryable afterward via terminal_read (head/tail/search) by id. Pass persistent:true to reuse one terminal (id shown) and retain shell state (cd / venv / ssh). PURPOSEFUL DESIGN — use the tool's OWN parameters instead of shell pipes: return only the last N lines with `tail` (the tool keeps streaming live to the visible terminal) rather than `| tail`; and don't pipe through `| head | grep | less | more | cat | sort | awk` to trim/filter output. Those pipes buffer, hide live progress, mask the real exit code (the pipe's last command, not the command you cared about, decides the result) and make a long-running/failed command hang until timeout. If the command is a long-running server, watcher or interactive program, prefer the persistent terminal tools (terminal_create + terminal_read / terminal_input / terminal_key + terminal_wait) instead of bash. If a bash command stays silent for a while it keeps running in the background and you get an automatic notice when it finishes; use terminal_wait to re-block, or terminal_read on that id to observe/interact with it afterward.",
|
|
1279
|
+
promptSnippet: "run commands in a visible terminal (id returned; persistent:true retains shell state)",
|
|
1105
1280
|
parameters: Type.Object({
|
|
1106
1281
|
command: Type.String({ description: "The shell command to run" }),
|
|
1107
1282
|
timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
|
|
@@ -1110,51 +1285,87 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1110
1285
|
maximum: 5000,
|
|
1111
1286
|
description: "Only return the LAST N lines of output (like `| tail -N`). Use this for verbose commands instead of piping through tail — the command keeps streaming live to the visible terminal while you only get the tail back.",
|
|
1112
1287
|
})),
|
|
1288
|
+
persistent: Type.Optional(Type.Boolean({
|
|
1289
|
+
description: "Reuse one terminal across calls, retaining shell state (cd / venv / ssh sessions). Default false = one-shot terminal per call (state not shared, output left queryable by id).",
|
|
1290
|
+
})),
|
|
1113
1291
|
}),
|
|
1114
1292
|
execute: async (_id, p, signal) => {
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
// bash
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1293
|
+
// 一次性(默认):每次新鲜终端 id,命令跑完 shell 也 exit——输出进 history
|
|
1294
|
+
// 缓冲(可事后按 id 查询/搜索),但不跨调用保留 shell 状态。
|
|
1295
|
+
// 持久(persistent:true):复用固定 ai-bash,保留 cd/venv/ssh 状态。
|
|
1296
|
+
const persistent = p.persistent === true;
|
|
1297
|
+
let termId;
|
|
1298
|
+
if (persistent) {
|
|
1299
|
+
termId = "ai-bash";
|
|
1300
|
+
}
|
|
1301
|
+
else {
|
|
1302
|
+
do {
|
|
1303
|
+
termId = `bash-${++bashCommandSeq}`;
|
|
1304
|
+
} while (terminals.has(termId));
|
|
1305
|
+
}
|
|
1306
|
+
// 拆掉模型常写的尾部输出限制/过滤管道(`| tail -N` / `| less` / `| more` / `| cat` / `| grep`):
|
|
1307
|
+
// 这类管道在持久终端里 ①缓冲输出——可见终端全程哑火、无法感知实时进度;
|
|
1308
|
+
// ②吞掉真实退出码——退出码取管道最后一个命令(tail/grep 恒 0/1),掩盖真实失败;
|
|
1309
|
+
// ③需 stdin 的管道(尤其长驻命令)出错后可能一直挂到超时。拆掉后底层命令直跑
|
|
1310
|
+
// (实时可见 + 真实退出码),只在返回给模型时取末尾 N 行或全部。
|
|
1311
|
+
const limiter = detectTrailingLimiter(p.command);
|
|
1312
|
+
const stripped = limiter !== null;
|
|
1313
|
+
const effectiveTail = p.tail ?? (limiter?.kind === "tail" ? limiter.lines : undefined);
|
|
1314
|
+
const runCommand = stripped ? limiter.base : p.command;
|
|
1315
|
+
// `cmd > log 2>&1 | tail -N`:拆掉 tail 后 stdout 进文件、终端为空 → 补一个
|
|
1316
|
+
// tail 文件让模型看到日志尾部与真实退出码(否则输出为空)。
|
|
1317
|
+
const redirect = stripped && limiter.kind === "tail" ? detectStdoutRedirect(runCommand) : null;
|
|
1318
|
+
const tailFile = redirect
|
|
1319
|
+
? { file: redirect.file, lines: limiter.lines ?? 10 }
|
|
1320
|
+
: undefined;
|
|
1321
|
+
const limiterNote = stripped
|
|
1322
|
+
? `\n[注:检测到你带了 ${limiter.segment}——这类管道在持久终端里会缓冲输出、吞掉真实退出码,命令出错时还可能一直挂到超时;已让底层命令直跑。${limiter.kind === "tail" ? (tailFile ? `输出已重定向到 ${redirect.file},改为 tail 该文件返回末尾 ${limiter.lines} 行。` : `仍只返回末尾 ${limiter.lines} 行。`) : "本次返回全部输出。"} 后续直接用 bash(command, tail=N) 参数限输出,别再套管道。]`
|
|
1323
|
+
: "";
|
|
1324
|
+
const bashLine = buildTerminalBashLine(runCommand, tailFile);
|
|
1325
|
+
const idLine = `[终端: ${termId}]`;
|
|
1326
|
+
// 一次性:以 `bash -c <line>` 非交互启动(无命令回显/提示符,缓冲=干净输出,
|
|
1327
|
+
// 便于事后按 id 查询/搜索),跑完自动退出。持久:交互 shell,命令经 stdin 写入
|
|
1328
|
+
//(回显/提示符属交互终端本身),保留 cd/venv/ssh 状态。forceBash:永远跑 bash。
|
|
1329
|
+
if (terminals.create(termId, opts.cwd, 120, 40, opts.cwd, persistent ? "AI bash" : `AI bash ${termId}`, persistent ? { forceBash: true } : { forceBash: true, runLine: bashLine }) === null) {
|
|
1330
|
+
throw new Error(`无法打开 AI bash 终端(${termId})`);
|
|
1122
1331
|
}
|
|
1123
1332
|
// 阻塞等待期间挂起活力提醒(我们自己在检测静默,避免双重通知)。
|
|
1124
|
-
terminals.suspendIdleWatch(
|
|
1125
|
-
const start = terminals.endCursor(
|
|
1333
|
+
terminals.suspendIdleWatch(termId);
|
|
1334
|
+
const start = terminals.endCursor(termId);
|
|
1126
1335
|
const ac = new AbortController();
|
|
1127
1336
|
opts.kills.add(ac);
|
|
1128
1337
|
const idleMs = Math.max(0, opts.idleMs());
|
|
1129
1338
|
const deadline = p.timeout && p.timeout > 0 ? Date.now() + p.timeout * 1000 : null;
|
|
1130
|
-
// tail
|
|
1131
|
-
// 让可见终端全程哑火,还容易白白触发静默解阻)。
|
|
1339
|
+
// tail 参数(或检测到的行数):只返回末尾 N 行(替代 `| tail -N` 管道)。
|
|
1132
1340
|
const applyTail = (t) => {
|
|
1133
|
-
if (!
|
|
1341
|
+
if (!effectiveTail || effectiveTail <= 0)
|
|
1134
1342
|
return t;
|
|
1135
1343
|
const lines = t.split("\n");
|
|
1136
|
-
return lines.length >
|
|
1137
|
-
? `…(前 ${lines.length -
|
|
1344
|
+
return lines.length > effectiveTail
|
|
1345
|
+
? `…(前 ${lines.length - effectiveTail} 行已省略)\n${lines.slice(-effectiveTail).join("\n")}`
|
|
1138
1346
|
: t;
|
|
1139
1347
|
};
|
|
1140
1348
|
try {
|
|
1141
1349
|
let collected = "";
|
|
1142
1350
|
let cursor = start;
|
|
1143
1351
|
let lastDataAt = Date.now();
|
|
1144
|
-
//
|
|
1145
|
-
|
|
1146
|
-
|
|
1352
|
+
// 一次性:命令已在 spawn 时经 `bash -c` 跑起(无 stdin 回显);持久:经 stdin 写入。
|
|
1353
|
+
const inputErr = persistent
|
|
1354
|
+
? terminals.inputChecked(termId, bashLine + "\r")
|
|
1355
|
+
: null;
|
|
1147
1356
|
if (inputErr)
|
|
1148
1357
|
throw new Error(inputErr);
|
|
1358
|
+
// 标记「有哨兵命令在跑」:terminal_wait 据此区分等待与空闲。
|
|
1359
|
+
terminals.setSentinelPending(termId, true);
|
|
1149
1360
|
for (;;) {
|
|
1150
1361
|
if (ac.signal.aborted || signal?.aborted) {
|
|
1151
1362
|
// Ctrl+C 杀前台进程;终端本身保留(会话状态还在)。
|
|
1152
|
-
terminals.setSentinelPending(
|
|
1153
|
-
terminals.inputChecked(
|
|
1363
|
+
terminals.setSentinelPending(termId, false);
|
|
1364
|
+
terminals.inputChecked(termId, "\x03");
|
|
1154
1365
|
throw new Error("Command aborted");
|
|
1155
1366
|
}
|
|
1156
1367
|
await sleep(60);
|
|
1157
|
-
const read = terminals.read(
|
|
1368
|
+
const read = terminals.read(termId, cursor);
|
|
1158
1369
|
if (read?.data) {
|
|
1159
1370
|
collected += read.data;
|
|
1160
1371
|
cursor = read.cursor;
|
|
@@ -1162,26 +1373,30 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1162
1373
|
}
|
|
1163
1374
|
const m = lastSentinel(collected);
|
|
1164
1375
|
if (m) {
|
|
1165
|
-
terminals.setSentinelPending(
|
|
1376
|
+
terminals.setSentinelPending(termId, false);
|
|
1166
1377
|
const text = applyTail(cleanBashOutput(collected));
|
|
1167
1378
|
return {
|
|
1168
1379
|
content: [
|
|
1169
1380
|
{
|
|
1170
1381
|
type: "text",
|
|
1171
|
-
text: `${text}${text ? "\n" : ""}[exit:${m[1]}]`,
|
|
1382
|
+
text: `${idLine}\n${text}${text ? "\n" : ""}${limiterNote}[exit:${m[1]}]`,
|
|
1172
1383
|
},
|
|
1173
1384
|
],
|
|
1174
|
-
details: {
|
|
1385
|
+
details: {
|
|
1386
|
+
exitCode: Number(m[1]),
|
|
1387
|
+
output: text,
|
|
1388
|
+
terminalId: termId,
|
|
1389
|
+
},
|
|
1175
1390
|
};
|
|
1176
1391
|
}
|
|
1177
1392
|
if (deadline !== null && Date.now() > deadline) {
|
|
1178
|
-
terminals.setSentinelPending(
|
|
1179
|
-
terminals.inputChecked(
|
|
1393
|
+
terminals.setSentinelPending(termId, false);
|
|
1394
|
+
terminals.inputChecked(termId, "\x03");
|
|
1180
1395
|
throw new Error(`Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${truncateMiddle(stripAnsi(collected), 4000)})`);
|
|
1181
1396
|
}
|
|
1182
1397
|
// 静默解阻:转后台 + 注册完成观察器,立即把控制权还给模型。
|
|
1183
1398
|
if (idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
|
|
1184
|
-
return backgroundResult(terminals, opts,
|
|
1399
|
+
return backgroundResult(terminals, opts, termId, runCommand, applyTail(cleanBashOutput(collected)), Math.round((Date.now() - lastDataAt) / 1000), limiterNote, idLine);
|
|
1185
1400
|
}
|
|
1186
1401
|
}
|
|
1187
1402
|
}
|
|
@@ -1192,12 +1407,12 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1192
1407
|
});
|
|
1193
1408
|
}
|
|
1194
1409
|
/** 静默解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
|
|
1195
|
-
function backgroundResult(terminals, opts, command, partialText, silentSeconds) {
|
|
1196
|
-
terminals.watchOutput(
|
|
1410
|
+
function backgroundResult(terminals, opts, terminalId, command, partialText, silentSeconds, note = "", idLine = "") {
|
|
1411
|
+
terminals.watchOutput(terminalId, BASH_SENTINEL_RE, (m) => {
|
|
1197
1412
|
// 后台命令最终结束(或终端被关)→ 清除待决标记,terminal_wait 不再适用。
|
|
1198
|
-
terminals.setSentinelPending(
|
|
1413
|
+
terminals.setSentinelPending(terminalId, false);
|
|
1199
1414
|
opts.notifyBackgroundDone({
|
|
1200
|
-
terminalId
|
|
1415
|
+
terminalId,
|
|
1201
1416
|
command,
|
|
1202
1417
|
exitCode: m ? Number(m[1]) : null,
|
|
1203
1418
|
});
|
|
@@ -1208,13 +1423,14 @@ function backgroundResult(terminals, opts, command, partialText, silentSeconds)
|
|
|
1208
1423
|
content: [
|
|
1209
1424
|
{
|
|
1210
1425
|
type: "text",
|
|
1211
|
-
text:
|
|
1426
|
+
text: `${idLine}\n命令仍在终端 ${terminalId} 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
|
|
1212
1427
|
`本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
|
|
1213
1428
|
`已有输出:\n${partial || "(暂无输出)"}\n` +
|
|
1214
|
-
`要重新阻塞等它结束就用 terminal_wait(terminalId="
|
|
1429
|
+
`要重新阻塞等它结束就用 terminal_wait(terminalId="${terminalId}")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。` +
|
|
1430
|
+
note,
|
|
1215
1431
|
},
|
|
1216
1432
|
],
|
|
1217
|
-
details: { running: true, terminalId
|
|
1433
|
+
details: { running: true, terminalId, silentSeconds },
|
|
1218
1434
|
};
|
|
1219
1435
|
}
|
|
1220
1436
|
/** Names of the agent-facing persistent-terminal tools(设置开关门控用)。 */
|
|
@@ -1319,14 +1535,30 @@ export function makePersistentTerminalTools(terminals, cwd) {
|
|
|
1319
1535
|
defineTool({
|
|
1320
1536
|
name: "terminal_read",
|
|
1321
1537
|
label: "Read terminal output",
|
|
1322
|
-
description: "Read
|
|
1538
|
+
description: "Read output from a persistent PTY (by id) either incrementally or as a snapshot query. INCREMENTAL (default): pass the cursor from the last read to get only new output (each read keeps its own cursor); optionally waitMs for new output or process exit. SNAPSHOT QUERY (give one of head/tail/search): view the retained buffer by 1-based line number — head=N (first N lines), tail=N (last N lines), or search=keyword with context=N (lines around each match). This works on terminals that already finished too (output is retained), so you can inspect an earlier one-shot bash terminal by its id.",
|
|
1323
1539
|
parameters: Type.Object({
|
|
1324
1540
|
terminalId: Type.String(),
|
|
1325
1541
|
cursor: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
1326
1542
|
maxBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 100000 })),
|
|
1327
1543
|
waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 120000 })),
|
|
1544
|
+
head: Type.Optional(Type.Integer({ minimum: 1, maximum: 10000, description: "Return the first N lines (snapshot query)" })),
|
|
1545
|
+
tail: Type.Optional(Type.Integer({ minimum: 1, maximum: 10000, description: "Return the last N lines (snapshot query)" })),
|
|
1546
|
+
search: Type.Optional(Type.String({ description: "Search this keyword (case-insensitive) in the retained buffer (snapshot query)" })),
|
|
1547
|
+
context: Type.Optional(Type.Integer({ minimum: 0, maximum: 100, description: "Lines of context around each search match (default 3)" })),
|
|
1328
1548
|
}),
|
|
1329
1549
|
execute: async (_id, p, signal) => {
|
|
1550
|
+
// 快照查询模式:head / tail / search 任一给出即按行号返回整段缓冲。
|
|
1551
|
+
if (p.head !== undefined || p.tail !== undefined || p.search !== undefined) {
|
|
1552
|
+
const q = terminals.query(p.terminalId, {
|
|
1553
|
+
head: p.head,
|
|
1554
|
+
tail: p.tail,
|
|
1555
|
+
search: p.search,
|
|
1556
|
+
context: p.context,
|
|
1557
|
+
});
|
|
1558
|
+
if (!q)
|
|
1559
|
+
throw new Error(`终端不存在:${p.terminalId}`);
|
|
1560
|
+
return result(JSON.stringify(q), q);
|
|
1561
|
+
}
|
|
1330
1562
|
const cursor = p.cursor ?? 0;
|
|
1331
1563
|
if (p.waitMs)
|
|
1332
1564
|
await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
|
|
@@ -115,8 +115,6 @@ export class WebUIContext {
|
|
|
115
115
|
});
|
|
116
116
|
}
|
|
117
117
|
// -- notifications --------------------------------------------------------
|
|
118
|
-
// Instance arrows so these survive the SDK's wrapUIPromptContext `{ ...ui }`
|
|
119
|
-
// (object spread copies own properties only, not class prototype methods).
|
|
120
118
|
notify = (message, type) => {
|
|
121
119
|
this.emit({ type: "notice", level: type ?? "info", text: message });
|
|
122
120
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -50,9 +50,10 @@
|
|
|
50
50
|
"dev": "concurrently -k -n server,web -c blue,green \"npm:dev:server\" \"npm:dev:web\"",
|
|
51
51
|
"dev:server": "cross-env PI_WEB_PORT=8788 PI_WEB_ALLOW_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 node --watch --import tsx server/index.ts",
|
|
52
52
|
"dev:web": "vite --config web/vite.config.ts",
|
|
53
|
-
"build": "npm run build:web && npm run build:server",
|
|
53
|
+
"build": "npm run build:web && npm run build:server && npm run build:dsh-runtime",
|
|
54
54
|
"build:web": "vite build --config web/vite.config.ts",
|
|
55
55
|
"build:server": "tsc -p tsconfig.server.json",
|
|
56
|
+
"build:dsh-runtime": "node scripts/copy-dsh-runtime.mjs",
|
|
56
57
|
"typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p web/tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit",
|
|
57
58
|
"test": "vitest run",
|
|
58
59
|
"test:unit": "vitest run",
|
|
@@ -62,6 +63,8 @@
|
|
|
62
63
|
"start": "node dist/server/index.js"
|
|
63
64
|
},
|
|
64
65
|
"dependencies": {
|
|
66
|
+
"@deepseek-ai/dsh-sdk-jsonrpc-server": "^0.1.1-rc.2",
|
|
67
|
+
"@deepseek-ai/dsh-sdk-protocol": "^0.1.1-rc.2",
|
|
65
68
|
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
66
69
|
"@xterm/addon-fit": "^0.11.0",
|
|
67
70
|
"@xterm/xterm": "^6.0.0",
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{a as d,j as n}from"./markdown-DRBrS2Nf.js";import{b as $,T as R,u as O,F as P,a as A,c as q,d as L,e as B,f as J,g as X,h as G,r as Q}from"./index-MeifpZzi.js";import{D as U,o as V}from"./xterm-D1D2FVe3.js";import"./react-C9ovnpIm.js";function W({conversationId:s,terminalId:r,command:x,cwd:a,active:p,send:m,register:v}){const w=d.useRef(null),N=d.useRef(null),b=x?JSON.stringify(x):"";return d.useEffect(()=>{const f=w.current;if(!f)return;const t=new U({theme:$(),fontFamily:'"SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',fontSize:13,cursorBlink:!0,scrollback:8e3}),c=new V;t.loadAddon(c),t.open(f),N.current={term:t,fit:c},p&&t.focus();const y=()=>{t.options.theme=$()};window.addEventListener(R,y),t.attachCustomKeyEventHandler(o=>{var F;if(o.type!=="keydown")return!0;const E=(F=o.key)==null?void 0:F.toLowerCase();if((o.ctrlKey||o.metaKey)&&E==="v")return!1;if(o.ctrlKey&&!o.shiftKey&&!o.altKey&&E==="c"&&t.hasSelection()){const T=t.textarea;return T&&(T.value=t.getSelection(),T.select()),!1}return!0});const g=v(s,r,{write:o=>t.write(o),dispose:()=>t.dispose()}),C=()=>{try{c.fit(),m({type:"terminal_resize",terminalId:r,conversationId:s,cols:t.cols,rows:t.rows})}catch{}},j=requestAnimationFrame(()=>{try{c.fit()}catch{}m(x?{type:"run_command",terminalId:r,conversationId:s,command:x,cols:t.cols,rows:t.rows}:{type:"terminal_create",terminalId:r,conversationId:s,cwd:a,cols:t.cols,rows:t.rows})}),k=t.onData(o=>{m({type:"terminal_input",terminalId:r,conversationId:s,data:o})});let h=null;return typeof ResizeObserver<"u"&&(h=new ResizeObserver(()=>{f.offsetWidth>0&&f.offsetHeight>0&&C()}),h.observe(f)),()=>{cancelAnimationFrame(j),k.dispose(),window.removeEventListener(R,y),h==null||h.disconnect(),g(),t.dispose(),N.current=null}},[s,r,b,m,v]),d.useEffect(()=>{if(!p)return;const f=requestAnimationFrame(()=>{const t=N.current;if(t){try{t.fit.fit(),m({type:"terminal_resize",terminalId:r,conversationId:s,cols:t.term.cols,rows:t.term.rows})}catch{}t.term.focus()}});return()=>cancelAnimationFrame(f)},[p]),n.jsx("div",{ref:w,className:`term-xterm ${p?"":"hidden"}`})}const I={name:"",command:"",cwd:"${pwd}"};function se({chat:s,send:r,terminal:x}){const a=O(),[p,m]=d.useState(null),[v,w]=d.useState(!1),[N,b]=d.useState(!1),[f,t]=d.useState(null),[c,y]=d.useState(I),[g,C]=d.useState(null),j=d.useRef(null);d.useEffect(()=>{s.terminals.length===0?m(null):s.terminals.some(e=>e.id===p)||m(s.terminals[s.terminals.length-1].id)},[s.terminals,p]),d.useEffect(()=>{s.terminalActiveId&&(m(s.terminalActiveId),w(!1))},[s.terminalActiveId]),d.useEffect(()=>()=>{j.current&&clearTimeout(j.current)},[]);const k=e=>{var u;if(!s.ready)return;const i=Q(),l=s.activeConversationId||((u=s.state)==null?void 0:u.conversationId)||"";x.create({...e,id:i,conversationId:l,cols:e.cols??80,rows:e.rows??24,running:!0,exitCode:null}),m(i),w(!1)},h=()=>{var e;return k({title:a("terminalTitle",{n:s.terminals.length+1}),cwd:((e=s.state)==null?void 0:e.cwd)??""})},o=e=>{var u;const i=e.name||e.command,l=s.terminals.find(_=>_.title===i);if(l){x.restart(l.id),m(l.id),r({type:"run_command",terminalId:l.id,conversationId:l.conversationId,command:e,cols:80,rows:24});return}k({title:i,cwd:((u=s.state)==null?void 0:u.cwd)??"",command:e})},E=e=>{const i=s.terminals.find(l=>l.id===e);if(i&&r({type:"terminal_kill",terminalId:e,conversationId:i.conversationId}),x.close(e),p===e){const l=s.terminals.filter(u=>u.id!==e);m(l.length>0?l[l.length-1].id:null)}},F=()=>{b(!0),t(null),y(I)},T=e=>{const i=s.commands[e];i&&(b(!1),t(e),y({name:i.name,command:i.command,cwd:i.cwd??""}))},D=()=>{b(!1),t(null)},S=()=>{const e=c.name.trim(),i=c.command.trim();if(!e||!i)return;const l=c.cwd.trim(),u={name:e,command:i,cwd:l||void 0},_=N?[...s.commands,u]:f!==null?s.commands.map((z,H)=>H===f?u:z):s.commands;r({type:"save_commands",commands:_}),D()},K=e=>{if(g===e){const i=s.commands.filter((l,u)=>u!==e);r({type:"save_commands",commands:i}),C(null),j.current&&clearTimeout(j.current)}else C(e),j.current&&clearTimeout(j.current),j.current=setTimeout(()=>C(null),2500)},M=N||f!==null;return n.jsxs("div",{className:"terminal-view",children:[n.jsxs("aside",{className:`term-side term-commands ${v?"open":""}`,children:[n.jsxs("div",{className:"panel-header",children:[n.jsx("span",{className:"panel-title",children:a("commands")}),n.jsxs("div",{className:"panel-header-actions",children:[n.jsx("button",{type:"button",className:"panel-refresh",title:a("rerun"),onClick:()=>r({type:"list_commands"}),children:n.jsx(P,{})}),n.jsx("button",{type:"button",className:"panel-new",title:a("newCommand"),onClick:F,children:n.jsx(A,{})})]})]}),n.jsx("div",{className:"panel-body",children:M?n.jsxs("div",{className:"cmd-form",children:[n.jsx("label",{htmlFor:"cmd-name",children:a("name")}),n.jsx("input",{id:"cmd-name",className:"cmd-input",value:c.name,placeholder:a("exampleName"),autoFocus:!0,onChange:e=>y({...c,name:e.target.value})}),n.jsx("label",{htmlFor:"cmd-command",children:a("command")}),n.jsx("input",{id:"cmd-command",className:"cmd-input",value:c.command,placeholder:a("exampleCommand"),onChange:e=>y({...c,command:e.target.value}),onKeyDown:e=>{e.key==="Enter"&&!e.nativeEvent.isComposing&&S()}}),n.jsxs("label",{htmlFor:"cmd-cwd",children:[a("directory")," ",n.jsx("span",{className:"cmd-hint",children:a("cwdHint")})]}),n.jsx("input",{id:"cmd-cwd",className:"cmd-input",value:c.cwd,placeholder:"${pwd}",onChange:e=>y({...c,cwd:e.target.value}),onKeyDown:e=>{e.key==="Enter"&&!e.nativeEvent.isComposing&&S()}}),n.jsxs("div",{className:"cmd-form-actions",children:[n.jsx("button",{type:"button",className:"btn",onClick:D,children:a("cancel")}),n.jsx("button",{type:"button",className:"btn primary",disabled:!c.name.trim()||!c.command.trim(),onClick:S,children:a("save")})]})]}):n.jsxs(n.Fragment,{children:[s.commands.length===0&&n.jsx("div",{className:"panel-empty",children:a("noCommands")}),s.commands.map((e,i)=>n.jsxs("div",{className:"cmd-item",children:[n.jsx("button",{type:"button",className:"cmd-run",title:a("clickToRun"),onClick:()=>o(e),children:n.jsx(q,{})}),n.jsxs("button",{type:"button",className:"cmd-main",title:a("clickToRun"),onClick:()=>o(e),children:[n.jsx("span",{className:"cmd-name",children:e.name}),n.jsx("span",{className:"cmd-command",children:e.command}),e.cwd&&n.jsx("span",{className:"cmd-cwd",children:e.cwd})]}),n.jsx("button",{type:"button",className:"cmd-act",title:a("edit"),onClick:()=>T(i),children:n.jsx(L,{})}),n.jsx("button",{type:"button",className:`cmd-act del ${g===i?"confirm":""}`,title:a("delete"),onClick:()=>K(i),children:g===i?a("confirmQ"):n.jsx(B,{})})]},i))]})}),n.jsxs("div",{className:"term-tabs-block",children:[n.jsxs("div",{className:"panel-header",children:[n.jsx("span",{className:"panel-title",children:a("terminal")}),n.jsx("button",{type:"button",className:"panel-new",title:a("newTerminal"),onClick:h,children:n.jsx(A,{})})]}),n.jsxs("div",{className:"panel-body",children:[s.terminals.length===0&&n.jsx("div",{className:"panel-empty",children:a("noTerminal")}),s.terminals.map(e=>n.jsxs("div",{className:`term-tab ${e.id===p?"active":""}`,children:[n.jsxs("button",{type:"button",className:"term-tab-main",title:`${e.cwd}${e.command?`
|
|
2
|
+
> ${e.command.command}`:""}`,onClick:()=>{m(e.id),w(!1)},children:[n.jsx("span",{className:`term-tab-dot ${e.running?"run":"exit"}`}),n.jsxs("span",{className:"term-tab-title",children:[e.title,!e.running&&n.jsx("span",{className:"term-tab-exit",children:a("exited",{code:e.exitCode===null?"":` ${e.exitCode}`})})]})]}),n.jsx("button",{type:"button",className:"term-tab-close",title:a("closeTerminal"),onClick:()=>E(e.id),children:n.jsx(J,{})})]},e.id))]})]})]}),n.jsxs("div",{className:"term-main",children:[v&&n.jsx("div",{className:"drawer-backdrop",onClick:()=>w(!1)}),n.jsx("button",{type:"button",className:"term-side-toggle",title:a("commands"),onClick:()=>w(e=>!e),children:n.jsx(X,{})}),s.terminals.length===0?n.jsxs("div",{className:"term-empty",children:[n.jsx(G,{className:"term-empty-icon"}),n.jsx("div",{className:"term-empty-title",children:a("builtinTerminal")}),n.jsx("div",{className:"term-empty-sub",children:a("termEmptySub")})]}):s.terminals.map(e=>n.jsx(W,{conversationId:e.conversationId,terminalId:e.id,command:e.command,cwd:e.cwd,active:e.id===p,send:r,register:x.register},`${e.conversationId}:${e.id}`))]})]})}export{se as TerminalPanel};
|