@armadra/agent 0.6.3 → 0.6.5

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 (73) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/CHANGELOG.zh-CN.md +21 -0
  3. package/dist/agent/queue.d.ts +9 -0
  4. package/dist/agent/queue.js +27 -0
  5. package/dist/agent/session-subagent.d.ts +1 -0
  6. package/dist/agent/session-subagent.js +9 -0
  7. package/dist/agent/session.d.ts +5 -0
  8. package/dist/agent/session.js +21 -2
  9. package/dist/agent/subagent-direct.d.ts +5 -2
  10. package/dist/agent/subagent-direct.js +34 -1
  11. package/dist/agent/subagent-registry.d.ts +2 -1
  12. package/dist/agent/subagent-registry.js +2 -2
  13. package/dist/agent/types.d.ts +7 -0
  14. package/dist/agents/task-record.d.ts +3 -3
  15. package/dist/ai/apis/chatgpt-rate-limits.js +7 -2
  16. package/dist/ai/types.d.ts +2 -1
  17. package/dist/bundle/ama.cjs +417 -106
  18. package/dist/config/json-schema.js +1 -0
  19. package/dist/config/key-docs.js +8 -1
  20. package/dist/config/schema.js +2 -0
  21. package/dist/config/settings-registry.js +1 -0
  22. package/dist/config/types.d.ts +2 -0
  23. package/dist/drivers/acp/client.d.ts +13 -4
  24. package/dist/drivers/acp/client.js +17 -6
  25. package/dist/drivers/acp/types.d.ts +22 -2
  26. package/dist/drivers/runner.js +29 -2
  27. package/dist/i18n/catalog.d.ts +13 -0
  28. package/dist/i18n/messages/agents.d.ts +4 -0
  29. package/dist/i18n/messages/agents.js +4 -0
  30. package/dist/i18n/messages/config-keys.d.ts +2 -0
  31. package/dist/i18n/messages/config-keys.js +2 -0
  32. package/dist/i18n/messages/config.d.ts +2 -0
  33. package/dist/i18n/messages/interactive-view.d.ts +8 -0
  34. package/dist/i18n/messages/interactive-view.js +10 -0
  35. package/dist/i18n/messages/interactive.d.ts +10 -0
  36. package/dist/i18n/messages/interactive.js +6 -0
  37. package/dist/i18n/messages/report.d.ts +8 -0
  38. package/dist/i18n/messages/report.js +8 -0
  39. package/dist/i18n/messages/settings.d.ts +2 -0
  40. package/dist/i18n/messages/settings.js +2 -0
  41. package/dist/modes/commands-core.js +11 -0
  42. package/dist/modes/interactive/agent-ui.js +1 -0
  43. package/dist/modes/interactive/agent-view.d.ts +5 -0
  44. package/dist/modes/interactive/agent-view.js +14 -3
  45. package/dist/modes/interactive/interactive-mode.js +13 -1
  46. package/dist/modes/interactive/interrupt-send.d.ts +25 -0
  47. package/dist/modes/interactive/interrupt-send.js +32 -0
  48. package/dist/modes/interactive/key-dispatch.d.ts +8 -1
  49. package/dist/modes/interactive/key-dispatch.js +37 -0
  50. package/dist/modes/interactive/line/line-mode.d.ts +1 -0
  51. package/dist/modes/interactive/line/line-mode.js +29 -7
  52. package/dist/modes/interactive/run-indicator.d.ts +19 -2
  53. package/dist/modes/interactive/run-indicator.js +29 -7
  54. package/dist/modes/interactive/status-bar.d.ts +4 -1
  55. package/dist/modes/interactive/status-bar.js +17 -6
  56. package/dist/modes/interactive/status-line.d.ts +10 -9
  57. package/dist/modes/interactive/status-line.js +46 -38
  58. package/dist/modes/interactive/status-quota.d.ts +16 -2
  59. package/dist/modes/interactive/status-quota.js +54 -34
  60. package/dist/modes/interactive/task-background.d.ts +2 -2
  61. package/dist/modes/rpc/commands.d.ts +2 -0
  62. package/dist/modes/rpc/commands.js +15 -1
  63. package/dist/rpc.d.ts +7 -0
  64. package/dist/tools/types.d.ts +6 -0
  65. package/dist/tui/keybindings.d.ts +7 -0
  66. package/dist/tui/keybindings.js +7 -0
  67. package/docs/acp.md +3 -0
  68. package/docs/en/rpc.md +9 -7
  69. package/docs/en/tui.md +49 -31
  70. package/docs/rpc.md +9 -7
  71. package/docs/session-format.md +1 -1
  72. package/docs/tui.md +49 -31
  73. package/package.json +1 -1
