dsh-neotui 0.1.18 → 0.1.20
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 +35 -1
- package/src/views.js +70 -14
- package/src/widgets.js +53 -6
package/package.json
CHANGED
package/src/panels.js
CHANGED
|
@@ -560,6 +560,9 @@ export class TrajectoryPanel extends Widget {
|
|
|
560
560
|
this.flashUntil = 0;
|
|
561
561
|
this.loadPromise = null; // dedupes concurrent load(currentSession)
|
|
562
562
|
this.loadTarget = null;
|
|
563
|
+
this.liveTickAt = 0; // ⏱ live timer re-render throttle
|
|
564
|
+
this.tailFetchAt = 0; // tail-window auto-refresh throttle
|
|
565
|
+
this.refreshing = false;
|
|
563
566
|
this.winSeqLo = null; // visible window = first-event SEQ range; null = follow the tail
|
|
564
567
|
this.winSeqHi = null;
|
|
565
568
|
// LEFT click toggles a step's 详细/简略 expansion (the ▸/▾ triangle).
|
|
@@ -937,7 +940,9 @@ export class TrajectoryPanel extends Widget {
|
|
|
937
940
|
const hasResult = step.events.some((e) => e.type === "tool/result");
|
|
938
941
|
const hasReasoning = step.events.some((e) => e.type === "assistant/chunk" && e.data?.chunk?.blockType === "reasoning");
|
|
939
942
|
const t0 = step.events[0]?.time, t1 = step.events[step.events.length - 1]?.time;
|
|
940
|
-
|
|
943
|
+
// deep-dive style live timer: the newest step ticks while the turn runs
|
|
944
|
+
const isLiveTail = this.app.chat?.running && this.winSeqLo == null && si === this.steps.length - 1;
|
|
945
|
+
const dur = isLiveTail ? `⏱${fmtMs(Date.now() - (t0 ?? Date.now()))}` : (t0 && t1 ? fmtMs(t1 - t0) : "—");
|
|
941
946
|
const bg = tools.length ? (hasResult ? T.TOOLOK : T.TOOLBG) : hasReasoning ? T.THINKBG : T.CARD;
|
|
942
947
|
const summary = tools.slice(0, 3).join(",") || (hasReasoning ? "模型推理" : "纯文本");
|
|
943
948
|
const open = this.expandedSteps.has(this.stepKey(step)); // 详细
|
|
@@ -975,8 +980,37 @@ export class TrajectoryPanel extends Widget {
|
|
|
975
980
|
screen.text(this.x + 2, this.y + 1, "加载轨迹…", { fg: K.FAINT });
|
|
976
981
|
return;
|
|
977
982
|
}
|
|
983
|
+
// the live step's ⏱ timer re-renders once per second while the turn runs,
|
|
984
|
+
// and the tail window refreshes periodically so a NEW turn's step (and
|
|
985
|
+
// its timer) appears without pressing r
|
|
986
|
+
if (this.app.chat?.running && this.winSeqLo == null) {
|
|
987
|
+
if (Date.now() - (this.liveTickAt ?? 0) > 1000) {
|
|
988
|
+
this.liveTickAt = Date.now();
|
|
989
|
+
this.buildLines();
|
|
990
|
+
}
|
|
991
|
+
if (!this.refreshing && Date.now() - (this.tailFetchAt ?? 0) > 4000) {
|
|
992
|
+
this.tailFetchAt = Date.now();
|
|
993
|
+
this.#refreshTail();
|
|
994
|
+
}
|
|
995
|
+
}
|
|
978
996
|
this.view.render(screen);
|
|
979
997
|
}
|
|
998
|
+
/** Re-fetch the tail window while following the live turn. */
|
|
999
|
+
async #refreshTail() {
|
|
1000
|
+
if (!this.sessionId) return;
|
|
1001
|
+
this.refreshing = true;
|
|
1002
|
+
try {
|
|
1003
|
+
const h = await this.app.api.call("session.history", { sessionId: this.sessionId, maxMessages: 20 });
|
|
1004
|
+
this.allEvents = h.events;
|
|
1005
|
+
this.minSeq = h.events[0]?.event?.seq ?? this.minSeq;
|
|
1006
|
+
this.hasMore = h.hasMore;
|
|
1007
|
+
this.stats = h.projections?.values?.sessionStats ?? this.stats;
|
|
1008
|
+
this.build();
|
|
1009
|
+
this.buildLines();
|
|
1010
|
+
this.app.redraw();
|
|
1011
|
+
} catch { /* next tick retries */ }
|
|
1012
|
+
this.refreshing = false;
|
|
1013
|
+
}
|
|
980
1014
|
onMouse(ev) {
|
|
981
1015
|
// RIGHT click on a step: context menu (expand/collapse · jump · detail).
|
|
982
1016
|
if (ev.kind === "press" && ev.button === 2) {
|
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
|
|
|
@@ -130,8 +130,11 @@ function applyEvent(nodes, event, view, log, state = null) {
|
|
|
130
130
|
const text = partsToText(d.content ?? d.message?.content);
|
|
131
131
|
const images = partsToImages(d.content ?? d.message?.content);
|
|
132
132
|
const id = d.id ?? null;
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
// the turn timer starts HERE: the user message carries the turn start
|
|
134
|
+
// so the 🕐 ticker shows even before the first assistant chunk lands
|
|
135
|
+
const turnStartAt = st.turnStart ?? event.time ?? Date.now();
|
|
136
|
+
if (text !== null) nodes.push({ kind: "user", text, images, id, step: st.step, turnStartAt });
|
|
137
|
+
else if (images) nodes.push({ kind: "user", text: "", images, id, step: st.step, turnStartAt });
|
|
135
138
|
break;
|
|
136
139
|
}
|
|
137
140
|
case "assistant/message": {
|
|
@@ -172,7 +175,7 @@ function applyEvent(nodes, event, view, log, state = null) {
|
|
|
172
175
|
const ch = d.chunk ?? {};
|
|
173
176
|
let node = cur();
|
|
174
177
|
if (!node || node.kind !== "assistant" || node.finalized) {
|
|
175
|
-
node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step };
|
|
178
|
+
node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step, turnStartAt: st.turnStart ?? undefined };
|
|
176
179
|
nodes.push(node);
|
|
177
180
|
}
|
|
178
181
|
node.streaming = true;
|
|
@@ -219,7 +222,7 @@ function applyEvent(nodes, event, view, log, state = null) {
|
|
|
219
222
|
}
|
|
220
223
|
let node = cur();
|
|
221
224
|
if (!node || node.kind !== "assistant") {
|
|
222
|
-
node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step };
|
|
225
|
+
node = { kind: "assistant", blocks: [], streaming: true, finalized: false, step: st.step, turnStartAt: st.turnStart ?? undefined };
|
|
223
226
|
nodes.push(node);
|
|
224
227
|
}
|
|
225
228
|
node.blocks.push({ kind: "tool", name: d.name, args: d.arguments, callId, view: view?.view, result: null, startedAt: event.time ?? Date.now() });
|
|
@@ -584,6 +587,16 @@ class SidebarTree extends Widget {
|
|
|
584
587
|
|
|
585
588
|
// ---- ChatView ----
|
|
586
589
|
|
|
590
|
+
/** Slash commands offered by the input's candidate bar (Tab completes). */
|
|
591
|
+
const SLASH_COMMANDS = [
|
|
592
|
+
{ name: "/reload", desc: "重新载入界面(不重启进程)" },
|
|
593
|
+
{ name: "/restart", desc: "重启 TUI 加载新版本" },
|
|
594
|
+
{ name: "/model", desc: "切换模型" },
|
|
595
|
+
{ name: "/theme", desc: "切换配色主题" },
|
|
596
|
+
{ name: "/permission", desc: "修改权限模式" },
|
|
597
|
+
{ name: "/goal", desc: "查看当前目标" },
|
|
598
|
+
];
|
|
599
|
+
|
|
587
600
|
export class ChatView extends Widget {
|
|
588
601
|
constructor(opts) {
|
|
589
602
|
super(opts);
|
|
@@ -612,9 +625,9 @@ export class ChatView extends Widget {
|
|
|
612
625
|
});
|
|
613
626
|
this.input = new Input({
|
|
614
627
|
x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
|
|
615
|
-
multi: true, maxLines: 6, app: this.app,
|
|
628
|
+
multi: true, maxLines: 6, app: this.app, commands: SLASH_COMMANDS,
|
|
616
629
|
bg: T.PANEL,
|
|
617
|
-
placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L
|
|
630
|
+
placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L 展开,↑/↓ 历史,Tab 补全 / 命令,Enter 发送)",
|
|
618
631
|
onEnter: (v) => this.send(v),
|
|
619
632
|
onChange: () => this.inputChanged(),
|
|
620
633
|
});
|
|
@@ -923,6 +936,10 @@ export class ChatView extends Widget {
|
|
|
923
936
|
const trimmed = text.trim();
|
|
924
937
|
if (trimmed === "/reload") { this.app.softReload(); return; }
|
|
925
938
|
if (trimmed === "/restart") { this.app.restartApp(); return; }
|
|
939
|
+
if (trimmed === "/model") { this.app.showModePicker(); return; }
|
|
940
|
+
if (trimmed === "/theme") { cycleTheme(); this.queueRebuild(); this.app.toast(`主题已切换: ${themeName()}`); return; }
|
|
941
|
+
if (trimmed === "/permission") { this.app.showPermissionPicker(); return; }
|
|
942
|
+
if (trimmed === "/goal") { this.app.showGoal(); return; }
|
|
926
943
|
if (!trimmed) return;
|
|
927
944
|
const { parts, images, errors } = buildPromptParts(trimmed, {
|
|
928
945
|
readFile: (p) => {
|
|
@@ -1187,7 +1204,9 @@ export class ChatView extends Widget {
|
|
|
1187
1204
|
const ckey = `${realIdx}|${w}|${expKey}|${blockKeys}|${this.thinkMode}|${this.bashMode}|${node.streaming ? "s" : "f"}|${themeName()}|${node.step ?? "-"}|${userPrefix()}|${node.turnMs ?? "-"}`;
|
|
1188
1205
|
// Streaming nodes re-render every frame: their text grows without any
|
|
1189
1206
|
// change to the cache key, so caching them freezes the live think/tool/text.
|
|
1190
|
-
|
|
1207
|
+
// The LAST node re-renders too while a turn runs — its ticking 🕐 timer
|
|
1208
|
+
// must not be baked into a cached entry.
|
|
1209
|
+
const hit = (node.streaming || (this.running && realIdx === this.nodes.length - 1)) ? undefined : this.cache.get(ckey);
|
|
1191
1210
|
if (hit) {
|
|
1192
1211
|
for (const [rs, re, bg] of hit.cards ?? []) {
|
|
1193
1212
|
this.cardRanges.push([lines.length + rs, lines.length + re, bg]);
|
|
@@ -1249,6 +1268,13 @@ export class ChatView extends Widget {
|
|
|
1249
1268
|
markImg(realIdx, ii);
|
|
1250
1269
|
}
|
|
1251
1270
|
}
|
|
1271
|
+
// the turn timer starts at the QUESTION: while the reply is still
|
|
1272
|
+
// in the request/queue phase (no assistant node yet), the ticker
|
|
1273
|
+
// lives under the user message — web-style deep-dive from t=0.
|
|
1274
|
+
if (this.running && realIdx === this.nodes.length - 1 && node.turnStartAt != null) {
|
|
1275
|
+
lines.push([{ t: ` 🕐 本轮进行中…已经过 ${fmtDuration(Date.now() - node.turnStartAt)}`, fg: T.WARN, bold: true }]);
|
|
1276
|
+
mark(realIdx);
|
|
1277
|
+
}
|
|
1252
1278
|
sep();
|
|
1253
1279
|
break;
|
|
1254
1280
|
}
|
|
@@ -1412,6 +1438,10 @@ export class ChatView extends Widget {
|
|
|
1412
1438
|
if (node.turnMs != null) {
|
|
1413
1439
|
lines.push([{ t: ` 🕐 本轮回答总耗时 ${fmtDuration(node.turnMs)}`, fg: T.WARN, bold: true }]);
|
|
1414
1440
|
mark(realIdx);
|
|
1441
|
+
} else if (node.streaming && node.turnStartAt != null && realIdx === this.nodes.length - 1) {
|
|
1442
|
+
// deep-dive style live timer: the running turn ticks in real time
|
|
1443
|
+
lines.push([{ t: ` 🕐 本轮进行中…已经过 ${fmtDuration(Date.now() - node.turnStartAt)}`, fg: T.WARN, bold: true }]);
|
|
1444
|
+
mark(realIdx);
|
|
1415
1445
|
}
|
|
1416
1446
|
break;
|
|
1417
1447
|
}
|
|
@@ -1524,6 +1554,23 @@ export class ChatView extends Widget {
|
|
|
1524
1554
|
}
|
|
1525
1555
|
this.#renderTodos(screen);
|
|
1526
1556
|
this.input.render(screen);
|
|
1557
|
+
this.#renderCmdBar(screen);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/** / command candidate bar above the input (↑/↓ cycle, Tab completes). */
|
|
1561
|
+
#renderCmdBar(screen) {
|
|
1562
|
+
const inp = this.input;
|
|
1563
|
+
if (!inp.cmdOpen || inp.cmds.length === 0) return;
|
|
1564
|
+
const n = Math.min(inp.cmds.length, 6);
|
|
1565
|
+
const w = Math.min(this.view.w, 44);
|
|
1566
|
+
const y0 = Math.max(this.view.y, inp.y - n - 1);
|
|
1567
|
+
screen.fillRect(this.x, y0, this.x + w - 1, y0 + n - 1, " ", { bg: T.BG2 });
|
|
1568
|
+
for (let i = 0; i < n; i++) {
|
|
1569
|
+
const c = inp.cmds[i];
|
|
1570
|
+
const sel = i === inp.cmdIdx;
|
|
1571
|
+
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 });
|
|
1572
|
+
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 });
|
|
1573
|
+
}
|
|
1527
1574
|
}
|
|
1528
1575
|
|
|
1529
1576
|
/** Collapsible todo block between the view and the input (Shift+T toggles). */
|
|
@@ -1549,7 +1596,8 @@ export class ChatView extends Widget {
|
|
|
1549
1596
|
return true;
|
|
1550
1597
|
}
|
|
1551
1598
|
if (this.view.inside(ev.x, ev.y)) {
|
|
1552
|
-
|
|
1599
|
+
// clicks act, but never exit INSERT mode — Esc is the only way out
|
|
1600
|
+
if (this.app.focused !== this.app.chat?.input) this.app.focus(this);
|
|
1553
1601
|
// Welcome-screen mode click: select the preset under the cursor.
|
|
1554
1602
|
if (this.nodes.length === 0 && ev.kind === "press" && ev.button === 0) {
|
|
1555
1603
|
const id = this.welcomeModes[ev.y];
|
|
@@ -2823,7 +2871,7 @@ export class App {
|
|
|
2823
2871
|
// mouse routes by position (click = focus + dispatch)
|
|
2824
2872
|
if (this.mode !== "chat") {
|
|
2825
2873
|
if (ev.type === "mouse" && this.sidebarVisible && this.sidebar.inside(ev.x, ev.y)) {
|
|
2826
|
-
this.focus(this.sidebar);
|
|
2874
|
+
if (this.focused !== this.chat.input) this.focus(this.sidebar); // INSERT exits only via Esc
|
|
2827
2875
|
if (this.sidebar.onMouse(ev)) this.redraw();
|
|
2828
2876
|
return;
|
|
2829
2877
|
}
|
|
@@ -2870,7 +2918,7 @@ export class App {
|
|
|
2870
2918
|
return;
|
|
2871
2919
|
}
|
|
2872
2920
|
if (this.sidebarVisible && this.sidebar.inside(ev.x, ev.y)) {
|
|
2873
|
-
this.focus(this.sidebar);
|
|
2921
|
+
if (this.focused !== this.chat.input) this.focus(this.sidebar); // INSERT exits only via Esc
|
|
2874
2922
|
if (this.sidebar.onMouse(ev)) this.redraw();
|
|
2875
2923
|
} else if (this.chat.input.inside(ev.x, ev.y)) {
|
|
2876
2924
|
// mouse SELECTION in the input works regardless of mode; typing stays
|
|
@@ -2887,7 +2935,7 @@ export class App {
|
|
|
2887
2935
|
this.toast("按 i 进入输入(vim 式)");
|
|
2888
2936
|
}
|
|
2889
2937
|
} else if (this.chat.inside(ev.x, ev.y)) {
|
|
2890
|
-
this.focus(this.chat);
|
|
2938
|
+
if (this.focused !== this.chat.input) this.focus(this.chat); // INSERT exits only via Esc
|
|
2891
2939
|
if (this.chat.onMouse(ev)) this.redraw();
|
|
2892
2940
|
} else if (this.focused?.onMouse(ev)) {
|
|
2893
2941
|
this.redraw();
|
|
@@ -2901,6 +2949,9 @@ export class App {
|
|
|
2901
2949
|
// typing and Ctrl+J / Shift+Enter newlines behave like a normal editor.
|
|
2902
2950
|
if (this.focused === this.chat.input) {
|
|
2903
2951
|
if (ev.name === "escape") {
|
|
2952
|
+
// Esc closes the open / command candidate bar first, then exits
|
|
2953
|
+
// insert — Esc is the ONLY way out of insert mode.
|
|
2954
|
+
if (this.chat.input.cmdOpen) { this.chat.input.cmdOpen = false; this.redraw(); return; }
|
|
2904
2955
|
// one ESC press: interrupt a running turn AND leave insert —
|
|
2905
2956
|
// otherwise the key just exits insert (vim muscle memory).
|
|
2906
2957
|
const interrupted = this.#interruptIfRunning();
|
|
@@ -3056,9 +3107,14 @@ export class App {
|
|
|
3056
3107
|
this.renderFrame();
|
|
3057
3108
|
}
|
|
3058
3109
|
if (this.toastMsg && Date.now() > this.toastUntil) { this.toastMsg = null; this.dirty = true; }
|
|
3059
|
-
// the status-bar clock ticks once per second
|
|
3110
|
+
// the status-bar clock ticks once per second; while a turn runs the
|
|
3111
|
+
// chat's live timers (已经过 / 🕐) tick with it
|
|
3060
3112
|
const sec = Math.floor(Date.now() / 1000);
|
|
3061
|
-
if (sec !== this.lastSec) {
|
|
3113
|
+
if (sec !== this.lastSec) {
|
|
3114
|
+
this.lastSec = sec;
|
|
3115
|
+
if (this.chat.running) this.chat.queueRebuild();
|
|
3116
|
+
this.dirty = true;
|
|
3117
|
+
}
|
|
3062
3118
|
} catch (e) {
|
|
3063
3119
|
this.log("render error (kept running):", e);
|
|
3064
3120
|
// 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) {
|