feat: onboarding UX for the TUI (#8513)

This commit is contained in:
Alex Hancock 2026-04-14 10:17:01 -04:00 committed by GitHub
parent 2f018285a4
commit 17a404d4f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2325 additions and 423 deletions

View file

@ -94,6 +94,28 @@ Errors: Don't add error context that doesn't add useful information (e.g., `.con
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: Edit ui/desktop/openapi.json manually

View file

@ -45,6 +45,11 @@
"requestType": "ListProvidersRequest",
"responseType": "ListProvidersResponse"
},
{
"method": "_goose/providers/details",
"requestType": "GetProviderDetailsRequest",
"responseType": "GetProviderDetailsResponse"
},
{
"method": "_goose/config/read",
"requestType": "ReadConfigRequest",

View file

@ -261,6 +261,112 @@
"label"
]
},
"GetProviderDetailsRequest": {
"type": "object",
"description": "List providers with full metadata (config keys, setup steps, etc.).",
"x-side": "agent",
"x-method": "_goose/providers/details"
},
"GetProviderDetailsResponse": {
"type": "object",
"properties": {
"providers": {
"type": "array",
"items": {
"$ref": "#/$defs/ProviderDetailEntry"
}
}
},
"required": [
"providers"
],
"description": "Provider details response.",
"x-side": "agent",
"x-method": "_goose/providers/details"
},
"ProviderDetailEntry": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"displayName": {
"type": "string"
},
"description": {
"type": "string"
},
"defaultModel": {
"type": "string"
},
"isConfigured": {
"type": "boolean"
},
"providerType": {
"type": "string"
},
"configKeys": {
"type": "array",
"items": {
"$ref": "#/$defs/ProviderConfigKey"
}
},
"setupSteps": {
"type": "array",
"items": {
"type": "string"
},
"default": []
}
},
"required": [
"name",
"displayName",
"description",
"defaultModel",
"isConfigured",
"providerType",
"configKeys"
]
},
"ProviderConfigKey": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"required": {
"type": "boolean"
},
"secret": {
"type": "boolean"
},
"default": {
"type": [
"string",
"null"
],
"default": null
},
"oauthFlow": {
"type": "boolean",
"default": false
},
"deviceCodeFlow": {
"type": "boolean",
"default": false
},
"primary": {
"type": "boolean",
"default": false
}
},
"required": [
"name",
"required",
"secret"
]
},
"ReadConfigRequest": {
"type": "object",
"properties": {
@ -568,6 +674,15 @@
"description": "Params for _goose/providers/list",
"title": "ListProvidersRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/GetProviderDetailsRequest"
}
],
"description": "Params for _goose/providers/details",
"title": "GetProviderDetailsRequest"
},
{
"allOf": [
{
@ -736,6 +851,14 @@
],
"title": "ListProvidersResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/GetProviderDetailsResponse"
}
],
"title": "GetProviderDetailsResponse"
},
{
"allOf": [
{

View file

@ -2323,6 +2323,58 @@ impl GooseAcpAgent {
})
}
#[custom_method(GetProviderDetailsRequest)]
async fn on_get_provider_details(
&self,
_req: GetProviderDetailsRequest,
) -> Result<GetProviderDetailsResponse, sacp::Error> {
let config = self.load_config().ok();
let all = goose::providers::providers().await;
let entries = all
.into_iter()
.map(|(metadata, provider_type)| {
let is_configured = config
.as_ref()
.map(|c| {
metadata.config_keys.iter().all(|k| {
if !k.required {
return true;
}
if k.secret {
c.get_secret::<String>(&k.name).is_ok()
} else {
c.get_param::<String>(&k.name).is_ok()
}
})
})
.unwrap_or(false);
ProviderDetailEntry {
name: metadata.name.clone(),
display_name: metadata.display_name.clone(),
description: metadata.description.clone(),
default_model: metadata.default_model.clone(),
is_configured,
provider_type: format!("{:?}", provider_type),
config_keys: metadata
.config_keys
.iter()
.map(|k| ProviderConfigKey {
name: k.name.clone(),
required: k.required,
secret: k.secret,
default: k.default.clone(),
oauth_flow: k.oauth_flow,
device_code_flow: k.device_code_flow,
primary: k.primary,
})
.collect(),
setup_steps: metadata.setup_steps.clone(),
}
})
.collect();
Ok(GetProviderDetailsResponse { providers: entries })
}
#[custom_method(ReadConfigRequest)]
async fn on_read_config(
&self,

View file

@ -254,6 +254,47 @@ pub struct ImportSessionResponse {
pub message_count: u64,
}
/// List providers with full metadata (config keys, setup steps, etc.).
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/providers/details", response = GetProviderDetailsResponse)]
pub struct GetProviderDetailsRequest {}
/// Provider details response.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
pub struct GetProviderDetailsResponse {
pub providers: Vec<ProviderDetailEntry>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProviderDetailEntry {
pub name: String,
pub display_name: String,
pub description: String,
pub default_model: String,
pub is_configured: bool,
pub provider_type: String,
pub config_keys: Vec<ProviderConfigKey>,
#[serde(default)]
pub setup_steps: Vec<String>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ProviderConfigKey {
pub name: String,
pub required: bool,
pub secret: bool,
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub oauth_flow: bool,
#[serde(default)]
pub device_code_flow: bool,
#[serde(default)]
pub primary: bool,
}
/// Empty success response for operations that return no data.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
pub struct EmptyResponse {}

View file

@ -17,6 +17,8 @@ import type {
ExportSessionResponse,
GetExtensionsRequest,
GetExtensionsResponse,
GetProviderDetailsRequest,
GetProviderDetailsResponse,
GetToolsRequest,
GetToolsResponse,
ImportSessionRequest,
@ -41,6 +43,7 @@ import {
zCheckSecretResponse,
zExportSessionResponse,
zGetExtensionsResponse,
zGetProviderDetailsResponse,
zGetToolsResponse,
zImportSessionResponse,
zListProvidersResponse,
@ -104,6 +107,13 @@ export class GooseExtClient {
return zListProvidersResponse.parse(raw) as ListProvidersResponse;
}
async GooseProvidersDetails(
params: GetProviderDetailsRequest,
): Promise<GetProviderDetailsResponse> {
const raw = await this.conn.extMethod("_goose/providers/details", params);
return zGetProviderDetailsResponse.parse(raw) as GetProviderDetailsResponse;
}
async GooseConfigRead(
params: ReadConfigRequest,
): Promise<ReadConfigResponse> {

View file

@ -1,6 +1,6 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, UpdateProviderRequest, UpdateProviderResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js';
export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ProviderConfigKey, ProviderDetailEntry, ProviderListEntry, ReadConfigRequest, ReadConfigResponse, ReadResourceRequest, ReadResourceResponse, RemoveConfigRequest, RemoveExtensionRequest, RemoveSecretRequest, UnarchiveSessionRequest, UpdateProviderRequest, UpdateProviderResponse, UpdateWorkingDirRequest, UpsertConfigRequest, UpsertSecretRequest } from './types.gen.js';
export const GOOSE_EXT_METHODS = [
{
@ -48,6 +48,11 @@ export const GOOSE_EXT_METHODS = [
requestType: "ListProvidersRequest",
responseType: "ListProvidersResponse",
},
{
method: "_goose/providers/details",
requestType: "GetProviderDetailsRequest",
responseType: "GetProviderDetailsResponse",
},
{
method: "_goose/config/read",
requestType: "ReadConfigRequest",

View file

@ -138,6 +138,41 @@ export type ProviderListEntry = {
label: string;
};
/**
* List providers with full metadata (config keys, setup steps, etc.).
*/
export type GetProviderDetailsRequest = {
[key: string]: unknown;
};
/**
* Provider details response.
*/
export type GetProviderDetailsResponse = {
providers: Array<ProviderDetailEntry>;
};
export type ProviderDetailEntry = {
name: string;
displayName: string;
description: string;
defaultModel: string;
isConfigured: boolean;
providerType: string;
configKeys: Array<ProviderConfigKey>;
setupSteps?: Array<string>;
};
export type ProviderConfigKey = {
name: string;
required: boolean;
secret: boolean;
default?: string | null;
oauthFlow?: boolean;
deviceCodeFlow?: boolean;
primary?: boolean;
};
/**
* Read a single non-secret config value.
*/
@ -244,14 +279,14 @@ export type UnarchiveSessionRequest = {
export type ExtRequest = {
id: string;
method: string;
params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | UpdateProviderRequest | ListProvidersRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | {
params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | UpdateProviderRequest | ListProvidersRequest | GetProviderDetailsRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | {
[key: string]: unknown;
} | null;
};
export type ExtResponse = {
id: string;
result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | UpdateProviderResponse | ListProvidersResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | unknown;
result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | UpdateProviderResponse | ListProvidersResponse | GetProviderDetailsResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | unknown;
} | {
error: {
code: number;

View file

@ -125,6 +125,42 @@ export const zListProvidersResponse = z.object({
providers: z.array(zProviderListEntry)
});
/**
* List providers with full metadata (config keys, setup steps, etc.).
*/
export const zGetProviderDetailsRequest = z.record(z.unknown());
export const zProviderConfigKey = z.object({
name: z.string(),
required: z.boolean(),
secret: z.boolean(),
default: z.union([
z.string(),
z.null()
]).optional().default(null),
oauthFlow: z.boolean().optional().default(false),
deviceCodeFlow: z.boolean().optional().default(false),
primary: z.boolean().optional().default(false)
});
export const zProviderDetailEntry = z.object({
name: z.string(),
displayName: z.string(),
description: z.string(),
defaultModel: z.string(),
isConfigured: z.boolean(),
providerType: z.string(),
configKeys: z.array(zProviderConfigKey),
setupSteps: z.array(z.string()).optional().default([])
});
/**
* Provider details response.
*/
export const zGetProviderDetailsResponse = z.object({
providers: z.array(zProviderDetailEntry)
});
/**
* Read a single non-secret config value.
*/
@ -248,6 +284,7 @@ export const zExtRequest = z.object({
zGetExtensionsRequest,
zUpdateProviderRequest,
zListProvidersRequest,
zGetProviderDetailsRequest,
zReadConfigRequest,
zUpsertConfigRequest,
zRemoveConfigRequest,
@ -277,6 +314,7 @@ export const zExtResponse = z.union([
zGetExtensionsResponse,
zUpdateProviderResponse,
zListProvidersResponse,
zGetProviderDetailsResponse,
zReadConfigResponse,
zCheckSecretResponse,
zExportSessionResponse,

View file

@ -63,63 +63,69 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
const { read, getProviders } = useConfig();
const intl = useIntl();
const changeModel = useCallback(async (sessionId: string | null, model: Model) => {
const modelName = model.name;
const providerName = model.provider;
let phase = 'agent';
const changeModel = useCallback(
async (sessionId: string | null, model: Model) => {
const modelName = model.name;
const providerName = model.provider;
let phase = 'agent';
try {
if (sessionId) {
const response = await updateAgentProvider({
body: {
session_id: sessionId,
provider: providerName,
model: modelName,
context_limit: model.context_limit,
request_params: model.request_params,
},
});
if (response.error) {
throw new Error(`Failed to update agent provider: ${response.error}`);
try {
if (sessionId) {
const response = await updateAgentProvider({
body: {
session_id: sessionId,
provider: providerName,
model: modelName,
context_limit: model.context_limit,
request_params: model.request_params,
},
});
if (response.error) {
throw new Error(`Failed to update agent provider: ${response.error}`);
}
}
}
// Only update the global config default when there's no session
// (i.e. changing from settings, not from within an existing chat)
if (!sessionId) {
phase = 'config';
await setConfigProvider({
body: {
// Only update the global config default when there's no session
// (i.e. changing from settings, not from within an existing chat)
if (!sessionId) {
phase = 'config';
await setConfigProvider({
body: {
provider: providerName,
model: modelName,
},
throwOnError: true,
});
}
if (!sessionId) {
setCurrentProvider(providerName);
setCurrentModel(modelName);
}
toastSuccess({
title: intl.formatMessage(i18n.modelChangedTitle),
msg: intl.formatMessage(i18n.switchModelSuccess, {
model: model.alias ?? modelName,
provider: model.subtext ?? providerName,
}),
});
return true;
} catch (error) {
console.error(`Failed to change model at ${phase} step -- ${modelName} ${providerName}`);
toastError({
title: intl.formatMessage(i18n.modelChangeFailed, {
provider: providerName,
model: modelName,
},
throwOnError: true,
}),
msg: `${error}`,
traceback: errorMessage(error),
});
return false;
}
if (!sessionId) {
setCurrentProvider(providerName);
setCurrentModel(modelName);
}
toastSuccess({
title: intl.formatMessage(i18n.modelChangedTitle),
msg: intl.formatMessage(i18n.switchModelSuccess, {
model: model.alias ?? modelName,
provider: model.subtext ?? providerName,
}),
});
return true;
} catch (error) {
console.error(`Failed to change model at ${phase} step -- ${modelName} ${providerName}`);
toastError({
title: intl.formatMessage(i18n.modelChangeFailed, { provider: providerName, model: modelName }),
msg: `${error}`,
traceback: errorMessage(error),
});
return false;
}
}, [intl]);
},
[intl]
);
const getFallbackModelAndProvider = useCallback(async () => {
const provider = window.appConfig.get('GOOSE_DEFAULT_PROVIDER') as string;

43
ui/pnpm-lock.yaml generated
View file

@ -384,6 +384,9 @@ importers:
'@agentclientprotocol/sdk':
specifier: ^0.14.1
version: 0.14.1(zod@4.3.6)
'@inkjs/ui':
specifier: ^2.0.0
version: 2.0.0(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))
ink:
specifier: ^6.8.0
version: 6.8.0(@types/react@19.2.14)(react@19.2.4)
@ -1341,6 +1344,12 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@inkjs/ui@2.0.0':
resolution: {integrity: sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==}
engines: {node: '>=18'}
peerDependencies:
ink: '>=5'
'@inquirer/checkbox@3.0.1':
resolution: {integrity: sha512-0hm2nrToWUdD6/UHnel/UKGdk1//ke5zGUpHIvk5ZWmaKezlGxZkOJXNSWsdxO/rEqTkbB3lNC2J6nBElV2aAQ==}
engines: {node: '>=18'}
@ -3577,6 +3586,10 @@ packages:
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
engines: {node: '>=6'}
cli-spinners@3.4.0:
resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==}
engines: {node: '>=18.20'}
cli-table3@0.6.5:
resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==}
engines: {node: 10.* || >= 12.*}
@ -3800,6 +3813,10 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
default-browser-id@5.0.1:
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
engines: {node: '>=18'}
@ -4234,6 +4251,10 @@ packages:
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@ -4867,6 +4888,10 @@ packages:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
is-unicode-supported@2.1.0:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@ -8421,6 +8446,14 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
'@inkjs/ui@2.0.0(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))':
dependencies:
chalk: 5.6.2
cli-spinners: 3.4.0
deepmerge: 4.3.1
figures: 6.1.0
ink: 6.8.0(@types/react@19.2.14)(react@19.2.4)
'@inquirer/checkbox@3.0.1':
dependencies:
'@inquirer/core': 9.2.1
@ -10785,6 +10818,8 @@ snapshots:
cli-spinners@2.9.2: {}
cli-spinners@3.4.0: {}
cli-table3@0.6.5:
dependencies:
string-width: 4.2.3
@ -10977,6 +11012,8 @@ snapshots:
deep-is@0.1.4: {}
deepmerge@4.3.1: {}
default-browser-id@5.0.1: {}
default-browser@5.5.0:
@ -11611,6 +11648,10 @@ snapshots:
fflate@0.8.2: {}
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@ -12333,6 +12374,8 @@ snapshots:
is-unicode-supported@0.1.0: {}
is-unicode-supported@2.1.0: {}
is-weakmap@2.0.2: {}
is-weakref@1.1.1:

446
ui/text/AGENTS.md Normal file
View file

@ -0,0 +1,446 @@
# 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

View file

@ -9,13 +9,30 @@ https://github.com/aaif-goose/goose/discussions/7309
The TUI automatically launches the goose ACP server using the `goose acp` command.
### Development (from source)
When running from source, `npm start` automatically builds the Rust binary from the workspace root if needed:
```bash
cd ui/text
npm i
npm run start
```
To use a custom server URL instead:
The `dev:binary` script checks if the Rust binary needs rebuilding by comparing timestamps of:
- `target/release/goose` binary
- `Cargo.toml` and `Cargo.lock`
- `crates/goose-cli/Cargo.toml`
If any source files are newer, it runs `cargo build --release -p goose-cli` automatically.
### Production (with prebuilt binaries)
In production, the TUI uses prebuilt binaries from the `@aaif/goose-binary-*` packages installed via `postinstall`.
### Custom server URL
To use a custom server URL instead of the built-in binary:
```bash
npm run start -- --server http://localhost:8080

View file

@ -25,13 +25,15 @@
],
"scripts": {
"build": "tsc",
"start": "tsx src/tui.tsx",
"dev:binary": "node scripts/dev-binary.mjs",
"start": "npm run dev:binary && tsx src/tui.tsx",
"postinstall": "node scripts/postinstall.mjs",
"lint": "tsc --noEmit"
},
"dependencies": {
"@aaif/goose-acp": "workspace:*",
"@agentclientprotocol/sdk": "^0.14.1",
"@inkjs/ui": "^2.0.0",
"ink": "^6.8.0",
"ink-multiline-input": "^0.1.0",
"marked": "^15.0.12",

103
ui/text/scripts/dev-binary.mjs Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env node
// For development: ensures the Rust binary is built from source and
// points server-binary.json to the local target/release/goose binary.
// Rebuilds if source files are newer than the binary.
import { writeFileSync, existsSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, "..", "..", "..");
const binaryName = process.platform === "win32" ? "goose.exe" : "goose";
const binaryPath = join(projectRoot, "target", "release", binaryName);
// Verify we're in a development environment with Cargo.toml
const cargoToml = join(projectRoot, "Cargo.toml");
if (!existsSync(cargoToml)) {
console.error("Error: Not in a Rust workspace (Cargo.toml not found)");
console.error("This script is for development only. In production, use the prebuilt binaries.");
process.exit(1);
}
function needsRebuild() {
if (!existsSync(binaryPath)) {
console.log("Binary not found, needs build");
return true;
}
const binaryMtime = statSync(binaryPath).mtimeMs;
// Check if any Rust source files are newer than the binary
const cargoLock = join(projectRoot, "Cargo.lock");
if (existsSync(cargoToml) && statSync(cargoToml).mtimeMs > binaryMtime) {
console.log("Cargo.toml changed, needs rebuild");
return true;
}
if (existsSync(cargoLock) && statSync(cargoLock).mtimeMs > binaryMtime) {
console.log("Cargo.lock changed, needs rebuild");
return true;
}
// Check if goose-acp crate sources are newer than the binary
const acpDir = join(projectRoot, "crates", "goose-acp");
if (existsSync(acpDir)) {
const result = spawnSync(
"find",
[acpDir, "-type", "f", "(", "-name", "*.rs", "-o", "-name", "Cargo.toml", ")", "-newer", binaryPath],
{ encoding: "utf-8" },
);
const changed = (result.stdout ?? "").trim();
if (changed) {
const first = changed.split("\n")[0];
console.log(`goose-acp changed (e.g. ${first}), needs rebuild`);
return true;
}
}
return false;
}
function buildBinary() {
console.log("Building goose-cli from source...");
const result = spawnSync(
"cargo",
["build", "--release", "-p", "goose-cli"],
{
cwd: projectRoot,
stdio: "inherit",
}
);
if (result.error) {
console.error(`Failed to build: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
console.error(`Build failed with exit code ${result.status}`);
process.exit(1);
}
console.log(`Built goose binary at ${binaryPath}`);
}
// Main logic
if (needsRebuild()) {
buildBinary();
} else {
console.log("Binary is up to date, skipping build");
}
// Write the server-binary.json to point to the local build
const outDir = join(__dirname, "..");
writeFileSync(
join(outDir, "server-binary.json"),
JSON.stringify({ binaryPath }, null, 2) + "\n",
);
console.log(`Using local goose binary at ${binaryPath}`);

View file

@ -0,0 +1,137 @@
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,
toolCallsExpanded: boolean,
isFirst: boolean,
hasToolCalls: 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, toolCallsExpanded, isFirst && hasToolCalls),
];
}
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>
));
}

View file

@ -0,0 +1,35 @@
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>
);
});

View file

@ -0,0 +1,57 @@
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;
hasPendingPermission: boolean;
turnInfo?: { current: number; total: number };
}
export const Header = React.memo(function Header({
width,
status,
loading,
spinIdx,
hasPendingPermission,
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 width={Math.max(leftSideWidth - 10, 5)}>
<Text color={statusColor} wrap="truncate-end">{status}</Text>
</Box>
{loading && !hasPendingPermission && (
<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}>^C exit</Text>
</Box>
</Box>
<Rule width={constrainedWidth} />
</Box>
);
});

View file

@ -0,0 +1,12 @@
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>;
});

View file

@ -0,0 +1,19 @@
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 };

82
ui/text/src/constants.tsx Normal file
View file

@ -0,0 +1,82 @@
// 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;
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)]!;
export const PERMISSION_LABELS: Record<string, string> = {
allow_once: "Allow once",
allow_always: "Always allow",
reject_once: "Reject once",
reject_always: "Always reject",
};
export const PERMISSION_KEYS: Record<string, string> = {
allow_once: "y",
allow_always: "a",
reject_once: "n",
reject_always: "r",
};

675
ui/text/src/onboarding.tsx Normal file
View file

@ -0,0 +1,675 @@
import React, { useState, useEffect, useCallback } from "react";
import { Box, Text, useInput, useStdout } from "ink";
import { TextInput, PasswordInput } from '@inkjs/ui';
import type { GooseClient, ProviderDetailEntry } from "@aaif/goose-acp";
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;
}
interface ProviderSelectorProps {
providers: ProviderDetailEntry[];
height: number;
onSelect: (provider: ProviderDetailEntry) => void;
}
const ProviderSelector = React.memo(function ProviderSelector({ providers, height, onSelect }: 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.displayName.toLowerCase().includes(q) ||
p.name.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 (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: ProviderDetailEntry, _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.name}
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.displayName}
</Text>
</Box>
<Box flexShrink={0}>
{provider.providerType === "Preferred" && (
<Text color={TEAL}></Text>
)}
{provider.isConfigured && (
<Text color={TEAL}></Text>
)}
</Box>
</Box>
<Box marginTop={1} flexDirection="column" flexGrow={1}>
<Box width={contentWidth}>
<Text color={TEXT_DIM} wrap="truncate">
{provider.name}
</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>
Welcome to goose
</Text>
</Box>
<Box justifyContent="center" marginBottom={2}>
<Text color={TEXT_DIM}>
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 · esc clear
</Text>
</Box>
</Box>
);
});
interface ProviderConfiguratorProps {
provider: ProviderDetailEntry;
height: number;
onComplete: (values: Record<string, string>) => void;
onBack: () => void;
}
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 */}
<Text color={TEXT_PRIMARY} bold>
Configure {provider.displayName}
</Text>
{provider.description && (
<Box marginTop={1} width={maxWidth - 4}>
<Text color={TEXT_DIM} wrap="wrap">{provider.description}</Text>
</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 to confirm · esc to go back
{currentKey.secret && (
<>
{" · tab to "}
{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: ProviderDetailEntry | 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.displayName}
</Text>
</Box>
)}
</Box>
</Box>
);
});
export default function Onboarding({
client,
width,
height,
onComplete,
}: OnboardingProps) {
const [phase, setPhase] = useState<Phase>("loading");
const [providers, setProviders] = useState<ProviderDetailEntry[]>([]);
const [selectedProvider, setSelectedProvider] =
useState<ProviderDetailEntry | 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.GooseProvidersDetails({});
const sorted = [...resp.providers].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.displayName.localeCompare(b.displayName);
});
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: ProviderDetailEntry, values: Record<string, string>) => {
setPhase("saving");
try {
for (const [key, value] of Object.entries(values)) {
const configKey = provider.configKeys.find((k) => k.name === key);
if (configKey?.secret) {
await client.goose.GooseSecretUpsert({ key, value });
} else {
await client.goose.GooseConfigUpsert({ key, value });
}
}
await client.goose.GooseConfigUpsert({
key: "GOOSE_PROVIDER",
value: provider.name,
});
await client.goose.GooseConfigUpsert({
key: "GOOSE_MODEL",
value: provider.defaultModel,
});
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: ProviderDetailEntry) => {
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}
/>
);
}

View file

@ -41,8 +41,9 @@ const STATUS_INDICATORS: Record<string, { icon: string; color: string }> = {
};
function truncateLine(line: string, maxWidth: number): string {
if (line.length <= maxWidth) return line;
return maxWidth > 1 ? line.slice(0, maxWidth - 1) + "…" : line.slice(0, maxWidth);
const safeMaxWidth = Math.max(maxWidth, 1);
if (line.length <= safeMaxWidth) return line;
return safeMaxWidth > 1 ? line.slice(0, safeMaxWidth - 1) + "…" : line.slice(0, safeMaxWidth);
}
function formatJsonLines(value: unknown, maxWidth: number): string[] {
@ -92,22 +93,23 @@ export function renderToolCallLines(
const borderColor = info.status === "failed" ? CRANBERRY : CEDAR;
const dimBorder = info.status !== "failed";
const innerWidth = Math.max(width - 4, 10);
const indentedWidth = Math.max(innerWidth - 2, 8);
const safeWidth = Math.max(width, 10);
const innerWidth = Math.max(safeWidth - 4, 6);
const indentedWidth = Math.max(innerWidth - 2, 4);
const lines: React.ReactElement[] = [];
const k = info.toolCallId;
const hRule = "─".repeat(Math.max(width - 2, 0));
const hRule = "─".repeat(Math.max(safeWidth - 2, 0));
lines.push(
<Box key={`${k}-t`} width={width} height={1}>
<Box key={`${k}-t`} width={safeWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}>{hRule}</Text>
</Box>,
);
const row = (key: string, content: React.ReactNode) => {
lines.push(
<Box key={key} width={width} height={1}>
<Box key={key} width={safeWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}> </Text>
<Box width={innerWidth} height={1}>
{content}
@ -166,7 +168,7 @@ export function renderToolCallLines(
}
lines.push(
<Box key={`${k}-b`} width={width} height={1}>
<Box key={`${k}-b`} width={safeWidth} height={1}>
<Text color={borderColor} dimColor={dimBorder}>{hRule}</Text>
</Box>,
);

View file

@ -19,195 +19,33 @@ import type {
} from "@agentclientprotocol/sdk";
import { ndJsonStream } from "@agentclientprotocol/sdk";
import { GooseClient } from "@aaif/goose-acp";
import { renderMarkdown } from "./markdown.js";
import { renderToolCallLines } from "./toolcall.js";
import type { ToolCallInfo } from "./toolcall.js";
import Onboarding from "./onboarding.js";
import type { PendingPermission, ResponseItem, Turn } from "./types.js";
import {
emptyLine,
renderUserPrompt,
renderToolCallItem,
renderErrorItem,
renderContentItem,
renderLoadingIndicator,
renderQueuedMessages,
} from "./components/ContentRenderers.js";
import { Header } from "./components/Header.js";
import { Rule } from "./components/Rule.js";
import { isErrorStatus, formatError } from "./utils.js";
import { CRANBERRY, TEAL, GOLD, TEXT_PRIMARY, TEXT_SECONDARY, TEXT_DIM, RULE_COLOR } from "./colors.js";
import { Spinner, SPINNER_FRAMES } from "./components/Spinner.js";
import {
PASTE_THRESHOLD,
INPUT_MAX_ROWS,
SENT_PREVIEW_LEN,
GOOSE_FRAMES,
INITIAL_GREETING,
PERMISSION_LABELS,
PERMISSION_KEYS,
} from "./constants.js";
interface PendingPermission {
toolTitle: string;
options: Array<{ optionId: string; name: string; kind: string }>;
resolve: (response: RequestPermissionResponse) => void;
}
type ResponseItem =
| (ContentChunk & { itemType: "content_chunk" })
| (ToolCall & { itemType: "tool_call" });
interface Turn {
userText: string;
responseItems: ResponseItem[];
toolCallsById: Map<string, number>;
}
function isErrorStatus(status: string): boolean {
return status.startsWith("error") || status.startsWith("failed");
}
const GOOSE_FRAMES = [
[
" ,_",
" (o >",
" //\\",
" \\\\ \\",
" \\\\_/",
" | |",
" ^ ^",
],
[
" ,_",
" (o >",
" //\\",
" \\\\ \\",
" \\\\_/",
" / |",
" ^ ^",
],
[
" ,_",
" (o >",
" //\\",
" \\\\ \\",
" \\\\_/",
" | |",
" ^ ^",
],
[
" ,_",
" (o >",
" //\\",
" \\\\ \\",
" \\\\_/",
" | \\",
" ^ ^",
],
];
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?",
];
const INITIAL_GREETING =
GREETING_MESSAGES[Math.floor(Math.random() * GREETING_MESSAGES.length)]!;
const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"];
const PERMISSION_LABELS: Record<string, string> = {
allow_once: "Allow once",
allow_always: "Always allow",
reject_once: "Reject once",
reject_always: "Always reject",
};
const PERMISSION_KEYS: Record<string, string> = {
allow_once: "y",
allow_always: "a",
reject_once: "n",
reject_always: "N",
};
function Rule({ width }: { width: number }) {
return <Text color={RULE_COLOR}>{"─".repeat(Math.max(width, 1))}</Text>;
}
function Spinner({ idx }: { idx: number }) {
return (
<Text color={CRANBERRY}>
{SPINNER_FRAMES[idx % SPINNER_FRAMES.length]}
</Text>
);
}
function Header({
width,
status,
loading,
spinIdx,
hasPendingPermission,
turnInfo,
}: {
width: number;
status: string;
loading: boolean;
spinIdx: number;
hasPendingPermission: boolean;
turnInfo?: { current: number; total: number };
}) {
const statusColor =
status === "ready" ? TEAL : isErrorStatus(status) ? CRANBERRY : TEXT_DIM;
return (
<Box flexDirection="column" width={width} flexShrink={0}>
<Box justifyContent="space-between" width={width}>
<Box>
<Text color={TEXT_PRIMARY} bold>goose</Text>
<Text color={RULE_COLOR}> · </Text>
<Text color={statusColor}>{status}</Text>
{loading && !hasPendingPermission && (
<Text> <Spinner idx={spinIdx} /></Text>
)}
</Box>
<Box>
{turnInfo && turnInfo.total > 1 && (
<Text color={TEXT_DIM}>
{turnInfo.current}/{turnInfo.total}{" "}
</Text>
)}
<Text color={TEXT_DIM}>^C exit</Text>
</Box>
</Box>
<Rule width={width} />
</Box>
);
}
const PASTE_THRESHOLD = 80;
const PASTE_PREVIEW_LEN = 40;
const INPUT_MAX_ROWS = 8;
const SENT_PREVIEW_LEN = 60;
function collapseForDisplay(text: string, availableWidth = PASTE_PREVIEW_LEN): string {
const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
if (flat.length <= availableWidth) return flat;
const suffix = ` (${flat.length.toLocaleString()} chars)`;
const previewLen = Math.max(availableWidth - suffix.length - 1, 10);
return flat.slice(0, previewLen) + "…" + suffix;
}
function collapsedUserPrompt(text: string, width: number): React.ReactElement {
const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
const maxPreview = Math.max(width - 30, SENT_PREVIEW_LEN);
if (flat.length <= maxPreview + 10) {
return <Text color={TEXT_PRIMARY} bold>{flat}</Text>;
}
const preview = flat.slice(0, maxPreview) + "…";
const remaining = flat.length - maxPreview;
return (
<Text>
<Text color={TEXT_PRIMARY} bold>{preview}</Text>
<Text color={TEXT_DIM}> ({remaining.toLocaleString()} more chars)</Text>
</Text>
);
}
function InputBar({
const InputBar = React.memo(function InputBar({
width,
input,
onChange,
@ -284,6 +122,8 @@ function InputBar({
);
const isPasteMode = pastedFull !== null;
const constrainedWidth = Math.max(width, 20);
const contentWidth = Math.max(constrainedWidth - 6, 10);
return (
<Box
@ -291,16 +131,26 @@ function InputBar({
borderStyle="round"
borderColor={RULE_COLOR}
paddingX={1}
width={width}
width={constrainedWidth}
flexShrink={0}
>
<Box>
<Text color={CRANBERRY} bold>{" "}</Text>
{isPasteMode ? (
<Box width={width - 4 - 2} justifyContent="space-between">
<Text color={TEXT_PRIMARY} wrap="truncate-end">
{collapseForDisplay(pastedFull, width - 4 - 2)}
</Text>
<Box width={contentWidth} justifyContent="space-between">
<Box width={Math.max(contentWidth - 20, 10)}>
<Text color={TEXT_PRIMARY} wrap="truncate-end">
{(() => {
const text = pastedFull;
const availableWidth = Math.max(contentWidth - 20, 10);
const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
if (flat.length <= availableWidth) return flat;
const suffix = ` (${flat.length.toLocaleString()} chars)`;
const previewLen = Math.max(availableWidth - suffix.length - 1, 5);
return flat.slice(0, previewLen) + "…" + suffix;
})()}
</Text>
</Box>
{scrollHint && <Text color={TEXT_DIM}>shift+ history</Text>}
</Box>
) : (
@ -344,74 +194,11 @@ function InputBar({
)}
</Box>
);
}
function emptyLine(key: string, width: number): React.ReactElement {
return <Box key={key} width={width} height={1}><Text> </Text></Box>;
}
function buildPermissionLines(
perm: PendingPermission,
selectedIdx: number,
fullWidth: number,
): React.ReactElement[] {
const dialogWidth = Math.min(fullWidth - 2, 58);
const innerWidth = Math.max(dialogWidth - 4, 10);
const hRule = "─".repeat(Math.max(dialogWidth - 2, 0));
const lines: React.ReactElement[] = [];
lines.push(emptyLine("pm-gap", fullWidth));
lines.push(
<Box key="pm-t" width={fullWidth} height={1}>
<Text color={GOLD}>{hRule}</Text>
</Box>,
);
const row = (key: string, content: React.ReactNode) => {
lines.push(
<Box key={key} width={fullWidth} height={1}>
<Text color={GOLD}> </Text>
<Box width={innerWidth} height={1}>{content}</Box>
<Text color={GOLD}> </Text>
</Box>,
);
};
row("pm-title", <Text color={GOLD} bold>🔒 Permission required</Text>);
row("pm-g1", <Text> </Text>);
row("pm-tool", <Text wrap="truncate-end" color={TEXT_PRIMARY}>{perm.toolTitle}</Text>);
row("pm-g2", <Text> </Text>);
for (let i = 0; i < perm.options.length; i++) {
const opt = perm.options[i]!;
const k = PERMISSION_KEYS[opt.kind] ?? String(i + 1);
const label = PERMISSION_LABELS[opt.kind] ?? opt.name;
const active = i === selectedIdx;
row(`pm-o${i}`, (
<>
<Text color={active ? GOLD : RULE_COLOR}>{active ? "▸ " : " "}</Text>
<Text color={active ? TEXT_PRIMARY : TEXT_SECONDARY} bold={active}>
[{k}] {label}
</Text>
</>
));
}
row("pm-g3", <Text> </Text>);
row("pm-help", <Text color={TEXT_DIM}> select · enter confirm · esc cancel</Text>);
lines.push(
<Box key="pm-b" width={fullWidth} height={1}>
<Text color={GOLD}>{hRule}</Text>
</Box>,
);
return lines;
}
});
function buildContentLines({
turn,
turnIndex,
width,
loading,
status,
@ -422,6 +209,7 @@ function buildContentLines({
queuedMessages,
}: {
turn: Turn | undefined;
turnIndex: number;
width: number;
loading: boolean;
status: string;
@ -434,15 +222,31 @@ function buildContentLines({
const lines: React.ReactElement[] = [];
if (!turn) return lines;
lines.push(emptyLine("u-gap", width));
lines.push(
<Box key="u-prompt" width={width} height={1}>
<Text color={CRANBERRY} bold>{" "}</Text>
{collapsedUserPrompt(turn.userText, width - 4)}
</Box>,
);
const safeWidth = Math.max(width, 20);
// Response items
const turnId = String(turnIndex);
lines.push(...renderUserPrompt(turn.userText, safeWidth, turnId, (text: string, availableWidth: number) => {
const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
const safeWidth = Math.max(availableWidth, 10);
const maxPreview = Math.max(safeWidth - 30, Math.min(SENT_PREVIEW_LEN, safeWidth - 10));
if (flat.length <= maxPreview + 10) {
return (
<Box width={safeWidth}>
<Text color={TEXT_PRIMARY} bold wrap="wrap">{flat}</Text>
</Box>
);
}
const preview = flat.slice(0, maxPreview) + "…";
const remaining = flat.length - maxPreview;
return (
<Box width={safeWidth}>
<Text color={TEXT_PRIMARY} bold wrap="wrap">{preview}</Text>
<Text color={TEXT_DIM}> ({remaining.toLocaleString()} more chars)</Text>
</Box>
);
}));
// Process response items
const hasToolCalls = turn.responseItems.some((it) => it.itemType === "tool_call");
let tcIdx = 0;
@ -450,69 +254,87 @@ function buildContentLines({
const item = turn.responseItems[i]!;
if (item.itemType === "tool_call") {
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,
};
lines.push(emptyLine(`tc-gap-${i}`, width));
lines.push(
...renderToolCallLines(info, width, toolCallsExpanded, tcIdx === 0 && hasToolCalls),
);
lines.push(...renderToolCallItem(item, i, safeWidth, toolCallsExpanded, tcIdx === 0, hasToolCalls));
tcIdx++;
} else if (
item.itemType === "content_chunk" &&
item.content.type === "text" &&
item.content.text
) {
const mdLines = renderMarkdown(item.content.text, width);
lines.push(emptyLine(`md-gap-${i}`, width));
for (let j = 0; j < mdLines.length; j++) {
lines.push(
<Box key={`md-${i}-${j}`} width={width} height={1}>
<Text wrap="truncate-end">{mdLines[j]}</Text>
</Box>,
);
}
} else if (item.itemType === "error") {
lines.push(...renderErrorItem(item, i, safeWidth));
} else if (item.itemType === "content_chunk") {
lines.push(...renderContentItem(item, i, safeWidth));
}
}
// Loading indicator
if (loading && !pendingPermission) {
lines.push(emptyLine("ld-gap", width));
lines.push(
<Box key="ld" width={width} height={1}>
<Spinner idx={spinIdx} />
<Text color={TEXT_DIM} italic> {status}</Text>
</Box>,
);
lines.push(...renderLoadingIndicator(status, spinIdx, safeWidth));
}
// Permission dialog
if (pendingPermission) {
lines.push(...buildPermissionLines(pendingPermission, permissionIdx, width));
const perm = pendingPermission;
const selectedIdx = permissionIdx;
const fullWidth = safeWidth;
const dialogWidth = Math.min(fullWidth - 2, 58);
const innerWidth = Math.max(dialogWidth - 4, 10);
const hRule = "─".repeat(Math.max(dialogWidth - 2, 0));
const permissionLines: React.ReactElement[] = [];
permissionLines.push(emptyLine(`pm-gap-${perm.toolTitle.slice(0, 10).replace(/[^a-zA-Z0-9]/g, '')}`, fullWidth));
permissionLines.push(
<Box key="pm-t" width={fullWidth} height={1}>
<Text color={GOLD}>{hRule}</Text>
</Box>,
);
const row = (key: string, content: React.ReactNode) => {
permissionLines.push(
<Box key={key} width={fullWidth} height={1}>
<Text color={GOLD}> </Text>
<Box width={innerWidth} height={1}>{content}</Box>
<Text color={GOLD}> </Text>
</Box>,
);
};
row("pm-title", <Text color={GOLD} bold>🔒 Permission required</Text>);
row("pm-g1", <Text> </Text>);
row("pm-tool", <Text wrap="truncate-end" color={TEXT_PRIMARY}>{perm.toolTitle}</Text>);
row("pm-g2", <Text> </Text>);
for (let i = 0; i < perm.options.length; i++) {
const opt = perm.options[i]!;
const k = PERMISSION_KEYS[opt.kind] ?? String(i + 1);
const label = PERMISSION_LABELS[opt.kind] ?? opt.name;
const active = i === selectedIdx;
row(`pm-o${i}`, (
<>
<Text color={active ? GOLD : RULE_COLOR}>{active ? "▸ " : " "}</Text>
<Text color={active ? TEXT_PRIMARY : TEXT_SECONDARY} bold={active}>
[{k}] {label}
</Text>
</>
));
}
row("pm-g3", <Text> </Text>);
row("pm-help", <Text color={TEXT_DIM}> select · enter confirm · esc cancel</Text>);
permissionLines.push(
<Box key="pm-b" width={fullWidth} height={1}>
<Text color={GOLD}>{hRule}</Text>
</Box>,
);
lines.push(...permissionLines);
}
// Queued messages
for (let i = 0; i < queuedMessages.length; i++) {
lines.push(
<Box key={`q-${i}`} width={width} height={1}>
<Text color={TEXT_DIM}>{" "}</Text>
<Text wrap="truncate-end" color={TEXT_DIM}>{queuedMessages[i]}</Text>
<Text color={GOLD} dimColor> (queued)</Text>
</Box>,
);
}
lines.push(...renderQueuedMessages(queuedMessages, safeWidth));
return lines;
}
function Viewport({
const Viewport = React.memo(function Viewport({
lines,
height,
width,
@ -551,7 +373,7 @@ function Viewport({
}
for (let i = 0; i < padCount; i++) {
elements.push(emptyLine(`vp-${i}`, width));
elements.push(emptyLine(`vp-pad-${i}`, width));
}
elements.push(...visible);
@ -566,14 +388,17 @@ function Viewport({
);
}
const constrainedWidth = Math.max(width, 10);
const constrainedHeight = Math.max(height, 1);
return (
<Box flexDirection="column" height={height} width={width}>
<Box flexDirection="column" height={constrainedHeight} width={constrainedWidth}>
{elements}
</Box>
);
}
});
function SplashScreen({
const SplashScreen = React.memo(function SplashScreen({
animFrame,
width,
height,
@ -596,12 +421,16 @@ function SplashScreen({
const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
// Use original dimensions for outer container to maintain centering
const safeWidth = Math.max(width, 20);
const safeHeight = Math.max(height, 10);
return (
<Box
flexDirection="column"
alignItems="center"
width={width}
height={height}
width={safeWidth}
height={safeHeight}
overflow="hidden"
>
{topPad > 0 && <Box height={topPad} />}
@ -613,14 +442,16 @@ function SplashScreen({
<Box marginTop={1}>
<Text color={TEXT_PRIMARY} bold>goose</Text>
</Box>
<Text color={TEXT_DIM}>your on-machine AI agent</Text>
<Box marginTop={2} gap={1}>
<Box alignItems="center">
<Text color={TEXT_DIM}>your on-machine AI agent</Text>
</Box>
<Box marginTop={2} gap={1} alignItems="center">
{loading && <Spinner idx={spinIdx} />}
<Text color={statusColor}>{status}</Text>
</Box>
</Box>
);
}
});
function App({
serverConnection,
@ -650,6 +481,7 @@ function App({
const [toolCallsExpanded, setToolCallsExpanded] = useState(false);
const [scrollOffset, setScrollOffset] = useState(0);
const [pastedFull, setPastedFull] = useState<string | null>(null);
const [needsOnboarding, setNeedsOnboarding] = useState(false);
const clientRef = useRef<GooseClient | null>(null);
const sessionIdRef = useRef<string | null>(null);
@ -704,6 +536,16 @@ function App({
});
}, []);
const appendError = useCallback((errorMessage: string) => {
setTurns((prev) => {
if (prev.length === 0) return prev;
const last = { ...prev[prev.length - 1]! };
const newItems = [...last.responseItems];
newItems.push({ itemType: "error", message: errorMessage });
return [...prev.slice(0, -1), { ...last, responseItems: newItems }];
});
}, []);
const handleToolCall = useCallback((tc: ToolCall) => {
setTurns((prev) => {
if (prev.length === 0) return prev;
@ -790,12 +632,14 @@ function App({
: `stopped: ${result.stopReason}`,
);
} catch (e: unknown) {
setStatus(`error: ${e instanceof Error ? e.message : String(e)}`);
const errorMsg = formatError(e);
setStatus(`error`);
appendError(errorMsg);
} finally {
setLoading(false);
}
},
[appendAgent, addUserTurn],
[appendAgent, appendError, addUserTurn],
);
const processQueue = useCallback(async () => {
@ -817,6 +661,36 @@ function App({
[executePrompt, processQueue],
);
const createSession = useCallback(async (client: GooseClient) => {
setStatus("creating session…");
setLoading(true);
try {
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
});
sessionIdRef.current = session.sessionId;
setLoading(false);
setStatus("ready");
if (initialPrompt && !sentInitialPrompt.current) {
sentInitialPrompt.current = true;
await sendPrompt(initialPrompt);
setTimeout(() => exit(), 100);
}
} catch (e: unknown) {
const errorMsg = formatError(e);
setStatus(`failed: ${errorMsg}`);
setLoading(false);
}
}, [initialPrompt, sendPrompt, exit]);
const handleOnboardingComplete = useCallback(() => {
setNeedsOnboarding(false);
const client = clientRef.current;
if (client) createSession(client);
}, [createSession]);
useEffect(() => {
let cancelled = false;
@ -870,32 +744,35 @@ function App({
});
if (cancelled) return;
setStatus("creating session…");
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
});
setStatus("checking provider…");
let hasProvider = false;
try {
const resp = await client.goose.GooseConfigRead({ key: "GOOSE_PROVIDER" });
hasProvider = resp.value != null && resp.value !== "" && resp.value !== "null";
} catch {
hasProvider = false;
}
if (cancelled) return;
sessionIdRef.current = session.sessionId;
setLoading(false);
setStatus("ready");
if (initialPrompt && !sentInitialPrompt.current) {
sentInitialPrompt.current = true;
await sendPrompt(initialPrompt);
setTimeout(() => exit(), 100);
if (!hasProvider && !initialPrompt) {
setNeedsOnboarding(true);
setLoading(false);
setStatus("setup required");
return;
}
await createSession(client);
} catch (e: unknown) {
if (cancelled) return;
setStatus(`failed: ${e instanceof Error ? e.message : String(e)}`);
const errorMsg = formatError(e);
setStatus(`failed: ${errorMsg}`);
setLoading(false);
}
})();
return () => { cancelled = true; };
}, [
serverConnection, initialPrompt, sendPrompt,
serverConnection, initialPrompt, createSession,
appendAgent, handleToolCall, handleToolCallUpdate, exit,
]);
@ -991,11 +868,13 @@ function App({
});
return;
}
});
}, { isActive: !needsOnboarding });
const PAD_X = 2;
const PAD_Y = 1;
const contentWidth = Math.max(termWidth - PAD_X * 2, 20);
const safeTermWidth = Math.max(termWidth, 40);
const safeTermHeight = Math.max(termHeight, 10);
const contentWidth = Math.max(safeTermWidth - PAD_X * 2, 20);
const effectiveTurnIdx = viewTurnIdx === -1 ? turns.length - 1 : viewTurnIdx;
const currentTurn = turns[effectiveTurnIdx];
@ -1015,12 +894,13 @@ function App({
const inputBarH = showInputBar ? 2 + inputContentRows + inputExtraLines : 0;
const historyBarH = isViewingHistory ? 2 : 0;
const viewportHeight = Math.max(
termHeight - PAD_Y * 2 - headerH - inputBarH - historyBarH,
safeTermHeight - PAD_Y * 2 - headerH - inputBarH - historyBarH,
3,
);
const contentLines = buildContentLines({
turn: currentTurn,
turnIndex: effectiveTurnIdx,
width: contentWidth,
loading: isLatest && loading,
status,
@ -1031,11 +911,28 @@ function App({
queuedMessages: isLatest ? queuedMessages : [],
});
if (needsOnboarding && clientRef.current) {
return (
<Box
flexDirection="column"
width={safeTermWidth}
height={safeTermHeight}
>
<Onboarding
client={clientRef.current}
width={safeTermWidth}
height={safeTermHeight}
onComplete={handleOnboardingComplete}
/>
</Box>
);
}
return (
<Box
flexDirection="column"
width={termWidth}
height={termHeight}
width={safeTermWidth}
height={safeTermHeight}
paddingX={PAD_X}
paddingY={PAD_Y}
>
@ -1043,7 +940,7 @@ function App({
<SplashScreen
animFrame={gooseFrame}
width={contentWidth}
height={Math.max(termHeight - PAD_Y * 2 - inputBarH, 0)}
height={Math.max(safeTermHeight - PAD_Y * 2 - inputBarH, 0)}
status={status}
loading={loading}
spinIdx={spinIdx}
@ -1119,22 +1016,7 @@ const cli = meow(
},
);
function findServerBinary(): string | null {
const __dirname = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(__dirname, "..", "server-binary.json"),
join(__dirname, "server-binary.json"),
];
for (const candidate of candidates) {
try {
const data = JSON.parse(readFileSync(candidate, "utf-8"));
return data.binaryPath ?? null;
} catch {
// not found here, try next
}
}
return null;
}
let serverProcess: ReturnType<typeof spawn> | null = null;
@ -1196,7 +1078,22 @@ async function main() {
if (cli.flags.server) {
serverConnection = cli.flags.server;
} else {
const binary = findServerBinary();
const binary = (() => {
const __dirname = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(__dirname, "..", "server-binary.json"),
join(__dirname, "server-binary.json"),
];
for (const candidate of candidates) {
try {
const data = JSON.parse(readFileSync(candidate, "utf-8"));
return data.binaryPath ?? null;
} catch {
// not found here, try next
}
}
return null;
})();
if (!binary) {
console.error(
"No goose binary found. Use --server <url> or install the native package.",

18
ui/text/src/types.tsx Normal file
View file

@ -0,0 +1,18 @@
import type { ContentChunk, ToolCall, RequestPermissionResponse } from "@agentclientprotocol/sdk";
export interface PendingPermission {
toolTitle: string;
options: Array<{ optionId: string; name: string; kind: string }>;
resolve: (response: RequestPermissionResponse) => void;
}
export type ResponseItem =
| (ContentChunk & { itemType: "content_chunk" })
| (ToolCall & { itemType: "tool_call" })
| { itemType: "error"; message: string };
export interface Turn {
userText: string;
responseItems: ResponseItem[];
toolCallsById: Map<string, number>;
}

20
ui/text/src/utils.tsx Normal file
View file

@ -0,0 +1,20 @@
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);
}