@oh-my-pi/pi-coding-agent 14.5.6 → 14.5.8

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 (34) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +7 -7
  3. package/src/config/model-registry.ts +23 -1
  4. package/src/config/settings-schema.ts +24 -1
  5. package/src/config/settings.ts +16 -0
  6. package/src/edit/modes/atom.ts +3 -5
  7. package/src/modes/components/hook-editor.ts +2 -2
  8. package/src/modes/components/settings-defs.ts +10 -0
  9. package/src/modes/components/status-line/presets.ts +7 -7
  10. package/src/modes/components/status-line/segments.ts +16 -10
  11. package/src/modes/components/status-line/types.ts +3 -0
  12. package/src/modes/components/status-line-segment-editor.ts +1 -1
  13. package/src/modes/components/status-line.ts +6 -0
  14. package/src/modes/controllers/event-controller.ts +14 -9
  15. package/src/modes/controllers/input-controller.ts +15 -0
  16. package/src/modes/interactive-mode.ts +72 -0
  17. package/src/modes/theme/defaults/dark-poimandres.json +1 -0
  18. package/src/modes/theme/defaults/light-poimandres.json +1 -0
  19. package/src/modes/theme/theme.ts +6 -0
  20. package/src/modes/types.ts +5 -0
  21. package/src/prompts/tools/run-command.md +16 -0
  22. package/src/slash-commands/builtin-registry.ts +10 -0
  23. package/src/tools/bash.ts +149 -115
  24. package/src/tools/index.ts +11 -0
  25. package/src/tools/renderers.ts +2 -0
  26. package/src/tools/run-command/index.ts +80 -0
  27. package/src/tools/run-command/render.ts +18 -0
  28. package/src/tools/run-command/runner.ts +198 -0
  29. package/src/tools/run-command/runners/cargo.ts +131 -0
  30. package/src/tools/run-command/runners/index.ts +8 -0
  31. package/src/tools/run-command/runners/just.ts +73 -0
  32. package/src/tools/run-command/runners/make.ts +101 -0
  33. package/src/tools/run-command/runners/pkg.ts +195 -0
  34. package/src/tools/run-command/runners/task.ts +72 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.5.8] - 2026-04-29
6
+ ### Breaking Changes
7
+
8
+ - Changed the task runner toggle from `just.enabled` to `runCommand.enabled`, so existing configurations using `just.enabled` must be migrated
9
+ - Removed the legacy `just` tool and replaced it with `run_command`
10
+ - Renamed the built-in tool API from `just` to `run_command`, so clients requesting/handling the old tool name must update
11
+
12
+ ### Added
13
+
14
+ - Added a new `run_command` tool that runs project tasks via a single `op` argument, auto-detecting and supporting recipes from justfiles, `package.json` scripts (including workspace packages), Cargo bin/example/test targets, Makefiles, and Taskfiles
15
+ - Added support for explicit runner-qualified tasks via `run_command` with `runnerId:task` syntax in the prompt guidance
16
+
17
+ ### Changed
18
+
19
+ - Changed automatic tool availability so requesting `bash` can now auto-include `run_command` when a supported task runner manifest is detected in the working directory
20
+ - Changed task resolution to disambiguate identical task names across multiple runners and show runner-aware command execution errors
21
+
22
+ ### Fixed
23
+
24
+ - Fixed editor draft being erased when a user message queued during streaming was eventually submitted; the queue/steer path now preserves any new prompt the user has typed since queuing, matching the existing optimistic-send protection.
25
+
26
+ ## [14.5.7] - 2026-04-29
27
+
28
+ ### Fixed
29
+
30
+ - Fixed hook editors to recognize Ctrl+Enter when terminals include NumLock or keypad Enter metadata.
5
31
  ## [14.5.6] - 2026-04-29
6
32
  ### Changed
