feat(coding-agent): add Ctrl+Z to suspend process (#267)

* feat(tui): add isCtrlZ key detection and resetRenderState method

* feat(coding-agent): add Ctrl+Z handler to suspend process

* docs(coding-agent): add Ctrl+Z to keyboard shortcuts documentation

* feat(tui): add force parameter to requestRender
This commit is contained in:
Aliou Diallo 2025-12-21 20:19:32 +01:00 committed by GitHub
parent 55ca650a40
commit 8868d623fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 48 additions and 1 deletions

View file

@ -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)) - **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 ## [0.25.2] - 2025-12-21
### Fixed ### Fixed

View file

@ -226,6 +226,7 @@ The agent reads, writes, and edits files, and executes commands via bash.
| Escape | Cancel autocomplete / abort streaming | | Escape | Cancel autocomplete / abort streaming |
| Ctrl+C | Clear editor (first) / exit (second) | | Ctrl+C | Clear editor (first) / exit (second) |
| Ctrl+D | Exit (when editor is empty) | | Ctrl+D | Exit (when editor is empty) |
| Ctrl+Z | Suspend to background |
| Shift+Tab | Cycle thinking level | | Shift+Tab | Cycle thinking level |
| Ctrl+P | Cycle models (scoped by `--models`) | | Ctrl+P | Cycle models (scoped by `--models`) |
| Ctrl+O | Toggle tool output expansion | | Ctrl+O | Toggle tool output expansion |

View file

@ -6,6 +6,7 @@ import {
isCtrlO, isCtrlO,
isCtrlP, isCtrlP,
isCtrlT, isCtrlT,
isCtrlZ,
isEscape, isEscape,
isShiftTab, isShiftTab,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
@ -22,6 +23,7 @@ export class CustomEditor extends Editor {
public onCtrlO?: () => void; public onCtrlO?: () => void;
public onCtrlT?: () => void; public onCtrlT?: () => void;
public onCtrlG?: () => void; public onCtrlG?: () => void;
public onCtrlZ?: () => void;
handleInput(data: string): void { handleInput(data: string): void {
// Intercept Ctrl+G for external editor // Intercept Ctrl+G for external editor
@ -30,6 +32,12 @@ export class CustomEditor extends Editor {
return; return;
} }
// Intercept Ctrl+Z for suspend
if (isCtrlZ(data) && this.onCtrlZ) {
this.onCtrlZ();
return;
}
// Intercept Ctrl+T for thinking block visibility toggle // Intercept Ctrl+T for thinking block visibility toggle
if (isCtrlT(data) && this.onCtrlT) { if (isCtrlT(data) && this.onCtrlT) {
this.onCtrlT(); this.onCtrlT();

View file

@ -213,6 +213,9 @@ export class InteractiveMode {
theme.fg("dim", "ctrl+d") + theme.fg("dim", "ctrl+d") +
theme.fg("muted", " to exit (empty)") + theme.fg("muted", " to exit (empty)") +
"\n" + "\n" +
theme.fg("dim", "ctrl+z") +
theme.fg("muted", " to suspend") +
"\n" +
theme.fg("dim", "ctrl+k") + theme.fg("dim", "ctrl+k") +
theme.fg("muted", " to delete line") + theme.fg("muted", " to delete line") +
"\n" + "\n" +
@ -576,6 +579,7 @@ export class InteractiveMode {
this.editor.onCtrlC = () => this.handleCtrlC(); this.editor.onCtrlC = () => this.handleCtrlC();
this.editor.onCtrlD = () => this.handleCtrlD(); this.editor.onCtrlD = () => this.handleCtrlD();
this.editor.onCtrlZ = () => this.handleCtrlZ();
this.editor.onShiftTab = () => this.cycleThinkingLevel(); this.editor.onShiftTab = () => this.cycleThinkingLevel();
this.editor.onCtrlP = () => this.cycleModel(); this.editor.onCtrlP = () => this.cycleModel();
this.editor.onCtrlO = () => this.toggleToolOutputExpansion(); this.editor.onCtrlO = () => this.toggleToolOutputExpansion();
@ -1159,6 +1163,20 @@ export class InteractiveMode {
process.exit(0); 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 { private updateEditorBorderColor(): void {
if (this.isBashMode) { if (this.isBashMode) {
this.editor.borderColor = theme.getBashModeBorderColor(); this.editor.borderColor = theme.getBashModeBorderColor();
@ -1747,6 +1765,7 @@ export class InteractiveMode {
| \`Escape\` | Cancel autocomplete / abort streaming | | \`Escape\` | Cancel autocomplete / abort streaming |
| \`Ctrl+C\` | Clear editor (first) / exit (second) | | \`Ctrl+C\` | Clear editor (first) / exit (second) |
| \`Ctrl+D\` | Exit (when editor is empty) | | \`Ctrl+D\` | Exit (when editor is empty) |
| \`Ctrl+Z\` | Suspend to background |
| \`Shift+Tab\` | Cycle thinking level | | \`Shift+Tab\` | Cycle thinking level |
| \`Ctrl+P\` | Cycle models | | \`Ctrl+P\` | Cycle models |
| \`Ctrl+O\` | Toggle tool output expansion | | \`Ctrl+O\` | Toggle tool output expansion |

View file

@ -42,6 +42,7 @@ export {
isCtrlT, isCtrlT,
isCtrlU, isCtrlU,
isCtrlW, isCtrlW,
isCtrlZ,
isDelete, isDelete,
isEnd, isEnd,
isEnter, isEnter,

View file

@ -35,6 +35,7 @@ const CODEPOINTS = {
t: 116, t: 116,
u: 117, u: 117,
w: 119, w: 119,
z: 122,
// Special keys // Special keys
escape: 27, escape: 27,
@ -168,6 +169,7 @@ export const Keys = {
CTRL_T: kittySequence(CODEPOINTS.t, MODIFIERS.ctrl), CTRL_T: kittySequence(CODEPOINTS.t, MODIFIERS.ctrl),
CTRL_U: kittySequence(CODEPOINTS.u, MODIFIERS.ctrl), CTRL_U: kittySequence(CODEPOINTS.u, MODIFIERS.ctrl),
CTRL_W: kittySequence(CODEPOINTS.w, MODIFIERS.ctrl), CTRL_W: kittySequence(CODEPOINTS.w, MODIFIERS.ctrl),
CTRL_Z: kittySequence(CODEPOINTS.z, MODIFIERS.ctrl),
// Enter combinations // Enter combinations
SHIFT_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.shift), SHIFT_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.shift),
@ -223,6 +225,7 @@ const RAW = {
CTRL_T: "\x14", CTRL_T: "\x14",
CTRL_U: "\x15", CTRL_U: "\x15",
CTRL_W: "\x17", CTRL_W: "\x17",
CTRL_Z: "\x1a",
ALT_BACKSPACE: "\x1b\x7f", ALT_BACKSPACE: "\x1b\x7f",
SHIFT_TAB: "\x1b[Z", SHIFT_TAB: "\x1b[Z",
} as const; } 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); 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). * Check if input matches Alt+Backspace (legacy or Kitty protocol).
* Ignores lock key bits. * Ignores lock key bits.

View file

@ -118,7 +118,12 @@ export class TUI extends Container {
this.terminal.stop(); this.terminal.stop();
} }
requestRender(): void { requestRender(force = false): void {
if (force) {
this.previousLines = [];
this.previousWidth = 0;
this.cursorRow = 0;
}
if (this.renderRequested) return; if (this.renderRequested) return;
this.renderRequested = true; this.renderRequested = true;
process.nextTick(() => { process.nextTick(() => {