diff --git a/ui/text/src/app.tsx b/ui/text/src/app.tsx index 135ccae9c5af..3125d097b630 100644 --- a/ui/text/src/app.tsx +++ b/ui/text/src/app.tsx @@ -15,16 +15,15 @@ interface PendingPermission { resolve: (response: RequestPermissionResponse) => void; } -const CRANBERRY_BRIGHT = "#C0354A"; -const HARBOR_NAVY = "#1B2A4A"; -const DEEP_SLATE = "#3A4F6F"; -const SLATE = "#6B7F99"; -const LIGHT_SLATE = "#8FA4BD"; -const AUTUMN_GOLD = "#C4883A"; -const OCEAN_TEAL = "#3A7D7B"; -const CEDAR_BROWN = "#6B5344"; -const FOG_WHITE = "#E8E4DF"; -const PARCHMENT = "#D4CFC8"; +const CRANBERRY = "#C0354A"; +const TEAL = "#3A7D7B"; +const GOLD = "#C4883A"; +const CEDAR = "#6B5344"; + +const TEXT_PRIMARY = "#E8E4DF"; +const TEXT_SECONDARY = "#8FA4BD"; +const TEXT_DIM = "#5A6D84"; +const RULE_COLOR = "#2E3D54"; const GOOSE_FRAMES = [ [ @@ -65,8 +64,6 @@ const GOOSE_FRAMES = [ ], ]; -const TITLE_TEXT = "goose"; - const GREETING_MESSAGES = [ "What would you like to work on?", "Ready to build something amazing?", @@ -107,120 +104,120 @@ const PERMISSION_KEYS: Record = { reject_always: "N", }; -interface TextMessage { - kind: "text"; - role: "user" | "agent"; - text: string; +interface Turn { + userText: string; + toolCalls: string[]; + agentText: string; } -interface ToolCallMessage { - kind: "tool_call"; - title: string; -} +// ─── Layout constants ─────────────────────────────────────────────────────── +// +// Every element indents by a multiple of INDENT (3 spaces). This keeps the +// left edge of user prompts, agent prose, and tool-call badges on a +// predictable grid so the eye can scan vertically without friction. +// +// col 0 rule / header +// col 3 user prompt caret + text, input caret + text +// col 5 agent prose, tool badges, loading spinner, permission dialog -type Message = TextMessage | ToolCallMessage; +const INDENT = 3; +const CONTENT_INDENT = 5; +const MAX_PROSE_WIDTH = 76; -function HRule({ width, color }: { width: number; color?: string }) { +function Rule({ width }: { width: number }) { return ( - - - {"─".repeat(Math.max(width, 1))} - - + {"─".repeat(Math.max(width, 1))} ); } -function HeaderBar({ +function Spinner({ idx }: { idx: number }) { + return ( + + {SPINNER_FRAMES[idx % SPINNER_FRAMES.length]} + + ); +} + +function Header({ width, status, loading, spinIdx, hasPendingPermission, + turnInfo, }: { width: number; status: string; loading: boolean; spinIdx: number; hasPendingPermission: boolean; + turnInfo?: { current: number; total: number }; }) { - const statusColor = - status === "ready" - ? OCEAN_TEAL - : status.startsWith("error") || status.startsWith("failed") - ? CRANBERRY_BRIGHT - : SLATE; - - const leftContent = ` ${TITLE_TEXT} `; - const spinner = - loading && !hasPendingPermission - ? ` ${SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]} ` - : ""; + const isError = + status.startsWith("error") || status.startsWith("failed"); + const statusColor = status === "ready" ? TEAL : isError ? CRANBERRY : TEXT_DIM; return ( - - {leftContent} + + goose - - {status} - {spinner && {spinner}} + · + {status} + {loading && !hasPendingPermission && ( + + )} - - ctrl+c to exit{" "} - + {turnInfo && turnInfo.total > 1 && ( + + {turnInfo.current}/{turnInfo.total} + {" "} + + )} + ^C exit - + + + ); +} + +function UserPrompt({ text }: { text: string }) { + return ( + + + {"❯ "} + + + {text} + ); } -function ToolCallBlock({ title, width }: { title: string; width: number }) { +function ToolBadge({ title, width }: { title: string; width: number }) { + const badgeWidth = Math.min(width - CONTENT_INDENT - 2, 68); return ( - - + + {title} ); } -function UserMessage({ text, width }: { text: string; width: number }) { - return ( - - - - {"❯ "} - - - {text} - - - - ); -} - -function AgentMessage({ text, width }: { text: string; width: number }) { - return ( - - {text} - - ); -} - -function PermissionPrompt({ +function PermissionDialog({ toolTitle, options, selectedIdx, @@ -231,32 +228,38 @@ function PermissionPrompt({ selectedIdx: number; width: number; }) { + const dialogWidth = Math.min(width - CONTENT_INDENT - 2, 58); return ( - + 🔒 Permission required - {toolTitle} + + {toolTitle} + {options.map((opt, i) => { const key = PERMISSION_KEYS[opt.kind] ?? String(i + 1); const label = PERMISSION_LABELS[opt.kind] ?? opt.name; - const selected = i === selectedIdx; + const active = i === selectedIdx; return ( - - {selected ? " ▸ " : " "} + + {active ? " ▸ " : " "} - + [{key}] {label} @@ -264,14 +267,199 @@ function PermissionPrompt({ })} - - ↑↓ select · enter confirm · esc cancel - + ↑↓ select · enter confirm · esc cancel + + + ); +} + +function QueuedMessage({ text }: { text: string }) { + return ( + + + {text} + + {" "} + (queued) + + + ); +} + +function inputBarHeight(input: string, width: number, queued: boolean): number { + // Inner text width: width minus border (2), paddingX (2), prompt "❯ " (2) + const textWidth = Math.max(width - 2 - 2 - 2, 1); + // +1 for the trailing cursor character + const contentLen = input.length + 1; + const wrappedLines = Math.max(Math.ceil(contentLen / textWidth), 1); + const queuedLine = queued ? 1 : 0; + return 2 + wrappedLines + queuedLine + 1; +} + +function InputBar({ + width, + input, + onChange, + onSubmit, + queued, + scrollHint, +}: { + width: number; + input: string; + onChange: (v: string) => void; + onSubmit: (v: string) => void; + queued: boolean; + scrollHint: boolean; +}) { + return ( + + + + + + {"❯ "} + + + + {scrollHint && ( + shift+↑↓ history + )} + + {queued && ( + + + message queued — will send when goose finishes + + + )} ); } +function wrapText(text: string, width: number): string[] { + if (width <= 0) return [text]; + const result: string[] = []; + for (const rawLine of text.split("\n")) { + if (rawLine.length === 0) { + result.push(""); + continue; + } + let remaining = rawLine; + while (remaining.length > width) { + let breakAt = remaining.lastIndexOf(" ", width); + if (breakAt <= 0) breakAt = width; + result.push(remaining.slice(0, breakAt)); + remaining = remaining.slice(breakAt).replace(/^ /, ""); + } + result.push(remaining); + } + return result; +} + +function TurnResponseBody({ + turn, + width, + height, + scrollOffset, + loading, + status, + spinIdx, + pendingPermission, + permissionIdx, +}: { + turn: Turn; + width: number; + height: number; + scrollOffset: number; + loading: boolean; + status: string; + spinIdx: number; + pendingPermission: PendingPermission | null; + permissionIdx: number; +}) { + const allLines: React.ReactNode[] = []; + const proseWidth = Math.min(width - CONTENT_INDENT - 1, MAX_PROSE_WIDTH); + + // blank line between user prompt and response content + allLines.push(); + + for (const tc of turn.toolCalls) { + allLines.push( + , + ); + } + + if (turn.agentText) { + // visual break between tool calls and prose + if (turn.toolCalls.length > 0) { + allLines.push(); + } + const wrapped = wrapText(turn.agentText, proseWidth); + for (const line of wrapped) { + allLines.push( + + {line} + , + ); + } + } + + if (loading && !pendingPermission) { + allLines.push( + + + + {" "} + {status} + + , + ); + } + + if (pendingPermission) { + allLines.push( + , + ); + } + + const totalLines = allLines.length; + const visibleCount = Math.max(height, 1); + const maxOffset = Math.max(totalLines - visibleCount, 0); + const offset = Math.min(Math.max(scrollOffset, 0), maxOffset); + const visible = allLines.slice(offset, offset + visibleCount); + const hasAbove = offset > 0; + const hasBelow = offset + visibleCount < totalLines; + + return ( + + {hasAbove && ( + + ▲ more + + )} + {visible} + {hasBelow && !hasAbove && visible.length > 1 && ( + + ▼ more + + )} + + ); +} + function SplashScreen({ animFrame, width, @@ -296,14 +484,10 @@ function SplashScreen({ onInputSubmit: (v: string) => void; }) { const frame = GOOSE_FRAMES[animFrame % GOOSE_FRAMES.length]!; - const statusColor = - status === "ready" - ? OCEAN_TEAL - : status.startsWith("error") || status.startsWith("failed") - ? CRANBERRY_BRIGHT - : SLATE; - - const inputWidth = Math.min(60, width - 8); + const isError = + status.startsWith("error") || status.startsWith("failed"); + const statusColor = status === "ready" ? TEAL : isError ? CRANBERRY : TEXT_DIM; + const inputWidth = Math.min(56, width - 8); return ( + {/* Goose art */} {frame.map((line, i) => ( - + {line} ))} + + {/* Title + subtitle */} - - {TITLE_TEXT} + + goose - - your on-machine AI agent - + your on-machine AI agent + {/* Input or status */} {showInput ? ( - <> - - + + + - - + + {"❯ "} - - + + - + ) : ( - <> - - - - - {loading && ( - - {SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]}{" "} - - )} - {status} - - + + {loading && } + {status} + )} ); } -function InputBar({ - width, - input, - onChange, - onSubmit, -}: { - width: number; - input: string; - onChange: (v: string) => void; - onSubmit: (v: string) => void; -}) { - return ( - - - - - {"❯ "} - - - - - ); -} - -function LoadingIndicator({ - status, - spinIdx, -}: { - status: string; - spinIdx: number; -}) { - return ( - - - {SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]}{" "} - - - {status} - - - ); -} - export default function App({ serverUrl, initialPrompt, @@ -424,20 +558,27 @@ export default function App({ const termWidth = stdout?.columns ?? 80; const termHeight = stdout?.rows ?? 24; - const [messages, setMessages] = useState([]); + const [turns, setTurns] = useState([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(true); - const [status, setStatus] = useState("connecting..."); + const [status, setStatus] = useState("connecting…"); const [spinIdx, setSpinIdx] = useState(0); const [gooseFrame, setGooseFrame] = useState(0); const [bannerVisible, setBannerVisible] = useState(true); const [pendingPermission, setPendingPermission] = useState(null); const [permissionIdx, setPermissionIdx] = useState(0); + const [queuedMessages, setQueuedMessages] = useState([]); + + const [viewTurnIdx, setViewTurnIdx] = useState(-1); + const [scrollOffset, setScrollOffset] = useState(0); + const clientRef = useRef(null); const sessionIdRef = useRef(null); const streamBuf = useRef(""); const sentInitialPrompt = useRef(false); + const queueRef = useRef([]); + const isProcessingRef = useRef(false); useEffect(() => { const t = setInterval(() => { @@ -448,30 +589,39 @@ export default function App({ }, []); useEffect(() => { - if (messages.length > 0) { - setBannerVisible(false); - } - }, [messages]); + if (turns.length > 0) setBannerVisible(false); + }, [turns]); + + const turnsLen = turns.length; + useEffect(() => { + if (viewTurnIdx === -1) setScrollOffset(0); + }, [turnsLen, viewTurnIdx]); const appendAgent = useCallback((text: string) => { - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last && last.kind === "text" && last.role === "agent") { - return [ - ...prev.slice(0, -1), - { - kind: "text" as const, - role: "agent" as const, - text: last.text + text, - }, - ]; - } - return [...prev, { kind: "text" as const, role: "agent" as const, text }]; + setTurns((prev) => { + if (prev.length === 0) return prev; + const last = { ...prev[prev.length - 1]! }; + last.agentText = last.agentText + text; + return [...prev.slice(0, -1), last]; }); }, []); const appendToolCall = useCallback((title: string) => { - setMessages((prev) => [...prev, { kind: "tool_call" as const, title }]); + setTurns((prev) => { + if (prev.length === 0) return prev; + const last = { ...prev[prev.length - 1]! }; + last.toolCalls = [...last.toolCalls, title]; + return [...prev.slice(0, -1), last]; + }); + }, []); + + const addUserTurn = useCallback((text: string) => { + setTurns((prev) => [ + ...prev, + { userText: text, toolCalls: [], agentText: "" }, + ]); + setViewTurnIdx(-1); + setScrollOffset(0); }, []); const resolvePermission = useCallback( @@ -491,18 +641,56 @@ export default function App({ [pendingPermission], ); + const processQueue = useCallback(async () => { + if (isProcessingRef.current) return; + isProcessingRef.current = true; + + while (queueRef.current.length > 0) { + const next = queueRef.current.shift()!; + setQueuedMessages([...queueRef.current]); + + const client = clientRef.current; + const sid = sessionIdRef.current; + if (!client || !sid) break; + + addUserTurn(next); + setLoading(true); + setStatus("thinking…"); + streamBuf.current = ""; + + try { + const result = await client.prompt({ + sessionId: sid, + prompt: [{ type: "text", text: next }], + }); + + if (streamBuf.current) appendAgent(""); + + setStatus( + result.stopReason === "end_turn" + ? "ready" + : `stopped: ${result.stopReason}`, + ); + } catch (e: unknown) { + const errMsg = e instanceof Error ? e.message : String(e); + setStatus(`error: ${errMsg}`); + } finally { + setLoading(false); + } + } + + isProcessingRef.current = false; + }, [appendAgent, addUserTurn]); + const sendPrompt = useCallback( async (text: string) => { const client = clientRef.current; const sid = sessionIdRef.current; if (!client || !sid) return; - setMessages((prev) => [ - ...prev, - { kind: "text" as const, role: "user" as const, text }, - ]); + addUserTurn(text); setLoading(true); - setStatus("thinking..."); + setStatus("thinking…"); streamBuf.current = ""; try { @@ -511,9 +699,7 @@ export default function App({ prompt: [{ type: "text", text }], }); - if (streamBuf.current) { - appendAgent(""); - } + if (streamBuf.current) appendAgent(""); setStatus( result.stopReason === "end_turn" @@ -525,9 +711,10 @@ export default function App({ setStatus(`error: ${errMsg}`); } finally { setLoading(false); + if (queueRef.current.length > 0) processQueue(); } }, - [appendAgent], + [appendAgent, addUserTurn, processQueue], ); useEffect(() => { @@ -535,7 +722,7 @@ export default function App({ (async () => { try { - setStatus("initializing..."); + setStatus("initializing…"); const stream = createHttpStream(serverUrl); const client = new GooseClient( @@ -573,7 +760,7 @@ export default function App({ if (cancelled) return; clientRef.current = client; - setStatus("handshaking..."); + setStatus("handshaking…"); await client.initialize({ protocolVersion: 0, clientInfo: { name: "goose-text", version: "0.1.0" }, @@ -582,7 +769,7 @@ export default function App({ if (cancelled) return; - setStatus("creating session..."); + setStatus("creating session…"); const session = await client.newSession({ cwd: process.cwd(), mcpServers: [], @@ -596,9 +783,7 @@ export default function App({ if (initialPrompt && !sentInitialPrompt.current) { sentInitialPrompt.current = true; await sendPrompt(initialPrompt); - if (initialPrompt) { - setTimeout(() => exit(), 100); - } + if (initialPrompt) setTimeout(() => exit(), 100); } } catch (e: unknown) { if (cancelled) return; @@ -616,9 +801,17 @@ export default function App({ const handleSubmit = useCallback( (value: string) => { const trimmed = value.trim(); - if (!trimmed || loading) return; + if (!trimmed) return; setInput(""); - sendPrompt(trimmed); + setViewTurnIdx(-1); + setScrollOffset(0); + + if (loading || isProcessingRef.current) { + queueRef.current.push(trimmed); + setQueuedMessages([...queueRef.current]); + } else { + sendPrompt(trimmed); + } }, [loading, sendPrompt], ); @@ -632,6 +825,7 @@ export default function App({ exit(); } + // Permission navigation if (pendingPermission) { const opts = pendingPermission.options; @@ -645,9 +839,7 @@ export default function App({ } if (key.return) { const selected = opts[permissionIdx]; - if (selected) { - resolvePermission({ optionId: selected.optionId }); - } + if (selected) resolvePermission({ optionId: selected.optionId }); return; } @@ -660,28 +852,72 @@ export default function App({ const targetKind = keyMap[ch]; if (targetKind) { const match = opts.find((o) => o.kind === targetKind); - if (match) { - resolvePermission({ optionId: match.optionId }); - return; - } + if (match) resolvePermission({ optionId: match.optionId }); } + return; + } + + // Turn navigation: shift+arrow + if (key.upArrow && key.shift) { + setTurns((currentTurns) => { + if (currentTurns.length <= 1) return currentTurns; + setViewTurnIdx((prev) => { + const effectiveIdx = prev === -1 ? currentTurns.length - 1 : prev; + setScrollOffset(0); + return Math.max(effectiveIdx - 1, 0); + }); + return currentTurns; + }); + return; + } + if (key.downArrow && key.shift) { + setTurns((currentTurns) => { + if (currentTurns.length <= 1) return currentTurns; + setViewTurnIdx((prev) => { + if (prev === -1) return -1; + const next = prev + 1; + setScrollOffset(0); + return next >= currentTurns.length ? -1 : next; + }); + return currentTurns; + }); + return; + } + + // Scroll within turn + if (key.pageUp || (key.upArrow && key.meta)) { + setScrollOffset((prev) => Math.max(prev - 5, 0)); + return; + } + if (key.pageDown || (key.downArrow && key.meta)) { + setScrollOffset((prev) => prev + 5); + return; } }); - const PAD_X = 2; - const PAD_BOTTOM = 1; - const innerWidth = Math.max(termWidth - PAD_X * 2, 20); - const headerHeight = 2; - const inputBarHeight = initialPrompt ? 0 : 2; - const bodyHeight = Math.max(termHeight - headerHeight - inputBarHeight - PAD_BOTTOM, 3); + // ── Layout math ─────────────────────────────────────────────────────── + // + // The vertical budget is: + // header (2 lines: title row + rule) + // user prompt (2 lines: blank line above + prompt text) + // body (flex: remaining space) + // input bar (dynamic: border top/bottom + wrapped content lines + margin bottom) — absent in pipe mode + + const GUTTER = 2; + const innerWidth = Math.max(termWidth - GUTTER * 2, 20); + const headerLines = 2; + const userPromptLines = 2; + const inputLines = initialPrompt + ? 0 + : inputBarHeight(input, innerWidth, queuedMessages.length > 0); + const bodyHeight = Math.max( + termHeight - headerLines - userPromptLines - inputLines - 1, + 3, + ); if (bannerVisible) { return ( - + - 1 + ? { current: effectiveTurnIdx + 1, total: turns.length } + : undefined + } /> - - {messages.map((msg, i) => { - if (msg.kind === "tool_call") { - return ; - } - if (msg.role === "user") { - return ( - - {i > 0 && } - - - - ); - } - return ; - })} + {currentTurn ? ( + <> + - {pendingPermission && ( - - )} + + 0 + ? queuedMessages.length + : 0) + } + scrollOffset={scrollOffset} + loading={isLatest && loading} + status={status} + spinIdx={spinIdx} + pendingPermission={isLatest ? pendingPermission : null} + permissionIdx={permissionIdx} + /> - {loading && !pendingPermission && messages.length > 0 && ( - - )} - + {isLatest && + queuedMessages.map((text, i) => ( + + ))} + + + ) : ( + + )} + + {isViewingHistory && ( + + + + + turn {effectiveTurnIdx + 1}/{turns.length} + + — shift+↓ to return + + + )} - {!loading && !pendingPermission && !initialPrompt && ( + {!isViewingHistory && !pendingPermission && !initialPrompt && ( 0} + scrollHint={turns.length > 1} /> )}