diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c62f03e8..83d29cf2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,8 @@ - **External editor support**: Press `Ctrl+G` to edit your message in an external editor. Uses `$VISUAL` or `$EDITOR` environment variable. On successful save, the message is replaced; on cancel, the original is kept. ([#266](https://github.com/badlogic/pi-mono/pull/266) by [@aliou](https://github.com/aliou)) +- **Process suspension**: Press `Ctrl+Z` to suspend pi and return to the shell. Resume with `fg` as usual. ([#267](https://github.com/badlogic/pi-mono/pull/267) by [@aliou](https://github.com/aliou)) + ## [0.25.2] - 2025-12-21 ### Fixed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 5c4e815f..e646b6a4 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -226,6 +226,7 @@ The agent reads, writes, and edits files, and executes commands via bash. | Escape | Cancel autocomplete / abort streaming | | Ctrl+C | Clear editor (first) / exit (second) | | Ctrl+D | Exit (when editor is empty) | +| Ctrl+Z | Suspend to background | | Shift+Tab | Cycle thinking level | | Ctrl+P | Cycle models (scoped by `--models`) | | Ctrl+O | Toggle tool output expansion | diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 62171ca1..a485b1fc 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -6,6 +6,7 @@ import { isCtrlO, isCtrlP, isCtrlT, + isCtrlZ, isEscape, isShiftTab, } from "@mariozechner/pi-tui"; @@ -22,6 +23,7 @@ export class CustomEditor extends Editor { public onCtrlO?: () => void; public onCtrlT?: () => void; public onCtrlG?: () => void; + public onCtrlZ?: () => void; handleInput(data: string): void { // Intercept Ctrl+G for external editor @@ -30,6 +32,12 @@ export class CustomEditor extends Editor { return; } + // Intercept Ctrl+Z for suspend + if (isCtrlZ(data) && this.onCtrlZ) { + this.onCtrlZ(); + return; + } + // Intercept Ctrl+T for thinking block visibility toggle if (isCtrlT(data) && this.onCtrlT) { this.onCtrlT(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0c5ebf9d..a8a7fe59 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -213,6 +213,9 @@ export class InteractiveMode { theme.fg("dim", "ctrl+d") + theme.fg("muted", " to exit (empty)") + "\n" + + theme.fg("dim", "ctrl+z") + + theme.fg("muted", " to suspend") + + "\n" + theme.fg("dim", "ctrl+k") + theme.fg("muted", " to delete line") + "\n" + @@ -576,6 +579,7 @@ export class InteractiveMode { this.editor.onCtrlC = () => this.handleCtrlC(); this.editor.onCtrlD = () => this.handleCtrlD(); + this.editor.onCtrlZ = () => this.handleCtrlZ(); this.editor.onShiftTab = () => this.cycleThinkingLevel(); this.editor.onCtrlP = () => this.cycleModel(); this.editor.onCtrlO = () => this.toggleToolOutputExpansion(); @@ -1159,6 +1163,20 @@ export class InteractiveMode { process.exit(0); } + private handleCtrlZ(): void { + // Set up handler to restore TUI when resumed + process.once("SIGCONT", () => { + this.ui.start(); + this.ui.requestRender(true); + }); + + // Stop the TUI (restore terminal to normal mode) + this.ui.stop(); + + // Send SIGTSTP to process group (pid=0 means all processes in group) + process.kill(0, "SIGTSTP"); + } + private updateEditorBorderColor(): void { if (this.isBashMode) { this.editor.borderColor = theme.getBashModeBorderColor(); @@ -1747,6 +1765,7 @@ export class InteractiveMode { | \`Escape\` | Cancel autocomplete / abort streaming | | \`Ctrl+C\` | Clear editor (first) / exit (second) | | \`Ctrl+D\` | Exit (when editor is empty) | +| \`Ctrl+Z\` | Suspend to background | | \`Shift+Tab\` | Cycle thinking level | | \`Ctrl+P\` | Cycle models | | \`Ctrl+O\` | Toggle tool output expansion | diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index e09b5255..1e8bb605 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -42,6 +42,7 @@ export { isCtrlT, isCtrlU, isCtrlW, + isCtrlZ, isDelete, isEnd, isEnter, diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index faa5e4d7..d7a82cd4 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -35,6 +35,7 @@ const CODEPOINTS = { t: 116, u: 117, w: 119, + z: 122, // Special keys escape: 27, @@ -168,6 +169,7 @@ export const Keys = { CTRL_T: kittySequence(CODEPOINTS.t, MODIFIERS.ctrl), CTRL_U: kittySequence(CODEPOINTS.u, MODIFIERS.ctrl), CTRL_W: kittySequence(CODEPOINTS.w, MODIFIERS.ctrl), + CTRL_Z: kittySequence(CODEPOINTS.z, MODIFIERS.ctrl), // Enter combinations SHIFT_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.shift), @@ -223,6 +225,7 @@ const RAW = { CTRL_T: "\x14", CTRL_U: "\x15", CTRL_W: "\x17", + CTRL_Z: "\x1a", ALT_BACKSPACE: "\x1b\x7f", SHIFT_TAB: "\x1b[Z", } as const; @@ -322,6 +325,14 @@ export function isCtrlW(data: string): boolean { return data === RAW.CTRL_W || data === Keys.CTRL_W || matchesKittySequence(data, CODEPOINTS.w, MODIFIERS.ctrl); } +/** + * Check if input matches Ctrl+Z (raw byte or Kitty protocol). + * Ignores lock key bits. + */ +export function isCtrlZ(data: string): boolean { + return data === RAW.CTRL_Z || data === Keys.CTRL_Z || matchesKittySequence(data, CODEPOINTS.z, MODIFIERS.ctrl); +} + /** * Check if input matches Alt+Backspace (legacy or Kitty protocol). * Ignores lock key bits. diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 61474f8a..1baf5e4d 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -118,7 +118,12 @@ export class TUI extends Container { this.terminal.stop(); } - requestRender(): void { + requestRender(force = false): void { + if (force) { + this.previousLines = []; + this.previousWidth = 0; + this.cursorRow = 0; + } if (this.renderRequested) return; this.renderRequested = true; process.nextTick(() => {