dsh-neotui 0.1.18 → 0.1.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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": {
package/src/panels.js CHANGED
@@ -560,6 +560,9 @@ export class TrajectoryPanel extends Widget {
560
560
  this.flashUntil = 0;
561
561
  this.loadPromise = null; // dedupes concurrent load(currentSession)
562
562
  this.loadTarget = null;
563
+ this.liveTickAt = 0; // ⏱ live timer re-render throttle
564
+ this.tailFetchAt = 0; // tail-window auto-refresh throttle
565
+ this.refreshing = false;
563
566
  this.winSeqLo = null; // visible window = first-event SEQ range; null = follow the tail
564
567
  this.winSeqHi = null;
565
568
  // LEFT click toggles a step's 详细/简略 expansion (the ▸/▾ triangle).
@@ -937,7 +940,9 @@ export class TrajectoryPanel extends Widget {
937
940
  const hasResult = step.events.some((e) => e.type === "tool/result");
938
941
  const hasReasoning = step.events.some((e) => e.type === "assistant/chunk" && e.data?.chunk?.blockType === "reasoning");
939
942
  const t0 = step.events[0]?.time, t1 = step.events[step.events.length - 1]?.time;
940
- const dur = t0 && t1 ? fmtMs(t1 - t0) : "—";
943
+ // deep-dive style live timer: the newest step ticks while the turn runs
944
+ const isLiveTail = this.app.chat?.running && this.winSeqLo == null && si === this.steps.length - 1;
945
+ const dur = isLiveTail ? `⏱${fmtMs(Date.now() - (t0 ?? Date.now()))}` : (t0 && t1 ? fmtMs(t1 - t0) : "—");
941
946
  const bg = tools.length ? (hasResult ? T.TOOLOK : T.TOOLBG) : hasReasoning ? T.THINKBG : T.CARD;
942
947
  const summary = tools.slice(0, 3).join(",") || (hasReasoning ? "模型推理" : "纯文本");
943
948
  const open = this.expandedSteps.has(this.stepKey(step)); // 详细
@@ -975,8 +980,37 @@ export class TrajectoryPanel extends Widget {
975
980
  screen.text(this.x + 2, this.y + 1, "加载轨迹…", { fg: K.FAINT });
976
981
  return;
977
982
  }
983
+ // the live step's ⏱ timer re-renders once per second while the turn runs,
984
+ // and the tail window refreshes periodically so a NEW turn's step (and
985
+ // its timer) appears without pressing r
986
+ if (this.app.chat?.running && this.winSeqLo == null) {
987
+ if (Date.now() - (this.liveTickAt ?? 0) > 1000) {
988
+ this.liveTickAt = Date.now();
989
+ this.buildLines();
990
+ }
991
+ if (!this.refreshing && Date.now() - (this.tailFetchAt ?? 0) > 4000) {
992
+ this.tailFetchAt = Date.now();
993
+ this.#refreshTail();
994
+ }
995
+ }
978
996
  this.view.render(screen);
979
997
  }
998
+ /** Re-fetch the tail window while following the live turn. */
999
+ async #refreshTail() {
1000
+ if (!this.sessionId) return;
1001
+ this.refreshing = true;
1002
+ try {
1003
+ const h = await this.app.api.call("session.history", { sessionId: this.sessionId, maxMessages: 20 });
1004
+ this.allEvents = h.events;
1005
+ this.minSeq = h.events[0]?.event?.seq ?? this.minSeq;
1006
+ this.hasMore = h.hasMore;
1007
+ this.stats = h.projections?.values?.sessionStats ?? this.stats;
1008
+ this.build();
1009
+ this.buildLines();
1010
+ this.app.redraw();
1011
+ } catch { /* next tick retries */ }
1012
+ this.refreshing = false;
1013
+ }
980
1014
  onMouse(ev) {
981
1015
  // RIGHT click on a step: context menu (expand/collapse · jump · detail).
982
1016
  if (ev.kind === "press" && ev.button === 2) {
package/src/views.js CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  SkillsPanel, ControlPanel, JobsPanel, fmtMs,
15
15
  } from "./panels.js";
16
16
 
17
- import { T, themeName } from "./theme.js";
17
+ import { T, themeName, cycleTheme } from "./theme.js";
18
18
  // Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
19
19
  const K = new Proxy({}, { get(_k, key) { return T[key]; } });
20
20
 
@@ -130,8 +130,11 @@ function applyEvent(nodes, event, view, log, state = null) {
130
130
  const text = partsToText(d.content ?? d.message?.content);
131
131
  const images = partsToImages(d.content ?? d.message?.content);
132
132
  const id = d.id ?? null;
133
- if (text !== null) nodes.push({ kind: "user", text, images, id, step: st.step });
134
- else if (images) nodes.push({ kind: "user", text: "", images, id, step: st.step });
133
+ // the turn timer starts HERE: the user message carries the turn start
134
+ // so the 🕐 ticker shows even before the first assistant chunk lands
135
+ const turnStartAt = st.turnStart ?? event.time ?? Date.now();
136
+ if (text !== null) nodes.push({ kind: "user", text, images, id, step: st.step, turnStartAt });
137
+ else if (images) nodes.push({ kind: "user", text: "", images, id, step: st.step, turnStartAt });
135
138
  break;
136
139
  }
137
140
  case "assistant/message": {
@@ -172,7 +175,7 @@ function applyEvent(nodes, event, view, log, state = null) {
172
175
  const ch = d.chunk ?? {};
173
176
  let node = cur();
174
177
  if (!node || node.kind !== "assistant" || node.finalized) {
175
- node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step };
178
+ node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step, turnStartAt: st.turnStart ?? undefined };
176
179
  nodes.push(node);
177
180
  }
178
181
  node.streaming = true;
@@ -219,7 +222,7 @@ function applyEvent(nodes, event, view, log, state = null) {
219
222
  }
220
223
  let node = cur();
221
224
  if (!node || node.kind !== "assistant") {
222
- node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step };
225
+ node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step, turnStartAt: st.turnStart ?? undefined };
223
226
  nodes.push(node);
