dsh-neotui 0.1.13 → 0.1.14

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.14",
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
@@ -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) {
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,7 +1,7 @@
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";
@@ -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);
@@ -573,6 +603,7 @@ export class ChatView extends Widget {
573
603
  this.hasMore = false;
574
604
  this.loadingOlder = false;
575
605
  this.minSeq = null;
606
+ this.earliestTime = null; // earliest loaded event time ≈ session start
576
607
  this.view = new ScrollView({
577
608
  x: this.x, y: this.y, w: this.w, h: this.h - 2,
578
609
  autoScroll: true, title: "",
@@ -633,10 +664,22 @@ export class ChatView extends Widget {
633
664
 
634
665
  /** Idempotently re-derive the tail node(s) from the complete last message.
635
666
  * Dedup by message id so already-loaded nodes are updated, never duplicated. */
667
+ /** Track the earliest event time ever loaded — the session's start time
668
+ * (converges to the true start as older pages load). */
669
+ #noteEarliest(events) {
670
+ let t = Infinity;
671
+ for (const e of events ?? []) {
672
+ const et = e?.event?.time;
673
+ if (typeof et === "number" && et < t) t = et;
674
+ }
675
+ if (t !== Infinity && (this.earliestTime == null || t < this.earliestTime)) this.earliestTime = t;
676
+ }
677
+
636
678
  syncTail(events) {
637
679
  const maxSeq = events[events.length - 1]?.event?.seq ?? 0;
638
680
  if (maxSeq <= (this.lastSyncedSeq ?? -1)) return;
639
681
  this.lastSyncedSeq = maxSeq;
682
+ this.#noteEarliest(events);
640
683
  const nodes = nodeForEvents(events, this.app.log);
641
684
  const lastAssistant = [...nodes].reverse().find((n) => n.kind === "assistant");
642
685
  if (!lastAssistant) {
@@ -771,6 +814,7 @@ export class ChatView extends Widget {
771
814
  this.lastSyncedSeq = -1;
772
815
  this.pollSlow = false;
773
816
  this.hasMore = hist.hasMore;
817
+ this.#noteEarliest(hist.events);
774
818
  this.nodes = nodeForEvents(hist.events, this.app.log);
775
819
  this.title = hist.projections?.values?.title ?? this.title;
776
820
  if (hist.projections?.values) {
@@ -797,6 +841,7 @@ export class ChatView extends Widget {
797
841
  const before = this.lines.length;
798
842
  this.minSeq = hist.events[0]?.event?.seq ?? this.minSeq;
799
843
  this.hasMore = hist.hasMore;
844
+ this.#noteEarliest(hist.events);
800
845
  const more = nodeForEvents(hist.events, this.app.log);
801
846
  this.nodes = [...more, ...this.nodes];
802
847
  }
@@ -1138,7 +1183,7 @@ export class ChatView extends Widget {
1138
1183
  return ".";
1139
1184
  }).join("")
1140
1185
  : "";
1141
- const ckey = `${realIdx}|${w}|${expKey}|${blockKeys}|${this.thinkMode}|${this.bashMode}|${node.streaming ? "s" : "f"}|${themeName()}|${node.step ?? "-"}|${userPrefix()}`;
1186
+ const ckey = `${realIdx}|${w}|${expKey}|${blockKeys}|${this.thinkMode}|${this.bashMode}|${node.streaming ? "s" : "f"}|${themeName()}|${node.step ?? "-"}|${userPrefix()}|${node.turnMs ?? "-"}`;
1142
1187
  // Streaming nodes re-render every frame: their text grows without any
1143
1188
  // change to the cache key, so caching them freezes the live think/tool/text.
1144
1189
  const hit = node.streaming ? undefined : this.cache.get(ckey);
@@ -1256,23 +1301,25 @@ export class ChatView extends Widget {
1256
1301
  // otherwise the timer runs forever ("timing chaos").
1257
1302
  const running = b.result == null && !b.done && node.streaming;
1258
1303
  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 ? "✗" : "✓";
1304
+ // An orphan (result never matched) is NOT a failure it renders
1305
+ // neutral (◌, TOOLBG), never the red of a failed exit code.
1306
+ const failed = !orphan && exitCode !== undefined && exitCode !== 0;
1307
+ const status = running ? "TOOLBG" : failed ? "TOOLERR" : "TOOLOK";
1308
+ const glyph = running ? "⏳" : failed ? "✗" : orphan ? "◌" : "✓";
1261
1309
  const card = b.view ? renderToolCard(b.view, w, open) : [];
1262
1310
  beginCard(status);
1263
1311
  let timing = "";
1264
1312
  if (running) {
1265
1313
  timing = ` 已经过 ${fmtDuration(Date.now() - (b.startedAt ?? Date.now()))}`;
1266
1314
  } else if (b.startedAt !== undefined && b.endedAt !== undefined) {
1267
- const failed = exitCode !== undefined && exitCode !== 0;
1268
- timing = ` ${failed ? "失败" : "已完成"},耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
1315
+ timing = ` ${failed ? "失败" : orphan ? "无结果" : "已完成"},耗时 ${fmtDuration(b.endedAt - b.startedAt)}`;
1269
1316
  } else if (orphan) {
1270
1317
  timing = " 无结果";
1271
1318
  }
1272
1319
  lines.push([
1273
1320
  { t: open ? "▾ " : "▸ ", fg: K.ACCENT },
1274
1321
  { t: ` ${b.name ?? "tool"}`, fg: K.TXT, bold: true },
1275
- { t: ` ${glyph}`, fg: status === "TOOLOK" ? K.OK : status === "TOOLERR" ? K.ERR : K.WARN },
1322
+ { t: ` ${glyph}`, fg: failed ? K.ERR : status === "TOOLOK" ? K.OK : K.WARN },
1276
1323
  { t: stepTag + timing, fg: K.DIM },
1277
1324
  { t: open ? " [b 折叠]" : " [b 展开]", fg: K.FAINT },
1278
1325
  ]);
@@ -1348,6 +1395,11 @@ export class ChatView extends Widget {
1348
1395
  sep();
1349
1396
  }
1350
1397
  }
1398
+ // the turn's FINAL reply carries the whole turn duration
1399
+ if (node.turnMs != null) {
1400
+ lines.push([{ t: ` 🕐 本轮回答总耗时 ${fmtDuration(node.turnMs)}`, fg: T.WARN, bold: true }]);
1401
+ mark(realIdx);
1402
+ }
1351
1403
  break;
1352
1404
  }
1353
1405
  default:
@@ -1810,6 +1862,7 @@ export class App {
1810
1862
  this.jobs = [];
1811
1863
  this.jobsBySession = new Map(); // sessionId → latest session/jobs snapshot
1812
1864
  this.ctrlCUntil = null; // NORMAL-mode double-Ctrl+C exit window
1865
+ this.lastSec = 0; // status-bar clock second pulse
1813
1866
  this.focused = null;
1814
1867
  this.provider = "";
1815
1868
  this.model = "";
@@ -2990,6 +3043,9 @@ export class App {
2990
3043
  this.renderFrame();
2991
3044
  }
2992
3045
  if (this.toastMsg && Date.now() > this.toastUntil) { this.toastMsg = null; this.dirty = true; }
3046
+ // the status-bar clock ticks once per second
3047
+ const sec = Math.floor(Date.now() / 1000);
3048
+ if (sec !== this.lastSec) { this.lastSec = sec; this.dirty = true; }
2993
3049
  } catch (e) {
2994
3050
  this.log("render error (kept running):", e);
2995
3051
  // stderr is invisible under the alt screen — record the stack where
@@ -3056,6 +3112,16 @@ export class App {
3056
3112
  if (this.sidebarVisible) row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
3057
3113
  else row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
3058
3114
  if (cur?.running) row0.left.push({ t: " ●运行 ", fg: T.OK, bg: T.STATUSBG });
3115
+ // session elapsed/start: effective time (model+tool work, not wall clock)
3116
+ // right after the session name; start = the earliest event time loaded
3117
+ {
3118
+ const stats = this.projections.sessionStats ?? cur?.projections?.values?.sessionStats;
3119
+ const startMs = this.chat?.earliestTime;
3120
+ const parts = [];
3121
+ if (stats && stats.llmMs != null) parts.push(`有效 ${fmtDuration(stats.llmMs + (stats.toolMs ?? 0))}`);
3122
+ if (startMs != null) parts.push(`开始 ${fmtDateTime(startMs)}`);
3123
+ if (parts.length) row0.left.push({ t: ` ${parts.join(" · ")} `, fg: T.DIM, bg: T.STATUSBG });
3124
+ }
3059
3125
  if (this.goalText) row0.right.push({ t: " 🎯" + truncate(this.goalText, 22) + " ", fg: T.SELFG, bg: T.WARN, bold: true });
3060
3126
  const plan = this.projections.plan;
3061
3127
  if (plan?.active || plan?.pending) row0.right.push({ t: plan.active ? " ✎计划中 " : " ✎计划待审 ", fg: T.SELFG, bg: T.ACCENT2 });
@@ -3095,6 +3161,8 @@ export class App {
3095
3161
  // full working directory
3096
3162
  const cwd = this.currentSession ? (this.sessions.find((x) => x.sessionId === this.currentSession)?.cwd) : process.cwd();
3097
3163
  if (cwd) row1.left.push({ t: ` ${cwd} `, fg: T.FAINT, bg: T.STATUSBG });
3164
+ // live clock after the working directory (ticks once per second)
3165
+ row1.left.push({ t: ` ${fmtClock(Date.now())} `, fg: T.DIM, bg: T.STATUSBG });
3098
3166
  const stats = this.projections.sessionStats;
3099
3167
  if (stats) {
3100
3168
  if (stats.steps) row1.right.push({ t: ` ⚙${stats.steps}步 `, fg: T.FAINT, bg: T.STATUSBG });