dsh-neotui 0.1.5 → 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.5",
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"); }],
@@ -1408,16 +1409,20 @@ export class JobsPanel extends Popup {
1408
1409
  #detailLines(j) {
1409
1410
  // Expanded = EVERYTHING: every field, full values — long commands wrap
1410
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: "结束于" };
1411
1415
  const lines = [];
1412
1416
  const budget = Math.max(20, this.w - 10);
1413
1417
  for (const [k, v] of Object.entries(j)) {
1414
- if (["status", "kind", "label"].includes(k)) continue;
1418
+ if (["status", "kind"].includes(k)) continue;
1415
1419
  const s = v !== null && typeof v === "object" ? JSON.stringify(v) : String(v ?? "");
1416
1420
  if (s === "") continue;
1421
+ const key = names[k] ?? k;
1417
1422
  let rest = s;
1418
1423
  let first = true;
1419
1424
  while (rest.length > 0 || first) {
1420
- const head = first ? `${k}: ` : " ";
1425
+ const head = first ? `${key}: ` : " ";
1421
1426
  const take = JobsPanel.#cutWidth(rest, budget - strWidth(head));
1422
1427
  lines.push([{ t: ` ${head}${take}`, fg: K.DIM }]);
1423
1428
  rest = rest.slice(take.length);
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":