224
227
  }
225
228
  node.blocks.push({ kind: "tool", name: d.name, args: d.arguments, callId, view: view?.view, result: null, startedAt: event.time ?? Date.now() });
@@ -584,6 +587,16 @@ class SidebarTree extends Widget {
584
587
 
585
588
  // ---- ChatView ----
586
589
 
590
+ /** Slash commands offered by the input's candidate bar (Tab completes). */
591
+ const SLASH_COMMANDS = [
592
+ { name: "/reload", desc: "重新载入界面(不重启进程)" },
593
+ { name: "/restart", desc: "重启 TUI 加载新版本" },
594
+ { name: "/model", desc: "切换模型" },
595
+ { name: "/theme", desc: "切换配色主题" },
596
+ { name: "/permission", desc: "修改权限模式" },
597
+ { name: "/goal", desc: "查看当前目标" },
598
+ ];
599
+
587
600
  export class ChatView extends Widget {
588
601
  constructor(opts) {
589
602
  super(opts);
@@ -612,9 +625,9 @@ export class ChatView extends Widget {
612
625
  });
613
626
  this.input = new Input({
614
627
  x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
615
- multi: true, maxLines: 6, app: this.app,
628
+ multi: true, maxLines: 6, app: this.app, commands: SLASH_COMMANDS,
616
629
  bg: T.PANEL,
617
- placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L 展开,Enter 发送)",
630
+ placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L 展开,↑/↓ 历史,Tab 补全 / 命令,Enter 发送)",
618
631
  onEnter: (v) => this.send(v),
619
632
  onChange: () => this.inputChanged(),
620
633
  });
