pi-web-ui 0.83.0 → 0.84.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/CHANGELOG.md +25 -2
- package/dist/server/agent-service.js +52 -11
- package/dist/server/dsh/dsh-agent-service.js +10 -3
- package/dist/server/terminals.js +2 -2
- package/package.json +1 -1
- package/web/dist/assets/{TerminalPanel-BF99sld0.js → TerminalPanel-B7W10RgC.js} +1 -1
- package/web/dist/assets/{index-D__1RagK.js → index-B1_0cm7B.js} +46 -46
- package/web/dist/assets/index-D6KtyrNW.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-B1XPfcM0.css +0 -10
package/CHANGELOG.md
CHANGED
|
@@ -10,7 +10,29 @@
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
## [0.84.0] — 2026-09-13
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Git 视图的左栏可以拖宽了**(issue #139)—— 改动文件 / 提交历史那一栏原来固定 300px,路径或分支名一长就被截断且无处可展。现在拖它右边的分隔条即可调宽(**双击复位**),宽度**跨会话记住**(`localStorage`);拖动时按容器宽度收敛,保证 diff 区不会被挤没(额外还有一条 `max-width` 兜底)。
|
|
18
|
+
- 与左右主面板同一套手感(拖动分隔条、双击复位、松开才写存档);纯函数 `web/src/scm-sidebar.ts` + 单测 `tests/unit/scm-sidebar.test.ts`,真浏览器回归 `tests/scm-test.mjs`(拖 +120px → 刷新后保持 → 双击回 300px)。
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **正在聊的那条对话不再从左栏「运行的对话」里消失**(issue #140)—— 原来新对话只有「被换到后台且仍在流式输出」时才入列,于是你在当前对话里发消息时,左栏里根本没有它;只开一条对话时连「运行的对话」这个标题都不渲染,观感像是对话丢了。现在**当前对话一旦有内容**(有消息、或已被首条提示词命名)就立刻出现在列表里(还在流式输出时也会亮起绿点),行上标「当前」、点击是安全的空动作。
|
|
23
|
+
- **空白新对话仍然不入列**(连点「新建对话」不会堆出一排空条目),历史对话列表也不受影响。
|
|
24
|
+
- 这是**纯展示口径**:`listed` 的语义,以及「被挤出后台时是保留还是释放 runtime」「每项目 8 个上限」这些规则完全没变(换走时该释放的仍然释放);列表里当前对话那一行的 ✕ 按同一口径判定,真能移出(不再静默无反应)。
|
|
25
|
+
- 回归:`tests/unit/…` 无(口径在服务端),零 token 协议测试 `tests/running-list-test.mjs`(空白不入列 / 流式中入列 / 跑完留住 / 当前行 ✕ 可移出 / 后台运行语义不变),真浏览器 `tests/panel-layout-test.mjs`(区标题、「当前」副标签、流式绿点、只有一行)。DSH 引擎同口径一并改了(共用同一套左栏与 wire 协议)。
|
|
26
|
+
|
|
27
|
+
- **切项目时左栏不再闪一下项目名**(#140 那个改动的回归)—— 切项目会自动切到该项目的对话;如果切过去时那个项目只有一条对话(就是这条当前对话),列表里只有一组,它顶上原来会闪出一行项目名再消失(实测约 8ms 一帧)。原因是两个信号不同时到达:`conversations` 推送先到(activeId 已是新项目的对话),带新 `cwd` 的快照后到,只按 `cwd` 分组的那一帧就把当前项目当成了「别的项目」。
|
|
28
|
+
- 现在**只要当前对话在列表里,就认它所在的分组为当前项目**(`currentCwd` 只在它不在列表里时——空白新对话——作为回落),并直接置顶,顺带免掉随后的位置跳动;其他项目的后台运行照旧显示项目名。
|
|
29
|
+
- 分组逻辑抽成纯函数 `web/src/conv-groups.ts` + 单测 `tests/unit/conv-groups.test.ts`,另有真浏览器逐帧回归 `tests/conv-group-flash-test.mjs`(MutationObserver 记录每一帧 DOM,两个方向都断言不再渲染项目名;改回只看 `cwd` 即变红)。
|
|
30
|
+
|
|
31
|
+
<!-- auto-i18n:start -->
|
|
32
|
+
### i18n
|
|
33
|
+
|
|
34
|
+
- 服务端新增 key(1):`terminals.cwd.outside.workspace`
|
|
35
|
+
<!-- auto-i18n:end -->
|
|
14
36
|
|
|
15
37
|
## [0.83.0] — 2026-09-13
|
|
16
38
|
|
|
@@ -686,7 +708,8 @@
|
|
|
686
708
|
- 0.35.1(2026-08-27):编辑重问保留附件(#18)+ 全窗口拖放(#19)。
|
|
687
709
|
- 0.29.0(2026-08-23):全局搜索弹窗(Ctrl+K)+ 消息列表惰性窗口化。
|
|
688
710
|
|
|
689
|
-
[Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.
|
|
711
|
+
[Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.84.0...main
|
|
712
|
+
[0.84.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.84.0
|
|
690
713
|
[0.83.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.83.0
|
|
691
714
|
[0.80.1]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.80.1
|
|
692
715
|
[0.80.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.80.0
|
|
@@ -20,7 +20,7 @@ import { createRequire } from "node:module";
|
|
|
20
20
|
import { existsSync, readFileSync, rmSync, statSync, mkdirSync, watch } from "node:fs";
|
|
21
21
|
import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
|
-
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices,
|
|
23
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { Type } from "typebox";
|
|
25
25
|
import { BgServerTracker } from "./bg-servers.js";
|
|
26
26
|
import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, } from "./update-check.js";
|
|
@@ -133,7 +133,7 @@ export function makeKillableBashTool(cwd, kills,
|
|
|
133
133
|
/** per-call 返回文本的服务端语言(默认英文);工具 definition 走 bilingual 内联双语。 */
|
|
134
134
|
lang = () => "en") {
|
|
135
135
|
const base = createLocalBashOperations();
|
|
136
|
-
const tool =
|
|
136
|
+
const tool = createBashToolDefinition(cwd, {
|
|
137
137
|
operations: {
|
|
138
138
|
exec: async (command, c, opts) => {
|
|
139
139
|
const ac = new AbortController();
|
|
@@ -151,7 +151,7 @@ lang = () => "en") {
|
|
|
151
151
|
},
|
|
152
152
|
},
|
|
153
153
|
});
|
|
154
|
-
//
|
|
154
|
+
// Keep the SDK definition so execute receives the current session context.
|
|
155
155
|
return {
|
|
156
156
|
name: tool.name,
|
|
157
157
|
label: tool.label,
|
|
@@ -175,8 +175,8 @@ lang = () => "en") {
|
|
|
175
175
|
}),
|
|
176
176
|
prepareArguments: tool.prepareArguments,
|
|
177
177
|
executionMode: tool.executionMode,
|
|
178
|
-
execute: async (toolCallId, params, signal, onUpdate) => {
|
|
179
|
-
const result = (await tool.execute(toolCallId, params, signal, onUpdate));
|
|
178
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
179
|
+
const result = (await tool.execute(toolCallId, params, signal, onUpdate, ctx));
|
|
180
180
|
// head/tail 后处理(native 无终端,直接截返回行即可)。
|
|
181
181
|
const p = params;
|
|
182
182
|
if ((p?.head || p?.tail) && result?.content?.[0]?.text != null) {
|
|
@@ -1740,8 +1740,12 @@ export class ClientSession {
|
|
|
1740
1740
|
session: runtime.session,
|
|
1741
1741
|
cwd: runtime.cwd,
|
|
1742
1742
|
createdAt: Date.now(),
|
|
1743
|
-
// A brand-new conversation is not yet
|
|
1744
|
-
// only when it is displaced to the background while still
|
|
1743
|
+
// A brand-new conversation is not yet LISTED — it enters the running
|
|
1744
|
+
// list only when it is displaced to the background while still
|
|
1745
|
+
// streaming (its runtime is what `listed` protects). A blank chat is
|
|
1746
|
+
// also kept out of the left panel's running list; it shows up there as
|
|
1747
|
+
// soon as it has content while it is the active chat — see
|
|
1748
|
+
// shownInRunningList (#140).
|
|
1745
1749
|
listed: false,
|
|
1746
1750
|
promptedSinceActive: false,
|
|
1747
1751
|
lastActiveAt: Date.now(),
|
|
@@ -2327,6 +2331,10 @@ export class ClientSession {
|
|
|
2327
2331
|
const task = conv.pendingTask;
|
|
2328
2332
|
conv.pendingTask = undefined;
|
|
2329
2333
|
this.emitRun(conv, task ? { type: "run_start", task } : { type: "run_start" });
|
|
2334
|
+
// #140:本轮真的开跑了(session.isStreaming 此刻已为 true)—— 左栏
|
|
2335
|
+
// 那行的「流式中」标识要立刻亮起来:首条提示词的入列 emit 早于本轮
|
|
2336
|
+
// 启动,那时它还是 false;后台对话被唤醒重跑也靠这里刷新。
|
|
2337
|
+
this.emitConversations();
|
|
2330
2338
|
break;
|
|
2331
2339
|
}
|
|
2332
2340
|
case "turn_start": {
|
|
@@ -3523,6 +3531,8 @@ export class ClientSession {
|
|
|
3523
3531
|
if (conv.title === DEFAULT_CONV_TITLE && text.trim() && !conv.session.sessionName?.trim()) {
|
|
3524
3532
|
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
3525
3533
|
conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
3534
|
+
// 同时也是 #140 的入列时刻:这条对话从此有内容了,左栏「运行的对话」
|
|
3535
|
+
// 立刻要有它(此刻还在流式输出,不能等 agent_end 的防抖刷新)。
|
|
3526
3536
|
this.emitConversations();
|
|
3527
3537
|
}
|
|
3528
3538
|
// Attach files as independent nextTurn context messages (asides) so the
|
|
@@ -4128,10 +4138,40 @@ export class ClientSession {
|
|
|
4128
4138
|
this.notifyConversationChanged();
|
|
4129
4139
|
this.flushSnapshot();
|
|
4130
4140
|
}
|
|
4141
|
+
/** 左栏「运行的对话」的展示口径(issue #140)。
|
|
4142
|
+
*
|
|
4143
|
+
* 老口径只有 listed:新对话要等「被换到后台且仍在跑」才入列 —— 用户正在聊的
|
|
4144
|
+
* 那条反而不在列表里(只有它一条时,左栏连「运行的对话」标题都不渲染,观感
|
|
4145
|
+
* 像是对话丢了)。新口径把「当前对话 + 已经有内容」也算进来:有消息的对话
|
|
4146
|
+
* (或已被首条提示词命名 —— 命名与首条消息是同一时刻,见 prompt() 里的
|
|
4147
|
+
* rename 块)立刻出现在列表里;空白新对话仍然不入列(防连点「新建对话」
|
|
4148
|
+
* 堆出一排空条目)。
|
|
4149
|
+
*
|
|
4150
|
+
* 只影响「列表里推什么」:listed 本身的语义、以及 displaceActive /
|
|
4151
|
+
* shouldRetainActive / MAX_OPEN_CONVERSATIONS 那套「什么算运行中」的规则
|
|
4152
|
+
* 完全不变(换走时该释放的仍然释放,不会被这次展示口径改动永久钉在列表里)。
|
|
4153
|
+
* Display-only: retention and disposal rules are deliberately untouched. */
|
|
4154
|
+
shownInRunningList(conv) {
|
|
4155
|
+
if (conv.listed)
|
|
4156
|
+
return true;
|
|
4157
|
+
if (conv.id !== this.activeId)
|
|
4158
|
+
return false;
|
|
4159
|
+
// 首条提示词给对话命名 = 用户真的开始聊了(此刻消息可能还没落进会话统计)。
|
|
4160
|
+
if (conv.title !== DEFAULT_CONV_TITLE)
|
|
4161
|
+
return true;
|
|
4162
|
+
try {
|
|
4163
|
+
return conv.session.getSessionStats().totalMessages > 0;
|
|
4164
|
+
}
|
|
4165
|
+
catch {
|
|
4166
|
+
// 会话替换中 —— 先不列,下一次 emit 会补上
|
|
4167
|
+
return false;
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4131
4170
|
/** Push every running conversation across ALL projects to the client. The
|
|
4132
4171
|
* running-conversation list is global so a background run from another
|
|
4133
4172
|
* workspace stays visible; clicking one switches both the conversation and
|
|
4134
|
-
* its project (see switchConversation). The client groups the list by cwd.
|
|
4173
|
+
* its project (see switchConversation). The client groups the list by cwd.
|
|
4174
|
+
* 推什么见 shownInRunningList(listed + 当前对话有内容时)。 */
|
|
4135
4175
|
emitConversations() {
|
|
4136
4176
|
const conversations = [];
|
|
4137
4177
|
// Active parents are normally absent from Running. Keep them visible while
|
|
@@ -4141,7 +4181,7 @@ export class ClientSession {
|
|
|
4141
4181
|
.map((conv) => conv.parentId)
|
|
4142
4182
|
.filter(Boolean));
|
|
4143
4183
|
for (const conv of this.convs.values()) {
|
|
4144
|
-
if (!conv
|
|
4184
|
+
if (!this.shownInRunningList(conv) && !visibleParents.has(conv.id))
|
|
4145
4185
|
continue;
|
|
4146
4186
|
let messageCount = 0;
|
|
4147
4187
|
let isStreaming = false;
|
|
@@ -4462,8 +4502,9 @@ export class ClientSession {
|
|
|
4462
4502
|
});
|
|
4463
4503
|
return;
|
|
4464
4504
|
}
|
|
4465
|
-
if (!conv
|
|
4466
|
-
// Not in list anyway — nothing to do.
|
|
4505
|
+
if (!this.shownInRunningList(conv)) {
|
|
4506
|
+
// Not in list anyway — nothing to do. 展示口径见 shownInRunningList
|
|
4507
|
+
// (当前对话有内容时也在列表里,对它的 ✕ 必须真的移出,不能静默 no-op)。
|
|
4467
4508
|
this.emitConversations();
|
|
4468
4509
|
return;
|
|
4469
4510
|
}
|
|
@@ -1121,11 +1121,18 @@ export class DshClientSession {
|
|
|
1121
1121
|
pendingMessages() {
|
|
1122
1122
|
return 0;
|
|
1123
1123
|
}
|
|
1124
|
+
/** 左栏展示口径(issue #140,与 pi 引擎同义):listed 之外,当前对话只要有
|
|
1125
|
+
* 内容(DSH 的对话消息全在内存里,直接数 messages)也在列表里;空白新对话
|
|
1126
|
+
* 不入列。只影响展示,不动 listed 的语义。 */
|
|
1127
|
+
shownInRunningList(conv) {
|
|
1128
|
+
return conv.id === this.activeId && conv.messages.length > 0;
|
|
1129
|
+
}
|
|
1124
1130
|
emitConversations() {
|
|
1125
1131
|
const list = [];
|
|
1126
1132
|
for (const conv of this.convs.values()) {
|
|
1127
|
-
//
|
|
1128
|
-
|
|
1133
|
+
// 被置换到后台的会话 + 当前对话(一旦有内容就是用户正在聊的那条,
|
|
1134
|
+
// 不该在列表里缺席——issue #140;空白新对话仍然不入列)。
|
|
1135
|
+
if (!conv.listed && !this.shownInRunningList(conv))
|
|
1129
1136
|
continue;
|
|
1130
1137
|
list.push({
|
|
1131
1138
|
id: conv.id,
|
|
@@ -1797,7 +1804,7 @@ export class DshClientSession {
|
|
|
1797
1804
|
});
|
|
1798
1805
|
return;
|
|
1799
1806
|
}
|
|
1800
|
-
if (!conv.listed) {
|
|
1807
|
+
if (!conv.listed && !this.shownInRunningList(conv)) {
|
|
1801
1808
|
this.emitConversations();
|
|
1802
1809
|
return;
|
|
1803
1810
|
}
|
package/dist/server/terminals.js
CHANGED
|
@@ -678,7 +678,7 @@ export class TerminalManager {
|
|
|
678
678
|
this.history.delete(id);
|
|
679
679
|
const safeCwd = this.safeCwd(cwd || fallbackCwd);
|
|
680
680
|
if (!safeCwd) {
|
|
681
|
-
this.fail(id, "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace");
|
|
681
|
+
this.fail(id, pick(this.lang?.() ?? "en", "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace", "terminals.cwd.outside.workspace"), "Terminal cwd must be inside the current workspace");
|
|
682
682
|
return null;
|
|
683
683
|
}
|
|
684
684
|
if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.agentBash, opts?.locale)) {
|
|
@@ -720,7 +720,7 @@ export class TerminalManager {
|
|
|
720
720
|
const command = expandPwd(def.command.trim(), pwd);
|
|
721
721
|
const title = def.name || command || `终端 ${++this.seq}`;
|
|
722
722
|
if (!dir) {
|
|
723
|
-
this.fail(id, "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace");
|
|
723
|
+
this.fail(id, pick(this.lang?.() ?? "en", "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace", "terminals.cwd.outside.workspace"), "Terminal cwd must be inside the current workspace");
|
|
724
724
|
return;
|
|
725
725
|
}
|
|
726
726
|
if (existing) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.84.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": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{a as l,j as n}from"./markdown-D3PKeHAZ.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-
|
|
1
|
+
import{a as l,j as n}from"./markdown-D3PKeHAZ.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-B1_0cm7B.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-w24rH0km.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(h,u)=>(c=Number(u),`\r
|
|
2
2
|
`)).replace(/\r?\n?\x1b\[90m\[(?:进程已退出,退出码 |Process exited with code )-?\d+\]\x1b\[0m\r?\n?/g,`\r
|
|
3
3
|
`),exitCode:c}}function ce({conversationId:t,terminalId:c,command:s,cwd:h,title:u,active:w,running:f,exitCode:k,register:g}){const C=l.useRef(null),j=l.useRef(null),{locale:o}=P(),y=l.useRef(o);y.current=o;const E=s?JSON.stringify(s):"";l.useEffect(()=>{const m=C.current;if(!m)return;const r=new re({theme:O(),fontFamily:'"SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',fontSize:13,cursorBlink:!0,scrollback:8e3}),v=new ie;r.loadAddon(v),r.open(m),j.current={term:r,fit:v},w&&r.focus();const b=()=>{r.options.theme=O()};window.addEventListener(M,b),r.attachCustomKeyEventHandler(d=>{if(d.type!=="keydown")return!0;const S=d.key?.toLowerCase();if((d.ctrlKey||d.metaKey)&&S==="v")return!1;if(d.ctrlKey&&!d.shiftKey&&!d.altKey&&S==="c"&&r.hasSelection()){const D=r.textarea;return D&&(D.value=r.getSelection(),D.select()),!1}return!0});const _=g(t,c,{write:d=>r.write(le(d).clean),dispose:()=>r.dispose()}),$=()=>{try{v.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.cols,rows:r.rows})}catch{}},B=requestAnimationFrame(()=>{try{v.fit()}catch{}s?p({type:"run_command",terminalId:c,conversationId:t,command:s,cols:r.cols,rows:r.rows}):p({type:"terminal_create",terminalId:c,title:u,locale:y.current,conversationId:t,cwd:h,cols:r.cols,rows:r.rows})}),R=r.onData(d=>{p({type:"terminal_input",terminalId:c,conversationId:t,data:d})});let T=null;return typeof ResizeObserver<"u"&&(T=new ResizeObserver(()=>{m.offsetWidth>0&&m.offsetHeight>0&&$()}),T.observe(m)),()=>{cancelAnimationFrame(B),R.dispose(),window.removeEventListener(M,b),T?.disconnect(),_(),r.dispose(),j.current=null}},[t,c,E,g]),l.useEffect(()=>{if(!w)return;const m=requestAnimationFrame(()=>{const r=j.current;if(r){try{r.fit.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.term.cols,rows:r.term.rows})}catch{}r.term.focus()}});return()=>cancelAnimationFrame(m)},[w]);const{t:F}=P(),x=l.useRef(void 0);return l.useEffect(()=>{if(f===x.current||(x.current=f,f!==!1))return;const m=j.current;m&&m.term.write(`\r
|
|
4
4
|
\x1B[90m${F("exitBanner",{code:k??""})}\x1B[0m\r
|