Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions crates/goose/src/acp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1208,15 +1208,17 @@ impl GooseAcpAgent {
.map(|a| serde_json::Value::Object(a.clone()));
let fallback_title = summarize_tool_call(&tool_name, args_value.as_ref());

let mut initial_tool_call = ToolCall::new(
ToolCallId::new(tool_request.id.clone()),
fallback_title.clone(),
)
.status(ToolCallStatus::Pending);
if let Some(args) = args_value.clone() {
initial_tool_call = initial_tool_call.raw_input(args);
}
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::ToolCall(
ToolCall::new(
ToolCallId::new(tool_request.id.clone()),
fallback_title.clone(),
)
.status(ToolCallStatus::Pending),
),
SessionUpdate::ToolCall(initial_tool_call),
))?;

if let Ok(tool_call) = &tool_request.tool_call {
Expand Down
8 changes: 3 additions & 5 deletions ui/text/src/components/ContentRenderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ export function renderToolCallItem(
item: ResponseItem & { itemType: "tool_call" },
index: number,
width: number,
toolCallsExpanded: boolean,
isFirst: boolean,
hasToolCalls: boolean
selected: boolean,
): React.ReactElement[] {
const info: ToolCallInfo = {
toolCallId: item.toolCallId,
Expand All @@ -47,10 +45,10 @@ export function renderToolCallItem(
content: item.content,
locations: item.locations,
};

return [
emptyLine(`tc-gap-${index}`, width),
...renderToolCallLines(info, width, toolCallsExpanded, isFirst && hasToolCalls),
...renderToolCallLines(info, width, selected),
];
}

Expand Down
4 changes: 1 addition & 3 deletions ui/text/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ interface HeaderProps {
status: string;
loading: boolean;
spinIdx: number;
hasPendingPermission: boolean;
turnInfo?: { current: number; total: number };
}

Expand All @@ -19,7 +18,6 @@ export const Header = React.memo(function Header({
status,
loading,
spinIdx,
hasPendingPermission,
turnInfo,
}: HeaderProps) {
const statusColor =
Expand All @@ -38,7 +36,7 @@ export const Header = React.memo(function Header({
<Box flexShrink={1}>
<Text color={statusColor} wrap="truncate-end">{status}</Text>
</Box>
{loading && !hasPendingPermission && (
{loading && (
<Text> <Spinner idx={spinIdx} /></Text>
)}
</Box>
Expand Down
273 changes: 273 additions & 0 deletions ui/text/src/components/ToolCallExpanded.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import React, { useMemo } from "react";
import { Box, Text, useInput } from "ink";
import type { ToolCallContent } from "@agentclientprotocol/sdk";
import {
formatJson,
type ToolCallInfo,
} from "../toolcall.js";
import {
CRANBERRY,
TEAL,
GOLD,
TEXT_PRIMARY,
TEXT_SECONDARY,
TEXT_DIM,
} from "../colors.js";
import { SCROLL_STEP, SCROLL_FAST_MULTIPLIER } from "../constants.js";

interface Props {
info: ToolCallInfo;
width: number;
height: number;
scrollOffset: number;
onScroll: (updater: (prev: number) => number) => void;
onClose: () => void;
}

const STATUS_COLORS: Record<string, string> = {
pending: TEXT_DIM,
in_progress: GOLD,
completed: TEAL,
failed: CRANBERRY,
};

function wrapOrTruncate(text: string, width: number): string[] {
const safeWidth = Math.max(width, 10);
const out: string[] = [];
for (const rawLine of text.split("\n")) {
if (rawLine.length <= safeWidth) {
out.push(rawLine);
continue;
}
let remaining = rawLine;
while (remaining.length > safeWidth) {
out.push(remaining.slice(0, safeWidth));
remaining = remaining.slice(safeWidth);
}
if (remaining.length > 0) out.push(remaining);
}
return out;
}

function extractContentText(content: ToolCallContent[] | undefined): string {
if (!content || content.length === 0) return "";
const parts: string[] = [];
for (const item of content) {
if (item.type === "content") {
const block = item.content;
if (block.type === "text" && block.text) {
parts.push(block.text);
} else if (block.type === "resource_link") {
parts.push(`🔗 ${block.uri}`);
} else if (block.type === "image") {
parts.push(`🖼 image (${block.mimeType ?? "unknown"})`);
} else if (block.type === "audio") {
parts.push(`🎵 audio (${block.mimeType ?? "unknown"})`);
} else if (block.type === "resource") {
const res = block.resource as { uri?: string; text?: string };
if (res.text) {
parts.push(res.text);
} else if (res.uri) {
parts.push(`📎 ${res.uri}`);
}
}
} else if (item.type === "diff") {
const header = `📝 diff: ${item.path}`;
const old = item.oldText ?? "";
parts.push(
[
header,
...(old ? old.split("\n").map((l) => `- ${l}`) : []),
...item.newText.split("\n").map((l) => `+ ${l}`),
].join("\n"),
);
} else if (item.type === "terminal") {
parts.push(`▶ terminal: ${item.terminalId}`);
}
}
return parts.join("\n\n");
}

function buildBody(
info: ToolCallInfo,
contentWidth: number,
): React.ReactElement[] {
const body: React.ReactElement[] = [];

const pushLabel = (label: string, keyPrefix: string, withTopGap: boolean) => {
if (withTopGap) {
body.push(
<Box key={`${keyPrefix}-gap`} height={1}>
<Text> </Text>
</Box>,
);
}
body.push(
<Box key={`${keyPrefix}-hdr`} height={1}>
<Text color={TEXT_SECONDARY} bold>
{label}
Comment on lines +106 to +108
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Truncate expanded tool title in fixed-height row

This label is rendered inside a fixed height={1} row but uses default Ink wrapping. If info.title exceeds the available width, Ink wraps to extra lines and does not clip overflow for fixed-height boxes, which can break the expanded card layout. Render this row with truncation (and/or pre-truncate to width) to keep the height budget stable.

Useful? React with 👍 / 👎.

</Text>
</Box>,
);
};

const pushText = (
text: string,
keyPrefix: string,
emptyHint: string,
) => {
if (!text) {
body.push(
<Box key={`${keyPrefix}-empty`} height={1}>
<Text color={TEXT_DIM} italic>
{emptyHint}
</Text>
</Box>,
);
return;
}
const lines = wrapOrTruncate(text, contentWidth);
lines.forEach((l, i) => {
body.push(
<Box key={`${keyPrefix}-${i}`} height={1}>
<Text color={TEXT_PRIMARY}>{l || " "}</Text>
</Box>,
);
});
};

pushLabel(info.title, "tool", false);

pushLabel("arguments", "in", true);
const argsText = formatJson(info.rawInput);
pushText(argsText, "in", "(no arguments)");

pushLabel("result", "out", true);
let resultText = formatJson(info.rawOutput);
if (!resultText) {
resultText = extractContentText(info.content);
}
const resultEmptyHint =
info.status === "in_progress"
? "(running…)"
: info.status === "pending"
? "(pending)"
: info.status === "failed"
? "(failed — no output)"
: "(no output)";
pushText(resultText, "out", resultEmptyHint);

return body;
}

export function ToolCallExpanded({
info,
width,
height,
scrollOffset,
onScroll,
onClose,
}: Props) {
const safeWidth = Math.max(width, 20);
const safeHeight = Math.max(height, 5);
const contentWidth = Math.max(safeWidth - 4, 10);

const allLines = useMemo(
() => buildBody(info, contentWidth),
[info, contentWidth],
);

useInput((ch, key) => {
if (key.escape || ch === " ") {
onClose();
return;
}
if (key.upArrow || key.downArrow) {
const step = key.meta
? SCROLL_STEP * SCROLL_FAST_MULTIPLIER
: SCROLL_STEP;
if (key.upArrow) {
onScroll((prev) => prev + step);
} else {
onScroll((prev) => Math.max(prev - step, 0));
}
}
});

const headerH = 2;
const footerH = 2;
const bodyHeight = Math.max(safeHeight - headerH - footerH, 1);

const total = allLines.length;
const overflows = total > bodyHeight;
const contentHeight = overflows ? Math.max(bodyHeight - 2, 1) : bodyHeight;

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 = allLines.slice(startIdx, endIdx);
const padCount = contentHeight - visible.length;

const elements: React.ReactElement[] = [];
if (overflows) {
const above = startIdx;
elements.push(
<Box key="exp-up" width={safeWidth} height={1} justifyContent="center">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Constrain expanded panel filler rows to content width

The expanded body container is rendered at contentWidth, but this row uses width={safeWidth} (same pattern also appears in the pad/down rows), so overflow-state chrome is wider than its parent. In Ink, overflow is not clipped, so when the expanded tool output is scrollable (or padded), these rows can bleed into the card border/adjacent cells and break the fixed-height layout. Use contentWidth for these rows to keep the panel within its width budget.

Useful? React with 👍 / 👎.

{above > 0 ? (
<Text color={TEXT_DIM}>▲ {above} more (↑)</Text>
) : (
<Text> </Text>
)}
</Box>,
);
}
for (let i = 0; i < padCount; i++) {
elements.push(
<Box key={`exp-pad-${i}`} width={safeWidth} height={1}>
<Text> </Text>
</Box>,
);
}
elements.push(...visible);
if (overflows) {
const below = total - endIdx;
elements.push(
<Box key="exp-dn" width={safeWidth} height={1} justifyContent="center">
{below > 0 ? (
<Text color={TEXT_DIM}>▼ {below} more (↓)</Text>
) : (
<Text> </Text>
)}
</Box>,
);
}

const statusColor = STATUS_COLORS[info.status] ?? TEXT_DIM;

return (
<Box
flexDirection="column"
width={safeWidth}
height={safeHeight}
borderStyle="round"
borderColor={GOLD}
paddingX={1}
>
<Box width={contentWidth} height={1}>
<Text color={statusColor}>●</Text>
<Text color={TEXT_DIM}> {info.status}</Text>
<Box flexGrow={1} />
<Text color={TEXT_DIM} italic>
space/esc to close
</Text>
</Box>
<Box flexDirection="column" width={contentWidth} height={bodyHeight}>
{elements}
</Box>
<Box width={contentWidth} height={1}>
<Text color={TEXT_DIM}>↑↓ scroll · ⌥↑↓ fast</Text>
</Box>
</Box>
);
}
18 changes: 4 additions & 14 deletions ui/text/src/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export const PASTE_PREVIEW_LEN = 40;
export const INPUT_MAX_ROWS = 8;
export const SENT_PREVIEW_LEN = 60;

// Viewport scroll step (lines per arrow press). Option/Alt applies the multiplier.
export const SCROLL_STEP = 3;
export const SCROLL_FAST_MULTIPLIER = 10;

export const GOOSE_FRAMES = [
[
" ,_",
Expand Down Expand Up @@ -66,17 +70,3 @@ export const GREETING_MESSAGES = [

export const INITIAL_GREETING =
GREETING_MESSAGES[Math.floor(Math.random() * GREETING_MESSAGES.length)]!;

export const PERMISSION_LABELS: Record<string, string> = {
allow_once: "Allow once",
allow_always: "Always allow",
reject_once: "Reject once",
reject_always: "Always reject",
};

export const PERMISSION_KEYS: Record<string, string> = {
allow_once: "y",
allow_always: "a",
reject_once: "n",
reject_always: "r",
};
Loading
Loading