dsh-neotui 0.1.17 → 0.1.19

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.17",
3
+ "version": "0.1.19",
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
@@ -937,7 +937,9 @@ export class TrajectoryPanel extends Widget {
937
937
  const hasResult = step.events.some((e) => e.type === "tool/result");
938
938
  const hasReasoning = step.events.some((e) => e.type === "assistant/chunk" && e.data?.chunk?.blockType === "reasoning");
939
939
  const t0 = step.events[0]?.time, t1 = step.events[step.events.length - 1]?.time;
940
- const dur = t0 && t1 ? fmtMs(t1 - t0) : "—";
940
+ // deep-dive style live timer: the newest step ticks while the turn runs
941
+ const isLiveTail = this.app.chat?.running && this.winSeqLo == null && si === this.steps.length - 1;
942
+ const dur = isLiveTail ? `⏱${fmtMs(Date.now() - (t0 ?? Date.now()))}` : (t0 && t1 ? fmtMs(t1 - t0) : "—");
941
943
  const bg = tools.length ? (hasResult ? T.TOOLOK : T.TOOLBG) : hasReasoning ? T.THINKBG : T.CARD;
942
944
  const summary = tools.slice(0, 3).join(",") || (hasReasoning ? "模型推理" : "纯文本");
943
945
  const open = this.expandedSteps.has(this.stepKey(step)); // 详细
@@ -975,6 +977,11 @@ export class TrajectoryPanel extends Widget {
975
977
  screen.text(this.x + 2, this.y + 1, "加载轨迹…", { fg: K.FAINT });
976
978
  return;
977
979
  }
980
+ // the live step's ⏱ timer re-renders once per second while the turn runs
981
+ if (this.app.chat?.running && this.winSeqLo == null && Date.now() - (this.liveTickAt ?? 0) > 1000) {
982
+ this.liveTickAt = Date.now();
983
+ this.buildLines();
984
+ }
978
985
  this.view.render(screen);
979
986
  }
980
987
  onMouse(ev) {
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
 
@@ -172,7 +172,7 @@ function applyEvent(nodes, event, view, log, state = null) {
172
172
  const ch = d.chunk ?? {};
173
173
  let node = cur();
174
174
  if (!node || node.kind !== "assistant" || node.finalized) {
175
- node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step };
175
+ node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step, turnStartAt: st.turnStart ?? undefined };
176
176
  nodes.push(node);
177
177
  }
178
178
  node.streaming = true;
@@ -219,7 +219,7 @@ function applyEvent(nodes, event, view, log, state = null) {
219
219
  }
220
220
  let node = cur();
221
221
  if (!node || node.kind !== "assistant") {
222
- node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step };
222
+ node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step, turnStartAt: st.turnStart ?? undefined };
223
223
  nodes.push(node);
224
224
  }
225
225
  node.blocks.push({ kind: "tool", name: d.name, args: d.arguments, callId, view: view?.view, result: null, startedAt: event.time ?? Date.now() });
@@ -584,6 +584,16 @@ class SidebarTree extends Widget {
584
584
 
585
585
  // ---- ChatView ----
586
586
 
587
+ /** Slash commands offered by the input's candidate bar (Tab completes). */
588
+ const SLASH_COMMANDS = [
589
+ { name: "/reload", desc: "重新载入界面(不重启进程)" },
590
+ { name: "/restart", desc: "重启 TUI 加载新版本" },
591
+ { name: "/model", desc: "切换模型" },
592
+ { name: "/theme", desc: "切换配色主题" },
593
+ { name: "/permission", desc: "修改权限模式" },
594
+ { name: "/goal", desc: "查看当前目标" },
595
+ ];
596
+
587
597
  export class ChatView extends Widget {
588
598
  constructor(opts) {
589
599
  super(opts);
@@ -612,9 +622,9 @@ export class ChatView extends Widget {
612
622
  });
613
623
  this.input = new Input({
614
624
  x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
615
- multi: true, maxLines: 6, app: this.app,
625
+ multi: true, maxLines: 6, app: this.app, commands: SLASH_COMMANDS,
616
626
  bg: T.PANEL,
617
- placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L 展开,Enter 发送)",
627
+ placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L 展开,↑/↓ 历史,Tab 补全 / 命令,Enter 发送)",
618
628
  onEnter: (v) => this.send(v),
619
629
  onChange: () => this.inputChanged(),
620
630
  });
