@ai-zen/cli 1.0.0-alpha.2 → 1.0.0-alpha.20

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.
Files changed (181) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/README.md +99 -28
  3. package/README.zh.md +99 -28
  4. package/dist/agent-creator.d.ts +11 -0
  5. package/dist/agent-creator.d.ts.map +1 -1
  6. package/dist/agent-creator.js +20 -11
  7. package/dist/agent-creator.js.map +1 -1
  8. package/dist/agents-store.d.ts.map +1 -1
  9. package/dist/agents-store.js +40 -3
  10. package/dist/agents-store.js.map +1 -1
  11. package/dist/auto-migrate-confirm.d.ts +43 -0
  12. package/dist/auto-migrate-confirm.d.ts.map +1 -0
  13. package/dist/auto-migrate-confirm.js +46 -0
  14. package/dist/auto-migrate-confirm.js.map +1 -0
  15. package/dist/config-editor.d.ts +4 -4
  16. package/dist/config-editor.d.ts.map +1 -1
  17. package/dist/config-editor.js +6 -8
  18. package/dist/config-editor.js.map +1 -1
  19. package/dist/config.d.ts +23 -2
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +26 -2
  22. package/dist/config.js.map +1 -1
  23. package/dist/conversation-commands/registry.d.ts.map +1 -1
  24. package/dist/conversation-commands/registry.js +4 -1
  25. package/dist/conversation-commands/registry.js.map +1 -1
  26. package/dist/conversation-persist-plugin.d.ts +0 -1
  27. package/dist/conversation-persist-plugin.d.ts.map +1 -1
  28. package/dist/conversation-persist-plugin.js +2 -3
  29. package/dist/conversation-persist-plugin.js.map +1 -1
  30. package/dist/conversation-repository.d.ts +5 -1
  31. package/dist/conversation-repository.d.ts.map +1 -1
  32. package/dist/conversation-repository.js +1 -2
  33. package/dist/conversation-repository.js.map +1 -1
  34. package/dist/handoff-service.d.ts +19 -0
  35. package/dist/handoff-service.d.ts.map +1 -0
  36. package/dist/handoff-service.js +86 -0
  37. package/dist/handoff-service.js.map +1 -0
  38. package/dist/index.js +21 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/model-switch.d.ts +58 -0
  41. package/dist/model-switch.d.ts.map +1 -0
  42. package/dist/model-switch.js +61 -0
  43. package/dist/model-switch.js.map +1 -0
  44. package/dist/reasoning-effort.d.ts +77 -0
  45. package/dist/reasoning-effort.d.ts.map +1 -0
  46. package/dist/reasoning-effort.js +137 -0
  47. package/dist/reasoning-effort.js.map +1 -0
  48. package/dist/reasoning-presets.d.ts +34 -0
  49. package/dist/reasoning-presets.d.ts.map +1 -0
  50. package/dist/reasoning-presets.js +85 -0
  51. package/dist/reasoning-presets.js.map +1 -0
  52. package/dist/sdk-log.d.ts +48 -0
  53. package/dist/sdk-log.d.ts.map +1 -0
  54. package/dist/sdk-log.js +67 -0
  55. package/dist/sdk-log.js.map +1 -0
  56. package/dist/session-pointer.d.ts +22 -9
  57. package/dist/session-pointer.d.ts.map +1 -1
  58. package/dist/session-pointer.js +55 -23
  59. package/dist/session-pointer.js.map +1 -1
  60. package/dist/stdio-runner.d.ts.map +1 -1
  61. package/dist/stdio-runner.js +15 -13
  62. package/dist/stdio-runner.js.map +1 -1
  63. package/dist/sub-agent-guard-plugin.d.ts +5 -5
  64. package/dist/sub-agent-guard-plugin.d.ts.map +1 -1
  65. package/dist/sub-agent-guard-plugin.js +7 -7
  66. package/dist/sub-agent-guard-plugin.js.map +1 -1
  67. package/dist/tui/chat-session.d.ts +83 -59
  68. package/dist/tui/chat-session.d.ts.map +1 -1
  69. package/dist/tui/chat-session.js +161 -105
  70. package/dist/tui/chat-session.js.map +1 -1
  71. package/dist/tui/components.d.ts +7 -2
  72. package/dist/tui/components.d.ts.map +1 -1
  73. package/dist/tui/components.js +3 -3
  74. package/dist/tui/components.js.map +1 -1
  75. package/dist/tui/config-wizard/engine.js +1 -1
  76. package/dist/tui/config-wizard/engine.js.map +1 -1
  77. package/dist/tui/config-wizard/fields.d.ts +3 -1
  78. package/dist/tui/config-wizard/fields.d.ts.map +1 -1
  79. package/dist/tui/config-wizard/fields.js +58 -6
  80. package/dist/tui/config-wizard/fields.js.map +1 -1
  81. package/dist/tui/config-wizard/menu.d.ts.map +1 -1
  82. package/dist/tui/config-wizard/menu.js +2 -3
  83. package/dist/tui/config-wizard/menu.js.map +1 -1
  84. package/dist/tui/config-wizard/step-defaults.d.ts.map +1 -1
  85. package/dist/tui/config-wizard/step-defaults.js +4 -11
  86. package/dist/tui/config-wizard/step-defaults.js.map +1 -1
  87. package/dist/tui/config-wizard/types.d.ts +18 -3
  88. package/dist/tui/config-wizard/types.d.ts.map +1 -1
  89. package/dist/tui/config-wizard/wizard.d.ts.map +1 -1
  90. package/dist/tui/config-wizard/wizard.js +4 -3
  91. package/dist/tui/config-wizard/wizard.js.map +1 -1
  92. package/dist/tui/highlight.d.ts +3 -2
  93. package/dist/tui/highlight.d.ts.map +1 -1
  94. package/dist/tui/highlight.js +3 -2
  95. package/dist/tui/highlight.js.map +1 -1
  96. package/dist/tui/index.d.ts +1 -0
  97. package/dist/tui/index.d.ts.map +1 -1
  98. package/dist/tui/index.js +87 -40
  99. package/dist/tui/index.js.map +1 -1
  100. package/dist/tui/input-editing.d.ts +49 -0
  101. package/dist/tui/input-editing.d.ts.map +1 -0
  102. package/dist/tui/input-editing.js +87 -0
  103. package/dist/tui/input-editing.js.map +1 -0
  104. package/dist/tui/key-debug.d.ts +62 -0
  105. package/dist/tui/key-debug.d.ts.map +1 -0
  106. package/dist/tui/key-debug.js +92 -0
  107. package/dist/tui/key-debug.js.map +1 -0
  108. package/dist/tui/mouse.d.ts +55 -0
  109. package/dist/tui/mouse.d.ts.map +1 -0
  110. package/dist/tui/mouse.js +74 -0
  111. package/dist/tui/mouse.js.map +1 -0
  112. package/dist/tui/screens/chat-commands.d.ts +12 -2
  113. package/dist/tui/screens/chat-commands.d.ts.map +1 -1
  114. package/dist/tui/screens/chat-commands.js +204 -17
  115. package/dist/tui/screens/chat-commands.js.map +1 -1
  116. package/dist/tui/screens/chat.d.ts +2 -5
  117. package/dist/tui/screens/chat.d.ts.map +1 -1
  118. package/dist/tui/screens/chat.js +460 -98
  119. package/dist/tui/screens/chat.js.map +1 -1
  120. package/dist/tui/screens/frame-buffer.d.ts +47 -0
  121. package/dist/tui/screens/frame-buffer.d.ts.map +1 -0
  122. package/dist/tui/screens/frame-buffer.js +78 -0
  123. package/dist/tui/screens/frame-buffer.js.map +1 -0
  124. package/dist/tui/screens/handoff-choice.d.ts +37 -0
  125. package/dist/tui/screens/handoff-choice.d.ts.map +1 -0
  126. package/dist/tui/screens/handoff-choice.js +41 -0
  127. package/dist/tui/screens/handoff-choice.js.map +1 -0
  128. package/dist/tui/screens/index.d.ts +8 -8
  129. package/dist/tui/screens/index.d.ts.map +1 -1
  130. package/dist/tui/screens/index.js +7 -7
  131. package/dist/tui/screens/index.js.map +1 -1
  132. package/dist/tui/screens/input.d.ts +35 -0
  133. package/dist/tui/screens/input.d.ts.map +1 -1
  134. package/dist/tui/screens/input.js +66 -0
  135. package/dist/tui/screens/input.js.map +1 -1
  136. package/dist/tui/screens/layout.d.ts +13 -3
  137. package/dist/tui/screens/layout.d.ts.map +1 -1
  138. package/dist/tui/screens/layout.js +15 -8
  139. package/dist/tui/screens/layout.js.map +1 -1
  140. package/dist/tui/screens/lines.d.ts +12 -41
  141. package/dist/tui/screens/lines.d.ts.map +1 -1
  142. package/dist/tui/screens/lines.js +87 -95
  143. package/dist/tui/screens/lines.js.map +1 -1
  144. package/dist/tui/screens/messages-view.d.ts +10 -0
  145. package/dist/tui/screens/messages-view.d.ts.map +1 -0
  146. package/dist/tui/screens/messages-view.js +115 -0
  147. package/dist/tui/screens/messages-view.js.map +1 -0
  148. package/dist/tui/screens/notice.d.ts +32 -0
  149. package/dist/tui/screens/notice.d.ts.map +1 -0
  150. package/dist/tui/screens/notice.js +46 -0
  151. package/dist/tui/screens/notice.js.map +1 -0
  152. package/dist/tui/screens/render.d.ts +2 -4
  153. package/dist/tui/screens/render.d.ts.map +1 -1
  154. package/dist/tui/screens/render.js +7 -43
  155. package/dist/tui/screens/render.js.map +1 -1
  156. package/dist/tui/screens/splash.d.ts.map +1 -1
  157. package/dist/tui/screens/splash.js +7 -1
  158. package/dist/tui/screens/splash.js.map +1 -1
  159. package/dist/tui/text.d.ts +12 -2
  160. package/dist/tui/text.d.ts.map +1 -1
  161. package/dist/tui/text.js +17 -3
  162. package/dist/tui/text.js.map +1 -1
  163. package/dist/tui/theme.d.ts +7 -1
  164. package/dist/tui/theme.d.ts.map +1 -1
  165. package/dist/tui/theme.js +15 -3
  166. package/dist/tui/theme.js.map +1 -1
  167. package/dist/types.d.ts +7 -5
  168. package/dist/types.d.ts.map +1 -1
  169. package/package.json +3 -3
  170. package/dist/auto-migrate-confirm-plugin.d.ts +0 -58
  171. package/dist/auto-migrate-confirm-plugin.d.ts.map +0 -1
  172. package/dist/auto-migrate-confirm-plugin.js +0 -61
  173. package/dist/auto-migrate-confirm-plugin.js.map +0 -1
  174. package/dist/migration-service.d.ts +0 -15
  175. package/dist/migration-service.d.ts.map +0 -1
  176. package/dist/migration-service.js +0 -59
  177. package/dist/migration-service.js.map +0 -1
  178. package/dist/tui/screens/state.d.ts +0 -13
  179. package/dist/tui/screens/state.d.ts.map +0 -1
  180. package/dist/tui/screens/state.js +0 -75
  181. package/dist/tui/screens/state.js.map +0 -1
