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 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
- 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)
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 # 纯客户端模式:连接已运行的 web 宿主(默认 3080)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.0.0",
3
+ "version": "0.0.2",
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": { "dsh-neotui": "bin/dsh-tui.js" },
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). Instant, and older steps load on demand via PgUp/click.
510
- const h = await this.app.api.call("session.history", { sessionId, maxMessages: 40 });
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: "轨迹 — 步骤时间轴(每行一个色块,点击查看详情)", fg: K.ACCENT, bold: true }]);
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 { i = buf.length; break; }
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
- this.pollTimer = setInterval(() => {
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
- this.refreshSessions();
1517
- }, this.chat.pollSlow ? 2000 : (this.chat.running ? 500 : 1500));
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.input) {
2178
- this.focus(this.chat);
2179
- this.toast("已退出输入(i 重新进入)");
2180
- this.redraw();
2181
- } else if (this.focused === this.sidebar) { this.focus(this.chat); this.redraw(); }
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);