dsh-neotui 0.1.29 → 0.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/CHANGELOG.md +38 -0
- package/README.md +228 -128
- package/bin/dsh-tui.js +7 -1
- package/package.json +5 -2
- package/src/api.js +77 -28
- package/src/config.js +18 -0
- package/src/file-picker.js +196 -0
- package/src/index.js +10 -0
- package/src/md.js +1 -1
- package/src/panels.js +893 -216
- package/src/screen.js +20 -6
- package/src/term.js +23 -6
- package/src/text.js +19 -5
- package/src/views.js +1099 -260
- package/src/widgets.js +33 -10
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-08-16
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Yazi 风格三栏文件选择器和工作区目录选择器。
|
|
8
|
+
- 路径编辑、目录筛选、隐藏项切换、内容型文件识别和 Nerd Font 图标。
|
|
9
|
+
- Kitty 图片附件、附件管理器和等比例图片预览。
|
|
10
|
+
- 会话树筛选和跨会话模糊定位。
|
|
11
|
+
- Ctrl+Space 控制面板中的快捷键、命令、设置和插件页面。
|
|
12
|
+
- 可校验、可恢复默认的快捷键覆盖配置。
|
|
13
|
+
- 插件清单筛选。
|
|
14
|
+
- Goal、Queue / Steering、后台任务、Subagent 和附件状态界面。
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Buffer 改为严格模态:点击外部不再隐式关闭。
|
|
19
|
+
- 历史分页不再受渲染层固定消息数量上限影响。
|
|
20
|
+
- 输入附件只显示在固定附件栏,不再写入消息文本。
|
|
21
|
+
- 工具和思考块交互、会话跳转与状态栏信息统一为 Nvim 风格操作。
|
|
22
|
+
- README 重构为安装、交互、快捷键、命令、配置和限制的完整指南。
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- 历史向上分页递归与 viewport 锚点。
|
|
27
|
+
- bracketed paste 和图片粘贴路由。
|
|
28
|
+
- Kitty 图片传输、placement、清理、返回和 framebuffer 重绘。
|
|
29
|
+
- 文件选择器 Space 多选、返回定位、居中滚动、筛选固定和路径展开。
|
|
30
|
+
- 附件管理器 `dd` 删除。
|
|
31
|
+
- 宽字符覆盖和附件图标 cell 宽度。
|
|
32
|
+
- PTY 退出、鼠标控制序列和 Kitty keyboard 尾部解析。
|
|
33
|
+
|
|
34
|
+
### Known limitations
|
|
35
|
+
|
|
36
|
+
- 当前 Host prompt 内容协议只接受文本和图片,不支持通用文件块。
|
|
37
|
+
- 快捷键覆盖已安全持久化,但部分旧 handler 尚未完全迁移到动态 dispatch。
|
|
38
|
+
- 图片体验取决于 Kitty graphics 兼容终端及其 cell 几何信息。
|
package/README.md
CHANGED
|
@@ -1,166 +1,266 @@
|
|
|
1
|
-
# dsh-neotui
|
|
1
|
+
# dsh-neotui
|
|
2
2
|
|
|
3
|
-
DeepSeek Harness
|
|
3
|
+
`dsh-neotui` 是 DeepSeek Harness 的终端客户端:以 Vim/Nvim 风格的 NORMAL / INSERT 模式、Yazi 风格文件选择器和鼠标交互,访问与 WebUI 相同的 Host 会话、工具、审批、任务和设置能力。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
本仓库发布两个 npm 包:
|
|
6
|
+
|
|
7
|
+
| 包 | 用途 |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `dsh-neotui` | TUI 客户端、核心界面和 `dsh-neotui` 命令 |
|
|
10
|
+
| `dsh-neotui-app` | 将 TUI、API gateway 和 Host 服务装配成 DSH profile 的 bundle |
|
|
11
|
+
|
|
12
|
+
## 0.2.0 亮点
|
|
13
|
+
|
|
14
|
+
- 三栏 Yazi 风格文件与工作区选择器:路径编辑、模糊筛选、隐藏项、Nerd Font 图标和 Kitty 图片预览;
|
|
15
|
+
- 图片附件栏、附件管理器、`dd` 删除和等比例 Kitty 预览;
|
|
16
|
+
- 会话树筛选、跨会话模糊定位和稳定的历史分页;
|
|
17
|
+
- NORMAL / INSERT 模式与可编辑的快捷键目录;
|
|
18
|
+
- 独立的命令、设置和插件目录,插件支持即时筛选;
|
|
19
|
+
- Queue / Steering、Goal、TODO、Plan Review、后台任务和 Subagent 状态;
|
|
20
|
+
- grapheme-aware framebuffer、CJK/组合字符、Kitty keyboard、SGR mouse、OSC 8/52。
|
|
21
|
+
|
|
22
|
+
完整变化见 [`CHANGELOG.md`](CHANGELOG.md)。
|
|
23
|
+
|
|
24
|
+
## 安装与启动
|
|
25
|
+
|
|
26
|
+
### 独立 DSH profile
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
dsh plugin --profile dsh-neotui add dsh-neotui-app
|
|
30
|
+
dsh --profile dsh-neotui
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
常用参数:
|
|
6
34
|
|
|
7
35
|
```bash
|
|
8
|
-
|
|
9
|
-
dsh
|
|
10
|
-
dsh --profile dsh-neotui
|
|
36
|
+
dsh --profile dsh-neotui --session <session-id>
|
|
37
|
+
dsh --profile dsh-neotui --cwd ~/work
|
|
38
|
+
dsh --profile dsh-neotui --host 127.0.0.1 --port 3981
|
|
11
39
|
```
|
|
12
40
|
|
|
13
|
-
|
|
41
|
+
出于安全原因,`--host 0.0.0.0` 会被拒绝。能够执行工具的 Host 不应直接暴露到不受信任的网络。
|
|
14
42
|
|
|
15
|
-
|
|
43
|
+
### 连接已有 Web Host
|
|
16
44
|
|
|
17
45
|
```bash
|
|
18
|
-
|
|
46
|
+
dsh --profile dsh-neotui --attach 3080
|
|
19
47
|
```
|
|
20
48
|
|
|
21
|
-
|
|
49
|
+
也可以直接运行客户端:
|
|
22
50
|
|
|
23
51
|
```bash
|
|
24
|
-
|
|
25
|
-
|
|
52
|
+
node bin/dsh-tui.js
|
|
53
|
+
node bin/dsh-tui.js --base http://127.0.0.1:3080
|
|
26
54
|
```
|
|
27
55
|
|
|
28
|
-
|
|
56
|
+
默认连接 `http://127.0.0.1:3080`;可通过 `--base`、`DSH_URL` 或 `DSH_WEB_URL` 覆盖。`--attach` 不会启动或替换 WebUI。
|
|
57
|
+
|
|
58
|
+
## 从源码运行
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
.
|
|
62
|
+
├── app/ dsh-neotui-app bundle
|
|
63
|
+
├── bin/dsh-tui.js 客户端入口
|
|
64
|
+
├── src/ TUI 核心
|
|
65
|
+
└── test/ 单元、终端协议与 PTY 测试
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
使用本地 profile:
|
|
29
69
|
|
|
30
70
|
```bash
|
|
71
|
+
mkdir -p ~/.dsh/profiles/node_modules
|
|
72
|
+
ln -sfn "$(pwd)" ~/.dsh/profiles/node_modules/dsh-neotui
|
|
73
|
+
ln -sfn "$(pwd)/app" ~/.dsh/profiles/node_modules/dsh-neotui-app
|
|
31
74
|
dsh --profile dsh-neotui
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
dsh --profile dsh-neotui --attach 3080 # 共存调试:连已运行的 web 宿主(存储隔离,不碰 $DSH_HOME)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
只调试客户端时,保证目标 Host 已运行即可:
|
|
36
78
|
|
|
37
|
-
|
|
38
|
-
node bin/dsh-tui.js --base http://
|
|
79
|
+
```bash
|
|
80
|
+
node bin/dsh-tui.js --base http://127.0.0.1:3080
|
|
39
81
|
```
|
|
40
82
|
|
|
41
|
-
|
|
83
|
+
## 交互模型
|
|
42
84
|
|
|
43
|
-
|
|
85
|
+
### NORMAL / INSERT
|
|
44
86
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
87
|
+
- `NORMAL`:单字符用于导航和操作;
|
|
88
|
+
- `INSERT`:键盘输入交给消息编辑器;
|
|
89
|
+
- `i` 或点击输入框进入 INSERT;
|
|
90
|
+
- `Esc` 离开 INSERT;
|
|
91
|
+
- INSERT 中 `Esc` 不会中断当前回合;
|
|
92
|
+
- NORMAL 中 `Esc` 会中断正在运行的回合,否则返回上一级;
|
|
93
|
+
- NORMAL 中连续两次 `Ctrl+C` 退出,INSERT 中 `Ctrl+C` 清空输入。
|
|
94
|
+
|
|
95
|
+
底栏始终显示当前模式。按 `Ctrl+Space` 打开快捷键、命令、设置和插件目录。
|
|
96
|
+
|
|
97
|
+
### 输入与附件
|
|
98
|
+
|
|
99
|
+
| 模式 | 按键 | 功能 |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| INSERT | `Enter` | 发送 |
|
|
102
|
+
| INSERT | `Shift+Enter` / `Ctrl+J` | 换行 |
|
|
103
|
+
| INSERT | `Ctrl+L` | 展开/折叠输入栏 |
|
|
104
|
+
| INSERT | `↑` / `↓` | 在首尾行浏览输入历史 |
|
|
105
|
+
| INSERT | `Ctrl+Shift+C` | 复制输入框选区 |
|
|
106
|
+
| INSERT | `Ctrl+O` | 打开文件选择器 |
|
|
107
|
+
| NORMAL | `Ctrl+O` | 打开附件管理器 |
|
|
108
|
+
|
|
109
|
+
文件选择器支持:
|
|
110
|
+
|
|
111
|
+
| 按键 | 功能 |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `↑` / `↓` | 移动光标 |
|
|
114
|
+
| `←` / `→` | 返回上级 / 进入目录 |
|
|
115
|
+
| `Space` | 选择或取消文件 |
|
|
116
|
+
| `Enter` | 确认上传 |
|
|
117
|
+
| `/` | 筛选当前目录 |
|
|
118
|
+
| `Ctrl+/` | 清除筛选并退出筛选模式 |
|
|
119
|
+
| `Ctrl+F` | 编辑路径,支持 `~`、`$HOME` 和环境变量 |
|
|
120
|
+
| `Ctrl+.` | 显示/隐藏隐藏项 |
|
|
121
|
+
| `Esc` | 关闭 |
|
|
122
|
+
|
|
123
|
+
附件管理器支持 `Enter` 预览、`Shift+Enter` 或双击用默认程序打开、`dd` 删除。当前 Host 内容协议只接受文本和图片;普通文件不会被伪装成可发送附件。
|
|
124
|
+
|
|
125
|
+
Kitty graphics 可用时,图片在文件选择器和附件预览中等比例显示;否则回退到 MIME、尺寸和文件大小信息。
|
|
50
126
|
|
|
51
|
-
|
|
127
|
+
### Queue / Steering
|
|
52
128
|
|
|
53
|
-
|
|
129
|
+
模型运行时,Enter 的策略由 `busyEnter` 决定:
|
|
130
|
+
|
|
131
|
+
- `queue`:加入下一回合队列;
|
|
132
|
+
- `steer`:追加到当前回合。
|
|
133
|
+
|
|
134
|
+
`Ctrl+Y` 切换策略,`Ctrl+U` 打开队列。队列面板使用 `e` 编辑、`s` steering、`d` 删除、`Esc` 关闭。
|
|
135
|
+
|
|
136
|
+
## 快捷键
|
|
137
|
+
|
|
138
|
+
以下是默认绑定。控制面板中的快捷键页以 `MODE / KEY / FUNCTION` 三列显示,并允许编辑用户覆盖;配置写入 `$DSH_HOME/tui-config.json`。
|
|
139
|
+
|
|
140
|
+
| 模式 | 按键 | 功能 |
|
|
141
|
+
|---|---|---|
|
|
142
|
+
| ALL | `Ctrl+Space` / `F7` | 控制面板 |
|
|
143
|
+
| NORMAL | `/` | 筛选会话树;`Ctrl+/` 退出 |
|
|
144
|
+
| NORMAL | `Ctrl+F` | 跨会话模糊定位 |
|
|
145
|
+
| NORMAL | `Ctrl+B` | 显示/隐藏侧栏 |
|
|
146
|
+
| NORMAL | `Ctrl+M` | 模型与思考强度 |
|
|
147
|
+
| NORMAL | `F8` / `F9` | 权限策略 / Agent 模式 |
|
|
148
|
+
| NORMAL | `Ctrl+W` | 工作区 |
|
|
149
|
+
| NORMAL | `Ctrl+Shift+W` | 添加工作区 |
|
|
150
|
+
| NORMAL | `Ctrl+T` | 轨迹视图 |
|
|
151
|
+
| NORMAL | `Shift+Tab` | 对话/轨迹切换 |
|
|
152
|
+
| NORMAL | `Ctrl+E` | 按 step 快速跳转 |
|
|
153
|
+
| NORMAL | `Ctrl+J` | 后台任务与 Subagent |
|
|
154
|
+
| NORMAL | `Ctrl+U` | 消息队列 |
|
|
155
|
+
| NORMAL | `Ctrl+G` | Goal / TODO |
|
|
156
|
+
| NORMAL | `Ctrl+S` | Settings |
|
|
157
|
+
| NORMAL | `Ctrl+A` | Subagent |
|
|
158
|
+
| NORMAL | `Ctrl+K` | Skills |
|
|
159
|
+
| ALL | `Ctrl+Q` | 退出 |
|
|
160
|
+
| NORMAL | `t` / `b` | 展开/折叠思考块 / 工具块 |
|
|
161
|
+
| NORMAL | `g g` / `G` | 对话顶部 / 底部 |
|
|
162
|
+
| NORMAL | `[` / `]` | 上一个 / 下一个提问终点 |
|
|
163
|
+
| NORMAL | `PgUp` / `PgDn` | 翻页;到顶时加载更早历史 |
|
|
164
|
+
|
|
165
|
+
快捷键目录中:`Enter` 编辑当前 JSON 配置项,`Shift+Tab` 在 NORMAL / INSERT / ALL 间轮换,`Alt+Enter` 恢复默认。保存前会校验 JSON、模式和按键字段;错误文本会保留以便继续修改。
|
|
166
|
+
|
|
167
|
+
## Slash 命令
|
|
168
|
+
|
|
169
|
+
输入 `/` 后使用 `Tab`、`↑`、`↓` 补全。TUI 本地命令包括:
|
|
170
|
+
|
|
171
|
+
| 命令 | 功能 |
|
|
54
172
|
|---|---|
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
- 消息反馈:助手消息右键 👍/👎(`messageFeedback/put` Typert RPC,`{request:…}` 载荷),已评状态回显、可删除
|
|
107
|
-
- 图片画廊:多图时 ←/→ 切换,标题显示 (N/M) + 尺寸
|
|
108
|
-
- 会话内搜索高亮:Ctrl+F 跳转后匹配词高亮(黄底),Esc 清除
|
|
109
|
-
- 轨迹逐事件详情:点击回合 → 事件列表 → 选事件看全文
|
|
110
|
-
- 用户消息前缀:自己发的消息第一行直接显示内容并带 `用户名 > ` 前缀(默认取系统登录名);**设置页新增「TUI 界面」命名空间**,编辑 `userPrefix` 并 Ctrl+S 即时生效(持久化到 `$DSH_HOME/tui-config.json`);环境变量 `DSH_TUI_USER_PREFIX` 亦可覆盖
|
|
111
|
-
- 块计时:流式块实时显示 `已经过 X秒/分/小时`,完成后冻结为 `已完成,耗时 X`(工具失败显示 `失败,耗时 X`);缺失 block-end 也不会出现永远走动的计时器
|
|
112
|
-
- 块编号:think/tool/text 块头部标注所属 `(step N)`,与轨迹页的 step 编号对应,Ctrl+E 可据此快速转跳
|
|
113
|
-
- 主题持久化:切主题写入 `$DSH_HOME/tui-theme.txt`(或 XDG config),重启保留
|
|
114
|
-
- 输入模式标识:标签栏右侧 NORMAL/INSERT 指示,Esc 退出输入
|
|
115
|
-
- 实时流:merge 修复(新回合不再覆盖旧回合)、活跃时 500ms 轮询、流式思考/工具自动展开
|
|
116
|
-
- footer 增加完整工作路径 + 缓存命中率
|
|
117
|
-
- 工作区右键「重命名工作区」(workspace.rename)
|
|
118
|
-
- 轨迹回合行加用户消息预览、移除「按 Esc 返回」提示
|
|
119
|
-
- 点击击穿修复:弹窗关闭时吞掉配对 release,不再误触下层会话
|
|
120
|
-
- footer 离线态:断连时第 1 行显示 ⚠离线
|
|
121
|
-
- 功能:会话列表/搜索/新建/重命名、流式对话(历史 + 轮询实时)、推理块折叠、工具卡(diff/通用卡)、审批/提问弹窗
|
|
122
|
-
- 面板:命令面板(模糊筛选)、模型选择(真实目录)、工作区文件树(展开/预览)、轨迹视图(braille 三车道时间轴 + 回合统计表)、任务/目标弹窗
|
|
123
|
-
- 设置:11 个命名空间通用 JSON 树编辑器(类型着色、布尔点击切换、标量编辑、pending 暂存、settings.mutate 保存、重启生效标记)
|
|
124
|
-
- 子代理:列表(activity/mode)、历史日志、continuable 发消息、中断
|
|
125
|
-
- 会话操作:分叉(session.fork)、日志导出(ZIP 落盘,已真机验证 1.5MB zip)
|
|
126
|
-
- 主题:theme.js 三套调色板(dark/light/gruvbox),全 UI 经 live Proxy 读色,命令面板一键切换
|
|
127
|
-
- 实时更新:mux 实时通道在本部署不工作(20s 探测仅 3 个 baseline 帧),改用 web 同款 resync 式轮询(1.2s,活跃时自适应),会话列表状态同步刷新
|
|
128
|
-
- 渲染性能:节点级渲染缓存(仅流式尾节点重渲染),冷重建 50 节点 ~3ms,热重建命中缓存
|
|
129
|
-
- 鼠标:滚动条点击跳转 + 拖拽 scrubbing、拖拽选区 + OSC 52 复制(nvim 式)、右键上下文菜单
|
|
130
|
-
- 技能:skill.list 真实数据(⚡ 模型可调用徽标 + 描述/何时使用详情)
|
|
131
|
-
- 图片输入:`@路径` 解析 → image 内容部件(15 项单测,解析/媒体类型/base64/容错全覆盖)
|
|
132
|
-
- 图片:消息内 🖼 占位 → kitty 图形协议内联 / chafa 字符预览 / 外部查看器兜底
|
|
133
|
-
- 状态栏:上下文压力 (ctx%)、token、目标 🎯、连接态、任务指示
|
|
173
|
+
| `/reload` | 重新绘制并载入界面状态 |
|
|
174
|
+
| `/restart` | 重启 TUI 并恢复会话 handoff |
|
|
175
|
+
| `/model` | 模型选择 |
|
|
176
|
+
| `/theme` | 切换主题 |
|
|
177
|
+
| `/permission` | 权限选择 |
|
|
178
|
+
| `/goal` | Goal 面板 |
|
|
179
|
+
|
|
180
|
+
Host 提供的 `/compact`、`/export`、`/feedback`、`/plan` 等命令会动态出现在命令页;实际清单以当前 Host 的 `commands/list` 为准。
|
|
181
|
+
|
|
182
|
+
## 面板与工具卡
|
|
183
|
+
|
|
184
|
+
TUI 支持:
|
|
185
|
+
|
|
186
|
+
- 工作区和分组会话树:新建、打开、重命名、移动、归档、删除和导出;
|
|
187
|
+
- terminal、read、search、web、diff 和 generic presentation;
|
|
188
|
+
- `run_code` 嵌套子调用树;
|
|
189
|
+
- 工具审批、AskUser 单选/多选、Plan Review;
|
|
190
|
+
- Goal、TODO、后台任务和 Subagent;
|
|
191
|
+
- 独立插件清单及 `/` 筛选;
|
|
192
|
+
- dark、light、gruvbox 主题。
|
|
193
|
+
|
|
194
|
+
所有 Buffer 都是模态的:点击外部只会吞掉事件,不会关闭 Buffer。退出必须使用界面明确提示的按键或操作。
|
|
195
|
+
|
|
196
|
+
## 鼠标与终端能力
|
|
197
|
+
|
|
198
|
+
- 点击工作区、会话、标签、工具块和输入框;
|
|
199
|
+
- 拖动侧栏分隔线;
|
|
200
|
+
- 滚轮浏览对话、列表和弹窗;
|
|
201
|
+
- 输入框拖选并通过 OSC 52 复制;
|
|
202
|
+
- 右键消息、轨迹 step 和工作区树打开菜单;
|
|
203
|
+
- SGR mouse、bracketed paste、Kitty keyboard、OSC 8、OSC 52;
|
|
204
|
+
- ANSI truecolor 差量 framebuffer;
|
|
205
|
+
- CJK、组合字符和 ZWJ emoji 的 grapheme-aware 渲染。
|
|
206
|
+
|
|
207
|
+
不同终端、tmux 和 SSH 环境对 Kitty graphics、keyboard、OSC 52 的支持不同。WezTerm 和 Kitty 是图片预览的推荐终端。
|
|
208
|
+
|
|
209
|
+
## 配置
|
|
210
|
+
|
|
211
|
+
TUI 设置:
|
|
212
|
+
|
|
213
|
+
```text
|
|
214
|
+
$DSH_HOME/tui-config.json
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
包含显示名、默认折叠状态、运行中 Enter 策略和快捷键覆盖。主题保存在:
|
|
218
|
+
|
|
219
|
+
```text
|
|
220
|
+
$DSH_HOME/tui-theme.txt
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`DSH_TUI_USER_PREFIX` 可覆盖默认用户名。
|
|
134
224
|
|
|
135
225
|
## 测试
|
|
136
226
|
|
|
137
227
|
```bash
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
node bin/dsh-tui.js --script test/smoke.script --plain # 真机冒烟
|
|
142
|
-
node bin/dsh-tui.js --script test/live.script --plain # 实时流验证
|
|
143
|
-
node bin/dsh-tui.js --script test/panels.script --plain # 面板全流程
|
|
144
|
-
node bin/dsh-tui.js --script test/final.script --plain # 目标/模型
|
|
228
|
+
npm test # 单元与协议测试
|
|
229
|
+
npm run test:pty # 真实 PTY 生命周期
|
|
230
|
+
npm run test:rc # 完整发布候选验证
|
|
145
231
|
```
|
|
146
232
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
脚本语法:`wait <ms>` / `key <name>` / `text ...` / `mouse <kind> <btn> <x> <y>` / `frame <json>` / `quit`。
|
|
233
|
+
PTY 测试会验证 alternate screen、SGR mouse、界面渲染、退出恢复和常见运行时错误。需要可用的 DSH Host;Host 不可用时测试会明确输出 `SKIP`。
|
|
150
234
|
|
|
151
|
-
|
|
235
|
+
脚本化 smoke:
|
|
152
236
|
|
|
237
|
+
```bash
|
|
238
|
+
node bin/dsh-tui.js --script test/smoke.script --plain
|
|
153
239
|
```
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
240
|
+
|
|
241
|
+
## 当前限制
|
|
242
|
+
|
|
243
|
+
- TUI 与 Host 必须使用兼容的事件、RPC 和内容块契约;
|
|
244
|
+
- 当前 Host 不支持通用二进制文件内容块,文件选择器只会发送图片;
|
|
245
|
+
- Kitty 图片效果受终端实现、cell 尺寸和复用器支持影响;
|
|
246
|
+
- 超长工具输出可能由 Host 截断,TUI 会显示工具提供的恢复位置;
|
|
247
|
+
- 快捷键覆盖配置已持久化并经过校验,部分旧 handler 仍使用内置 dispatch,后续版本会继续统一动态绑定。
|
|
248
|
+
|
|
249
|
+
## 代码结构
|
|
250
|
+
|
|
251
|
+
```text
|
|
252
|
+
src/api.js HTTP RPC、WebSocket 和 respond
|
|
253
|
+
src/term.js raw mode、鼠标、paste、Kitty keyboard
|
|
254
|
+
src/screen.js cell framebuffer 与 ANSI diff
|
|
255
|
+
src/text.js grapheme、显示宽度和截断
|
|
256
|
+
src/md.js Markdown、代码块和 OSC 8
|
|
257
|
+
src/widgets.js Input、Popup、ScrollView、Menu、StatusBar
|
|
258
|
+
src/views.js App、ChatView、会话树和主路由
|
|
259
|
+
src/panels.js Workspace、Trajectory、Queue、Jobs、Settings
|
|
260
|
+
src/file-picker.js 三栏文件/目录选择器与图片预览
|
|
261
|
+
app/ DSH bundle 与 Cordis patch
|
|
162
262
|
```
|
|
163
263
|
|
|
164
|
-
##
|
|
264
|
+
## 许可证
|
|
165
265
|
|
|
166
|
-
|
|
266
|
+
MIT
|
package/bin/dsh-tui.js
CHANGED
|
@@ -20,6 +20,7 @@ const has = (name) => args.includes(name);
|
|
|
20
20
|
|
|
21
21
|
const base = opt("--base", opt("--attach", process.env.DSH_URL || process.env.DSH_WEB_URL || "http://127.0.0.1:3080"));
|
|
22
22
|
const log = (...a) => console.error("[dsh-tui]", ...a);
|
|
23
|
+
let activeTerm = null;
|
|
23
24
|
|
|
24
25
|
async function main() {
|
|
25
26
|
const script = opt("--script", null);
|
|
@@ -38,6 +39,7 @@ async function main() {
|
|
|
38
39
|
onResize: (w, h) => app.resize(w, h),
|
|
39
40
|
});
|
|
40
41
|
app.term = term;
|
|
42
|
+
activeTerm = term;
|
|
41
43
|
screen.resize(term.w, term.h);
|
|
42
44
|
app.resize(term.w, term.h);
|
|
43
45
|
term.start();
|
|
@@ -127,4 +129,8 @@ function dump(app, plain) {
|
|
|
127
129
|
out.chunks.length = 0;
|
|
128
130
|
}
|
|
129
131
|
|
|
130
|
-
main().catch((e) => {
|
|
132
|
+
main().catch((e) => {
|
|
133
|
+
log("fatal:", e);
|
|
134
|
+
try { activeTerm?.stop(); } catch {}
|
|
135
|
+
process.exit(1);
|
|
136
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-neotui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Neo-TUI: mouse-driven terminal UI client for DeepSeek Harness (B-tier per dsh-tui-design.md)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
"bin",
|
|
15
15
|
"src",
|
|
16
16
|
"README.md",
|
|
17
|
+
"CHANGELOG.md",
|
|
17
18
|
"LICENSE"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"start": "node bin/dsh-tui.js",
|
|
21
|
-
"test": "node test
|
|
22
|
+
"test": "node --test test/*.test.mjs",
|
|
23
|
+
"test:pty": "python3 test/pty-crash.py",
|
|
24
|
+
"test:rc": "npm test && npm run test:pty"
|
|
22
25
|
},
|
|
23
26
|
"repository": {
|
|
24
27
|
"type": "git",
|
package/src/api.js
CHANGED
|
@@ -29,8 +29,10 @@ export class Api {
|
|
|
29
29
|
this.muxWs = null;
|
|
30
30
|
this.closed = false;
|
|
31
31
|
this.connected = false;
|
|
32
|
-
this.
|
|
33
|
-
|
|
32
|
+
this.connectionState = {
|
|
33
|
+
mux: { ws: null, connected: false, retryDelay: 500, timer: null },
|
|
34
|
+
host: { ws: null, connected: false, retryDelay: 500, timer: null },
|
|
35
|
+
};
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
async call(method, payload = {}) {
|
|
@@ -52,20 +54,36 @@ export class Api {
|
|
|
52
54
|
return body.result.value;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
/** Answer an approval/question frame
|
|
57
|
+
/** Answer an approval/question frame successfully. */
|
|
56
58
|
async respond(rpcId, value) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
body: JSON.stringify(env),
|
|
59
|
+
return this.#respondEnvelope(rpcId, { ok: true, value });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Cancel an answerable question using the gateway's fail-closed envelope. */
|
|
63
|
+
async cancelResponse(rpcId) {
|
|
64
|
+
return this.#respondEnvelope(rpcId, {
|
|
65
|
+
ok: false,
|
|
66
|
+
error: { code: "cancelled", message: "cancelled by the TUI user" },
|
|
66
67
|
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async #respondEnvelope(rpcId, result) {
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetch(`${this.base}/api/respond`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "content-type": "application/json" },
|
|
76
|
+
body: JSON.stringify({ type: "client-response", rpcId, result }),
|
|
77
|
+
});
|
|
78
|
+
} catch (e) {
|
|
79
|
+
throw new ApiError({ code: "transport", message: `respond unreachable: ${e.message}` });
|
|
80
|
+
}
|
|
67
81
|
if (!res.ok) throw new ApiError({ code: "http", message: `respond HTTP ${res.status}` });
|
|
68
|
-
|
|
82
|
+
const receipt = await res.json();
|
|
83
|
+
if (receipt?.accepted === false) {
|
|
84
|
+
throw new ApiError({ code: "response-rejected", message: receipt.reason ?? "response rejected" });
|
|
85
|
+
}
|
|
86
|
+
return receipt;
|
|
69
87
|
}
|
|
70
88
|
|
|
71
89
|
connectMux() {
|
|
@@ -82,17 +100,22 @@ export class Api {
|
|
|
82
100
|
|
|
83
101
|
#connect(url, kind) {
|
|
84
102
|
if (this.closed) return;
|
|
103
|
+
const state = this.connectionState[kind];
|
|
104
|
+
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
85
105
|
const ws = new WebSocket(url);
|
|
106
|
+
state.ws = ws;
|
|
86
107
|
if (kind === "mux") this.muxWs = ws;
|
|
87
108
|
else this.hostWs = ws;
|
|
88
|
-
this.ws = ws; // most recent socket (
|
|
109
|
+
this.ws = ws; // most recent socket (diagnostics only)
|
|
89
110
|
ws.onopen = () => {
|
|
90
|
-
this.
|
|
91
|
-
|
|
111
|
+
if (state.ws !== ws || this.closed) return;
|
|
112
|
+
state.connected = true;
|
|
113
|
+
state.retryDelay = 500;
|
|
92
114
|
this.log(`[api] ${kind} stream connected`);
|
|
93
|
-
this
|
|
115
|
+
this.#publishConnectionState();
|
|
94
116
|
};
|
|
95
117
|
ws.onmessage = (m) => {
|
|
118
|
+
if (state.ws !== ws || this.closed) return;
|
|
96
119
|
let body;
|
|
97
120
|
try { body = JSON.parse(String(m.data)); } catch { return; }
|
|
98
121
|
if (body?.type !== "server-request") return;
|
|
@@ -102,16 +125,29 @@ export class Api {
|
|
|
102
125
|
else this.onHostFrame(frame);
|
|
103
126
|
};
|
|
104
127
|
ws.onclose = () => {
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
if (state.ws !== ws) return;
|
|
129
|
+
state.connected = false;
|
|
130
|
+
state.ws = null;
|
|
131
|
+
this.#publishConnectionState();
|
|
107
132
|
if (this.closed) return;
|
|
108
|
-
|
|
109
|
-
this.
|
|
110
|
-
|
|
133
|
+
const delay = state.retryDelay;
|
|
134
|
+
this.log(`[api] ${kind} stream closed, reconnecting in ${delay}ms`);
|
|
135
|
+
state.timer = setTimeout(() => {
|
|
136
|
+
state.timer = null;
|
|
137
|
+
this.#connect(url, kind);
|
|
138
|
+
}, delay);
|
|
139
|
+
state.retryDelay = Math.min(delay * 2, 15000);
|
|
111
140
|
};
|
|
112
141
|
ws.onerror = () => { /* onclose follows */ };
|
|
113
142
|
}
|
|
114
143
|
|
|
144
|
+
#publishConnectionState() {
|
|
145
|
+
const mux = this.connectionState.mux.connected;
|
|
146
|
+
const host = this.connectionState.host.connected;
|
|
147
|
+
this.connected = mux;
|
|
148
|
+
this.onStateChange(mux && host ? "connected" : (mux || host ? "degraded" : "disconnected"));
|
|
149
|
+
}
|
|
150
|
+
|
|
115
151
|
/** Typert gateway RPC: POST /api/<namespace>/<method> with {args} payload
|
|
116
152
|
* (verified: pluginInventory/list returns the live Loader entry roster). */
|
|
117
153
|
async rpcCall(method, payload = {}) {
|
|
@@ -139,15 +175,28 @@ export class Api {
|
|
|
139
175
|
* e.g. when the connect-time snapshot arrived before a session opened. */
|
|
140
176
|
refreshMux() {
|
|
141
177
|
if (this.closed) return;
|
|
142
|
-
|
|
143
|
-
|
|
178
|
+
const state = this.connectionState.mux;
|
|
179
|
+
state.retryDelay = 0;
|
|
180
|
+
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
181
|
+
if (state.ws) {
|
|
182
|
+
try { state.ws.close(); } catch {}
|
|
183
|
+
} else {
|
|
184
|
+
this.#connect(this.wsUrl("events.mux"), "mux");
|
|
185
|
+
}
|
|
144
186
|
}
|
|
145
187
|
|
|
188
|
+
get muxConnected() { return this.connectionState.mux.connected; }
|
|
189
|
+
get hostConnected() { return this.connectionState.host.connected; }
|
|
190
|
+
|
|
146
191
|
close() {
|
|
147
192
|
this.closed = true;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
193
|
+
for (const state of Object.values(this.connectionState)) {
|
|
194
|
+
if (state.timer) clearTimeout(state.timer);
|
|
195
|
+
state.timer = null;
|
|
196
|
+
state.connected = false;
|
|
197
|
+
try { state.ws?.close(); } catch {}
|
|
198
|
+
state.ws = null;
|
|
199
|
+
}
|
|
200
|
+
this.connected = false;
|
|
152
201
|
}
|
|
153
202
|
}
|