dsh-neotui 0.0.0 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -7
- package/package.json +1 -1
- package/src/panels.js +13 -3
- package/src/term.js +7 -1
- package/src/views.js +35 -11
- package/src/widgets.js +1 -0
package/README.md
CHANGED
|
@@ -2,16 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
DeepSeek Harness 的终端客户端(B 档范围,按 `../dsh-tui-design.md`)。纯 Node 标准库,零依赖。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 安装(推荐:从 npm 装成 profile)
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
npm i -g pnpm # 首次需要 pnpm(dsh plugin 通过它安装)
|
|
9
|
+
dsh plugin --profile dsh-neotui add dsh-neotui-app # 装 bundle,自动加入 profile 层栈
|
|
10
|
+
dsh --profile dsh-neotui # 启动
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 本地开发(从源码跑)
|
|
14
|
+
|
|
15
|
+
克隆后先自链核心包(app bundle 通过包名 `dsh-neotui` 引核心,Node 会从真实路径向上找 `node_modules`):
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
ln -sfn .. node_modules/dsh-neotui # 仓库根自链,让 app/src 能 import "dsh-neotui"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
然后把 profile 的两条软链指到本仓库(`~/.dsh/profiles/node_modules/`):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
ln -sfn "$(pwd)" ~/.dsh/profiles/node_modules/dsh-neotui
|
|
25
|
+
ln -sfn "$(pwd)/app" ~/.dsh/profiles/node_modules/dsh-neotui-app
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
启动(本地开发 profile `~/.dsh/profiles/dsh-neotui`):
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
dsh --profile dsh-neotui
|
|
32
|
+
dsh --profile dsh-neotui --session <id>
|
|
33
|
+
dsh --profile dsh-neotui --cwd ~/work
|
|
34
|
+
dsh --profile dsh-neotui --port 3981
|
|
35
|
+
dsh --profile dsh-neotui --attach 3080 # 共存调试:连已运行的 web 宿主(存储隔离,不碰 $DSH_HOME)
|
|
13
36
|
|
|
14
|
-
node bin/dsh-tui.js
|
|
37
|
+
node bin/dsh-tui.js # 纯客户端模式:连接已运行的 web 宿主(默认 3080)
|
|
15
38
|
node bin/dsh-tui.js --base http://host:port
|
|
16
39
|
```
|
|
17
40
|
|
package/package.json
CHANGED
package/src/panels.js
CHANGED
|
@@ -496,6 +496,15 @@ export class TrajectoryPanel extends Widget {
|
|
|
496
496
|
this.buildLines();
|
|
497
497
|
}
|
|
498
498
|
async load(sessionId) {
|
|
499
|
+
// Re-entry into the same session reuses the already-built steps — instant,
|
|
500
|
+
// like the web view (which keeps its timeline in memory). A fresh session
|
|
501
|
+
// or an explicit refresh (r) re-fetches the recent window.
|
|
502
|
+
if (this.sessionId === sessionId && this.steps.length > 0) {
|
|
503
|
+
this.loading = false;
|
|
504
|
+
this.buildLines();
|
|
505
|
+
this.app.redraw();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
499
508
|
this.sessionId = sessionId;
|
|
500
509
|
this.loading = true;
|
|
501
510
|
this.steps = [];
|
|
@@ -506,8 +515,8 @@ export class TrajectoryPanel extends Widget {
|
|
|
506
515
|
this.app.setStatus("加载轨迹…");
|
|
507
516
|
try {
|
|
508
517
|
// One bounded call for the recent steps (maxMessages = model messages =
|
|
509
|
-
// steps).
|
|
510
|
-
const h = await this.app.api.call("session.history", { sessionId, maxMessages:
|
|
518
|
+
// steps). Older steps load on demand via PgUp/click.
|
|
519
|
+
const h = await this.app.api.call("session.history", { sessionId, maxMessages: 20 });
|
|
511
520
|
this.stats = h.projections?.values?.sessionStats ?? null;
|
|
512
521
|
this.minSeq = h.events[0]?.event?.seq ?? null;
|
|
513
522
|
this.hasMore = h.hasMore;
|
|
@@ -557,7 +566,7 @@ export class TrajectoryPanel extends Widget {
|
|
|
557
566
|
buildLines() {
|
|
558
567
|
const w = Math.max(40, this.w - 2);
|
|
559
568
|
const lines = [];
|
|
560
|
-
lines.push([{ t: "轨迹 —
|
|
569
|
+
lines.push([{ t: "轨迹 — 步骤时间轴(每行一个色块,点击查看详情 · r 刷新)", fg: K.ACCENT, bold: true }]);
|
|
561
570
|
if (this.hasMore) lines.push([{ t: "▲ 更早步骤(点击 / PgUp 加载)", fg: K.FAINT }]);
|
|
562
571
|
else lines.push([{ t: "" }]);
|
|
563
572
|
const st = this.stats;
|
|
@@ -609,6 +618,7 @@ export class TrajectoryPanel extends Widget {
|
|
|
609
618
|
return true;
|
|
610
619
|
}
|
|
611
620
|
if (ev.name === "backspace") { this.query = this.query.slice(0, -1); this.buildLines(); this.app.redraw(); return true; }
|
|
621
|
+
if (ev.name === "char" && ev.key === "r" && !ev.ctrl) { this.steps = []; this.load(this.sessionId); return true; }
|
|
612
622
|
if (ev.name === "pgup") { if (this.view.scrollY === 0 && this.hasMore) { this.loadOlder(); return true; } return this.view.scroll(-this.view.h); }
|
|
613
623
|
if (ev.name === "pgdn" || ev.name === "up" || ev.name === "down") return this.view.onKey(ev);
|
|
614
624
|
return false;
|
package/src/term.js
CHANGED
|
@@ -128,7 +128,13 @@ export class Term {
|
|
|
128
128
|
if (cp === 13) this.#emit({ type: "key", name: "enter", ctrl: false, alt: true, shift: false });
|
|
129
129
|
else this.#emit({ type: "key", name: "char", key: next.toLowerCase(), text: next, ctrl: false, alt: true, shift: false });
|
|
130
130
|
i += 2;
|
|
131
|
-
} else {
|
|
131
|
+
} else {
|
|
132
|
+
// Lone ESC at end of buffer = the standalone Escape key. Terminals
|
|
133
|
+
// deliver escape sequences atomically, so a bare \x1b is never the
|
|
134
|
+
// head of a sequence that will arrive in a later chunk.
|
|
135
|
+
this.#emit({ type: "key", name: "escape", ctrl: false, alt: false, shift: false });
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
132
138
|
} else if (ch === "\r") {
|
|
133
139
|
this.#emit({ type: "key", name: "enter", ctrl: false, alt: false, shift: false });
|
|
134
140
|
i++;
|
package/src/views.js
CHANGED
|
@@ -1511,10 +1511,18 @@ export class App {
|
|
|
1511
1511
|
setJobs(jobs) { this.jobs = jobs; this.layout(); this.redraw(); }
|
|
1512
1512
|
|
|
1513
1513
|
#startPolling() {
|
|
1514
|
-
|
|
1514
|
+
// Self-rescheduling so the interval actually tracks run state (a fixed
|
|
1515
|
+
// setInterval would freeze at the idle 1500ms forever).
|
|
1516
|
+
let ticks = 0;
|
|
1517
|
+
const tick = () => {
|
|
1515
1518
|
if (this.chat.sessionId) this.chat.pollTail();
|
|
1516
|
-
|
|
1517
|
-
|
|
1519
|
+
// session.list is expensive (~100ms); refresh the sidebar every ~5s, not
|
|
1520
|
+
// on every streaming poll.
|
|
1521
|
+
if (ticks++ % 10 === 0) this.refreshSessions();
|
|
1522
|
+
const delay = this.chat.pollSlow ? 2000 : (this.chat.running ? 500 : 1500);
|
|
1523
|
+
this.pollTimer = setTimeout(tick, delay);
|
|
1524
|
+
};
|
|
1525
|
+
this.pollTimer = setTimeout(tick, 500);
|
|
1518
1526
|
}
|
|
1519
1527
|
|
|
1520
1528
|
async init() {
|
|
@@ -2007,9 +2015,6 @@ export class App {
|
|
|
2007
2015
|
tx += strWidth(seg);
|
|
2008
2016
|
}
|
|
2009
2017
|
if (this.currentSession == null) s.text(x + w - 16, 0, "未选会话", { fg: T.FAINT, bg: T.PANEL });
|
|
2010
|
-
const modeLabel = this.focused === this.chat?.input ? " INSERT " : " NORMAL ";
|
|
2011
|
-
const modeColor = this.focused === this.chat?.input ? T.OK : T.FAINT;
|
|
2012
|
-
s.text(x + w - strWidth(modeLabel), 0, modeLabel, { fg: modeColor, bg: T.PANEL, attrs: 1 });
|
|
2013
2018
|
}
|
|
2014
2019
|
|
|
2015
2020
|
#clickTab(px) {
|
|
@@ -2135,6 +2140,19 @@ export class App {
|
|
|
2135
2140
|
}
|
|
2136
2141
|
// global keys
|
|
2137
2142
|
if (ev.type === "key") {
|
|
2143
|
+
// INSERT mode: Esc exits, everything else goes to the input for editing.
|
|
2144
|
+
// Global shortcuts (Ctrl+P, Ctrl+B, F7, …) are disabled here so plain
|
|
2145
|
+
// typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
|
|
2146
|
+
if (this.focused === this.chat.input) {
|
|
2147
|
+
if (ev.name === "escape") {
|
|
2148
|
+
this.focus(this.chat);
|
|
2149
|
+
this.toast("已退出输入(i 重新进入)");
|
|
2150
|
+
} else {
|
|
2151
|
+
this.chat.input.onKey(ev);
|
|
2152
|
+
}
|
|
2153
|
+
this.redraw();
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2138
2156
|
if (this.searchActive && (ev.ctrl && ev.key === " " || ev.name === "f7")) {
|
|
2139
2157
|
this.overlay = new ControlPanel(this, { startPage: 0 });
|
|
2140
2158
|
this.redraw();
|
|
@@ -2174,11 +2192,13 @@ export class App {
|
|
|
2174
2192
|
if (ev.name === "char" && ev.key === "/" && !ev.ctrl && this.focused !== this.chat.input) { this.startSearch(); this.redraw(); return; }
|
|
2175
2193
|
if (ev.name === "char" && ev.key === "n" && !ev.ctrl && this.focused === this.sidebar) { this.newSession(); return; }
|
|
2176
2194
|
if (ev.name === "escape") {
|
|
2177
|
-
if (this.focused === this.chat.
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
this.
|
|
2181
|
-
|
|
2195
|
+
if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
|
|
2196
|
+
else {
|
|
2197
|
+
// Esc in NORMAL mode interrupts a running turn (insert→normal→interrupt).
|
|
2198
|
+
const cur = this.sessions.find((s) => s.sessionId === this.currentSession);
|
|
2199
|
+
if (cur?.running) { this.cancelSession(cur); this.toast("已请求中断当前回合"); }
|
|
2200
|
+
else if (this.mode !== "chat") this.setMode("chat");
|
|
2201
|
+
}
|
|
2182
2202
|
return;
|
|
2183
2203
|
}
|
|
2184
2204
|
if (ev.name === "char" && ev.key === "i" && this.focused === this.sidebar) { this.focus(this.chat.input); this.redraw(); return; }
|
|
@@ -2297,6 +2317,10 @@ export class App {
|
|
|
2297
2317
|
const rows = [];
|
|
2298
2318
|
// ── row 0: identity ──
|
|
2299
2319
|
const row0 = { left: [], right: [] };
|
|
2320
|
+
// Editing-mode badge first (near the input at the bottom): INSERT is loud,
|
|
2321
|
+
// NORMAL is subtle — the user's eyes are at the input line, not the top.
|
|
2322
|
+
const editing = this.focused === this.chat?.input;
|
|
2323
|
+
row0.left.push({ t: editing ? " INSERT " : " NORMAL ", fg: editing ? T.OK : T.FAINT, bg: T.STATUSBG, bold: editing });
|
|
2300
2324
|
// Left badge: the session's permission/mode (e.g. "工作区写入/创造模式"),
|
|
2301
2325
|
// which is far more meaningful than a static "工作区" label.
|
|
2302
2326
|
const perm = this.projections.permissions?.currentValue;
|
package/src/widgets.js
CHANGED
|
@@ -401,6 +401,7 @@ export class Input extends Widget {
|
|
|
401
401
|
this.insert(ev.text);
|
|
402
402
|
return true;
|
|
403
403
|
case "enter":
|
|
404
|
+
if (ev.shift && this.multi) { this.insert("\n"); return true; } // Shift+Enter = newline
|
|
404
405
|
if (this.value.trim() === "" && !this.allowEmptyEnter) return false;
|
|
405
406
|
const v = this.value;
|
|
406
407
|
this.history.push(v);
|