` but with `display: flex`)
-- Supports Flexbox properties, padding, margin, borders
-- Use for all layout and positioning
-
-### 3. Static Component
-- For content that doesn't change after rendering
-- Useful for logs, completed tasks, permanent output
-- Renders above dynamic content
-
-```jsx
-
- {task => (
-
- ✓ {task.name}
-
- )}
-
-```
-
-### 4. Spacer Component
-- Flexible space that expands along the major axis
-- Useful for pushing content to edges
-
-```jsx
-
- Left
-
- Right
-
-```
-
-## Input and Interaction
-
-### Keyboard Input
-```jsx
-import {useInput} from 'ink';
-
-const InteractiveComponent = () => {
- useInput((input, key) => {
- if (input === 'q') {
- process.exit(0);
- }
-
- if (key.upArrow) {
- // Handle up arrow
- }
-
- if (key.return) {
- // Handle enter key
- }
- });
-
- return
Press 'q' to quit;
-};
-```
-
-### Focus Management
-```jsx
-import {useFocus} from 'ink';
-
-const FocusableComponent = () => {
- const {isFocused} = useFocus();
-
- return (
-
- {isFocused ? '> ' : ' '}Focusable item
-
- );
-};
-```
-
-## Performance Considerations
-
-### 1. Minimize Re-renders
-Terminal rendering is expensive - avoid unnecessary updates:
-
-```jsx
-// Use React.memo for stable components
-const StatusLine = React.memo(({status}) => (
-
Status: {status}
-));
-
-// Debounce rapid updates
-const [debouncedValue] = useDebounce(rapidlyChangingValue, 100);
-```
-
-### 2. Animation Considerations
-```jsx
-import {useAnimation} from 'ink';
-
-const Spinner = () => {
- const {frame} = useAnimation({interval: 80}); // Not too fast
- const chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
-
- return
{chars[frame % chars.length]};
-};
-```
-
-### 3. Control Frame Rate
-```jsx
-// Limit updates for better performance
-render(
, {
- maxFps: 30, // Default is 30, lower for less CPU usage
-});
-```
-
-## Common Pitfalls and Solutions
-
-### 1. Text Overflow
-❌ **Problem:** Text exceeds terminal width
-```jsx
-
Very long text that might overflow the terminal width causing display issues
-```
-
-✅ **Solution:** Use width constraints and wrapping
-```jsx
-
- Very long text that might overflow the terminal width causing display issues
-
-```
-
-### 2. Nested Box Issues
-❌ **Problem:** Unnecessary nesting causing layout issues
-```jsx
-
-
-
- Over-nested content
-
-
-
-```
-
-✅ **Solution:** Flatten structure when possible
-```jsx
-
- Properly structured content
-
-```
-
-### 3. Color and Styling
-❌ **Problem:** Assuming rich styling support
-```jsx
-
Styled text
-```
-
-✅ **Solution:** Use Ink's supported styling props
-```jsx
-
Styled text
-```
-
-### 4. Dynamic Content Height
-❌ **Problem:** Unlimited dynamic content
-```jsx
-{messages.map(msg => (
-
{msg.content}
-))}
-```
-
-✅ **Solution:** Implement scrolling or pagination
-```jsx
-const visibleMessages = messages.slice(-maxVisible);
-return (
-
- {visibleMessages.map(msg => (
- {msg.content}
- ))}
-
-);
-```
-
-## Testing Terminal UIs
-
-### 1. Use ink-testing-library
-```jsx
-import {render} from 'ink-testing-library';
-
-const {lastFrame, stdin} = render(
);
-
-// Test output
-expect(lastFrame()).toMatch(/Expected text/);
-
-// Test input
-stdin.write('q');
-expect(lastFrame()).toMatch(/Quit message/);
-```
-
-### 2. Test Different Terminal Sizes
-```jsx
-// Test with different widths
-const {lastFrame} = render(
, {columns: 40});
-expect(lastFrame()).toMatch(/Wrapped content/);
-```
-
-## Accessibility Considerations
-
-### Screen Reader Support
-```jsx
-// Provide meaningful labels
-
- Accept terms
-
-
-// Use descriptive labels for progress indicators
-
-
- 50%
-
-```
-
-## Best Practices Summary
-
-1. **Always constrain content width** - Use `width` props or percentage widths
-2. **Handle text wrapping explicitly** - Set appropriate `wrap` values
-3. **Consider terminal size** - Use `useWindowSize()` for responsive layouts
-4. **Minimize vertical content** - Implement pagination for long lists
-5. **Use semantic structure** - Proper component hierarchy with `
` and ``
-6. **Test with different terminal sizes** - Ensure layouts work across screen sizes
-7. **Optimize for performance** - Avoid unnecessary re-renders and high frame rates
-8. **Provide keyboard navigation** - Implement proper focus management
-9. **Consider accessibility** - Use ARIA labels where appropriate
-10. **Handle edge cases** - Empty states, loading states, error conditions
-
-## Example: Well-Structured Ink Component
-
-```jsx
-import React, {useState} from 'react';
-import {Box, Text, useInput, useWindowSize, Spacer} from 'ink';
-
-const TaskList = ({tasks}) => {
- const [selectedIndex, setSelectedIndex] = useState(0);
- const {columns} = useWindowSize();
-
- useInput((input, key) => {
- if (key.upArrow && selectedIndex > 0) {
- setSelectedIndex(selectedIndex - 1);
- }
- if (key.downArrow && selectedIndex < tasks.length - 1) {
- setSelectedIndex(selectedIndex + 1);
- }
- });
-
- const maxWidth = Math.min(columns - 4, 80);
-
- return (
-
-
- Task List ({tasks.length})
-
-
-
- {tasks.map((task, index) => (
-
-
- {task.completed ? '✓' : '○'}
-
-
-
- {task.title}
-
-
- {task.priority}
-
- ))}
-
-
-
- Use ↑↓ to navigate
-
-
- );
-};
-```
-
-This example demonstrates:
-- Proper width constraints and responsive design
-- Keyboard input handling
-- Appropriate use of Ink components
-- Text truncation for overflow handling
-- Clear visual hierarchy and spacing
-- Accessibility considerations with clear navigation hints
\ No newline at end of file
diff --git a/ui/text/CLAUDE.md b/ui/text/CLAUDE.md
deleted file mode 100644
index 43c994c2d..000000000
--- a/ui/text/CLAUDE.md
+++ /dev/null
@@ -1 +0,0 @@
-@AGENTS.md
diff --git a/ui/text/README.md b/ui/text/README.md
index f08e8b644..46b3f7571 100644
--- a/ui/text/README.md
+++ b/ui/text/README.md
@@ -1,41 +1,17 @@
-# goose ACP TUI
+# goose ACP TUI — Deprecated
-Early stage and part of goose's broader move to ACP
+> [!WARNING]
+> **This project is deprecated and no longer maintained.**
+>
+> The experimental terminal UI (TUI) that lived here, published to npm as
+> [`@aaif/goose`](https://www.npmjs.com/package/@aaif/goose), is no longer being
+> developed. The source has been removed and the npm package has been marked as
+> deprecated.
-https://github.com/aaif-goose/goose/issues/6642
-https://github.com/aaif-goose/goose/discussions/7309
+## What to use instead
-## Running
+- **CLI** — use the `goose` command line interface (`crates/goose-cli`).
+- **Desktop app** — use the goose desktop application (`ui/desktop`).
+- Any ACP based TUI from https://agentclientprotocol.com/get-started/clients#cli-and-tui
-The TUI launches the goose ACP server by spawning `goose acp`. Which binary it spawns is resolved by `@aaif/goose-sdk`:
-
-1. the `GOOSE_BINARY` environment variable, if set, otherwise
-2. the platform's prebuilt `@aaif/goose-binary-*` package (an optional dependency of the pinned `@aaif/goose-sdk`).
-
-```bash
-cd ui/text
-pnpm install # pulls the pinned @aaif/goose-sdk and its matching @aaif/goose-binary-* package
-pnpm start # tsx src/tui.tsx — runs against the released binary, no Rust build
-```
-
-The TUI pins a specific `@aaif/goose-sdk` version, so `pnpm start` always runs against a goose binary that matches the SDK.
-
-### Building goose from local source
-
-To test local Rust changes, run the dev launcher directly. It builds a debug binary (`cargo build -p goose-cli` → `target/debug/goose`) from the workspace root and points the TUI at it via `GOOSE_BINARY`:
-
-```bash
-node scripts/dev-start.mjs
-```
-
-If your changes touch the ACP schema, also point the TUI at the in-repo SDK so the two stay matched: set `@aaif/goose-sdk` to `workspace:*` in `package.json` and re-run `pnpm install`. Otherwise the locally built binary may not match the pinned published SDK's schema. Revert that change before committing — the TUI is meant to stay frozen on its pinned SDK version.
-
-To run any other prebuilt binary, set `GOOSE_BINARY=/path/to/goose` and use `pnpm start`.
-
-### Custom server URL
-
-To connect to an already-running server instead of spawning a binary:
-
-```bash
-pnpm start -- --server http://localhost:8080
-```
+All of these options are actively maintained and receive ongoing feature work.
diff --git a/ui/text/package.json b/ui/text/package.json
deleted file mode 100644
index 560a3d2d4..000000000
--- a/ui/text/package.json
+++ /dev/null
@@ -1,51 +0,0 @@
-{
- "name": "@aaif/goose",
- "version": "0.20.1",
- "description": "Goose - an open-source AI agent",
- "license": "Apache-2.0",
- "repository": {
- "type": "git",
- "url": "git+https://github.com/aaif-goose/goose.git"
- },
- "keywords": [
- "goose",
- "ai",
- "agent",
- "cli",
- "terminal"
- ],
- "type": "module",
- "bin": {
- "goose-tui": "dist/tui.js"
- },
- "files": [
- "dist"
- ],
- "scripts": {
- "build": "tsc",
- "start": "tsx src/tui.tsx",
- "lint": "tsc --noEmit"
- },
- "dependencies": {
- "@aaif/goose-sdk": "0.20.2",
- "@agentclientprotocol/sdk": "^0.19.0",
- "@inkjs/ui": "^2.0.0",
- "ink": "^6.8.0",
- "ink-multiline-input": "^0.1.0",
- "marked": "^15.0.12",
- "marked-terminal": "^7.3.0",
- "meow": "^13.2.0",
- "react": "^19.2.4"
- },
- "overrides": {
- "react": "^19.2.4"
- },
- "devDependencies": {
- "@types/marked-terminal": "^6.1.1",
- "@types/node": "^25.2.3",
- "@types/react": "^19.2.0",
- "esbuild": "^0.25.0",
- "tsx": "^4.19.0",
- "typescript": "^5.7.0"
- }
-}
diff --git a/ui/text/scripts/dev-start.mjs b/ui/text/scripts/dev-start.mjs
deleted file mode 100644
index 58d7e6e70..000000000
--- a/ui/text/scripts/dev-start.mjs
+++ /dev/null
@@ -1,44 +0,0 @@
-#!/usr/bin/env node
-
-// Development entrypoint: ensures a goose binary is available, then launches the TUI
-// Skips the cargo build if GOOSE_BINARY is already set or if --server is provided
-
-import { execFileSync } from "node:child_process";
-import { existsSync } from "node:fs";
-import { join, dirname } from "node:path";
-import { fileURLToPath } from "node:url";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const repoRoot = join(__dirname, "..", "..", "..");
-const args = process.argv.slice(2);
-const hasServerFlag = args.some(
- (arg) =>
- arg === "--server" ||
- arg === "-s" ||
- arg.startsWith("--server=") ||
- arg.startsWith("-s="),
-);
-
-if (!hasServerFlag && !process.env.GOOSE_BINARY) {
- const binName = process.platform === "win32" ? "goose.exe" : "goose";
- const binaryPath = join(repoRoot, "target", "debug", binName);
-
- console.log("Building goose (debug)…");
- execFileSync("cargo", ["build", "-p", "goose-cli"], {
- cwd: repoRoot,
- stdio: "inherit",
- });
-
- if (!existsSync(binaryPath)) {
- console.error(`Build succeeded but binary not found at ${binaryPath}`);
- process.exit(1);
- }
-
- process.env.GOOSE_BINARY = binaryPath;
-}
-
-execFileSync("tsx", [join(__dirname, "..", "src", "tui.tsx"), ...process.argv.slice(2)], {
- cwd: process.cwd(),
- stdio: "inherit",
- env: process.env,
-});
diff --git a/ui/text/src/colors.tsx b/ui/text/src/colors.tsx
deleted file mode 100644
index 4c5bcb319..000000000
--- a/ui/text/src/colors.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export const CRANBERRY = "#C0354A";
-export const TEAL = "#3A7D7B";
-export const GOLD = "#C4883A";
-export const TEXT_PRIMARY = "#E8E4DF";
-export const TEXT_SECONDARY = "#8FA4BD";
-export const TEXT_DIM = "#5A6D84";
-export const RULE_COLOR = "#2E3D54";
diff --git a/ui/text/src/components/ContentRenderers.tsx b/ui/text/src/components/ContentRenderers.tsx
deleted file mode 100644
index dbae9e7cb..000000000
--- a/ui/text/src/components/ContentRenderers.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import React from "react";
-import { Box, Text } from "ink";
-import { renderMarkdown } from "../markdown.js";
-import { renderToolCallLines } from "../toolcall.js";
-import type { ToolCallInfo } from "../toolcall.js";
-import type { ResponseItem } from "../types.js";
-import { CRANBERRY, TEXT_DIM, GOLD } from "../colors.js";
-import { Spinner } from "./Spinner.js";
-
-export function emptyLine(key: string, width: number): React.ReactElement {
- return ;
-}
-
-export function renderUserPrompt(
- userText: string,
- width: number,
- turnId: string,
- collapsedUserPrompt: (text: string, width: number) => React.ReactElement
-): React.ReactElement[] {
- const constrainedWidth = Math.max(width - 4, 10);
- return [
- emptyLine(`u-gap-${turnId}`, width),
-
- {"❯ "}
-
- {collapsedUserPrompt(userText, constrainedWidth)}
-
- ,
- ];
-}
-
-export function renderToolCallItem(
- item: ResponseItem & { itemType: "tool_call" },
- index: number,
- width: number,
- selected: boolean,
-): React.ReactElement[] {
- const info: ToolCallInfo = {
- toolCallId: item.toolCallId,
- title: item.title,
- status: item.status ?? "pending",
- kind: item.kind,
- rawInput: item.rawInput,
- rawOutput: item.rawOutput,
- content: item.content,
- locations: item.locations,
- };
-
- return [
- emptyLine(`tc-gap-${index}`, width),
- ...renderToolCallLines(info, width, selected),
- ];
-}
-
-export function renderErrorItem(
- item: ResponseItem & { itemType: "error" },
- index: number,
- width: number
-): React.ReactElement[] {
- const lines: React.ReactElement[] = [
- emptyLine(`err-gap-${index}`, width),
-
- {"⚠ Error: "}
- ,
- ];
-
- const errorLines = item.message.split("\n");
- errorLines.forEach((line, j) => {
- lines.push(
-
-
- {line}
-
-
- );
- });
-
- return lines;
-}
-
-export function renderContentItem(
- item: ResponseItem & { itemType: "content_chunk" },
- index: number,
- width: number
-): React.ReactElement[] {
- if (item.content.type !== "text" || !item.content.text) {
- return [];
- }
-
- const constrainedWidth = Math.max(width - 2, 10);
- const mdLines = renderMarkdown(item.content.text, constrainedWidth);
- const lines: React.ReactElement[] = [emptyLine(`md-gap-${index}`, width)];
-
- mdLines.forEach((mdLine, j) => {
- lines.push(
-
-
- {mdLine}
-
-
- );
- });
-
- return lines;
-}
-
-export function renderLoadingIndicator(
- status: string,
- spinIdx: number,
- width: number
-): React.ReactElement[] {
- return [
- emptyLine("ld-gap", width),
-
-
- {status}
- ,
- ];
-}
-
-export function renderQueuedMessages(
- queuedMessages: string[],
- width: number
-): React.ReactElement[] {
- const messageWidth = Math.max(width - 20, 10);
- return queuedMessages.map((message, i) => (
-
- {"❯ "}
-
- {message}
-
- (queued)
-
- ));
-}
diff --git a/ui/text/src/components/DiffViewer.tsx b/ui/text/src/components/DiffViewer.tsx
deleted file mode 100644
index 5f4619048..000000000
--- a/ui/text/src/components/DiffViewer.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-import React, { useEffect, useMemo, useState } from "react";
-import { Box, Text, useInput } from "ink";
-import {
- TEXT_DIM,
- TEXT_PRIMARY,
- GOLD,
- TEAL,
- CRANBERRY,
- TEXT_SECONDARY,
-} from "../colors.js";
-import { SCROLL_FAST_MULTIPLIER } from "../constants.js";
-
-const PAD_X = 2;
-const PAD_Y = 1;
-const HEADER_LINES = 1;
-const FOOTER_LINES = 1;
-
-type LineKind = "add" | "remove" | "hunk" | "meta" | "context";
-
-function classifyLine(line: string): LineKind {
- if (line.startsWith("+++") || line.startsWith("---")) return "meta";
- if (
- line.startsWith("diff ") ||
- line.startsWith("index ") ||
- line.startsWith("new file") ||
- line.startsWith("deleted file") ||
- line.startsWith("rename ") ||
- line.startsWith("similarity ") ||
- line.startsWith("Binary ")
- ) {
- return "meta";
- }
- if (line.startsWith("@@")) return "hunk";
- if (line.startsWith("+")) return "add";
- if (line.startsWith("-")) return "remove";
- return "context";
-}
-
-function padLine(line: string, width: number): string {
- if (line.length >= width) return line.slice(0, width);
- return line + " ".repeat(width - line.length);
-}
-
-interface Props {
- content: string;
- truncated: boolean;
- width: number;
- height: number;
- onClose: () => void;
-}
-
-export function DiffViewer({
- content,
- truncated,
- width,
- height,
- onClose,
-}: Props) {
- const lines = useMemo(() => {
- const split = content.split("\n");
- if (split.length > 0 && split[split.length - 1] === "") split.pop();
- return split;
- }, [content]);
-
- const innerWidth = Math.max(width - PAD_X * 2, 10);
- const innerHeight = Math.max(height - PAD_Y * 2, 3);
- const viewportHeight = Math.max(
- innerHeight - HEADER_LINES - FOOTER_LINES,
- 1,
- );
- const maxScroll = Math.max(lines.length - viewportHeight, 0);
-
- const [scroll, setScroll] = useState(0);
-
- useEffect(() => {
- setScroll((prev) => Math.min(prev, maxScroll));
- }, [maxScroll]);
-
- useInput((ch, key) => {
- if (ch === "q" || ch === "Q" || key.escape) {
- onClose();
- return;
- }
- if (key.ctrl && (ch === "c" || ch === "C")) {
- onClose();
- return;
- }
-
- if (key.downArrow || ch === "j") {
- const step = key.meta ? SCROLL_FAST_MULTIPLIER : 1;
- setScroll((s) => Math.min(s + step, maxScroll));
- return;
- }
- if (key.upArrow || ch === "k") {
- const step = key.meta ? SCROLL_FAST_MULTIPLIER : 1;
- setScroll((s) => Math.max(s - step, 0));
- return;
- }
- if (key.pageDown || ch === " " || (key.ctrl && ch === "d")) {
- setScroll((s) => Math.min(s + viewportHeight, maxScroll));
- return;
- }
- if (key.pageUp || ch === "b" || (key.ctrl && ch === "u")) {
- setScroll((s) => Math.max(s - viewportHeight, 0));
- return;
- }
- if (ch === "g") {
- setScroll(0);
- return;
- }
- if (ch === "G") {
- setScroll(maxScroll);
- return;
- }
- });
-
- const visible = lines.slice(scroll, scroll + viewportHeight);
-
- const atEnd = scroll >= maxScroll;
- const atStart = scroll === 0;
- const position = maxScroll === 0
- ? "ALL"
- : atEnd
- ? "END"
- : `${Math.round((scroll / maxScroll) * 100)}%`;
-
- return (
-
-
-
- git diff{truncated ? " (truncated)" : ""}
-
-
- {atStart ? "" : "↑ "}lines {scroll + 1}–
- {Math.min(scroll + viewportHeight, lines.length)} / {lines.length}
- {" "}[{position}]
-
-
-
- {visible.map((line, i) => {
- const kind = classifyLine(line);
- const padded = padLine(line, innerWidth);
- switch (kind) {
- case "add":
- return (
-
- {padded}
-
- );
- case "remove":
- return (
-
- {padded}
-
- );
- case "hunk":
- return (
-
- {padded}
-
- );
- case "meta":
- return (
-
- {padded}
-
- );
- default:
- return (
-
- {padded}
-
- );
- }
- })}
-
-
- q
- close ·
- ↑↓
- /
- j k
- scroll ·
- space
- /
- b
- page ·
- g
- /
- G
- top/bottom
-
-
- );
-}
diff --git a/ui/text/src/components/ErrorScreen.tsx b/ui/text/src/components/ErrorScreen.tsx
deleted file mode 100644
index d9cf961b8..000000000
--- a/ui/text/src/components/ErrorScreen.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import React from "react";
-import { Box, Text, useInput, useStdout } from "ink";
-import { CRANBERRY, TEXT_PRIMARY, TEXT_DIM } from "../colors.js";
-
-interface ErrorScreenProps {
- errorMsg: string;
- onRetry: () => void;
-}
-
-export const ErrorScreen = React.memo(function ErrorScreen({ errorMsg, onRetry }: ErrorScreenProps) {
- const { stdout } = useStdout();
- const columns = stdout?.columns ?? 80;
-
- useInput((ch, key) => {
- if (key.return || key.escape) {
- onRetry();
- }
- });
-
- const maxWidth = Math.min(columns - 4, 80);
-
- return (
-
- ✗ Setup error
- {errorMsg && (
-
- {errorMsg}
-
- )}
-
- press enter to retry
-
-
- );
-});
diff --git a/ui/text/src/components/Header.tsx b/ui/text/src/components/Header.tsx
deleted file mode 100644
index 70b80e9e1..000000000
--- a/ui/text/src/components/Header.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from "react";
-import { Box, Text } from "ink";
-import { Spinner } from "./Spinner.js";
-import { Rule } from "./Rule.js";
-import { TEAL, CRANBERRY, TEXT_PRIMARY, TEXT_DIM, RULE_COLOR } from "../colors.js";
-import { isErrorStatus } from "../utils.js";
-
-interface HeaderProps {
- width: number;
- status: string;
- loading: boolean;
- spinIdx: number;
- turnInfo?: { current: number; total: number };
-}
-
-export const Header = React.memo(function Header({
- width,
- status,
- loading,
- spinIdx,
- turnInfo,
-}: HeaderProps) {
- const statusColor =
- status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM;
-
- const constrainedWidth = Math.max(width, 20);
- const leftSideWidth = Math.min(Math.floor(constrainedWidth * 0.7), constrainedWidth - 15);
- const rightSideWidth = constrainedWidth - leftSideWidth;
-
- return (
-
-
-
- goose
- ·
-
- {status}
-
- {loading && (
-
- )}
-
-
- {turnInfo && turnInfo.total > 1 && (
-
- {turnInfo.current}/{turnInfo.total}{" "}
-
- )}
- ^E exts · ^M models · ^P providers
-
-
-
-
- );
-});
diff --git a/ui/text/src/components/Rule.tsx b/ui/text/src/components/Rule.tsx
deleted file mode 100644
index ad7313afd..000000000
--- a/ui/text/src/components/Rule.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from "react";
-import { Text } from "ink";
-import { RULE_COLOR } from "../colors.js";
-
-interface RuleProps {
- width: number;
-}
-
-export const Rule = React.memo(function Rule({ width }: RuleProps) {
- const ruleWidth = Math.max(width, 1);
- return {"─".repeat(ruleWidth)};
-});
diff --git a/ui/text/src/components/Spinner.tsx b/ui/text/src/components/Spinner.tsx
deleted file mode 100644
index b2708640d..000000000
--- a/ui/text/src/components/Spinner.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import React from "react";
-import { Text } from "ink";
-import { CRANBERRY } from "../colors.js";
-
-const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"];
-
-interface SpinnerProps {
- idx: number;
-}
-
-export const Spinner = React.memo(function Spinner({ idx }: SpinnerProps) {
- return (
-
- {SPINNER_FRAMES[idx % SPINNER_FRAMES.length]}
-
- );
-});
-
-export { SPINNER_FRAMES };
diff --git a/ui/text/src/components/ToolCallExpanded.tsx b/ui/text/src/components/ToolCallExpanded.tsx
deleted file mode 100644
index abf655c80..000000000
--- a/ui/text/src/components/ToolCallExpanded.tsx
+++ /dev/null
@@ -1,273 +0,0 @@
-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 = {
- 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(
-
-
- ,
- );
- }
- body.push(
-
-
- {label}
-
- ,
- );
- };
-
- const pushText = (
- text: string,
- keyPrefix: string,
- emptyHint: string,
- ) => {
- if (!text) {
- body.push(
-
-
- {emptyHint}
-
- ,
- );
- return;
- }
- const lines = wrapOrTruncate(text, contentWidth);
- lines.forEach((l, i) => {
- body.push(
-
- {l || " "}
- ,
- );
- });
- };
-
- 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(
-
- {above > 0 ? (
- ▲ {above} more (↑)
- ) : (
-
- )}
- ,
- );
- }
- for (let i = 0; i < padCount; i++) {
- elements.push(
-
-
- ,
- );
- }
- elements.push(...visible);
- if (overflows) {
- const below = total - endIdx;
- elements.push(
-
- {below > 0 ? (
- ▼ {below} more (↓)
- ) : (
-
- )}
- ,
- );
- }
-
- const statusColor = STATUS_COLORS[info.status] ?? TEXT_DIM;
-
- return (
-
-
- ●
- {info.status}
-
-
- space/esc to close
-
-
-
- {elements}
-
-
- ↑↓ scroll · ⌥↑↓ fast
-
-
- );
-}
diff --git a/ui/text/src/configure.tsx b/ui/text/src/configure.tsx
deleted file mode 100644
index ef889d81a..000000000
--- a/ui/text/src/configure.tsx
+++ /dev/null
@@ -1,577 +0,0 @@
-import React, { useState, useEffect, useCallback } from "react";
-import { Box, Text, useInput, useStdout } from "ink";
-import type { GooseClient, ProviderInventoryEntryDto } from "@aaif/goose-sdk";
-import {
- CRANBERRY,
- TEAL,
- GOLD,
- TEXT_PRIMARY,
- TEXT_DIM,
- RULE_COLOR,
-} from "./colors.js";
-import { Spinner, SPINNER_FRAMES } from "./components/Spinner.js";
-import { ErrorScreen } from "./components/ErrorScreen.js";
-import { ProviderSelector, ProviderConfigurator } from "./onboarding.js";
-
-const LOAD_MODELS_TIMEOUT_MS = 30000;
-
-type Phase =
- | "loading"
- | "select_provider"
- | "configure"
- | "loading_models"
- | "select_model"
- | "saving"
- | "error";
-
-export type ConfigureIntent = "provider" | "model";
-
-interface ConfigureProps {
- client: GooseClient;
- sessionId: string;
- width: number;
- height: number;
- onComplete: () => void;
- onCancel: () => void;
- initialIntent?: ConfigureIntent;
-}
-
-interface ModelSelectorProps {
- provider: ProviderInventoryEntryDto;
- height: number;
- onSelect: (model: string) => void;
- onBack: () => void;
-}
-
-const ModelSelector = React.memo(function ModelSelector({
- provider,
- height,
- onSelect,
- onBack,
-}: ModelSelectorProps) {
- const [loading, setLoading] = useState(true);
- const [models, setModels] = useState([]);
- const [selectedIdx, setSelectedIdx] = useState(0);
- const [searchQuery, setSearchQuery] = useState("");
- const [manualEntry, setManualEntry] = useState(false);
- const { stdout } = useStdout();
- const columns = stdout?.columns ?? 80;
-
- useEffect(() => {
- const availableModels = provider.models.map((model) => model.id);
- setModels(availableModels);
- const defaultIdx = availableModels.findIndex(
- (model) => model === provider.defaultModel,
- );
- setSelectedIdx(defaultIdx >= 0 ? defaultIdx : 0);
- setLoading(false);
- }, [provider.models, provider.defaultModel]);
-
- const filtered = (() => {
- if (!searchQuery) return models;
- const q = searchQuery.toLowerCase();
- return models.filter((m) => m.toLowerCase().includes(q));
- })();
-
- const maxWidth = Math.min(columns - 4, 80);
- const HEADER_HEIGHT = 2;
- const SEARCH_BOX_HEIGHT = 3;
- const FOOTER_HEIGHT = 3;
- const CHROME_HEIGHT = HEADER_HEIGHT + SEARCH_BOX_HEIGHT + FOOTER_HEIGHT + 4;
- const listHeight = Math.max(height - CHROME_HEIGHT, 3);
- const [scrollOffset, setScrollOffset] = useState(0);
-
- useEffect(() => {
- if (selectedIdx < scrollOffset) {
- setScrollOffset(selectedIdx);
- } else if (selectedIdx >= scrollOffset + listHeight) {
- setScrollOffset(selectedIdx - listHeight + 1);
- }
- }, [selectedIdx, scrollOffset, listHeight]);
-
- useInput((ch, key) => {
- if (key.escape) {
- if (manualEntry) {
- setManualEntry(false);
- setSearchQuery("");
- return;
- }
- if (searchQuery) {
- setSearchQuery("");
- setSelectedIdx(0);
- setScrollOffset(0);
- return;
- }
- onBack();
- return;
- }
- if (manualEntry) {
- if (key.return) {
- if (searchQuery.trim()) {
- onSelect(searchQuery.trim());
- }
- return;
- }
- if (key.backspace || key.delete) {
- setSearchQuery((q) => q.slice(0, -1));
- return;
- }
- if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
- setSearchQuery((q) => q + ch);
- }
- return;
- }
- if (key.upArrow) {
- setSelectedIdx((i) => Math.max(i - 1, 0));
- return;
- }
- if (key.downArrow) {
- setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1));
- return;
- }
- if (key.return) {
- const m = filtered[selectedIdx];
- if (m) onSelect(m);
- return;
- }
- if (key.backspace || key.delete) {
- setSearchQuery((q) => q.slice(0, -1));
- setSelectedIdx(0);
- setScrollOffset(0);
- return;
- }
- if (ch === "m" && !searchQuery) {
- setManualEntry(true);
- return;
- }
- if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
- setSearchQuery((q) => q + ch);
- setSelectedIdx(0);
- setScrollOffset(0);
- }
- });
-
- if (loading) {
- return (
-
-
-
-
- ◆ Select model ◆
-
-
-
-
- Loading models for {provider.providerName}…
-
-
-
-
-
-
- );
- }
-
- if (models.length === 0) {
- return (
-
-
-
-
- ◆ Select model ◆
-
-
-
- ⚠ No models available
-
-
-
-
- This provider does not currently expose any models in inventory.
-
-
-
-
- m manual entry · esc back
-
-
- );
- }
-
- if (manualEntry) {
- const inputWidth = Math.min(60, maxWidth - 4);
- const displayText = searchQuery || "type model name…";
- const truncatedText =
- displayText.length > inputWidth - 6
- ? displayText.slice(0, inputWidth - 9) + "…"
- : displayText;
-
- return (
-
-
-
-
- ◆ Enter model name ◆
-
-
-
-
- Type a model identifier for {provider.providerName}
-
-
-
-
-
-
- {"❯ "}
-
-
- {truncatedText}
-
-
-
-
-
- enter confirm · esc cancel
-
-
- );
- }
-
- const visible = filtered.slice(scrollOffset, scrollOffset + listHeight);
- const searchBoxWidth = Math.min(60, maxWidth - 4);
-
- return (
-
- {/* Header */}
-
-
-
- ◆ Select model ◆
-
-
-
- Choose a model for {provider.providerName}
-
-
- {/* Search Bar */}
-
-
-
- {"❯ "}
-
-
-
- {searchQuery || "search models…"}
-
-
-
-
-
- {/* Model List */}
-
- {filtered.length === 0 ? (
-
- No matching models
-
- ) : (
- <>
- {scrollOffset > 0 && (
-
- ▲ {scrollOffset} more above
-
- )}
-
-
- {visible.map((model, vi) => {
- const idx = vi + scrollOffset;
- const active = idx === selectedIdx;
- const isDefault = model === provider.defaultModel;
- const modelWidth = maxWidth - 8;
- const truncatedModel =
- model.length > modelWidth
- ? model.slice(0, modelWidth - 1) + "…"
- : model;
-
- return (
-
-
- {active ? "▸ " : " "}
-
-
- {truncatedModel}
-
- {isDefault && (default)}
-
- );
- })}
-
-
- {scrollOffset + listHeight < filtered.length && (
-
-
- ▼ {filtered.length - scrollOffset - listHeight} more below
-
-
- )}
- >
- )}
-
-
- {/* Footer */}
-
-
- ↑↓ navigate · enter select · m manual · esc back
-
-
-
- );
-});
-
-export default function ConfigureScreen({
- client,
- sessionId,
- width,
- height,
- onComplete,
- onCancel,
- initialIntent,
-}: ConfigureProps) {
- const [phase, setPhase] = useState("loading");
- const [providers, setProviders] = useState([]);
- const [selectedProvider, setSelectedProvider] =
- useState(null);
- const [errorMsg, setErrorMsg] = useState("");
- const [spinIdx, setSpinIdx] = useState(0);
- const [fetchKey, setFetchKey] = useState(0);
-
- useEffect(() => {
- const t = setInterval(
- () => setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length),
- 300,
- );
- return () => clearInterval(t);
- }, []);
-
- useEffect(() => {
- let cancelled = false;
-
- (async () => {
- try {
- const resp = await client.goose.providersList_unstable({
- providerIds: [],
- });
- if (cancelled) return;
- const sorted = [...resp.entries].sort((a, b) => {
- const aP = a.providerType === "Preferred" ? 0 : 1;
- const bP = b.providerType === "Preferred" ? 0 : 1;
- if (aP !== bP) return aP - bP;
- return a.providerName.localeCompare(b.providerName);
- });
- setProviders(sorted);
-
- if (initialIntent === "model") {
- try {
- const cfg = await client.goose.defaultsRead_unstable({});
- if (cancelled) return;
- const current = sorted.find((p) => p.providerId === cfg.providerId);
- if (current) {
- setSelectedProvider(current);
- setPendingConfigValues({});
- setPhase("select_model");
- return;
- }
- } catch {
- // fall through to provider selector
- }
- }
-
- if (!cancelled) setPhase("select_provider");
- } catch (e: unknown) {
- if (!cancelled) {
- setErrorMsg(e instanceof Error ? e.message : String(e));
- setPhase("error");
- }
- }
- })();
-
- return () => {
- cancelled = true;
- };
- }, [client, fetchKey, initialIntent]);
-
- const applyProviderModel = useCallback(
- async (
- provider: ProviderInventoryEntryDto,
- model: string,
- configValues: Record,
- ) => {
- setPhase("saving");
- try {
- await client.goose.providersConfigSave_unstable({
- providerId: provider.providerId,
- fields: Object.entries(configValues).map(([key, value]) => ({
- key,
- value,
- })),
- });
- await client.setSessionConfigOption({
- sessionId,
- configId: "provider",
- value: provider.providerId,
- });
- await client.setSessionConfigOption({
- sessionId,
- configId: "model",
- value: model,
- });
- onComplete();
- } catch (e: unknown) {
- setErrorMsg(e instanceof Error ? e.message : String(e));
- setPhase("error");
- }
- },
- [client, sessionId, onComplete],
- );
-
- const [pendingConfigValues, setPendingConfigValues] = useState<
- Record
- >({});
-
- const handleProviderSelected = useCallback(
- (provider: ProviderInventoryEntryDto) => {
- const keys = provider.configKeys.filter(
- (k) => k.required && !k.oauthFlow && !k.deviceCodeFlow,
- );
- setSelectedProvider(provider);
- if (keys.length > 0 && !provider.configured) {
- setPhase("configure");
- } else {
- setPendingConfigValues({});
- setPhase("select_model");
- }
- },
- [],
- );
-
- const handleConfigComplete = useCallback(
- (values: Record) => {
- if (!selectedProvider) return;
- setPendingConfigValues(values);
- setPhase("select_model");
- },
- [selectedProvider],
- );
-
- const handleModelSelected = useCallback(
- (model: string) => {
- if (!selectedProvider) return;
- applyProviderModel(selectedProvider, model, pendingConfigValues);
- },
- [selectedProvider, pendingConfigValues, applyProviderModel],
- );
-
- const handleRetry = useCallback(() => {
- setErrorMsg("");
- setFetchKey((k) => k + 1);
- setPhase("loading");
- }, []);
-
- if (phase === "loading" || phase === "loading_models" || phase === "saving") {
- const label =
- phase === "loading"
- ? "Loading providers…"
- : phase === "loading_models"
- ? "Loading models…"
- : "Applying changes…";
- return (
-
-
-
-
- ◆ Configure provider ◆
-
-
-
- {label}
-
-
-
-
-
- );
- }
-
- if (phase === "error") {
- return (
-
-
-
-
- ◆ Configure provider ◆
-
-
-
-
- );
- }
-
- if (phase === "configure" && selectedProvider) {
- return (
- {
- setSelectedProvider(null);
- setPhase("select_provider");
- }}
- />
- );
- }
-
- if (phase === "select_model" && selectedProvider) {
- return (
- {
- if (initialIntent === "model") {
- onCancel();
- } else {
- setPhase("select_provider");
- }
- }}
- />
- );
- }
-
- return (
-
- );
-}
diff --git a/ui/text/src/constants.tsx b/ui/text/src/constants.tsx
deleted file mode 100644
index 3d3290273..000000000
--- a/ui/text/src/constants.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-// UI Layout Constants
-export const PASTE_THRESHOLD = 80;
-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 = [
- [
- " ,_",
- " (o >",
- " //\\",
- " \\\\ \\",
- " \\\\_/",
- " | |",
- " ^ ^",
- ],
- [
- " ,_",
- " (o >",
- " //\\",
- " \\\\ \\",
- " \\\\_/",
- " / |",
- " ^ ^",
- ],
- [
- " ,_",
- " (o >",
- " //\\",
- " \\\\ \\",
- " \\\\_/",
- " | |",
- " ^ ^",
- ],
- [
- " ,_",
- " (o >",
- " //\\",
- " \\\\ \\",
- " \\\\_/",
- " | \\",
- " ^ ^",
- ],
-];
-
-export const GREETING_MESSAGES = [
- "What would you like to work on?",
- "Ready to build something amazing?",
- "What would you like to explore?",
- "What's on your mind?",
- "What shall we create today?",
- "What project needs attention?",
- "What would you like to tackle?",
- "What needs to be done?",
- "What's the plan for today?",
- "Ready to create something great?",
- "What can be built today?",
- "What's the next challenge?",
- "What progress can be made?",
- "What would you like to accomplish?",
- "What task awaits?",
- "What's the mission today?",
- "What can be achieved?",
- "What project is ready to begin?",
-];
-
-export const INITIAL_GREETING =
- GREETING_MESSAGES[Math.floor(Math.random() * GREETING_MESSAGES.length)]!;
diff --git a/ui/text/src/extensions.tsx b/ui/text/src/extensions.tsx
deleted file mode 100644
index 3928fb609..000000000
--- a/ui/text/src/extensions.tsx
+++ /dev/null
@@ -1,653 +0,0 @@
-import React, { useCallback, useEffect, useState } from "react";
-import { Box, Text, useInput, useStdout } from "ink";
-import { TextInput } from "@inkjs/ui";
-import type {
- GooseClient,
- GooseExtension,
- GooseExtensionEntry,
- McpServerStdio,
-} from "@aaif/goose-sdk";
-import {
- CRANBERRY,
- GOLD,
- RULE_COLOR,
- TEAL,
- TEXT_DIM,
- TEXT_PRIMARY,
-} from "./colors.js";
-import { Spinner, SPINNER_FRAMES } from "./components/Spinner.js";
-import { ErrorScreen } from "./components/ErrorScreen.js";
-
-type ExtEntry = {
- enabled: boolean;
- type: string;
- name: string;
- description: string;
- [key: string]: unknown;
-};
-
-function entryToExtEntry(entry: GooseExtensionEntry): ExtEntry | null {
- const ext = entry.extension;
- if (ext.type !== "mcp") {
- return {
- enabled: entry.enabled,
- type: ext.type,
- name: ext.name,
- description: ext.description ?? "",
- display_name: ext.display_name ?? null,
- timeout: "timeout" in ext ? (ext.timeout ?? null) : null,
- bundled: ext.bundled ?? null,
- };
- }
- const server = ext.server;
- if ("type" in server && server.type === "sse") return null;
- const common = {
- enabled: entry.enabled,
- description: ext.description ?? "",
- env_keys: ext.envKeys ?? [],
- timeout: ext.timeout ?? null,
- bundled: ext.bundled ?? null,
- };
- if ("type" in server && server.type === "http") {
- return {
- ...common,
- type: "streamable_http",
- name: server.name,
- uri: server.url,
- headers: Object.fromEntries(
- (server.headers ?? []).map((h) => [h.name, h.value]),
- ),
- socket: ext.socket ?? null,
- };
- }
- const stdio = server as McpServerStdio;
- return {
- ...common,
- type: "stdio",
- name: stdio.name,
- cmd: stdio.command,
- args: stdio.args,
- };
-}
-
-function toGooseExtension(e: ExtEntry): GooseExtension {
- if (e.type === "streamable_http") {
- return {
- type: "mcp",
- server: { type: "http", name: e.name, url: String(e.uri ?? ""), headers: [] },
- description: e.description || undefined,
- };
- }
- return {
- type: "mcp",
- server: { name: e.name, command: String(e.cmd ?? ""), args: (e.args as string[]) ?? [], env: [] },
- description: e.description || undefined,
- };
-}
-
-type AddType = "stdio" | "streamable_http";
-type Phase =
- | "loading"
- | "list"
- | "add_type"
- | "add_value"
- | "add_name"
- | "add_desc"
- | "saving"
- | "error";
-
-function deriveNameFromValue(addType: AddType, value: string): string {
- if (addType === "stdio") {
- const cmd = value.trim().split(/\s+/)[0] ?? "";
- return cmd.split("/").pop() ?? cmd;
- }
- try {
- return new URL(value.trim()).hostname;
- } catch {
- return value.trim();
- }
-}
-
-function buildConfig(
- addType: AddType,
- value: string,
- name: string,
- description: string,
-): ExtEntry {
- if (addType === "stdio") {
- const parts = value.trim().split(/\s+/);
- return {
- type: "stdio",
- enabled: true,
- name,
- description,
- cmd: parts[0] ?? "",
- args: parts.slice(1),
- };
- }
- return {
- type: "streamable_http",
- enabled: true,
- name,
- description,
- uri: value.trim(),
- };
-}
-
-export default function ExtensionsManager({
- client,
- sessionId,
- height,
- onClose,
-}: {
- client: GooseClient;
- sessionId: string;
- height: number;
- onClose: () => void;
-}) {
- const { stdout } = useStdout();
- const columns = stdout?.columns ?? 80;
-
- const [phase, setPhase] = useState("loading");
- const [spinIdx, setSpinIdx] = useState(0);
- const [errorMsg, setErrorMsg] = useState("");
- const [entries, setEntries] = useState([]);
- const [warnings, setWarnings] = useState([]);
- const [selectedIdx, setSelectedIdx] = useState(0);
-
- const [addType, setAddType] = useState("stdio");
- const [addValue, setAddValue] = useState("");
- const [addName, setAddName] = useState("");
- const [addDesc, setAddDesc] = useState("");
- const [inputKey, setInputKey] = useState(0);
-
- useEffect(() => {
- const t = setInterval(
- () => setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length),
- 300,
- );
- return () => clearInterval(t);
- }, []);
-
- const reload = useCallback(async () => {
- setPhase("loading");
- try {
- const [configResp, sessionResp] = await Promise.all([
- client.goose.configExtensionsList_unstable({}),
- client.goose.sessionExtensionsList_unstable({ sessionId }),
- ]);
-
- const allExtensions = (configResp.extensions as GooseExtensionEntry[])
- .map(entryToExtEntry)
- .filter((e): e is ExtEntry => e !== null);
- const activeNames = new Set(
- (sessionResp.extensions as Array<{ name?: string }>).map((e) => e.name),
- );
-
- setEntries(
- allExtensions.map((ext) => ({
- ...ext,
- enabled: activeNames.has(ext.name),
- })),
- );
- setWarnings(configResp.warnings ?? []);
- setPhase("list");
- } catch (e: unknown) {
- setErrorMsg(e instanceof Error ? e.message : String(e));
- setPhase("error");
- }
- }, [client, sessionId]);
-
- useEffect(() => {
- reload();
- }, [reload]);
-
- const withSaving = useCallback(
- async (fn: () => Promise) => {
- setPhase("saving");
- try {
- await fn();
- await reload();
- } catch (e: unknown) {
- setErrorMsg(e instanceof Error ? e.message : String(e));
- setPhase("error");
- }
- },
- [reload],
- );
-
- const toggleSelected = useCallback(() => {
- const sel = entries[selectedIdx];
- if (!sel) return;
- withSaving(async () => {
- if (sel.enabled) {
- await client.goose.sessionExtensionsRemove_unstable({
- sessionId,
- name: sel.name,
- });
- } else {
- await client.goose.sessionExtensionsAdd_unstable({
- sessionId,
- config: sel as any,
- });
- }
- });
- }, [entries, selectedIdx, client, sessionId, withSaving]);
-
- const saveNewExtension = useCallback(
- (description: string) => {
- const config = buildConfig(addType, addValue, addName, description);
- withSaving(async () => {
- await client.goose.configExtensionsAdd_unstable({
- extension: toGooseExtension(config),
- enabled: true,
- });
- await client.goose.sessionExtensionsAdd_unstable({
- sessionId,
- config: config as any,
- });
- });
- },
- [addType, addValue, addName, client, sessionId, withSaving],
- );
-
- useInput((ch, key) => {
- if (phase === "list") {
- if (key.escape) {
- onClose();
- return;
- }
- if (key.upArrow) {
- setSelectedIdx((i) => Math.max(i - 1, 0));
- return;
- }
- if (key.downArrow) {
- setSelectedIdx((i) => Math.min(i + 1, entries.length - 1));
- return;
- }
- if (ch === " " || key.return) {
- toggleSelected();
- return;
- }
- if (ch === "a") {
- setAddType("stdio");
- setPhase("add_type");
- return;
- }
- }
- if (phase === "add_type") {
- if (key.escape) {
- setPhase("list");
- return;
- }
- if (key.upArrow || key.downArrow) {
- setAddType((t) => (t === "stdio" ? "streamable_http" : "stdio"));
- return;
- }
- if (key.return) {
- setAddValue("");
- setInputKey((k) => k + 1);
- setPhase("add_value");
- return;
- }
- }
- if (key.escape) {
- if (phase === "add_value") {
- setPhase("add_type");
- return;
- }
- if (phase === "add_name") {
- setInputKey((k) => k + 1);
- setPhase("add_value");
- return;
- }
- if (phase === "add_desc") {
- setInputKey((k) => k + 1);
- setPhase("add_name");
- return;
- }
- }
- });
-
- if (phase === "loading" || phase === "saving") {
- return (
-
-
-
-
- ◆ Manage extensions ◆
-
-
-
-
- {phase === "loading" ? "Loading extensions…" : "Saving…"}
-
-
-
-
-
-
- );
- }
-
- if (phase === "error") {
- return (
-
-
-
-
- ◆ Manage extensions ◆
-
-
- reload()} />
-
- );
- }
-
- const maxW = Math.min(columns - 4, 80);
- const inputW = Math.min(maxW - 10, 70);
-
- if (phase === "add_type") {
- const types: { value: AddType; label: string; hint: string }[] = [
- { value: "stdio", label: "Command (stdio)", hint: "run a local command" },
- {
- value: "streamable_http",
- label: "Endpoint (HTTP)",
- hint: "connect to a remote server",
- },
- ];
- return (
-
-
-
-
- ◆ Add extension ◆
-
-
-
- Choose a connection type
-
-
-
- {types.map((t) => {
- const active = addType === t.value;
- return (
-
-
- {active ? "▸ " : " "}
-
-
- {t.label}
-
- {t.hint}
-
- );
- })}
-
-
-
- ↑↓ select · enter confirm · esc cancel
-
-
- );
- }
-
- if (phase === "add_value") {
- const isStdio = addType === "stdio";
- const placeholder = isStdio
- ? "npx -y @modelcontextprotocol/server-filesystem /tmp"
- : "http://localhost:8080/mcp";
- return (
-
-
-
-
- ◆ {isStdio ? "Enter command" : "Enter endpoint URL"} ◆
-
-
-
-
- {isStdio
- ? "The command to launch the extension"
- : "URL of the remote MCP server"}
-
-
-
-
-
- {"❯ "}
-
- {
- if (!v.trim()) return;
- setAddValue(v);
- setAddName(deriveNameFromValue(addType, v));
- setInputKey((k) => k + 1);
- setPhase("add_name");
- }}
- />
-
-
-
- enter continue · esc back
-
-
- );
- }
-
- if (phase === "add_name") {
- return (
-
-
-
-
- ◆ Name this extension ◆
-
-
-
- A short name to identify this extension
-
-
-
-
- {"❯ "}
-
- {
- if (!v.trim()) return;
- setAddName(v.trim());
- setAddDesc("");
- setInputKey((k) => k + 1);
- setPhase("add_desc");
- }}
- />
-
-
-
- enter continue · esc back
-
-
- );
- }
-
- if (phase === "add_desc") {
- return (
-
-
-
-
- ◆ Description ◆
-
-
-
- What does this extension do? (optional)
-
-
-
-
- {"❯ "}
-
- saveNewExtension(v.trim())}
- />
-
-
-
-
- enter save (leave empty to skip) · esc back
-
-
-
- );
- }
-
- const layoutW = maxW;
- const GUTTER = 2;
- const STATUS_W = 10;
- const nameW = Math.max(16, Math.floor(layoutW * 0.3));
- const descW = Math.max(8, layoutW - 2 - STATUS_W - nameW - 2 * GUTTER);
-
- const rows = Math.max(height - 9, 4);
- const maxStart = Math.max(0, entries.length - rows);
- const start = Math.min(
- maxStart,
- Math.max(0, selectedIdx - Math.floor(rows / 2)),
- );
- const end = Math.min(entries.length, start + rows);
- const windowed = entries.slice(start, end);
-
- return (
-
- {/* Header */}
-
-
-
- ◆ Manage extensions ◆
-
-
-
-
- Toggle, add, or remove extensions for this session
-
-
-
- {/* Extension List */}
-
- {entries.length === 0 ? (
-
-
- No extensions configured — press a to add one
-
-
- ) : (
- <>
- {start > 0 && (
-
- ▲ {start} more above
-
- )}
-
-
- {windowed.map((ext, i) => {
- const globalIdx = start + i;
- const active = globalIdx === selectedIdx;
- return (
-
-
- {active ? "▸ " : " "}
-
-
-
- {ext.name}
-
-
-
- {" ".repeat(GUTTER)}
-
-
-
- {ext.description || ""}
-
-
-
- {" ".repeat(GUTTER)}
-
-
-
- {ext.enabled ? "enabled" : "disabled"}
-
-
-
- );
- })}
-
-
- {end < entries.length && (
-
-
- ▼ {entries.length - end} more below
-
-
- )}
- >
- )}
-
-
- {warnings.length > 0 && (
-
-
- Warnings
- {warnings.map((w, i) => (
-
-
- • {w}
-
-
- ))}
-
-
- )}
-
- {/* Footer */}
-
- space/enter toggle · a add · esc back
-
-
- );
-}
diff --git a/ui/text/src/markdown.tsx b/ui/text/src/markdown.tsx
deleted file mode 100644
index 63b16a53b..000000000
--- a/ui/text/src/markdown.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Marked } from "marked";
-import { markedTerminal } from "marked-terminal";
-
-let renderer: Marked | null = null;
-let rendererWidth = 0;
-
-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/onboarding.tsx b/ui/text/src/onboarding.tsx
deleted file mode 100644
index 485d3afac..000000000
--- a/ui/text/src/onboarding.tsx
+++ /dev/null
@@ -1,733 +0,0 @@
-import React, { useState, useEffect, useCallback } from "react";
-import { Box, Text, useInput, useStdout } from "ink";
-import { TextInput, PasswordInput } from "@inkjs/ui";
-import type { GooseClient, ProviderInventoryEntryDto } from "@aaif/goose-sdk";
-import {
- CRANBERRY,
- TEAL,
- GOLD,
- TEXT_PRIMARY,
- TEXT_SECONDARY,
- TEXT_DIM,
- RULE_COLOR,
-} from "./colors.js";
-import { Spinner, SPINNER_FRAMES } from "./components/Spinner.js";
-import { ErrorScreen } from "./components/ErrorScreen.js";
-
-type Phase =
- | "loading"
- | "select_provider"
- | "configure"
- | "saving"
- | "success"
- | "error";
-
-interface OnboardingProps {
- client: GooseClient;
- width: number;
- height: number;
- onComplete: () => void;
-}
-
-export interface ProviderSelectorProps {
- providers: ProviderInventoryEntryDto[];
- height: number;
- onSelect: (provider: ProviderInventoryEntryDto) => void;
- title?: string;
- subtitle?: string;
- onBack?: () => void;
-}
-
-export const ProviderSelector = React.memo(function ProviderSelector({
- providers,
- height,
- onSelect,
- title,
- subtitle,
- onBack,
-}: ProviderSelectorProps) {
- const [selectedIdx, setSelectedIdx] = useState(0);
- const [searchQuery, setSearchQuery] = useState("");
- const { stdout } = useStdout();
- const columns = stdout?.columns ?? 80;
-
- const filtered = (() => {
- if (!searchQuery) return providers;
- const q = searchQuery.toLowerCase();
- return providers.filter(
- (p) =>
- p.providerName.toLowerCase().includes(q) ||
- p.providerId.toLowerCase().includes(q),
- );
- })();
-
- // Calculate grid dimensions based on terminal size
- const cardWidth = 36; // Width of each provider card
- const cardHeight = 8; // Height of each provider card
- const minSpacing = 2; // Minimum spacing between cards
-
- const availableWidth = columns - 4; // Leave margins
- // Header: marginTop(1) + title+mb(2) + subtitle+mb(3) + searchbar+mb(5) = 11
- // Footer: mt(2) + text(1) = 3, plus potential scroll indicators(2)
- const availableHeight = height - 16;
-
- const cardsPerRow = Math.max(
- 1,
- Math.floor(availableWidth / (cardWidth + minSpacing)),
- );
- // Cap horizontal gap so it doesn't grow unbounded on wide terminals
- const columnSpacing = Math.min(
- minSpacing,
- Math.floor(
- (availableWidth - cardsPerRow * cardWidth) / Math.max(1, cardsPerRow - 1),
- ),
- );
- // Terminal chars are ~2× taller than wide, so 1 row ≈ 2 columns visually
- const rowSpacing = 1;
- const rowsVisible = Math.max(
- 1,
- Math.floor((availableHeight + rowSpacing) / (cardHeight + rowSpacing)),
- );
-
- const totalRows = Math.ceil(filtered.length / cardsPerRow);
- const selectedRow = Math.floor(selectedIdx / cardsPerRow);
- // Calculate scroll offset for rows
- const [scrollRow, setScrollRow] = useState(0);
-
- useEffect(() => {
- if (selectedRow < scrollRow) {
- setScrollRow(selectedRow);
- } else if (selectedRow >= scrollRow + rowsVisible) {
- setScrollRow(selectedRow - rowsVisible + 1);
- }
- }, [selectedRow, rowsVisible, scrollRow]);
-
- useInput((ch, key) => {
- if (key.escape) {
- if (searchQuery) {
- setSearchQuery("");
- setSelectedIdx(0);
- setScrollRow(0);
- return;
- }
- if (onBack) {
- onBack();
- return;
- }
- }
- if (filtered.length === 0) {
- // Only allow typing/backspace when no results match; skip navigation
- if (key.backspace || key.delete) {
- setSearchQuery((q) => q.slice(0, -1));
- setSelectedIdx(0);
- setScrollRow(0);
- return;
- }
- if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
- setSearchQuery((q) => q + ch);
- setSelectedIdx(0);
- setScrollRow(0);
- }
- return;
- }
- if (key.upArrow) {
- const newIdx = Math.max(selectedIdx - cardsPerRow, 0);
- setSelectedIdx(newIdx);
- return;
- }
- if (key.downArrow) {
- const newIdx = Math.min(selectedIdx + cardsPerRow, filtered.length - 1);
- setSelectedIdx(newIdx);
- return;
- }
- if (key.leftArrow) {
- const newIdx = Math.max(selectedIdx - 1, 0);
- setSelectedIdx(newIdx);
- return;
- }
- if (key.rightArrow) {
- const newIdx = Math.min(selectedIdx + 1, filtered.length - 1);
- setSelectedIdx(newIdx);
- return;
- }
- if (key.return) {
- const p = filtered[selectedIdx];
- if (p) onSelect(p);
- return;
- }
- if (key.backspace || key.delete) {
- setSearchQuery((q) => q.slice(0, -1));
- setSelectedIdx(0);
- setScrollRow(0);
- return;
- }
- if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
- setSearchQuery((q) => q + ch);
- setSelectedIdx(0);
- setScrollRow(0);
- }
- });
-
- // Create grid of provider cards
- const renderProviderCard = (
- provider: ProviderInventoryEntryDto,
- _index: number,
- isSelected: boolean,
- ) => {
- const cardBorder = isSelected ? "double" : "single";
- const cardBorderColor = isSelected ? GOLD : RULE_COLOR;
- const textColor = isSelected ? TEXT_PRIMARY : TEXT_SECONDARY;
-
- // Calculate actual content width: cardWidth - borders (2) - paddingX (2)
- const contentWidth = cardWidth - 4;
- // Width for title (leave space for icons: 2-3 chars)
- const titleWidth = contentWidth - 3;
- // Available lines for description: cardHeight - borders (2) - title (1) - margin (1) - name (1) - margin (1)
- const descriptionMaxLines = Math.max(1, cardHeight - 6);
- const descriptionMaxChars = descriptionMaxLines * contentWidth;
-
- return (
-
-
-
-
- {provider.providerName}
-
-
-
- {provider.providerType === "Preferred" && (
- ★
- )}
- {provider.configured && ✓}
-
-
-
-
-
-
- {provider.providerId}
-
-
- {provider.description && (
-
-
- {provider.description.length > descriptionMaxChars
- ? provider.description.slice(0, descriptionMaxChars - 1) + "…"
- : provider.description}
-
-
- )}
-
-
- );
- };
-
- const visibleRows = [];
- for (
- let row = scrollRow;
- row < Math.min(scrollRow + rowsVisible, totalRows);
- row++
- ) {
- const rowProviders = [];
- for (let col = 0; col < cardsPerRow; col++) {
- const index = row * cardsPerRow + col;
- if (index < filtered.length) {
- const isSelected = index === selectedIdx;
- rowProviders.push(
- renderProviderCard(filtered[index], index, isSelected),
- );
- }
- }
-
- if (rowProviders.length > 0) {
- const isLastVisibleRow =
- row === Math.min(scrollRow + rowsVisible, totalRows) - 1;
- visibleRows.push(
-
- {rowProviders}
- ,
- );
- }
- }
-
- return (
-
- {/* Header */}
-
-
-
- {title ?? "◆ Welcome to goose ◆"}
-
-
-
-
- {subtitle ?? "Connect an AI model provider to get started"}
-
-
-
- {/* Search Bar */}
-
-
-
- {"❯ "}
-
-
- {searchQuery || "search providers…"}
-
-
-
-
- {/* Provider Grid */}
-
- {filtered.length === 0 ? (
-
- No matching providers found
-
- ) : (
- <>
- {scrollRow > 0 && (
-
-
- ▲ {scrollRow * cardsPerRow} more above
-
-
- )}
-
-
- {visibleRows}
-
-
- {scrollRow + rowsVisible < totalRows && (
-
-
- ▼ {filtered.length - (scrollRow + rowsVisible) * cardsPerRow}{" "}
- more below
-
-
- )}
- >
- )}
-
-
- {/* Footer */}
-
-
- ↑↓←→ navigate · enter select · type to search
- {onBack ? " · esc back" : " · esc clear"}
-
-
-
- );
-});
-
-export interface ProviderConfiguratorProps {
- provider: ProviderInventoryEntryDto;
- height: number;
- onComplete: (values: Record) => void;
- onBack: () => void;
-}
-
-export const ProviderConfigurator = React.memo(function ProviderConfigurator({
- provider,
- height,
- onComplete,
- onBack,
-}: ProviderConfiguratorProps) {
- const [keyValues, setKeyValues] = useState>({});
- const [activeKeyIdx, setActiveKeyIdx] = useState(0);
- const [showMasked, setShowMasked] = useState>({});
- const [inputKey, setInputKey] = useState(0);
- const { stdout } = useStdout();
- const columns = stdout?.columns ?? 80;
-
- const keys = provider.configKeys.filter(
- (k) => k.required && !k.oauthFlow && !k.deviceCodeFlow,
- );
- const currentKey = keys[activeKeyIdx];
-
- useInput((_ch, key) => {
- if (!currentKey) return;
-
- if (key.escape) {
- onBack();
- return;
- }
- if (key.tab && currentKey.secret) {
- setShowMasked((prev) => ({
- ...prev,
- [currentKey.name]: !prev[currentKey.name],
- }));
- return;
- }
- });
-
- const handleSubmit = (value: string) => {
- if (!currentKey) return;
- const effective = value.trim() || currentVal.trim();
- if (!effective) return;
- const newValues = { ...keyValues, [currentKey.name]: effective };
- setKeyValues(newValues);
- if (activeKeyIdx < keys.length - 1) {
- setActiveKeyIdx(activeKeyIdx + 1);
- setShowMasked({});
- setInputKey((prev) => prev + 1); // Force new input component
- } else {
- onComplete(newValues);
- }
- };
-
- const handleChange = (value: string) => {
- if (!currentKey) return;
- setKeyValues((prev) => ({
- ...prev,
- [currentKey.name]: value,
- }));
- };
-
- const currentVal = keyValues[currentKey?.name ?? ""] ?? "";
- const masked = currentKey?.secret && !showMasked[currentKey?.name ?? ""];
- const maxWidth = Math.min(columns - 4, 80);
-
- // Calculate content height for proper centering
- const headerHeight = 1 + (provider.description ? 2 : 0) + 1; // title + description + spacer
- const keysHeight = keys.length; // one line per key
- const inputHeight = currentKey ? 3 : 0; // input + help text + spacing
- const setupStepsHeight = provider.setupSteps?.length
- ? provider.setupSteps.length + 1
- : 0;
- const contentHeight =
- headerHeight + keysHeight + inputHeight + setupStepsHeight;
- const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
-
- return (
-
- {topPad > 0 && }
-
- {/* Header */}
-
-
- ◆ Configure {provider.providerName} ◆
-
-
- {provider.description && (
-
-
-
- {provider.description}
-
-
-
- )}
-
-
- {/* Configuration Keys */}
- {keys.map((k, i) => (
-
-
- {i < activeKeyIdx ? "✓ " : i === activeKeyIdx ? "▸ " : " "}
-
-
- {k.name}
-
- {i < activeKeyIdx && ••••••}
-
- ))}
-
- {/* Current Input Field */}
- {currentKey && (
-
-
-
- {"❯ "}
-
- {masked ? (
-
- ) : (
-
- )}
-
-
-
-
- enter confirm · esc back
- {currentKey.secret && (
- <>
- {" · tab "}
- {masked ? "reveal" : "hide"}
- >
- )}
-
-
-
-
- )}
-
- {/* Setup Steps */}
- {provider.setupSteps && provider.setupSteps.length > 0 && (
-
- Setup steps:
- {provider.setupSteps.map((step, i) => (
-
-
- {i + 1}. {step}
-
-
- ))}
-
- )}
-
-
- );
-});
-
-interface SuccessScreenProps {
- provider: ProviderInventoryEntryDto | null;
- height: number;
-}
-
-const SuccessScreen = React.memo(function SuccessScreen({
- provider,
- height,
-}: SuccessScreenProps) {
- const { stdout } = useStdout();
- const columns = stdout?.columns ?? 80;
-
- // Calculate content height for proper centering
- const contentHeight = 1 + (provider ? 1 : 0); // success message + provider text
- const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
-
- return (
-
- {topPad > 0 && }
-
-
- ✓ Provider configured
-
- {provider && (
-
-
- Connected to {provider.providerName}
-
-
- )}
-
-
- );
-});
-
-export default function Onboarding({
- client,
- width,
- height,
- onComplete,
-}: OnboardingProps) {
- const [phase, setPhase] = useState("loading");
- const [providers, setProviders] = useState([]);
- const [selectedProvider, setSelectedProvider] =
- useState(null);
- const [errorMsg, setErrorMsg] = useState("");
- const [spinIdx, setSpinIdx] = useState(0);
- const [fetchKey, setFetchKey] = useState(0);
-
- useEffect(() => {
- const t = setInterval(
- () => setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length),
- 300,
- );
- return () => clearInterval(t);
- }, []);
-
- useEffect(() => {
- (async () => {
- try {
- const resp = await client.goose.providersList_unstable({
- providerIds: [],
- });
- const sorted = [...resp.entries].sort((a, b) => {
- const aP = a.providerType === "Preferred" ? 0 : 1;
- const bP = b.providerType === "Preferred" ? 0 : 1;
- if (aP !== bP) return aP - bP;
- return a.providerName.localeCompare(b.providerName);
- });
- setProviders(sorted);
- setPhase("select_provider");
- } catch (e: unknown) {
- setErrorMsg(e instanceof Error ? e.message : JSON.stringify(e));
- setPhase("error");
- }
- })();
- }, [client, fetchKey]);
-
- const saveProvider = useCallback(
- async (
- provider: ProviderInventoryEntryDto,
- values: Record,
- ) => {
- setPhase("saving");
- try {
- await client.goose.providersConfigSave_unstable({
- providerId: provider.providerId,
- fields: Object.entries(values).map(([key, value]) => ({
- key,
- value,
- })),
- });
- setPhase("success");
- setTimeout(onComplete, 1000);
- } catch (e: unknown) {
- setErrorMsg(e instanceof Error ? e.message : JSON.stringify(e));
- setPhase("error");
- }
- },
- [client, onComplete],
- );
-
- const confirmProvider = useCallback(
- (provider: ProviderInventoryEntryDto) => {
- const keys = provider.configKeys.filter(
- (k) => k.required && !k.oauthFlow && !k.deviceCodeFlow,
- );
- if (keys.length === 0) {
- saveProvider(provider, {});
- return;
- }
- setSelectedProvider(provider);
- setPhase("configure");
- },
- [saveProvider],
- );
-
- const handleRetry = useCallback(() => {
- setErrorMsg("");
- setFetchKey((k) => k + 1);
- setPhase("loading");
- }, []);
-
- if (phase === "loading") {
- const contentHeight = 3; // spinner + text + spacing
- const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
-
- return (
-
- {topPad > 0 && }
-
-
-
- loading providers…
-
-
-
- );
- }
-
- if (phase === "error") {
- return (
-
-
-
- );
- }
-
- if (phase === "saving") {
- const contentHeight = 3; // spinner + text + spacing
- const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
-
- return (
-
- {topPad > 0 && }
-
-
-
- saving configuration…
-
-
-
- );
- }
-
- if (phase === "success") {
- return ;
- }
-
- if (phase === "configure" && selectedProvider) {
- return (
- saveProvider(selectedProvider, values)}
- onBack={() => {
- setSelectedProvider(null);
- setPhase("select_provider");
- }}
- />
- );
- }
-
- return (
-
- );
-}
diff --git a/ui/text/src/slashCommands.tsx b/ui/text/src/slashCommands.tsx
deleted file mode 100644
index bb837bd9d..000000000
--- a/ui/text/src/slashCommands.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { spawnSync } from "node:child_process";
-
-export interface SlashCommandContext {
- cwd: string;
-}
-
-export type SlashCommandResult =
- | { handled: true; message?: string }
- | { handled: true; overlay: "diff"; content: string; truncated: boolean }
- | { handled: false };
-
-export interface SlashCommand {
- name: string;
- description: string;
- run: (ctx: SlashCommandContext) => SlashCommandResult;
-}
-
-function isGitRepo(cwd: string): boolean {
- const result = spawnSync(
- "git",
- [
- "-c",
- "safe.bareRepository=explicit",
- "-c",
- "core.fsmonitor=false",
- "rev-parse",
- "--is-inside-work-tree",
- ],
- {
- cwd,
- stdio: ["ignore", "ignore", "ignore"],
- },
- );
- return result.status === 0;
-}
-
-const MAX_DIFF_BYTES = 2_000_000;
-
-function readDiff(cwd: string): { text: string; truncated: boolean } | null {
- const result = spawnSync(
- "git",
- [
- "-c",
- "safe.bareRepository=explicit",
- "-c",
- "core.fsmonitor=false",
- "--no-pager",
- "diff",
- "--no-color",
- ],
- {
- cwd,
- encoding: "utf8",
- maxBuffer: 32 * 1024 * 1024,
- },
- );
- if (result.status !== 0 && result.status !== null) return null;
- const stdout = result.stdout ?? "";
- if (stdout.length > MAX_DIFF_BYTES) {
- return { text: stdout.slice(0, MAX_DIFF_BYTES), truncated: true };
- }
- return { text: stdout, truncated: false };
-}
-
-const diffCommand: SlashCommand = {
- name: "diff",
- description: "show unstaged changes",
- run: (ctx) => {
- if (!isGitRepo(ctx.cwd)) {
- return {
- handled: true,
- message: `not a git repository: ${ctx.cwd}`,
- };
- }
-
- const diff = readDiff(ctx.cwd);
- if (diff === null) {
- return { handled: true, message: "failed to run `git diff`" };
- }
-
- if (diff.text.trim().length === 0) {
- return { handled: true, message: "no unstaged changes" };
- }
-
- return {
- handled: true,
- overlay: "diff",
- content: diff.text,
- truncated: diff.truncated,
- };
- },
-};
-
-const COMMANDS: Record = {
- diff: diffCommand,
-};
-
-export function tryRunSlashCommand(
- input: string,
- ctx: SlashCommandContext,
-): SlashCommandResult {
- const trimmed = input.trim();
- if (!trimmed.startsWith("/")) return { handled: false };
- const name = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
- const cmd = COMMANDS[name];
- if (!cmd) return { handled: false };
- return cmd.run(ctx);
-}
-
-export function listSlashCommands(): SlashCommand[] {
- return Object.values(COMMANDS);
-}
diff --git a/ui/text/src/toolcall.tsx b/ui/text/src/toolcall.tsx
deleted file mode 100644
index a24f23a59..000000000
--- a/ui/text/src/toolcall.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import React from "react";
-import { Box, Text } from "ink";
-import type {
- ToolCallContent,
- ToolCallStatus,
- ToolKind,
-} from "@agentclientprotocol/sdk";
-import { CRANBERRY, TEAL, GOLD, TEXT_SECONDARY, TEXT_DIM } from "./colors.js";
-
-export interface ToolCallInfo {
- toolCallId: string;
- title: string;
- status: ToolCallStatus;
- kind?: ToolKind;
- rawInput?: unknown;
- rawOutput?: unknown;
- content?: ToolCallContent[];
- locations?: Array<{ path: string; line?: number | null }>;
-}
-
-const CEDAR = "#6B5344";
-
-const KIND_ICONS: Record = {
- read: "📖",
- edit: "✏️",
- delete: "🗑",
- move: "📦",
- search: "🔍",
- execute: "▶",
- think: "💭",
- fetch: "🌐",
- switch_mode: "🔀",
- other: "⚙",
-};
-
-const STATUS_INDICATORS: Record = {
- pending: { icon: "○", color: TEXT_DIM },
- in_progress: { icon: "◑", color: GOLD },
- completed: { icon: "●", color: TEAL },
- failed: { icon: "✗", color: CRANBERRY },
-};
-
-function truncateLine(line: string, maxWidth: number): string {
- const safeMaxWidth = Math.max(maxWidth, 1);
- if (line.length <= safeMaxWidth) return line;
- return safeMaxWidth > 1
- ? line.slice(0, safeMaxWidth - 1) + "…"
- : line.slice(0, safeMaxWidth);
-}
-
-export function formatJson(value: unknown): string {
- if (value === undefined || value === null) return "";
- if (typeof value === "string") {
- // If it looks like JSON, try to parse and re-format; otherwise return as-is.
- const trimmed = value.trim();
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
- try {
- return JSON.stringify(JSON.parse(trimmed), null, 2);
- } catch {
- return value;
- }
- }
- return value;
- }
- try {
- return JSON.stringify(value, null, 2);
- } catch {
- return String(value);
- }
-}
-
-/**
- * Render a tool call as a single-line boxed summary.
- *
- * The box always has the same content and height as before; when `selected`
- * is true we swap the border color and show a hint that space will expand it.
- */
-export function renderToolCallLines(
- info: ToolCallInfo,
- width: number,
- selected: boolean,
-): React.ReactElement[] {
- const kindIcon = KIND_ICONS[info.kind ?? "other"] ?? "⚙";
- const statusInfo =
- STATUS_INDICATORS[info.status] ?? STATUS_INDICATORS.pending!;
-
- const borderColor = selected
- ? GOLD
- : info.status === "failed"
- ? CRANBERRY
- : CEDAR;
- const dimBorder = !selected && info.status !== "failed";
-
- const safeWidth = Math.max(width, 10);
- const innerWidth = Math.max(safeWidth - 4, 6);
-
- const k = info.toolCallId;
- const lines: React.ReactElement[] = [];
-
- const hRule = "─".repeat(Math.max(safeWidth - 2, 0));
- lines.push(
-
-
- ╭{hRule}╮
-
- ,
- );
-
- const statusIcon = statusInfo.icon;
- const runningText = info.status === "in_progress" ? " running…" : "";
- const hintText = selected ? "space to expand" : "";
- const fixedLen = 4 + runningText.length + hintText.length;
- const titleMax = Math.max(innerWidth - fixedLen, 4);
- const title = truncateLine(info.title, titleMax);
-
- lines.push(
-
-
- │{" "}
-
-
- {statusIcon}
- {kindIcon}
-
- {title}
-
- {runningText ? (
-
- {runningText}
-
- ) : null}
-
- {hintText ? (
-
- {hintText}
-
- ) : null}
-
-
- {" "}
- │
-
- ,
- );
-
- lines.push(
-
-
- ╰{hRule}╯
-
- ,
- );
-
- return lines;
-}
-
-/**
- * Height in lines of the rendered single-line tool-call box.
- * Kept in sync with `renderToolCallLines`.
- */
-export const TOOL_CALL_BOX_HEIGHT = 3;
diff --git a/ui/text/src/tui.tsx b/ui/text/src/tui.tsx
deleted file mode 100644
index 5be8a2dfb..000000000
--- a/ui/text/src/tui.tsx
+++ /dev/null
@@ -1,1424 +0,0 @@
-#!/usr/bin/env node
-import React, {
- useState,
- useEffect,
- useCallback,
- useMemo,
- useRef,
-} from "react";
-import { Box, Text, render, useApp, useInput, useStdout } from "ink";
-import { MultilineInput } from "ink-multiline-input";
-import meow from "meow";
-import { spawn } from "node:child_process";
-import { Readable, Writable } from "node:stream";
-import type {
- SessionNotification,
- Stream,
- ContentChunk,
- ToolCall,
- ToolCallUpdate,
- RequestPermissionRequest,
- RequestPermissionResponse,
-} from "@agentclientprotocol/sdk";
-import { PROTOCOL_VERSION, ndJsonStream } from "@agentclientprotocol/sdk";
-import { GooseClient } from "@aaif/goose-sdk";
-import { resolveGooseBinary } from "@aaif/goose-sdk/node";
-import Onboarding from "./onboarding.js";
-import ConfigureScreen, { ConfigureIntent } from "./configure.js";
-import ExtensionsManager from "./extensions.js";
-import { DiffViewer } from "./components/DiffViewer.js";
-import type { Turn } from "./types.js";
-import {
- emptyLine,
- renderUserPrompt,
- renderToolCallItem,
- renderErrorItem,
- renderContentItem,
- renderLoadingIndicator,
- renderQueuedMessages,
-} from "./components/ContentRenderers.js";
-import { Header } from "./components/Header.js";
-import { Rule } from "./components/Rule.js";
-import { ToolCallExpanded } from "./components/ToolCallExpanded.js";
-import type { ToolCallInfo } from "./toolcall.js";
-import { isErrorStatus, formatError } from "./utils.js";
-import {
- CRANBERRY,
- TEAL,
- GOLD,
- TEXT_PRIMARY,
- TEXT_DIM,
- RULE_COLOR,
-} from "./colors.js";
-import { Spinner, SPINNER_FRAMES } from "./components/Spinner.js";
-import {
- PASTE_THRESHOLD,
- INPUT_MAX_ROWS,
- SENT_PREVIEW_LEN,
- GOOSE_FRAMES,
- INITIAL_GREETING,
- SCROLL_STEP,
- SCROLL_FAST_MULTIPLIER,
-} from "./constants.js";
-import { tryRunSlashCommand } from "./slashCommands.js";
-
-const InputBar = React.memo(function InputBar({
- width,
- input,
- onChange,
- onSubmit,
- queued,
- scrollHint,
- placeholder,
- focused,
- pastedFull,
- onPastedFullChange,
-}: {
- width: number;
- input: string;
- onChange: (v: string) => void;
- onSubmit: (v: string) => void;
- queued: boolean;
- scrollHint: boolean;
- placeholder?: string;
- focused: boolean;
- pastedFull: string | null;
- onPastedFullChange: (v: string | null) => void;
-}) {
- const prevLenRef = useRef(input.length);
-
- const handleChange = useCallback(
- (newValue: string) => {
- const delta = newValue.length - prevLenRef.current;
- prevLenRef.current = newValue.length;
- if (delta >= PASTE_THRESHOLD) {
- onPastedFullChange(newValue);
- onChange(newValue);
- } else {
- if (pastedFull !== null) onPastedFullChange(null);
- onChange(newValue);
- }
- },
- [onChange, pastedFull, onPastedFullChange],
- );
-
- const handleSubmit = useCallback(
- (value: string) => {
- prevLenRef.current = 0;
- onPastedFullChange(null);
- onSubmit(value);
- },
- [onSubmit, onPastedFullChange],
- );
-
- useInput(
- (ch, key) => {
- if (key.return) {
- handleSubmit(input);
- return;
- }
- if (key.backspace || key.delete) {
- prevLenRef.current = 0;
- onPastedFullChange(null);
- onChange("");
- return;
- }
- if (key.escape) {
- prevLenRef.current = 0;
- onPastedFullChange(null);
- onChange("");
- return;
- }
- if (ch && !key.ctrl && !key.meta) {
- prevLenRef.current = ch.length;
- onPastedFullChange(null);
- onChange(ch);
- }
- },
- { isActive: focused && pastedFull !== null },
- );
-
- const isPasteMode = pastedFull !== null;
- const constrainedWidth = Math.max(width, 20);
- const contentWidth = Math.max(constrainedWidth - 6, 10);
-
- return (
-
-
-
- {"❯ "}
-
- {isPasteMode ? (
-
-
-
- {(() => {
- const text = pastedFull;
- const availableWidth = Math.max(contentWidth - 20, 10);
- const flat = text
- .replace(/\n/g, " ")
- .replace(/\s+/g, " ")
- .trim();
- if (flat.length <= availableWidth) return flat;
- const suffix = ` (${flat.length.toLocaleString()} chars)`;
- const previewLen = Math.max(
- availableWidth - suffix.length - 1,
- 5,
- );
- return flat.slice(0, previewLen) + "…" + suffix;
- })()}
-
-
- {scrollHint && (
-
- ↑↓ scroll · ⌥↑↓ fast · shift+↑↓ history
-
- )}
-
- ) : (
-
- key.return && !key.ctrl,
- newline: (key) => key.return && key.ctrl,
- }}
- useCustomInput={(handler, isActive) => {
- useInput(
- (ch, key) => {
- if (key.shift && (key.upArrow || key.downArrow)) return;
- handler(ch, key);
- },
- { isActive },
- );
- }}
- />
- {scrollHint && (
-
- ↑↓ scroll · ⌥↑↓ fast · shift+↑↓ history
-
- )}
-
- )}
-
- {isPasteMode && (
-
-
- enter to send · esc to clear
-
-
- )}
- {queued && (
-
-
- message queued — will send when goose finishes
-
-
- )}
-
- );
-});
-
-export interface ToolCallRange {
- responseItemIndex: number;
- startLine: number;
- endLine: number;
-}
-
-export interface ContentLayout {
- lines: React.ReactElement[];
- toolCallRanges: ToolCallRange[];
-}
-
-function buildContentLines({
- turn,
- turnIndex,
- width,
- loading,
- status,
- spinIdx,
- selectedToolCallIdx,
- queuedMessages,
-}: {
- turn: Turn | undefined;
- turnIndex: number;
- width: number;
- loading: boolean;
- status: string;
- spinIdx: number;
- selectedToolCallIdx: number | null;
- queuedMessages: string[];
-}): ContentLayout {
- const lines: React.ReactElement[] = [];
- const toolCallRanges: ToolCallRange[] = [];
- if (!turn) return { lines, toolCallRanges };
-
- const safeWidth = Math.max(width, 20);
-
- const turnId = String(turnIndex);
- lines.push(
- ...renderUserPrompt(
- turn.userText,
- safeWidth,
- turnId,
- (text: string, availableWidth: number) => {
- const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
- const safeWidth = Math.max(availableWidth, 10);
- const maxPreview = Math.max(
- safeWidth - 30,
- Math.min(SENT_PREVIEW_LEN, safeWidth - 10),
- );
- if (flat.length <= maxPreview + 10) {
- return (
-
-
- {flat}
-
-
- );
- }
- const preview = flat.slice(0, maxPreview) + "…";
- const remaining = flat.length - maxPreview;
- return (
-
-
- {preview}
-
-
- {" "}
- ({remaining.toLocaleString()} more chars)
-
-
- );
- },
- ),
- );
-
- let tcIdx = 0;
-
- for (let i = 0; i < turn.responseItems.length; i++) {
- const item = turn.responseItems[i]!;
-
- if (item.itemType === "tool_call") {
- const isSelected = selectedToolCallIdx === tcIdx;
- const rendered = renderToolCallItem(item, i, safeWidth, isSelected);
- const startLine = lines.length;
- lines.push(...rendered);
- toolCallRanges.push({
- responseItemIndex: i,
- startLine,
- endLine: lines.length - 1,
- });
- tcIdx++;
- } else if (item.itemType === "error") {
- lines.push(...renderErrorItem(item, i, safeWidth));
- } else if (item.itemType === "content_chunk") {
- lines.push(...renderContentItem(item, i, safeWidth));
- }
- }
-
- if (loading) {
- lines.push(...renderLoadingIndicator(status, spinIdx, safeWidth));
- }
-
- lines.push(...renderQueuedMessages(queuedMessages, safeWidth));
-
- return { lines, toolCallRanges };
-}
-
-const Viewport = React.memo(function Viewport({
- lines,
- height,
- width,
- scrollOffset,
-}: {
- lines: React.ReactElement[];
- height: number;
- width: number;
- scrollOffset: number;
-}) {
- const total = lines.length;
- 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 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-pad-${i}`, width));
- }
- elements.push(...visible);
-
- if (overflows) {
- const below = total - endIdx;
- elements.push(
-
- {below > 0 ? (
- ▼ {below} more (↓)
- ) : (
-
- )}
- ,
- );
- }
-
- const constrainedWidth = Math.max(width, 10);
- const constrainedHeight = Math.max(height, 1);
-
- return (
-
- {elements}
-
- );
-});
-
-const SplashScreen = React.memo(function SplashScreen({
- animFrame,
- width,
- height,
- status,
- loading,
- spinIdx,
-}: {
- animFrame: number;
- width: number;
- height: number;
- status: string;
- loading: boolean;
- spinIdx: number;
-}) {
- const frame = GOOSE_FRAMES[animFrame % GOOSE_FRAMES.length]!;
- const statusColor =
- status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM;
-
- const contentHeight = frame.length + 1 + 1 + 1 + 2 + 1;
-
- const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
-
- // Use original dimensions for outer container to maintain centering
- const safeWidth = Math.max(width, 20);
- const safeHeight = Math.max(height, 10);
-
- return (
-
- {topPad > 0 && }
-
- {frame.map((line, i) => (
-
- {line}
-
- ))}
-
-
-
- goose
-
-
-
- your on-machine AI agent
-
-
- {loading && }
- {status}
-
-
- );
-});
-
-function App({
- serverConnection,
- initialPrompt,
-}: {
- serverConnection: Stream | string;
- initialPrompt?: string;
-}) {
- const { exit } = useApp();
- const { stdout } = useStdout();
- // `useStdout()` returns the live stream but does not trigger a React
- // re-render when the terminal is resized. Without this subscription the
- // outer Box keeps its old width/height after SIGWINCH, producing a
- // misaligned frame until some other state change forces a render.
- const [termSize, setTermSize] = useState(() => ({
- width: stdout?.columns ?? 80,
- height: stdout?.rows ?? 24,
- }));
- useEffect(() => {
- if (!stdout) return;
- const onResize = () => {
- setTermSize({
- width: stdout.columns ?? 80,
- height: stdout.rows ?? 24,
- });
- };
- stdout.on("resize", onResize);
- return () => {
- stdout.off("resize", onResize);
- };
- }, [stdout]);
- const termWidth = termSize.width;
- const termHeight = termSize.height;
-
- const [turns, setTurns] = useState([]);
- const [input, setInput] = useState("");
- const [loading, setLoading] = useState(true);
- const [status, setStatus] = useState("connecting…");
- const [spinIdx, setSpinIdx] = useState(0);
- const [gooseFrame, setGooseFrame] = useState(0);
- const [bannerVisible, setBannerVisible] = useState(true);
- const [queuedMessages, setQueuedMessages] = useState([]);
-
- const [viewTurnIdx, setViewTurnIdx] = useState(-1);
- const [selectedToolCallIdx, setSelectedToolCallIdx] = useState(
- null,
- );
- const [toolCallExpanded, setToolCallExpanded] = useState(false);
- const [toolCallExpandedScroll, setToolCallExpandedScroll] = useState(0);
- const [scrollOffset, setScrollOffset] = useState(0);
- const [pastedFull, setPastedFull] = useState(null);
- const [needsOnboarding, setNeedsOnboarding] = useState(false);
- type Overlay =
- | { screen: "configure"; intent: ConfigureIntent }
- | { screen: "extensions" }
- | { screen: "diff"; content: string; truncated: boolean };
- const [overlay, setOverlay] = useState(null);
-
- const clientRef = useRef(null);
- const sessionIdRef = useRef(null);
- const sessionCwdRef = useRef(process.cwd());
- const streamBuf = useRef("");
- const sentInitialPrompt = useRef(false);
- const queueRef = useRef([]);
- const isProcessingRef = useRef(false);
-
- // Only run the animation tick when something is actually animating:
- // the splash goose while the banner is up, or the spinner while loading.
- // Otherwise we were re-rendering the entire viewport every 300ms forever,
- // which rebuilds every turn's markdown and can OOM long-running sessions.
- useEffect(() => {
- if (!bannerVisible && !loading) return;
- const t = setInterval(() => {
- if (loading) setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length);
- if (bannerVisible) setGooseFrame((f) => (f + 1) % GOOSE_FRAMES.length);
- }, 300);
- return () => clearInterval(t);
- }, [bannerVisible, loading]);
-
- useEffect(() => {
- if (turns.length > 0) setBannerVisible(false);
- }, [turns]);
-
- useEffect(() => {
- setSelectedToolCallIdx(null);
- setToolCallExpanded(false);
- setToolCallExpandedScroll(0);
- setScrollOffset(0);
- }, [viewTurnIdx, turns.length]);
-
- // Re-layout invalidates any scroll offset we were holding (line counts
- // change with width), so snap back to the latest content on resize.
- useEffect(() => {
- setScrollOffset(0);
- }, [termWidth, termHeight]);
-
- const appendAgent = useCallback((text: string) => {
- setTurns((prev) => {
- if (prev.length === 0) return prev;
- const last = { ...prev[prev.length - 1]! };
- const newItems = [...last.responseItems];
-
- 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,
- },
- };
- } else {
- newItems.push({
- itemType: "content_chunk",
- content: { type: "text", text },
- });
- }
- } else {
- newItems.push({
- itemType: "content_chunk",
- content: { type: "text", text },
- });
- }
-
- return [...prev.slice(0, -1), { ...last, responseItems: newItems }];
- });
- }, []);
-
- const appendError = useCallback((errorMessage: string) => {
- setTurns((prev) => {
- if (prev.length === 0) return prev;
- const last = { ...prev[prev.length - 1]! };
- const newItems = [...last.responseItems];
- newItems.push({ itemType: "error", message: errorMessage });
- 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);
- 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 addUserTurn = useCallback((text: string) => {
- setTurns((prev) => [
- ...prev,
- { userText: text, responseItems: [], toolCallsById: new Map() },
- ]);
- setViewTurnIdx(-1);
- setSelectedToolCallIdx(null);
- setToolCallExpanded(false);
- setToolCallExpandedScroll(0);
- setScrollOffset(0);
- }, []);
-
- const executePrompt = useCallback(
- async (text: string) => {
- const client = clientRef.current;
- const sid = sessionIdRef.current;
- if (!client || !sid) return;
-
- addUserTurn(text);
- setLoading(true);
- setStatus("thinking…");
- streamBuf.current = "";
-
- try {
- const result = await client.prompt({
- sessionId: sid,
- prompt: [{ type: "text", text }],
- });
- if (streamBuf.current) appendAgent("");
- setStatus(
- result.stopReason === "end_turn"
- ? "ready"
- : `stopped: ${result.stopReason}`,
- );
- } catch (e: unknown) {
- const errorMsg = formatError(e);
- setStatus(`error`);
- appendError(errorMsg);
- } finally {
- setLoading(false);
- }
- },
- [appendAgent, appendError, addUserTurn],
- );
-
- 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]);
-
- const sendPrompt = useCallback(
- async (text: string) => {
- await executePrompt(text);
- if (queueRef.current.length > 0) processQueue();
- },
- [executePrompt, processQueue],
- );
-
- const createSession = useCallback(
- async (client: GooseClient) => {
- setStatus("creating session…");
- setLoading(true);
- try {
- const cwd = process.cwd();
- sessionCwdRef.current = cwd;
- const session = await client.newSession({
- cwd,
- mcpServers: [],
- });
- sessionIdRef.current = session.sessionId;
- setLoading(false);
- setStatus("ready");
-
- if (initialPrompt && !sentInitialPrompt.current) {
- sentInitialPrompt.current = true;
- await sendPrompt(initialPrompt);
- setTimeout(() => exit(), 100);
- }
- } catch (e: unknown) {
- const errorMsg = formatError(e);
- setStatus(`failed: ${errorMsg}`);
- setLoading(false);
- }
- },
- [initialPrompt, sendPrompt, exit],
- );
-
- const handleOnboardingComplete = useCallback(() => {
- setNeedsOnboarding(false);
- const client = clientRef.current;
- if (client) createSession(client);
- }, [createSession]);
-
- useEffect(() => {
- let cancelled = false;
-
- (async () => {
- try {
- setStatus("initializing…");
-
- const client = new GooseClient(
- () => ({
- requestPermission: async (
- params: RequestPermissionRequest,
- ): Promise => {
- const optionId = params.options?.[0]?.optionId ?? "approve";
- return {
- outcome: {
- outcome: "selected",
- optionId,
- },
- };
- },
- sessionUpdate: async (params: SessionNotification) => {
- const update = params.update;
- if (update.sessionUpdate === "agent_message_chunk") {
- if (update.content.type === "text") {
- streamBuf.current += update.content.text;
- appendAgent(update.content.text);
- }
- } else if (update.sessionUpdate === "tool_call") {
- handleToolCall(update);
- } else if (update.sessionUpdate === "tool_call_update") {
- handleToolCallUpdate(update);
- }
- },
- }),
- serverConnection,
- );
-
- if (cancelled) return;
- clientRef.current = client;
-
- setStatus("handshaking…");
- await client.initialize({
- protocolVersion: PROTOCOL_VERSION,
- clientInfo: { name: "goose-text", version: "0.1.0" },
- clientCapabilities: {},
- });
- if (cancelled) return;
-
- setStatus("checking provider…");
- let hasProvider = false;
- try {
- const resp = await client.goose.defaultsRead_unstable({});
- hasProvider =
- resp.providerId != null &&
- resp.providerId !== "" &&
- resp.providerId !== "null";
- } catch {
- hasProvider = false;
- }
- if (cancelled) return;
-
- if (!hasProvider && !initialPrompt) {
- setNeedsOnboarding(true);
- setLoading(false);
- setStatus("setup required");
- return;
- }
-
- await createSession(client);
- } catch (e: unknown) {
- if (cancelled) return;
- const errorMsg = formatError(e);
- setStatus(`failed: ${errorMsg}`);
- setLoading(false);
- }
- })();
-
- return () => {
- cancelled = true;
- };
- }, [
- serverConnection,
- initialPrompt,
- createSession,
- appendAgent,
- handleToolCall,
- handleToolCallUpdate,
- exit,
- ]);
-
- const addLocalTurn = useCallback((userText: string, message?: string) => {
- setTurns((prev) => [
- ...prev,
- {
- userText,
- responseItems: message
- ? [
- {
- itemType: "content_chunk",
- content: { type: "text", text: message },
- },
- ]
- : [],
- toolCallsById: new Map(),
- },
- ]);
- setViewTurnIdx(-1);
- setSelectedToolCallIdx(null);
- setToolCallExpanded(false);
- setToolCallExpandedScroll(0);
- setScrollOffset(0);
- }, []);
-
- const runSlashCommand = useCallback(
- (raw: string): boolean => {
- const result = tryRunSlashCommand(raw, {
- cwd: sessionCwdRef.current,
- });
- if (!result.handled) return false;
- if ("overlay" in result && result.overlay === "diff") {
- setOverlay({
- screen: "diff",
- content: result.content,
- truncated: result.truncated,
- });
- return true;
- }
- addLocalTurn(raw, "message" in result ? result.message : undefined);
- return true;
- },
- [addLocalTurn],
- );
-
- const handleSubmit = useCallback(
- (value: string) => {
- const trimmed = value.trim();
- if (!trimmed) return;
- setInput("");
- setPastedFull(null);
- setViewTurnIdx(-1);
- setSelectedToolCallIdx(null);
- setToolCallExpanded(false);
- setToolCallExpandedScroll(0);
- setScrollOffset(0);
-
- if (trimmed.startsWith("/") && runSlashCommand(trimmed)) return;
-
- if (loading || isProcessingRef.current) {
- queueRef.current.push(trimmed);
- setQueuedMessages([...queueRef.current]);
- } else {
- sendPrompt(trimmed);
- }
- },
- [loading, sendPrompt, runSlashCommand],
- );
-
- const PAD_X = 2;
- const PAD_TOP = 0;
- const PAD_BOTTOM = 0;
- const safeTermWidth = Math.max(termWidth, 40);
- const safeTermHeight = Math.max(termHeight, 10);
- const contentWidth = Math.max(safeTermWidth - PAD_X * 2, 20);
-
- const effectiveTurnIdx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx;
- const currentTurn = turns[effectiveTurnIdx];
- const isViewingHistory = viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1;
- const isLatest = !isViewingHistory;
- const showInputBar = !initialPrompt && !isViewingHistory;
-
- const headerH = 2;
- const isPasteMode = pastedFull !== null;
- const inputContentRows = showInputBar
- ? isPasteMode
- ? 1
- : Math.min(Math.max(input.split("\n").length, 1), INPUT_MAX_ROWS)
- : 0;
- const inputExtraLines =
- (isPasteMode ? 1 : 0) + (queuedMessages.length > 0 ? 1 : 0);
- const inputBarH = showInputBar
- ? 2 + inputContentRows + inputExtraLines + 1 // +1 for marginTop gap above input bar
- : 0;
- const historyBarH = isViewingHistory ? 2 : 0;
- const viewportHeight = Math.max(
- safeTermHeight - PAD_TOP - PAD_BOTTOM - headerH - inputBarH - historyBarH,
- 3,
- );
-
- const contentLayout = useMemo(
- () =>
- buildContentLines({
- turn: currentTurn,
- turnIndex: effectiveTurnIdx,
- width: contentWidth,
- loading: isLatest && loading,
- status,
- spinIdx,
- selectedToolCallIdx,
- queuedMessages: isLatest ? queuedMessages : [],
- }),
- [
- currentTurn,
- effectiveTurnIdx,
- contentWidth,
- isLatest,
- loading,
- status,
- spinIdx,
- selectedToolCallIdx,
- queuedMessages,
- ],
- );
- const contentLines = contentLayout.lines;
- const toolCallRanges = contentLayout.toolCallRanges;
-
- useEffect(() => {
- if (
- selectedToolCallIdx !== null &&
- selectedToolCallIdx >= toolCallRanges.length
- ) {
- setSelectedToolCallIdx(
- toolCallRanges.length === 0 ? null : toolCallRanges.length - 1,
- );
- }
- }, [toolCallRanges.length, selectedToolCallIdx]);
-
- const selectedToolCallInfo = useMemo(() => {
- if (selectedToolCallIdx === null || !currentTurn) return null;
- const range = toolCallRanges[selectedToolCallIdx];
- if (!range) return null;
- const item = currentTurn.responseItems[range.responseItemIndex];
- if (!item || item.itemType !== "tool_call") return null;
- return {
- toolCallId: item.toolCallId,
- title: item.title,
- status: item.status ?? "pending",
- kind: item.kind,
- rawInput: item.rawInput,
- rawOutput: item.rawOutput,
- content: item.content,
- locations: item.locations,
- };
- }, [selectedToolCallIdx, toolCallRanges, currentTurn]);
-
- // Compute a scroll offset that keeps the given tool-call range fully
- // visible, moving just enough from the current offset. scrollOffset is
- // measured in lines-from-bottom, matching Viewport's math.
- const scrollOffsetForRange = useCallback(
- (range: ToolCallRange, current: number): number => {
- const total = contentLines.length;
- const overflows = total > viewportHeight;
- const contentHeight = overflows
- ? Math.max(viewportHeight - 2, 1)
- : viewportHeight;
- if (!overflows) return 0;
- const maxOffset = total - contentHeight;
- const minForTop = total - range.startLine - contentHeight;
- const maxForBottom = total - range.endLine - 1;
- const lo = Math.max(0, minForTop);
- const hi = Math.max(lo, Math.min(maxOffset, maxForBottom));
- if (current < lo) return lo;
- if (current > hi) return hi;
- return current;
- },
- [contentLines.length, viewportHeight],
- );
-
- const moveSelection = useCallback(
- (direction: -1 | 1) => {
- if (toolCallRanges.length === 0) return false;
- let nextIdx: number;
- if (selectedToolCallIdx === null) {
- nextIdx = direction === -1 ? toolCallRanges.length - 1 : 0;
- } else {
- nextIdx = selectedToolCallIdx + direction;
- if (nextIdx < 0 || nextIdx >= toolCallRanges.length) return false;
- }
- setSelectedToolCallIdx(nextIdx);
- const range = toolCallRanges[nextIdx]!;
- setScrollOffset((prev) => scrollOffsetForRange(range, prev));
- return true;
- },
- [toolCallRanges, selectedToolCallIdx, scrollOffsetForRange],
- );
-
- useInput(
- (ch, key) => {
- if (toolCallExpanded) return;
-
- if (key.escape || (ch === "c" && key.ctrl)) {
- if (key.escape && pastedFull !== null) return;
- exit();
- }
-
- if (!loading && sessionIdRef.current) {
- if (key.ctrl && (ch === "p" || ch === "P")) {
- setOverlay({ screen: "configure", intent: "provider" });
- return;
- }
- if (key.ctrl && (ch === "m" || ch === "M")) {
- setOverlay({ screen: "configure", intent: "model" });
- return;
- }
- if (key.ctrl && (ch === "e" || ch === "E")) {
- setOverlay({ screen: "extensions" });
- return;
- }
- if (ch === "g" && key.ctrl) {
- setOverlay({ screen: "configure", intent: "provider" });
- return;
- }
- }
-
- const viewingHistory =
- viewTurnIdx !== -1 && viewTurnIdx < turns.length - 1;
- const multilineOwnsArrows =
- !initialPrompt &&
- !viewingHistory &&
- pastedFull === null &&
- input.includes("\n");
-
- if (ch === " " && selectedToolCallIdx !== null) {
- setToolCallExpandedScroll(0);
- setToolCallExpanded(true);
- return;
- }
-
- if ((key.upArrow || key.downArrow) && !key.shift) {
- if (multilineOwnsArrows) return;
-
- if (key.meta) {
- const step = SCROLL_STEP * SCROLL_FAST_MULTIPLIER;
- if (key.upArrow) {
- setScrollOffset((prev) => prev + step);
- } else {
- setScrollOffset((prev) => Math.max(prev - step, 0));
- }
- return;
- }
-
- if (toolCallRanges.length > 0) {
- const direction: -1 | 1 = key.upArrow ? -1 : 1;
- if (moveSelection(direction)) return;
- if (selectedToolCallIdx !== null) {
- setSelectedToolCallIdx(null);
- }
- }
-
- const step = SCROLL_STEP;
- if (key.upArrow) {
- setScrollOffset((prev) => prev + step);
- } else {
- setScrollOffset((prev) => Math.max(prev - step, 0));
- }
- return;
- }
-
- if (key.upArrow && key.shift) {
- setTurns((cur) => {
- if (cur.length <= 1) return cur;
- setViewTurnIdx((prev) => {
- const eff = prev === -1 ? cur.length - 1 : prev;
- return Math.max(eff - 1, 0);
- });
- return cur;
- });
- return;
- }
- if (key.downArrow && key.shift) {
- setTurns((cur) => {
- if (cur.length <= 1) return cur;
- setViewTurnIdx((prev) => {
- if (prev === -1) return -1;
- const next = prev + 1;
- return next >= cur.length ? -1 : next;
- });
- return cur;
- });
- return;
- }
- },
- { isActive: !needsOnboarding && !overlay },
- );
-
- if (needsOnboarding && clientRef.current) {
- return (
-
-
-
- );
- }
-
- if (overlay && overlay.screen === "diff") {
- return (
- setOverlay(null)}
- />
- );
- }
-
- if (overlay && clientRef.current && sessionIdRef.current) {
- if (overlay.screen === "configure") {
- const intent = overlay.intent;
- return (
-
- {
- setOverlay(null);
- setStatus("ready");
- }}
- onCancel={() => setOverlay(null)}
- initialIntent={intent}
- />
-
- );
- } else if (overlay.screen === "extensions") {
- return (
-
- setOverlay(null)}
- />
-
- );
- }
- }
-
- return (
-
- {bannerVisible ? (
-
- ) : (
- <>
- 1
- ? { current: effectiveTurnIdx + 1, total: turns.length }
- : undefined
- }
- />
-
- {toolCallExpanded && selectedToolCallInfo ? (
- {
- setToolCallExpanded(false);
- setToolCallExpandedScroll(0);
- }}
- />
- ) : (
-
- )}
-
- {isViewingHistory && (
-
-
-
-
- turn {effectiveTurnIdx + 1}/{turns.length}
-
- — shift+↓ to return
-
-
- )}
- >
- )}
- {showInputBar && (
- 0}
- scrollHint={!bannerVisible && turns.length > 1}
- placeholder={bannerVisible ? INITIAL_GREETING : undefined}
- focused={showInputBar}
- pastedFull={pastedFull}
- onPastedFullChange={setPastedFull}
- />
- )}
-
- );
-}
-
-const cli = meow(
- `
- Usage
- $ goose
-
- Options
- --server, -s Server URL (default: auto-launch bundled server)
- --text, -t Send a single prompt and exit
-`,
- {
- importMeta: import.meta,
- flags: {
- server: { type: "string", shortFlag: "s" },
- text: { type: "string", shortFlag: "t" },
- },
- },
-);
-
-let serverProcess: ReturnType | null = null;
-
-async function runTextMode(serverConnection: Stream | string, prompt: string) {
- try {
- const client = new GooseClient(
- () => ({
- requestPermission: async (
- params: RequestPermissionRequest,
- ): Promise => {
- const optionId = params.options?.[0]?.optionId ?? "approve";
- return {
- outcome: {
- outcome: "selected",
- optionId,
- },
- };
- },
- sessionUpdate: async (params: SessionNotification) => {
- const update = params.update;
- if (update.sessionUpdate === "agent_message_chunk") {
- if (update.content.type === "text") {
- process.stdout.write(update.content.text);
- }
- }
- },
- }),
- serverConnection,
- );
-
- await client.initialize({
- protocolVersion: PROTOCOL_VERSION,
- clientInfo: { name: "goose-text", version: "0.1.0" },
- clientCapabilities: {},
- });
-
- const session = await client.newSession({
- cwd: process.cwd(),
- mcpServers: [],
- });
-
- await client.prompt({
- sessionId: session.sessionId,
- prompt: [{ type: "text", text: prompt }],
- });
-
- process.stdout.write("\n");
- } catch (e: unknown) {
- const errMsg = e instanceof Error ? e.message : String(e);
- console.error(`Error: ${errMsg}`);
- process.exit(1);
- }
-}
-
-async function main() {
- let serverConnection: Stream | string;
-
- if (cli.flags.server) {
- serverConnection = cli.flags.server;
- } else {
- const binary = resolveGooseBinary();
- serverProcess = spawn(binary, ["acp"], {
- stdio: ["pipe", "pipe", "ignore"],
- detached: false,
- });
-
- serverProcess.on("error", (err) => {
- console.error(`Failed to start goose acp: ${err.message}`);
- process.exit(1);
- });
-
- const output = Writable.toWeb(
- serverProcess.stdin!,
- ) as WritableStream;
- const input = Readable.toWeb(
- serverProcess.stdout!,
- ) as ReadableStream;
- serverConnection = ndJsonStream(output, input);
- }
-
- // Text mode: bypass TUI and stream directly to stdout
- if (cli.flags.text) {
- await runTextMode(serverConnection, cli.flags.text);
- cleanup();
- return;
- }
-
- // Interactive TUI mode
- const { waitUntilExit } = render(
- ,
- );
-
- await waitUntilExit();
- cleanup();
-}
-
-function cleanup() {
- if (serverProcess && !serverProcess.killed) {
- serverProcess.kill();
- }
-}
-
-process.on("exit", cleanup);
-process.on("SIGINT", () => {
- cleanup();
- process.exit(0);
-});
-process.on("SIGTERM", () => {
- cleanup();
- process.exit(0);
-});
-
-main().catch((err) => {
- console.error(err);
- cleanup();
- process.exit(1);
-});
diff --git a/ui/text/src/types.tsx b/ui/text/src/types.tsx
deleted file mode 100644
index cbac0622b..000000000
--- a/ui/text/src/types.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { ContentChunk, ToolCall } from "@agentclientprotocol/sdk";
-
-export type ResponseItem =
- | (ContentChunk & { itemType: "content_chunk" })
- | (ToolCall & { itemType: "tool_call" })
- | { itemType: "error"; message: string };
-
-export interface Turn {
- userText: string;
- responseItems: ResponseItem[];
- toolCallsById: Map;
-}
diff --git a/ui/text/src/utils.tsx b/ui/text/src/utils.tsx
deleted file mode 100644
index a379f3999..000000000
--- a/ui/text/src/utils.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-export function isErrorStatus(status: string): boolean {
- return status.startsWith("error") || status.startsWith("failed");
-}
-
-export function formatError(e: unknown): string {
- if (e instanceof Error) {
- return e.message || e.toString();
- }
- if (typeof e === "string") {
- return e;
- }
- if (e && typeof e === "object") {
- try {
- return JSON.stringify(e, null, 2);
- } catch {
- return String(e);
- }
- }
- return String(e);
-}
diff --git a/ui/text/tsconfig.json b/ui/text/tsconfig.json
deleted file mode 100644
index c3ee214ac..000000000
--- a/ui/text/tsconfig.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2022",
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
- "jsx": "react-jsx",
- "strict": true,
- "esModuleInterop": true,
- "skipLibCheck": true,
- "outDir": "dist",
- "rootDir": "src",
- "declaration": true,
- "resolveJsonModule": true
- },
- "include": ["src"]
-}