dsh-neotui 0.4.3 → 0.4.4

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/src/panels.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // timeline, jobs/goal panels, and the terminal image viewer (kitty graphics
3
3
  // protocol with external-viewer / chafa fallbacks).
4
4
  import { Widget, ScrollView, Input, Popup, wrapIndex } from "./widgets.js";
5
- import { strWidth, truncate, pad, graphemes, graphemeWidth, bytesLabel, prettyJson } from "./text.js";
5
+ import { strWidth, truncate, pad, graphemes, graphemeWidth, bytesLabel, prettyJson, fmtDuration } from "./text.js";
6
6
  import { renderMd, C } from "./md.js";
7
7
  import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
@@ -1361,7 +1361,12 @@ export class TrajectoryPanel extends Widget {
1361
1361
  const flash = this.flashKey === this.stepKey(step) && Date.now() < this.flashUntil;
1362
1362
  const rowBg = flash ? T.ACCENT : bg;
1363
1363
  const selected = this.stepKey(step) === this.selectedStepKey;
1364
- const label = `${selected ? "=>" : " "} ${open ? "▾" : "▸"} step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary} ${open ? "[折叠]" : "[展开]"}`;
1364
+ const srcKind = (step.events ?? []).find((e) => e.type === "user/message")?.data?.source?.kind;
1365
+ let summary2 = summary;
1366
+ let srcPrefix = "";
1367
+ const srcLabel = srcKind ? sourceBadge(srcKind) : "";
1368
+ if (srcKind === "plugin" || srcKind === "goal") { srcPrefix = `${sourceBadge(srcKind)} `; summary2 = `${srcKind === "goal" ? `目标轮 ${step.events.find((e) => e.type === "user/message")?.data?.source?.round ?? "?"}` : "注入上下文"} · ${summary}`; }
1369
+ const label = `${selected ? "=>" : " "} ${open ? "▾" : "▸"} ${srcPrefix}step ${String(step.step).padStart(3)} ${pad(dur, 8)} ${summary2} ${open ? "[折叠]" : "[展开]"}`;
1365
1370
  const segs = [{ t: label, fg: flash ? T.SELFG : K.TXT, bg: rowBg, bold: true }];
1366
1371
  const fill = w - strWidth(label);
1367
1372
  if (fill > 0) segs.push({ t: " ".repeat(fill), bg: rowBg });
@@ -1716,7 +1721,10 @@ export class ControlPanel extends Widget {
1716
1721
  const h = Math.min(24, app.screen.h - 4);
1717
1722
  super({ x: Math.floor((app.screen.w - w) / 2), y: Math.floor((app.screen.h - h) / 2), w, h });
1718
1723
  this.app = app;
1719
- this.pages = ["快捷键", "命令", "设置", "插件"];
1724
+ // Prefix-key page (leader, nvim-style EDITING logic only): a one-key
1725
+ // command page as the START page; the keys fire only while this page is
1726
+ // shown, so it never leaks into normal mode.
1727
+ this.pages = ["前缀", "快捷键", "命令", "设置", "插件"];
1720
1728
  this.page = startPage;
1721
1729
  this.pluginQuery = ""; this.pluginFilter = false;
1722
1730
  this.sel = 0;
@@ -1752,9 +1760,14 @@ export class ControlPanel extends Widget {
1752
1760
  row("think","思考块 展开/折叠"),row("tools","工具块 展开/折叠"),row("insert","进入输入"),row("leaveInsert","退出输入"),row("sessionFilter","跨会话搜索"),row("newSession","新建会话"),row("top","跳到首个正文块"),row("bottom","跳到最新正文块"),row("prevQuestion","上一提问的终点"),row("nextQuestion","下一提问的终点"),row("expandInput","输入栏 展开/折叠"),row("copyInput","复制输入栏选区"),row("undoInput","输入撤销"),row("redoInput","输入重做"),row("insertFilePicker","输入中打开文件选择器"),row("pasteImage","输入中粘贴剪贴板图片"),row("copySelection","复制正文选区"),row("addWorkspace","添加工作区"),row("commandPalette","命令面板"),row("modePicker","模式选择器"),row("themePicker","配色主题选择器"),row("logoPicker","欢迎页 logo(预设/自定义/关闭)"),row("quitDouble","双击 Ctrl+C 退出"),row("panel","控制面板"),row("model","切换模型"),row("trajectory","轨迹视图"),row("panePrev","pane 焦点 ← 上一窗格"),row("paneNext","pane 焦点 → 下一窗格"),row("permissionRotate","权限模式轮换"),row("workspace","工作区"),row("settings","设置"),row("subagent","子代理"),row("skills","技能"),row("goal","目标"),row("jobs","后台任务"),row("queue","后台队列"),row("busyEnter","运行中 Enter 策略"),row("attachments","附件管理"),row("stepJump","步骤转跳"),row("sidebar","侧栏显示/隐藏"),row("editConfig","编辑配置文件(默认编辑器)"),row("quit","退出"),
1753
1761
  ];
1754
1762
  }
1763
+ prefixRows() {
1764
+ // 前缀页 = 引擎同一张表的可视化(which-key);键路由走同一引擎。
1765
+ return this.app.prefixRowsData();
1766
+ }
1755
1767
  items() {
1756
- if (this.page === 0) return this.shortcutItems();
1757
- if (this.page === 1) {
1768
+ if (this.page === 0) return this.prefixRows().map(([k, d, hint, action]) => [`${k} ${d}`, hint, action]);
1769
+ if (this.page === 1) return this.shortcutItems();
1770
+ if (this.page === 2) {
1758
1771
  return this.commands.map((c) => [
1759
1772
  `/${c.name}${c.input?.hint ? " " + c.input.hint : ""}`,
1760
1773
  c.description,
@@ -1766,7 +1779,7 @@ export class ControlPanel extends Widget {
1766
1779
  },
1767
1780
  ]);
1768
1781
  }
1769
- if (this.page === 2) {
1782
+ if (this.page === 3) {
1770
1783
  return [
1771
1784
  ["模型管理(含思考强度)", "切换模型并选择思考强度", () => { this.app.overlay = buildModelPicker(this.app); }],
1772
1785
  ["模式(Agent 预设)", "标准 / PTC / 极简 / 创造", () => { this.app.overlay = buildModePicker(this.app); this.app.redraw(); }],
@@ -1778,7 +1791,7 @@ export class ControlPanel extends Widget {
1778
1791
  ["复制会话 ID", "", () => this.app.copyText(this.app.currentSession ?? "")],
1779
1792
  ];
1780
1793
  }
1781
- if (this.plugins) {
1794
+ if (this.page === 4 && this.plugins) {
1782
1795
  const q=this.pluginQuery.toLowerCase();
1783
1796
  return this.plugins.filter((pl)=>!q||`${pl.moduleName} ${pl.fiberPhase??""}`.toLowerCase().includes(q)).map((pl) => [`${pl.enabled ? "●" : "○"} ${pl.moduleName}`, pl.fiberPhase ?? "", null]);
1784
1797
  }
@@ -1801,7 +1814,8 @@ export class ControlPanel extends Widget {
1801
1814
  if (this.sel < this.scroll) this.scroll = this.sel;
1802
1815
  else if (this.sel >= this.scroll + visible) this.scroll = this.sel - visible + 1;
1803
1816
  this.scroll = Math.max(0, Math.min(Math.max(0, items.length - visible), this.scroll));
1804
- if(this.page===0){s.text(this.x+2,this.y+1,"MODE",{fg:T.PURPLE,bg:T.PANEL,attrs:1});s.text(this.x+13,this.y+1,"KEY1",{fg:T.ACCENT,bg:T.PANEL,attrs:1});s.text(this.x+31,this.y+1,"KEY2",{fg:T.ACCENT,bg:T.PANEL,attrs:1});s.text(this.x+49,this.y+1,"FUNCTION",{fg:T.OK,bg:T.PANEL,attrs:1});}
1817
+ if(this.page===0){s.text(this.x+2,this.y+1,"KEY",{fg:T.PURPLE,bg:T.PANEL,attrs:1});s.text(this.x+13,this.y+1,"ACTION",{fg:T.OK,bg:T.PANEL,attrs:1});s.text(this.x+this.w-24,this.y+1,"等价命令",{fg:T.ACCENT,bg:T.PANEL,attrs:1});}
1818
+ if(this.page===1){s.text(this.x+2,this.y+1,"MODE",{fg:T.PURPLE,bg:T.PANEL,attrs:1});s.text(this.x+13,this.y+1,"KEY1",{fg:T.ACCENT,bg:T.PANEL,attrs:1});s.text(this.x+31,this.y+1,"KEY2",{fg:T.ACCENT,bg:T.PANEL,attrs:1});s.text(this.x+49,this.y+1,"FUNCTION",{fg:T.OK,bg:T.PANEL,attrs:1});}
1805
1819
  if(this.page===3&&this.pluginFilter){s.text(this.x+2,this.y+1,`/ ${this.pluginQuery}`,{fg:T.ACCENT,bg:T.PANEL,attrs:1});}
1806
1820
  for (let i = 0; i < visible; i++) {
1807
1821
  const idx = this.scroll + i;
@@ -1810,10 +1824,11 @@ export class ControlPanel extends Widget {
1810
1824
  const sel = idx === this.sel;
1811
1825
  s.fillRect(this.x + 1, this.y + 2 + i, this.x + this.w - 2, this.y + 2 + i, " ", { bg: sel ? T.MENUSEL : T.PANEL });
1812
1826
  const label = it[0];
1813
- if(this.page===0){const [mode,key1,key2]=label.split("\t");s.text(this.x+2,this.y+2+i,pad(mode,9),{fg:T.PURPLE,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+13,this.y+2+i,pad(truncate(key1,16),17),{fg:T.ACCENT,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+31,this.y+2+i,pad(truncate(key2,16),17),{fg:T.ACCENT,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+49,this.y+2+i,truncate(it[1],this.w-52),{fg:T.OK,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});}
1827
+ if(this.page===0){const [k, d2] = label.split(" ", 2);s.text(this.x+2,this.y+2+i,pad(k,9),{fg:T.PURPLE,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+13,this.y+2+i,pad(truncate(d2, 30), 32),{fg:T.OK,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+this.w-24,this.y+2+i,truncate(it[1]??"",22),{fg:T.ACCENT,bg:sel?T.MENUSEL:T.PANEL});}
1828
+ else if(this.page===1){const [mode,key1,key2]=label.split("\t");s.text(this.x+2,this.y+2+i,pad(mode,9),{fg:T.PURPLE,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+13,this.y+2+i,pad(truncate(key1,16),17),{fg:T.ACCENT,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+31,this.y+2+i,pad(truncate(key2,16),17),{fg:T.ACCENT,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});s.text(this.x+49,this.y+2+i,truncate(it[1],this.w-52),{fg:T.OK,bg:sel?T.MENUSEL:T.PANEL,attrs:sel?1:0});}
1814
1829
  else{s.text(this.x + 2, this.y + 2 + i, truncate(label, this.w - 34), { fg: sel ? T.BOLD : T.TXT, bg: sel ? T.MENUSEL : T.PANEL, attrs: sel ? 1 : 0 });if (it[1]) s.text(this.x + this.w - 30, this.y + 2 + i, truncate(it[1], 28), { fg: T.FAINT, bg: sel ? T.MENUSEL : T.PANEL });}
1815
1830
  }
1816
- s.text(this.x + 2, this.y + this.h - 1, this.page===0?"↑↓ 选择 · Enter 编辑 · Shift+Tab 轮换模式 · Alt+Enter 恢复默认 · Esc 关闭":this.page===3?`/ 筛选插件 · Ctrl+/ 清除 · ↑↓ 选择 · Esc 关闭${this.pluginQuery?` · ${this.pluginQuery}`:""}`:"↑↓ 选择 · Enter 执行 · Esc 关闭", { fg: T.FAINT });
1831
+ s.text(this.x + 2, this.y + this.h - 1, this.page===0?"按行首键执行(同 NORMAL 前缀层) · ↑↓/Enter 也可以 · Esc 关闭":this.page===1?"↑↓ 选择 · Enter 编辑 · Shift+Tab 轮换模式 · Alt+Enter 恢复默认 · Esc 关闭":this.page===3?`/ 筛选插件 · Ctrl+/ 清除 · ↑↓ 选择 · Esc 关闭${this.pluginQuery?` · ${this.pluginQuery}`:""}`:"↑↓ 选择 · Enter 执行 · Esc 关闭", { fg: T.FAINT });
1817
1832
  }
1818
1833
  onKey(ev) {
1819
1834
  if(this.page===3&&this.pluginFilter){if(ev.type==="text"){this.pluginQuery+=ev.text;this.sel=0;return true;}if(ev.type==="key"&&ev.name==="backspace"){this.pluginQuery=this.pluginQuery.slice(0,-1);this.sel=0;return true;}if(ev.type==="key"&&ev.name==="enter"){this.pluginFilter=false;return true;}if(ev.type==="key"&&ev.ctrl&&(ev.key==="/"||ev.key==="_")){this.pluginFilter=false;this.pluginQuery="";return true;}}
@@ -1822,6 +1837,13 @@ export class ControlPanel extends Widget {
1822
1837
  if(this.page===3&&ev.name==="char"&&ev.key==="/"){this.pluginFilter=true;this.pluginQuery="";return true;}
1823
1838
  if(this.page===3&&ev.ctrl&&(ev.key==="/"||ev.key==="_")){this.pluginQuery="";return true;}
1824
1839
  if (ev.name === "escape") { this.app.closeOverlay(); return true; }
1840
+ // Prefix page: a plain one-key press fires the mapped command — but ONLY
1841
+ // while the prefix page itself is on screen (leader-style EDITING logic).
1842
+ if (this.page === 0 && ev.name === "char" && !ev.ctrl && !ev.alt) {
1843
+ const key = ev.key ?? "";
1844
+ const row = this.prefixRows().find(([k]) => k === key || (k === key.toUpperCase() && ev.shift && k !== key));
1845
+ if (row) { row[3](); this.app.redraw(); return true; }
1846
+ }
1825
1847
  if(this.page===0&&ev.name==="backtab"){const it=this.items()[this.sel],id=it?.[3];if(id){const b=keyBindings()[id],modes=["normal","insert","all"],mode=modes[(modes.indexOf(b.mode)+1)%3];setKeyBinding(id,{...b,mode});this.app.toast(`适用模式: ${mode.toUpperCase()}`);}return true;}
1826
1848
  if(this.page===0&&ev.alt&&ev.name==="enter"){const id=this.items()[this.sel]?.[3];if(id){resetKeyBinding(id);this.app.toast("已恢复默认快捷键");}return true;}
1827
1849
  if (ev.name === "tab" || ev.name === "right") {
@@ -2278,7 +2300,7 @@ export class QueuePanel extends Popup {
2278
2300
 
2279
2301
  export class GoalPanel extends Popup {
2280
2302
  constructor(app) {
2281
- super({ x: 4, y: 2, w: Math.max(24, Math.min(84, app.screen.w - 8)), h: Math.max(10, Math.min(28, app.screen.h - 4)), title: "目标与任务", lines: [], buttons: [], scrollable: true });
2303
+ super({ x: 4, y: 2, w: Math.max(24, Math.min(96, app.screen.w - 8)), h: Math.max(10, Math.min(app.screen.h - 4, 34)), title: "目标与任务", lines: [], buttons: [], scrollable: true });
2282
2304
  this.app = app; this.busy = false; this.actionSel = 0; this.actions = []; this.actionRows = []; this.rebuild();
2283
2305
  }
2284
2306
  /** Called by App when a live goal/todo projection arrives while open. */
@@ -2292,6 +2314,14 @@ export class GoalPanel extends Popup {
2292
2314
  else {
2293
2315
  lines.push([{ t: ` 目标: ${goal.objective ?? goal}`, fg: K.TXT, bold: true }]);
2294
2316
  lines.push([{ t: ` 阶段: ${goal.phase ?? "active"} · 轮次 ${this.app.goalData?.roundsStarted ?? 0}/${goal.maxGoalRounds ?? "∞"} · 修订 ${goal.revision ?? "?"}`, fg: K.DIM }]);
2317
+ const rounds = (this.app.chat?.nodes ?? []).filter((n) => n?.kind === "goal-round" || n?.source?.kind === "goal");
2318
+ if (rounds.length) {
2319
+ lines.push([{ t: "" }, { t: " ◆ 目标延续轮(自动进入)", fg: K.PURPLE, bold: true }]);
2320
+ for (const r of rounds) {
2321
+ const text = (r.blocks ?? []).filter((b) => b?.type === "text").map((b) => b.text ?? "").join("").replace(/\s+/g, " ").trim();
2322
+ lines.push([{ t: ` ◆ ${r.source?.round ?? "?"}轮 · ${truncate(text || r.id || "(无文本)", Math.max(12, this.w - 12))}`, fg: K.DIM }]);
2323
+ }
2324
+ }
2295
2325
  if (goal.blockedReason?.message) lines.push([{ t: ` 阻塞: ${goal.blockedReason.message}`, fg: K.ERR }]);
2296
2326
  }
2297
2327
  this.actions = goal ? [
@@ -4742,3 +4772,381 @@ export class SkillsPanel extends Widget {
4742
4772
  return false;
4743
4773
  }
4744
4774
  }
4775
+
4776
+ /** Open a scrollable detail Popup over `returnTo` (restored as app.overlay on
4777
+ * Escape/q). `lines` may be raw string lines or styled segment arrays. */
4778
+ function openDetailPopup(app, returnTo, title, lines) {
4779
+ const w = Math.max(40, Math.min(110, app.screen.w - 4));
4780
+ const h = Math.max(12, Math.min(36, app.screen.h - 4));
4781
+ const pop = new Popup({
4782
+ x: Math.max(0, Math.floor((app.screen.w - w) / 2)),
4783
+ y: Math.max(0, Math.floor((app.screen.h - h) / 2)),
4784
+ w, h, title, lines: [], buttons: [], scrollable: true,
4785
+ onAction: () => { app.overlay = returnTo; app.redraw(); },
4786
+ });
4787
+ for (const line of (lines ?? [])) {
4788
+ pop.lines.push(Array.isArray(line) ? line.map((seg) => ({ ...seg })) : [{ t: String(line), fg: K.TXT }]);
4789
+ }
4790
+ const base = pop.onKey.bind(pop);
4791
+ pop.onKey = (ev) => {
4792
+ if (ev.type === "key" && (ev.name === "escape" || (ev.name === "char" && ev.key === "q" && !ev.ctrl))) {
4793
+ app.overlay = returnTo; app.redraw(); return true;
4794
+ }
4795
+ return base(ev);
4796
+ };
4797
+ app.overlay = pop;
4798
+ app.redraw();
4799
+ return pop;
4800
+ }
4801
+
4802
+ /** Pretty, bounded JSON-ish summary of an entry for the detail popup. */
4803
+ function detailText(value) {
4804
+ const s = prettyJson(JSON.stringify(value));
4805
+ return (s ?? JSON.stringify(value, null, 2) ?? String(value)).slice(0, 6000);
4806
+ }
4807
+
4808
+ /** Message-source badge: ● direct prompt (user) · ◇ injected context
4809
+ * (plugin: cron/inject/skill…) · ◆ goal continuation round · ● unknown. */
4810
+ export function sourceBadge(kind) {
4811
+ return kind === "user" ? "●" : kind === "plugin" ? "◇" : kind === "goal" ? "◆" : "●";
4812
+ }
4813
+ export function sourceGroup(kind) {
4814
+ return kind === "user" ? "排队提问" : kind === "plugin" ? "注入上下文" : kind === "goal" ? "目标延续" : "排队提问";
4815
+ }
4816
+ /** 徽标图例(页脚小字)。 */
4817
+ export function sourceLegend() {
4818
+ return "● 提问 · ◇ 注入 · ◆ 目标轮";
4819
+ }
4820
+
4821
+ // ---- SubagentPage (main-area pane 子代理): single-column subagent list ----
4822
+
4823
+ export class SubagentPage extends Widget {
4824
+ constructor(app) {
4825
+ super({ x: 0, y: 1, w: app.screen.w, h: Math.max(2, app.screen.h - 1) });
4826
+ this.app = app;
4827
+ this.entries = [];
4828
+ this.error = null;
4829
+ this.loading = false;
4830
+ this.sel = 0;
4831
+ this.rows = []; // { kind:"header"|"item", item? } flattened selectable view
4832
+ this.selector = []; // entry indices selectable in order
4833
+ this.view = new ScrollView({ x: this.x, y: this.y, w: this.w, h: this.h, showScrollbar: true });
4834
+ this.loadToken = 0;
4835
+ this.inputMode = false;
4836
+ this.input = new Input({
4837
+ x: this.x, y: this.y + this.h - 1, w: this.w, h: 1,
4838
+ placeholder: "给选中子代理发消息…(continuable · i 聚焦 · Esc 回列表)",
4839
+ onEnter: (v) => this.send(v),
4840
+ });
4841
+ }
4842
+ relayout(x, y, w, h) {
4843
+ this.x = x; this.y = y; this.w = w; this.h = h;
4844
+ this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = Math.max(1, h - 1);
4845
+ this.input.x = x; this.input.y = y + h - 1; this.input.w = w;
4846
+ }
4847
+ onActivate() { this.ensureLoaded(); }
4848
+ ensureLoaded() {
4849
+ if (this.loading || (this.entries.length > 0 && this.error == null)) return;
4850
+ this.load();
4851
+ }
4852
+ /** Live refresh (frame injection), throttled to one call per second. */
4853
+ reload() {
4854
+ const now = Date.now();
4855
+ if (this.loading) return;
4856
+ if (this.lastReloadAt != null && now - this.lastReloadAt < 1000) return;
4857
+ this.lastReloadAt = now;
4858
+ this.load();
4859
+ }
4860
+ async load() {
4861
+ const token = ++this.loadToken;
4862
+ this.loading = true;
4863
+ this.error = null;
4864
+ try {
4865
+ const res = await this.app.api.call("subagent.list", { parentSessionId: this.app.currentSession });
4866
+ if (token !== this.loadToken) return;
4867
+ this.entries = res.items ?? res.entries ?? [];
4868
+ } catch (e) {
4869
+ if (token !== this.loadToken) return;
4870
+ this.entries = [];
4871
+ this.error = e.message;
4872
+ this.app.toast(`子代理加载失败: ${e.message}`);
4873
+ }
4874
+ this.loading = false;
4875
+ this.sel = 0;
4876
+ this.#rebuild();
4877
+ this.app.redraw();
4878
+ }
4879
+ #parentOf(e) { return e.parentSessionId ?? e.parentId ?? e.parent ?? null; }
4880
+ #statusOf(e) {
4881
+ return e.activity ?? e.state ?? e.status ?? (e.done ? "inactive" : "idle");
4882
+ }
4883
+ #elapsedMs(e) {
4884
+ if (e.elapsed != null) return e.elapsed;
4885
+ if (e.duration != null) return e.duration;
4886
+ if (typeof e.settledMs === "number") return e.settledMs;
4887
+ if (typeof e.timing?.settledMs === "number") return e.timing.settledMs;
4888
+ if (typeof e.startedAt === "number" && typeof e.finishedAt === "number") return e.finishedAt - e.startedAt;
4889
+ if (typeof e.startedAt === "number") return Date.now() - e.startedAt;
4890
+ return null;
4891
+ }
4892
+ #modelOf(e) {
4893
+ if (typeof e.model === "string") return e.model;
4894
+ if (e.model?.id) return `${e.model.provider ? e.model.provider + "/" : ""}${e.model.id}`;
4895
+ if (typeof e.modelId === "string") return e.modelId;
4896
+ return null;
4897
+ }
4898
+ #tokensOf(e) {
4899
+ const t = e.tokenUsage ?? e.tokens ?? e.tokenCount;
4900
+ if (t == null) return null;
4901
+ const total = typeof t === "object" ? (t.total ?? t.input + (t.output ?? 0) ?? null) : t;
4902
+ return total == null ? null : total;
4903
+ }
4904
+ #exitReason(e) {
4905
+ return e.exitReason ?? e.stoppedReason ?? e.reason ?? e.error?.message ?? null;
4906
+ }
4907
+ #shortId(e, w) {
4908
+ const label = e.label ?? e.sessionId ?? e.id;
4909
+ return truncate(String(label ?? "子代理"), w);
4910
+ }
4911
+ #rebuild() {
4912
+ const w = Math.max(20, this.w - 2);
4913
+ const rows = [], selector = [];
4914
+ for (let i = 0; i < this.entries.length; i++) {
4915
+ const e = this.entries[i];
4916
+ const parent = this.#parentOf(e);
4917
+ if (parent != null) {
4918
+ const prev = i > 0 ? this.#parentOf(this.entries[i - 1]) : undefined;
4919
+ if (prev !== parent) rows.push({ kind: "header", text: `▼ 父会话 ${truncate(String(parent), 30)} / 子代理` });
4920
+ } else if (i === 0) {
4921
+ rows.push({ kind: "header", text: "▼ 父会话 / 子代理" });
4922
+ }
4923
+ const running = this.#statusOf(e) === "running";
4924
+ const model = this.#modelOf(e);
4925
+ const elapsed = this.#elapsedMs(e);
4926
+ const tokens = this.#tokensOf(e);
4927
+ const exit = this.#exitReason(e);
4928
+ const idw = Math.max(10, w - 14);
4929
+ let line = `${running ? "●" : "○"} ${this.#shortId(e, idw)}`;
4930
+ if (model) line += ` · ${truncate(String(model), 18)}`;
4931
+ if (elapsed != null) line += ` · ${fmtDuration(elapsed)}`;
4932
+ if (tokens != null) line += ` · ${tokens} tok`;
4933
+ if (exit) line += ` · ${truncate(String(exit), 22)}`;
4934
+ rows.push({ kind: "item", item: e, index: i, text: line, running });
4935
+ selector.push(i);
4936
+ }
4937
+ if (this.entries.length === 0) {
4938
+ rows.push({ kind: "empty", text: this.error ? ` 子代理加载失败: ${this.error}` : " 暂无子代理" });
4939
+ }
4940
+ this.rows = rows;
4941
+ this.selector = selector;
4942
+ if (this.sel >= selector.length) this.sel = Math.max(0, selector.length - 1);
4943
+ }
4944
+ render(screen) {
4945
+ screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG });
4946
+ if (this.entries.length === 0 && !this.loading) {
4947
+ const msg = this.error ? `子代理加载失败: ${this.error}` : "暂无子代理";
4948
+ screen.text(this.x + 1, this.y + 1, msg, { fg: this.error ? K.ERR : K.FAINT });
4949
+ return;
4950
+ }
4951
+ // Render rows through the shared ScrollView so scrolling works.
4952
+ this.view.setLines(this.rows.map((row) => {
4953
+ if (row.kind === "header") return [{ t: row.text, fg: K.ACCENT, bold: true }];
4954
+ if (row.kind === "empty") return [{ t: row.text, fg: row.text.startsWith("子代理加载失败") ? K.ERR : K.FAINT }];
4955
+ const sel = this.selector[this.sel] === row.index;
4956
+ const fg = row.running ? K.OK : sel ? T.SELFG : K.TXT;
4957
+ const bg = sel ? T.MENUSEL : -1;
4958
+ return [{ t: row.text, fg, bg, bold: sel }];
4959
+ }), { keep: true });
4960
+ this.view.render(screen);
4961
+ // selection highlight is handled above via bg; ensure selected row visible
4962
+ const selIdx = this.selector[this.sel];
4963
+ if (selIdx != null) {
4964
+ const rowPos = this.rows.findIndex((r) => r.kind === "item" && r.index === selIdx);
4965
+ const selLine = this.rows.slice(0, rowPos + 1).filter((r) => r.kind !== "empty").length - 1;
4966
+ if (selLine < this.view.scrollY) this.view.scrollY = selLine;
4967
+ else if (selLine >= this.view.scrollY + this.view.h) this.view.scrollY = selLine - this.view.h + 1;
4968
+ }
4969
+ screen.fillRect(this.x, this.y + this.h - 2, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: this.inputMode ? T.BG2 : T.BG });
4970
+ screen.text(this.x + 1, this.y + this.h - 2, " ↑↓ 选择 · Enter 详情 · r 刷新 · i 给子代理发消息 · q/Esc 返回聊天", { fg: K.FAINT });
4971
+ this.input.render(screen);
4972
+ }
4973
+ /** Continuable message to the selected subagent (original panel parity). */
4974
+ async send(text) {
4975
+ const child = this.entries[this.selector[this.sel]];
4976
+ if (!child) { this.app.toast("先选择子代理"); return; }
4977
+ try {
4978
+ await this.app.api.call("subagent.prompt", {
4979
+ parentSessionId: this.app.currentSession,
4980
+ childSessionId: child.id,
4981
+ mode: "continuable",
4982
+ content: [{ type: "text", text }],
4983
+ clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
4984
+ });
4985
+ this.app.toast(`已发送给 ${child.id.slice(0, 8)}`);
4986
+ this.input.setValue("");
4987
+ this.reload();
4988
+ } catch (e) { this.app.toast(`发送失败: ${e.message}`); }
4989
+ }
4990
+ openDetail() {
4991
+ const e = this.entries[this.selector[this.sel]];
4992
+ if (!e) return;
4993
+ openDetailPopup(this.app, this.app.overlay, "子代理详情", detailText(e).split("\n"));
4994
+ }
4995
+ onKey(ev) {
4996
+ if (ev.type !== "key") return false;
4997
+ if (this.inputMode) {
4998
+ if (ev.name === "escape") { this.inputMode = false; this.input.setValue(""); this.app.redraw(); return true; }
4999
+ this.input.onKey(ev);
5000
+ return true;
5001
+ }
5002
+ if (ev.name === "char" && (ev.key === "i" || ev.key === "I") && !ev.ctrl) { this.inputMode = true; this.app.redraw(); return true; }
5003
+ if (ev.name === "tab") { this.inputMode = true; this.app.redraw(); return true; }
5004
+ if (ev.name === "char" && ev.key === "r" && !ev.ctrl) { this.load(); return true; }
5005
+ if (ev.name === "up" || ev.name === "down") {
5006
+ if (this.selector.length === 0) return false;
5007
+ const next = wrapIndex(this.sel + (ev.name === "up" ? -1 : 1), this.selector.length);
5008
+ this.sel = next;
5009
+ this.app.redraw();
5010
+ return true;
5011
+ }
5012
+ if (ev.name === "enter") { this.openDetail(); return true; }
5013
+ if (ev.name === "pgup") { this.view.scroll(-this.view.h); this.app.redraw(); return true; }
5014
+ if (ev.name === "pgdn") { this.view.scroll(this.view.h); this.app.redraw(); return true; }
5015
+ return false;
5016
+ }
5017
+ onMouse(ev) {
5018
+ if (ev.kind === "press" && ev.button === 0) {
5019
+ const line = ev.y - this.view.y + this.view.scrollY;
5020
+ const row = this.rows[line];
5021
+ if (row?.kind === "item") {
5022
+ this.sel = this.selector.indexOf(row.index);
5023
+ this.openDetail();
5024
+ }
5025
+ return true;
5026
+ }
5027
+ return this.view.onMouse(ev);
5028
+ }
5029
+ }
5030
+
5031
+ // ---- TasksPage (main-area pane 后台任务): background jobs + queue, source-kind badges ----
5032
+
5033
+ export class TasksPage extends Widget {
5034
+ constructor(app) {
5035
+ super({ x: 0, y: 1, w: app.screen.w, h: Math.max(2, app.screen.h - 1) });
5036
+ this.app = app;
5037
+ this.sel = 0;
5038
+ this.groups = [];
5039
+ this.selector = [];
5040
+ this.view = new ScrollView({ x: this.x, y: this.y, w: this.w, h: this.h, showScrollbar: true });
5041
+ }
5042
+ relayout(x, y, w, h) {
5043
+ this.x = x; this.y = y; this.w = w; this.h = h;
5044
+ this.view.x = x; this.view.y = y; this.view.w = w; this.view.h = h;
5045
+ }
5046
+ onActivate() { this.#rebuild(); }
5047
+ /** One normalized entry: queue item or job. */
5048
+ #entries() {
5049
+ const out = [];
5050
+ for (const item of (this.app.queueItems ?? [])) {
5051
+ const src = item?.source?.kind ?? item?.message?.source?.kind ?? "unknown";
5052
+ out.push({ kind: "queue", badge: this.#badgeFor(src), group: this.#groupFor(src), src, item, text: this.#queueSummary(item) });
5053
+ }
5054
+ for (const job of (this.app.jobs ?? [])) {
5055
+ const src = job?.source?.kind ?? "unknown";
5056
+ out.push({ kind: "job", badge: this.#badgeFor(src), group: this.#groupFor(src), src, job, text: this.#jobSummary(job) });
5057
+ }
5058
+ return out;
5059
+ }
5060
+ #badgeFor(src) { return sourceBadge(src); }
5061
+ #groupFor(src) { return sourceGroup(src); }
5062
+ #queueSummary(item) {
5063
+ const text = partsText(item?.message?.content).replace(/\s+/g, " ").trim();
5064
+ return truncate(text || item?.id || "(无文本)", 56);
5065
+ }
5066
+ #jobSummary(job) {
5067
+ let s = truncate(job?.label ?? job?.kind ?? "任务", 56);
5068
+ if (job?.status) s += ` · ${job.status}`;
5069
+ return s;
5070
+ }
5071
+ #durationMs(entry) {
5072
+ const e = entry.kind === "job" ? entry.job : entry.item;
5073
+ if (e?.elapsed != null) return e.elapsed;
5074
+ if (e?.duration != null) return e.duration;
5075
+ if (typeof e?.startedAt === "number" && typeof e?.finishedAt === "number") return e.finishedAt - e.startedAt;
5076
+ if (typeof e?.startedAt === "number") return Date.now() - e.startedAt;
5077
+ return null;
5078
+ }
5079
+ #hasLogTail(entry) {
5080
+ const e = entry.kind === "job" ? entry.job : entry.item;
5081
+ return !!(e?.logTail ?? e?.tail ?? e?.result);
5082
+ }
5083
+ #exitCode(entry) {
5084
+ const e = entry.kind === "job" ? entry.job : entry.item;
5085
+ return e?.exitCode ?? e?.code ?? (e?.status === "failed" ? 1 : null);
5086
+ }
5087
+ #rebuild() {
5088
+ const entries = this.#entries();
5089
+ const groupOrder = [{ name: "排队提问", badge: "●" }, { name: "注入上下文", badge: "◇" }, { name: "目标延续", badge: "◆" }];
5090
+ const rows = [], selector = [];
5091
+ for (const g of groupOrder) {
5092
+ const members = entries.filter((e) => e.group === g.name);
5093
+ if (members.length === 0) continue;
5094
+ rows.push({ kind: "header", text: `${g.badge} ${g.name}(${members.length})` });
5095
+ for (const entry of members) {
5096
+ const srcTag = entry.src === "unknown" ? " (unknown source)" : "";
5097
+ let text = `${entry.badge} ${entry.text}${srcTag}`;
5098
+ const dur = this.#durationMs(entry);
5099
+ if (dur != null) text += ` · ${fmtDuration(dur)}`;
5100
+ const code = this.#exitCode(entry);
5101
+ if (code != null) text += ` · exit ${code}`;
5102
+ if (this.#hasLogTail(entry)) text += " …";
5103
+ rows.push({ kind: "item", entry, text, index: selector.length });
5104
+ selector.push(entries.indexOf(entry));
5105
+ }
5106
+ }
5107
+ if (rows.length === 0) rows.push({ kind: "empty", text: " 暂无后台任务或排队命令" });
5108
+ this.groups = rows;
5109
+ this.selector = selector;
5110
+ if (this.sel >= selector.length) this.sel = Math.max(0, selector.length - 1);
5111
+ }
5112
+ render(screen) {
5113
+ screen.fillRect(this.x, this.y, this.x + this.w - 1, this.y + this.h - 1, " ", { bg: T.BG });
5114
+ this.view.setLines(this.groups.map((row) => {
5115
+ if (row.kind === "header") return [{ t: row.text, fg: K.ACCENT, bold: true }];
5116
+ if (row.kind === "empty") return [{ t: row.text, fg: K.FAINT }];
5117
+ const sel = this.selector[this.sel] === row.index;
5118
+ return [{ t: row.text, fg: sel ? T.SELFG : K.TXT, bg: sel ? T.MENUSEL : -1, bold: sel }];
5119
+ }), { keep: true });
5120
+ this.view.render(screen);
5121
+ screen.text(this.x + 1, this.y + this.h - 1, " ↑↓ 选择 · Enter 详情 · q/Esc 返回聊天", { fg: K.FAINT });
5122
+ }
5123
+ openDetail() {
5124
+ const rowIdx = this.selector[this.sel];
5125
+ const entry = this.#entries()[rowIdx];
5126
+ if (!entry) return;
5127
+ const raw = entry.kind === "job" ? entry.job : entry.item;
5128
+ openDetailPopup(this.app, this.app.overlay, "任务详情", detailText(raw).split("\n"));
5129
+ }
5130
+ onKey(ev) {
5131
+ if (ev.type !== "key") return false;
5132
+ if (ev.name === "up" || ev.name === "down") {
5133
+ if (this.selector.length === 0) return false;
5134
+ this.sel = wrapIndex(this.sel + (ev.name === "up" ? -1 : 1), this.selector.length);
5135
+ this.app.redraw();
5136
+ return true;
5137
+ }
5138
+ if (ev.name === "enter") { this.openDetail(); return true; }
5139
+ if (ev.name === "pgup") { this.view.scroll(-this.view.h); this.app.redraw(); return true; }
5140
+ if (ev.name === "pgdn") { this.view.scroll(this.view.h); this.app.redraw(); return true; }
5141
+ return false;
5142
+ }
5143
+ onMouse(ev) {
5144
+ if (ev.type === "mouse" && ev.kind === "press" && ev.button === 0) {
5145
+ const line = ev.y - this.view.y + this.view.scrollY;
5146
+ const row = this.groups[line];
5147
+ if (row?.kind === "item") { this.sel = this.selector.indexOf(row.index); this.openDetail(); }
5148
+ return true;
5149
+ }
5150
+ return this.view.onMouse(ev);
5151
+ }
5152
+ }
@@ -0,0 +1,75 @@
1
+ // side-question.mjs — /btw 侧问的独立执行器(子进程,密钥一次性使用)。
2
+ // 只在子进程内存中读取 ~/.dsh/.credentials.yaml 的 DEEPSEEK_API_KEY,
3
+ // 直调 DeepSeek API(无工具、单轮),stdout 只输出答案 JSON。
4
+ // ── 重要 ── 绝不打印/回显密钥;错误消息不包含请求头内容。
5
+ import { readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ /** env-style credential 文件解析(仅取键名列表,值不落日志)。 */
10
+ export function parseCredentials(text) {
11
+ const out = {};
12
+ for (const line of String(text).split("\n")) {
13
+ // YAML "KEY: value" 或 env "KEY=value" 两种形态
14
+ const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::|=)\s*(.*?)\s*$/.exec(line);
15
+ if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, "");
16
+ }
17
+ return out;
18
+ }
19
+
20
+ /** 侧问题请求体(系统围栏 + 会话摘要上下文 + 问题)。 */
21
+ export function buildMessages(question, context = "") {
22
+ const system = `<system-reminder>This is a side question from the user. Answer it directly in a single response.
23
+
24
+ IMPORTANT CONTEXT:
25
+ - You are a separate, lightweight agent spawned to answer this one question
26
+ - The main agent is NOT interrupted - it continues working independently
27
+ - You share the conversation context but are a separate instance
28
+
29
+ CRITICAL CONSTRAINTS:
30
+ - You have NO tools - you cannot read files, run commands, search, or take actions
31
+ - This is a one-off response; there will be no follow-up turns
32
+ - Answer only with what you know from the conversation context
33
+ - Never promise to take any action; if you do not know, say so
34
+
35
+ Simply answer the question with the information you have.</system-reminder>`;
36
+ const msgs = [{ role: "system", content: system }];
37
+ if (context) msgs.push({ role: "user", content: `对话摘要(供参考,非问题本身):\n${context}` });
38
+ msgs.push({ role: "user", content: question });
39
+ return msgs;
40
+ }
41
+
42
+ /** 直调 DeepSeek 聊天补全(无工具、单轮)。fetchImpl 可注入(测试)。 */
43
+ export async function callDeepSeek(apiKey, messages, { baseUrl = "https://api.deepseek.com", model = "deepseek-chat", fetchImpl = fetch } = {}) {
44
+ const res = await fetchImpl(`${baseUrl}/chat/completions`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
47
+ body: JSON.stringify({ model, messages, temperature: 0.6, max_tokens: 2048 }),
48
+ });
49
+ if (!res.ok) throw new Error(`API 响应 ${res.status}`);
50
+ const data = await res.json();
51
+ const answer = data?.choices?.[0]?.message?.content?.trim();
52
+ if (!answer) throw new Error("模型未返回内容");
53
+ return answer;
54
+ }
55
+
56
+ // ── CLI 入口:node src/side-question.mjs --question "..." [--context "..."] [--credentials path] ──
57
+ if (import.meta.url === `file://${process.argv[1]}`) {
58
+ const args = process.argv.slice(2);
59
+ const opt = (name) => args.indexOf(name) >= 0 ? args[args.indexOf(name) + 1] : null;
60
+ const question = opt("--question");
61
+ const context = opt("--context") ?? "";
62
+ if (!question) { console.error("缺少 --question"); process.exit(1); }
63
+ const credPath = opt("--credentials") ?? join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), ".credentials.yaml");
64
+ try {
65
+ const creds = parseCredentials(readFileSync(credPath, "utf8"));
66
+ const key = creds.DEEPSEEK_API_KEY;
67
+ if (!key) { console.error("credentials 中未找到 DEEPSEEK_API_KEY"); process.exit(1); }
68
+ const answer = await callDeepSeek(key, buildMessages(question, context));
69
+ process.stdout.write(JSON.stringify({ ok: true, answer }));
70
+ } catch (e) {
71
+ // 只输出通用错误(不含密钥与请求头)
72
+ console.error(`side-question 失败: ${e?.message ?? e}`);
73
+ process.exit(1);
74
+ }
75
+ }
package/src/term.js CHANGED
@@ -30,6 +30,8 @@ export class Term {
30
30
  this.onResize = onResize ?? (() => {});
31
31
  this.kitty = kitty;
32
32
  this.kittyActive = false; // set when the terminal answers the CSI ? u query
33
+ this.cellSize = null; // [w, h] chars + pixels from CSI 6/4 t replies
34
+ this.pixelSize = null; // [w, h] pixels
33
35
  this.handoff = process.env.DSH_TUI_RESTART_HANDOFF === "1";
34
36
  this.escTimer = null; // pending lone-ESC fallback (split-sequence safety)
35
37
  this.decoder = new StringDecoder("utf8");
@@ -58,6 +60,8 @@ export class Term {
58
60
  o.write("\x1b[?2004h"); // bracketed paste
59
61
  o.write("\x1b[?7l"); // no autowrap (we clip ourselves)
60
62
  if (this.kitty) { o.write("\x1b[>1u"); o.write("\x1b[?u"); }
63
+ // Cell/pixel geometry probes (XTerm rep): CSI 14 t → pixels, CSI 16 t → chars.
64
+ o.write("\x1b[14t\x1b[16t");
61
65
  this.resizeHandler = () => this.#resize();
62
66
  process.on("SIGWINCH", this.resizeHandler);
63
67
  this.#resize();
@@ -285,6 +289,23 @@ export class Term {
285
289
  return; // other private responses (cursor pos) stay ignored
286
290
  }
287
291
  if (prefix === ">") return;
292
+ if (prefix === "" && (final === "t") && params.startsWith("4;")) {
293
+ const [, ph, pw] = params.split(";").map(Number);
294
+ if (ph && pw) this.pixelSize = [pw, ph];
295
+ return;
296
+ }
297
+ if (prefix === "" && final === "t" && params.startsWith("6;")) {
298
+ const [, chh, chw] = params.split(";").map(Number);
299
+ if (chh && chw) {
300
+ this.cellSize = [chw, chh];
301
+ if (this.pixelSize) {
302
+ // exact cell aspect ratio (pixels per char) — 实际几何而非假设 2:1
303
+ const [pw, ph] = this.pixelSize, [cw, ch] = this.cellSize;
304
+ this.cellAspect = { w: pw / cw, h: ph / ch, ratio: (pw / cw) / (ph / ch) };
305
+ }
306
+ }
307
+ return;
308
+ }
288
309
  if (final === "Z") { // Shift+Tab (backtab)
289
310
  this.#emit({ type: "key", name: "backtab", ctrl: false, alt: false, shift: true });
290
311
  return;