From 7368f74e7449f22c1d3678934312af66512490c0 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Fri, 27 Mar 2026 15:45:12 -0400 Subject: [PATCH] feat(tui): UI improvements for messages, tool calls, text entry, etc (#8156) --- ui/text/src/toolcall.tsx | 232 ++++++++++++------------------- ui/text/src/tui.tsx | 293 ++++++++++++++++----------------------- 2 files changed, 210 insertions(+), 315 deletions(-) diff --git a/ui/text/src/toolcall.tsx b/ui/text/src/toolcall.tsx index 38defdc5..6eb3a473 100644 --- a/ui/text/src/toolcall.tsx +++ b/ui/text/src/toolcall.tsx @@ -120,8 +120,6 @@ function summarizeContent(info: ToolCallInfo): string { return parts.join(" ยท "); } -const MAX_PREVIEW_LINES = 8; - export function findFeaturedToolCallId( toolCallOrder: string[], toolCalls: Map, @@ -135,16 +133,21 @@ export function findFeaturedToolCallId( return toolCallOrder[toolCallOrder.length - 1]; } -export function buildToolCallCardLines( - info: ToolCallInfo, - indent: number, - totalWidth: number, - expanded: boolean, - keyPrefix: string = "card", -): React.ReactNode[] { - const cardWidth = Math.min(totalWidth - indent - 2, 72); - const innerWidth = cardWidth - 2; - const contentWidth = innerWidth - 2; +interface ToolCallProps { + info: ToolCallInfo; + width: number; + expanded: boolean; + showTabHint: boolean; + keyPrefix: string; +} + +export function ToolCallCard({ + info, + width, + expanded, + showTabHint, + keyPrefix, +}: ToolCallProps): React.ReactNode[] { const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "โš™"; const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!; const borderColor = info.status === "failed" ? CRANBERRY : CEDAR; @@ -155,150 +158,89 @@ export function buildToolCallCardLines( const hasContent = info.content && info.content.length > 0; const hasLocations = info.locations && info.locations.length > 0; - const inputLines = hasInput ? formatJsonCompact(info.rawInput, contentWidth - 6) : []; - const outputLines = hasOutput ? formatJsonCompact(info.rawOutput, contentWidth - 6) : []; - const contentLines = hasContent ? extractTextFromContent(info.content!) : []; + const contentWidth = width - 4; - const shownInput = expanded ? inputLines : inputLines.slice(0, MAX_PREVIEW_LINES); - const shownOutput = expanded ? outputLines : outputLines.slice(0, MAX_PREVIEW_LINES); - const shownContent = expanded ? contentLines : contentLines.slice(0, MAX_PREVIEW_LINES); - - const hasTruncated = - inputLines.length > MAX_PREVIEW_LINES || - outputLines.length > MAX_PREVIEW_LINES || - contentLines.length > MAX_PREVIEW_LINES; - - const bodyRows: Array<{ text: string; color?: string; italic?: boolean }> = []; + const lines: React.ReactNode[] = []; + const content: React.ReactNode[] = []; + // Header const runningText = info.status === "in_progress" ? " runningโ€ฆ" : ""; - bodyRows.push({ text: "__HEADER__" }); - - if (hasLocations) { - for (const loc of info.locations!) { - bodyRows.push({ text: ` ๐Ÿ“ ${loc.path}${loc.line ? `:${loc.line}` : ""}`, color: TEXT_DIM }); - } - } - - function addSection(label: string, lines: string[], totalCount: number) { - if (lines.length === 0) return; - bodyRows.push({ text: ` โ–ธ ${label}:`, color: TEXT_DIM }); - for (const line of lines) { - bodyRows.push({ text: ` ${line}`, color: TEXT_DIM }); - } - if (!expanded && totalCount > MAX_PREVIEW_LINES) { - const remaining = totalCount - MAX_PREVIEW_LINES; - bodyRows.push({ text: ` โ–ธ ${remaining} more lines (tab to expand)`, color: GOLD, italic: true }); - } - } - - addSection("input", shownInput, inputLines.length); - addSection("output", shownOutput, outputLines.length); - addSection("content", shownContent, contentLines.length); - - const result: React.ReactNode[] = []; - const topBorder = "โ•ญ" + "โ”€".repeat(innerWidth) + "โ•ฎ"; - const botBorder = "โ•ฐ" + "โ”€".repeat(innerWidth) + "โ•ฏ"; - - result.push( - - {topBorder} - , + content.push( + + {statusInfo.icon} + + {kindIcon} + + {info.title} + {runningText && {runningText}} + + {showTabHint && !expanded && tab โ†”} + ); - for (let i = 0; i < bodyRows.length; i++) { - const row = bodyRows[i]!; - - if (row.text === "__HEADER__") { - result.push( - - โ”‚ - - - {statusInfo.icon} - {kindIcon} - {info.title} - {runningText ? {runningText} : null} - - - โ”‚ - , + // Compact view - show summary + if (!expanded) { + const summary = summarizeContent(info); + if (summary) { + content.push( + + {summary} + ); - continue; + } + } 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}` : ""} + + ); + } } - result.push( - - โ”‚ - - {row.text} + 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]} + + ); + } + }; + + addSection("input", inputLines); + addSection("output", outputLines); + addSection("content", contentLines); } - result.push( - - {botBorder} - , + lines.push( + + {content} + ); - return result; -} - -export function ToolCallCompact({ - info, - indent, - width, - keyPrefix, - showTabHint, -}: { - info: ToolCallInfo; - indent: number; - width: number; - keyPrefix: string; - showTabHint: boolean; -}): React.ReactNode[] { - const statusInfo = STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!; - const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "โš™"; - const summary = summarizeContent(info); - const borderColor = info.status === "failed" ? CRANBERRY : CEDAR; - const dimBorder = info.status !== "failed"; - - const cardWidth = Math.min(width - indent - 2, 72); - const innerWidth = cardWidth - 2; - - const tabHintText = "tab โ†”"; - const maxSummaryWidth = innerWidth - info.title.length - 8 - (showTabHint ? tabHintText.length + 2 : 0); - const trimmedSummary = - summary.length > maxSummaryWidth && maxSummaryWidth > 3 - ? summary.slice(0, maxSummaryWidth - 1) + "โ€ฆ" - : summary; - - const topBorder = "โ•ญ" + "โ”€".repeat(innerWidth) + "โ•ฎ"; - const botBorder = "โ•ฐ" + "โ”€".repeat(innerWidth) + "โ•ฏ"; - - return [ - - {topBorder} - , - - โ”‚ - - - {statusInfo.icon} - {kindIcon} - {info.title} - {trimmedSummary ? ( - โ€” {trimmedSummary} - ) : null} - - {showTabHint && {tabHintText}} - - โ”‚ - , - - {botBorder} - , - ]; + return lines; } diff --git a/ui/text/src/tui.tsx b/ui/text/src/tui.tsx index 94fbe2b1..6f0407f4 100644 --- a/ui/text/src/tui.tsx +++ b/ui/text/src/tui.tsx @@ -25,7 +25,7 @@ import type { import { ndJsonStream } from "@agentclientprotocol/sdk"; import { GooseClient } from "@aaif/goose-acp"; import { renderMarkdown } from "./markdown.js"; -import { buildToolCallCardLines, ToolCallCompact, findFeaturedToolCallId } from "./toolcall.js"; +import { ToolCallCard } from "./toolcall.js"; import type { ToolCallInfo } from "./toolcall.js"; import { CRANBERRY, TEAL, GOLD, TEXT_PRIMARY, TEXT_SECONDARY, TEXT_DIM, RULE_COLOR } from "./colors.js"; @@ -128,9 +128,6 @@ const PERMISSION_KEYS: Record = { reject_always: "N", }; -const INDENT = 3; -const CONTENT_INDENT = 5; - function Rule({ width }: { width: number }) { return {"โ”€".repeat(Math.max(width, 1))}; } @@ -193,7 +190,7 @@ function Header({ function UserPrompt({ text }: { text: string }) { return ( - + {"โฏ "} @@ -215,11 +212,10 @@ function PermissionDialog({ selectedIdx: number; width: number; }) { - const dialogWidth = Math.min(width - CONTENT_INDENT - 2, 58); + const dialogWidth = Math.min(width - 2, 58); return ( + โฏ {text} @@ -280,6 +276,7 @@ function InputBar({ onSubmit, queued, scrollHint, + placeholder, }: { width: number; input: string; @@ -287,33 +284,37 @@ function InputBar({ onSubmit: (v: string) => void; queued: boolean; scrollHint: boolean; + placeholder?: string; }) { return ( - - - - - - {"โฏ "} - - - - {scrollHint && shift+โ†‘โ†“ history} + + + + + {"โฏ "} + + - {queued && ( - - - message queued โ€” will send when goose finishes - - - )} + {scrollHint && shift+โ†‘โ†“ history} + {queued && ( + + + message queued โ€” will send when goose finishes + + + )} ); } @@ -338,18 +339,17 @@ function buildTurnBodyLines({ toolCallsExpanded: boolean; }): React.ReactNode[] { const lines: React.ReactNode[] = []; - - lines.push(); + const hasToolCalls = turn.responseItems.some(item => item.itemType === "tool_call"); let toolCallIndex = 0; let textChunkIndex = 0; - // Render items in the order they arrived for (let i = 0; i < turn.responseItems.length; i++) { const item = turn.responseItems[i]!; + lines.push( ); + if (item.itemType === "tool_call") { - const tcId = item.toolCallId; const info: ToolCallInfo = { toolCallId: item.toolCallId, title: item.title, @@ -361,42 +361,36 @@ function buildTurnBodyLines({ locations: item.locations, }; - if (toolCallsExpanded) { - const cardLines = buildToolCallCardLines(info, CONTENT_INDENT, width, true, `tc-${tcId}`); - lines.push(...cardLines); - } else { - const compactLines = ToolCallCompact({ - info, - indent: CONTENT_INDENT, - width, - keyPrefix: `tc-${tcId}`, - showTabHint: toolCallIndex === 0, - }); - lines.push(...compactLines); - } + 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") { - if (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++; + } 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++; } } } if (loading && !pendingPermission) { + lines.push( ); lines.push( - + {" "} @@ -407,6 +401,7 @@ function buildTurnBodyLines({ } if (pendingPermission) { + lines.push( ); lines.push( void; - onInputSubmit: (v: string) => void; }) { const frame = GOOSE_FRAMES[animFrame % GOOSE_FRAMES.length]!; const statusColor = status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM; - const inputWidth = Math.min(56, width - 8); return ( {frame.map((line, i) => ( @@ -532,33 +516,10 @@ function SplashScreen({ your on-machine AI agent - {showInput ? ( - - - - - - - {"โฏ "} - - - - - - - - ) : ( - - {loading && } - {status} - - )} + + {loading && } + {status} + ); } @@ -988,27 +949,9 @@ function App({ } }); - const GUTTER = 2; - const innerWidth = Math.max(termWidth - GUTTER * 2, 20); - - if (bannerVisible) { - return ( - - - - ); - } + const PAD_X = 2; + const PAD_Y = 1; + const contentWidth = Math.max(termWidth - PAD_X * 2, 20); const effectiveTurnIdx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx; @@ -1023,9 +966,9 @@ function App({ toolCallsById: new Map(), }; - const bodyLines = buildTurnBodyLines({ + const responseLines = buildTurnBodyLines({ turn: currentTurn ?? emptyTurn, - width: innerWidth, + width: contentWidth, loading: isLatest && loading, status, spinIdx, @@ -1034,69 +977,79 @@ function App({ toolCallsExpanded, }); - const allBodyLines = isLatest - ? [ - ...bodyLines, - ...queuedMessages.map((text, i) => ( - - )), - ] - : bodyLines; + 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 ( -
1 - ? { current: effectiveTurnIdx + 1, total: turns.length } - : undefined - } - /> - - {currentTurn ? ( + {bannerVisible ? ( + + ) : ( <> - - +
1 + ? { current: effectiveTurnIdx + 1, total: turns.length } + : undefined + } + /> + {isViewingHistory && ( + + + + + turn {effectiveTurnIdx + 1}/{turns.length} + + โ€” shift+โ†“ to return + + + )} - ) : ( - )} - - {isViewingHistory && ( - - - - - turn {effectiveTurnIdx + 1}/{turns.length} - - โ€” shift+โ†“ to return - - - )} - - {!isViewingHistory && !pendingPermission && !initialPrompt && ( + {showInputBar && ( 0} - scrollHint={turns.length > 1} + scrollHint={!bannerVisible && turns.length > 1} + placeholder={bannerVisible ? INITIAL_GREETING : undefined} /> )}