zen-gitsync 2.17.34 → 2.17.36
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 +17 -3
- package/package.json +4 -1
- package/src/cli/ai/agent.js +34 -2
- package/src/cli/ai/cli.integration.test.js +30 -0
- package/src/cli/ai/paste.js +372 -0
- package/src/cli/ai/paste.test.js +276 -0
- package/src/cli/ai/termui.js +44 -6
- package/src/cli/ai/termui.test.js +39 -3
- package/src/ui/public/assets/{AgentEngineSelector-DVuUEDHz.js → AgentEngineSelector-DVs4LGHZ.js} +1 -1
- package/src/ui/public/assets/{AgentView-xdFrDybl.js → AgentView-Blp40VRr.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-CCqQiZPO.js → AppVersionBadge-BEnoFzwh.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-GD8NqzJj.js → BranchSelector-C7eXeFif.js} +1 -1
- package/src/ui/public/assets/{CommitForm-DNuJSePt.js → CommitForm-CK9jLJOC.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-Cg3qSsFG.js → CommonDialog-BTeIpgGT.js} +1 -1
- package/src/ui/public/assets/{EditorView-ivXCi959.js → EditorView-D00zvntf.js} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-DS1-m59W.js → FileDiffViewer-zqgG9ok3.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-DXhO7HwA.js → FlowExecutionViewer-VFnxkbIs.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-DIJboi94.js → FlowOrchestrationWorkspace-CIIdKK3v.js} +1 -1
- package/src/ui/public/assets/{IconButton-BVgngz_Y.js → IconButton-Cgc5v9vb.js} +1 -1
- package/src/ui/public/assets/{LogList-DGRGqcb_.js → LogList-DyQv-Yec.js} +1 -1
- package/src/ui/public/assets/{MindmapView-CRtCsbfO.js → MindmapView-EC2Wk_Qi.js} +1 -1
- package/src/ui/public/assets/{MonitorView-Ml1e4m41.js → MonitorView-Z5ZbGtaU.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-BDoX_Equ.js → ProjectStartupButton-Ci19q1bL.js} +1 -1
- package/src/ui/public/assets/{RecentDirectoriesChat-CwPIeHuv.js → RecentDirectoriesChat-DT0sguEG.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-B9JvGgsK.js → RemoteManagerDialog-BRQpjMSK.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-BvZJtHNN.js → RemoteRepoCard-yK90_VfS.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CxTHZDOP.js → SourceMapView-C55ixUwz.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-dJcILWS2.js → SvgIcon-ub85vovN.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-BqrwTyxG.js → TemplateManager-uDno_64z.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-D28zKp0v.js → UserInputNode-Ce55MMEZ.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-CtJD3pz-.js +228 -0
- package/src/ui/public/assets/WorkbenchView-D1Pf6Etd.css +1 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-Be8qXN0e.js → _plugin-vue_export-helper-VuE2vqnn.js} +2 -2
- package/src/ui/public/assets/{agentConversations-BAvoMaCc.js → agentConversations-DcW54Tlt.js} +1 -1
- package/src/ui/public/assets/{configStore-Bj7SEMRL.js → configStore-De_8eQuS.js} +1 -1
- package/src/ui/public/assets/{gitStore-BaBKqLlD.js → gitStore-BP6TATf7.js} +1 -1
- package/src/ui/public/assets/{index-B0MdMohu.js → index-Bq_-TrTP.js} +5 -5
- package/src/ui/public/assets/{previewSandbox-DKUIjkxY.js → previewSandbox-DcVlFmag.js} +1 -1
- package/src/ui/public/assets/{toolsStore-MahCZx-D.js → toolsStore-CQclze0T.js} +1 -1
- package/src/ui/public/index.html +11 -11
- package/src/ui/server/routes/workbench/hiddenProjects.js +87 -0
- package/src/ui/server/routes/workbench/hiddenProjects.test.js +60 -0
- package/src/ui/server/routes/workbench/index.js +69 -1
- package/src/ui/server/routes/workbench/shared.js +8 -0
- package/src/ui/public/assets/WorkbenchView-BhWKWSFS.js +0 -220
- package/src/ui/public/assets/WorkbenchView-Bx5r6sZ5.css +0 -1
package/README.md
CHANGED
|
@@ -378,7 +378,7 @@ A dedicated view for running coding agents across one or many projects. Every ta
|
|
|
378
378
|
| Feature | Description |
|
|
379
379
|
|---|---|
|
|
380
380
|
| Task list | Create, edit, delete tasks. Rows are grouped by project (current project first), each a single line — the title, or the first characters of the description (ellipsised) when the title is empty. Tasks with neither a title nor a description count as drafts that are never saved (switching away drops them) |
|
|
381
|
-
| Multi-project board | Three resizable / collapsible panes: the project list with its run monitor, the kanban board, and the master-agent console. Drag a divider to resize (project list 180–420 px, console 260–560 px), double-click it to reset the responsive default, or fold a side away — the top-bar button folds the project list, the console folds into a 32 px rail that expands on click. The run monitor has its own divider (120 px up to half the viewport height) for when several jobs run at once. All widths / heights are remembered across sessions |
|
|
381
|
+
| Multi-project board | Three resizable / collapsible panes: the project list with its run monitor, the kanban board, and the master-agent console. Drag a divider to resize (project list 180–420 px, console 260–560 px), double-click it to reset the responsive default, or fold a side away — the top-bar button folds the project list, the console folds into a 32 px rail that expands on click. The run monitor has its own divider (120 px up to half the viewport height) for when several jobs run at once. All widths / heights are remembered across sessions. Below 860 px the panes stack vertically with the console **above** the board — a stacked board runs to five figures of pixels, so a console placed after it would be out of reach — and the progress report is shown in full instead of clamped to six lines |
|
|
382
382
|
| Kanban board | **Todo / Doing / Done** columns with the done column sorted newest-first; a filter checkbox narrows the board to tasks whose last run failed, and a table view is one click away for a denser listing. A **New task** entry sits at the end of the Todo column whether or not that column already holds cards (it doubles as the empty state, and opens the same create dialog as the top-right button) |
|
|
383
383
|
| Card status while running | A running task is more than a blinking dot: the card itself carries its executor's brand icon (hover for the product name), the elapsed time, how many tool calls it has made (hover for the mix of types), its most recent tool call, what it is thinking right now, and its newest reply — plus a silence figure once the job has gone quiet for a while. A row with nothing in it is left out instead of being filled with "none yet", and a card that is not running has no such block at all. The facts come from the execution log, the same source the progress report reads, so they refresh with the board's 5-second poll and a task running in another `g ui` instance shows up here too; in table view the same summary sits under the task name. Hovering the card also reveals a **Stop** button in the very slot an idle card puts **Run** in (a running card does not show both — there is no second run to start), so killing a stuck run no longer means opening the editor and scrolling down to it; it goes through the same endpoint and the very same confirmation text as the editor's stop, and a job running inside another `g ui` instance cannot be stopped from this window — the server says exactly that instead of a flat "stop failed" |
|
|
384
384
|
| Master-agent console | Give the master agent an instruction and it dispatches a new task, deciding the target project itself (or honouring the project you selected). Enter dispatches, Shift+Enter starts a new line. Dispatching can be paused and resumed — while paused, a dispatch creates the task without running it |
|
|
@@ -388,6 +388,7 @@ A dedicated view for running coding agents across one or many projects. Every ta
|
|
|
388
388
|
| Marking a task done by hand | The columns are derived from execution facts, which cannot express the two things only you know: a task sitting in **Todo** that was really finished somewhere else, and an **In progress** task whose model has gone quiet while you can see the work is done. Both cards now hover out a **Done** button next to **Run** / **Stop**, and a card that got into Done *by hand* trades it for **Undo**. A card that finished on its own keeps showing neither — undoing a mark it never had would do nothing at all. Marking a still-running task done asks first and stops that run as part of the same action (a card cannot be Done and running at once), which holds across windows too: if the job lives in another `g ui` instance the server refuses with that very reason instead of pretending. The mark is stored on the task rather than faked as a run record, and a later run of that task supersedes it, so the card goes back to following the facts. Undo is one click with no confirmation — it is the "I clicked the wrong one" path — and hands the card back to the execution facts, which is why it is offered only where the mark is what keeps the card in Done |
|
|
389
389
|
| Dispatch default prompts | Give every dispatch a standing prompt: one **global** entry that applies to all projects, plus one **per project** that is appended after it whenever you dispatch to that project (it supplements the global one rather than replacing it). Both are edited from the gear button in the console's composer; the combined text is prepended to the instruction, a "Default prompt (global + this project)" checkbox appears next to **Run now** so a single dispatch can opt out, and the instruction log records which level was attached. The prompt is copied onto the task at dispatch time, so editing the setting later never rewrites tasks that already exist; it lands in the task's own prompt field, where you can still edit it per task |
|
|
390
390
|
| Open-with menu per project | Hovering a project row in the board's project list reveals two buttons: **Open folder** (straight to your file manager) and **Open with**, a menu holding file manager / terminal / `g ui` in a new tab plus every editor and AI tool (VS Code, Codex, OpenCode, Kimi Code, ZCode, DeepSeek Harness, and Claude Code in default or fully-approved mode). Tools that are not installed are dimmed and labelled "Not installed" — clicking one opens the very same install guide the top bar uses. Every action applies to that row's project only; the board's selection is never touched |
|
|
391
|
+
| Remove a dead project | A row whose folder no longer exists still shows up — the list is the union of recent directories and the paths your tasks remember — so that row gets a single red **Remove from list** button instead of the open actions (opening a missing folder can only fail). The confirm dialog spells it out: **no tasks are deleted**, and the toast repeats how many were kept. The entry disappears from the list while every task, job and history record stays put; if you ever clone the folder back, the row returns on its own |
|
|
391
392
|
| Task editor overlay | Clicking a board card opens the editor as an overlay: a flat sidebar holding the task list and prompt presets, then the task header, preset selector, executor split-button, the copy-execution / execution-log / clear-execution actions, and a chat-style execution body. The description collapses into a one-line "Task description (optional)" summary until clicked, showing a "Filled" badge and the attachment count once there is content. Opening the overlay lands the conversation on its **newest** turn rather than its first: the flow keeps re-pinning to the bottom while markdown highlighting and tool-call folding are still growing it after mount, and lets go as soon as you scroll yourself |
|
|
392
393
|
| Copy execution content | **Copy execution content** in the task header puts the whole task — every turn, not just whatever is on screen — on the clipboard as plain Markdown: a header line for the task and its project, then one section per turn (`## Round N`, with the executor, status and start time on the line under it) holding the user's prompt, the agent's thinking, its tool calls (arguments and results in fenced blocks) and the model's output. It is assembled from the run records rather than scraped from the DOM, so what you get never depends on what happens to be selected. The user side goes through the same trimming the chat bubbles use, so what you copy is what you typed — the injected environment / memory blocks and the attachment list stay behind in `job.prompt`. Turns with nothing in them are skipped without renumbering the ones after them, and a task that has never run answers with a "nothing to copy" toast instead of putting an empty string on the clipboard |
|
|
393
394
|
| Executor choice | Run each task with **Claude Code**, **OpenCode** or **Codex**. The global default is set in **Settings → General → Task executor** (`config.taskExecutor`); the split-button next to the run button switches it for the next run and remembers that pick in the browser. A continued conversation always stays on the executor that started it — Claude's `--resume`, OpenCode's `--session` and Codex's thread id are not interchangeable |
|
|
@@ -511,6 +512,13 @@ selection + Enter to confirm** (typing a number also jumps directly; `0` selects
|
|
|
511
512
|
"custom / manual input" entry). Non-TTY environments (CI, piped input) automatically fall back
|
|
512
513
|
to numeric input. `Esc` or `Ctrl+C` cancels the wizard cleanly.
|
|
513
514
|
|
|
515
|
+
Paste a whole block of text and it goes out as **one** message with its line breaks intact — the
|
|
516
|
+
input line shows a short placeholder (`[paste #1 · 4 lines]`) instead of stretching to dozens of
|
|
517
|
+
rows, and the content actually sent is echoed above the prompt as soon as you hit Enter. Recalling
|
|
518
|
+
that line with ↑ re-expands the same text. Terminals without bracketed-paste support (e.g. the
|
|
519
|
+
legacy Windows console host) fall back to readline's native behaviour: the paste submits line by
|
|
520
|
+
line, and only the first line starts a turn.
|
|
521
|
+
|
|
514
522
|
`/skills` (alias `/mcp`) lists the skills and MCP servers already installed for the agent and
|
|
515
523
|
where they came from. Installation itself happens in the GUI's **Skill / MCP plaza** (Agent
|
|
516
524
|
view): pick the current project or the `g ai` agent as the target, and the entry becomes usable
|
|
@@ -1064,17 +1072,18 @@ Activity Bar 第四个视图,在 GUI 内直接浏览并编辑项目文件:
|
|
|
1064
1072
|
| 功能 | 说明 |
|
|
1065
1073
|
|---|---|
|
|
1066
1074
|
| 任务列表 | 新建、编辑、删除任务;按项目分组(当前项目排在最前),每条任务只占一行 —— 有标题显示标题,没标题则显示描述前若干字符(超出省略);标题和描述都没填的任务视为草稿,切走时直接丢弃、不落盘 |
|
|
1067
|
-
| 多项目看板 | 三栏均可拖动 / 可折叠:项目列表(含执行监控)、看板、主 Agent 控制台。拖动分隔条调整宽度(项目列表 180–420 px、控制台 260 px ~ `min(900, 视口 46%)`),双击恢复响应式默认值;两侧也都能收起 —— 顶栏按钮收项目列表,控制台自己的按钮把它收成 32px 收纳条、点一下展开。项目列表下方的执行监控有独立分隔条(120 px ~
|
|
1075
|
+
| 多项目看板 | 三栏均可拖动 / 可折叠:项目列表(含执行监控)、看板、主 Agent 控制台。拖动分隔条调整宽度(项目列表 180–420 px、控制台 260 px ~ `min(900, 视口 46%)`),双击恢复响应式默认值;两侧也都能收起 —— 顶栏按钮收项目列表,控制台自己的按钮把它收成 32px 收纳条、点一下展开。项目列表下方的执行监控有独立分隔条(120 px ~ 视口高度一半),并行跑多个任务时上下拖动即可加高。所有宽高跨会话记住。窄屏(≤860px)三栏改成上下排列,主 Agent 控制台排在**看板前面**(看板三列摞起来有一万多像素,控制台落在它后面就够不着了),报告正文也不再按 6 行截断 |
|
|
1068
1076
|
| 看板视图 | **待处理 / 进行中 / 已完成** 三列,已完成列按完成时间倒序;顶部勾选可只看「最近一次执行报错」的任务;一键切换成表格视图,信息密度更高。待处理列末尾常驻一个「新建任务」入口 —— 列里已有卡片时它照样在(列空时它还兼任空状态),点开的是和右上角按钮同一个新建弹窗 |
|
|
1069
1077
|
| 进行中卡片 | 正在跑的任务不只是右上角一个圆点:卡片上直接带着**执行器的品牌图标**(悬停看是哪个产品)、已运行时长、调用了多少次工具(悬停看类型分布)、最近一次工具调用、最近在思考什么、最新的回复 —— 一段时间没有产出时再补一句静默多久。某一项没有内容就**整行不渲染**(不写"暂无"),没在跑的任务则完全没有这块。这些事实与右栏进度报告同源(都来自执行记录),所以跟随看板 5 秒轮询刷新,别的 `g ui` 实例里跑的任务在这里同样看得到;切到表格视图时同一行摘要压在任务名下方。悬停时卡片右下角还会浮出 **停止** —— 位置正是空闲卡片摆「执行」的那一格(在跑时两者不并排:没有第二轮可跑),所以停掉一条卡住的任务不必再点进编辑器往下翻;它走的是与编辑器里那个「停止」**同一个接口、同一段确认文案**,而在**别的 g ui 实例**里跑的任务在这个窗口停不了 —— 服务端会直说这一句,而不是笼统回一句「停止失败」 |
|
|
1070
1078
|
| 跑完卡片 | 「已完成」列的卡片不再只有标题和时间:它留下一小段**智能体最后说的话** —— 最近一轮执行的正文尾部,压平成一句话、最多 100 字。这条最有用的时候正是它被做出来的理由:一次执行常常以反问收尾(「要 push 吗?」),标着"已完成"的任务其实在等你回一句话,而以前只有点进编辑器翻日志才知道。**执行器的品牌图标就挂在这段摘录的开头** —— 这句话是"它"说的,图标说的是哪个 CLI 说的,放在这里比放在卡片别处更直接。正在跑的任务仍然走上面那块活动区(两者互斥);那次执行没写正文时这段整块不渲染,不拿上一轮的旧话顶;完整摘录在悬停提示里。表格视图的任务名下方同样有这一行。图标只在**执行器认得出来**时才画:加 `agent` 字段之前跑的记录、或值不在已知执行器里的,一律不画,而不是猜一个品牌显示(表格视图里,正在跑的任务图标在状态摘要行、跑完的在最后回复行) |
|
|
1071
1079
|
| 主 Agent 控制台 | 两种工作方式,拨片切换、选择记在浏览器里:**对话** —— 直接跟内置 g ai 聊,由它按需把活派给某个项目(可多轮、可先问清再派);**指令** —— 你自己写一句话,落点由主 Agent 判断(或遵从此前选中的项目),Enter 派发、Shift+Enter 换行。两种方式落到**同一条派发链路与同一条指令流水**,差别只在"谁决定派什么"。调度可暂停 / 恢复,暂停期间派发只建任务不执行 |
|
|
1072
|
-
| 进度报告 | 「指令」模式下这块报的是**正在跑的任务现在到哪一步了**,而不是"谁派发了、谁完成了"(那些看板本身就能看出来)。主 Agent 会读一遍每个在跑的 job —— 已运行多久、**最近在想什么**、**最近 20 次都在调哪类工具**、**已经多久没动静**、最近一行输出 —— 写一段汇报:每个任务在做什么、走到哪一步、有没有卡住的迹象。每次汇报还带一条**进度条**:模型在正文之前先交它自己估的整体进度与每个任务各自的百分比,界面把它们画成条并标上「AI 估计」(悬停说明这个数字是怎么估出来的)。它是估计不是实测 —— 模型不肯给数字时不画那条,不会拿一个 0% 糊弄过去。**思考那一行是关键**:一句正文都不写的任务才是常态(它们的输出从头到尾是空的),只看输出就只能写"看不出来";而"是不是卡住了"现在必须给依据(静默时长、或工具分布是不是在原地打转),不许因为调用次数多就暗示卡住。报告按你设的间隔自动生成(5 / 10 / 15 / 30 / 60
|
|
1080
|
+
| 进度报告 | 「指令」模式下这块报的是**正在跑的任务现在到哪一步了**,而不是"谁派发了、谁完成了"(那些看板本身就能看出来)。主 Agent 会读一遍每个在跑的 job —— 已运行多久、**最近在想什么**、**最近 20 次都在调哪类工具**、**已经多久没动静**、最近一行输出 —— 写一段汇报:每个任务在做什么、走到哪一步、有没有卡住的迹象。每次汇报还带一条**进度条**:模型在正文之前先交它自己估的整体进度与每个任务各自的百分比,界面把它们画成条并标上「AI 估计」(悬停说明这个数字是怎么估出来的)。它是估计不是实测 —— 模型不肯给数字时不画那条,不会拿一个 0% 糊弄过去。**思考那一行是关键**:一句正文都不写的任务才是常态(它们的输出从头到尾是空的),只看输出就只能写"看不出来";而"是不是卡住了"现在必须给依据(静默时长、或工具分布是不是在原地打转),不许因为调用次数多就暗示卡住。报告按你设的间隔自动生成(5 / 10 / 15 / 30 / 60 分钟,也可以关掉),也能随时点「立即报告」;面板上展示最新那份及其依据(项目、已运行时长、工具分布、最近思考、最近一次工具调用、静默时长),更早的从下方历史列表里点开回看。**卡片分两层摆**:上面那段是主 Agent 自己的**判断**,下面「任务事实」那区是它**凭什么这么说** —— 每个任务一张卡(标题加粗、自己的进度条、三行证据前面各带一个「工具 / 思考 / 回复」的标签好一列扫下来),静默过久的那张卡整条描一遍告警色。这样正文与事实对不上时,你一眼能看出是模型在编,而不是两片同样灰度的小字糊在一起。右栏只有这么宽,正文默认只露 6 行(底下渐隐 + 「展开全文」,想看整段再点开),不然一段汇报会把下面几组任务事实整个顶出视口。自动报告由**服务端**定时产生,所以标签页关着也照样攒历史,回来就能看到;**没有任务在跑就不记条目** —— 自动报告直接跳过,「立即报告」改成回你一句"当前没有正在执行的任务"(都不白调一次模型);间隔内内容一样的两份也不重复落盘,连点两下只留一条。汇报按界面语言生成,输出看不出进度时会直说,而不是编一段像模像样的进展 |
|
|
1073
1081
|
| 静默收尾 | 一条任务静默超过 **10 分钟**(没有正文、没有思考、也没有工具调用)时,编排台会让主 Agent 读一遍它手上的事实 —— 最近在想什么、最后说了什么、工具分布长什么样 —— 判断它是**已经做完了**,还是还在干活 / 卡住了。判成做完就把这一轮落成终态:卡片从「进行中」挪到「已完成」,并在用时下面标一枚 **AI 判定完成**,悬停看模型给的依据。判据不足时**什么都不做**(只记一笔,等下一个 10 分钟再问,同一条最多问 3 次)—— 不动手,好过把还在跑的任务标成完成。它只改记录、**不动进程**:真要把挂住的进程收掉,仍然是卡片上那个「停止」。只判**本实例**跑的任务 —— 别的 `g ui` 窗口里派出去的那条,由那个窗口自己收尾 |
|
|
1074
1082
|
| 手动标记完成 | 列是执行事实推出来的,推不出两件只有你自己知道的事:一条躺在**待处理**里的任务其实早在别处干完了;一条**进行中**的任务模型已经不说话了,而你一眼能看出它做完了。这两种卡片现在 hover 会浮出一颗 **完成**(就在「执行 / 停止」旁边那一格),而**手动**收进已完成的卡片把那颗换成 **撤销**;自己跑完的卡片两颗都没有 —— 撤销一个它从来没有过的标记,点下去什么都不会发生。给正在跑的任务标完成会先问一句,并在同一次动作里把那一轮停掉(一张卡片不能既是「已完成」又在跑);跨窗口也一样:那个 job 活在别的 `g ui` 实例里时服务端直说原因,不假装标成功。标记记在任务身上,不补假的执行记录;这条任务之后再跑一轮,标记就作废,列重新跟着执行事实走。撤销是一键、不弹确认(它走的就是"点错了退回来"那条路),撤完回哪一列仍然由执行事实说了算 —— 所以那颗按钮只出现在"正是它把卡片留在已完成列"的卡上 |
|
|
1075
1083
|
| 派发执行器 | 派出去的任务由哪个 CLI 跑:**对话模式**在输入框下方选,**指令模式**在派发栏选 —— 两处是同一个组件、同一份选择,与执行按钮的临时切换也共用。注意「引擎」和「执行器」不是一回事:只有**内置 g ai** 能在服务端跑工具循环、才有派发能力,所以对话模式下把引擎切成外部 CLI 时,执行器选择会换成一句「当前引擎只能对话,不能派发任务 —— 切到内置 g ai 才能派活」的说明,而不是让你点了个不会生效的下拉 |
|
|
1076
1084
|
| 派发默认提示词 | 给每次派发配一段常驻提示词:一条**全局**的(所有项目都附加)+ 每个项目一条(派发到该项目时追加在全局之后,是补充而不是覆盖)。两者都在控制台输入区的齿轮按钮里设置;拼好的正文放在指令**之前**,「立即执行」旁边会多出一个「默认提示词(全局 + 本项目)」勾选,单次派发可以取消勾选,指令流水里也记下这条指令附带的是哪一级。提示词在派发那一刻就抄进任务自己的提示词字段,之后改设置不会回头改写已建任务,单条任务仍可再改 |
|
|
1077
1085
|
| 项目行打开方式 | 编排台项目列表里 hover 任意一行会出现两个按钮:「打开文件夹」一键进资源管理器,以及「打开方式」菜单 —— 文件管理器 / 终端 / 新标签页跑 `g ui`,以及各编辑器与 AI 工具(VS Code、Codex、OpenCode、Kimi Code、ZCode、DeepSeek Harness,加上默认权限或完全批准的 Claude Code)。没安装的工具会置灰并标「未安装」,点它弹的是顶栏那套安装引导;菜单里的动作只作用于该行项目,不会改变看板选中态 |
|
|
1086
|
+
| 移除死项目 | 目录已经不存在的项目行仍会留在清单里(清单是常用目录与任务路径两份来源的并集),这一行 hover 时不再给任何打开动作(点了只会报「无法打开目录」),只留一颗红色的**「从清单移除」**。确认框会写明**任务不会被删除**,成功提示还会带上保留的条数。移除后这一行从清单消失,而任务、执行记录、历史一条不动;目录哪天被克隆回来,它会自己现身 |
|
|
1078
1087
|
| 任务编辑器浮层 | 点击看板卡片时以浮层打开:左侧是扁平化的任务列表与提示词预置,右侧依次是任务头部、预置下拉、执行器 split 按钮、「复制执行内容 / 执行日志 / 清空执行」动作,以及对话式执行主体。描述默认折叠成一行「任务描述(可选)」摘要,点击展开;已填写描述或挂有附件时摘要右侧显示「已填写」徽标与附件数量。打开浮层时对话流直接落在**最新一轮**而不是第一轮:正文异步高亮与工具调用折叠会在挂载后继续长高,这段时间里持续贴底,用户自己一滚就立刻撒手 |
|
|
1079
1088
|
| 复制执行内容 | 任务头部的「复制执行内容」把**整条任务**(所有轮次,不只是屏幕上那一段)以纯 Markdown 放进剪贴板:先是任务与项目的标题行,然后一轮一节(`## 第 N 轮`,下面一行是本轮执行器、状态与开始时间),每节依次是用户提示词、智能体思考、工具调用(参数与结果都放在围栏代码块里)与模型返回。内容是从执行记录现拼的、不是从 DOM 里抠的,所以复制到什么跟你当前选中了哪段文字无关。用户侧走的是与对话气泡同一套裁剪,复制出来的就是你真正说过的话 —— 注入的环境块 / 记忆块 / 附件清单留在 `job.prompt` 里不跟着出去。一个字都没有的轮次会被跳过,且不让后面的轮次跟着往前重编号;从没跑过的任务回答「暂无执行内容可复制」,而不是把空串写进剪贴板 |
|
|
1080
1089
|
| 执行器选择 | 每个任务可用 **Claude Code** / **OpenCode** / **Codex** 执行。全局默认在 **设置 → 通用设置 → 任务执行器**(`config.taskExecutor`);执行按钮旁的 split 按钮可临时切换下一次执行用的执行器,选择记在浏览器里。续聊固定沿用最初那个执行器 —— Claude 的 `--resume`、OpenCode 的 `--session`、Codex 的 thread id 互不通用 |
|
|
@@ -1192,6 +1201,11 @@ $ g ai --model=2 # 使用第 2 个已配置的模型(序号或
|
|
|
1192
1201
|
直接输入数字跳转,`0` = 列表底部的"自定义 / 手动输入");非 TTY 环境下自动回退为数字输入。
|
|
1193
1202
|
`Esc` 或 `Ctrl+C` 一键取消整个向导。
|
|
1194
1203
|
|
|
1204
|
+
**多行粘贴**:直接粘一整段文本即可 —— 整段作为**一条**消息发出,换行原样保留。输入行里只显示
|
|
1205
|
+
一个短占位符(`[粘贴 #1 · 4 行]`),不会被撑成几十行;回车那一刻会把真正发出去的内容回显在提示
|
|
1206
|
+
符上方。用 ↑ 召回该行再回车,占位符会再次展开成同一段原文。终端不支持 bracketed paste 时
|
|
1207
|
+
(例如旧版 Windows 控制台宿主)回退为 readline 原生行为:粘贴逐行提交,且只有第一行会真正执行。
|
|
1208
|
+
|
|
1195
1209
|
`/skills`(`/mcp` 为别名)列出智能体当前已安装的 Skill 与 MCP 服务及来源。安装本身在 GUI 的
|
|
1196
1210
|
**Skill / MCP 广场**(智能体视图)里完成:选择安装到当前项目或 `g ai` 智能体,装好后对应一侧即可使用。
|
|
1197
1211
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zen-gitsync",
|
|
3
|
-
"version": "2.17.
|
|
3
|
+
"version": "2.17.36",
|
|
4
4
|
"description": "Auto commit, scheduled sync, and visual GUI for Git. Run `g` in any repo for one-key commit & push, AI commit messages, scheduled background sync, and a drag-and-drop workflow builder.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -53,6 +53,8 @@
|
|
|
53
53
|
"verify:wb-card-opened": "node scripts/verify-wb-card-opened.cjs",
|
|
54
54
|
"verify:wb-card-stop": "node scripts/verify-wb-card-stop.cjs",
|
|
55
55
|
"verify:wb-card-stop:reverse": "node scripts/verify-wb-card-stop.cjs --reverse",
|
|
56
|
+
"verify:wb-card-fade-band": "node scripts/verify-wb-card-fade-band.cjs",
|
|
57
|
+
"verify:wb-card-fade-band:reverse": "node scripts/verify-wb-card-fade-band.cjs --reverse",
|
|
56
58
|
"verify:wb-card-done": "node scripts/verify-wb-card-done.cjs",
|
|
57
59
|
"verify:wb-card-done:reverse": "node scripts/verify-wb-card-done.cjs --reverse",
|
|
58
60
|
"verify:wb-rerun-label": "node scripts/verify-wb-rerun-label.cjs",
|
|
@@ -64,6 +66,7 @@
|
|
|
64
66
|
"verify:wb-task-sound": "node scripts/verify-wb-task-sound.cjs",
|
|
65
67
|
"verify:wb-proj-branch": "node scripts/verify-workbench-proj-branch.cjs",
|
|
66
68
|
"verify:wb-proj-filter": "node scripts/verify-wb-proj-filter.cjs",
|
|
69
|
+
"verify:wb-proj-remove": "node scripts/verify-wb-project-remove.cjs",
|
|
67
70
|
"verify:wb-panel-resize": "node scripts/verify-wb-panel-resize.cjs",
|
|
68
71
|
"verify:wb-toolcalls": "node scripts/verify-workbench-toolcalls.mjs",
|
|
69
72
|
"verify:wb-responsive": "node scripts/verify-wb-responsive.cjs",
|
package/src/cli/ai/agent.js
CHANGED
|
@@ -51,10 +51,11 @@ import { repairToolHistory, loadProjectInstructions } from './context.js'
|
|
|
51
51
|
import {
|
|
52
52
|
printBanner, printHelpPanel,
|
|
53
53
|
filterSlashCommands, renderSlashHintBody, parseKeyForSlashHint,
|
|
54
|
-
printTurnSummary,
|
|
54
|
+
printTurnSummary, renderPasteEcho,
|
|
55
55
|
printOk, printWarn, printError, printDim,
|
|
56
56
|
} from './termui.js'
|
|
57
57
|
import { readClipboardImage, checkImageFile, formatBytes } from './images.js'
|
|
58
|
+
import { installPasteCapture, enableBracketedPaste, disableBracketedPaste } from './paste.js'
|
|
58
59
|
import { runModelSetup, collectModelInput, buildModelConfig, selectFromList } from './modelSetup.js'
|
|
59
60
|
import { genSessionId, autoTitle, writeSession, enforceRetention, listSessions } from './sessionStore.js'
|
|
60
61
|
|
|
@@ -113,6 +114,8 @@ const STRINGS = {
|
|
|
113
114
|
imageListEmpty: '当前没有待发送的图片(Alt+V 粘贴或 /image <路径> 附加)',
|
|
114
115
|
imageCleared: '已清除待发送的图片',
|
|
115
116
|
imageSending: (n) => `📎 附带 ${n} 张图片`,
|
|
117
|
+
pasteToken: (n, lines) => `[粘贴 #${n} · ${lines} 行]`,
|
|
118
|
+
pasteExpired: (n) => `有 ${n} 段粘贴内容已从内存回收,无法还原;如仍需要请重新粘贴`,
|
|
116
119
|
addModelTitle: '添加模型配置',
|
|
117
120
|
addModelSaved: (name) => `✓ 模型 "${name}" 已添加并切换为当前模型`,
|
|
118
121
|
addModelCancelled: '已取消添加模型',
|
|
@@ -170,6 +173,8 @@ const STRINGS = {
|
|
|
170
173
|
imageListEmpty: 'No pending images (Alt+V to paste, or /image <path>)',
|
|
171
174
|
imageCleared: 'Pending images cleared',
|
|
172
175
|
imageSending: (n) => `📎 ${n} image(s) attached`,
|
|
176
|
+
pasteToken: (n, lines) => `[paste #${n} · ${lines} lines]`,
|
|
177
|
+
pasteExpired: (n) => `${n} pasted block(s) were dropped from memory and could not be restored; paste again if still needed`,
|
|
173
178
|
addModelTitle: 'Add Model Configuration',
|
|
174
179
|
addModelSaved: (name) => `✓ Model "${name}" added and switched to`,
|
|
175
180
|
addModelCancelled: 'Add model cancelled',
|
|
@@ -416,6 +421,7 @@ function printSlashHelp(t, locale) {
|
|
|
416
421
|
' /exit, /quit 退出',
|
|
417
422
|
'',
|
|
418
423
|
' Alt+V 粘贴剪贴板图片,随下一条消息发送(需视觉模型)',
|
|
424
|
+
' 多行粘贴 直接粘贴多行文本:整段作为一条消息发送,输入行里显示 [粘贴 #N] 占位符',
|
|
419
425
|
'',
|
|
420
426
|
'权限说明: 工作目录内全部操作直接执行;其他目录可读写;',
|
|
421
427
|
'命令守卫识别部分系统级危险操作,不是隔离沙箱。',
|
|
@@ -437,6 +443,7 @@ function printSlashHelp(t, locale) {
|
|
|
437
443
|
' /exit, /quit Quit',
|
|
438
444
|
'',
|
|
439
445
|
' Alt+V Paste clipboard image (needs a vision-capable model)',
|
|
446
|
+
' Multi-line paste Paste a block as one message; the input line shows a [paste #N] placeholder',
|
|
440
447
|
'',
|
|
441
448
|
'Permissions: full access in the working directory; other dirs readable/writable;',
|
|
442
449
|
'the command guard detects some dangerous operations; it is not a sandbox.',
|
|
@@ -824,6 +831,9 @@ export async function runAiAgent(argv = []) {
|
|
|
824
831
|
state.cancelRequested = true
|
|
825
832
|
try { state.abortController?.abort() } catch (_) {}
|
|
826
833
|
await terminateCommand(state.currentChild)
|
|
834
|
+
// Ctrl+C 硬退时终端仍停在 bracketed paste 模式 → 之后 shell 里粘贴会带出 200~/201~ 乱码。
|
|
835
|
+
// 这个开关是终端层的,进程死了没人替它关,必须在这里补一刀。
|
|
836
|
+
disableBracketedPaste()
|
|
827
837
|
// MCP 子进程必须显式收掉:它们挂在 stdio 上,不收会让进程无法自然退出
|
|
828
838
|
await extensions.close()
|
|
829
839
|
await persistSession()
|
|
@@ -867,6 +877,13 @@ export async function runAiAgent(argv = []) {
|
|
|
867
877
|
})
|
|
868
878
|
state.rl = rl // 供 /addmodel 等需要 rl.question 的斜杠命令复用
|
|
869
879
|
|
|
880
|
+
// 多行粘贴:终端里粘一整段文本时,readline 会把每个 \n 当 Enter —— 一次粘贴变成多次提交,
|
|
881
|
+
// 第 2 条起还会撞上下面的 busy 分支被丢掉。这里在数据进 readline 之前把整段捕获成占位符,
|
|
882
|
+
// 提交时再还原(详见 paste.js)。必须在处理 line 事件之前装好。
|
|
883
|
+
const pasteCapture = installPasteCapture(rl, {
|
|
884
|
+
formatToken: ({ index, lines }) => t.pasteToken(index, lines),
|
|
885
|
+
})
|
|
886
|
+
|
|
870
887
|
// ask_user pauses the tool loop inside the same readline session. The REPL
|
|
871
888
|
// line handler is bypassed while this small wizard owns the input line.
|
|
872
889
|
state.ctx.askUser = ({ question, options = [], allowFreeText = true, multiple = false }) => {
|
|
@@ -995,6 +1012,9 @@ export async function runAiAgent(argv = []) {
|
|
|
995
1012
|
if (process.stdin.isTTY && !process.stdin.readableFlowing) {
|
|
996
1013
|
process.stdin.resume()
|
|
997
1014
|
}
|
|
1015
|
+
// bracketed paste 是终端层的开关,跑过的外部命令(全屏 TUI 之类)退出时可能把它关掉,
|
|
1016
|
+
// 之后粘贴又会退回"每个 \n 一次提交"。它是幂等的,每次画提示符时重申一次。
|
|
1017
|
+
enableBracketedPaste()
|
|
998
1018
|
if (process.stdout.isTTY) {
|
|
999
1019
|
// 确保光标在新行第 0 列(\r 回行首,\n 换行)
|
|
1000
1020
|
process.stdout.write('\r\n')
|
|
@@ -1017,6 +1037,7 @@ export async function runAiAgent(argv = []) {
|
|
|
1017
1037
|
const safeRefreshPrompt = () => {
|
|
1018
1038
|
try {
|
|
1019
1039
|
if (rl.closed) return
|
|
1040
|
+
enableBracketedPaste()
|
|
1020
1041
|
rl.prevRows = 0
|
|
1021
1042
|
rl.prompt()
|
|
1022
1043
|
} catch (_) {
|
|
@@ -1232,7 +1253,11 @@ export async function runAiAgent(argv = []) {
|
|
|
1232
1253
|
// /addmodel 等交互式向导进行中时,用户的回答由向导自身的 rl.question 处理,
|
|
1233
1254
|
// 不应进入 REPL 的正常输入流程(否则会把向导的答案当成命令/消息发给模型)
|
|
1234
1255
|
if (state.inWizard) return
|
|
1235
|
-
|
|
1256
|
+
// 把输入行里的粘贴占位符还原成原文(带真实换行)—— 在判断是不是斜杠命令之前,
|
|
1257
|
+
// 这样 `请写进 .env [粘贴 #1]` 这种混排也能正确展开
|
|
1258
|
+
const pasted = pasteCapture.expand(line)
|
|
1259
|
+
if (pasted.stale.length > 0) printWarn(t.pasteExpired(pasted.stale.length))
|
|
1260
|
+
const input = pasted.text.trim()
|
|
1236
1261
|
|
|
1237
1262
|
if (input.startsWith('/')) {
|
|
1238
1263
|
const r = await handleSlashCommand(state, input, t)
|
|
@@ -1252,6 +1277,12 @@ export async function runAiAgent(argv = []) {
|
|
|
1252
1277
|
const images = state.pendingImages.splice(0)
|
|
1253
1278
|
if (images.length > 0) printDim(t.imageSending(images.length))
|
|
1254
1279
|
|
|
1280
|
+
// 用了粘贴的这条消息:输入行里只有占位符,这里把真正发出去的内容亮一遍,
|
|
1281
|
+
// 否则回看终端记录只剩一个 [粘贴 #N],等于把内容藏起来了。
|
|
1282
|
+
// 只在 TTY 里回显 —— 管道/CI 下 readline 本来就不回显输入行(没有占位符这回事),
|
|
1283
|
+
// 再补一块只会粘在提示符后面变成半行。
|
|
1284
|
+
if (pasted.used > 0 && process.stdout.isTTY) process.stdout.write(renderPasteEcho(input, { locale: state.locale }))
|
|
1285
|
+
|
|
1255
1286
|
state.busy = true
|
|
1256
1287
|
state.cancelRequested = false
|
|
1257
1288
|
state.abortController = new AbortController()
|
|
@@ -1275,6 +1306,7 @@ export async function runAiAgent(argv = []) {
|
|
|
1275
1306
|
void terminateCommand(state.currentChild)
|
|
1276
1307
|
}
|
|
1277
1308
|
rl.input.removeListener('keypress', interceptInteractiveControlKeys)
|
|
1309
|
+
pasteCapture.dispose() // 卸载数据改写 + 把终端从 bracketed paste 模式里放出来
|
|
1278
1310
|
printWarn('\n' + t.bye)
|
|
1279
1311
|
})
|
|
1280
1312
|
|
|
@@ -122,3 +122,33 @@ test('CLI help lists thinking modes and /think full shows reasoning beyond the p
|
|
|
122
122
|
assert.doesNotMatch(output, /first 12 lines shown/)
|
|
123
123
|
assert.equal(f.requests.length, 2, 'display commands must not call the model')
|
|
124
124
|
})
|
|
125
|
+
|
|
126
|
+
// 多行粘贴:终端开了 bracketed paste 之后,一次粘贴是 `\x1b[200~…\x1b[201~` 包起来的一整段。
|
|
127
|
+
// 这里用真进程验证最关键的那条契约 —— 里面 3 个换行**不会**被当成 3 次回车,
|
|
128
|
+
// 模型收到的是一条消息,内容是带真实换行的原文。
|
|
129
|
+
test('multi-line paste is sent as one message with real newlines', { timeout: 15000 }, async t => {
|
|
130
|
+
const f = await fixture(t)
|
|
131
|
+
let stage = 0
|
|
132
|
+
const output = await run(f, [], (stdout, child) => {
|
|
133
|
+
if (stage === 0 && stdout.includes('Zen GitSync')) {
|
|
134
|
+
stage++
|
|
135
|
+
child.stdin.write('\x1b[200~RADAR_BASE_URL=https://example.test/v1\nRADAR_API_KEY=sk-secret\nRADAR_MODEL=deepseek\x1b[201~\n')
|
|
136
|
+
} else if (stage === 1 && stdout.includes('Token 180')) {
|
|
137
|
+
stage++
|
|
138
|
+
child.stdin.write('/exit\n')
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
assert.equal(stage, 2)
|
|
142
|
+
|
|
143
|
+
// 管道模式下 readline 不回显输入行,所以这里看不到 [paste #N] 占位符(TTY 里由
|
|
144
|
+
// readline 自己回显);占位符的生成/还原与 TTY 回显在 paste.test.js / termui.test.js 里单测覆盖。
|
|
145
|
+
// 这里只钉住真进程链路上最关键的两条:没有第 2、3 次提交,以及模型拿到的是带换行的原文。
|
|
146
|
+
assert.doesNotMatch(output, /Agent is working/)
|
|
147
|
+
|
|
148
|
+
const userMessage = f.requests[0].messages.at(-1)
|
|
149
|
+
assert.equal(userMessage.role, 'user')
|
|
150
|
+
assert.equal(
|
|
151
|
+
userMessage.content,
|
|
152
|
+
'RADAR_BASE_URL=https://example.test/v1\nRADAR_API_KEY=sk-secret\nRADAR_MODEL=deepseek'
|
|
153
|
+
)
|
|
154
|
+
})
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// g ai 输入层的多行粘贴支持(bracketed paste)
|
|
16
|
+
//
|
|
17
|
+
// 背景:Node 内建 readline **不支持 bracketed paste** —— 它从不发 \x1b[?2004h 去请求
|
|
18
|
+
// 终端包夹粘贴,也不认 \x1b[200~ / \x1b[201~。于是终端把粘贴原样发字节,粘贴里每个 \n
|
|
19
|
+
// 都被 readline 当成 Enter:一次粘贴 = 多次提交,而第 2 条起会撞上 agent.js 的 busy 分支
|
|
20
|
+
// 被丢弃 —— 用户粘的 4 行 .env 只有第一行真的发给了模型。
|
|
21
|
+
//
|
|
22
|
+
// 方案:
|
|
23
|
+
// 1. 自己开 bracketed paste(终端从此把整段粘贴包在 200~/201~ 之间)
|
|
24
|
+
// 2. 在数据进 readline **之前**把整段内容抠出来存进内存(createPasteStore)
|
|
25
|
+
// 3. 输入行里只放一个短占位符(如 `[粘贴 #1 · 4 行]`)
|
|
26
|
+
// 4. 提交时把占位符还原成原文(带真实换行)再交给模型
|
|
27
|
+
//
|
|
28
|
+
// 这样换来三件事:一次粘贴 = 一条消息;粘贴里的控制字符/转义序列根本进不了 readline
|
|
29
|
+
// (不会污染输入行);长粘贴不会把输入行撑成几十行。
|
|
30
|
+
//
|
|
31
|
+
// 拦截点:input.emit('data')。readline 的键位解析、回显、光标、历史全部照旧,我们只在
|
|
32
|
+
// 数据流上做一层"改写" —— 这也是唯一能抢在 readline 解析之前的钩子(readline 的
|
|
33
|
+
// keypress 是在 'data' 监听里同步派发的,光靠加监听器改不了它读到的内容)。
|
|
34
|
+
//
|
|
35
|
+
// 未启用/不支持的终端(旧 conhost、CI、管道):拿不到 200~/201~ 标记,一切照旧 ——
|
|
36
|
+
// 这个模块在数据流上不产生任何行为,不会把原本能用的输入搞坏。
|
|
37
|
+
|
|
38
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
39
|
+
|
|
40
|
+
export const PASTE_START = '\x1b[200~'
|
|
41
|
+
export const PASTE_END = '\x1b[201~'
|
|
42
|
+
|
|
43
|
+
// 判定"这段数据可能含粘贴标记"的探针。取 `\x1b[20` 而不是完整的 `\x1b[200~`:
|
|
44
|
+
// 标记可能被切在两个 chunk 里(只剩前半截),探针短一点才能命中。
|
|
45
|
+
// 副作用是 F9(\x1b[20~)之类也会走改写路径 —— 解析器找不到起止标记就原样返回,无影响。
|
|
46
|
+
const MARKER_NEEDLE = '\x1b[20'
|
|
47
|
+
|
|
48
|
+
const DEFAULT_FLUSH_MS = 1200 // 只收到起始标记、迟迟等不到结束标记时的兜底(见 kickTimer)
|
|
49
|
+
const DEFAULT_MAX_ENTRIES = 200
|
|
50
|
+
const DEFAULT_MAX_CHARS = 4 * 1024 * 1024
|
|
51
|
+
|
|
52
|
+
/** 粘贴原文归一化:CRLF/CR → LF,并去掉复制时最常见的那个行尾换行 */
|
|
53
|
+
export function normalizePasteText(raw) {
|
|
54
|
+
return String(raw ?? '').replace(/\r\n?/g, '\n').replace(/\n$/, '')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 默认占位符文案(agent.js 会传本地化版本进来) */
|
|
58
|
+
function defaultFormatToken({ index, lines }) {
|
|
59
|
+
return `[paste #${index} · ${lines} lines]`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 粘贴内容仓库:token ↔ 原文。
|
|
64
|
+
*
|
|
65
|
+
* token 直接当 Map 的 key,**不用正则解析** —— 这样占位符文案可以随语言自由变化,
|
|
66
|
+
* 还原时只认"自己发出去过的那个字符串",不会误伤用户手打的普通方括号文本。
|
|
67
|
+
*
|
|
68
|
+
* 容量:超过 maxEntries / maxChars 时淘汰最旧的(粘贴内容常是几 KB 的配置/日志)。
|
|
69
|
+
* 被淘汰的 token 记在 known 里,submit 时若又出现就报告 stale —— 场景是"很久以前从
|
|
70
|
+
* 历史里召回了带旧占位符的那行",此时原文已不在内存,必须让用户知道而不是静默出错。
|
|
71
|
+
*/
|
|
72
|
+
export function createPasteStore({
|
|
73
|
+
maxEntries = DEFAULT_MAX_ENTRIES,
|
|
74
|
+
maxChars = DEFAULT_MAX_CHARS,
|
|
75
|
+
formatToken = defaultFormatToken,
|
|
76
|
+
} = {}) {
|
|
77
|
+
const entries = new Map() // token -> 原文
|
|
78
|
+
const known = new Set() // 发出去过的所有 token(含已淘汰的)
|
|
79
|
+
let seq = 0
|
|
80
|
+
let chars = 0
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
/** 存一段粘贴原文,返回要插进输入行的占位符(内容为空则返回空串,不占位) */
|
|
84
|
+
add(rawText) {
|
|
85
|
+
const text = normalizePasteText(rawText)
|
|
86
|
+
if (!text) return ''
|
|
87
|
+
seq += 1
|
|
88
|
+
const token = formatToken({ index: seq, lines: text.split('\n').length, chars: text.length })
|
|
89
|
+
entries.set(token, text)
|
|
90
|
+
known.add(token)
|
|
91
|
+
chars += text.length
|
|
92
|
+
while (entries.size > maxEntries || chars > maxChars) {
|
|
93
|
+
const oldest = entries.keys().next().value
|
|
94
|
+
if (oldest === undefined) break
|
|
95
|
+
chars -= entries.get(oldest).length
|
|
96
|
+
entries.delete(oldest)
|
|
97
|
+
}
|
|
98
|
+
return token
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 还原一行输入里的所有占位符。
|
|
103
|
+
* @returns {{text:string, used:number, stale:string[]}}
|
|
104
|
+
* used = 命中的占位符个数(>0 表示这行确实用了粘贴,调用方据此决定要不要回显)
|
|
105
|
+
* stale = 原文已不在内存的占位符(调用方应提醒用户重新粘贴)
|
|
106
|
+
*/
|
|
107
|
+
expand(line) {
|
|
108
|
+
const text = String(line ?? '')
|
|
109
|
+
if (entries.size === 0) return { text, used: 0, stale: [] }
|
|
110
|
+
let out = text
|
|
111
|
+
let used = 0
|
|
112
|
+
for (const [token, body] of entries) {
|
|
113
|
+
if (!out.includes(token)) continue
|
|
114
|
+
out = out.split(token).join(body)
|
|
115
|
+
used += 1
|
|
116
|
+
}
|
|
117
|
+
const stale = []
|
|
118
|
+
// 只有淘汰发生过(known 比 entries 大)才需要扫,平时零成本
|
|
119
|
+
if (known.size > entries.size) {
|
|
120
|
+
for (const token of known) {
|
|
121
|
+
if (!entries.has(token) && out.includes(token)) stale.push(token)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { text: out, used, stale }
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
get size() {
|
|
128
|
+
return entries.size
|
|
129
|
+
},
|
|
130
|
+
/** 诊断/测试用 */
|
|
131
|
+
knownTokens() {
|
|
132
|
+
return [...known]
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** buffer 末尾是否是 marker 的前缀(跨 chunk 被切断)→ 返回要留到下一块的字符数 */
|
|
138
|
+
function markerPrefixSuffix(buf, marker) {
|
|
139
|
+
const max = Math.min(buf.length, marker.length - 1)
|
|
140
|
+
for (let n = max; n > 0; n--) {
|
|
141
|
+
if (buf.endsWith(marker.slice(0, n))) return n
|
|
142
|
+
}
|
|
143
|
+
return 0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 粘贴数据流过滤器:把流里的 `200~…201~` 段替换成占位符。
|
|
148
|
+
*
|
|
149
|
+
* 跨越 chunk 的状态(半个标记、未闭合的粘贴)都存在闭包里,所以可以放心地把
|
|
150
|
+
* 任意切分的 chunk 依次喂进来。
|
|
151
|
+
*
|
|
152
|
+
* @param {object} opts
|
|
153
|
+
* @param {ReturnType<typeof createPasteStore>} opts.store
|
|
154
|
+
* @param {(text:string)=>void} [opts.onFlush] - 兜底关闭粘贴时把占位符补写回流(见 kickTimer)
|
|
155
|
+
* @param {number} [opts.flushDelayMs]
|
|
156
|
+
*/
|
|
157
|
+
export function createPasteFilter({ store, onFlush, flushDelayMs = DEFAULT_FLUSH_MS } = {}) {
|
|
158
|
+
const decoder = new StringDecoder('utf8')
|
|
159
|
+
let pending = '' // 上块末尾可能是标记前缀的残字节
|
|
160
|
+
let content = '' // 正在收集的粘贴原文
|
|
161
|
+
let inPaste = false
|
|
162
|
+
let timer = null
|
|
163
|
+
|
|
164
|
+
const clearTimer = () => {
|
|
165
|
+
if (timer) { clearTimeout(timer); timer = null }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 兜底:只收到 200~ 却一直等不到 201~ 时(终端异常/连接断了),不能无限吞输入 ——
|
|
169
|
+
// 否则用户之后敲的每个字符都被当成粘贴内容吃掉,输入行彻底卡死。
|
|
170
|
+
// 每收到一块数据就重置计时,所以正常粘贴(哪怕几十 MB、跨很多 chunk)不会被误伤。
|
|
171
|
+
const kickTimer = () => {
|
|
172
|
+
clearTimer()
|
|
173
|
+
if (!Number.isFinite(flushDelayMs) || flushDelayMs <= 0) return
|
|
174
|
+
timer = setTimeout(() => {
|
|
175
|
+
timer = null
|
|
176
|
+
if (!inPaste) return
|
|
177
|
+
try {
|
|
178
|
+
onFlush?.(closePaste())
|
|
179
|
+
} catch { /* 兜底路径出问题也不能炸掉会话 */ }
|
|
180
|
+
}, flushDelayMs)
|
|
181
|
+
timer.unref?.()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 结束一段粘贴:内容入库 → 返回占位符(空内容返回空串,等于把这段整个吞掉)
|
|
185
|
+
const closePaste = () => {
|
|
186
|
+
const token = store.add(content)
|
|
187
|
+
content = ''
|
|
188
|
+
inPaste = false
|
|
189
|
+
clearTimer()
|
|
190
|
+
return token
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const toText = (chunk) => {
|
|
194
|
+
if (typeof chunk === 'string') {
|
|
195
|
+
// 正常路径下 stdin 不会设 encoding;万一设了,先把解码器里残留的半个字符吐出来
|
|
196
|
+
const rest = decoder.end()
|
|
197
|
+
return rest + chunk
|
|
198
|
+
}
|
|
199
|
+
return decoder.write(chunk)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
/**
|
|
204
|
+
* 这块数据要不要走改写。
|
|
205
|
+
* 普通打字(不含 ESC)直接返回 false → 原样透传,Buffer 不会被转成字符串,
|
|
206
|
+
* 也就完全不影响 readline 自己的多字节解码。
|
|
207
|
+
*/
|
|
208
|
+
needsRewrite(chunk) {
|
|
209
|
+
if (inPaste || pending) return true
|
|
210
|
+
if (typeof chunk === 'string') return chunk.includes(MARKER_NEEDLE)
|
|
211
|
+
return chunk.includes(0x1b)
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
/** 喂一块数据,返回要交给 readline 的文本 */
|
|
215
|
+
push(chunk) {
|
|
216
|
+
let buf = pending + toText(chunk)
|
|
217
|
+
pending = ''
|
|
218
|
+
let out = ''
|
|
219
|
+
while (buf.length > 0) {
|
|
220
|
+
if (!inPaste) {
|
|
221
|
+
const start = buf.indexOf(PASTE_START)
|
|
222
|
+
if (start < 0) {
|
|
223
|
+
const keep = markerPrefixSuffix(buf, PASTE_START)
|
|
224
|
+
out += buf.slice(0, buf.length - keep)
|
|
225
|
+
pending = buf.slice(buf.length - keep)
|
|
226
|
+
break
|
|
227
|
+
}
|
|
228
|
+
out += buf.slice(0, start)
|
|
229
|
+
buf = buf.slice(start + PASTE_START.length)
|
|
230
|
+
inPaste = true
|
|
231
|
+
content = ''
|
|
232
|
+
kickTimer()
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
const end = buf.indexOf(PASTE_END)
|
|
236
|
+
if (end < 0) {
|
|
237
|
+
const keep = markerPrefixSuffix(buf, PASTE_END)
|
|
238
|
+
content += buf.slice(0, buf.length - keep)
|
|
239
|
+
pending = buf.slice(buf.length - keep)
|
|
240
|
+
kickTimer()
|
|
241
|
+
break
|
|
242
|
+
}
|
|
243
|
+
content += buf.slice(0, end)
|
|
244
|
+
buf = buf.slice(end + PASTE_END.length)
|
|
245
|
+
out += closePaste()
|
|
246
|
+
}
|
|
247
|
+
return out
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
/** 结束标记一直没来时的收尾(定时器与 dispose 共用) */
|
|
251
|
+
flush() {
|
|
252
|
+
return inPaste ? closePaste() : ''
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
dispose() {
|
|
256
|
+
clearTimer()
|
|
257
|
+
pending = ''
|
|
258
|
+
content = ''
|
|
259
|
+
inPaste = false
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
get inPaste() {
|
|
263
|
+
return inPaste
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 终端/环境是否支持(且被允许)开 bracketed paste */
|
|
269
|
+
export function bracketedPasteEnabled(env = process.env, stream = process.stdout) {
|
|
270
|
+
if (env.ZEN_AI_NO_BRACKETED_PASTE) return false // 终端不兼容时的逃生开关
|
|
271
|
+
if (!stream?.isTTY) return false
|
|
272
|
+
if (env.TERM === 'dumb') return false
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 开 bracketed paste(DECSET 2004)。重复调用无副作用 —— 幂等,可用来在被外部程序关掉后重申 */
|
|
277
|
+
export function enableBracketedPaste(write = (s) => process.stdout.write(s), options = {}) {
|
|
278
|
+
if (!bracketedPasteEnabled(options.env, options.stream)) return false
|
|
279
|
+
write('\x1b[?2004h')
|
|
280
|
+
return true
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 关 bracketed paste。退出前必须调用,否则终端会一直把粘贴包夹住 */
|
|
284
|
+
export function disableBracketedPaste(write = (s) => process.stdout.write(s), options = {}) {
|
|
285
|
+
if (!bracketedPasteEnabled(options.env, options.stream)) return false
|
|
286
|
+
write('\x1b[?2004l')
|
|
287
|
+
return true
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 把粘贴捕获装到 readline 实例上。
|
|
292
|
+
*
|
|
293
|
+
* 做三件事:改 input.emit('data')、包一层 rl.question、开 bracketed paste。
|
|
294
|
+
* rl.question 必须单独包:`question` 进行中时 readline 直接把 this.line 交给回调,
|
|
295
|
+
* **不触发 'line' 事件**(实测 line 事件数为 0)—— 只拦 'data' 的话,向导里粘贴的
|
|
296
|
+
* 内容会以占位符形式落到答案里。
|
|
297
|
+
*
|
|
298
|
+
* @param {import('node:readline').Interface} rl
|
|
299
|
+
* @param {object} [options]
|
|
300
|
+
* @param {ReturnType<typeof createPasteStore>} [options.store]
|
|
301
|
+
* @param {(meta:{index:number,lines:number,chars:number})=>string} [options.formatToken]
|
|
302
|
+
* @returns {null | {store, expand, dispose}}
|
|
303
|
+
*/
|
|
304
|
+
export function installPasteCapture(rl, options = {}) {
|
|
305
|
+
const input = rl?.input
|
|
306
|
+
// 拿不到可改写的输入流时给一个空实现,而不是 null —— 调用方(agent.js)可以无条件
|
|
307
|
+
// 使用 expand/dispose,不必到处判空
|
|
308
|
+
if (!input || typeof input.emit !== 'function') {
|
|
309
|
+
return {
|
|
310
|
+
store: options.store || createPasteStore({ formatToken: options.formatToken }),
|
|
311
|
+
expand: (line) => ({ text: String(line ?? ''), used: 0, stale: [] }),
|
|
312
|
+
dispose() {},
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const store = options.store || createPasteStore({ formatToken: options.formatToken })
|
|
317
|
+
const hadOwnEmit = Object.prototype.hasOwnProperty.call(input, 'emit')
|
|
318
|
+
const previousEmit = input.emit
|
|
319
|
+
const callThrough = (...args) => previousEmit.apply(input, args)
|
|
320
|
+
const filter = createPasteFilter({
|
|
321
|
+
store,
|
|
322
|
+
flushDelayMs: options.flushDelayMs,
|
|
323
|
+
// 兜底关闭时占位符已经在数据流之后了,直接把它当一块数据补回去
|
|
324
|
+
onFlush: (text) => { if (text) callThrough('data', text) },
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
const patchedEmit = function (event, ...args) {
|
|
328
|
+
if (event !== 'data') return callThrough(event, ...args)
|
|
329
|
+
const chunk = args[0]
|
|
330
|
+
if (!filter.needsRewrite(chunk)) return callThrough(event, ...args)
|
|
331
|
+
return callThrough(event, filter.push(chunk))
|
|
332
|
+
}
|
|
333
|
+
input.emit = patchedEmit
|
|
334
|
+
|
|
335
|
+
const previousQuestion = rl.question
|
|
336
|
+
if (typeof previousQuestion === 'function') {
|
|
337
|
+
rl.question = function patchedQuestion(query, optsOrCb, maybeCb) {
|
|
338
|
+
if (typeof optsOrCb === 'function') {
|
|
339
|
+
return previousQuestion.call(this, query, (answer) => optsOrCb(store.expand(answer).text))
|
|
340
|
+
}
|
|
341
|
+
return previousQuestion.call(this, query, optsOrCb, (answer) => maybeCb(store.expand(answer).text))
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
enableBracketedPaste()
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
store,
|
|
349
|
+
expand: (line) => store.expand(line),
|
|
350
|
+
dispose() {
|
|
351
|
+
filter.dispose()
|
|
352
|
+
if (input.emit === patchedEmit) {
|
|
353
|
+
if (hadOwnEmit) input.emit = previousEmit
|
|
354
|
+
else delete input.emit
|
|
355
|
+
}
|
|
356
|
+
if (typeof previousQuestion === 'function') rl.question = previousQuestion
|
|
357
|
+
disableBracketedPaste()
|
|
358
|
+
},
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export default {
|
|
363
|
+
PASTE_START,
|
|
364
|
+
PASTE_END,
|
|
365
|
+
normalizePasteText,
|
|
366
|
+
createPasteStore,
|
|
367
|
+
createPasteFilter,
|
|
368
|
+
installPasteCapture,
|
|
369
|
+
bracketedPasteEnabled,
|
|
370
|
+
enableBracketedPaste,
|
|
371
|
+
disableBracketedPaste,
|
|
372
|
+
}
|