Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/channels-tui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"agents": patch
---

Add `npx agents tui <url>`, a terminal client for a conversation's Web Channel.

It streams the transcript with highlighted Markdown, collapsible reasoning and tool cards, and shows messages and turns from every surface live. It answers approvals inline, lets a person type a client tool's result, and cancels the running turn with Esc. `--header` adds headers to the WebSocket upgrade, and `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` are sent as an Access service token. Its dependencies are bundled into `dist/cli.js`, so installing `agents` adds none.
8 changes: 8 additions & 0 deletions examples/next/channels/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,11 @@ pnpm tui [room]
`getLocation` itself and asks for approvals with `y` / `n`. Set
`AGENT_ORIGIN` if the dev server is not on `ws://localhost:5173`. The browser
and the TUI can share a room; each is its own participant.

`pnpm tui2 [harness] [room]` runs the Agents SDK terminal client built on Pi
TUI (`npx agents tui <url>`) against either agent, for example
`pnpm tui2 ai-sdk lobby`. It shows turns started from other surfaces, lets you
provide client-tool results, and can start, fork, list and switch
Comment on lines +61 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Example advertises an unregistered harness

The terminal instructions claim tui2 works with either harness, but the gateway resolver registers only ai-sdk. The example needs either the second harness or narrower instructions.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

conversations (`/new`, `/fork`, `/conversations`, `/switch <id>`). A
conversation is also reachable directly at
`/channels/<harness>/<room>/<conversation>`.
3 changes: 2 additions & 1 deletion examples/next/channels/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"start": "vite dev",
"deploy": "vite build && wrangler deploy",
"types": "wrangler types env.d.ts --include-runtime false",
"tui": "node src/ai-sdk-tui.ts"
"tui": "node src/ai-sdk-tui.ts",
"tui2": "sh -c 'tsx ../../../packages/agents/src/cli/index.ts tui \"${AGENT_ORIGIN:-http://localhost:5173}/channels/${1:-ai-sdk}/${2:-default}\" --as \"terminal-$$\"' tui2"
},
"dependencies": {
"@cloudflare/kumo": "^2.6.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"durable objects"
],
"type": "module",
"bin": {
"agents": "./dist/cli.js"
},
"version": "0.26.0",
"license": "MIT",
"repository": {
Expand Down Expand Up @@ -42,6 +45,7 @@
"@cloudflare/codemode": "workspace:*",
"@earendil-works/pi-ai": "^1.0.0",
"@earendil-works/pi-durable": "^1.0.0",
"@earendil-works/pi-tui": "1.0.0",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/conformance-v2": "npm:@modelcontextprotocol/conformance@0.2.0-alpha.10",
"@modelcontextprotocol/sdk": "1.30.0",
Expand All @@ -54,6 +58,7 @@
"ai": "^7.0.0",
"chat": "^4.31.0",
"glob": "^13.0.6",
"highlight.js": "11.12.0",
"just-bash": "^3.0.2",
"react": "^19.2.7",
"teleproto": "^1.228.5",
Expand Down
22 changes: 22 additions & 0 deletions packages/agents/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ async function main() {
fixedExtension: false
});

// The CLI is self-contained: its dependencies are devDependencies bundled
// here, so installing `agents` pulls in none of them.
await build({
clean: false,
dts: false,
platform: "node",
target: "node22",
entry: { cli: "src/cli/index.ts" },
deps: {
onlyBundle: [
"@earendil-works/pi-tui",
"highlight.js",
"marked",
"get-east-asian-width"
]
},
format: "esm",
minify: true,
banner: "#!/usr/bin/env node",
fixedExtension: false
});

// then run oxfmt on the generated .d.ts files
formatDeclarationFiles();

Expand Down
18 changes: 18 additions & 0 deletions packages/agents/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { main as tui } from "../experimental/channels/web/tui/main";

const usage = `Usage: agents <command>

Commands:
tui <url> Chat with an agent's Web Channel from the terminal`;

const [command, ...rest] = process.argv.slice(2);

if (command === "tui") {
process.exit(await tui(rest));
}
if (command === undefined || command === "--help" || command === "-h") {
console.log(usage);
process.exit(0);
}
console.error(`Unknown command "${command}"\n\n${usage}`);
process.exit(2);
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { parseArgs, UsageError } from "../web/tui/args";

