diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 5a53a22..29365a6 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -8,22 +8,19 @@ import { getModelInfo, MODEL_GROUPS, MODELS } from "../grok/models.js"; import { saveProjectSettings } from "../utils/settings.js"; import { dark, type Theme } from "./theme.js"; -const SPLIT_BORDER = { +const SPLIT = { topLeft: "", bottomLeft: "", vertical: "┃", topRight: "", bottomRight: "", horizontal: " ", bottomT: "", topT: "", cross: "", leftT: "", rightT: "", }; -const SPLIT_BORDER_END = { ...SPLIT_BORDER, bottomLeft: "╹" }; -const EMPTY_BORDER = { +const SPLIT_END = { ...SPLIT, bottomLeft: "╹" }; +const EMPTY = { topLeft: "", bottomLeft: "", vertical: "", topRight: "", bottomRight: "", horizontal: " ", bottomT: "", topT: "", cross: "", leftT: "", rightT: "", }; -interface AppProps { - agent: Agent; - initialMessage?: string; -} +interface AppProps { agent: Agent; initialMessage?: string } export function App({ agent, initialMessage }: AppProps) { const t = dark; @@ -36,11 +33,13 @@ export function App({ agent, initialMessage }: AppProps) { const [showModelPicker, setShowModelPicker] = useState(false); const [modelPickerIndex, setModelPickerIndex] = useState(0); const [activeToolCalls, setActiveToolCalls] = useState([]); + const [toolCallCount, setToolCallCount] = useState(0); const inputRef = useRef(null); const scrollRef = useRef(null); const { width, height } = useTerminalDimensions(); const processedInitial = useRef(false); const contentAccRef = useRef(""); + const startTimeRef = useRef(0); const setMode = useCallback((m: AgentMode) => { agent.setMode(m); setModeState(m); }, [agent]); const cycleMode = useCallback(() => { @@ -59,31 +58,62 @@ export function App({ agent, initialMessage }: AppProps) { const processMessage = useCallback(async (text: string) => { if (!text.trim() || isProcessing) return; setIsProcessing(true); setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]); contentAccRef.current = ""; + setToolCallCount(0); startTimeRef.current = Date.now(); setMessages((prev) => [...prev, { type: "user", content: text.trim(), timestamp: new Date() }]); setTimeout(scrollToBottom, 50); try { for await (const chunk of agent.processMessage(text.trim())) { switch (chunk.type) { - case "content": contentAccRef.current += chunk.content || ""; setStreamContent(contentAccRef.current); setTimeout(scrollToBottom, 10); break; - case "reasoning": setStreamReasoning((p) => p + (chunk.content || "")); break; + case "content": + contentAccRef.current += chunk.content || ""; + setStreamContent(contentAccRef.current); + setTimeout(scrollToBottom, 10); + break; + case "reasoning": + setStreamReasoning((p) => p + (chunk.content || "")); + break; case "tool_calls": if (chunk.toolCalls) { - if (contentAccRef.current.trim()) { const s = contentAccRef.current; setMessages((p) => [...p, { type: "assistant", content: s, timestamp: new Date() }]); contentAccRef.current = ""; setStreamContent(""); } + if (contentAccRef.current.trim()) { + const s = contentAccRef.current; + setMessages((p) => [...p, { type: "assistant", content: s, timestamp: new Date() }]); + contentAccRef.current = ""; setStreamContent(""); + } setActiveToolCalls(chunk.toolCalls); - } break; + setToolCallCount((c) => c + chunk.toolCalls!.length); + } + break; case "tool_result": if (chunk.toolCall && chunk.toolResult) { - setMessages((p) => [...p, { type: "tool_result", content: chunk.toolResult!.success ? (chunk.toolResult!.output || "Success") : (chunk.toolResult!.error || "Error"), timestamp: new Date(), toolCall: chunk.toolCall, toolResult: chunk.toolResult }]); + setMessages((p) => [...p, { + type: "tool_result", + content: chunk.toolResult!.success ? (chunk.toolResult!.output || "Success") : (chunk.toolResult!.error || "Error"), + timestamp: new Date(), toolCall: chunk.toolCall, toolResult: chunk.toolResult, + }]); setActiveToolCalls([]); setTimeout(scrollToBottom, 10); - } break; - case "error": contentAccRef.current += `\n${chunk.content || "Unknown error"}`; setStreamContent(contentAccRef.current); break; + } + break; + case "error": + contentAccRef.current += `\n${chunk.content || "Unknown error"}`; + setStreamContent(contentAccRef.current); + break; case "done": break; } } } catch { contentAccRef.current += "\nAn unexpected error occurred."; setStreamContent(contentAccRef.current); } - if (contentAccRef.current.trim()) { const f = contentAccRef.current; setMessages((p) => [...p, { type: "assistant", content: f, timestamp: new Date() }]); } - contentAccRef.current = ""; setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]); setIsProcessing(false); setTimeout(scrollToBottom, 50); - }, [agent, isProcessing, scrollToBottom]); + if (contentAccRef.current.trim()) { + const f = contentAccRef.current; + setMessages((p) => [...p, { type: "assistant", content: f, timestamp: new Date() }]); + } + const elapsed = Date.now() - startTimeRef.current; + const dur = formatDuration(elapsed); + setMessages((p) => [...p, { + type: "tool_call" as const, content: `▣ ${modeInfo.label} · ${model}${dur ? ` · ${dur}` : ""}`, + timestamp: new Date(), + }]); + contentAccRef.current = ""; setStreamContent(""); setStreamReasoning(""); setActiveToolCalls([]); + setIsProcessing(false); setTimeout(scrollToBottom, 50); + }, [agent, isProcessing, scrollToBottom, modeInfo, model]); useEffect(() => { if (initialMessage && !processedInitial.current) { processedInitial.current = true; processMessage(initialMessage); } }, [initialMessage, processMessage]); @@ -124,59 +154,58 @@ export function App({ agent, initialMessage }: AppProps) { return ( {hasMessages ? ( - - {/* Top bar */} - - {modelInfo?.name || model}{isProcessing ? " working..." : ""} - {modeInfo.label} - - {"─".repeat(Math.max(1, width - 4))} - {/* Messages */} + + {/* Session header — ┃ left-border panel like OpenCode's Header */} + + {/* Scrollable messages */} - {messages.map((msg, i) => )} - {activeToolCalls.map((tc, i) => ( - - {"› "}{tc.function.name === "bash" ? toolArgs(tc) : `${tc.function.name} ${toolArgs(tc)}`} - + {messages.map((msg, i) => ( + ))} + {/* Active tool calls — pending inline */} + {activeToolCalls.map((tc, i) => ( + {toolLabel(tc)} + ))} + {/* Reasoning */} {streamReasoning && ( - {"thinking: "}{trunc(streamReasoning, 200)} + + {"Thinking: "}{trunc(streamReasoning, 300)} + )} - {streamContent && {streamContent}} + {/* Streaming assistant content */} + {streamContent && ( + + {streamContent} + + )} + {/* Waiting indicator */} {isProcessing && !streamContent && activeToolCalls.length === 0 && ( - {"..."} + {"Thinking..."} )} - {/* Prompt box */} + {/* Prompt */} - - {agent.getCwd()} - {model} - ) : ( - /* ── Home Screen ──────────────────────────────────── */ + /* ── Home ───────────────────────────────────────── */ <> - {/* Logo */} {" ✦ G R O K"} {"AI coding agent for the terminal"} - {/* Prompt box */} - {/* Hints */} {"tab"}{" cycle modes"} {"/model"}{" switch"} @@ -196,7 +225,26 @@ export function App({ agent, initialMessage }: AppProps) { ); } -/* ── Prompt Box (┃ left-border with mode/model inside) ───────── */ +/* ── Session Header ──────────────────────────────────────────── */ + +function SessionHeader({ t, model, modelInfo, modeInfo }: { + t: Theme; model: string; modelInfo: ReturnType; + modeInfo: typeof MODES[number]; +}) { + return ( + + + {"# "}{modelInfo?.name || model} + + + ); +} + +/* ── Prompt Box ──────────────────────────────────────────────── */ function PromptBox({ t, inputRef, isProcessing, showModelPicker, onSubmit, mode, modeInfo, model, modelInfo, placeholder }: { t: Theme; inputRef: React.RefObject; @@ -206,7 +254,7 @@ function PromptBox({ t, inputRef, isProcessing, showModelPicker, onSubmit, mode, }) { return ( - + - {/* Shadow edge */} - - + + - {/* Hints */} {isProcessing ? ( {"esc "}{"interrupt"} @@ -245,38 +291,92 @@ function MessageView({ entry, index, t, modeColor }: { entry: ChatEntry; index: switch (entry.type) { case "user": return ( - - {"▸ "}{entry.content} + + + {entry.content} + ); + case "assistant": - return {entry.content}; + return ( + + {entry.content} + + ); + + case "tool_call": + return ( + + + {"▣ "} + {entry.content.replace("▣ ", "")} + + + ); + case "tool_result": { const name = entry.toolCall?.function.name || "tool"; const ok = entry.toolResult?.success ?? true; const args = toolArgs(entry.toolCall); const output = entry.content; + if (name === "bash" && output && output.split("\n").length > 3) { const lines = output.split("\n"); - const preview = lines.slice(0, 8).join("\n") + (lines.length > 8 ? "\n ..." : ""); + const preview = lines.slice(0, 10).join("\n") + (lines.length > 10 ? "\n…" : ""); return ( - - {"$ "}{args} - {preview} + + {"# Shell"} + + {"$ "}{args} + {preview} + ); } - return ( - - {ok ? "$ " : "✗ "}{name === "bash" ? args : `${name} ${args}`} - - ); + + if (name === "search_web" || name === "search_x") { + if (output && output.length > 100) { + return ( + + {"# "}{name === "search_web" ? "Web Search" : "X Search"}{` "${args}"`} + {trunc(output, 500)} + + ); + } + return {name === "search_web" ? "Web" : "X"}{` Search "${args}"`}; + } + + return {name === "bash" ? args : `${name} ${args}`}; } - default: return {entry.content}; + + default: + return {entry.content}; } } -/* ── Model Picker Modal ──────────────────────────────────────── */ +function InlineTool({ t, icon, pending, children }: { t: Theme; icon: string; pending: boolean; children: React.ReactNode }) { + return ( + + + {pending ? `~ ` : `${icon} `}{children} + + + ); +} + +/* ── Model Picker ────────────────────────────────────────────── */ function ModelPickerModal({ t, currentModel, selectedIndex, width, height }: { t: Theme; currentModel: string; selectedIndex: number; width: number; height: number; @@ -330,4 +430,18 @@ function toolArgs(tc?: ToolCall): string { if (!tc) return ""; try { const a = JSON.parse(tc.function.arguments); return tc.function.name === "bash" ? a.command || "" : a.query || ""; } catch { return ""; } } +function toolLabel(tc: ToolCall): string { + const args = toolArgs(tc); + if (tc.function.name === "bash") return args || "Running command..."; + if (tc.function.name === "search_web") return `Web Search "${args}"`; + if (tc.function.name === "search_x") return `X Search "${args}"`; + return `${tc.function.name} ${args}`; +} function trunc(s: string, n: number): string { return s.length <= n ? s : s.slice(0, n) + "…"; } +function formatDuration(ms: number): string { + if (ms < 1000) return ""; + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + return `${m}m ${s % 60}s`; +}