diff --git a/AGENTS.md b/AGENTS.md index 0f01fcde5..7f980606a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,23 +85,6 @@ ui/desktop/ # Electron app - Simplicity: Avoid overly defensive code - trust Rust's type system - Logging: Clean up existing logs, don't add more unless for errors or security events -## Ink / Terminal UI (ui/text) - -- Ink renders React to a fixed character grid — not a browser. Content that exceeds a Box's dimensions is NOT clipped; it visually overflows into neighboring cells and breaks the layout. - -- Ink-Text: Never use `wrap="wrap"` inside a fixed-height Box — wrapped text can exceed the Box height and bleed into adjacent components. Use `wrap="truncate"` and pre-truncate the string to fit the available character budget (lines × width). - -- Ink-Layout: When changing card/cell dimensions, always recalculate how much content fits. Account for borders (2 chars), padding, margins, and sibling elements when computing the -remaining space for dynamic text. - -- Ink-Overflow: Ink has no `overflow: hidden`. The only way to prevent overflow is to ensure content never exceeds the container size — truncate text, limit list items, or cap height. - -- Ink-FlexGrow: Avoid `flexGrow={1}` on text containers inside fixed-height cards — the text will try to fill available space but Ink won't clip it if it exceeds the boundary. - -- Ink-HeightBudget: When computing how many rows/items fit vertically, count EVERY line used by headers, footers, margins, borders, and scroll indicators. Under-reserving vertical space (e.g., `height - 8` when chrome actually uses 16 lines) causes Ink to squeeze out margins between items, making borders collapse. Always audit the actual line count. - -- Ink-TrailingMargin: Don't apply `marginBottom` to the last item in a list — it wastes a line and can push content out of the container. Use conditional margins or container `gap`. - ## Never - Never: Recreate `ui/desktop/src/api` or add `@hey-api/openapi-ts` to `ui/desktop` diff --git a/documentation/docs/guides/acp-clients.md b/documentation/docs/guides/acp-clients.md index 44c68faf5..b1203a9e7 100644 --- a/documentation/docs/guides/acp-clients.md +++ b/documentation/docs/guides/acp-clients.md @@ -165,49 +165,13 @@ All MCP servers in `context_servers` are automatically available to goose, provi If a server in `context_servers` has the same name as a goose extension, goose uses its own [configuration](/docs/guides/config-files). ::: -## TUI Client +## TUI Client (Deprecated) -For terminal-based workflows, goose provides a TUI (Terminal User Interface) client that communicates with goose via ACP. This is useful for developers who prefer working entirely in the terminal or need a lightweight alternative to the desktop app. +:::warning Deprecated +The experimental terminal UI (TUI) client, formerly published to npm as `@aaif/goose`, is no longer maintained and has been removed. Use the [CLI](/docs/getting-started/installation) or the desktop app instead. +::: -### Features - -- **Full terminal-based chat interface** - Interactive conversation UI rendered directly in your terminal -- **Real-time streaming responses** - See goose's responses as they're generated -- **Tool call visualization** - View tool executions with status indicators, inputs, and outputs -- **Permission dialogs** - Approve or reject tool permissions inline -- **Keyboard navigation** - Navigate conversation history and scroll through responses -- **Markdown rendering** - Formatted output for code blocks, lists, and other markdown elements -- **Message queuing** - Queue messages while goose is processing - -### Installation - -```bash -cd ui/text -npm install -``` - -### Running the TUI - -**Option 1: Auto-launch server (recommended)** - -The TUI will automatically start the goose acp server if you have it installed: - -```bash -npm start -``` - -**Option 2: Connect to a custom server** - -For servers that support the draft standard ACP over Streamable HTTP https://github.com/agentclientprotocol/agent-client-protocol/pull/721 - -```bash -npm start -- --server http://HOST:PORT - -# example server -GOOSE_SERVER__SECRET_KEY='a-long-random-secret' cargo run -p goose-cli --bin goose -- serve -``` - -### Server Authentication +## Server Authentication Set the `GOOSE_SERVER__SECRET_KEY` environment variable to authenticate the ACP endpoint. `goose serve` refuses to start without this secret unless you explicitly pass `--dangerously-unauthenticated`: diff --git a/ui/goose-binary/README.md b/ui/goose-binary/README.md index 852fdad91..e29597735 100644 --- a/ui/goose-binary/README.md +++ b/ui/goose-binary/README.md @@ -48,12 +48,10 @@ For manual publishing: ./ui/scripts/publish.sh --real ``` -This will publish all native packages along with `@aaif/goose-sdk` and `@aaif/goose`. +This will publish all native packages along with `@aaif/goose-sdk`. ## Usage -These packages are installed as optional dependencies by `@aaif/goose` (the TUI). -The appropriate package for the user's platform is automatically selected during -installation. - -See `ui/text/scripts/postinstall.mjs` for how the binary path is resolved. +These packages are installed as optional dependencies by `@aaif/goose-sdk`, which +resolves the appropriate package for the user's platform automatically. See +`ui/sdk/src/resolve-binary.ts` for how the binary path is resolved. diff --git a/ui/scripts/publish.sh b/ui/scripts/publish.sh index 4aa163795..331ddc894 100755 --- a/ui/scripts/publish.sh +++ b/ui/scripts/publish.sh @@ -4,7 +4,9 @@ set -euo pipefail # Builds and publishes all @aaif npm packages: # @aaif/goose-sdk — ACP TypeScript SDK # @aaif/goose-binary-* — platform-specific goose CLI binaries -# @aaif/goose — TUI that depends on the above +# +# NOTE: @aaif/goose (the terminal TUI, formerly ui/text) is DEPRECATED and no +# longer built or published. See ui/text/README.md. # # Linux binaries are built inside Docker containers on their native arch. # macOS binaries are built natively (requires macOS host with Rust). @@ -23,7 +25,6 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" NATIVE_DIR="${REPO_ROOT}/ui/goose-binary" SDK_DIR="${REPO_ROOT}/ui/sdk" -TEXT_DIR="${REPO_ROOT}/ui/text" REGISTRY="https://registry.npmjs.org" DOCKER_IMAGE="rust:1.92-bookworm" @@ -174,9 +175,6 @@ echo "" echo "==> Building @aaif/goose-sdk" (cd "${SDK_DIR}" && pnpm run build:ts) -echo "==> Building @aaif/goose" -(cd "${TEXT_DIR}" && pnpm run build) - # --------------------------------------------------------------------------- # Step 5: Publish # --------------------------------------------------------------------------- @@ -220,9 +218,6 @@ for plat in darwin-arm64 darwin-x64 linux-arm64 linux-x64; do (cd "${REPO_ROOT}/ui" && pnpm publish "${PUBLISH_ARGS[@]}" "${pkg}") done -echo "==> Publishing @aaif/goose" -(cd "${REPO_ROOT}/ui" && pnpm publish "${PUBLISH_ARGS[@]}" text) - echo "" if [[ "${DRY_RUN}" == "true" ]]; then echo "✅ Dry run complete. Pass --real to publish for real." diff --git a/ui/sdk/README.md b/ui/sdk/README.md index ce845fbf6..32dd40096 100644 --- a/ui/sdk/README.md +++ b/ui/sdk/README.md @@ -44,14 +44,14 @@ npm run build:native:all ### Local Development with npm link -To use this package locally in another project (e.g., `@aaif/goose`): +To use this package locally in another project: ```bash # In ui/sdk npm run build npm link -# In ui/text (or another project) +# In the consuming project npm link @aaif/goose-sdk ``` @@ -113,7 +113,6 @@ For manual publishing: This will: 1. Build and publish `@aaif/goose-sdk` 2. Publish all native binary packages -3. Publish `@aaif/goose` (which depends on the above) ## Usage diff --git a/ui/text/.gitignore b/ui/text/.gitignore deleted file mode 100644 index de4d1f007..000000000 --- a/ui/text/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist -node_modules diff --git a/ui/text/AGENTS.md b/ui/text/AGENTS.md deleted file mode 100644 index 1344f1f54..000000000 --- a/ui/text/AGENTS.md +++ /dev/null @@ -1,446 +0,0 @@ -# AGENTS.md - Working with Ink CLI Applications - -## Overview - -Ink is a React renderer for building command-line interfaces. Unlike web React, Ink renders to terminal output with strict constraints. This guide helps AI agents understand the unique considerations when writing React code for Ink applications. - -## Key Differences from Web React - -### 1. Terminal Rendering Environment -- **Fixed-width character grid**: Terminals use monospace fonts with fixed character cells -- **No pixel-based layouts**: Everything is measured in character columns and rows -- **Text-only output**: No images, videos, or rich media -- **Limited color support**: 16 colors, 256 colors, or RGB depending on terminal -- **No mouse interaction**: Primarily keyboard-driven (unless terminal supports mouse) - -### 2. Layout System -- **Flexbox only**: All elements use `display: flex` by default -- **No CSS**: Styling is done through component props, not CSS classes -- **Character-based dimensions**: Width/height measured in characters, not pixels -- **No scrolling**: Content that exceeds terminal bounds is clipped or wrapped - -## Text Handling and Overflow - -### Text Wrapping -Text in Ink has specific wrapping behaviors controlled by the `wrap` prop: - -```jsx -// Default wrapping - breaks at word boundaries - - Hello World - -// Output: "Hello\nWorld" - -// Hard wrapping - breaks anywhere to fill width - - Hello World - -// Output: "Hello W\norld" - -// Truncation options - - Hello World - -// Output: "Hello…" - - - Hello World - -// Output: "He…ld" -``` - -### Common Text Overflow Issues -❌ **Don't assume unlimited width:** -```jsx -// BAD - Text may overflow terminal width -This is a very long line that might exceed the terminal width and cause layout issues -``` - -✅ **Do constrain text appropriately:** -```jsx -// GOOD - Constrain width and handle wrapping - - This is a very long line that will wrap properly within the container - -``` - -## Layout Constraints and Best Practices - -### 1. Terminal Width Awareness -Always consider terminal width limitations: - -```jsx -import {useWindowSize} from 'ink'; - -const ResponsiveComponent = () => { - const {columns} = useWindowSize(); - - return ( - {/* Leave margin, cap at 80 */} - Content that adapts to terminal size - - ); -}; -``` - -### 2. Vertical Space Management -Terminal height is limited - avoid excessive vertical content: - -❌ **Don't create unlimited vertical lists:** -```jsx -// BAD - Could exceed terminal height -{items.map(item => ( - - {item.title} - -))} -``` - -✅ **Do implement pagination or scrolling:** -```jsx -// GOOD - Paginate or limit visible items -const visibleItems = items.slice(currentPage * pageSize, (currentPage + 1) * pageSize); -return ( - <> - {visibleItems.map(item => ( - - {item.title} - - ))} - Page {currentPage + 1} of {Math.ceil(items.length / pageSize)} - -); -``` - -### 3. Flexbox Layout Patterns - -**Horizontal layouts:** -```jsx -// Side-by-side content - - - Left panel - - - Right panel - - - -// Label-value pairs - - Status: - - Running - - -``` - -**Vertical layouts:** -```jsx -// Stacked content - - Header - - Main content - - Footer - -``` - -## Ink-Specific Components - -### 1. Text Component -- **All text must be wrapped in ``** -- Only text nodes and nested `` components allowed inside -- No `` or other components inside `` - -```jsx -// ✅ Correct -Success: Operation completed - -// ❌ Incorrect -Status: Running -``` - -### 2. Box Component -- Primary layout component (like `
` 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"] -}