7
33
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.5.6",
4
+ "version": "14.5.8",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -46,12 +46,12 @@
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.20.0",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "@oh-my-pi/omp-stats": "14.5.6",
50
- "@oh-my-pi/pi-agent-core": "14.5.6",
51
- "@oh-my-pi/pi-ai": "14.5.6",
52
- "@oh-my-pi/pi-natives": "14.5.6",
53
- "@oh-my-pi/pi-tui": "14.5.6",
54
- "@oh-my-pi/pi-utils": "14.5.6",
49
+ "@oh-my-pi/omp-stats": "14.5.8",
50
+ "@oh-my-pi/pi-agent-core": "14.5.8",
51
+ "@oh-my-pi/pi-ai": "14.5.8",
52
+ "@oh-my-pi/pi-natives": "14.5.8",
53
+ "@oh-my-pi/pi-tui": "14.5.8",
54
+ "@oh-my-pi/pi-utils": "14.5.8",
55
55
  "@puppeteer/browsers": "^2.13.0",
56
56
  "@sinclair/typebox": "^0.34.49",
57
57
  "@xterm/headless": "^6.0.0",
@@ -249,7 +249,7 @@ const ProviderDiscoverySchema = Type.Object({
249
249
  type: Type.Union([Type.Literal("ollama"), Type.Literal("llama.cpp"), Type.Literal("lm-studio")]),
250
250
  });
251
251
 
252
- const ProviderAuthSchema = Type.Union([Type.Literal("apiKey"), Type.Literal("none")]);
252
+ const ProviderAuthSchema = Type.Union([Type.Literal("apiKey"), Type.Literal("none"), Type.Literal("oauth")]);
253
253
 
