pi-better-btw-plus 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 (`Alt+M`)** — 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.
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, `Ctrl+T` switches to edit mode.
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
- | `Ctrl+T` | Toggle read-only / edit mode |
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
- | `Alt+M` | Open the fork model picker (scoped + authenticated models; `↑/↓` select, `Enter` confirm, `Esc` cancel) |
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 (`Ctrl+T`) is unaffected.
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** — `Alt+M` 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.
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
- | `Alt+M` | Open the fork model picker (`↑/↓` select, `Enter` confirm, `Esc` cancel) |
116
- | `Ctrl+T` | Toggle read-only / edit mode |
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` | `Alt+M` does nothing |
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 / Ctrl+T)
219
- │ ├── model-switch.ts # Alt+M fork model picker: list building + thinking clamp
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 模型切换(`Alt+M`)** —— 侧聊内选择任意已认证模型,无需重建 fork;仅作用于 fork 本身(ADR 0002),thinking level 按新模型能力自动钳制。
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顺手做些小修改,Ctrl + t To Edit Mode.
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
- | `Ctrl+T` | 切换只读 / 编辑模式 |
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
- | `Alt+M` | 打开 fork 模型选择器(scoped + 已认证模型;`↑/↓` 选择,`Enter` 确认,`Esc` 取消) |
81
+ | `Ctrl+L` | 打开 fork 模型选择器(scoped + 已认证模型;`↑/↓` 选择,`Enter` 确认,`Esc` 取消) |
82
82
 
83
- 在Readonly Mode(default),只读车道是**强制的**:越权调用工具会被硬阻断并注入prompt;第二次违规会升级措辞并中止该轮,提示(`🚧 lane blocked` 状态行)。已执行但失败的只读调用会被 `afterToolCall` 备注再次归位。编辑模式(`Ctrl+T`)不受影响。
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 模型切换** —— `Alt+M` 在浮层内打开模型选择器(`↑/↓` 移动,`Enter` 确认,`Esc` 取消)。列表优先展示会话作用域模型(`--models` / `enabledModels`),为空时回退可用模型目录,且只显示已配置认证的模型。确认后直接替换 fork agent 的运行时模型——下一 turn 生效,无需重建 fork——并按新模型能力钳制 thinking level(无 reasoning 的模型钳制为 `off`)。选择为 fork 局部状态(ADR 0002):主会话模型不受影响。后台化(`Alt+W`)保留选择;`Alt+R` / `Alt+N` / `Esc` 关闭后随实例重置。头部显示当前 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
- | `Alt+M` | 打开 fork 模型选择器(`↑/↓` 选择,`Enter` 确认,`Esc` 取消) |
117
- | `Ctrl+T` | 切换只读 / 编辑模式 |
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` | `Alt+M` 无反应 |
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 # Alt+M fork 模型选择器:列表构建 + thinking 钳制
220
- │ ├── shortcuts.ts # 快捷键绑定(Alt+W / Ctrl+T)
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.0",
3
+ "version": "1.2.0",
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": [
@@ -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 (Ctrl+T) or ask the main agent.
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 (Ctrl+T).
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).
@@ -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. Each platform maps to a primary channel:
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` (pi's own clipboard read), then
10
- * PowerShell `Get-Clipboard -Raw`, then an OSC 52 query;
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 channel: `powershell.exe -NoProfile -Command "Get-Clipboard
128
- * -Raw"`, mirroring pi's clipboard-image PowerShell channel family. The
129
- * `-Raw` flag preserves the exact text (no extra newline), and the output
130
- * encoding is pinned to UTF-8 because the text travels over the console
131
- * pipe (pi's image reader sidesteps this by writing a temp file).
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 channel: `powershell.exe -NoProfile -Command "Get-Clipboard
248
- * -Raw"`, mirroring pi's clipboard-image PowerShell channel family. The
249
- * `-Raw` flag preserves the exact text (no extra newline), and the output
250
- * encoding is pinned to UTF-8 because the text travels over the console
251
- * pipe (pi's image reader sidesteps this by writing a temp file).
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 { PROVIDER_RETRY_KEYS, type ProviderRetrySettings } from "./provider-retry.ts";
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
- /** Alt+M fork model picker. Default: true. */
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). The fork shares pi's settings files
70
- * rather than re-declaring them: global <agentConfigDir>/settings.json merged
71
- * with project <cwd>/.pi/settings.json (project wins per key, mirroring pi's
72
- * deepMergeSettings), then the `retry` block is extracted with pi's defaults
73
- * (settingsManager.getRetrySettings: enabled=true, maxRetries=3,
74
- * baseDelayMs=2000). The `retry.provider` block (spec #20 D4: timeoutMs /
75
- * maxRetries / maxRetryDelayMs HTTP-layer retry knobs) is extracted too,
76
- * with only defined number keys kept; it is consumed solely by the overlay's
77
- * stream assembly, never by the turn loop. Invalid/absent files contribute
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 merged = deepMergeSettings(
126
- readSettingsFile(join(agentConfigDir, "settings.json"), options.onWarning),
127
- options.cwd
128
- ? readSettingsFile(join(options.cwd, ".pi", "settings.json"), options.onWarning)
129
- : {},
130
- );
131
- const retry = isPlainRecord(merged.retry) ? merged.retry : {};
132
- // D4 (spec #20): extract the provider block (HTTP-layer retry knobs)
133
- // from the already-global+project-merged settings. Only defined number
134
- // keys are kept; missing keys fall empty no maxRetryDelayMs default
135
- // here, because pi-ai's streamSimple defaults it to 60000 internally.
136
- const provider = isPlainRecord(retry.provider) ? retry.provider : {};
137
- const providerSettings: ProviderRetrySettings = {};
138
- for (const key of PROVIDER_RETRY_KEYS) {
139
- if (typeof provider[key] === "number") providerSettings[key] = provider[key];
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
- typeof retry.enabled === "boolean" ? retry.enabled : true,
144
- maxRetries:
145
- typeof retry.maxRetries === "number" ? retry.maxRetries : 3,
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 Ctrl+T). */
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 / Ctrl+T. */
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
- return capturedRunner.getAllRegisteredTools().map((rt): AgentTool => {
49
- const { definition } = rt;
50
- return {
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";
@@ -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 Alt+M modal gate
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,46 @@
1
- import type { KeyId } from "@earendil-works/pi-tui";
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
+ /**
32
+ * Copy the active mouse selection (pi `tui.input.copy` parity). Without
33
+ * one, bare Ctrl+C clears the input box (pi `app.clear` parity, spec #22);
34
+ * Ctrl+Shift+C stays a forced copy.
35
+ */
36
+ copySelection: { keys: ["ctrl+c", "ctrl+shift+c"], hint: "C+c copy/clear" },
37
+ /** Copy the last side-chat assistant message (pi `app.message.copy` parity). */
38
+ copyLastMessage: { keys: ["ctrl+x"], hint: "C+x last" },
39
+ /** Paste clipboard text (pi `app.clipboard.pasteImage` parity). */
40
+ paste: { keys: ["ctrl+v", "alt+v"], hint: "C+v paste" },
41
+ } as const satisfies Record<string, Keybinding>;
42
+
43
+ /** True when `data` matches any of the keys. */
44
+ export function matchesAnyKey(data: string, keys: readonly KeyId[]): boolean {
45
+ return keys.some((key) => matchesKey(data, key));
46
+ }
@@ -57,8 +57,9 @@ export class SideChatMessages implements Component {
57
57
  private totalLines = 0;
58
58
  /**
59
59
  * Active mouse selection over the visible chat lines ({@link CellPos} in
60
- * rendered-line coordinates). Cleared on scroll / content changes and on
61
- * the next press; kept after copy so Ctrl+C can copy again (B+C scheme).
60
+ * rendered-line coordinates). Cleared on scroll / content changes, on the
61
+ * next press, and after a successful copy (spec #22: Ctrl+C with no
62
+ * selection clears the input instead of re-copying).
62
63
  */
63
64
  private selection: { anchor: CellPos; focus: CellPos } | null = null;
64
65
  /** Plain (ANSI-stripped) text of the last rendered lines, for hit-testing and copy. */
@@ -62,7 +62,7 @@ import {
62
62
  modelKey,
63
63
  type ModelChoice,
64
64
  } from "./model-switch.ts";
65
- import { SIDE_CHAT_SHORTCUT } from "./shortcuts.ts";
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 Alt+M model picker modal, or null when closed (modal replaces the chat area). */
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[] = [];
@@ -267,13 +269,29 @@ export class SideChatOverlay implements Component, Focusable {
267
269
  * Copy the current mouse selection to the clipboard (native → wl-copy /
268
270
  * xclip → OSC 52 cascade via {@link copyToClipboard}, matching the main
269
271
  * app's tree selector) and show a transient status. Copying is hotkey-only:
270
- * `Ctrl+C` / `Ctrl+Shift+C` with an active mouse selection. The selection
271
- * stays highlighted so a second copy key press re-copies. Returns false when
272
- * there is nothing to copy.
272
+ * `Ctrl+C` / `Ctrl+Shift+C` with an active mouse selection. A successful
273
+ * copy consumes the selection (spec #22: Ctrl+C then returns to clearing
274
+ * the input); a failed copy keeps it so the user can retry. Returns false
275
+ * when there is nothing to copy.
273
276
  */
274
277
  async copySelectionToClipboard(): Promise<boolean> {
275
278
  const text = this.messages.getSelectedText();
276
279
  if (!text) return false;
280
+ const ok = await this.copyTextWithFeedback(text);
281
+ if (ok) {
282
+ this.messages.clearSelection();
283
+ this.options.tui.requestRender();
284
+ }
285
+ return ok;
286
+ }
287
+
288
+ /**
289
+ * Copy text to the system clipboard and surface the outcome: a success
290
+ * flash on the status line (labelled by `hint`) or an error line on
291
+ * failure. The shared skeleton behind selection copy (Ctrl+C / right-click)
292
+ * and last-message copy (Ctrl+X).
293
+ */
294
+ private async copyTextWithFeedback(text: string, hint?: string): Promise<boolean> {
277
295
  try {
278
296
  await copyToClipboard(text);
279
297
  } catch (error) {
@@ -283,11 +301,26 @@ export class SideChatOverlay implements Component, Focusable {
283
301
  this.options.tui.requestRender();
284
302
  return false;
285
303
  }
286
- const status = `${COPIED_STATUS_PREFIX}${Array.from(text).length} chars`;
287
- this.status.flash(status, COPIED_STATUS_CLEAR_MS);
304
+ const label = hint
305
+ ? `${hint} · ${Array.from(text).length} chars`
306
+ : `${Array.from(text).length} chars`;
307
+ this.status.flash(`${COPIED_STATUS_PREFIX}${label}`, COPIED_STATUS_CLEAR_MS);
288
308
  return true;
289
309
  }
290
310
 
311
+ /**
312
+ * Ctrl+X (app.message.copy parity): copy the last assistant message the
313
+ * side chat produced itself — never the forked main-lane context. No-op
314
+ * with a hint when the side chat has produced no reply yet.
315
+ */
316
+ async copyLastAssistantMessage(): Promise<void> {
317
+ const text = this.runner.getLastAssistantText();
318
+ if (!text) {
319
+ this.status.flash(NO_ASSISTANT_MESSAGE_STATUS, COPIED_STATUS_CLEAR_MS);
320
+ return;
321
+ }
322
+ await this.copyTextWithFeedback(text, "last message");
323
+ }
291
324
  /**
292
325
  * Right-click paste (issue #7, D4): read plain text from the system
293
326
  * clipboard via the injected platform-channel matrix (clipboard-read.ts,
@@ -422,7 +455,7 @@ export class SideChatOverlay implements Component, Focusable {
422
455
  // SGR events into actions; the overlay translates them onto the message
423
456
  // store and render loop (applyGestureAction). The hit queries bridge the
424
457
  // module's 0-based screen coordinates to the overlay's geometry. The
425
- // Alt+M modal gate stays in handleMouseEvent — the module knows nothing
458
+ // Ctrl+L modal gate stays in handleMouseEvent — the module knows nothing
426
459
  // about the modal.
427
460
  this.gesture = new PointerGesture({
428
461
  hit: {
@@ -473,7 +506,7 @@ export class SideChatOverlay implements Component, Focusable {
473
506
  enabled: options.features.retry && options.retryPolicy.enabled,
474
507
  },
475
508
  promptPack,
476
- // Live closures: Ctrl+T toggles toolMode (the runner re-reads it per
509
+ // Live closures: Alt+T toggles toolMode (the runner re-reads it per
477
510
  // event); the read-only tool set is fixed at open time.
478
511
  isReadOnlyLane: () => this.toolMode === "read-only",
479
512
  isReadOnlyTool: (name) => this.readOnlyToolNames.has(name),
@@ -612,7 +645,7 @@ export class SideChatOverlay implements Component, Focusable {
612
645
  }
613
646
 
614
647
  /**
615
- * Re-substitute the framing block with the fork's CURRENT model (Alt+M may
648
+ * Re-substitute the framing block with the fork's CURRENT model (Ctrl+L may
616
649
  * have switched `agent.state.model`, D5). The framing text is built once at
617
650
  * open time with the main session's model; the message lives in the
618
651
  * transcript (marked, never rendered as a bubble), so refreshing its content
@@ -637,7 +670,7 @@ export class SideChatOverlay implements Component, Focusable {
637
670
  if (!trimmed || this.runner.isRunning || this.disposed) return;
638
671
 
639
672
  // Keep the framing block's `Model:` line in sync with the fork's current
640
- // model (Alt+M, D5): the text is substituted once at open time with the
673
+ // model (Ctrl+L, D5): the text is substituted once at open time with the
641
674
  // main session's model, so without this refresh the agent self-reports the
642
675
  // old model after a switch (bug #3).
643
676
  this.refreshFramingModel();
@@ -785,7 +818,9 @@ export class SideChatOverlay implements Component, Focusable {
785
818
 
786
819
  const escHint = this.runner.isRunning ? "Esc stop" : "Esc close";
787
820
  const modeHint =
788
- this.toolMode === "read-only" ? "C+t Edit" : "C+t Readonly";
821
+ this.toolMode === "read-only"
822
+ ? `${KEYBINDINGS.toggleMode.hint} Edit`
823
+ : `${KEYBINDINGS.toggleMode.hint} Readonly`;
789
824
  const scrolled = !this.messages.isAtBottom();
790
825
  const scrollHint = scrolled
791
826
  ? theme.fg(
@@ -833,7 +868,7 @@ export class SideChatOverlay implements Component, Focusable {
833
868
 
834
869
  handleInput(data: string): void {
835
870
  // Backgrounding (Alt+W) while the picker is open cancels the modal first.
836
- if (matchesKey(data, SIDE_CHAT_SHORTCUT)) {
871
+ if (matchesAnyKey(data, KEYBINDINGS.background.keys)) {
837
872
  this.closeModelPicker();
838
873
  this.options.onBackground();
839
874
  return;
@@ -857,35 +892,50 @@ export class SideChatOverlay implements Component, Focusable {
857
892
  }
858
893
  return;
859
894
  }
860
- if (matchesKey(data, Key.alt("r"))) {
895
+ if (matchesAnyKey(data, KEYBINDINGS.refork.keys)) {
861
896
  this.dispose("refork");
862
897
  return;
863
898
  }
864
- if (matchesKey(data, Key.alt("n"))) {
899
+ if (matchesAnyKey(data, KEYBINDINGS.clear.keys)) {
865
900
  this.dispose("clear");
866
901
  return;
867
902
  }
868
- if (matchesKey(data, Key.alt("e"))) {
903
+ if (matchesAnyKey(data, KEYBINDINGS.export.keys)) {
869
904
  this.exportChatHistory();
870
905
  return;
871
906
  }
872
- if (matchesKey(data, Key.alt("m"))) {
907
+ if (matchesAnyKey(data, KEYBINDINGS.modelPicker.keys)) {
873
908
  this.openModelPicker();
874
909
  return;
875
910
  }
876
- if (
877
- matchesKey(data, Key.ctrl("c")) ||
878
- matchesKey(data, Key.ctrlShift("c"))
879
- ) {
911
+ if (matchesAnyKey(data, KEYBINDINGS.copySelection.keys)) {
880
912
  // Hotkey copy: with an active mouse selection, Ctrl+C / Ctrl+Shift+C
881
- // copies it. Without one, fall through so Ctrl+C keeps the editor's
882
- // own semantics.
913
+ // copies it. Without one, Ctrl+C clears the input box (pi `app.clear`
914
+ // parity, spec #22); Ctrl+Shift+C falls through — pi binds no such
915
+ // key, so it stays a forced copy (terminal habit).
883
916
  if (this.messages.hasSelection()) {
884
917
  void this.copySelectionToClipboard();
885
918
  return;
886
919
  }
920
+ if (matchesKey(data, KEYBINDINGS.copySelection.keys[0])) {
921
+ this.editor.setText("");
922
+ this.options.tui.requestRender();
923
+ return;
924
+ }
925
+ }
926
+ if (matchesAnyKey(data, KEYBINDINGS.copyLastMessage.keys)) {
927
+ // app.message.copy parity: copy the last assistant message — no
928
+ // selection needed (mirrors the main session's Ctrl+X default).
929
+ void this.copyLastAssistantMessage();
930
+ return;
931
+ }
932
+ if (matchesAnyKey(data, KEYBINDINGS.paste.keys)) {
933
+ // app.clipboard.pasteImage parity: paste clipboard text into the editor
934
+ // (Ctrl+V; Alt+V is the Windows/WSL binding).
935
+ void this.pasteFromClipboard();
936
+ return;
887
937
  }
888
- if (matchesKey(data, Key.ctrl("t"))) {
938
+ if (matchesAnyKey(data, KEYBINDINGS.toggleMode.keys)) {
889
939
  this.toolMode = this.toolMode === "full" ? "read-only" : "full";
890
940
  // Read-only lane keeps the strip philosophy; edit mode stays untouched
891
941
  // (enforcement out of scope until the crash bug is understood, #4).
@@ -915,14 +965,14 @@ export class SideChatOverlay implements Component, Focusable {
915
965
  }
916
966
 
917
967
  /**
918
- * Alt+M: open the fork model picker as a modal inside the overlay (D7).
968
+ * Ctrl+L: open the fork model picker as a modal inside the overlay (D7).
919
969
  * The list shows scoped + authenticated models, falling back to the
920
970
  * available catalogue (D6). Rejected while streaming: swapping the model
921
971
  * mid-turn would corrupt the in-flight request.
922
972
  */
923
973
  private openModelPicker(): void {
924
974
  if (this.modelPicker) return;
925
- // Feature switch (D11): Alt+M is inert when model switching is off.
975
+ // Feature switch (D11): Ctrl+L is inert when model switching is off.
926
976
  if (!this.options.features.modelSwitch) return;
927
977
  if (this.runner.isRunning) {
928
978
  this.status.setSteady("feedback", {
@@ -1142,8 +1192,13 @@ function frameLine(
1142
1192
  }
1143
1193
 
1144
1194
  /** Alt-actions hint row base; the model entry is appended only when the switch is on. */
1145
- const ALT_ACTIONS_BASE = `A+w bg · A+r fork · A+n new · A+e export`;
1146
- const ALT_ACTION_HINTS = `${ALT_ACTIONS_BASE} · A+m model`;
1195
+ const ALT_ACTIONS_BASE = [
1196
+ KEYBINDINGS.background.hint,
1197
+ KEYBINDINGS.refork.hint,
1198
+ KEYBINDINGS.clear.hint,
1199
+ KEYBINDINGS.export.hint,
1200
+ ].join(" · ");
1201
+ const ALT_ACTION_HINTS = `${ALT_ACTIONS_BASE} · ${KEYBINDINGS.modelPicker.hint}`;
1147
1202
  /**
1148
1203
  * Build the fixed two-row key-hint bar. Row 1: scrolling, copy, mode toggle,
1149
1204
  * Esc and send; row 2: the Alt-actions (Alt abbreviated as A, A+w = Alt+W).
@@ -1163,13 +1218,13 @@ export function buildSideChatHintLines(options: {
1163
1218
  const rightClickHint = features.rightClickCopyPaste
1164
1219
  ? " · R-click copy/paste"
1165
1220
  : "";
1166
- const primary = `${scrollHint} · C+c copy${rightClickHint} · ${modeHint} · ${escHint} · Enter send`;
1167
- const secondary = `${ALT_ACTIONS_BASE}${features.modelSwitch ? " · A+m model" : ""}`;
1221
+ const primary = `${scrollHint} · ${KEYBINDINGS.copySelection.hint} · ${KEYBINDINGS.copyLastMessage.hint} · ${KEYBINDINGS.paste.hint}${rightClickHint} · ${modeHint} · ${escHint} · Enter send`;
1222
+ const secondary = `${ALT_ACTIONS_BASE}${features.modelSwitch ? ` · ${KEYBINDINGS.modelPicker.hint}` : ""}`;
1168
1223
  return [primary, secondary];
1169
1224
  }
1170
1225
 
1171
1226
  /**
1172
- * Hint bar while the Alt+M model picker modal is open: row 1 switches to
1227
+ * Hint bar while the Ctrl+L model picker modal is open: row 1 switches to
1173
1228
  * the picker keys, row 2 keeps the Alt-actions (still two rows, so the
1174
1229
  * message-area height stays stable).
1175
1230
  */