mirror of
https://github.com/aaif-goose/goose.git
synced 2026-06-02 06:14:27 +02:00
feat: TUI client of goose-acp (#7362)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use goose::builtin_extension::register_builtin_extensions;
|
||||
use goose::config::paths::Paths;
|
||||
use goose_acp::server_factory::{AcpServer, AcpServerFactoryConfig};
|
||||
use std::net::SocketAddr;
|
||||
@@ -29,6 +30,8 @@ async fn main() -> Result<()> {
|
||||
.with(tracing_subscriber::fmt::layer().with_target(true))
|
||||
.init();
|
||||
|
||||
register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone());
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let builtins = if cli.builtins.is_empty() {
|
||||
|
||||
@@ -28,7 +28,6 @@ main().catch((err) => {
|
||||
async function main() {
|
||||
const schemaSrc = await fs.readFile(SCHEMA_PATH, "utf8");
|
||||
const jsonSchema = JSON.parse(
|
||||
// Convert JSON Schema $defs refs to OpenAPI component refs
|
||||
schemaSrc.replaceAll("#/$defs/", "#/components/schemas/"),
|
||||
);
|
||||
|
||||
@@ -63,7 +62,6 @@ async function main() {
|
||||
async function postProcessTypes() {
|
||||
const tsPath = resolve(OUTPUT_DIR, "types.gen.ts");
|
||||
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");
|
||||
await fs.writeFile(tsPath, src);
|
||||
}
|
||||
@@ -72,17 +70,14 @@ async function postProcessIndex(meta: { methods: unknown[] }) {
|
||||
const indexPath = resolve(OUTPUT_DIR, "index.ts");
|
||||
let src = await fs.readFile(indexPath, "utf8");
|
||||
|
||||
// Strip ClientOptions from re-exports
|
||||
src = src.replace(/,?\s*ClientOptions\s*,?/g, (match) => {
|
||||
if (match.startsWith(",") && match.endsWith(",")) return ",";
|
||||
if (match.startsWith(",")) return "";
|
||||
return "";
|
||||
});
|
||||
|
||||
// Fix bare relative imports to use .js extensions (required by nodenext consumers)
|
||||
src = fixRelativeImports(src);
|
||||
|
||||
// Append method constants
|
||||
const methodConstants = await prettier.format(
|
||||
`
|
||||
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}`);
|
||||
|
||||
// Also fix imports in zod.gen.ts (it may import from types.gen)
|
||||
for (const file of ["zod.gen.ts", "types.gen.ts"]) {
|
||||
const filePath = resolve(OUTPUT_DIR, file);
|
||||
try {
|
||||
@@ -127,9 +121,6 @@ interface MethodMeta {
|
||||
responseType: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a method path like "session/list" or "working_dir/update" to camelCase "sessionList", "workingDirUpdate".
|
||||
*/
|
||||
function methodToCamelCase(method: string): string {
|
||||
return method
|
||||
.split(/[/_]/)
|
||||
@@ -139,10 +130,6 @@ function methodToCamelCase(method: string): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a typed GooseClient class that wraps ClientSideConnection.extMethod()
|
||||
* with proper TypeScript types and Zod runtime validation.
|
||||
*/
|
||||
async function generateClient(meta: { methods: MethodMeta[] }) {
|
||||
const typeImports = new Set<string>();
|
||||
const zodImports = new Set<string>();
|
||||
@@ -153,7 +140,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) {
|
||||
const fnName = methodToCamelCase(m.method);
|
||||
const fullMethod = `_goose/${m.method}`;
|
||||
|
||||
// Build param type and arg
|
||||
let paramType = "";
|
||||
let paramArg = "";
|
||||
let callParams = "{}";
|
||||
@@ -164,7 +150,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) {
|
||||
callParams = "params";
|
||||
}
|
||||
|
||||
// Build return type and validation
|
||||
let returnType: string;
|
||||
let bodyLines: string[];
|
||||
|
||||
@@ -183,7 +168,6 @@ async function generateClient(meta: { methods: MethodMeta[] }) {
|
||||
`await this.conn.extMethod("${fullMethod}", ${callParams});`,
|
||||
];
|
||||
} else {
|
||||
// Both request and response are untyped (serde_json::Value)
|
||||
returnType = "Record<string, unknown>";
|
||||
bodyLines = [
|
||||
`return await this.conn.extMethod("${fullMethod}", ${callParams ? callParams : "{}"});`,
|
||||
@@ -212,11 +196,7 @@ export interface ExtMethodProvider {
|
||||
${typeImportLine}
|
||||
${zodImportLine}
|
||||
|
||||
/**
|
||||
* Typed client for Goose custom extension methods.
|
||||
* Wraps an ExtMethodProvider (e.g. ClientSideConnection) with proper types and Zod validation.
|
||||
*/
|
||||
export class GooseClient {
|
||||
export class GooseExtClient {
|
||||
constructor(private conn: ExtMethodProvider) {}
|
||||
${methodDefs.join("\n")}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
* Typed client for Goose custom extension methods.
|
||||
* Wraps an ExtMethodProvider (e.g. ClientSideConnection) with proper types and Zod validation.
|
||||
*/
|
||||
export class GooseClient {
|
||||
export class GooseExtClient {
|
||||
constructor(private conn: ExtMethodProvider) {}
|
||||
|
||||
async extensionsAdd(params: AddExtensionRequest): Promise<void> {
|
||||
@@ -98,32 +98,4 @@ export class GooseClient {
|
||||
const raw = await this.conn.extMethod("_goose/config/extensions", {});
|
||||
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,
|
||||
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;
|
||||
|
||||
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 { 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