@@ -923,6 +933,10 @@ export class ChatView extends Widget {
923
933
  const trimmed = text.trim();
924
934
  if (trimmed === "/reload") { this.app.softReload(); return; }
925
935
  if (trimmed === "/restart") { this.app.restartApp(); return; }
936
+ if (trimmed === "/model") { this.app.showModePicker(); return; }
937
+ if (trimmed === "/theme") { cycleTheme(); this.queueRebuild(); this.app.toast(`主题已切换: ${themeName()}`); return; }
938
+ if (trimmed === "/permission") { this.app.showPermissionPicker(); return; }
939
+ if (trimmed === "/goal") { this.app.showGoal(); return; }
926
940
  if (!trimmed) return;
927
941
  const { parts, images, errors } = buildPromptParts(trimmed, {
928
942
  readFile: (p) => {
@@ -1318,7 +1332,10 @@ export class ChatView extends Widget {
1318
1332
  if (running) {
1319
1333
  timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
1320
1334
  } else if (b.startedAt !== undefined && b.endedAt !== undefined) {
1321
- timing = ` ${failed ? "失败" : orphan ? "结果未保留" : "已完成"},耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
1335
+ // orphan note: the tool's own end timestamp was pruned WITH
1336
+ // its result, so endedAt here is the step-end stamp — the
1337
+ // duration is an upper bound, shown as ≤.
1338
+ timing = ` ${failed ? "失败" : orphan ? "结果未保留" : "已完成"},耗时 ${orphan ? "≤" : ""}${fmtDuration(b.endedAt - b.startedAt)}`;
1322
1339
  } else if (orphan) {
1323
1340
  timing = " 结果未保留";
1324
1341
  }
@@ -1409,6 +1426,10 @@ export class ChatView extends Widget {
1409
1426
  if (node.turnMs != null) {
1410
1427
  lines.push([{ t: ` 🕐 本轮回答总耗时 ${fmtDuration(node.turnMs)}`, fg: T.WARN, bold: true }]);
1411
1428
  mark(realIdx);
1429
+ } else if (node.streaming && node.turnStartAt != null && realIdx === this.nodes.length - 1) {
1430
+ // deep-dive style live timer: the running turn ticks in real time
1431
+ lines.push([{ t: ` 🕐 本轮进行中…已经过 ${fmtDuration(Date.now() - node.turnStartAt)}`, fg: T.WARN, bold: true }]);
1432
+ mark(realIdx);
1412
1433
  }
1413
1434
  break;
1414
1435
  }
@@ -1521,6 +1542,23 @@ export class ChatView extends Widget {
1521
1542
  }
1522
1543
  this.#renderTodos(screen);
1523
1544
  this.input.render(screen);
1545
+ this.#renderCmdBar(screen);
1546
+ }
1547
+
1548
+ /** / command candidate bar above the input (↑/↓ cycle, Tab completes). */
1549
+ #renderCmdBar(screen) {
1550
+ const inp = this.input;
1551
+ if (!inp.cmdOpen || inp.cmds.length === 0) return;
1552
+ const n = Math.min(inp.cmds.length, 6);
1553
+ const w = Math.min(this.view.w, 44);
1554
+ const y0 = Math.max(this.view.y, inp.y - n - 1);
1555
+ screen.fillRect(this.x, y0, this.x + w - 1, y0 + n - 1, " ", { bg: T.BG2 });
1556
+ for (let i = 0; i < n; i++) {
1557
+ const c = inp.cmds[i];
1558
+ const sel = i === inp.cmdIdx;
1559
+ 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 });
1560
+ 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 });
1561
+ }
1524
1562
  }
1525
1563
 
1526
1564
  /** Collapsible todo block between the view and the input (Shift+T toggles). */
@@ -1546,7 +1584,8 @@ export class ChatView extends Widget {
1546
1584
  return true;
1547
1585
  }
1548
1586
  if (this.view.inside(ev.x, ev.y)) {
1549
- this.app.focus(this);
1587
+ // clicks act, but never exit INSERT mode — Esc is the only way out
1588
+ if (this.app.focused !== this.app.chat?.input) this.app.focus(this);
1550
1589
  // Welcome-screen mode click: select the preset under the cursor.
1551
1590
  if (this.nodes.length === 0 && ev.kind === "press" && ev.button === 0) {
1552
1591
  const id = this.welcomeModes[ev.y];
@@ -2820,7 +2859,7 @@ export class App {
2820
2859
  // mouse routes by position (click = focus + dispatch)
2821
2860
  if (this.mode !== "chat") {
2822
2861
  if (ev.type === "mouse" && this.sidebarVisible && this.sidebar.inside(ev.x, ev.y)) {
2823
- this.focus(this.sidebar);
2862
+ if (this.focused !== this.chat.input) this.focus(this.sidebar); // INSERT exits only via Esc
2824
2863
  if (this.sidebar.onMouse(ev)) this.redraw();
2825
2864
  return;
2826
2865
  }
@@ -2867,7 +2906,7 @@ export class App {
2867
2906
  return;
2868
2907
  }
2869
2908
  if (this.sidebarVisible && this.sidebar.inside(ev.x, ev.y)) {
2870
- this.focus(this.sidebar);
2909
+ if (this.focused !== this.chat.input) this.focus(this.sidebar); // INSERT exits only via Esc
2871
2910
  if (this.sidebar.onMouse(ev)) this.redraw();
2872
2911
  } else if (this.chat.input.inside(ev.x, ev.y)) {
2873
2912
  // mouse SELECTION in the input works regardless of mode; typing stays
@@ -2884,7 +2923,7 @@ export class App {
2884
2923
  this.toast("按 i 进入输入(vim 式)");
2885
2924
  }
2886
2925
  } else if (this.chat.inside(ev.x, ev.y)) {
2887
- this.focus(this.chat);
2926
+ if (this.focused !== this.chat.input) this.focus(this.chat); // INSERT exits only via Esc
2888
2927
  if (this.chat.onMouse(ev)) this.redraw();
2889
2928
  } else if (this.focused?.onMouse(ev)) {
2890
2929
  this.redraw();
@@ -2898,6 +2937,9 @@ export class App {
2898
2937
  // typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
2899
2938
  if (this.focused === this.chat.input) {
2900
2939
  if (ev.name === "escape") {
2940
+ // Esc closes the open / command candidate bar first, then exits
2941
+ // insert — Esc is the ONLY way out of insert mode.
2942
+ if (this.chat.input.cmdOpen) { this.chat.input.cmdOpen = false; this.redraw(); return; }
2901
2943
  // one ESC press: interrupt a running turn AND leave insert —
2902
2944
  // otherwise the key just exits insert (vim muscle memory).
2903
2945
  const interrupted = this.#interruptIfRunning();
@@ -3053,9 +3095,14 @@ export class App {
3053
3095
  this.renderFrame();
3054
3096
  }
3055
3097
  if (this.toastMsg && Date.now() > this.toastUntil) { this.toastMsg = null; this.dirty = true; }
3056
- // the status-bar clock ticks once per second
3098
+ // the status-bar clock ticks once per second; while a turn runs the
3099
+ // chat's live timers (已经过 / 🕐) tick with it
3057
3100
  const sec = Math.floor(Date.now() / 1000);
3058
- if (sec !== this.lastSec) { this.lastSec = sec; this.dirty = true; }
3101
+ if (sec !== this.lastSec) {
3102
+ this.lastSec = sec;
3103
+ if (this.chat.running) this.chat.queueRebuild();
3104
+ this.dirty = true;
3105
+ }
3059
3106
  } catch (e) {
3060
3107
  this.log("render error (kept running):", e);
3061
3108
  // 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) {