@aragon-agent/cli 0.6.11 → 0.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,7 +5,16 @@ All notable changes to `@aragon-agent/cli` are documented here. Entries at
5
5
  — those releases really were published under that name, so they are left as they
6
6
  were written.
7
7
 
8
- ## Unreleased
8
+ ## Unreleased
9
+
10
+ ### TUI 输入与排队可靠性
11
+
12
+ - Shift+Enter 与粘贴按输入顺序处理;普通 Enter 保持弹层兼容,混合输入最多提交一次,
13
+ 余稿及拒绝提交的正文保留。结束输入流时完整排空,不泄漏内部控制标记。
14
+ - 超限粘贴拒绝同一外部输入块的正文和 Enter,避免误提交旧稿;未闭合粘贴继续按正文消费。
15
+ - 拖选后仅 Ctrl+C 复制;文字变化与丢失鼠标释放时取消无效高亮,避免卡住视口。
16
+ - 固定 Queue 状态行按消息 ID 回执更新,支持窄屏、暂停与多条消息计数。
17
+ - 保存和退出保留所有未发送全文;恢复先验证存档,旧 queued 作为警告展示且不自动重放。
9
18
 
10
19
  ### Removed
11
20
 
package/README.md CHANGED
@@ -493,10 +493,10 @@ is what it used to do. Inline mode is unaffected either way.
493
493
 
494
494
  ## Terminal setup for Shift+Enter
495
495
 
496
- `Ctrl+J` and `Alt+Enter` insert a newline in every terminal with no setup. A
497
- dedicated `Shift+Enter` key needs the terminal to send the CSI-u sequence
498
- `\u001b[13;2u` instead of a plain `Enter` — run `/terminal-setup` inside aragon
499
- for copy-paste bindings per terminal:
496
+ 输入支持 `Ctrl+J`、`Alt+Enter` 和可独立编码的 `Shift+Enter` 换行。如果终端把
497
+ Shift+Enter 与 Enter 都发送为 CR,应用无法区分;请用 `/terminal-setup` 查看绑定,
498
+ 让 Shift+Enter 发送 `\u001b[13;2u`。合并到已有终端配置,不要覆盖其他快捷键;
499
+ 终端拦截某个组合键时可使用另外两种方式。参考绑定如下:
500
500
 
501
501
  - **Windows Terminal** — `settings.json`, `actions`:
502
502
  `{ "keys": "shift+enter", "command": { "action": "sendInput", "input": "\u001b[13;2u" } }`
@@ -518,20 +518,31 @@ user-side binding reaches the same result for the one key that needs it.
518
518
 
519
519
  ## Queued messages while the agent runs
520
520
 
521
- Sending a message while a run is in flight **queues** it for the agent's next
522
- turn. The transcript shows the queue honestly:
521
+ Agent 运行中发送的消息会进入队列。固定状态行显示最早待接收消息的首个非空行,
522
+ 多条时显示剩余数量;即使转录滚动离开底部,提示仍可见,且没有自动消失的时间限制。
523
+ 转录同时保留每条完整消息:
523
524
 
524
525
  ```
525
526
  ◷ Queue: also check the tests
526
527
  ```
527
528
 