@@ -1,30 +1,36 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // 由 src/tui/screens.tsx 拆分而来 —— 对话屏幕 Chat
3
- import { useEffect, useReducer, useRef, useState } from "react";
2
+ // 对话屏幕 —— 以 SDK 的 `agent.messages` 为唯一数据源渲染(无自定义中间模型)
3
+ import { useEffect, useRef, useState } from "react";
4
4
  import { Box, Text, useApp, useCursor, useInput, useWindowSize } from "ink";
5
+ import { keyDebug } from "../key-debug.js";
6
+ import { parseWheel, WHEEL_STEP } from "../mouse.js";
7
+ import { AgentNS } from "@ai-zen/agents-core";
5
8
  import { theme } from "../theme.js";
6
9
  import { Spinner, StatusBar, SlashMenu } from "../components.js";
7
10
  import { layoutInput, usableFrameRows, isTerminalReply } from "../text.js";
8
11
  import { ChatSession } from "../chat-session.js";
9
12
  import { matchCommandHints } from "../../conversation-commands/registry.js";
10
- import { readConfig, readMcpConfig, saveConfig, writeMcpConfig } from "../../config.js";
13
+ import { readConfig, readMcpConfig, readReasoningPresets, saveConfig, writeMcpConfig } from "../../config.js";
11
14
  import { readAgentStore } from "../../agents-store.js";
12
15
  import { resolveCredential } from "../../config-editor.js";
13
16
  import { ConfigWizard } from "../config-wizard/index.js";
14
- import { computeInputCursorPosition, wordLeft, wordRight } from "./input.js";
15
- import { computeChatLayout } from "./layout.js";
16
- import { chatReducer } from "./state.js";
17
- import { LineView, messagesToBlocks } from "./render.js";
17
+ import { computeInputCursorPosition, deleteToLineEnd, deleteToLineStart, deleteWordLeft, deleteWordRight, wordLeft, wordRight } from "./input.js";
18
+ import { deleteToPrevWhitespace, killedText, killAt, popUndo, pushKill, pushUndo, } from "../input-editing.js";
19
+ import { clampViewTop, computeChatLayout } from "./layout.js";
20
+ import { FrameBuffer } from "./frame-buffer.js";
21
+ import { LineView } from "./render.js";
22
+ import { messageToLines, transcriptLines } from "./messages-view.js";
18
23
  import { PickerOverlay, Confirm } from "./overlays.js";
24
+ import { chooseHandoffMode } from "./handoff-choice.js";
19
25
  import { runCommand } from "./chat-commands.js";
26
+ import { describeEffort } from "../../reasoning-effort.js";
27
+ import { NoticeChannel, noticeLines } from "./notice.js";
20
28
  /**
21
29
  * 计算回车时应提交的文本。
22
30
  *
23
31
  * 斜杠候选菜单可见时(输入以 `/` 开头且尚未输入参数),回车应**直接执行高亮命令**,
24
32
  * 而不是提交半截输入 —— 否则用户只输入 `/` 或未补全的命令名时,回车会落到
25
33
  * 「未知命令」报错(体验很差)。非命令输入原样返回。
26
- *
27
- * `menuIndex` 越界时回落到最后一项,避免菜单看似有选中却回车落空。
28
34
  */
