dsh-neotui 0.0.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/LICENSE +21 -0
- package/README.md +133 -0
- package/bin/dsh-tui.js +130 -0
- package/package.json +35 -0
- package/src/api.js +140 -0
- package/src/index.js +54 -0
- package/src/md.js +374 -0
- package/src/panels.js +1464 -0
- package/src/screen.js +215 -0
- package/src/term.js +270 -0
- package/src/text.js +110 -0
- package/src/theme.js +90 -0
- package/src/views.js +2403 -0
- package/src/widgets.js +567 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 edabchann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# dsh-neotui — 鼠标驱动 Neo-TUI 客户端
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness 的终端客户端(B 档范围,按 `../dsh-tui-design.md`)。纯 Node 标准库,零依赖。
|
|
4
|
+
|
|
5
|
+
## 运行
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx dsh --profile ntui # 自带宿主:TUI + 内嵌 API(推荐,已装到 ~/.dsh/profiles/ntui)
|
|
9
|
+
npx dsh --profile ntui --session <id> # 启动即打开某会话
|
|
10
|
+
npx dsh --profile ntui --cwd ~/work # 新建会话的默认目录
|
|
11
|
+
npx dsh --profile ntui --port 3981 # 内嵌 API 指定端口(默认 0 = 系统分配)
|
|
12
|
+
npx dsh --profile ntui --attach 3080 # 共存调试:连已运行的 web 宿主(存储自动隔离,不碰 $DSH_HOME)
|
|
13
|
+
|
|
14
|
+
node bin/dsh-tui.js # 纯客户端模式:连接已运行的 web 宿主(默认 3080)
|
|
15
|
+
node bin/dsh-tui.js --base http://host:port
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**共存调试**:web UI 和 TUI 同时跑用 `--attach`——TUI 直连 web 宿主的 API(两边实时互见对方消息),自身的内嵌宿主存储重定向到 `/tmp/dsh-neotui-attach`,绝不写共享的 `$DSH_HOME`。纯客户端 `node bin/dsh-tui.js --attach 3080` 同理。
|
|
19
|
+
|
|
20
|
+
## 结构
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
tui/ TUI 核心(src/) + 独立入口(bin/) + 测试(test/)
|
|
24
|
+
tui/app/ dsh-neotui-app bundle:cordis.patch.yml + tui-startup/tui-runtime 插件
|
|
25
|
+
(~/.dsh/profiles/ntui 通过 profiles/node_modules 软链解析它)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 操作
|
|
29
|
+
|
|
30
|
+
| 鼠标 | 键盘等价 |
|
|
31
|
+
|---|---|
|
|
32
|
+
| 左键点击会话(工作区树内) | ↑↓ + Enter |
|
|
33
|
+
| 点击/Enter/←→ 折叠工作区文件夹 | 右键「折叠全部/展开全部」 |
|
|
34
|
+
| 隐藏/显示侧栏(nvim 式整体收起) | **Ctrl+B** |
|
|
35
|
+
| 控制面板(三页) | **F7**/Ctrl+Space 打开(首页快捷键),**Ctrl+P** 直达命令页;**Tab** 翻页:快捷键 / 命令 / 设置 |
|
|
36
|
+
| 设置次级页 | 在「设置」页内 **Shift+Tab** 翻次级页(常规 / 插件) |
|
|
37
|
+
| 主页切换 | **Shift+Tab** 在对话 ↔ 轨迹之间切换;顶部标签页(对话/轨迹/工作区/设置/技能/子代理)可点击直达 |
|
|
38
|
+
| 输入模式 | `i` 或点击进入,**Esc 退出**(nvim normal/insert);未聚焦时字母=快捷键,多字粘贴直接输入 |
|
|
39
|
+
| 思考/工具折叠 | `t` 思考块展开折叠,`b` 工具块(bash 等)展开折叠 |
|
|
40
|
+
| 右键文件夹 → 新建会话(归属该工作区) | `n` |
|
|
41
|
+
| 滚轮翻页 / 拖拽 | PgUp / PgDn / gg / G |
|
|
42
|
+
| 点击展开思考块/工具卡 | — |
|
|
43
|
+
| 点击输入框输入 | i / 直接打字 |
|
|
44
|
+
| 滚轮到顶加载更早记录 | PgUp 到顶 |
|
|
45
|
+
| — | `/` 搜索会话,`n` 新建会话,Ctrl+B/N 切换焦点 |
|
|
46
|
+
| — | Ctrl+P 命令面板,Ctrl+M 模型,Ctrl+W 工作区,Ctrl+T 轨迹,Ctrl+J 任务,Ctrl+G 目标 |
|
|
47
|
+
| — | Ctrl+S 设置(JSON 树编辑器,settings.mutate 保存),Ctrl+A 子代理(历史/发消息/中断) |
|
|
48
|
+
| — | Ctrl+K 技能列表(详情/复制名) |
|
|
49
|
+
| — | 输入框 `@/路径/图.png` 附带图片发送(base64 image 内容部件) |
|
|
50
|
+
| — | nvim 式按键:聊天聚焦时字母=快捷键(`t` 思考/`i` 输入/`g g` 顶/`G` 底/`/` 搜索),其余字母自动进输入栏 |
|
|
51
|
+
| — | 命令面板「切换主题」:dark / light / gruvbox 实时切换 |
|
|
52
|
+
| — | 会话右键菜单:打开/重命名/停止/复制 ID/分叉/导出日志 |
|
|
53
|
+
| — | Ctrl+Q 退出 |
|
|
54
|
+
|
|
55
|
+
## 验证过的能力
|
|
56
|
+
|
|
57
|
+
- 协议:unary RPC(POST `/api/<method>` 信封)+ WS 帧流(`/api/events.mux`)+ `/api/respond`(审批/提问应答)
|
|
58
|
+
- 输入:SGR 鼠标(1000/1002/1003/1006)press/release/drag/wheel、修饰键、CJK、bracketed paste、kitty 键盘协议(24/24 单测通过)
|
|
59
|
+
- 渲染:cell diff + truecolor、OSC 8 可点击链接、CJK 宽度、滚动缓冲 + 游标分页(beforeSeq)
|
|
60
|
+
- 侧栏:web 同款「工作区(文件夹) → 会话(文件)」可折叠树(运行徽标、未分组兜底、右键菜单)
|
|
61
|
+
- 降噪:焦点感知高亮(仅聚焦窗格显示选中态)、消息流无粗体标题(用户绿色 gutter、助手空白分隔)、链接无下划线、统一提示样式
|
|
62
|
+
- 块式渲染(pi 风格):输出按块拆分,每块独立底色——思考 `THINKBG`(暗)、工具调用 `TOOLBG`(蓝调 `#1e1e2e`)、工具成功 `TOOLOK`(绿调 `#1e2e1e`)、工具失败 `TOOLERR`(红调 `#2e1e1e`)、正文 `CARD`、用户消息 `USERBG`(`#2d2d30`);⏳/✓/✗ 状态字形;块间留白;颜色随主题([pi 主题参考](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/themes.md))
|
|
63
|
+
- 工具块**默认展开**(命令 JSON + 结果常显),点击折叠;思考块默认折叠,**`t` 键全局展开/折叠**(pi 式快捷键),点击单个思考块覆盖全局模式
|
|
64
|
+
- 稳定性:宽字符贴右缘自动截断(防终端换行损坏)、渲染循环 try/catch 兜底(出错不再杀进程,日志+继续)
|
|
65
|
+
- SGR 复位语义:样式分量回到默认值即显式复位(`\x1b[0m`),滚动后背景残留/蔓延彻底消除,块间空隙稳定露出终端背景
|
|
66
|
+
- 输入光标:下划线式(不再反显白块);搜索模式独占输入(IME 多字文本进搜索框、结果实时刷新、退格/leader 可用)
|
|
67
|
+
- leader 面板对鼠标 motion 免疫(仅点击关闭),搜索模式内 F7/Ctrl+Space 仍可呼出
|
|
68
|
+
- 模型切换支持思考强度:选模型后有 Off/High/Max 二次选择(默认 High);插件清单页通过 Typert RPC(`pluginInventory/list`,HTTP POST + `{args}` 载荷)读真实 Loader 条目
|
|
69
|
+
- 命令页显示真实 slash 命令(`commands/list`):/compact /export /feedback /goal /permission /plan,点选填入输入框
|
|
70
|
+
- 布局:顶部标签页(对话/轨迹,面板以额外活动标签呈现)+ 侧栏「▣ 工作区」标题行
|
|
71
|
+
- footer(powerline 风格,2–3 行):第 1 行身份(工作区/会话/运行态/🎯目标/✎计划/模型),第 2 行用量(ctx 条+%、入/出/缓存/共 token、⚙步/回合/首响),第 3 行任务(有 job 时)
|
|
72
|
+
- 折叠块预览:bash 折叠后显示命令概要(view.title 或 command),思考折叠后显示前 3 行
|
|
73
|
+
- 会话内搜索:**Ctrl+F** 模糊搜本会话消息/工具名,回车跳转
|
|
74
|
+
- 轨迹详情:点击回合行弹窗(工具调用 + 耗时),`/` 过滤回合;**增量加载**(每页 20 回合逐页渲染,最近回合秒开,600 回合封顶,不再阻塞等待全量历史)
|
|
75
|
+
- 工作区文件搜索:工作区面板内直接打字(`/`)模糊搜文件名,回车预览
|
|
76
|
+
- 消息反馈:助手消息右键 👍/👎(`messageFeedback/put` Typert RPC),已评状态回显、可删除
|
|
77
|
+
- 图片画廊:多图时 ←/→ 切换,标题显示 (N/M) + 尺寸
|
|
78
|
+
- 会话内搜索高亮:Ctrl+F 跳转后匹配词高亮(黄底),Esc 清除
|
|
79
|
+
- 轨迹逐事件详情:点击回合 → 事件列表 → 选事件看全文
|
|
80
|
+
- 主题持久化:切主题写入 `$DSH_HOME/tui-theme.txt`(或 XDG config),重启保留
|
|
81
|
+
- 输入模式标识:标签栏右侧 NORMAL/INSERT 指示,Esc 退出输入
|
|
82
|
+
- 实时流:merge 修复(新回合不再覆盖旧回合)、活跃时 500ms 轮询、流式思考/工具自动展开
|
|
83
|
+
- footer 增加完整工作路径 + 缓存命中率
|
|
84
|
+
- 工作区右键「重命名工作区」(workspace.rename)
|
|
85
|
+
- 轨迹回合行加用户消息预览、移除「按 Esc 返回」提示
|
|
86
|
+
- 点击击穿修复:弹窗关闭时吞掉配对 release,不再误触下层会话
|
|
87
|
+
- footer 离线态:断连时第 1 行显示 ⚠离线
|
|
88
|
+
- 功能:会话列表/搜索/新建/重命名、流式对话(历史 + 轮询实时)、推理块折叠、工具卡(diff/通用卡)、审批/提问弹窗
|
|
89
|
+
- 面板:命令面板(模糊筛选)、模型选择(真实目录)、工作区文件树(展开/预览)、轨迹视图(braille 三车道时间轴 + 回合统计表)、任务/目标弹窗
|
|
90
|
+
- 设置:11 个命名空间通用 JSON 树编辑器(类型着色、布尔点击切换、标量编辑、pending 暂存、settings.mutate 保存、重启生效标记)
|
|
91
|
+
- 子代理:列表(activity/mode)、历史日志、continuable 发消息、中断
|
|
92
|
+
- 会话操作:分叉(session.fork)、日志导出(ZIP 落盘,已真机验证 1.5MB zip)
|
|
93
|
+
- 主题:theme.js 三套调色板(dark/light/gruvbox),全 UI 经 live Proxy 读色,命令面板一键切换
|
|
94
|
+
- 实时更新:mux 实时通道在本部署不工作(20s 探测仅 3 个 baseline 帧),改用 web 同款 resync 式轮询(1.2s,活跃时自适应),会话列表状态同步刷新
|
|
95
|
+
- 渲染性能:节点级渲染缓存(仅流式尾节点重渲染),冷重建 50 节点 ~3ms,热重建命中缓存
|
|
96
|
+
- 鼠标:滚动条点击跳转 + 拖拽 scrubbing、拖拽选区 + OSC 52 复制(nvim 式)、右键上下文菜单
|
|
97
|
+
- 技能:skill.list 真实数据(⚡ 模型可调用徽标 + 描述/何时使用详情)
|
|
98
|
+
- 图片输入:`@路径` 解析 → image 内容部件(15 项单测,解析/媒体类型/base64/容错全覆盖)
|
|
99
|
+
- 图片:消息内 🖼 占位 → kitty 图形协议内联 / chafa 字符预览 / 外部查看器兜底
|
|
100
|
+
- 状态栏:上下文压力 (ctx%)、token、目标 🎯、连接态、任务指示
|
|
101
|
+
|
|
102
|
+
## 测试
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
node test/term.test.mjs # 输入解码器单测 (24 项)
|
|
106
|
+
node test/image.test.mjs # 图片输入解析单测 (15 项)
|
|
107
|
+
# 真机脚本(10 个):smoke live panels final settings edit theme skills select poll
|
|
108
|
+
node bin/dsh-tui.js --script test/smoke.script --plain # 真机冒烟
|
|
109
|
+
node bin/dsh-tui.js --script test/live.script --plain # 实时流验证
|
|
110
|
+
node bin/dsh-tui.js --script test/panels.script --plain # 面板全流程
|
|
111
|
+
node bin/dsh-tui.js --script test/final.script --plain # 目标/模型
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
真 tty 端到端(raw 模式 + alt-screen):`script -qec "node bin/dsh-tui.js" /dev/null`
|
|
115
|
+
|
|
116
|
+
脚本语法:`wait <ms>` / `key <name>` / `text ...` / `mouse <kind> <btn> <x> <y>` / `frame <json>` / `quit`。
|
|
117
|
+
|
|
118
|
+
## 结构(TUI 核心)
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
src/text.js Unicode 宽度、braille、bars
|
|
122
|
+
src/screen.js cell 帧缓冲 + ANSI diff 渲染
|
|
123
|
+
src/term.js raw 模式、SGR/kitty 输入解码
|
|
124
|
+
src/api.js RPC + WS + respond
|
|
125
|
+
src/md.js Markdown → 终端行(OSC 8 链接、轻量高亮)
|
|
126
|
+
src/widgets.js List/ScrollView/Input/Popup/Menu/StatusBar
|
|
127
|
+
src/views.js App + 会话列表 + ChatView + 审批弹窗
|
|
128
|
+
bin/dsh-tui.js 入口(交互 / 脚本化测试台)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## 路线图(剩余 B 档模块)
|
|
132
|
+
|
|
133
|
+
图片插件端到端验证(需真实附件)、kitty 图形协议实机验证(需 kitty/wezterm 终端)、TUI slot 契约三层(见设计文档 §2)、子代理完整流程验证(需真实子代理)、交付物/反馈面板(无对应投影数据)、locale 切换。
|
package/bin/dsh-tui.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// dsh-tui — mouse-driven terminal client for DeepSeek Harness.
|
|
3
|
+
// Usage:
|
|
4
|
+
// dsh-tui interactive mode (needs a real terminal)
|
|
5
|
+
// dsh-tui --base http://host:port
|
|
6
|
+
// dsh-tui --script <file> scripted mode: feed events from a file, dump frames
|
|
7
|
+
// dsh-tui --plain with --script: dump plain text frames (no ANSI)
|
|
8
|
+
import { Term, detectKitty } from "../src/term.js";
|
|
9
|
+
import { Screen } from "../src/screen.js";
|
|
10
|
+
import { Api } from "../src/api.js";
|
|
11
|
+
import { App } from "../src/views.js";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const opt = (name, dflt) => {
|
|
16
|
+
const i = args.indexOf(name);
|
|
17
|
+
return i >= 0 ? args[i + 1] : dflt;
|
|
18
|
+
};
|
|
19
|
+
const has = (name) => args.includes(name);
|
|
20
|
+
|
|
21
|
+
const base = opt("--base", opt("--attach", process.env.DSH_URL || process.env.DSH_WEB_URL || "http://127.0.0.1:3080"));
|
|
22
|
+
const log = (...a) => console.error("[dsh-tui]", ...a);
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const script = opt("--script", null);
|
|
26
|
+
if (script) {
|
|
27
|
+
await runScripted(script);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
// ---- interactive ----
|
|
31
|
+
const screen = new Screen(process.stdout.columns || 80, process.stdout.rows || 24);
|
|
32
|
+
const api = new Api({ base, log, onFrame: () => {}, onHostFrame: () => {} });
|
|
33
|
+
const app = new App({ screen, term: null, api, log });
|
|
34
|
+
const term = new Term({
|
|
35
|
+
output: process.stdout,
|
|
36
|
+
kitty: detectKitty(),
|
|
37
|
+
onEvent: (ev) => app.onEvent(ev),
|
|
38
|
+
onResize: (w, h) => app.resize(w, h),
|
|
39
|
+
});
|
|
40
|
+
app.term = term;
|
|
41
|
+
screen.resize(term.w, term.h);
|
|
42
|
+
app.resize(term.w, term.h);
|
|
43
|
+
term.start();
|
|
44
|
+
process.on("SIGINT", () => app.stop());
|
|
45
|
+
process.on("SIGTERM", () => app.stop());
|
|
46
|
+
await app.init();
|
|
47
|
+
app.redraw();
|
|
48
|
+
app.run();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- scripted test mode ----
|
|
52
|
+
class FakeOutput {
|
|
53
|
+
constructor() { this.chunks = []; this.columns = 100; this.rows = 30; }
|
|
54
|
+
write(s) { this.chunks.push(s); return true; }
|
|
55
|
+
toString() { return this.chunks.join(""); }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function runScripted(scriptFile) {
|
|
59
|
+
const plain = has("--plain");
|
|
60
|
+
const out = new FakeOutput();
|
|
61
|
+
const screen = new Screen(100, 30);
|
|
62
|
+
const api = new Api({ base, log, onFrame: () => {}, onHostFrame: () => {} });
|
|
63
|
+
const app = new App({ screen, term: { output: out, write: (s) => out.write(s) }, api, log });
|
|
64
|
+
const events = readFileSync(scriptFile, "utf8")
|
|
65
|
+
.split("\n")
|
|
66
|
+
.map((l) => l.trim())
|
|
67
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
68
|
+
|
|
69
|
+
await app.init();
|
|
70
|
+
app.renderFrame();
|
|
71
|
+
dump(app, plain);
|
|
72
|
+
|
|
73
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
74
|
+
let quit = false;
|
|
75
|
+
for (const line of events) {
|
|
76
|
+
if (quit) break;
|
|
77
|
+
const [cmd, ...rest] = line.split(/\s+/);
|
|
78
|
+
switch (cmd) {
|
|
79
|
+
case "wait": await sleep(Number(rest[0] ?? 100)); break;
|
|
80
|
+
case "quit": quit = true; break;
|
|
81
|
+
case "key": {
|
|
82
|
+
const [name, mods] = rest;
|
|
83
|
+
const ev = { type: "key", name, ctrl: false, alt: false, shift: false, key: name, text: name };
|
|
84
|
+
if (mods?.includes("c")) { ev.ctrl = true; ev.key = name; ev.text = name; }
|
|
85
|
+
app.onEvent(ev);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "text": app.onEvent({ type: "text", text: line.slice(5) }); break;
|
|
89
|
+
case "space": app.onEvent({ type: "text", text: " " }); break;
|
|
90
|
+
case "mouse": {
|
|
91
|
+
const [kind, btn, x, y] = rest;
|
|
92
|
+
app.onEvent({ type: "mouse", kind, button: Number(btn ?? 0), x: Number(x ?? 0), y: Number(y ?? 0), ctrl: false, shift: false, alt: false, motion: false });
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
case "resize": {
|
|
96
|
+
const [w, h] = rest.map(Number);
|
|
97
|
+
app.resize(w, h);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "frame": {
|
|
101
|
+
// inject a raw mux frame for testing: frame <json>
|
|
102
|
+
app.injectFrame(JSON.parse(line.slice(6)));
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
log(`unknown script cmd: ${cmd}`);
|
|
107
|
+
}
|
|
108
|
+
await sleep(30);
|
|
109
|
+
app.renderFrame();
|
|
110
|
+
dump(app, plain);
|
|
111
|
+
}
|
|
112
|
+
api.close();
|
|
113
|
+
await sleep(100);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function dump(app, plain) {
|
|
118
|
+
const screen = app.screen;
|
|
119
|
+
const out = app.term.output;
|
|
120
|
+
if (plain) {
|
|
121
|
+
console.log("───── frame ─────");
|
|
122
|
+
console.log(screen.toPlain());
|
|
123
|
+
} else {
|
|
124
|
+
console.log("───── frame ─────");
|
|
125
|
+
console.log(out.toString().replace(/\x1b/g, "<ESC>"));
|
|
126
|
+
}
|
|
127
|
+
out.chunks.length = 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
main().catch((e) => { log("fatal:", e); process.exit(1); });
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-neotui",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Neo-TUI: mouse-driven terminal UI client for DeepSeek Harness (B-tier per dsh-tui-design.md)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "dsh-neotui": "bin/dsh-tui.js" },
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node bin/dsh-tui.js",
|
|
19
|
+
"test": "node test/scripted.mjs"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/edabchann/dsh-neotui.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/edabchann/dsh-neotui#readme",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"dsh",
|
|
28
|
+
"deepseek",
|
|
29
|
+
"harness",
|
|
30
|
+
"tui",
|
|
31
|
+
"terminal",
|
|
32
|
+
"cli"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT"
|
|
35
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// api.js — DeepSeek Harness wire client: unary RPC over HTTP POST, live frames
|
|
2
|
+
// over the /api/events.mux WebSocket, responses over /api/respond.
|
|
3
|
+
// Protocol (verified against the live host):
|
|
4
|
+
// request → POST /api/<method> {type:"client-request", rpcId, method, payload}
|
|
5
|
+
// response → {type:"server-response", rpcId, result:{ok:true,value} | {ok:false,error}}
|
|
6
|
+
// frames → WS server-request {type:"server-request", rpcId, method, payload:{type,...}}
|
|
7
|
+
// respond → POST /api/respond {type:"client-response", rpcId, result}
|
|
8
|
+
|
|
9
|
+
export class ApiError extends Error {
|
|
10
|
+
constructor(error) {
|
|
11
|
+
super(`${error?.code ?? "error"}: ${error?.message ?? JSON.stringify(error)}`);
|
|
12
|
+
this.name = "ApiError";
|
|
13
|
+
this.code = error?.code;
|
|
14
|
+
this.details = error?.details;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BASE = "http://127.0.0.1:3080";
|
|
19
|
+
|
|
20
|
+
export class Api {
|
|
21
|
+
constructor({ base = DEFAULT_BASE, log = () => {}, onFrame = () => {}, onHostFrame = () => {}, onStateChange = () => {} } = {}) {
|
|
22
|
+
this.base = base.replace(/\/$/, "");
|
|
23
|
+
this.log = log;
|
|
24
|
+
this.onFrame = onFrame;
|
|
25
|
+
this.onHostFrame = onHostFrame;
|
|
26
|
+
this.onStateChange = onStateChange;
|
|
27
|
+
this.ws = null;
|
|
28
|
+
this.hostWs = null;
|
|
29
|
+
this.closed = false;
|
|
30
|
+
this.connected = false;
|
|
31
|
+
this.retryDelay = 500;
|
|
32
|
+
this.pendingFrames = []; // frames buffered while reconnecting
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async call(method, payload = {}) {
|
|
36
|
+
const env = { type: "client-request", rpcId: crypto.randomUUID(), method, payload };
|
|
37
|
+
let res;
|
|
38
|
+
try {
|
|
39
|
+
res = await fetch(`${this.base}/api/${method}`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "content-type": "application/json" },
|
|
42
|
+
body: JSON.stringify(env),
|
|
43
|
+
});
|
|
44
|
+
} catch (e) {
|
|
45
|
+
throw new ApiError({ code: "transport", message: `unreachable: ${e.message}` });
|
|
46
|
+
}
|
|
47
|
+
if (!res.ok) throw new ApiError({ code: "http", message: `HTTP ${res.status}` });
|
|
48
|
+
const body = await res.json();
|
|
49
|
+
if (body?.type !== "server-response") throw new ApiError({ code: "protocol", message: "bad envelope" });
|
|
50
|
+
if (!body.result?.ok) throw new ApiError(body.result.error);
|
|
51
|
+
return body.result.value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Answer an approval/question frame. ok=false means reject. */
|
|
55
|
+
async respond(rpcId, value) {
|
|
56
|
+
const env = {
|
|
57
|
+
type: "client-response",
|
|
58
|
+
rpcId,
|
|
59
|
+
result: { ok: true, value },
|
|
60
|
+
};
|
|
61
|
+
const res = await fetch(`${this.base}/api/respond`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "content-type": "application/json" },
|
|
64
|
+
body: JSON.stringify(env),
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) throw new ApiError({ code: "http", message: `respond HTTP ${res.status}` });
|
|
67
|
+
return res.json();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
connectMux() {
|
|
71
|
+
this.#connect(this.wsUrl("events.mux"), "mux");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
connectHost() {
|
|
75
|
+
this.#connect(this.wsUrl("events.host"), "host");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
wsUrl(path) {
|
|
79
|
+
return `${this.base.replace(/^http/, "ws")}/api/${path}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#connect(url, kind) {
|
|
83
|
+
if (this.closed) return;
|
|
84
|
+
const ws = new WebSocket(url);
|
|
85
|
+
(kind === "mux" ? this : this).ws = kind === "mux" ? ws : this.hostWs;
|
|
86
|
+
ws.onopen = () => {
|
|
87
|
+
this.connected = true;
|
|
88
|
+
this.retryDelay = 500;
|
|
89
|
+
this.log(`[api] ${kind} stream connected`);
|
|
90
|
+
this.onStateChange("connected");
|
|
91
|
+
};
|
|
92
|
+
ws.onmessage = (m) => {
|
|
93
|
+
let body;
|
|
94
|
+
try { body = JSON.parse(String(m.data)); } catch { return; }
|
|
95
|
+
if (body?.type !== "server-request") return;
|
|
96
|
+
const frame = body.payload ?? {};
|
|
97
|
+
frame.__rpcId = body.rpcId; // answerable frames: respond() echoes this id
|
|
98
|
+
if (kind === "mux") this.onFrame(frame);
|
|
99
|
+
else this.onHostFrame(frame);
|
|
100
|
+
};
|
|
101
|
+
ws.onclose = () => {
|
|
102
|
+
this.connected = false;
|
|
103
|
+
this.onStateChange("disconnected");
|
|
104
|
+
if (this.closed) return;
|
|
105
|
+
this.log(`[api] ${kind} stream closed, reconnecting in ${this.retryDelay}ms`);
|
|
106
|
+
this.reconnectTimer = setTimeout(() => this.#connect(url, kind), this.retryDelay);
|
|
107
|
+
this.retryDelay = Math.min(this.retryDelay * 2, 15000);
|
|
108
|
+
};
|
|
109
|
+
ws.onerror = () => { /* onclose follows */ };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Typert gateway RPC: POST /api/<namespace>/<method> with {args} payload
|
|
113
|
+
* (verified: pluginInventory/list returns the live Loader entry roster). */
|
|
114
|
+
async rpcCall(method, payload = {}) {
|
|
115
|
+
const env = { type: "client-request", rpcId: crypto.randomUUID(), method, payload: { args: payload ?? {} } };
|
|
116
|
+
let res;
|
|
117
|
+
try {
|
|
118
|
+
res = await fetch(`${this.base}/api/${method}`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { "content-type": "application/json" },
|
|
121
|
+
body: JSON.stringify(env),
|
|
122
|
+
});
|
|
123
|
+
} catch (e) {
|
|
124
|
+
throw new ApiError({ code: "transport", message: `rpc unreachable: ${e.message}` });
|
|
125
|
+
}
|
|
126
|
+
if (!res.ok) throw new ApiError({ code: "http", message: `rpc HTTP ${res.status}` });
|
|
127
|
+
const body = await res.json();
|
|
128
|
+
if (body?.type !== "server-response") throw new ApiError({ code: "protocol", message: "bad rpc envelope" });
|
|
129
|
+
if (!body.result?.ok) throw new ApiError(body.result.error);
|
|
130
|
+
return body.result.value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
close() {
|
|
134
|
+
this.closed = true;
|
|
135
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
136
|
+
try { this.ws?.close(); } catch {}
|
|
137
|
+
try { this.hostWs?.close(); } catch {}
|
|
138
|
+
try { this.rpcWs?.close(); } catch {}
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// index.js — Programmatic launch entry shared by the standalone bin and the
|
|
2
|
+
// dsh tui profile bundle.
|
|
3
|
+
import { Term, detectKitty } from "./term.js";
|
|
4
|
+
import { Screen } from "./screen.js";
|
|
5
|
+
import { Api } from "./api.js";
|
|
6
|
+
import { App } from "./views.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Launch the interactive TUI over the current stdin/stdout.
|
|
10
|
+
* @param {object} opts
|
|
11
|
+
* @param {string} [opts.base] fixed API base URL
|
|
12
|
+
* @param {() => Promise<string>} [opts.getBase] resolve the base before init (e.g. wait for the embedded webserver port)
|
|
13
|
+
* @param {string} [opts.resume] session id to open on start
|
|
14
|
+
* @param {function} [opts.log]
|
|
15
|
+
* @returns {() => void} disposer (restores the terminal and exits)
|
|
16
|
+
*/
|
|
17
|
+
export function launchTui(opts = {}) {
|
|
18
|
+
const log = opts.log ?? ((...a) => console.error("[dsh-tui]", ...a));
|
|
19
|
+
if (typeof process.stdin.isTTY === "function" && !process.stdin.isTTY && process.env.DSH_TUI_NO_TTY !== "1") {
|
|
20
|
+
console.error("dsh-tui: stdin is not a terminal; run inside a real terminal (kitty/wezterm/foot/tmux…)");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const screen = new Screen(process.stdout.columns || 80, process.stdout.rows || 24);
|
|
24
|
+
const api = new Api({ base: opts.base ?? "http://127.0.0.1:1", log, onFrame: () => {}, onHostFrame: () => {} });
|
|
25
|
+
const app = new App({ screen, term: null, api, log });
|
|
26
|
+
const term = new Term({
|
|
27
|
+
output: process.stdout,
|
|
28
|
+
kitty: detectKitty(),
|
|
29
|
+
onEvent: (ev) => app.onEvent(ev),
|
|
30
|
+
onResize: (w, h) => app.resize(w, h),
|
|
31
|
+
});
|
|
32
|
+
app.term = term;
|
|
33
|
+
screen.resize(term.w, term.h);
|
|
34
|
+
app.resize(term.w, term.h);
|
|
35
|
+
term.start();
|
|
36
|
+
process.on("SIGINT", () => app.stop());
|
|
37
|
+
process.on("SIGTERM", () => app.stop());
|
|
38
|
+
(async () => {
|
|
39
|
+
try {
|
|
40
|
+
if (opts.getBase) api.base = await opts.getBase();
|
|
41
|
+
await app.init();
|
|
42
|
+
if (opts.resume) await app.openSession(opts.resume);
|
|
43
|
+
app.redraw();
|
|
44
|
+
app.run();
|
|
45
|
+
} catch (e) {
|
|
46
|
+
log("fatal:", e);
|
|
47
|
+
term.stop();
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
return () => app.stop();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { Term, Screen, Api, App, detectKitty };
|