diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cf780463..e5a95ded 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed flaky `edit-tool-no-full-redraw` TUI tests by waiting for asynchronous preview and preflight error rendering instead of relying on fixed render ticks. - Fixed `kimi-coding` default model selection to use `kimi-for-coding` instead of `kimi-k2-thinking` ([#3242](https://github.com/badlogic/pi-mono/issues/3242)) +- Fixed `ctrl+z` on native Windows to avoid crashing interactive mode, disable the default suspend binding there, and show a status message when suspend is invoked manually ([#3191](https://github.com/badlogic/pi-mono/issues/3191)) ## [0.67.3] - 2026-04-15 diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 148789a9..1ed72083 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -85,7 +85,7 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1 | `app.interrupt` | `escape` | Cancel / abort | | `app.clear` | `ctrl+c` | Clear editor | | `app.exit` | `ctrl+d` | Exit (when editor empty) | -| `app.suspend` | `ctrl+z` | Suspend to background | +| `app.suspend` | `ctrl+z` (none on Windows) | Suspend to background | | `app.editor.external` | `ctrl+g` | Open in external editor (`$VISUAL` or `$EDITOR`) | | `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows) | Paste image from clipboard | @@ -145,6 +145,8 @@ Create `~/.pi/agent/keybindings.json`: Each action can have a single key or an array of keys. User config overrides defaults. +On native Windows, `app.suspend` has no default binding because Windows terminals do not support Unix job control. If you bind it manually, pi shows a status message instead of suspending. In WSL, the normal Linux `ctrl+z`/`fg` behavior still applies. + ### Emacs Example ```json diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index a27d32cb..4f5f10f6 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -52,7 +52,10 @@ export const KEYBINDINGS = { "app.interrupt": { defaultKeys: "escape", description: "Cancel or abort" }, "app.clear": { defaultKeys: "ctrl+c", description: "Clear editor" }, "app.exit": { defaultKeys: "ctrl+d", description: "Exit when editor is empty" }, - "app.suspend": { defaultKeys: "ctrl+z", description: "Suspend to background" }, + "app.suspend": { + defaultKeys: process.platform === "win32" ? [] : "ctrl+z", + description: "Suspend to background", + }, "app.thinking.cycle": { defaultKeys: "shift+tab", description: "Cycle thinking level", diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 760ea3fa..c1f5089d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2958,6 +2958,11 @@ export class InteractiveMode { } private handleCtrlZ(): void { + if (process.platform === "win32") { + this.showStatus("Suspend to background is not supported on Windows"); + return; + } + // Keep the event loop alive while suspended. Without this, stopping the TUI // can leave Node with no ref'ed handles, causing the process to exit on fg // before the SIGCONT handler gets a chance to restore the terminal. diff --git a/packages/coding-agent/test/interactive-mode-suspend.test.ts b/packages/coding-agent/test/interactive-mode-suspend.test.ts index e9e53c14..3f41fe2e 100644 --- a/packages/coding-agent/test/interactive-mode-suspend.test.ts +++ b/packages/coding-agent/test/interactive-mode-suspend.test.ts @@ -28,6 +28,40 @@ describe("InteractiveMode.handleCtrlZ", () => { vi.restoreAllMocks(); }); + test("shows a status message and skips suspend on Windows", () => { + const ui: FakeUi = { + start: vi.fn(), + stop: vi.fn(), + requestRender: vi.fn(), + }; + const showStatus = vi.fn(); + const context: HandleCtrlZThis & { showStatus: (message: string) => void } = { ui, showStatus }; + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32", + }); + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + const processOnSpy = vi.spyOn(process, "on"); + const processOnceSpy = vi.spyOn(process, "once"); + const processKillSpy = vi.spyOn(process, "kill"); + + try { + callHandleCtrlZ(context); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + } + + expect(showStatus).toHaveBeenCalledWith("Suspend to background is not supported on Windows"); + expect(ui.stop).not.toHaveBeenCalled(); + expect(setIntervalSpy).not.toHaveBeenCalled(); + expect(processOnSpy).not.toHaveBeenCalledWith("SIGINT", expect.any(Function)); + expect(processOnceSpy).not.toHaveBeenCalledWith("SIGCONT", expect.any(Function)); + expect(processKillSpy).not.toHaveBeenCalled(); + }); + test("keeps the process alive while suspended and restores the TUI on SIGCONT", () => { const ui: FakeUi = { start: vi.fn(),