dsh-neotui 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 — 2026-08-17
4
+
5
+ ### Added
6
+
7
+ - tmux 风格 pane 焦点:`Ctrl+←/→` 在工作区栏、对话和轨迹间循环。
8
+ - 正文块 `=>` 游标、只读 NORMAL / VISUAL 导航、原子代码块与 `y` / `Ctrl+Shift+C` 复制。
9
+ - 全屏跨会话全文搜索:工作区→会话→匹配块树、附近内容预览和按 seq 精确跳转;Host 索引不可用时降级为有界本地扫描。
10
+ - Host 供应商目录选项预览,模型选择器默认定位当前会话模型。
11
+ - 可编辑快捷键改为真正的动态 dispatch:定义独立于 `src/keybindings.js`,每个功能主/备两个槽位,修改后立即生效。
12
+ - `Ctrl+K` 用默认编辑器打开 `tui-config.json`,关闭编辑器后快捷键自动重载;Skills 移至 `Ctrl+H`。
13
+ - 控制面板快捷键页升级为 `MODE / KEY1 / KEY2 / FUNCTION` 四列,JSON 编辑同时校验两个槽位。
14
+ - 模型选择器改为供应商文件夹 → 具体模型的层级结构:`Space` 展开/折叠、`Enter` 确认、当前模型默认展开并选中;`/` 进入筛选、`Ctrl+/` 退出筛选,与其它 buffer 一致。
15
+ - 代码块标题行重排:语言标签移入框内、按钮字段按固定宽度预留,`[按y复制]` 切换不再推动右边框;整框每行严格等宽。
16
+ - 旧版单槽位快捷键配置自动迁移(`sessionFilter "/"`、`homeSwitch "Ctrl+Left/Right"`、`skills "Ctrl+K"`),避免旧配置让 `Ctrl+F`、`Ctrl+←/→`、`Ctrl+H` 失效。
17
+
18
+ ### Changed
19
+
20
+ - 所有有限选项列表统一首尾循环;文本光标和纯滚动位置保持有界。
21
+ - 侧栏、轨迹和欢迎页改为 keyboard-first 导航;全局 Tab/Shift+Tab 切换由 `Ctrl+←/→` / F8 取代。
22
+ - 轨迹展开直接呈现全部事件,移除重复且未实现完整的“查看详情”入口。
23
+ - 鼠标正文拖选固定为自由字符选择,不再由 `v` 切换整行模式。
24
+ - 工作区、设置、模型供应商、子代理和技能从“标签页模式”改为全屏模态 Buffer(`Esc` 关闭),与 `Ctrl+←/→` pane 聚焦不再冲突;轨迹仍是 pane 序列成员。
25
+ - 正文块游标改为两端停留(`j`/`k`、`↑`/`↓` 不环绕);`Shift+G` 选中最新块并让块头落在视口底部。
26
+ - 清除未使用的 `List` 导入与死字段 `expandedTools`。
27
+
3
28
  ## 0.2.2 — 2026-08-16
4
29
 
5
30
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-neotui
2
2
 
3
- `dsh-neotui` 是 DeepSeek Harness 的终端客户端:以 Vim/Nvim 风格的 NORMAL / INSERT 模式、Yazi 风格文件选择器和鼠标交互,访问与 WebUI 相同的 Host 会话、工具、审批、任务和设置能力。
3
+ `dsh-neotui` 是 DeepSeek Harness keyboard-first 终端客户端:以 Vim/Nvim 风格的 NORMAL / INSERT / VISUAL 只读导航、tmux 风格 pane 焦点和 Yazi 风格文件选择器为主,鼠标作为辅助,访问与 WebUI 相同的 Host 会话、工具、审批、任务和设置能力。
4
4
 
5
5
  本仓库发布两个 npm 包:
6
6
 
@@ -13,8 +13,8 @@
13
13
 
14
14
  - 三栏 Yazi 风格文件与工作区选择器:路径编辑、模糊筛选、隐藏项、Nerd Font 图标和 Kitty 图片预览;
15
15
  - 图片附件栏、附件管理器、`dd` 删除和等比例 Kitty 预览;
16
- - 会话树筛选、跨会话模糊定位和稳定的历史分页;
17
- - NORMAL / INSERT 模式与可编辑的快捷键目录;
16
+ - 全屏跨会话全文搜索:工作区→会话→匹配块树、附近内容预览和精确跳转;
17
+ - NORMAL / INSERT / VISUAL 只读模式、正文块选择与可编辑的快捷键目录;
18
18
  - 独立的命令、设置和插件目录,插件支持即时筛选;
19
19
  - Queue / Steering、Goal、TODO、Plan Review、后台任务和 Subagent 状态;
20
20
  - grapheme-aware framebuffer、CJK/组合字符、Kitty keyboard、SGR mouse、OSC 8/52。
@@ -23,6 +23,8 @@
23
23
 
24
24
  ## 安装与启动
