From b51e3d49a64516f73a8628a4202dc325d7ac9d21 Mon Sep 17 00:00:00 2001 From: Carlos Gutierrez Date: Sat, 14 Feb 2026 12:47:53 -0500 Subject: [PATCH] feat: add Ctrl+O activity panel toggle, fix terminal 997;1n garbage on exit Wire up the activity_toggle keybind (Ctrl+O) to show/hide the activity panel via new activityVisible store state. Fix terminal garbage text on exit by draining stdin after renderer teardown to consume pending DECRQM mode 997 responses before they echo in the shell. --- README.md | 3 ++ docs/CHANGELOG.md | 2 + src/commands/components/execute/index.ts | 5 ++- src/constants/keybinds.ts | 2 +- src/tui-solid/app.tsx | 13 +++++- src/tui-solid/context/app.tsx | 17 ++++++++ src/tui-solid/routes/session.tsx | 14 ++++--- src/utils/core/terminal.ts | 50 ++++++++++++++++++++++++ 8 files changed, 98 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c489fcc..d9bb4cc 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ Full-screen terminal interface with real-time streaming responses. - `/` - Open command menu (works anywhere in input) - `Ctrl+M` - Toggle interaction mode - `Ctrl+T` - Toggle todo panel +- `Ctrl+O` - Toggle activity panel - `Shift+Up/Down` - Scroll log panel - `Ctrl+C` (twice) - Exit @@ -465,7 +466,9 @@ bun run lint ## Recent Changes (v0.4.2) - **Pink Purple Theme**: New built-in color theme +- **Activity Panel Toggle**: `Ctrl+O` to show/hide the activity panel - **Image Paste Fix**: Fixed race condition where pasted images were silently dropped +- **Terminal Exit Fix**: Fixed `997;1n` garbage text appearing on exit - **@ and / Anywhere**: File picker and command menu now work at any cursor position - **Plan Approval Gate**: User confirmation before agent executes plans - **Execution Control**: Pause, resume, and abort agent execution diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dd9c0c4..6e1ac34 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,12 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Pink Purple Theme**: New built-in theme with hot pink primary, purple secondary, and deep magenta accent on a dark plum background +- **Activity Panel Toggle**: `Ctrl+O` keybind to show/hide the activity panel (context/tokens, modified files) ### Fixed - **Image Paste Race Condition**: Fixed images being silently dropped when pasting via Ctrl+V. The `clearPastedImages()` call in the input area was racing with the async message handler, clearing images before they could be read and attached to the message - **@ File Picker**: Now works at any cursor position in the input, not just when the input is empty - **/ Command Menu**: Now works at any cursor position in the input, not just when the input is empty +- **Terminal Garbage on Exit**: Fixed `997;1n` text appearing on exit, caused by unanswered DECRQM mode 997 query from the TUI renderer ### Planned diff --git a/src/commands/components/execute/index.ts b/src/commands/components/execute/index.ts index 866779c..fc8df9a 100644 --- a/src/commands/components/execute/index.ts +++ b/src/commands/components/execute/index.ts @@ -20,6 +20,7 @@ import { registerExitHandlers, exitFullscreen, clearScreen, + drainStdin, } from "@utils/core/terminal"; import { createCallbacks } from "@commands/chat-tui"; import { agentLoader } from "@services/agent-loader"; @@ -35,7 +36,9 @@ const createHandleExit = (): (() => void) => (): void => { exitFullscreen(); clearScreen(); console.log("Goodbye!"); - process.exit(0); + // Drain stdin to consume pending terminal responses (e.g. DECRQM 997;1n) + // before exiting, so they don't echo as garbage text in the shell + drainStdin().then(() => process.exit(0)); }; const createHandleModelSelect = diff --git a/src/constants/keybinds.ts b/src/constants/keybinds.ts index ce23b5c..8e7c3f1 100644 --- a/src/constants/keybinds.ts +++ b/src/constants/keybinds.ts @@ -113,7 +113,7 @@ export const DEFAULT_KEYBINDS: Readonly> = { // Sidebar / panels sidebar_toggle: "b", - activity_toggle: "s", + activity_toggle: "ctrl+o", } as const; /** diff --git a/src/tui-solid/app.tsx b/src/tui-solid/app.tsx index 9cbd196..ce22c7c 100644 --- a/src/tui-solid/app.tsx +++ b/src/tui-solid/app.tsx @@ -286,8 +286,19 @@ function AppContent(props: AppProps) { return; } + // Toggle activity panel + if (matchesAction(evt, "activity_toggle")) { + app.toggleActivity(); + evt.preventDefault(); + return; + } + // Command menu trigger from "/" when input is empty - if (matchesAction(evt, "command_menu") && app.mode() === "idle" && !app.inputBuffer()) { + if ( + matchesAction(evt, "command_menu") && + app.mode() === "idle" && + !app.inputBuffer() + ) { app.openCommandMenu(); evt.preventDefault(); return; diff --git a/src/tui-solid/context/app.tsx b/src/tui-solid/context/app.tsx index 9735cf0..ef25535 100644 --- a/src/tui-solid/context/app.tsx +++ b/src/tui-solid/context/app.tsx @@ -44,6 +44,7 @@ interface AppStore { availableModels: ProviderModel[]; sessionStats: SessionStats; todosVisible: boolean; + activityVisible: boolean; debugLogVisible: boolean; interruptPending: boolean; exitPending: boolean; @@ -89,6 +90,7 @@ interface AppContextValue { availableModels: Accessor; sessionStats: Accessor; todosVisible: Accessor; + activityVisible: Accessor; debugLogVisible: Accessor; interruptPending: Accessor; exitPending: Accessor; @@ -172,6 +174,7 @@ interface AppContextValue { // UI state actions toggleTodos: () => void; + toggleActivity: () => void; toggleDebugLog: () => void; setInterruptPending: (pending: boolean) => void; setExitPending: (pending: boolean) => void; @@ -276,6 +279,7 @@ export const { provider: AppStoreProvider, use: useAppStore } = availableModels: [], sessionStats: createInitialSessionStats(), todosVisible: true, + activityVisible: true, debugLogVisible: false, interruptPending: false, exitPending: false, @@ -331,6 +335,7 @@ export const { provider: AppStoreProvider, use: useAppStore } = const availableModels = (): ProviderModel[] => store.availableModels; const sessionStats = (): SessionStats => store.sessionStats; const todosVisible = (): boolean => store.todosVisible; + const activityVisible = (): boolean => store.activityVisible; const debugLogVisible = (): boolean => store.debugLogVisible; const interruptPending = (): boolean => store.interruptPending; const exitPending = (): boolean => store.exitPending; @@ -700,6 +705,10 @@ export const { provider: AppStoreProvider, use: useAppStore } = setStore("todosVisible", !store.todosVisible); }; + const toggleActivity = (): void => { + setStore("activityVisible", !store.activityVisible); + }; + const toggleDebugLog = (): void => { setStore("debugLogVisible", !store.debugLogVisible); }; @@ -889,6 +898,7 @@ export const { provider: AppStoreProvider, use: useAppStore } = availableModels, sessionStats, todosVisible, + activityVisible, debugLogVisible, interruptPending, exitPending, @@ -990,6 +1000,7 @@ export const { provider: AppStoreProvider, use: useAppStore } = // UI state actions toggleTodos, + toggleActivity, toggleDebugLog, setInterruptPending, setExitPending, @@ -1085,6 +1096,7 @@ export const appStore = { sessionStats: storeRef.sessionStats(), cascadeEnabled: storeRef.cascadeEnabled(), todosVisible: storeRef.todosVisible(), + activityVisible: storeRef.activityVisible(), debugLogVisible: storeRef.debugLogVisible(), interruptPending: storeRef.interruptPending(), exitPending: storeRef.exitPending(), @@ -1217,6 +1229,11 @@ export const appStore = { storeRef.toggleTodos(); }, + toggleActivity: (): void => { + if (!storeRef) return; + storeRef.toggleActivity(); + }, + toggleDebugLog: (): void => { if (!storeRef) return; storeRef.toggleDebugLog(); diff --git a/src/tui-solid/routes/session.tsx b/src/tui-solid/routes/session.tsx index 4877f85..1093d86 100644 --- a/src/tui-solid/routes/session.tsx +++ b/src/tui-solid/routes/session.tsx @@ -282,7 +282,9 @@ export function Session(props: SessionProps) { - + + + @@ -303,9 +305,7 @@ export function Session(props: SessionProps) { /> - + - + diff --git a/src/utils/core/terminal.ts b/src/utils/core/terminal.ts index 7124449..ac638bb 100644 --- a/src/utils/core/terminal.ts +++ b/src/utils/core/terminal.ts @@ -19,6 +19,56 @@ let spinner: Ora | null = null; */ let exitHandlersRegistered = false; +/** + * Drain any pending stdin data (e.g. DECRQM responses from @opentui/core's + * theme-mode detection that queries mode 997). The terminal responds with + * `\x1b[?997;1n` or `\x1b[?997;2n`, but if the TUI renderer has already + * torn down its stdin listener and disabled raw mode, those bytes echo as + * visible garbage ("997;1n") in the shell. + * + * Strategy: re-enable raw mode so the response doesn't echo, attach a + * temporary listener to swallow any bytes that arrive, wait long enough + * for the terminal to respond, then clean up. + */ +export const drainStdin = (): Promise => + new Promise((resolve) => { + try { + if (!process.stdin.isTTY) { + resolve(); + return; + } + + // Re-enable raw mode so pending responses don't echo + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + + // Swallow any bytes that arrive + const sink = (): void => {}; + process.stdin.on("data", sink); + + // Wait for in-flight terminal responses then clean up + setTimeout(() => { + try { + process.stdin.removeListener("data", sink); + // Read and discard any remaining buffered data + while (process.stdin.read() !== null) { + // drain + } + process.stdin.setRawMode(false); + process.stdin.pause(); + // Unref so this doesn't keep the process alive + process.stdin.unref(); + } catch { + // Ignore — stdin may already be destroyed + } + resolve(); + }, 100); + } catch { + resolve(); + } + }); + /** * Emergency cleanup for terminal state on process exit * Uses writeSync to fd 1 (stdout) to guarantee bytes are flushed