528
- The row stays until Core actually accepts the message into the conversation —
529
- the next `turn_start` — at which point it is rewritten **in place** into the
530
- user message it became, followed by the answer. Interrupting the run does not
531
- clear it: the message really is still queued, and it will be picked up when
532
- you continue the session. Messages still queued when the session is saved are
533
- restored as `Queued but never sent: …` warnings, because the in-memory queue
534
- does not survive a restart.
529
+ 仅当 Core 将消息写入对话历史并发出对应消息 ID 的接收回执,Queue 状态才移除。
530
+ 这表示引擎已接管后续处理,不表示回答完成;`turn_start`、动画和提示超时都不能代替回执。
531
+ 相同正文的两次发送仍是两条消息。中断或失败后尚未接收的消息显示 `Queue (paused)`。
532
+
533
+ 窄屏使用 `P>B` / `B>P` 表示待切换模式,`E2` / `E!` 表示两次 Esc 中断 / 再按 Esc 强停,
534
+ `S+` / `T+` 表示后台服务 / TODO 存在,`Queue(p)` 表示暂停;`(+99+)` 表示至少还有
535
+ 100 条消息。完整服务和 TODO 数量分别可用 `/bg` 与 `/todo status` 查看。
536
+
537
+ `/clear` 清转录和 TODO,但保留待接收队列。保存和退出会补回被清屏或保留环裁剪的
538
+ 未发送全文。恢复存档时,queued 一律成为 `Queued but never sent: ...` 警告,
539
+ 不会自动重发;需要发送时请复制正文重新提交。运行尚未退出时 `/reset`、`/resume`
540
+ 会拒绝切换,损坏存档在替换当前会话之前报错。
541
+
542
+ 应用捕获鼠标时,拖选只保留高亮,按 Ctrl+C 才复制;本次按键不会停止服务或退出。
543
+ 选中文字改变、滚轮、其他按键或关闭选区会取消高亮。`--no-mouse` 或宿主原生选择的
544
+ 选中即复制由终端设置控制。OSC 52 发送成功仅证明请求已发出,不保证远程终端剪贴板
545
+ 已写入;SSH/tmux 是否允许该请求需在实际环境中验证。
535
546
 
536
547
  ## Themes
537
548
 
