feat: TUI client of goose-acp (#7362)

This commit is contained in:
Alex Hancock
2026-02-19 15:54:07 -05:00
committed by GitHub
parent 398bf8be8c
commit d4dfa5d311
15 changed files with 2343 additions and 87 deletions
+3
View File
@@ -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() {
+1 -21
View File
@@ -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")}
}
+1 -29
View File
@@ -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", {});
}
}
-35
View File
@@ -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];
+118
View File
@@ -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
View File
@@ -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";
+1
View File
@@ -0,0 +1 @@
dist
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
+16
View File
@@ -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
```
+1244
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+764
View File
@@ -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>
);
}
+25
View File
@@ -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} />);
+120
View File
@@ -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 };
}
+16
View File
@@ -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"]
}