@co0ontty/wand 1.20.4 → 1.21.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.
@@ -177,8 +177,11 @@ export declare class ClaudePtyBridge extends EventEmitter {
177
177
  private finalizeResponse;
178
178
  /**
179
179
  * Find the end index of the echoed user input in the PTY buffer.
180
- * The echo may contain ANSI codes between characters.
181
- * Returns the index after the last character of the echo.
180
+ * Returns 0 if the echo cannot be fully matched.
181
+ *
182
+ * Why: ANSI escapes and whitespace can interleave the echoed characters
183
+ * (line wrapping, padding, color codes), so matching skips them while
184
+ * comparing every printable codepoint of `userInput` in order.
182
185
  */
183
186
  private findEchoEndIndex;
184
187
  private cleanForChat;
@@ -7,9 +7,9 @@
7
7
  * 2. Structured messages for chat view (parsed)
8
8
  */
9
9
  import { EventEmitter } from "node:events";
10
- import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitConfirmSyntax, hasPermissionActionContext, scorePermissionLikelihood, FALLBACK_SCORE_THRESHOLD, isSlashCommandMenu } from "./pty-text-utils.js";
10
+ import { stripAnsi, isNoiseLine, appendWindow, normalizePromptText, hasExplicitConfirmSyntax, hasPermissionActionContext, scorePermissionLikelihood, FALLBACK_SCORE_THRESHOLD, isSlashCommandMenu, stripForEchoMatch, skipAnsiSequence, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
11
11
  // ── Constants ──
12
- const OUTPUT_MAX_SIZE = 120000;
12
+ const OUTPUT_MAX_SIZE = PTY_OUTPUT_MAX_SIZE;
13
13
  const SESSION_ID_WINDOW_SIZE = 16384;
14
14
  const PERMISSION_WINDOW_SIZE = 2000;
15
15
  const AUTO_APPROVE_DELAY_MS = 350;
@@ -830,29 +830,42 @@ export class ClaudePtyBridge extends EventEmitter {
830
830
  // ── Text Processing Utilities ──
831
831
  /**
832
832
  * Find the end index of the echoed user input in the PTY buffer.
833
- * The echo may contain ANSI codes between characters.
834
- * Returns the index after the last character of the echo.
833
+ * Returns 0 if the echo cannot be fully matched.
834
+ *
835
+ * Why: ANSI escapes and whitespace can interleave the echoed characters
836
+ * (line wrapping, padding, color codes), so matching skips them while
837
+ * comparing every printable codepoint of `userInput` in order.
835
838
  */
836
839
  findEchoEndIndex(buffer, userInput) {
837
- // Keep alphanumeric and common symbols for matching
838
- const inputChars = userInput.replace(/[^a-zA-Z0-9+=?!\-]/g, "");
840
+ const inputChars = stripForEchoMatch(userInput);
839
841
  if (inputChars.length === 0)
840
842
  return 0;
841
843
  let matchedChars = 0;
842
844
  let endIndex = 0;
843
- for (let i = 0; i < buffer.length && matchedChars < inputChars.length; i++) {
845
+ let i = 0;
846
+ while (i < buffer.length && matchedChars < inputChars.length) {
844
847
  const ch = buffer[i];
845
- // Check if this printable char matches the next expected char
846
- if (/[a-zA-Z0-9+=?!\-]/.test(ch) && ch.toLowerCase() === inputChars[matchedChars].toLowerCase()) {
848
+ const code = ch.charCodeAt(0);
849
+ // Skip a complete ANSI escape sequence (CSI/OSC/etc.).
850
+ if (code === 0x1b) {
851
+ i = skipAnsiSequence(buffer, i);
852
+ continue;
853
+ }
854
+ // Skip control chars and whitespace — they do not appear in `inputChars`.
855
+ if (code < 0x20 || code === 0x7f || code === 0x20) {
856
+ i++;
857
+ continue;
858
+ }
859
+ if (ch.toLowerCase() === inputChars[matchedChars].toLowerCase()) {
847
860
  matchedChars++;
848
861
  endIndex = i + 1;
849
862
  }
850
- // Skip ANSI codes and other non-matching characters
863
+ i++;
851
864
  }
852
865
  // Look for a newline or prompt marker after the echo
853
- for (let i = endIndex; i < buffer.length && i < endIndex + 50; i++) {
854
- if (buffer[i] === "\n" || buffer[i] === "\r") {
855
- endIndex = i + 1;
866
+ for (let j = endIndex; j < buffer.length && j < endIndex + 50; j++) {
867
+ if (buffer[j] === "\n" || buffer[j] === "\r") {
868
+ endIndex = j + 1;
856
869
  break;
857
870
  }
858
871
  }
package/dist/config.js CHANGED
@@ -20,6 +20,7 @@ export const defaultConfig = () => ({
20
20
  android: defaultAndroidApkConfig(),
21
21
  cardDefaults: defaultCardExpandDefaults(),
22
22
  defaultModel: "",
23
+ structuredRunner: "cli",
23
24
  commandPresets: [
24
25
  {
25
26
  label: "Claude",
@@ -185,6 +186,7 @@ function mergeWithDefaults(input) {
185
186
  android: normalizeAndroidApkConfig(input.android) ?? defaults.android,
186
187
  cardDefaults: normalizeCardDefaults(input.cardDefaults),
187
188
  defaultModel: typeof input.defaultModel === "string" ? input.defaultModel.trim() : defaults.defaultModel,
189
+ structuredRunner: (input.structuredRunner === "sdk" || input.structuredRunner === "cli") ? input.structuredRunner : defaults.structuredRunner,
188
190
  };
189
191
  }
190
192
  export function isExecutionMode(value) {
@@ -341,9 +341,19 @@ export async function runQuickCommit(opts) {
341
341
  if (push) {
342
342
  try {
343
343
  let hasUpstream = false;
344
+ let pushRemote = "origin";
344
345
  try {
345
346
  runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
346
347
  hasUpstream = true;
348
+ try {
349
+ const currentBranch = runGit(["branch", "--show-current"], cwd);
350
+ if (currentBranch) {
351
+ pushRemote = runGit(["config", "--get", `branch.${currentBranch}.remote`], cwd) || "origin";
352
+ }
353
+ }
354
+ catch {
355
+ pushRemote = "origin";
356
+ }
347
357
  }
348
358
  catch {
349
359
  hasUpstream = false;
@@ -352,11 +362,9 @@ export async function runQuickCommit(opts) {
352
362
  runGit(["push", "--recurse-submodules=on-demand"], cwd, GIT_PUSH_TIMEOUT_MS);
353
363
  }
354
364
  else {
355
- runGit(["push", "-u", "--recurse-submodules=on-demand", "origin", "HEAD"], cwd, GIT_PUSH_TIMEOUT_MS);
356
- }
357
- if (tagName) {
358
- runGit(["push", "origin", `refs/tags/${tagName}`], cwd, GIT_PUSH_TIMEOUT_MS);
365
+ runGit(["push", "-u", "--recurse-submodules=on-demand", pushRemote, "HEAD"], cwd, GIT_PUSH_TIMEOUT_MS);
359
366
  }
367
+ runGit(["push", pushRemote, "--tags"], cwd, GIT_PUSH_TIMEOUT_MS);
360
368
  pushed = true;
361
369
  }
362
370
  catch (error) {
package/dist/models.d.ts CHANGED
@@ -1,11 +1,13 @@
1
- import { ClaudeModelInfo } from "./types.js";
1
+ import { ClaudeModelInfo, SessionProvider } from "./types.js";
2
2
  interface ModelCache {
3
3
  models: ClaudeModelInfo[];
4
+ codexModels: ClaudeModelInfo[];
4
5
  claudeVersion: string | null;
5
6
  refreshedAt: string;
6
7
  }
7
8
  export declare function getCachedModels(): ModelCache;
8
9
  export declare function refreshModels(): Promise<ModelCache>;
10
+ export declare function getModelsForProvider(provider: SessionProvider): ClaudeModelInfo[];
9
11
  /** 返回可用于 claude CLI 的全部已知 model id(含别名) */
10
12
  export declare function knownModelIds(): string[];
11
13
  /** 判断传入值是否是已知模型;允许自由文本,因此总是返回 true。保留接口以便将来严格校验。 */
package/dist/models.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  const execAsync = promisify(exec);
4
- const BUILT_IN_MODELS = [
4
+ const CLAUDE_MODELS = [
5
5
  { id: "default", label: "default(跟随 Claude Code 默认)", alias: true },
6
6
  { id: "opus", label: "opus(最新 Opus)", alias: true },
7
7
  { id: "sonnet", label: "sonnet(最新 Sonnet)", alias: true },
@@ -11,9 +11,12 @@ const BUILT_IN_MODELS = [
11
11
  { id: "claude-sonnet-4-6", label: "Sonnet 4.6 · claude-sonnet-4-6" },
12
12
  { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5 · claude-haiku-4-5-20251001" },
13
13
  ];
14
+ const CODEX_FALLBACK_MODELS = [
15
+ { id: "default", label: "default(跟随 Codex 默认)", alias: true },
16
+ ];
14
17
  let cache = null;
15
- function cloneDefaults() {
16
- return BUILT_IN_MODELS.map((m) => ({ ...m }));
18
+ function cloneClaudeModels() {
19
+ return CLAUDE_MODELS.map((m) => ({ ...m }));
17
20
  }
18
21
  async function probeClaudeVersion() {
19
22
  try {
@@ -25,10 +28,37 @@ async function probeClaudeVersion() {
25
28
  return null;
26
29
  }
27
30
  }
31
+ async function probeCodexModels() {
32
+ try {
33
+ const { stdout } = await execAsync("codex debug models", { timeout: 8000 });
34
+ const data = JSON.parse(stdout);
35
+ const visible = data.models
36
+ .filter((m) => m.visibility === "list")
37
+ .sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99));
38
+ if (!visible.length)
39
+ return CODEX_FALLBACK_MODELS.map((m) => ({ ...m }));
40
+ const result = [
41
+ { id: "default", label: "default(跟随 Codex 默认)", alias: true },
42
+ ];
43
+ for (const m of visible) {
44
+ result.push({
45
+ id: m.slug,
46
+ label: m.display_name && m.display_name !== m.slug
47
+ ? `${m.display_name} · ${m.slug}`
48
+ : m.slug,
49
+ });
50
+ }
51
+ return result;
52
+ }
53
+ catch {
54
+ return CODEX_FALLBACK_MODELS.map((m) => ({ ...m }));
55
+ }
56
+ }
28
57
  export function getCachedModels() {
29
58
  if (!cache) {
30
59
  cache = {
31
- models: cloneDefaults(),
60
+ models: cloneClaudeModels(),
61
+ codexModels: CODEX_FALLBACK_MODELS.map((m) => ({ ...m })),
32
62
  claudeVersion: null,
33
63
  refreshedAt: new Date().toISOString(),
34
64
  };
@@ -36,17 +66,25 @@ export function getCachedModels() {
36
66
  return cache;
37
67
  }
38
68
  export async function refreshModels() {
39
- const version = await probeClaudeVersion();
69
+ const [version, codexModels] = await Promise.all([
70
+ probeClaudeVersion(),
71
+ probeCodexModels(),
72
+ ]);
40
73
  cache = {
41
- models: cloneDefaults(),
74
+ models: cloneClaudeModels(),
75
+ codexModels,
42
76
  claudeVersion: version,
43
77
  refreshedAt: new Date().toISOString(),
44
78
  };
45
79
  return cache;
46
80
  }
81
+ export function getModelsForProvider(provider) {
82
+ const cached = getCachedModels();
83
+ return provider === "codex" ? cached.codexModels : cached.models;
84
+ }
47
85
  /** 返回可用于 claude CLI 的全部已知 model id(含别名) */
48
86
  export function knownModelIds() {
49
- return BUILT_IN_MODELS.map((m) => m.id);
87
+ return CLAUDE_MODELS.map((m) => m.id);
50
88
  }
51
89
  /** 判断传入值是否是已知模型;允许自由文本,因此总是返回 true。保留接口以便将来严格校验。 */
52
90
  export function isKnownModel(_value) {
@@ -50,6 +50,8 @@ export declare class ProcessManager extends EventEmitter {
50
50
  provider?: SessionProvider;
51
51
  model?: string;
52
52
  reuseId?: string;
53
+ cols?: number;
54
+ rows?: number;
53
55
  }): SessionSnapshot;
54
56
  list(): SessionSnapshot[];
55
57
  /** Return lightweight snapshots for the session list (no output/messages). */
@@ -8,7 +8,7 @@ import pty from "node-pty";
8
8
  import { SessionLogger } from "./session-logger.js";
9
9
  import { ClaudePtyBridge } from "./claude-pty-bridge.js";
10
10
  import { truncateMessagesForTransport } from "./message-truncator.js";
11
- import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText } from "./pty-text-utils.js";
11
+ import { appendWindow, hasExplicitConfirmSyntax, hasPermissionActionContext, normalizePromptText, PTY_OUTPUT_MAX_SIZE } from "./pty-text-utils.js";
12
12
  import { prepareSessionWorktree } from "./git-worktree.js";
13
13
  import { getResumeCommandSessionId } from "./resume-policy.js";
14
14
  function resolveProviderFromCommand(command) {
@@ -108,6 +108,10 @@ function hasRecentProjectActivity(candidate, startedAt) {
108
108
  }
109
109
  function selectClaudeProjectSessionForRecord(record) {
110
110
  const knownMtimes = record.knownClaudeProjectMtimes ?? new Map();
111
+ // Only consider files created/touched AFTER this wand session started — those
112
+ // are the ones a fresh `claude` invocation could have produced. Files that
113
+ // existed before (knownMtimes entry present) are tolerated only if they
114
+ // grew since we observed them, but we de-prioritize them below.
111
115
  const candidates = listClaudeProjectSessionCandidates(record.cwd)
112
116
  .filter((candidate) => {
113
117
  const previousMtime = knownMtimes.get(candidate.id);
@@ -125,7 +129,19 @@ function selectClaudeProjectSessionForRecord(record) {
125
129
  if (!hasUserTurn) {
126
130
  return null;
127
131
  }
128
- return candidates[0] ?? null;
132
+ // Prefer brand-new files (id not in knownMtimes at session start). When more
133
+ // than one fresh candidate exists in parallel sessions, refuse to bind —
134
+ // mis-binding another session's history is worse than waiting for the bridge
135
+ // to capture the canonical session id from PTY output.
136
+ const fresh = candidates.filter((candidate) => !knownMtimes.has(candidate.id));
137
+ if (fresh.length === 1) {
138
+ return fresh[0];
139
+ }
140
+ if (fresh.length > 1) {
141
+ return null;
142
+ }
143
+ // Fallback: existing file that grew. Only bind if a single grown candidate.
144
+ return candidates.length === 1 ? candidates[0] : null;
129
145
  }
130
146
  function getLatestClaudeProjectSessionId(record) {
131
147
  return selectClaudeProjectSessionForRecord(record)?.id ?? null;
@@ -407,7 +423,10 @@ export class ProcessManager extends EventEmitter {
407
423
  confirmWindow: "",
408
424
  ptyPermissionBlocked: false,
409
425
  lastAutoConfirmAt: 0,
410
- autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
426
+ // Preserve a user-toggled auto-approve setting across server restarts
427
+ // instead of recomputing it from the command/mode pair.
428
+ autoApprovePermissions: snapshot.autoApprovePermissions
429
+ ?? this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
411
430
  pendingEscalation: snapshot.pendingEscalation ?? null,
412
431
  lastEscalationResult: snapshot.lastEscalationResult ?? null,
413
432
  autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
@@ -426,7 +445,9 @@ export class ProcessManager extends EventEmitter {
426
445
  claudeTaskDiscoveryTimer: null,
427
446
  knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(updated.cwd) : undefined,
428
447
  claudeSessionId: resumeCommandSessionId ?? updated.claudeSessionId,
429
- approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
448
+ approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
449
+ ptyCols: snapshot.ptyCols ?? 120,
450
+ ptyRows: snapshot.ptyRows ?? 36,
430
451
  });
431
452
  this.orphanRecoveredCount += 1;
432
453
  }
@@ -440,7 +461,8 @@ export class ProcessManager extends EventEmitter {
440
461
  confirmWindow: "",
441
462
  ptyPermissionBlocked: false,
442
463
  lastAutoConfirmAt: 0,
443
- autoApprovePermissions: this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
464
+ autoApprovePermissions: snapshot.autoApprovePermissions
465
+ ?? this.shouldAutoApprovePermissions(snapshot.command, snapshot.mode, provider),
444
466
  pendingEscalation: snapshot.pendingEscalation ?? null,
445
467
  lastEscalationResult: snapshot.lastEscalationResult ?? null,
446
468
  autonomyPolicy: snapshot.autonomyPolicy ?? this.defaultAutonomyPolicy(snapshot.mode),
@@ -459,7 +481,9 @@ export class ProcessManager extends EventEmitter {
459
481
  claudeTaskDiscoveryTimer: null,
460
482
  knownClaudeProjectMtimes: isClaudeCmd ? listClaudeProjectSessionMtimes(snapshot.cwd) : undefined,
461
483
  claudeSessionId: resumeCommandSessionId ?? snapshot.claudeSessionId,
462
- approvalStats: { tool: 0, command: 0, file: 0, total: 0 }
484
+ approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
485
+ ptyCols: snapshot.ptyCols ?? 120,
486
+ ptyRows: snapshot.ptyRows ?? 36,
463
487
  });
464
488
  }
465
489
  }
@@ -531,12 +555,22 @@ export class ProcessManager extends EventEmitter {
531
555
  ? path.resolve(process.cwd(), cwd)
532
556
  : path.resolve(process.cwd(), this.config.defaultCwd);
533
557
  const id = opts?.reuseId || randomUUID();
558
+ // When a session is being resumed under the same id, capture its prior
559
+ // structured messages so the new bridge can present them as the chat
560
+ // history. We deliberately do NOT carry over rawOutput — `claude --resume`
561
+ // re-prints its own banner and replayed history into the new PTY, and
562
+ // mixing the two would surface every line twice in the terminal view.
563
+ let priorMessages = [];
534
564
  if (opts?.reuseId) {
535
565
  const oldRecord = this.sessions.get(id);
536
566
  if (oldRecord) {
567
+ priorMessages = oldRecord.ptyBridge?.getMessages() ?? oldRecord.messages ?? [];
537
568
  this.cleanupRecord(oldRecord);
538
569
  this.sessions.delete(id);
539
570
  }
571
+ else {
572
+ priorMessages = this.storage.getSession(id)?.messages ?? [];
573
+ }
540
574
  }
541
575
  const worktreeSetup = opts?.worktreeEnabled
542
576
  ? prepareSessionWorktree({ cwd: baseCwd, sessionId: id })
@@ -588,7 +622,7 @@ export class ProcessManager extends EventEmitter {
588
622
  rememberedEscalationScopes: new Set(),
589
623
  rememberedEscalationTargets: new Set(),
590
624
  storedOutput: "",
591
- messages: [],
625
+ messages: priorMessages,
592
626
  childProcess: null,
593
627
  ptyBridge: null,
594
628
  currentTask: null,
@@ -599,6 +633,8 @@ export class ProcessManager extends EventEmitter {
599
633
  knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined,
600
634
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
601
635
  selectedModel: selectedModel ?? null,
636
+ ptyCols: opts?.cols !== undefined ? clampDimension(opts.cols, 20, 400) : 120,
637
+ ptyRows: opts?.rows !== undefined ? clampDimension(opts.rows, 10, 160) : 36,
602
638
  };
603
639
  if (isClaudeProvider) {
604
640
  record.ptyBridge = new ClaudePtyBridge({
@@ -606,6 +642,7 @@ export class ProcessManager extends EventEmitter {
606
642
  isClaudeCommand: true,
607
643
  autoApprove: record.autoApprovePermissions,
608
644
  approvalPolicy: record.approvalPolicy,
645
+ initialMessages: priorMessages,
609
646
  });
610
647
  record.ptyBridge.on("event", (event) => {
611
648
  this.handleBridgeEvent(record, event);
@@ -629,8 +666,10 @@ export class ProcessManager extends EventEmitter {
629
666
  WAND_AUTO_EDIT: effectiveMode === "auto-edit" ? "1" : "0"
630
667
  },
631
668
  name: "xterm-color",
632
- cols: 120,
633
- rows: 36
669
+ // 使用 record 上由前端协商好的真实尺寸,避免"先 120 列、几百毫秒后再 resize"
670
+ // 期间 Claude/Codex 用错列宽渲染出 \x1b[120G 这类绝对列定位序列。
671
+ cols: record.ptyCols,
672
+ rows: record.ptyRows
634
673
  });
635
674
  }
636
675
  catch (err) {
@@ -705,7 +744,7 @@ export class ProcessManager extends EventEmitter {
705
744
  rec.output = rec.ptyBridge.getRawOutput();
706
745
  }
707
746
  else {
708
- rec.output = appendWindow(rec.output, chunk, 200_000);
747
+ rec.output = appendWindow(rec.output, chunk, PTY_OUTPUT_MAX_SIZE);
709
748
  }
710
749
  this.logger.appendPtyOutput(id, chunk);
711
750
  if (!rec.ptyBridge) {
@@ -874,12 +913,9 @@ export class ProcessManager extends EventEmitter {
874
913
  */
875
914
  setSessionModel(id, model) {
876
915
  const record = this.mustGet(id);
877
- if (record.provider !== "claude") {
878
- throw new Error("仅 Claude 会话支持切换模型。");
879
- }
880
916
  const normalized = model?.trim() || null;
881
917
  record.selectedModel = normalized;
882
- if (record.status === "running" && record.ptyProcess) {
918
+ if (record.provider === "claude" && record.status === "running" && record.ptyProcess) {
883
919
  const value = normalized && normalized !== "default" ? normalized : "default";
884
920
  record.ptyProcess.write(`/model ${value}\r`);
885
921
  }
@@ -952,7 +988,20 @@ export class ProcessManager extends EventEmitter {
952
988
  }
953
989
  const safeCols = clampDimension(cols, 20, 400);
954
990
  const safeRows = clampDimension(rows, 10, 160);
991
+ const changed = safeCols !== record.ptyCols || safeRows !== record.ptyRows;
955
992
  record.ptyProcess.resize(safeCols, safeRows);
993
+ record.ptyCols = safeCols;
994
+ record.ptyRows = safeRows;
995
+ if (changed) {
996
+ // Notify every subscribed client of the new authoritative dimensions so
997
+ // any other tab/device can re-fit its terminal instead of rendering
998
+ // wrap-broken output sized for someone else's viewport.
999
+ this.emitEvent({
1000
+ type: "status",
1001
+ sessionId: id,
1002
+ data: { ptyCols: safeCols, ptyRows: safeRows },
1003
+ });
1004
+ }
956
1005
  return this.snapshot(record);
957
1006
  }
958
1007
  stop(id) {
@@ -1151,6 +1200,8 @@ export class ProcessManager extends EventEmitter {
1151
1200
  summary: deriveSessionSummary(messages, record.currentTask?.title ?? null),
1152
1201
  currentTaskTitle: record.status === "running" ? record.currentTask?.title ?? undefined : undefined,
1153
1202
  selectedModel: record.selectedModel ?? null,
1203
+ ptyCols: record.ptyCols,
1204
+ ptyRows: record.ptyRows,
1154
1205
  };
1155
1206
  }
1156
1207
  /** Lightweight snapshot for list views — omits output and messages. */
@@ -1369,9 +1420,16 @@ export class ProcessManager extends EventEmitter {
1369
1420
  record.output = record.ptyBridge?.getRawOutput() ?? record.output;
1370
1421
  const rawMessages = record.ptyBridge?.getMessages() ?? [];
1371
1422
  const isStreaming = record.status === "running";
1423
+ const bridgeData = event.data;
1372
1424
  const data = {
1373
1425
  permissionBlocked: this.isPermissionBlocked(record),
1374
1426
  };
1427
+ // 透传 bridge 给出的 isResponding(true=流式中, false=本轮已完成)。
1428
+ // 前端用它检测 thinking→idle 边界并主动做一次终端 resync,把 Claude/Codex
1429
+ // 在流式渲染过程中残留的错位光标定位序列洗掉(等价于按一次右上角缩放)。
1430
+ if (bridgeData && typeof bridgeData.isResponding === "boolean") {
1431
+ data.isResponding = bridgeData.isResponding;
1432
+ }
1375
1433
  if (isStreaming && rawMessages.length > 0) {
1376
1434
  data.incremental = true;
1377
1435
  const lastTurn = rawMessages[rawMessages.length - 1];
@@ -1523,13 +1581,18 @@ export class ProcessManager extends EventEmitter {
1523
1581
  }
1524
1582
  processCommandForMode(command, mode, provider, model) {
1525
1583
  if (provider === "codex") {
1526
- if (mode !== "full-access") {
1527
- return command;
1584
+ let result = command;
1585
+ const trimmedModel = model?.trim();
1586
+ if (trimmedModel && trimmedModel !== "default" && !/--model\s/.test(command) && !/-m\s/.test(command)) {
1587
+ const escapedModel = trimmedModel.replace(/'/g, "'\\''");
1588
+ result += ` --model '${escapedModel}'`;
1528
1589
  }
1529
- if (/--dangerously-bypass-approvals-and-sandbox(?:\s|$)/.test(command)) {
1530
- return command;
1590
+ if (mode === "full-access") {
1591
+ if (!/--dangerously-bypass-approvals-and-sandbox(?:\s|$)/.test(result)) {
1592
+ result += " --dangerously-bypass-approvals-and-sandbox";
1593
+ }
1531
1594
  }
1532
- return `${command} --dangerously-bypass-approvals-and-sandbox`;
1595
+ return result;
1533
1596
  }
1534
1597
  const isClaudeCmd = /^(?:claude|npx\s+claude|[^\s]+\/claude)(?:\s|$)/.test(command);
1535
1598
  if (!isClaudeCmd)
@@ -1,12 +1,42 @@
1
1
  /**
2
2
  * Shared PTY text processing utilities for consistent ANSI stripping and noise filtering.
3
3
  */
4
+ /**
5
+ * Hard cap on the in-memory PTY replay buffer. Shared between ProcessManager
6
+ * and ClaudePtyBridge so a session keeps the same amount of history regardless
7
+ * of which capture path is active.
8
+ */
9
+ export declare const PTY_OUTPUT_MAX_SIZE = 200000;
4
10
  /** Strip ANSI escape sequences and control characters from raw PTY output. */
5
11
  export declare function stripAnsi(text: string): string;
6
12
  /** Lines considered as UI noise that should be excluded from chat view. */
7
13
  export declare function isNoiseLine(line: string): boolean;
8
- /** Append text to a windowed buffer, trimming from start if over max size. */
14
+ /**
15
+ * Append text to a windowed buffer, trimming from start if over max size.
16
+ *
17
+ * The cut point is chosen so it never lands inside:
18
+ * - a UTF-16 surrogate pair (would corrupt the leading codepoint)
19
+ * - an unterminated ANSI escape sequence (would feed orphan "[31m..."
20
+ * text to a downstream terminal renderer)
21
+ *
22
+ * The returned buffer may be slightly shorter than maxSize.
23
+ */
9
24
  export declare function appendWindow(buffer: string, chunk: string, maxSize: number): string;
25
+ /** Slice keeping the last ~maxSize chars on a safe boundary. Exported for tests. */
26
+ export declare function safeSliceTail(text: string, maxSize: number): string;
27
+ /**
28
+ * Strip a string down to the printable codepoints used for echo matching.
29
+ * Removes control characters, whitespace and ANSI escapes; keeps all other
30
+ * visible characters (including `/`, `()`, `:`, CJK, emoji, etc.) so that
31
+ * echo alignment works for any user input.
32
+ */
33
+ export declare function stripForEchoMatch(input: string): string;
34
+ /**
35
+ * Given an index pointing at ESC (0x1b), return the index of the first
36
+ * character AFTER the escape sequence. Handles CSI, OSC and simple ESC-
37
+ * letter forms. Returns idx+1 if nothing matches (best-effort skip).
38
+ */
39
+ export declare function skipAnsiSequence(text: string, idx: number): number;
10
40
  export declare function hasExplicitConfirmSyntax(normalized: string): boolean;
11
41
  export declare function hasPermissionActionContext(normalized: string): boolean;
12
42
  /**