@@ -214,8 +214,11 @@ export declare class AgentController {
214
214
  * Cleared at the next `agent_start`.
215
215
  */
216
216
  private abortRequested;
217
- /** User messages queued since the last `turn_start` (§3.5.4a guard 2). */
218
- private userSteerCount;
217
+ /** Only exact acceptance receipts release user messages from reviewer protection. */
218
+ private readonly pendingUserSteering;
219
+ /** Session changes must not make a delayed receipt match a new message. */
220
+ private readonly steeringPrefix;
221
+ private steeringSequence;
219
222
  constructor(config: CliConfig, deps?: ControllerDeps);
220
223
  private resolveKey;
221
224
  /**
@@ -360,7 +363,11 @@ export declare class AgentController {
360
363
  * which it has to be, since a normal quit reaches it twice.
361
364
  */
362
365
  reapServicesSync(): void;
366
+ /** Queue automation/user guidance with the same protection as visible input. */
363
367
  steer(text: string): void;
368
+ /** Return the opaque ID used by the view to match Core's acceptance receipt. */
369
+ queueUserMessage(text: string): string;
370
+ private enqueueSteering;
364
371
  followUp(text: string): void;
365
372
  isRunning(): boolean;
366
373
  getAgentMode(): AgentMode;
@@ -1,13 +1,4 @@
1
- /**
2
- * AgentController — builds the core `Agent`, wires the built-in toolset, and
3
- * exposes a small run/abort/steer surface plus the pre-flight validation that
4
- * turns the most common failure (no API key) into a guided action instead of a
5
- * blank screen (spec §3.3.1 / R2).
6
- *
7
- * The controller never inspects the resolved promise of `agent.prompt()` for
8
- * success — that promise resolves even on failure. Success/failure is derived
9
- * from the event stream by the reducer (TUI) and the headless writer.
10
- */
1
+ import { randomUUID } from 'node:crypto';
11
2
  import { Agent, initProviders, ModelRegistry, } from '@aragon-agent/core';
12
3
  import { clampCompactionConfig, clampFastConfig, clampRetryConfig, clampTeamConfig, clampTodoConfig, isAdapterProvider, toRetryPolicy, } from '../config/schema.js';
13
4
  import { makeGetApiKey } from '../config/load.js';
@@ -252,8 +243,11 @@ export class AgentController {
252
243
  * Cleared at the next `agent_start`.
253
244
  */
254
245
  abortRequested = false;
255
- /** User messages queued since the last `turn_start` (§3.5.4a guard 2). */
256
- userSteerCount = 0;
246
+ /** Only exact acceptance receipts release user messages from reviewer protection. */
247
+ pendingUserSteering = new Set();
248
+ /** Session changes must not make a delayed receipt match a new message. */
249
+ steeringPrefix = randomUUID();
250
+ steeringSequence = 0;
257
251
  constructor(config, deps = {}) {
258
252
  this.config = config;
259
253
  this.cwd = config.cwd;
@@ -634,33 +628,26 @@ export class AgentController {
634
628
  isPricedModel: (ref) => this.isPricedModel(ref),
635
629
  subscribe: (listener) => this.agent.subscribe(listener),
636
630
  complete: (providerId, request) => this.providerRegistry.complete(providerId, request),
637
- // THE REVIEWER GOES THROUGH THIS METHOD, NOT `agent.steer()` DIRECTLY,
638
- // so its own block is counted alongside the user's — which is what
639
- // `userSteerCount` would otherwise mis-report and what would make
640
- // guard 2 clear a queue it does not own (D-21).
641
- //
642
- // It is decremented back out below: the counter must answer "did the
643
- // USER queue anything", and the reviewer's own message is not that.
644
- steer: (text) => {
645
- this.steer(text);
646
- this.userSteerCount -= 1;
647
- },
631
+ // Reviewer advice shares skill activation, but cannot own a user ID.
632
+ steer: (text) => this.enqueueSteering(text, false),
648
633
  isRunning: () => this.isRunning(),
649
634
  isAbortRequested: () => this.abortRequested,
650
- userSteerCount: () => this.userSteerCount,
635
+ userSteerCount: () => this.pendingUserSteering.size,
651
636
  clearAllQueues: () => this.clearAllQueues(),
652
637
  notify: (level, text) => deps.notify?.(level, text),
653
638
  onPromptChanged: () => this.rebuildSystemPrompt(),
654
639
  })
655
640
  : null;
656
641
  // The two guards' bookkeeping, and the only reason this class subscribes to
657
- // its own agent. `turn_start` is where a user steer stops being pending;
642
+ // its own agent. A receipt is where a user steer stops being pending;
658
643
  // `agent_start` is where a requested abort stops being in force.
659
644
  this.agent.subscribe((event) => {
660
645
  if (event.type === 'agent_start')
661
646
  this.abortRequested = false;
662
- if (event.type === 'turn_start')
663
- this.userSteerCount = 0;
647
+ if (event.type === 'steering_accepted') {
648
+ for (const id of event.ids)
649
+ this.pendingUserSteering.delete(id);
650
+ }
664
651
  // The authoritative result arrives with this event, so from this instant
665
652
  // the tail is superseded and its slot must be released (§3.1.1). Clearing
666
653
  // HERE rather than on read is what keeps the store's size a function of
@@ -1052,21 +1039,23 @@ export class AgentController {
1052
1039
  reapServicesSync() {
1053
1040
  this.procs.reapSync();
1054
1041
  }
1042
+ /** Queue automation/user guidance with the same protection as visible input. */
1055
1043
  steer(text) {
1056
- // Mid-run interjection: promote the queue, keep what is already in force
1057
- // (D-G3). Clearing here would make "ask a follow-up" the way around the
1058
- // ceiling; skipping the promotion would miss slash commands, which the TUI
1059
- // routes through `steer` whenever the agent happens to be running.
1044
+ this.enqueueSteering(text, true);
1045
+ }
1046
+ /** Return the opaque ID used by the view to match Core's acceptance receipt. */
1047
+ queueUserMessage(text) {
1048
+ return this.enqueueSteering(text, true);
1049
+ }
1050
+ enqueueSteering(text, user) {
1051
+ // Both reviewer and user guidance can activate queued skill frames.
1060
1052
  if (this.skillsEnabled)
1061
1053
  this.skills.absorbPendingFrames();
1062
- // COUNTED so the reviewer can prove it owns the queue before clearing it
1063
- // (§3.5.4a guard 2 / D-21). This method is the only other writer of
1064
- // `steeringQueue` in the package, and `Agent` exposes no way to remove ONE
1065
- // message — so `userSteerCount === 0` is the only sound licence for
1066
- // `clearAllQueues()`, and with a user message in there the reviewer must
1067
- // leave its own residue rather than destroy something the user typed.
1068
- this.userSteerCount += 1;
1069
- this.agent.steer(text);
1054
+ const id = user ? `${this.steeringPrefix}:${++this.steeringSequence}` : undefined;
1055
+ if (id !== undefined)
1056
+ this.pendingUserSteering.add(id);
1057
+ this.agent.steer(text, id);
1058
+ return id;
1070
1059
  }
1071
1060
  followUp(text) {
1072
1061
  this.agent.followUp(text);
@@ -1524,6 +1513,7 @@ export class AgentController {
1524
1513
  }
1525
1514
  clearAllQueues() {
1526
1515
  this.agent.clearAllQueues();
1516
+ this.pendingUserSteering.clear();
1527
1517
  }
1528
1518
  // -----------------------------------------------------------------------
1529
1519
  // Model / provider info
@@ -0,0 +1,8 @@
1
+ import type { Entry } from './reducer.js';
2
+ /** Full user text retained until Core acknowledges its opaque ID. */
3
+ export interface PendingSteering {
4
+ readonly queueId: string;
5
+ readonly text: string;
6
+ }
7
+ /** Supplement trimmed/cleared history for save and exit without deduplicating text. */
8
+ export declare function mergePendingEntries(entries: Entry[], pending: readonly PendingSteering[]): Entry[];
@@ -0,0 +1,28 @@
1
+ /** Supplement trimmed/cleared history for save and exit without deduplicating text. */
2
+ export function mergePendingEntries(entries, pending) {
3
+ if (pending.length === 0)
4
+ return entries;
5
+ const positions = new Map(pending.map((item, index) => [item.queueId, index]));
6
+ const visible = new Map(entries.flatMap((entry) => entry.kind === 'queued' && entry.queueId !== undefined
7
+ ? [[entry.queueId, entry]] : []));
8
+ const merged = [];
9
+ let cursor = 0;
10
+ const appendThrough = (last) => {
11
+ while (cursor <= last) {
12
+ const item = pending[cursor++];
13
+ const existing = visible.get(item.queueId);
14
+ merged.push(existing ? { ...existing, text: item.text } :
15
+ { id: `pending:${item.queueId}`, kind: 'queued', ...item });
16
+ }
17
+ };
18
+ for (const entry of entries) {
19
+ const position = entry.kind === 'queued' && entry.queueId !== undefined
20
+ ? positions.get(entry.queueId) : undefined;
21
+ if (position === undefined)
22
+ merged.push(entry);
23
+ else
24
+ appendThrough(position);
25
+ }
26
+ appendThrough(pending.length - 1);
27
+ return merged;
28
+ }
@@ -10,6 +10,7 @@
10
10
  * - `agent_end` after a turn that produced no `turn_end` and no error notice
11
11
  * synthesizes a generic error notice (a swallowed throw is never a no-op).
12
12
  */
13
+ import type { PendingSteering } from './queued-messages.js';
13
14
  import type { AgentEvent, ModelCost, ToolResult, TokenUsage } from '@aragon-agent/core';
14
15
  import type { AgentMode } from './agent-mode.js';
15
16
  import type { DispatchOutcome, SubagentRun, SubagentSpec, TeamSnapshot } from '../team/types.js';
@@ -40,7 +41,7 @@ export type Entry = {
40
41
  /**
41
42
  * A message submitted while a run was in flight (tui-shift-enter-copy-queue
42
43
  * 5.2). It renders as `Queue: <first line>` and is rewritten IN PLACE, same
43
- * id, into the `user` entry above by the `turnStart` that proves Core
44
+ * id, into the `user` entry above by the matching receipt that proves Core
44
45
  * accepted it -- so the id-based height cache re-measures rather than
45
46
  * freezes, and the settled boundary (positional, monotonic) never moves.
46
47
  *
@@ -51,6 +52,7 @@ export type Entry = {
51
52
  id: string;
52
53
  kind: 'queued';
53
54
  text: string;
55
+ queueId?: string;
54
56
  } | {
55
57
  id: string;
56
58
  kind: 'assistant';
@@ -275,6 +277,7 @@ export interface RetrySnapshot {
275
277
  }
276
278
  export interface ViewState {
277
279
  entries: Entry[];
280
+ pendingSteering: PendingSteering[];
278
281
  status: 'idle' | 'running';
279
282
  usageTotal: UsageTotal;
280
283
  /**
@@ -402,7 +405,11 @@ export type ViewAction = {
402
405
  */
403
406
  | {
404
407
  type: 'steerQueued';
408
+ queueId: string;
405
409
  text: string;
410
+ } | {
411
+ type: 'steeringAccepted';
412
+ ids: readonly string[];
406
413
  } | {
407
414
  type: 'runStart';
408
415
  } | {
@@ -46,6 +46,7 @@ const EMPTY_USAGE_TOTAL = {
46
46
  export function initialViewState(seed = {}) {
47
47
  return {
48
48
  entries: [],
49
+ pendingSteering: [],
49
50
  status: 'idle',
50
51
  usageTotal: EMPTY_USAGE_TOTAL,
51
52
  context: emptyContextUsage(),
@@ -127,6 +128,8 @@ function firstLine(text) {
127
128
  */
128
129
  export function reduceEvent(event, cost, patches) {
129
130
  switch (event.type) {
131
+ case 'steering_accepted':
132
+ return [{ type: 'steeringAccepted', ids: event.ids }];
130
133
  case 'agent_start':
131
134
  return [{ type: 'runStart' }];
132
135
  case 'turn_start':
@@ -289,29 +292,6 @@ function nextId(state) {
289
292
  function mapEntry(entries, id, fn) {
290
293
  return entries.map((e) => (e.id === id ? fn(e) : e));
291
294
  }
292
- /**
293
- * Rewrite every `queued` entry in place as the user message it became
294
- * (tui-shift-enter-copy-queue 5.2.3).
295
- *
296
- * SAME ID, NEW OBJECT -- the `mapEntry` discipline for a non-append change:
297
- * the position (and therefore the settled boundary, which advances by
298
- * POSITION and never by identity) is untouched, while the new object
299
- * reference is what lets `React.memo` and the height cache see that this
300
- * one row changed. ALL of them at once, because `drainSteering` is a full
301
- * drain: converting only some would show a partial acceptance that never
302
- * happened. Identity-preserving when nothing is queued -- `turnStart` fires
303
- * every turn, and this must stay free for the common case.
304
- */
305
- function convertQueuedEntries(entries) {
306
- let touched = false;
307
- const next = entries.map((e) => {
308
- if (e.kind !== 'queued')
309
- return e;
310
- touched = true;
311
- return { id: e.id, kind: 'user', text: e.text };
312
- });
313
- return touched ? next : entries;
314
- }
315
295
  /**
316
296
  * Seal `thinkingMs`, once, as a spreadable patch (§3.1.2).
317
297
  *
@@ -450,12 +430,27 @@ export function viewReducer(state, action) {
450
430
  }
451
431
  case 'steerQueued': {
452
432
  const { id, seq } = nextId(state);
453
- const entry = { id, kind: 'queued', text: action.text };
433
+ const entry = { id, kind: 'queued', queueId: action.queueId, text: action.text };
454
434
  // NOTHING ELSE RESETS. This is not a new turn -- `turnProduced`,
455
435
  // `errorNoticed` and `aborted` describe the run that is still in
456
436
  // flight, and clearing them here would let the next `runEnd` failure
457
437
  // guard misfire on a turn that produced plenty.
458
- return { ...state, seq, ...appendEntry(state, state.entries, entry) };
438
+ return {
439
+ ...state, seq, ...appendEntry(state, state.entries, entry),
440
+ pendingSteering: [...state.pendingSteering, { queueId: action.queueId, text: action.text }],
441
+ };
442
+ }
443
+ case 'steeringAccepted': {
444
+ const receipts = new Set(action.ids);
445
+ const ids = new Set(state.pendingSteering
446
+ .filter((item) => receipts.has(item.queueId)).map((item) => item.queueId));
447
+ if (ids.size === 0)
448
+ return state;
449
+ const pendingSteering = state.pendingSteering.filter((item) => !ids.has(item.queueId));
450
+ const entries = state.entries.map((entry) => entry.kind === 'queued' && entry.queueId !== undefined && ids.has(entry.queueId)
451
+ ? { id: entry.id, kind: 'user', text: entry.text }
452
+ : entry);
453
+ return { ...state, pendingSteering, entries };
459
454
  }
460
455
  case 'runStart':
461
456
  return {
@@ -467,14 +462,6 @@ export function viewReducer(state, action) {
467
462
  };
468
463
  case 'turnStart': {
469
464
  const { id, seq } = nextId(state);
470
- // CONVERT, THEN APPEND, IN THAT ORDER AND IN THIS ONE ACTION
471
- // (5.2.3): `turn_start` is the authoritative "the steering queue was
472
- // drained into the conversation" signal (the loop drains at its top,
473
- // immediately before emitting it), so the user message and the answer
474
- // that follows it must land in the same commit, in render order, or a
475
- // frame can exist where the message has vanished and the answer has
476
- // not appeared.
477
- const entries = convertQueuedEntries(state.entries);
478
465
  const entry = {
479
466
  id,
480
467
  kind: 'assistant',
@@ -483,7 +470,7 @@ export function viewReducer(state, action) {
483
470
  thinkingOpen: false,
484
471
  streaming: true,
485
472
  };
486
- return { ...state, seq, ...appendEntry(state, entries, entry), streamingId: id };
473
+ return { ...state, seq, ...appendEntry(state, state.entries, entry), streamingId: id };
487
474
  }
488
475
  case 'thinkingStart': {
489
476
  if (!state.streamingId)
@@ -777,6 +764,7 @@ export function viewReducer(state, action) {
777
764
  case 'resetConversation':
778
765
  return {
779
766
  ...state,
767
+ pendingSteering: [],
780
768
  entries: [],
781
769
  droppedEntries: 0,
782
770
  streamingId: undefined,
@@ -1295,6 +1283,7 @@ export function viewReducer(state, action) {
1295
1283
  return {
1296
1284
  ...state,
1297
1285
  entries: restored.entries,
1286
+ pendingSteering: [],
1298
1287
  droppedEntries: restored.dropped,
1299
1288
  seq: maxSeq,
1300
1289
  teamEntryId: undefined,
@@ -9,6 +9,7 @@ import { isAbsolute, resolve } from 'node:path';
9
9
  import { resolveOutputTokens } from '@aragon-agent/core';
10
10
  import { clampThinkingLevel, clampTheme, isAdapterProvider, isThemeName, parseMaxTokensInput, ADAPTER_PROVIDERS, DEFAULT_MAX_TOKENS, HARD_MAX_RETRIES, LEGACY_DARK_THEME, THEME_NAMES, THINKING_LEVELS, } from '../config/schema.js';
11
11
  import { computeCost, formatCost, formatDuration, formatTokens } from '../agent/usage.js';
12
+ import { mergePendingEntries } from '../agent/queued-messages.js';
12
13
  import { TODO_FOLLOW_LIMITS } from '../todo/limits.js';
13
14
  import { buildContinuationMessage } from '../todo/follow-through.js';
14
15
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
@@ -882,6 +883,10 @@ const COMMANDS = [
882
883
  name: 'reset',
883
884
  description: 'Start a new conversation',
884
885
  run: (ctx) => {
886
+ if (ctx.controller.isRunning()) {
887
+ ctx.notify('warn', 'Cannot reset while the agent is running. Wait for it to stop.');
888
+ return;
889
+ }
885
890
  ctx.controller.clearMessages();
886
891
  ctx.controller.clearAllQueues();
887
892
  ctx.dispatch({ type: 'resetConversation' });
@@ -920,7 +925,7 @@ const COMMANDS = [
920
925
  ...(cfg.baseUrl ? { baseUrl: cfg.baseUrl } : {}),
921
926
  },
922
927
  messages: ctx.controller.getMessages(),
923
- entries: ctx.state.entries,
928
+ entries: mergePendingEntries(ctx.state.entries, ctx.state.pendingSteering),
924
929
  // `/resume` restores `messages`, so the belief that justifies the
925
930
  // panel comes back — and the panel has to come back with it (§3.13).
926
931
  todos: ctx.controller.getTodoSnapshot()?.items ?? [],
@@ -936,16 +941,22 @@ const COMMANDS = [
936
941
  name: 'resume',
937
942
  description: 'Load a saved session',
938
943
  run: (ctx) => {
944
+ if (ctx.controller.isRunning()) {
945
+ ctx.notify('warn', 'Cannot resume while the agent is running. Wait for it to stop.');
946
+ return;
947
+ }
939
948
  try {
940
949
  const path = resolveSessionPath(ctx.args, ctx.controller.getCwd());
941
950
  const session = loadSession(path);
951
+ if (ctx.controller.isRunning()) {
952
+ ctx.notify('warn', 'Cannot resume while the agent is running. Wait for it to stop.');
953
+ return;
954
+ }
955
+ // Loading has validated and normalized every external value. Commit
956
+ // synchronously so no new run can start between clearing and restoring.
957
+ ctx.controller.clearAllQueues();
942
958
  ctx.controller.replaceMessages(session.messages);
943
- // IN THE SAME `try`, IMMEDIATELY AFTER `replaceMessages`, so a malformed
944
- // file fails the whole resume rather than half of it — and AN ABSENT
945
- // `todos` CLEARS RATHER THAN SKIPPING (P0-2 / D-22). `replaceMessages`
946
- // has just discarded the conversation the current list belonged to, so
947
- // leaving the rail up produces a panel that disagrees with the model,
948
- // which §1.2 ranks below having no panel at all.
959
+ // Absence clears the previous conversation's list, including old files.
949
960
  ctx.controller.restoreTodos(session.todos ?? []);
950
961
  ctx.dispatch({ type: 'restoreEntries', entries: session.entries });
951
962
  if (session.model?.providerId && session.model?.modelId) {
@@ -1049,6 +1060,9 @@ const COMMANDS = [
1049
1060
  ' Windows Terminal settings.json, actions array:',
1050
1061
  ' { "keys": "shift+enter", "command": { "action": "sendInput",',
1051
1062
  ' "input": "\\u001b[13;2u" } }',
1063
+ ' PowerShell runs inside the terminal; configure this binding in',
1064
+ ' Windows Terminal, not in the PowerShell profile. If both keys',
1065
+ ' send CR, the application cannot distinguish Shift+Enter from Enter.',
1052
1066
  ' VS Code terminal keybindings.json:',
1053
1067
  ' { "key": "shift+enter",',
1054
1068
  ' "command": "workbench.action.terminal.sendSequence",',
@@ -38,16 +38,17 @@ export interface EnterSequenceSpec {
38
38
  * `'\r'` rather than to a newline.
39
39
  */
40
40
  export declare const ENTER_SEQUENCES: readonly EnterSequenceSpec[];
41
- /**
42
- * Rewrite every recognized Enter-family sequence to its normalized token.
43
- *
44
- * ONE ANCHORED REGEX, not a per-sequence scan and not a state machine: the
45
- * CSI-u family shares one shape and the two literal Alt+Enter encodings cannot
46
- * nest inside it, so a single left-to-right pass is unambiguous. `\x1b[13;9u`
47
- * and other unknown modifiers are deliberately left untouched -- rewriting an
48
- * unrecognized sequence would be guessing at a terminal dialect we have not
49
- * seen.
50
- */
41
+ export type EnterSequenceSegment = {
42
+ kind: 'text';
43
+ text: string;
44
+ } | {
45
+ kind: 'newline';
46
+ } | {
47
+ kind: 'submit';
48
+ };
49
+ /** Split explicit keys using the recognition table; bare CR/LF remain text. */
50
+ export declare function splitEnterSequences(text: string): EnterSequenceSegment[];
51
+ /** Normalize explicit Enter keys without changing unknown sequences. */
51
52
  export declare function rewriteEnterSequences(text: string): string;
52
53
  /**
53
54
  * Length of the trailing run of `text` that is a proper prefix of an
@@ -30,6 +30,7 @@ import { ENTER_NEWLINE_FRAME } from './limits.js';
30
30
  * `'\r'` rather than to a newline.
31
31
  */
32
32
  export const ENTER_SEQUENCES = [
33
+ { seq: '\x1b[13;1u', meaning: 'enter', to: '\r' },
33
34
  {
34
35
  seq: '\x1b[13;2u',
35
36
  meaning: 'shift+enter',
@@ -88,34 +89,35 @@ export const ENTER_SEQUENCES = [
88
89
  * up to 6 of them.
89
90
  */
90
91
  const MAX_ENTER_PREFIX = Math.max(...ENTER_SEQUENCES.map((s) => s.seq.length)) - 1;
91
- /** `\x1b[13;2u` .. `\x1b[13;8u`, or `\x1b[13u` when the modifier is absent. */
92
- const CSI_U_ENTER = /\x1b\[13(?:;[2-8])?u/g;
93
- /**
94
- * Rewrite every recognized Enter-family sequence to its normalized token.
95
- *
96
- * ONE ANCHORED REGEX, not a per-sequence scan and not a state machine: the
97
- * CSI-u family shares one shape and the two literal Alt+Enter encodings cannot
98
- * nest inside it, so a single left-to-right pass is unambiguous. `\x1b[13;9u`
99
- * and other unknown modifiers are deliberately left untouched -- rewriting an
100
- * unrecognized sequence would be guessing at a terminal dialect we have not
101
- * seen.
102
- */
92
+ /** Split explicit keys using the recognition table; bare CR/LF remain text. */
93
+ export function splitEnterSequences(text) {
94
+ const segments = [];
95
+ let start = 0;
96
+ let index = 0;
97
+ while (index < text.length) {
98
+ const match = text[index] === '\x1b'
99
+ ? ENTER_SEQUENCES.find((spec) => text.startsWith(spec.seq, index)) : undefined;
100
+ if (!match) {
101
+ index += 1;
102
+ continue;
103
+ }
104
+ if (index > start)
105
+ segments.push({ kind: 'text', text: text.slice(start, index) });
106
+ segments.push({ kind: match.to === '\r' ? 'submit' : 'newline' });
107
+ index += match.seq.length;
108
+ start = index;
109
+ }
110
+ if (start < text.length)
111
+ segments.push({ kind: 'text', text: text.slice(start) });
112
+ return segments;
113
+ }
114
+ /** Normalize explicit Enter keys without changing unknown sequences. */
103
115
  export function rewriteEnterSequences(text) {
104
- if (!text.includes('\x1b'))
105
- return text;
106
- // ONE PASS, TWO TARGETS: a MODIFIED Enter is a newline intent, while the
107
- // modifier-less CSI-u Enter a leftover kitty mode emits is a PLAIN Enter
108
- // and must still submit (`\r`). The `;` is the discriminator the table
109
- // above records in each entry's `to` field.
110
- let out = text.replace(CSI_U_ENTER, (seq) => (seq.includes(';') ? ENTER_NEWLINE_FRAME : '\r'));
111
- // Only the two literal Alt+Enter encodings remain. `\x1b\n` is checked
112
- // before `\x1b\r` is replaced; they share no bytes, so order is free, but
113
- // splitting the escapes keeps each replacement self-evident.
114
- if (out.includes('\x1b\n'))
115
- out = out.split('\x1b\n').join(ENTER_NEWLINE_FRAME);
116
- if (out.includes('\x1b\r'))
117
- out = out.split('\x1b\r').join(ENTER_NEWLINE_FRAME);
118
- return out;
116
+ return splitEnterSequences(text).map((segment) => {
117
+ if (segment.kind === 'text')
118
+ return segment.text;
119
+ return segment.kind === 'submit' ? '\r' : ENTER_NEWLINE_FRAME;
120
+ }).join('');
119
121
  }
120
122
  /** True when `s` is a PROPER prefix of some recognized sequence (never whole). */
121
123
  function isEnterPrefix(s) {