dsh-neotui 0.2.3 → 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 +25 -0
- package/README.md +30 -13
- package/bin/dsh-tui.js +1 -1
- package/package.json +5 -2
- package/src/config.js +46 -12
- package/src/file-picker.js +4 -4
- package/src/keybindings.js +172 -0
- package/src/md.js +23 -21
- package/src/panels.js +1771 -427
- package/src/term.js +18 -10
- package/src/text.js +11 -3
- package/src/views.js +925 -263
- package/src/widgets.js +37 -27
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
|
|
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,39 +133,49 @@ Kitty graphics 可用时,图片在文件选择器和附件预览中等比例
|
|
|
131
133
|
- `queue`:加入下一回合队列;
|
|
132
134
|
- `steer`:追加到当前回合。
|
|
133
135
|
|
|
134
|
-
`Ctrl+Y` 切换策略,`Ctrl+N` 打开排队命令详情。队列面板每条命令一行,使用 `↑/↓`
|
|
136
|
+
`Ctrl+Y` 切换策略,`Ctrl+N` 打开排队命令详情。队列面板每条命令一行,使用 `↑/↓` 循环选择、`Enter` 展开详情、`Ctrl+↑/↓` 独立滚动详情、`dd` 删除、`Esc` 关闭。
|
|
135
137
|
|
|
136
138
|
## 快捷键
|
|
137
139
|
|
|
138
|
-
以下是默认绑定。控制面板中的快捷键页以 `MODE /
|
|
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 | `/` |
|
|
144
|
-
| NORMAL | `Ctrl+F` | 跨会话模糊定位 |
|
|
145
|
+
| NORMAL | `Ctrl+F` / `/` | 打开全屏跨会话全文搜索;Enter 执行,`/` 重新编辑 |
|
|
145
146
|
| NORMAL | `Ctrl+B` | 显示/隐藏侧栏 |
|
|
146
147
|
| NORMAL | `Ctrl+M` | 模型与思考强度 |
|
|
147
|
-
| NORMAL | `
|
|
148
|
+
| NORMAL | `F8` | 权限模式轮换 |
|
|
148
149
|
| NORMAL | `F9` | Agent 模式 |
|
|
149
150
|
| NORMAL | `Ctrl+W` | 工作区 |
|
|
150
151
|
| NORMAL | `Ctrl+Shift+W` | 添加工作区 |
|
|
151
152
|
| NORMAL | `Ctrl+T` | 轨迹视图 |
|
|
152
|
-
| NORMAL | `
|
|
153
|
+
| NORMAL | `Ctrl+←` / `Ctrl+→` | 工作区栏 / 对话 / 轨迹 pane 循环聚焦 |
|
|
153
154
|
| NORMAL | `Ctrl+E` | 按 step 快速跳转 |
|
|
154
155
|
| NORMAL | `Ctrl+J` | 后台任务与 Subagent |
|
|
155
156
|
| NORMAL | `Ctrl+N` | 排队命令详情 |
|
|
157
|
+
| NORMAL | `Ctrl+Y` | 运行中 Enter 策略(追加 / 排队) |
|
|
158
|
+
| NORMAL | `Ctrl+O` | 附件管理 |
|
|
156
159
|
| NORMAL | `Ctrl+G` | Goal / TODO |
|
|
157
160
|
| NORMAL | `Ctrl+S` | Settings |
|
|
158
161
|
| NORMAL | `Ctrl+A` | Subagent |
|
|
159
|
-
| NORMAL | `Ctrl+
|
|
162
|
+
| NORMAL | `Ctrl+H` | Skills |
|
|
163
|
+
| NORMAL | `Ctrl+K` | 用默认编辑器(`$EDITOR` / `$VISUAL` / `vi`)打开 `tui-config.json`;编辑完成后快捷键立即重载 |
|
|
160
164
|
| ALL | `Ctrl+Q` | 退出 |
|
|
161
|
-
|
|
|
162
|
-
|
|
|
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` 选中最新块并让块头落在视口底部 |
|
|
163
171
|
| NORMAL | `[` / `]` | 上一个 / 下一个提问终点 |
|
|
164
172
|
| NORMAL | `PgUp` / `PgDn` | 翻页;到顶时加载更早历史 |
|
|
165
173
|
|
|
166
|
-
|
|
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`)以空格分隔书写。
|
|
167
179
|
|
|
168
180
|
## Slash 命令
|
|
169
181
|
|
|
@@ -192,6 +204,10 @@ TUI 支持:
|
|
|
192
204
|
- 独立插件清单及 `/` 筛选;
|
|
193
205
|
- dark、light、gruvbox 主题。
|
|
194
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
|
+
|
|
195
211
|
所有 Buffer 都是模态的:点击外部只会吞掉事件,不会关闭 Buffer。退出必须使用界面明确提示的按键或操作。
|
|
196
212
|
|
|
197
213
|
## 鼠标与终端能力
|
|
@@ -255,6 +271,7 @@ src/term.js raw mode、鼠标、paste、Kitty keyboard
|
|
|
255
271
|
src/screen.js cell framebuffer 与 ANSI diff
|
|
256
272
|
src/text.js grapheme、显示宽度和截断
|
|
257
273
|
src/md.js Markdown、代码块和 OSC 8
|
|
274
|
+
src/keybindings.js 可编辑快捷键注册表(每个功能主/备两个槽位)
|
|
258
275
|
src/widgets.js Input、Popup、ScrollView、Menu、StatusBar
|
|
259
276
|
src/views.js App、ChatView、会话树和主路由
|
|
260
277
|
src/panels.js Workspace、Trajectory、Queue、Jobs、Settings
|
package/bin/dsh-tui.js
CHANGED
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-neotui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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,21 +72,52 @@ export function busyEnter() {
|
|
|
69
72
|
return loadTuiConfig().busyEnter === "steer" ? "steer" : "queue";
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
permissionRotate: { mode: "normal", key: "Shift+Tab" },
|
|
79
|
-
workspace: { mode: "normal", key: "Ctrl+W" }, settings: { mode: "normal", key: "Ctrl+S" }, subagent: { mode: "normal", key: "Ctrl+A" },
|
|
80
|
-
skills: { mode: "normal", key: "Ctrl+K" }, goal: { mode: "normal", key: "Ctrl+G" }, jobs: { mode: "normal", key: "Ctrl+J" },
|
|
81
|
-
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" },
|
|
82
81
|
};
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
}
|
|
85
116
|
export function resetKeyBinding(id) { const all = { ...(loadTuiConfig().keyBindings ?? {}) }; delete all[id]; return saveTuiConfig({ keyBindings: all }); }
|
|
86
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
|
+
|
|
87
121
|
export function foldDefaults() {
|
|
88
122
|
const fd = loadTuiConfig().foldDefaults ?? {};
|
|
89
123
|
return {
|
package/src/file-picker.js
CHANGED
|
@@ -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 =
|
|
187
|
-
if (ev.name === 'down') { this.clearKitty(); this.sel =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
261
|
-
// always sit above the vertical bars, never above the code
|
|
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("
|
|
269
|
-
SEG(
|
|
270
|
-
SEG("
|
|
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
|
|
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
|
-
}
|