fix(coding-agent): handle ctrl+z on windows closes #3191

This commit is contained in:
Mario Zechner
2026-04-16 12:33:47 +02:00
parent eb1cf80b10
commit 624a7f794f
5 changed files with 47 additions and 2 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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.

View File

@@ -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(),