diff --git a/AGENTS.md b/AGENTS.md index 6036a33b06..cb036c9eac 100644 --- a/AGENTS.md +++ b/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 diff --git a/crates/goose-acp/acp-meta.json b/crates/goose-acp/acp-meta.json index 8542de106c..d94f0bf745 100644 --- a/crates/goose-acp/acp-meta.json +++ b/crates/goose-acp/acp-meta.json @@ -45,6 +45,11 @@ "requestType": "ListProvidersRequest", "responseType": "ListProvidersResponse" }, + { + "method": "_goose/providers/details", + "requestType": "GetProviderDetailsRequest", + "responseType": "GetProviderDetailsResponse" + }, { "method": "_goose/config/read", "requestType": "ReadConfigRequest", diff --git a/crates/goose-acp/acp-schema.json b/crates/goose-acp/acp-schema.json index 0b8e85c059..6ecad2222b 100644 --- a/crates/goose-acp/acp-schema.json +++ b/crates/goose-acp/acp-schema.json @@ -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": [ { diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 3031c57f78..0715cb6cb8 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -2323,6 +2323,58 @@ impl GooseAcpAgent { }) } + #[custom_method(GetProviderDetailsRequest)] + async fn on_get_provider_details( + &self, + _req: GetProviderDetailsRequest, + ) -> Result { + 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::(&k.name).is_ok() + } else { + c.get_param::(&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, diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index c5b73469b6..e89aef1adf 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -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, +} + +#[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, + #[serde(default)] + pub setup_steps: Vec, +} + +#[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, + #[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 {} diff --git a/ui/acp/src/generated/client.gen.ts b/ui/acp/src/generated/client.gen.ts index 39314029b8..8c81f0fd7c 100644 --- a/ui/acp/src/generated/client.gen.ts +++ b/ui/acp/src/generated/client.gen.ts @@ -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 { + const raw = await this.conn.extMethod("_goose/providers/details", params); + return zGetProviderDetailsResponse.parse(raw) as GetProviderDetailsResponse; + } + async GooseConfigRead( params: ReadConfigRequest, ): Promise { diff --git a/ui/acp/src/generated/index.ts b/ui/acp/src/generated/index.ts index 727f4c1d3f..7f0dd71abb 100644 --- a/ui/acp/src/generated/index.ts +++ b/ui/acp/src/generated/index.ts @@ -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", diff --git a/ui/acp/src/generated/types.gen.ts b/ui/acp/src/generated/types.gen.ts index a5e8b237a7..8114690a7f 100644 --- a/ui/acp/src/generated/types.gen.ts +++ b/ui/acp/src/generated/types.gen.ts @@ -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; +}; + +export type ProviderDetailEntry = { + name: string; + displayName: string; + description: string; + defaultModel: string; + isConfigured: boolean; + providerType: string; + configKeys: Array; + setupSteps?: Array; +}; + +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; diff --git a/ui/acp/src/generated/zod.gen.ts b/ui/acp/src/generated/zod.gen.ts index 6c4860195b..5b0bc3c054 100644 --- a/ui/acp/src/generated/zod.gen.ts +++ b/ui/acp/src/generated/zod.gen.ts @@ -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, diff --git a/ui/desktop/src/components/ModelAndProviderContext.tsx b/ui/desktop/src/components/ModelAndProviderContext.tsx index 42df0578da..8b5f19d624 100644 --- a/ui/desktop/src/components/ModelAndProviderContext.tsx +++ b/ui/desktop/src/components/ModelAndProviderContext.tsx @@ -63,63 +63,69 @@ export const ModelAndProviderProvider: React.FC = 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; diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 1dab131e54..e6d9beb2f9 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -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: diff --git a/ui/text/AGENTS.md b/ui/text/AGENTS.md new file mode 100644 index 0000000000..1344f1f545 --- /dev/null +++ b/ui/text/AGENTS.md @@ -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 + + Hello World + +// Output: "Hello\nWorld" + +// Hard wrapping - breaks anywhere to fill width + + Hello World + +// Output: "Hello W\norld" + +// Truncation options + + Hello World + +// Output: "Hello…" + + + Hello World + +// Output: "He…ld" +``` + +### Common Text Overflow Issues +❌ **Don't assume unlimited width:** +```jsx +// BAD - Text may overflow terminal width +This is a very long line that might exceed the terminal width and cause layout issues +``` + +✅ **Do constrain text appropriately:** +```jsx +// GOOD - Constrain width and handle wrapping + + This is a very long line that will wrap properly within the container + +``` + +## Layout Constraints and Best Practices + +### 1. Terminal Width Awareness +Always consider terminal width limitations: + +```jsx +import {useWindowSize} from 'ink'; + +const ResponsiveComponent = () => { + const {columns} = useWindowSize(); + + return ( + {/* Leave margin, cap at 80 */} + Content that adapts to terminal size + + ); +}; +``` + +### 2. Vertical Space Management +Terminal height is limited - avoid excessive vertical content: + +❌ **Don't create unlimited vertical lists:** +```jsx +// BAD - Could exceed terminal height +{items.map(item => ( + + {item.title} + +))} +``` + +✅ **Do implement pagination or scrolling:** +```jsx +// GOOD - Paginate or limit visible items +const visibleItems = items.slice(currentPage * pageSize, (currentPage + 1) * pageSize); +return ( + <> + {visibleItems.map(item => ( + + {item.title} + + ))} + Page {currentPage + 1} of {Math.ceil(items.length / pageSize)} + +); +``` + +### 3. Flexbox Layout Patterns + +**Horizontal layouts:** +```jsx +// Side-by-side content + + + Left panel + + + Right panel + + + +// Label-value pairs + + Status: + + Running + + +``` + +**Vertical layouts:** +```jsx +// Stacked content + + Header + + Main content + + Footer + +``` + +## Ink-Specific Components + +### 1. Text Component +- **All text must be wrapped in ``** +- Only text nodes and nested `` components allowed inside +- No `` or other components inside `` + +```jsx +// ✅ Correct +Success: Operation completed + +// ❌ Incorrect +Status: Running +``` + +### 2. Box Component +- Primary layout component (like `
` but with `display: flex`) +- Supports Flexbox properties, padding, margin, borders +- Use for all layout and positioning + +### 3. Static Component +- For content that doesn't change after rendering +- Useful for logs, completed tasks, permanent output +- Renders above dynamic content + +```jsx + + {task => ( + + ✓ {task.name} + + )} + +``` + +### 4. Spacer Component +- Flexible space that expands along the major axis +- Useful for pushing content to edges + +```jsx + + Left + + Right + +``` + +## Input and Interaction + +### Keyboard Input +```jsx +import {useInput} from 'ink'; + +const InteractiveComponent = () => { + useInput((input, key) => { + if (input === 'q') { + process.exit(0); + } + + if (key.upArrow) { + // Handle up arrow + } + + if (key.return) { + // Handle enter key + } + }); + + return Press 'q' to quit; +}; +``` + +### Focus Management +```jsx +import {useFocus} from 'ink'; + +const FocusableComponent = () => { + const {isFocused} = useFocus(); + + return ( + + {isFocused ? '> ' : ' '}Focusable item + + ); +}; +``` + +## Performance Considerations + +### 1. Minimize Re-renders +Terminal rendering is expensive - avoid unnecessary updates: + +```jsx +// Use React.memo for stable components +const StatusLine = React.memo(({status}) => ( + Status: {status} +)); + +// Debounce rapid updates +const [debouncedValue] = useDebounce(rapidlyChangingValue, 100); +``` + +### 2. Animation Considerations +```jsx +import {useAnimation} from 'ink'; + +const Spinner = () => { + const {frame} = useAnimation({interval: 80}); // Not too fast + const chars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + + return {chars[frame % chars.length]}; +}; +``` + +### 3. Control Frame Rate +```jsx +// Limit updates for better performance +render(, { + maxFps: 30, // Default is 30, lower for less CPU usage +}); +``` + +## Common Pitfalls and Solutions + +### 1. Text Overflow +❌ **Problem:** Text exceeds terminal width +```jsx +Very long text that might overflow the terminal width causing display issues +``` + +✅ **Solution:** Use width constraints and wrapping +```jsx + + Very long text that might overflow the terminal width causing display issues + +``` + +### 2. Nested Box Issues +❌ **Problem:** Unnecessary nesting causing layout issues +```jsx + + + + Over-nested content + + + +``` + +✅ **Solution:** Flatten structure when possible +```jsx + + Properly structured content + +``` + +### 3. Color and Styling +❌ **Problem:** Assuming rich styling support +```jsx +Styled text +``` + +✅ **Solution:** Use Ink's supported styling props +```jsx +Styled text +``` + +### 4. Dynamic Content Height +❌ **Problem:** Unlimited dynamic content +```jsx +{messages.map(msg => ( + {msg.content} +))} +``` + +✅ **Solution:** Implement scrolling or pagination +```jsx +const visibleMessages = messages.slice(-maxVisible); +return ( + + {visibleMessages.map(msg => ( + {msg.content} + ))} + +); +``` + +## Testing Terminal UIs + +### 1. Use ink-testing-library +```jsx +import {render} from 'ink-testing-library'; + +const {lastFrame, stdin} = render(); + +// Test output +expect(lastFrame()).toMatch(/Expected text/); + +// Test input +stdin.write('q'); +expect(lastFrame()).toMatch(/Quit message/); +``` + +### 2. Test Different Terminal Sizes +```jsx +// Test with different widths +const {lastFrame} = render(, {columns: 40}); +expect(lastFrame()).toMatch(/Wrapped content/); +``` + +## Accessibility Considerations + +### Screen Reader Support +```jsx +// Provide meaningful labels + + Accept terms + + +// Use descriptive labels for progress indicators + + + 50% + +``` + +## Best Practices Summary + +1. **Always constrain content width** - Use `width` props or percentage widths +2. **Handle text wrapping explicitly** - Set appropriate `wrap` values +3. **Consider terminal size** - Use `useWindowSize()` for responsive layouts +4. **Minimize vertical content** - Implement pagination for long lists +5. **Use semantic structure** - Proper component hierarchy with `` and `` +6. **Test with different terminal sizes** - Ensure layouts work across screen sizes +7. **Optimize for performance** - Avoid unnecessary re-renders and high frame rates +8. **Provide keyboard navigation** - Implement proper focus management +9. **Consider accessibility** - Use ARIA labels where appropriate +10. **Handle edge cases** - Empty states, loading states, error conditions + +## Example: Well-Structured Ink Component + +```jsx +import React, {useState} from 'react'; +import {Box, Text, useInput, useWindowSize, Spacer} from 'ink'; + +const TaskList = ({tasks}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const {columns} = useWindowSize(); + + useInput((input, key) => { + if (key.upArrow && selectedIndex > 0) { + setSelectedIndex(selectedIndex - 1); + } + if (key.downArrow && selectedIndex < tasks.length - 1) { + setSelectedIndex(selectedIndex + 1); + } + }); + + const maxWidth = Math.min(columns - 4, 80); + + return ( + + + Task List ({tasks.length}) + + + + {tasks.map((task, index) => ( + + + {task.completed ? '✓' : '○'} + + + + {task.title} + + + {task.priority} + + ))} + + + + Use ↑↓ to navigate + + + ); +}; +``` + +This example demonstrates: +- Proper width constraints and responsive design +- Keyboard input handling +- Appropriate use of Ink components +- Text truncation for overflow handling +- Clear visual hierarchy and spacing +- Accessibility considerations with clear navigation hints \ No newline at end of file diff --git a/ui/text/README.md b/ui/text/README.md index e725627544..191dbe0bf7 100644 --- a/ui/text/README.md +++ b/ui/text/README.md @@ -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 diff --git a/ui/text/package.json b/ui/text/package.json index fa7731ceff..a2cce4ec25 100644 --- a/ui/text/package.json +++ b/ui/text/package.json @@ -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", diff --git a/ui/text/scripts/dev-binary.mjs b/ui/text/scripts/dev-binary.mjs new file mode 100755 index 0000000000..9fc52dd2bc --- /dev/null +++ b/ui/text/scripts/dev-binary.mjs @@ -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}`); diff --git a/ui/text/src/components/ContentRenderers.tsx b/ui/text/src/components/ContentRenderers.tsx new file mode 100644 index 0000000000..3a300257ab --- /dev/null +++ b/ui/text/src/components/ContentRenderers.tsx @@ -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 ; +} + +export function renderUserPrompt( + userText: string, + width: number, + turnId: string, + collapsedUserPrompt: (text: string, width: number) => React.ReactElement +): React.ReactElement[] { + const constrainedWidth = Math.max(width - 4, 10); + return [ + emptyLine(`u-gap-${turnId}`, width), + + {"❯ "} + + {collapsedUserPrompt(userText, constrainedWidth)} + + , + ]; +} + +export function renderToolCallItem( + item: ResponseItem & { itemType: "tool_call" }, + index: number, + width: number, + 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), + + {"⚠ Error: "} + , + ]; + + const errorLines = item.message.split("\n"); + errorLines.forEach((line, j) => { + lines.push( + + + {line} + + + ); + }); + + return lines; +} + +export function renderContentItem( + item: ResponseItem & { itemType: "content_chunk" }, + index: number, + width: number +): React.ReactElement[] { + if (item.content.type !== "text" || !item.content.text) { + return []; + } + + const constrainedWidth = Math.max(width - 2, 10); + const mdLines = renderMarkdown(item.content.text, constrainedWidth); + const lines: React.ReactElement[] = [emptyLine(`md-gap-${index}`, width)]; + + mdLines.forEach((mdLine, j) => { + lines.push( + + + {mdLine} + + + ); + }); + + return lines; +} + +export function renderLoadingIndicator( + status: string, + spinIdx: number, + width: number +): React.ReactElement[] { + return [ + emptyLine("ld-gap", width), + + + {status} + , + ]; +} + +export function renderQueuedMessages( + queuedMessages: string[], + width: number +): React.ReactElement[] { + const messageWidth = Math.max(width - 20, 10); + return queuedMessages.map((message, i) => ( + + {"❯ "} + + {message} + + (queued) + + )); +} diff --git a/ui/text/src/components/ErrorScreen.tsx b/ui/text/src/components/ErrorScreen.tsx new file mode 100644 index 0000000000..d9cf961b88 --- /dev/null +++ b/ui/text/src/components/ErrorScreen.tsx @@ -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 ( + + ✗ Setup error + {errorMsg && ( + + {errorMsg} + + )} + + press enter to retry + + + ); +}); diff --git a/ui/text/src/components/Header.tsx b/ui/text/src/components/Header.tsx new file mode 100644 index 0000000000..0e66e4ac21 --- /dev/null +++ b/ui/text/src/components/Header.tsx @@ -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 ( + + + + goose + · + + {status} + + {loading && !hasPendingPermission && ( + + )} + + + {turnInfo && turnInfo.total > 1 && ( + + {turnInfo.current}/{turnInfo.total}{" "} + + )} + ^C exit + + + + + ); +}); diff --git a/ui/text/src/components/Rule.tsx b/ui/text/src/components/Rule.tsx new file mode 100644 index 0000000000..ad7313afd9 --- /dev/null +++ b/ui/text/src/components/Rule.tsx @@ -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 {"─".repeat(ruleWidth)}; +}); diff --git a/ui/text/src/components/Spinner.tsx b/ui/text/src/components/Spinner.tsx new file mode 100644 index 0000000000..b2708640dd --- /dev/null +++ b/ui/text/src/components/Spinner.tsx @@ -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 ( + + {SPINNER_FRAMES[idx % SPINNER_FRAMES.length]} + + ); +}); + +export { SPINNER_FRAMES }; diff --git a/ui/text/src/constants.tsx b/ui/text/src/constants.tsx new file mode 100644 index 0000000000..406705a08e --- /dev/null +++ b/ui/text/src/constants.tsx @@ -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 = { + allow_once: "Allow once", + allow_always: "Always allow", + reject_once: "Reject once", + reject_always: "Always reject", +}; + +export const PERMISSION_KEYS: Record = { + allow_once: "y", + allow_always: "a", + reject_once: "n", + reject_always: "r", +}; diff --git a/ui/text/src/onboarding.tsx b/ui/text/src/onboarding.tsx new file mode 100644 index 0000000000..b6e59b12fd --- /dev/null +++ b/ui/text/src/onboarding.tsx @@ -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 ( + + + + + {provider.displayName} + + + + {provider.providerType === "Preferred" && ( + + )} + {provider.isConfigured && ( + + )} + + + + + + + {provider.name} + + + {provider.description && ( + + + {provider.description.length > descriptionMaxChars + ? provider.description.slice(0, descriptionMaxChars - 1) + "…" + : provider.description} + + + )} + + + ); + }; + + const visibleRows = []; + for (let row = scrollRow; row < Math.min(scrollRow + rowsVisible, totalRows); row++) { + const rowProviders = []; + for (let col = 0; col < cardsPerRow; col++) { + const index = row * cardsPerRow + col; + if (index < filtered.length) { + const isSelected = index === selectedIdx; + rowProviders.push(renderProviderCard(filtered[index], index, isSelected)); + } + } + + if (rowProviders.length > 0) { + const isLastVisibleRow = row === Math.min(scrollRow + rowsVisible, totalRows) - 1; + visibleRows.push( + + {rowProviders} + + ); + } + } + + return ( + + {/* Header */} + + + + ◆ Welcome to goose ◆ + + + + + Connect an AI model provider to get started + + + + {/* Search Bar */} + + + + {"❯ "} + + + {searchQuery || "search providers…"} + + + + + {/* Provider Grid */} + + {filtered.length === 0 ? ( + + No matching providers found + + ) : ( + <> + {scrollRow > 0 && ( + + ▲ {scrollRow * cardsPerRow} more above + + )} + + + + {visibleRows} + + + + {scrollRow + rowsVisible < totalRows && ( + + + ▼ {filtered.length - (scrollRow + rowsVisible) * cardsPerRow} more below + + + )} + + )} + + + {/* Footer */} + + + ↑↓←→ navigate · enter select · type to search · esc clear + + + + ); +}); + +interface ProviderConfiguratorProps { + provider: ProviderDetailEntry; + height: number; + onComplete: (values: Record) => void; + onBack: () => void; +} + +const ProviderConfigurator = React.memo(function ProviderConfigurator({ provider, height, onComplete, onBack }: ProviderConfiguratorProps) { + const [keyValues, setKeyValues] = useState>({}); + const [activeKeyIdx, setActiveKeyIdx] = useState(0); + const [showMasked, setShowMasked] = useState>({}); + const [inputKey, setInputKey] = useState(0); + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + + const keys = provider.configKeys.filter( + (k) => k.required && !k.oauthFlow && !k.deviceCodeFlow, + ); + const currentKey = keys[activeKeyIdx]; + + useInput((_ch, key) => { + if (!currentKey) return; + + if (key.escape) { + onBack(); + return; + } + if (key.tab && currentKey.secret) { + setShowMasked((prev) => ({ + ...prev, + [currentKey.name]: !prev[currentKey.name], + })); + return; + } + }); + + const handleSubmit = (value: string) => { + if (!currentKey) return; + const effective = value.trim() || currentVal.trim(); + if (!effective) return; + const newValues = { ...keyValues, [currentKey.name]: effective }; + setKeyValues(newValues); + if (activeKeyIdx < keys.length - 1) { + setActiveKeyIdx(activeKeyIdx + 1); + setShowMasked({}); + setInputKey(prev => prev + 1); // Force new input component + } else { + onComplete(newValues); + } + }; + + const handleChange = (value: string) => { + if (!currentKey) return; + setKeyValues((prev) => ({ + ...prev, + [currentKey.name]: value, + })); + }; + + const currentVal = keyValues[currentKey?.name ?? ""] ?? ""; + const masked = currentKey?.secret && !showMasked[currentKey?.name ?? ""]; + const maxWidth = Math.min(columns - 4, 80); + + // Calculate content height for proper centering + const headerHeight = 1 + (provider.description ? 2 : 0) + 1; // title + description + spacer + const keysHeight = keys.length; // one line per key + const inputHeight = currentKey ? 3 : 0; // input + help text + spacing + const setupStepsHeight = provider.setupSteps?.length ? provider.setupSteps.length + 1 : 0; + const contentHeight = headerHeight + keysHeight + inputHeight + setupStepsHeight; + const topPad = Math.max(0, Math.floor((height - contentHeight) / 2)); + + return ( + + {topPad > 0 && } + + {/* Header */} + + Configure {provider.displayName} + + {provider.description && ( + + {provider.description} + + )} + + + {/* Configuration Keys */} + {keys.map((k, i) => ( + + + {i < activeKeyIdx ? "✓ " : i === activeKeyIdx ? "▸ " : " "} + + + {k.name} + + {i < activeKeyIdx && ( + •••••• + )} + + ))} + + {/* Current Input Field */} + {currentKey && ( + + + + {"❯ "} + + {masked ? ( + + ) : ( + + )} + + + + + enter to confirm · esc to go back + {currentKey.secret && ( + <> + {" · tab to "} + {masked ? "reveal" : "hide"} + + )} + + + + + )} + + {/* Setup Steps */} + {provider.setupSteps && + provider.setupSteps.length > 0 && ( + + Setup steps: + {provider.setupSteps.map((step, i) => ( + + + {i + 1}. {step} + + + ))} + + )} + + + ); +}); + +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 ( + + {topPad > 0 && } + + + ✓ Provider configured + + {provider && ( + + + Connected to {provider.displayName} + + + )} + + + ); +}); + +export default function Onboarding({ + client, + width, + height, + onComplete, +}: OnboardingProps) { + const [phase, setPhase] = useState("loading"); + const [providers, setProviders] = useState([]); + const [selectedProvider, setSelectedProvider] = + useState(null); + const [errorMsg, setErrorMsg] = useState(""); + const [spinIdx, setSpinIdx] = useState(0); + const [fetchKey, setFetchKey] = useState(0); + + useEffect(() => { + const t = setInterval( + () => setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length), + 300, + ); + return () => clearInterval(t); + }, []); + + useEffect(() => { + (async () => { + try { + const resp = await client.goose.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) => { + 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 ( + + {topPad > 0 && } + + + + loading providers… + + + + ); + } + + if (phase === "error") { + return ( + + + + ); + } + + if (phase === "saving") { + const contentHeight = 3; // spinner + text + spacing + const topPad = Math.max(0, Math.floor((height - contentHeight) / 2)); + + return ( + + {topPad > 0 && } + + + + saving configuration… + + + + ); + } + + if (phase === "success") { + return ( + + ); + } + + if (phase === "configure" && selectedProvider) { + return ( + saveProvider(selectedProvider, values)} + onBack={() => { + setSelectedProvider(null); + setPhase("select_provider"); + }} + /> + ); + } + + return ( + + ); +} diff --git a/ui/text/src/toolcall.tsx b/ui/text/src/toolcall.tsx index d34912b736..ac1edbe4c8 100644 --- a/ui/text/src/toolcall.tsx +++ b/ui/text/src/toolcall.tsx @@ -41,8 +41,9 @@ const STATUS_INDICATORS: Record = { }; 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( - + ╭{hRule}╮ , ); const row = (key: string, content: React.ReactNode) => { lines.push( - + {content} @@ -166,7 +168,7 @@ export function renderToolCallLines( } lines.push( - + ╰{hRule}╯ , ); diff --git a/ui/text/src/tui.tsx b/ui/text/src/tui.tsx index ec20e97cd7..248413bdbb 100644 --- a/ui/text/src/tui.tsx +++ b/ui/text/src/tui.tsx @@ -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; -} - -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 = { - allow_once: "Allow once", - allow_always: "Always allow", - reject_once: "Reject once", - reject_always: "Always reject", -}; - -const PERMISSION_KEYS: Record = { - allow_once: "y", - allow_always: "a", - reject_once: "n", - reject_always: "N", -}; - -function Rule({ width }: { width: number }) { - return {"─".repeat(Math.max(width, 1))}; -} - -function Spinner({ idx }: { idx: number }) { - return ( - - {SPINNER_FRAMES[idx % SPINNER_FRAMES.length]} - - ); -} - -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 ( - - - - goose - · - {status} - {loading && !hasPendingPermission && ( - - )} - - - {turnInfo && turnInfo.total > 1 && ( - - {turnInfo.current}/{turnInfo.total}{" "} - - )} - ^C exit - - - - - ); -} - -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 {flat}; - } - const preview = flat.slice(0, maxPreview) + "…"; - const remaining = flat.length - maxPreview; - return ( - - {preview} - ({remaining.toLocaleString()} more chars) - - ); -} - -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 ( {"❯ "} {isPasteMode ? ( - - - {collapseForDisplay(pastedFull, width - 4 - 2)} - + + + + {(() => { + const text = pastedFull; + const availableWidth = Math.max(contentWidth - 20, 10); + const flat = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim(); + if (flat.length <= availableWidth) return flat; + const suffix = ` (${flat.length.toLocaleString()} chars)`; + const previewLen = Math.max(availableWidth - suffix.length - 1, 5); + return flat.slice(0, previewLen) + "…" + suffix; + })()} + + {scrollHint && shift+↑↓ history} ) : ( @@ -344,74 +194,11 @@ function InputBar({ )} ); -} - -function emptyLine(key: string, width: number): React.ReactElement { - return ; -} - -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( - - ╭{hRule}╮ - , - ); - - const row = (key: string, content: React.ReactNode) => { - lines.push( - - - {content} - - , - ); - }; - - row("pm-title", 🔒 Permission required); - row("pm-g1", ); - row("pm-tool", {perm.toolTitle}); - row("pm-g2", ); - - 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}`, ( - <> - {active ? "▸ " : " "} - - [{k}] {label} - - - )); - } - - row("pm-g3", ); - row("pm-help", ↑↓ select · enter confirm · esc cancel); - - lines.push( - - ╰{hRule}╯ - , - ); - - 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( - - {"❯ "} - {collapsedUserPrompt(turn.userText, width - 4)} - , - ); + 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 ( + + {flat} + + ); + } + const preview = flat.slice(0, maxPreview) + "…"; + const remaining = flat.length - maxPreview; + return ( + + {preview} + ({remaining.toLocaleString()} more chars) + + ); + })); + + // 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( - - {mdLines[j]} - , - ); - } + } 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( - - - {status} - , - ); + 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( + + ╭{hRule}╮ + , + ); + + const row = (key: string, content: React.ReactNode) => { + permissionLines.push( + + + {content} + + , + ); + }; + + row("pm-title", 🔒 Permission required); + row("pm-g1", ); + row("pm-tool", {perm.toolTitle}); + row("pm-g2", ); + + 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}`, ( + <> + {active ? "▸ " : " "} + + [{k}] {label} + + + )); + } + + row("pm-g3", ); + row("pm-help", ↑↓ select · enter confirm · esc cancel); + + permissionLines.push( + + ╰{hRule}╯ + , + ); + + lines.push(...permissionLines); } // Queued messages - for (let i = 0; i < queuedMessages.length; i++) { - lines.push( - - {"❯ "} - {queuedMessages[i]} - (queued) - , - ); - } + 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 ( - + {elements} ); -} +}); -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 ( {topPad > 0 && } @@ -613,14 +442,16 @@ function SplashScreen({ goose - your on-machine AI agent - + + your on-machine AI agent + + {loading && } {status} ); -} +}); function App({ serverConnection, @@ -650,6 +481,7 @@ function App({ const [toolCallsExpanded, setToolCallsExpanded] = useState(false); const [scrollOffset, setScrollOffset] = useState(0); const [pastedFull, setPastedFull] = useState(null); + const [needsOnboarding, setNeedsOnboarding] = useState(false); const clientRef = useRef(null); const sessionIdRef = useRef(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 ( + + + + ); + } + return ( @@ -1043,7 +940,7 @@ function App({ | 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 or install the native package.", diff --git a/ui/text/src/types.tsx b/ui/text/src/types.tsx new file mode 100644 index 0000000000..ce15d50b03 --- /dev/null +++ b/ui/text/src/types.tsx @@ -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; +} diff --git a/ui/text/src/utils.tsx b/ui/text/src/utils.tsx new file mode 100644 index 0000000000..a379f39994 --- /dev/null +++ b/ui/text/src/utils.tsx @@ -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); +}