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

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
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.5.7] - 2026-04-29
6
+
7
+ ### Fixed
8
+
9
+ - Fixed hook editors to recognize Ctrl+Enter when terminals include NumLock or keypad Enter metadata.
5
10
  ## [14.5.6] - 2026-04-29
6
11
  ### Changed
7
12
 
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.7",
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.7",
50
+ "@oh-my-pi/pi-agent-core": "14.5.7",
51
+ "@oh-my-pi/pi-ai": "14.5.7",
52
+ "@oh-my-pi/pi-natives": "14.5.7",
53
+ "@oh-my-pi/pi-tui": "14.5.7",
54
+ "@oh-my-pi/pi-utils": "14.5.7",
55
55
  "@puppeteer/browsers": "^2.13.0",
56
56
  "@sinclair/typebox": "^0.34.49",
57
57
  "@xterm/headless": "^6.0.0",
@@ -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"
@@ -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
  }
@@ -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,
@@ -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
  }
@@ -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[] = [];
@@ -483,9 +486,64 @@ export class InteractiveMode implements InteractiveModeContext {
483
486
  this.onInputCallback = undefined;
484
487
  resolve(input);
485
488
  };
489
+ this.#scheduleLoopAutoSubmit();
486
490
  return promise;
487
491
  }
488
492
 
493
+ #scheduleLoopAutoSubmit(): void {
494
+ if (this.#loopAutoSubmitTimer) {
495
+ clearTimeout(this.#loopAutoSubmitTimer);
496
+ this.#loopAutoSubmitTimer = undefined;
497
+ }
498
+ if (!this.loopModeEnabled || !this.loopPrompt) return;
499
+ const prompt = this.loopPrompt;
500
+ // Brief delay so the user has a chance to press Esc between iterations.
501
+ this.#loopAutoSubmitTimer = setTimeout(() => {
502
+ this.#loopAutoSubmitTimer = undefined;
503
+ if (!this.loopModeEnabled || !this.onInputCallback) return;
504
+ this.onInputCallback(this.startPendingSubmission({ text: prompt }));
505
+ }, 800);
506
+ }
507
+
508
+ disableLoopMode(options?: { silent?: boolean }): void {
509
+ const wasEnabled = this.loopModeEnabled;
510
+ this.loopModeEnabled = false;
511
+ this.loopPrompt = undefined;
512
+ if (this.#loopAutoSubmitTimer) {
513
+ clearTimeout(this.#loopAutoSubmitTimer);
514
+ this.#loopAutoSubmitTimer = undefined;
515
+ }
516
+ this.statusLine.setLoopModeStatus(undefined);
517
+ this.updateEditorTopBorder();
518
+ this.ui.requestRender();
519
+ if (wasEnabled && !options?.silent) {
520
+ this.showStatus("Loop mode disabled.");
521
+ }
522
+ }
523
+
524
+ async handleLoopCommand(prompt?: string): Promise<void> {
525
+ if (this.loopModeEnabled) {
526
+ this.disableLoopMode();
527
+ return;
528
+ }
529
+ const trimmed = prompt?.trim();
530
+ if (!trimmed) {
531
+ this.showError("Usage: /loop <prompt>");
532
+ return;
533
+ }
534
+ this.loopModeEnabled = true;
535
+ this.loopPrompt = trimmed;
536
+ this.statusLine.setLoopModeStatus({ enabled: true });
537
+ this.updateEditorTopBorder();
538
+ this.ui.requestRender();
539
+ this.showStatus("Loop mode enabled. Esc to stop.");
540
+
541
+ // Submit the first iteration immediately so the loop kicks off.
542
+ if (this.onInputCallback) {
543
+ this.onInputCallback(this.startPendingSubmission({ text: trimmed }));
544
+ }
545
+ }
546
+
489
547
  startPendingSubmission(input: { text: string; images?: ImageContent[] }): SubmittedUserInput {
490
548
  const submission: SubmittedUserInput = {
491
549
  text: input.text,
@@ -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"],
@@ -86,6 +86,8 @@ export interface InteractiveModeContext {
86
86
  toolOutputExpanded: boolean;
87
87
  todoExpanded: boolean;
88
88
  planModeEnabled: boolean;
89
+ loopModeEnabled: boolean;
90
+ loopPrompt?: string;
89
91
  planModePlanFilePath?: string;
90
92
  hideThinkingBlock: boolean;
91
93
  pendingImages: ImageContent[];
@@ -233,6 +235,8 @@ export interface InteractiveModeContext {
233
235
  openExternalEditor(): void;
234
236
  registerExtensionShortcuts(): void;
235
237
  handlePlanModeCommand(initialPrompt?: string): Promise<void>;
238
+ handleLoopCommand(prompt?: string): Promise<void>;
239
+ disableLoopMode(options?: { silent?: boolean }): void;
236
240
  handleExitPlanModeTool(details: ExitPlanModeDetails): Promise<void>;
237
241
 
238
242
  // Hook UI methods
@@ -121,6 +121,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
121
121
  runtime.ctx.editor.setText("");
122
122
  },
123
123
  },
124
+ {
125
+ name: "loop",
126
+ description: "Loop the agent: re-submit the same prompt every time it yields (Esc to stop)",
127
+ inlineHint: "<prompt>",
128
+ allowArgs: true,
129
+ handle: async (command, runtime) => {
130
+ await runtime.ctx.handleLoopCommand(command.args || undefined);
131
+ runtime.ctx.editor.setText("");
132
+ },
133
+ },
124
134
  {
125
135
  name: "model",
126
136
  aliases: ["models"],