From 410b1ffb976e48b6e07e07f3d01cd441e810fbc8 Mon Sep 17 00:00:00 2001 From: Shubham Naik Date: Wed, 10 Dec 2025 16:14:34 -0800 Subject: [PATCH] feat: support suspending ui (#173) Co-authored-by: Shubham Naik --- src/cli/App.tsx | 5 ++- src/cli/components/InputRich.tsx | 6 ++-- src/cli/hooks/useSuspend/useSuspend.ts | 46 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 src/cli/hooks/useSuspend/useSuspend.ts diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 14db491..354b12f 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -65,6 +65,7 @@ import { generatePlanFilePath } from "./helpers/planName"; import { safeJsonParseOr } from "./helpers/safeJsonParse"; import { type ApprovalRequest, drainStreamWithResume } from "./helpers/stream"; import { getRandomThinkingMessage } from "./helpers/thinkingMessages"; +import { useSuspend } from "./hooks/useSuspend/useSuspend.ts"; import { useTerminalWidth } from "./hooks/useTerminalWidth"; const CLEAR_SCREEN_AND_HOME = "\u001B[2J\u001B[H"; @@ -240,6 +241,8 @@ export default function App({ const [agentId, setAgentId] = useState(initialAgentId); const [agentState, setAgentState] = useState(initialAgentState); + const resumeKey = useSuspend(); + // Sync with prop changes (e.g., when parent updates from "loading" to actual ID) useEffect(() => { if (initialAgentId !== agentId) { @@ -2899,7 +2902,7 @@ Plan file path: ${planFilePath}`; ]); return ( - + { if (!visible) return; + + // Handle CTRL-C for double-ctrl-c-to-exit if (input === "c" && key.ctrl) { if (ctrlCPressed) { // Second CTRL-C - call onExit callback which handles stats and exit diff --git a/src/cli/hooks/useSuspend/useSuspend.ts b/src/cli/hooks/useSuspend/useSuspend.ts new file mode 100644 index 0000000..b38d507 --- /dev/null +++ b/src/cli/hooks/useSuspend/useSuspend.ts @@ -0,0 +1,46 @@ +import { useInput, useStdin } from "ink"; +import { useCallback, useEffect, useState } from "react"; + +export function useSuspend() { + const { stdin, isRawModeSupported } = useStdin(); + // Use a state variable to force a re-render when needed + const [resumeKey, setResumeKey] = useState(0); + + const forceUpdate = useCallback(() => { + setResumeKey((prev) => prev + 1); + }, []); + + useInput((input, key) => { + // Handle CTRL-Z for suspend + if (key.ctrl && input === "z") { + if (stdin && isRawModeSupported) { + stdin.setRawMode(false); + } + + process.kill(process.pid, "SIGTSTP"); + return; + } + }); + + // Handle the SIGCONT (fg command) resume + useEffect(() => { + const handleResume = () => { + if (stdin && isRawModeSupported && stdin.setRawMode) { + stdin.setRawMode(true); + } + + // clear the console + process.stdout.write("\x1B[H\x1B[2J"); + + forceUpdate(); + }; + + process.on("SIGCONT", handleResume); + + return () => { + process.off("SIGCONT", handleResume); + }; + }, [stdin, isRawModeSupported, forceUpdate]); + + return resumeKey; +}