@@ -923,6 +936,10 @@ export class ChatView extends Widget {
923
936
  const trimmed = text.trim();
924
937
  if (trimmed === "/reload") { this.app.softReload(); return; }
925
938
  if (trimmed === "/restart") { this.app.restartApp(); return; }
939
+ if (trimmed === "/model") { this.app.showModePicker(); return; }
940
+ if (trimmed === "/theme") { cycleTheme(); this.queueRebuild(); this.app.toast(`主题已切换: ${themeName()}`); return; }
941
+ if (trimmed === "/permission") { this.app.showPermissionPicker(); return; }
942
+ if (trimmed === "/goal") { this.app.showGoal(); return; }
926
943
  if (!trimmed) return;
927
944
  const { parts, images, errors } = buildPromptParts(trimmed, {
928
945
  readFile: (p) => {
@@ -1187,7 +1204,9 @@ export class ChatView extends Widget {
1187
1204
  const ckey = `${realIdx}|${w}|${expKey}|${blockKeys}|${this.thinkMode}|${this.bashMode}|${node.streaming ? "s" : "f"}|${themeName()}|${node.step ?? "-"}|${userPrefix()}|${node.turnMs ?? "-"}`;
1188
1205
  // Streaming nodes re-render every frame: their text grows without any
1189
1206
  // change to the cache key, so caching them freezes the live think/tool/text.
1190
- const hit = node.streaming ? undefined : this.cache.get(ckey);
1207
+ // The LAST node re-renders too while a turn runs — its ticking 🕐 timer
1208
+ // must not be baked into a cached entry.
1209
+ const hit = (node.streaming || (this.running && realIdx === this.nodes.length - 1)) ? undefined : this.cache.get(ckey);
1191
1210
  if (hit) {
1192
1211
  for (const [rs, re, bg] of hit.cards ?? []) {
1193
1212
  this.cardRanges.push([lines.length + rs, lines.length + re, bg]);
@@ -1249,6 +1268,13 @@ export class ChatView extends Widget {
1249
1268
  markImg(realIdx, ii);
1250
1269
  }
1251
1270
  }
1271
+ // the turn timer starts at the QUESTION: while the reply is still
1272
+ // in the request/queue phase (no assistant node yet), the ticker
1273
+ // lives under the user message — web-style deep-dive from t=0.
1274
+ if (this.running && realIdx === this.nodes.length - 1 && node.turnStartAt != null) {
1275
+ lines.push([{ t: ` 🕐 本轮进行中…已经过 ${fmtDuration(Date.now() - node.turnStartAt)}`, fg: T.WARN, bold: true }]);
1276
+ mark(realIdx);
1277
+ }
1252
1278
  sep();
1253
1279
  break;
1254
1280
  }
@@ -1412,6 +1438,10 @@ export class ChatView extends Widget {
1412
1438
  if (node.turnMs != null) {
1413
1439
  lines.push([{ t: ` 🕐 本轮回答总耗时 ${fmtDuration(node.turnMs)}`, fg: T.WARN, bold: true }]);
1414
1440
  mark(realIdx);
1441
+ } else if (node.streaming && node.turnStartAt != null && realIdx === this.nodes.length - 1) {
1442
+ // deep-dive style live timer: the running turn ticks in real time
1443
+ lines.push([{ t: ` 🕐 本轮进行中…已经过 ${fmtDuration(Date.now() - node.turnStartAt)}`, fg: T.WARN, bold: true }]);
1444
+ mark(realIdx);
1415
1445
  }
1416
1446
  break;
1417
1447
  }
@@ -1524,6 +1554,23 @@ export class ChatView extends Widget {
1524
1554
  }
1525
1555
  this.#renderTodos(screen);
1526
1556
  this.input.render(screen);
1557
+ this.#renderCmdBar(screen);
1558
+ }
1559
+
1560
+ /** / command candidate bar above the input (↑/↓ cycle, Tab completes). */
1561
+ #renderCmdBar(screen) {
1562
+ const inp = this.input;
1563
+ if (!inp.cmdOpen || inp.cmds.length === 0) return;
1564
+ const n = Math.min(inp.cmds.length, 6);
1565
+ const w = Math.min(this.view.w, 44);
1566
+ const y0 = Math.max(this.view.y, inp.y - n - 1);
1567
+ screen.fillRect(this.x, y0, this.x + w - 1, y0 + n - 1, " ", { bg: T.BG2 });
1568
+ for (let i = 0; i < n; i++) {
1569
+ const c = inp.cmds[i];
1570
+ const sel = i === inp.cmdIdx;
1571
+ screen.text(this.x + 1, y0 + i, `${sel ? "▸" : " "} ${c.name}`, { fg: sel ? T.SELFG : T.TXT, bg: sel ? T.MENUSEL : T.BG2, attrs: sel ? 1 : 0 });
1572
+ screen.text(this.x + 2 + strWidth(c.name) + 2, y0 + i, truncate(c.desc ?? "", w - strWidth(c.name) - 6), { fg: T.FAINT, bg: sel ? T.MENUSEL : T.BG2 });
1573
+ }
1527
1574
  }
1528
1575
 
1529
1576
  /** Collapsible todo block between the view and the input (Shift+T toggles). */
@@ -1549,7 +1596,8 @@ export class ChatView extends Widget {
1549
1596
  return true;
1550
1597
  }
1551
1598
  if (this.view.inside(ev.x, ev.y)) {
1552
- this.app.focus(this);
1599
+ // clicks act, but never exit INSERT mode — Esc is the only way out
1600
+ if (this.app.focused !== this.app.chat?.input) this.app.focus(this);
1553
1601
  // Welcome-screen mode click: select the preset under the cursor.
1554
1602
  if (this.nodes.length === 0 && ev.kind === "press" && ev.button === 0) {
1555
1603
  const id = this.welcomeModes[ev.y];
@@ -2823,7 +2871,7 @@ export class App {
2823
2871
  // mouse routes by position (click = focus + dispatch)
2824
2872
  if (this.mode !== "chat") {
2825
2873
  if (ev.type === "mouse" && this.sidebarVisible && this.sidebar.inside(ev.x, ev.y)) {
2826
- this.focus(this.sidebar);
2874
+ if (this.focused !== this.chat.input) this.focus(this.sidebar); // INSERT exits only via Esc
2827
2875
  if (this.sidebar.onMouse(ev)) this.redraw();
2828
2876
  return;
2829
2877
  }
@@ -2870,7 +2918,7 @@ export class App {
2870
2918
  return;
2871
2919
  }
2872
2920
  if (this.sidebarVisible && this.sidebar.inside(ev.x, ev.y)) {
2873
- this.focus(this.sidebar);
2921
+ if (this.focused !== this.chat.input) this.focus(this.sidebar); // INSERT exits only via Esc
2874
2922
  if (this.sidebar.onMouse(ev)) this.redraw();
2875
2923
  } else if (this.chat.input.inside(ev.x, ev.y)) {
2876
2924
  // mouse SELECTION in the input works regardless of mode; typing stays
@@ -2887,7 +2935,7 @@ export class App {
2887
2935
  this.toast("按 i 进入输入(vim 式)");
2888
2936
  }
2889
2937
  } else if (this.chat.inside(ev.x, ev.y)) {
2890
- this.focus(this.chat);
2938
+ if (this.focused !== this.chat.input) this.focus(this.chat); // INSERT exits only via Esc
2891
2939
  if (this.chat.onMouse(ev)) this.redraw();
2892
2940
  } else if (this.focused?.onMouse(ev)) {
2893
2941
  this.redraw();
@@ -2901,6 +2949,9 @@ export class App {
2901
2949
  // typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
2902
2950
  if (this.focused === this.chat.input) {
2903
2951
  if (ev.name === "escape") {
2952
+ // Esc closes the open / command candidate bar first, then exits
2953
+ // insert — Esc is the ONLY way out of insert mode.
2954
+ if (this.chat.input.cmdOpen) { this.chat.input.cmdOpen = false; this.redraw(); return; }
2904
2955
  // one ESC press: interrupt a running turn AND leave insert —
2905
2956
  // otherwise the key just exits insert (vim muscle memory).
2906
2957
  const interrupted = this.#interruptIfRunning();
@@ -3056,9 +3107,14 @@ export class App {
3056
3107
  this.renderFrame();
3057
3108
  }
3058
3109
  if (this.toastMsg && Date.now() > this.toastUntil) { this.toastMsg = null; this.dirty = true; }
3059
- // the status-bar clock ticks once per second
3110
+ // the status-bar clock ticks once per second; while a turn runs the
3111
+ // chat's live timers (已经过 / 🕐) tick with it
3060
3112
  const sec = Math.floor(Date.now() / 1000);
3061
- if (sec !== this.lastSec) { this.lastSec = sec; this.dirty = true; }
3113
+ if (sec !== this.lastSec) {
3114
+ this.lastSec = sec;
3115
+ if (this.chat.running) this.chat.queueRebuild();
3116
+ this.dirty = true;
3117
+ }
3062
3118
  } catch (e) {
3063
3119
  this.log("render error (kept running):", e);
3064
3120
  // stderr is invisible under the alt screen — record the stack where
package/src/widgets.js CHANGED
@@ -260,6 +260,10 @@ export class Input extends Widget {
260
260
  this.pasteMark = null; // code-point span of the immutable "[已复制…]" token
261
261
  this.selStart = null; // drag-selection [start, end) code-point span
262
262
  this.selEnd = null;
263
+ this.commands = opts.commands ?? []; // / command candidates: [{name, desc}]
264
+ this.cmdOpen = false; // the candidate bar is showing
265
+ this.cmdIdx = 0; // highlighted candidate
266
+ this.cmds = []; // filtered candidates
263
267
  this.onChange = opts.onChange ?? null;
264
268
  this.allowEmptyEnter = opts.allowEmptyEnter ?? false;
265
269
  this.history = [];
@@ -324,6 +328,7 @@ export class Input extends Widget {
324
328
  this.value = String(v);
325
329
  this.cursor = this.#cps().length;
326
330
  this.selectAll = Boolean(opts.select); // first insert/text replaces the whole value
331
+ this.#updateCmds();
327
332
  this.onChange?.();
328
333
  }
329
334
  insert(text) {
@@ -336,6 +341,21 @@ export class Input extends Widget {
336
341
  }
337
342
  this.#edit(at, at, text);
338
343
  }
344
+ /** The / command candidate bar opens while the value is a bare "/…" prefix. */
345
+ #updateCmds() {
346
+ const v = this.value;
347
+ if (v.startsWith("/") && !v.includes(" ") && !v.includes("\n")) {
348
+ this.cmds = this.commands.filter((c) => c.name.startsWith(v));
349
+ if (this.cmds.length > 0) {
350
+ this.cmdOpen = true;
351
+ if (this.cmdIdx >= this.cmds.length) this.cmdIdx = 0;
352
+ return;
353
+ }
354
+ }
355
+ this.cmdOpen = false;
356
+ this.cmdIdx = 0;
357
+ this.cmds = [];
358
+ }
339
359
  /** EVERY edit goes through here so the immutable paste token behaves as
340
360
  * one unit: deleting any part of it removes it whole, typing inside it
341
361
  * replaces it, edits elsewhere just shift it. Always notifies onChange —
@@ -354,6 +374,7 @@ export class Input extends Widget {
354
374
  this.pasteMark = null;
355
375
  this.pendingPaste = null;
356
376
  this.value = cps.join("");
377
+ this.#updateCmds();
357
378
  this.onChange?.();
358
379
  return;
359
380
  }
@@ -364,6 +385,7 @@ export class Input extends Widget {
364
385
  cps.splice(from, to - from, ...t);
365
386
  this.cursor = from + t.length;
366
387
  this.value = cps.join("");
388
+ this.#updateCmds();
367
389
  this.onChange?.();
368
390
  }
369
391
  #deleteAt(idx) {
@@ -568,29 +590,54 @@ export class Input extends Widget {
568
590
  this.#snapCursor();
569
591
  return true;
570
592
  }
571
- case "up":
593
+ case "up": {
572
594
  this.selStart = this.selEnd = null;
595
+ if (this.cmdOpen && this.cmds.length) {
596
+ this.cmdIdx = (this.cmdIdx - 1 + this.cmds.length) % this.cmds.length;
597
+ this.onChange?.();
598
+ return true;
599
+ }
573
600
  if (this.multi) {
574
601
  const rows = this.#visualRows();
575
602
  const { row, col } = this.#cursorVisual();
576
- if (row > 0) { this.cursor = this.#indexAtVisual(row - 1, col); this.#snapCursor(); }
577
- } else if (this.history.length) {
603
+ if (row > 0) { this.cursor = this.#indexAtVisual(row - 1, col); this.#snapCursor(); return true; }
604
+ // at the first visual row: ↑ walks the history like other clients
605
+ }
606
+ if (this.history.length) {
578
607
  this.histIdx = this.histIdx < 0 ? this.history.length - 1 : Math.max(0, this.histIdx - 1);
579
608
  this.setValue(this.history[this.histIdx] ?? "");
580
609
  }
581
610
  return true;
582
- case "down":
611
+ }
612
+ case "down": {
583
613
  this.selStart = this.selEnd = null;
614
+ if (this.cmdOpen && this.cmds.length) {
615
+ this.cmdIdx = (this.cmdIdx + 1) % this.cmds.length;
616
+ this.onChange?.();
617
+ return true;
618
+ }
584
619
  if (this.multi) {
585
620
  const rows = this.#visualRows();
586
621
  const { row, col } = this.#cursorVisual();
587
- if (row < rows.length - 1) { this.cursor = this.#indexAtVisual(row + 1, col); this.#snapCursor(); }
588
- } else if (this.histIdx >= 0) {
622
+ if (row < rows.length - 1) { this.cursor = this.#indexAtVisual(row + 1, col); this.#snapCursor(); return true; }
623
+ // at the last visual row: ↓ walks the history forward
624
+ }
625
+ if (this.histIdx >= 0) {
589
626
  this.histIdx++;
590
627
  if (this.histIdx >= this.history.length) { this.histIdx = -1; this.setValue(""); }
591
628
  else this.setValue(this.history[this.histIdx]);
592
629
  }
593
630
  return true;
631
+ }
632
+ case "tab":
633
+ if (this.cmdOpen && this.cmds.length) {
634
+ // Tab completes the highlighted / command candidate
635
+ const c = this.cmds[this.cmdIdx];
636
+ this.setValue(c.name + " ");
637
+ this.cmdOpen = false;
638
+ return true;
639
+ }
640
+ return false;
594
641
  case "char":
595
642
  if (ev.ctrl) {
596
643
  switch (ev.key) {