feat: TUI client of goose-acp (#7362)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use goose::builtin_extension::register_builtin_extensions;
|
||||||
use goose::config::paths::Paths;
|
use goose::config::paths::Paths;
|
||||||
use goose_acp::server_factory::{AcpServer, AcpServerFactoryConfig};
|
use goose_acp::server_factory::{AcpServer, AcpServerFactoryConfig};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
@@ -29,6 +30,8 @@ async fn main() -> Result<()> {
|
|||||||
.with(tracing_subscriber::fmt::layer().with_target(true))
|
.with(tracing_subscriber::fmt::layer().with_target(true))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
|
register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone());
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
let builtins = if cli.builtins.is_empty() {
|
let builtins = if cli.builtins.is_empty() {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ main().catch((err) => {
|
|||||||
async function main() {
|
async function main() {
|
||||||
const schemaSrc = await fs.readFile(SCHEMA_PATH, "utf8");
|
const schemaSrc = await fs.readFile(SCHEMA_PATH, "utf8");
|
||||||
const jsonSchema = JSON.parse(
|
const jsonSchema = JSON.parse(
|
||||||
// Convert JSON Schema $defs refs to OpenAPI component refs
|
|
||||||
schemaSrc.replaceAll("#/$defs/", "#/components/schemas/"),
|
schemaSrc.replaceAll("#/$defs/", "#/components/schemas/"),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,7 +62,6 @@ async function main() {
|
|||||||
async function postProcessTypes() {
|
async function postProcessTypes() {
|
||||||
const tsPath = resolve(OUTPUT_DIR, "types.gen.ts");
|
const tsPath = resolve(OUTPUT_DIR, "types.gen.ts");
|
||||||
let src = await fs.readFile(tsPath, "utf8");
|
let src = await fs.readFile(tsPath, "utf8");
|
||||||
// Remove the ClientOptions type block injected by @hey-api (not part of our schema)
|
|
||||||
src = src.replace(/\nexport type ClientOptions =[\s\S]*?^};\n/m, "\n");
|
src = src.replace(/\nexport type ClientOptions =[\s\S]*?^};\n/m, "\n");
|
||||||
await fs.writeFile(tsPath, src);
|
await fs.writeFile(tsPath, src);
|
||||||
}
|
}
|
||||||
@@ -72,17 +70,14 @@ async function postProcessIndex(meta: { methods: unknown[] }) {
|
|||||||
const indexPath = resolve(OUTPUT_DIR, "index.ts");
|
const indexPath = resolve(OUTPUT_DIR, "index.ts");
|
||||||
let src = await fs.readFile(indexPath, "utf8");
|
let src = await fs.readFile(indexPath, "utf8");
|
||||||
|
|
||||||
// Strip ClientOptions from re-exports
|
|
||||||
src = src.replace(/,?\s*ClientOptions\s*,?/g, (match) => {
|
src = src.replace(/,?\s*ClientOptions\s*,?/g, (match) => {
|
||||||
if (match.startsWith(",") && match.endsWith(",")) return ",";
|
if (match.startsWith(",") && match.endsWith(",")) return ",";
|
||||||
if (match.startsWith(",")) return "";
|
if (match.startsWith(",")) return "";
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fix bare relative imports to use .js extensions (required by nodenext consumers)
|
|
||||||
src = fixRelativeImports(src);
|
src = fixRelativeImports(src);
|
||||||
|
|
||||||
// Append method constants
|
|
||||||
const methodConstants = await prettier.format(
|
const methodConstants = await prettier.format(
|
||||||
`
|
`
|
||||||
export const GOOSE_EXT_METHODS = ${JSON.stringify(meta.methods, null, 2)} as const;
|
export const GOOSE_EXT_METHODS = ${JSON.stringify(meta.methods, null, 2)} as const;
|
||||||
@@ -94,7 +89,6 @@ export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number];
|
|||||||
|
|
||||||
await fs.writeFile(indexPath, `${src}\n${methodConstants}`);
|
await fs.writeFile(indexPath, `${src}\n${methodConstants}`);
|
||||||
|
|
||||||
// Also fix imports in zod.gen.ts (it may import from types.gen)
|
|
||||||
for (const file of ["zod.gen.ts", "types.gen.ts"]) {
|
for (const file of ["zod.gen.ts", "types.gen.ts"]) {
|
||||||
const filePath = resolve(OUTPUT_DIR, file);
|
const filePath = resolve(OUTPUT_DIR, file);
|
||||||
try {
|
try {
|
||||||
@@ -127,9 +121,6 @@ interface MethodMeta {
|
|||||||
responseType: string | null;
|
responseType: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a method path like "session/list" or "working_dir/update" to camelCase "sessionList", "workingDirUpdate".
|
|
||||||
*/
|
|
||||||
function methodToCamelCase(method: string): string {
|
function methodToCamelCase(method: string): string {
|
||||||
return method
|
return method
|
||||||
.split(/[/_]/)
|
.split(/[/_]/)
|
||||||
@@ -139,10 +130,6 @@ function methodToCamelCase(method: string): string {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a typed GooseClient class that wraps ClientSideConnection.extMethod()
|
|
||||||
* with proper TypeScript types and Zod runtime validation.
|
|
||||||
*/
|
|
||||||
async function generateClient(meta: { methods: MethodMeta[] }) {
|
async function generateClient(meta: { methods: MethodMeta[] }) {
|
||||||
const typeImports = new Set<string>();
|
const typeImports = new Set<string>();
|
||||||
const zodImports = new Set<string>();
|
const zodImports = new Set<string>();
|
||||||
@@ -153,7 +140,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) {
|
|||||||
const fnName = methodToCamelCase(m.method);
|
const fnName = methodToCamelCase(m.method);
|
||||||
const fullMethod = `_goose/${m.method}`;
|
const fullMethod = `_goose/${m.method}`;
|
||||||
|
|
||||||
// Build param type and arg
|
|
||||||
let paramType = "";
|
let paramType = "";
|
||||||
let paramArg = "";
|
let paramArg = "";
|
||||||
let callParams = "{}";
|
let callParams = "{}";
|
||||||
@@ -164,7 +150,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) {
|
|||||||
callParams = "params";
|
callParams = "params";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build return type and validation
|
|
||||||
let returnType: string;
|
let returnType: string;
|
||||||
let bodyLines: string[];
|
let bodyLines: string[];
|
||||||
|
|
||||||
@@ -183,7 +168,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) {
|
|||||||
`await this.conn.extMethod("${fullMethod}", ${callParams});`,
|
`await this.conn.extMethod("${fullMethod}", ${callParams});`,
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
// Both request and response are untyped (serde_json::Value)
|
|
||||||
returnType = "Record<string, unknown>";
|
returnType = "Record<string, unknown>";
|
||||||
bodyLines = [
|
bodyLines = [
|
||||||
`return await this.conn.extMethod("${fullMethod}", ${callParams ? callParams : "{}"});`,
|
`return await this.conn.extMethod("${fullMethod}", ${callParams ? callParams : "{}"});`,
|
||||||
@@ -212,11 +196,7 @@ export interface ExtMethodProvider {
|
|||||||
${typeImportLine}
|
${typeImportLine}
|
||||||
${zodImportLine}
|
${zodImportLine}
|
||||||
|
|
||||||
/**
|
export class GooseExtClient {
|
||||||
* Typed client for Goose custom extension methods.
|
|
||||||
* Wraps an ExtMethodProvider (e.g. ClientSideConnection) with proper types and Zod validation.
|
|
||||||
*/
|
|
||||||
export class GooseClient {
|
|
||||||
constructor(private conn: ExtMethodProvider) {}
|
constructor(private conn: ExtMethodProvider) {}
|
||||||
${methodDefs.join("\n")}
|
${methodDefs.join("\n")}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
* Typed client for Goose custom extension methods.
|
* Typed client for Goose custom extension methods.
|
||||||
* Wraps an ExtMethodProvider (e.g. ClientSideConnection) with proper types and Zod validation.
|
* Wraps an ExtMethodProvider (e.g. ClientSideConnection) with proper types and Zod validation.
|
||||||
*/
|
*/
|
||||||
export class GooseClient {
|
export class GooseExtClient {
|
||||||
constructor(private conn: ExtMethodProvider) {}
|
constructor(private conn: ExtMethodProvider) {}
|
||||||
|
|
||||||
async extensionsAdd(params: AddExtensionRequest): Promise<void> {
|
async extensionsAdd(params: AddExtensionRequest): Promise<void> {
|
||||||
@@ -98,32 +98,4 @@ export class GooseClient {
|
|||||||
const raw = await this.conn.extMethod("_goose/config/extensions", {});
|
const raw = await this.conn.extMethod("_goose/config/extensions", {});
|
||||||
return zGetExtensionsResponse.parse(raw) as GetExtensionsResponse;
|
return zGetExtensionsResponse.parse(raw) as GetExtensionsResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
async toolCall(): Promise<Record<string, unknown>> {
|
|
||||||
return await this.conn.extMethod("_goose/tool/call", {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async providerUpdate(): Promise<Record<string, unknown>> {
|
|
||||||
return await this.conn.extMethod("_goose/provider/update", {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async containerSet(): Promise<Record<string, unknown>> {
|
|
||||||
return await this.conn.extMethod("_goose/container/set", {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async appsList(): Promise<Record<string, unknown>> {
|
|
||||||
return await this.conn.extMethod("_goose/apps/list", {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async appsExport(): Promise<Record<string, unknown>> {
|
|
||||||
return await this.conn.extMethod("_goose/apps/export", {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async appsImport(): Promise<Record<string, unknown>> {
|
|
||||||
return await this.conn.extMethod("_goose/apps/import", {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async configProviders(): Promise<Record<string, unknown>> {
|
|
||||||
return await this.conn.extMethod("_goose/config/providers", {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,41 +58,6 @@ export const GOOSE_EXT_METHODS = [
|
|||||||
requestType: null,
|
requestType: null,
|
||||||
responseType: "GetExtensionsResponse",
|
responseType: "GetExtensionsResponse",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
method: "tool/call",
|
|
||||||
requestType: null,
|
|
||||||
responseType: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "provider/update",
|
|
||||||
requestType: null,
|
|
||||||
responseType: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "container/set",
|
|
||||||
requestType: null,
|
|
||||||
responseType: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "apps/list",
|
|
||||||
requestType: null,
|
|
||||||
responseType: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "apps/export",
|
|
||||||
requestType: null,
|
|
||||||
responseType: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "apps/import",
|
|
||||||
requestType: null,
|
|
||||||
responseType: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
method: "config/providers",
|
|
||||||
requestType: null,
|
|
||||||
responseType: null,
|
|
||||||
},
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number];
|
export type GooseExtMethod = (typeof GOOSE_EXT_METHODS)[number];
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
ClientSideConnection,
|
||||||
|
type Client,
|
||||||
|
type Stream,
|
||||||
|
type InitializeRequest,
|
||||||
|
type InitializeResponse,
|
||||||
|
type NewSessionRequest,
|
||||||
|
type NewSessionResponse,
|
||||||
|
type LoadSessionRequest,
|
||||||
|
type LoadSessionResponse,
|
||||||
|
type PromptRequest,
|
||||||
|
type PromptResponse,
|
||||||
|
type CancelNotification,
|
||||||
|
type AuthenticateRequest,
|
||||||
|
type AuthenticateResponse,
|
||||||
|
type SetSessionModeRequest,
|
||||||
|
type SetSessionModeResponse,
|
||||||
|
type SetSessionConfigOptionRequest,
|
||||||
|
type SetSessionConfigOptionResponse,
|
||||||
|
type ForkSessionRequest,
|
||||||
|
type ForkSessionResponse,
|
||||||
|
type ListSessionsRequest,
|
||||||
|
type ListSessionsResponse,
|
||||||
|
type ResumeSessionRequest,
|
||||||
|
type ResumeSessionResponse,
|
||||||
|
type SetSessionModelRequest,
|
||||||
|
type SetSessionModelResponse,
|
||||||
|
} from "@agentclientprotocol/sdk";
|
||||||
|
import { GooseExtClient } from "./generated/client.gen.js";
|
||||||
|
|
||||||
|
export class GooseClient {
|
||||||
|
private conn: ClientSideConnection;
|
||||||
|
private ext: GooseExtClient;
|
||||||
|
|
||||||
|
constructor(toClient: () => Client, stream: Stream) {
|
||||||
|
this.conn = new ClientSideConnection(toClient, stream);
|
||||||
|
this.ext = new GooseExtClient(this.conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
get signal(): AbortSignal {
|
||||||
|
return this.conn.signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
get closed(): Promise<void> {
|
||||||
|
return this.conn.closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize(params: InitializeRequest): Promise<InitializeResponse> {
|
||||||
|
return this.conn.initialize(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||||
|
return this.conn.newSession(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||||
|
return this.conn.loadSession(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||||
|
return this.conn.prompt(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(params: CancelNotification): Promise<void> {
|
||||||
|
return this.conn.cancel(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticate(params: AuthenticateRequest): Promise<AuthenticateResponse> {
|
||||||
|
return this.conn.authenticate(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionMode(
|
||||||
|
params: SetSessionModeRequest,
|
||||||
|
): Promise<SetSessionModeResponse> {
|
||||||
|
return this.conn.setSessionMode(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSessionConfigOption(
|
||||||
|
params: SetSessionConfigOptionRequest,
|
||||||
|
): Promise<SetSessionConfigOptionResponse> {
|
||||||
|
return this.conn.setSessionConfigOption(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
unstable_forkSession(
|
||||||
|
params: ForkSessionRequest,
|
||||||
|
): Promise<ForkSessionResponse> {
|
||||||
|
return this.conn.unstable_forkSession(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
unstable_listSessions(
|
||||||
|
params: ListSessionsRequest,
|
||||||
|
): Promise<ListSessionsResponse> {
|
||||||
|
return this.conn.unstable_listSessions(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
unstable_resumeSession(
|
||||||
|
params: ResumeSessionRequest,
|
||||||
|
): Promise<ResumeSessionResponse> {
|
||||||
|
return this.conn.unstable_resumeSession(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
unstable_setSessionModel(
|
||||||
|
params: SetSessionModelRequest,
|
||||||
|
): Promise<SetSessionModelResponse> {
|
||||||
|
return this.conn.unstable_setSessionModel(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
extMethod(
|
||||||
|
method: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return this.conn.extMethod(method, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
get goose(): GooseExtClient {
|
||||||
|
return this.ext;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-2
@@ -1,3 +1,9 @@
|
|||||||
export * from "./generated/index.js";
|
export * from "./generated/types.gen.js";
|
||||||
export * from "./generated/zod.gen.js";
|
export * from "./generated/zod.gen.js";
|
||||||
export { GooseClient } from "./generated/client.gen.js";
|
export { GooseClient } from "./goose-client.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ClientSideConnection,
|
||||||
|
type Client,
|
||||||
|
type Stream,
|
||||||
|
} from "@agentclientprotocol/sdk";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
dist
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
registry=https://registry.npmjs.org/
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# goose ACP TUI
|
||||||
|
|
||||||
|
Early stage and part of goose's broader move to ACP
|
||||||
|
|
||||||
|
https://github.com/block/goose/issues/6642
|
||||||
|
https://github.com/block/goose/discussions/7309
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
1. Run the server `cargo run -p goose-acp --bin goose-acp-server`
|
||||||
|
2. Run the tui
|
||||||
|
```
|
||||||
|
cd ui/text
|
||||||
|
npm i
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
Generated
+1244
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "goose-text",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "tsx src/cli.tsx",
|
||||||
|
"lint": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@agentclientprotocol/sdk": "^0.14.1",
|
||||||
|
"@goose/acp": "file:../acp",
|
||||||
|
"ink": "^5.1.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
|
"meow": "^13.2.0",
|
||||||
|
"react": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { Box, Text, useApp, useInput, useStdout } from "ink";
|
||||||
|
import TextInput from "ink-text-input";
|
||||||
|
import type {
|
||||||
|
SessionNotification,
|
||||||
|
RequestPermissionRequest,
|
||||||
|
RequestPermissionResponse,
|
||||||
|
} from "@agentclientprotocol/sdk";
|
||||||
|
import { GooseClient } from "@goose/acp";
|
||||||
|
import { createHttpStream } from "./transport.js";
|
||||||
|
|
||||||
|
interface PendingPermission {
|
||||||
|
toolTitle: string;
|
||||||
|
options: Array<{ optionId: string; name: string; kind: string }>;
|
||||||
|
resolve: (response: RequestPermissionResponse) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRANBERRY_BRIGHT = "#C0354A";
|
||||||
|
const HARBOR_NAVY = "#1B2A4A";
|
||||||
|
const DEEP_SLATE = "#3A4F6F";
|
||||||
|
const SLATE = "#6B7F99";
|
||||||
|
const LIGHT_SLATE = "#8FA4BD";
|
||||||
|
const AUTUMN_GOLD = "#C4883A";
|
||||||
|
const OCEAN_TEAL = "#3A7D7B";
|
||||||
|
const CEDAR_BROWN = "#6B5344";
|
||||||
|
const FOG_WHITE = "#E8E4DF";
|
||||||
|
const PARCHMENT = "#D4CFC8";
|
||||||
|
|
||||||
|
const GOOSE_FRAMES = [
|
||||||
|
[
|
||||||
|
" ,_",
|
||||||
|
" (o >",
|
||||||
|
" //\\",
|
||||||
|
" \\\\ \\",
|
||||||
|
" \\\\_/",
|
||||||
|
" | |",
|
||||||
|
" ^ ^",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
" ,_",
|
||||||
|
" (o >",
|
||||||
|
" //\\",
|
||||||
|
" \\\\ \\",
|
||||||
|
" \\\\_/",
|
||||||
|
" / |",
|
||||||
|
" ^ ^",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
" ,_",
|
||||||
|
" (o >",
|
||||||
|
" //\\",
|
||||||
|
" \\\\ \\",
|
||||||
|
" \\\\_/",
|
||||||
|
" | |",
|
||||||
|
" ^ ^",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
" ,_",
|
||||||
|
" (o >",
|
||||||
|
" //\\",
|
||||||
|
" \\\\ \\",
|
||||||
|
" \\\\_/",
|
||||||
|
" | \\",
|
||||||
|
" ^ ^",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const TITLE_TEXT = "goose";
|
||||||
|
|
||||||
|
const GREETING_MESSAGES = [
|
||||||
|
"What would you like to work on?",
|
||||||
|
"Ready to build something amazing?",
|
||||||
|
"What would you like to explore?",
|
||||||
|
"What's on your mind?",
|
||||||
|
"What shall we create today?",
|
||||||
|
"What project needs attention?",
|
||||||
|
"What would you like to tackle?",
|
||||||
|
"What needs to be done?",
|
||||||
|
"What's the plan for today?",
|
||||||
|
"Ready to create something great?",
|
||||||
|
"What can be built today?",
|
||||||
|
"What's the next challenge?",
|
||||||
|
"What progress can be made?",
|
||||||
|
"What would you like to accomplish?",
|
||||||
|
"What task awaits?",
|
||||||
|
"What's the mission today?",
|
||||||
|
"What can be achieved?",
|
||||||
|
"What project is ready to begin?",
|
||||||
|
];
|
||||||
|
|
||||||
|
const INITIAL_GREETING =
|
||||||
|
GREETING_MESSAGES[Math.floor(Math.random() * GREETING_MESSAGES.length)]!;
|
||||||
|
|
||||||
|
const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"];
|
||||||
|
|
||||||
|
const PERMISSION_LABELS: Record<string, string> = {
|
||||||
|
allow_once: "Allow once",
|
||||||
|
allow_always: "Always allow",
|
||||||
|
reject_once: "Reject once",
|
||||||
|
reject_always: "Always reject",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERMISSION_KEYS: Record<string, string> = {
|
||||||
|
allow_once: "y",
|
||||||
|
allow_always: "a",
|
||||||
|
reject_once: "n",
|
||||||
|
reject_always: "N",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TextMessage {
|
||||||
|
kind: "text";
|
||||||
|
role: "user" | "agent";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolCallMessage {
|
||||||
|
kind: "tool_call";
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message = TextMessage | ToolCallMessage;
|
||||||
|
|
||||||
|
function HRule({ width, color }: { width: number; color?: string }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text color={color ?? DEEP_SLATE} dimColor>
|
||||||
|
{"─".repeat(Math.max(width, 1))}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderBar({
|
||||||
|
width,
|
||||||
|
status,
|
||||||
|
loading,
|
||||||
|
spinIdx,
|
||||||
|
hasPendingPermission,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
status: string;
|
||||||
|
loading: boolean;
|
||||||
|
spinIdx: number;
|
||||||
|
hasPendingPermission: boolean;
|
||||||
|
}) {
|
||||||
|
const statusColor =
|
||||||
|
status === "ready"
|
||||||
|
? OCEAN_TEAL
|
||||||
|
: status.startsWith("error") || status.startsWith("failed")
|
||||||
|
? CRANBERRY_BRIGHT
|
||||||
|
: SLATE;
|
||||||
|
|
||||||
|
const leftContent = ` ${TITLE_TEXT} `;
|
||||||
|
const spinner =
|
||||||
|
loading && !hasPendingPermission
|
||||||
|
? ` ${SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]} `
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={width}>
|
||||||
|
<Box justifyContent="space-between" width={width}>
|
||||||
|
<Box>
|
||||||
|
<Text color={FOG_WHITE} bold>
|
||||||
|
{leftContent}
|
||||||
|
</Text>
|
||||||
|
<Text color={DEEP_SLATE}>│</Text>
|
||||||
|
<Text color={statusColor}> {status}</Text>
|
||||||
|
{spinner && <Text color={CRANBERRY_BRIGHT}>{spinner}</Text>}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={DEEP_SLATE} dimColor>
|
||||||
|
ctrl+c to exit{" "}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<HRule width={width} color={DEEP_SLATE} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCallBlock({ title, width }: { title: string; width: number }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
marginLeft={3}
|
||||||
|
marginY={0}
|
||||||
|
paddingX={1}
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={CEDAR_BROWN}
|
||||||
|
borderDimColor
|
||||||
|
width={Math.min(width - 6, 72)}
|
||||||
|
>
|
||||||
|
<Text color={OCEAN_TEAL}>⚙ </Text>
|
||||||
|
<Text color={LIGHT_SLATE} italic>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserMessage({ text, width }: { text: string; width: number }) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={width}>
|
||||||
|
<Box paddingLeft={1} paddingY={0}>
|
||||||
|
<Text color={CRANBERRY_BRIGHT} bold>
|
||||||
|
{"❯ "}
|
||||||
|
</Text>
|
||||||
|
<Text color={FOG_WHITE} bold>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentMessage({ text, width }: { text: string; width: number }) {
|
||||||
|
return (
|
||||||
|
<Box paddingLeft={3} paddingRight={2} width={width}>
|
||||||
|
<Text color={PARCHMENT}>{text}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionPrompt({
|
||||||
|
toolTitle,
|
||||||
|
options,
|
||||||
|
selectedIdx,
|
||||||
|
width,
|
||||||
|
}: {
|
||||||
|
toolTitle: string;
|
||||||
|
options: Array<{ optionId: string; name: string; kind: string }>;
|
||||||
|
selectedIdx: number;
|
||||||
|
width: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
marginLeft={3}
|
||||||
|
marginY={0}
|
||||||
|
paddingX={2}
|
||||||
|
paddingY={1}
|
||||||
|
borderStyle="round"
|
||||||
|
borderColor={AUTUMN_GOLD}
|
||||||
|
width={Math.min(width - 6, 64)}
|
||||||
|
>
|
||||||
|
<Text color={AUTUMN_GOLD} bold>
|
||||||
|
🔒 Permission required
|
||||||
|
</Text>
|
||||||
|
<Text color={FOG_WHITE}>{toolTitle}</Text>
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{options.map((opt, i) => {
|
||||||
|
const key = PERMISSION_KEYS[opt.kind] ?? String(i + 1);
|
||||||
|
const label = PERMISSION_LABELS[opt.kind] ?? opt.name;
|
||||||
|
const selected = i === selectedIdx;
|
||||||
|
return (
|
||||||
|
<Box key={opt.optionId}>
|
||||||
|
<Text color={selected ? AUTUMN_GOLD : DEEP_SLATE}>
|
||||||
|
{selected ? " ▸ " : " "}
|
||||||
|
</Text>
|
||||||
|
<Text color={selected ? FOG_WHITE : LIGHT_SLATE} bold={selected}>
|
||||||
|
[{key}] {label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={SLATE} dimColor>
|
||||||
|
↑↓ select · enter confirm · esc cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SplashScreen({
|
||||||
|
animFrame,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
status,
|
||||||
|
loading,
|
||||||
|
spinIdx,
|
||||||
|
showInput,
|
||||||
|
input,
|
||||||
|
onInputChange,
|
||||||
|
onInputSubmit,
|
||||||
|
}: {
|
||||||
|
animFrame: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
status: string;
|
||||||
|
loading: boolean;
|
||||||
|
spinIdx: number;
|
||||||
|
showInput: boolean;
|
||||||
|
input: string;
|
||||||
|
onInputChange: (v: string) => void;
|
||||||
|
onInputSubmit: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const frame = GOOSE_FRAMES[animFrame % GOOSE_FRAMES.length]!;
|
||||||
|
const statusColor =
|
||||||
|
status === "ready"
|
||||||
|
? OCEAN_TEAL
|
||||||
|
: status.startsWith("error") || status.startsWith("failed")
|
||||||
|
? CRANBERRY_BRIGHT
|
||||||
|
: SLATE;
|
||||||
|
|
||||||
|
const inputWidth = Math.min(60, width - 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
>
|
||||||
|
<Box flexDirection="column" alignItems="center">
|
||||||
|
{frame.map((line, i) => (
|
||||||
|
<Text key={i} color={FOG_WHITE}>
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={FOG_WHITE} bold>
|
||||||
|
{TITLE_TEXT}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Text color={SLATE}>your on-machine AI agent</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{showInput ? (
|
||||||
|
<>
|
||||||
|
<Box marginTop={2}>
|
||||||
|
<HRule width={inputWidth} color={DEEP_SLATE} />
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<Text color={CRANBERRY_BRIGHT} bold>
|
||||||
|
{"❯ "}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={input}
|
||||||
|
placeholder={INITIAL_GREETING}
|
||||||
|
onChange={onInputChange}
|
||||||
|
onSubmit={onInputSubmit}
|
||||||
|
showCursor
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={0}>
|
||||||
|
<HRule width={inputWidth} color={DEEP_SLATE} />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box marginTop={2}>
|
||||||
|
<HRule width={Math.min(40, width - 4)} color={DEEP_SLATE} />
|
||||||
|
</Box>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
{loading && (
|
||||||
|
<Text color={CRANBERRY_BRIGHT}>
|
||||||
|
{SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]}{" "}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text color={statusColor}>{status}</Text>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputBar({
|
||||||
|
width,
|
||||||
|
input,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
width: number;
|
||||||
|
input: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
onSubmit: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={width}>
|
||||||
|
<HRule width={width} color={DEEP_SLATE} />
|
||||||
|
<Box paddingLeft={1} paddingY={0}>
|
||||||
|
<Text color={CRANBERRY_BRIGHT} bold>
|
||||||
|
{"❯ "}
|
||||||
|
</Text>
|
||||||
|
<TextInput value={input} onChange={onChange} onSubmit={onSubmit} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingIndicator({
|
||||||
|
status,
|
||||||
|
spinIdx,
|
||||||
|
}: {
|
||||||
|
status: string;
|
||||||
|
spinIdx: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box paddingLeft={3} marginTop={0}>
|
||||||
|
<Text color={CRANBERRY_BRIGHT}>
|
||||||
|
{SPINNER_FRAMES[spinIdx % SPINNER_FRAMES.length]}{" "}
|
||||||
|
</Text>
|
||||||
|
<Text color={SLATE} italic>
|
||||||
|
{status}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App({
|
||||||
|
serverUrl,
|
||||||
|
initialPrompt,
|
||||||
|
}: {
|
||||||
|
serverUrl: string;
|
||||||
|
initialPrompt?: string;
|
||||||
|
}) {
|
||||||
|
const { exit } = useApp();
|
||||||
|
const { stdout } = useStdout();
|
||||||
|
const termWidth = stdout?.columns ?? 80;
|
||||||
|
const termHeight = stdout?.rows ?? 24;
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [status, setStatus] = useState("connecting...");
|
||||||
|
const [spinIdx, setSpinIdx] = useState(0);
|
||||||
|
const [gooseFrame, setGooseFrame] = useState(0);
|
||||||
|
const [bannerVisible, setBannerVisible] = useState(true);
|
||||||
|
const [pendingPermission, setPendingPermission] =
|
||||||
|
useState<PendingPermission | null>(null);
|
||||||
|
const [permissionIdx, setPermissionIdx] = useState(0);
|
||||||
|
const clientRef = useRef<GooseClient | null>(null);
|
||||||
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
const streamBuf = useRef("");
|
||||||
|
const sentInitialPrompt = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => {
|
||||||
|
setSpinIdx((i) => (i + 1) % SPINNER_FRAMES.length);
|
||||||
|
setGooseFrame((f) => f + 1);
|
||||||
|
}, 300);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0) {
|
||||||
|
setBannerVisible(false);
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const appendAgent = useCallback((text: string) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (last && last.kind === "text" && last.role === "agent") {
|
||||||
|
return [
|
||||||
|
...prev.slice(0, -1),
|
||||||
|
{
|
||||||
|
kind: "text" as const,
|
||||||
|
role: "agent" as const,
|
||||||
|
text: last.text + text,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [...prev, { kind: "text" as const, role: "agent" as const, text }];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const appendToolCall = useCallback((title: string) => {
|
||||||
|
setMessages((prev) => [...prev, { kind: "tool_call" as const, title }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolvePermission = useCallback(
|
||||||
|
(option: { optionId: string } | "cancelled") => {
|
||||||
|
if (!pendingPermission) return;
|
||||||
|
const { resolve } = pendingPermission;
|
||||||
|
if (option === "cancelled") {
|
||||||
|
resolve({ outcome: { outcome: "cancelled" } });
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
outcome: { outcome: "selected", optionId: option.optionId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setPendingPermission(null);
|
||||||
|
setPermissionIdx(0);
|
||||||
|
},
|
||||||
|
[pendingPermission],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendPrompt = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
const client = clientRef.current;
|
||||||
|
const sid = sessionIdRef.current;
|
||||||
|
if (!client || !sid) return;
|
||||||
|
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ kind: "text" as const, role: "user" as const, text },
|
||||||
|
]);
|
||||||
|
setLoading(true);
|
||||||
|
setStatus("thinking...");
|
||||||
|
streamBuf.current = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.prompt({
|
||||||
|
sessionId: sid,
|
||||||
|
prompt: [{ type: "text", text }],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (streamBuf.current) {
|
||||||
|
appendAgent("");
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(
|
||||||
|
result.stopReason === "end_turn"
|
||||||
|
? "ready"
|
||||||
|
: `stopped: ${result.stopReason}`,
|
||||||
|
);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const errMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
setStatus(`error: ${errMsg}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appendAgent],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setStatus("initializing...");
|
||||||
|
const stream = createHttpStream(serverUrl);
|
||||||
|
|
||||||
|
const client = new GooseClient(
|
||||||
|
() => ({
|
||||||
|
sessionUpdate: async (params: SessionNotification) => {
|
||||||
|
const update = params.update;
|
||||||
|
|
||||||
|
if (update.sessionUpdate === "agent_message_chunk") {
|
||||||
|
if (update.content.type === "text") {
|
||||||
|
streamBuf.current += update.content.text;
|
||||||
|
appendAgent(update.content.text);
|
||||||
|
}
|
||||||
|
} else if (update.sessionUpdate === "tool_call") {
|
||||||
|
appendToolCall(update.title || "tool");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestPermission: async (
|
||||||
|
params: RequestPermissionRequest,
|
||||||
|
): Promise<RequestPermissionResponse> => {
|
||||||
|
return new Promise<RequestPermissionResponse>((resolve) => {
|
||||||
|
const toolTitle = params.toolCall.title ?? "unknown tool";
|
||||||
|
const options = params.options.map((opt) => ({
|
||||||
|
optionId: opt.optionId,
|
||||||
|
name: opt.name,
|
||||||
|
kind: opt.kind,
|
||||||
|
}));
|
||||||
|
setPendingPermission({ toolTitle, options, resolve });
|
||||||
|
setPermissionIdx(0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
stream,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
setStatus("handshaking...");
|
||||||
|
await client.initialize({
|
||||||
|
protocolVersion: 0,
|
||||||
|
clientInfo: { name: "goose-text", version: "0.1.0" },
|
||||||
|
clientCapabilities: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setStatus("creating session...");
|
||||||
|
const session = await client.newSession({
|
||||||
|
cwd: process.cwd(),
|
||||||
|
mcpServers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
sessionIdRef.current = session.sessionId;
|
||||||
|
setLoading(false);
|
||||||
|
setStatus("ready");
|
||||||
|
|
||||||
|
if (initialPrompt && !sentInitialPrompt.current) {
|
||||||
|
sentInitialPrompt.current = true;
|
||||||
|
await sendPrompt(initialPrompt);
|
||||||
|
if (initialPrompt) {
|
||||||
|
setTimeout(() => exit(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const errMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
setStatus(`failed: ${errMsg}`);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [serverUrl, initialPrompt, sendPrompt, appendAgent, appendToolCall, exit]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || loading) return;
|
||||||
|
setInput("");
|
||||||
|
sendPrompt(trimmed);
|
||||||
|
},
|
||||||
|
[loading, sendPrompt],
|
||||||
|
);
|
||||||
|
|
||||||
|
useInput((ch, key) => {
|
||||||
|
if (key.escape || (ch === "c" && key.ctrl)) {
|
||||||
|
if (pendingPermission) {
|
||||||
|
resolvePermission("cancelled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingPermission) {
|
||||||
|
const opts = pendingPermission.options;
|
||||||
|
|
||||||
|
if (key.upArrow) {
|
||||||
|
setPermissionIdx((i) => (i - 1 + opts.length) % opts.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
setPermissionIdx((i) => (i + 1) % opts.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.return) {
|
||||||
|
const selected = opts[permissionIdx];
|
||||||
|
if (selected) {
|
||||||
|
resolvePermission({ optionId: selected.optionId });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyMap: Record<string, string> = {
|
||||||
|
y: "allow_once",
|
||||||
|
a: "allow_always",
|
||||||
|
n: "reject_once",
|
||||||
|
N: "reject_always",
|
||||||
|
};
|
||||||
|
const targetKind = keyMap[ch];
|
||||||
|
if (targetKind) {
|
||||||
|
const match = opts.find((o) => o.kind === targetKind);
|
||||||
|
if (match) {
|
||||||
|
resolvePermission({ optionId: match.optionId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const PAD_X = 2;
|
||||||
|
const PAD_BOTTOM = 1;
|
||||||
|
const innerWidth = Math.max(termWidth - PAD_X * 2, 20);
|
||||||
|
const headerHeight = 2;
|
||||||
|
const inputBarHeight = initialPrompt ? 0 : 2;
|
||||||
|
const bodyHeight = Math.max(termHeight - headerHeight - inputBarHeight - PAD_BOTTOM, 3);
|
||||||
|
|
||||||
|
if (bannerVisible) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
width={termWidth}
|
||||||
|
height={termHeight}
|
||||||
|
>
|
||||||
|
<SplashScreen
|
||||||
|
animFrame={gooseFrame}
|
||||||
|
width={termWidth}
|
||||||
|
height={termHeight}
|
||||||
|
status={status}
|
||||||
|
loading={loading}
|
||||||
|
spinIdx={spinIdx}
|
||||||
|
showInput={!loading && !initialPrompt}
|
||||||
|
input={input}
|
||||||
|
onInputChange={setInput}
|
||||||
|
onInputSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
width={termWidth}
|
||||||
|
height={termHeight}
|
||||||
|
paddingX={PAD_X}
|
||||||
|
paddingBottom={PAD_BOTTOM}
|
||||||
|
>
|
||||||
|
<HeaderBar
|
||||||
|
width={innerWidth}
|
||||||
|
status={status}
|
||||||
|
loading={loading}
|
||||||
|
spinIdx={spinIdx}
|
||||||
|
hasPendingPermission={!!pendingPermission}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
flexGrow={1}
|
||||||
|
height={bodyHeight}
|
||||||
|
overflowY="hidden"
|
||||||
|
paddingY={0}
|
||||||
|
>
|
||||||
|
{messages.map((msg, i) => {
|
||||||
|
if (msg.kind === "tool_call") {
|
||||||
|
return <ToolCallBlock key={i} title={msg.title} width={innerWidth} />;
|
||||||
|
}
|
||||||
|
if (msg.role === "user") {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{i > 0 && <Box height={1} />}
|
||||||
|
<UserMessage text={msg.text} width={innerWidth} />
|
||||||
|
<HRule width={innerWidth} color={HARBOR_NAVY} />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <AgentMessage key={i} text={msg.text} width={innerWidth} />;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{pendingPermission && (
|
||||||
|
<PermissionPrompt
|
||||||
|
toolTitle={pendingPermission.toolTitle}
|
||||||
|
options={pendingPermission.options}
|
||||||
|
selectedIdx={permissionIdx}
|
||||||
|
width={innerWidth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !pendingPermission && messages.length > 0 && (
|
||||||
|
<LoadingIndicator status={status} spinIdx={spinIdx} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!loading && !pendingPermission && !initialPrompt && (
|
||||||
|
<InputBar
|
||||||
|
width={innerWidth}
|
||||||
|
input={input}
|
||||||
|
onChange={setInput}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import React from "react";
|
||||||
|
import { render } from "ink";
|
||||||
|
import meow from "meow";
|
||||||
|
import App from "./app.js";
|
||||||
|
|
||||||
|
const cli = meow(
|
||||||
|
`
|
||||||
|
Usage
|
||||||
|
$ goose-text
|
||||||
|
|
||||||
|
Options
|
||||||
|
--server, -s Server URL (default: http://127.0.0.1:3284)
|
||||||
|
--text, -t Send a single prompt and exit
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
importMeta: import.meta,
|
||||||
|
flags: {
|
||||||
|
server: { type: "string", shortFlag: "s", default: "http://127.0.0.1:3284" },
|
||||||
|
text: { type: "string", shortFlag: "t" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<App serverUrl={cli.flags.server} initialPrompt={cli.flags.text} />);
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { AnyMessage, Stream } from "@agentclientprotocol/sdk";
|
||||||
|
|
||||||
|
const ACP_SESSION_HEADER = "Acp-Session-Id";
|
||||||
|
|
||||||
|
export function createHttpStream(serverUrl: string): Stream {
|
||||||
|
let sessionId: string | null = null;
|
||||||
|
const incoming: AnyMessage[] = [];
|
||||||
|
const waiters: Array<() => void> = [];
|
||||||
|
const sseAbort = new AbortController();
|
||||||
|
|
||||||
|
function pushMessage(msg: AnyMessage) {
|
||||||
|
incoming.push(msg);
|
||||||
|
const w = waiters.shift();
|
||||||
|
if (w) w();
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForMessage(): Promise<void> {
|
||||||
|
if (incoming.length > 0) return Promise.resolve();
|
||||||
|
return new Promise<void>((r) => waiters.push(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeSSE(response: Response) {
|
||||||
|
if (!response.body) return;
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
const parts = buffer.split("\n\n");
|
||||||
|
buffer = parts.pop() || "";
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
for (const line of part.split("\n")) {
|
||||||
|
if (line.startsWith("data: ")) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line.slice(6)) as AnyMessage;
|
||||||
|
pushMessage(msg);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof DOMException && e.name === "AbortError") return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST initialize (no session header) opens a long-lived SSE stream that receives
|
||||||
|
// ALL subsequent responses and notifications. Later POSTs with the session header
|
||||||
|
// are fire-and-forget for requests (responses arrive on the first stream) or
|
||||||
|
// return 202 immediately for notifications/responses.
|
||||||
|
let isFirstRequest = true;
|
||||||
|
|
||||||
|
const readable = new ReadableStream<AnyMessage>({
|
||||||
|
async pull(controller) {
|
||||||
|
await waitForMessage();
|
||||||
|
while (incoming.length > 0) {
|
||||||
|
controller.enqueue(incoming.shift()!);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const writable = new WritableStream<AnyMessage>({
|
||||||
|
async write(msg) {
|
||||||
|
const isRequest =
|
||||||
|
"method" in msg && "id" in msg && msg.id !== undefined && msg.id !== null;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json, text/event-stream",
|
||||||
|
};
|
||||||
|
if (sessionId) {
|
||||||
|
headers[ACP_SESSION_HEADER] = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFirstRequest && isRequest) {
|
||||||
|
isFirstRequest = false;
|
||||||
|
|
||||||
|
const response = await fetch(`${serverUrl}/acp`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(msg),
|
||||||
|
signal: sseAbort.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sid = response.headers.get(ACP_SESSION_HEADER);
|
||||||
|
if (sid) sessionId = sid;
|
||||||
|
|
||||||
|
consumeSSE(response);
|
||||||
|
} else if (isRequest) {
|
||||||
|
const abort = new AbortController();
|
||||||
|
fetch(`${serverUrl}/acp`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(msg),
|
||||||
|
signal: abort.signal,
|
||||||
|
}).catch(() => {});
|
||||||
|
setTimeout(() => abort.abort(), 200);
|
||||||
|
} else {
|
||||||
|
await fetch(`${serverUrl}/acp`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(msg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
sseAbort.abort();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { readable, writable };
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user