diff --git a/src/tui/App.tsx b/src/tui/App.tsx index bc58484..ea6838a 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -7,6 +7,7 @@ import { detectOpenSpecSteps } from "../services/openspec-hook"; import { computeSuggestions, getCurrentStepId } from "../services/suggestions"; import { hasThinkingSupport } from "../services/thinking-parser"; import { buildSkillsPrompt, loadSkills } from "../skills/registry"; +import { getSavedTheme, saveTheme } from "../state/config"; import { createSessionId, saveSession } from "../state/session"; import { executeTool } from "../tools/executor"; import { loadTools } from "../tools/registry"; @@ -27,6 +28,8 @@ import { ResultPane } from "./components/ResultPane"; import { StatusBar } from "./components/StatusBar"; import { ThinkingPane } from "./components/ThinkingPane"; import { Timeline } from "./components/Timeline"; +import { useLayout } from "./hooks/use-terminal"; +import { getThemeNames, setThemeByName } from "./theme"; const MAX_TOOL_ITERATIONS = 10; @@ -48,10 +51,13 @@ interface AppState { paletteIndex: number; suggestions: string[]; suggestionIndex: number; + confirmQuit: boolean; + selectedStepIndex: number; } export function App() { const { exit } = useApp(); + const layout = useLayout(); const [state, setState] = useState({ model: null, @@ -69,6 +75,8 @@ export function App() { paletteIndex: 0, suggestions: [], suggestionIndex: -1, + confirmQuit: false, + selectedStepIndex: 0, }); const [prompt, setPrompt] = useState(""); @@ -82,6 +90,9 @@ export function App() { // Initialize useEffect(() => { async function init() { + const savedTheme = getSavedTheme(); + setThemeByName(savedTheme); + const models = await fetchModels(); if (models.length === 0) { console.error( @@ -226,6 +237,16 @@ export function App() { }, [exit]); // Actions for palette and toolbar + const themeActions: Action[] = getThemeNames().map((name) => ({ + id: `theme-${name}`, + label: `Theme: ${name}`, + shortcut: "", + action: () => { + setThemeByName(name); + saveTheme(name); + }, + })); + const actions: Action[] = [ { id: "model", @@ -247,6 +268,7 @@ export function App() { shortcut: "", action: () => update({ thinkingVisible: !state.thinkingVisible }), }, + ...themeActions, { id: "clear", label: "Clear", @@ -280,7 +302,7 @@ export function App() { useInput((input, key) => { const s = state; - // Ctrl+C / Ctrl+D + // Ctrl+C / Ctrl+D — immediate quit if (key.ctrl && (input === "c" || input === "d")) { if (cancelStreaming()) return; quit(); @@ -333,8 +355,18 @@ export function App() { // Normal mode if (key.escape) { + if (s.confirmQuit) { + quit(); + return; + } if (cancelStreaming()) return; - quit(); + update({ confirmQuit: true }); + return; + } + + // Any other key dismisses quit confirmation + if (s.confirmQuit) { + update({ confirmQuit: false }); return; } @@ -385,8 +417,23 @@ export function App() { return; } - // : to open palette - if (input === ":") { + // Ctrl+Left/Right to navigate steps + if (key.ctrl && key.leftArrow) { + update({ selectedStepIndex: Math.max(0, s.selectedStepIndex - 1) }); + return; + } + if (key.ctrl && key.rightArrow) { + update({ + selectedStepIndex: Math.min( + s.steps.length - 1, + s.selectedStepIndex + 1, + ), + }); + return; + } + + // Ctrl+P to open palette + if (key.ctrl && input === "p") { update({ uiMode: "palette", paletteIndex: 0 }); return; } @@ -462,14 +509,19 @@ export function App() { return (
- - + + + + - - {state.uiMode === "palette" && ( diff --git a/src/tui/hooks/use-terminal.ts b/src/tui/hooks/use-terminal.ts new file mode 100644 index 0000000..85f62f8 --- /dev/null +++ b/src/tui/hooks/use-terminal.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; + +export function useTerminalSize() { + const [size, setSize] = useState({ + rows: process.stdout.rows ?? 24, + columns: process.stdout.columns ?? 80, + }); + + useEffect(() => { + const onResize = () => { + setSize({ + rows: process.stdout.rows ?? 24, + columns: process.stdout.columns ?? 80, + }); + }; + process.stdout.on("resize", onResize); + return () => { + process.stdout.off("resize", onResize); + }; + }, []); + + return size; +} + +const FIXED_LINES = 9; + +export function useLayout() { + const { rows } = useTerminalSize(); + const contentHeight = Math.max(4, rows - FIXED_LINES); + + const thinkingMax = Math.min(Math.floor(contentHeight * 0.25), 8); + const agentMax = Math.min(Math.floor(contentHeight * 0.25), 6); + const resultHeight = Math.max(3, contentHeight - thinkingMax - agentMax); + + return { contentHeight, thinkingMax, agentMax, resultHeight }; +}