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