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 +1 -1
- package/src/panels.js +5 -2
- package/src/text.js +14 -0
- package/src/views.js +81 -13
package/package.json
CHANGED
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
|
-
|
|
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
|
-
//
|
|
151
|
-
//
|
|
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
|
-
|
|
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(["
|
|
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
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
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:
|
|
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 });
|