diff --git a/ui/text/src/markdown.tsx b/ui/text/src/markdown.tsx index 23453336..63b16a53 100644 --- a/ui/text/src/markdown.tsx +++ b/ui/text/src/markdown.tsx @@ -1,10 +1,20 @@ -import { marked } from "marked"; +import { Marked } from "marked"; import { markedTerminal } from "marked-terminal"; -marked.use(markedTerminal({ width: 76, reflowText: true, tab: 2 }) as any); +let renderer: Marked | null = null; +let rendererWidth = 0; -export function renderMarkdown(src: string): string { - if (!src) return ""; - const rendered = marked.parse(src) as string; - return rendered.replace(/\n+$/, ""); +function getRenderer(width: number): Marked { + if (renderer && rendererWidth === width) return renderer; + renderer = new Marked(); + renderer.use(markedTerminal({ width, reflowText: true, tab: 2 }) as any); + rendererWidth = width; + return renderer; +} + +export function renderMarkdown(src: string, width = 76): string[] { + if (!src) return []; + const m = getRenderer(width); + const rendered = (m.parse(src) as string).replace(/\n+$/, ""); + return rendered.split("\n"); } diff --git a/ui/text/src/toolcall.tsx b/ui/text/src/toolcall.tsx index 6eb3a473..f3ef65e3 100644 --- a/ui/text/src/toolcall.tsx +++ b/ui/text/src/toolcall.tsx @@ -40,51 +40,48 @@ const STATUS_INDICATORS: Record = { failed: { icon: "✗", color: CRANBERRY }, }; -function formatJsonCompact(value: unknown, maxWidth: number): string[] { - if (value === undefined || value === null) return []; - let raw: string; - try { - raw = JSON.stringify(value, null, 2); - } catch { - raw = String(value); - } - const lines = raw.split("\n"); - const result: string[] = []; - for (const line of lines) { - if (line.length <= maxWidth) { - result.push(line); - } else { - let remaining = line; - while (remaining.length > maxWidth) { - result.push(remaining.slice(0, maxWidth)); - remaining = remaining.slice(maxWidth); - } - if (remaining) result.push(remaining); - } - } - return result; +function truncateLine(line: string, maxWidth: number): string { + if (line.length <= maxWidth) return line; + return maxWidth > 1 ? line.slice(0, maxWidth - 1) + "…" : line.slice(0, maxWidth); } -function extractTextFromContent(content: ToolCallContent[]): string[] { +function formatJsonLines(value: unknown, maxWidth: number): string[] { + if (value === undefined || value === null) return []; + let raw: string; + if (typeof value === "string") { + raw = value; + } else { + try { + raw = JSON.stringify(value, null, 2); + } catch { + raw = String(value); + } + } + return raw.split("\n").map((line) => truncateLine(line, maxWidth)); +} + +function extractTextLines(content: ToolCallContent[], maxWidth: number): string[] { const lines: string[] = []; for (const item of content) { if (item.type === "content" && item.content) { const block = item.content as any; if (block.type === "text" && block.text) { - lines.push(...block.text.split("\n")); + for (const line of block.text.split("\n")) { + lines.push(truncateLine(line, maxWidth)); + } } } else if (item.type === "diff") { const diff = item as any; - lines.push(`diff: ${diff.path || "unknown"}`); + lines.push(truncateLine(`diff: ${diff.path || "unknown"}`, maxWidth)); } else if (item.type === "terminal") { const term = item as any; - lines.push(`terminal: ${term.terminalId || "unknown"}`); + lines.push(truncateLine(`terminal: ${term.terminalId || "unknown"}`, maxWidth)); } } return lines; } -function summarizeContent(info: ToolCallInfo): string { +function summarizeContent(info: ToolCallInfo, maxWidth: number): string { const parts: string[] = []; if (info.locations && info.locations.length > 0) { @@ -94,7 +91,7 @@ function summarizeContent(info: ToolCallInfo): string { } if (info.content && info.content.length > 0) { - const textLines = extractTextFromContent(info.content); + const textLines = extractTextLines(info.content, maxWidth); if (textLines.length > 0) { const first = textLines[0]!.trim(); if (first.length > 60) { @@ -117,129 +114,102 @@ function summarizeContent(info: ToolCallInfo): string { } } - return parts.join(" · "); + return truncateLine(parts.join(" · "), maxWidth); } -export function findFeaturedToolCallId( - toolCallOrder: string[], - toolCalls: Map, -): string | undefined { - for (let i = toolCallOrder.length - 1; i >= 0; i--) { - const tc = toolCalls.get(toolCallOrder[i]!); - if (tc && (tc.status === "pending" || tc.status === "in_progress")) { - return toolCallOrder[i]!; - } - } - return toolCallOrder[toolCallOrder.length - 1]; -} - -interface ToolCallProps { - info: ToolCallInfo; - width: number; - expanded: boolean; - showTabHint: boolean; - keyPrefix: string; -} - -export function ToolCallCard({ - info, - width, - expanded, - showTabHint, - keyPrefix, -}: ToolCallProps): React.ReactNode[] { +export function renderToolCallLines( + info: ToolCallInfo, + width: number, + expanded: boolean, + showTabHint: boolean, +): React.ReactElement[] { const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙"; const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!; const borderColor = info.status === "failed" ? CRANBERRY : CEDAR; const dimBorder = info.status !== "failed"; - const hasInput = info.rawInput !== undefined && info.rawInput !== null; - const hasOutput = info.rawOutput !== undefined && info.rawOutput !== null; - const hasContent = info.content && info.content.length > 0; - const hasLocations = info.locations && info.locations.length > 0; + const innerWidth = Math.max(width - 4, 10); + const indentedWidth = Math.max(innerWidth - 2, 8); - const contentWidth = width - 4; + const lines: React.ReactElement[] = []; + const k = info.toolCallId; - const lines: React.ReactNode[] = []; - const content: React.ReactNode[] = []; - - // Header - const runningText = info.status === "in_progress" ? " running…" : ""; - content.push( - - {statusInfo.icon} - - {kindIcon} - - {info.title} - {runningText && {runningText}} - - {showTabHint && !expanded && tab ↔} - + const hRule = "─".repeat(Math.max(width - 2, 0)); + lines.push( + + ╭{hRule}╮ + , ); - // Compact view - show summary - if (!expanded) { - const summary = summarizeContent(info); - if (summary) { - content.push( - - {summary} + const row = (key: string, content: React.ReactNode) => { + lines.push( + + + + {content} - ); + + , + ); + }; + + const statusIcon = statusInfo.icon; + const runningText = info.status === "in_progress" ? " running…" : ""; + const tabHintText = showTabHint && !expanded ? "tab ↔" : ""; + const fixedLen = 4 + runningText.length + tabHintText.length; // icon+space+kind+space + suffix + hint + const titleMax = Math.max(innerWidth - fixedLen, 4); + const title = truncateLine(info.title, titleMax); + + row(`${k}-h`, ( + <> + {statusIcon} + {kindIcon} + {title} + {runningText ? {runningText} : null} + + {tabHintText ? {tabHintText} : null} + + )); + + if (!expanded) { + const summary = summarizeContent(info, innerWidth); + if (summary) { + row(`${k}-s`, {summary}); } } else { - // Expanded view - show all details - const inputLines = hasInput ? formatJsonCompact(info.rawInput, contentWidth - 6) : []; - const outputLines = hasOutput ? formatJsonCompact(info.rawOutput, contentWidth - 6) : []; - const contentLines = hasContent ? extractTextFromContent(info.content!) : []; - - if (hasLocations) { - for (let i = 0; i < info.locations!.length; i++) { - const loc = info.locations![i]!; - content.push( - - 📁 {loc.path}{loc.line ? `:${loc.line}` : ""} - - ); + if (info.locations) { + for (let i = 0; i < info.locations.length; i++) { + const loc = info.locations[i]!; + const t = truncateLine(`📁 ${loc.path}${loc.line ? `:${loc.line}` : ""}`, innerWidth); + row(`${k}-l${i}`, {t}); } } - const addSection = (label: string, sectionLines: string[]) => { - if (sectionLines.length === 0) return; - - content.push( - - ▸ {label}: - - ); - - for (let i = 0; i < sectionLines.length; i++) { - content.push( - - {sectionLines[i]} - - ); + const section = (label: string, sLines: string[]) => { + if (sLines.length === 0) return; + row(`${k}-${label}H`, ▸ {label}:); + for (let i = 0; i < sLines.length; i++) { + row(`${k}-${label}${i}`, ( + {" "}{sLines[i]} + )); } }; - addSection("input", inputLines); - addSection("output", outputLines); - addSection("content", contentLines); + if (info.rawInput !== undefined && info.rawInput !== null) { + section("in", formatJsonLines(info.rawInput, indentedWidth)); + } + if (info.rawOutput !== undefined && info.rawOutput !== null) { + section("out", formatJsonLines(info.rawOutput, indentedWidth)); + } + if (info.content && info.content.length > 0) { + section("ct", extractTextLines(info.content, indentedWidth)); + } } lines.push( - - {content} - + + ╰{hRule}╯ + , ); return lines; diff --git a/ui/text/src/tui.tsx b/ui/text/src/tui.tsx index 6f0407f4..a07a87bb 100644 --- a/ui/text/src/tui.tsx +++ b/ui/text/src/tui.tsx @@ -1,7 +1,6 @@ #!/usr/bin/env node import React, { useState, useEffect, useCallback, useRef } from "react"; -import { Box, Text, render, useApp, useInput, useStdout, measureElement } from "ink"; -import type { DOMElement } from "ink"; +import { Box, Text, render, useApp, useInput, useStdout } from "ink"; import TextInput from "ink-text-input"; import meow from "meow"; import { spawn } from "node:child_process"; @@ -13,19 +12,15 @@ import type { SessionNotification, RequestPermissionRequest, RequestPermissionResponse, - ToolCallContent, - ToolCallStatus, - ToolKind, Stream, ContentChunk, ToolCall, ToolCallUpdate, - SessionUpdate, } from "@agentclientprotocol/sdk"; import { ndJsonStream } from "@agentclientprotocol/sdk"; import { GooseClient } from "@aaif/goose-acp"; import { renderMarkdown } from "./markdown.js"; -import { ToolCallCard } from "./toolcall.js"; +import { renderToolCallLines } from "./toolcall.js"; import type { ToolCallInfo } from "./toolcall.js"; import { CRANBERRY, TEAL, GOLD, TEXT_PRIMARY, TEXT_SECONDARY, TEXT_DIM, RULE_COLOR } from "./colors.js"; @@ -42,7 +37,7 @@ type ResponseItem = interface Turn { userText: string; responseItems: ResponseItem[]; - toolCallsById: Map; // maps toolCallId to index in responseItems + toolCallsById: Map; } function isErrorStatus(status: string): boolean { @@ -155,29 +150,24 @@ function Header({ hasPendingPermission: boolean; turnInfo?: { current: number; total: number }; }) { - const statusColor = status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM; + const statusColor = + status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM; return ( - + - - goose - + goose · {status} {loading && !hasPendingPermission && ( - - {" "} - - + )} {turnInfo && turnInfo.total > 1 && ( - {turnInfo.current}/{turnInfo.total} - {" "} + {turnInfo.current}/{turnInfo.total}{" "} )} ^C exit @@ -188,87 +178,6 @@ function Header({ ); } -function UserPrompt({ text }: { text: string }) { - return ( - - - {"❯ "} - - - {text} - - - ); -} - -function PermissionDialog({ - toolTitle, - options, - selectedIdx, - width, -}: { - toolTitle: string; - options: Array<{ optionId: string; name: string; kind: string }>; - selectedIdx: number; - width: number; -}) { - const dialogWidth = Math.min(width - 2, 58); - return ( - - - 🔒 Permission required - - - {toolTitle} - - - {options.map((opt, i) => { - const key = PERMISSION_KEYS[opt.kind] ?? String(i + 1); - const label = PERMISSION_LABELS[opt.kind] ?? opt.name; - const active = i === selectedIdx; - return ( - - - {active ? " ▸ " : " "} - - - [{key}] {label} - - - ); - })} - - - ↑↓ select · enter confirm · esc cancel - - - ); -} - -function QueuedMessage({ text }: { text: string }) { - return ( - - - {text} - - {" "} - (queued) - - - ); -} - function InputBar({ width, input, @@ -293,12 +202,11 @@ function InputBar({ borderColor={RULE_COLOR} paddingX={1} width={width} + flexShrink={0} > - - {"❯ "} - + {"❯ "} ; +} + +function buildPermissionLines( + perm: PendingPermission, + selectedIdx: number, + fullWidth: number, +): React.ReactElement[] { + const dialogWidth = Math.min(fullWidth - 2, 58); + const innerWidth = Math.max(dialogWidth - 4, 10); + const hRule = "─".repeat(Math.max(dialogWidth - 2, 0)); + const lines: React.ReactElement[] = []; + + lines.push(emptyLine("pm-gap", fullWidth)); + + lines.push( + + ╭{hRule}╮ + , + ); + + const row = (key: string, content: React.ReactNode) => { + lines.push( + + + {content} + + , + ); + }; + + row("pm-title", 🔒 Permission required); + row("pm-g1", ); + row("pm-tool", {perm.toolTitle}); + row("pm-g2", ); + + for (let i = 0; i < perm.options.length; i++) { + const opt = perm.options[i]!; + const k = PERMISSION_KEYS[opt.kind] ?? String(i + 1); + const label = PERMISSION_LABELS[opt.kind] ?? opt.name; + const active = i === selectedIdx; + row(`pm-o${i}`, ( + <> + {active ? "▸ " : " "} + + [{k}] {label} + + + )); + } + + row("pm-g3", ); + row("pm-help", ↑↓ select · enter confirm · esc cancel); + + lines.push( + + ╰{hRule}╯ + , + ); + + return lines; +} + +function buildContentLines({ turn, width, loading, @@ -328,8 +300,9 @@ function buildTurnBodyLines({ pendingPermission, permissionIdx, toolCallsExpanded, + queuedMessages, }: { - turn: Turn; + turn: Turn | undefined; width: number; loading: boolean; status: string; @@ -337,18 +310,27 @@ function buildTurnBodyLines({ pendingPermission: PendingPermission | null; permissionIdx: number; toolCallsExpanded: boolean; -}): React.ReactNode[] { - const lines: React.ReactNode[] = []; - const hasToolCalls = turn.responseItems.some(item => item.itemType === "tool_call"); + queuedMessages: string[]; +}): React.ReactElement[] { + const lines: React.ReactElement[] = []; + if (!turn) return lines; - let toolCallIndex = 0; - let textChunkIndex = 0; + // User prompt + lines.push(emptyLine("u-gap", width)); + lines.push( + + {"❯ "} + {turn.userText} + , + ); + + // Response items + const hasToolCalls = turn.responseItems.some((it) => it.itemType === "tool_call"); + let tcIdx = 0; for (let i = 0; i < turn.responseItems.length; i++) { const item = turn.responseItems[i]!; - lines.push( ); - if (item.itemType === "tool_call") { const info: ToolCallInfo = { toolCallId: item.toolCallId, @@ -360,119 +342,115 @@ function buildTurnBodyLines({ content: item.content, locations: item.locations, }; - - const toolCallLines = ToolCallCard({ - info, - width, - expanded: toolCallsExpanded, - showTabHint: toolCallIndex === 0 && hasToolCalls, - keyPrefix: `tc-${item.toolCallId}`, - }); - lines.push(...toolCallLines); - toolCallIndex++; - } else if (item.itemType === "content_chunk" && item.content.type === "text") { - const text = item.content.text; - if (text) { - const rendered = renderMarkdown(text); - const mdLines = rendered.split("\n"); - for (let j = 0; j < mdLines.length; j++) { - lines.push( - - {mdLines[j]} - , - ); - } - textChunkIndex++; + lines.push(emptyLine(`tc-gap-${i}`, width)); + lines.push( + ...renderToolCallLines(info, width, toolCallsExpanded, tcIdx === 0 && hasToolCalls), + ); + tcIdx++; + } else if ( + item.itemType === "content_chunk" && + item.content.type === "text" && + item.content.text + ) { + const mdLines = renderMarkdown(item.content.text, width); + lines.push(emptyLine(`md-gap-${i}`, width)); + for (let j = 0; j < mdLines.length; j++) { + lines.push( + + {mdLines[j]} + , + ); } } } + // Loading indicator if (loading && !pendingPermission) { - lines.push( ); + lines.push(emptyLine("ld-gap", width)); lines.push( - + - - {" "} - {status} - + {status} , ); } + // Permission dialog if (pendingPermission) { - lines.push( ); + lines.push(...buildPermissionLines(pendingPermission, permissionIdx, width)); + } + + // Queued messages + for (let i = 0; i < queuedMessages.length; i++) { lines.push( - , + + {"❯ "} + {queuedMessages[i]} + (queued) + , ); } return lines; } -function ScrollableBody({ +function Viewport({ lines, + height, width, scrollOffset, }: { - lines: React.ReactNode[]; + lines: React.ReactElement[]; + height: number; width: number; scrollOffset: number; }) { - const ref = useRef(null); - const [measured, setMeasured] = useState(0); - - useEffect(() => { - if (ref.current) { - const { height } = measureElement(ref.current); - if (height !== measured) setMeasured(height); - } - }); - const total = lines.length; - const availableHeight = measured || total; - const needsScroll = total > availableHeight; - const viewSize = needsScroll - ? Math.max(availableHeight - 2, 1) - : availableHeight; - const maxOffset = Math.max(total - viewSize, 0); - const clampedOffset = Math.min(Math.max(scrollOffset, 0), maxOffset); - const endIdx = total - clampedOffset; - const startIdx = Math.max(endIdx - viewSize, 0); + const overflows = total > height; + + const contentHeight = overflows ? Math.max(height - 2, 1) : height; + + const maxEnd = total; + const minEnd = Math.min(contentHeight, total); + const endIdx = Math.max(minEnd, Math.min(maxEnd - scrollOffset, maxEnd)); + const startIdx = Math.max(0, endIdx - contentHeight); + const visible = lines.slice(startIdx, endIdx); - const hiddenAbove = startIdx; - const hiddenBelow = Math.max(total - endIdx, 0); + const padCount = contentHeight - visible.length; + + const elements: React.ReactElement[] = []; + + if (overflows) { + const above = startIdx; + elements.push( + + {above > 0 + ? ▲ {above} more (↑) + : } + , + ); + } + + for (let i = 0; i < padCount; i++) { + elements.push(emptyLine(`vp-${i}`, width)); + } + elements.push(...visible); + + if (overflows) { + const below = total - endIdx; + elements.push( + + {below > 0 + ? ▼ {below} more (↓) + : } + , + ); + } return ( - - {needsScroll && ( - - {hiddenAbove > 0 ? ( - ▲ {hiddenAbove} more (↑) - ) : ( - - )} - - )} - - {visible} - - {needsScroll && ( - - {hiddenBelow > 0 ? ( - ▼ {hiddenBelow} more (↓) - ) : ( - - )} - - )} + + {elements} ); } @@ -491,7 +469,8 @@ function SplashScreen({ spinIdx: number; }) { const frame = GOOSE_FRAMES[animFrame % GOOSE_FRAMES.length]!; - const statusColor = status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM; + const statusColor = + status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM; return ( {frame.map((line, i) => ( - - {line} - + {line} ))} - - - goose - + goose your on-machine AI agent - {loading && } {status} @@ -581,98 +554,72 @@ function App({ if (prev.length === 0) return prev; const last = { ...prev[prev.length - 1]! }; const newItems = [...last.responseItems]; - - // If last item is a content chunk with text, append to it; otherwise create new content chunk - if (newItems.length > 0 && newItems[newItems.length - 1]!.itemType === "content_chunk") { - const lastItem = newItems[newItems.length - 1] as ContentChunk & { itemType: "content_chunk" }; + + if ( + newItems.length > 0 && + newItems[newItems.length - 1]!.itemType === "content_chunk" + ) { + const lastItem = newItems[newItems.length - 1] as ContentChunk & { + itemType: "content_chunk"; + }; if (lastItem.content.type === "text") { newItems[newItems.length - 1] = { ...lastItem, - content: { - ...lastItem.content, - text: lastItem.content.text + text, - }, + content: { ...lastItem.content, text: lastItem.content.text + text }, }; } else { - // Last item is not text, create new content chunk - newItems.push({ - itemType: "content_chunk", - content: { type: "text", text }, - }); + newItems.push({ itemType: "content_chunk", content: { type: "text", text } }); } } else { - // No items or last item is tool call, create new content chunk - newItems.push({ - itemType: "content_chunk", - content: { type: "text", text }, - }); + newItems.push({ itemType: "content_chunk", content: { type: "text", text } }); } - + return [...prev.slice(0, -1), { ...last, responseItems: newItems }]; }); }, []); - const handleToolCall = useCallback( - (tc: ToolCall) => { - setTurns((prev) => { - if (prev.length === 0) return prev; - const last = { ...prev[prev.length - 1]! }; - - const newItems = [...last.responseItems]; - const newById = new Map(last.toolCallsById); - - // Add new tool call to the array - const index = newItems.length; - newItems.push({ ...tc, itemType: "tool_call" }); - newById.set(tc.toolCallId, index); - - return [ - ...prev.slice(0, -1), - { ...last, responseItems: newItems, toolCallsById: newById }, - ]; - }); - }, - [], - ); + const handleToolCall = useCallback((tc: ToolCall) => { + setTurns((prev) => { + if (prev.length === 0) return prev; + const last = { ...prev[prev.length - 1]! }; + const newItems = [...last.responseItems]; + const newById = new Map(last.toolCallsById); + const index = newItems.length; + newItems.push({ ...tc, itemType: "tool_call" }); + newById.set(tc.toolCallId, index); + return [ + ...prev.slice(0, -1), + { ...last, responseItems: newItems, toolCallsById: newById }, + ]; + }); + }, []); - const handleToolCallUpdate = useCallback( - (update: ToolCallUpdate) => { - setTurns((prev) => { - if (prev.length === 0) return prev; - const last = { ...prev[prev.length - 1]! }; - - const index = last.toolCallsById.get(update.toolCallId); - if (index === undefined) return prev; - - const item = last.responseItems[index]; - if (!item || item.itemType !== "tool_call") return prev; - - const updated: ToolCall & { itemType: "tool_call" } = { ...item }; - if (update.title != null) updated.title = update.title; - if (update.status != null) updated.status = update.status; - if (update.kind != null) updated.kind = update.kind; - if (update.rawInput !== undefined) updated.rawInput = update.rawInput; - if (update.rawOutput !== undefined) updated.rawOutput = update.rawOutput; - if (update.content != null) updated.content = update.content; - if (update.locations != null) updated.locations = update.locations; - - const newItems = [...last.responseItems]; - newItems[index] = updated; - - return [...prev.slice(0, -1), { ...last, responseItems: newItems }]; - }); - }, - [], - ); + const handleToolCallUpdate = useCallback((update: ToolCallUpdate) => { + setTurns((prev) => { + if (prev.length === 0) return prev; + const last = { ...prev[prev.length - 1]! }; + const index = last.toolCallsById.get(update.toolCallId); + if (index === undefined) return prev; + const item = last.responseItems[index]; + if (!item || item.itemType !== "tool_call") return prev; + const updated: ToolCall & { itemType: "tool_call" } = { ...item }; + if (update.title != null) updated.title = update.title; + if (update.status != null) updated.status = update.status; + if (update.kind != null) updated.kind = update.kind; + if (update.rawInput !== undefined) updated.rawInput = update.rawInput; + if (update.rawOutput !== undefined) updated.rawOutput = update.rawOutput; + if (update.content != null) updated.content = update.content; + if (update.locations != null) updated.locations = update.locations; + const newItems = [...last.responseItems]; + newItems[index] = updated; + return [...prev.slice(0, -1), { ...last, responseItems: newItems }]; + }); + }, []); const addUserTurn = useCallback((text: string) => { setTurns((prev) => [ ...prev, - { - userText: text, - responseItems: [], - toolCallsById: new Map(), - }, + { userText: text, responseItems: [], toolCallsById: new Map() }, ]); setViewTurnIdx(-1); setToolCallsExpanded(false); @@ -686,9 +633,7 @@ function App({ if (option === "cancelled") { resolve({ outcome: { outcome: "cancelled" } }); } else { - resolve({ - outcome: { outcome: "selected", optionId: option.optionId }, - }); + resolve({ outcome: { outcome: "selected", optionId: option.optionId } }); } setPendingPermission(null); setPermissionIdx(0); @@ -712,17 +657,14 @@ function App({ sessionId: sid, prompt: [{ type: "text", text }], }); - 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}`); + setStatus(`error: ${e instanceof Error ? e.message : String(e)}`); } finally { setLoading(false); } @@ -733,13 +675,11 @@ function App({ const processQueue = useCallback(async () => { if (isProcessingRef.current) return; isProcessingRef.current = true; - while (queueRef.current.length > 0) { const next = queueRef.current.shift()!; setQueuedMessages([...queueRef.current]); await executePrompt(next); } - isProcessingRef.current = false; }, [executePrompt]); @@ -762,7 +702,6 @@ function App({ () => ({ sessionUpdate: async (params: SessionNotification) => { const update = params.update; - if (update.sessionUpdate === "agent_message_chunk") { if (update.content.type === "text") { streamBuf.current += update.content.text; @@ -778,13 +717,15 @@ function App({ params: RequestPermissionRequest, ): Promise => { return new Promise((resolve) => { - const toolTitle = params.toolCall.title ?? "unknown tool"; - const options = params.options.map((opt) => ({ - optionId: opt.optionId, - name: opt.name, - kind: opt.kind, - })); - setPendingPermission({ toolTitle, options, resolve }); + setPendingPermission({ + toolTitle: params.toolCall.title ?? "unknown tool", + options: params.options.map((o) => ({ + optionId: o.optionId, + name: o.name, + kind: o.kind, + })), + resolve, + }); setPermissionIdx(0); }); }, @@ -801,7 +742,6 @@ function App({ clientInfo: { name: "goose-text", version: "0.1.0" }, clientCapabilities: {}, }); - if (cancelled) return; setStatus("creating session…"); @@ -809,8 +749,8 @@ function App({ cwd: process.cwd(), mcpServers: [], }); - if (cancelled) return; + sessionIdRef.current = session.sessionId; setLoading(false); setStatus("ready"); @@ -822,23 +762,15 @@ function App({ } } catch (e: unknown) { if (cancelled) return; - const errMsg = e instanceof Error ? e.message : String(e); - setStatus(`failed: ${errMsg}`); + setStatus(`failed: ${e instanceof Error ? e.message : String(e)}`); setLoading(false); } })(); - return () => { - cancelled = true; - }; + return () => { cancelled = true; }; }, [ - serverConnection, - initialPrompt, - sendPrompt, - appendAgent, - handleToolCall, - handleToolCallUpdate, - exit, + serverConnection, initialPrompt, sendPrompt, + appendAgent, handleToolCall, handleToolCallUpdate, exit, ]); const handleSubmit = useCallback( @@ -862,88 +794,68 @@ function App({ useInput((ch, key) => { if (key.escape || (ch === "c" && key.ctrl)) { - if (pendingPermission) { - resolvePermission("cancelled"); - return; - } + if (pendingPermission) { resolvePermission("cancelled"); return; } exit(); } if (pendingPermission) { const opts = pendingPermission.options; - - if (key.upArrow) { - setPermissionIdx((i) => (i - 1 + opts.length) % opts.length); - return; - } - if (key.downArrow) { - setPermissionIdx((i) => (i + 1) % opts.length); - return; - } + if (key.upArrow) { setPermissionIdx((i) => (i - 1 + opts.length) % opts.length); return; } + if (key.downArrow) { setPermissionIdx((i) => (i + 1) % opts.length); return; } if (key.return) { - const selected = opts[permissionIdx]; - if (selected) resolvePermission({ optionId: selected.optionId }); + const sel = opts[permissionIdx]; + if (sel) resolvePermission({ optionId: sel.optionId }); return; } - const keyMap: Record = { - y: "allow_once", - a: "allow_always", - n: "reject_once", - N: "reject_always", + y: "allow_once", a: "allow_always", n: "reject_once", N: "reject_always", }; - const targetKind = keyMap[ch]; - if (targetKind) { - const match = opts.find((o) => o.kind === targetKind); - if (match) resolvePermission({ optionId: match.optionId }); + const kind = keyMap[ch]; + if (kind) { + const m = opts.find((o) => o.kind === kind); + if (m) resolvePermission({ optionId: m.optionId }); } return; } if (key.tab) { - const effectiveIdx = - viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx; - const currentTurn = turns[effectiveIdx]; - if (!currentTurn) return; - - // Check if there are any tool calls in the response items - const hasToolCalls = currentTurn.responseItems.some(item => item.itemType === "tool_call"); - if (!hasToolCalls) return; - - setToolCallsExpanded((prev) => !prev); + const idx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx; + const t = turns[idx]; + if (t && t.responseItems.some((it) => it.itemType === "tool_call")) { + setToolCallsExpanded((prev) => !prev); + } return; } - if (key.upArrow && !key.shift && !key.meta) { + if (key.upArrow && !key.shift) { setScrollOffset((prev) => prev + 3); return; } - if (key.downArrow && !key.shift && !key.meta) { + if (key.downArrow && !key.shift) { setScrollOffset((prev) => Math.max(prev - 3, 0)); return; } if (key.upArrow && key.shift) { - setTurns((currentTurns) => { - if (currentTurns.length <= 1) return currentTurns; + setTurns((cur) => { + if (cur.length <= 1) return cur; setViewTurnIdx((prev) => { - const effectiveIdx = - prev === -1 ? currentTurns.length - 1 : prev; - return Math.max(effectiveIdx - 1, 0); + const eff = prev === -1 ? cur.length - 1 : prev; + return Math.max(eff - 1, 0); }); - return currentTurns; + return cur; }); return; } if (key.downArrow && key.shift) { - setTurns((currentTurns) => { - if (currentTurns.length <= 1) return currentTurns; + setTurns((cur) => { + if (cur.length <= 1) return cur; setViewTurnIdx((prev) => { if (prev === -1) return -1; const next = prev + 1; - return next >= currentTurns.length ? -1 : next; + return next >= cur.length ? -1 : next; }); - return currentTurns; + return cur; }); return; } @@ -953,21 +865,22 @@ function App({ const PAD_Y = 1; const contentWidth = Math.max(termWidth - PAD_X * 2, 20); - const effectiveTurnIdx = - viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx; + const effectiveTurnIdx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx; const currentTurn = turns[effectiveTurnIdx]; - const isViewingHistory = - viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1; + const isViewingHistory = viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1; const isLatest = !isViewingHistory; + const showInputBar = !pendingPermission && !initialPrompt && !isViewingHistory; - const emptyTurn: Turn = { - userText: "", - responseItems: [], - toolCallsById: new Map(), - }; + const headerH = 2; + const inputBarH = showInputBar ? (queuedMessages.length > 0 ? 4 : 3) : 0; + const historyBarH = isViewingHistory ? 2 : 0; + const viewportHeight = Math.max( + termHeight - PAD_Y * 2 - headerH - inputBarH - historyBarH, + 3, + ); - const responseLines = buildTurnBodyLines({ - turn: currentTurn ?? emptyTurn, + const contentLines = buildContentLines({ + turn: currentTurn, width: contentWidth, loading: isLatest && loading, status, @@ -975,24 +888,9 @@ function App({ pendingPermission: isLatest ? pendingPermission : null, permissionIdx, toolCallsExpanded, + queuedMessages: isLatest ? queuedMessages : [], }); - const scrollLines: React.ReactNode[] = []; - if (currentTurn) { - scrollLines.push( ); - scrollLines.push(); - scrollLines.push(...responseLines); - } - if (isLatest) { - scrollLines.push( - ...queuedMessages.map((text, i) => ( - - )), - ); - } - - const showInputBar = !pendingPermission && !initialPrompt && !isViewingHistory; - return ( - + {isViewingHistory && ( - + @@ -1076,12 +977,10 @@ const cli = meow( function findServerBinary(): string | null { const __dirname = dirname(fileURLToPath(import.meta.url)); - const candidates = [ join(__dirname, "..", "server-binary.json"), join(__dirname, "server-binary.json"), ]; - for (const candidate of candidates) { try { const data = JSON.parse(readFileSync(candidate, "utf-8")); @@ -1090,7 +989,6 @@ function findServerBinary(): string | null { // not found here, try next } } - return null; } @@ -1140,18 +1038,11 @@ function cleanup() { } process.on("exit", cleanup); -process.on("SIGINT", () => { - cleanup(); - process.exit(0); -}); -process.on("SIGTERM", () => { - cleanup(); - process.exit(0); -}); +process.on("SIGINT", () => { cleanup(); process.exit(0); }); +process.on("SIGTERM", () => { cleanup(); process.exit(0); }); main().catch((err) => { console.error(err); cleanup(); process.exit(1); }); -