dsh-neotui 0.1.4 → 0.1.6
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 +43 -8
- package/src/views.js +22 -11
- package/src/widgets.js +88 -6
package/package.json
CHANGED
package/src/panels.js
CHANGED
|
@@ -1218,6 +1218,7 @@ export class ControlPanel extends Widget {
|
|
|
1218
1218
|
["G", "滚动到底", () => { this.app.closeOverlay(); this.app.chat.view.scrollY = this.app.chat.view.maxScroll(); }],
|
|
1219
1219
|
["[", "上一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "[", text: "[", ctrl: false, alt: false, shift: false }); }],
|
|
1220
1220
|
["]", "下一提问的终点", () => { this.app.closeOverlay(); this.app.focus(this.app.chat); this.app.chat.onKey({ type: "key", name: "char", key: "]", text: "]", ctrl: false, alt: false, shift: false }); }],
|
|
1221
|
+
["Ctrl+L", "输入栏 展开/折叠", () => { this.app.closeOverlay(); this.app.focus(this.app.chat.input); this.app.chat.input.onKey({ type: "key", name: "char", key: "l", text: "l", ctrl: true, alt: false, shift: false }); }],
|
|
1221
1222
|
["Ctrl+P", "控制面板", () => { this.page = 1; this.sel = 0; this.app.redraw(); }],
|
|
1222
1223
|
["Ctrl+M", "切换模型", () => { this.app.overlay = buildModelPicker(this.app); }],
|
|
1223
1224
|
["Ctrl+T", "轨迹视图", () => { this.app.closeOverlay(); this.app.setMode("trajectory"); }],
|
|
@@ -1386,6 +1387,7 @@ export class JobsPanel extends Popup {
|
|
|
1386
1387
|
title: "后台任务(Ctrl+J 查看详情)", lines: [],
|
|
1387
1388
|
buttons: [{ label: "关闭(q)", action: "close" }],
|
|
1388
1389
|
onAction: () => app.closeOverlay(),
|
|
1390
|
+
scrollable: true, // expanded details scroll instead of being clipped
|
|
1389
1391
|
});
|
|
1390
1392
|
this.app = app;
|
|
1391
1393
|
this.jobs = jobs;
|
|
@@ -1394,19 +1396,44 @@ export class JobsPanel extends Popup {
|
|
|
1394
1396
|
this.rowOf = []; // rendered line → job index (-1 = chrome/detail)
|
|
1395
1397
|
this.rebuild();
|
|
1396
1398
|
}
|
|
1399
|
+
/** Width-aware character cut (no ellipsis — continuation chunks follow). */
|
|
1400
|
+
static #cutWidth(s, w) {
|
|
1401
|
+
let out = "", cw = 0;
|
|
1402
|
+
for (const ch of s) {
|
|
1403
|
+
const c = strWidth(ch);
|
|
1404
|
+
if (cw + c > w) break;
|
|
1405
|
+
out += ch; cw += c;
|
|
1406
|
+
}
|
|
1407
|
+
return out;
|
|
1408
|
+
}
|
|
1397
1409
|
#detailLines(j) {
|
|
1410
|
+
// Expanded = EVERYTHING: every field, full values — long commands wrap
|
|
1411
|
+
// across lines instead of being truncated (the web clips them; we don't).
|
|
1412
|
+
// `label` carries the full command, so it is shown here too (the header
|
|
1413
|
+
// row keeps only a 36-column preview of it).
|
|
1414
|
+
const names = { label: "命令", detail: "结果", startedAt: "开始于", finishedAt: "结束于" };
|
|
1398
1415
|
const lines = [];
|
|
1416
|
+
const budget = Math.max(20, this.w - 10);
|
|
1399
1417
|
for (const [k, v] of Object.entries(j)) {
|
|
1400
|
-
if (["status", "kind"
|
|
1418
|
+
if (["status", "kind"].includes(k)) continue;
|
|
1401
1419
|
const s = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v ?? "");
|
|
1402
1420
|
if (s === "") continue;
|
|
1403
|
-
|
|
1404
|
-
|
|
1421
|
+
const key = names[k] ?? k;
|
|
1422
|
+
let rest = s;
|
|
1423
|
+
let first = true;
|
|
1424
|
+
while (rest.length > 0 || first) {
|
|
1425
|
+
const head = first ? `${key}: ` : " ";
|
|
1426
|
+
const take = JobsPanel.#cutWidth(rest, budget - strWidth(head));
|
|
1427
|
+
lines.push([{ t: ` ${head}${take}`, fg: K.DIM }]);
|
|
1428
|
+
rest = rest.slice(take.length);
|
|
1429
|
+
first = false;
|
|
1430
|
+
if (lines.length > 200) break; // pathological safety, never reached in practice
|
|
1431
|
+
}
|
|
1405
1432
|
}
|
|
1406
1433
|
return lines;
|
|
1407
1434
|
}
|
|
1408
1435
|
rebuild() {
|
|
1409
|
-
let lines = [[{ t: " 这些任务在后台运行,不阻塞会话 — Enter/→/l 展开,←/h 折叠,q 关闭", fg: K.DIM }]];
|
|
1436
|
+
let lines = [[{ t: " 这些任务在后台运行,不阻塞会话 — Enter/→/l 展开,←/h 折叠,PgUp/PgDn 滚动,q 关闭", fg: K.DIM }]];
|
|
1410
1437
|
const rowOf = [-1];
|
|
1411
1438
|
const jobs = this.jobs;
|
|
1412
1439
|
if (jobs.length === 0) {
|
|
@@ -1427,11 +1454,19 @@ export class JobsPanel extends Popup {
|
|
|
1427
1454
|
rowOf.push(i);
|
|
1428
1455
|
if (open) for (const fl of this.#detailLines(j)) { lines.push(fl); rowOf.push(-1); }
|
|
1429
1456
|
}
|
|
1430
|
-
|
|
1431
|
-
lines = [...lines.slice(0, this.h - 4), [{ t: ` …共 ${jobs.length} 个任务`, fg: K.FAINT }]];
|
|
1432
|
-
}
|
|
1457
|
+
// keep the full content — the panel scrolls now (no more hard clip)
|
|
1433
1458
|
this.lines = lines;
|
|
1434
1459
|
this.rowOf = rowOf;
|
|
1460
|
+
this.#ensureVisible();
|
|
1461
|
+
}
|
|
1462
|
+
/** Keep the selected job row inside the scrollable viewport. */
|
|
1463
|
+
#ensureVisible() {
|
|
1464
|
+
const avail = this.contentRows();
|
|
1465
|
+
const row = this.rowOf.findIndex((r) => r === this.sel);
|
|
1466
|
+
if (row < 0) return;
|
|
1467
|
+
if (row < this.scrollY) this.scrollY = row;
|
|
1468
|
+
else if (row >= this.scrollY + avail) this.scrollY = row - avail + 1;
|
|
1469
|
+
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll()));
|
|
1435
1470
|
}
|
|
1436
1471
|
#toggle(i) {
|
|
1437
1472
|
if (this.expanded.has(i)) this.expanded.delete(i);
|
|
@@ -1459,7 +1494,7 @@ export class JobsPanel extends Popup {
|
|
|
1459
1494
|
return super.onKey(ev);
|
|
1460
1495
|
}
|
|
1461
1496
|
onMouse(ev) {
|
|
1462
|
-
if (super.onMouse(ev)) return true; // buttons
|
|
1497
|
+
if (super.onMouse(ev)) return true; // buttons + wheel scrolling
|
|
1463
1498
|
if (ev.kind === "press" && ev.button === 0) {
|
|
1464
1499
|
const i = ev.y - this.y - 1;
|
|
1465
1500
|
const jIdx = this.rowOf[i];
|
package/src/views.js
CHANGED
|
@@ -580,9 +580,9 @@ export class ChatView extends Widget {
|
|
|
580
580
|
});
|
|
581
581
|
this.input = new Input({
|
|
582
582
|
x: this.x, y: this.y + this.h - 2, w: this.w, h: 1,
|
|
583
|
-
multi: true, maxLines: 6,
|
|
583
|
+
multi: true, maxLines: 6, app: this.app,
|
|
584
584
|
bg: T.PANEL,
|
|
585
|
-
placeholder: "输入消息…(Shift+Enter
|
|
585
|
+
placeholder: "输入消息…(Shift+Enter/Ctrl+J 换行,Ctrl+L 展开,Enter 发送)",
|
|
586
586
|
onEnter: (v) => this.send(v),
|
|
587
587
|
onChange: () => this.inputChanged(),
|
|
588
588
|
});
|
|
@@ -731,10 +731,12 @@ export class ChatView extends Widget {
|
|
|
731
731
|
|
|
732
732
|
inputChanged() {
|
|
733
733
|
// Multi-line input grew/shrunk → reflow view vs input, keep the tail visible.
|
|
734
|
-
|
|
734
|
+
// The expanded (Ctrl+L) input may claim the whole window: clamp so the
|
|
735
|
+
// scroll view keeps at least one row.
|
|
736
|
+
const th = this.todoHeight();
|
|
737
|
+
const ih = Math.min(this.input.height(), Math.max(1, this.h - th - 2));
|
|
735
738
|
const prevIh = this.input.h;
|
|
736
739
|
this.input.h = ih;
|
|
737
|
-
const th = this.todoHeight();
|
|
738
740
|
this.view.h = this.h - ih - th - 1;
|
|
739
741
|
this.input.y = this.y + this.h - ih;
|
|
740
742
|
if (ih !== prevIh) this.app.layout();
|
|
@@ -743,9 +745,9 @@ export class ChatView extends Widget {
|
|
|
743
745
|
|
|
744
746
|
resize(x, y, w, h) {
|
|
745
747
|
this.x = x; this.y = y; this.w = w; this.h = h;
|
|
746
|
-
const ih = this.input.height();
|
|
747
|
-
this.input.h = ih;
|
|
748
748
|
const th = this.todoHeight();
|
|
749
|
+
const ih = Math.min(this.input.height(), Math.max(1, h - th - 2));
|
|
750
|
+
this.input.h = ih;
|
|
749
751
|
this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h - ih - th - 1;
|
|
750
752
|
this.input.x = x; this.input.y = y + h - ih; this.input.w = w;
|
|
751
753
|
this.cache.clear();
|
|
@@ -1970,10 +1972,16 @@ export class App {
|
|
|
1970
1972
|
this.model = host.model ?? "";
|
|
1971
1973
|
} catch (e) { this.log(`[app] host.describe: ${e.message}`); }
|
|
1972
1974
|
await this.refreshSessions();
|
|
1973
|
-
//
|
|
1974
|
-
//
|
|
1975
|
-
//
|
|
1976
|
-
|
|
1975
|
+
// A /restart handoff carries the session to reopen: resume it instead of
|
|
1976
|
+
// minting a fresh blank session (which was leaking a stray "new session"
|
|
1977
|
+
// into 未分组 on every restart).
|
|
1978
|
+
const resumeId = process.env.DSH_TUI_RESUME_SESSION;
|
|
1979
|
+
if (resumeId && this.sessions.some((s) => s.sessionId === resumeId)) {
|
|
1980
|
+
await this.openSession(resumeId);
|
|
1981
|
+
} else if (!this.currentSession) {
|
|
1982
|
+
// Open on a blank session at the launch directory (reusing an existing
|
|
1983
|
+
// draft when available) so the blank-session homepage shows immediately
|
|
1984
|
+
// instead of a fully empty chat area.
|
|
1977
1985
|
await this.newSessionIn(null);
|
|
1978
1986
|
}
|
|
1979
1987
|
this.api.connectMux();
|
|
@@ -2488,7 +2496,10 @@ export class App {
|
|
|
2488
2496
|
try {
|
|
2489
2497
|
const { spawn } = await import("node:child_process");
|
|
2490
2498
|
const argv = process.argv.slice(1);
|
|
2491
|
-
|
|
2499
|
+
// hand the CURRENT session to the new instance so it reopens it instead
|
|
2500
|
+
// of minting a fresh blank session (the "strange new session" in 未分组)
|
|
2501
|
+
const env = { ...process.env, DSH_TUI_RESUME_SESSION: this.currentSession ?? "" };
|
|
2502
|
+
const child = spawn("sh", ["-c", 'sleep 1; exec "$@"', "sh", ...argv], { detached: true, stdio: "inherit", env });
|
|
2492
2503
|
child.unref();
|
|
2493
2504
|
} catch (e) {
|
|
2494
2505
|
this.toast(`重启失败: ${e.message}(请手动重启)`);
|
package/src/widgets.js
CHANGED
|
@@ -253,6 +253,11 @@ export class Input extends Widget {
|
|
|
253
253
|
this.border = opts.border ?? T.BORDER2;
|
|
254
254
|
this.multi = opts.multi ?? false;
|
|
255
255
|
this.maxLines = opts.maxLines ?? 6;
|
|
256
|
+
this.baseMaxLines = this.maxLines; // restored when the input collapses
|
|
257
|
+
this.expanded = false; // Ctrl+L: drop the line cap and fill the window
|
|
258
|
+
this.app = opts.app ?? null;
|
|
259
|
+
this.pendingPaste = null; // large-paste stage 1: held-back clipboard text
|
|
260
|
+
this.pendingPlaceholder = null; // the "[已复制 N 行内容]" marker currently shown
|
|
256
261
|
this.onChange = opts.onChange ?? null;
|
|
257
262
|
this.allowEmptyEnter = opts.allowEmptyEnter ?? false;
|
|
258
263
|
this.history = [];
|
|
@@ -313,6 +318,7 @@ export class Input extends Widget {
|
|
|
313
318
|
/** Rendered height: 1, or wrapped rows capped at maxLines when multi. */
|
|
314
319
|
height() { return this.multi ? Math.max(1, Math.min(this.maxLines, this.#visualRows().length)) : 1; }
|
|
315
320
|
setValue(v, opts = {}) {
|
|
321
|
+
this.#touch();
|
|
316
322
|
this.value = String(v);
|
|
317
323
|
this.cursor = this.#cps().length;
|
|
318
324
|
this.selectAll = Boolean(opts.select); // first insert/text replaces the whole value
|
|
@@ -416,15 +422,45 @@ export class Input extends Widget {
|
|
|
416
422
|
}
|
|
417
423
|
return false;
|
|
418
424
|
}
|
|
425
|
+
/** Any manual edit cancels the held-back paste (the placeholder stays as
|
|
426
|
+
* ordinary text). */
|
|
427
|
+
#touch() { this.pendingPaste = null; this.pendingPlaceholder = null; }
|
|
428
|
+
/** Claude-Code-style two-stage paste: the first Ctrl+Shift+V of a large
|
|
429
|
+
* clipboard shows a "[已复制 N 行内容]" placeholder; pasting the same
|
|
430
|
+
* content again inserts it in full, like a normal paste. */
|
|
431
|
+
#paste(text) {
|
|
432
|
+
text = String(text ?? "");
|
|
433
|
+
const large = text.includes("\n") || text.length > 300;
|
|
434
|
+
if (large) {
|
|
435
|
+
if (this.pendingPaste && this.pendingPaste.text === text) {
|
|
436
|
+
const full = this.pendingPaste.text;
|
|
437
|
+
const placeholder = this.pendingPlaceholder;
|
|
438
|
+
this.#touch();
|
|
439
|
+
if (this.value === placeholder) this.setValue(full);
|
|
440
|
+
else this.insert(full);
|
|
441
|
+
this.app?.toast?.("已粘贴完整内容");
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
const lines = text.split("\n").length;
|
|
445
|
+
this.pendingPaste = { text };
|
|
446
|
+
this.pendingPlaceholder = `[已复制 ${lines} 行内容]`;
|
|
447
|
+
this.insert(this.pendingPlaceholder);
|
|
448
|
+
this.app?.toast?.("再次 Ctrl+Shift+V 粘贴完整内容(Ctrl+L 展开输入栏)");
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
this.#touch();
|
|
452
|
+
this.insert(text);
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
419
455
|
onKey(ev) {
|
|
420
|
-
if (ev.type === "text") { this
|
|
456
|
+
if (ev.type === "text") { return this.#paste(ev.text ?? ""); }
|
|
421
457
|
if (ev.type !== "key") return false;
|
|
422
458
|
switch (ev.name) {
|
|
423
459
|
case "backspace":
|
|
424
|
-
if (this.cursor > 0) { this.#deleteAt(this.cursor - 1); this.cursor--; this.onChange?.(); }
|
|
460
|
+
if (this.cursor > 0) { this.#touch(); this.#deleteAt(this.cursor - 1); this.cursor--; this.onChange?.(); }
|
|
425
461
|
return true;
|
|
426
462
|
case "delete":
|
|
427
|
-
if (this.cursor < this.#cps().length) { this.#deleteAt(this.cursor); this.onChange?.(); }
|
|
463
|
+
if (this.cursor < this.#cps().length) { this.#touch(); this.#deleteAt(this.cursor); this.onChange?.(); }
|
|
428
464
|
return true;
|
|
429
465
|
case "left": this.selectAll = false; this.cursor = Math.max(0, this.cursor - 1); return true;
|
|
430
466
|
case "right": this.selectAll = false; this.cursor = Math.min(this.#cps().length, this.cursor + 1); return true;
|
|
@@ -463,8 +499,17 @@ export class Input extends Widget {
|
|
|
463
499
|
if (ev.ctrl) {
|
|
464
500
|
switch (ev.key) {
|
|
465
501
|
case "j": if (this.multi) { this.insert("\n"); return true; } return false;
|
|
466
|
-
case "u": this.value = this.#cps().slice(this.cursor).join(""); this.cursor = 0; this.onChange?.(); return true;
|
|
467
|
-
case "k": this.value = this.#cps().slice(0, this.cursor).join(""); this.onChange?.(); return true;
|
|
502
|
+
case "u": this.#touch(); this.value = this.#cps().slice(this.cursor).join(""); this.cursor = 0; this.onChange?.(); return true;
|
|
503
|
+
case "k": this.#touch(); this.value = this.#cps().slice(0, this.cursor).join(""); this.onChange?.(); return true;
|
|
504
|
+
case "l": {
|
|
505
|
+
// expand/collapse the input: expanded drops the 6-line cap and
|
|
506
|
+
// lets the editor fill the window above the input
|
|
507
|
+
this.expanded = !this.expanded;
|
|
508
|
+
this.maxLines = this.expanded ? 1000 : this.baseMaxLines;
|
|
509
|
+
this.onChange?.();
|
|
510
|
+
this.app?.toast?.(this.expanded ? "输入栏已展开(Ctrl+L 折叠)" : "输入栏已折叠(Ctrl+L 展开)");
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
468
513
|
case "a": this.cursor = 0; return true;
|
|
469
514
|
case "e": this.cursor = this.#cps().length; return true;
|
|
470
515
|
case "w": {
|
|
@@ -481,6 +526,7 @@ export class Input extends Widget {
|
|
|
481
526
|
}
|
|
482
527
|
return false;
|
|
483
528
|
}
|
|
529
|
+
this.#touch();
|
|
484
530
|
this.insert(ev.text);
|
|
485
531
|
return true;
|
|
486
532
|
case "enter":
|
|
@@ -510,12 +556,23 @@ export class Popup extends Widget {
|
|
|
510
556
|
this.btnIdx = 0;
|
|
511
557
|
this.onAction = opts.onAction ?? null;
|
|
512
558
|
this.fg = opts.fg;
|
|
559
|
+
// opt-in vertical scrolling: content longer than the box scrolls with
|
|
560
|
+
// PgUp/PgDn/up/down/wheel instead of being clipped away
|
|
561
|
+
this.scrollable = opts.scrollable ?? false;
|
|
562
|
+
this.scrollY = 0;
|
|
563
|
+
}
|
|
564
|
+
contentRows() {
|
|
565
|
+
// rows between the border and the buttons row
|
|
566
|
+
return this.h - 2 - (this.buttons.length ? 1 : 0);
|
|
567
|
+
}
|
|
568
|
+
maxScroll() {
|
|
569
|
+
return Math.max(0, this.lines.length - this.contentRows());
|
|
513
570
|
}
|
|
514
571
|
render(screen) {
|
|
515
572
|
screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG2 });
|
|
516
573
|
screen.box(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, { fg: this.fg ?? 0x67b7ff, bg: T.BG2 }, this.title);
|
|
517
574
|
let ly = this.y + 1;
|
|
518
|
-
|
|
575
|
+
const draw = (line) => {
|
|
519
576
|
if (Array.isArray(line)) {
|
|
520
577
|
// styled line: array of segments
|
|
521
578
|
let px = this.x + 2;
|
|
@@ -532,6 +589,23 @@ export class Popup extends Widget {
|
|
|
532
589
|
screen.text(this.x + 2, ly, truncate(String(line), this.w - 4), { fg: T.TXT, bg: T.BG2 });
|
|
533
590
|
}
|
|
534
591
|
ly++;
|
|
592
|
+
};
|
|
593
|
+
if (this.scrollable) {
|
|
594
|
+
const avail = this.contentRows();
|
|
595
|
+
this.scrollY = Math.max(0, Math.min(this.scrollY, this.maxScroll()));
|
|
596
|
+
const start = this.scrollY;
|
|
597
|
+
for (let i = 0; i < avail; i++) {
|
|
598
|
+
const line = this.lines[start + i];
|
|
599
|
+
if (line === undefined) break;
|
|
600
|
+
draw(line);
|
|
601
|
+
}
|
|
602
|
+
if (this.maxScroll() > 0) {
|
|
603
|
+
// "↑N" / "↓N" overflow indicators on the border row
|
|
604
|
+
if (this.scrollY > 0) screen.text(this.x + this.w - 6, this.y, ` ↑${this.scrollY}`, { fg: T.ACCENT, bg: T.BG2 });
|
|
605
|
+
if (this.scrollY < this.maxScroll()) screen.text(this.x + this.w - 8, this.y + this.h - 1, ` ↓${this.maxScroll() - this.scrollY}`, { fg: T.ACCENT, bg: T.BG2 });
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
for (const line of this.lines) draw(line);
|
|
535
609
|
}
|
|
536
610
|
// buttons on last row
|
|
537
611
|
if (this.buttons.length) {
|
|
@@ -550,6 +624,10 @@ export class Popup extends Widget {
|
|
|
550
624
|
}
|
|
551
625
|
}
|
|
552
626
|
onMouse(ev) {
|
|
627
|
+
if (this.scrollable) {
|
|
628
|
+
if (ev.kind === "wheel-up") { this.scrollY = Math.max(0, this.scrollY - 3); return true; }
|
|
629
|
+
if (ev.kind === "wheel-down") { this.scrollY = Math.min(this.maxScroll(), this.scrollY + 3); return true; }
|
|
630
|
+
}
|
|
553
631
|
if (ev.kind === "press" && ev.button === 0 && this.buttons.length) {
|
|
554
632
|
let bx = this.x + 2;
|
|
555
633
|
const by = this.y + this.h - 2;
|
|
@@ -566,6 +644,10 @@ export class Popup extends Widget {
|
|
|
566
644
|
}
|
|
567
645
|
onKey(ev) {
|
|
568
646
|
if (ev.type !== "key") return false;
|
|
647
|
+
if (this.scrollable) {
|
|
648
|
+
if (ev.name === "pgup") { this.scrollY = Math.max(0, this.scrollY - this.contentRows()); return true; }
|
|
649
|
+
if (ev.name === "pgdn") { this.scrollY = Math.min(this.maxScroll(), this.scrollY + this.contentRows()); return true; }
|
|
650
|
+
}
|
|
569
651
|
if (ev.name === "escape") { this.onAction?.({ label: "__cancel__", action: "__cancel__" }, -1); return true; }
|
|
570
652
|
if (ev.name === "tab") { this.btnIdx = (this.btnIdx + 1) % Math.max(1, this.buttons.length); return true; }
|
|
571
653
|
if (ev.name === "left") { this.btnIdx = Math.max(0, this.btnIdx - 1); return true; }
|