feat(tui): add extension management screen (#8536)
This commit is contained in:
@@ -35,6 +35,11 @@
|
|||||||
"requestType": "GetExtensionsRequest",
|
"requestType": "GetExtensionsRequest",
|
||||||
"responseType": "GetExtensionsResponse"
|
"responseType": "GetExtensionsResponse"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "_goose/session/extensions",
|
||||||
|
"requestType": "GetSessionExtensionsRequest",
|
||||||
|
"responseType": "GetSessionExtensionsResponse"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "_goose/session/provider/update",
|
"method": "_goose/session/provider/update",
|
||||||
"requestType": "UpdateProviderRequest",
|
"requestType": "UpdateProviderRequest",
|
||||||
|
|||||||
@@ -168,6 +168,33 @@
|
|||||||
"x-side": "agent",
|
"x-side": "agent",
|
||||||
"x-method": "_goose/config/extensions"
|
"x-method": "_goose/config/extensions"
|
||||||
},
|
},
|
||||||
|
"GetSessionExtensionsRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sessionId": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"sessionId"
|
||||||
|
],
|
||||||
|
"x-side": "agent",
|
||||||
|
"x-method": "_goose/session/extensions"
|
||||||
|
},
|
||||||
|
"GetSessionExtensionsResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"extensions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"extensions"
|
||||||
|
],
|
||||||
|
"x-side": "agent",
|
||||||
|
"x-method": "_goose/session/extensions"
|
||||||
|
},
|
||||||
"UpdateProviderRequest": {
|
"UpdateProviderRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -710,6 +737,15 @@
|
|||||||
"description": "Params for _goose/config/extensions",
|
"description": "Params for _goose/config/extensions",
|
||||||
"title": "GetExtensionsRequest"
|
"title": "GetExtensionsRequest"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/GetSessionExtensionsRequest"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Params for _goose/session/extensions",
|
||||||
|
"title": "GetSessionExtensionsRequest"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
@@ -898,6 +934,14 @@
|
|||||||
],
|
],
|
||||||
"title": "GetExtensionsResponse"
|
"title": "GetExtensionsResponse"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/$defs/GetSessionExtensionsResponse"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "GetSessionExtensionsResponse"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2291,6 +2291,34 @@ impl GooseAcpAgent {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[custom_method(GetSessionExtensionsRequest)]
|
||||||
|
async fn on_get_session_extensions(
|
||||||
|
&self,
|
||||||
|
req: GetSessionExtensionsRequest,
|
||||||
|
) -> Result<GetSessionExtensionsResponse, sacp::Error> {
|
||||||
|
let internal_id = self.internal_session_id(&req.session_id).await?;
|
||||||
|
let session = self
|
||||||
|
.session_manager
|
||||||
|
.get_session(&internal_id, false)
|
||||||
|
.await
|
||||||
|
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
|
||||||
|
|
||||||
|
let extensions = EnabledExtensionsState::extensions_or_default(
|
||||||
|
Some(&session.extension_data),
|
||||||
|
goose::config::Config::global(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let extensions_json = extensions
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| serde_json::to_value(&e))
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| sacp::Error::internal_error().data(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(GetSessionExtensionsResponse {
|
||||||
|
extensions: extensions_json,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[custom_method(UpdateProviderRequest)]
|
#[custom_method(UpdateProviderRequest)]
|
||||||
async fn on_update_provider(
|
async fn on_update_provider(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -104,6 +104,18 @@ pub struct GetExtensionsResponse {
|
|||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||||
|
#[request(method = "_goose/session/extensions", response = GetSessionExtensionsResponse)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GetSessionExtensionsRequest {
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||||
|
pub struct GetSessionExtensionsResponse {
|
||||||
|
pub extensions: Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Atomically update the provider for a live session.
|
/// Atomically update the provider for a live session.
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||||
#[request(method = "_goose/session/provider/update", response = UpdateProviderResponse)]
|
#[request(method = "_goose/session/provider/update", response = UpdateProviderResponse)]
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import type {
|
|||||||
GetProviderDetailsResponse,
|
GetProviderDetailsResponse,
|
||||||
GetProviderModelsRequest,
|
GetProviderModelsRequest,
|
||||||
GetProviderModelsResponse,
|
GetProviderModelsResponse,
|
||||||
|
GetSessionExtensionsRequest,
|
||||||
|
GetSessionExtensionsResponse,
|
||||||
GetToolsRequest,
|
GetToolsRequest,
|
||||||
GetToolsResponse,
|
GetToolsResponse,
|
||||||
ImportSessionRequest,
|
ImportSessionRequest,
|
||||||
@@ -47,6 +49,7 @@ import {
|
|||||||
zGetExtensionsResponse,
|
zGetExtensionsResponse,
|
||||||
zGetProviderDetailsResponse,
|
zGetProviderDetailsResponse,
|
||||||
zGetProviderModelsResponse,
|
zGetProviderModelsResponse,
|
||||||
|
zGetSessionExtensionsResponse,
|
||||||
zGetToolsResponse,
|
zGetToolsResponse,
|
||||||
zImportSessionResponse,
|
zImportSessionResponse,
|
||||||
zListProvidersResponse,
|
zListProvidersResponse,
|
||||||
@@ -93,6 +96,15 @@ export class GooseExtClient {
|
|||||||
return zGetExtensionsResponse.parse(raw) as GetExtensionsResponse;
|
return zGetExtensionsResponse.parse(raw) as GetExtensionsResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async GooseSessionExtensions(
|
||||||
|
params: GetSessionExtensionsRequest,
|
||||||
|
): Promise<GetSessionExtensionsResponse> {
|
||||||
|
const raw = await this.conn.extMethod("_goose/session/extensions", params);
|
||||||
|
return zGetSessionExtensionsResponse.parse(
|
||||||
|
raw,
|
||||||
|
) as GetSessionExtensionsResponse;
|
||||||
|
}
|
||||||
|
|
||||||
async GooseSessionProviderUpdate(
|
async GooseSessionProviderUpdate(
|
||||||
params: UpdateProviderRequest,
|
params: UpdateProviderRequest,
|
||||||
): Promise<UpdateProviderResponse> {
|
): Promise<UpdateProviderResponse> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
export type { AddExtensionRequest, ArchiveSessionRequest, CheckSecretRequest, CheckSecretResponse, DeleteSessionRequest, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetProviderDetailsRequest, GetProviderDetailsResponse, GetProviderModelsRequest, GetProviderModelsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ModelEntry, ProviderConfigKey, ProviderDetailEntry, 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, GetProviderModelsRequest, GetProviderModelsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, ImportSessionRequest, ImportSessionResponse, ListProvidersRequest, ListProvidersResponse, ModelEntry, 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 = [
|
export const GOOSE_EXT_METHODS = [
|
||||||
{
|
{
|
||||||
@@ -38,6 +38,11 @@ export const GOOSE_EXT_METHODS = [
|
|||||||
requestType: "GetExtensionsRequest",
|
requestType: "GetExtensionsRequest",
|
||||||
responseType: "GetExtensionsResponse",
|
responseType: "GetExtensionsResponse",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: "_goose/session/extensions",
|
||||||
|
requestType: "GetSessionExtensionsRequest",
|
||||||
|
responseType: "GetSessionExtensionsResponse",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: "_goose/session/provider/update",
|
method: "_goose/session/provider/update",
|
||||||
requestType: "UpdateProviderRequest",
|
requestType: "UpdateProviderRequest",
|
||||||
|
|||||||
@@ -96,6 +96,14 @@ export type GetExtensionsResponse = {
|
|||||||
warnings: Array<string>;
|
warnings: Array<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetSessionExtensionsRequest = {
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetSessionExtensionsResponse = {
|
||||||
|
extensions: Array<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically update the provider for a live session.
|
* Atomically update the provider for a live session.
|
||||||
*/
|
*/
|
||||||
@@ -299,14 +307,14 @@ export type UnarchiveSessionRequest = {
|
|||||||
export type ExtRequest = {
|
export type ExtRequest = {
|
||||||
id: string;
|
id: string;
|
||||||
method: string;
|
method: string;
|
||||||
params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | UpdateProviderRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderModelsRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | {
|
params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | GetSessionExtensionsRequest | UpdateProviderRequest | ListProvidersRequest | GetProviderDetailsRequest | GetProviderModelsRequest | ReadConfigRequest | UpsertConfigRequest | RemoveConfigRequest | CheckSecretRequest | UpsertSecretRequest | RemoveSecretRequest | ExportSessionRequest | ImportSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ExtResponse = {
|
export type ExtResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | UpdateProviderResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderModelsResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | unknown;
|
result?: EmptyResponse | GetToolsResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | UpdateProviderResponse | ListProvidersResponse | GetProviderDetailsResponse | GetProviderModelsResponse | ReadConfigResponse | CheckSecretResponse | ExportSessionResponse | ImportSessionResponse | unknown;
|
||||||
} | {
|
} | {
|
||||||
error: {
|
error: {
|
||||||
code: number;
|
code: number;
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ export const zGetExtensionsResponse = z.object({
|
|||||||
warnings: z.array(z.string())
|
warnings: z.array(z.string())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const zGetSessionExtensionsRequest = z.object({
|
||||||
|
sessionId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zGetSessionExtensionsResponse = z.object({
|
||||||
|
extensions: z.array(z.unknown())
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically update the provider for a live session.
|
* Atomically update the provider for a live session.
|
||||||
*/
|
*/
|
||||||
@@ -302,6 +310,7 @@ export const zExtRequest = z.object({
|
|||||||
zUpdateWorkingDirRequest,
|
zUpdateWorkingDirRequest,
|
||||||
zDeleteSessionRequest,
|
zDeleteSessionRequest,
|
||||||
zGetExtensionsRequest,
|
zGetExtensionsRequest,
|
||||||
|
zGetSessionExtensionsRequest,
|
||||||
zUpdateProviderRequest,
|
zUpdateProviderRequest,
|
||||||
zListProvidersRequest,
|
zListProvidersRequest,
|
||||||
zGetProviderDetailsRequest,
|
zGetProviderDetailsRequest,
|
||||||
@@ -333,6 +342,7 @@ export const zExtResponse = z.union([
|
|||||||
zGetToolsResponse,
|
zGetToolsResponse,
|
||||||
zReadResourceResponse,
|
zReadResourceResponse,
|
||||||
zGetExtensionsResponse,
|
zGetExtensionsResponse,
|
||||||
|
zGetSessionExtensionsResponse,
|
||||||
zUpdateProviderResponse,
|
zUpdateProviderResponse,
|
||||||
zListProvidersResponse,
|
zListProvidersResponse,
|
||||||
zGetProviderDetailsResponse,
|
zGetProviderDetailsResponse,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const Header = React.memo(function Header({
|
|||||||
{turnInfo.current}/{turnInfo.total}{" "}
|
{turnInfo.current}/{turnInfo.total}{" "}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text color={TEXT_DIM}>^G configure · ^C exit</Text>
|
<Text color={TEXT_DIM}>^E exts · ^M models · ^P providers</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Rule width={constrainedWidth} />
|
<Rule width={constrainedWidth} />
|
||||||
|
|||||||
+167
-104
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
|||||||
import { Box, Text, useInput, useStdout } from "ink";
|
import { Box, Text, useInput, useStdout } from "ink";
|
||||||
import type { GooseClient, ProviderDetailEntry } from "@aaif/goose-sdk";
|
import type { GooseClient, ProviderDetailEntry } from "@aaif/goose-sdk";
|
||||||
import {
|
import {
|
||||||
|
CRANBERRY,
|
||||||
TEAL,
|
TEAL,
|
||||||
GOLD,
|
GOLD,
|
||||||
TEXT_PRIMARY,
|
TEXT_PRIMARY,
|
||||||
@@ -23,6 +24,8 @@ type Phase =
|
|||||||
| "saving"
|
| "saving"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
|
export type ConfigureIntent = "provider" | "model";
|
||||||
|
|
||||||
interface ConfigureProps {
|
interface ConfigureProps {
|
||||||
client: GooseClient;
|
client: GooseClient;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -30,6 +33,7 @@ interface ConfigureProps {
|
|||||||
height: number;
|
height: number;
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
initialIntent?: ConfigureIntent;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
@@ -180,10 +184,16 @@ const ModelSelector = React.memo(function ModelSelector({
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" justifyContent="center" alignItems="center" width={columns} height={height}>
|
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
|
||||||
<Spinner idx={0} />
|
<Box marginTop={1} />
|
||||||
<Box marginTop={1}>
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
<Text color={TEXT_DIM}>loading models…</Text>
|
<Text color={TEXT_PRIMARY} bold>◆ Select model ◆</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
|
<Text color={TEXT_DIM}>Loading models for {provider.displayName}…</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" flexGrow={1} alignItems="center">
|
||||||
|
<Spinner idx={0} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -191,15 +201,21 @@ const ModelSelector = React.memo(function ModelSelector({
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" justifyContent="center" alignItems="center" width={columns} height={height}>
|
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
|
||||||
<Box flexDirection="column" alignItems="center" width={maxWidth}>
|
<Box marginTop={1} />
|
||||||
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
<Text color={TEXT_PRIMARY} bold>◆ Select model ◆</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
<Text color={GOLD}>⚠ Failed to load models</Text>
|
<Text color={GOLD}>⚠ Failed to load models</Text>
|
||||||
<Box marginTop={1} width={maxWidth}>
|
</Box>
|
||||||
|
<Box justifyContent="center">
|
||||||
|
<Box width={maxWidth}>
|
||||||
<Text color={TEXT_DIM} wrap="wrap">{error}</Text>
|
<Text color={TEXT_DIM} wrap="wrap">{error}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box marginTop={2}>
|
</Box>
|
||||||
<Text color={TEXT_DIM}>m manual entry · esc back</Text>
|
<Box justifyContent="center" marginTop={2}>
|
||||||
</Box>
|
<Text color={TEXT_DIM}>m manual entry · esc back</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -213,32 +229,32 @@ const ModelSelector = React.memo(function ModelSelector({
|
|||||||
: displayText;
|
: displayText;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" justifyContent="center" alignItems="center" height={height} width={columns}>
|
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
|
||||||
<Box flexDirection="column" width={maxWidth} paddingX={2}>
|
<Box marginTop={1} />
|
||||||
<Text color={TEXT_PRIMARY} bold>
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
Enter model name manually
|
<Text color={TEXT_PRIMARY} bold>◆ Enter model name ◆</Text>
|
||||||
</Text>
|
</Box>
|
||||||
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
|
<Text color={TEXT_DIM}>Type a model identifier for {provider.displayName}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box justifyContent="center">
|
||||||
<Box
|
<Box
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
borderColor={GOLD}
|
borderColor={GOLD}
|
||||||
paddingX={2}
|
paddingX={2}
|
||||||
width={inputWidth}
|
width={inputWidth}
|
||||||
>
|
>
|
||||||
<Text color={GOLD} bold>{"❯ "}</Text>
|
<Text color={GOLD} bold>{"❯ "}</Text>
|
||||||
<Text color={searchQuery ? TEXT_PRIMARY : TEXT_DIM}>
|
<Text color={searchQuery ? TEXT_PRIMARY : TEXT_DIM}>
|
||||||
{truncatedText}
|
{truncatedText}
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={2}>
|
|
||||||
<Text color={TEXT_DIM}>
|
|
||||||
enter confirm · esc cancel
|
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box justifyContent="center" marginTop={2}>
|
||||||
|
<Text color={TEXT_DIM}>enter confirm · esc cancel</Text>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -247,71 +263,87 @@ const ModelSelector = React.memo(function ModelSelector({
|
|||||||
const searchBoxWidth = Math.min(60, maxWidth - 4);
|
const searchBoxWidth = Math.min(60, maxWidth - 4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" justifyContent="center" alignItems="center" height={height} width={columns}>
|
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
|
||||||
<Box flexDirection="column" width={maxWidth} paddingX={2}>
|
{/* Header */}
|
||||||
<Text color={TEXT_PRIMARY} bold>
|
<Box marginTop={1} />
|
||||||
Select model for {provider.displayName}
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
</Text>
|
<Text color={TEXT_PRIMARY} bold>◆ Select model ◆</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
|
<Text color={TEXT_DIM}>Choose a model for {provider.displayName}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
{/* Search Bar */}
|
||||||
<Box
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
borderStyle="round"
|
<Box
|
||||||
borderColor={RULE_COLOR}
|
borderStyle="round"
|
||||||
paddingX={2}
|
borderColor={RULE_COLOR}
|
||||||
width={searchBoxWidth}
|
paddingX={2}
|
||||||
>
|
width={searchBoxWidth}
|
||||||
<Text color={GOLD} bold>{"❯ "}</Text>
|
>
|
||||||
<Box width={searchBoxWidth - 8}>
|
<Text color={CRANBERRY} bold>{"❯ "}</Text>
|
||||||
<Text color={searchQuery ? TEXT_PRIMARY : TEXT_DIM} wrap="truncate">
|
<Box width={searchBoxWidth - 8}>
|
||||||
{searchQuery || "search models…"}
|
<Text color={searchQuery ? TEXT_PRIMARY : TEXT_DIM} wrap="truncate">
|
||||||
</Text>
|
{searchQuery || "search models…"}
|
||||||
</Box>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection="column" height={listHeight}>
|
{/* Model List */}
|
||||||
{filtered.length === 0 ? (
|
<Box flexDirection="column" flexGrow={1} justifyContent="flex-start">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<Box justifyContent="center" alignItems="center" height={Math.max(listHeight, 1)}>
|
||||||
<Text color={TEXT_DIM}>No matching models</Text>
|
<Text color={TEXT_DIM}>No matching models</Text>
|
||||||
) : (
|
</Box>
|
||||||
<>
|
) : (
|
||||||
{scrollOffset > 0 && (
|
<>
|
||||||
|
{scrollOffset > 0 && (
|
||||||
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
<Text color={TEXT_DIM}>▲ {scrollOffset} more above</Text>
|
<Text color={TEXT_DIM}>▲ {scrollOffset} more above</Text>
|
||||||
)}
|
</Box>
|
||||||
{visible.map((model, vi) => {
|
)}
|
||||||
const idx = vi + scrollOffset;
|
<Box justifyContent="center">
|
||||||
const active = idx === selectedIdx;
|
<Box flexDirection="column" width={maxWidth}>
|
||||||
const isDefault = model === provider.defaultModel;
|
{visible.map((model, vi) => {
|
||||||
const modelWidth = maxWidth - 8;
|
const idx = vi + scrollOffset;
|
||||||
const truncatedModel = model.length > modelWidth
|
const active = idx === selectedIdx;
|
||||||
? model.slice(0, modelWidth - 1) + "…"
|
const isDefault = model === provider.defaultModel;
|
||||||
: model;
|
const modelWidth = maxWidth - 8;
|
||||||
|
const truncatedModel = model.length > modelWidth
|
||||||
|
? model.slice(0, modelWidth - 1) + "…"
|
||||||
|
: model;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={model}>
|
<Box key={model}>
|
||||||
<Text color={active ? GOLD : TEXT_DIM}>
|
<Text color={active ? GOLD : TEXT_DIM}>
|
||||||
{active ? "▸ " : " "}
|
{active ? "▸ " : " "}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={active ? TEXT_PRIMARY : TEXT_DIM} bold={active}>
|
<Text color={active ? TEXT_PRIMARY : TEXT_DIM} bold={active}>
|
||||||
{truncatedModel}
|
{truncatedModel}
|
||||||
</Text>
|
</Text>
|
||||||
{isDefault && <Text color={TEAL}> (default)</Text>}
|
{isDefault && <Text color={TEAL}> (default)</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{scrollOffset + listHeight < filtered.length && (
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{scrollOffset + listHeight < filtered.length && (
|
||||||
|
<Box justifyContent="center" marginTop={1}>
|
||||||
<Text color={TEXT_DIM}>
|
<Text color={TEXT_DIM}>
|
||||||
▼ {filtered.length - scrollOffset - listHeight} more below
|
▼ {filtered.length - scrollOffset - listHeight} more below
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
</Box>
|
||||||
</>
|
)}
|
||||||
)}
|
</>
|
||||||
</Box>
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
{/* Footer */}
|
||||||
<Text color={TEXT_DIM}>
|
<Box justifyContent="center" marginTop={2}>
|
||||||
↑↓ navigate · enter select · m manual · esc back
|
<Text color={TEXT_DIM}>
|
||||||
</Text>
|
↑↓ navigate · enter select · m manual · esc back
|
||||||
</Box>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -324,6 +356,7 @@ export default function ConfigureScreen({
|
|||||||
height,
|
height,
|
||||||
onComplete,
|
onComplete,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
initialIntent,
|
||||||
}: ConfigureProps) {
|
}: ConfigureProps) {
|
||||||
const [phase, setPhase] = useState<Phase>("loading");
|
const [phase, setPhase] = useState<Phase>("loading");
|
||||||
const [providers, setProviders] = useState<ProviderDetailEntry[]>([]);
|
const [providers, setProviders] = useState<ProviderDetailEntry[]>([]);
|
||||||
@@ -346,16 +379,32 @@ export default function ConfigureScreen({
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await client.goose.GooseProvidersDetails({});
|
const resp = await client.goose.GooseProvidersDetails({});
|
||||||
if (!cancelled) {
|
if (cancelled) return;
|
||||||
const sorted = [...resp.providers].sort((a, b) => {
|
const sorted = [...resp.providers].sort((a, b) => {
|
||||||
const aP = a.providerType === "Preferred" ? 0 : 1;
|
const aP = a.providerType === "Preferred" ? 0 : 1;
|
||||||
const bP = b.providerType === "Preferred" ? 0 : 1;
|
const bP = b.providerType === "Preferred" ? 0 : 1;
|
||||||
if (aP !== bP) return aP - bP;
|
if (aP !== bP) return aP - bP;
|
||||||
return a.displayName.localeCompare(b.displayName);
|
return a.displayName.localeCompare(b.displayName);
|
||||||
});
|
});
|
||||||
setProviders(sorted);
|
setProviders(sorted);
|
||||||
setPhase("select_provider");
|
|
||||||
|
if (initialIntent === "model") {
|
||||||
|
try {
|
||||||
|
const cfg = await client.goose.GooseConfigRead({ key: "GOOSE_PROVIDER" });
|
||||||
|
if (cancelled) return;
|
||||||
|
const current = sorted.find((p) => p.name === cfg.value);
|
||||||
|
if (current) {
|
||||||
|
setSelectedProvider(current);
|
||||||
|
setPendingConfigValues({});
|
||||||
|
setPhase("select_model");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to provider selector
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!cancelled) setPhase("select_provider");
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setErrorMsg(e instanceof Error ? e.message : String(e));
|
setErrorMsg(e instanceof Error ? e.message : String(e));
|
||||||
@@ -367,7 +416,7 @@ export default function ConfigureScreen({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [client, fetchKey]);
|
}, [client, fetchKey, initialIntent]);
|
||||||
|
|
||||||
const applyProviderModel = useCallback(
|
const applyProviderModel = useCallback(
|
||||||
async (provider: ProviderDetailEntry, model: string, configValues: Record<string, string>) => {
|
async (provider: ProviderDetailEntry, model: string, configValues: Record<string, string>) => {
|
||||||
@@ -440,22 +489,32 @@ export default function ConfigureScreen({
|
|||||||
|
|
||||||
if (phase === "loading" || phase === "loading_models" || phase === "saving") {
|
if (phase === "loading" || phase === "loading_models" || phase === "saving") {
|
||||||
const label =
|
const label =
|
||||||
phase === "loading" ? "loading providers…" :
|
phase === "loading" ? "Loading providers…" :
|
||||||
phase === "loading_models" ? "loading models…" :
|
phase === "loading_models" ? "Loading models…" :
|
||||||
"applying changes…";
|
"Applying changes…";
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" justifyContent="center" alignItems="center" width={width} height={height}>
|
<Box flexDirection="column" height={height} width={width} paddingX={2}>
|
||||||
<Spinner idx={spinIdx} />
|
<Box marginTop={1} />
|
||||||
<Box marginTop={1}>
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
<Text color={TEXT_PRIMARY} bold>◆ Configure provider ◆</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
<Text color={TEXT_DIM}>{label}</Text>
|
<Text color={TEXT_DIM}>{label}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box justifyContent="center" flexGrow={1} alignItems="center">
|
||||||
|
<Spinner idx={spinIdx} />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase === "error") {
|
if (phase === "error") {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" height={height} alignItems="center" width={width}>
|
<Box flexDirection="column" height={height} width={width} paddingX={2}>
|
||||||
|
<Box marginTop={1} />
|
||||||
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
<Text color={TEXT_PRIMARY} bold>◆ Configure provider ◆</Text>
|
||||||
|
</Box>
|
||||||
<ErrorScreen errorMsg={errorMsg} onRetry={handleRetry} />
|
<ErrorScreen errorMsg={errorMsg} onRetry={handleRetry} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -483,7 +542,11 @@ export default function ConfigureScreen({
|
|||||||
height={height}
|
height={height}
|
||||||
onSelect={handleModelSelected}
|
onSelect={handleModelSelected}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setPhase("select_provider");
|
if (initialIntent === "model") {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
setPhase("select_provider");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import React, {useCallback, useEffect, useState} from "react";
|
||||||
|
import {Box, Text, useInput, useStdout} from "ink";
|
||||||
|
import {TextInput} from "@inkjs/ui";
|
||||||
|
import type {GooseClient} from "@aaif/goose-acp";
|
||||||
|
import {CRANBERRY, GOLD, RULE_COLOR, TEAL, TEXT_DIM, TEXT_PRIMARY} from "./colors.js";
|
||||||
|
import {Spinner, SPINNER_FRAMES} from "./components/Spinner.js";
|
||||||
|
import {ErrorScreen} from "./components/ErrorScreen.js";
|
||||||
|
|
||||||
|
type ExtEntry = {
|
||||||
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isExtEntry(v: unknown): v is ExtEntry {
|
||||||
|
return !!v && typeof v === "object" && "enabled" in v && "type" in v && "name" in v
|
||||||
|
&& typeof (v as ExtEntry).enabled === "boolean"
|
||||||
|
&& typeof (v as ExtEntry).type === "string"
|
||||||
|
&& typeof (v as ExtEntry).name === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddType = "stdio" | "streamable_http";
|
||||||
|
type Phase = "loading" | "list" | "add_type" | "add_value" | "add_name" | "add_desc" | "saving" | "error";
|
||||||
|
|
||||||
|
function deriveNameFromValue(addType: AddType, value: string): string {
|
||||||
|
if (addType === "stdio") {
|
||||||
|
const cmd = value.trim().split(/\s+/)[0] ?? "";
|
||||||
|
return cmd.split("/").pop() ?? cmd;
|
||||||
|
}
|
||||||
|
try { return new URL(value.trim()).hostname; } catch { return value.trim(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyFromName(name: string): string {
|
||||||
|
return name.replace(/[^A-Za-z0-9_-]/g, "_").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfig(addType: AddType, value: string, name: string, description: string): ExtEntry {
|
||||||
|
if (addType === "stdio") {
|
||||||
|
const parts = value.trim().split(/\s+/);
|
||||||
|
return {type: "stdio", enabled: true, name, description, cmd: parts[0] ?? "", args: parts.slice(1)};
|
||||||
|
}
|
||||||
|
return {type: "streamable_http", enabled: true, name, description, uri: value.trim()};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExtensionsManager({
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
height,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
client: GooseClient;
|
||||||
|
sessionId: string;
|
||||||
|
height: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const {stdout} = useStdout();
|
||||||
|
const columns = stdout?.columns ?? 80;
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<Phase>("loading");
|
||||||
|
const [spinIdx, setSpinIdx] = useState(0);
|
||||||
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
const [entries, setEntries] = useState<ExtEntry[]>([]);
|
||||||
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||||
|
|
||||||
|
const [addType, setAddType] = useState<AddType>("stdio");
|
||||||
|
const [addValue, setAddValue] = useState("");
|
||||||
|
const [addName, setAddName] = useState("");
|
||||||
|
const [addDesc, setAddDesc] = useState("");
|
||||||
|
const [inputKey, setInputKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setSpinIdx(i => (i + 1) % SPINNER_FRAMES.length), 300);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setPhase("loading");
|
||||||
|
try {
|
||||||
|
const [configResp, sessionResp] = await Promise.all([
|
||||||
|
client.goose.GooseConfigExtensions({}),
|
||||||
|
client.goose.GooseSessionExtensions({sessionId}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allExtensions = (configResp.extensions as unknown[]).filter(isExtEntry);
|
||||||
|
const activeNames = new Set(
|
||||||
|
(sessionResp.extensions as Array<{name?: string}>).map(e => e.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
setEntries(allExtensions.map(ext => ({...ext, enabled: activeNames.has(ext.name)})));
|
||||||
|
setWarnings(configResp.warnings ?? []);
|
||||||
|
setPhase("list");
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : String(e));
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
}, [client, sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => { reload(); }, [reload]);
|
||||||
|
|
||||||
|
const withSaving = useCallback(async (fn: () => Promise<void>) => {
|
||||||
|
setPhase("saving");
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
await reload();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : String(e));
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const toggleSelected = useCallback(() => {
|
||||||
|
const sel = entries[selectedIdx];
|
||||||
|
if (!sel) return;
|
||||||
|
withSaving(async () => {
|
||||||
|
if (sel.enabled) {
|
||||||
|
await client.goose.GooseExtensionsRemove({sessionId, name: sel.name});
|
||||||
|
} else {
|
||||||
|
await client.goose.GooseExtensionsAdd({sessionId, config: sel as any});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [entries, selectedIdx, client, sessionId, withSaving]);
|
||||||
|
|
||||||
|
const saveNewExtension = useCallback((description: string) => {
|
||||||
|
const config = buildConfig(addType, addValue, addName, description);
|
||||||
|
const key = keyFromName(config.name);
|
||||||
|
withSaving(async () => {
|
||||||
|
let extMap: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const raw = await client.goose.GooseConfigRead({key: "extensions"});
|
||||||
|
if (raw.value && typeof raw.value === "object") extMap = raw.value as Record<string, unknown>;
|
||||||
|
} catch { }
|
||||||
|
extMap[key] = config;
|
||||||
|
await client.goose.GooseConfigUpsert({key: "extensions", value: extMap as any});
|
||||||
|
await client.goose.GooseExtensionsAdd({sessionId, config: config as any});
|
||||||
|
});
|
||||||
|
}, [addType, addValue, addName, client, sessionId, withSaving]);
|
||||||
|
|
||||||
|
useInput((ch, key) => {
|
||||||
|
if (phase === "list") {
|
||||||
|
if (key.escape) { onClose(); return; }
|
||||||
|
if (key.upArrow) { setSelectedIdx(i => Math.max(i - 1, 0)); return; }
|
||||||
|
if (key.downArrow) { setSelectedIdx(i => Math.min(i + 1, entries.length - 1)); return; }
|
||||||
|
if (ch === " " || key.return) { toggleSelected(); return; }
|
||||||
|
if (ch === "a") { setAddType("stdio"); setPhase("add_type"); return; }
|
||||||
|
}
|
||||||
|
if (phase === "add_type") {
|
||||||
|
if (key.escape) { setPhase("list"); return; }
|
||||||
|
if (key.upArrow || key.downArrow) { setAddType(t => t === "stdio" ? "streamable_http" : "stdio"); return; }
|
||||||
|
if (key.return) { setAddValue(""); setInputKey(k => k + 1); setPhase("add_value"); return; }
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
if (phase === "add_value") { setPhase("add_type"); return; }
|
||||||
|
if (phase === "add_name") { setInputKey(k => k + 1); setPhase("add_value"); return; }
|
||||||
|
if (phase === "add_desc") { setInputKey(k => k + 1); setPhase("add_name"); return; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (phase === "loading" || phase === "saving") {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
|
||||||
|
<Box marginTop={1} />
|
||||||
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
<Text color={TEXT_PRIMARY} bold>◆ Manage extensions ◆</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
|
<Text color={TEXT_DIM}>{phase === "loading" ? "Loading extensions…" : "Saving…"}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" flexGrow={1} alignItems="center">
|
||||||
|
<Spinner idx={spinIdx} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "error") {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" height={height} width={columns} paddingX={2}>
|
||||||
|
<Box marginTop={1} />
|
||||||
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
<Text color={TEXT_PRIMARY} bold>◆ Manage extensions ◆</Text>
|
||||||
|
</Box>
|
||||||
|
<ErrorScreen errorMsg={errorMsg} onRetry={() => reload()} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxW = Math.min(columns - 4, 80);
|
||||||
|
const inputW = Math.min(maxW - 10, 70);
|
||||||
|
|
||||||
|
if (phase === "add_type") {
|
||||||
|
const types: {value: AddType; label: string; hint: string}[] = [
|
||||||
|
{value: "stdio", label: "Command (stdio)", hint: "run a local command"},
|
||||||
|
{value: "streamable_http", label: "Endpoint (HTTP)", hint: "connect to a remote server"},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
|
||||||
|
<Box marginTop={1} />
|
||||||
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
<Text color={TEXT_PRIMARY} bold>◆ Add extension ◆</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
|
<Text color={TEXT_DIM}>Choose a connection type</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center">
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{types.map(t => {
|
||||||
|
const active = addType === t.value;
|
||||||
|
return (
|
||||||
|
<Box key={t.value}>
|
||||||
|
<Text color={active ? GOLD : TEXT_DIM}>{active ? "▸ " : " "}</Text>
|
||||||
|
<Text color={active ? TEXT_PRIMARY : TEXT_DIM} bold={active}>{t.label}</Text>
|
||||||
|
<Text color={TEXT_DIM}> {t.hint}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginTop={2}>
|
||||||
|
<Text color={TEXT_DIM}>↑↓ select · enter confirm · esc cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "add_value") {
|
||||||
|
const isStdio = addType === "stdio";
|
||||||
|
const placeholder = isStdio
|
||||||
|
? "npx -y @modelcontextprotocol/server-filesystem /tmp"
|
||||||
|
: "http://localhost:8080/mcp";
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
|
||||||
|
<Box marginTop={1} />
|
||||||
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
<Text color={TEXT_PRIMARY} bold>◆ {isStdio ? "Enter command" : "Enter endpoint URL"} ◆</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
|
<Text color={TEXT_DIM}>{isStdio ? "The command to launch the extension" : "URL of the remote MCP server"}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center">
|
||||||
|
<Box borderStyle="round" borderColor={RULE_COLOR} paddingX={2} width={inputW}>
|
||||||
|
<Text color={CRANBERRY} bold>{"❯ "}</Text>
|
||||||
|
<TextInput
|
||||||
|
key={`value-${inputKey}`}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={setAddValue}
|
||||||
|
onSubmit={(v) => {
|
||||||
|
if (!v.trim()) return;
|
||||||
|
setAddValue(v);
|
||||||
|
setAddName(deriveNameFromValue(addType, v));
|
||||||
|
setInputKey(k => k + 1);
|
||||||
|
setPhase("add_name");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginTop={2}>
|
||||||
|
<Text color={TEXT_DIM}>enter continue · esc back</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "add_name") {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
|
||||||
|
<Box marginTop={1} />
|
||||||
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
<Text color={TEXT_PRIMARY} bold>◆ Name this extension ◆</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
|
<Text color={TEXT_DIM}>A short name to identify this extension</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center">
|
||||||
|
<Box borderStyle="round" borderColor={RULE_COLOR} paddingX={2} width={inputW}>
|
||||||
|
<Text color={CRANBERRY} bold>{"❯ "}</Text>
|
||||||
|
<TextInput
|
||||||
|
key={`name-${inputKey}`}
|
||||||
|
defaultValue={addName}
|
||||||
|
placeholder="extension name"
|
||||||
|
onChange={setAddName}
|
||||||
|
onSubmit={(v) => {
|
||||||
|
if (!v.trim()) return;
|
||||||
|
setAddName(v.trim());
|
||||||
|
setAddDesc("");
|
||||||
|
setInputKey(k => k + 1);
|
||||||
|
setPhase("add_desc");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginTop={2}>
|
||||||
|
<Text color={TEXT_DIM}>enter continue · esc back</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "add_desc") {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
|
||||||
|
<Box marginTop={1} />
|
||||||
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
<Text color={TEXT_PRIMARY} bold>◆ Description ◆</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
|
<Text color={TEXT_DIM}>What does this extension do? (optional)</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center">
|
||||||
|
<Box borderStyle="round" borderColor={RULE_COLOR} paddingX={2} width={inputW}>
|
||||||
|
<Text color={CRANBERRY} bold>{"❯ "}</Text>
|
||||||
|
<TextInput
|
||||||
|
key={`desc-${inputKey}`}
|
||||||
|
placeholder="what does this extension do?"
|
||||||
|
onChange={setAddDesc}
|
||||||
|
onSubmit={(v) => saveNewExtension(v.trim())}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginTop={2}>
|
||||||
|
<Text color={TEXT_DIM}>enter save (leave empty to skip) · esc back</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutW = maxW;
|
||||||
|
const GUTTER = 2;
|
||||||
|
const STATUS_W = 10;
|
||||||
|
const nameW = Math.max(16, Math.floor(layoutW * 0.30));
|
||||||
|
const descW = Math.max(8, layoutW - 2 - STATUS_W - nameW - 2 * GUTTER);
|
||||||
|
|
||||||
|
const rows = Math.max(height - 9, 4);
|
||||||
|
const maxStart = Math.max(0, entries.length - rows);
|
||||||
|
const start = Math.min(maxStart, Math.max(0, selectedIdx - Math.floor(rows / 2)));
|
||||||
|
const end = Math.min(entries.length, start + rows);
|
||||||
|
const windowed = entries.slice(start, end);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={columns} height={height} paddingX={2}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box marginTop={1} />
|
||||||
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
<Text color={TEXT_PRIMARY} bold>◆ Manage extensions ◆</Text>
|
||||||
|
</Box>
|
||||||
|
<Box justifyContent="center" marginBottom={2}>
|
||||||
|
<Text color={TEXT_DIM}>Toggle, add, or remove extensions for this session</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Extension List */}
|
||||||
|
<Box flexDirection="column" flexGrow={1} justifyContent="flex-start">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<Box justifyContent="center" alignItems="center" height={Math.max(rows - 1, 1)}>
|
||||||
|
<Text color={TEXT_DIM}>No extensions configured — press a to add one</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{start > 0 && (
|
||||||
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
<Text color={TEXT_DIM}>▲ {start} more above</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box justifyContent="center">
|
||||||
|
<Box flexDirection="column" width={layoutW}>
|
||||||
|
{windowed.map((ext, i) => {
|
||||||
|
const globalIdx = start + i;
|
||||||
|
const active = globalIdx === selectedIdx;
|
||||||
|
return (
|
||||||
|
<Box key={`${ext.type}:${ext.name}`} width={layoutW}>
|
||||||
|
<Text color={active ? GOLD : TEXT_DIM}>{active ? "▸ " : " "}</Text>
|
||||||
|
<Box width={nameW}><Text color={active ? TEXT_PRIMARY : TEXT_DIM} bold={active} wrap="truncate">{ext.name}</Text></Box>
|
||||||
|
<Box width={GUTTER}><Text>{" ".repeat(GUTTER)}</Text></Box>
|
||||||
|
<Box width={descW}><Text color={TEXT_DIM} wrap="truncate">{ext.description || ""}</Text></Box>
|
||||||
|
<Box width={GUTTER}><Text>{" ".repeat(GUTTER)}</Text></Box>
|
||||||
|
<Box width={STATUS_W}><Text color={ext.enabled ? TEAL : TEXT_DIM} wrap="truncate">{ext.enabled ? "enabled" : "disabled"}</Text></Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{end < entries.length && (
|
||||||
|
<Box justifyContent="center" marginTop={1}>
|
||||||
|
<Text color={TEXT_DIM}>▼ {entries.length - end} more below</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<Box justifyContent="center" marginTop={1}>
|
||||||
|
<Box width={layoutW} flexDirection="column">
|
||||||
|
<Text color={GOLD}>Warnings</Text>
|
||||||
|
{warnings.map((w, i) => (
|
||||||
|
<Box key={i} width={layoutW}><Text color={TEXT_DIM} wrap="truncate">• {w}</Text></Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Box justifyContent="center" marginTop={2}>
|
||||||
|
<Text color={TEXT_DIM}>space/enter toggle · a add · esc back</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -381,12 +381,16 @@ export const ProviderConfigurator = React.memo(function ProviderConfigurator({ p
|
|||||||
{topPad > 0 && <Box height={topPad} />}
|
{topPad > 0 && <Box height={topPad} />}
|
||||||
<Box flexDirection="column" width={maxWidth} paddingX={2}>
|
<Box flexDirection="column" width={maxWidth} paddingX={2}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Text color={TEXT_PRIMARY} bold>
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
Configure {provider.displayName}
|
<Text color={TEXT_PRIMARY} bold>
|
||||||
</Text>
|
◆ Configure {provider.displayName} ◆
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
{provider.description && (
|
{provider.description && (
|
||||||
<Box marginTop={1} width={maxWidth - 4}>
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
<Text color={TEXT_DIM} wrap="wrap">{provider.description}</Text>
|
<Box width={maxWidth - 4}>
|
||||||
|
<Text color={TEXT_DIM} wrap="wrap">{provider.description}</Text>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box marginTop={1} />
|
<Box marginTop={1} />
|
||||||
@@ -436,10 +440,10 @@ export const ProviderConfigurator = React.memo(function ProviderConfigurator({ p
|
|||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Box width={maxWidth - 4}>
|
<Box width={maxWidth - 4}>
|
||||||
<Text color={TEXT_DIM} wrap="wrap">
|
<Text color={TEXT_DIM} wrap="wrap">
|
||||||
enter to confirm · esc to go back
|
enter confirm · esc back
|
||||||
{currentKey.secret && (
|
{currentKey.secret && (
|
||||||
<>
|
<>
|
||||||
{" · tab to "}
|
{" · tab "}
|
||||||
{masked ? "reveal" : "hide"}
|
{masked ? "reveal" : "hide"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+38
-26
@@ -20,7 +20,8 @@ import type {
|
|||||||
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
||||||
import { GooseClient } from "@aaif/goose-sdk";
|
import { GooseClient } from "@aaif/goose-sdk";
|
||||||
import Onboarding from "./onboarding.js";
|
import Onboarding from "./onboarding.js";
|
||||||
import ConfigureScreen from "./configure.js";
|
import ConfigureScreen, { ConfigureIntent } from "./configure.js";
|
||||||
|
import ExtensionsManager from "./extensions.js";
|
||||||
import type { PendingPermission, ResponseItem, Turn } from "./types.js";
|
import type { PendingPermission, ResponseItem, Turn } from "./types.js";
|
||||||
import {
|
import {
|
||||||
emptyLine,
|
emptyLine,
|
||||||
@@ -483,7 +484,8 @@ function App({
|
|||||||
const [scrollOffset, setScrollOffset] = useState(0);
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
const [pastedFull, setPastedFull] = useState<string | null>(null);
|
const [pastedFull, setPastedFull] = useState<string | null>(null);
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
const [configuring, setConfiguring] = useState(false);
|
type Overlay = { screen: "configure"; intent: ConfigureIntent } | { screen: "extensions" };
|
||||||
|
const [overlay, setOverlay] = useState<Overlay | null>(null);
|
||||||
|
|
||||||
const clientRef = useRef<GooseClient | null>(null);
|
const clientRef = useRef<GooseClient | null>(null);
|
||||||
const sessionIdRef = useRef<string | null>(null);
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
@@ -805,9 +807,11 @@ function App({
|
|||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ch === "g" && key.ctrl && !loading && !pendingPermission && sessionIdRef.current) {
|
if (!loading && !pendingPermission && sessionIdRef.current) {
|
||||||
setConfiguring(true);
|
if (key.ctrl && (ch === "p" || ch === "P")) { setOverlay({ screen: "configure", intent: "provider" }); return; }
|
||||||
return;
|
if (key.ctrl && (ch === "m" || ch === "M")) { setOverlay({ screen: "configure", intent: "model" }); return; }
|
||||||
|
if (key.ctrl && (ch === "e" || ch === "E")) { setOverlay({ screen: "extensions" }); return; }
|
||||||
|
if (ch === "g" && key.ctrl) { setOverlay({ screen: "configure", intent: "provider" }); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingPermission) {
|
if (pendingPermission) {
|
||||||
@@ -875,7 +879,7 @@ function App({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, { isActive: !needsOnboarding && !configuring });
|
}, { isActive: !needsOnboarding && !overlay });
|
||||||
|
|
||||||
const PAD_X = 2;
|
const PAD_X = 2;
|
||||||
const PAD_Y = 1;
|
const PAD_Y = 1;
|
||||||
@@ -935,26 +939,34 @@ function App({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configuring && clientRef.current && sessionIdRef.current) {
|
if (overlay && clientRef.current && sessionIdRef.current) {
|
||||||
return (
|
if (overlay.screen === "configure") {
|
||||||
<Box
|
const intent = overlay.intent;
|
||||||
flexDirection="column"
|
return (
|
||||||
width={safeTermWidth}
|
<Box flexDirection="column" width={safeTermWidth} height={safeTermHeight}>
|
||||||
height={safeTermHeight}
|
<ConfigureScreen
|
||||||
>
|
client={clientRef.current}
|
||||||
<ConfigureScreen
|
sessionId={sessionIdRef.current}
|
||||||
client={clientRef.current}
|
width={safeTermWidth}
|
||||||
sessionId={sessionIdRef.current}
|
height={safeTermHeight}
|
||||||
width={safeTermWidth}
|
onComplete={() => { setOverlay(null); setStatus("ready"); }}
|
||||||
height={safeTermHeight}
|
onCancel={() => setOverlay(null)}
|
||||||
onComplete={() => {
|
initialIntent={intent}
|
||||||
setConfiguring(false);
|
/>
|
||||||
setStatus("ready");
|
</Box>
|
||||||
}}
|
);
|
||||||
onCancel={() => setConfiguring(false)}
|
} else if (overlay.screen === "extensions") {
|
||||||
/>
|
return (
|
||||||
</Box>
|
<Box flexDirection="column" width={safeTermWidth} height={safeTermHeight}>
|
||||||
);
|
<ExtensionsManager
|
||||||
|
client={clientRef.current}
|
||||||
|
sessionId={sessionIdRef.current}
|
||||||
|
height={safeTermHeight}
|
||||||
|
onClose={() => setOverlay(null)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user