describe("tui args", () => {
it("turns http(s) into ws(s) and keeps the path as given", () => {
expect(parseArgs(["https://example.com/a/b/channels?x=1"]).url).toBe(
"wss://example.com/a/b/channels?x=1"
);
expect(parseArgs(["http://localhost:5173/c"]).url).toBe(
"ws://localhost:5173/c"
);
expect(parseArgs(["wss://example.com/c"]).url).toBe("wss://example.com/c");
});

it("sets the participant with --as", () => {
expect(parseArgs(["https://h/c", "--as", "ann"]).url).toBe(
"wss://h/c?as=ann"
);
expect(parseArgs(["--as=bo b", "https://h/c?as=x"]).url).toBe(
"wss://h/c?as=bo+b"
);
});

it("collects headers in either form", () => {
const { headers } = parseArgs([
"https://h/c",
"--header",
"X-One=a=b",
"-H",
"Authorization: Bearer t:1",
"--header=x-two=2"
]);
expect(headers).toEqual({
"x-one": "a=b",
authorization: "Bearer t:1",
"x-two": "2"
});
});

it("sends an Access service token from the environment", () => {
const env = { CF_ACCESS_CLIENT_ID: "id", CF_ACCESS_CLIENT_SECRET: "s" };
expect(parseArgs(["https://h/c"], env).headers).toEqual({
"cf-access-client-id": "id",
"cf-access-client-secret": "s"
});
expect(
parseArgs(["https://h/c", "-H", "CF-Access-Client-Id: other"], env)
.headers["cf-access-client-id"]
).toBe("other");
expect(() =>
parseArgs(["https://h/c"], { CF_ACCESS_CLIENT_ID: "id" })
).toThrow(UsageError);
});

it("rejects bad input", () => {
for (const argv of [
[],
["not a url"],
["ftp://h/c"],
["https://h/c", "extra"],
["https://h/c", "--nope"],
["https://h/c", "--as"],
["https://h/c", "-H", "novalue"]
]) {
expect(() => parseArgs(argv), argv.join(" ")).toThrow(UsageError);
}
});
});
102 changes: 102 additions & 0 deletions packages/agents/src/experimental/channels/__tests__/tui-render.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { type TUI, visibleWidth } from "@earendil-works/pi-tui";
import { describe, expect, it } from "vitest";
import type { ToolPart } from "../protocol";
import { type Context, TranscriptView } from "../web/tui/components/blocks";
import { PendingView, StatusBar } from "../web/tui/components/footer";
import { ClientToolModal, ReasonModal } from "../web/tui/components/modal";
import { type Block, initialUi, type Status } from "../web/tui/view";

const tool: ToolPart = {
type: "tool",
toolCallId: "c1",
toolName: "aVeryLongToolNameThatKeepsGoing",
state: "approval-requested",
input: { path: "/a/very/long/path/that/does/not/fit/anywhere", n: 1 },
approval: { id: "a1" }
};

const blocks: Block[] = [
{
kind: "user",
key: "m1",
text: "a long message ".repeat(10),
from: { you: false, name: "Ann with a long name" }
},
{
kind: "reasoning",
key: "r",
text: "thinking ".repeat(40),
streaming: true
},
{
kind: "text",
key: "t",
text: "# Title\n\nSome `code` and\n\n```ts\nconst x: number = 1; // a long trailing comment\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |",
streaming: false
},
{ kind: "tool", key: "tool:c1", part: tool, turnId: "t1", turn: "awaiting" },
{ kind: "attachment", key: "f", label: "file a-very-long-file-name.png" },
{ kind: "notice", key: "n", text: "✗ Turn failed: ".repeat(5), tone: "error" }
];

const status: Status = {
connected: false,
seen: true,
you: "terminal-123456",
running: { turnId: "t", yours: true, seconds: 75, cancelling: true },
queued: [{ turnId: "q", label: "queued ".repeat(20), yours: true }],
awaiting: 2,
sending: [{ eventId: "e", label: "sending ".repeat(20) }]
};

const fakeTui = {
terminal: { rows: 24, columns: 80 },
requestRender() {}
} as unknown as TUI;

describe("tui rendering", () => {
for (const width of [6, 12, 30, 80]) {
it(`fits every line in ${width} columns`, () => {
for (const expanded of [true, false]) {
const context: Context = {
frame: 3,
you: "me",
ui: {
...initialUi,
toolsExpanded: expanded,
reasoningExpanded: expanded,
selected: "tool:c1"
}
};
const transcript = new TranscriptView(context);
transcript.update(blocks);
const pending = new PendingView();
pending.status = status;
pending.prompts = ["aVeryLongToolNameThatKeepsGoing"];
const bar = new StatusBar("example.com/agents/a/b/channels", context);
bar.status = status;
const lines = [
...transcript.render(width),
...pending.render(width),
...bar.render(width),
...new ClientToolModal(
fakeTui,
tool,
() => {},
() => {}
).render(width),
...new ReasonModal(
tool,
() => {},
() => {}
).render(width)
];
for (const line of lines) {
expect(visibleWidth(line), JSON.stringify(line)).toBeLessThanOrEqual(
width
);
}
}
});
}
});
Loading
Loading