diff --git a/README.md b/README.md index 1e9b0517945..dba299c5ad4 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,31 @@ brew install --cask t3-code yay -S t3code-bin ``` +### Terminal UI (over SSH, no port forwarding) + +If you run a T3 Code server on a remote machine, you can monitor and drive its +threads from a terminal UI that talks to the already-running local server — no +port forwarding required. The TUI renders with [OpenTUI](https://opentui.com) +and runs on [Bun](https://bun.sh), so install Bun on the box first: + +```bash +ssh my-remote-box +curl -fsSL https://bun.sh/install | bash # if Bun isn't already installed +t3 tui # or: npx t3@latest tui +``` + +`t3 tui` (Node) bootstraps auth and launches the UI in a Bun subprocess; if Bun +isn't on `PATH` it prints an install hint and exits. + +The prompt is always ready: pick a thread with `↑`/`↓` and just start typing, +then press `Enter` to send. Conversations render as Markdown and follow the +latest reply; scroll with `PgUp`/`PgDn`. The TUI also lets you approve/deny tool +prompts (`^A`/`^R`), interrupt a running turn (`^G`), start new threads (`^N`), +and attach to a thread's terminal (`^E`; `Ctrl-Q` detaches). Start a server +first with `t3 serve` if one isn't already running. Long conversations stay +responsive by showing a bounded page; use the earlier/newer rows to move through +older history. + ## Some notes We are very very early in this project. Expect bugs. diff --git a/apps/server/package.json b/apps/server/package.json index 48ee9121b51..cf1336da676 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -29,9 +29,11 @@ "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-node": "0.9.4", "@opencode-ai/sdk": "^1.3.15", + "@opentui/core": "^0.4.1", "@pierre/diffs": "catalog:", "effect": "catalog:", "node-pty": "^1.1.0", + "sharp": "^0.34.5", "yaml": "catalog:" }, "devDependencies": { diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 517f633577c..7646c2e024c 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -27,7 +27,9 @@ import { ServerCliDevelopmentIconTargetMissingError, ServerCliPublishIconSourceMissingError, ServerCliPublishIconTargetMissingError, + ServerCliTuiBundleImportError, } from "./cliErrors.ts"; +import { findUnresolvedTuiBundleImport } from "./tuiBundle.ts"; interface PackageJson { name: string; @@ -162,6 +164,23 @@ const buildCmd = Command.make( }), ); + const tuiEntry = path.join(repoRoot, "apps/tui/dist/index.js"); + const tuiTarget = path.join(serverDir, "dist/tui/index.js"); + if (!(yield* fs.exists(tuiEntry))) { + return yield* new ServerCliBuildAssetMissingError({ assetPath: tuiEntry }); + } + yield* fs.makeDirectory(path.dirname(tuiTarget), { recursive: true }); + yield* fs.copyFile(tuiEntry, tuiTarget); + const tuiBundle = yield* fs.readFileString(tuiTarget); + const unresolvedImport = findUnresolvedTuiBundleImport(tuiBundle); + if (unresolvedImport !== null) { + return yield* new ServerCliTuiBundleImportError({ + assetPath: tuiTarget, + specifier: unresolvedImport, + }); + } + yield* Effect.log("[cli] Bundled TUI entry into dist/tui"); + const webDist = path.join(repoRoot, "apps/web/dist"); const clientTarget = path.join(serverDir, "dist/client"); @@ -223,7 +242,7 @@ const publishCmd = Command.make( const packageJsonPath = path.join(serverDir, "package.json"); // Assert build assets exist - for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { + for (const relPath of ["dist/bin.mjs", "dist/client/index.html", "dist/tui/index.js"]) { const abs = path.join(serverDir, relPath); if (!(yield* fs.exists(abs))) { return yield* new ServerCliBuildAssetMissingError({ assetPath: abs }); diff --git a/apps/server/scripts/cliErrors.test.ts b/apps/server/scripts/cliErrors.test.ts index 91754290db9..8b651421688 100644 --- a/apps/server/scripts/cliErrors.test.ts +++ b/apps/server/scripts/cliErrors.test.ts @@ -1,6 +1,10 @@ import { assert, describe, it } from "@effect/vitest"; -import { ServerCliBuildAssetMissingError, ServerCliCommandExitError } from "./cliErrors.ts"; +import { + ServerCliBuildAssetMissingError, + ServerCliCommandExitError, + ServerCliTuiBundleImportError, +} from "./cliErrors.ts"; describe("server CLI errors", () => { it("preserves failed command context without changing its message", () => { @@ -28,4 +32,16 @@ describe("server CLI errors", () => { "Missing build asset: /repo/server.mjs. Run the build subcommand first.", ); }); + + it("identifies an unresolved TUI runtime import", () => { + const error = new ServerCliTuiBundleImportError({ + assetPath: "/repo/dist/tui/index.js", + specifier: "@xterm/headless", + }); + + assert.equal( + error.message, + "TUI bundle contains an unresolved runtime import (@xterm/headless): /repo/dist/tui/index.js", + ); + }); }); diff --git a/apps/server/scripts/cliErrors.ts b/apps/server/scripts/cliErrors.ts index d384c745f29..10969d70449 100644 --- a/apps/server/scripts/cliErrors.ts +++ b/apps/server/scripts/cliErrors.ts @@ -68,3 +68,15 @@ export class ServerCliBuildAssetMissingError extends Schema.TaggedErrorClass()( + "ServerCliTuiBundleImportError", + { + assetPath: Schema.String, + specifier: Schema.String, + }, +) { + override get message(): string { + return `TUI bundle contains an unresolved runtime import (${this.specifier}): ${this.assetPath}`; + } +} diff --git a/apps/server/scripts/tuiBundle.test.ts b/apps/server/scripts/tuiBundle.test.ts new file mode 100644 index 00000000000..241ef4944e8 --- /dev/null +++ b/apps/server/scripts/tuiBundle.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { findUnresolvedTuiBundleImport } from "./tuiBundle.ts"; + +describe("findUnresolvedTuiBundleImport", () => { + it("rejects private workspace imports left in the release bundle", () => { + expect(findUnresolvedTuiBundleImport('import { x } from "@t3tools/contracts";')).toBe( + "@t3tools/contracts", + ); + }); + + it("rejects package lookups hidden behind createRequire", () => { + expect( + findUnresolvedTuiBundleImport( + 'NodeModule.createRequire(import.meta.url)("@xterm/headless");', + ), + ).toBe("@xterm/headless"); + }); + + it("allows explicit public and native runtime imports", () => { + expect( + findUnresolvedTuiBundleImport( + 'import { createCliRenderer } from "@opentui/core";\nimport sharp from "sharp";', + ), + ).toBeNull(); + }); +}); diff --git a/apps/server/scripts/tuiBundle.ts b/apps/server/scripts/tuiBundle.ts new file mode 100644 index 00000000000..023fd72f7a6 --- /dev/null +++ b/apps/server/scripts/tuiBundle.ts @@ -0,0 +1,9 @@ +const PRIVATE_WORKSPACE_IMPORT = /(?:from\s+|import\(|require\()\s*["'](@t3tools\/[^"']+)["']/u; +const OPAQUE_PACKAGE_REQUIRE = /createRequire\([^)]*\)\(\s*["']((?:@[^/"']+\/)?[^/"']+)["']\s*\)/u; + +/** Find a package lookup that Bun left unresolved in the staged TUI bundle. */ +export function findUnresolvedTuiBundleImport(source: string): string | null { + return ( + source.match(PRIVATE_WORKSPACE_IMPORT)?.[1] ?? source.match(OPAQUE_PACKAGE_REQUIRE)?.[1] ?? null + ); +} diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index fb753b9aa4b..8f0beb0ad85 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -82,6 +82,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.terminalClear]: AuthTerminalOperateScope, [WS_METHODS.terminalRestart]: AuthTerminalOperateScope, [WS_METHODS.terminalClose]: AuthTerminalOperateScope, + [WS_METHODS.terminalList]: AuthTerminalOperateScope, [WS_METHODS.subscribeTerminalEvents]: AuthTerminalOperateScope, [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalOperateScope, [WS_METHODS.previewOpen]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index ab60749e389..0f4d8b4949d 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -15,6 +15,7 @@ import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; +import { tuiCommand } from "./cli/tui.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); @@ -50,6 +51,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => startCommand, serveCommand, pairCommand, + tuiCommand, authCommand, projectCommand, serviceCommand, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index fe093c451e2..02b0bf89dac 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -77,6 +77,8 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), + getThreadActivitiesPage: () => + Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -186,6 +188,8 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), + getThreadActivitiesPage: () => + Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -270,6 +274,8 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), + getThreadActivitiesPage: () => + Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -339,6 +345,8 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), + getThreadActivitiesPage: () => + Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => @@ -393,6 +401,8 @@ describe("CheckpointDiffQuery.layer", () => { Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("CheckpointDiffQuery should not request the command read model"), + getThreadActivitiesPage: () => + Effect.die("CheckpointDiffQuery should not request thread activities"), getSnapshot: () => Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"), getShellSnapshot: () => diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 38fa3be8bb5..d3a9401fb65 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -31,12 +31,7 @@ import * as Option from "effect/Option"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; import { Command, Flag, GlobalFlag } from "effect/unstable/cli"; -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, - HttpClientResponse, -} from "effect/unstable/http"; +import { FetchHttpClient } from "effect/unstable/http"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerConfig from "../config.ts"; @@ -54,9 +49,12 @@ import { resolveHeadlessConnectionString, } from "../startupAccess.ts"; import { baseDirFlag, DurationFromString } from "./config.ts"; +import { + type EnvironmentProbeResult, + isProcessAlive, + probeEnvironmentDescriptor, +} from "./runningServer.ts"; -const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; -const PAIR_PROBE_TIMEOUT = Duration.millis(2_500); // Tailscale provisions an HTTPS certificate on the first request to a fresh // serve mapping, which can take a few seconds. const TAILSCALE_PROBE_ATTEMPTS = 5; @@ -192,54 +190,6 @@ export const formatPairOutput = (input: { "", ].join("\n"); -/** - * Three outcomes, because they drive different decisions: a T3 descriptor - * (pair with it), nothing answering (safe to configure Tailscale Serve), or - * something answering that is not a T3 server (do NOT overwrite its mapping). - */ -type EnvironmentProbeResult = - | { readonly _tag: "descriptor"; readonly descriptor: ExecutionEnvironmentDescriptor } - | { readonly _tag: "unreachable" } - | { readonly _tag: "not-a-t3-server" }; - -const probeEnvironmentDescriptor = ( - baseUrl: string, -): Effect.Effect => - Effect.gen(function* () { - const client = yield* HttpClient.HttpClient; - const request = HttpClientRequest.get(new URL(WELL_KNOWN_ENVIRONMENT_PATH, baseUrl).toString()); - const response = yield* client.execute(request).pipe( - Effect.timeout(PAIR_PROBE_TIMEOUT), - // Transport failure or timeout: nothing (reachable) is listening there. - Effect.mapError(() => ({ _tag: "unreachable" }) as const), - ); - // Bad-gateway family means a proxy (Tailscale Serve) answered for a - // backend that is gone — a stale mapping, not a live occupant. Treating - // it as unreachable lets `t3 pair --tailscale` repair its own mapping - // after the server's port changed. - if (response.status === 502 || response.status === 503 || response.status === 504) { - return { _tag: "unreachable" } as const; - } - // Anything else that answered HTTP but not with a valid descriptor is - // some other service. - const descriptor = yield* HttpClientResponse.filterStatusOk(response).pipe( - Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)), - Effect.mapError(() => ({ _tag: "not-a-t3-server" }) as const), - ); - return { _tag: "descriptor", descriptor } as const; - }).pipe(Effect.catch((outcome) => Effect.succeed(outcome))); - -// signal 0 delivers nothing; it only reports whether the pid exists. EPERM -// means it exists but belongs to another user, which still counts as alive. -const isProcessAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return error instanceof Error && "code" in error && error.code === "EPERM"; - } -}; - interface DiscoveredPairTarget { readonly baseDir: string; readonly variant: PairStateVariant; diff --git a/apps/server/src/cli/runningServer.test.ts b/apps/server/src/cli/runningServer.test.ts new file mode 100644 index 00000000000..7379ddb83f6 --- /dev/null +++ b/apps/server/src/cli/runningServer.test.ts @@ -0,0 +1,73 @@ +// @effect-diagnostics nodeBuiltinImport:off - integration test owns a loopback HTTP server. +import * as NodeHttp from "node:http"; + +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { FetchHttpClient } from "effect/unstable/http"; + +import type { PersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { isLivePersistedServerRuntimeState } from "./runningServer.ts"; + +const descriptor = { + environmentId: "running-server-test", + label: "running-server-test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: true }, +}; + +const state = (origin: string, pid = process.pid): PersistedServerRuntimeState => ({ + version: 1, + pid, + port: Number(new URL(origin).port), + origin, + startedAt: "2026-08-01T00:00:00.000Z", +}); + +const withServer = (run: (origin: string) => Effect.Effect) => + Effect.acquireUseRelease( + Effect.callback((resume) => { + const server = NodeHttp.createServer((request, response) => { + if (request.url === "/.well-known/t3/environment") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(descriptor)); + return; + } + response.writeHead(404); + response.end(); + }); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => { + const address = server.address(); + if (address === null || typeof address === "string") { + return Effect.die(new Error("Expected a TCP address")); + } + return run(`http://127.0.0.1:${String(address.port)}`); + }, + (server) => Effect.sync(() => server.close()), + ); + +describe("live persisted server validation", () => { + it.effect("accepts a live pid whose origin serves a T3 descriptor", () => + withServer((origin) => + Effect.gen(function* () { + assert.isTrue(yield* isLivePersistedServerRuntimeState(state(origin))); + }), + ).pipe(Effect.provide(FetchHttpClient.layer)), + ); + + it.effect("rejects a dead pid even when the origin was reused by T3", () => + withServer((origin) => + Effect.gen(function* () { + assert.isFalse(yield* isLivePersistedServerRuntimeState(state(origin, 4_194_305))); + }), + ).pipe(Effect.provide(FetchHttpClient.layer)), + ); + + it.effect("rejects a live pid when the recorded origin is unreachable", () => + Effect.gen(function* () { + assert.isFalse(yield* isLivePersistedServerRuntimeState(state("http://127.0.0.1:1"))); + }).pipe(Effect.provide(FetchHttpClient.layer)), + ); +}); diff --git a/apps/server/src/cli/runningServer.ts b/apps/server/src/cli/runningServer.ts new file mode 100644 index 00000000000..6d188bef7ef --- /dev/null +++ b/apps/server/src/cli/runningServer.ts @@ -0,0 +1,54 @@ +import { ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import type { PersistedServerRuntimeState } from "../serverRuntimeState.ts"; + +const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; +const SERVER_PROBE_TIMEOUT = Duration.millis(2_500); + +/** Distinguishes a live T3 server from a dead origin or an unrelated responder. */ +export type EnvironmentProbeResult = + | { readonly _tag: "descriptor"; readonly descriptor: ExecutionEnvironmentDescriptor } + | { readonly _tag: "unreachable" } + | { readonly _tag: "not-a-t3-server" }; + +export const probeEnvironmentDescriptor = Effect.fn("runningServer.probeEnvironmentDescriptor")( + function* (baseUrl: string) { + const client = yield* HttpClient.HttpClient; + const request = HttpClientRequest.get(new URL(WELL_KNOWN_ENVIRONMENT_PATH, baseUrl).toString()); + const response = yield* client.execute(request).pipe( + Effect.timeout(SERVER_PROBE_TIMEOUT), + Effect.mapError(() => ({ _tag: "unreachable" }) as const), + ); + if (response.status === 502 || response.status === 503 || response.status === 504) { + return { _tag: "unreachable" } as const; + } + const descriptor = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)), + Effect.mapError(() => ({ _tag: "not-a-t3-server" }) as const), + ); + return { _tag: "descriptor", descriptor } as const; + }, + Effect.catch((outcome) => Effect.succeed(outcome)), +); + +// Signal 0 delivers nothing; EPERM still proves that the process exists. +export const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + +/** Reject stale runtime files before callers open auth storage or mint credentials. */ +export const isLivePersistedServerRuntimeState = Effect.fn( + "runningServer.isLivePersistedServerRuntimeState", +)(function* (state: PersistedServerRuntimeState) { + if (!isProcessAlive(state.pid)) return false; + const probe = yield* probeEnvironmentDescriptor(state.origin); + return probe._tag === "descriptor"; +}); diff --git a/apps/server/src/cli/tui.ts b/apps/server/src/cli/tui.ts new file mode 100644 index 00000000000..07527d351aa --- /dev/null +++ b/apps/server/src/cli/tui.ts @@ -0,0 +1,223 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; + +import { AuthStandardClientScopes } from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import { Command, GlobalFlag } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { ServerConfig } from "../config.ts"; +import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { authLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { isLivePersistedServerRuntimeState } from "./runningServer.ts"; + +/** Mirror of the server's accepted websocket-ticket query parameter. */ +const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; + +/** Build the `ws(s)://host:port/ws?wsTicket=…` URL from the server origin. */ +function buildSocketUrl(origin: string, ticket: string): string { + const url = new URL(origin); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.pathname = "/ws"; + url.search = new URLSearchParams([[WEBSOCKET_TICKET_QUERY_PARAM, ticket]]).toString(); + return url.toString(); +} + +/** A websocket-url request sent by the Bun TUI child over the IPC channel. */ +interface MintRequest { + readonly type?: string; + readonly id?: number; +} + +/** + * OpenTUI's native renderer reads colour capabilities from the child's real + * environ (COLORTERM for truecolor, a "256color" TERM for the indexed palette), + * and Bun does not propagate `process.env` writes down to it — so the child + * cannot fix this up itself. Terminals that ship their own TERM value (Ghostty's + * `xterm-ghostty`) and sessions that dropped COLORTERM would otherwise render + * with the renderer's baked legacy palette instead of the terminal's theme. + * Mirrors `ensureColorCapabilityEnv` in @t3tools/tui. + */ +const TRUECOLOR_TERMINAL_PATTERN = + /ghostty|kitty|wezterm|alacritty|foot|rio|contour|iterm|vscode|-direct/i; + +export function colorCapabilityEnv(env: NodeJS.ProcessEnv): { readonly COLORTERM?: string } { + if (env.COLORTERM) return {}; + const truecolor = + TRUECOLOR_TERMINAL_PATTERN.test(env.TERM ?? "") || + TRUECOLOR_TERMINAL_PATTERN.test(env.TERM_PROGRAM ?? ""); + return truecolor ? { COLORTERM: "truecolor" } : {}; +} + +/** + * Run the Bun TUI subprocess. The OpenTUI renderer requires Bun, so the Node + * `t3 tui` command (which holds the server's auth) bootstraps a session here and + * spawns `bun `, then answers the child's websocket-ticket requests over + * the Node IPC channel (fd 3). Resolves when the child exits, or immediately with + * a hint if Bun isn't installed. + */ +function runBunTui(input: { + readonly origin: string; + readonly bearerToken: string; + readonly logPath: string; + readonly mintSocketUrl: () => Promise; +}): Promise { + const bunCommand = process.env.T3_TUI_BUN ?? "bun"; + const bundledEntry = NodeURL.fileURLToPath(new URL("./tui/index.js", import.meta.url)); + const unpackedEntry = bundledEntry.replace(/app\.asar([\\/])/, "app.asar.unpacked$1"); + const workspaceEntry = NodeURL.fileURLToPath( + new URL("../../../tui/dist/index.js", import.meta.url), + ); + const tuiEntry = NodeFS.existsSync(unpackedEntry) + ? unpackedEntry + : NodeFS.existsSync(bundledEntry) + ? bundledEntry + : workspaceEntry; + + return new Promise((resolve) => { + const child = NodeChildProcess.spawn(bunCommand, [tuiEntry], { + stdio: ["inherit", "inherit", "inherit", "ipc"], + env: { + ...process.env, + ...colorCapabilityEnv(process.env), + T3_TUI_ORIGIN: input.origin, + T3_TUI_BEARER: input.bearerToken, + T3_TUI_LOG: input.logPath, + }, + }); + + child.on("message", (message: MintRequest) => { + if (message.type !== "mintSocketUrl" || typeof message.id !== "number") return; + const id = message.id; + input + .mintSocketUrl() + .then((url) => { + if (child.connected) child.send({ type: "socketUrl", id, url }); + }) + .catch((error: unknown) => { + if (child.connected) { + child.send({ type: "socketUrl", id, url: null, error: String(error) }); + } + }); + }); + + child.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") { + process.stderr.write( + "`t3 tui` needs Bun to run its terminal UI. Install it from https://bun.sh " + + "(or set T3_TUI_BUN to a bun binary).\n", + ); + } else { + process.stderr.write(`t3 tui: failed to start Bun: ${error.message}\n`); + } + resolve(); + }); + + child.on("close", () => resolve()); + }); +} + +export const tuiCommand = Command.make("tui", { ...authLocationFlags }).pipe( + Command.withDescription( + "Open a terminal UI for the running local T3 Code server (requires Bun; no port forwarding).", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + yield* Console.error( + "No running T3 Code server was found. Start one with `t3 serve` (or `t3 start`) first.", + ); + return; + } + if (!(yield* isLivePersistedServerRuntimeState(runtimeState.value))) { + yield* Console.error( + "The recorded T3 Code server is no longer running. Start it with `t3 serve` (or `t3 start`) first.", + ); + return; + } + const origin = runtimeState.value.origin; + + // The TUI runs in a separate Bun process and never touches the server's + // auth internals. We do the loopback bootstrap here: issue one long-lived + // bearer session, then answer the child's per-connect websocket-ticket + // requests over the IPC channel. + const authRuntime = ManagedRuntime.make( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provideMerge(Layer.succeed(ServerConfig, config)), + Layer.provideMerge(NodeServices.layer), + ), + ); + + // Track the issued session so the ensuring below can revoke it and dispose + // the runtime even if issueSession itself fails — otherwise the runtime + // (and its DB/secret resources) would leak. + let issuedSession: EnvironmentAuth.IssuedBearerSession | null = null; + + yield* Effect.gen(function* () { + const session = yield* Effect.promise(() => + authRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + return yield* auth.issueSession({ + scopes: AuthStandardClientScopes, + subject: "t3-tui", + label: "T3 Code TUI", + ttl: Duration.days(30), + }); + }), + ), + ); + issuedSession = session; + + const mintSocketUrl = () => + authRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const result = yield* auth.issueWebSocketTicket({ sessionId: session.sessionId }); + return buildSocketUrl(origin, result.ticket); + }), + ); + + yield* Effect.promise(() => + runBunTui({ + origin, + bearerToken: session.token, + logPath: `${config.serverRuntimeStatePath}.tui.log`, + mintSocketUrl, + }), + ); + }).pipe( + Effect.ensuring( + Effect.promise(async () => { + const session = issuedSession; + if (session) { + // Best-effort: don't leave a 30-day session valid after the TUI quits. + await authRuntime + .runPromise( + Effect.gen(function* () { + const auth = yield* EnvironmentAuth.EnvironmentAuth; + yield* auth.revokeSession(session.sessionId); + }), + ) + .catch(() => {}); + } + await authRuntime.dispose(); + }), + ), + ); + }).pipe(Effect.provide(FetchHttpClient.layer)), + ), +); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 5c5da4666b0..953feb49cf4 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -27,6 +27,7 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), @@ -57,6 +58,7 @@ const makeTerminalManagerLayer = ( clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, + list: () => Effect.die(new Error("unused")), subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 569e8a51c37..a6395d13123 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4627,6 +4627,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { path.join(workspaceDir, "src", "index.ts"), "export const answer = 42;\n", ); + yield* fs.writeFile(path.join(workspaceDir, "diagram.png"), Uint8Array.from([0, 1, 2, 255])); yield* buildAppUnderTest(); @@ -4639,6 +4640,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cwd: workspaceDir, relativePath: "src/index.ts", }), + binary: client[WS_METHODS.projectsReadFile]({ + cwd: workspaceDir, + relativePath: "diagram.png", + encoding: "base64", + }), }), ), ); @@ -4650,6 +4656,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { byteLength: 26, truncated: false, }); + assert.deepEqual(response.binary, { + relativePath: "diagram.png", + contents: "AAEC/w==", + byteLength: 4, + truncated: false, + }); }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 1cf7e8dffec..f46bcf09378 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -655,6 +655,23 @@ it.layer( }), ); + it.effect("lists persisted terminal histories that have not been attached since restart", () => + Effect.gen(function* () { + const { manager, logsDir } = yield* createManager(); + const primaryHistory = yield* historyLogPath(logsDir); + const secondHistory = yield* multiTerminalHistoryLogPath(logsDir, "thread-1", "term-2"); + const otherThreadHistory = yield* historyLogPath(logsDir, "thread-2"); + yield* writeFileString(primaryHistory, "old primary history\n"); + yield* writeFileString(secondHistory, "old second history\n"); + yield* writeFileString(otherThreadHistory, "other thread\n"); + yield* manager.open(openInput({ terminalId: "term-3" })); + + const result = yield* manager.list({ threadId: "thread-1" }); + + expect(result.terminalIds).toEqual(["term-1", "term-2", "term-3"]); + }), + ); + it.effect("clears transcript and emits cleared event", () => Effect.gen(function* () { const { manager, ptyAdapter, logsDir, getEvents } = yield* createManager(); @@ -1531,6 +1548,42 @@ it.layer( }), ); + it.effect("publishes removal when closing a persisted terminal without a live session", () => + Effect.gen(function* () { + const { manager } = yield* createManager(); + yield* manager.open(openInput({ threadId: "persisted-thread" })); + yield* manager.close({ + threadId: "persisted-thread", + terminalId: DEFAULT_TERMINAL_ID, + }); + + const metadataEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.subscribeMetadata((event) => + Ref.update(metadataEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + yield* Ref.set(metadataEvents, []); + + yield* manager.close({ + threadId: "persisted-thread", + terminalId: DEFAULT_TERMINAL_ID, + deleteHistory: true, + }); + + yield* waitFor( + Effect.map(Ref.get(metadataEvents), (events) => + events.some( + (event) => + event.type === "remove" && + event.threadId === "persisted-thread" && + event.terminalId === DEFAULT_TERMINAL_ID, + ), + ), + "1200 millis", + ); + }), + ); + it.effect("removes terminal metadata subscriptions when initial delivery fails", () => Effect.gen(function* () { const { manager } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index caa5106bb9f..a4b72bcc41a 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -23,6 +23,7 @@ import { type TerminalClearInput, type TerminalCloseInput, type TerminalEvent, + type TerminalListResult, type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalResizeInput, @@ -30,6 +31,7 @@ import { type TerminalSessionSnapshot, type TerminalSessionStatus, type TerminalSummary, + type TerminalThreadInput, type TerminalWriteInput, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; @@ -46,6 +48,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; @@ -168,6 +171,14 @@ export class TerminalManager extends Context.Service< */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + /** + * List live and persisted terminal identities for one thread. + * + * Persisted history remains a terminal instance across server restarts even + * before a client attaches it back into the in-memory session manager. + */ + readonly list: (input: TerminalThreadInput) => Effect.Effect; + /** * Subscribe to terminal runtime events with a direct callback. * @@ -1122,6 +1133,12 @@ function createTerminalSpawnEnv( if (shouldExcludeTerminalEnvKey(key)) continue; spawnEnv[key] = value; } + // The PTY always feeds a headless xterm.js emulator, so TERM/COLORTERM must + // describe that emulator — not whatever terminal (if any) launched the server + // daemon. Without COLORTERM in particular, truecolor-capable programs inside + // the terminal silently quantise their colours to the 256-colour cube. + spawnEnv.TERM = "xterm-256color"; + spawnEnv.COLORTERM = "truecolor"; if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { spawnEnv[key] = value; @@ -1233,6 +1250,28 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const legacyHistoryPath = (threadId: string) => path.join(logsDir, `${legacySafeThreadId(threadId)}.log`); + const listPersistedTerminalIds = Effect.fn("terminal.listPersistedTerminalIds")(function* ( + threadId: string, + ) { + const threadPart = toSafeThreadId(threadId); + const terminalPrefix = `${threadPart}_`; + const entries = yield* fileSystem + .readDirectory(logsDir, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as Array)); + const ids = new Set(); + for (const name of entries) { + if (name === `${threadPart}.log` || name === `${legacySafeThreadId(threadId)}.log`) { + ids.add(DEFAULT_TERMINAL_ID); + continue; + } + if (!name.startsWith(terminalPrefix) || !name.endsWith(".log")) continue; + const encoded = name.slice(terminalPrefix.length, -".log".length); + const terminalId = Result.getOrUndefined(Encoding.decodeBase64UrlString(encoded)); + if (terminalId) ids.add(terminalId); + } + return ids; + }); + const readManagerState = SynchronizedRef.get(managerStateRef); const modifyManagerState = ( @@ -1983,6 +2022,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ) { const key = toSessionKey(threadId, terminalId); const session = yield* getSession(threadId, terminalId); + const persisted = (yield* listPersistedTerminalIds(threadId)).has(terminalId); const closedEventSequence = Option.isSome(session) ? session.value.eventSequence + 1 : 0; if (Option.isSome(session)) { @@ -2002,7 +2042,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return [true, { ...state, sessions }] as const; }); - if (removed) { + // An evicted/inactive terminal can still exist as persisted history and be + // visible in clients returned by terminal.list. Its explicit close must + // publish the same removal event as an in-memory session so every client + // drops the tab immediately. + if (removed || persisted) { yield* publishEvent({ type: "closed", threadId, @@ -2333,6 +2377,20 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), ); + const list: TerminalManager["Service"]["list"] = Effect.fn("terminal.list")(function* (input) { + const ids = yield* listPersistedTerminalIds(input.threadId); + const sessions = yield* sessionsForThread(input.threadId); + for (const session of sessions) ids.add(session.terminalId); + return { + terminalIds: [...ids].sort((left, right) => { + const leftNumber = /^term-(\d+)$/.exec(left); + const rightNumber = /^term-(\d+)$/.exec(right); + if (leftNumber && rightNumber) return Number(leftNumber[1]) - Number(rightNumber[1]); + return left.localeCompare(right); + }), + }; + }); + const readTerminalMetadata = (input: { readonly threadId: string; readonly terminalId: string; @@ -2648,6 +2706,17 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func { discard: true }, ); + // Close persisted-only terminals too (advertised by terminal.list but + // not live in memory) so every client that hydrated its tab strip from + // the list receives their `closed` events before any history deletion. + const liveTerminalIds = new Set(threadSessions.map((session) => session.terminalId)); + const persistedTerminalIds = yield* listPersistedTerminalIds(input.threadId); + yield* Effect.forEach( + [...persistedTerminalIds].filter((terminalId) => !liveTerminalIds.has(terminalId)), + (terminalId) => closeSession(input.threadId, terminalId, false), + { discard: true }, + ); + if (input.deleteHistory) { yield* deleteAllHistoryForThread(input.threadId); } @@ -2662,6 +2731,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func clear, restart, close, + list, subscribe, subscribeMetadata, }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993..49bb54c5850 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,4 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -166,6 +167,79 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("returns binary workspace files as base64 when explicitly requested", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* fileSystem.writeFile( + path.join(cwd, "image.png"), + Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0, 0xff]), + ); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: "image.png", + encoding: "base64", + }); + + expect(result).toEqual({ + relativePath: "image.png", + contents: "iVBORwD/", + byteLength: 6, + truncated: false, + }); + }), + ); + + it.effect("rejects base64 reads of non-image paths", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const absolutePath = path.join(cwd, "database.sqlite"); + yield* fileSystem.writeFile(absolutePath, Uint8Array.from([0x53, 0x51, 0x4c, 0])); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "database.sqlite", encoding: "base64" }) + .pipe(Effect.flip); + const resolvedPath = yield* fileSystem.realPath(absolutePath); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "database.sqlite", + resolvedPath, + }); + }), + ); + + it.effect("rejects oversized base64 reads without returning a partial payload", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const byteLength = PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1; + yield* fileSystem.writeFile(path.join(cwd, "large.png"), new Uint8Array(byteLength)); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: "large.png", + encoding: "base64", + }); + + expect(result).toEqual({ + relativePath: "large.png", + contents: "", + byteLength, + truncated: true, + }); + }), + ); + it.effect("preserves the real cause and path for I/O failures", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb39..a0ee6e43bd2 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -15,6 +15,7 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "@t3tools/contracts"; +import { chatImageMimeTypeForPath, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -213,7 +214,26 @@ export const make = Effect.gen(function* () { }); } - const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); + const encoding = input.encoding ?? "utf8"; + // Base64 reads exist for the image-attach flow only. Gate them to + // image extensions so the raised 10MB cap and the skipped binary + // guard below cannot become an arbitrary-binary read primitive. + if (encoding === "base64" && chatImageMimeTypeForPath(input.relativePath) === null) { + return yield* new WorkspaceBinaryFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + }); + } + const maxBytes = + encoding === "base64" + ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + : PROJECT_READ_FILE_MAX_BYTES; + // Oversized binary payloads cannot be attached. Return only their + // metadata so a remote client can reject them without first moving a + // max-sized base64 response over the WebSocket. + const bytesToRead = + encoding === "base64" && stat.size > maxBytes ? 0 : Math.min(stat.size, maxBytes); const buffer = Buffer.alloc(bytesToRead); const { bytesRead } = yield* Effect.tryPromise({ try: () => handle.read(buffer, 0, bytesToRead, 0), @@ -228,7 +248,7 @@ export const make = Effect.gen(function* () { }), }); const fileBytes = buffer.subarray(0, bytesRead); - if (fileBytes.includes(0)) { + if (encoding === "utf8" && fileBytes.includes(0)) { return yield* new WorkspaceBinaryFileError({ workspaceRoot: input.cwd, relativePath: input.relativePath, @@ -238,9 +258,12 @@ export const make = Effect.gen(function* () { return { relativePath: target.relativePath, - contents: new TextDecoder("utf-8").decode(fileBytes), + contents: + encoding === "base64" + ? fileBytes.toString("base64") + : new TextDecoder("utf-8").decode(fileBytes), byteLength: stat.size, - truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, + truncated: stat.size > maxBytes, }; }), (handle) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 909a51a4cf5..b4bb503e10f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1889,6 +1889,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { "rpc.aggregate": "terminal", }), + [WS_METHODS.terminalList]: (input) => + observeRpcEffect(WS_METHODS.terminalList, terminalManager.list(input), { + "rpc.aggregate": "terminal", + }), [WS_METHODS.subscribeTerminalEvents]: (_input) => observeRpcStream( WS_METHODS.subscribeTerminalEvents, diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 521654f3279..e514c7c6f54 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -26,7 +26,7 @@ export default mergeConfig( tasks: { build: { command: "node scripts/cli.ts build", - dependsOn: ["@t3tools/web#build"], + dependsOn: ["@t3tools/web#build", "@t3tools/tui#build"], cache: false, }, }, diff --git a/apps/tui/package.json b/apps/tui/package.json new file mode 100644 index 00000000000..1ec3390bdff --- /dev/null +++ b/apps/tui/package.json @@ -0,0 +1,40 @@ +{ + "name": "@t3tools/tui", + "version": "0.0.27", + "private": true, + "files": [ + "dist" + ], + "type": "module", + "exports": { + ".": { + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "bun build src/index.tsx --target bun --external @opentui/core --external sharp --outfile dist/index.js", + "typecheck": "tsgo --noEmit", + "test": "bun test" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "@opentui/core": "^0.4.1", + "@opentui/react": "^0.4.1", + "@t3tools/client-runtime": "workspace:*", + "@t3tools/contracts": "workspace:*", + "@t3tools/opentui-image": "workspace:*", + "@t3tools/shared": "workspace:*", + "@xterm/headless": "^5.5.0", + "effect": "catalog:", + "react": "19.2.6" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/node": "catalog:", + "@types/react": "~19.2.14", + "vite-plus": "catalog:" + }, + "engines": { + "bun": ">=1.3.0" + } +} diff --git a/apps/tui/src/approvals.test.ts b/apps/tui/src/approvals.test.ts new file mode 100644 index 00000000000..a3d71ee556a --- /dev/null +++ b/apps/tui/src/approvals.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "bun:test"; + +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { derivePendingApprovals } from "./approvals.ts"; + +/** Minimal activity fixture — only the fields derivePendingApprovals reads. */ +const activity = ( + kind: string, + payload: Record, + sequence: number, +): OrchestrationThreadActivity => + ({ + kind, + payload, + sequence, + createdAt: new Date(Date.UTC(2020, 0, 1, 0, 0, sequence)).toISOString(), + }) as unknown as OrchestrationThreadActivity; + +describe("derivePendingApprovals", () => { + it("Given an approval.requested with no matching resolved, then it stays open", () => { + const open = derivePendingApprovals([ + activity("approval.requested", { requestId: "r1", requestKind: "command", detail: "ls" }, 1), + ]); + expect(open).toHaveLength(1); + expect(open[0]).toMatchObject({ requestId: "r1", requestKind: "command", detail: "ls" }); + }); + + it("Given a request then a matching approval.resolved, then it is closed", () => { + const open = derivePendingApprovals([ + activity("approval.requested", { requestId: "r1", requestKind: "command" }, 1), + activity("approval.resolved", { requestId: "r1" }, 2), + ]); + expect(open).toHaveLength(0); + }); + + it("Given a request then a TRANSIENT respond.failed, then it stays open for retry", () => { + // A network blip / provider hiccup leaves the request open server-side; the + // prompt must stay visible so the user can respond again (matches web). + const open = derivePendingApprovals([ + activity("approval.requested", { requestId: "r1", requestKind: "command" }, 1), + activity( + "provider.approval.respond.failed", + { requestId: "r1", detail: "connection reset by peer" }, + 2, + ), + ]); + expect(open).toHaveLength(1); + }); + + it("Given a request then a STALE-request respond.failed, then it is closed", () => { + const open = derivePendingApprovals([ + activity("approval.requested", { requestId: "r1", requestKind: "command" }, 1), + activity( + "provider.approval.respond.failed", + { requestId: "r1", detail: "Unknown pending approval request: r1" }, + 2, + ), + ]); + expect(open).toHaveLength(0); + }); + + it("Given two open requests, then both are returned in creation order", () => { + const open = derivePendingApprovals([ + activity("approval.requested", { requestId: "r2", requestKind: "file-change" }, 2), + activity("approval.requested", { requestId: "r1", requestKind: "command" }, 1), + ]); + expect(open.map((a) => a.requestId)).toEqual(["r1", "r2"]); + }); +}); diff --git a/apps/tui/src/approvals.ts b/apps/tui/src/approvals.ts new file mode 100644 index 00000000000..41d87b1c000 --- /dev/null +++ b/apps/tui/src/approvals.ts @@ -0,0 +1,69 @@ +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; + +import { isStalePendingRequestFailureDetail } from "./staleRequest.ts"; + +export interface PendingApproval { + readonly requestId: string; + readonly requestKind: string; + readonly detail?: string; + readonly createdAt: string; +} + +/** + * Derive the still-open approval requests for a thread from its activity log. + * Mirrors the web client's logic: an `approval.requested` activity opens a + * request, and a later `approval.resolved` (or stale-request failure) closes + * it. Kept intentionally small — the TUI only needs requestId + a label. + */ +export function derivePendingApprovals( + activities: ReadonlyArray, +): PendingApproval[] { + const open = new Map(); + const ordered = [...activities].sort((a, b) => { + const sa = a.sequence ?? Number.MAX_SAFE_INTEGER; + const sb = b.sequence ?? Number.MAX_SAFE_INTEGER; + if (sa !== sb) return sa - sb; + return a.createdAt.localeCompare(b.createdAt); + }); + + for (const activity of ordered) { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + const requestId = + payload && typeof payload.requestId === "string" ? payload.requestId : null; + + if (activity.kind === "approval.requested" && requestId) { + const requestKind = + payload && typeof payload.requestKind === "string" + ? payload.requestKind + : "approval"; + const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; + open.set(requestId, { + requestId, + requestKind, + createdAt: activity.createdAt, + ...(detail ? { detail } : {}), + }); + continue; + } + + if (requestId && activity.kind === "approval.resolved") { + open.delete(requestId); + continue; + } + + // A respond failure only closes the request when the provider reports it + // stale/unknown — a transient failure (network blip) leaves it open so the + // user can retry, matching the web derivation. + if (requestId && activity.kind === "provider.approval.respond.failed") { + const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; + if (isStalePendingRequestFailureDetail(detail)) { + open.delete(requestId); + } + } + } + + return [...open.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); +} diff --git a/apps/tui/src/attachmentImages.test.ts b/apps/tui/src/attachmentImages.test.ts new file mode 100644 index 00000000000..f08b4691e72 --- /dev/null +++ b/apps/tui/src/attachmentImages.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test"; + +import { createAttachmentImageCache } from "./attachmentImages.ts"; + +const IMAGE = { + data: new Uint8Array([1, 2, 3, 255]), + imageWidth: 1, + imageHeight: 1, +}; + +describe("attachment image cache", () => { + it("deduplicates concurrent downloads and decoded previews", async () => { + let fetchCount = 0; + let decodeCount = 0; + const cache = createAttachmentImageCache({ + fetcher: async () => { + fetchCount += 1; + return new Response(new Uint8Array([1, 2, 3])); + }, + decoder: async () => { + decodeCount += 1; + return IMAGE; + }, + }); + + const [first, second] = await Promise.all([ + cache.load("attachment-1", "https://example.test/one"), + cache.load("attachment-1", "https://example.test/one"), + ]); + + expect(first).toBe(IMAGE); + expect(second).toBe(IMAGE); + expect(fetchCount).toBe(1); + expect(decodeCount).toBe(1); + }); + + it("does not permanently cache transient failures", async () => { + let fetchCount = 0; + const cache = createAttachmentImageCache({ + fetcher: async () => { + fetchCount += 1; + return fetchCount === 1 + ? new Response(null, { status: 503 }) + : new Response(new Uint8Array([1])); + }, + decoder: async () => IMAGE, + }); + + expect(await cache.load("attachment-1", "https://example.test/one")).toBeNull(); + expect(await cache.load("attachment-1", "https://example.test/two")).toBe(IMAGE); + expect(fetchCount).toBe(2); + }); + + it("rejects oversized responses before decoding", async () => { + let decoded = false; + const cache = createAttachmentImageCache({ + maxEncodedBytes: 4, + fetcher: async () => + new Response(new Uint8Array([1, 2, 3, 4, 5]), { + headers: { "content-length": "5" }, + }), + decoder: async () => { + decoded = true; + return IMAGE; + }, + }); + + expect(await cache.load("attachment-1", "https://example.test/large")).toBeNull(); + expect(decoded).toBe(false); + }); + + it("passes a bounded abort signal to attachment downloads", async () => { + const cache = createAttachmentImageCache({ + fetchTimeoutMs: 1, + fetcher: (_url, signal) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + decoder: async () => IMAGE, + }); + + expect(await cache.load("attachment-1", "https://example.test/one")).toBeNull(); + }); +}); diff --git a/apps/tui/src/attachmentImages.ts b/apps/tui/src/attachmentImages.ts new file mode 100644 index 00000000000..40fe658af3b --- /dev/null +++ b/apps/tui/src/attachmentImages.ts @@ -0,0 +1,83 @@ +// @effect-diagnostics globalFetch:off +import { decodeImage, type RgbaImage } from "@t3tools/opentui-image"; +import { PROVIDER_SEND_TURN_MAX_IMAGE_BYTES } from "@t3tools/contracts"; + +const DEFAULT_CACHE_ENTRIES = 24; +const DEFAULT_FETCH_TIMEOUT_MS = 10_000; +const PREVIEW_MAX_WIDTH = 720; +const PREVIEW_MAX_HEIGHT = 480; + +export interface AttachmentImageCache { + readonly load: (attachmentId: string, url: string) => Promise; + readonly clear: () => void; +} + +export interface AttachmentImageCacheOptions { + readonly fetcher?: (url: string, signal: AbortSignal) => Promise; + readonly decoder?: (encoded: Uint8Array) => Promise; + readonly maxEntries?: number; + readonly maxEncodedBytes?: number; + readonly fetchTimeoutMs?: number; +} + +export function createAttachmentImageCache( + options: AttachmentImageCacheOptions = {}, +): AttachmentImageCache { + const fetcher = + options.fetcher ?? ((url: string, signal: AbortSignal) => globalThis.fetch(url, { signal })); + const decoder = + options.decoder ?? + ((encoded: Uint8Array) => + decodeImage(encoded, { maxWidth: PREVIEW_MAX_WIDTH, maxHeight: PREVIEW_MAX_HEIGHT })); + const maxEntries = options.maxEntries ?? DEFAULT_CACHE_ENTRIES; + const maxEncodedBytes = options.maxEncodedBytes ?? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES; + const fetchTimeoutMs = options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; + assertPositiveInteger(maxEntries, "maxEntries"); + assertPositiveInteger(maxEncodedBytes, "maxEncodedBytes"); + assertPositiveInteger(fetchTimeoutMs, "fetchTimeoutMs"); + const cache = new Map>(); + + const load = (attachmentId: string, url: string): Promise => { + const existing = cache.get(attachmentId); + if (existing) { + cache.delete(attachmentId); + cache.set(attachmentId, existing); + return existing; + } + + const pending = (async () => { + try { + const response = await fetcher(url, AbortSignal.timeout(fetchTimeoutMs)); + if (!response.ok) return null; + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > maxEncodedBytes) return null; + const encoded = new Uint8Array(await response.arrayBuffer()); + if (encoded.byteLength === 0 || encoded.byteLength > maxEncodedBytes) return null; + return await decoder(encoded); + } catch { + return null; + } + })(); + cache.set(attachmentId, pending); + while (cache.size > maxEntries) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } + void pending.then((image) => { + if (image === null && cache.get(attachmentId) === pending) cache.delete(attachmentId); + }); + return pending; + }; + + return { + load, + clear: () => cache.clear(), + }; +} + +function assertPositiveInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new RangeError(`${name} must be a positive safe integer`); + } +} diff --git a/apps/tui/src/commands.test.ts b/apps/tui/src/commands.test.ts new file mode 100644 index 00000000000..b7e2b5b0df5 --- /dev/null +++ b/apps/tui/src/commands.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "bun:test"; + +import { type Command, filterCommands } from "./commands.ts"; + +const noop = () => {}; +const cmd = (id: string, title: string, keywords?: string): Command => + keywords ? { id, title, run: noop, keywords } : { id, title, run: noop }; + +const commands: ReadonlyArray = [ + cmd("new", "New thread"), + cmd("plan", "Toggle plan/build mode", "interaction"), + cmd("rename", "Rename thread"), + cmd("archive", "Archive thread"), + cmd("pr", "Push & create PR", "git commit"), +]; + +describe("filterCommands", () => { + it("Given an empty query, then it returns every command unchanged", () => { + expect(filterCommands(commands, "").map((c) => c.id)).toEqual([ + "new", + "plan", + "rename", + "archive", + "pr", + ]); + }); + + it("Given a query, then prefix matches rank before substring matches", () => { + // "thread" is a substring of New/Rename/Archive thread; a title that starts + // with the query would win, but here all are substring matches — original + // order is preserved. + const ids = filterCommands(commands, "thread").map((c) => c.id); + expect(ids).toEqual(["new", "rename", "archive"]); + }); + + it("Given a keyword-only match, then the command still appears", () => { + expect(filterCommands(commands, "git").map((c) => c.id)).toEqual(["pr"]); + }); + + it("Given a title-prefix query, then it ranks first", () => { + const ids = filterCommands(commands, "rename").map((c) => c.id); + expect(ids[0]).toBe("rename"); + }); + + it("Given a subsequence query, then it still matches (fuzzy)", () => { + // "ahd" is a subsequence of "Arc[h]ive threa[d]"? a-r-c-h-i-v-e... "ahd": + // a(rchive) h(?) — use a clearer one: "nwthrd" ⊆ "new thread". + expect(filterCommands(commands, "nwthrd").map((c) => c.id)).toContain("new"); + }); + + it("Given a non-matching query, then nothing is returned", () => { + expect(filterCommands(commands, "zzzz")).toEqual([]); + }); +}); diff --git a/apps/tui/src/commands.ts b/apps/tui/src/commands.ts new file mode 100644 index 00000000000..a7709fd2c36 --- /dev/null +++ b/apps/tui/src/commands.ts @@ -0,0 +1,52 @@ +// The command-palette model (mirrors the web CommandPalette): a flat list of +// runnable commands plus a pure fuzzy filter. ChatView builds the list from its +// handlers + current context; the palette renders the filtered result. Keeping +// the filter pure makes the ranking unit-testable without a renderer. + +export interface Command { + readonly id: string; + readonly title: string; + /** A shortcut hint shown on the right, e.g. "^N". */ + readonly hint?: string; + /** Extra search terms (synonyms) not shown but matched. */ + readonly keywords?: string; + readonly run: () => void; +} + +function subsequenceMatch(query: string, text: string): boolean { + let index = 0; + for (const char of text) { + if (char === query[index]) index += 1; + if (index === query.length) return true; + } + return query.length === 0; +} + +/** + * Rank commands against a query: title prefix > title substring > keyword + * substring > subsequence, preserving the original order within a tier. An + * empty query returns every command unchanged. + */ +export function filterCommands( + commands: ReadonlyArray, + query: string, +): Command[] { + const q = query.trim().toLowerCase(); + if (q.length === 0) return [...commands]; + + const scored: Array<{ command: Command; score: number; order: number }> = []; + commands.forEach((command, order) => { + const title = command.title.toLowerCase(); + const keywords = command.keywords?.toLowerCase() ?? ""; + let score: number; + if (title.startsWith(q)) score = 0; + else if (title.includes(q)) score = 1; + else if (keywords.includes(q)) score = 2; + else if (subsequenceMatch(q, title)) score = 3; + else return; + scored.push({ command, score, order }); + }); + + scored.sort((a, b) => a.score - b.score || a.order - b.order); + return scored.map((entry) => entry.command); +} diff --git a/apps/tui/src/components/AddProjectOverlay.tsx b/apps/tui/src/components/AddProjectOverlay.tsx new file mode 100644 index 00000000000..cdaea67a3fa --- /dev/null +++ b/apps/tui/src/components/AddProjectOverlay.tsx @@ -0,0 +1,153 @@ +import * as React from "react"; + +import { clip } from "../format.ts"; +import { usePalette } from "../theme.ts"; + +export interface AddProjectRow { + readonly id: string; + readonly title: string; + readonly description?: string; + readonly disabled?: boolean; +} + +export type AddProjectStatus = "ready" | "loading" | "empty" | "error"; + +export const AddProjectOverlay = React.memo(function AddProjectOverlay({ + title, + query, + placeholder, + inputFocused, + rows, + selectedIndex, + status, + width, + maxRows, + context, + actionLabel, + emptyMessage, + onInput, + onFocusInput, + onAction, + onActivate, +}: { + readonly title: string; + readonly query: string; + readonly placeholder: string; + readonly inputFocused: boolean; + readonly rows: ReadonlyArray; + readonly selectedIndex: number; + readonly status: AddProjectStatus; + readonly width: number; + readonly maxRows: number; + readonly context?: { readonly title: string; readonly description: string } | null; + readonly actionLabel: string; + readonly emptyMessage: string; + readonly onInput: (value: string) => void; + readonly onFocusInput: () => void; + readonly onAction: () => void; + readonly onActivate: (index: number) => void; +}): React.ReactNode { + const palette = usePalette(); + const labelRoom = Math.max(8, width - 8); + const contextRows = context ? 3 : 0; + const windowSize = Math.max(1, Math.floor((maxRows - contextRows - 3) / 2)); + const start = Math.min( + Math.max(0, selectedIndex - Math.floor(windowSize / 2)), + Math.max(0, rows.length - windowSize), + ); + const visibleRows = rows.slice(start, start + windowSize); + + return ( + + + + {"+ "} + + {inputFocused ? ( + + ) : ( + + + 0 ? palette.text : palette.dim}> + {clip(query.length > 0 ? query : placeholder, labelRoom)} + + + + )} + + + {" "} + {actionLabel} + + + + + {`${title} ▸ `} + + {inputFocused + ? "Enter action · Tab browse · Esc back" + : "↑/↓ navigate · Enter select · Tab edit · Esc back"} + + + {context ? ( + + Repository + {clip(context.title, labelRoom)} + {clip(context.description, labelRoom)} + + ) : null} + {status === "loading" ? ( + loading… + ) : status === "error" ? ( + failed to load + ) : status === "empty" || rows.length === 0 ? ( + {emptyMessage} + ) : ( + visibleRows.map((row, offset) => { + const index = start + offset; + const active = index === selectedIndex; + return ( + { + if (!row.disabled) onActivate(index); + }} + {...(active ? { backgroundColor: palette.selectedBg } : {})} + > + + {active ? "▸ " : " "} + + {clip(row.title, labelRoom)} + + {row.disabled ? {" setup required"} : null} + + {row.description ? ( + + {` ${clip(row.description, labelRoom)}`} + + ) : null} + + ); + }) + )} + + ); +}); diff --git a/apps/tui/src/components/ChatComposer.test.tsx b/apps/tui/src/components/ChatComposer.test.tsx new file mode 100644 index 00000000000..fca3c3f6ed4 --- /dev/null +++ b/apps/tui/src/components/ChatComposer.test.tsx @@ -0,0 +1,392 @@ +import { describe, expect, it } from "bun:test"; +import * as React from "react"; +import { testRender } from "@opentui/react/test-utils"; + +import { ChatComposer } from "./ChatComposer.tsx"; + +// Component specs for the composer, exercised through OpenTUI's real (headless) +// renderer under bun:test. They lock in the double-input fix (no while the +// terminal holds focus) and the auxiliary rename/filter/commit surfaces. + +const noop = () => {}; + +const base = { + reply: "", + auxValue: "", + placeholder: "Type a reply, Enter to send", + editorRows: 3, + composerEpoch: 0, + controls: { + interactionMode: "default", + runtimeMode: "full-access", + model: "gpt-5", + reasoning: "high", + }, + working: false, + attachments: [], + inlineImagesSupported: false, + width: 56, + pendingUserInput: null, + uiQuestionIndex: 0, + uiOptionIndex: 0, + uiSelectedLabels: [], + answerDraft: "", + onAnswerInput: noop, + onFocusInput: noop, + onReplyInput: noop, + onReplySubmit: noop, + onAuxInput: noop, + onTogglePlan: noop, + onOpenAccess: noop, + onOpenModel: noop, + onOpenReasoning: noop, + onStop: noop, + onSend: noop, + onSubmitAnswer: noop, + onRemoveAttachment: noop, + onPasteImage: noop, + onPasteImagePath: () => null, +} as const; + +async function frameOf(node: React.ReactNode): Promise { + const t = await testRender(node, { width: 60, height: 12 }); + await t.renderOnce(); + const frame = t.captureCharFrame(); + t.renderer.destroy(); + return frame; +} + +describe("ChatComposer", () => { + it("Given compose mode without focus, when rendered, then it shows the static prompt shortcut (no input)", async () => { + const frame = await frameOf(); + expect(frame).toContain("^P prompt · Type a reply"); + }); + + it("Given compose mode with focus, when rendered, then the ^P hint is gone (input is mounted)", async () => { + const frame = await frameOf(); + expect(frame).not.toContain("^P prompt"); + }); + + it("Given a pending question, then the composer stays put with the question panel + Submit answer", async () => { + const pending = { + requestId: "r1", + createdAt: "2026-06-19T00:00:00.000Z", + questions: [ + { + id: "q1", + header: "Scope", + question: "Which scope should the plan target?", + options: [ + { label: "Both", description: "" }, + { label: "Data only", description: "" }, + ], + multiSelect: false, + }, + ], + } as never; + const t = await testRender( + , + { width: 80, height: 12 }, + ); + await t.renderOnce(); + await t.flush(); + const frame = t.captureCharFrame(); + // Question panel + custom-answer field + the Submit-answer primary action, + // all inside the still-present composer (with its model footer). + expect(frame).toContain("Which scope should the plan target?"); + expect(frame).toContain("Type your own answer"); + expect(frame).toContain("Submit answer"); + expect(frame).toContain("model gpt-5"); + t.renderer.destroy(); + }); + + it("Given compose mode, then the controls render inside the composer box, model first", async () => { + const t = await testRender(, { + width: 72, + height: 8, + }); + await t.renderOnce(); + const lines = t.captureCharFrame().split("\n"); + // The controls sit on a row framed by the composer's left/right border cells. + const controlsRow = lines.find((line) => line.includes("model gpt-5")) ?? ""; + expect(controlsRow).toContain("model gpt-5"); + expect(controlsRow).toContain("effort high"); + expect(controlsRow.indexOf("Full access")).toBeLessThan(controlsRow.indexOf("^B")); + expect(controlsRow.trimStart().startsWith("│") || controlsRow.includes("│")).toBe(true); + // model precedes the plan/build (^B) chip — matches the web footer order. + expect(controlsRow.indexOf("model")).toBeLessThan(controlsRow.indexOf("^B")); + t.renderer.destroy(); + }); + + it("Given a staged image, when the composer renders, then it shows a removal affordance", async () => { + const attachment = { + relativePath: "docs/diagram.png", + upload: { + type: "image" as const, + name: "diagram.png", + mimeType: "image/png", + sizeBytes: 4, + dataUrl: "data:image/png;base64,/wAA/w==", + }, + preview: { + data: new Uint8Array([255, 0, 0, 255]), + imageWidth: 1, + imageHeight: 1, + }, + }; + const t = await testRender( + , + { width: 90, height: 10 }, + ); + await t.renderOnce(); + const frame = t.captureCharFrame(); + expect(frame).toContain("× diagram.png"); + expect(frame).toContain("▸ Send"); + t.renderer.destroy(); + }); + + it("Given rename mode, when rendered, then it shows the rename label and hint", async () => { + const frame = await frameOf( + , + ); + expect(frame).toContain("rename"); + expect(frame).toContain("Enter rename"); + }); + + it("Given filter mode, when rendered, then it shows the find label and hint", async () => { + const frame = await frameOf( + , + ); + expect(frame).toContain("find"); + expect(frame).toContain("Enter keep"); + }); + + it("Given commit mode, when rendered, then it shows the commit label, message, and hint", async () => { + const frame = await frameOf( + , + ); + expect(frame).toContain("commit"); + expect(frame).toContain("fix the bug"); + expect(frame).toContain("Enter commit"); + }); + + it("Given a focused multiline reply editor, when text is typed, then it renders the content", async () => { + function Harness(): React.ReactNode { + const [reply, setReply] = React.useState(""); + return ( + + ); + } + const t = await testRender(, { width: 60, height: 8 }); + await t.renderOnce(); + await t.mockInput.typeText("hello"); + const frame = await t.waitForFrame((f) => f.includes("hello")); + expect(frame).toContain("hello"); + t.renderer.destroy(); + }); + + it("Given multiline clipboard text, when pasted, then every line is inserted (no single-line cap) without sending", async () => { + let sent = 0; + let captured = ""; + function Harness(): React.ReactNode { + const [reply, setReply] = React.useState(""); + return ( + { + captured = value; + setReply(value); + }} + onReplySubmit={() => { + sent += 1; + }} + /> + ); + } + const t = await testRender(, { width: 60, height: 12 }); + await t.renderOnce(); + await t.mockInput.pasteBracketedText("line one\nline two\nline three"); + const frame = await t.waitForFrame((f) => f.includes("line three")); + expect(frame).toContain("line one"); + expect(frame).toContain("line three"); + expect(captured).toBe("line one\nline two\nline three"); + expect(sent).toBe(0); + t.renderer.destroy(); + }); + + it("Given image clipboard bytes, when pasted in a non-empty prompt, then it stages the image without changing the draft", async () => { + let captured = ""; + let pasted: + | { + readonly bytes: Uint8Array; + readonly mimeType: string; + } + | undefined; + function Harness(): React.ReactNode { + const [reply, setReply] = React.useState(""); + return ( + { + captured = value; + setReply(value); + }} + onPasteImage={(value) => { + pasted = value; + }} + /> + ); + } + const t = await testRender(, { width: 60, height: 10 }); + await t.renderOnce(); + await t.mockInput.typeText("keep this draft"); + t.renderer.keyInput.processPaste(new Uint8Array([137, 80, 78, 71]), { + kind: "binary", + mimeType: "image/png", + }); + await t.waitFor(() => pasted !== undefined); + + expect(pasted?.mimeType).toBe("image/png"); + expect(pasted?.bytes).toEqual(new Uint8Array([137, 80, 78, 71])); + expect(captured).toBe("keep this draft"); + expect(t.captureCharFrame()).toContain("keep this draft"); + t.renderer.destroy(); + }); + + it("Given a complete image path is pasted, when it is attached, then the path is not inserted into the prompt", async () => { + let captured = ""; + let pastedPath = ""; + function Harness(): React.ReactNode { + const [reply, setReply] = React.useState(""); + return ( + { + captured = value; + setReply(value); + }} + onPasteImagePath={(value) => { + pastedPath = value; + return Promise.resolve({ attached: true, textToInsert: "" }); + }} + /> + ); + } + const t = await testRender(, { width: 60, height: 8 }); + await t.renderOnce(); + await t.mockInput.pasteBracketedText("./screenshots/error.png"); + await t.waitFor(() => pastedPath.length > 0); + + expect(pastedPath).toBe("./screenshots/error.png"); + expect(captured).toBe(""); + expect(t.captureCharFrame()).not.toContain("./screenshots/error.png"); + t.renderer.destroy(); + }); + + it("Given a pasted image path cannot be attached, when loading fails, then it remains ordinary prompt text", async () => { + function Harness(): React.ReactNode { + const [reply, setReply] = React.useState(""); + return ( + Promise.resolve({ attached: false, textToInsert: value })} + /> + ); + } + const t = await testRender(, { width: 60, height: 8 }); + await t.renderOnce(); + await t.mockInput.pasteBracketedText("./screenshots/missing.png"); + await t.waitForFrame((frame) => frame.includes("./screenshots/missing.png")); + t.renderer.destroy(); + }); + + it("Given prose and an image path are pasted together, when attached, then only the prose remains in the prompt", async () => { + function Harness(): React.ReactNode { + const [reply, setReply] = React.useState(""); + return ( + + Promise.resolve({ attached: true, textToInsert: "Explain this screenshot " }) + } + /> + ); + } + const t = await testRender(, { width: 60, height: 8 }); + await t.renderOnce(); + await t.mockInput.pasteBracketedText( + "Explain this screenshot ~/Downloads/Screenshot\\ 2026.png", + ); + const frame = await t.waitForFrame((value) => value.includes("Explain this screenshot")); + expect(frame).not.toContain("~/Downloads"); + t.renderer.destroy(); + }); + + it("Given a non-empty reply, when the editor mounts, then it seeds the draft (survives remount)", async () => { + const frame = await frameOf( + , + ); + expect(frame).toContain("restored draft"); + }); + + it("Given a draft, when a global Ctrl-shortcut key is pressed, then the editor keeps the draft", async () => { + function Harness(): React.ReactNode { + const [reply, setReply] = React.useState(""); + return ( + + ); + } + const t = await testRender(, { width: 60, height: 8 }); + await t.renderOnce(); + await t.mockInput.typeText("keep this draft"); + // ^U / ^K would delete-to-line-start / -end if the editor still owned them. + t.mockInput.pressKey("u", { ctrl: true }); + t.mockInput.pressKey("k", { ctrl: true }); + const frame = await t.waitForFrame((f) => f.includes("keep this draft")); + expect(frame).toContain("keep this draft"); + t.renderer.destroy(); + }); + + it("Given a reply, when plain Enter is pressed, then it submits (like the web composer)", async () => { + let sent = 0; + function Harness(): React.ReactNode { + const [reply, setReply] = React.useState(""); + return ( + { + sent += 1; + }} + /> + ); + } + const t = await testRender(, { width: 60, height: 8 }); + await t.renderOnce(); + await t.mockInput.typeText("ship it"); + t.mockInput.pressEnter(); + await t.waitFor(() => sent > 0); + expect(sent).toBe(1); + t.renderer.destroy(); + }); +}); diff --git a/apps/tui/src/components/ChatComposer.tsx b/apps/tui/src/components/ChatComposer.tsx new file mode 100644 index 00000000000..9e0cc63f050 --- /dev/null +++ b/apps/tui/src/components/ChatComposer.tsx @@ -0,0 +1,371 @@ +import { + decodePasteBytes, + defaultTextareaKeyBindings, + stripAnsiSequences, + type PasteEvent, + type TextareaRenderable, +} from "@opentui/core"; +import { Image } from "@t3tools/opentui-image/react"; +import * as React from "react"; + +import type { ComposerImageAttachment } from "../composerAttachments.ts"; +import type { ComposerControls } from "../controls.ts"; +import { clip } from "../format.ts"; +import { usePalette } from "../theme.ts"; +import type { PendingUserInput } from "../userInput.ts"; +import { ComposerFooter } from "./ComposerFooter.tsx"; +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel.tsx"; + +// Reply key map. The textarea ALWAYS merges these over its defaults, so we can't +// remove a default by omission — we override it. Two concerns: +// 1. Enter sends (like the web composer); Shift+Enter / Ctrl+J insert a newline. +// 2. The editor's default ^K (delete-to-line-end) and ^U (delete-to-line-start) +// collide with the app's global ^K (actions) and ^U (user-input), which fire +// alongside the focused editor — left as-is, pressing them would also shred +// the draft. Override them to harmless cursor moves so the keys belong to the +// app, not the editor. (^A/^E/^B/^F also overlap but only move the cursor.) +const replyKeyBindings: typeof defaultTextareaKeyBindings = [ + ...defaultTextareaKeyBindings.filter( + (binding) => + binding.name !== "return" && binding.name !== "kpenter" && binding.name !== "linefeed", + ), + { name: "k", ctrl: true, action: "line-end" }, + { name: "u", ctrl: true, action: "line-home" }, + { name: "return", shift: true, action: "newline" }, + { name: "kpenter", shift: true, action: "newline" }, + { name: "linefeed", action: "newline" }, + { name: "return", action: "submit" }, + { name: "kpenter", action: "submit" }, +]; + +// The prompt composer (mirrors apps/web/src/components/chat/ChatComposer.tsx). +// New threads use this same always-ready prompt; ChatView swaps the local draft +// and submit action without introducing a second form or keyboard mode. +// +// When `inputFocused` is false (the terminal pane holds focus) we render the +// field as STATIC text instead of an — OpenTUI doesn't reliably blur an +// input when nothing else takes focus, so a mounted input would keep consuming +// keystrokes that are meant for the terminal. Not mounting it guarantees a single +// consumer. + +function ComposerImageAttachments({ + attachments, + inlineImagesSupported, + width, + onRemove, +}: { + readonly attachments: ReadonlyArray; + readonly inlineImagesSupported: boolean; + readonly width: number; + readonly onRemove: (relativePath: string) => void; +}): React.ReactNode { + const palette = usePalette(); + if (attachments.length === 0) return null; + const itemWidth = 14; + const visibleCount = Math.max(1, Math.min(4, Math.floor(width / (itemWidth + 1)))); + const visible = attachments.slice(0, visibleCount); + const hiddenCount = attachments.length - visible.length; + + return ( + + {visible.map((attachment) => ( + onRemove(attachment.relativePath)} + > + + × + {clip(attachment.upload.name, itemWidth - 2)} + + {inlineImagesSupported ? ( + + ) : null} + + ))} + {hiddenCount > 0 ? {`+${hiddenCount} more`} : null} + + ); +} + +export interface PasteImagePathResult { + readonly attached: boolean; + readonly textToInsert: string; +} + +export const ChatComposer = React.memo(function ChatComposer({ + mode, + reply, + auxValue, + placeholder, + editorRows, + inputFocused, + composerEpoch, + controls, + working, + attachments, + inlineImagesSupported, + width, + pendingUserInput, + uiQuestionIndex, + uiOptionIndex, + uiSelectedLabels, + answerDraft, + onAnswerInput, + onFocusInput, + onReplyInput, + onReplySubmit, + onAuxInput, + onTogglePlan, + onOpenAccess, + onOpenModel, + onOpenReasoning, + onStop, + onSend, + onSubmitAnswer, + onRemoveAttachment, + onPasteImage, + onPasteImagePath, +}: { + readonly mode: "compose" | "rename" | "filter" | "commit"; + readonly reply: string; + /** Value for the single-line rename/filter inputs. */ + readonly auxValue: string; + readonly placeholder: string; + /** Fixed height (rows) of the reply editor; content beyond it scrolls. */ + readonly editorRows: number; + /** False when the terminal pane holds focus — render static text, not an input. */ + readonly inputFocused: boolean; + /** Bumped by the parent to remount (clear) the reply editor after send/clear. */ + readonly composerEpoch: number; + /** Composer controls shown inside the box (compose mode only), mirroring web. */ + readonly controls: ComposerControls; + readonly working: boolean; + readonly attachments: ReadonlyArray; + readonly inlineImagesSupported: boolean; + /** Content width for the pending-question panel's wrapping. */ + readonly width: number; + /** When set, a question panel renders above the input and Enter submits the answer. */ + readonly pendingUserInput: PendingUserInput | null; + readonly uiQuestionIndex: number; + readonly uiOptionIndex: number; + readonly uiSelectedLabels: ReadonlyArray; + /** The free-text custom answer typed while a question is pending. */ + readonly answerDraft: string; + readonly onAnswerInput: (value: string) => void; + /** Restore prompt focus when the static editor surface is clicked. */ + readonly onFocusInput: () => void; + readonly onReplyInput: (value: string) => void; + readonly onReplySubmit: () => void; + readonly onAuxInput: (value: string) => void; + readonly onTogglePlan: () => void; + readonly onOpenAccess: () => void; + readonly onOpenModel: () => void; + readonly onOpenReasoning: () => void; + readonly onStop: () => void; + readonly onSend: () => void; + readonly onSubmitAnswer: () => void; + readonly onRemoveAttachment: (relativePath: string) => void; + readonly onPasteImage: (paste: { readonly bytes: Uint8Array; readonly mimeType: string }) => void; + /** + * Returns null for an ordinary text paste, otherwise resolves whether a + * pasted path was staged and which non-path text should remain in the prompt. + */ + readonly onPasteImagePath: (pastedText: string) => Promise | null; +}): React.ReactNode { + const palette = usePalette(); + const replyRef = React.useRef(null); + // On (re)mount with a seeded draft — restored after an overlay, prompt focus, + // or $EDITOR — drop the cursor at the end so typing continues from there. + React.useEffect(() => { + if (inputFocused && reply.length > 0) replyRef.current?.gotoBufferEnd(); + // Mount/focus/epoch only; not on every keystroke. + }, [composerEpoch, inputFocused]); + + if (mode === "rename" || mode === "filter" || mode === "commit") { + const label = mode === "rename" ? "rename ▸ " : mode === "commit" ? "commit ▸ " : "find ▸ "; + const hint = + mode === "rename" + ? "Enter rename · Esc cancel" + : mode === "commit" + ? "Enter commit · Esc cancel" + : "Enter keep · Esc clear"; + const inputPlaceholder = + mode === "rename" + ? "New thread title…" + : mode === "commit" + ? "Commit message…" + : "Filter by title…"; + return ( + + + + {label} + + + + {hint} + + ); + } + + // While a question is pending the composer stays put (mirroring the web): the + // question panel renders above a single-line custom-answer field, and the + // footer's primary action becomes Submit answer. A single-line (not the + // multiline editor) leaves ↑/↓ + Enter to the question keymap (option nav + + // submit) while typing fills a free-text answer. + const answering = pendingUserInput !== null; + // A free-text answer only makes sense for single-select questions (multi-select + // toggles options); for those, Space must type rather than toggle (see the + // `answerTyping` flag in ChatView/useKeyBindings). + const allowCustomAnswer = answering && !pendingUserInput.questions[uiQuestionIndex]?.multiSelect; + const showReplyEditor = inputFocused && !answering; + const showAnswerInput = inputFocused && allowCustomAnswer; + return ( + + {pendingUserInput ? ( + + ) : null} + + + {showReplyEditor ? ( + // Multiline editor: Enter sends, Shift+Enter newlines, paste inserts the + // full clipboard (no single-line cap). Uncontrolled — remounted via + // `composerEpoch` to clear after send; content mirrored out via onContentChange. +