pi-better-btw-plus 1.1.0 → 1.1.1
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 +13 -11
- package/README.zh-CN.md +13 -11
- package/package.json +1 -1
- package/prompts/lane-preamble.md +1 -1
- package/prompts/lane-reminder-base.md +1 -1
- package/srcs/clipboard-read.ts +16 -13
- package/srcs/config.ts +45 -66
- package/srcs/fork-turn.ts +32 -2
- package/srcs/index.ts +4 -17
- package/srcs/pointer-gesture.ts +1 -1
- package/srcs/shortcuts.ts +38 -1
- package/srcs/side-chat-overlay.ts +69 -26
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ You're in the middle of a longer task and want to ask something small without de
|
|
|
32
32
|
Everything in [`@yceachan/pi-better-btw`](https://www.npmjs.com/package/@yceachan/pi-better-btw) is here, plus:
|
|
33
33
|
|
|
34
34
|
- **Right-click copy & paste** — drag-select chat text and right-click to copy (Windows Terminal muscle memory); right-click inside the input editor pastes the system clipboard through the editor's own normalization and `[paste #N …]` markers for large pastes. No more hotkey-only copying.
|
|
35
|
-
- **Fork model switching (`
|
|
35
|
+
- **Fork model switching (`Ctrl+L`)** — pick any authenticated model for the side chat without rebuilding the fork; fork-local only (ADR 0002), thinking level auto-clamped to the new model's capabilities.
|
|
36
36
|
- **Turn-level auto-retry** — shares the main session's `settings.retry` budget: transient provider errors back off and retry with a live countdown; `Esc` cancels.
|
|
37
37
|
- **Feature kill switches** — the layered config's `features` block turns any of the above off (`rightClickCopyPaste` / `modelSwitch` / `retry`), plus `readOnlyExtensionAllowlistExclude` to drop bundled allowlist defaults.
|
|
38
38
|
|
|
@@ -54,7 +54,7 @@ See [Feat](#feat) for the full feature set.
|
|
|
54
54
|
|
|
55
55
|
- `TUI scroll, select, copy`: subscribes to mouse/hotkey events in the TUI overlay for scrolling, text selection, and `Ctrl+C` copy.
|
|
56
56
|
|
|
57
|
-
- `Readonly/Edit Mode`: read-only by default to answer btw questions; if you want the agent to make small edits along the way, `
|
|
57
|
+
- `Readonly/Edit Mode`: read-only by default to answer btw questions; if you want the agent to make small edits along the way, `Alt+T` switches to edit mode.
|
|
58
58
|
|
|
59
59
|
- ToolAllowList: bundle + config.json custom
|
|
60
60
|
|
|
@@ -72,13 +72,13 @@ Press `Esc` to close it. Reopen with `/btw` or `Alt+W` to continue where you lef
|
|
|
72
72
|
| Shortcut | Action |
|
|
73
73
|
| -------- | ------ |
|
|
74
74
|
| `Alt+W` | Open (when closed) / background (when visible) / restore (when hidden) |
|
|
75
|
-
| `
|
|
75
|
+
| `Alt+T` | Toggle read-only / edit mode |
|
|
76
76
|
| `Alt+R` | Re-fork from the latest main context |
|
|
77
77
|
| `Alt+N` | Start an empty conversation |
|
|
78
78
|
| `Alt+E` | Export the transcript to `$CWD/.agents/eval/pi-better-btw-<timestamp>.md` |
|
|
79
|
-
| `
|
|
79
|
+
| `Ctrl+L` | Open the fork model picker (scoped + authenticated models; `↑/↓` select, `Enter` confirm, `Esc` cancel) |
|
|
80
80
|
|
|
81
|
-
In Read-only Mode (default), the read-only lane is **enforced**: attempting an out-of-lane tool call is hard-blocked with a prompt injection; a second violation escalates the wording and aborts the turn (a `🚧 lane blocked` status line). Executed-but-failed read-only calls are re-grounded by an `afterToolCall` note. Edit mode (`
|
|
81
|
+
In Read-only Mode (default), the read-only lane is **enforced**: attempting an out-of-lane tool call is hard-blocked with a prompt injection; a second violation escalates the wording and aborts the turn (a `🚧 lane blocked` status line). Executed-but-failed read-only calls are re-grounded by an `afterToolCall` note. Edit mode (`Alt+T`) is unaffected.
|
|
82
82
|
|
|
83
83
|
**Peek at the main agent** — the `peek_main` tool reads recent activity from the main session.
|
|
84
84
|
|
|
@@ -98,7 +98,7 @@ What changed since I opened this side chat?
|
|
|
98
98
|
**Right-click paste in the input box** — right-click inside the editor pastes the system clipboard at the cursor through the editor's built-in paste entry: line endings/tabs are normalized (`\r`→`\n`, `\t`→4 spaces), large pastes (>10 lines or >1000 chars) collapse to a `[paste #N +X lines]` / `[paste #N X chars]` marker that expands back to full text on submit, and the paste is a single undo step. When the clipboard can't be read (platform channel + OSC 52 fallback both unavailable) or holds no text, a one-line hint appears and the editor is left untouched.
|
|
99
99
|
|
|
100
100
|
**Transcript export** — `Alt+E` dumps the btw history (forked context, framing block, conversation, in-flight stream) to `$CWD/.agents/eval/pi-better-btw-<timestamp>.md` as a markdown diagnostic artifact, useful for debugging feature work.
|
|
101
|
-
**Fork model switching** — `
|
|
101
|
+
**Fork model switching** — `Ctrl+L` opens a model picker inside the overlay (`↑/↓` move, `Enter` confirm, `Esc` cancel). It lists the session's scoped models first (`--models` / `enabledModels`), falling back to the available catalogue, and only shows models with configured auth. Confirming swaps the fork agent's runtime model — the next turn uses it without rebuilding the fork — and clamps the thinking level to the new model's capabilities (no-reasoning models go to `off`). The choice is fork-local (ADR 0002): the main session's model is never touched. It survives backgrounding (`Alt+W`) and resets on `Alt+R`/`Alt+N`/`Esc` close. The header shows the current fork model; opening is rejected while streaming.
|
|
102
102
|
**Auto-retry (turn-level)** — reads pi's `settings.retry` budget (`enabled` / `maxRetries` / `baseDelayMs`, same defaults as the main session). Transient provider errors (overloaded / rate limit / 5xx) auto-retry with exponential backoff — the status area shows `Retrying (n/m) in Xs…` with a live countdown — and the failed assistant message is stripped before the retry so it never re-enters the next request. Context overflow and aborts never retry. `Esc` during the backoff cancels the wait and surfaces the last error as the final result; budget exhaustion does the same. With `enabled: false` (e.g. local-model debugging) errors surface immediately with zero overhead.
|
|
103
103
|
|
|
104
104
|
|
|
@@ -112,14 +112,16 @@ What changed since I opened this side chat?
|
|
|
112
112
|
| `Alt+R` | Re-fork from latest main context |
|
|
113
113
|
| `Alt+N` | Start empty conversation |
|
|
114
114
|
| `Alt+E` | Export the btw chat history to `$CWD/.agents/eval/pi-better-btw-<timestamp>.md` |
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
115
|
+
| `Ctrl+L` | Open the fork model picker (`↑/↓` select, `Enter` confirm, `Esc` cancel) |
|
|
116
|
+
| `Alt+T` | Toggle read-only / edit mode |
|
|
117
117
|
| `PgUp` / `PgDn` | Scroll history by a page |
|
|
118
118
|
| `Shift+↑` / `Shift+↓` | Scroll by a few lines |
|
|
119
119
|
| Mouse wheel | Scroll when the pointer is over the chat |
|
|
120
120
|
| Mouse drag | Select text in the chat area (inverse-video highlight); no copy on release |
|
|
121
121
|
| Double-click | Select the whole rendered line |
|
|
122
122
|
| `Ctrl+C` / `Ctrl+Shift+C` | Copy the active mouse selection (the selection is kept until you click elsewhere, so repeated presses re-copy) |
|
|
123
|
+
| `Ctrl+X` | Copy the last assistant message |
|
|
124
|
+
| `Ctrl+V` / `Alt+V` | Paste the system clipboard at the cursor (same normalization + `[paste #N …]` markers as right-click paste) |
|
|
123
125
|
| Mouse right-click (chat area) | Copy the retained mouse selection (fires on release, keeps the highlight) |
|
|
124
126
|
| Mouse right-click (input editor) | Paste the system clipboard at the cursor (editor normalization + `[paste #N …]` markers for large pastes) |
|
|
125
127
|
## Command Reference
|
|
@@ -161,7 +163,7 @@ Keys:
|
|
|
161
163
|
| Switch | Behavior when `false` |
|
|
162
164
|
|--------|------------------------|
|
|
163
165
|
| `rightClickCopyPaste` | right-click copy (chat) / paste (editor) is inert — the hotkeys still work |
|
|
164
|
-
| `modelSwitch` | `
|
|
166
|
+
| `modelSwitch` | `Ctrl+L` does nothing |
|
|
165
167
|
| `retry` | fork turns run a single attempt with zero backoff, even if pi's `settings.retry` is enabled |
|
|
166
168
|
|
|
167
169
|
Example (user or project layer):
|
|
@@ -215,8 +217,8 @@ Structure:
|
|
|
215
217
|
│ ├── side-chat-mouse.ts # minimal SGR mouse parsing
|
|
216
218
|
│ ├── clipboard-read.ts # platform clipboard read (win32 / darwin / linux + OSC 52 fallback)
|
|
217
219
|
│ ├── retry.ts # turn-level retry: classifyRetryable + runWithRetry
|
|
218
|
-
│ ├── shortcuts.ts # hotkey bindings (Alt+W /
|
|
219
|
-
│ ├── model-switch.ts #
|
|
220
|
+
│ ├── shortcuts.ts # hotkey bindings (Alt+W / Alt+T)
|
|
221
|
+
│ ├── model-switch.ts # Ctrl+L fork model picker: list building + thinking clamp
|
|
220
222
|
│ ├── side-chat-export.ts # Alt+E transcript export
|
|
221
223
|
│ ├── tool-wrapper.ts # write-path overlap warnings
|
|
222
224
|
│ └── file-activity-tracker.ts
|
package/README.zh-CN.md
CHANGED
|
@@ -32,7 +32,7 @@ pi install npm:pi-better-btw-plus
|
|
|
32
32
|
[`@yceachan/pi-better-btw`](https://www.npmjs.com/package/@yceachan/pi-better-btw) 的全部功能都在,另新增:
|
|
33
33
|
|
|
34
34
|
- **右键复制与粘贴** —— 聊天区拖选后右键复制(延续 Windows Terminal 肌肉记忆);输入框右键从系统剪贴板粘贴,走编辑器内置归一化与大段 `[paste #N …]` 折叠标记。不再是 hotkey-only。
|
|
35
|
-
- **Fork 模型切换(`
|
|
35
|
+
- **Fork 模型切换(`Ctrl+L`)** —— 侧聊内选择任意已认证模型,无需重建 fork;仅作用于 fork 本身(ADR 0002),thinking level 按新模型能力自动钳制。
|
|
36
36
|
- **Turn 级自动重试** —— 与主会话共用 `settings.retry` 预算:瞬时 provider 错误指数退避重试,带实时倒计时;`Esc` 取消。
|
|
37
37
|
- **功能开关** —— 分层配置的 `features` 块可关闭以上任意功能(`rightClickCopyPaste` / `modelSwitch` / `retry`),另有 `readOnlyExtensionAllowlistExclude` 移除内置 allowlist 默认项。
|
|
38
38
|
|
|
@@ -54,7 +54,7 @@ pi install npm:pi-better-btw-plus
|
|
|
54
54
|
|
|
55
55
|
- `TUI scroll,select,copy` :在TUI-overlay 自订阅鼠标/hotkey事件,实现滚屏 ,text选中 , Ctrl +C 复制功能
|
|
56
56
|
|
|
57
|
-
- `Readonly/Edit Mode` : 默认只读来回应btw question,如果你希望Agent顺手做些小修改,
|
|
57
|
+
- `Readonly/Edit Mode` : 默认只读来回应btw question,如果你希望Agent顺手做些小修改,Alt + T To Edit Mode.
|
|
58
58
|
|
|
59
59
|
- ToolAllowList : bundle + config.json custom
|
|
60
60
|
|
|
@@ -74,13 +74,13 @@ pi install npm:pi-better-btw-plus
|
|
|
74
74
|
| 快捷键 | 作用 |
|
|
75
75
|
| ------ | ---- |
|
|
76
76
|
| `Alt+W` | 打开(关闭时)/ 后台化(显示时)/ 恢复(隐藏时) |
|
|
77
|
-
| `
|
|
77
|
+
| `Alt+T` | 切换只读 / 编辑模式 |
|
|
78
78
|
| `Alt+R` | 从最新主线上下文重新 fork |
|
|
79
79
|
| `Alt+N` | 开始空白对话 |
|
|
80
80
|
| `Alt+E` | 导出对话记录到 `$CWD/.agents/eval/pi-better-btw-<timestamp>.md` |
|
|
81
|
-
| `
|
|
81
|
+
| `Ctrl+L` | 打开 fork 模型选择器(scoped + 已认证模型;`↑/↓` 选择,`Enter` 确认,`Esc` 取消) |
|
|
82
82
|
|
|
83
|
-
在Readonly Mode(default),只读车道是**强制的**:越权调用工具会被硬阻断并注入prompt;第二次违规会升级措辞并中止该轮,提示(`🚧 lane blocked` 状态行)。已执行但失败的只读调用会被 `afterToolCall` 备注再次归位。编辑模式(`
|
|
83
|
+
在Readonly Mode(default),只读车道是**强制的**:越权调用工具会被硬阻断并注入prompt;第二次违规会升级措辞并中止该轮,提示(`🚧 lane blocked` 状态行)。已执行但失败的只读调用会被 `afterToolCall` 备注再次归位。编辑模式(`Alt+T`)不受影响。
|
|
84
84
|
|
|
85
85
|
**窥视主线 agent** —— `peek_main` 工具读取主线会话的近期活动。
|
|
86
86
|
|
|
@@ -101,7 +101,7 @@ What changed since I opened this side chat?
|
|
|
101
101
|
|
|
102
102
|
**对话导出** —— `Alt+E` 把 btw 历史(fork 上下文、framing 块、对话、流式中内容)导出为 `$CWD/.agents/eval/pi-better-btw-<timestamp>.md` 的 markdown 诊断产物,便于调试功能开发。
|
|
103
103
|
|
|
104
|
-
**Fork 模型切换** —— `
|
|
104
|
+
**Fork 模型切换** —— `Ctrl+L` 在浮层内打开模型选择器(`↑/↓` 移动,`Enter` 确认,`Esc` 取消)。列表优先展示会话作用域模型(`--models` / `enabledModels`),为空时回退可用模型目录,且只显示已配置认证的模型。确认后直接替换 fork agent 的运行时模型——下一 turn 生效,无需重建 fork——并按新模型能力钳制 thinking level(无 reasoning 的模型钳制为 `off`)。选择为 fork 局部状态(ADR 0002):主会话模型不受影响。后台化(`Alt+W`)保留选择;`Alt+R` / `Alt+N` / `Esc` 关闭后随实例重置。头部显示当前 fork 模型;流式期间拒绝打开。
|
|
105
105
|
|
|
106
106
|
## 快捷键
|
|
107
107
|
|
|
@@ -113,14 +113,16 @@ What changed since I opened this side chat?
|
|
|
113
113
|
| `Alt+R` | 从最新主线上下文重新 fork |
|
|
114
114
|
| `Alt+N` | 开始空白对话 |
|
|
115
115
|
| `Alt+E` | 导出 btw 对话历史到 `$CWD/.agents/eval/pi-better-btw-<timestamp>.md` |
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
116
|
+
| `Ctrl+L` | 打开 fork 模型选择器(`↑/↓` 选择,`Enter` 确认,`Esc` 取消) |
|
|
117
|
+
| `Alt+T` | 切换只读 / 编辑模式 |
|
|
118
118
|
| `PgUp` / `PgDn` | 整页滚动历史 |
|
|
119
119
|
| `Shift+↑` / `Shift+↓` | 按行滚动 |
|
|
120
120
|
| 鼠标滚轮 | 指针位于聊天区域时滚动 |
|
|
121
121
|
| 鼠标拖拽 | 选择聊天文本(反色高亮);松开不自动复制 |
|
|
122
122
|
| 双击 | 选择整行 |
|
|
123
123
|
| `Ctrl+C` / `Ctrl+Shift+C` | 复制当前鼠标选择(选择保留到下次点击,可重复复制) |
|
|
124
|
+
| `Ctrl+X` | 复制最后一条 assistant 消息 |
|
|
125
|
+
| `Ctrl+V` / `Alt+V` | 把系统剪贴板粘贴到光标处(归一化 + `[paste #N …]` 标记,同右键粘贴) |
|
|
124
126
|
| 鼠标右键(聊天区) | 复制保留的鼠标选择(松开时触发,保持高亮) |
|
|
125
127
|
| 鼠标右键(输入框) | 把系统剪贴板粘贴到光标处(编辑器归一化 + 大段 `[paste #N …]` 标记) |
|
|
126
128
|
## 命令参考
|
|
@@ -162,7 +164,7 @@ pi-better-btw 从三个位置按优先级递增读取 `config.json` —— 每
|
|
|
162
164
|
| 开关 | 为 `false` 时的行为 |
|
|
163
165
|
| ---- | ---- |
|
|
164
166
|
| `rightClickCopyPaste` | 右键复制(聊天区)/ 粘贴(输入框)失效——快捷键不受影响 |
|
|
165
|
-
| `modelSwitch` | `
|
|
167
|
+
| `modelSwitch` | `Ctrl+L` 无反应 |
|
|
166
168
|
| `retry` | fork 每轮只跑一次尝试、零退避,即使 pi 的 `settings.retry` 开启 |
|
|
167
169
|
|
|
168
170
|
示例(用户或项目层):
|
|
@@ -216,8 +218,8 @@ btw 上下文保留主线的 system prompt 于 system 槽位,并逐字注入 f
|
|
|
216
218
|
│ ├── side-chat-mouse.ts # 最小 SGR 鼠标解析
|
|
217
219
|
│ ├── clipboard-read.ts # 平台剪贴板读取(win32 / darwin / linux + OSC 52 兜底)
|
|
218
220
|
│ ├── retry.ts # turn 级重试引擎:classifyRetryable + runWithRetry
|
|
219
|
-
│ ├── model-switch.ts #
|
|
220
|
-
│ ├── shortcuts.ts # 快捷键绑定(Alt+W /
|
|
221
|
+
│ ├── model-switch.ts # Ctrl+L fork 模型选择器:列表构建 + thinking 钳制
|
|
222
|
+
│ ├── shortcuts.ts # 快捷键绑定(Alt+W / Alt+T)
|
|
221
223
|
│ ├── side-chat-export.ts # Alt+E 对话导出
|
|
222
224
|
│ ├── tool-wrapper.ts # 写路径重叠警告
|
|
223
225
|
│ └── file-activity-tracker.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-btw-plus",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "pi extension: /btw side-chat overlay — maintained fork of @yceachan/pi-better-btw (+ right-click copy/paste, fork model switch, turn-level retry)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/prompts/lane-preamble.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
You are in the read-only lane: only read/grep/find/ls, allowlisted tools and peek_main are available here. You cannot write files, edit, or execute commands — that is the main lane's job. If a write is genuinely needed, tell the user to switch to edit mode (
|
|
1
|
+
You are in the read-only lane: only read/grep/find/ls, allowlisted tools and peek_main are available here. You cannot write files, edit, or execute commands — that is the main lane's job. If a write is genuinely needed, tell the user to switch to edit mode (Alt+T) or ask the main agent.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
🚧 Lane blocked: `{{tool}}` is not available in this side chat. You are the btw side chat — a quick-question lane parallel to the main agent; the main line is the main agent's job. Answer the latest user message only, with read-only tools (read/grep/find/ls + allowlisted tools). If a write is genuinely needed, tell the user to switch to edit mode (
|
|
1
|
+
🚧 Lane blocked: `{{tool}}` is not available in this side chat. You are the btw side chat — a quick-question lane parallel to the main agent; the main line is the main agent's job. Answer the latest user message only, with read-only tools (read/grep/find/ls + allowlisted tools). If a write is genuinely needed, tell the user to switch to edit mode (Alt+T).
|
package/srcs/clipboard-read.ts
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The write side is pi's own `copyToClipboard` (public export); the read side
|
|
6
6
|
* is self-built because `readClipboardText` is not re-exported from the
|
|
7
|
-
* package entry.
|
|
7
|
+
* package entry. The PRIMARY channel mirrors pi's own `readClipboardText` (the
|
|
8
|
+
* `@mariozechner/clipboard` native addon); the remaining channels — PowerShell
|
|
9
|
+
* `Get-Clipboard -Raw` (win32), `pbpaste` (darwin), and an OSC 52 query — are
|
|
10
|
+
* extension-only fallbacks pi's read side does not have:
|
|
8
11
|
*
|
|
9
|
-
* - win32 → native addon `getText
|
|
10
|
-
*
|
|
12
|
+
* - win32 → native addon `getText`, then PowerShell `Get-Clipboard -Raw`,
|
|
13
|
+
* then an OSC 52 query;
|
|
11
14
|
* - darwin → native addon `getText`, then `pbpaste`, then OSC 52 query;
|
|
12
15
|
* - linux → OSC 52 query (`\x1b]52;c;?\x07`, read the reply).
|
|
13
16
|
*
|
|
@@ -124,11 +127,11 @@ export interface CommandChannelOptions {
|
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
/**
|
|
127
|
-
* PowerShell
|
|
128
|
-
*
|
|
129
|
-
* `-Raw` flag preserves the exact text (no extra newline), and the
|
|
130
|
-
* encoding is pinned to UTF-8 because the text travels over the console
|
|
131
|
-
* pipe
|
|
130
|
+
* PowerShell read fallback (extension-only; pi's readClipboardText has no
|
|
131
|
+
* PowerShell channel): `powershell.exe -NoProfile -Command "Get-Clipboard
|
|
132
|
+
* -Raw"`. The `-Raw` flag preserves the exact text (no extra newline), and the
|
|
133
|
+
* output encoding is pinned to UTF-8 because the text travels over the console
|
|
134
|
+
* pipe.
|
|
132
135
|
*/
|
|
133
136
|
const PS_GET_CLIPBOARD_SCRIPT =
|
|
134
137
|
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; Get-Clipboard -Raw";
|
|
@@ -244,11 +247,11 @@ export function makeNativeChannel(
|
|
|
244
247
|
}
|
|
245
248
|
|
|
246
249
|
/**
|
|
247
|
-
* PowerShell
|
|
248
|
-
*
|
|
249
|
-
* `-Raw` flag preserves the exact text (no extra newline), and the
|
|
250
|
-
* encoding is pinned to UTF-8 because the text travels over the console
|
|
251
|
-
* pipe
|
|
250
|
+
* PowerShell read fallback (extension-only; pi's readClipboardText has no
|
|
251
|
+
* PowerShell channel): `powershell.exe -NoProfile -Command "Get-Clipboard
|
|
252
|
+
* -Raw"`. The `-Raw` flag preserves the exact text (no extra newline), and the
|
|
253
|
+
* output encoding is pinned to UTF-8 because the text travels over the console
|
|
254
|
+
* pipe.
|
|
252
255
|
*/
|
|
253
256
|
export function makePowerShellChannel(
|
|
254
257
|
options: CommandChannelOptions = {},
|
package/srcs/config.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import type { PromptPackManifest } from "./prompt-pack.ts";
|
|
5
|
-
import {
|
|
6
|
+
import type { ProviderRetrySettings } from "./provider-retry.ts";
|
|
6
7
|
import type { RetryPolicy } from "./retry.ts";
|
|
7
8
|
/**
|
|
8
9
|
* Layered config resolution for pi-better-btw.
|
|
@@ -37,7 +38,7 @@ import type { RetryPolicy } from "./retry.ts";
|
|
|
37
38
|
export interface SideChatFeatures {
|
|
38
39
|
/** Right-click copy (chat selection) / paste (editor). Default: true. */
|
|
39
40
|
rightClickCopyPaste: boolean;
|
|
40
|
-
/**
|
|
41
|
+
/** Ctrl+L fork model picker. Default: true. */
|
|
41
42
|
modelSwitch: boolean;
|
|
42
43
|
/** Turn-level auto-retry of transient provider errors. Default: true. */
|
|
43
44
|
retry: boolean;
|
|
@@ -66,16 +67,15 @@ export const USER_CONFIG_DIR = join(homedir(), ".pi", "agent", CONFIG_SUBDIR);
|
|
|
66
67
|
export const AGENT_CONFIG_DIR = join(homedir(), ".pi", "agent");
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
|
-
* Read pi's `settings.retry` budget (D8)
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* (
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* nothing; a present-but-unreadable file warns instead of failing the fork.
|
|
70
|
+
* Read pi's `settings.retry` budget (D8) via pi's own `SettingsManager` —
|
|
71
|
+
* file reading, deep merge (project wins per key), legacy migration
|
|
72
|
+
* (`retry.maxDelayMs` → `retry.provider.maxRetryDelayMs`) and the canonical
|
|
73
|
+
* defaults (enabled=true, maxRetries=3, baseDelayMs=2000) all come from pi; no
|
|
74
|
+
* hand-rolled duplicate. The `retry.provider` block (spec #20 D4: timeoutMs /
|
|
75
|
+
* maxRetries / maxRetryDelayMs) is forwarded only when a provider block is
|
|
76
|
+
* actually configured, and only number keys; it is consumed solely by the
|
|
77
|
+
* overlay's stream assembly, never by the turn loop. A present-but-unreadable
|
|
78
|
+
* settings file warns and contributes nothing (pi's own fallback).
|
|
79
79
|
*/
|
|
80
80
|
export interface LoadRetryPolicyOptions {
|
|
81
81
|
/** Agent config dir holding pi's global settings.json (~/.pi/agent). */
|
|
@@ -89,65 +89,44 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
|
89
89
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/** Deep merge like pi's deepMergeSettings: nested plain objects merge, arrays/others replace. */
|
|
93
|
-
function deepMergeSettings(
|
|
94
|
-
base: Record<string, unknown>,
|
|
95
|
-
overrides: Record<string, unknown>,
|
|
96
|
-
): Record<string, unknown> {
|
|
97
|
-
const result = { ...base };
|
|
98
|
-
for (const [key, value] of Object.entries(overrides)) {
|
|
99
|
-
const baseValue = result[key];
|
|
100
|
-
result[key] =
|
|
101
|
-
isPlainRecord(baseValue) && isPlainRecord(value)
|
|
102
|
-
? deepMergeSettings(baseValue, value)
|
|
103
|
-
: value;
|
|
104
|
-
}
|
|
105
|
-
return result;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function readSettingsFile(
|
|
109
|
-
path: string,
|
|
110
|
-
onWarning?: (message: string) => void,
|
|
111
|
-
): Record<string, unknown> {
|
|
112
|
-
try {
|
|
113
|
-
const raw: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
114
|
-
return isPlainRecord(raw) ? raw : {};
|
|
115
|
-
} catch {
|
|
116
|
-
if (existsSync(path) && onWarning) {
|
|
117
|
-
onWarning(`pi-better-btw: ignoring invalid settings ${path}`);
|
|
118
|
-
}
|
|
119
|
-
return {};
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
92
|
|
|
123
93
|
export function loadRetryPolicy(options: LoadRetryPolicyOptions = {}): RetryPolicy {
|
|
124
94
|
const agentConfigDir = options.agentConfigDir ?? AGENT_CONFIG_DIR;
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
95
|
+
const settings = SettingsManager.create(options.cwd ?? process.cwd(), agentConfigDir, {
|
|
96
|
+
projectTrusted: true,
|
|
97
|
+
});
|
|
98
|
+
// Surface load errors (invalid JSON etc.) through the caller's warning hook,
|
|
99
|
+
// mirroring the old loader's present-but-unreadable → warn behavior.
|
|
100
|
+
for (const err of settings.drainErrors()) {
|
|
101
|
+
options.onWarning?.(`pi-better-btw: ignoring invalid settings (${err.scope})`);
|
|
102
|
+
}
|
|
103
|
+
const retry = settings.getRetrySettings();
|
|
104
|
+
// Provider block (spec #20): forward only number keys from an actually
|
|
105
|
+
// configured `retry.provider`. getProviderRetrySettings() defaults
|
|
106
|
+
// maxRetryDelayMs to 60000 even when no block exists, so configured-ness is
|
|
107
|
+
// detected from the (already-migrated) per-scope settings; an absent block
|
|
108
|
+
// keeps `provider` undefined so the overlay's stream assembly stays identity
|
|
109
|
+
// (D3: unconfigured users keep the bare streamSimple, zero overhead).
|
|
110
|
+
const providerRetry = settings.getProviderRetrySettings();
|
|
111
|
+
const providerConfigured = [
|
|
112
|
+
settings.getGlobalSettings(),
|
|
113
|
+
settings.getProjectSettings(),
|
|
114
|
+
].some((scope) => isPlainRecord(scope.retry) && isPlainRecord(scope.retry.provider));
|
|
115
|
+
const providerBlock: ProviderRetrySettings = {};
|
|
116
|
+
if (providerConfigured) {
|
|
117
|
+
// Number filter kept (vs. blindly forwarding getProviderRetrySettings) to
|
|
118
|
+
// keep the D3 identity contract stable: non-number keys never reach
|
|
119
|
+
// injectProviderRetry, and an unconfigured provider stays undefined.
|
|
120
|
+
if (typeof providerRetry.timeoutMs === "number") providerBlock.timeoutMs = providerRetry.timeoutMs;
|
|
121
|
+
if (typeof providerRetry.maxRetries === "number") providerBlock.maxRetries = providerRetry.maxRetries;
|
|
122
|
+
if (typeof providerRetry.maxRetryDelayMs === "number")
|
|
123
|
+
providerBlock.maxRetryDelayMs = providerRetry.maxRetryDelayMs;
|
|
140
124
|
}
|
|
141
125
|
return {
|
|
142
|
-
enabled:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
baseDelayMs:
|
|
147
|
-
typeof retry.baseDelayMs === "number" ? retry.baseDelayMs : 2000,
|
|
148
|
-
...(Object.keys(providerSettings).length > 0
|
|
149
|
-
? { provider: providerSettings }
|
|
150
|
-
: {}),
|
|
126
|
+
enabled: retry.enabled,
|
|
127
|
+
maxRetries: retry.maxRetries,
|
|
128
|
+
baseDelayMs: retry.baseDelayMs,
|
|
129
|
+
...(Object.keys(providerBlock).length > 0 ? { provider: providerBlock } : {}),
|
|
151
130
|
};
|
|
152
131
|
}
|
|
153
132
|
|
package/srcs/fork-turn.ts
CHANGED
|
@@ -71,7 +71,7 @@ export interface ForkTurnRunnerOptions {
|
|
|
71
71
|
retryPolicy: RetryPolicy;
|
|
72
72
|
/** Resolved prompt texts (focus anchor + lane reminders). */
|
|
73
73
|
promptPack: PromptPack;
|
|
74
|
-
/** Live lane predicate (tool-mode closure; live after
|
|
74
|
+
/** Live lane predicate (tool-mode closure; live after Alt+T). */
|
|
75
75
|
isReadOnlyLane: () => boolean;
|
|
76
76
|
/** Read-only tool-set membership (tool-set closure). */
|
|
77
77
|
isReadOnlyTool: (name: string) => boolean;
|
|
@@ -82,9 +82,14 @@ export interface ForkTurnRunnerOptions {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export class ForkTurnRunner {
|
|
85
|
-
/** The owned fork agent; the overlay reaches it for model picker / export /
|
|
85
|
+
/** The owned fork agent; the overlay reaches it for model picker / export / Alt+T. */
|
|
86
86
|
readonly agent: Agent;
|
|
87
87
|
|
|
88
|
+
/** Fork boundary index: transcript length at construction (forked context +
|
|
89
|
+
* framing block). Messages at or after it are the side chat's own; before
|
|
90
|
+
* it is the inherited forked context (CONTEXT.md: Fork boundary). */
|
|
91
|
+
private readonly forkBoundaryIndex: number;
|
|
92
|
+
|
|
88
93
|
/** Out-of-lane attempts in the current turn (reset at the top of run()). */
|
|
89
94
|
private laneViolations = 0;
|
|
90
95
|
/** Reminder queued for injection by transformContext before the next LLM call. */
|
|
@@ -105,6 +110,7 @@ export class ForkTurnRunner {
|
|
|
105
110
|
beforeToolCall: (context) => this.beforeToolCall(context),
|
|
106
111
|
afterToolCall: (context) => this.afterToolCall(context),
|
|
107
112
|
});
|
|
113
|
+
this.forkBoundaryIndex = this.agent.state.messages.length;
|
|
108
114
|
this.agent.subscribe((event) => this.handleAgentEvent(event));
|
|
109
115
|
}
|
|
110
116
|
|
|
@@ -177,6 +183,30 @@ export class ForkTurnRunner {
|
|
|
177
183
|
this.agent.abort();
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
/**
|
|
187
|
+
* The text of the last assistant message the side chat produced itself
|
|
188
|
+
* (text blocks joined, or its errorMessage for an error stop) — searched
|
|
189
|
+
* from the fork boundary, never reaching back into the inherited forked
|
|
190
|
+
* context. Undefined when no such assistant message has text.
|
|
191
|
+
*/
|
|
192
|
+
getLastAssistantText(): string | undefined {
|
|
193
|
+
const messages = this.agent.state.messages;
|
|
194
|
+
const start = this.forkBoundaryIndex;
|
|
195
|
+
for (let i = messages.length - 1; i >= start; i--) {
|
|
196
|
+
const msg = messages[i];
|
|
197
|
+
if (msg.role !== "assistant") continue;
|
|
198
|
+
let text = msg.content
|
|
199
|
+
.filter((block) => block.type === "text")
|
|
200
|
+
.map((block) => block.text)
|
|
201
|
+
.join("\n");
|
|
202
|
+
if (!text && "errorMessage" in msg && typeof msg.errorMessage === "string") {
|
|
203
|
+
text = msg.errorMessage;
|
|
204
|
+
}
|
|
205
|
+
if (text) return text;
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
180
210
|
// --- Agent event → phase translation --------------------------------------
|
|
181
211
|
|
|
182
212
|
private handleAgentEvent(event: AgentEvent): void {
|
package/srcs/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { OverlayHandle, Terminal, TUI } from "@earendil-works/pi-tui";
|
|
|
8
8
|
import {
|
|
9
9
|
buildSessionContext,
|
|
10
10
|
ExtensionRunner,
|
|
11
|
+
wrapRegisteredTools,
|
|
11
12
|
} from "@earendil-works/pi-coding-agent";
|
|
12
13
|
import { FileActivityTracker } from "./file-activity-tracker.ts";
|
|
13
14
|
import { loadConfig, loadRetryPolicy } from "./config.ts";
|
|
@@ -45,23 +46,9 @@ if (!(ExtensionRunner.prototype as unknown as Record<symbol, unknown>)[RUNNER_CA
|
|
|
45
46
|
|
|
46
47
|
function getExtensionAgentTools(): AgentTool[] {
|
|
47
48
|
if (!capturedRunner) return [];
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
name: definition.name,
|
|
52
|
-
label: definition.label,
|
|
53
|
-
description: definition.description,
|
|
54
|
-
parameters: definition.parameters,
|
|
55
|
-
execute: (toolCallId, params, signal, onUpdate) =>
|
|
56
|
-
definition.execute(
|
|
57
|
-
toolCallId,
|
|
58
|
-
params,
|
|
59
|
-
signal,
|
|
60
|
-
onUpdate,
|
|
61
|
-
capturedRunner!.createContext(),
|
|
62
|
-
),
|
|
63
|
-
};
|
|
64
|
-
});
|
|
49
|
+
// pi's official RegisteredTool → AgentTool adapter (runner.createContext() +
|
|
50
|
+
// dynamic-tool `addedToolNames` tracking) — replaces the hand-rolled mapping.
|
|
51
|
+
return wrapRegisteredTools(capturedRunner.getAllRegisteredTools(), capturedRunner);
|
|
65
52
|
}
|
|
66
53
|
|
|
67
54
|
const OVERLAY_BLOCKED_ERROR = "PI_SIDE_CHAT_OVERLAY_BLOCKED";
|
package/srcs/pointer-gesture.ts
CHANGED
|
@@ -98,7 +98,7 @@ export interface PointerGestureOptions {
|
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
100
|
* Pointer gesture state machine. One instance per overlay; `onEvent` is
|
|
101
|
-
* called for every SGR event routed to the overlay (the
|
|
101
|
+
* called for every SGR event routed to the overlay (the Ctrl+L modal gate
|
|
102
102
|
* lives in the overlay, the module knows nothing about the modal).
|
|
103
103
|
*/
|
|
104
104
|
export class PointerGesture {
|
package/srcs/shortcuts.ts
CHANGED
|
@@ -1,4 +1,41 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { matchesKey, type KeyId } from "@earendil-works/pi-tui";
|
|
2
2
|
|
|
3
3
|
/** Open, background, or restore the side chat from the main editor. */
|
|
4
4
|
export const SIDE_CHAT_SHORTCUT: KeyId = "alt+w";
|
|
5
|
+
|
|
6
|
+
interface Keybinding {
|
|
7
|
+
readonly keys: readonly KeyId[];
|
|
8
|
+
readonly hint: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Side-chat keybindings — the single source of truth for each action's bound
|
|
13
|
+
* key(s) AND its hint-bar label. `handleInput` matches against `.keys` and the
|
|
14
|
+
* hint bar reads `.hint`, so a rebind updates matching and label together
|
|
15
|
+
* instead of scattering magic strings across handleInput / hints / docs.
|
|
16
|
+
*/
|
|
17
|
+
export const KEYBINDINGS = {
|
|
18
|
+
/** Open (closed) / background (visible) / restore (hidden). */
|
|
19
|
+
background: { keys: [SIDE_CHAT_SHORTCUT], hint: "A+w bg" },
|
|
20
|
+
/** Re-fork from the latest main context. */
|
|
21
|
+
refork: { keys: ["alt+r"], hint: "A+r fork" },
|
|
22
|
+
/** Start an empty conversation. */
|
|
23
|
+
clear: { keys: ["alt+n"], hint: "A+n new" },
|
|
24
|
+
/** Export the transcript. */
|
|
25
|
+
export: { keys: ["alt+e"], hint: "A+e export" },
|
|
26
|
+
/** Fork model picker (pi `app.model.select` parity). */
|
|
27
|
+
modelPicker: { keys: ["ctrl+l"], hint: "C+l model" },
|
|
28
|
+
/** Read-only / edit mode toggle (Ctrl+T freed for pi's thinking toggle). */
|
|
29
|
+
toggleMode: { keys: ["alt+t"], hint: "A+t" },
|
|
30
|
+
/** Copy the active mouse selection (pi `tui.input.copy` parity). */
|
|
31
|
+
copySelection: { keys: ["ctrl+c", "ctrl+shift+c"], hint: "C+c copy" },
|
|
32
|
+
/** Copy the last side-chat assistant message (pi `app.message.copy` parity). */
|
|
33
|
+
copyLastMessage: { keys: ["ctrl+x"], hint: "C+x last" },
|
|
34
|
+
/** Paste clipboard text (pi `app.clipboard.pasteImage` parity). */
|
|
35
|
+
paste: { keys: ["ctrl+v", "alt+v"], hint: "C+v paste" },
|
|
36
|
+
} as const satisfies Record<string, Keybinding>;
|
|
37
|
+
|
|
38
|
+
/** True when `data` matches any of the keys. */
|
|
39
|
+
export function matchesAnyKey(data: string, keys: readonly KeyId[]): boolean {
|
|
40
|
+
return keys.some((key) => matchesKey(data, key));
|
|
41
|
+
}
|
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
modelKey,
|
|
63
63
|
type ModelChoice,
|
|
64
64
|
} from "./model-switch.ts";
|
|
65
|
-
import {
|
|
65
|
+
import { KEYBINDINGS, matchesAnyKey } from "./shortcuts.ts";
|
|
66
66
|
import { wrapToolsWithOverlapDetection } from "./tool-wrapper.ts";
|
|
67
67
|
import type { SideChatFeatures } from "./config.ts";
|
|
68
68
|
import type { RetryPolicy } from "./retry.ts";
|
|
@@ -132,6 +132,8 @@ const PASTE_FAILED_STATUS = "Clipboard read failed";
|
|
|
132
132
|
/** Hint when the clipboard is readable but holds no text. */
|
|
133
133
|
const PASTE_EMPTY_STATUS = "Clipboard is empty";
|
|
134
134
|
const PASTE_STATUS_CLEAR_MS = 1200;
|
|
135
|
+
/** Ctrl+X hint when the side chat has produced no assistant reply yet. */
|
|
136
|
+
const NO_ASSISTANT_MESSAGE_STATUS = "No assistant message to copy yet";
|
|
135
137
|
|
|
136
138
|
/**
|
|
137
139
|
* Shared-prefix layout (#9, reverses decision #6): the main lane's system
|
|
@@ -171,7 +173,7 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
171
173
|
private forkedMessageCount: number;
|
|
172
174
|
/** Tool names allowed in the read-only lane (builtins + allowlist + peek_main). */
|
|
173
175
|
private readOnlyToolNames = new Set<string>();
|
|
174
|
-
/** Open
|
|
176
|
+
/** Open Ctrl+L model picker modal, or null when closed (modal replaces the chat area). */
|
|
175
177
|
private modelPicker: SelectList | null = null;
|
|
176
178
|
/** Choices backing the open picker (index-aligned with its SelectItems). */
|
|
177
179
|
private modelPickerChoices: ModelChoice[] = [];
|
|
@@ -274,6 +276,16 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
274
276
|
async copySelectionToClipboard(): Promise<boolean> {
|
|
275
277
|
const text = this.messages.getSelectedText();
|
|
276
278
|
if (!text) return false;
|
|
279
|
+
return this.copyTextWithFeedback(text);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Copy text to the system clipboard and surface the outcome: a success
|
|
284
|
+
* flash on the status line (labelled by `hint`) or an error line on
|
|
285
|
+
* failure. The shared skeleton behind selection copy (Ctrl+C / right-click)
|
|
286
|
+
* and last-message copy (Ctrl+X).
|
|
287
|
+
*/
|
|
288
|
+
private async copyTextWithFeedback(text: string, hint?: string): Promise<boolean> {
|
|
277
289
|
try {
|
|
278
290
|
await copyToClipboard(text);
|
|
279
291
|
} catch (error) {
|
|
@@ -283,11 +295,26 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
283
295
|
this.options.tui.requestRender();
|
|
284
296
|
return false;
|
|
285
297
|
}
|
|
286
|
-
const
|
|
287
|
-
|
|
298
|
+
const label = hint
|
|
299
|
+
? `${hint} · ${Array.from(text).length} chars`
|
|
300
|
+
: `${Array.from(text).length} chars`;
|
|
301
|
+
this.status.flash(`${COPIED_STATUS_PREFIX}${label}`, COPIED_STATUS_CLEAR_MS);
|
|
288
302
|
return true;
|
|
289
303
|
}
|
|
290
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Ctrl+X (app.message.copy parity): copy the last assistant message the
|
|
307
|
+
* side chat produced itself — never the forked main-lane context. No-op
|
|
308
|
+
* with a hint when the side chat has produced no reply yet.
|
|
309
|
+
*/
|
|
310
|
+
async copyLastAssistantMessage(): Promise<void> {
|
|
311
|
+
const text = this.runner.getLastAssistantText();
|
|
312
|
+
if (!text) {
|
|
313
|
+
this.status.flash(NO_ASSISTANT_MESSAGE_STATUS, COPIED_STATUS_CLEAR_MS);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
await this.copyTextWithFeedback(text, "last message");
|
|
317
|
+
}
|
|
291
318
|
/**
|
|
292
319
|
* Right-click paste (issue #7, D4): read plain text from the system
|
|
293
320
|
* clipboard via the injected platform-channel matrix (clipboard-read.ts,
|
|
@@ -422,7 +449,7 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
422
449
|
// SGR events into actions; the overlay translates them onto the message
|
|
423
450
|
// store and render loop (applyGestureAction). The hit queries bridge the
|
|
424
451
|
// module's 0-based screen coordinates to the overlay's geometry. The
|
|
425
|
-
//
|
|
452
|
+
// Ctrl+L modal gate stays in handleMouseEvent — the module knows nothing
|
|
426
453
|
// about the modal.
|
|
427
454
|
this.gesture = new PointerGesture({
|
|
428
455
|
hit: {
|
|
@@ -473,7 +500,7 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
473
500
|
enabled: options.features.retry && options.retryPolicy.enabled,
|
|
474
501
|
},
|
|
475
502
|
promptPack,
|
|
476
|
-
// Live closures:
|
|
503
|
+
// Live closures: Alt+T toggles toolMode (the runner re-reads it per
|
|
477
504
|
// event); the read-only tool set is fixed at open time.
|
|
478
505
|
isReadOnlyLane: () => this.toolMode === "read-only",
|
|
479
506
|
isReadOnlyTool: (name) => this.readOnlyToolNames.has(name),
|
|
@@ -612,7 +639,7 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
612
639
|
}
|
|
613
640
|
|
|
614
641
|
/**
|
|
615
|
-
* Re-substitute the framing block with the fork's CURRENT model (
|
|
642
|
+
* Re-substitute the framing block with the fork's CURRENT model (Ctrl+L may
|
|
616
643
|
* have switched `agent.state.model`, D5). The framing text is built once at
|
|
617
644
|
* open time with the main session's model; the message lives in the
|
|
618
645
|
* transcript (marked, never rendered as a bubble), so refreshing its content
|
|
@@ -637,7 +664,7 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
637
664
|
if (!trimmed || this.runner.isRunning || this.disposed) return;
|
|
638
665
|
|
|
639
666
|
// Keep the framing block's `Model:` line in sync with the fork's current
|
|
640
|
-
// model (
|
|
667
|
+
// model (Ctrl+L, D5): the text is substituted once at open time with the
|
|
641
668
|
// main session's model, so without this refresh the agent self-reports the
|
|
642
669
|
// old model after a switch (bug #3).
|
|
643
670
|
this.refreshFramingModel();
|
|
@@ -785,7 +812,9 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
785
812
|
|
|
786
813
|
const escHint = this.runner.isRunning ? "Esc stop" : "Esc close";
|
|
787
814
|
const modeHint =
|
|
788
|
-
this.toolMode === "read-only"
|
|
815
|
+
this.toolMode === "read-only"
|
|
816
|
+
? `${KEYBINDINGS.toggleMode.hint} Edit`
|
|
817
|
+
: `${KEYBINDINGS.toggleMode.hint} Readonly`;
|
|
789
818
|
const scrolled = !this.messages.isAtBottom();
|
|
790
819
|
const scrollHint = scrolled
|
|
791
820
|
? theme.fg(
|
|
@@ -833,7 +862,7 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
833
862
|
|
|
834
863
|
handleInput(data: string): void {
|
|
835
864
|
// Backgrounding (Alt+W) while the picker is open cancels the modal first.
|
|
836
|
-
if (
|
|
865
|
+
if (matchesAnyKey(data, KEYBINDINGS.background.keys)) {
|
|
837
866
|
this.closeModelPicker();
|
|
838
867
|
this.options.onBackground();
|
|
839
868
|
return;
|
|
@@ -857,26 +886,23 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
857
886
|
}
|
|
858
887
|
return;
|
|
859
888
|
}
|
|
860
|
-
if (
|
|
889
|
+
if (matchesAnyKey(data, KEYBINDINGS.refork.keys)) {
|
|
861
890
|
this.dispose("refork");
|
|
862
891
|
return;
|
|
863
892
|
}
|
|
864
|
-
if (
|
|
893
|
+
if (matchesAnyKey(data, KEYBINDINGS.clear.keys)) {
|
|
865
894
|
this.dispose("clear");
|
|
866
895
|
return;
|
|
867
896
|
}
|
|
868
|
-
if (
|
|
897
|
+
if (matchesAnyKey(data, KEYBINDINGS.export.keys)) {
|
|
869
898
|
this.exportChatHistory();
|
|
870
899
|
return;
|
|
871
900
|
}
|
|
872
|
-
if (
|
|
901
|
+
if (matchesAnyKey(data, KEYBINDINGS.modelPicker.keys)) {
|
|
873
902
|
this.openModelPicker();
|
|
874
903
|
return;
|
|
875
904
|
}
|
|
876
|
-
if (
|
|
877
|
-
matchesKey(data, Key.ctrl("c")) ||
|
|
878
|
-
matchesKey(data, Key.ctrlShift("c"))
|
|
879
|
-
) {
|
|
905
|
+
if (matchesAnyKey(data, KEYBINDINGS.copySelection.keys)) {
|
|
880
906
|
// Hotkey copy: with an active mouse selection, Ctrl+C / Ctrl+Shift+C
|
|
881
907
|
// copies it. Without one, fall through so Ctrl+C keeps the editor's
|
|
882
908
|
// own semantics.
|
|
@@ -885,7 +911,19 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
885
911
|
return;
|
|
886
912
|
}
|
|
887
913
|
}
|
|
888
|
-
if (
|
|
914
|
+
if (matchesAnyKey(data, KEYBINDINGS.copyLastMessage.keys)) {
|
|
915
|
+
// app.message.copy parity: copy the last assistant message — no
|
|
916
|
+
// selection needed (mirrors the main session's Ctrl+X default).
|
|
917
|
+
void this.copyLastAssistantMessage();
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
if (matchesAnyKey(data, KEYBINDINGS.paste.keys)) {
|
|
921
|
+
// app.clipboard.pasteImage parity: paste clipboard text into the editor
|
|
922
|
+
// (Ctrl+V; Alt+V is the Windows/WSL binding).
|
|
923
|
+
void this.pasteFromClipboard();
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (matchesAnyKey(data, KEYBINDINGS.toggleMode.keys)) {
|
|
889
927
|
this.toolMode = this.toolMode === "full" ? "read-only" : "full";
|
|
890
928
|
// Read-only lane keeps the strip philosophy; edit mode stays untouched
|
|
891
929
|
// (enforcement out of scope until the crash bug is understood, #4).
|
|
@@ -915,14 +953,14 @@ export class SideChatOverlay implements Component, Focusable {
|
|
|
915
953
|
}
|
|
916
954
|
|
|
917
955
|
/**
|
|
918
|
-
*
|
|
956
|
+
* Ctrl+L: open the fork model picker as a modal inside the overlay (D7).
|
|
919
957
|
* The list shows scoped + authenticated models, falling back to the
|
|
920
958
|
* available catalogue (D6). Rejected while streaming: swapping the model
|
|
921
959
|
* mid-turn would corrupt the in-flight request.
|
|
922
960
|
*/
|
|
923
961
|
private openModelPicker(): void {
|
|
924
962
|
if (this.modelPicker) return;
|
|
925
|
-
// Feature switch (D11):
|
|
963
|
+
// Feature switch (D11): Ctrl+L is inert when model switching is off.
|
|
926
964
|
if (!this.options.features.modelSwitch) return;
|
|
927
965
|
if (this.runner.isRunning) {
|
|
928
966
|
this.status.setSteady("feedback", {
|
|
@@ -1142,8 +1180,13 @@ function frameLine(
|
|
|
1142
1180
|
}
|
|
1143
1181
|
|
|
1144
1182
|
/** Alt-actions hint row base; the model entry is appended only when the switch is on. */
|
|
1145
|
-
const ALT_ACTIONS_BASE =
|
|
1146
|
-
|
|
1183
|
+
const ALT_ACTIONS_BASE = [
|
|
1184
|
+
KEYBINDINGS.background.hint,
|
|
1185
|
+
KEYBINDINGS.refork.hint,
|
|
1186
|
+
KEYBINDINGS.clear.hint,
|
|
1187
|
+
KEYBINDINGS.export.hint,
|
|
1188
|
+
].join(" · ");
|
|
1189
|
+
const ALT_ACTION_HINTS = `${ALT_ACTIONS_BASE} · ${KEYBINDINGS.modelPicker.hint}`;
|
|
1147
1190
|
/**
|
|
1148
1191
|
* Build the fixed two-row key-hint bar. Row 1: scrolling, copy, mode toggle,
|
|
1149
1192
|
* Esc and send; row 2: the Alt-actions (Alt abbreviated as A, A+w = Alt+W).
|
|
@@ -1163,13 +1206,13 @@ export function buildSideChatHintLines(options: {
|
|
|
1163
1206
|
const rightClickHint = features.rightClickCopyPaste
|
|
1164
1207
|
? " · R-click copy/paste"
|
|
1165
1208
|
: "";
|
|
1166
|
-
const primary = `${scrollHint} ·
|
|
1167
|
-
const secondary = `${ALT_ACTIONS_BASE}${features.modelSwitch ?
|
|
1209
|
+
const primary = `${scrollHint} · ${KEYBINDINGS.copySelection.hint} · ${KEYBINDINGS.copyLastMessage.hint} · ${KEYBINDINGS.paste.hint}${rightClickHint} · ${modeHint} · ${escHint} · Enter send`;
|
|
1210
|
+
const secondary = `${ALT_ACTIONS_BASE}${features.modelSwitch ? ` · ${KEYBINDINGS.modelPicker.hint}` : ""}`;
|
|
1168
1211
|
return [primary, secondary];
|
|
1169
1212
|
}
|
|
1170
1213
|
|
|
1171
1214
|
/**
|
|
1172
|
-
* Hint bar while the
|
|
1215
|
+
* Hint bar while the Ctrl+L model picker modal is open: row 1 switches to
|
|
1173
1216
|
* the picker keys, row 2 keeps the Alt-actions (still two rows, so the
|
|
1174
1217
|
* message-area height stays stable).
|
|
1175
1218
|
*/
|