29
35
  export function resolveSubmitText(input, hints, menuIndex) {
30
36
  const slash = input.startsWith("/") && !input.includes("\n") && !input.includes(" ");
@@ -38,15 +44,10 @@ export function Chat(props) {
38
44
  const { exit, suspendTerminal } = useApp();
39
45
  const { setCursorPosition } = useCursor();
40
46
  const { columns, rows } = useWindowSize();
41
- const [state, dispatch] = useReducer(chatReducer, {
42
- blocks: props.clearTranscript ? [] : messagesToBlocks(props.messages ?? []),
43
- live: null,
44
- });
45
47
  const [input, setInput] = useState(props.initialInput ?? "");
46
48
  /** 光标位置:以 code point 计的字符索引(0..len) */
47
49
  const [cursor, setCursor] = useState(Array.from(props.initialInput ?? "").length);
48
50
  const [menuIndex, setMenuIndex] = useState(0);
49
- // 输入变化后候选集随之改变:高亮重置回首项,避免索引越界(看似选中实为无高亮 / 回车落空)
50
51
  useEffect(() => {
51
52
  setMenuIndex(0);
52
53
  }, [input]);
@@ -55,13 +56,10 @@ export function Chat(props) {
55
56
  /** 配置向导:非 null 时整屏接管(凭据设置 / 配置中心) */
56
57
  const [wizardStep, setWizardStep] = useState(null);
57
58
  const [wizardConfig, setWizardConfig] = useState(null);
58
- /** 可用 Agent 列表(供配置中心「默认 Agent」选择) */
59
+ const [wizardPresets, setWizardPresets] = useState({});
59
60
  const [wizardAgents, setWizardAgents] = useState([]);
60
- /** MCP 配置快照(全局 + 项目),打开向导时刷新 */
61
61
  const [wizardMcp, setWizardMcp] = useState(null);
62
- /** Agent / Sub-agent 定义仓储(快照 + 读写),打开向导时刷新 */
63
62
  const [wizardAgentStore, setWizardAgentStore] = useState(null);
64
- /** 当前会话所用端点 id(打开向导时刷新,用于判断改动是否影响本会话) */
65
63
  const currentEndpointRef = useRef(null);
66
64
  const [history, setHistory] = useState([]);
67
65
  const historyIndex = useRef(-1);
@@ -72,36 +70,128 @@ export function Chat(props) {
72
70
  const [viewTop, setViewTop] = useState(null);
73
71
  /** 最近一次布局,供键盘回调读取总行数 / 视口高度(应用内翻页) */
74
72
  const layoutRef = useRef(null);
75
- const busy = state.live != null;
73
+ /** 本轮是否在生成中(spinner / 输入禁用) */
74
+ const [busy, setBusy] = useState(false);
75
+ /**
76
+ * 当前模型 id —— **从会话(即 Agent 定义)读**,不作为 prop 传入。
77
+ *
78
+ * 模型的唯一真相源是 `AgentDefinition.modelId`:所以状态栏、凭据检查、思考状态
79
+ * 与 SDK 实发请求用的永远是同一个值,不会出现「显示 A、实发 B」。
80
+ */
81
+ const [modelId, setModelId] = useState("");
82
+ /**
83
+ * 思考强度展示文案(关 / 低 / 中 / 高 / 最高 / 开 / —)。
84
+ *
85
+ * 真相在配置里(模型的 `defaultParams`),这里只镜像一份供状态栏用:
86
+ * `/think` 写入后由命令层回调刷新,切模型(会话重建)时靠 `modelId` 变化重读。
87
+ */
88
+ const [effortText, setEffortText] = useState(null);
89
+ /** 乐观回显:提交后、真实 user 消息落进 messages 之前,先显示用户提问 */
90
+ const [pendingUser, setPendingUser] = useState(null);
91
+ /** 转录显示起点(/clear 清屏用:隐藏此索引之前的消息,但不改上下文) */
92
+ const [transcriptFrom, setTranscriptFrom] = useState(0);
93
+ // ---- Claude Code 同款编辑状态 ----
94
+ /** kill ring:Ctrl+W/U/K 删掉的文本,Ctrl+Y 取回、Alt+Y 循环 */
95
+ const killRingRef = useRef([]);
96
+ const yankIndexRef = useRef(-1);
97
+ /** 编辑撤销栈(Ctrl+_) */
98
+ const undoStackRef = useRef([]);
99
+ /** 暂存区(Ctrl+S) */
100
+ const stashRef = useRef(null);
101
+ /** Ctrl+D 双击退出计时 */
102
+ const ctrlDAtRef = useRef(0);
103
+ /**
104
+ * 通知通道:notice / error 走独立栏位,绝不进对话转录(messages)。
105
+ */
106
+ const noticeChannelRef = useRef(null);
107
+ if (noticeChannelRef.current == null)
108
+ noticeChannelRef.current = new NoticeChannel();
109
+ const noticeChannel = noticeChannelRef.current;
110
+ const [notice, setNotice] = useState(null);
111
+ useEffect(() => noticeChannel.subscribe(setNotice), [noticeChannel]);
112
+ /**
113
+ * 重挂载附带的系统提示(如 `/model` 的切换结果)。
114
+ *
115
+ * 用 ref 而非 props 直读:本值只在首次挂载消费一次,若写进 create 的依赖数组,
116
+ * 会把「展示一条提示」误变成「重建会话」。
117
+ */
118
+ const initialNoticeRef = useRef(props.initialNotice);
119
+ /**
120
+ * 渲染信号 + 合帧:SDK 内容变更 → FrameBuffer 合帧 → setTick 触发重渲染。
121
+ * 转录内容始终从 `agent.messages` 现取,tick 只负责「告诉 React 该重画了」。
122
+ */
123
+ const [, setTick] = useState(0);
124
+ const frameBufferRef = useRef(null);
125
+ if (frameBufferRef.current == null) {
126
+ frameBufferRef.current = new FrameBuffer(() => setTick((t) => t + 1));
127
+ }
128
+ // 思考强度展示:从当前模型的 defaultParams 派生;`/think` 写入后由命令层回调刷新,
129
+ // 切模型(会话重建)时靠 modelId 变化重读。
130
+ useEffect(() => {
131
+ if (!modelId)
132
+ return;
133
+ let alive = true;
134
+ // 档位存在 Agent 定义上(`modelParams`),可选档位在策略表里(`reasoningPreset` 指向),两者都要读
135
+ Promise.all([readConfig(), readAgentStore(), readReasoningPresets()])
136
+ .then(([config, store, presets]) => {
137
+ const model = config.models?.find((item) => item.id === modelId);
138
+ const agentId = props.agentId ?? "default";
139
+ const definition = store.agents.find((item) => item.id === agentId) ?? null;
140
+ if (alive)
141
+ setEffortText(describeEffort(model, presets, definition));
142
+ })
143
+ .catch(() => {
144
+ if (alive)
145
+ setEffortText(null);
146
+ });
147
+ return () => {
148
+ alive = false;
149
+ };
150
+ }, [modelId, props.agentId]);
76
151
  // 创建会话
77
152
  useEffect(() => {
78
153
  let disposed = false;
154
+ const notifier = {
155
+ onChange: (immediate) => {
156
+ if (disposed)
157
+ return;
158
+ frameBufferRef.current?.push(null, immediate);
159
+ },
160
+ onNotice: (text, tone) => {
161
+ if (!disposed)
162
+ noticeChannel.push(text, tone);
163
+ },
164
+ };
79
165
  ChatSession.create({
80
- modelId: props.modelId,
81
166
  agentId: props.agentId,
82
167
  messages: props.messages,
83
168
  conversationId: props.conversationId,
84
169
  conversationName: props.conversationName,
85
- requestConfirm: (question) => new Promise((resolve) => setConfirmState({ question, resolve })),
86
- }, (event) => {
87
- if (!disposed)
88
- dispatch(event);
89
- })
170
+ // 自动迁移:与手动 /migrate、/handoff 共用同一套「交接方式三选一」选择器
171
+ requestMode: (info) => chooseHandoffMode(setPickerState, `上下文即将超限(${info.promptTokens}/${info.handoffTokens} tokens)· 选择交接方式`),
172
+ }, notifier)
90
173
  .then((session) => {
91
174
  if (disposed) {
92
175
  session.dispose();
93
176
  return;
94
177
  }
95
178
  sessionRef.current = session;
179
+ setModelId(session.modelId);
96
180
  setReady(true);
181
+ // 重挂载附带的提示只需展示一次(消费后置空,避免再次触发)
182
+ if (initialNoticeRef.current) {
183
+ noticeChannel.push(initialNoticeRef.current);
184
+ initialNoticeRef.current = undefined;
185
+ }
186
+ setTick((t) => t + 1);
97
187
  })
98
188
  .catch((error) => setBootError(error?.message ?? String(error)));
99
189
  return () => {
100
190
  disposed = true;
191
+ frameBufferRef.current?.dispose();
101
192
  sessionRef.current?.dispose();
102
193
  };
103
194
  }, [
104
- props.modelId,
105
195
  props.agentId,
106
196
  props.messages,
107
197
  props.conversationId,
@@ -120,32 +210,29 @@ export function Chat(props) {
120
210
  });
121
211
  };
122
212
  // ---- 配置向导(凭据 / 配置中心)----
123
- /** 打开配置向导:先取配置 / MCP / Agent 快照,再切入指定步骤 */
124
213
  const openWizard = async (step) => {
125
214
  try {
126
215
  const config = await readConfig();
127
- currentEndpointRef.current = resolveCredential(config, props.modelId)?.endpointId ?? null;
216
+ currentEndpointRef.current = resolveCredential(config, modelId)?.endpointId ?? null;
128
217
  const agentStore = await readAgentStore();
129
218
  setWizardConfig(config);
219
+ setWizardPresets(await readReasoningPresets());
130
220
  setWizardAgents(agentStore.agents.map((agent) => ({ id: agent.id, name: agent.name || agent.id })));
131
221
  setWizardAgentStore(agentStore);
132
222
  setWizardMcp({ global: await readMcpConfig("global"), project: await readMcpConfig("project") });
133
223
  setWizardStep(step);
134
224
  }
135
225
  catch (error) {
136
- dispatch({ type: "error", text: `读取配置失败:${error?.message ?? error}` });
226
+ noticeChannel.push(`读取配置失败:${error?.message ?? error}`, "error");
137
227
  }
138
228
  };
139
229
  /** `/key`:直达「当前模型所用端点」的 API Key 编辑步骤 */
140
230
  const openKeyEditor = async () => {
141
231
  try {
142
232
  const config = await readConfig();
143
- const credential = resolveCredential(config, props.modelId);
233
+ const credential = resolveCredential(config, modelId);
144
234
  if (!credential) {
145
- dispatch({
146
- type: "error",
147
- text: `模型 ${props.modelId} 未绑定可用端点,请用 /config 检查配置`,
148
- });
235
+ noticeChannel.push(`模型 ${modelId} 未绑定可用端点,请用 /config 检查配置`, "error");
149
236
  return;
150
237
  }
151
238
  currentEndpointRef.current = credential.endpointId;
@@ -153,7 +240,7 @@ export function Chat(props) {
153
240
  setWizardStep({ kind: "edit", field: "apiKey", endpointId: credential.endpointId });
154
241
  }
155
242
  catch (error) {
156
- dispatch({ type: "error", text: `读取配置失败:${error?.message ?? error}` });
243
+ noticeChannel.push(`读取配置失败:${error?.message ?? error}`, "error");
157
244
  }
158
245
  };
159
246
  /** 向导关闭:渲染变更摘要;若改到当前端点则重建会话(消息保留) */
@@ -163,10 +250,9 @@ export function Chat(props) {
163
250
  setWizardMcp(null);
164
251
  setWizardAgentStore(null);
165
252
  for (const line of result.summary)
166
- dispatch({ type: "notice", text: `配置已更新:${line}` });
253
+ noticeChannel.push(`配置已更新:${line}`);
167
254
  if (!result.dirty)
168
255
  return;
169
- // 会话尚未建立(启动失败进入向导):重挂载对话屏重试
170
256
  if (!sessionRef.current) {
171
257
  exit({ restart: true, messages: props.messages });
172
258
  return;
@@ -175,50 +261,53 @@ export function Chat(props) {
175
261
  if (endpointChanged || result.mcpChanged || result.agentsChanged) {
176
262
  sessionRef.current
177
263
  .reload()
178
- .then(() => dispatch({ type: "notice", text: "已按新配置重建会话(消息与上下文保留)" }))
179
- .catch((error) => dispatch({ type: "error", text: `重建会话失败:${error?.message ?? error}` }));
264
+ .then(() => noticeChannel.push("已按新配置重建会话(消息与上下文保留)"))
265
+ .catch((error) => noticeChannel.push(`重建会话失败:${error?.message ?? error}`, "error"));
180
266
  }
181
267
  };
182
268
  // ---- 滚动视口控制(应用内翻页,回看历史)----
183
- /** 跳到指定顶部行;越界钳制;一旦贴到底部则恢复「自动跟随」 */
184
269
  const scrollTo = (nextTop) => {
185
270
  const L = layoutRef.current;
186
271
  if (!L)
187
272
  return;
188
- const maxTop = Math.max(0, L.totalLines - L.budget);
189
- const clamped = Math.max(0, Math.min(maxTop, nextTop));
190
- setViewTop(clamped >= maxTop ? null : clamped);
273
+ setViewTop(clampViewTop(L.totalLines, L.budget, nextTop));
191
274
  };
192
- /** 翻页滚动(dir:-1 上翻 / +1 下翻) */
193
275
  const scrollByPage = (dir) => {
194
276
  const L = layoutRef.current;
195
277
  if (!L)
196
278
  return;
197
279
  scrollTo(L.viewTop + dir * Math.max(1, L.budget - 1));
198
280
  };
199
- /** 单行滚动(dir:-1 上 / +1 下) */
200
281
  const scrollByLine = (dir) => {
201
282
  const L = layoutRef.current;
202
283
  if (!L)
203
284
  return;
204
285
  scrollTo(L.viewTop + dir);
205
286
  };
206
- /** 回到最新(贴底,恢复自动跟随) */
287
+ /** 鼠标滚轮:一格滚 `WHEEL_STEP` 行(与按行滚动共用一个钳制入口) */
288
+ const scrollByWheel = (dir) => {
289
+ const L = layoutRef.current;
290
+ if (!L)
291
+ return;
292
+ scrollTo(L.viewTop + dir * WHEEL_STEP);
293
+ };
207
294
  const scrollToBottom = () => setViewTop(null);
295
+ /** 清屏但保留上下文:把显示起点推到当前消息末尾 */
296
+ const clearTranscript = () => {
297
+ setTranscriptFrom(sessionRef.current?.messages.length ?? 0);
298
+ setNotice(null);
299
+ };
208
300
  // ---- 输入编辑(光标感知)----
209
- /** 替换整个输入并把光标移到末尾 */
210
301
  const setInputText = (text) => {
211
302
  setInput(text);
212
303
  setCursor(Array.from(text).length);
213
304
  };
214
- /** 在光标处插入文本 */
215
305
  const insertAtCursor = (text) => {
216
306
  const chars = Array.from(input);
217
307
  const cp = Math.max(0, Math.min(cursor, chars.length));
218
308
  setInput(chars.slice(0, cp).join("") + text + chars.slice(cp).join(""));
219
309
  setCursor(cp + Array.from(text).length);
220
310
  };
221
- /** 删除光标前一个字符(Backspace) */
222
311
  const deleteBeforeCursor = () => {
223
312
  const chars = Array.from(input);
224
313
  const cp = Math.max(0, Math.min(cursor, chars.length));
@@ -227,7 +316,6 @@ export function Chat(props) {
227
316
  setInput(chars.slice(0, cp - 1).join("") + chars.slice(cp).join(""));
228
317
  setCursor(cp - 1);
229
318
  };
230
- /** 删除光标后一个字符(Delete) */
231
319
  const deleteAfterCursor = () => {
232
320
  const chars = Array.from(input);
233
321
  const cp = Math.max(0, Math.min(cursor, chars.length));
@@ -235,12 +323,73 @@ export function Chat(props) {
235
323
  return;
236
324
  setInput(chars.slice(0, cp).join("") + chars.slice(cp + 1).join(""));
237
325
  };
238
- /** 以字符为单位移动光标 */
326
+ /** 套用纯函数算好的「文本 + 光标」结果(无变化则不触发重渲染) */
327
+ const applyEdit = (r) => {
328
+ if (r.text === input && r.cursor === cursor)
329
+ return;
330
+ undoStackRef.current = pushUndo(undoStackRef.current, { text: input, cursor });
331
+ setInput(r.text);
332
+ setCursor(r.cursor);
333
+ };
334
+ /** 删除类编辑:额外把删掉的文本放进 kill ring(Ctrl+Y 可取回) */
335
+ const applyKill = (r) => {
336
+ if (r.text === input && r.cursor === cursor)
337
+ return;
338
+ killRingRef.current = pushKill(killRingRef.current, killedText(input, r.text));
339
+ yankIndexRef.current = -1;
340
+ applyEdit(r);
341
+ };
342
+ const deleteWordBeforeCursor = () => applyKill(deleteWordLeft(input, cursor));
343
+ const deleteWordAfterCursor = () => applyKill(deleteWordRight(input, cursor));
344
+ const deleteToEndOfLine = () => applyKill(deleteToLineEnd(input, cursor));
345
+ const deleteToStartOfLine = () => applyKill(deleteToLineStart(input, cursor));
346
+ /** Ctrl+W:删到前一空白(标点不切分,整条路径一次删掉) */
347
+ const deletePrevWhitespace = () => applyKill(deleteToPrevWhitespace(input, cursor));
348
+ /** Ctrl+Y / Alt+Y:插入 kill ring 中的一段(index 循环) */
349
+ const yank = (advance) => {
350
+ const ring = killRingRef.current;
351
+ if (ring.length === 0)
352
+ return;
353
+ const next = advance ? yankIndexRef.current + 1 : 0;
354
+ if (next >= ring.length)
355
+ return;
356
+ yankIndexRef.current = next;
357
+ const text = killAt(ring, next) ?? "";
358
+ insertAtCursor(text);
359
+ };
360
+ /** Ctrl+_:撤销上一次编辑 */
361
+ const undoEdit = () => {
362
+ const popped = popUndo(undoStackRef.current);
363
+ if (!popped)
364
+ return;
365
+ undoStackRef.current = popped.rest;
366
+ setInput(popped.state.text);
367
+ setCursor(popped.state.cursor);
368
+ };
369
+ /** Ctrl+S:暂存 / 恢复输入 */
370
+ const toggleStash = () => {
371
+ if (input.length > 0) {
372
+ stashRef.current = { text: input, cursor };
373
+ setInputText("");
374
+ noticeChannel.push("已暂存输入(Ctrl+S 恢复)");
375
+ return;
376
+ }
377
+ const stashed = stashRef.current;
378
+ if (!stashed)
379
+ return;
380
+ stashRef.current = null;
381
+ setInput(stashed.text);
382
+ setCursor(stashed.cursor);
383
+ noticeChannel.push("已恢复暂存的输入");
384
+ };
385
+ /** Ctrl+V:粘粘图片 —— 暂未支持(需先做「文件 → 内容块」能力探测,见 SKILL/后续迭代) */
386
+ const pasteImageUnsupported = () => {
387
+ noticeChannel.push("暂不支持粘贴图片(Ctrl+V 预留给图片粘贴,当前未实现)", "error");
388
+ };
239
389
  const moveCursor = (delta) => {
240
390
  const len = Array.from(input).length;
241
391
  setCursor((c) => Math.max(0, Math.min(len, c + delta)));
242
392
  };
243
- /** 光标移到当前逻辑行行首(Home) */
244
393
  const moveCursorToLineStart = () => {
245
394
  const chars = Array.from(input);
246
395
  const cp = Math.max(0, Math.min(cursor, chars.length));
@@ -249,7 +398,6 @@ export function Chat(props) {
249
398
  i -= 1;
250
399
  setCursor(i);
251
400
  };
252
- /** 光标移到当前逻辑行行尾(End) */
253
401
  const moveCursorToLineEnd = () => {
254
402
  const chars = Array.from(input);
255
403
  const cp = Math.max(0, Math.min(cursor, chars.length));
@@ -268,33 +416,56 @@ export function Chat(props) {
268
416
  setInputText("");
269
417
  historyIndex.current = -1;
270
418
  scrollToBottom();
419
+ setNotice(null);
271
420
  if (trimmed.startsWith("/")) {
272
- const name = trimmed.slice(1).toLowerCase();
273
- await runCommand(name, trimmed, {
274
- session: sessionRef.current,
275
- dispatch,
276
- rows: rows ?? 24,
277
- exit,
278
- suspendTerminal,
279
- openWizard,
280
- openKeyEditor,
281
- doExit,
282
- setPickerState,
283
- setConfirmState,
284
- setInputText,
285
- });
421
+ // 命令名取首个 token:支持 `/migrate summary`、`/handoff self` 这类带参数的写法
422
+ const name = trimmed.slice(1).split(/\s+/)[0].toLowerCase();
423
+ // 交接/迁移会调用模型写文档(较慢):期间应像自动迁移一样拦住输入框。
424
+ const blocksInput = name === "migrate" || name === "migration" || name === "handoff";
425
+ if (blocksInput)
426
+ setBusy(true);
427
+ try {
428
+ await runCommand(name, trimmed, {
429
+ session: sessionRef.current,
430
+ notice: (text, tone) => noticeChannel.push(text, tone),
431
+ clearTranscript,
432
+ rows: rows ?? 24,
433
+ exit,
434
+ suspendTerminal,
435
+ openWizard,
436
+ openKeyEditor,
437
+ doExit,
438
+ setPickerState,
439
+ setConfirmState,
440
+ setInputText,
441
+ setEffortText,
442
+ });
443
+ }
444
+ finally {
445
+ if (blocksInput)
446
+ setBusy(false);
447
+ }
286
448
  return;
287
449
  }
288
450
  setHistory((h) => [...h, trimmed]);
289
- await sessionRef.current.send(trimmed);
451
+ const base = sessionRef.current.messages.filter((m) => m.role === AgentNS.Role.User).length;
452
+ setPendingUser({ text: trimmed, base });
453
+ setBusy(true);
454
+ try {
455
+ await sessionRef.current.send(trimmed);
456
+ }
457
+ finally {
458
+ setBusy(false);
459
+ setPendingUser(null);
460
+ }
290
461
  };
291
462
  const doExit = () => {
292
- // 会话已由 ConversationPersistPlugin 实时落盘,退出无需再询问是否保存
293
463
  exit("done");
294
464
  };
295
465
  // 输入处理
296
466
  useInput((char, key) => {
297
- // 启动失败:给出「按 k 打开配置中心」的出路,而不是只能退出
467
+ // 按键诊断(AIZ_DEBUG_KEYS=1):必须在任何 return 之前记录,否则被吞掉的键不会留痕
468
+ keyDebug(char, key);
298
469
  if (bootError) {
299
470
  if (char === "k" || char === "K") {
300
471
  void openWizard({ kind: "menu" });
@@ -304,10 +475,16 @@ export function Chat(props) {
304
475
  exit("done");
305
476
  return;
306
477
  }
307
- // 终端应答序列(如 Kitty 查询应答 `[?0u`)不是用户输入,丢弃
478
+ // 鼠标滚轮:滚视口。**必须**排在下面 `isTerminalReply` 之前——那里会把整条鼠标序列
479
+ // 当「终端上报」吃掉,放后面就永远收不到滚轮。其余鼠标事件(点击 / 拖拽 / 横向滚轮)
480
+ // 交给 `isTerminalReply` 丢弃,绝不落下到输入框。
481
+ const wheel = parseWheel(char);
482
+ if (wheel) {
483
+ scrollByWheel(wheel.direction);
484
+ return;
485
+ }
308
486
  if (isTerminalReply(char))
309
487
  return;
310
- // 翻页滚动(回看历史)
311
488
  if (key.pageUp) {
312
489
  scrollByPage(-1);
313
490
  return;
@@ -316,43 +493,147 @@ export function Chat(props) {
316
493
  scrollByPage(1);
317
494
  return;
318
495
  }
319
- // 换行:Ctrl+Enter / Alt+Enter / Shift+Enter
320
- // (仅当终端**原生**以 Kitty CSI-u 序列上报这些组合键时才生效 —— 本 CLI 不再
321
- // 主动查询/开启该协议,见 tui/index.tsx 的 RENDER_OPTIONS;换行主推 Ctrl+N)
496
+ // 翻页的「兜底键」:部分终端把 PgUp/PgDn 留给自己滚 scrollback(应用收不到)。
497
+ // 注意 `Ctrl+V` 预留给图片粘贴(当前未实现),不再兼任 page-down。
498
+ if (char === "v" && key.meta) {
499
+ scrollByPage(-1);
500
+ return;
501
+ }
502
+ // macOS 习惯键:⌘[ / ⌘] 翻页、⌘; / ⌘' 按行滚动。
503
+ // Cmd 只在 kitty 键盘协议下才会带 super 位(或终端把 ⌘ 组合映射成带 8 号
504
+ // 修饰位的转义序列,那种会落到上面的 meta 分支里,同样生效)。
505
+ if (key.super && (char === "[" || char === "]")) {
506
+ scrollByPage(char === "[" ? -1 : 1);
507
+ return;
508
+ }
509
+ if (key.super && (char === ";" || char === "'")) {
510
+ scrollByLine(char === ";" ? -1 : 1);
511
+ return;
512
+ }
322
513
  if (key.return && (key.ctrl || key.meta || key.shift)) {
323
514
  insertAtCursor("\n");
324
515
  return;
325
516
  }
326
517
  if (char === "c" && key.ctrl) {
518
+ // Claude Code:生成中中断;空闲时有草稿就先清空,已空则退出
327
519
  if (busy)
328
520
  sessionRef.current?.abort();
521
+ else if (input.length > 0) {
522
+ setInputText("");
523
+ noticeChannel.push("已清空输入 · 再按一次 Ctrl+C 退出");
524
+ }
329
525
  else
330
526
  void doExit();
331
527
  return;
332
528
  }
333
529
  if (char === "d" && key.ctrl) {
334
- void doExit();
530
+ // Claude Code:有草稿时删光标后一个字符;空输入需双击确认退出
531
+ if (input.length > 0) {
532
+ deleteAfterCursor();
533
+ return;
534
+ }
535
+ const now = Date.now();
536
+ if (now - ctrlDAtRef.current < 800) {
537
+ void doExit();
538
+ return;
539
+ }
540
+ ctrlDAtRef.current = now;
541
+ noticeChannel.push("再按一次 Ctrl+D 退出");
335
542
  return;
336
543
  }
337
544
  if (char === "l" && key.ctrl) {
338
- exit({ restart: true, messages: sessionRef.current?.messages, clearTranscript: true });
545
+ clearTranscript();
339
546
  return;
340
547
  }
548
+ // ---- kill / yank / undo / stash(照抄 Claude Code / readline)----
341
549
  if (char === "u" && key.ctrl) {
342
- setInputText("");
550
+ // 删到行首。macOS 上 ⌘⌫ 会被终端映射成 Ctrl+U,故不能做成「清空全部」
551
+ deleteToStartOfLine();
552
+ return;
553
+ }
554
+ if (char === "k" && key.ctrl) {
555
+ deleteToEndOfLine();
556
+ return;
557
+ }
558
+ if (char === "w" && key.ctrl) {
559
+ // 删到前一空白(标点不切分:`src/utils/foo.ts` 一次删完)
560
+ deletePrevWhitespace();
561
+ return;
562
+ }
563
+ if (char === "y" && key.ctrl) {
564
+ yank(false);
565
+ return;
566
+ }
567
+ if (char === "y" && key.meta) {
568
+ yank(true);
569
+ return;
570
+ }
571
+ // Ctrl+_ / Ctrl+Shift+-:撤销上一次输入编辑
572
+ if ((char === "_" && key.ctrl) || (char === "-" && key.ctrl && key.shift)) {
573
+ undoEdit();
574
+ return;
575
+ }
576
+ if (char === "s" && key.ctrl) {
577
+ toggleStash();
578
+ return;
579
+ }
580
+ if ((char === "v" && key.ctrl) || (key.super && (char === "v" || char === "V"))) {
581
+ pasteImageUnsupported();
582
+ return;
583
+ }
584
+ // readline 的行首 / 行尾(macOS 上部分终端把 ⌘← / ⌘→ 发成 ^A / ^E)
585
+ if (char === "a" && key.ctrl) {
586
+ moveCursorToLineStart();
587
+ return;
588
+ }
589
+ if (char === "e" && key.ctrl) {
590
+ moveCursorToLineEnd();
343
591
  return;
344
592
  }
345
593
  if (char === "j" && key.ctrl) {
346
594
  insertAtCursor("\n");
347
595
  return;
348
596
  }
349
- // 换行:Ctrl+N(不依赖终端协议,兼容性最好)
350
- if (char === "n" && key.ctrl) {
351
- insertAtCursor("\n");
597
+ // Ctrl+P / Ctrl+N:历史上下(Claude Code 语义;换行改用 Ctrl+J / Alt+Enter)
598
+ if ((char === "p" && key.ctrl) || (char === "n" && key.ctrl)) {
599
+ const up = char === "p";
600
+ if (hints.length > 0) {
601
+ setMenuIndex((i) => (up ? (i - 1 + hints.length) % hints.length : (i + 1) % hints.length));
602
+ return;
603
+ }
604
+ if (up && history.length > 0) {
605
+ const idx = historyIndex.current < 0 ? history.length - 1 : Math.max(0, historyIndex.current - 1);
606
+ historyIndex.current = idx;
607
+ setInputText(history[idx]);
608
+ }
609
+ else if (!up && historyIndex.current >= 0) {
610
+ const idx = historyIndex.current + 1;
611
+ if (idx >= history.length) {
612
+ historyIndex.current = -1;
613
+ setInputText("");
614
+ }
615
+ else {
616
+ historyIndex.current = idx;
617
+ setInputText(history[idx]);
618
+ }
619
+ }
620
+ return;
621
+ }
622
+ // 按词删除:Alt+Backspace / Ctrl+Backspace 删前一个词;Alt+D / Ctrl+Delete 删后一个词。
623
+ // 必须在下面「单字符 Backspace/Delete」之前判定,否则带修饰键的删除只掉一个字符。
624
+ if (key.backspace && (key.ctrl || key.meta || key.super)) {
625
+ deleteWordBeforeCursor();
626
+ return;
627
+ }
628
+ if (key.delete && (key.ctrl || key.meta || key.super)) {
629
+ deleteWordAfterCursor();
630
+ return;
631
+ }
632
+ if (char === "d" && key.meta) {
633
+ deleteWordAfterCursor();
352
634
  return;
353
635
  }
354
636
  if (key.return) {
355
- // 斜杠候选菜单可见时回车直接执行高亮命令,避免把半截输入(如仅 `/`)当命令提交
356
637
  void runSubmit(resolveSubmitText(input, hints, menuIndex));
357
638
  return;
358
639
  }
@@ -361,7 +642,6 @@ export function Chat(props) {
361
642
  return;
362
643
  }
363
644
  if (key.upArrow || key.downArrow) {
364
- // Shift / Alt + ↑↓:单行滚动(优先于历史召回)
365
645
  if (key.shift || key.meta) {
366
646
  scrollByLine(key.upArrow ? -1 : 1);
367
647
  return;
@@ -370,7 +650,6 @@ export function Chat(props) {
370
650
  setMenuIndex((i) => key.upArrow ? (i - 1 + hints.length) % hints.length : (i + 1) % hints.length);
371
651
  return;
372
652
  }
373
- // 历史召回
374
653
  if (key.upArrow && history.length > 0) {
375
654
  const idx = historyIndex.current < 0 ? history.length - 1 : Math.max(0, historyIndex.current - 1);
376
655
  historyIndex.current = idx;
@@ -389,21 +668,31 @@ export function Chat(props) {
389
668
  }
390
669
  return;
391
670
  }
392
- // 光标移动:← → 单字符,Ctrl/Alt+← → 跨词,Home / End 到行首 / 行尾
393
671
  if (key.leftArrow) {
394
- if (key.ctrl || key.meta)
672
+ if (key.ctrl || key.meta || key.super)
395
673
  setCursor(wordLeft(input, cursor));
396
674
  else
397
675
  moveCursor(-1);
398
676
  return;
399
677
  }
400
678
  if (key.rightArrow) {
401
- if (key.ctrl || key.meta)
679
+ if (key.ctrl || key.meta || key.super)
402
680
  setCursor(wordRight(input, cursor));
403
681
  else
404
682
  moveCursor(1);
405
683
  return;
406
684
  }
685
+ // 跨词移动的「兜底键」:macOS 上 Option+←/→ 往往发的是 ESC b / ESC f
686
+ // (readline 的 backward-word / forward-word),并不带 leftArrow 标记;
687
+ // 而 Ctrl+←/→ 常被系统「Mission Control → 左右切换空间」截走,应用根本收不到。
688
+ if (char === "b" && key.meta) {
689
+ setCursor(wordLeft(input, cursor));
690
+ return;
691
+ }
692
+ if (char === "f" && key.meta) {
693
+ setCursor(wordRight(input, cursor));
694
+ return;
695
+ }
407
696
  if (key.home) {
408
697
  moveCursorToLineStart();
409
698
  return;
@@ -413,8 +702,13 @@ export function Chat(props) {
413
702
  return;
414
703
  }
415
704
  if (key.escape) {
416
- setInputText("");
417
- scrollToBottom();
705
+ // Claude Code:生成中 Esc = 中断本轮;否则清空草稿并回到底部
706
+ if (busy)
707
+ sessionRef.current?.abort();
708
+ else {
709
+ setInputText("");
710
+ scrollToBottom();
711
+ }
418
712
  return;
419
713
  }
420
714
  if (key.backspace) {
@@ -425,13 +719,72 @@ export function Chat(props) {
425
719
  deleteAfterCursor();
426
720
  return;
427
721
  }
428
- if (char && !key.ctrl && !key.meta) {
722
+ if (char && !key.ctrl && !key.meta && !key.super) {
429
723
  insertAtCursor(char);
430
724
  }
431
- }, { isActive: !confirmState && !pickerState && !wizardStep });
725
+ },
726
+ // 生成 / 自动迁移期间主输入框整体失活:此时输入框已被 Spinner 顶替,若仍激活,
727
+ // 按键会被静默吞进隐藏的输入缓冲、待本轮结束后才冒出来(即「迁移过程中输入框
728
+ // 没被拦住」)。失活后改由下方「忙碌期」处理器只放行中止 / 退出 / 回看滚动。
729
+ { isActive: !busy && !confirmState && !pickerState && !wizardStep });
730
+ // 忙碌期(生成 / 迁移中)专用:输入框不可编辑,仅保留「中止(Ctrl+C) / 退出(Ctrl+D) / 回看滚动」。
731
+ useInput((char, key) => {
732
+ keyDebug(char, key);
733
+ // 忙碌期只剩「中止 / 退出 / 回看」,滚轮同样生效(生成时正是最想往回看的时机)
734
+ const wheel = parseWheel(char);
735
+ if (wheel) {
736
+ scrollByWheel(wheel.direction);
737
+ return;
738
+ }
739
+ if (isTerminalReply(char))
740
+ return;
741
+ if (char === "c" && key.ctrl) {
742
+ sessionRef.current?.abort();
743
+ return;
744
+ }
745
+ if (key.escape) {
746
+ // 忙碌期 Esc 也充当「中断」(与 Claude Code 一致)
747
+ sessionRef.current?.abort();
748
+ return;
749
+ }
750
+ if (char === "d" && key.ctrl) {
751
+ const now = Date.now();
752
+ if (now - ctrlDAtRef.current < 800) {
753
+ void doExit();
754
+ return;
755
+ }
756
+ ctrlDAtRef.current = now;
757
+ noticeChannel.push("再按一次 Ctrl+D 退出");
758
+ return;
759
+ }
760
+ if (key.pageUp) {
761
+ scrollByPage(-1);
762
+ return;
763
+ }
764
+ if (key.pageDown) {
765
+ scrollByPage(1);
766
+ return;
767
+ }
768
+ if ((key.upArrow || key.downArrow) && (key.shift || key.meta || key.super)) {
769
+ scrollByLine(key.upArrow ? -1 : 1);
770
+ return;
771
+ }
772
+ if (char === "v" && (key.meta || key.super)) {
773
+ scrollByPage(-1);
774
+ return;
775
+ }
776
+ if (key.super && (char === "[" || char === "]")) {
777
+ scrollByPage(char === "[" ? -1 : 1);
778
+ return;
779
+ }
780
+ if (key.super && (char === ";" || char === "'")) {
781
+ scrollByLine(char === ";" ? -1 : 1);
782
+ return;
783
+ }
784
+ }, { isActive: busy && !confirmState && !pickerState && !wizardStep });
432
785
  // 配置向导:整屏接管(凭据设置 / 配置中心),关闭后回到对话屏
433
786
  if (wizardStep && wizardConfig) {
434
- return (_jsx(ConfigWizard, { config: wizardConfig, initialStep: wizardStep, closeOnSave: wizardStep.kind !== "menu", agents: wizardAgents, mcp: wizardMcp ?? undefined, agentStore: wizardAgentStore ?? undefined, onSave: saveConfig, onSaveMcp: async (scope, cfg) => {
787
+ return (_jsx(ConfigWizard, { config: wizardConfig, reasoningPresets: wizardPresets, initialStep: wizardStep, closeOnSave: wizardStep.kind !== "menu", agents: wizardAgents, mcp: wizardMcp ?? undefined, agentStore: wizardAgentStore ?? undefined, onSave: saveConfig, onSaveMcp: async (scope, cfg) => {
435
788
  await writeMcpConfig(cfg, scope);
436
789
  }, onClose: closeWizard }));
437
790
  }
@@ -440,16 +793,25 @@ export function Chat(props) {
440
793
  return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { color: theme.error, wrap: "truncate", children: ["\u2716 \u65E0\u6CD5\u542F\u52A8\u5BF9\u8BDD\uFF1A", bootError] }), _jsx(Text, { color: theme.dim, wrap: "truncate", children: "\u6309 k \u6253\u5F00\u914D\u7F6E\u4E2D\u5FC3\uFF08\u53EF\u8BBE\u7F6E API Key / \u7AEF\u70B9\u5730\u5740\uFF09\u00B7 Enter \u8FD4\u56DE" })] }));
441
794
  }
442
795
  const session = sessionRef.current;
443
- const tokens = session?.lastUsage?.total_tokens;
796
+ const lastTokens = session?.lastUsage?.total_tokens;
797
+ const totalTokens = session?.totalUsage.total_tokens;
444
798
  const cols = columns ?? 80;
799
+ const messages = session?.messages ?? [];
800
+ const userCount = messages.filter((m) => m.role === AgentNS.Role.User).length;
801
+ const showPending = pendingUser != null && userCount <= pendingUser.base;
802
+ const transcript = transcriptLines(messages.slice(transcriptFrom), cols);
803
+ if (showPending) {
804
+ transcript.push(...messageToLines({ id: "__pending_user", role: AgentNS.Role.User, content: pendingUser.text }, cols));
805
+ }
806
+ const noticeTextLines = notice ? noticeLines(notice, cols) : [];
445
807
  const layout = computeChatLayout({
446
808
  columns: cols,
447
809
  rows: usableFrameRows(rows ?? 24),
448
- blocks: state.blocks,
449
- live: state.live,
810
+ transcript,
450
811
  input,
451
812
  busy,
452
813
  hintCount: hints.length,
814
+ noticeCount: noticeTextLines.length,
453
815
  overlay: confirmState ? "confirm" : pickerState ? "picker" : "none",
454
816
  pickerItems: pickerState?.items.length ?? 0,
455
817
  viewTop,
@@ -472,7 +834,7 @@ export function Chat(props) {
472
834
  else {
473
835
  setCursorPosition(undefined);
474
836
  }
475
- return (_jsxs(Box, { flexDirection: "column", width: cols, children: [layout.lines.map((line) => (_jsx(LineView, { line: line }, line.key))), confirmState ? (_jsx(Confirm, { question: confirmState.question, onAnswer: resolveConfirm })) : pickerState ? (_jsx(PickerOverlay, { title: pickerState.title, items: pickerState.items, columns: cols, onPick: resolvePicker })) : (_jsxs(Box, { flexDirection: "column", children: [hints.length > 0 ? _jsx(SlashMenu, { hints: hints, selected: menuIndex }) : null, _jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.border, wrap: "truncate", children: "─".repeat(Math.max(1, cols - 2)) }) }), _jsx(Box, { flexDirection: "column", paddingX: 1, children: busy ? (_jsx(Spinner, { label: "\u601D\u8003\u4E2D\u2026" })) : (bodyLines.map((line, i) => {
837
+ return (_jsxs(Box, { flexDirection: "column", width: cols, children: [layout.lines.map((line) => (_jsx(LineView, { line: line }, line.key))), confirmState ? (_jsx(Confirm, { question: confirmState.question, onAnswer: resolveConfirm })) : pickerState ? (_jsx(PickerOverlay, { title: pickerState.title, items: pickerState.items, columns: cols, onPick: resolvePicker })) : (_jsxs(Box, { flexDirection: "column", children: [noticeTextLines.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingX: 1, children: noticeTextLines.map((line, i) => (_jsx(Text, { color: notice?.tone === "error" ? theme.error : theme.dim, wrap: "truncate", children: line }, i))) })) : null, hints.length > 0 ? _jsx(SlashMenu, { hints: hints, selected: menuIndex }) : null, _jsx(Box, { paddingX: 1, children: _jsx(Text, { color: theme.border, wrap: "truncate", children: "─".repeat(Math.max(1, cols - 2)) }) }), _jsx(Box, { flexDirection: "column", paddingX: 1, children: busy ? (_jsx(Spinner, { label: "\u601D\u8003\u4E2D\u2026" })) : (bodyLines.map((line, i) => {
476
838
  const prompt = i === 0 ? (_jsx(Text, { color: theme.brand[0], bold: true, children: "❯ " })) : (_jsx(Text, { children: " " }));
477
839
  if (isPlaceholder) {
478
840
  return (_jsxs(Text, { wrap: "truncate", children: [prompt, _jsx(Text, { color: theme.faint, children: line }), i === bodyLines.length - 1 ? (_jsx(Text, { color: theme.brand[1], children: "\u258F" })) : null] }, i));
@@ -485,10 +847,10 @@ export function Chat(props) {
485
847
  return (_jsxs(Text, { wrap: "truncate", children: [prompt, _jsx(Text, { children: before }), _jsx(Text, { inverse: true, color: theme.brand[0], children: at ?? " " }), _jsx(Text, { children: after })] }, i));
486
848
  }
487
849
  return (_jsxs(Text, { wrap: "truncate", children: [prompt, _jsx(Text, { children: line })] }, i));
488
- })) })] })), _jsx(StatusBar, { model: props.modelId, agent: props.agentId ?? "default", tokens: tokens, busy: busy, hint: !ready
850
+ })) })] })), _jsx(StatusBar, { model: modelId, agent: props.agentId ?? "default", effort: effortText, lastTokens: lastTokens, totalTokens: totalTokens, busy: busy, hint: !ready
489
851
  ? "初始化中…"
490
852
  : layout.scrolled
491
853
  ? "↕ 滚动中 · PgUp/PgDn 翻页 · 发送回到底部"
492
- : "Enter 发送 · Ctrl+N 换行 · / 命令 · PgUp 看历史" })] }));
854
+ : "Enter 发送 · Ctrl+J 换行 · / 命令 · PgUp 看历史" })] }));
493
855
  }
494
856
  //# sourceMappingURL=chat.js.map