254
254
  const ProviderConfigSchema = Type.Object({
255
255
  baseUrl: Type.Optional(Type.String({ minLength: 1 })),
@@ -643,6 +643,7 @@ type CustomModelOverlay = {
643
643
  compat?: Model<Api>["compat"];
644
644
  contextPromotionTarget?: string;
645
645
  premiumMultiplier?: number;
646
+ isOAuth?: boolean;
646
647
  };
647
648
 
648
649
  function mergeCustomModelHeaders(
@@ -661,6 +662,22 @@ function mergeCustomModelHeaders(
661
662
  return headers;
662
663
  }
663
664
 
665
+ /**
666
+ * Decide whether a custom-yaml model should force OAuth-style request shaping.
667
+ * - Explicit `auth: oauth` → force on.
668
+ * - Explicit `auth: apiKey` / `auth: none` → leave unset (auto-detect by key prefix).
669
+ * - No `auth` specified and `api: anthropic-messages` → default on. Custom Anthropic
670
+ * endpoints are typically Claude-Code-style proxies (e.g. CLIProxyAPI) that expect
671
+ * the cloaked request shape regardless of how the proxy itself is authenticated.
672
+ * - Otherwise → unset.
673
+ */
674
+ function resolveCustomModelIsOAuth(api: Api, providerAuth: ProviderAuthMode | undefined): boolean | undefined {
675
+ if (providerAuth === "oauth") return true;
676
+ if (providerAuth !== undefined) return undefined;
677
+ if (api === "anthropic-messages") return true;
678
+ return undefined;
679
+ }
680
+
664
681
  function buildCustomModelOverlay(
665
682
  providerName: string,
666
683
  providerBaseUrl: string,
@@ -669,6 +686,7 @@ function buildCustomModelOverlay(
669
686
  providerApiKey: string | undefined,
670
687
  authHeader: boolean | undefined,
671
688
  providerCompat: Model<Api>["compat"] | undefined,
689
+ providerAuth: ProviderAuthMode | undefined,
672
690
  modelDef: CustomModelDefinitionLike,
673
691
  ): CustomModelOverlay | undefined {
674
692
  const api = modelDef.api ?? providerApi;
@@ -689,6 +707,7 @@ function buildCustomModelOverlay(
689
707
  compat: mergeCompat(providerCompat, modelDef.compat),
690
708
  contextPromotionTarget: modelDef.contextPromotionTarget,
691
709
  premiumMultiplier: modelDef.premiumMultiplier,
710
+ isOAuth: resolveCustomModelIsOAuth(api, providerAuth),
692
711
  };
693
712
  }
694
713
 
@@ -720,6 +739,7 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
720
739
  compat: resolvedModel.compat,
721
740
  contextPromotionTarget: resolvedModel.contextPromotionTarget,
722
741
  premiumMultiplier: resolvedModel.premiumMultiplier,
742
+ isOAuth: resolvedModel.isOAuth,
723
743
  } as Model<Api>);
724
744
  }
725
745
 
@@ -1735,6 +1755,7 @@ export class ModelRegistry {
1735
1755
  providerConfig.apiKey,
1736
1756
  providerConfig.authHeader,
1737
1757
  providerConfig.compat,
1758
+ (providerConfig.auth as ProviderAuthMode | undefined) ?? undefined,
1738
1759
  modelDef as CustomModelDefinitionLike,
1739
1760
  );
1740
1761
  if (!model) continue;
@@ -2069,6 +2090,7 @@ export class ModelRegistry {
2069
2090
  config.apiKey,
2070
2091
  config.authHeader,
2071
2092
  config.compat,
2093
+ undefined,
2072
2094
  modelDef as CustomModelDefinitionLike,
2073
2095
  );
2074
2096
  if (!overlay) {
@@ -59,7 +59,7 @@ export const TAB_METADATA: Record<SettingTab, { label: string; icon: `tab.${stri
59
59
  export type StatusLineSegmentId =
60
60
  | "pi"
61
61
  | "model"
62
- | "plan_mode"
62
+ | "mode"
63
63
  | "path"
64
64
  | "git"
65
65
  | "pr"
@@ -608,6 +608,18 @@ export const SETTINGS_SCHEMA = {
608
608
  },
609
609
  },
610
610
 
611
+ "loop.mode": {
612
+ type: "enum",
613
+ values: ["prompt", "compact", "reset"] as const,
614
+ default: "prompt",
615
+ ui: {
616
+ tab: "interaction",
617
+ label: "Loop Mode",
618
+ description: "What happens between /loop iterations before re-submitting the prompt",
619
+ submenu: true,
620
+ },
621
+ },
622
+
611
623
  // Input and startup
612
624
  doubleEscapeAction: {
613
625
  type: "enum",
@@ -1294,6 +1306,17 @@ export const SETTINGS_SCHEMA = {
1294
1306
  },
1295
1307
  },
1296
1308
 
1309
+ "runCommand.enabled": {
1310
+ type: "boolean",
1311
+ default: true,
1312
+ ui: {
1313
+ tab: "tools",
1314
+ label: "Run command",
1315
+ description:
1316
+ "Enable the run_command tool when a justfile / package.json / Cargo.toml / Makefile / Taskfile is present",
1317
+ },
1318
+ },
1319
+
1297
1320
  "inspect_image.enabled": {
1298
1321
  type: "boolean",
1299
1322
  default: false,
@@ -532,6 +532,22 @@ export class Settings {
532
532
  delete isolationObj.enabled;
533
533
  }
534
534
 
535
+ // statusLine: rename "plan_mode" segment to "mode"
536
+ const statusLineObj = raw.statusLine as Record<string, unknown> | undefined;
537
+ if (statusLineObj) {
538
+ for (const key of ["leftSegments", "rightSegments"] as const) {
539
+ const segments = statusLineObj[key];
540
+ if (Array.isArray(segments)) {
541
+ statusLineObj[key] = segments.map(seg => (seg === "plan_mode" ? "mode" : seg));
542
+ }
543
+ }
544
+ const segmentOptions = statusLineObj.segmentOptions as Record<string, unknown> | undefined;
545
+ if (segmentOptions && "plan_mode" in segmentOptions && !("mode" in segmentOptions)) {
546
+ segmentOptions.mode = segmentOptions.plan_mode;
547
+ delete segmentOptions.plan_mode;
548
+ }
549
+ }
550
+
535
551
  return raw;
536
552
  }
537
553
 
@@ -821,11 +821,9 @@ export function applyAtomEdits(text: string, edits: AtomEdit[]): AtomApplyResult
821
821
  anchorMutated = true;
822
822
  break;
823
823
  case "delete":
824
- if (edit.oldAssertion !== undefined && edit.oldAssertion !== currentLine) {
825
- throw new Error(
826
- `Diff line ${edit.lineNum}: \`-${edit.anchor.line}${edit.anchor.hash}\` asserts the deleted line is ${JSON.stringify(edit.oldAssertion)}, but the file has ${JSON.stringify(currentLine)}. Re-anchor and retry.`,
827
- );
828
- }
824
+ // `-Lid|OLD` / `-Lid=OLD`: the OLD payload is informational only.
825
+ // The Lid hash already validates the line content (and auto-rebases
826
+ // when lines have shifted), so we ignore any OLD mismatch here.
829
827
  replacement = [];
830
828
  replacementSet = true;
831
829
  anchorMutated = true;
@@ -104,8 +104,8 @@ export class HookEditorComponent extends Container {
104
104
 
105
105
  /** Hook-style: Enter=newline, Ctrl+Enter=submit (original behavior) */
106
106
  #handleHookStyleInput(keyData: string): void {
107
- // Ctrl+Enter to submit
108
- if (keyData === "\x1b[13;5u" || keyData === "\x1b[27;5;13~") {
107
+ // Ctrl+Enter to submit. Use key matching so lock-key and keypad Enter variants work.
108
+ if (matchesKey(keyData, "ctrl+enter")) {
109
109
  this.#onSubmitCallback(this.#editor.getText());
110
110
  return;
111
111
  }
@@ -450,6 +450,16 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
450
450
  { value: "none", label: "None", description: "Space only" },
451
451
  { value: "ascii", label: "ASCII", description: "Greater-than signs" },
452
452
  ],
453
+ // Loop mode
454
+ "loop.mode": [
455
+ {
456
+ value: "prompt",
457
+ label: "Prompt",
458
+ description: "Re-submit the prompt as a follow-up message (current behavior)",
459
+ },
460
+ { value: "compact", label: "Compact", description: "Compact the session context, then re-submit the prompt" },
461
+ { value: "reset", label: "Reset", description: "Start a new session, then re-submit the prompt" },
462
+ ],
453
463
  };
454
464
 
455
465
  function createSubmenuSettingDef(base: Omit<SettingDef, "type" | "options">, provider: OptionProvider): SettingDef {
@@ -2,7 +2,7 @@ import type { PresetDef, StatusLinePreset } from "./types";
2
2
 
3
3
  export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
4
4
  default: {
5
- leftSegments: ["pi", "model", "plan_mode", "path", "git", "pr", "context_pct", "token_total", "cost"],
5
+ leftSegments: ["pi", "model", "mode", "path", "git", "pr", "context_pct", "token_total", "cost"],
6
6
  rightSegments: ["session_name"],
7
7
  separator: "powerline-thin",
8
8
  segmentOptions: {
@@ -14,7 +14,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
14
14
 
15
15
  minimal: {
16
16
  leftSegments: ["path", "git"],
17
- rightSegments: ["session_name", "plan_mode", "context_pct"],
17
+ rightSegments: ["session_name", "mode", "context_pct"],
18
18
  separator: "slash",
19
19
  segmentOptions: {
20
20
  path: { abbreviate: true, maxLength: 30 },
@@ -23,7 +23,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
23
23
  },
24
24
 
25
25
  compact: {
26
- leftSegments: ["model", "plan_mode", "git", "pr"],
26
+ leftSegments: ["model", "mode", "git", "pr"],
27
27
  rightSegments: ["session_name", "cost", "context_pct"],
28
28
  separator: "powerline-thin",
29
29
  segmentOptions: {
@@ -33,7 +33,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
33
33
  },
34
34
 
35
35
  full: {
36
- leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "pr", "subagents"],
36
+ leftSegments: ["pi", "hostname", "model", "mode", "path", "git", "pr", "subagents"],
37
37
  rightSegments: [
38
38
  "session_name",
39
39
  "token_in",
@@ -56,7 +56,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
56
56
 
57
57
  nerd: {
58
58
  // Full preset with all Nerd Font icons
59
- leftSegments: ["pi", "hostname", "model", "plan_mode", "path", "git", "pr", "session", "subagents"],
59
+ leftSegments: ["pi", "hostname", "model", "mode", "path", "git", "pr", "session", "subagents"],
60
60
  rightSegments: [
61
61
  "session_name",
62
62
  "token_in",
@@ -81,7 +81,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
81
81
 
82
82
  ascii: {
83
83
  // No Nerd Font dependencies
84
- leftSegments: ["model", "plan_mode", "path", "git", "pr"],
84
+ leftSegments: ["model", "mode", "path", "git", "pr"],
85
85
  rightSegments: ["session_name", "token_total", "cost", "context_pct"],
86
86
  separator: "ascii",
87
87
  segmentOptions: {
@@ -93,7 +93,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
93
93
 
94
94
  custom: {
95
95
  // User-defined - these are just defaults that get overridden
96
- leftSegments: ["model", "plan_mode", "path", "git", "pr"],
96
+ leftSegments: ["model", "mode", "path", "git", "pr"],
97
97
  rightSegments: ["session_name", "token_total", "cost", "context_pct"],
98
98
  separator: "powerline-thin",
99
99
  segmentOptions: {},
@@ -76,18 +76,24 @@ const modelSegment: StatusLineSegment = {
76
76
  },
77
77
  };
78
78
 
79
- const planModeSegment: StatusLineSegment = {
80
- id: "plan_mode",
79
+ const modeSegment: StatusLineSegment = {
80
+ id: "mode",
81
81
  render(ctx) {
82
- const status = ctx.planMode;
83
- if (!status || (!status.enabled && !status.paused)) {
84
- return { content: "", visible: false };
82
+ const plan = ctx.planMode;
83
+ if (plan && (plan.enabled || plan.paused)) {
84
+ const label = plan.paused ? "Plan ⏸" : "Plan";
85
+ const content = withIcon(theme.icon.plan, label);
86
+ const color = plan.paused ? "warning" : "accent";
87
+ return { content: theme.fg(color, content), visible: true };
88
+ }
89
+
90
+ const loop = ctx.loopMode;
91
+ if (loop?.enabled) {
92
+ const content = withIcon(theme.icon.loop, "Loop");
93
+ return { content: theme.fg("customMessageLabel", content), visible: true };
85
94
  }
86
95
 
87
- const label = status.paused ? "Plan ⏸" : "Plan";
88
- const content = withIcon(theme.icon.plan, label);
89
- const color = status.paused ? "warning" : "accent";
90
- return { content: theme.fg(color, content), visible: true };
96
+ return { content: "", visible: false };
91
97
  },
92
98
  };
93
99
 
@@ -375,7 +381,7 @@ const sessionNameSegment: StatusLineSegment = {
375
381
  export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
376
382
  pi: piSegment,
377
383
  model: modelSegment,
378
- plan_mode: planModeSegment,
384
+ mode: modeSegment,
379
385
  path: pathSegment,
380
386
  git: gitSegment,
381
387
  pr: prSegment,
@@ -24,6 +24,9 @@ export interface SegmentContext {
24
24
  enabled: boolean;
25
25
  paused: boolean;
26
26
  } | null;
27
+ loopMode: {
28
+ enabled: boolean;
29
+ } | null;
27
30
  // Cached values for performance (computed once per render)
28
31
  usageStats: {
29
32
  input: number;
@@ -18,7 +18,7 @@ import { ALL_SEGMENT_IDS } from "./status-line/segments";
18
18
  const SEGMENT_INFO: Record<StatusLineSegmentId, { label: string; short: string }> = {
19
19
  pi: { label: "Pi", short: "π icon" },
20
20
  model: { label: "Model", short: "model name" },
21
- plan_mode: { label: "Plan Mode", short: "plan status" },
21
+ mode: { label: "Mode", short: "plan/loop status" },
22
22
  path: { label: "Path", short: "working dir" },
23
23
  git: { label: "Git", short: "branch/status" },
24
24
  pr: { label: "PR", short: "pull request" },
@@ -57,6 +57,7 @@ export class StatusLineComponent implements Component {
57
57
  #subagentCount: number = 0;
58
58
  #sessionStartTime: number = Date.now();
59
59
  #planModeStatus: { enabled: boolean; paused: boolean } | null = null;
60
+ #loopModeStatus: { enabled: boolean } | null = null;
60
61
 
61
62
  // Git status caching (1s TTL)
62
63
  #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
@@ -102,6 +103,10 @@ export class StatusLineComponent implements Component {
102
103
  this.#planModeStatus = status ?? null;
103
104
  }
104
105
 
106
+ setLoopModeStatus(status: { enabled: boolean } | undefined): void {
107
+ this.#loopModeStatus = status ?? null;
108
+ }
109
+
105
110
  setHookStatus(key: string, text: string | undefined): void {
106
111
  if (text === undefined) {
107
112
  this.#hookStatuses.delete(key);
@@ -326,6 +331,7 @@ export class StatusLineComponent implements Component {
326
331
  width,
327
332
  options: this.#resolveSettings().segmentOptions ?? {},
328
333
  planMode: this.#planModeStatus,
334
+ loopMode: this.#loopModeStatus,
329
335
  usageStats,
330
336
  contextPercent,
331
337
  contextWindow,
@@ -174,18 +174,23 @@ export class EventController {
174
174
 
175
175
  this.#resetReadGroup();
176
176
  const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
177
+ const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
177
178
  if (!wasOptimistic) {
178
179
  this.ctx.addMessageToChat(event.message);
179
180
  }
180
- this.ctx.optimisticUserMessageSignature = undefined;
181
-
182
- // Clear the editor only when the submission did not originate from this
183
- // session's optimistic flow (which already cleared the editor at submit
184
- // time). Clearing here on the optimistic path would race with the user
185
- // typing the next prompt while the previous large redraw lands and erase
186
- // their in-progress draft (#783).
187
- if (!event.message.synthetic && !wasOptimistic) {
188
- this.ctx.editor.setText("");
181
+ if (wasOptimistic) {
182
+ this.ctx.optimisticUserMessageSignature = undefined;
183
+ }
184
+
185
+ // Clear the editor only when the submission did not originate from a
186
+ // local submission (optimistic or queued-while-streaming). Both local
187
+ // paths already cleared the editor at submit time; clearing again here
188
+ // would race with the user typing the next prompt while the previous
189
+ // large redraw lands and erase their in-progress draft (#783).
190
+ if (!event.message.synthetic) {
191
+ if (!wasLocallySubmitted) {
192
+ this.ctx.editor.setText("");
193
+ }
189
194
  this.ctx.updatePendingMessagesDisplay();
190
195
  }
191
196
  this.ctx.ui.requestRender();
@@ -44,6 +44,15 @@ export class InputController {
44
44
  this.ctx.retryEscapeHandler,
45
45
  );
46
46
  this.ctx.editor.onEscape = () => {
47
+ if (this.ctx.loopModeEnabled) {
48
+ this.ctx.disableLoopMode();
49
+ if (this.ctx.session.isStreaming) {
50
+ void this.ctx.session.abort();
51
+ } else {
52
+ this.ctx.cancelPendingSubmission();
53
+ }
54
+ return;
55
+ }
47
56
  if (this.ctx.hasActiveBtw() && this.ctx.handleBtwEscape()) {
48
57
  return;
49
58
  }
@@ -325,6 +334,11 @@ export class InputController {
325
334
  this.ctx.editor.setText("");
326
335
  const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
327
336
  this.ctx.pendingImages = [];
337
+ // Record the signature so the queued message's eventual delivery
338
+ // (a user-role `message_start` event) leaves any draft the user has
339
+ // typed since queuing intact. Same protection as #783, applied to
340
+ // the streaming/queue path.
341
+ this.ctx.locallySubmittedUserSignatures.add(`${text}\u0000${images?.length ?? 0}`);
328
342
  await this.ctx.session.prompt(text, { streamingBehavior: "steer", images });
329
343
  this.ctx.updatePendingMessagesDisplay();
330
344
  this.ctx.ui.requestRender();
@@ -434,6 +448,7 @@ export class InputController {
434
448
  }
435
449
 
436
450
  restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
451
+ this.ctx.locallySubmittedUserSignatures.clear();
437
452
  const { steering, followUp } = this.ctx.session.clearQueue();
438
453
  const allQueued = [...steering, ...followUp];
439
454
  if (allQueued.length === 0) {
@@ -145,6 +145,9 @@ export class InteractiveMode implements InteractiveModeContext {
145
145
  planModeEnabled = false;
146
146
  planModePaused = false;
147
147
  planModePlanFilePath: string | undefined = undefined;
148
+ loopModeEnabled = false;
149
+ loopPrompt: string | undefined = undefined;
150
+ #loopAutoSubmitTimer: NodeJS.Timeout | undefined;
148
151
  todoPhases: TodoPhase[] = [];
149
152
  hideThinkingBlock = false;
150
153
  pendingImages: ImageContent[] = [];
@@ -167,6 +170,7 @@ export class InteractiveMode implements InteractiveModeContext {
167
170
  unsubscribe?: () => void;
168
171
  onInputCallback?: (input: SubmittedUserInput) => void;
169
172
  optimisticUserMessageSignature: string | undefined = undefined;
173
+ locallySubmittedUserSignatures: Set<string> = new Set();
170
174
  #pendingSubmittedInput: SubmittedUserInput | undefined;
171
175
  lastSigintTime = 0;
172
176
  lastEscapeTime = 0;
@@ -483,9 +487,75 @@ export class InteractiveMode implements InteractiveModeContext {
483
487
  this.onInputCallback = undefined;
484
488
  resolve(input);
485
489
  };
490
+ this.#scheduleLoopAutoSubmit();
486
491
  return promise;
487
492
  }
488
493
 
494
+ #scheduleLoopAutoSubmit(): void {
495
+ if (this.#loopAutoSubmitTimer) {
496
+ clearTimeout(this.#loopAutoSubmitTimer);
497
+ this.#loopAutoSubmitTimer = undefined;
498
+ }
499
+ if (!this.loopModeEnabled || !this.loopPrompt) return;
500
+ const prompt = this.loopPrompt;
501
+ const loopAction = settings.get("loop.mode");
502
+ // Brief delay so the user has a chance to press Esc between iterations.
503
+ this.#loopAutoSubmitTimer = setTimeout(() => {
504
+ this.#loopAutoSubmitTimer = undefined;
505
+ if (!this.loopModeEnabled || !this.onInputCallback) return;
506
+ void this.#runLoopIteration(loopAction, prompt);
507
+ }, 800);
508
+ }
509
+
510
+ async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
511
+ if (action === "compact") {
512
+ await this.handleCompactCommand();
513
+ } else if (action === "reset") {
514
+ await this.handleClearCommand();
515
+ }
516
+ if (!this.loopModeEnabled || !this.onInputCallback) return;
517
+ this.onInputCallback(this.startPendingSubmission({ text: prompt }));
518
+ }
519
+
520
+ disableLoopMode(options?: { silent?: boolean }): void {
521
+ const wasEnabled = this.loopModeEnabled;
522
+ this.loopModeEnabled = false;
523
+ this.loopPrompt = undefined;
524
+ if (this.#loopAutoSubmitTimer) {
525
+ clearTimeout(this.#loopAutoSubmitTimer);
526
+ this.#loopAutoSubmitTimer = undefined;
527
+ }
528
+ this.statusLine.setLoopModeStatus(undefined);
529
+ this.updateEditorTopBorder();
530
+ this.ui.requestRender();
531
+ if (wasEnabled && !options?.silent) {
532
+ this.showStatus("Loop mode disabled.");
533
+ }
534
+ }
535
+
536
+ async handleLoopCommand(prompt?: string): Promise<void> {
537
+ if (this.loopModeEnabled) {
538
+ this.disableLoopMode();
539
+ return;
540
+ }
541
+ const trimmed = prompt?.trim();
542
+ if (!trimmed) {
543
+ this.showError("Usage: /loop <prompt>");
544
+ return;
545
+ }
546
+ this.loopModeEnabled = true;
547
+ this.loopPrompt = trimmed;
548
+ this.statusLine.setLoopModeStatus({ enabled: true });
549
+ this.updateEditorTopBorder();
550
+ this.ui.requestRender();
551
+ this.showStatus("Loop mode enabled. Esc to stop.");
552
+
553
+ // Submit the first iteration immediately so the loop kicks off.
554
+ if (this.onInputCallback) {
555
+ this.onInputCallback(this.startPendingSubmission({ text: trimmed }));
556
+ }
557
+ }
558
+
489
559
  startPendingSubmission(input: { text: string; images?: ImageContent[] }): SubmittedUserInput {
490
560
  const submission: SubmittedUserInput = {
491
561
  text: input.text,
@@ -495,6 +565,7 @@ export class InteractiveMode implements InteractiveModeContext {
495
565
  };
496
566
  this.#pendingSubmittedInput = submission;
497
567
  this.optimisticUserMessageSignature = `${submission.text}\u0000${submission.images?.length ?? 0}`;
568
+ this.locallySubmittedUserSignatures.add(this.optimisticUserMessageSignature);
498
569
  this.addMessageToChat({
499
570
  role: "user",
500
571
  content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
@@ -516,6 +587,7 @@ export class InteractiveMode implements InteractiveModeContext {
516
587
  submission.cancelled = true;
517
588
  this.#pendingSubmittedInput = undefined;
518
589
  this.optimisticUserMessageSignature = undefined;
590
+ this.locallySubmittedUserSignatures.delete(`${submission.text}\u0000${submission.images?.length ?? 0}`);
519
591
  this.#pendingWorkingMessage = undefined;
520
592
  if (this.loadingAnimation) {
521
593
  this.loadingAnimation.stop();
@@ -129,6 +129,7 @@
129
129
  "thinking.xhigh": "●",
130
130
  "icon.model": "◇",
131
131
  "icon.plan": "◈",
132
+ "icon.loop": "↻",
132
133
  "icon.folder": "▸",
133
134
  "icon.pi": "π",
134
135
  "format.bullet": "◦",
@@ -129,6 +129,7 @@
129
129
  "thinking.xhigh": "●",
130
130
  "icon.model": "◇",
131
131
  "icon.plan": "◈",
132
+ "icon.loop": "↻",
132
133
  "icon.folder": "▸",
133
134
  "icon.pi": "π",
134
135
  "format.bullet": "◦",
@@ -91,6 +91,7 @@ export type SymbolKey =
91
91
  // Icons
92
92
  | "icon.model"
93
93
  | "icon.plan"
94
+ | "icon.loop"
94
95
  | "icon.folder"
95
96
  | "icon.file"
96
97
  | "icon.git"
@@ -250,6 +251,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
250
251
  // Icons
251
252
  "icon.model": "⬢",
252
253
  "icon.plan": "🗺",
254
+ "icon.loop": "↻",
253
255
  "icon.folder": "📁",
254
256
  "icon.file": "📄",
255
257
  "icon.git": "⎇",
@@ -460,6 +462,8 @@ const NERD_SYMBOLS: SymbolMap = {
460
462
  "icon.model": "\uec19",
461
463
  // pick:  | alt:  
462
464
  "icon.plan": "\uf2d2",
465
+ // pick: ↻ | alt: ⟳
466
+ "icon.loop": "\uf021",
463
467
  // pick:  | alt:  
464
468
  "icon.folder": "\uf115",
465
469
  // pick:  | alt:  
@@ -659,6 +663,7 @@ const ASCII_SYMBOLS: SymbolMap = {
659
663
  // Icons
660
664
  "icon.model": "[M]",
661
665
  "icon.plan": "plan",
666
+ "icon.loop": "loop",
662
667
  "icon.folder": "[D]",
663
668
  "icon.file": "[F]",
664
669
  "icon.git": "git:",
@@ -1434,6 +1439,7 @@ export class Theme {
1434
1439
  return {
1435
1440
  model: this.#symbols["icon.model"],
1436
1441
  plan: this.#symbols["icon.plan"],
1442
+ loop: this.#symbols["icon.loop"],
1437
1443
  folder: this.#symbols["icon.folder"],
1438
1444
  file: this.#symbols["icon.file"],
1439
1445
  git: this.#symbols["icon.git"],