dsh-neotui 0.1.13 → 0.1.15

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.13",
3
+ "version": "0.1.15",
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/config.js CHANGED
@@ -62,3 +62,14 @@ export function userName() {
62
62
  export function userPrefix() {
63
63
  return `${userName()} > `;
64
64
  }
65
+
66
+ /** Fold defaults (settings → 默认展开/折叠): think/tool blocks and the
67
+ * todo list, with the shipped defaults when nothing is configured. */
68
+ export function foldDefaults() {
69
+ const fd = loadTuiConfig().foldDefaults ?? {};
70
+ return {
71
+ think: fd.think !== false, // think blocks default expanded
72
+ bash: fd.bash === true, // tool blocks default collapsed
73
+ todos: fd.todos !== false, // todo list default visible
74
+ };
75
+ }
package/src/panels.js CHANGED
@@ -10,7 +10,7 @@ import { join, basename, extname } from "node:path";
10
10
  import { spawn, execFileSync } from "node:child_process";
11
11
 
12
12
  import { T, cycleTheme, themeName } from "./theme.js";
13
- import { loadTuiConfig, saveTuiConfig, userPrefix, userName } from "./config.js";
13
+ import { loadTuiConfig, saveTuiConfig, userPrefix, userName, foldDefaults } from "./config.js";
14
14
  // Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
15
15
  const K = new Proxy({}, { get(_k, key) { return T[key]; } });
16
16
 
@@ -950,10 +950,13 @@ export class TrajectoryPanel extends Widget {
950
950
  lines.push(segs);
951
951
  this.stepLines[lines.length - 1] = si;
952
952
  if (open) {
953
- // 详细 mode: the step's events inline under its color block.
953
+ // 详细 mode: the step's events inline under its color block, each
954
+ // with its deep-dive elapsed time since the step started (web-style).
955
+ const t0 = step.events[0]?.time;
954
956
  const evs = step.events.slice(0, 12);
955
957
  for (const e of evs) {
956
- lines.push([{ t: ` #${String(e.seq).padStart(4)} ${truncate(this.#eventSummary(e), w - 12)}`, fg: K.DIM, bg }]);
958
+ const dt = t0 != null && e.time != null ? ` +${fmtMs(e.time - t0)}` : "";
959
+ lines.push([{ t: ` #${String(e.seq).padStart(4)}${dt} ${truncate(this.#eventSummary(e), w - 12 - strWidth(dt))}`, fg: K.DIM, bg }]);
957
960
  this.stepLines[lines.length - 1] = si;
958
961
  }
959
962
  if (step.events.length > evs.length) {
@@ -1588,6 +1591,13 @@ export class SettingsPanel extends Widget {
1588
1591
  ns: "TUI 界面", applies: "live", local: true,
1589
1592
  value: { userPrefix: userName() },
1590
1593
  });
1594
+ // 默认展开/折叠: the fold-related defaults as a local sub-panel of
1595
+ // booleans (click to toggle), persisted to the TUI config file.
1596
+ const fd = foldDefaults();
1597
+ this.namespaces.splice(1, 0, {
1598
+ ns: "默认展开/折叠", applies: "live", local: true,
1599
+ value: { 思考块默认展开: fd.think, 工具块默认展开: fd.bash, 任务清单默认显示: fd.todos },
1600
+ });
1591
1601
  this.selectNs(0);
1592
1602
  }
1593
1603
  selectNs(i) {
@@ -1716,6 +1726,27 @@ export class SettingsPanel extends Widget {
1716
1726
  if (ns.local) {
1717
1727
  // TUI-local config: write the config file, apply instantly.
1718
1728
  const v = applyOps(ns.value, this.pendingOps);
1729
+ if (ns.ns === "默认展开/折叠") {
1730
+ const patch = { foldDefaults: { think: !!v.思考块默认展开, bash: !!v.工具块默认展开, todos: !!v.任务清单默认显示 } };
1731
+ if (saveTuiConfig(patch)) {
1732
+ this.pendingOps = [];
1733
+ this.app.toast("已保存展开/折叠默认值(即时生效)");
1734
+ // apply live to the current chat
1735
+ const chat = this.app.chat;
1736
+ if (chat) {
1737
+ chat.thinkMode = v.思考块默认展开 ? "expanded" : "collapsed";
1738
+ chat.bashMode = v.工具块默认展开 ? "expanded" : "collapsed";
1739
+ chat.todosVisible = !!v.任务清单默认显示;
1740
+ chat.expanded.clear();
1741
+ chat.collapsedBlocks.clear();
1742
+ chat.queueRebuild();
1743
+ }
1744
+ await this.load();
1745
+ } else {
1746
+ this.app.toast("保存失败:无法写入 TUI 配置文件");
1747
+ }
1748
+ return;
1749
+ }
1719
1750
  const name = String(v.userPrefix ?? "").trim();
1720
1751
  if (saveTuiConfig({ userPrefix: name })) {
1721
1752
  this.pendingOps = [];
package/src/text.js CHANGED
@@ -64,6 +64,20 @@ export function fmtDuration(ms) {
64
64
  return `${sec}秒`;
65
65
  }
66
66
 
67
+ /** Local wall-clock "HH:MM:SS" (status-bar live clock). */
68
+ export function fmtClock(ms) {
69
+ const d = new Date(ms);
70
+ const p = (n) => String(n).padStart(2, "0");
71
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
72
+ }
73
+
74
+ /** Local wall-clock "MM-DD HH:MM" (session start time). */
75
+ export function fmtDateTime(ms) {
76
+ const d = new Date(ms);
77
+ const p = (n) => String(n).padStart(2, "0");
78
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
79
+ }
80
+
67
81
  export function hexRgb(hex) {
68
82
  const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
69
83
  if (!m) return null;
package/src/views.js CHANGED
@@ -1,12 +1,12 @@
1
1
  // views.js — App composition: session list + chat timeline + approvals + status.
2
2
  import { Screen } from "./screen.js";
3
3
  import { renderMd, C } from "./md.js";
4
- import { truncate, strWidth, bars, fmtDuration } from "./text.js";
4
+ import { truncate, strWidth, bars, fmtDuration, fmtClock, fmtDateTime } from "./text.js";
5
5
  import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { Widget, List, ScrollView, Input, Popup, Menu, StatusBar } from "./widgets.js";
8
- import { userPrefix, saveTuiConfig, loadTuiConfig, userName } from "./config.js";
9
- export { userPrefix, saveTuiConfig, loadTuiConfig, userName } from "./config.js";
8
+ import { userPrefix, saveTuiConfig, loadTuiConfig, userName, foldDefaults } from "./config.js";
9
+ export { userPrefix, saveTuiConfig, loadTuiConfig, userName, foldDefaults } from "./config.js";
10
10
  import {
11
11
  Picker, buildCommandPalette, buildModelPicker, buildModePicker, buildPermissionPicker,
12
12
  modeName, permName, WorkspacePanel, TrajectoryPanel, DirPicker,
@@ -147,12 +147,17 @@ function applyEvent(nodes, event, view, log, state = null) {
147
147
  else blocks.push({ kind: "other", text: JSON.stringify(p).slice(0, 500) });
148
148
  }
149
149
  // Finalization stamps: endedAt marks when the message completed. The
150
- // block start time is left untouched so syncTail's inheritStarts can
151
- // carry the ORIGINAL block start across re-derivations.
150
+ // final message REPLACES the chunk-built blocks, so carry their start
151
+ // times by position (same rule as syncTail's inheritStarts) —
152
+ // otherwise every reloaded think block loses 耗时 after completion.
153
+ const last = cur();
154
+ const prevBlocks = last && last.kind === "assistant" ? last.blocks ?? [] : [];
152
155
  for (const b of blocks) b.endedAt = event.time ?? Date.now();
156
+ for (let bi = 0; bi < blocks.length; bi++) {
157
+ if (blocks[bi].startedAt === undefined && prevBlocks[bi]?.startedAt !== undefined) blocks[bi].startedAt = prevBlocks[bi].startedAt;
158
+ }
153
159
  const images = partsToImages(d.message?.content);
154
160
  const id = d.message?.id ?? null;
155
- const last = cur();
156
161
  if (last && last.kind === "assistant" && last.streaming !== false) {
157
162
  last.blocks = blocks;
158
163
  last.images = images ?? last.images;
@@ -224,13 +229,38 @@ function applyEvent(nodes, event, view, log, state = null) {
224
229
  const callId = d.message?.source?.callId;
225
230
  const text = partsToText(d.message?.content);
226
231
  const node = nodes.findLast((nd) => nd.kind === "assistant" && nd.blocks.some((b) => b.kind === "tool" && b.callId === callId));
227
- const block = node?.blocks.find((b) => b.kind === "tool" && b.callId === callId);
232
+ let block = node?.blocks.find((b) => b.kind === "tool" && b.callId === callId);
233
+ if (!block) {
234
+ // the callId did not match (missed tool/call in the loaded window):
235
+ // attach to the most recent tool block still awaiting a result —
236
+ // otherwise a SUCCESSFUL bash renders as a red "无结果" failure
237
+ outer:
238
+ for (let ni = nodes.length - 1; ni >= 0; ni--) {
239
+ const nd = nodes[ni];
240
+ if (nd.kind !== "assistant") continue;
241
+ for (const b of nd.blocks ?? []) {
242
+ if (b.kind === "tool" && b.result == null) { block = b; break outer; }
243
+ }
244
+ }
245
+ }
228
246
  if (block) {
229
247
  block.result = text ?? JSON.stringify(d).slice(0, 400);
230
248
  block.endedAt = event.time ?? Date.now();
231
249
  }
232
250
  break;
233
251
  }
252
+ case "turn/start": {
253
+ st.turnStart = event.time ?? Date.now();
254
+ break;
255
+ }
256
+ case "turn/end": {
257
+ const node = cur();
258
+ if (node?.kind === "assistant" && st.turnStart !== undefined) {
259
+ node.turnMs = Math.max(0, (event.time ?? Date.now()) - st.turnStart);
260
+ }
261
+ st.turnStart = null;
262
+ break;
263
+ }
234
264
  case "step/end": {
235
265
  const node = cur();
236
266
  if (node && node.kind === "assistant") {
@@ -254,7 +284,7 @@ function applyEvent(nodes, event, view, log, state = null) {
254
284
  }
255
285
  default: {
256
286
  // known benign control events: silently ignored
257
- const KNOWN = new Set(["turn/start", "turn/end", "todo/write",
287
+ const KNOWN = new Set(["todo/write",
258
288
  "agent/inbox/spliced", "request/header", "request/context", "permission/preset",
259
289
  "sandbox/mode", "approval/policy", "session/title-llm-request", "command/done", "command/failed"]);
260
290
  if (!KNOWN.has(event.type) && !SEEN_TYPES.has(event.type)) {
@@ -266,7 +296,7 @@ function applyEvent(nodes, event, view, log, state = null) {
266
296
  }
267
297
 
268
298
  /** Re-derive the whole node list from a complete event window (open/poll). */
269
- function nodeForEvents(events, log) {
299
+ export function nodeForEvents(events, log) {
270
300
  const nodes = [];
271
301
  const state = { step: null };
272
302
  for (const { event, view } of events) applyEvent(nodes, event, view, log, state);
@@ -565,14 +595,16 @@ export class ChatView extends Widget {
565
595
  this.expanded = new Set(); // node indexes (user-message full text)
566
596
  this.expandedTools = new Set();
567
597
  this.collapsedBlocks = new Set(); // per-block COLLAPSE (default expanded): `${realIdx}:${bi}`
568
- this.thinkMode = "expanded"; // think blocks: expanded by default (t toggles)
569
- this.bashMode = "collapsed"; // tool blocks: collapsed by default (b toggles)
570
- this.todosVisible = true; // todo block above the input (Shift+T toggles)
598
+ const fd = foldDefaults();
599
+ this.thinkMode = fd.think ? "expanded" : "collapsed"; // t toggles
600
+ this.bashMode = fd.bash ? "expanded" : "collapsed"; // b toggles
601
+ this.todosVisible = fd.todos; // Shift+T toggles
571
602
  this.todoSeen = false; // once seen, the todo box keeps its height
572
603
  this.running = false;
573
604
  this.hasMore = false;
574
605
  this.loadingOlder = false;
575
606
  this.minSeq = null;
607
+ this.earliestTime = null; // earliest loaded event time ≈ session start
576
608
  this.view = new ScrollView({
577
609
  x: this.x, y: this.y, w: this.w, h: this.h - 2,
578
610
  autoScroll: true, title: "",
@@ -633,10 +665,22 @@ export class ChatView extends Widget {
633
665
 
634
666
  /** Idempotently re-derive the tail node(s) from the complete last message.
635
667
  * Dedup by message id so already-loaded nodes are updated, never duplicated. */
668
+ /** Track the earliest event time ever loaded — the session's start time
669
+ * (converges to the true start as older pages load). */
670
+ #noteEarliest(events) {
671
+ let t = Infinity;
672
+ for (const e of events ?? []) {
673
+ const et = e?.event?.time;
674
+ if (typeof et === "number" && et < t) t = et;
675
+ }
676
+ if (t !== Infinity && (this.earliestTime == null || t < this.earliestTime)) this.earliestTime = t;
677
+ }
678
+
636
679
  syncTail(events) {
637
680
  const maxSeq = events[events.length - 1]?.event?.seq ?? 0;
638
681
  if (maxSeq <= (this.lastSyncedSeq ?? -1)) return;
639
682
  this.lastSyncedSeq = maxSeq;
683
+ this.#noteEarliest(events);
640
684
  const nodes = nodeForEvents(events, this.app.log);
641
685
  const lastAssistant = [...nodes].reverse().find((n) => n.kind === "assistant");
642
686
  if (!lastAssistant) {
@@ -771,6 +815,7 @@ export class ChatView extends Widget {
771
815
  this.lastSyncedSeq = -1;
772
816
  this.pollSlow = false;
773
817
  this.hasMore = hist.hasMore;
818
+ this.#noteEarliest(hist.events);
774
819
  this.nodes = nodeForEvents(hist.events, this.app.log);
775
820
  this.title = hist.projections?.values?.title ?? this.title;
776
821
  if (hist.projections?.values) {
@@ -797,6 +842,7 @@ export class ChatView extends Widget {
797
842
  const before = this.lines.length;
798
843
  this.minSeq = hist.events[0]?.event?.seq ?? this.minSeq;
799
844
  this.hasMore = hist.hasMore;
845
+ this.#noteEarliest(hist.events);
800
846
  const more = nodeForEvents(hist.events, this.app.log);
801
847
  this.nodes = [...more, ...this.nodes];
802
848
  }
@@ -1138,7 +1184,7 @@ export class ChatView extends Widget {
1138
1184
  return ".";
1139
1185
  }).join("")
1140
1186
  : "";
1141
- const ckey = `${realIdx}|${w}|${expKey}|${blockKeys}|${this.thinkMode}|${this.bashMode}|${node.streaming ? "s" : "f"}|${themeName()}|${node.step ?? "-"}|${userPrefix()}`;
1187
+ const ckey = `${realIdx}|${w}|${expKey}|${blockKeys}|${this.thinkMode}|${this.bashMode}|${node.streaming ? "s" : "f"}|${themeName()}|${node.step ?? "-"}|${userPrefix()}|${node.turnMs ?? "-"}`;
1142
1188
  // Streaming nodes re-render every frame: their text grows without any
1143
1189
  // change to the cache key, so caching them freezes the live think/tool/text.
1144
1190
  const hit = node.streaming ? undefined : this.cache.get(ckey);
@@ -1256,23 +1302,25 @@ export class ChatView extends Widget {
1256
1302
  // otherwise the timer runs forever ("timing chaos").
1257
1303
  const running = b.result == null && !b.done && node.streaming;
1258
1304
  const orphan = b.result == null && !b.done && !node.streaming;
1259
- const status = orphan ? "TOOLERR" : running ? "TOOLBG" : exitCode !== undefined && exitCode !== 0 ? "TOOLERR" : "TOOLOK";
1260
- const glyph = orphan ? "✗" : running ? "⏳" : exitCode !== undefined && exitCode !== 0 ? "✗" : "✓";
1305
+ // An orphan (result never matched) is NOT a failure it renders
1306
+ // neutral (◌, TOOLBG), never the red of a failed exit code.
1307
+ const failed = !orphan && exitCode !== undefined && exitCode !== 0;
1308
+ const status = running ? "TOOLBG" : failed ? "TOOLERR" : "TOOLOK";
1309
+ const glyph = running ? "⏳" : failed ? "✗" : orphan ? "◌" : "✓";
1261
1310
  const card = b.view ? renderToolCard(b.view, w, open) : [];
1262
1311
  beginCard(status);
1263
1312
  let timing = "";
1264
1313
  if (running) {
1265
1314
  timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
1266
1315
  } else if (b.startedAt !== undefined && b.endedAt !== undefined) {
1267
- const failed = exitCode !== undefined && exitCode !== 0;
1268
- timing = ` ${failed ? "失败" : "已完成"},耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
1316
+ timing = ` ${failed ? "失败" : orphan ? "无结果" : "已完成"},耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
1269
1317
  } else if (orphan) {
1270
1318
  timing = " 无结果";
1271
1319
  }
1272
1320
  lines.push([
1273
1321
  { t: open ? "▾ " : "▸ ", fg: K.ACCENT },
1274
1322
  { t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
1275
- { t: ` ${glyph}`, fg: status === "TOOLOK" ? K.OK : status === "TOOLERR" ? K.ERR : K.WARN },
1323
+ { t: ` ${glyph}`, fg: failed ? K.ERR : status === "TOOLOK" ? K.OK : K.WARN },
1276
1324
  { t: stepTag + timing, fg: K.DIM },
1277
1325
  { t: open ? " [b 折叠]" : " [b 展开]", fg: K.FAINT },
1278
1326
  ]);
@@ -1348,6 +1396,11 @@ export class ChatView extends Widget {
1348
1396
  sep();
1349
1397
  }
1350
1398
  }
1399
+ // the turn's FINAL reply carries the whole turn duration
1400
+ if (node.turnMs != null) {
1401
+ lines.push([{ t: ` 🕐 本轮回答总耗时 ${fmtDuration(node.turnMs)}`, fg: T.WARN, bold: true }]);
1402
+ mark(realIdx);
1403
+ }
1351
1404
  break;
1352
1405
  }
1353
1406
  default:
@@ -1810,6 +1863,7 @@ export class App {
1810
1863
  this.jobs = [];
1811
1864
  this.jobsBySession = new Map(); // sessionId → latest session/jobs snapshot
1812
1865
  this.ctrlCUntil = null; // NORMAL-mode double-Ctrl+C exit window
1866
+ this.lastSec = 0; // status-bar clock second pulse
1813
1867
  this.focused = null;
1814
1868
  this.provider = "";
1815
1869
  this.model = "";
@@ -2990,6 +3044,9 @@ export class App {
2990
3044
  this.renderFrame();
2991
3045
  }
2992
3046
  if (this.toastMsg && Date.now() > this.toastUntil) { this.toastMsg = null; this.dirty = true; }
3047
+ // the status-bar clock ticks once per second
3048
+ const sec = Math.floor(Date.now() / 1000);
3049
+ if (sec !== this.lastSec) { this.lastSec = sec; this.dirty = true; }
2993
3050
  } catch (e) {
2994
3051
  this.log("render error (kept running):", e);
2995
3052
  // stderr is invisible under the alt screen — record the stack where
@@ -3056,6 +3113,16 @@ export class App {
3056
3113
  if (this.sidebarVisible) row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
3057
3114
  else row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
3058
3115
  if (cur?.running) row0.left.push({ t: " ●运行 ", fg: T.OK, bg: T.STATUSBG });
3116
+ // session elapsed/start: effective time (model+tool work, not wall clock)
3117
+ // right after the session name; start = the earliest event time loaded
3118
+ {
3119
+ const stats = this.projections.sessionStats ?? cur?.projections?.values?.sessionStats;
3120
+ const startMs = this.chat?.earliestTime;
3121
+ const parts = [];
3122
+ if (stats && stats.llmMs != null) parts.push(`有效 ${fmtDuration(stats.llmMs + (stats.toolMs ?? 0))}`);
3123
+ if (startMs != null) parts.push(`开始 ${fmtDateTime(startMs)}`);
3124
+ if (parts.length) row0.left.push({ t: ` ${parts.join(" · ")} `, fg: T.DIM, bg: T.STATUSBG });
3125
+ }
3059
3126
  if (this.goalText) row0.right.push({ t: " 🎯" + truncate(this.goalText, 22) + " ", fg: T.SELFG, bg: T.WARN, bold: true });
3060
3127
  const plan = this.projections.plan;
3061
3128
  if (plan?.active || plan?.pending) row0.right.push({ t: plan.active ? " ✎计划中 " : " ✎计划待审 ", fg: T.SELFG, bg: T.ACCENT2 });
@@ -3095,6 +3162,8 @@ export class App {
3095
3162
  // full working directory
3096
3163
  const cwd = this.currentSession ? (this.sessions.find((x) => x.sessionId === this.currentSession)?.cwd) : process.cwd();
3097
3164
  if (cwd) row1.left.push({ t: ` ${cwd} `, fg: T.FAINT, bg: T.STATUSBG });
3165
+ // live clock after the working directory (ticks once per second)
3166
+ row1.left.push({ t: ` ${fmtClock(Date.now())} `, fg: T.DIM, bg: T.STATUSBG });
3098
3167
  const stats = this.projections.sessionStats;
3099
3168
  if (stats) {
3100
3169
  if (stats.steps) row1.right.push({ t: ` ⚙${stats.steps}步 `, fg: T.FAINT, bg: T.STATUSBG });