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 +1 -1
- package/src/panels.js +8 -1
- package/src/views.js +59 -12
- package/src/widgets.js +53 -6
package/package.json
CHANGED
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|