dsh-neotui 0.2.1 → 0.2.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2 — 2026-08-16
4
+
5
+ ### Added
6
+
7
+ - 底栏在子代理状态后显示“有 x 条命令正在排队 Ctrl+N 查看详情”。
8
+ - Ctrl+N 打开精简排队命令面板;每条命令一行,支持 `dd` 删除。
9
+
10
+ ### Changed
11
+
12
+ - AskUser 窗口按实际内容自适应高度,长内容才启用滚动。
13
+ - 选项标签与描述只显示一次,完整描述按终端宽度换行。
14
+ - 自定义答案常驻在选项下一行并支持最多六行自动换行。
15
+ - 长正文使用 Ctrl+↑/Ctrl+↓、PgUp/PgDn、Home/End 和滚轮翻页。
16
+ - 权限审批窗口保留完整原因和命令,明确显示滚动提示与位置。
17
+
18
+ ### Fixed
19
+
20
+ - AskUser 自定义回答、真实跳过项、精确鼠标 hitbox 和文本光标编辑。
21
+ - 权限申请窗口支持 ↑/↓ 逐行滚动,不改变安全默认“拒绝”。
22
+ - 超长审批命令不再只保留前六行。
23
+ - Queue 面板移除未要求的编辑与 steering 操作,`dd` 同时兼容 text/key 事件。
24
+
3
25
  ## 0.2.1 — 2026-08-16
4
26
 
5
27
  ### Fixed
package/README.md CHANGED
@@ -131,7 +131,7 @@ Kitty graphics 可用时,图片在文件选择器和附件预览中等比例
131
131
  - `queue`:加入下一回合队列;
132
132
  - `steer`:追加到当前回合。
133
133
 
134
- `Ctrl+Y` 切换策略,`Ctrl+U` 打开队列。队列面板使用 `e` 编辑、`s` steering、`d` 删除、`Esc` 关闭。
134
+ `Ctrl+Y` 切换策略,`Ctrl+N` 打开排队命令详情。队列面板每条命令一行,使用 `↑/↓` 选择、`dd` 删除、`Esc` 关闭。
135
135
 
136
136
  ## 快捷键
137
137
 
@@ -144,14 +144,15 @@ Kitty graphics 可用时,图片在文件选择器和附件预览中等比例
144
144
  | NORMAL | `Ctrl+F` | 跨会话模糊定位 |
145
145
  | NORMAL | `Ctrl+B` | 显示/隐藏侧栏 |
146
146
  | NORMAL | `Ctrl+M` | 模型与思考强度 |
147
- | NORMAL | `F8` / `F9` | 权限策略 / Agent 模式 |
147
+ | NORMAL | `Shift+Tab` / `F8` | 权限模式轮换 |
148
+ | NORMAL | `F9` | Agent 模式 |
148
149
  | NORMAL | `Ctrl+W` | 工作区 |
149
150
  | NORMAL | `Ctrl+Shift+W` | 添加工作区 |
150
151
  | NORMAL | `Ctrl+T` | 轨迹视图 |
151
- | NORMAL | `Shift+Tab` | 对话/轨迹切换 |
152
+ | NORMAL | `Tab` | 对话/轨迹切换 |
152
153
  | NORMAL | `Ctrl+E` | 按 step 快速跳转 |
153
154
  | NORMAL | `Ctrl+J` | 后台任务与 Subagent |
154
- | NORMAL | `Ctrl+U` | 消息队列 |
155
+ | NORMAL | `Ctrl+N` | 排队命令详情 |
155
156
  | NORMAL | `Ctrl+G` | Goal / TODO |
156
157
  | NORMAL | `Ctrl+S` | Settings |
157
158
  | NORMAL | `Ctrl+A` | Subagent |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-neotui",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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/config.js CHANGED