25
25
 
26
+ 需要 **Node.js 22 或更高版本**(客户端使用 Node 内置 `fetch`、`WebSocket`、`crypto.randomUUID` 和 `Intl.Segmenter`)。
27
+
26
28
  ### 独立 DSH profile
27
29
 
28
30
  ```bash
@@ -131,38 +133,49 @@ Kitty graphics 可用时,图片在文件选择器和附件预览中等比例
131
133
  - `queue`:加入下一回合队列;
132
134
  - `steer`:追加到当前回合。
133
135
 
134
- `Ctrl+Y` 切换策略,`Ctrl+U` 打开队列。队列面板使用 `e` 编辑、`s` steering、`d` 删除、`Esc` 关闭。
136
+ `Ctrl+Y` 切换策略,`Ctrl+N` 打开排队命令详情。队列面板每条命令一行,使用 `↑/↓` 循环选择、`Enter` 展开详情、`Ctrl+↑/↓` 独立滚动详情、`dd` 删除、`Esc` 关闭。
135
137
 
136
138
  ## 快捷键
137
139
 
138
- 以下是默认绑定。控制面板中的快捷键页以 `MODE / KEY / FUNCTION` 三列显示,并允许编辑用户覆盖;配置写入 `$DSH_HOME/tui-config.json`。
140
+ 以下是默认绑定。控制面板中的快捷键页以 `MODE / KEY1 / KEY2 / FUNCTION` 四列显示,每个功能拥有主/备两个槽位,并允许编辑用户覆盖;配置写入 `$DSH_HOME/tui-config.json`。这些绑定是运行时的唯一来源:修改后立即生效(无需重启),全部定义集中在 `src/keybindings.js`。
139
141
 
140
142
  | 模式 | 按键 | 功能 |
141
143
  |---|---|---|
142
144
  | ALL | `Ctrl+Space` / `F7` | 控制面板 |
143
- | NORMAL | `/` | 筛选会话树;`Ctrl+/` 退出 |
144
- | NORMAL | `Ctrl+F` | 跨会话模糊定位 |
145
+ | NORMAL | `Ctrl+F` / `/` | 打开全屏跨会话全文搜索;Enter 执行,`/` 重新编辑 |
145
146
  | NORMAL | `Ctrl+B` | 显示/隐藏侧栏 |
146
147
  | NORMAL | `Ctrl+M` | 模型与思考强度 |
147
- | NORMAL | `F8` / `F9` | 权限策略 / Agent 模式 |
148
+ | NORMAL | `F8` | 权限模式轮换 |
149
+ | NORMAL | `F9` | Agent 模式 |
148
150
  | NORMAL | `Ctrl+W` | 工作区 |
149
151
  | NORMAL | `Ctrl+Shift+W` | 添加工作区 |
150
152
  | NORMAL | `Ctrl+T` | 轨迹视图 |
151
- | NORMAL | `Shift+Tab` | 对话/轨迹切换 |
153
+ | NORMAL | `Ctrl+←` / `Ctrl+→` | 工作区栏 / 对话 / 轨迹 pane 循环聚焦 |
152
154
  | NORMAL | `Ctrl+E` | 按 step 快速跳转 |
153
155
  | NORMAL | `Ctrl+J` | 后台任务与 Subagent |
154
- | NORMAL | `Ctrl+U` | 消息队列 |
156
+ | NORMAL | `Ctrl+N` | 排队命令详情 |
157
+ | NORMAL | `Ctrl+Y` | 运行中 Enter 策略(追加 / 排队) |
158
+ | NORMAL | `Ctrl+O` | 附件管理 |
155
159
  | NORMAL | `Ctrl+G` | Goal / TODO |
156
160
  | NORMAL | `Ctrl+S` | Settings |
157
161
  | NORMAL | `Ctrl+A` | Subagent |
158
- | NORMAL | `Ctrl+K` | Skills |
162
+ | NORMAL | `Ctrl+H` | Skills |
163
+ | NORMAL | `Ctrl+K` | 用默认编辑器(`$EDITOR` / `$VISUAL` / `vi`)打开 `tui-config.json`;编辑完成后快捷键立即重载 |
159
164
  | ALL | `Ctrl+Q` | 退出 |
160
- | NORMAL | `t` / `b` | 展开/折叠思考块 / 工具块 |
161
- | NORMAL | `g g` / `G` | 对话顶部 / 底部 |
165
+ | 正文块 | `↑` / `↓`、`j` / `k` | 上下选择正文块,两端停留不环绕;`Ctrl+↑/↓` 只滚动视口 |
166
+ | 正文块 | `Space` / `Enter` / `Ctrl+R` | 折叠块 / 进入只读光标 / 打开上下文菜单 |
167
+ | NORMAL 光标 | `h l w b e 0 $`、`v` / `V` | Vim 式只读移动与字符/整行 VISUAL(无 `x` / `d`) |
168
+ | NORMAL / VISUAL | `y` / `Ctrl+Shift+C` | 复制当前代码块/正文块或选区 |
169
+ | 正文块 | `t` / `b` | 全局展开/折叠思考块 / 工具块 |
170
+ | 正文块 | `g g` / `G` | 首个 / 最新正文块;`G` 选中最新块并让块头落在视口底部 |
162
171
  | NORMAL | `[` / `]` | 上一个 / 下一个提问终点 |
163
172
  | NORMAL | `PgUp` / `PgDn` | 翻页;到顶时加载更早历史 |
164
173
 
165
- 快捷键目录中:`Enter` 编辑当前 JSON 配置项,`Shift+Tab` NORMAL / INSERT / ALL 间轮换,`Alt+Enter` 恢复默认。保存前会校验 JSON、模式和按键字段;错误文本会保留以便继续修改。
174
+ 侧栏聚焦后,`↑/↓` 循环选择工作区或会话,`Space` 展开/折叠工作区,`Enter` 打开会话且保持侧栏焦点,`n` 新建会话,`i` 进入输入,`Ctrl+R` 打开当前项菜单。轨迹聚焦后,`↑/↓` 循环选择 step,`Space` 展开完整事件,`Enter` 跳回对话,`Ctrl+R` 打开 step 菜单。
175
+
176
+ 搜索 buffer 中,Enter 执行查询或跳转结果,`/` 返回查询编辑,`Space` 折叠工作区/会话,`t` / `b` 折叠思考/工具匹配,`Ctrl+↑/↓` 独立滚动右侧预览。若 Host 未挂载搜索索引(`session.search` 不可用),会自动降级为对最近 20 个会话近期历史的有界本地扫描,并在 buffer 顶部提示。
177
+
178
+ 快捷键目录中:`Enter` 编辑当前 JSON 配置项(`{"mode":"normal|insert|all","key":"…","key2":"…"}`,`key2` 可为空),`Shift+Tab` 在 NORMAL / INSERT / ALL 间轮换,`Alt+Enter` 恢复默认。保存前会校验 JSON、模式和两个按键槽位;错误文本会保留以便继续修改。两按组合键(如 `g g`)以空格分隔书写。
166
179
 
167
180
  ## Slash 命令
168
181
 
@@ -191,6 +204,10 @@ TUI 支持:
191
204
  - 独立插件清单及 `/` 筛选;
192
205
  - dark、light、gruvbox 主题。
193
206
 
207
+ 工作区(`Ctrl+W`)、设置(`Ctrl+S`)、模型供应商(模型选择器里的 `⚙ 管理供应商…`)、子代理(`Ctrl+A`)和技能(`Ctrl+H`)都是**全屏模态 Buffer**:打开后覆盖整个界面,`Esc` 逐级返回并最终关闭。它们不再占用“标签页模式”,因此关闭后 `Ctrl+←/→` 的 pane 聚焦立即恢复,两者互不冲突。轨迹仍是 pane 序列的一部分,由 `Ctrl+T` 或 `Ctrl+←/→` 进入。
208
+
209
+ 模型选择器(`Ctrl+M` / `/model`)是供应商文件夹 → 具体模型的层级结构:`Space` 展开/折叠文件夹,`↑/↓` 移动,`Enter` 确认具体模型(带思考强度时再选一档),当前模型以 `●` 标记并默认展开、默认选中。筛选与其他 buffer 一致:`/` 进入筛选、`Ctrl+/` 退出筛选,普通字符在浏览模式下不会误触发筛选。
210
+
194
211
  所有 Buffer 都是模态的:点击外部只会吞掉事件,不会关闭 Buffer。退出必须使用界面明确提示的按键或操作。
195
212
 
196
213
  ## 鼠标与终端能力
@@ -254,6 +271,7 @@ src/term.js raw mode、鼠标、paste、Kitty keyboard
254
271
  src/screen.js cell framebuffer 与 ANSI diff
255
272
  src/text.js grapheme、显示宽度和截断
256
273
  src/md.js Markdown、代码块和 OSC 8
274
+ src/keybindings.js 可编辑快捷键注册表(每个功能主/备两个槽位)
257
275
  src/widgets.js Input、Popup、ScrollView、Menu、StatusBar
258
276
  src/views.js App、ChatView、会话树和主路由
259
277
  src/panels.js Workspace、Trajectory、Queue、Jobs、Settings
package/bin/dsh-tui.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // dsh-tui — mouse-driven terminal client for DeepSeek Harness.
2
+ // dsh-tui — keyboard-first terminal client for DeepSeek Harness.
3
3
  // Usage:
4
4
  // dsh-tui interactive mode (needs a real terminal)
5
5
  // dsh-tui --base http://host:port
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.2.2",
4
- "description": "Neo-TUI: mouse-driven terminal UI client for DeepSeek Harness (B-tier per dsh-tui-design.md)",
3
+ "version": "0.3.0",
4
+ "description": "Keyboard-first Vim/tmux-style terminal client for DeepSeek Harness",
5
5
  "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
6
9
  "bin": {
7
10
  "dsh-neotui": "bin/dsh-tui.js"
8
11
  },
package/src/config.js CHANGED
@@ -3,6 +3,9 @@
3
3
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
4
  import { join, dirname } from "node:path";
5
5
  import { userInfo } from "node:os";
6
+ import { DEFAULT_KEYBINDINGS, normalizeKeyBinding, validateKeySpec } from "./keybindings.js";
7
+
8
+ export { DEFAULT_KEYBINDINGS };
6
9
 
7
10
  export function tuiConfigFile() {
8
11
  const base = process.env.DSH_HOME ?? (process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? ".", ".config"));
@@ -69,20 +72,52 @@ export function busyEnter() {
69
72
  return loadTuiConfig().busyEnter === "steer" ? "steer" : "queue";
70
73
  }
71
74
 
72
- export const DEFAULT_KEYBINDINGS = {
73
- think: { mode: "normal", key: "t" }, tools: { mode: "normal", key: "b" }, insert: { mode: "normal", key: "i" },
74
- leaveInsert: { mode: "insert", key: "Esc" }, sessionFilter: { mode: "normal", key: "/" }, newSession: { mode: "normal", key: "n" },
75
- top: { mode: "normal", key: "g g" }, bottom: { mode: "normal", key: "G" }, prevQuestion: { mode: "normal", key: "[" }, nextQuestion: { mode: "normal", key: "]" },
76
- expandInput: { mode: "insert", key: "Ctrl+L" }, copyInput: { mode: "insert", key: "Ctrl+Shift+C" }, panel: { mode: "all", key: "Ctrl+Space" },
77
- model: { mode: "normal", key: "Ctrl+M" }, trajectory: { mode: "normal", key: "Ctrl+T" }, homeSwitch: { mode: "normal", key: "Shift+Tab" },
78
- workspace: { mode: "normal", key: "Ctrl+W" }, settings: { mode: "normal", key: "Ctrl+S" }, subagent: { mode: "normal", key: "Ctrl+A" },
79
- skills: { mode: "normal", key: "Ctrl+K" }, goal: { mode: "normal", key: "Ctrl+G" }, jobs: { mode: "normal", key: "Ctrl+J" },
80
- stepJump: { mode: "normal", key: "Ctrl+E" }, sidebar: { mode: "normal", key: "Ctrl+B" }, quit: { mode: "all", key: "Ctrl+Q" },
75
+ /** Pre-registry one-slot defaults that would silently kill the new two-slot
76
+ * bindings (e.g. an old sessionFilter "/" override removes Ctrl+F). */
77
+ const LEGACY_KEY_VALUES = {
78
+ sessionFilter: { mode: "normal", key: "/" },
79
+ homeSwitch: { mode: "normal", key: "Ctrl+Left/Right" },
80
+ skills: { mode: "normal", key: "Ctrl+K" },
81
81
  };
82
- export function keyBindings() { return { ...DEFAULT_KEYBINDINGS, ...(loadTuiConfig().keyBindings ?? {}) }; }
83
- export function setKeyBinding(id, value) { const all = { ...(loadTuiConfig().keyBindings ?? {}) }; all[id] = value; return saveTuiConfig({ keyBindings: all }); }
82
+
83
+ /** Effective two-slot keybindings: defaults merged per id with user overrides. */
84
+ export function keyBindings() {
85
+ const overrides = loadTuiConfig().keyBindings ?? {};
86
+ const merged = {};
87
+ for (const id of Object.keys(DEFAULT_KEYBINDINGS)) {
88
+ const def = DEFAULT_KEYBINDINGS[id];
89
+ const raw = overrides[id];
90
+ if (!raw) { merged[id] = { ...def }; continue; }
91
+ const normalized = normalizeKeyBinding(raw);
92
+ // A saved legacy default value maps back onto the current defaults so
93
+ // remapped chords (Ctrl+F, Ctrl+Left/Right, Ctrl+H) keep working.
94
+ const legacy = LEGACY_KEY_VALUES[id];
95
+ if (legacy && !Object.hasOwn(raw, "key2") && normalized.mode === legacy.mode && normalized.key === legacy.key) {
96
+ merged[id] = { ...def };
97
+ continue;
98
+ }
99
+ if (!normalized.key || !validateKeySpec(normalized.key).ok) { merged[id] = { ...def }; continue; }
100
+ const key2 = Object.hasOwn(raw, "key2")
101
+ ? (normalized.key2 && validateKeySpec(normalized.key2).ok ? normalized.key2 : "")
102
+ : def.key2;
103
+ merged[id] = { mode: normalized.mode, key: normalized.key, key2 };
104
+ }
105
+ return merged;
106
+ }
107
+ export function setKeyBinding(id, value) {
108
+ if (!Object.hasOwn(DEFAULT_KEYBINDINGS, id)) return false;
109
+ const normalized = normalizeKeyBinding(value);
110
+ if (!normalized.key || !validateKeySpec(normalized.key).ok) return false;
111
+ if (normalized.key2 && !validateKeySpec(normalized.key2).ok) return false;
112
+ const all = { ...(loadTuiConfig().keyBindings ?? {}) };
113
+ all[id] = normalized;
114
+ return saveTuiConfig({ keyBindings: all });
115
+ }
84
116
  export function resetKeyBinding(id) { const all = { ...(loadTuiConfig().keyBindings ?? {}) }; delete all[id]; return saveTuiConfig({ keyBindings: all }); }
85
117
 
118
+ /** Drop the 1s read cache so an external editor's changes apply immediately. */
119
+ export function reloadTuiConfig() { cache = { file: null, data: null, at: 0 }; }
120
+
86
121
  export function foldDefaults() {
87
122
  const fd = loadTuiConfig().foldDefaults ?? {};
88
123
  return {
@@ -1,4 +1,4 @@
1
- import { Widget, Input, Popup } from './widgets.js';
1
+ import { Widget, Input, Popup, wrapIndex } from './widgets.js';
2
2
  import { truncate } from './text.js';
3
3
  import { T } from './theme.js';
4
4
  import { readdirSync, readFileSync, statSync } from 'node:fs';
@@ -183,8 +183,8 @@ export class UploadPicker extends Widget {
183
183
  if (ev.ctrl && ev.key === '.') { const name=this.current()?.name; this.showHidden=!this.showHidden; this.load(name); this.app.toast(this.showHidden?'已显示隐藏文件':'已隐藏隐藏文件'); return true; }
184
184
  if (ev.ctrl && (ev.key === '/' || ev.key === '_')) { this.filter = ''; this.load(); return true; }
185
185
  if (ev.name === 'escape') { this.clearKitty(); this.onCancel?.(); return true; }
186
- if (ev.name === 'up') { this.clearKitty(); this.sel = Math.max(0, this.sel - 1); return true; }
187
- if (ev.name === 'down') { this.clearKitty(); this.sel = Math.min(this.items().length - 1, this.sel + 1); return true; }
186
+ if (ev.name === 'up') { this.clearKitty(); this.sel = wrapIndex(this.sel - 1, this.items().length); return true; }
187
+ if (ev.name === 'down') { this.clearKitty(); this.sel = wrapIndex(this.sel + 1, this.items().length); return true; }
188
188
  if (ev.name === 'left') { this.goParent(); return true; }
189
189
  if (ev.name === 'right') { this.enterDir(); return true; }
190
190
  if (ev.name === 'enter') { this.confirmUpload(); return true; }
@@ -192,5 +192,5 @@ export class UploadPicker extends Widget {
192
192
  if (ev.name === 'char' && ev.key === '/') { this.startFilter(); return true; }
193
193
  return false;
194
194
  }
195
- onMouse(ev) { if (ev.kind === 'wheel-up') { this.sel = Math.max(0, this.sel - 1); return true; } if (ev.kind === 'wheel-down') { this.sel = Math.min(this.items().length - 1, this.sel + 1); return true; } return true; }
195
+ onMouse(ev) { if (ev.kind === 'wheel-up') { this.sel = wrapIndex(this.sel - 1, this.items().length); return true; } if (ev.kind === 'wheel-down') { this.sel = wrapIndex(this.sel + 1, this.items().length); return true; } return true; }
196
196
  }
@@ -0,0 +1,172 @@
1
+ // keybindings.js — the single source of truth for editable shortcuts.
2
+ //
3
+ // Every function owns TWO slots: `key` (primary) and `key2` (alternate).
4
+ // User overrides live in tui-config.json under
5
+ // keyBindings.<id> = { "mode": "normal"|"insert"|"all", "key": "...", "key2": "..." }
6
+ // and the dispatchers in views.js consult this registry instead of hardcoded
7
+ // key checks. A spec is either one press ("Ctrl+F"), or a two-press chord
8
+ // ("g g"). Modifier prefixes are Ctrl / Shift / Alt; named keys are
9
+ // Up/Down/Left/Right/Enter/Escape(Esc)/Tab/Backtab/PgUp/PgDn/Home/End/
10
+ // Insert/Delete/Space and F1–F12; anything else must be a single character.
11
+
12
+ export const KEYBINDING_MODES = ["normal", "insert", "all"];
13
+
14
+ export const DEFAULT_KEYBINDINGS = {
15
+ think: { mode: "normal", key: "t", key2: "" },
16
+ tools: { mode: "normal", key: "b", key2: "" },
17
+ insert: { mode: "normal", key: "i", key2: "" },
18
+ leaveInsert: { mode: "insert", key: "Esc", key2: "" },
19
+ sessionFilter: { mode: "normal", key: "Ctrl+F", key2: "/" },
20
+ newSession: { mode: "normal", key: "n", key2: "" },
21
+ top: { mode: "normal", key: "g g", key2: "" },
22
+ bottom: { mode: "normal", key: "G", key2: "" },
23
+ prevQuestion: { mode: "normal", key: "[", key2: "" },
24
+ nextQuestion: { mode: "normal", key: "]", key2: "" },
25
+ expandInput: { mode: "insert", key: "Ctrl+L", key2: "" },
26
+ copyInput: { mode: "insert", key: "Ctrl+Shift+C", key2: "" },
27
+ panel: { mode: "all", key: "Ctrl+Space", key2: "F7" },
28
+ model: { mode: "normal", key: "Ctrl+M", key2: "" },
29
+ trajectory: { mode: "normal", key: "Ctrl+T", key2: "" },
30
+ homeSwitch: { mode: "normal", key: "Ctrl+Left", key2: "Ctrl+Right" },
31
+ permissionRotate: { mode: "normal", key: "F8", key2: "" },
32
+ workspace: { mode: "normal", key: "Ctrl+W", key2: "" },
33
+ settings: { mode: "normal", key: "Ctrl+S", key2: "" },
34
+ subagent: { mode: "normal", key: "Ctrl+A", key2: "" },
35
+ skills: { mode: "normal", key: "Ctrl+H", key2: "" },
36
+ goal: { mode: "normal", key: "Ctrl+G", key2: "" },
37
+ jobs: { mode: "normal", key: "Ctrl+J", key2: "" },
38
+ queue: { mode: "normal", key: "Ctrl+N", key2: "" },
39
+ busyEnter: { mode: "normal", key: "Ctrl+Y", key2: "" },
40
+ attachments: { mode: "normal", key: "Ctrl+O", key2: "" },
41
+ stepJump: { mode: "normal", key: "Ctrl+E", key2: "" },
42
+ sidebar: { mode: "normal", key: "Ctrl+B", key2: "" },
43
+ editConfig: { mode: "normal", key: "Ctrl+K", key2: "" },
44
+ quit: { mode: "all", key: "Ctrl+Q", key2: "" },
45
+ };
46
+
47
+ /** App-level dispatch precedence: the first matching binding wins. */
48
+ export const KEYBINDING_ORDER = [
49
+ "sessionFilter", "panel", "homeSwitch", "permissionRotate", "editConfig", "quit",
50
+ "model", "trajectory", "workspace", "settings", "subagent", "skills", "goal",
51
+ "jobs", "queue", "busyEnter", "attachments", "stepJump", "sidebar",
52
+ ];
53
+
54
+ /** Transcript-level bindings evaluated inside ChatView.onKey. */
55
+ export const CHAT_BINDING_ORDER = ["think", "tools", "insert", "top", "bottom", "prevQuestion", "nextQuestion", "sessionFilter"];
56
+
57
+ /** Sidebar-level bindings evaluated inside SidebarTree.onKey. */
58
+ export const SIDEBAR_BINDING_ORDER = ["insert", "newSession"];
59
+
60
+ const NAMED_KEYS = {
61
+ up: "up", down: "down", left: "left", right: "right", enter: "enter",
62
+ escape: "escape", esc: "escape", tab: "tab", backtab: "backtab",
63
+ pgup: "pgup", pgdn: "pgdn", home: "home", end: "end",
64
+ insert: "insert", delete: "delete",
65
+ };
66
+
67
+ /** Parse one key press spec ("Ctrl+Shift+C", "F8", "g"). Null when malformed. */
68
+ function parseKeyPart(part) {
69
+ if (typeof part !== "string") return null;
70
+ const pieces = part.split("+");
71
+ const base = pieces.pop() ?? "";
72
+ if (!base) return null;
73
+ const mods = { ctrl: false, shift: false, alt: false };
74
+ for (const piece of pieces) {
75
+ if (piece === "Ctrl") mods.ctrl = true;
76
+ else if (piece === "Shift") mods.shift = true;
77
+ else if (piece === "Alt") mods.alt = true;
78
+ else return null;
79
+ }
80
+ const named = NAMED_KEYS[base.toLowerCase()];
81
+ if (named) return { ...mods, named };
82
+ if (base === " " || base.toLowerCase() === "space") return { ...mods, space: true };
83
+ if (/^f\d{1,2}$/i.test(base)) return { ...mods, fkey: base.toLowerCase() };
84
+ if (base.length === 1) {
85
+ const lower = base.toLowerCase();
86
+ // An uppercase letter implies Shift only for unmodified letters ("G"):
87
+ // "Ctrl+F" denotes the Ctrl chord, not Ctrl+Shift+F.
88
+ const wantsShift = base !== lower && !mods.ctrl && !mods.alt;
89
+ return { ...mods, shift: mods.shift || wantsShift, char: lower };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** Split a full spec into its presses ("g g" → ["g", "g"]). */
95
+ export function specParts(spec) {
96
+ return String(spec ?? "").split(/\s+/).filter(Boolean);
97
+ }
98
+
99
+ /** Whether one key event satisfies one press spec. */
100
+ export function matchKeyPart(ev, part) {
101
+ if (!ev || ev.type !== "key") return false;
102
+ const parsed = parseKeyPart(part);
103
+ if (!parsed) return false;
104
+ const ctrl = ev.ctrl === true, shift = ev.shift === true, alt = ev.alt === true;
105
+ if (ctrl !== parsed.ctrl || alt !== parsed.alt) return false;
106
+ if (parsed.space) return ev.name === "char" && ev.key === " " && shift === parsed.shift;
107
+ if (parsed.named) return ev.name === parsed.named && shift === parsed.shift;
108
+ if (parsed.fkey) return ev.name === parsed.fkey && shift === parsed.shift;
109
+ return ev.name === "char" && ev.key === parsed.char && shift === parsed.shift;
110
+ }
111
+
112
+ /**
113
+ * Match an event against one binding's two slots. Returns
114
+ * { kind:"full", slot } | { kind:"pending", slot, part } | null.
115
+ * `pending` carries an in-progress two-press chord across events.
116
+ */
117
+ export function matchKeyBinding(ev, spec, pending = null) {
118
+ if (!spec) return null;
119
+ for (const slot of ["key", "key2"]) {
120
+ const parts = specParts(spec[slot]);
121
+ if (parts.length === 0) continue;
122
+ if (pending && pending.slot === slot) {
123
+ const part = parts[pending.part];
124
+ if (!part || !matchKeyPart(ev, part)) continue;
125
+ return pending.part + 1 >= parts.length ? { kind: "full", slot } : { kind: "pending", slot, part: pending.part + 1 };
126
+ }
127
+ if (parts.length === 1 && matchKeyPart(ev, parts[0])) return { kind: "full", slot };
128
+ if (parts.length === 2 && matchKeyPart(ev, parts[0])) return { kind: "pending", slot, part: 1 };
129
+ }
130
+ return null;
131
+ }
132
+
133
+ /** First matching binding across an ordered id list. `editing` = INSERT mode. */
134
+ export function bindingMatchFor(ev, bindings, editing, order = KEYBINDING_ORDER) {
135
+ for (const id of order) {
136
+ const spec = bindings?.[id];
137
+ if (!spec) continue;
138
+ if (spec.mode === "normal" && editing) continue;
139
+ if (spec.mode === "insert" && !editing) continue;
140
+ const hit = matchKeyBinding(ev, spec);
141
+ if (hit?.kind === "full") return { id, slot: hit.slot };
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /** Human-readable spec ("Ctrl+Left", "g, g"); "—" when empty. */
147
+ export function describeSpec(spec) {
148
+ const parts = specParts(spec);
149
+ return parts.length ? parts.join(", ") : "—";
150
+ }
151
+
152
+ /** Validate a user-supplied key spec. */
153
+ export function validateKeySpec(spec) {
154
+ const parts = specParts(spec);
155
+ if (parts.length === 0 || parts.length > 2) {
156
+ return { ok: false, reason: `需要 1–2 次按键(如 "Ctrl+F" 或 "g g"),得到 ${parts.length || 0} 次` };
157
+ }
158
+ for (const part of parts) {
159
+ if (!parseKeyPart(part)) {
160
+ return { ok: false, reason: `无法解析按键 "${part}"(支持 Ctrl/Shift/Alt 修饰、方向/功能键、单字符,如 Ctrl+Left、F8、g g)` };
161
+ }
162
+ }
163
+ return { ok: true };
164
+ }
165
+
166
+ /** Normalize a raw user override into { mode, key, key2 } (backwards compatible). */
167
+ export function normalizeKeyBinding(value) {
168
+ const mode = KEYBINDING_MODES.includes(value?.mode) ? value.mode : "normal";
169
+ const key = typeof value?.key === "string" ? value.key.trim() : "";
170
+ const key2 = typeof value?.key2 === "string" ? value.key2.trim() : "";
171
+ return { mode, key, key2 };
172
+ }
package/src/md.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // Segments: { t: string, fg?, bg?, bold, italic, underline, strike, code, link? }
3
3
  // Links use OSC 8 (clickable in most terminals, emitted by Screen.render).
4
4
 
5
- import { hexRgb, strWidth, truncate } from "./text.js";
5
+ import { hexRgb, strWidth, truncate, graphemes } from "./text.js";
6
6
 
7
7
  import { T } from "./theme.js";
8
8
 
@@ -255,31 +255,39 @@ export function renderMd(text, width, sink = null, opts = {}) {
255
255
  const lang = inCode || "text";
256
256
  const hw = Math.max(2, width - 4);
257
257
  const codeLines = codeBuf.length === 0 ? [""] : codeBuf;
258
- if (sink?.codeBlocks) sink.codeBlocks.push({ text: codeBuf.join("\n"), lineIdx: lines.length, lang });
258
+ const codeMeta = { text: codeBuf.join("\n"), lineIdx: lines.length, lang };
259
+ if (sink?.codeBlocks) sink.codeBlocks.push(codeMeta);
259
260
  // Fixed-width box: EVERY row is exactly (hw + 4) columns wide —
260
- // top `┌…[复制]…┐`, content `│ … │`, bottom `└…┘` — so the corners
261
- // always sit above the vertical bars, never above the code text.
261
+ // top `┌─ [tag] [复制…] ───┐`, content `│ … │`, bottom `└…┘` — so the
262
+ // corners always sit above the vertical bars, never above the code.
262
263
  const btn = "[复制]";
264
+ const activeBtn = "[按y复制]";
263
265
  const btnW = strWidth(btn);
266
+ // The tag sits INSIDE the box, before the button: the button field is
267
+ // padded to a fixed reserve width so the NORMAL-mode [按y复制] swap
268
+ // never shifts the right corner. Nothing extends past the box.
269
+ const tagPart = lang && lang !== "text" ? lang + " " : "";
270
+ const tagW = strWidth(tagPart);
271
+ const reserveW = Math.max(btnW, Math.min(strWidth(activeBtn), Math.max(0, hw - tagW)));
272
+ const btnField = btn + " ".repeat(Math.max(0, reserveW - btnW));
273
+ const tailPad = Math.max(1, hw + 1 - tagW - reserveW);
264
274
  const inner = hw + 2; // columns between the corners
265
- const leftLen = Math.max(1, inner - btnW - 2);
266
- const tag = lang && lang !== "text" ? " " + lang : "";
267
275
  lines.push([
268
- SEG("" + "─".repeat(leftLen) + " ", { fg: C.hr }),
269
- SEG(btn, { fg: C.link, bold: true, copyCode: codeBuf.join("\n") }),
270
- SEG("" + tag, { fg: C.hr }),
276
+ SEG("┌─" + tagPart, { fg: C.hr, codeBlock: codeMeta }),
277
+ SEG(btnField, { fg: C.link, bold: true, copyCode: codeBuf.join("\n"), codeBlock: codeMeta }),
278
+ SEG("".repeat(tailPad) + "┐", { fg: C.hr, codeBlock: codeMeta }),
271
279
  ]);
272
280
  for (const cl of codeLines) {
273
281
  const hls = highlightLine(cl, lang);
274
282
  for (const row of wrapSegs(hls, hw)) {
275
283
  const rowW = strWidth(row.map((g) => g.t ?? "").join(""));
276
- const segs = [SEG("│ ", { fg: C.hr }), ...row];
284
+ const segs = [SEG("│ ", { fg: C.hr, codeBlock: codeMeta }), ...row.map((seg) => ({ ...seg, codeBlock: codeMeta }))];
277
285
  if (rowW < hw) segs.push(SEG(" ".repeat(hw - rowW)));
278
- segs.push(SEG(" │", { fg: C.hr }));
286
+ segs.push(SEG(" │", { fg: C.hr, codeBlock: codeMeta }));
279
287
  lines.push(segs);
280
288
  }
281
289
  }
282
- lines.push([SEG("└" + "─".repeat(inner) + "┘", { fg: C.hr })]);
290
+ lines.push([SEG("└" + "─".repeat(inner) + "┘", { fg: C.hr, codeBlock: codeMeta })]);
283
291
  inCode = null;
284
292
  }
285
293
  i++;
@@ -368,13 +376,12 @@ export function wrapSegs(segs, width, { pad: padLines = false } = {}) {
368
376
  while (strWidth(rest) > width) {
369
377
  let cut = "";
370
378
  let cw = 0;
371
- for (const ch of rest) {
372
- const c = ch.codePointAt(0);
373
- const cwc = wcwidthFor(c);
379
+ for (const ch of graphemes(rest)) {
380
+ const cwc = strWidth(ch);
374
381
  if (cw + cwc > width) break;
375
382
  cut += ch; cw += cwc;
376
383
  }
377
- if (!cut) cut = rest[0];
384
+ if (!cut) cut = graphemes(rest)[0] ?? "";
378
385
  if (curW > 0) { lines.push(cur); cur = []; curW = 0; }
379
386
  lines.push([{ ...seg, t: cut }]);
380
387
  rest = rest.slice(cut.length);
@@ -395,8 +402,3 @@ export function wrapSegs(segs, width, { pad: padLines = false } = {}) {
395
402
  if (lines.length === 0) lines.push([SEG("")]);
396
403
  return lines;
397
404
  }
398
-
399
- function wcwidthFor(cp) {
400
- // local import to avoid cycles
401
- return strWidth(String.fromCodePoint(cp));
402
- }