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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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
@@ -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", "label"].includes(k)) continue;
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
- lines.push([{ t: ` ${k}: ${truncate(s, this.w - 14)}`, fg: K.DIM }]);
1404
- if (lines.length > 8) break;
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
- if (lines.length > this.h - 3) {
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 / Ctrl+J 换行,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
- const ih = this.input.height();
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
- // Open on a blank session at the launch directory (reusing an existing
1974
- // draft when available) so the blank-session homepage shows immediately
1975
- // instead of a fully empty chat area.
1976
- if (!this.currentSession) {
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
- const child = spawn("sh", ["-c", 'sleep 1; exec "$@"', "sh", ...argv], { detached: true, stdio: "inherit" });
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.insert(ev.text); return true; }
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
- for (const line of this.lines) {
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; }