dsh-neotui 0.2.2 → 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/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.2",
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;
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
  }
@@ -2596,11 +2669,12 @@ export class ApprovalPopup extends Popup {
2596
2669
  // ---- App ----
2597
2670
 
2598
2671
  export class App {
2599
- constructor({ screen, term, api, base, log }) {
2672
+ constructor({ screen, term, api, base, log, versionFetcher = latestNpmVersion }) {
2600
2673
  this.screen = screen;
2601
2674
  this.term = term;
2602
2675
  this.api = api;
2603
2676
  this.log = log ?? (() => {});
2677
+ this.versionFetcher = versionFetcher;
2604
2678
  this.popup = null;
2605
2679
  this.activePrompt = null;
2606
2680
  this.promptQueue = [];
@@ -2626,6 +2700,11 @@ export class App {
2626
2700
  this.tokenUsage = null;
2627
2701
  this.sessions = [];
2628
2702
  this.currentSession = null;
2703
+ this.dshVersion = installedDshVersion();
2704
+ this.versionChecks = {
2705
+ dsh: { state: "idle", latest: null },
2706
+ tui: { state: "idle", latest: null },
2707
+ };
2629
2708
  this.searchActive = false;
2630
2709
  this.overlay = null; // Picker / Popup / ImagePopup modal
2631
2710
  this.mode = "chat"; // chat | workspace | trajectory
@@ -2693,6 +2772,31 @@ export class App {
2693
2772
  this.setMode(this.mode === "trajectory" ? "chat" : "trajectory");
2694
2773
  }
2695
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
+
2696
2800
  /** Chat → trajectory: open the trajectory panel at the step containing a
2697
2801
  * chat node (right-click menu), loading older steps on demand. */
2698
2802
  async jumpToTrajectoryNode(nodeIdx) {
@@ -2825,6 +2929,7 @@ export class App {
2825
2929
  this.provider = host.provider ?? "";
2826
2930
  this.model = host.model ?? "";
2827
2931
  } catch (e) { this.log(`[app] host.describe: ${e.message}`); }
2932
+ void this.checkUpdates();
2828
2933
  await this.refreshSessions();
2829
2934
  // A /restart handoff carries the session to reopen: resume it instead of
2830
2935
  // minting a fresh blank session (which was leaking a stray "new session"
@@ -3498,7 +3603,9 @@ export class App {
3498
3603
  }
3499
3604
  try {
3500
3605
  await this.api.call("agentPreset.select", { sessionId: this.currentSession, agentPreset: id });
3606
+ if (sess) sess.agentPreset = id;
3501
3607
  this.toast(`模式已切换: ${modeName(id)}`);
3608
+ this.redraw();
3502
3609
  this.refreshSessions();
3503
3610
  } catch (e) {
3504
3611
  if (e.code === "agent-preset-locked") { this.toast("会话已开始,模式固定;已设为新会话默认"); this.setDefaultPreset(id); }
@@ -3547,7 +3654,7 @@ export class App {
3547
3654
  } catch (e) { this.toast(`权限切换失败: ${e.message}`); }
3548
3655
  }
3549
3656
 
3550
- /** F8: cycle read-only → workspace-write → danger-full-access (full access gates). */
3657
+ /** Shift+Tab/F8: cycle read-only → workspace-write → danger-full-access. */
3551
3658
  rotatePermission() {
3552
3659
  const order = ["read-only", "workspace-write", "danger-full-access"];
3553
3660
  const cur = this.projections.permissions?.currentValue;
@@ -3826,12 +3933,8 @@ export class App {
3826
3933
  this.redraw();
3827
3934
  return;
3828
3935
  }
3829
- if (ev.name === "backtab" && !ev.ctrl) {
3830
- if (this.mode === "trajectory") this.setMode("chat");
3831
- else if (this.mode === "chat") this.setMode("trajectory");
3832
- this.redraw();
3833
- return;
3834
- }
3936
+ if (ev.name === "tab" && !ev.ctrl) { this.toggleChatTrajectory(); return; }
3937
+ if (ev.name === "backtab" && !ev.ctrl) { this.rotatePermission(); return; }
3835
3938
  if (ev.ctrl && ev.key === "q") { this.stop(); return; }
3836
3939
  if (ev.ctrl && ev.key === "c") {
3837
3940
  // NORMAL-mode Ctrl+C: two presses within the toast window exit the
@@ -3844,7 +3947,6 @@ export class App {
3844
3947
  this.toast("再按一次 Ctrl+C 退出 TUI");
3845
3948
  return;
3846
3949
  }
3847
- if (ev.ctrl && ev.key === "n") { this.focus(this.chat); this.redraw(); return; }
3848
3950
  if (ev.ctrl && ev.key === "o") { this.overlay = new AttachmentPanel(this); this.focus(this.overlay); this.redraw(); return; }
3849
3951
  if (ev.ctrl && ev.key === "b") { this.toggleSidebar(); return; }
3850
3952
  if (ev.ctrl && ev.key === "p") { this.overlay = new ControlPanel(this, { startPage: 1 }); this.redraw(); return; }