@@ -67,6 +67,7 @@ export const BUILTIN_COMMANDS = [
67
67
  command("tasks", "tasks", "[id] | stop <id> | bg [id]"),
68
68
  command("agents", "agents"),
69
69
  command("paste", "paste"),
70
+ command("interrupt", "interrupt", { key: "interruptArgs" }),
70
71
  command("exit", "exit"),
71
72
  // [W6-C0] 第六波(W6-S / W6-T1 / W6-M 实现)
72
73
  command("config", "config", "[key=value]"),
@@ -246,6 +247,16 @@ export async function runSlashCommand(line, ctx) {
246
247
  return planCommand(session, args, ctx);
247
248
  case "tasks":
248
249
  return { kind: "handled", message: await tasksCommand(session, args) };
250
+ case "interrupt": {
251
+ // 打断并立即发送:运行中中止当前回合、以「排队的插话 + 本条」开新回合;空闲时就是普通提示
252
+ if (args === "")
253
+ throw new AmaError("invalid_arguments", msg().report.command.interruptUsage);
254
+ const { isStreaming, isCompacting } = session.state;
255
+ if (!isStreaming && !isCompacting)
256
+ return { kind: "prompt", text: args };
257
+ void session.prompt(args, { interrupt: true }).catch(() => undefined);
258
+ return { kind: "handled", message: msg().report.command.interruptSent(args) };
259
+ }
249
260
  case "agents":
250
261
  return { kind: "handled", message: describeAgents(session.state.sessionId) };
251
262
  case "paste": {
@@ -376,6 +376,7 @@ export class AgentUi {
376
376
  close: () => this.closeView(),
377
377
  stop: (id) => stopTask(this.deps.session().state.sessionId, id),
378
378
  background: (id) => this.deps.session().backgroundTask(id, "user"),
379
+ enterMode: () => this.deps.area?.ui().enterWhileRunning === "interrupt" ? "interrupt" : "queue",
379
380
  messages: {
380
381
  ...(ui.showThinking !== undefined ? { showThinking: ui.showThinking } : {}),
381
382
  ...(ui.markdown !== undefined ? { markdown: ui.markdown } : {}),
@@ -18,6 +18,8 @@
18
18
  * 所查看的任务在等审批时标题显示「等待审批」,审批框照常以覆盖层弹在视图上面。
19
19
  * [W7-C] `Ctrl+B`(`app.tasks.background`)转后台正在看的任务(只对前台任务有效,否则落回编辑器);
20
20
  * 视图里另认 `/tasks bg [id]`。
21
+ * [打断并发送] `Ctrl+X`(`app.message.interrupt`)= 打断该子 Agent 当前回合并立即发送(`registry.message` 的
22
+ * interrupt);外部 Agent 的驱动不能中断回合时退回排队并提示。`ui.enterWhileRunning: "interrupt"` 时与 Enter 互换。
21
23
  */
22
24
  import { type Component, type Focusable, type Keybindings, type Theme } from "../../tui.js";
23
25
  import { type TaskSource } from "./agent-bar.js";
@@ -43,6 +45,8 @@ export interface AgentViewDeps {
43
45
  background?(taskId: string): string[];
44
46
  /** 消息区的显示选项(思考块、Markdown、紧凑)。 */
45
47
  messages?: Omit<MessageViewOptions, "theme">;
48
+ /** `ui.enterWhileRunning`:`interrupt` 时 Enter 打断并发送、专用键排队。 */
49
+ enterMode?(): "queue" | "interrupt";
46
50
  }
47
51
  export declare class AgentView implements Component, Focusable {
48
52
  private taskId;
@@ -67,6 +71,7 @@ export declare class AgentView implements Component, Focusable {
67
71
  /** 每秒:运行中的工具重画耗时。 */
68
72
  tick(): void;
69
73
  private switchTo;
74
+ private enterInterrupts;
70
75
  private submit;
71
76
  handleInput(data: string): void;
72
77
  private scroll;
@@ -18,6 +18,8 @@
18
18
  * 所查看的任务在等审批时标题显示「等待审批」,审批框照常以覆盖层弹在视图上面。
19
19
  * [W7-C] `Ctrl+B`(`app.tasks.background`)转后台正在看的任务(只对前台任务有效,否则落回编辑器);
20
20
  * 视图里另认 `/tasks bg [id]`。
21
+ * [打断并发送] `Ctrl+X`(`app.message.interrupt`)= 打断该子 Agent 当前回合并立即发送(`registry.message` 的
22
+ * interrupt);外部 Agent 的驱动不能中断回合时退回排队并提示。`ui.enterWhileRunning: "interrupt"` 时与 Enter 互换。
21
23
  */
22
24
  import { msg } from "../../i18n/index.js";
23
25
  import { Editor, isPrintableText, parseKey, truncateToWidth, visibleWidth, } from "../../tui.js";
@@ -47,7 +49,7 @@ export class AgentView {
47
49
  maxVisibleLines: 4,
48
50
  placeholder: msg().agents.view.placeholder(taskId),
49
51
  requestRender: () => deps.render(),
50
- onSubmit: (text) => void this.submit(text),
52
+ onSubmit: (text) => void this.submit(text, this.enterInterrupts()),
51
53
  });
52
54
  this.attach();
53
55
  }
@@ -137,7 +139,10 @@ export class AgentView {
137
139
  this.attach();
138
140
  this.deps.onSwitch?.(next);
139
141
  }
140
- async submit(text) {
142
+ enterInterrupts() {
143
+ return this.deps.enterMode?.() === "interrupt";
144
+ }
145
+ async submit(text, interrupt = false) {
141
146
  const m = msg().agents.view;
142
147
  const trimmed = text.trim();
143
148
  if (trimmed === "")
@@ -167,7 +172,7 @@ export class AgentView {
167
172
  try {
168
173
  if (registry === undefined)
169
174
  throw new Error(m.unknown(taskId));
170
- const reply = await registry.message(taskId, text);
175
+ const reply = await registry.message(taskId, text, interrupt);
171
176
  this.flash = m[reply](taskId);
172
177
  }
173
178
  catch (error) {
@@ -188,6 +193,12 @@ export class AgentView {
188
193
  return;
189
194
  }
190
195
  }
196
+ if (keys.matches(data, "app.message.interrupt") && !this.editor.isCompletionOpen) {
197
+ const text = this.editor.takeSubmission();
198
+ if (text !== null)
199
+ void this.submit(text, !this.enterInterrupts());
200
+ return;
201
+ }
191
202
  if (keys.matches(data, "tui.select.cancel") && !this.editor.isCompletionOpen) {
192
203
  if (!empty)
193
204
  this.editor.clear();
@@ -39,6 +39,7 @@ import { InteractiveCompletion } from "./completion.js";
39
39
  import { confirmBypass } from "./confirm-dialog.js";
40
40
  import { configUiFor } from "./config-ui.js";
41
41
  import { createKeyDispatch } from "./key-dispatch.js";
42
+ import { enterModeOf, interruptKeys, interruptSender } from "./interrupt-send.js";
42
43
  import { MessageView, exitSummaryLines } from "./message-view.js";
43
44
  import { openPicker } from "./pickers.js";
44
45
  import { modelPickerFor } from "./model-picker.js";
@@ -84,7 +85,9 @@ export function runInteractiveMode(runtime, context, options = {}) {
84
85
  spinner: () => loader.frame,
85
86
  subagent: (toolCallId) => subagents.forToolCall(toolCallId),
86
87
  });
87
- const queueView = new QueueView(theme);
88
+ const enterMode = () => enterModeOf(runtime);
89
+ const sendKeys = interruptKeys(keys, enterMode);
90
+ const queueView = new QueueView(theme, sendKeys.now);
88
91
  const loaderSlot = new Container();
89
92
  const loader = new Loader(() => tui.requestRender(), {
90
93
  theme,
@@ -124,6 +127,7 @@ export function runInteractiveMode(runtime, context, options = {}) {
124
127
  requestRender: () => tui.requestRender(),
125
128
  ...(historyFile !== undefined ? { historyFile } : {}),
126
129
  onSubmit: (text) => submit(text, "enter"),
130
+ onChange: () => indicator.refreshHints(),
127
131
  });
128
132
  tui.addChild(view);
129
133
  tui.addChild(new Spacer());
@@ -161,7 +165,9 @@ export function runInteractiveMode(runtime, context, options = {}) {
161
165
  const indicator = new RunIndicator({
162
166
  ...{ theme, loader, slot: loaderSlot, tools, render },
163
167
  ...(backgroundKey !== undefined ? { backgroundKey } : {}),
168
+ ...(sendKeys.key !== undefined ? { interruptKey: sendKeys.key } : {}),
164
169
  });
170
+ Object.assign(indicator, { draft: () => !editor.isEmpty(), enterMode });
165
171
  const setQueue = (steering, followUp) => {
166
172
  status.setQueue(steering.length, followUp.length);
167
173
  queueView.setQueue(steering, followUp);
@@ -361,6 +367,12 @@ export function runInteractiveMode(runtime, context, options = {}) {
361
367
  onPasteImage: () => void agentUi.paste(),
362
368
  confirmMode,
363
369
  submit: (text, via) => submit(text, via),
370
+ enterWhileRunning: enterMode,
371
+ interruptSend: interruptSender({
372
+ session: () => session,
373
+ takeImages: () => draftImages.splice(0),
374
+ showHint,
375
+ }),
364
376
  runCommand: (line) => void runCommand(line),
365
377
  exit: (code) => exit(code),
366
378
  onInterrupted: (empty) => void rewind.afterInterrupt(empty),
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 打断并立即发送(交互模式):运行中按 `app.message.interrupt`(缺省 `Ctrl+X`;`ui.enterWhileRunning:
3
+ * "interrupt"` 时是 Enter)→ `prompt(text, { interrupt: true })`:取走排队的插话、中止当前回合(工具按
4
+ * abort 收尾),以「插话… + 本条」开新回合(会话里 origin `interrupt`)。不走「中断即撤回」。
5
+ */
6
+ import type { AgentSession } from "../../agent/types.js";
7
+ import type { ImageBlock } from "../../ai/types.js";
8
+ import type { Runtime } from "../../cli/runtime.js";
9
+ import type { Keybindings } from "../../tui.js";
10
+ import type { EnterMode } from "./run-indicator.js";
11
+ export interface InterruptSendDeps {
12
+ session(): AgentSession;
13
+ /** 取走输入框附带的图片(粘贴的剪贴板图片)。 */
14
+ takeImages(): ImageBlock[];
15
+ showHint(text: string): void;
16
+ }
17
+ /** 返回 `interruptSend(text)`:文字可为空(只把排队的插话立即送出);没有可发的内容给一行提示。 */
18
+ export declare function interruptSender(deps: InterruptSendDeps): (text: string) => void;
19
+ /** `ui.enterWhileRunning`(每次按键时读,`/config` 改了立即生效)。 */
20
+ export declare function enterModeOf(runtime: Pick<Runtime, "config">): EnterMode;
21
+ /** 专用键的标签与「立即送出排队插话」的键(interrupt 模式下是 Enter)。 */
22
+ export declare function interruptKeys(keys: Keybindings, mode: () => EnterMode): {
23
+ key: string | undefined;
24
+ now(): string | undefined;
25
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 打断并立即发送(交互模式):运行中按 `app.message.interrupt`(缺省 `Ctrl+X`;`ui.enterWhileRunning:
3
+ * "interrupt"` 时是 Enter)→ `prompt(text, { interrupt: true })`:取走排队的插话、中止当前回合(工具按
4
+ * abort 收尾),以「插话… + 本条」开新回合(会话里 origin `interrupt`)。不走「中断即撤回」。
5
+ */
6
+ import { isAmaError } from "../../errors.js";
7
+ import { msg } from "../../i18n/index.js";
8
+ import { keyLabel } from "./task-background.js";
9
+ /** 返回 `interruptSend(text)`:文字可为空(只把排队的插话立即送出);没有可发的内容给一行提示。 */
10
+ export function interruptSender(deps) {
11
+ return (text) => {
12
+ const images = deps.takeImages();
13
+ deps.showHint(msg().interactive.keys.interruptSent);
14
+ const options = images.length > 0 ? { interrupt: true, images } : { interrupt: true };
15
+ void deps
16
+ .session()
17
+ .prompt(text, options)
18
+ .catch((error) => {
19
+ if (isAmaError(error) && error.code === "invalid_arguments")
20
+ deps.showHint(msg().interactive.keys.nothingToSend);
21
+ });
22
+ };
23
+ }
24
+ /** `ui.enterWhileRunning`(每次按键时读,`/config` 改了立即生效)。 */
25
+ export function enterModeOf(runtime) {
26
+ return runtime.config.ui?.enterWhileRunning === "interrupt" ? "interrupt" : "queue";
27
+ }
28
+ /** 专用键的标签与「立即送出排队插话」的键(interrupt 模式下是 Enter)。 */
29
+ export function interruptKeys(keys, mode) {
30
+ const key = keyLabel(keys, "app.message.interrupt");
31
+ return { key, now: () => (mode() === "interrupt" ? "Enter" : key) };
32
+ }
@@ -16,6 +16,9 @@
16
16
  * [W7-C] `app.tasks.background`(缺省 `Ctrl+B`):有阻塞中的前台任务时全部转后台(不看输入框),底部提示
17
17
  * 「已转后台:t2 …」;没有时落回编辑器(光标左移),不吞键。Esc 中断只连带前台任务,仍在运行的后台任务
18
18
  * 写进中断提示。
19
+ * [打断并发送] 运行中 Enter 缺省排队(steer);`app.message.interrupt`(缺省 `Ctrl+X`)= 打断当前回合并立即
20
+ * 以输入框的文字(连同排队的插话)开新回合(`prompt{interrupt:true}`)。`ui.enterWhileRunning: "interrupt"`
21
+ * 时两者互换。补全打开、有覆盖层(审批 / 选择器)时不触发;斜杠命令照常当命令;空闲时专用键等同 Enter。
19
22
  */
20
23
  import type { AgentSession } from "../../agent/types.js";
21
24
  import type { PermissionMode } from "../../permissions/types.js";
@@ -60,7 +63,11 @@ export interface KeyDispatchDeps {
60
63
  confirmMode?(mode: PermissionMode): boolean | Promise<boolean>;
61
64
  /** Ctrl+V:粘贴剪贴板图片(W5-U)。 */
62
65
  onPasteImage?(): void;
63
- submit(text: string, via: "followUp"): void;
66
+ submit(text: string, via: "enter" | "followUp"): void;
67
+ /** 运行中 Enter 的语义(`ui.enterWhileRunning`,缺省 `queue`)。 */
68
+ enterWhileRunning?(): "queue" | "interrupt";
69
+ /** 打断并立即发送(文字可为空:只把排队的插话立即送出)。没有时专用键不处理。 */
70
+ interruptSend?(text: string): void;
64
71
  runCommand(line: string): void;
65
72
  exit(code: number): void;
66
73
  /** Esc 中断之后(参数:中断后输入框是否为空)。 */
@@ -16,6 +16,9 @@
16
16
  * [W7-C] `app.tasks.background`(缺省 `Ctrl+B`):有阻塞中的前台任务时全部转后台(不看输入框),底部提示
17
17
  * 「已转后台:t2 …」;没有时落回编辑器(光标左移),不吞键。Esc 中断只连带前台任务,仍在运行的后台任务
18
18
  * 写进中断提示。
19
+ * [打断并发送] 运行中 Enter 缺省排队(steer);`app.message.interrupt`(缺省 `Ctrl+X`)= 打断当前回合并立即
20
+ * 以输入框的文字(连同排队的插话)开新回合(`prompt{interrupt:true}`)。`ui.enterWhileRunning: "interrupt"`
21
+ * 时两者互换。补全打开、有覆盖层(审批 / 选择器)时不触发;斜杠命令照常当命令;空闲时专用键等同 Enter。
19
22
  */
20
23
  import { msg } from "../../i18n/index.js";
21
24
  import { ExitCode } from "../../cli/exit-codes.js";
@@ -99,6 +102,38 @@ export function createKeyDispatch(deps) {
99
102
  else
100
103
  void answer.then(settle);
101
104
  };
105
+ /**
106
+ * 运行中的「排队 / 打断并发送」:专用键与(`interrupt` 模式下的)Enter。返回 false 交给编辑器
107
+ * (Enter 照常提交 = 排队;斜杠命令照常执行)。
108
+ */
109
+ const sendKeys = (is) => {
110
+ if (deps.interruptSend === undefined || editor.isCompletionOpen)
111
+ return false;
112
+ const viaKey = is("app.message.interrupt");
113
+ if (!deps.busy()) {
114
+ if (!viaKey || editor.isEmpty())
115
+ return false;
116
+ const text = editor.takeSubmission();
117
+ if (text !== null)
118
+ deps.submit(text, "enter");
119
+ return true;
120
+ }
121
+ const interruptMode = deps.enterWhileRunning?.() === "interrupt";
122
+ if (!viaKey && !(interruptMode && is("tui.editor.submit")))
123
+ return false;
124
+ const command = editor.getExpandedText().trimStart().startsWith("/");
125
+ if (command || viaKey === interruptMode) {
126
+ // 排队(interrupt 模式下的专用键)或斜杠命令:同 Enter 提交
127
+ if (!viaKey)
128
+ return false;
129
+ const text = editor.takeSubmission();
130
+ if (text !== null)
131
+ deps.submit(text, "enter");
132
+ return true;
133
+ }
134
+ deps.interruptSend(editor.takeSubmission() ?? "");
135
+ return true;
136
+ };
102
137
  /** 光标在最后一行(`↓` 不再下移,只会到行尾)。 */
103
138
  const atLastLine = () => editor.cursor.line >= editor.getText().split("\n").length - 1;
104
139
  /** `app.agents.focus`:进栏返回 true;落空给提示后返回 false(按键照常交给编辑器)。 */
@@ -170,6 +205,8 @@ export function createKeyDispatch(deps) {
170
205
  return true;
171
206
  }
172
207
  ctrlCArmedAt = Number.NEGATIVE_INFINITY;
208
+ if (sendKeys(is))
209
+ return true;
173
210
  if (is("app.interrupt") && !editor.isCompletionOpen && deps.busy()) {
174
211
  esc.reset();
175
212
  interrupt();
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * - stdin 是 TTY 且可开 raw:自带单行编辑器(line-editor.ts:历史、括号粘贴折叠),运行中回车
5
5
  * = steer;Ctrl+C 运行中中断、空闲时连按两次退出(130);空行 Ctrl+D 退出;审批 y / a / N 单键问答。
6
+ * 运行中 `/interrupt <文本>` = 打断当前回合并立即以它开新回合(排队的插话在前)。
6
7
  * 问句之前逐行打印执行前预览(`request.preview`,W3-B9a-2)。
7
8
  * - 否则(管道):逐行读 stdin,每行依次执行(等上一条运行结束);没有审批 UI,ask → deny。
8
9
  * 模型错误在运行结束时只打印一次(重试中只显示 ↻);有运行最终失败时退出码 1。
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * - stdin 是 TTY 且可开 raw:自带单行编辑器(line-editor.ts:历史、括号粘贴折叠),运行中回车
5
5
  * = steer;Ctrl+C 运行中中断、空闲时连按两次退出(130);空行 Ctrl+D 退出;审批 y / a / N 单键问答。
6
+ * 运行中 `/interrupt <文本>` = 打断当前回合并立即以它开新回合(排队的插话在前)。
6
7
  * 问句之前逐行打印执行前预览(`request.preview`,W3-B9a-2)。
7
8
  * - 否则(管道):逐行读 stdin,每行依次执行(等上一条运行结束);没有审批 UI,ask → deny。
8
9
  * 模型错误在运行结束时只打印一次(重试中只显示 ↻);有运行最终失败时退出码 1。
@@ -134,13 +135,14 @@ export async function runLineMode(runtime, context, options = {}) {
134
135
  const question = approvalQuestion(request, (taskId) => taskRegistryView(session.state.sessionId)?.get(taskId)?.agent);
135
136
  void ed.ask(`\n${preview.join("")}${question}`, "n").then((answer) => {
136
137
  resolve(answer === "y" ? "allow" : answer === "a" ? "allow_session" : "deny");
137
- if (!busy)
138
+ if (running === 0)
138
139
  ed.render();
139
140
  });
140
141
  }),
141
142
  });
142
143
  runtime.notifier.set((message, level) => err(`ama: [${level}] ${message}\n`));
143
- let busy = false;
144
+ /** 进行中的提示(`/interrupt` 开的新回合与被它中止的那一轮可能短暂重叠);> 0 = 运行中。 */
145
+ let running = 0;
144
146
  let lastInterrupt = 0;
145
147
  return new Promise((resolve) => {
146
148
  let finished = false;
@@ -164,12 +166,27 @@ export async function runLineMode(runtime, context, options = {}) {
164
166
  });
165
167
  };
166
168
  const run = (text) => {
167
- busy = true;
169
+ running++;
168
170
  void handle(text).then((outcome) => {
169
- busy = false;
171
+ running--;
170
172
  if (outcome === "exit")
171
173
  finish(ExitCode.Ok);
172
- else
174
+ else if (running === 0)
175
+ ed.render();
176
+ });
177
+ };
178
+ /** 运行中 `/interrupt <文本>`:打断当前回合并立即以它(排队的插话在前)开新回合。 */
179
+ const interruptWith = (text) => {
180
+ if (text === "")
181
+ return err(`ama: ${msg().report.command.interruptUsage}\n`);
182
+ out(`${msg().report.command.interruptSent(text)}\n`);
183
+ running++;
184
+ void session
185
+ .prompt(text, { interrupt: true })
186
+ .catch((error) => err(`ama: ${errorText(error)}\n`))
187
+ .finally(() => {
188
+ printer.endLine();
189
+ if (--running === 0)
173
190
  ed.render();
174
191
  });
175
192
  };
@@ -179,7 +196,7 @@ export async function runLineMode(runtime, context, options = {}) {
179
196
  if (action.kind === "eof")
180
197
  return finish(ExitCode.Ok);
181
198
  if (action.kind === "interrupt") {
182
- if (busy) {
199
+ if (running > 0) {
183
200
  void session.abort();
184
201
  err(`${msg().interactive.line.interrupted}\n`);
185
202
  }
@@ -193,7 +210,12 @@ export async function runLineMode(runtime, context, options = {}) {
193
210
  }
194
211
  continue;
195
212
  }
196
- if (busy) {
213
+ if (running > 0) {
214
+ const forced = /^\/interrupt(?:\s+([\s\S]*))?$/.exec(action.text.trim());
215
+ if (forced !== null) {
216
+ interruptWith((forced[1] ?? "").trim());
217
+ continue;
218
+ }
197
219
  // [W7-C] 运行中 `/tasks bg [id]` 照样是命令(转后台阻塞中的前台任务),不当插话
198
220
  if (/^\/tasks\s+bg(\s+\S+)?\s*$/.test(action.text.trim())) {
199
221
  void handle(action.text.trim());
@@ -14,8 +14,11 @@
14
14
  * [W7-C] 有阻塞中的前台任务时可丢弃项前面加 `Ctrl+B 转后台`;有停靠的审批时 `↓ Agent 栏` 换成
15
15
  * `↓ 处理审批`。顺序 `Esc 中断 · Ctrl+B 转后台 · ↓ Agent 栏`,窄屏从后往前丢。
16
16
  *
17
+ * [打断并发送] 输入框有字时可丢弃项最前面加 `Enter 排队 · Ctrl+X 打断并发送`(`ui.enterWhileRunning:
18
+ * "interrupt"` 时语义互换),顺序 `Esc 中断 · Enter 排队 · Ctrl+X 打断并发送 · Ctrl+B 转后台 · ↓ Agent 栏`。
19
+ *
17
20
  * 排队消息:缩进 2 列(挂在当前回合下),`↳ 插话 / 之后 文本` 整体 muted、标签 dim;超过 3 条首行
18
- * `… 另 N 条`;末行 `Alt+↑ 取回 · Esc 回填并中断`。
21
+ * `… 另 N 条`;末行 `Alt+↑ 取回 · Esc 回填并中断`,有插话时中间加 `Ctrl+X 立即发送`(interrupt 模式是 Enter)。
19
22
  */
20
23
  import type { SessionEvent } from "../../agent/types.js";
21
24
  import { Container, Text, type Loader, type Theme } from "../../tui.js";
@@ -30,7 +33,11 @@ export interface RunIndicatorDeps {
30
33
  render(): void;
31
34
  /** [W7-C] `app.tasks.background` 的按键标签(`Ctrl+B`);没有绑定时不提示。 */
32
35
  backgroundKey?: string;
36
+ /** `app.message.interrupt` 的按键标签(`Ctrl+X`);没有绑定时不提示。 */
37
+ interruptKey?: string;
33
38
  }
39
+ /** 运行中 Enter 的语义(`ui.enterWhileRunning`)。 */
40
+ export type EnterMode = "queue" | "interrupt";
34
41
  export declare class RunIndicator {
35
42
  private readonly deps;
36
43
  running: boolean;
@@ -44,8 +51,14 @@ export declare class RunIndicator {
44
51
  background: () => boolean;
45
52
  /** [W7-C] 有停靠在 Agent 栏的审批。 */
46
53
  docked: () => boolean;
54
+ /** 输入框有字(显示 Enter / 专用键的提示)。 */
55
+ draft: () => boolean;
56
+ /** 运行中 Enter 的语义。 */
57
+ enterMode: () => EnterMode;
47
58
  private hintKey;
48
59
  constructor(deps: RunIndicatorDeps);
60
+ /** 可丢弃提示变了才换动词(每帧、输入框内容变化时调)。 */
61
+ refreshHints(): void;
49
62
  /** 可丢弃的按键提示(按丢弃的逆序排列)。 */
50
63
  private optionalHints;
51
64
  get busy(): boolean;
@@ -61,6 +74,10 @@ export declare class RunIndicator {
61
74
  /** 排队消息(插话 / 之后),挂在 Loader 之上。 */
62
75
  export declare class QueueView extends Text {
63
76
  private readonly theme;
64
- constructor(theme: Theme);
77
+ private readonly nowKey;
78
+ /**
79
+ * @param nowKey 立即送出排队插话的键(专用键;interrupt 模式下是 Enter);undefined 不提示。
80
+ */
81
+ constructor(theme: Theme, nowKey?: () => string | undefined);
65
82
  setQueue(steering: readonly string[], followUp: readonly string[]): void;
66
83
  }
@@ -14,8 +14,11 @@
14
14
  * [W7-C] 有阻塞中的前台任务时可丢弃项前面加 `Ctrl+B 转后台`;有停靠的审批时 `↓ Agent 栏` 换成
15
15
  * `↓ 处理审批`。顺序 `Esc 中断 · Ctrl+B 转后台 · ↓ Agent 栏`,窄屏从后往前丢。
16
16
  *
17
+ * [打断并发送] 输入框有字时可丢弃项最前面加 `Enter 排队 · Ctrl+X 打断并发送`(`ui.enterWhileRunning:
18
+ * "interrupt"` 时语义互换),顺序 `Esc 中断 · Enter 排队 · Ctrl+X 打断并发送 · Ctrl+B 转后台 · ↓ Agent 栏`。
19
+ *
17
20
  * 排队消息:缩进 2 列(挂在当前回合下),`↳ 插话 / 之后 文本` 整体 muted、标签 dim;超过 3 条首行
18
- * `… 另 N 条`;末行 `Alt+↑ 取回 · Esc 回填并中断`。
21
+ * `… 另 N 条`;末行 `Alt+↑ 取回 · Esc 回填并中断`,有插话时中间加 `Ctrl+X 立即发送`(interrupt 模式是 Enter)。
19
22
  */
20
23
  import { msg } from "../../i18n/index.js";
21
24
  import { Container, Text } from "../../tui.js";
@@ -58,19 +61,28 @@ export class RunIndicator {
58
61
  background = () => false;
59
62
  /** [W7-C] 有停靠在 Agent 栏的审批。 */
60
63
  docked = () => false;
64
+ /** 输入框有字(显示 Enter / 专用键的提示)。 */
65
+ draft = () => false;
66
+ /** 运行中 Enter 的语义。 */
67
+ enterMode = () => "queue";
61
68
  hintKey = "";
62
69
  constructor(deps) {
63
70
  this.deps = deps;
64
- deps.loader.onFrame(() => {
65
- if (this.busy && !this.approval && this.optionalHints().join("\n") !== this.hintKey)
66
- this.applyVerb();
67
- });
71
+ deps.loader.onFrame(() => this.refreshHints());
72
+ }
73
+ /** 可丢弃提示变了才换动词(每帧、输入框内容变化时调)。 */
74
+ refreshHints() {
75
+ if (this.busy && !this.approval && this.optionalHints().join("\n") !== this.hintKey)
76
+ this.applyVerb();
68
77
  }
69
78
  /** 可丢弃的按键提示(按丢弃的逆序排列)。 */
70
79
  optionalHints() {
71
80
  const down = this.deps.theme.glyphs.arrowDown;
72
81
  const key = this.deps.backgroundKey;
73
82
  const out = [];
83
+ const send = this.deps.interruptKey;
84
+ if (send !== undefined && this.draft())
85
+ out.push(msg().interactive.view.run.sendHint(this.enterMode(), send));
74
86
  if (key !== undefined && this.background())
75
87
  out.push(msg().agents.background.runHint(key));
76
88
  if (this.docked())
@@ -200,9 +212,14 @@ export class RunIndicator {
200
212
  /** 排队消息(插话 / 之后),挂在 Loader 之上。 */
201
213
  export class QueueView extends Text {
202
214
  theme;
203
- constructor(theme) {
215
+ nowKey;
216
+ /**
217
+ * @param nowKey 立即送出排队插话的键(专用键;interrupt 模式下是 Enter);undefined 不提示。
218
+ */
219
+ constructor(theme, nowKey = () => undefined) {
204
220
  super("");
205
221
  this.theme = theme;
222
+ this.nowKey = nowKey;
206
223
  }
207
224
  setQueue(steering, followUp) {
208
225
  const t = this.theme;
@@ -217,7 +234,12 @@ export class QueueView extends Text {
217
234
  t.fg("dim", msg().interactive.view.run.queueMore(t.glyphs.ellipsis, rows.length - shown.length)));
218
235
  }
219
236
  if (rows.length > 0) {
220
- shown.push(" " + t.fg("dim", msg().interactive.view.run.queueHint(t.glyphs.arrowUp)));
237
+ const run = msg().interactive.view.run;
238
+ const now = steering.length > 0 ? this.nowKey() : undefined;
239
+ const hint = now === undefined
240
+ ? run.queueHint(t.glyphs.arrowUp)
241
+ : run.queueHintNow(t.glyphs.arrowUp, now);
242
+ shown.push(" " + t.fg("dim", hint));
221
243
  }
222
244
  this.setText(shown.join("\n"));
223
245
  }
@@ -25,7 +25,10 @@
25
25
  * - [W6] compact 在行尾(宿主状态之后)追加订阅配额短项 `5h 10% wk 31%`(status-quota.ts),最先丢;
26
26
  * 既有记号顺序不变。full 的配额在状态栏下方单独一行。
27
27
  * - [W5-U] bash 在操作系统沙箱里跑时(S2)用量类项多一个 `沙箱`(与 codemode 同一丢弃优先级)。
28
- * - [W5-U] 模型回退中(`model_fallback`)模型项显示 `主模型 → 回退模型`,切回主模型后恢复。
28
+ * - [W5-U] 模型回退中(`model_fallback`)compact 模型项显示 `主模型 → 回退模型`,切回主模型后恢复;[W7] full
29
+ * 本行只显示主模型,`→ 回退模型` 作为开关类项在速率行左区。
30
+ * - [W7] full 左右分区:左 = 状态(权限模式、`shift+tab` 提示),右 = 模型与度量;开关类项在速率行左区,
31
+ * 配额在第三行右区(status-line.ts / status-quota.ts)。
29
32
  * - 模型名缩写:宽 < 100 去掉供应商前缀,< 60 再去掉 `@渠道`,< 48 去掉版本后缀(第一个 `-数字` 起)。
30
33
  * - ASCII:`⎇` → `git`(字形表 `branch`)、`−` → `-`、`♨` → `~`。
31
34
  * - 宽度不够时按优先级丢弃(数字大的先丢,表见 §1.2);会变的数字按最宽形状占位(`reserve`),
@@ -25,7 +25,10 @@
25
25
  * - [W6] compact 在行尾(宿主状态之后)追加订阅配额短项 `5h 10% wk 31%`(status-quota.ts),最先丢;
26
26
  * 既有记号顺序不变。full 的配额在状态栏下方单独一行。
27
27
  * - [W5-U] bash 在操作系统沙箱里跑时(S2)用量类项多一个 `沙箱`(与 codemode 同一丢弃优先级)。
28
- * - [W5-U] 模型回退中(`model_fallback`)模型项显示 `主模型 → 回退模型`,切回主模型后恢复。
28
+ * - [W5-U] 模型回退中(`model_fallback`)compact 模型项显示 `主模型 → 回退模型`,切回主模型后恢复;[W7] full
29
+ * 本行只显示主模型,`→ 回退模型` 作为开关类项在速率行左区。
30
+ * - [W7] full 左右分区:左 = 状态(权限模式、`shift+tab` 提示),右 = 模型与度量;开关类项在速率行左区,
31
+ * 配额在第三行右区(status-line.ts / status-quota.ts)。
29
32
  * - 模型名缩写:宽 < 100 去掉供应商前缀,< 60 再去掉 `@渠道`,< 48 去掉版本后缀(第一个 `-数字` 起)。
30
33
  * - ASCII:`⎇` → `git`(字形表 `branch`)、`−` → `-`、`♨` → `~`。
31
34
  * - 宽度不够时按优先级丢弃(数字大的先丢,表见 §1.2);会变的数字按最宽形状占位(`reserve`),
@@ -121,9 +124,10 @@ export function layoutRow(parts, width, theme, style = {}) {
121
124
  let kept = [...parts];
122
125
  const zone = (z) => kept.filter((p) => p.zone === z);
123
126
  const fits = () => {
124
- const l = join(zone("left"))[1];
127
+ const left = zone("left");
125
128
  const right = zone("right");
126
- return l + (right.length === 0 ? 0 : sep.length + join(right)[1]) <= width;
129
+ const between = left.length === 0 || right.length === 0 ? 0 : sep.length;
130
+ return join(left)[1] + between + join(right)[1] <= width;
127
131
  };
128
132
  while (!fits()) {
129
133
  const droppable = kept.filter((p) => p.priority !== undefined);
@@ -135,7 +139,14 @@ export function layoutRow(parts, width, theme, style = {}) {
135
139
  const l = join(zone("left"))[0];
136
140
  const r = join(zone("right"))[0];
137
141
  const gap = width - visibleWidth(l) - visibleWidth(r);
138
- const line = r === "" ? l : gap >= MIN_GAP ? l + " ".repeat(gap) + r : l + dim(sep) + r;
142
+ // 左区为空时右区右对齐([W7] full 的速率行没有开关、配额行)
143
+ const line = r === ""
144
+ ? l
145
+ : l === ""
146
+ ? " ".repeat(Math.max(0, gap)) + r
147
+ : gap >= MIN_GAP
148
+ ? l + " ".repeat(gap) + r
149
+ : l + dim(sep) + r;
139
150
  return truncateToWidth(line, width);
140
151
  }
141
152
  /** 按终端宽度缩写模型名(§3.10)。 */
@@ -277,8 +288,8 @@ export class StatusBar {
277
288
  }
278
289
  const model = state.model === undefined ? "?" : abbreviateModel(formatModelRef(state.model), width);
279
290
  const p = full ? FULL : COMPACT;
280
- const fallback = this.source.fallback?.();
281
- // 回退中:`主模型 → 回退模型`(回退模型 warning),切回主模型后恢复原样
291
+ // 回退中:compact 显示 `主模型 → 回退模型`(回退模型 warning);full 本行只留主模型,`→ 回退模型` 在速率行左区
292
+ const fallback = full ? undefined : this.source.fallback?.();
282
293
  const modelText = fallback === undefined
283
294
  ? theme.fg("accent", model)
284
295
  : theme.fg("accent", abbreviateModel(fallback.from, width)) +
@@ -2,20 +2,21 @@
2
2
  * 速率行(第五波 §1.1,D1、D2):`ui.statusLine: "full"` 时在状态栏上方多一行。[W5-A]
3
3
  *
4
4
  * ```text
5
- * tps: 100 tok/s • 546 tok / 5.5s (avg 100 · ttft 1.4s) ↑12.3k ↓1.2k · cache 83% ♨ · [-]
5
+ * codemode on · preset x tps: 100 tok/s • 546 tok / 5.5s (avg 100 · ttft 1.4s) · ↑12.3k ↓1.2k · cache 83% ♨ · [-]
6
6
  * ```
7
7
  *
8
- * - 左区(用户样例):`tps: <速率>`(流式中是最近 2 s 窗口的瞬时值、前缀 accent;结束后是该请求的平均值;
9
- * 还没有请求时 `tps: —`)• `<输出 token> tok / <耗时>`(首 token 起算)`(avg <会话均速> · ttft <首 token 延迟>)`;
10
- * `•` 走字形表(ASCII `*`);
11
- * - 右区:从状态栏迁来的用量类项 `↑ ↓` · cache · rebill · queue · codemode · 预设 · [宿主],行尾 `[-]`
12
- * (折叠提示:Ctrl+G 或 `/statusline compact`);
8
+ * - [W7] 左右分区:左 = 状态 / 开关(`codemode on|only`〔网络未隔离追加 `net!`〕· `沙箱` · `preset 名` ·
9
+ * `→ 回退模型` · `queue N` · [宿主]),没有开关时左区为空、右区右对齐;右 = 度量:
10
+ * `tps: <速率>`(流式中是最近 2 s 窗口的瞬时值、前缀 accent;结束后是该请求的平均值;还没有请求时 `tps: —`)
11
+ * • `<输出 token> tok / <耗时>`(首 token 起算)`(avg <会话均速> · ttft <首 token 延迟>)` · `↑ ↓` · cache ·
12
+ * rebill,行尾 `[-]`(折叠提示:Ctrl+G 或 `/statusline compact`);`•` 走字形表(ASCII `*`);
13
13
  * - 数据:`StatusBar.current()` 的 `telemetry` 与用量(同一次 getStats);流式中 `telemetry_tick` 时刷新;
14
- * - 丢弃顺序(§1.2,数字大先丢;`tps` 与 `[-]` 永不丢):tok/耗时 10 → codemode 9 → queue 8 → token 7 →
15
- * cache 6 → rebill 5 → 预设 4 → 宿主 3 → avg 2 → ttft 1;数字按最宽形状占位,不随数值抖动;
14
+ * - 丢弃顺序(数字大先丢;`tps` 与 `[-]` 永不丢):右区度量先丢——tok/耗时 12 → token 11 → cache 10 →
15
+ * rebill 9 → avg 8 → ttft 7,左区开关最后丢——宿主 6 → queue 5 → 预设 4 → 回退 3 → 沙箱 2 → codemode 1;
16
+ * 数字按最宽形状占位,不随数值抖动;
16
17
  * - `compact` 时不占行(render 返回空)。
17
18
  * - [W6] 配色(用户参考):标签、单位、`•`、括号 dim;速率数字 `tool`(紫);输出量与耗时、avg 数字 accent(蓝);
18
- * ttft 数字 `tool`;右区用量项不变。
19
+ * ttft 数字 `tool`;用量项 dim;回退模型 warning。
19
20
  */
20
21
  import { type Component, type Theme } from "../../tui.js";
21
22
  import { type StatusBar, type StatusBarSource } from "./status-bar.js";