mirror of
https://github.com/block/goose.git
synced 2026-04-28 03:29:36 +00:00
feat: onboarding UX for the TUI (#8513)
This commit is contained in:
parent
2f018285a4
commit
17a404d4f9
26 changed files with 2325 additions and 423 deletions
22
AGENTS.md
22
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@
|
|||
"requestType": "ListProvidersRequest",
|
||||
"responseType": "ListProvidersResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/providers/details",
|
||||
"requestType": "GetProviderDetailsRequest",
|
||||
"responseType": "GetProviderDetailsResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/config/read",
|
||||
"requestType": "ReadConfigRequest",
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
43
ui/pnpm-lock.yaml
generated
|
|
@ -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
446
ui/text/AGENTS.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
103
ui/text/scripts/dev-binary.mjs
Executable 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}`);
|
||||
137
ui/text/src/components/ContentRenderers.tsx
Normal file
137
ui/text/src/components/ContentRenderers.tsx
Normal 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>
|
||||
));
|
||||
}
|
||||
35
ui/text/src/components/ErrorScreen.tsx
Normal file
35
ui/text/src/components/ErrorScreen.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
57
ui/text/src/components/Header.tsx
Normal file
57
ui/text/src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
12
ui/text/src/components/Rule.tsx
Normal file
12
ui/text/src/components/Rule.tsx
Normal 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>;
|
||||
});
|
||||
19
ui/text/src/components/Spinner.tsx
Normal file
19
ui/text/src/components/Spinner.tsx
Normal 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
82
ui/text/src/constants.tsx
Normal 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
675
ui/text/src/onboarding.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
18
ui/text/src/types.tsx
Normal 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
20
ui/text/src/utils.tsx
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue