Deprecate and remove ui/text TUI (#10799)
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
dist
|
||||
node_modules
|
||||
@@ -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 +0,0 @@
|
||||
@AGENTS.md
|
||||
+13
-37
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>;
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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)]!;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user