@@ -74,7 +74,8 @@ export const DEFAULT_KEYBINDINGS = {
74
74
  leaveInsert: { mode: "insert", key: "Esc" }, sessionFilter: { mode: "normal", key: "/" }, newSession: { mode: "normal", key: "n" },
75
75
  top: { mode: "normal", key: "g g" }, bottom: { mode: "normal", key: "G" }, prevQuestion: { mode: "normal", key: "[" }, nextQuestion: { mode: "normal", key: "]" },
76
76
  expandInput: { mode: "insert", key: "Ctrl+L" }, copyInput: { mode: "insert", key: "Ctrl+Shift+C" }, panel: { mode: "all", key: "Ctrl+Space" },
77
- model: { mode: "normal", key: "Ctrl+M" }, trajectory: { mode: "normal", key: "Ctrl+T" }, homeSwitch: { mode: "normal", key: "Shift+Tab" },
77
+ model: { mode: "normal", key: "Ctrl+M" }, trajectory: { mode: "normal", key: "Ctrl+T" }, homeSwitch: { mode: "normal", key: "Tab" },
78
+ permissionRotate: { mode: "normal", key: "Shift+Tab" },
78
79
  workspace: { mode: "normal", key: "Ctrl+W" }, settings: { mode: "normal", key: "Ctrl+S" }, subagent: { mode: "normal", key: "Ctrl+A" },
79
80
  skills: { mode: "normal", key: "Ctrl+K" }, goal: { mode: "normal", key: "Ctrl+G" }, jobs: { mode: "normal", key: "Ctrl+J" },
80
81
  stepJump: { mode: "normal", key: "Ctrl+E" }, sidebar: { mode: "normal", key: "Ctrl+B" }, quit: { mode: "all", key: "Ctrl+Q" },
package/src/panels.js CHANGED
@@ -1453,7 +1453,7 @@ export class ControlPanel extends Widget {
1453
1453
  const b=keyBindings();
1454
1454
  const row=(id,desc)=>[`${(b[id]?.mode??"all").toUpperCase()}\t${b[id]?.key??""}`,desc,null,id];
1455
1455
  return [
1456
- 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("panel","控制面板"),row("model","切换模型"),row("trajectory","轨迹视图"),row("homeSwitch","对话/轨迹切换"),row("workspace","工作区"),row("settings","设置"),row("subagent","子代理"),row("skills","技能"),row("goal","目标"),row("jobs","后台任务"),row("stepJump","步骤转跳"),row("sidebar","侧栏显示/隐藏"),row("quit","退出"),
1456
+ 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("panel","控制面板"),row("model","切换模型"),row("trajectory","轨迹视图"),row("homeSwitch","对话/轨迹切换"),row("permissionRotate","权限模式轮换"),row("workspace","工作区"),row("settings","设置"),row("subagent","子代理"),row("skills","技能"),row("goal","目标"),row("jobs","后台任务"),row("stepJump","步骤转跳"),row("sidebar","侧栏显示/隐藏"),row("quit","退出"),
1457
1457
  ];
1458
1458
  }
1459
1459
  items() {
@@ -1669,7 +1669,8 @@ export class JobsPanel extends Popup {
1669
1669
  for (let i = 0; i < this.subagents.length; i++) {
1670
1670
  const child = this.subagents[i], bg = i === this.sel ? T.MENUSEL : T.BG2;
1671
1671
  const status = child.activity ?? child.status ?? child.mode ?? "idle";
1672
- lines.push([{ t: ` ${i === this.sel ? "▸" : " "} 🛰 ${truncate(child.label ?? child.sessionId ?? child.id ?? "子代理", 42)} `, fg: K.TXT, bg, bold: i === this.sel }, { t: status, fg: status === "running" ? K.WARN : K.DIM, bg }]); rowOf.push(i);
1672
+ const open = this.expanded.has(i);
1673
+ lines.push([{ t: ` ${open ? "▾" : "▸"} 🛰 ${truncate(child.label ?? child.sessionId ?? child.id ?? "子代理", 42)} `, fg: K.TXT, bg, bold: i === this.sel }, { t: status, fg: status === "running" ? K.WARN : K.DIM, bg }]); rowOf.push(i);
1673
1674
  if (this.expanded.has(i)) for (const [key, value] of Object.entries(child)) { lines.push([{ t: ` ${key}: ${truncate(typeof value === "object" ? JSON.stringify(value) : value, this.w - 16)}`, fg: K.DIM }]); rowOf.push(-1); }
1674
1675
  }
1675
1676
  this.lines = lines; this.rowOf = rowOf; this.#ensureVisible(); return;
@@ -1751,18 +1752,17 @@ export class QueuePanel extends Popup {
1751
1752
  const items = app.queueItems ?? [];
1752
1753
  const w = Math.max(24, Math.min(84, app.screen.w - 4));
1753
1754
  const h = Math.max(7, Math.min(24, app.screen.h - 4));
1754
- super({ x: Math.max(0, Math.floor((app.screen.w - w) / 2)), y: Math.max(0, Math.floor((app.screen.h - h) / 2)), w, h, title: "消息队列", lines: [], buttons: [{ label: "关闭", action: "close" }], onAction: () => app.closeOverlay(), scrollable: true });
1755
- this.app = app; this.items = items; this.sel = 0; this.pending = false; this.editInput = null; this.rebuild();
1755
+ super({ x: Math.max(0, Math.floor((app.screen.w - w) / 2)), y: Math.max(0, Math.floor((app.screen.h - h) / 2)), w, h, title: "排队命令 · ↑↓ 选择 · dd 删除 · Esc 关闭", lines: [], buttons: [], scrollable: true });
1756
+ this.app = app; this.items = items; this.sel = 0; this.pending = false; this.dArmed=false; this.rebuild();
1756
1757
  }
1757
1758
  rebuild() {
1758
- const lines = [[{ t: ` ↑↓选择 · e 编辑 · s 立即 steering · d 删除 · Esc关闭${this.pending ? " · 操作中…" : ""}`, fg: this.pending ? K.WARN : K.DIM }]];
1759
+ const lines = [];
1759
1760
  for (let i = 0; i < this.items.length; i++) {
1760
1761
  const item = this.items[i];
1761
1762
  const text = partsText(item.message?.content).replace(/\s+/g, " ");
1762
1763
  lines.push([{ t: `${i === this.sel ? "▸" : " "} ${item.placement === "queued" ? "⏳" : item.placement === "steering" ? "↪" : "ℹ"} ${truncate(text || item.id, this.w - 8)}`, fg: i === this.sel ? T.SELFG : K.TXT, bg: i === this.sel ? T.MENUSEL : -1 }]);
1763
1764
  }
1764
1765
  if (!this.items.length) lines.push([{ t: " (队列为空)", fg: K.FAINT }]);
1765
- if (this.editInput) lines.push([{ t: ` 编辑: ${truncate(this.editInput.value, this.w - 8)}`, fg: K.ACCENT }]);
1766
1766
  this.lines = lines;
1767
1767
  }
1768
1768
  syncItems(items) {
@@ -1782,7 +1782,6 @@ export class QueuePanel extends Popup {
1782
1782
  const action = kind === "edit" ? { kind, content: [{ type: "text", text: content }] } : { kind };
1783
1783
  await this.app.api.call("session.updateQueue", { sessionId: this.app.currentSession, itemId: item.id, action });
1784
1784
  if (kind === "remove") this.syncItems(this.items.filter((row) => row.id !== item.id));
1785
- this.editInput = null;
1786
1785
  } catch (e) {
1787
1786
  const code = this.#errorCode(e);
1788
1787
  if (code === "queue-item-not-found" || /queue-item-not-found/.test(e.message ?? "")) {
@@ -1793,31 +1792,19 @@ export class QueuePanel extends Popup {
1793
1792
  } else this.app.toast(`队列操作失败: ${e.message}`);
1794
1793
  } finally { this.pending = false; this.rebuild(); this.app.redraw(); }
1795
1794
  }
1796
- #beginEdit() {
1797
- const item = this.items[this.sel];
1798
- if (!item || item.placement !== "queued") { this.app.toast("只有排队消息可以编辑"); return; }
1799
- this.editInput = new Input({ x: this.x + 2, y: this.y + this.h - 3, w: this.w - 4, h: 1, prompt: "编辑: ", allowEmptyEnter: false, onEnter: (value) => this.#mutate("edit", value) });
1800
- this.editInput.setValue(partsText(item.message?.content));
1801
- this.rebuild();
1802
- }
1803
1795
  onKey(ev) {
1804
- if (this.editInput) {
1805
- if (ev.type === "key" && ev.name === "escape") { this.editInput = null; this.rebuild(); return true; }
1806
- if (this.editInput.onKey(ev)) { this.rebuild(); this.app.redraw(); return true; }
1807
- }
1796
+ const ch=ev.type==="text"?ev.text:ev.type==="key"&&ev.name==="char"?ev.key:null;
1797
+ if(ch==="d"){if(this.dArmed){this.dArmed=false;this.#mutate("remove");}else{this.dArmed=true;this.app.toast("再按 d 删除这条排队命令");}return true;}
1808
1798
  if (ev.type === "key") {
1809
1799
  if (ev.name === "escape") { this.app.closeOverlay(); return true; }
1810
- if (ev.name === "up") { this.sel = Math.max(0, this.sel - 1); this.rebuild(); return true; }
1811
- if (ev.name === "down") { this.sel = Math.min(this.items.length - 1, this.sel + 1); this.rebuild(); return true; }
1812
- if (ev.name === "char" && ev.key === "e" && !ev.ctrl) { this.#beginEdit(); return true; }
1813
- if (ev.name === "char" && ev.key === "s" && !ev.ctrl) { this.#mutate("steer"); return true; }
1814
- if (ev.name === "char" && ev.key === "d" && !ev.ctrl) { this.#mutate("remove"); return true; }
1800
+ if (ev.name === "up") { this.dArmed=false;this.sel = Math.max(0, this.sel - 1); this.rebuild(); return true; }
1801
+ if (ev.name === "down") { this.dArmed=false;this.sel = Math.min(this.items.length - 1, this.sel + 1); this.rebuild(); return true; }
1815
1802
  }
1816
- return super.onKey(ev);
1803
+ this.dArmed=false;return false;
1817
1804
  }
1818
1805
  onMouse(ev) {
1819
1806
  if (ev.kind === "press" && ev.button === 0) {
1820
- const idx = ev.y - this.y - 2;
1807
+ const idx = ev.y - this.y - 1;
1821
1808
  if (idx >= 0 && idx < this.items.length) { this.sel = idx; this.rebuild(); this.app.redraw(); return true; }
1822
1809
  }
1823
1810
  return super.onMouse(ev);
package/src/views.js CHANGED
@@ -5,6 +5,7 @@ import { truncate, strWidth, bars, fmtDuration, fmtClock, fmtDateTime, graphemes
5
5
  import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { spawnSync } from "node:child_process";
8
+ import { createRequire } from "node:module";
8
9
  import { Widget, List, ScrollView, Input, Popup, Menu, StatusBar } from "./widgets.js";
9
10
  import { UploadPicker } from "./file-picker.js";
10
11
  import { userPrefix, saveTuiConfig, loadTuiConfig, userName, busyEnter, foldDefaults, keyBindings, DEFAULT_KEYBINDINGS } from "./config.js";
@@ -20,6 +21,67 @@ import { T, themeName, cycleTheme } from "./theme.js";
20
21
  // Live theme accessor: K.K.DIM etc. resolve against the active palette at render time.
21
22
  const K = new Proxy({}, { get(_k, key) { return T[key]; } });
22
23
 
24
+ const require = createRequire(import.meta.url);
25
+ export const TUI_VERSION = (() => {
26
+ try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; }
27
+ catch { return "unknown"; }
28
+ })();
29
+ let dshVersionCache = null;
30
+ export function installedDshVersion(run = spawnSync) {
31
+ if (dshVersionCache) return dshVersionCache;
32
+ if (process.env.DSH_VERSION) return (dshVersionCache = process.env.DSH_VERSION.replace(/^v/, ""));
33
+ try {
34
+ const file = require.resolve("@deepseek-ai/dsh/package.json");
35
+ const version = JSON.parse(readFileSync(file, "utf8")).version;
36
+ if (version) return (dshVersionCache = version);
37
+ } catch {}
38
+ try {
39
+ const result = run("dsh", ["--version"], { encoding: "utf8", timeout: 2000 });
40
+ const version = String(result.stdout ?? "").trim().replace(/^v/, "");
41
+ if (result.status === 0 && version) return (dshVersionCache = version);
42
+ } catch {}
43
+ return (dshVersionCache = "unknown");
44
+ }
45
+
46
+ async function latestNpmVersion(name) {
47
+ const controller = new AbortController();
48
+ const timer = setTimeout(() => controller.abort(), 5000);
49
+ try {
50
+ const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, {
51
+ headers: { accept: "application/json" }, signal: controller.signal,
52
+ });
53
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
54
+ const body = await response.json();
55
+ if (typeof body?.version !== "string" || !body.version) throw new Error("缺少版本号");
56
+ return body.version;
57
+ } finally { clearTimeout(timer); }
58
+ }
59
+
60
+ /** Semver comparison for update checks. Returns null for an unparseable value,
61
+ * otherwise -1/0/1 for left older/equal/newer than right. In particular, a
62
+ * locally newer build must never be advertised as "可更新" to an older npm tag. */
63
+ function compareSemver(left, right) {
64
+ const parse = (value) => {
65
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value ?? ""));
66
+ if (!match) return null;
67
+ return { core: match.slice(1, 4).map(Number), pre: match[4]?.split(".") ?? [] };
68
+ };
69
+ const a = parse(left), b = parse(right);
70
+ if (!a || !b) return null;
71
+ for (let i = 0; i < 3; i++) if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i] ? 1 : -1;
72
+ if (a.pre.length === 0 || b.pre.length === 0) return a.pre.length === b.pre.length ? 0 : a.pre.length === 0 ? 1 : -1;
73
+ const n = Math.max(a.pre.length, b.pre.length);
74
+ for (let i = 0; i < n; i++) {
75
+ if (a.pre[i] === undefined || b.pre[i] === undefined) return a.pre[i] === b.pre[i] ? 0 : a.pre[i] === undefined ? -1 : 1;
76
+ if (a.pre[i] === b.pre[i]) continue;
77
+ const ai = /^\d+$/.test(a.pre[i]), bi = /^\d+$/.test(b.pre[i]);
78
+ if (ai && bi) return Number(a.pre[i]) > Number(b.pre[i]) ? 1 : -1;
79
+ if (ai !== bi) return ai ? -1 : 1;
80
+ return a.pre[i] > b.pre[i] ? 1 : -1;
81
+ }
82
+ return 0;
83
+ }
84
+
23
85
  // ---- Tool card renderers (host-computed view models) ----
24
86
 
25
87
  function renderToolCard(view, width, expanded) {
@@ -913,7 +975,6 @@ export class ChatView extends Widget {
913
975
  this.thinkMode = fd.think ? "expanded" : "collapsed"; // t toggles
914
976
  this.bashMode = fd.bash ? "expanded" : "collapsed"; // b toggles
915
977
  this.todosVisible = fd.todos; // Shift+T toggles
916
- this.todoSeen = false; // once seen, the todo box keeps its height
917
978
  this.running = false;
918
979
  this.hasMore = false;
919
980
  this.loadingOlder = false;
@@ -1100,20 +1161,15 @@ export class ChatView extends Widget {
1100
1161
  }
1101
1162
  }
1102
1163
 
1103
- /** Height of the collapsible todo block. FROZEN at the max (1 header + 6
1104
- * items) once the session has ever shown todos: the harness updates the
1105
- * list in the background while the user reads, and a height that tracks
1106
- * the count reflows the whole layout every time (the idle 2-line shifts). */
1164
+ /** Height of the collapsible todo block: one framed row per visible task,
1165
+ * capped at six items. Short lists must not reserve blank body rows. */
1107
1166
  todoHeight() {
1108
1167
  const todos = this.app.todos;
1109
1168
  const subagent = this.app.projections.subagent;
1110
- let h = 0;
1111
- if (subagent) h++;
1112
- if (!todos || todos.length === 0) { this.todoSeen = false; return h; }
1113
- this.todoSeen = true;
1114
- // The task dock owns a framed header and footer. Folded keeps the framed
1115
- // two-row strip visible rather than blending one text row into transcript.
1116
- return h + (this.todosVisible ? 8 : 2);
1169
+ const subagentRows = subagent ? 1 : 0;
1170
+ if (!todos || todos.length === 0) return subagentRows;
1171
+ // Header + actual body rows + footer. Folded retains only the frame.
1172
+ return subagentRows + (this.todosVisible ? Math.min(todos.length, 6) + 2 : 2);
1117
1173
  }
1118
1174
 
1119
1175
  divingNode() { return [...this.nodes].reverse().find((node) => node.kind === "turn-progress") ?? null; }
@@ -1980,8 +2036,20 @@ export class ChatView extends Widget {
1980
2036
  let y = this.view.y + 1;
1981
2037
  const put = (t, fg, bold) => { if (y < this.view.y + this.view.h) { screen.text(cx, y, t, { fg, attrs: bold ? 1 : 0 }); } y++; };
1982
2038
  put("", 0, false);
1983
- put(" DeepSeek Harness", T.HEADING, true);
1984
- put(" dsh-neotui", T.FAINT, false);
2039
+ this.welcomeVersionRows = [];
2040
+ const versionLine = (name, version, key, fg, bold) => {
2041
+ const check = this.app.versionChecks?.[key];
2042
+ const status = check?.state === "checking" ? "← 检查更新…"
2043
+ : check?.state === "current" ? "← 已是最新"
2044
+ : check?.state === "update" ? `← 可更新 ${check.latest}`
2045
+ : check?.state === "error" ? "← 检查失败(点击重试)"
2046
+ : "← 检查更新";
2047
+ const text = ` ${name} ${version === "unknown" ? "版本未知" : `v${version}`} ${status}`;
2048
+ put(text, fg, bold);
2049
+ this.welcomeVersionRows[y - 1] = { key, x1: cx, x2: cx + strWidth(text) - 1 };
2050
+ };
2051
+ versionLine("DeepSeek Harness", this.app.dshVersion ?? "unknown", "dsh", T.HEADING, true);
2052
+ versionLine("dsh-neotui", TUI_VERSION, "tui", T.FAINT, false);
1985
2053
  put("", 0, false);
1986
2054
  if (this.app.currentSession == null) {
1987
2055
  put(" 打开一个会话开始,或 Ctrl+N 新建", T.DIM, false);
@@ -1990,6 +2058,7 @@ export class ChatView extends Widget {
1990
2058
  put(" 请选择模式(F9 或点击下方,选择后立即生效):", T.WARN, true);
1991
2059
  put("", 0, false);
1992
2060
  this.welcomeModes = [];
2061
+ const currentPreset = this.app.sessions.find((s) => s.sessionId === this.app.currentSession)?.agentPreset;
1993
2062
  const presets = [
1994
2063
  ["standard", "标准模式", "完整编码 Agent(文件/Shell/检索/Skills/目标/子代理)"],
1995
2064
  ["code", "PTC 模式", "标准模式能力 + Code Mode SDK 单程序多步操作"],
@@ -1998,8 +2067,10 @@ export class ChatView extends Widget {
1998
2067
  ];
1999
2068
  for (const [id, name, desc] of presets) {
2000
2069
  if (y < this.view.y + this.view.h) {
2001
- screen.text(cx, y, ` ○ ${name}`, { fg: T.ACCENT, attrs: 1 });
2002
- screen.text(cx + 2 + strWidth(`○ ${name}`) + 1, y, truncate(desc, this.view.w - 20), { fg: T.DIM });
2070
+ const selected = id === currentPreset;
2071
+ const label = `${selected ? "●" : "○"} ${name}${selected ? " [当前]" : ""}`;
2072
+ screen.text(cx, y, ` ${label}`, { fg: selected ? T.OK : T.ACCENT, bg: selected ? T.MENUSEL : -1, attrs: 1 });
2073
+ screen.text(cx + 2 + strWidth(label) + 1, y, truncate(desc, Math.max(1, this.view.w - strWidth(label) - 8)), { fg: selected ? T.TXT : T.DIM, bg: selected ? T.MENUSEL : -1 });
2003
2074
  this.welcomeModes[y] = id;
2004
2075
  }
2005
2076
  y++;
@@ -2148,6 +2219,8 @@ export class ChatView extends Widget {
2148
2219
  if (this.app.focused !== this.app.chat?.input) this.app.focus(this);
2149
2220
  // Welcome-screen mode click: select the preset under the cursor.
2150
2221
  if (this.nodes.length === 0 && ev.kind === "press" && ev.button === 0) {
2222
+ const versionHit = this.welcomeVersionRows?.[ev.y];
2223
+ if (versionHit && ev.x >= versionHit.x1 && ev.x <= versionHit.x2) { this.app.checkUpdates(versionHit.key, true); return true; }
2151
2224
  const id = this.welcomeModes[ev.y];
2152
2225
  if (id) { this.app.selectPreset(id); return true; }
2153
2226
  }
@@ -2344,14 +2417,31 @@ function truncateText(s, n) {
2344
2417
  return s.length > n ? s.slice(0, n) + "\n…(截断)" : s;
2345
2418
  }
2346
2419
 
2420
+ function wrapDisplayText(value, width) {
2421
+ const out = [];
2422
+ for (const raw of String(value ?? "").split("\n")) {
2423
+ if (!raw) { out.push(""); continue; }
2424
+ let line = "", used = 0;
2425
+ for (const g of graphemes(raw)) {
2426
+ const gw = graphemeWidth(g);
2427
+ if (line && used + gw > width) { out.push(line); line = ""; used = 0; }
2428
+ line += g; used += gw;
2429
+ }
2430
+ out.push(line);
2431
+ }
2432
+ return out;
2433
+ }
2434
+
2347
2435
  // ---- Question popup ----
2348
2436
 
2349
2437
  export class QuestionPopup extends Popup {
2350
2438
  constructor({ app, frame }) {
2351
2439
  const questions = frame.questions ?? [];
2352
2440
  const planReview = questions.length === 1 && questions[0]?.intent?.kind === "plan-review";
2353
- const w = Math.max(12, Math.min(planReview ? 88 : 72, app.screen.w - 2));
2354
- const h = Math.max(5, Math.min(Math.max(5, app.screen.h - 2), planReview ? app.screen.h - 2 : questions.length * 5 + 8));
2441
+ const w = Math.max(12, Math.min(planReview ? 96 : 92, app.screen.w - 2));
2442
+ const wrapCount=(value,width)=>wrapDisplayText(value,width).length;
2443
+ const estimated=questions.reduce((n,q)=>n+1+wrapCount(q.question??"",w-4)+(q.detail?wrapCount(q.detail,w-4):0)+(q.options??[]).reduce((m,o)=>m+1+(o.description?wrapCount(o.description,w-10):0),0)+(q.intent?.kind==="plan-review"?0:4),0)+2;
2444
+ const h=Math.max(10,Math.min(app.screen.h-2,estimated));
2355
2445
  super({
2356
2446
  x: Math.max(0, Math.floor((app.screen.w - w) / 2)), y: Math.max(0, Math.floor((app.screen.h - h) / 2)),
2357
2447
  w, h, title: planReview ? "✎ 计划审阅" : "❓ 需要你的回答",
@@ -2387,44 +2477,44 @@ export class QuestionPopup extends Popup {
2387
2477
  const q = this.questions[this.questionIdx];
2388
2478
  if (!q) return;
2389
2479
  const draft = this.drafts[this.questionIdx];
2390
- let ly = this.y + 1;
2391
- screen.text(this.x + 2, ly++, truncate(`▎ ${q.header ?? `问题 ${this.questionIdx + 1}/${this.questions.length}`}`, this.w - 4), { fg: K.ACCENT, bold: true });
2392
- screen.text(this.x + 2, ly++, truncate(q.question ?? "", this.w - 4), { fg: K.TXT });
2393
- if (this.planReview) screen.text(this.x + 2, ly++, "Enter 执行计划 · ↓ 继续规划 · Esc 取消审阅", { fg: K.FAINT });
2394
2480
  const opts = q.options ?? [];
2395
- if (q.detail) {
2396
- const detailLines = String(q.detail).split("\n");
2397
- const optionRows = opts.reduce((n, option) => n + (option.description ? 2 : 1), 0);
2398
- const room = Math.max(1, this.y + this.h - 4 - ly - optionRows);
2399
- const maxScroll = Math.max(0, detailLines.length - room);
2400
- this.detailScrollY = Math.max(0, Math.min(this.detailScrollY, maxScroll));
2401
- this.detailPage = room;
2402
- const visible = detailLines.slice(this.detailScrollY, this.detailScrollY + room);
2403
- for (const detail of visible) screen.text(this.x + 2, ly++, truncate(detail, this.w - 4), { fg: K.DIM });
2404
- if (this.planReview && detailLines.length > room && ly < this.y + this.h - 3) {
2405
- const start = this.detailScrollY + 1, end = Math.min(detailLines.length, this.detailScrollY + room);
2406
- screen.text(this.x + 2, ly++, `计划 ${start}–${end} / ${detailLines.length} · PgUp/PgDn/Home/End/滚轮`, { fg: K.FAINT });
2407
- }
2408
- }
2481
+ const optionDescriptionRows=opts.reduce((n,o)=>n+(o.description?wrapDisplayText(o.description,this.w-10).length:0),0);
2482
+ const actionRows = opts.length + optionDescriptionRows + (this.planReview ? 0 : Math.min(6,Math.max(3,wrapDisplayText(draft.custom||"在此输入…",this.w-10).length))+2);
2483
+ const actionTop = Math.max(this.y + 5, this.y + this.h - 1 - Math.min(actionRows,this.h-6));
2484
+ const doc = [
2485
+ { text: `▎ ${q.header ?? `问题 ${this.questionIdx + 1}/${this.questions.length}`}`, fg: K.ACCENT, bold: true },
2486
+ ...wrapDisplayText(q.question ?? "", this.w - 4).map((text) => ({ text, fg: K.TXT })),
2487
+ ...(q.detail ? wrapDisplayText(q.detail, this.w - 4).map((text) => ({ text, fg: K.DIM })) : []),
2488
+ ];
2489
+
2490
+ const room = Math.max(1, actionTop - (this.y + 1));
2491
+ const maxScroll = Math.max(0, doc.length - room);
2492
+ this.detailScrollY = Math.max(0, Math.min(this.detailScrollY, maxScroll)); this.detailPage = room; this.detailTotal = doc.length;
2493
+ let ly = this.y + 1;
2494
+ for (const line of doc.slice(this.detailScrollY, this.detailScrollY + room)) screen.text(this.x + 2, ly++, truncate(line.text, this.w - 4), { fg: line.fg, attrs: line.bold ? 1 : 0 });
2495
+ if(maxScroll>0){screen.text(this.x+this.w-26,this.y,`正文 Ctrl+↑↓ 翻页 ${this.detailScrollY+1}-${Math.min(doc.length,this.detailScrollY+room)}/${doc.length}`,{fg:K.ACCENT,bg:T.BG2});}
2496
+ screen.hline(this.x+1,this.x+this.w-2,actionTop-1,"─",{fg:T.BORDER2,bg:T.BG2});screen.text(this.x+3,actionTop-1," 回答(↑/↓ 选择) ",{fg:K.ACCENT,bg:T.BG2});
2497
+ ly=actionTop;
2409
2498
  this.optionRows = []; this.optionHitboxes = [];
2410
- for (let i = 0; i < opts.length && ly < this.y + this.h - 3; i++) {
2499
+ for (let i = 0; i < opts.length; i++) {
2411
2500
  this.optionRows[i] = ly;
2412
2501
  const chosen = draft.selected.includes(opts[i].label);
2413
2502
  const cursor = this.selIdx === i;
2414
2503
  const glyph = q.multiSelect ? (chosen ? "☑" : "☐") : (chosen ? "●" : "○");
2415
2504
  const optionText=truncate(` ${cursor ? "▸" : " "} ${glyph} ${opts[i].label}`, this.w - 6);
2416
- this.optionHitboxes[i]={x1:this.x+2,x2:this.x+2+strWidth(optionText)-1,y1:ly,y2:ly+(opts[i].description?1:0)};
2505
+ const descLines=opts[i].description?wrapDisplayText(opts[i].description,this.w-10):[];
2506
+ this.optionHitboxes[i]={x1:this.x+2,x2:this.x+2+strWidth(optionText)-1,y1:ly,y2:ly+descLines.length};
2417
2507
  screen.text(this.x + 2, ly++, optionText, { fg: cursor ? T.SELFG : K.TXT, bg: cursor ? T.MENUSEL : -1 });
2418
- if (opts[i].description && ly < this.y + this.h - 3) screen.text(this.x + 7, ly++, truncate(opts[i].description, this.w - 10), { fg: K.FAINT });
2508
+ for(const line of descLines)screen.text(this.x+7,ly++,line,{fg:K.FAINT,bg:cursor?T.MENUSEL:-1});
2419
2509
  }
2420
- if (!this.planReview && ly < this.y + this.h - 3) {
2510
+ if (!this.planReview) {
2421
2511
  this.optionRows[opts.length] = ly;
2422
2512
  const cursor = this.selIdx === opts.length;
2423
2513
  const customText=truncate(` ${cursor ? "▸" : " "} ✎ 输入自己的回答`, this.w - 6);
2424
2514
  this.optionHitboxes[opts.length]={x1:this.x+2,x2:this.x+2+strWidth(customText)-1,y1:ly,y2:ly};
2425
2515
  screen.text(this.x + 2, ly++, customText, { fg: cursor ? T.SELFG : K.ACCENT, bg: cursor ? T.MENUSEL : -1 });
2426
- if(ly<this.y+this.h-3){const chars=Array.from(draft.custom),shown=this.customEditing?[...chars.slice(0,this.customCursor),"▏",...chars.slice(this.customCursor)].join(""):draft.custom;const inputText=truncate(` > ${shown||"在此输入…"}`,this.w-8);screen.text(this.x+2,ly++,inputText,{fg:this.customEditing?K.TXT:K.FAINT,bg:this.customEditing?T.BG2:-1});}
2427
- if(ly<this.y+this.h-3){this.optionRows[opts.length+1]=ly;const skip=this.selIdx===opts.length+1,skipText=` ${skip?"▸":" "} ↷ 跳过此问题`;this.optionHitboxes[opts.length+1]={x1:this.x+2,x2:this.x+2+strWidth(skipText)-1,y1:ly,y2:ly};screen.text(this.x+2,ly++,skipText,{fg:skip?T.SELFG:K.FAINT,bg:skip?T.MENUSEL:-1});}
2516
+ {const chars=Array.from(draft.custom),shown=this.customEditing?[...chars.slice(0,this.customCursor),"▏",...chars.slice(this.customCursor)].join(""):draft.custom;const inputLines=wrapDisplayText(shown||"在此输入…",this.w-10).slice(-6);for(let i=0;i<inputLines.length;i++)screen.text(this.x+4,ly++,`${i===0?"> ":" "}${inputLines[i]}`,{fg:this.customEditing?K.TXT:K.FAINT,bg:this.customEditing?T.BG2:-1});}
2517
+ {this.optionRows[opts.length+1]=ly;const skip=this.selIdx===opts.length+1,skipText=` ${skip?"▸":" "} ↷ 跳过此问题`;this.optionHitboxes[opts.length+1]={x1:this.x+2,x2:this.x+2+strWidth(skipText)-1,y1:ly,y2:ly};screen.text(this.x+2,ly++,skipText,{fg:skip?T.SELFG:K.FAINT,bg:skip?T.MENUSEL:-1});}
2428
2518
  }
2429
2519
  }
2430
2520
 
@@ -2441,8 +2531,9 @@ export class QuestionPopup extends Popup {
2441
2531
  }
2442
2532
 
2443
2533
  onMouse(ev) {
2444
- if (this.planReview && (ev.kind === "wheel-up" || ev.kind === "wheel-down")) {
2445
- this.detailScrollY = Math.max(0, this.detailScrollY + (ev.kind === "wheel-up" ? -3 : 3));
2534
+ if (ev.kind === "wheel-up" || ev.kind === "wheel-down") {
2535
+ const max=Math.max(0,(this.detailTotal??0)-(this.detailPage??1));
2536
+ this.detailScrollY = Math.max(0,Math.min(max,this.detailScrollY + (ev.kind === "wheel-up" ? -3 : 3)));
2446
2537
  this.app.redraw(); return true;
2447
2538
  }
2448
2539
  if (ev.kind === "press" && ev.button === 0) {
@@ -2469,14 +2560,16 @@ export class QuestionPopup extends Popup {
2469
2560
  const choices = count + (this.planReview ? 0 : 2);
2470
2561
  if (ev.type === "text") { if(this.customEditing||this.selIdx===count){this.customEditing=true;draft.selected=[];const chars=Array.from(draft.custom);chars.splice(this.customCursor,0,...Array.from(ev.text));draft.custom=chars.join("");this.customCursor+=Array.from(ev.text).length;draft.skipped=false;return true;} return false; }
2471
2562
  if (ev.type !== "key") return false;
2472
- if (this.planReview && ["pageup", "pagedown", "home", "end"].includes(ev.name)) {
2473
- const total = String(q?.detail ?? "").split("\n").length;
2563
+ if (["pageup", "pagedown", "home", "end"].includes(ev.name)) {
2564
+ const total=this.detailTotal??0,max=Math.max(0,total-this.detailPage);
2474
2565
  if (ev.name === "pageup") this.detailScrollY = Math.max(0, this.detailScrollY - this.detailPage);
2475
- else if (ev.name === "pagedown") this.detailScrollY = Math.min(Math.max(0, total - this.detailPage), this.detailScrollY + this.detailPage);
2566
+ else if (ev.name === "pagedown") this.detailScrollY = Math.min(max, this.detailScrollY + this.detailPage);
2476
2567
  else if (ev.name === "home") this.detailScrollY = 0;
2477
- else this.detailScrollY = Math.max(0, total - this.detailPage);
2568
+ else this.detailScrollY = max;
2478
2569
  this.app.redraw(); return true;
2479
2570
  }
2571
+ if(!this.customEditing&&ev.ctrl&&ev.name==="up"){this.detailScrollY=Math.max(0,this.detailScrollY-this.detailPage);return true;}
2572
+ if(!this.customEditing&&ev.ctrl&&ev.name==="down"){this.detailScrollY=Math.min(Math.max(0,(this.detailTotal??0)-this.detailPage),this.detailScrollY+this.detailPage);return true;}
2480
2573
  if(this.customEditing&&ev.name==="left"){this.customCursor=Math.max(0,this.customCursor-1);return true;}
2481
2574
  if(this.customEditing&&ev.name==="right"){this.customCursor=Math.min(Array.from(draft.custom).length,this.customCursor+1);return true;}
2482
2575
  if(this.customEditing&&ev.name==="home"){this.customCursor=0;return true;}
@@ -2538,13 +2631,14 @@ export class ApprovalPopup extends Popup {
2538
2631
  const maxW = Math.max(12, app.screen.w - 2);
2539
2632
  const w = Math.min(72, maxW);
2540
2633
  const command = app.chat?.toolCommandForCall?.(frame.callId) ?? null;
2541
- const lines = [
2542
- [{ t: ` ${frame.reason ?? `工具 ${frame.toolName ?? "tool"} 请求越权执行`}`, fg: K.WARN }],
2543
- ];
2544
- if (command) lines.push([{ t: " 将执行:", fg: K.DIM, underline: true }], ...String(command).split("\n").slice(0, 6).map((line) => [{ t: " " + truncate(line, w - 4), fg: K.TXT }]));
2634
+ const wrap=(value,width)=>{const out=[];for(const raw of String(value??"").split("\n")){const gs=graphemes(raw);let line="",used=0;for(const g of gs){const gw=graphemeWidth(g);if(used+gw>width&&line){out.push(line);line="";used=0;}line+=g;used+=gw;}out.push(line);}return out;};
2635
+ const reason=frame.reason ?? `工具 ${frame.toolName ?? "tool"} 请求越权执行`;
2636
+ const lines = [[{t:" 请求原因",fg:K.DIM,underline:true}],...wrap(reason,w-6).map(line=>[{t:` ${line}`,fg:K.WARN}])];
2637
+ if (command) lines.push([{ t: " 将执行", fg: K.DIM, underline: true }], ...wrap(command,w-6).map((line) => [{ t: " " + line, fg: K.TXT }]));
2638
+ lines.push([{t:" 导航:↑/↓ 逐行 · PgUp/PgDn 翻页 · 滚轮 · ←/→ 选择",fg:K.FAINT}]);
2545
2639
  super({
2546
- x: Math.max(0, Math.floor((app.screen.w - w) / 2)), y: Math.max(0, Math.floor(app.screen.h / 2) - 4),
2547
- w, h: Math.min(app.screen.h, command ? 10 : 7), title: "⚠ 工具需要授权",
2640
+ x: Math.max(0, Math.floor((app.screen.w - w) / 2)), y: Math.max(0, Math.floor((app.screen.h - Math.min(app.screen.h - 2, Math.max(10, Math.min(24, lines.length + 4)))) / 2)),
2641
+ w, h: Math.min(app.screen.h - 2, Math.max(10, Math.min(24, lines.length + 4))), title: "⚠ 工具需要授权 · 可滚动",
2548
2642
  lines,
2549
2643
  buttons: [
2550
2644
  { label: "允许一次", action: "allowed-once" },
@@ -2575,11 +2669,12 @@ export class ApprovalPopup extends Popup {
2575
2669
  // ---- App ----
2576
2670
 
2577
2671
  export class App {
2578
- constructor({ screen, term, api, base, log }) {
2672
+ constructor({ screen, term, api, base, log, versionFetcher = latestNpmVersion }) {
2579
2673
  this.screen = screen;
2580
2674
  this.term = term;
2581
2675
  this.api = api;
2582
2676
  this.log = log ?? (() => {});
2677
+ this.versionFetcher = versionFetcher;
2583
2678
  this.popup = null;
2584
2679
  this.activePrompt = null;
2585
2680
  this.promptQueue = [];
@@ -2605,6 +2700,11 @@ export class App {
2605
2700
  this.tokenUsage = null;
2606
2701
  this.sessions = [];
2607
2702
  this.currentSession = null;
2703
+ this.dshVersion = installedDshVersion();
2704
+ this.versionChecks = {
2705
+ dsh: { state: "idle", latest: null },
2706
+ tui: { state: "idle", latest: null },
2707
+ };
2608
2708
  this.searchActive = false;
2609
2709
  this.overlay = null; // Picker / Popup / ImagePopup modal
2610
2710
  this.mode = "chat"; // chat | workspace | trajectory
@@ -2672,6 +2772,31 @@ export class App {
2672
2772
  this.setMode(this.mode === "trajectory" ? "chat" : "trajectory");
2673
2773
  }
2674
2774
 
2775
+ async checkUpdates(target = null, notify = false) {
2776
+ const specs = {
2777
+ dsh: { package: "@deepseek-ai/dsh", current: this.dshVersion, label: "DeepSeek Harness" },
2778
+ tui: { package: "dsh-neotui", current: TUI_VERSION, label: "dsh-neotui" },
2779
+ };
2780
+ const keys = target ? [target] : Object.keys(specs);
2781
+ await Promise.all(keys.map(async (key) => {
2782
+ const spec = specs[key];
2783
+ if (!spec) return;
2784
+ this.versionChecks[key] = { state: "checking", latest: null };
2785
+ this.redraw();
2786
+ try {
2787
+ const latest = await this.versionFetcher(spec.package);
2788
+ const comparison = compareSemver(spec.current, latest);
2789
+ const state = comparison === null ? (latest === spec.current ? "current" : "update") : comparison < 0 ? "update" : "current";
2790
+ this.versionChecks[key] = { state, latest };
2791
+ if (notify) this.toast(state === "current" ? `${spec.label} 已是最新版本 ${spec.current}` : `${spec.label} 可更新: ${spec.current} → ${latest}`);
2792
+ } catch (error) {
2793
+ this.versionChecks[key] = { state: "error", latest: null };
2794
+ if (notify) this.toast(`${spec.label} 更新检查失败: ${error.message}`);
2795
+ }
2796
+ this.redraw();
2797
+ }));
2798
+ }
2799
+
2675
2800
  /** Chat → trajectory: open the trajectory panel at the step containing a
2676
2801
  * chat node (right-click menu), loading older steps on demand. */
2677
2802
  async jumpToTrajectoryNode(nodeIdx) {
@@ -2804,6 +2929,7 @@ export class App {
2804
2929
  this.provider = host.provider ?? "";
2805
2930
  this.model = host.model ?? "";
2806
2931
  } catch (e) { this.log(`[app] host.describe: ${e.message}`); }
2932
+ void this.checkUpdates();
2807
2933
  await this.refreshSessions();
2808
2934
  // A /restart handoff carries the session to reopen: resume it instead of
2809
2935
  // minting a fresh blank session (which was leaking a stray "new session"
@@ -3477,7 +3603,9 @@ export class App {
3477
3603
  }
3478
3604
  try {
3479
3605
  await this.api.call("agentPreset.select", { sessionId: this.currentSession, agentPreset: id });
3606
+ if (sess) sess.agentPreset = id;
3480
3607
  this.toast(`模式已切换: ${modeName(id)}`);
3608
+ this.redraw();
3481
3609
  this.refreshSessions();
3482
3610
  } catch (e) {
3483
3611
  if (e.code === "agent-preset-locked") { this.toast("会话已开始,模式固定;已设为新会话默认"); this.setDefaultPreset(id); }
@@ -3526,7 +3654,7 @@ export class App {
3526
3654
  } catch (e) { this.toast(`权限切换失败: ${e.message}`); }
3527
3655
  }
3528
3656
 
3529
- /** F8: cycle read-only → workspace-write → danger-full-access (full access gates). */
3657
+ /** Shift+Tab/F8: cycle read-only → workspace-write → danger-full-access. */
3530
3658
  rotatePermission() {
3531
3659
  const order = ["read-only", "workspace-write", "danger-full-access"];
3532
3660
  const cur = this.projections.permissions?.currentValue;
@@ -3805,12 +3933,8 @@ export class App {
3805
3933
  this.redraw();
3806
3934
  return;
3807
3935
  }
3808
- if (ev.name === "backtab" && !ev.ctrl) {
3809
- if (this.mode === "trajectory") this.setMode("chat");
3810
- else if (this.mode === "chat") this.setMode("trajectory");
3811
- this.redraw();
3812
- return;
3813
- }
3936
+ if (ev.name === "tab" && !ev.ctrl) { this.toggleChatTrajectory(); return; }
3937
+ if (ev.name === "backtab" && !ev.ctrl) { this.rotatePermission(); return; }
3814
3938
  if (ev.ctrl && ev.key === "q") { this.stop(); return; }
3815
3939
  if (ev.ctrl && ev.key === "c") {
3816
3940
  // NORMAL-mode Ctrl+C: two presses within the toast window exit the
@@ -3823,7 +3947,6 @@ export class App {
3823
3947
  this.toast("再按一次 Ctrl+C 退出 TUI");
3824
3948
  return;
3825
3949
  }
3826
- if (ev.ctrl && ev.key === "n") { this.focus(this.chat); this.redraw(); return; }
3827
3950
  if (ev.ctrl && ev.key === "o") { this.overlay = new AttachmentPanel(this); this.focus(this.overlay); this.redraw(); return; }
3828
3951
  if (ev.ctrl && ev.key === "b") { this.toggleSidebar(); return; }
3829
3952
  if (ev.ctrl && ev.key === "p") { this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return; }
@@ -3837,7 +3960,7 @@ export class App {
3837
3960
  if (ev.ctrl && ev.key === "t") { this.setMode("trajectory"); return; }
3838
3961
  if (ev.ctrl && ev.key === "e") { this.quickJumpStep(); return; }
3839
3962
  if (ev.ctrl && ev.key === "j") { this.showJobs(); return; }
3840
- if (ev.ctrl && ev.key === "u") { this.showQueue(); return; }
3963
+ if (ev.ctrl && ev.key === "n") { this.showQueue(); return; }
3841
3964
  if (ev.ctrl && ev.key === "y") {
3842
3965
  const next = busyEnter() === "queue" ? "steer" : "queue";
3843
3966
  saveTuiConfig({ busyEnter: next });
@@ -4031,7 +4154,6 @@ export class App {
4031
4154
  if (rawGoal && !["complete", "completed", "cleared"].includes(rawGoal.phase)) {
4032
4155
  row0.left.push({ t: ` 🎯 ${truncate(rawGoal.objective ?? "目标", 14)} · Ctrl+G `, fg: T.SELFG, bg: T.WARN, bold: true });
4033
4156
  }
4034
- if (this.queueItems.length) row0.left.push({ t: ` ⏳队列 ${this.queueItems.length} (Ctrl+U) `, fg: T.SELFG, bg: T.WARN, bold: true });
4035
4157
  if (this.sidebarVisible) row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
4036
4158
  else row0.left.push({ t: " " + truncate(t || "(未选择会话)", 40) + " ", fg: T.TXT, bg: T.STATUSBG });
4037
4159
  if (cur?.running) row0.left.push({ t: " ●运行 ", fg: T.OK, bg: T.STATUSBG });
@@ -4118,6 +4240,7 @@ export class App {
4118
4240
  row2.left.push({ t: ` ${done}已完成${failed > 0 ? ` ${failed}失败` : ""} `, fg: done > 0 ? T.OK : T.FAINT, bg: T.STATUSBG });
4119
4241
  row2.left.push({ t: ` ${subStats.running > 0 ? `${subStats.running} 个子代理运行中` : "没有子代理运行"} ${subStats.completed}已完成 `, fg: subStats.running > 0 ? T.PURPLE : T.FAINT, bg: T.STATUSBG, bold: subStats.running > 0 });
4120
4242
  if (sub) row2.left.push({ t: ` 🛰 ${truncate(sub.label ?? sub.mode ?? "子代理", 20)} `, fg: T.PURPLE, bg: T.STATUSBG });
4243
+ if(this.queueItems.length)row2.left.push({t:` 有${this.queueItems.length}条命令正在排队 Ctrl+N查看详情 `,fg:T.SELFG,bg:T.WARN,bold:true});
4121
4244
  row2.right.push({ t: " Ctrl+J 任务/子代理 ", fg: T.DIM, bg: T.STATUSBG });
4122
4245
  rows.push(row2);
4123
4246
  }
package/src/widgets.js CHANGED
@@ -788,8 +788,8 @@ export class Popup extends Widget {
788
788
  }
789
789
  if (this.maxScroll() > 0) {
790
790
  // "↑N" / "↓N" overflow indicators on the border row
791
- if (this.scrollY > 0) screen.text(this.x + this.w - 6, this.y, ` ↑${this.scrollY}`, { fg: T.ACCENT, bg: T.BG2 });
792
- 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 });
791
+ if (this.scrollY > 0) screen.text(this.x + this.w - 10, this.y, ` ↑ ${this.scrollY}/${this.lines.length}`, { fg: T.ACCENT, bg: T.BG2 });
792
+ if (this.scrollY < this.maxScroll()) screen.text(this.x + this.w - 12, this.y + this.h - 1, ` ↓ ${this.scrollY + this.contentRows()}/${this.lines.length}`, { fg: T.ACCENT, bg: T.BG2 });
793
793
  }
794
794
  } else {
795
795
  for (const line of this.lines) draw(line);
@@ -832,6 +832,8 @@ export class Popup extends Widget {
832
832
  onKey(ev) {
833
833
  if (ev.type !== "key") return false;
834
834
  if (this.scrollable) {
835
+ if (ev.name === "up") { this.scrollY = Math.max(0, this.scrollY - 1); return true; }
836
+ if (ev.name === "down") { this.scrollY = Math.min(this.maxScroll(), this.scrollY + 1); return true; }
835
837
  if (ev.name === "pgup") { this.scrollY = Math.max(0, this.scrollY - this.contentRows()); return true; }
836
838
  if (ev.name === "pgdn") { this.scrollY = Math.min(this.maxScroll(), this.scrollY + this.contentRows()); return true; }
837
839
  }