Deprecate and remove ui/text TUI (#10799)

This commit is contained in:
Alex Hancock
2026-07-29 14:45:39 -04:00
committed by GitHub
parent 40380ce6d0
commit dd45906cb8
30 changed files with 27 additions and 5203 deletions
-17
View File
@@ -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`
+5 -41
View File
@@ -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`:
+4 -6
View File
@@ -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.
+3 -8
View File
@@ -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."
+2 -3
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
dist
node_modules
-446
View File
@@ -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
<Box width={10}>
<Text>Hello World</Text>
</Box>
// Output: "Hello\nWorld"
// Hard wrapping - breaks anywhere to fill width
<Box width={7}>
<Text wrap="hard">Hello World</Text>
</Box>
// Output: "Hello W\norld"
// Truncation options
<Box width={7}>
<Text wrap="truncate">Hello World</Text>
</Box>
// Output: "Hello…"
<Box width={7}>
<Text wrap="truncate-middle">Hello World</Text>
</Box>
// Output: "He…ld"
```
### Common Text Overflow Issues
**Don't assume unlimited width:**
```jsx
// BAD - Text may overflow terminal width
<Text>This is a very long line that might exceed the terminal width and cause layout issues</Text>
```
**Do constrain text appropriately:**
```jsx
// GOOD - Constrain width and handle wrapping
<Box width="80%">
<Text wrap="wrap">This is a very long line that will wrap properly within the container</Text>
</Box>
```
## 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 (
<Box width={Math.min(columns - 4, 80)}> {/* Leave margin, cap at 80 */}
<Text>Content that adapts to terminal size</Text>
</Box>
);
};
```
### 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 => (
<Box key={item.id} height={3}>
<Text>{item.title}</Text>
</Box>
))}
```
**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 => (
<Box key={item.id}>
<Text>{item.title}</Text>
</Box>
))}
<Text dimColor>Page {currentPage + 1} of {Math.ceil(items.length / pageSize)}</Text>
</>
);
```
### 3. Flexbox Layout Patterns
**Horizontal layouts:**
```jsx
// Side-by-side content
<Box>
<Box width="50%">
<Text>Left panel</Text>
</Box>
<Box width="50%">
<Text>Right panel</Text>
</Box>
</Box>
// Label-value pairs
<Box>
<Text>Status: </Text>
<Box flexGrow={1}>
<Text color="green">Running</Text>
</Box>
</Box>
```
**Vertical layouts:**
```jsx
// Stacked content
<Box flexDirection="column">
<Text>Header</Text>
<Box flexGrow={1}>
<Text>Main content</Text>
</Box>
<Text>Footer</Text>
</Box>
```
## Ink-Specific Components
### 1. Text Component
- **All text must be wrapped in `<Text>`**
- Only text nodes and nested `<Text>` components allowed inside
- No `<Box>` or other components inside `<Text>`
```jsx
// ✅ Correct
<Text color="green">Success: <Text bold>Operation completed</Text></Text>
// ❌ Incorrect
<Text>Status: <Box><Text>Running</Text></Box></Text>
```
### 2. Box Component
- Primary layout component (like `<div>` 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
<Static items={completedTasks}>
{task => (
<Box key={task.id}>
<Text color="green"> {task.name}</Text>
</Box>
)}
</Static>
```
### 4. Spacer Component
- Flexible space that expands along the major axis
- Useful for pushing content to edges
```jsx
<Box>
<Text>Left</Text>
<Spacer />
<Text>Right</Text>
</Box>
```
## 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 <Text>Press 'q' to quit</Text>;
};
```
### Focus Management
```jsx
import {useFocus} from 'ink';
const FocusableComponent = () => {
const {isFocused} = useFocus();
return (
<Text color={isFocused ? 'blue' : 'white'}>
{isFocused ? '> ' : ' '}Focusable item
</Text>
);
};
```
## 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}) => (
<Text color="blue">Status: {status}</Text>
));
// 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 <Text>{chars[frame % chars.length]}</Text>;
};
```
### 3. Control Frame Rate
```jsx
// Limit updates for better performance
render(<App />, {
maxFps: 30, // Default is 30, lower for less CPU usage
});
```
## Common Pitfalls and Solutions
### 1. Text Overflow
**Problem:** Text exceeds terminal width
```jsx
<Text>Very long text that might overflow the terminal width causing display issues</Text>
```
**Solution:** Use width constraints and wrapping
```jsx
<Box width="100%">
<Text wrap="wrap">Very long text that might overflow the terminal width causing display issues</Text>
</Box>
```
### 2. Nested Box Issues
**Problem:** Unnecessary nesting causing layout issues
```jsx
<Box>
<Box>
<Box>
<Text>Over-nested content</Text>
</Box>
</Box>
</Box>
```
**Solution:** Flatten structure when possible
```jsx
<Box padding={1}>
<Text>Properly structured content</Text>
</Box>
```
### 3. Color and Styling
**Problem:** Assuming rich styling support
```jsx
<Text style={{fontSize: '16px', fontFamily: 'Arial'}}>Styled text</Text>
```
**Solution:** Use Ink's supported styling props
```jsx
<Text color="blue" bold underline>Styled text</Text>
```
### 4. Dynamic Content Height
**Problem:** Unlimited dynamic content
```jsx
{messages.map(msg => (
<Text key={msg.id}>{msg.content}</Text>
))}
```
**Solution:** Implement scrolling or pagination
```jsx
const visibleMessages = messages.slice(-maxVisible);
return (
<Box flexDirection="column" height={maxVisible}>
{visibleMessages.map(msg => (
<Text key={msg.id}>{msg.content}</Text>
))}
</Box>
);
```
## Testing Terminal UIs
### 1. Use ink-testing-library
```jsx
import {render} from 'ink-testing-library';
const {lastFrame, stdin} = render(<MyComponent />);
// 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(<MyComponent />, {columns: 40});
expect(lastFrame()).toMatch(/Wrapped content/);
```
## Accessibility Considerations
### Screen Reader Support
```jsx
// Provide meaningful labels
<Box aria-role="checkbox" aria-state={{checked: true}}>
<Text>Accept terms</Text>
</Box>
// Use descriptive labels for progress indicators
<Box>
<Box width="50%" backgroundColor="green" />
<Text aria-label="Progress: 50%">50%</Text>
</Box>
```
## 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 `<Box>` and `<Text>`
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 (
<Box flexDirection="column" width={maxWidth}>
<Box borderStyle="round" padding={1}>
<Text bold>Task List ({tasks.length})</Text>
</Box>
<Box flexDirection="column" marginTop={1}>
{tasks.map((task, index) => (
<Box key={task.id} backgroundColor={index === selectedIndex ? 'blue' : undefined}>
<Text color={task.completed ? 'green' : 'white'}>
{task.completed ? '✓' : '○'}
</Text>
<Text> </Text>
<Box width="100%">
<Text wrap="truncate">{task.title}</Text>
</Box>
<Spacer />
<Text dimColor>{task.priority}</Text>
</Box>
))}
</Box>
<Box marginTop={1}>
<Text dimColor>Use to navigate</Text>
</Box>
</Box>
);
};
```
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
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+13 -37
View File
@@ -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.
-51
View File
@@ -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"
}
}
-44
View File
@@ -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,
});
-7
View File
@@ -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";
-135
View File
@@ -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 <Box key={key} width={width} height={1}><Text> </Text></Box>;
}
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),
<Box key={`u-prompt-${turnId}`} width={width} height={1}>
<Text color={CRANBERRY} bold>{" "}</Text>
<Box width={constrainedWidth}>
{collapsedUserPrompt(userText, constrainedWidth)}
</Box>
</Box>,
];
}
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),
<Box key={`err-box-${index}`} width={width} height={1}>
<Text color={CRANBERRY} bold>{"⚠ Error: "}</Text>
</Box>,
];
const errorLines = item.message.split("\n");
errorLines.forEach((line, j) => {
lines.push(
<Box key={`err-${index}-${j}`} width={width} height={1}>
<Box width={width}>
<Text color={CRANBERRY} wrap="truncate">{line}</Text>
</Box>
</Box>
);
});
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(
<Box key={`md-${index}-${j}`} width={width} height={1}>
<Box width={constrainedWidth}>
<Text wrap="truncate">{mdLine}</Text>
</Box>
</Box>
);
});
return lines;
}
export function renderLoadingIndicator(
status: string,
spinIdx: number,
width: number
): React.ReactElement[] {
return [
emptyLine("ld-gap", width),
<Box key="ld" width={width} height={1}>
<Spinner idx={spinIdx} />
<Text color={TEXT_DIM} italic> {status}</Text>
</Box>,
];
}
export function renderQueuedMessages(
queuedMessages: string[],
width: number
): React.ReactElement[] {
const messageWidth = Math.max(width - 20, 10);
return queuedMessages.map((message, i) => (
<Box key={`q-${i}`} width={width} height={1}>
<Text color={TEXT_DIM}>{" "}</Text>
<Box width={messageWidth}>
<Text wrap="truncate-end" color={TEXT_DIM}>{message}</Text>
</Box>
<Text color={GOLD} dimColor> (queued)</Text>
</Box>
));
}
-211
View File
@@ -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 (
<Box
flexDirection="column"
width={width}
height={height}
paddingX={PAD_X}
paddingY={PAD_Y}
>
<Box width={innerWidth} justifyContent="space-between" flexShrink={0}>
<Text color={TEXT_PRIMARY} bold>
git diff{truncated ? " (truncated)" : ""}
</Text>
<Text color={TEXT_DIM}>
{atStart ? "" : "↑ "}lines {scroll + 1}
{Math.min(scroll + viewportHeight, lines.length)} / {lines.length}
{" "}[{position}]
</Text>
</Box>
<Box flexDirection="column" width={innerWidth} height={viewportHeight}>
{visible.map((line, i) => {
const kind = classifyLine(line);
const padded = padLine(line, innerWidth);
switch (kind) {
case "add":
return (
<Text
key={i}
wrap="truncate-end"
color={TEXT_PRIMARY}
backgroundColor={TEAL}
>
{padded}
</Text>
);
case "remove":
return (
<Text
key={i}
wrap="truncate-end"
color={TEXT_PRIMARY}
backgroundColor={CRANBERRY}
>
{padded}
</Text>
);
case "hunk":
return (
<Text key={i} wrap="truncate-end" color={GOLD} bold>
{padded}
</Text>
);
case "meta":
return (
<Text key={i} wrap="truncate-end" color={TEXT_SECONDARY} bold>
{padded}
</Text>
);
default:
return (
<Text key={i} wrap="truncate-end" color={TEXT_PRIMARY}>
{padded}
</Text>
);
}
})}
</Box>
<Box width={innerWidth} flexShrink={0}>
<Text color={GOLD}>q</Text>
<Text color={TEXT_DIM}> close · </Text>
<Text color={GOLD}></Text>
<Text color={TEXT_DIM}>/</Text>
<Text color={GOLD}>j k</Text>
<Text color={TEXT_DIM}> scroll · </Text>
<Text color={GOLD}>space</Text>
<Text color={TEXT_DIM}>/</Text>
<Text color={GOLD}>b</Text>
<Text color={TEXT_DIM}> page · </Text>
<Text color={GOLD}>g</Text>
<Text color={TEXT_DIM}>/</Text>
<Text color={GOLD}>G</Text>
<Text color={TEXT_DIM}> top/bottom</Text>
</Box>
</Box>
);
}
-35
View File
@@ -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 (
<Box flexDirection="column" paddingX={2} width={maxWidth}>
<Text color={CRANBERRY} bold> Setup error</Text>
{errorMsg && (
<Box width={maxWidth - 4}>
<Text color={TEXT_PRIMARY} wrap="wrap">{errorMsg}</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={TEXT_DIM}>press enter to retry</Text>
</Box>
</Box>
);
});
-55
View File
@@ -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 (
<Box flexDirection="column" width={constrainedWidth} flexShrink={0}>
<Box justifyContent="space-between" width={constrainedWidth}>
<Box width={leftSideWidth}>
<Text color={TEXT_PRIMARY} bold>goose</Text>
<Text color={RULE_COLOR}> · </Text>
<Box flexShrink={1}>
<Text color={statusColor} wrap="truncate-end">{status}</Text>
</Box>
{loading && (
<Text> <Spinner idx={spinIdx} /></Text>
)}
</Box>
<Box width={rightSideWidth} justifyContent="flex-end">
{turnInfo && turnInfo.total > 1 && (
<Text color={TEXT_DIM}>
{turnInfo.current}/{turnInfo.total}{" "}
</Text>
)}
<Text color={TEXT_DIM}>^E exts · ^M models · ^P providers</Text>
</Box>
</Box>
<Rule width={constrainedWidth} />
</Box>
);
});
-12
View File
@@ -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 <Text color={RULE_COLOR}>{"─".repeat(ruleWidth)}</Text>;
});
-19
View File
@@ -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 (
<Text color={CRANBERRY}>
{SPINNER_FRAMES[idx % SPINNER_FRAMES.length]}
</Text>
);
});
export { SPINNER_FRAMES };
-273
View File
@@ -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<string, string> = {
pending: TEXT_DIM,
in_progress: GOLD,
completed: TEAL,
failed: CRANBERRY,
};
function wrapOrTruncate(text: string, width: number): string[] {
const safeWidth = Math.max(width, 10);
const out: string[] = [];
for (const rawLine of text.split("\n")) {
if (rawLine.length <= safeWidth) {
out.push(rawLine);
continue;
}
let remaining = rawLine;
while (remaining.length > safeWidth) {
out.push(remaining.slice(0, safeWidth));
remaining = remaining.slice(safeWidth);
}
if (remaining.length > 0) out.push(remaining);
}
return out;
}
function extractContentText(content: ToolCallContent[] | undefined): string {
if (!content || content.length === 0) return "";
const parts: string[] = [];
for (const item of content) {
if (item.type === "content") {
const block = item.content;
if (block.type === "text" && block.text) {
parts.push(block.text);
} else if (block.type === "resource_link") {
parts.push(`🔗 ${block.uri}`);
} else if (block.type === "image") {
parts.push(`🖼 image (${block.mimeType ?? "unknown"})`);
} else if (block.type === "audio") {
parts.push(`🎵 audio (${block.mimeType ?? "unknown"})`);
} else if (block.type === "resource") {
const res = block.resource as { uri?: string; text?: string };
if (res.text) {
parts.push(res.text);
} else if (res.uri) {
parts.push(`📎 ${res.uri}`);
}
}
} else if (item.type === "diff") {
const header = `📝 diff: ${item.path}`;
const old = item.oldText ?? "";
parts.push(
[
header,
...(old ? old.split("\n").map((l) => `- ${l}`) : []),
...item.newText.split("\n").map((l) => `+ ${l}`),
].join("\n"),
);
} else if (item.type === "terminal") {
parts.push(`▶ terminal: ${item.terminalId}`);
}
}
return parts.join("\n\n");
}
function buildBody(
info: ToolCallInfo,
contentWidth: number,
): React.ReactElement[] {
const body: React.ReactElement[] = [];
const pushLabel = (label: string, keyPrefix: string, withTopGap: boolean) => {
if (withTopGap) {
body.push(
<Box key={`${keyPrefix}-gap`} height={1}>
<Text> </Text>
</Box>,
);
}
body.push(
<Box key={`${keyPrefix}-hdr`} height={1}>
<Text color={TEXT_SECONDARY} bold>
{label}
</Text>
</Box>,
);
};
const pushText = (
text: string,
keyPrefix: string,
emptyHint: string,
) => {
if (!text) {
body.push(
<Box key={`${keyPrefix}-empty`} height={1}>
<Text color={TEXT_DIM} italic>
{emptyHint}
</Text>
</Box>,
);
return;
}
const lines = wrapOrTruncate(text, contentWidth);
lines.forEach((l, i) => {
body.push(
<Box key={`${keyPrefix}-${i}`} height={1}>
<Text color={TEXT_PRIMARY}>{l || " "}</Text>
</Box>,
);
});
};
pushLabel(info.title, "tool", false);
pushLabel("arguments", "in", true);
const argsText = formatJson(info.rawInput);
pushText(argsText, "in", "(no arguments)");
pushLabel("result", "out", true);
let resultText = formatJson(info.rawOutput);
if (!resultText) {
resultText = extractContentText(info.content);
}
const resultEmptyHint =
info.status === "in_progress"
? "(running…)"
: info.status === "pending"
? "(pending)"
: info.status === "failed"
? "(failed — no output)"
: "(no output)";
pushText(resultText, "out", resultEmptyHint);
return body;
}
export function ToolCallExpanded({
info,
width,
height,
scrollOffset,
onScroll,
onClose,
}: Props) {
const safeWidth = Math.max(width, 20);
const safeHeight = Math.max(height, 5);
const contentWidth = Math.max(safeWidth - 4, 10);
const allLines = useMemo(
() => buildBody(info, contentWidth),
[info, contentWidth],
);
useInput((ch, key) => {
if (key.escape || ch === " ") {
onClose();
return;
}
if (key.upArrow || key.downArrow) {
const step = key.meta
? SCROLL_STEP * SCROLL_FAST_MULTIPLIER
: SCROLL_STEP;
if (key.upArrow) {
onScroll((prev) => prev + step);
} else {
onScroll((prev) => Math.max(prev - step, 0));
}
}
});
const headerH = 2;
const footerH = 2;
const bodyHeight = Math.max(safeHeight - headerH - footerH, 1);
const total = allLines.length;
const overflows = total > bodyHeight;
const contentHeight = overflows ? Math.max(bodyHeight - 2, 1) : bodyHeight;
const maxEnd = total;
const minEnd = Math.min(contentHeight, total);
const endIdx = Math.max(minEnd, Math.min(maxEnd - scrollOffset, maxEnd));
const startIdx = Math.max(0, endIdx - contentHeight);
const visible = allLines.slice(startIdx, endIdx);
const padCount = contentHeight - visible.length;
const elements: React.ReactElement[] = [];
if (overflows) {
const above = startIdx;
elements.push(
<Box key="exp-up" width={safeWidth} height={1} justifyContent="center">
{above > 0 ? (
<Text color={TEXT_DIM}> {above} more ()</Text>
) : (
<Text> </Text>
)}
</Box>,
);
}
for (let i = 0; i < padCount; i++) {
elements.push(
<Box key={`exp-pad-${i}`} width={safeWidth} height={1}>
<Text> </Text>
</Box>,
);
}
elements.push(...visible);
if (overflows) {
const below = total - endIdx;
elements.push(
<Box key="exp-dn" width={safeWidth} height={1} justifyContent="center">
{below > 0 ? (
<Text color={TEXT_DIM}> {below} more ()</Text>
) : (
<Text> </Text>
)}
</Box>,
);
}
const statusColor = STATUS_COLORS[info.status] ?? TEXT_DIM;
return (
<Box
flexDirection="column"
width={safeWidth}
height={safeHeight}
borderStyle="round"
borderColor={GOLD}
paddingX={1}
>
<Box width={contentWidth} height={1}>
<Text color={statusColor}></Text>
<Text color={TEXT_DIM}> {info.status}</Text>
<Box flexGrow={1} />
<Text color={TEXT_DIM} italic>
space/esc to close
</Text>
</Box>
<Box flexDirection="column" width={contentWidth} height={bodyHeight}>
{elements}
</Box>
<Box width={contentWidth} height={1}>
<Text color={TEXT_DIM}> scroll · fast</Text>
</Box>
</Box>
);
}
-577
View File
@@ -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<string[]>([]);
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 (
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Select model
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>
Loading models for {provider.providerName}
</Text>
</Box>
<Box justifyContent="center" flexGrow={1} alignItems="center">
<Spinner idx={0} />
</Box>
</Box>
);
}
if (models.length === 0) {
return (
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Select model
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={GOLD}> No models available</Text>
</Box>
<Box justifyContent="center">
<Box width={maxWidth}>
<Text color={TEXT_DIM} wrap="wrap">
This provider does not currently expose any models in inventory.
</Text>
</Box>
</Box>
<Box justifyContent="center" marginTop={2}>
<Text color={TEXT_DIM}>m manual entry · esc back</Text>
</Box>
</Box>
);
}
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 (
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Enter model name
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>
Type a model identifier for {provider.providerName}
</Text>
</Box>
<Box justifyContent="center">
<Box
borderStyle="round"
borderColor={GOLD}
paddingX={2}
width={inputWidth}
>
<Text color={GOLD} bold>
{" "}
</Text>
<Text color={searchQuery ? TEXT_PRIMARY : TEXT_DIM}>
{truncatedText}
</Text>
</Box>
</Box>
<Box justifyContent="center" marginTop={2}>
<Text color={TEXT_DIM}>enter confirm · esc cancel</Text>
</Box>
</Box>
);
}
const visible = filtered.slice(scrollOffset, scrollOffset + listHeight);
const searchBoxWidth = Math.min(60, maxWidth - 4);
return (
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
{/* Header */}
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Select model
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>Choose a model for {provider.providerName}</Text>
</Box>
{/* Search Bar */}
<Box justifyContent="center" marginBottom={2}>
<Box
borderStyle="round"
borderColor={RULE_COLOR}
paddingX={2}
width={searchBoxWidth}
>
<Text color={CRANBERRY} bold>
{" "}
</Text>
<Box width={searchBoxWidth - 8}>
<Text color={searchQuery ? TEXT_PRIMARY : TEXT_DIM} wrap="truncate">
{searchQuery || "search models…"}
</Text>
</Box>
</Box>
</Box>
{/* Model List */}
<Box flexDirection="column" flexGrow={1} justifyContent="flex-start">
{filtered.length === 0 ? (
<Box
justifyContent="center"
alignItems="center"
height={Math.max(listHeight, 1)}
>
<Text color={TEXT_DIM}>No matching models</Text>
</Box>
) : (
<>
{scrollOffset > 0 && (
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_DIM}> {scrollOffset} more above</Text>
</Box>
)}
<Box justifyContent="center">
<Box flexDirection="column" width={maxWidth}>
{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 (
<Box key={model}>
<Text color={active ? GOLD : TEXT_DIM}>
{active ? "▸ " : " "}
</Text>
<Text
color={active ? TEXT_PRIMARY : TEXT_DIM}
bold={active}
>
{truncatedModel}
</Text>
{isDefault && <Text color={TEAL}> (default)</Text>}
</Box>
);
})}
</Box>
</Box>
{scrollOffset + listHeight < filtered.length && (
<Box justifyContent="center" marginTop={1}>
<Text color={TEXT_DIM}>
{filtered.length - scrollOffset - listHeight} more below
</Text>
</Box>
)}
</>
)}
</Box>
{/* Footer */}
<Box justifyContent="center" marginTop={2}>
<Text color={TEXT_DIM}>
navigate · enter select · m manual · esc back
</Text>
</Box>
</Box>
);
});
export default function ConfigureScreen({
client,
sessionId,
width,
height,
onComplete,
onCancel,
initialIntent,
}: ConfigureProps) {
const [phase, setPhase] = useState<Phase>("loading");
const [providers, setProviders] = useState<ProviderInventoryEntryDto[]>([]);
const [selectedProvider, setSelectedProvider] =
useState<ProviderInventoryEntryDto | null>(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<string, string>,
) => {
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<string, string>
>({});
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<string, string>) => {
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 (
<Box flexDirection="column" height={height} width={width} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Configure provider
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>{label}</Text>
</Box>
<Box justifyContent="center" flexGrow={1} alignItems="center">
<Spinner idx={spinIdx} />
</Box>
</Box>
);
}
if (phase === "error") {
return (
<Box flexDirection="column" height={height} width={width} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Configure provider
</Text>
</Box>
<ErrorScreen errorMsg={errorMsg} onRetry={handleRetry} />
</Box>
);
}
if (phase === "configure" && selectedProvider) {
return (
<ProviderConfigurator
provider={selectedProvider}
height={height}
onComplete={handleConfigComplete}
onBack={() => {
setSelectedProvider(null);
setPhase("select_provider");
}}
/>
);
}
if (phase === "select_model" && selectedProvider) {
return (
<ModelSelector
provider={selectedProvider}
height={height}
onSelect={handleModelSelected}
onBack={() => {
if (initialIntent === "model") {
onCancel();
} else {
setPhase("select_provider");
}
}}
/>
);
}
return (
<ProviderSelector
providers={providers}
height={height}
onSelect={handleProviderSelected}
title="◆ Configure provider ◆"
subtitle="Select a provider and model for this session"
onBack={onCancel}
/>
);
}
-72
View File
@@ -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)]!;
-653
View File
@@ -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<Phase>("loading");
const [spinIdx, setSpinIdx] = useState(0);
const [errorMsg, setErrorMsg] = useState("");
const [entries, setEntries] = useState<ExtEntry[]>([]);
const [warnings, setWarnings] = useState<string[]>([]);
const [selectedIdx, setSelectedIdx] = useState(0);
const [addType, setAddType] = useState<AddType>("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<void>) => {
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 (
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Manage extensions
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>
{phase === "loading" ? "Loading extensions…" : "Saving…"}
</Text>
</Box>
<Box justifyContent="center" flexGrow={1} alignItems="center">
<Spinner idx={spinIdx} />
</Box>
</Box>
);
}
if (phase === "error") {
return (
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Manage extensions
</Text>
</Box>
<ErrorScreen errorMsg={errorMsg} onRetry={() => reload()} />
</Box>
);
}
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 (
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Add extension
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>Choose a connection type</Text>
</Box>
<Box justifyContent="center">
<Box flexDirection="column">
{types.map((t) => {
const active = addType === t.value;
return (
<Box key={t.value}>
<Text color={active ? GOLD : TEXT_DIM}>
{active ? "▸ " : " "}
</Text>
<Text color={active ? TEXT_PRIMARY : TEXT_DIM} bold={active}>
{t.label}
</Text>
<Text color={TEXT_DIM}> {t.hint}</Text>
</Box>
);
})}
</Box>
</Box>
<Box justifyContent="center" marginTop={2}>
<Text color={TEXT_DIM}> select · enter confirm · esc cancel</Text>
</Box>
</Box>
);
}
if (phase === "add_value") {
const isStdio = addType === "stdio";
const placeholder = isStdio
? "npx -y @modelcontextprotocol/server-filesystem /tmp"
: "http://localhost:8080/mcp";
return (
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
{isStdio ? "Enter command" : "Enter endpoint URL"}
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>
{isStdio
? "The command to launch the extension"
: "URL of the remote MCP server"}
</Text>
</Box>
<Box justifyContent="center">
<Box
borderStyle="round"
borderColor={RULE_COLOR}
paddingX={2}
width={inputW}
>
<Text color={CRANBERRY} bold>
{" "}
</Text>
<TextInput
key={`value-${inputKey}`}
placeholder={placeholder}
onChange={setAddValue}
onSubmit={(v) => {
if (!v.trim()) return;
setAddValue(v);
setAddName(deriveNameFromValue(addType, v));
setInputKey((k) => k + 1);
setPhase("add_name");
}}
/>
</Box>
</Box>
<Box justifyContent="center" marginTop={2}>
<Text color={TEXT_DIM}>enter continue · esc back</Text>
</Box>
</Box>
);
}
if (phase === "add_name") {
return (
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Name this extension
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>A short name to identify this extension</Text>
</Box>
<Box justifyContent="center">
<Box
borderStyle="round"
borderColor={RULE_COLOR}
paddingX={2}
width={inputW}
>
<Text color={CRANBERRY} bold>
{" "}
</Text>
<TextInput
key={`name-${inputKey}`}
defaultValue={addName}
placeholder="extension name"
onChange={setAddName}
onSubmit={(v) => {
if (!v.trim()) return;
setAddName(v.trim());
setAddDesc("");
setInputKey((k) => k + 1);
setPhase("add_desc");
}}
/>
</Box>
</Box>
<Box justifyContent="center" marginTop={2}>
<Text color={TEXT_DIM}>enter continue · esc back</Text>
</Box>
</Box>
);
}
if (phase === "add_desc") {
return (
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Description
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>What does this extension do? (optional)</Text>
</Box>
<Box justifyContent="center">
<Box
borderStyle="round"
borderColor={RULE_COLOR}
paddingX={2}
width={inputW}
>
<Text color={CRANBERRY} bold>
{" "}
</Text>
<TextInput
key={`desc-${inputKey}`}
placeholder="what does this extension do?"
onChange={setAddDesc}
onSubmit={(v) => saveNewExtension(v.trim())}
/>
</Box>
</Box>
<Box justifyContent="center" marginTop={2}>
<Text color={TEXT_DIM}>
enter save (leave empty to skip) · esc back
</Text>
</Box>
</Box>
);
}
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 (
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
{/* Header */}
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Manage extensions
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>
Toggle, add, or remove extensions for this session
</Text>
</Box>
{/* Extension List */}
<Box flexDirection="column" flexGrow={1} justifyContent="flex-start">
{entries.length === 0 ? (
<Box
justifyContent="center"
alignItems="center"
height={Math.max(rows - 1, 1)}
>
<Text color={TEXT_DIM}>
No extensions configured press a to add one
</Text>
</Box>
) : (
<>
{start > 0 && (
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_DIM}> {start} more above</Text>
</Box>
)}
<Box justifyContent="center">
<Box flexDirection="column" width={layoutW}>
{windowed.map((ext, i) => {
const globalIdx = start + i;
const active = globalIdx === selectedIdx;
return (
<Box key={`${ext.type}:${ext.name}`} width={layoutW}>
<Text color={active ? GOLD : TEXT_DIM}>
{active ? "▸ " : " "}
</Text>
<Box width={nameW}>
<Text
color={active ? TEXT_PRIMARY : TEXT_DIM}
bold={active}
wrap="truncate"
>
{ext.name}
</Text>
</Box>
<Box width={GUTTER}>
<Text>{" ".repeat(GUTTER)}</Text>
</Box>
<Box width={descW}>
<Text color={TEXT_DIM} wrap="truncate">
{ext.description || ""}
</Text>
</Box>
<Box width={GUTTER}>
<Text>{" ".repeat(GUTTER)}</Text>
</Box>
<Box width={STATUS_W}>
<Text
color={ext.enabled ? TEAL : TEXT_DIM}
wrap="truncate"
>
{ext.enabled ? "enabled" : "disabled"}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
{end < entries.length && (
<Box justifyContent="center" marginTop={1}>
<Text color={TEXT_DIM}>
{entries.length - end} more below
</Text>
</Box>
)}
</>
)}
</Box>
{warnings.length > 0 && (
<Box justifyContent="center" marginTop={1}>
<Box width={layoutW} flexDirection="column">
<Text color={GOLD}>Warnings</Text>
{warnings.map((w, i) => (
<Box key={i} width={layoutW}>
<Text color={TEXT_DIM} wrap="truncate">
{w}
</Text>
</Box>
))}
</Box>
</Box>
)}
{/* Footer */}
<Box justifyContent="center" marginTop={2}>
<Text color={TEXT_DIM}>space/enter toggle · a add · esc back</Text>
</Box>
</Box>
);
}
-20
View File
@@ -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");
}
-733
View File
@@ -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 (
<Box
key={provider.providerId}
width={cardWidth}
height={cardHeight}
borderStyle={cardBorder}
borderColor={cardBorderColor}
paddingX={1}
paddingY={0}
flexDirection="column"
>
<Box justifyContent="space-between" alignItems="center">
<Box width={titleWidth} flexShrink={1}>
<Text color={textColor} bold={isSelected} wrap="truncate">
{provider.providerName}
</Text>
</Box>
<Box flexShrink={0}>
{provider.providerType === "Preferred" && (
<Text color={TEAL}></Text>
)}
{provider.configured && <Text color={TEAL}></Text>}
</Box>
</Box>
<Box marginTop={1} flexDirection="column" flexGrow={1}>
<Box width={contentWidth}>
<Text color={TEXT_DIM} wrap="truncate">
{provider.providerId}
</Text>
</Box>
{provider.description && (
<Box marginTop={1} width={contentWidth}>
<Text color={TEXT_DIM} wrap="truncate" dimColor>
{provider.description.length > descriptionMaxChars
? provider.description.slice(0, descriptionMaxChars - 1) + "…"
: provider.description}
</Text>
</Box>
)}
</Box>
</Box>
);
};
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(
<Box
key={row}
gap={columnSpacing}
marginBottom={isLastVisibleRow ? 0 : rowSpacing}
>
{rowProviders}
</Box>,
);
}
}
return (
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
{/* Header */}
<Box marginTop={1} />
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
{title ?? "◆ Welcome to goose ◆"}
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>
{subtitle ?? "Connect an AI model provider to get started"}
</Text>
</Box>
{/* Search Bar */}
<Box justifyContent="center" marginBottom={2}>
<Box
borderStyle="round"
borderColor={RULE_COLOR}
paddingX={2}
width={Math.min(60, availableWidth)}
>
<Text color={CRANBERRY} bold>
{" "}
</Text>
<Text color={searchQuery ? TEXT_PRIMARY : TEXT_DIM} wrap="truncate">
{searchQuery || "search providers…"}
</Text>
</Box>
</Box>
{/* Provider Grid */}
<Box flexDirection="column" flexGrow={1} justifyContent="flex-start">
{filtered.length === 0 ? (
<Box justifyContent="center" alignItems="center" height={10}>
<Text color={TEXT_DIM}>No matching providers found</Text>
</Box>
) : (
<>
{scrollRow > 0 && (
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_DIM}>
{scrollRow * cardsPerRow} more above
</Text>
</Box>
)}
<Box justifyContent="center">
<Box flexDirection="column">{visibleRows}</Box>
</Box>
{scrollRow + rowsVisible < totalRows && (
<Box justifyContent="center" marginTop={1}>
<Text color={TEXT_DIM}>
{filtered.length - (scrollRow + rowsVisible) * cardsPerRow}{" "}
more below
</Text>
</Box>
)}
</>
)}
</Box>
{/* Footer */}
<Box justifyContent="center" marginTop={2}>
<Text color={TEXT_DIM}>
navigate · enter select · type to search
{onBack ? " · esc back" : " · esc clear"}
</Text>
</Box>
</Box>
);
});
export interface ProviderConfiguratorProps {
provider: ProviderInventoryEntryDto;
height: number;
onComplete: (values: Record<string, string>) => void;
onBack: () => void;
}
export const ProviderConfigurator = React.memo(function ProviderConfigurator({
provider,
height,
onComplete,
onBack,
}: ProviderConfiguratorProps) {
const [keyValues, setKeyValues] = useState<Record<string, string>>({});
const [activeKeyIdx, setActiveKeyIdx] = useState(0);
const [showMasked, setShowMasked] = useState<Record<string, boolean>>({});
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 (
<Box
flexDirection="column"
height={height}
alignItems="center"
width={columns}
>
{topPad > 0 && <Box height={topPad} />}
<Box flexDirection="column" width={maxWidth} paddingX={2}>
{/* Header */}
<Box justifyContent="center" marginBottom={1}>
<Text color={TEXT_PRIMARY} bold>
Configure {provider.providerName}
</Text>
</Box>
{provider.description && (
<Box justifyContent="center" marginBottom={1}>
<Box width={maxWidth - 4}>
<Text color={TEXT_DIM} wrap="wrap">
{provider.description}
</Text>
</Box>
</Box>
)}
<Box marginTop={1} />
{/* Configuration Keys */}
{keys.map((k, i) => (
<Box key={k.name} marginBottom={1}>
<Text color={i === activeKeyIdx ? GOLD : TEXT_DIM}>
{i < activeKeyIdx ? "✓ " : i === activeKeyIdx ? "▸ " : " "}
</Text>
<Text
color={i === activeKeyIdx ? TEXT_PRIMARY : TEXT_DIM}
bold={i === activeKeyIdx}
>
{k.name}
</Text>
{i < activeKeyIdx && <Text color={TEAL}> </Text>}
</Box>
))}
{/* Current Input Field */}
{currentKey && (
<Box marginTop={1} flexDirection="column">
<Box>
<Text color={CRANBERRY} bold>
{" "}
</Text>
{masked ? (
<PasswordInput
key={`password-${currentKey.name}-${inputKey}`}
placeholder={currentKey.name}
onChange={handleChange}
onSubmit={handleSubmit}
/>
) : (
<TextInput
key={`text-${currentKey.name}-${inputKey}`}
defaultValue={currentVal}
placeholder={currentKey.name}
onChange={handleChange}
onSubmit={handleSubmit}
/>
)}
</Box>
<Box marginTop={1}>
<Box width={maxWidth - 4}>
<Text color={TEXT_DIM} wrap="wrap">
enter confirm · esc back
{currentKey.secret && (
<>
{" · tab "}
{masked ? "reveal" : "hide"}
</>
)}
</Text>
</Box>
</Box>
</Box>
)}
{/* Setup Steps */}
{provider.setupSteps && provider.setupSteps.length > 0 && (
<Box marginTop={2} flexDirection="column">
<Text color={TEXT_DIM}>Setup steps:</Text>
{provider.setupSteps.map((step, i) => (
<Box key={i} width={maxWidth - 4} marginTop={1}>
<Text color={TEXT_DIM} wrap="wrap">
{i + 1}. {step}
</Text>
</Box>
))}
</Box>
)}
</Box>
</Box>
);
});
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 (
<Box
flexDirection="column"
alignItems="center"
width={columns}
height={height}
overflow="hidden"
>
{topPad > 0 && <Box height={topPad} />}
<Box flexDirection="column" alignItems="center">
<Text color={TEAL} bold>
Provider configured
</Text>
{provider && (
<Box marginTop={1}>
<Text color={TEXT_SECONDARY}>
Connected to {provider.providerName}
</Text>
</Box>
)}
</Box>
</Box>
);
});
export default function Onboarding({
client,
width,
height,
onComplete,
}: OnboardingProps) {
const [phase, setPhase] = useState<Phase>("loading");
const [providers, setProviders] = useState<ProviderInventoryEntryDto[]>([]);
const [selectedProvider, setSelectedProvider] =
useState<ProviderInventoryEntryDto | null>(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<string, string>,
) => {
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 (
<Box
flexDirection="column"
alignItems="center"
width={width}
height={height}
overflow="hidden"
>
{topPad > 0 && <Box height={topPad} />}
<Box flexDirection="column" alignItems="center">
<Spinner idx={spinIdx} />
<Box marginTop={1}>
<Text color={TEXT_DIM}>loading providers</Text>
</Box>
</Box>
</Box>
);
}
if (phase === "error") {
return (
<Box
flexDirection="column"
height={height}
alignItems="center"
width={width}
>
<ErrorScreen errorMsg={errorMsg} onRetry={handleRetry} />
</Box>
);
}
if (phase === "saving") {
const contentHeight = 3; // spinner + text + spacing
const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
return (
<Box
flexDirection="column"
alignItems="center"
width={width}
height={height}
overflow="hidden"
>
{topPad > 0 && <Box height={topPad} />}
<Box flexDirection="column" alignItems="center">
<Spinner idx={spinIdx} />
<Box marginTop={1}>
<Text color={TEXT_DIM}>saving configuration</Text>
</Box>
</Box>
</Box>
);
}
if (phase === "success") {
return <SuccessScreen provider={selectedProvider} height={height} />;
}
if (phase === "configure" && selectedProvider) {
return (
<ProviderConfigurator
provider={selectedProvider}
height={height}
onComplete={(values) => saveProvider(selectedProvider, values)}
onBack={() => {
setSelectedProvider(null);
setPhase("select_provider");
}}
/>
);
}
return (
<ProviderSelector
providers={providers}
height={height}
onSelect={confirmProvider}
/>
);
}
-112
View File
@@ -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<string, SlashCommand> = {
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);
}
-161
View File
@@ -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<string, string> = {
read: "📖",
edit: "✏️",
delete: "🗑",
move: "📦",
search: "🔍",
execute: "▶",
think: "💭",
fetch: "🌐",
switch_mode: "🔀",
other: "⚙",
};
const STATUS_INDICATORS: Record<string, { icon: string; color: string }> = {
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(
<Box key={`${k}-t`} width={safeWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}>
{hRule}
</Text>
</Box>,
);
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(
<Box key={`${k}-h`} width={safeWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}>
{" "}
</Text>
<Box width={innerWidth} height={1}>
<Text color={statusInfo.color}>{statusIcon}</Text>
<Text> {kindIcon} </Text>
<Text wrap="truncate-end" color={TEXT_SECONDARY} bold>
{title}
</Text>
{runningText ? (
<Text color={TEXT_DIM} italic>
{runningText}
</Text>
) : null}
<Box flexGrow={1} />
{hintText ? (
<Text color={GOLD} italic>
{hintText}
</Text>
) : null}
</Box>
<Text color={borderColor} dimColor={dimBorder}>
{" "}
</Text>
</Box>,
);
lines.push(
<Box key={`${k}-b`} width={safeWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}>
{hRule}
</Text>
</Box>,
);
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;
-1424
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -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<string, number>;
}
-20
View File
@@ -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);
}
-16
View File
@@ -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"]
}