From c33d610f437a775a020174291ddd326f2506e26d Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sun, 11 Oct 2026 23:47:45 +0530 Subject: [PATCH 1/4] feat(cli): codework tui with managed server discovery Run the Rust TUI from the CLI against a server the CLI owns: codework tui The CLI ensures a healthy local server before launching the UI, reusing a registered one or starting a detached `codework serve` and waiting for it to become reachable. It then hands the endpoint to the TUI and forwards arguments, environment, the terminal, signals and exit status. Server lifecycle: - A new `server.info` RPC returns the live `{ pid, version }`, the health probe. Health rides the RPC channel rather than a separate HTTP surface, and a registration is only trusted when the live pid and version match what it names. - A running server writes `/service.json` (atomic, 0600) and removes it on clean shutdown if still its own. Discovery reads it as a hint, not proof: it verifies over the wire and retires a stale file before starting a replacement. - Startup is bounded and reported clearly; a failure names the port and the `--server` escape hatch. Launcher (`@codeworksh/tui`): - Resolves a prebuilt binary per OS/arch from an optional platform package, falling back to `CODEWORK_TUI_BIN` and a source-tree build. Nothing is downloaded at runtime. - `launch()` runs the binary with inherited stdio, forwards signals and maps its exit status onto the command. TUI: - Reads the endpoint from `CODEWORK_RPC_SERVER`; it no longer spawns a server, since process startup belongs to the CLI alone. - Reconnects after a dropped socket and shows a reconnecting state instead of failing the turn. Adds an E2E suite covering dispatch, help, `--server` validation, managed start + registration, reuse, exit-status forwarding, home propagation and startup failure. --- .gitignore | 3 + packages/codework/package.json | 1 + packages/codework/src/cli/cmd/cmd.ts | 28 + packages/codework/src/cli/cmd/handlers/tui.ts | 71 + packages/codework/src/index.ts | 1 + packages/codework/src/server/contract.ts | 12 + packages/codework/src/server/discovery.ts | 126 + packages/codework/src/server/handlers.ts | 3 + packages/codework/src/server/health.ts | 31 + packages/codework/src/server/identity.ts | 20 + packages/codework/src/server/registration.ts | 86 + packages/codework/src/server/server.ts | 26 +- packages/codework/test/server.test.ts | 2 + packages/codework/test/tui.test.ts | 225 ++ packages/codework/tsconfig.json | 1 + packages/codework/vite.config.ts | 2 + packages/tui/Cargo.lock | 2136 +++++++++++++++++ packages/tui/Cargo.toml | 24 + packages/tui/README.md | 128 + packages/tui/package.json | 49 + packages/tui/src/app.rs | 1671 +++++++++++++ packages/tui/src/binary.ts | 79 + packages/tui/src/catalog.rs | 930 +++++++ packages/tui/src/config.rs | 49 + packages/tui/src/credentials.rs | 77 + packages/tui/src/diff.rs | 317 +++ packages/tui/src/git.rs | 47 + packages/tui/src/index.ts | 77 + packages/tui/src/main.rs | 176 ++ packages/tui/src/markdown/block.rs | 281 +++ packages/tui/src/markdown/inline.rs | 126 + packages/tui/src/markdown/mod.rs | 5 + packages/tui/src/markdown/table.rs | 237 ++ packages/tui/src/rpc/client.rs | 652 +++++ packages/tui/src/rpc/mod.rs | 4 + packages/tui/src/rpc/protocol.rs | 132 + packages/tui/src/tool.rs | 597 +++++ packages/tui/src/types.rs | 195 ++ packages/tui/src/ui/input.rs | 401 ++++ packages/tui/src/ui/logo.rs | 125 + packages/tui/src/ui/mod.rs | 21 + packages/tui/src/ui/model.rs | 999 ++++++++ packages/tui/src/ui/session.rs | 764 ++++++ packages/tui/src/ui/spinner.rs | 49 + packages/tui/src/ui/text.rs | 60 + packages/tui/src/ui/theme.rs | 416 ++++ packages/tui/src/ui/theme_picker.rs | 105 + packages/tui/src/ui/welcome.rs | 512 ++++ packages/tui/tsconfig.json | 8 + packages/tui/vite.config.ts | 31 + pnpm-lock.yaml | 23 +- vite.config.ts | 4 + 52 files changed, 12136 insertions(+), 9 deletions(-) create mode 100644 packages/codework/src/cli/cmd/handlers/tui.ts create mode 100644 packages/codework/src/server/discovery.ts create mode 100644 packages/codework/src/server/health.ts create mode 100644 packages/codework/src/server/identity.ts create mode 100644 packages/codework/src/server/registration.ts create mode 100644 packages/codework/test/tui.test.ts create mode 100644 packages/tui/Cargo.lock create mode 100644 packages/tui/Cargo.toml create mode 100644 packages/tui/README.md create mode 100644 packages/tui/package.json create mode 100644 packages/tui/src/app.rs create mode 100644 packages/tui/src/binary.ts create mode 100644 packages/tui/src/catalog.rs create mode 100644 packages/tui/src/config.rs create mode 100644 packages/tui/src/credentials.rs create mode 100644 packages/tui/src/diff.rs create mode 100644 packages/tui/src/git.rs create mode 100644 packages/tui/src/index.ts create mode 100644 packages/tui/src/main.rs create mode 100644 packages/tui/src/markdown/block.rs create mode 100644 packages/tui/src/markdown/inline.rs create mode 100644 packages/tui/src/markdown/mod.rs create mode 100644 packages/tui/src/markdown/table.rs create mode 100644 packages/tui/src/rpc/client.rs create mode 100644 packages/tui/src/rpc/mod.rs create mode 100644 packages/tui/src/rpc/protocol.rs create mode 100644 packages/tui/src/tool.rs create mode 100644 packages/tui/src/types.rs create mode 100644 packages/tui/src/ui/input.rs create mode 100644 packages/tui/src/ui/logo.rs create mode 100644 packages/tui/src/ui/mod.rs create mode 100644 packages/tui/src/ui/model.rs create mode 100644 packages/tui/src/ui/session.rs create mode 100644 packages/tui/src/ui/spinner.rs create mode 100644 packages/tui/src/ui/text.rs create mode 100644 packages/tui/src/ui/theme.rs create mode 100644 packages/tui/src/ui/theme_picker.rs create mode 100644 packages/tui/src/ui/welcome.rs create mode 100644 packages/tui/tsconfig.json create mode 100644 packages/tui/vite.config.ts diff --git a/.gitignore b/.gitignore index e92b02cd..0a0c8259 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ models.gen.json .astro .alchemy + +# Rust build output (packages/tui binary; the launcher loads the built artifact). +target diff --git a/packages/codework/package.json b/packages/codework/package.json index 52d3e790..7ffff691 100644 --- a/packages/codework/package.json +++ b/packages/codework/package.json @@ -52,6 +52,7 @@ "@agentclientprotocol/sdk": "1.5.1", "@codeworksh/aikit": "workspace:^", "@codeworksh/harness": "workspace:*", + "@codeworksh/tui": "workspace:*", "@effect/platform-node": "4.0.1", "effect": "4.0.1", "jsonc-parser": "3.3.1", diff --git a/packages/codework/src/cli/cmd/cmd.ts b/packages/codework/src/cli/cmd/cmd.ts index 3daf45c7..aaaf7e00 100644 --- a/packages/codework/src/cli/cmd/cmd.ts +++ b/packages/codework/src/cli/cmd/cmd.ts @@ -161,6 +161,34 @@ export const Cmd = Spec.make("codework", { }, ], }), + Spec.make("tui", { + description: "Launch the CodeWork terminal UI against a managed server", + params: { + server: Flag.String("server").pipe( + Flag.withDescription("RPC server URL to use instead of a managed one (ws://host:port/rpc)"), + Flag.optional, + ), + host: Flag.String("host").pipe( + Flag.withDescription("Host interface a managed server binds"), + Flag.withDefault("127.0.0.1"), + ), + port: Flag.Int("port").pipe( + Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 65535 }))), + Flag.withDescription("Port a managed server listens on"), + Flag.withDefault(7433), + ), + args: Argument.variadic( + Argument.String("args").pipe(Argument.withDescription("Arguments forwarded to the terminal UI")), + ), + }, + examples: [ + { command: "codework tui", description: "Start or reuse the local server and open the terminal UI" }, + { + command: "codework tui --server ws://127.0.0.1:7433/rpc", + description: "Attach the terminal UI to a server you started yourself", + }, + ], + }), Spec.make("serve", { description: "Start the CodeWork RPC server", params: { diff --git a/packages/codework/src/cli/cmd/handlers/tui.ts b/packages/codework/src/cli/cmd/handlers/tui.ts new file mode 100644 index 00000000..5530238d --- /dev/null +++ b/packages/codework/src/cli/cmd/handlers/tui.ts @@ -0,0 +1,71 @@ +import { Effect, Option } from "effect"; +import { Runtime } from "../../../framework/runtime.ts"; +import { Discovery } from "../../../server/discovery.ts"; +import { Health } from "../../../server/health.ts"; +import { InvalidInputError, renderError } from "../../error.ts"; +import { writeError } from "../../output.ts"; +import { Cmd } from "../cmd.ts"; + +interface Shared { + readonly home: Option.Option; + readonly database: Option.Option; + readonly userConfigDir: Option.Option; +} + +/** The server-owned flags a managed server needs, in the order `serve` parses them. */ +const serverFlags = (shared: Shared): ReadonlyArray => [ + ...(Option.isSome(shared.home) ? ["--home", shared.home.value] : []), + ...(Option.isSome(shared.database) ? ["--database", shared.database.value] : []), + ...(Option.isSome(shared.userConfigDir) ? ["--user-config-dir", shared.userConfigDir.value] : []), +]; + +export default Runtime.handler( + Cmd.commands.tui, + Effect.fn("CLI.tui")(function* ({ server, host, port, args }) { + const shared = yield* Cmd.spec; + + const program = Effect.gen(function* () { + if (Option.isSome(server) && [shared.home, shared.database, shared.userConfigDir].some(Option.isSome)) { + return yield* new InvalidInputError({ + message: "--home, --database, and --user-config-dir belong on codework serve when using --server", + }); + } + + const endpoint = yield* Option.isSome(server) + ? // A supplied server is adopted only if it answers `server.info`; the UI must + // never launch against an endpoint that is not actually a CodeWork server. + Health.probe(server.value).pipe( + Effect.flatMap((info) => + info === undefined + ? Effect.fail( + new InvalidInputError({ message: `no CodeWork server answered at ${server.value}` }), + ) + : Effect.succeed(server.value), + ), + ) + : Discovery.ensure({ + home: Discovery.home(Option.getOrUndefined(shared.home)), + host, + port, + args: serverFlags(shared), + env: process.env, + onStart: () => void process.stderr.write(`Starting CodeWork server on ${host}:${port}...\n`), + }).pipe(Effect.map((resolved) => resolved.url)); + + // The TUI reads its own config and credentials, so it must resolve the same home + // the managed server used; otherwise the UI would show a different installation. + const env: NodeJS.ProcessEnv = { ...process.env }; + if (Option.isSome(shared.home)) env["CODEWORK_HOME_DIR"] = shared.home.value; + + // Imported lazily: the launcher must stay off the path of every other command. + const launcher = yield* Effect.promise(() => import("@codeworksh/tui")); + const forwarded: ReadonlyArray = args; + return yield* Effect.promise(() => launcher.launch({ server: endpoint, args: forwarded, env })); + }); + + const code = yield* program.pipe(Effect.catch((error) => writeError(renderError(error)).pipe(Effect.as(1)))); + yield* Effect.sync(() => { + process.exitCode = code; + }); + }), +); diff --git a/packages/codework/src/index.ts b/packages/codework/src/index.ts index 878adcd5..b1811ec4 100644 --- a/packages/codework/src/index.ts +++ b/packages/codework/src/index.ts @@ -17,6 +17,7 @@ const Handlers = Runtime.handlers(Cmd, { logout: () => import("./cli/cmd/handlers/auth/logout.ts"), }, run: () => import("./cli/cmd/handlers/run.ts"), + tui: () => import("./cli/cmd/handlers/tui.ts"), serve: () => import("./cli/cmd/handlers/serve.ts"), session: { link: () => import("./cli/cmd/handlers/session/link.ts"), diff --git a/packages/codework/src/server/contract.ts b/packages/codework/src/server/contract.ts index a14e6033..6698ab73 100644 --- a/packages/codework/src/server/contract.ts +++ b/packages/codework/src/server/contract.ts @@ -113,6 +113,18 @@ const SandboxErrors = Schema.Union([ const LocationErrors = Schema.Union([Location.DirectoryNotFoundError, Location.NotDirectoryError]); export const Api = RpcGroup.make( + /** + * The server's own identity: the process and build a client is talking to. + * + * The health probe: a discovery registration names a pid and version, and a + * caller confirms the process answering is the one that registered before it + * trusts the endpoint. Like every other capability it rides the RPC channel, + * so there is no separate HTTP surface to keep in step. + */ + Rpc.make("server.info", { + payload: {}, + success: Schema.Struct({ pid: Schema.Int, version: Schema.String }), + }), Rpc.make("auth.save", { payload: { credentials: OAuthCredentials }, success: OAuthInfo, diff --git a/packages/codework/src/server/discovery.ts b/packages/codework/src/server/discovery.ts new file mode 100644 index 00000000..301fa833 --- /dev/null +++ b/packages/codework/src/server/discovery.ts @@ -0,0 +1,126 @@ +/* @effect-diagnostics nodeBuiltinImport:off -- a detached managed server is started through node. */ +import { Clock, Effect, FileSystem, Schema } from "effect"; +import { spawn } from "node:child_process"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { Health } from "./health.ts"; +import { Registration } from "./registration.ts"; + +/** + * The CodeWork home a managed server and its registration share. + * + * `--home` wins, then `CODEWORK_HOME_DIR` (the harness default), then + * `~/.codework`. `~` expands because the CLI accepts it and the harness does + * too; keeping the two spellings identical is what lets discovery find the file + * the server wrote. + */ +export const home = (explicit?: string): string => { + const value = explicit ?? process.env["CODEWORK_HOME_DIR"] ?? join(homedir(), ".codework"); + const expanded = value === "~" ? homedir() : value.startsWith("~/") ? join(homedir(), value.slice(2)) : value; + return isAbsolute(expanded) ? expanded : resolve(expanded); +}; + +/** What a caller gets back: the endpoint to connect a TUI or `run` to. */ +export interface Endpoint { + readonly url: string; +} + +/** A managed server could not be produced, in the words the CLI prints. */ +export class ServerStartError extends Schema.TaggedError()("Discovery.ServerStartError", { + message: Schema.String, +}) {} + +const decode = Schema.decodeUnknownEffect(Registration.infoJson); + +const read = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const text = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => undefined)); + if (text === undefined) return undefined; + return yield* decode(text).pipe(Effect.orElseSucceed(() => undefined)); + }); + +/** + * Reuse a registered server if it answers; otherwise retire the stale file. + * + * The registration is a hint, not proof: only a live `server.info` answer with + * matching pid and version is trusted, and a file nothing answers for is removed + * so it cannot mislead the next caller. + */ +const reuse = (path: string) => + Effect.gen(function* () { + const info = yield* read(path); + if (info === undefined) return undefined; + if (yield* Health.matches(info)) return { url: info.url } satisfies Endpoint; + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path); + }).pipe(Effect.ignore); + return undefined; + }); + +/** + * Ensure a healthy local server for `home`, reusing the registered one when it + * answers and starting a managed server when it does not. + * + * The managed server is a detached process that outlives this caller, so the + * next invocation reuses it rather than starting another. When two callers race, + * both may start a contender, but only the one that binds the port can register; + * the loser exits and both callers adopt the winner, because discovery only ever + * believes a registration it can confirm over the wire. + */ +export const ensure = (options: { + readonly home: string; + readonly host: string; + readonly port: number; + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; + readonly onStart?: () => void; +}) => + Effect.gen(function* () { + const path = Registration.file(options.home); + const existing = yield* reuse(path); + if (existing !== undefined) return existing; + + yield* Effect.sync(() => options.onStart?.()); + yield* startDetached(options); + + const deadline = (yield* Clock.currentTimeMillis) + START_TIMEOUT_MS; + while ((yield* Clock.currentTimeMillis) < deadline) { + yield* Effect.sleep("250 millis"); + const info = yield* read(path); + if (info !== undefined && (yield* Health.matches(info))) return { url: info.url } satisfies Endpoint; + } + + return yield* new ServerStartError({ + message: + `could not start a CodeWork server on ${options.host}:${options.port}. ` + + "Run `codework serve` to see why, or point `codework tui --server` at a running server.", + }); + }); + +const START_TIMEOUT_MS = 20_000; + +/** + * Spawns `codework serve` detached from this process, inheriting the caller's + * environment so the child resolves the same home, catalog and credentials. + * `unref` lets the caller exit without waiting on or signalling the server. + */ +const startDetached = (options: { + readonly host: string; + readonly port: number; + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; +}) => + Effect.sync(() => { + const [file, ...base] = Registration.command(); + if (file === undefined) throw new Error("could not resolve the codework entrypoint"); + const child = spawn( + file, + [...base, ...options.args, "serve", "--host", options.host, "--port", String(options.port)], + { detached: true, stdio: "ignore", env: options.env }, + ); + child.unref(); + }); + +export * as Discovery from "./discovery.ts"; diff --git a/packages/codework/src/server/handlers.ts b/packages/codework/src/server/handlers.ts index 835f263b..d4dd4e40 100644 --- a/packages/codework/src/server/handlers.ts +++ b/packages/codework/src/server/handlers.ts @@ -3,6 +3,7 @@ import { Effect, Option, Stream } from "effect"; import { Contract, type RuntimeConfig, type SandboxInfo, type SessionInfo } from "./contract.ts"; import { Envelope } from "./envelope.ts"; import { EventFeed } from "./feed.ts"; +import { Identity } from "./identity.ts"; import { OAuth } from "./oauth.ts"; const toSandboxInfo = (info: Sandbox.Info): SandboxInfo => ({ @@ -51,8 +52,10 @@ export const layer = Contract.Api.toLayer( const sessions = yield* SessionStore.Service; const state = yield* State.Service; const oauth = yield* OAuth.Service; + const identity = yield* Identity.Service; return Contract.Api.of({ + "server.info": () => Effect.succeed({ pid: identity.pid, version: identity.version }), "auth.save": ({ credentials }) => oauth.save(credentials), "auth.status": ({ provider }) => oauth.status(provider), "auth.refresh": ({ provider }) => oauth.refresh(provider), diff --git a/packages/codework/src/server/health.ts b/packages/codework/src/server/health.ts new file mode 100644 index 00000000..24b24c72 --- /dev/null +++ b/packages/codework/src/server/health.ts @@ -0,0 +1,31 @@ +import { Effect, Layer } from "effect"; +import { Client } from "./client.ts"; + +/** + * One `server.info` call, with the answer normalized to `undefined` for every + * way a server can fail to answer: unreachable, slow, or speaking a different + * protocol. A health check that throws where it should say "no" would make + * every caller wrap it in a catch. + */ +export const probe = (url: string) => + Effect.gen(function* () { + const context = yield* Layer.build(Client.layer(url)).pipe(Effect.orElseSucceed(() => undefined)); + if (context === undefined) return undefined; + return yield* Effect.gen(function* () { + const rpc = yield* Client.make; + return yield* rpc["server.info"]({}).pipe(Effect.timeout("1 second")); + }).pipe(Effect.provideContext(context), Effect.orElseSucceed(() => undefined)); + }).pipe(Effect.scoped); + +/** + * Whether the process behind `url` is the exact one the registration named. + * + * The pid is the point: a different server on a reused port, or an older build, + * must not pass as the one that registered, or discovery would adopt a stranger. + */ +export const matches = (info: { readonly url: string; readonly pid: number; readonly version: string }) => + probe(info.url).pipe( + Effect.map((live) => live !== undefined && live.pid === info.pid && live.version === info.version), + ); + +export * as Health from "./health.ts"; diff --git a/packages/codework/src/server/identity.ts b/packages/codework/src/server/identity.ts new file mode 100644 index 00000000..1ea06d23 --- /dev/null +++ b/packages/codework/src/server/identity.ts @@ -0,0 +1,20 @@ +import { Context, Layer } from "effect"; +import pkg from "../../package.json" with { type: "json" }; + +/** + * What a running server is, as its clients need to know it. + * + * A discovery registration names the pid and version it wrote; `server.info` + * answers with the live ones so a caller can tell the process it reached is the + * one that registered rather than a different server that took the same port. + */ +export interface Interface { + readonly pid: number; + readonly version: string; +} + +export class Service extends Context.Service()("@codeworksh/cli/server/identity/Service") {} + +export const layer = Layer.succeed(Service, Service.of({ pid: process.pid, version: pkg.version })); + +export * as Identity from "./identity.ts"; diff --git a/packages/codework/src/server/registration.ts b/packages/codework/src/server/registration.ts new file mode 100644 index 00000000..779ab079 --- /dev/null +++ b/packages/codework/src/server/registration.ts @@ -0,0 +1,86 @@ +/* @effect-diagnostics nodeBuiltinImport:off -- the registration is a file at a fixed host path. */ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Effect, FileSystem, Layer, Schema } from "effect"; + +/** + * How a running server advertises itself so later invocations can find it + * without scanning ports or the process table. + * + * The file is a discovery hint, never proof: a caller reads it for the endpoint, + * then asks the endpoint `server.info` and believes the answer only when the + * live pid and version match the ones on file. A crashed server leaves a + * registration behind; the failed probe is what retires it. + */ +export const Info = Schema.Struct({ + /** Operating system process identifier of the server that wrote this. */ + pid: Schema.Int, + /** The build serving this endpoint; a mismatch means a different binary. */ + version: Schema.String, + /** The `ws://host:port/rpc` endpoint a client connects to. */ + url: Schema.String, + /** The TCP port the server bound, for a caller that would rather reuse it than default. */ + port: Schema.Int, +}); +export type Info = typeof Info.Type; + +export const infoJson = Schema.fromJsonString(Info); + +/** The registration file for a CodeWork home: `/service.json`. */ +export const file = (home: string) => `${home}/service.json`; + +const decode = Schema.decodeUnknownEffect(infoJson); + +export const write = Effect.fn("ServerRegistration.write")(function* (path: string, info: Info) { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dirname(path), { recursive: true }); + const encoded = yield* Schema.encodeEffect(infoJson)(info); + // Write beside the target and rename, so a reader never sees a half-written + // file. 0600 because a future registration may carry a credential. + const temp = `${path}.${info.pid}.tmp`; + yield* fs.writeFileString(temp, encoded, { mode: 0o600 }); + yield* fs.rename(temp, path); +}); + +/** + * Publishes a registration for as long as this server lives, removing it on the + * way out if it is still the one on file. A file left by a crash is overwritten + * by the next server to start, and retired by a probe that cannot confirm it. + */ +export const register = (options: { readonly home: string; readonly info: Info }) => + Layer.effectDiscard( + Effect.gen(function* () { + const path = file(options.home); + yield* write(path, options.info); + const fs = yield* FileSystem.FileSystem; + yield* Effect.addFinalizer(() => + // Remove the file only while it is still ours; a newer server that took + // over must keep the registration that names it. + fs.readFileString(path).pipe( + Effect.flatMap(decode), + Effect.flatMap((found) => + found.pid === options.info.pid && found.url === options.info.url + ? fs.remove(path) + : Effect.void, + ), + Effect.ignore, + ), + ); + }), + ); + +/** + * The launcher command for a managed server: this same CLI, asked to serve. + * + * A packaged install runs `dist/pack/index.mjs`; under `--conditions=development` + * the entry is TypeScript, and `process.execArgv` carries the conditions forward + * so a managed server resolves workspace sources the same way this process did. + */ +export const command = (): ReadonlyArray => { + const entry = process.argv[1]; + if (entry === undefined) return [process.execPath]; + const runtime = fileURLToPath(import.meta.url).endsWith(".ts") ? process.execArgv : []; + return [process.execPath, ...runtime, entry]; +}; + +export * as Registration from "./registration.ts"; diff --git a/packages/codework/src/server/server.ts b/packages/codework/src/server/server.ts index 0974e86a..51ebca50 100644 --- a/packages/codework/src/server/server.ts +++ b/packages/codework/src/server/server.ts @@ -1,4 +1,4 @@ -import { Control, Harness, Sandbox } from "@codeworksh/harness/effect"; +import { Control, Global, Harness, Sandbox } from "@codeworksh/harness/effect"; import { NodeHttpServer } from "@effect/platform-node"; import { Effect, Layer } from "effect"; import { HttpRouter, HttpServer } from "effect/http"; @@ -9,7 +9,9 @@ import { createServer } from "node:http"; import { Contract } from "./contract.ts"; import { EventFeed } from "./feed.ts"; import { Handlers } from "./handlers.ts"; +import { Identity } from "./identity.ts"; import { OAuth } from "./oauth.ts"; +import { Registration } from "./registration.ts"; // Managed instances outlive any single RPC; the serve scope is what stops them. const managedShutdown = Layer.unwrap( @@ -49,11 +51,24 @@ const managedShutdown = Layer.unwrap( }), ); -const listenLog = Layer.unwrap( +/** + * Advertises the listener once it has an address: logs the endpoint and files + * the registration later invocations discover it through. + */ +const announce = Layer.unwrap( Effect.gen(function* () { const server = yield* HttpServer.HttpServer; - yield* Effect.log(`codework serve listening on ${NetAddress.formatUrlUnsafe(server.address, "ws")}/rpc`); - return Layer.empty; + const global = yield* Global.Service; + const identity = yield* Identity.Service; + // A ws server always binds TCP; the guard keeps the logger honest if that ever changes. + const address = server.address; + const url = `${NetAddress.formatUrlUnsafe(address, "ws")}/rpc`; + yield* Effect.log(`codework serve listening on ${url}`); + if (!NetAddress.isInetAddress(address)) return Layer.empty; + return Registration.register({ + home: global.home, + info: { pid: identity.pid, version: identity.version, url, port: address.port }, + }); }), ); @@ -66,9 +81,10 @@ export const layer = (options: { host: string; port: number; harness: Harness.Op Layer.provide(OAuth.layer(options.harness.home === undefined ? {} : { home: options.harness.home })), ); return app.pipe( - Layer.provideMerge(listenLog), + Layer.provideMerge(announce), Layer.provideMerge(NodeHttpServer.layer(createServer, { host: options.host, port: options.port })), Layer.provide(RpcSerialization.layerNdjson), + Layer.provide(Identity.layer), Layer.provide(Harness.layer(options.harness)), ); }; diff --git a/packages/codework/test/server.test.ts b/packages/codework/test/server.test.ts index 2dff45da..0ca365f3 100644 --- a/packages/codework/test/server.test.ts +++ b/packages/codework/test/server.test.ts @@ -19,6 +19,7 @@ import { Contract } from "../src/server/contract.ts"; import { Envelope } from "../src/server/envelope.ts"; import { EventFeed } from "../src/server/feed.ts"; import { Handlers } from "../src/server/handlers.ts"; +import { Identity } from "../src/server/identity.ts"; import { OAuth } from "../src/server/oauth.ts"; import { Server } from "../src/server/server.ts"; import { linkedDirectory } from "../src/cli/cmd/handlers/plugin/session.ts"; @@ -51,6 +52,7 @@ const layer = (options: Harness.Options = {}) => { return Handlers.layer.pipe( Layer.provide(EventFeed.layer), Layer.provide(OAuth.layer({ home })), + Layer.provide(Identity.layer), Layer.provideMerge(Harness.layer({ home, hostCwd: home, database: ":memory:", ...options })), ); }; diff --git a/packages/codework/test/tui.test.ts b/packages/codework/test/tui.test.ts new file mode 100644 index 00000000..8cba4cb5 --- /dev/null +++ b/packages/codework/test/tui.test.ts @@ -0,0 +1,225 @@ +/* @effect-diagnostics nodeBuiltinImport:off -- this suite spawns the CLI and a fake TUI as child processes. */ +/* @effect-diagnostics globalTimers:off -- polling a spawned process from the test host is not Effect code. */ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +const cli = fileURLToPath(new URL("../src/index.ts", import.meta.url)); +const models = fileURLToPath(new URL("../../../models.gen.json", import.meta.url)); + +/** + * A stand-in for the Rust TUI. It writes what the launcher gave it to its own + * stderr and exits with a chosen code, so a test can assert the contract the + * launcher promises without building the real binary. + */ +const fake = (dir: string, body: string): string => { + const path = join(dir, "fake-tui.mjs"); + writeFileSync(path, `#!/usr/bin/env node\n${body}\n`); + // The launcher execs the binary directly, so the stand-in needs the bit too. + chmodSync(path, 0o755); + return path; +}; + +const report = (dir: string) => + fake( + dir, + [ + 'import process from "node:process";', + "process.stderr.write(JSON.stringify({", + " server: process.env.CODEWORK_RPC_SERVER,", + " home: process.env.CODEWORK_HOME_DIR,", + " args: process.argv.slice(2),", + ` cwd: process.cwd(),`, + "}) + \"\\n\");", + "process.exit(Number(process.env.FAKE_TUI_EXIT ?? 0));", + ].join("\n"), + ); + +const homes: string[] = []; +const servers: ChildProcess[] = []; + +const home = () => { + const dir = mkdtempSync(join(tmpdir(), "codework-tui-")); + homes.push(dir); + return dir; +}; + +afterEach(() => { + for (const server of servers.splice(0)) server.kill("SIGKILL"); + // A managed server is detached and outlives its CLI, so it must be stopped by + // the pid in its registration or it would hold the port for the next case. + for (const dir of homes.splice(0)) { + try { + const info = JSON.parse(readFileSync(join(dir, "service.json"), "utf8")) as { pid: number }; + process.kill(info.pid, "SIGKILL"); + } catch { + // No registration, or the server already exited. + } + rmSync(dir, { recursive: true, force: true }); + } +}); + +const spawnCli = (dir: string, env: NodeJS.ProcessEnv, args: ReadonlyArray) => { + const childEnv: NodeJS.ProcessEnv = { ...process.env, HOME: dir, CODEWORK_MODELS_FILE: models, ...env }; + return spawn(process.execPath, ["--conditions=development", cli, "--home", dir, "--database", ":memory:", ...args], { + env: childEnv, + cwd: dir, + }); +}; + +/** A background `codework tui`, held open by the fake binary until it is signalled. */ +const hold = (dir: string, env: NodeJS.ProcessEnv, args: ReadonlyArray) => { + const child = spawnCli(dir, env, args); + servers.push(child); + return child; +}; + +const waitFor = async (predicate: () => boolean, timeoutMs = 15_000): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("timed out waiting for condition"); +}; + +const readReport = async (child: ChildProcess): Promise> => + new Promise((resolve, reject) => { + let buffer = ""; + child.stderr?.on("data", (chunk) => { + buffer += String(chunk); + const line = buffer.split("\n")[0]; + if (line !== undefined && line.startsWith("{")) { + try { + resolve(JSON.parse(line)); + } catch (error) { + reject(error); + } + } + }); + child.once("exit", (code) => { + if (buffer.includes("{")) return; + reject(new Error(`fake TUI exited with ${code} before reporting`)); + }); + }); + +const exitCode = (child: ChildProcess) => + new Promise((resolve) => child.once("exit", (code) => resolve(code ?? -1))); + +describe("codework tui", () => { + it("documents the command, its server flag and forwarded arguments", () => { + const result = spawnSync(process.execPath, ["--conditions=development", cli, "tui", "--help"], { + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("Launch the CodeWork terminal UI"); + expect(result.stdout).toContain("--server"); + expect(result.stdout).toContain("--port"); + expect(result.stdout).toContain(""); + }); + + it("rejects server-owned flags when --server names a running server", () => { + const result = spawnSync( + process.execPath, + ["--conditions=development", cli, "--home", home(), "tui", "--server", "ws://127.0.0.1:1/rpc"], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("belong on codework serve"); + }); + + it("fails clearly when --server does not answer as a CodeWork server", () => { + const result = spawnSync( + process.execPath, + ["--conditions=development", cli, "tui", "--server", "ws://127.0.0.1:1/rpc"], + { encoding: "utf8" }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("no CodeWork server answered at ws://127.0.0.1:1/rpc"); + }); + + it("starts a managed server, registers it, and launches the TUI against its endpoint", async () => { + const dir = home(); + const binary = report(dir); + const child = hold(dir, { CODEWORK_TUI_BIN: binary }, ["tui", "--port", "7601", "hello", "world"]); + + const endpoint = join(dir, "service.json"); + await waitFor(() => existsSync(endpoint)); + const registration = JSON.parse(readFileSync(endpoint, "utf8")) as { url: string; port: number; pid: number }; + expect(registration.port).toBe(7601); + expect(registration.url).toBe("ws://127.0.0.1:7601/rpc"); + expect(registration.pid).toBeGreaterThan(0); + + const message = await readReport(child); + expect(message.server).toBe("ws://127.0.0.1:7601/rpc"); + expect(message.home).toBe(dir); + // Positional arguments after the command reach the binary untouched. + expect(message.args).toEqual(["hello", "world"]); + + child.kill("SIGKILL"); + }, 30_000); + + it("reuses a registered server instead of starting another", async () => { + const dir = home(); + const binary = report(dir); + const first = hold(dir, { CODEWORK_TUI_BIN: binary }, ["tui", "--port", "7602"]); + const firstMessage = await readReport(first); + expect(firstMessage.server).toBe("ws://127.0.0.1:7602/rpc"); + first.kill("SIGKILL"); + // Let the first CLI release the port before the second attaches. + await new Promise((resolve) => setTimeout(resolve, 300)); + + const second = spawnCli(dir, { CODEWORK_TUI_BIN: binary }, ["tui", "--port", "7602"]); + const secondMessage = await readReport(second); + expect(secondMessage.server).toBe("ws://127.0.0.1:7602/rpc"); + // No "Starting CodeWork server" on the reuse path: the same registration was adopted. + expect(second.stderr.read()).toBeNull(); + }, 30_000); + + it("forwards the TUI's exit status as the command's own", async () => { + const dir = home(); + const binary = report(dir); + const child = spawnCli(dir, { CODEWORK_TUI_BIN: binary, FAKE_TUI_EXIT: "7" }, ["tui", "--port", "7603"]); + + const code = await exitCode(child); + expect(code).toBe(7); + }, 30_000); + + it("passes the resolved home to the TUI so it reads the same installation", async () => { + const dir = home(); + const binary = report(dir); + const child = spawnCli(dir, { CODEWORK_TUI_BIN: binary }, ["tui", "--port", "7604"]); + + const message = await readReport(child); + expect(message.home).toBe(dir); + + child.kill("SIGKILL"); + }, 30_000); + + it("reports a startup failure when a managed server cannot bind", async () => { + const dir = home(); + const binary = report(dir); + // Occupy the port with something that is not a CodeWork server, so the managed + // server's bind fails and the health probe never succeeds. + const blocker = spawn(process.execPath, ["-e", "require('node:net').createServer().listen(7605, '127.0.0.1')"], { + stdio: "ignore", + }); + servers.push(blocker); + await new Promise((resolve) => setTimeout(resolve, 500)); + + const result = spawnSync(process.execPath, ["--conditions=development", cli, "--home", dir, "tui", "--port", "7605"], { + encoding: "utf8", + env: { ...process.env, HOME: dir, CODEWORK_MODELS_FILE: models, CODEWORK_TUI_BIN: binary }, + timeout: 30_000, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("could not start a CodeWork server"); + }, 40_000); +}); diff --git a/packages/codework/tsconfig.json b/packages/codework/tsconfig.json index c55d1f21..9eae6388 100644 --- a/packages/codework/tsconfig.json +++ b/packages/codework/tsconfig.json @@ -11,6 +11,7 @@ "@codeworksh/harness/sandbox": ["../harness/src/sandbox.ts"], "@codeworksh/harness/sandboxes/daytona": ["../harness/src/sandboxes/daytona/index.ts"], "@codeworksh/harness/sandboxes/vercel": ["../harness/src/sandboxes/vercel/index.ts"], + "@codeworksh/tui": ["../tui/src/index.ts"], "@codeworksh/aikit": ["../aikit/dist/pack/index.d.mts"], "@codeworksh/aikit/failure": ["../aikit/dist/pack/llm/failure.d.mts"], "@codeworksh/aikit/modelgen": ["../aikit/dist/pack/modelgen.d.mts"], diff --git a/packages/codework/vite.config.ts b/packages/codework/vite.config.ts index c105be66..086fd012 100644 --- a/packages/codework/vite.config.ts +++ b/packages/codework/vite.config.ts @@ -36,6 +36,8 @@ const aliases = { "@codeworksh/aikit/oauth/summary": fileURLToPath(new URL("../aikit/src/oauth/summary.ts", import.meta.url)), "@codeworksh/aikit/failure": fileURLToPath(new URL("../aikit/src/llm/failure.ts", import.meta.url)), "@codeworksh/aikit": fileURLToPath(new URL("../aikit/src/index.ts", import.meta.url)), + // The TUI launcher is a workspace package; dev and tests resolve it to source. + "@codeworksh/tui": fileURLToPath(new URL("../tui/src/index.ts", import.meta.url)), }; // `@codeworksh/plugin` is the published plugin SDK, resolved to source in this repo so the CLI, // the harness and the example plugins all compile against one copy of the contract. diff --git a/packages/tui/Cargo.lock b/packages/tui/Cargo.lock new file mode 100644 index 00000000..e6f0f666 --- /dev/null +++ b/packages/tui/Cargo.lock @@ -0,0 +1,2136 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "codework-tui" +version = "0.0.1" +dependencies = [ + "chrono", + "crossterm", + "dirs", + "futures-util", + "ratatui", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", + "unicode-width 0.2.0", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.8", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01016da373cd8f7ef12624f796309f5c31ba8d646dd08856c02cd741d823c622" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.8", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.8", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/tui/Cargo.toml b/packages/tui/Cargo.toml new file mode 100644 index 00000000..a7d3333c --- /dev/null +++ b/packages/tui/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "codework-tui" +version = "0.0.1" +edition = "2021" +description = "High-performance Terminal User Interface for CodeWork in Rust using Ratatui" +authors = ["Prem "] +license = "MIT" + +[[bin]] +name = "codework-tui" +path = "src/main.rs" + +[dependencies] +ratatui = { version = "0.29", features = ["crossterm"] } +crossterm = { version = "0.28", features = ["event-stream"] } +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } +futures-util = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +unicode-width = "0.2" +dirs = "6.0" +chrono = "0.4" diff --git a/packages/tui/README.md b/packages/tui/README.md new file mode 100644 index 00000000..6fa5695d --- /dev/null +++ b/packages/tui/README.md @@ -0,0 +1,128 @@ +# codework-tui + +A fast and lightweight terminal interface for [CodeWork](https://www.codework.sh), written in **Rust**. + +`codework-tui` lets you chat with your AI coding agent directly from your terminal. Watch it write code, run tools, and edit files in real time, all while keeping track of token usage and costs. + +--- + +## Features + +- **Fast and Lightweight** — Starts up instantly and uses minimal memory. +- **Live Streaming** — Watch the AI think, stream answers, and run tools (like running terminal commands or editing files) as it happens. +- **Side-by-Side View** — + - **Chat screen:** See your conversation, AI replies, tool results, and response speed (`tok/s`). + - **Sidebar:** Keep an eye on your current model, context window usage, tokens used, cost, and active Git branch. +- **Easy Model Picker (`/model`)** — Browse models (Anthropic, OpenAI, Google, OpenRouter, Groq), check pricing, and enter API keys directly in the terminal. +- **Neat Markdown Formatting** — Code blocks, lists, bold text, links, and tables look clean and readable in the terminal. +- **Cancel Anytime** — Press `Esc` or `Ctrl+C` while the AI is responding to safely stop it without losing your conversation or quitting the app. +- **Auto-Connect** — Automatically connects to the CodeWork background server, starting it for you if it isn't running yet. + +--- + +## Getting Started + +### Prerequisites + +- [Rust](https://rustup.rs/) (latest stable version) +- Node.js and pnpm (to run the CodeWork CLI and background server) + +### Running the App + +You can run the app directly using Cargo: + +```bash +# Run in development mode +cargo run + +# Build and run an optimized release version +cargo build --release +./target/release/codework-tui +``` + +Or using pnpm: + +```bash +# From packages/tui +pnpm start + +# Or from the project root +pnpm --filter @codeworksh/tui start +``` + +--- + +## Commands + +Type `/` in the prompt input to open the command menu: + +| Command | What it does | +| :--- | :--- | +| `/help` | Show available commands and shortcuts | +| `/model` | Pick an AI model or enter API keys | +| `/session` | View, switch, or resume past sessions | +| `/compact` | Shorten conversation history to save tokens | +| `/clear` | Clear the current chat screen | +| `/exit` | Exit the app | + +--- + +## Keyboard Shortcuts + +### General & Chat + +| Shortcut | Action | +| :--- | :--- | +| `Enter` | Send message / run command | +| `Esc` | Close popup, cancel current action, or stop the AI | +| `Ctrl+C` | Stop the AI response / exit app when idle | +| `PageUp` / `PageDown` | Scroll chat up or down by a full page | +| `Ctrl+U` / `Ctrl+D` | Scroll chat up or down by half a page | +| `Home` / `End` | Jump to the very top or bottom of the chat | +| `Up` / `Down` | Move through slash commands (or scroll lines when input is empty) | + +### Model Picker (`/model`) + +| Shortcut | Action | +| :--- | :--- | +| `Tab` / `Shift+Tab` | Move between Providers, Models, and Details columns | +| `Up` / `Down` (or `j` / `k`) | Move through the list | +| `/` | Search models or providers | +| `s` | Change sort order (by name, context size, cost) | +| `c` / `y` | Copy the selected model ID to clipboard | +| `o` | Open provider website in your browser | +| `Enter` | Select the model (asks for an API key if needed) | + +--- + +## API Keys & Settings + +You can provide API keys in two simple ways: + +1. **Inside the app:** Type `/model`, choose a provider, and enter your API key when prompted. +2. **Environment variables:** Export the key in your terminal before running: + ```bash + export ANTHROPIC_API_KEY="your-key-here" + export OPENAI_API_KEY="your-key-here" + export GEMINI_API_KEY="your-key-here" + export OPENROUTER_API_KEY="your-key-here" + export GROQ_API_KEY="your-key-here" + ``` + +Your configuration and keys are saved in your home folder: +- **`~/.codework/config.json`** — Your selected model and provider. +- **`~/.codework/credentials.json`** — Your securely stored API keys. + +--- + +## Contributors + +- **Prem** ([@prem315](https://github.com/prem315)) +- **Sanchit** ([@sanchitrk](https://github.com/sanchitrk)) + +--- + +## License + +MIT © [CodeWork](https://codework.sh) + diff --git a/packages/tui/package.json b/packages/tui/package.json new file mode 100644 index 00000000..e36ac757 --- /dev/null +++ b/packages/tui/package.json @@ -0,0 +1,49 @@ +{ + "name": "@codeworksh/tui", + "version": "0.0.1", + "description": "High-performance Terminal User Interface (TUI) for CodeWork in Rust, with a thin TypeScript launcher", + "keywords": [ + "codework", + "ratatui", + "rust", + "tui" + ], + "homepage": "https://www.codework.sh", + "author": "Prem (https://github.com/prem315)", + "license": "MIT", + "type": "module", + "files": [ + "dist", + "src", + "README.md" + ], + "exports": { + ".": { + "development": "./src/index.ts", + "import": "./dist/pack/index.mjs", + "default": "./dist/pack/index.mjs" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "vp pack", + "build:rust": "cargo build --release", + "check": "vp check", + "check:rust": "cargo check", + "package:binaries": "node scripts/package.mjs", + "start": "cargo run", + "typecheck": "tsc --noEmit" + }, + "optionalDependencies": { + "@codeworksh/tui-darwin-arm64": "0.0.1", + "@codeworksh/tui-darwin-x64": "0.0.1", + "@codeworksh/tui-linux-arm64": "0.0.1", + "@codeworksh/tui-linux-x64": "0.0.1", + "@codeworksh/tui-win32-x64": "0.0.1" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/tui/src/app.rs b/packages/tui/src/app.rs new file mode 100644 index 00000000..835b2a44 --- /dev/null +++ b/packages/tui/src/app.rs @@ -0,0 +1,1671 @@ +use std::sync::Arc; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use tokio::sync::mpsc; + +use crate::catalog::{ + copy_to_clipboard, open_browser_url, validate_api_key, +}; +use crate::config::ConfigManager; +use crate::credentials::CredentialStore; +use crate::rpc::{RpcClient, StreamEvent}; +use crate::tool; +use crate::types::{ + ConversationTurn, FocusedPanel, ModelConfig, ModelSortMode, SessionInfo, SessionStats, +}; +use crate::ui::model::ModelBrowserState; +use crate::ui::spinner::Spinner; +use crate::ui::welcome::commands; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ActiveScreen { + Welcome, + ModelFlow, + Session, +} + +pub struct App { + pub screen: ActiveScreen, + pub should_quit: bool, + pub config_manager: ConfigManager, + pub active_config: Option, + pub spinner: Spinner, + + // Welcome screen state + pub welcome_input: String, + pub welcome_cursor: usize, + pub welcome_dropdown_index: usize, + pub welcome_status_message: Option, + + // Model browser state + pub model_browser: ModelBrowserState, + + // Session state + pub session_info: Option, + pub turns: Vec, + pub session_stats: SessionStats, + pub session_input: String, + pub session_cursor: usize, + pub session_scroll_offset: usize, + pub session_dropdown_index: usize, + pub is_streaming: bool, + /// The socket dropped mid-turn and a reconnect is in flight. Distinct from an + /// error: the turn is still running server-side, so the UI shows recovery + /// rather than a failure. + pub reconnecting: bool, + + // Theme picker state + pub theme_picker_open: bool, + pub theme_picker_index: usize, + + // RPC Client + pub rpc_client: Arc, + pub stream_tx: mpsc::UnboundedSender, + pub stream_rx: mpsc::UnboundedReceiver, +} + +impl App { + pub fn new() -> Self { + let config_manager = ConfigManager::new(); + let active_config = config_manager.load(); + let (stream_tx, stream_rx) = mpsc::unbounded_channel(); + + Self { + screen: ActiveScreen::Welcome, + should_quit: false, + config_manager, + active_config, + spinner: Spinner::new(), + + welcome_input: String::new(), + welcome_cursor: 0, + welcome_dropdown_index: 0, + welcome_status_message: None, + + model_browser: ModelBrowserState::new(), + + session_info: None, + turns: Vec::new(), + session_stats: SessionStats::default(), + session_input: String::new(), + session_cursor: 0, + session_scroll_offset: 0, + session_dropdown_index: 0, + is_streaming: false, + reconnecting: false, + + theme_picker_open: false, + theme_picker_index: 0, + + rpc_client: Arc::new(RpcClient::default()), + stream_tx, + stream_rx, + } + } + + pub fn on_tick(&mut self) { + self.spinner.tick(); + + // Process any pending stream events from background RPC tasks + while let Ok(event) = self.stream_rx.try_recv() { + self.handle_stream_event(event); + } + } + + pub fn handle_stream_event(&mut self, event: StreamEvent) { + if self.screen == ActiveScreen::ModelFlow { + match event { + StreamEvent::Succeeded => { + self.model_browser.validating = false; + self.model_browser.validation_error = None; + self.model_browser.api_key_modal_open = false; + self.active_config = self.config_manager.load(); + if let Some(cfg) = &self.active_config { + self.welcome_status_message = Some(format!("Model configured: {} ({})", cfg.model, cfg.provider)); + } + self.screen = ActiveScreen::Welcome; + } + StreamEvent::Failed(err) => { + self.model_browser.validating = false; + self.model_browser.validation_error = Some(err); + } + _ => {} + } + return; + } + + // Connection state is not tied to a turn: a drop and its recovery can + // arrive whether or not the transcript has produced its first turn yet. + match &event { + StreamEvent::Disconnected => { + self.reconnecting = true; + return; + } + StreamEvent::Reconnected => { + self.reconnecting = false; + return; + } + _ => {} + } + + let last_turn_idx = if self.turns.is_empty() { + return; + } else { + self.turns.len() - 1 + }; + + match event { + StreamEvent::SessionCreated(info) => { + self.session_info = Some(info); + } + StreamEvent::TextDelta(delta) => { + if let Some(turn) = self.turns.get_mut(last_turn_idx) { + turn.response.push_str(&delta); + let approx_tokens = (delta.len() / 4).max(1) as u64; + turn.tokens += approx_tokens; + let elapsed_sec = (chrono::Utc::now().timestamp_millis() - turn.start_time) as f64 / 1000.0; + if elapsed_sec > 0.1 { + turn.tokens_per_sec = turn.tokens as f64 / elapsed_sec; + } + } + } + StreamEvent::ThinkingDelta(delta) => { + if let Some(turn) = self.turns.get_mut(last_turn_idx) { + turn.thinking.push_str(&delta); + } + } + StreamEvent::ToolStarted { + call_id, + name, + label, + arguments, + } => { + if let Some(turn) = self.turns.get_mut(last_turn_idx) { + // Title the running line from the call's arguments, so an in-flight tool + // reads `bash npm test` rather than just `bash`. + turn.active_tool = Some(tool::derive(&name, label.as_deref(), &arguments, None, false)); + turn.active_call_id = Some(call_id); + } + } + StreamEvent::ToolSettled { + call_id, + name, + is_error, + arguments, + details, + } => { + if let Some(turn) = self.turns.get_mut(last_turn_idx) { + // Pair by callID: parallel tool calls settle out of order, so a settle that + // is not the current active call must not clear or steal its line. + let live = if turn.active_call_id.as_deref() == Some(call_id.as_str()) { + turn.active_call_id = None; + turn.active_tool.take() + } else { + None + }; + let tool_name = name.clone().unwrap_or_else(|| call_id.clone()); + let settled = + tool::derive(&tool_name, None, &arguments, details.as_ref(), is_error); + // The running call already resolved its own identity, including the + // tool's declared label, which the terminal part does not carry. + let result = match live { + Some(running) => tool::settle(running, settled), + None => settled, + }; + turn.completed_tools.push(result); + } + } + StreamEvent::Usage(usage) => { + self.session_stats.total_tokens += usage.total_tokens; + self.session_stats.cost += usage.cost; + self.session_stats.turns_count += 1; + + if let Some(turn) = self.turns.get_mut(last_turn_idx) { + turn.tokens = usage.output_tokens.max(turn.tokens); + let elapsed_sec = (chrono::Utc::now().timestamp_millis() - turn.start_time) as f64 / 1000.0; + if elapsed_sec > 0.1 { + turn.tokens_per_sec = turn.tokens as f64 / elapsed_sec; + } + } + } + StreamEvent::Succeeded => { + if let Some(turn) = self.turns.get_mut(last_turn_idx) { + turn.streaming = false; + turn.end_time = Some(chrono::Utc::now().timestamp_millis()); + // A call that never settled keeps the line it was titled with. + if let Some(running) = turn.active_tool.take() { + turn.active_call_id = None; + turn.completed_tools.push(running); + } + } + self.is_streaming = false; + } + StreamEvent::Failed(err) => { + if let Some(turn) = self.turns.get_mut(last_turn_idx) { + turn.streaming = false; + turn.error = Some(err); + turn.active_tool = None; + turn.active_call_id = None; + } + self.is_streaming = false; + self.reconnecting = false; + } + StreamEvent::Interrupted(reason) => { + if let Some(turn) = self.turns.get_mut(last_turn_idx) { + turn.streaming = false; + turn.error = Some(format!("Interrupted: {}", reason)); + turn.active_tool = None; + turn.active_call_id = None; + } + self.is_streaming = false; + self.reconnecting = false; + } + // Handled before the transcript: they are connection state, not turns. + StreamEvent::Disconnected | StreamEvent::Reconnected => {} + } + } + + pub fn get_session_input_width(&self) -> usize { + let (term_w, _) = crossterm::terminal::size().unwrap_or((80, 24)); + let sidebar_width = (term_w / 4).clamp(24, 32); + let main_width = term_w.saturating_sub(sidebar_width + 1); + (main_width as usize).saturating_sub(6).max(10) + } + + pub fn get_welcome_input_width(&self) -> usize { + let (term_w, _) = crossterm::terminal::size().unwrap_or((80, 24)); + let card_width = 78u16.min(term_w.saturating_sub(2)); + (card_width as usize).saturating_sub(6).max(10) + } + + pub fn handle_key(&mut self, key: KeyEvent) { + self.spinner.reset_blink(); + + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + if self.is_streaming { + // Interrupt active session + if let Some(sess) = &self.session_info { + let rpc = self.rpc_client.clone(); + let s_id = sess.id.clone(); + tokio::spawn(async move { + let _ = rpc.interrupt_session(&s_id).await; + }); + } + self.is_streaming = false; + return; + } + self.should_quit = true; + return; + } + + if self.theme_picker_open && self.screen != ActiveScreen::ModelFlow { + return self.handle_theme_picker_key(key); + } + + match self.screen { + ActiveScreen::Welcome => self.handle_welcome_key(key), + ActiveScreen::ModelFlow => self.handle_model_flow_key(key), + ActiveScreen::Session => self.handle_session_key(key), + } + } + + /// Live-preview navigation inside the `/theme` picker: moving the cursor + /// switches the palette immediately, so the whole UI recolors as you move. + /// Esc dismisses back to the palette you came from; Enter keeps it. + fn handle_theme_picker_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + let _ = crate::ui::theme::set_active(self.theme_picker_index); + self.theme_picker_open = false; + } + KeyCode::Up | KeyCode::Left => { + let count = crate::ui::theme::PALETTES.len(); + self.theme_picker_index = if self.theme_picker_index == 0 { + count - 1 + } else { + self.theme_picker_index - 1 + }; + let _ = crate::ui::theme::set_active(self.theme_picker_index); + } + KeyCode::Down | KeyCode::Right => { + self.theme_picker_index = (self.theme_picker_index + 1) % crate::ui::theme::PALETTES.len(); + let _ = crate::ui::theme::set_active(self.theme_picker_index); + } + KeyCode::Enter => { + self.theme_picker_open = false; + self.persist_theme(); + } + _ => {} + } + } + + /// Save the active theme id alongside the model config, so the choice + /// survives restarts. Without a configured model it stays session-only. + fn persist_theme(&self) { + if let Some(cfg) = &self.active_config { + let cfg = ModelConfig { + provider: cfg.provider.clone(), + model: cfg.model.clone(), + updated_at: cfg.updated_at.clone(), + theme: Some(crate::ui::theme::active().id.to_string()), + }; + let _ = self.config_manager.save(&cfg); + } + } + + fn handle_welcome_key(&mut self, key: KeyEvent) { + let is_dropdown_open = self.welcome_input.starts_with('/') && !self.welcome_input.contains(' '); + let all_commands = commands(); + let matching_cmds = if is_dropdown_open { + all_commands + .iter() + .filter(|cmd| cmd.name.starts_with(&self.welcome_input)) + .collect::>() + } else { + Vec::new() + }; + + match key.code { + KeyCode::Esc => { + if is_dropdown_open { + self.welcome_input.clear(); + self.welcome_cursor = 0; + self.welcome_dropdown_index = 0; + } else { + self.should_quit = true; + } + } + KeyCode::Up => { + if is_dropdown_open && !matching_cmds.is_empty() { + if self.welcome_dropdown_index == 0 { + self.welcome_dropdown_index = matching_cmds.len() - 1; + } else { + self.welcome_dropdown_index -= 1; + } + } else if !is_dropdown_open { + let w = self.get_welcome_input_width(); + if let Some(new_cur) = crate::ui::input::cursor_up_in_input(&self.welcome_input, self.welcome_cursor, w) { + self.welcome_cursor = new_cur; + } + } + } + KeyCode::Down => { + if is_dropdown_open && !matching_cmds.is_empty() { + if self.welcome_dropdown_index + 1 >= matching_cmds.len() { + self.welcome_dropdown_index = 0; + } else { + self.welcome_dropdown_index += 1; + } + } else if !is_dropdown_open { + let w = self.get_welcome_input_width(); + if let Some(new_cur) = crate::ui::input::cursor_down_in_input(&self.welcome_input, self.welcome_cursor, w) { + self.welcome_cursor = new_cur; + } + } + } + KeyCode::Left => { + self.welcome_cursor = self.welcome_cursor.saturating_sub(1); + } + KeyCode::Right => { + let char_count = self.welcome_input.chars().count(); + if self.welcome_cursor < char_count { + self.welcome_cursor += 1; + } + } + KeyCode::Home => { + self.welcome_cursor = 0; + } + KeyCode::End => { + self.welcome_cursor = self.welcome_input.chars().count(); + } + KeyCode::Delete => { + delete_at(&mut self.welcome_input, self.welcome_cursor); + } + KeyCode::Tab => { + if is_dropdown_open && !matching_cmds.is_empty() { + let cmd_name = matching_cmds[self.welcome_dropdown_index].name; + self.welcome_input = format!("{} ", cmd_name); + self.welcome_cursor = self.welcome_input.chars().count(); + } + } + KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) || key.modifiers.contains(KeyModifiers::ALT) => { + insert_char_at(&mut self.welcome_input, &mut self.welcome_cursor, '\n'); + self.welcome_dropdown_index = 0; + } + KeyCode::Enter => { + if is_dropdown_open && !matching_cmds.is_empty() { + let cmd_name = matching_cmds[self.welcome_dropdown_index.min(matching_cmds.len() - 1)].name; + self.welcome_input.clear(); + self.welcome_cursor = 0; + self.welcome_dropdown_index = 0; + if self.execute_command(cmd_name) { + return; + } else { + self.welcome_input = format!("{} ", cmd_name); + self.welcome_cursor = self.welcome_input.chars().count(); + return; + } + } + + let trimmed = self.welcome_input.trim().to_string(); + if trimmed.is_empty() { + return; + } + + if self.execute_command(&trimmed) { + self.welcome_input.clear(); + self.welcome_cursor = 0; + self.welcome_dropdown_index = 0; + return; + } + + if self.active_config.is_none() { + self.welcome_status_message = Some("Please connect a model first (/model)".to_string()); + self.open_model_flow(); + return; + } + + // Start Session + self.welcome_input.clear(); + self.welcome_cursor = 0; + self.welcome_dropdown_index = 0; + self.start_session(trimmed); + } + KeyCode::Backspace => { + backspace_at(&mut self.welcome_input, &mut self.welcome_cursor); + self.welcome_dropdown_index = 0; + } + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.welcome_cursor = 0; + } + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.welcome_cursor = self.welcome_input.chars().count(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + clear_to_start(&mut self.welcome_input, &mut self.welcome_cursor); + } + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { + delete_word_backward(&mut self.welcome_input, &mut self.welcome_cursor); + } + KeyCode::Char(c) => { + insert_char_at(&mut self.welcome_input, &mut self.welcome_cursor, c); + self.welcome_dropdown_index = 0; + self.welcome_status_message = None; + } + _ => {} + } + } + + pub fn execute_command(&mut self, cmd: &str) -> bool { + let trimmed = cmd.trim(); + let cmd_name = trimmed.split_whitespace().next().unwrap_or(trimmed); + + match cmd_name { + "/model" => { + self.open_model_flow(); + true + } + "/theme" => { + self.theme_picker_open = true; + self.theme_picker_index = crate::ui::theme::active_index(); + true + } + "/exit" => { + self.should_quit = true; + true + } + "/clear" => { + self.turns.clear(); + self.session_scroll_offset = 0; + true + } + "/session" => { + self.screen = ActiveScreen::Welcome; + true + } + "/help" => { + let mut help_turn = ConversationTurn::new("help".to_string(), "/help".to_string(), "system".to_string()); + help_turn.streaming = false; + help_turn.end_time = Some(chrono::Utc::now().timestamp_millis()); + help_turn.response = "### CodeWork Help & Commands\n\n| Command | Description |\n| :--- | :--- |\n| `/help` | Show available commands and shortcuts | +| `/theme` | Change the color theme |\n| `/clear` | Clear conversation history and screen |\n| `/model` | Switch or view active LLM model |\n| `/session` | List or resume recent sessions |\n| `/compact` | Compact current conversation context |\n| `/exit` | Exit CodeWork TUI |\n\n**Shortcuts:**\n- `↑` / `↓` : Scroll chat history (or navigate commands when typing `/`)\n- `Home` / `End` : Jump to top / bottom of chat\n- `Ctrl+C` : Interrupt streaming response / Exit".to_string(); + self.turns.push(help_turn); + true + } + "/compact" => { + if self.turns.len() > 1 { + let old_count = self.turns.len(); + let last_turn = self.turns.pop().unwrap(); + self.turns.clear(); + let mut compact_turn = ConversationTurn::new("compact".to_string(), "[Context Compacted]".to_string(), "system".to_string()); + compact_turn.streaming = false; + compact_turn.end_time = Some(chrono::Utc::now().timestamp_millis()); + compact_turn.response = format!("Previous {} turns compacted into session context summary.", old_count - 1); + self.turns.push(compact_turn); + self.turns.push(last_turn); + self.session_scroll_offset = 0; + } + true + } + _ => false, + } + } + + pub fn open_model_flow(&mut self) { + self.model_browser = ModelBrowserState::new(); + self.screen = ActiveScreen::ModelFlow; + } + + fn handle_model_flow_key(&mut self, key: KeyEvent) { + // Case 1: Help Modal is Open + if self.model_browser.show_help { + self.model_browser.show_help = false; + return; + } + + // Case 2: API Key Modal is Open + if self.model_browser.api_key_modal_open { + if self.model_browser.validating { + return; + } + match key.code { + KeyCode::Esc => { + self.model_browser.api_key_modal_open = false; + self.model_browser.validation_error = None; + } + KeyCode::Backspace => { + self.model_browser.api_key_input.pop(); + } + KeyCode::Char(c) => { + self.model_browser.api_key_input.push(c); + } + KeyCode::Enter => { + let api_key = self.model_browser.api_key_input.trim().to_string(); + let pending = match &self.model_browser.pending_model { + Some(m) => m.clone(), + None => return, + }; + + if api_key.is_empty() && pending.provider_id != "ollama" { + self.model_browser.validation_error = Some("API key cannot be empty".to_string()); + return; + } + + self.model_browser.validating = true; + self.model_browser.validation_error = None; + + let tx = self.stream_tx.clone(); + tokio::spawn(async move { + let val_res = validate_api_key(&pending.provider_id, &api_key).await; + match val_res { + Ok(_) => { + if !api_key.is_empty() { + let cred_store = CredentialStore::new(); + let _ = cred_store.set_api_key(&pending.provider_id, &api_key); + } + + let cfg_mgr = ConfigManager::new(); + let _ = cfg_mgr.save(&ModelConfig { + provider: pending.provider_id.clone(), + model: pending.id.clone(), + updated_at: Some(chrono::Utc::now().to_rfc3339()), + theme: Some(crate::ui::theme::active().id.to_string()), + }); + + let _ = tx.send(StreamEvent::Succeeded); + } + Err(err) => { + let _ = tx.send(StreamEvent::Failed(err)); + } + } + }); + } + _ => {} + } + return; + } + + // Case 3: Search mode is active + if self.model_browser.is_searching { + match self.model_browser.focused_panel { + FocusedPanel::Providers => match key.code { + KeyCode::Esc => { + self.model_browser.provider_search.clear(); + self.model_browser.is_searching = false; + self.model_browser.provider_index = 0; + self.model_browser.provider_scroll_offset = 0; + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + KeyCode::Enter => { + self.model_browser.is_searching = false; + } + KeyCode::Backspace => { + self.model_browser.provider_search.pop(); + let filtered = self.model_browser.filtered_providers(); + if self.model_browser.provider_search.is_empty() { + self.model_browser.provider_index = 0; + } else if !filtered.is_empty() { + self.model_browser.provider_index = 1; + } else { + self.model_browser.provider_index = 0; + } + self.model_browser.provider_scroll_offset = 0; + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + KeyCode::Char(c) => { + self.model_browser.provider_search.push(c); + let filtered = self.model_browser.filtered_providers(); + if !filtered.is_empty() { + self.model_browser.provider_index = 1; + } else { + self.model_browser.provider_index = 0; + } + self.model_browser.provider_scroll_offset = 0; + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + _ => {} + }, + FocusedPanel::Models => match key.code { + KeyCode::Esc => { + self.model_browser.model_search.clear(); + self.model_browser.search_query.clear(); + self.model_browser.is_searching = false; + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + KeyCode::Enter => { + self.model_browser.is_searching = false; + self.model_browser.focused_panel = FocusedPanel::Models; + } + KeyCode::Backspace => { + self.model_browser.model_search.pop(); + self.model_browser.search_query.pop(); + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + KeyCode::Char(c) => { + self.model_browser.model_search.push(c); + self.model_browser.search_query.push(c); + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + _ => {} + }, + } + return; + } + + // Case 4: Normal browser dashboard navigation + match key.code { + KeyCode::Char('q') => { + self.screen = ActiveScreen::Welcome; + } + KeyCode::Esc => { + match self.model_browser.focused_panel { + FocusedPanel::Providers => { + if !self.model_browser.provider_search.is_empty() { + self.model_browser.provider_search.clear(); + self.model_browser.provider_index = 0; + self.model_browser.provider_scroll_offset = 0; + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } else if !self.model_browser.model_search.is_empty() || !self.model_browser.search_query.is_empty() { + self.model_browser.model_search.clear(); + self.model_browser.search_query.clear(); + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } else { + self.screen = ActiveScreen::Welcome; + } + } + FocusedPanel::Models => { + if !self.model_browser.model_search.is_empty() || !self.model_browser.search_query.is_empty() { + self.model_browser.model_search.clear(); + self.model_browser.search_query.clear(); + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } else if !self.model_browser.provider_search.is_empty() { + self.model_browser.provider_search.clear(); + self.model_browser.provider_index = 0; + self.model_browser.provider_scroll_offset = 0; + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } else { + self.screen = ActiveScreen::Welcome; + } + } + } + } + KeyCode::Tab | KeyCode::BackTab => { + self.model_browser.focused_panel = match self.model_browser.focused_panel { + FocusedPanel::Providers => FocusedPanel::Models, + FocusedPanel::Models => FocusedPanel::Providers, + }; + } + KeyCode::Char('/') => { + self.model_browser.is_searching = true; + } + KeyCode::Char('s') => { + self.model_browser.sort_mode = match self.model_browser.sort_mode { + ModelSortMode::Id => ModelSortMode::Provider, + ModelSortMode::Provider => ModelSortMode::Context, + ModelSortMode::Context => ModelSortMode::Cost, + ModelSortMode::Cost => ModelSortMode::Id, + }; + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + let sort_label = match self.model_browser.sort_mode { + ModelSortMode::Id => "Model ID", + ModelSortMode::Provider => "Provider", + ModelSortMode::Context => "Context Window", + ModelSortMode::Cost => "Cost", + }; + self.model_browser.set_notification(format!("Sorted by {}", sort_label)); + } + KeyCode::Char('c') => { + match self.model_browser.focused_panel { + FocusedPanel::Providers => { + if let Some(prov) = self.model_browser.provider_for_details() { + let val = prov.env_keys.first().cloned().unwrap_or_else(|| prov.id.clone()); + copy_to_clipboard(&val); + self.model_browser.set_notification(format!("Copied \"{}\"", val)); + } + } + FocusedPanel::Models => { + if let Some(m) = self.model_browser.current_model() { + copy_to_clipboard(&m.id); + self.model_browser.set_notification(format!("Copied \"{}\"", m.id)); + } + } + } + } + KeyCode::Char('o') => { + if let Some(prov) = self.model_browser.provider_for_details() { + if let Some(url) = &prov.docs_url { + open_browser_url(url); + self.model_browser.set_notification(format!("Opening {}", url)); + } + } + } + KeyCode::Char('?') => { + self.model_browser.show_help = true; + } + KeyCode::Up | KeyCode::Char('k') => { + match self.model_browser.focused_panel { + FocusedPanel::Providers => { + let total_provs = 1 + self.model_browser.filtered_providers().len(); + if self.model_browser.provider_index > 0 { + self.model_browser.provider_index -= 1; + } else { + self.model_browser.provider_index = total_provs.saturating_sub(1); + } + if self.model_browser.provider_index < self.model_browser.provider_scroll_offset { + self.model_browser.provider_scroll_offset = self.model_browser.provider_index; + } + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + FocusedPanel::Models => { + if self.model_browser.model_index > 0 { + self.model_browser.model_index -= 1; + if self.model_browser.model_index < self.model_browser.model_scroll_offset { + self.model_browser.model_scroll_offset = self.model_browser.model_index; + } + } + } + } + } + KeyCode::Down | KeyCode::Char('j') => { + match self.model_browser.focused_panel { + FocusedPanel::Providers => { + let total_provs = 1 + self.model_browser.filtered_providers().len(); + if self.model_browser.provider_index + 1 < total_provs { + self.model_browser.provider_index += 1; + } else { + self.model_browser.provider_index = 0; + } + let visible = 14usize; + if self.model_browser.provider_index >= self.model_browser.provider_scroll_offset + visible { + self.model_browser.provider_scroll_offset = self.model_browser.provider_index + 1 - visible; + } + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + FocusedPanel::Models => { + let models = self.model_browser.filtered_models(); + if !models.is_empty() && self.model_browser.model_index + 1 < models.len() { + self.model_browser.model_index += 1; + let visible = 14usize; + if self.model_browser.model_index >= self.model_browser.model_scroll_offset + visible { + self.model_browser.model_scroll_offset = self.model_browser.model_index + 1 - visible; + } + } + } + } + } + KeyCode::PageUp => { + match self.model_browser.focused_panel { + FocusedPanel::Providers => { + self.model_browser.provider_index = self.model_browser.provider_index.saturating_sub(12); + self.model_browser.provider_scroll_offset = self.model_browser.provider_index; + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + FocusedPanel::Models => { + self.model_browser.model_index = self.model_browser.model_index.saturating_sub(12); + if self.model_browser.model_index < self.model_browser.model_scroll_offset { + self.model_browser.model_scroll_offset = self.model_browser.model_index; + } + } + } + } + KeyCode::PageDown => { + match self.model_browser.focused_panel { + FocusedPanel::Providers => { + let total_provs = 1 + self.model_browser.filtered_providers().len(); + self.model_browser.provider_index = (self.model_browser.provider_index + 12).min(total_provs.saturating_sub(1)); + let visible = 14usize; + if self.model_browser.provider_index >= self.model_browser.provider_scroll_offset + visible { + self.model_browser.provider_scroll_offset = self.model_browser.provider_index + 1 - visible; + } + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + FocusedPanel::Models => { + let models = self.model_browser.filtered_models(); + if !models.is_empty() { + self.model_browser.model_index = (self.model_browser.model_index + 12).min(models.len() - 1); + let visible = 14usize; + if self.model_browser.model_index >= self.model_browser.model_scroll_offset + visible { + self.model_browser.model_scroll_offset = self.model_browser.model_index + 1 - visible; + } + } + } + } + } + KeyCode::Home => { + match self.model_browser.focused_panel { + FocusedPanel::Providers => { + self.model_browser.provider_index = 0; + self.model_browser.provider_scroll_offset = 0; + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + FocusedPanel::Models => { + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + } + } + KeyCode::End => { + match self.model_browser.focused_panel { + FocusedPanel::Providers => { + let total_provs = 1 + self.model_browser.filtered_providers().len(); + self.model_browser.provider_index = total_provs.saturating_sub(1); + let visible = 14usize; + if self.model_browser.provider_index >= visible { + self.model_browser.provider_scroll_offset = self.model_browser.provider_index + 1 - visible; + } + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + FocusedPanel::Models => { + let models = self.model_browser.filtered_models(); + if !models.is_empty() { + self.model_browser.model_index = models.len() - 1; + let visible = 14usize; + if self.model_browser.model_index >= visible { + self.model_browser.model_scroll_offset = self.model_browser.model_index + 1 - visible; + } + } + } + } + } + KeyCode::Enter => { + if let Some(m) = self.model_browser.current_model() { + let m_clone = m.clone(); + let has_key = m.provider_id == "ollama" || { + let mut found = false; + for env_k in &m.env_keys { + if let Ok(v) = std::env::var(env_k) { + if !v.trim().is_empty() { + found = true; + break; + } + } + } + if !found { + let cred = CredentialStore::new(); + if let Some(k) = cred.get_api_key(&m.provider_id) { + if !k.trim().is_empty() { + found = true; + } + } + } + found + }; + + if has_key { + let _ = self.config_manager.save(&ModelConfig { + provider: m_clone.provider_id.clone(), + model: m_clone.id.clone(), + updated_at: Some(chrono::Utc::now().to_rfc3339()), + theme: Some(crate::ui::theme::active().id.to_string()), + }); + self.active_config = self.config_manager.load(); + self.welcome_status_message = Some(format!("Connected to {} ({})", m_clone.name, m_clone.id)); + self.screen = ActiveScreen::Welcome; + } else { + self.model_browser.pending_model = Some(m_clone); + self.model_browser.api_key_input.clear(); + self.model_browser.validation_error = None; + self.model_browser.validating = false; + self.model_browser.api_key_modal_open = true; + } + } + } + _ => {} + } + } + + pub fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) { + if self.screen == ActiveScreen::Session { + match mouse.kind { + crossterm::event::MouseEventKind::ScrollUp => { + self.session_scroll_offset += 2; + } + crossterm::event::MouseEventKind::ScrollDown => { + self.session_scroll_offset = self.session_scroll_offset.saturating_sub(2); + } + _ => {} + } + } + } + + fn handle_session_key(&mut self, key: KeyEvent) { + if self.is_streaming { + match key.code { + KeyCode::Up => { + self.session_scroll_offset += 2; + } + KeyCode::Down => { + self.session_scroll_offset = self.session_scroll_offset.saturating_sub(2); + } + KeyCode::PageUp => { + self.session_scroll_offset += 15; + } + KeyCode::PageDown => { + self.session_scroll_offset = self.session_scroll_offset.saturating_sub(15); + } + KeyCode::Home => { + self.session_scroll_offset = usize::MAX / 2; + } + KeyCode::End => { + self.session_scroll_offset = 0; + } + _ => {} + } + return; + } + + let is_dropdown_open = self.session_input.starts_with('/') && !self.session_input.contains(' '); + let all_commands = commands(); + let matching_cmds = if is_dropdown_open { + all_commands + .iter() + .filter(|cmd| cmd.name.starts_with(&self.session_input)) + .collect::>() + } else { + Vec::new() + }; + + if is_dropdown_open { + match key.code { + KeyCode::Esc => { + self.session_input.clear(); + self.session_cursor = 0; + self.session_dropdown_index = 0; + return; + } + KeyCode::Up => { + if !matching_cmds.is_empty() { + if self.session_dropdown_index == 0 { + self.session_dropdown_index = matching_cmds.len() - 1; + } else { + self.session_dropdown_index -= 1; + } + } + return; + } + KeyCode::Down => { + if !matching_cmds.is_empty() { + if self.session_dropdown_index + 1 >= matching_cmds.len() { + self.session_dropdown_index = 0; + } else { + self.session_dropdown_index += 1; + } + } + return; + } + KeyCode::Tab => { + if !matching_cmds.is_empty() { + let cmd_name = matching_cmds[self.session_dropdown_index.min(matching_cmds.len() - 1)].name; + self.session_input = format!("{} ", cmd_name); + self.session_cursor = self.session_input.chars().count(); + } + return; + } + KeyCode::Enter => { + if !matching_cmds.is_empty() { + let cmd_name = matching_cmds[self.session_dropdown_index.min(matching_cmds.len() - 1)].name; + self.session_input.clear(); + self.session_cursor = 0; + self.session_dropdown_index = 0; + if self.execute_command(cmd_name) { + return; + } else { + self.session_input = format!("{} ", cmd_name); + self.session_cursor = self.session_input.chars().count(); + return; + } + } + } + _ => {} + } + } + + match key.code { + KeyCode::Esc => { + self.screen = ActiveScreen::Welcome; + } + KeyCode::Up => { + let w = self.get_session_input_width(); + if let Some(new_cur) = crate::ui::input::cursor_up_in_input(&self.session_input, self.session_cursor, w) { + self.session_cursor = new_cur; + } else { + self.session_scroll_offset += 2; + } + } + KeyCode::Down => { + let w = self.get_session_input_width(); + if let Some(new_cur) = crate::ui::input::cursor_down_in_input(&self.session_input, self.session_cursor, w) { + self.session_cursor = new_cur; + } else { + self.session_scroll_offset = self.session_scroll_offset.saturating_sub(2); + } + } + KeyCode::PageUp => { + self.session_scroll_offset += 15; + } + KeyCode::PageDown => { + self.session_scroll_offset = self.session_scroll_offset.saturating_sub(15); + } + KeyCode::Left => { + self.session_cursor = self.session_cursor.saturating_sub(1); + } + KeyCode::Right => { + let char_count = self.session_input.chars().count(); + if self.session_cursor < char_count { + self.session_cursor += 1; + } + } + KeyCode::Home => { + if self.session_input.is_empty() { + self.session_scroll_offset = usize::MAX / 2; + } else { + self.session_cursor = 0; + } + } + KeyCode::End => { + if self.session_input.is_empty() { + self.session_scroll_offset = 0; + } else { + self.session_cursor = self.session_input.chars().count(); + } + } + KeyCode::Delete => { + delete_at(&mut self.session_input, self.session_cursor); + self.session_dropdown_index = 0; + } + KeyCode::Backspace => { + backspace_at(&mut self.session_input, &mut self.session_cursor); + self.session_dropdown_index = 0; + } + KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) || key.modifiers.contains(KeyModifiers::ALT) => { + insert_char_at(&mut self.session_input, &mut self.session_cursor, '\n'); + self.session_dropdown_index = 0; + } + KeyCode::Enter => { + let prompt = self.session_input.trim().to_string(); + if prompt.is_empty() { + return; + } + self.session_input.clear(); + self.session_cursor = 0; + self.session_dropdown_index = 0; + if self.execute_command(&prompt) { + return; + } + self.dispatch_turn(prompt); + } + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.session_cursor = 0; + } + KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.session_cursor = self.session_input.chars().count(); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + clear_to_start(&mut self.session_input, &mut self.session_cursor); + self.session_dropdown_index = 0; + } + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { + delete_word_backward(&mut self.session_input, &mut self.session_cursor); + self.session_dropdown_index = 0; + } + KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let byte_pos = self.session_input.char_indices().nth(self.session_cursor).map(|(i, _)| i).unwrap_or(self.session_input.len()); + self.session_input.truncate(byte_pos); + self.session_dropdown_index = 0; + } + KeyCode::Char(c) => { + insert_char_at(&mut self.session_input, &mut self.session_cursor, c); + self.session_dropdown_index = 0; + } + _ => {} + } + } + + pub fn handle_paste(&mut self, text: &str) { + self.spinner.reset_blink(); + match self.screen { + ActiveScreen::Welcome => { + insert_str_at(&mut self.welcome_input, &mut self.welcome_cursor, text); + self.welcome_dropdown_index = 0; + self.welcome_status_message = None; + } + ActiveScreen::ModelFlow => { + if self.model_browser.api_key_modal_open { + self.model_browser.api_key_input.push_str(text); + } else if self.model_browser.is_searching { + match self.model_browser.focused_panel { + FocusedPanel::Providers => { + self.model_browser.provider_search.push_str(text); + let filtered = self.model_browser.filtered_providers(); + if !filtered.is_empty() { + self.model_browser.provider_index = 1; + } else { + self.model_browser.provider_index = 0; + } + self.model_browser.provider_scroll_offset = 0; + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + FocusedPanel::Models => { + self.model_browser.model_search.push_str(text); + self.model_browser.search_query.push_str(text); + self.model_browser.model_index = 0; + self.model_browser.model_scroll_offset = 0; + } + } + } + } + ActiveScreen::Session => { + if !self.is_streaming { + insert_str_at(&mut self.session_input, &mut self.session_cursor, text); + self.session_dropdown_index = 0; + } + } + } + } + + pub fn start_session(&mut self, initial_prompt: String) { + self.screen = ActiveScreen::Session; + self.session_cursor = 0; + self.session_scroll_offset = 0; + self.session_dropdown_index = 0; + self.reconnecting = false; + + let cfg = match &self.active_config { + Some(c) => c.clone(), + None => return, + }; + + let rpc = self.rpc_client.clone(); + let tx = self.stream_tx.clone(); + let prompt_clone = initial_prompt.clone(); + + let model_name = cfg.model.clone(); + let new_turn = ConversationTurn::new( + format!("turn_{}", chrono::Utc::now().timestamp_millis()), + initial_prompt, + model_name, + ); + self.turns.push(new_turn); + self.is_streaming = true; + + tokio::spawn(async move { + // The CLI has already made a server ready; this only guards the instant + // between it registering and the socket actually accepting. Starting a + // server is the CLI's job, never this one's. + if !rpc.wait_for_server().await { + let _ = tx.send(StreamEvent::Failed(format!( + "Could not connect to the CodeWork server at {}", + rpc.url() + ))); + return; + } + + match rpc.create_session(&cfg.provider, &cfg.model, None, Some("high".to_string())).await { + Ok(info) => { + let s_id = info.id.clone(); + let _ = tx.send(StreamEvent::SessionCreated(info)); + let _ = rpc.prompt_session(s_id, prompt_clone, tx).await; + } + Err(err) => { + let _ = tx.send(StreamEvent::Failed(err)); + } + } + }); + } + + pub fn dispatch_turn(&mut self, prompt: String) { + let cfg = match &self.active_config { + Some(c) => c.clone(), + None => return, + }; + + let model_name = cfg.model.clone(); + let new_turn = ConversationTurn::new( + format!("turn_{}", chrono::Utc::now().timestamp_millis()), + prompt.clone(), + model_name, + ); + self.turns.push(new_turn); + self.is_streaming = true; + self.session_scroll_offset = 0; + self.reconnecting = false; + + let rpc = self.rpc_client.clone(); + let tx = self.stream_tx.clone(); + let existing_sess_id = self.session_info.as_ref().map(|s| s.id.clone()); + + tokio::spawn(async move { + let sess_id = match existing_sess_id { + Some(id) => id, + None => match rpc.create_session(&cfg.provider, &cfg.model, None, Some("high".to_string())).await { + Ok(info) => { + let id = info.id.clone(); + let _ = tx.send(StreamEvent::SessionCreated(info)); + id + } + Err(e) => { + let _ = tx.send(StreamEvent::Failed(e)); + return; + } + }, + }; + let _ = rpc.prompt_session(sess_id, prompt, tx).await; + }); + } +} + +fn insert_char_at(s: &mut String, cursor: &mut usize, c: char) { + let byte_pos = s.char_indices().nth(*cursor).map(|(i, _)| i).unwrap_or(s.len()); + s.insert(byte_pos, c); + *cursor += 1; +} + +fn insert_str_at(s: &mut String, cursor: &mut usize, text: &str) { + let byte_pos = s.char_indices().nth(*cursor).map(|(i, _)| i).unwrap_or(s.len()); + s.insert_str(byte_pos, text); + *cursor += text.chars().count(); +} + +fn backspace_at(s: &mut String, cursor: &mut usize) { + if *cursor > 0 { + *cursor -= 1; + let byte_pos = s.char_indices().nth(*cursor).map(|(i, _)| i).unwrap_or(s.len()); + if let Some((_, ch)) = s[byte_pos..].char_indices().next() { + s.drain(byte_pos..byte_pos + ch.len_utf8()); + } + } +} + +fn delete_at(s: &mut String, cursor: usize) { + let char_count = s.chars().count(); + if cursor < char_count { + let byte_pos = s.char_indices().nth(cursor).map(|(i, _)| i).unwrap_or(s.len()); + if let Some((_, ch)) = s[byte_pos..].char_indices().next() { + s.drain(byte_pos..byte_pos + ch.len_utf8()); + } + } +} + +fn delete_word_backward(s: &mut String, cursor: &mut usize) { + if *cursor == 0 { + return; + } + let chars: Vec = s.chars().collect(); + let mut new_cursor = *cursor; + while new_cursor > 0 && chars[new_cursor - 1].is_whitespace() { + new_cursor -= 1; + } + while new_cursor > 0 && !chars[new_cursor - 1].is_whitespace() { + new_cursor -= 1; + } + let remove_count = *cursor - new_cursor; + let start_byte = s.char_indices().nth(new_cursor).map(|(i, _)| i).unwrap_or(s.len()); + let end_byte = start_byte + + chars[new_cursor..new_cursor + remove_count] + .iter() + .map(|c| c.len_utf8()) + .sum::(); + s.drain(start_byte..end_byte); + *cursor = new_cursor; +} + +fn clear_to_start(s: &mut String, cursor: &mut usize) { + let byte_pos = s.char_indices().nth(*cursor).map(|(i, _)| i).unwrap_or(s.len()); + s.drain(0..byte_pos); + *cursor = 0; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_text_editing_operations() { + let mut s = String::new(); + let mut cursor = 0; + + // Typing fast + for c in "hello world".chars() { + insert_char_at(&mut s, &mut cursor, c); + } + assert_eq!(s, "hello world"); + assert_eq!(cursor, 11); + + // Move cursor back + cursor = 5; + insert_char_at(&mut s, &mut cursor, ','); + assert_eq!(s, "hello, world"); + assert_eq!(cursor, 6); + + // Backspace + backspace_at(&mut s, &mut cursor); + assert_eq!(s, "hello world"); + assert_eq!(cursor, 5); + + // Delete char at cursor (' ') + delete_at(&mut s, cursor); + assert_eq!(s, "helloworld"); + assert_eq!(cursor, 5); + + // Paste + insert_str_at(&mut s, &mut cursor, " beautiful "); + assert_eq!(s, "hello beautiful world"); + assert_eq!(cursor, 16); + + // Delete word backward + delete_word_backward(&mut s, &mut cursor); + assert_eq!(s, "hello world"); + assert_eq!(cursor, 6); + + // Clear to start + clear_to_start(&mut s, &mut cursor); + assert_eq!(s, "world"); + assert_eq!(cursor, 0); + } + + #[test] + fn test_unicode_text_editing() { + let mut s = String::new(); + let mut cursor = 0; + + insert_char_at(&mut s, &mut cursor, '🚀'); + insert_char_at(&mut s, &mut cursor, ' '); + insert_char_at(&mut s, &mut cursor, 'h'); + insert_char_at(&mut s, &mut cursor, 'i'); + assert_eq!(s, "🚀 hi"); + assert_eq!(cursor, 4); + + // Backspace 'i' + backspace_at(&mut s, &mut cursor); + assert_eq!(s, "🚀 h"); + assert_eq!(cursor, 3); + + // Backspace 'h', ' ', '🚀' + backspace_at(&mut s, &mut cursor); + backspace_at(&mut s, &mut cursor); + backspace_at(&mut s, &mut cursor); + assert_eq!(s, ""); + assert_eq!(cursor, 0); + } + + #[tokio::test] + async fn test_model_browser_full_flow() { + let mut app = App::new(); + assert_eq!(app.screen, ActiveScreen::Welcome); + + // Type /model and press Enter + app.welcome_input = "/model".to_string(); + app.handle_key(KeyEvent::from(KeyCode::Enter)); + assert_eq!(app.screen, ActiveScreen::ModelFlow); + + // Check initial state: 6001 models, focused on Models panel + assert_eq!(app.model_browser.focused_panel, FocusedPanel::Models); + assert_eq!(app.model_browser.filtered_models().len(), 6001); + + // Tab switches to Providers panel + app.handle_key(KeyEvent::from(KeyCode::Tab)); + assert_eq!(app.model_browser.focused_panel, FocusedPanel::Providers); + + // Arrow down moves to first provider + app.handle_key(KeyEvent::from(KeyCode::Down)); + assert_eq!(app.model_browser.provider_index, 1); + let models_for_p1 = app.model_browser.filtered_models(); + assert!(!models_for_p1.is_empty() && models_for_p1.len() < 6001); + + // Tab back to Models panel + app.handle_key(KeyEvent::from(KeyCode::Tab)); + assert_eq!(app.model_browser.focused_panel, FocusedPanel::Models); + + // Press / to search + app.handle_key(KeyEvent::from(KeyCode::Char('/'))); + assert!(app.model_browser.is_searching); + + for c in "claude".chars() { + app.handle_key(KeyEvent::from(KeyCode::Char(c))); + } + assert_eq!(app.model_browser.search_query, "claude"); + + // Enter finishes typing and focuses Models panel + app.handle_key(KeyEvent::from(KeyCode::Enter)); + assert!(!app.model_browser.is_searching); + assert_eq!(app.model_browser.focused_panel, FocusedPanel::Models); + + // Cycle sort with s + app.handle_key(KeyEvent::from(KeyCode::Char('s'))); + assert_eq!(app.model_browser.sort_mode, ModelSortMode::Provider); + + // Press Esc clears search filter + app.handle_key(KeyEvent::from(KeyCode::Esc)); + assert_eq!(app.model_browser.search_query, ""); + + // Press q exits back to Welcome screen + app.handle_key(KeyEvent::from(KeyCode::Char('q'))); + assert_eq!(app.screen, ActiveScreen::Welcome); + } + + #[tokio::test] + async fn test_provider_search_flow() { + let mut app = App::new(); + app.welcome_input = "/model".to_string(); + app.handle_key(KeyEvent::from(KeyCode::Enter)); + assert_eq!(app.screen, ActiveScreen::ModelFlow); + + // Switch to Providers panel + app.handle_key(KeyEvent::from(KeyCode::Tab)); + assert_eq!(app.model_browser.focused_panel, FocusedPanel::Providers); + + // Press / to search provider + app.handle_key(KeyEvent::from(KeyCode::Char('/'))); + assert!(app.model_browser.is_searching); + + for c in "openai".chars() { + app.handle_key(KeyEvent::from(KeyCode::Char(c))); + } + assert_eq!(app.model_browser.provider_search, "openai"); + assert_eq!(app.model_browser.provider_index, 1); + assert_eq!(app.model_browser.selected_provider_id(), Some("openai")); + + // Models panel now filtered to openai models + let models = app.model_browser.filtered_models(); + assert!(!models.is_empty()); + for m in &models { + assert_eq!(m.provider_id, "openai"); + } + + // Enter stops search typing but keeps provider selected + app.handle_key(KeyEvent::from(KeyCode::Enter)); + assert!(!app.model_browser.is_searching); + assert_eq!(app.model_browser.provider_search, "openai"); + + // Esc clears provider filter and resets to All + app.handle_key(KeyEvent::from(KeyCode::Esc)); + assert_eq!(app.model_browser.provider_search, ""); + assert_eq!(app.model_browser.provider_index, 0); + assert_eq!(app.model_browser.filtered_models().len(), 6001); + } + + #[tokio::test] + async fn test_session_scrolling() { + let mut app = App::new(); + app.screen = ActiveScreen::Session; + assert_eq!(app.session_scroll_offset, 0); + + // Press Up scrolls up + app.handle_key(KeyEvent::from(KeyCode::Up)); + assert_eq!(app.session_scroll_offset, 2); + + // PageUp scrolls up 15 lines + app.handle_key(KeyEvent::from(KeyCode::PageUp)); + assert_eq!(app.session_scroll_offset, 17); + + // Down scrolls back down + app.handle_key(KeyEvent::from(KeyCode::Down)); + assert_eq!(app.session_scroll_offset, 15); + + // PageDown scrolls back down + app.handle_key(KeyEvent::from(KeyCode::PageDown)); + assert_eq!(app.session_scroll_offset, 0); + + // Down at bottom stays at 0 + app.handle_key(KeyEvent::from(KeyCode::Down)); + assert_eq!(app.session_scroll_offset, 0); + + // Mouse scroll + app.handle_mouse(crossterm::event::MouseEvent { + kind: crossterm::event::MouseEventKind::ScrollUp, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(app.session_scroll_offset, 2); + + // End resets to 0 (when input empty) + app.handle_key(KeyEvent::from(KeyCode::End)); + assert_eq!(app.session_scroll_offset, 0); + } + + #[tokio::test] + async fn test_session_command_dropdown() { + let mut app = App::new(); + app.screen = ActiveScreen::Session; + assert_eq!(app.session_input, ""); + + // Typing '/' opens the command dropdown + app.handle_key(KeyEvent::from(KeyCode::Char('/'))); + assert_eq!(app.session_input, "/"); + assert_eq!(app.session_dropdown_index, 0); + + // Pressing Down navigates to next command + app.handle_key(KeyEvent::from(KeyCode::Down)); + assert_eq!(app.session_dropdown_index, 1); + + // Pressing Up navigates back + app.handle_key(KeyEvent::from(KeyCode::Up)); + assert_eq!(app.session_dropdown_index, 0); + + // Typing 'm' filters commands + app.handle_key(KeyEvent::from(KeyCode::Char('m'))); + assert_eq!(app.session_input, "/m"); + + // Pressing Tab auto-completes the selected command + app.handle_key(KeyEvent::from(KeyCode::Tab)); + assert_eq!(app.session_input, "/model "); + + // Pressing Esc clears the input and closes dropdown + app.session_input = "/".to_string(); + app.session_cursor = 1; + app.handle_key(KeyEvent::from(KeyCode::Esc)); + assert_eq!(app.session_input, ""); + assert_eq!(app.screen, ActiveScreen::Session); + + // Executing /clear clears turns + let mut turn = ConversationTurn::new("1".to_string(), "hello".to_string(), "model".to_string()); + turn.response = "world".to_string(); + turn.streaming = false; + app.turns.push(turn); + assert_eq!(app.turns.len(), 1); + app.session_input = "/clear".to_string(); + app.session_cursor = 6; + app.handle_key(KeyEvent::from(KeyCode::Enter)); + assert!(app.turns.is_empty()); + assert_eq!(app.session_input, ""); + } + + #[tokio::test] + async fn test_multiline_session_input_and_navigation() { + let mut app = App::new(); + app.screen = ActiveScreen::Session; + app.session_input = "hello world".to_string(); + app.session_cursor = 11; + + // Shift+Enter inserts newline + let shift_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); + app.handle_key(shift_enter); + assert_eq!(app.session_input, "hello world\n"); + assert_eq!(app.session_cursor, 12); + + // Type second line + for c in "next line".chars() { + app.handle_key(KeyEvent::from(KeyCode::Char(c))); + } + assert_eq!(app.session_input, "hello world\nnext line"); + assert_eq!(app.session_cursor, 21); + + // Press Up should move cursor to line 1 (col 9, which is 'r') instead of scrolling chat + let initial_scroll = app.session_scroll_offset; + app.handle_key(KeyEvent::from(KeyCode::Up)); + assert_eq!(app.session_scroll_offset, initial_scroll); + assert_eq!(app.session_cursor, 9); // col 9 on "hello world" + + // Press Up again from line 0 should scroll chat history + app.handle_key(KeyEvent::from(KeyCode::Up)); + assert_eq!(app.session_scroll_offset, initial_scroll + 2); + + // Press Down moves cursor to line 1 + app.handle_key(KeyEvent::from(KeyCode::Down)); + assert_eq!(app.session_cursor, 12 + 9); // col 9 on "next line" (12 + 9 = 21) + + // Press Down again from last line scrolls chat down + app.handle_key(KeyEvent::from(KeyCode::Down)); + assert_eq!(app.session_scroll_offset, initial_scroll); + } +} + + diff --git a/packages/tui/src/binary.ts b/packages/tui/src/binary.ts new file mode 100644 index 00000000..7a65632f --- /dev/null +++ b/packages/tui/src/binary.ts @@ -0,0 +1,79 @@ +/* @effect-diagnostics nodeBuiltinImport:off -- the launcher resolves a file on the host. */ +import { createRequire } from "node:module"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); + +/** The Rust binary, by the package that publishes a prebuilt for this platform. */ +export interface Platform { + readonly os: "darwin" | "linux" | "win32"; + readonly arch: "arm64" | "x64"; +} + +/** Resolve the current platform, or `undefined` where no prebuilt is published. */ +export function platform(): Platform | undefined { + const os = process.platform; + const arch = process.arch; + if (os !== "darwin" && os !== "linux" && os !== "win32") return undefined; + if (arch !== "arm64" && arch !== "x64") return undefined; + return { os, arch }; +} + +const packageFor = (value: Platform) => `@codeworksh/tui-${value.os}-${value.arch}`; +const binaryName = (value: Platform) => (value.os === "win32" ? "codework-tui.exe" : "codework-tui"); + +/** A source-tree build, used when neither an override nor a platform package is present. */ +function devBuild(name: string): string | undefined { + for (const profile of ["release", "debug"]) { + const candidate = fileURLToPath(new URL(`../target/${profile}/${name}`, import.meta.url)); + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +export class LauncherError extends Error {} + +/** + * The `codework-tui` executable to run. + * + * Resolution order, most specific first: + * 1. `CODEWORK_TUI_BIN` — an explicit path, for a local build or a test. + * 2. The platform package's `bin/` — what a normal install ships. + * 3. A `target/{release,debug}` build in the source tree, so `codework tui` + * works from a checkout without publishing anything. + * + * Nothing is ever downloaded: a missing binary is an error that names the + * package to install, because a silent runtime fetch is both surprising and a + * supply-chain surface. + */ +export function resolveBinary(env: NodeJS.ProcessEnv = process.env): string { + const override = env["CODEWORK_TUI_BIN"]; + if (override !== undefined && override.length > 0) { + if (!existsSync(override)) throw new LauncherError(`CODEWORK_TUI_BIN points at a missing file: ${override}`); + return override; + } + + const current = platform(); + const name = current === undefined ? "codework-tui" : binaryName(current); + if (current !== undefined) { + try { + // Resolve through the package's own manifest so the binary path is not part of + // the platform package's public `exports` surface. + const manifest = require.resolve(`${packageFor(current)}/package.json`); + return join(dirname(manifest), "bin", name); + } catch { + // Fall through to the source-tree build below. + } + } + + const dev = devBuild(name); + if (dev !== undefined) return dev; + + throw new LauncherError( + current === undefined + ? `no prebuilt CodeWork TUI is published for ${process.platform}-${process.arch}` + : `the CodeWork TUI binary is missing. Install ${packageFor(current)}, or build it with \`cargo build --release\` in packages/tui`, + ); +} diff --git a/packages/tui/src/catalog.rs b/packages/tui/src/catalog.rs new file mode 100644 index 00000000..0b07b936 --- /dev/null +++ b/packages/tui/src/catalog.rs @@ -0,0 +1,930 @@ +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; +use std::sync::OnceLock; +use std::time::Duration; +use crate::credentials::{get_codework_dir, CredentialStore}; +use crate::types::{ModelCost, ModelDetails, ModelEntry, ProviderDefinition, ProviderSummary}; + +static CATALOG_JSON: OnceLock> = OnceLock::new(); +static ALL_MODELS: OnceLock> = OnceLock::new(); +static ALL_PROVIDERS: OnceLock> = OnceLock::new(); + +fn get_catalog_data() -> Option<&'static serde_json::Value> { + CATALOG_JSON.get_or_init(|| { + let mut candidates = Vec::new(); + if let Ok(p) = std::env::var("CODEWORK_MODELS_FILE") { + candidates.push(PathBuf::from(p)); + } + candidates.push(get_codework_dir().join("models.gen.json")); + candidates.push(PathBuf::from("packages/aikit/models.gen.json")); + candidates.push(PathBuf::from("../aikit/models.gen.json")); + candidates.push(PathBuf::from("models.gen.json")); + candidates.push(PathBuf::from("../models.gen.json")); + candidates.push(PathBuf::from("../../models.gen.json")); + candidates.push(PathBuf::from("packages/codework/.codework/models.gen.json")); + + if let Ok(cwd) = std::env::current_dir() { + candidates.push(cwd.join("packages/aikit/models.gen.json")); + candidates.push(cwd.join("models.gen.json")); + candidates.push(cwd.join("../models.gen.json")); + candidates.push(cwd.join("../../models.gen.json")); + candidates.push(cwd.join(".codework/models.gen.json")); + candidates.push(cwd.join("../packages/aikit/models.gen.json")); + } + + for path in candidates { + if path.exists() { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(val) = serde_json::from_str::(&content) { + return Some(val); + } + } + } + } + None + }).as_ref() +} + +pub fn get_all_models() -> &'static [ModelDetails] { + ALL_MODELS.get_or_init(|| { + let mut list = Vec::new(); + if let Some(root) = get_catalog_data().and_then(|v| v.as_object()) { + for (prov_id, prov_val) in root { + if let Some(models_map) = prov_val.as_object() { + for (model_key, model_val) in models_map { + let id = model_val + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or(model_key) + .to_string(); + let name = model_val + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(&id) + .to_string(); + + let (provider_id, provider_name, env_keys) = if let Some(p) = model_val.get("provider").and_then(|v| v.as_object()) { + let p_id = p.get("id").and_then(|v| v.as_str()).unwrap_or(prov_id).to_string(); + let p_name = p.get("name").and_then(|v| v.as_str()).unwrap_or(&p_id).to_string(); + let env = if let Some(arr) = p.get("env").and_then(|v| v.as_array()) { + arr.iter().filter_map(|e| e.as_str().map(|s| s.to_string())).collect() + } else { + default_env_for_provider(&p_id) + }; + (p_id, p_name, env) + } else { + (prov_id.to_string(), prov_id.to_string(), default_env_for_provider(prov_id)) + }; + + let base_url = model_val + .get("baseUrl") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let npm = model_val + .get("npm") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let reasoning = model_val + .get("reasoning") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let input_modalities: Vec = if let Some(arr) = model_val.get("input").and_then(|v| v.as_array()) { + arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect() + } else { + vec!["text".to_string()] + }; + + let cost = model_val.get("cost").and_then(|c| { + let input = c.get("input").and_then(|v| v.as_f64()); + let output = c.get("output").and_then(|v| v.as_f64()); + let cache_read = c.get("cacheRead").and_then(|v| v.as_f64()); + let cache_write = c.get("cacheWrite").and_then(|v| v.as_f64()); + if input.is_some() || output.is_some() || cache_read.is_some() || cache_write.is_some() { + Some(ModelCost { input, output, cache_read, cache_write }) + } else { + None + } + }); + + let context_window = model_val.get("contextWindow").and_then(|v| v.as_u64()); + let max_tokens = model_val.get("maxTokens").and_then(|v| v.as_u64()); + let family = infer_family(&id, &name); + let docs_url = Some(docs_url_for_provider(&provider_id, base_url.as_deref())); + let released = extract_date(&id).or_else(|| extract_date(&name)); + let updated = released.clone(); + + list.push(ModelDetails { + id, + name, + provider_id, + provider_name, + env_keys, + base_url, + npm, + reasoning, + input_modalities, + cost, + context_window, + max_tokens, + family, + status: "active".to_string(), + docs_url, + released, + updated, + }); + } + } + } + } + list.sort_by(|a, b| a.id.cmp(&b.id)); + list + }) +} + +pub fn get_all_providers() -> &'static [ProviderSummary] { + ALL_PROVIDERS.get_or_init(|| { + let models = get_all_models(); + let mut map: std::collections::BTreeMap, Option, Option, Option)> = std::collections::BTreeMap::new(); + + for m in models { + let entry = map.entry(m.provider_id.clone()).or_insert_with(|| { + ( + m.provider_name.clone(), + 0, + m.env_keys.clone(), + m.base_url.clone(), + m.npm.clone(), + m.docs_url.clone(), + ) + }); + entry.1 += 1; + if entry.3.is_none() && m.base_url.is_some() { + entry.3 = m.base_url.clone(); + } + if entry.4.is_none() && m.npm.is_some() { + entry.4 = m.npm.clone(); + } + } + + let mut providers = Vec::new(); + for (id, (name, count, env_keys, base_url, npm, docs_url)) in map { + providers.push(ProviderSummary { + id, + name, + model_count: count, + env_keys, + base_url, + npm, + docs_url, + }); + } + providers.sort_by(|a, b| a.id.cmp(&b.id)); + providers + }) +} + +pub fn default_env_for_provider(provider_id: &str) -> Vec { + match provider_id { + "google" => vec!["GEMINI_API_KEY".to_string(), "GOOGLE_GENERATIVE_AI_API_KEY".to_string(), "GOOGLE_API_KEY".to_string()], + "openai" => vec!["OPENAI_API_KEY".to_string()], + "anthropic" => vec!["ANTHROPIC_API_KEY".to_string()], + "openrouter" => vec!["OPENROUTER_API_KEY".to_string()], + "groq" => vec!["GROQ_API_KEY".to_string()], + "deepseek" => vec!["DEEPSEEK_API_KEY".to_string()], + "mistral" => vec!["MISTRAL_API_KEY".to_string()], + "cohere" => vec!["COHERE_API_KEY".to_string()], + "together" | "together-ai" => vec!["TOGETHER_API_KEY".to_string()], + "fireworks" | "fireworks-ai" => vec!["FIREWORKS_API_KEY".to_string()], + "perplexity" | "perplexity-agent" => vec!["PERPLEXITY_API_KEY".to_string()], + "cerebras" => vec!["CEREBRAS_API_KEY".to_string()], + "chutes" => vec!["CHUTES_API_KEY".to_string()], + "baseten" => vec!["BASETEN_API_KEY".to_string()], + "ai21" => vec!["AI21_API_KEY".to_string()], + "alibaba" | "alibaba-cn" => vec!["DASHSCOPE_API_KEY".to_string()], + "abacus" => vec!["ABACUS_API_KEY".to_string()], + "ollama" => vec!["OLLAMA_API_KEY".to_string()], + _ => { + let normalized = provider_id.replace('-', "_").to_uppercase(); + vec![format!("{}_API_KEY", normalized)] + } + } +} + +pub fn docs_url_for_provider(provider_id: &str, base_url: Option<&str>) -> String { + match provider_id { + "anthropic" => "https://docs.anthropic.com".to_string(), + "openai" => "https://platform.openai.com/docs".to_string(), + "google" => "https://ai.google.dev/docs".to_string(), + "abacus" => "https://abacus.ai/help/api".to_string(), + "groq" => "https://console.groq.com/docs".to_string(), + "deepseek" => "https://api-docs.deepseek.com".to_string(), + "mistral" => "https://docs.mistral.ai".to_string(), + "openrouter" => "https://openrouter.ai/docs".to_string(), + "cohere" => "https://docs.cohere.com".to_string(), + "together" | "together-ai" => "https://docs.together.ai".to_string(), + "fireworks" | "fireworks-ai" => "https://docs.fireworks.ai".to_string(), + "perplexity" | "perplexity-agent" => "https://docs.perplexity.ai".to_string(), + "cerebras" => "https://inference-docs.cerebras.net".to_string(), + "azure" | "azure-cognitive-services" => "https://learn.microsoft.com/azure/ai-services".to_string(), + "amazon-bedrock" => "https://docs.aws.amazon.com/bedrock".to_string(), + "cloudflare-workers-ai" | "cloudflare-ai-gateway" => "https://developers.cloudflare.com/workers-ai".to_string(), + "ollama" => "https://ollama.com".to_string(), + _ => { + if let Some(url) = base_url { + if let Some(host) = url.strip_prefix("https://").or_else(|| url.strip_prefix("http://")) { + let domain = host.split('/').next().unwrap_or(host); + return format!("https://{}", domain); + } + } + format!("https://{}.ai", provider_id) + } + } +} + +pub fn infer_family(id: &str, _name: &str) -> String { + let lower = id.to_lowercase(); + if lower.contains("claude-3-7") || lower.contains("claude-3.7") { + "claude-3.7".to_string() + } else if lower.contains("claude-3-5") || lower.contains("claude-3.5") { + "claude-3.5".to_string() + } else if lower.contains("claude") { + "claude".to_string() + } else if lower.contains("gpt-4o") { + "gpt-4o".to_string() + } else if lower.contains("gpt-4") { + "gpt-4".to_string() + } else if lower.contains("gpt-5") { + "gpt-5".to_string() + } else if lower.contains("o1") { + "o1".to_string() + } else if lower.contains("o3") { + "o3".to_string() + } else if lower.contains("gemini-2.5") { + "gemini-2.5".to_string() + } else if lower.contains("gemini-2.0") { + "gemini-2.0".to_string() + } else if lower.contains("gemini-1.5") { + "gemini-1.5".to_string() + } else if lower.contains("gemini") { + "gemini".to_string() + } else if lower.contains("qwen") || lower.contains("qwq") { + "qwen".to_string() + } else if lower.contains("deepseek") { + "deepseek".to_string() + } else if lower.contains("llama") { + "llama".to_string() + } else if lower.contains("mistral") || lower.contains("codestral") { + "mistral".to_string() + } else if lower.contains("command-r") || lower.contains("cohere") { + "cohere".to_string() + } else { + let first = lower.split(|c| c == '/' || c == '-' || c == ':').next().unwrap_or(&lower); + first.to_string() + } +} + +pub fn extract_date(s: &str) -> Option { + for part in s.split(|c| c == '-' || c == '_' || c == '/' || c == '.') { + if part.len() == 8 && part.starts_with("202") && part.chars().all(|c| c.is_ascii_digit()) { + return Some(format!("{}-{}-{}", &part[0..4], &part[4..6], &part[6..8])); + } + } + let bytes = s.as_bytes(); + if bytes.len() >= 10 { + for i in 0..=bytes.len() - 10 { + if bytes[i] == b'2' && bytes[i + 1] == b'0' && bytes[i + 2] == b'2' + && bytes[i + 3].is_ascii_digit() + && bytes[i + 4] == b'-' + && bytes[i + 5].is_ascii_digit() && bytes[i + 6].is_ascii_digit() + && bytes[i + 7] == b'-' + && bytes[i + 8].is_ascii_digit() && bytes[i + 9].is_ascii_digit() + { + return Some(s[i..i + 10].to_string()); + } + } + } + None +} + +pub fn format_context(tokens: Option) -> String { + match tokens { + None | Some(0) => "-".to_string(), + Some(t) if t >= 1_000_000 => { + if t % 1_000_000 == 0 { + format!("{}M", t / 1_000_000) + } else if (1_040_000..=1_050_000).contains(&t) { + "1M".to_string() + } else if (2_090_000..=2_100_000).contains(&t) { + "2M".to_string() + } else if t % 100_000 == 0 { + format!("{:.1}M", t as f64 / 1_000_000.0) + } else { + let m = (t as f64 / 1_000_000.0 * 10.0).round() / 10.0; + if m.fract() == 0.0 { + format!("{:.0}M", m) + } else { + format!("{:.1}M", m) + } + } + } + Some(t) if t >= 1_000 => { + format!("{}k", t / 1_000) + } + Some(t) => format!("{}", t), + } +} + +pub fn clean_cost(val: Option) -> String { + match val { + None => "-".to_string(), + Some(v) if v <= 0.0 => "-".to_string(), + Some(v) if v.fract() == 0.0 => format!("${:.0}", v), + Some(v) if (v * 10.0).fract() == 0.0 => format!("${:.1}", v), + Some(v) if (v * 100.0).fract() == 0.0 => format!("${:.2}", v), + Some(v) => { + let s = format!("${:.4}", v); + s.trim_end_matches('0').trim_end_matches('.').to_string() + } + } +} + +pub fn format_cost_table(cost: Option<&ModelCost>) -> String { + match cost { + None => "-/-".to_string(), + Some(c) => { + let inp = clean_cost(c.input); + let out = clean_cost(c.output); + if inp == "-" && out == "-" { + "-/-".to_string() + } else { + format!("{}/{}", inp, out) + } + } + } +} + +pub fn format_rate(cost: Option) -> String { + match cost { + Some(v) if v > 0.0 => { + let c = clean_cost(Some(v)); + format!("{}/M", c) + } + _ => "-".to_string(), + } +} + +pub fn copy_to_clipboard(text: &str) { + #[cfg(target_os = "macos")] + { + use std::io::Write; + use std::process::{Command, Stdio}; + if let Ok(mut child) = Command::new("pbcopy") + .stdin(Stdio::piped()) + .spawn() + { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); + } + } + + let base64_str = { + const B64_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let bytes = text.as_bytes(); + let mut buf = String::with_capacity((bytes.len() + 2) / 3 * 4); + for chunk in bytes.chunks(3) { + let b0 = chunk[0]; + let b1 = if chunk.len() > 1 { chunk[1] } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] } else { 0 }; + buf.push(B64_CHARS[(b0 >> 2) as usize] as char); + buf.push(B64_CHARS[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + if chunk.len() > 1 { + buf.push(B64_CHARS[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char); + } else { + buf.push('='); + } + if chunk.len() > 2 { + buf.push(B64_CHARS[(b2 & 0x3F) as usize] as char); + } else { + buf.push('='); + } + } + buf + }; + + use std::io::Write; + let mut out = std::io::stdout(); + let _ = write!(out, "\x1b]52;c;{}\x07", base64_str); + let _ = out.flush(); +} + +pub fn open_browser_url(url: &str) { + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("open").arg(url).spawn(); + } + #[cfg(target_os = "linux")] + { + let _ = std::process::Command::new("xdg-open").arg(url).spawn(); + } + #[cfg(target_os = "windows")] + { + let _ = std::process::Command::new("cmd").args(["/C", "start", url]).spawn(); + } +} + +pub const PROVIDERS: &[ProviderDefinition] = &[ + ProviderDefinition { + id: "google", + name: "Google", + description: "Gemini 2.5 Pro, Flash, and Gemma models", + env_keys: &["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_API_KEY"], + default_model: "gemini-2.5-pro", + }, + ProviderDefinition { + id: "openai", + name: "OpenAI", + description: "GPT-4o, o1, o3-mini, and GPT-4 Turbo models", + env_keys: &["OPENAI_API_KEY"], + default_model: "gpt-4o", + }, + ProviderDefinition { + id: "anthropic", + name: "Anthropic", + description: "Claude 3.7 Sonnet, 3.5 Sonnet, and Haiku models", + env_keys: &["ANTHROPIC_API_KEY"], + default_model: "claude-3-7-sonnet-latest", + }, + ProviderDefinition { + id: "openrouter", + name: "OpenRouter", + description: "Multi-provider gateway with unified access", + env_keys: &["OPENROUTER_API_KEY"], + default_model: "anthropic/claude-3.7-sonnet", + }, + ProviderDefinition { + id: "groq", + name: "Groq", + description: "Ultra-low latency Llama and Mixtral inference", + env_keys: &["GROQ_API_KEY"], + default_model: "llama-3.3-70b-versatile", + }, + ProviderDefinition { + id: "deepseek", + name: "DeepSeek", + description: "DeepSeek V3 and R1 reasoning models", + env_keys: &["DEEPSEEK_API_KEY"], + default_model: "deepseek-chat", + }, + ProviderDefinition { + id: "mistral", + name: "Mistral", + description: "Mistral Large, Codestral, and Pixtral models", + env_keys: &["MISTRAL_API_KEY"], + default_model: "codestral-latest", + }, + ProviderDefinition { + id: "ollama", + name: "Ollama (Local)", + description: "Local model execution via Ollama runner", + env_keys: &["OLLAMA_API_KEY"], + default_model: "qwen2.5-coder:latest", + }, +]; + +#[allow(dead_code)] +pub fn get_provider(id: &str) -> Option<&'static ProviderDefinition> { + PROVIDERS.iter().find(|p| p.id == id) +} + +#[allow(dead_code)] +pub fn get_models_for_provider(provider_id: &str) -> Vec { + let mut models = match provider_id { + "google" => vec![ + ModelEntry { + id: "gemini-2.5-pro".to_string(), + name: "Gemini 2.5 Pro".to_string(), + description: Some("State-of-the-art coding and reasoning".to_string()), + is_default: true, + }, + ModelEntry { + id: "gemini-2.5-flash".to_string(), + name: "Gemini 2.5 Flash".to_string(), + description: Some("High speed, balanced capability".to_string()), + is_default: false, + }, + ModelEntry { + id: "gemini-2.0-flash".to_string(), + name: "Gemini 2.0 Flash".to_string(), + description: Some("Fast production-grade model".to_string()), + is_default: false, + }, + ModelEntry { + id: "gemini-1.5-pro".to_string(), + name: "Gemini 1.5 Pro".to_string(), + description: Some("Deep context window and analysis".to_string()), + is_default: false, + }, + ModelEntry { + id: "gemini-1.5-flash".to_string(), + name: "Gemini 1.5 Flash".to_string(), + description: Some("Lightweight and cost-efficient".to_string()), + is_default: false, + }, + ], + "openai" => vec![ + ModelEntry { + id: "gpt-4o".to_string(), + name: "GPT-4o".to_string(), + description: Some("Flagship multimodal intelligence".to_string()), + is_default: true, + }, + ModelEntry { + id: "gpt-4o-mini".to_string(), + name: "GPT-4o Mini".to_string(), + description: Some("Fast, cost-efficient everyday tasks".to_string()), + is_default: false, + }, + ModelEntry { + id: "o1".to_string(), + name: "o1".to_string(), + description: Some("Advanced reasoning and deep math".to_string()), + is_default: false, + }, + ModelEntry { + id: "o3-mini".to_string(), + name: "o3-mini".to_string(), + description: Some("High-speed reasoning and code synthesis".to_string()), + is_default: false, + }, + ModelEntry { + id: "gpt-4-turbo".to_string(), + name: "GPT-4 Turbo".to_string(), + description: Some("High capability GPT-4 generation".to_string()), + is_default: false, + }, + ], + "anthropic" => vec![ + ModelEntry { + id: "claude-3-7-sonnet-latest".to_string(), + name: "Claude 3.7 Sonnet".to_string(), + description: Some("Hybrid reasoning and coding champion".to_string()), + is_default: true, + }, + ModelEntry { + id: "claude-3-5-sonnet-latest".to_string(), + name: "Claude 3.5 Sonnet".to_string(), + description: Some("Industry standard coding model".to_string()), + is_default: false, + }, + ModelEntry { + id: "claude-3-5-haiku-latest".to_string(), + name: "Claude 3.5 Haiku".to_string(), + description: Some("Ultra-fast intelligent responses".to_string()), + is_default: false, + }, + ModelEntry { + id: "claude-3-opus-latest".to_string(), + name: "Claude 3 Opus".to_string(), + description: Some("Deep intellectual analysis".to_string()), + is_default: false, + }, + ], + "openrouter" => vec![ + ModelEntry { + id: "anthropic/claude-3.7-sonnet".to_string(), + name: "Claude 3.7 Sonnet".to_string(), + description: Some("Anthropic via OpenRouter".to_string()), + is_default: true, + }, + ModelEntry { + id: "anthropic/claude-3.5-sonnet".to_string(), + name: "Claude 3.5 Sonnet".to_string(), + description: Some("Anthropic via OpenRouter".to_string()), + is_default: false, + }, + ModelEntry { + id: "openai/gpt-4o".to_string(), + name: "GPT-4o".to_string(), + description: Some("OpenAI via OpenRouter".to_string()), + is_default: false, + }, + ModelEntry { + id: "deepseek/deepseek-r1".to_string(), + name: "DeepSeek R1".to_string(), + description: Some("Open reasoning model".to_string()), + is_default: false, + }, + ModelEntry { + id: "meta-llama/llama-3.3-70b-instruct".to_string(), + name: "Llama 3.3 70B".to_string(), + description: Some("Meta open weights".to_string()), + is_default: false, + }, + ModelEntry { + id: "google/gemini-2.5-pro".to_string(), + name: "Gemini 2.5 Pro".to_string(), + description: Some("Google via OpenRouter".to_string()), + is_default: false, + }, + ], + "groq" => vec![ + ModelEntry { + id: "llama-3.3-70b-versatile".to_string(), + name: "Llama 3.3 70B Versatile".to_string(), + description: Some("Ultra-low latency inference".to_string()), + is_default: true, + }, + ModelEntry { + id: "llama-3.1-8b-instant".to_string(), + name: "Llama 3.1 8B Instant".to_string(), + description: Some("Fast small parameter model".to_string()), + is_default: false, + }, + ModelEntry { + id: "mixtral-8x7b-32768".to_string(), + name: "Mixtral 8x7B".to_string(), + description: Some("High throughput mixture of experts".to_string()), + is_default: false, + }, + ], + "deepseek" => vec![ + ModelEntry { + id: "deepseek-chat".to_string(), + name: "DeepSeek V3".to_string(), + description: Some("High-capability reasoning and general coding".to_string()), + is_default: true, + }, + ModelEntry { + id: "deepseek-reasoner".to_string(), + name: "DeepSeek R1".to_string(), + description: Some("Deep chain-of-thought reasoning model".to_string()), + is_default: false, + }, + ], + "mistral" => vec![ + ModelEntry { + id: "codestral-latest".to_string(), + name: "Codestral".to_string(), + description: Some("Specialized code completion and synthesis".to_string()), + is_default: true, + }, + ModelEntry { + id: "mistral-large-latest".to_string(), + name: "Mistral Large".to_string(), + description: Some("Flagship multilingual model".to_string()), + is_default: false, + }, + ], + "ollama" => vec![ + ModelEntry { + id: "qwen2.5-coder:latest".to_string(), + name: "Qwen 2.5 Coder".to_string(), + description: Some("State-of-the-art open code model".to_string()), + is_default: true, + }, + ModelEntry { + id: "llama3.3:latest".to_string(), + name: "Llama 3.3".to_string(), + description: Some("High capability open weights".to_string()), + is_default: false, + }, + ModelEntry { + id: "deepseek-r1:latest".to_string(), + name: "DeepSeek R1 (Local)".to_string(), + description: Some("Local reasoning model".to_string()), + is_default: false, + }, + ], + _ => Vec::new(), + }; + + let mut seen: HashSet = models.iter().map(|m| m.id.clone()).collect(); + + if let Some(catalog) = get_catalog_data() { + if let Some(provider_data) = catalog.get(provider_id).and_then(|v| v.as_object()) { + for (id, info) in provider_data { + if !seen.contains(id) { + let name = info + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(id) + .to_string(); + + let description = if let Some(desc) = info.get("description").and_then(|v| v.as_str()) { + Some(desc.to_string()) + } else { + let mut parts = Vec::new(); + if let Some(ctx) = info.get("contextWindow").and_then(|v| v.as_u64()) { + if ctx >= 1_000_000 { + parts.push(format!("{:.1}M context", ctx as f64 / 1_000_000.0)); + } else if ctx >= 1_000 { + parts.push(format!("{}k context", ctx / 1000)); + } + } + if info.get("reasoning").and_then(|v| v.as_bool()).unwrap_or(false) { + parts.push("reasoning".to_string()); + } + if !parts.is_empty() { + Some(parts.join(" · ")) + } else { + None + } + }; + + models.push(ModelEntry { + id: id.clone(), + name, + description, + is_default: false, + }); + seen.insert(id.clone()); + } + } + } + } + + models +} + +#[allow(dead_code)] +pub struct KeyResolution { + pub key: String, + pub source: &'static str, // "env" or "store" +} + +#[allow(dead_code)] +pub fn resolve_api_key(provider: &ProviderDefinition) -> Option { + for env_key in provider.env_keys { + if let Ok(val) = std::env::var(env_key) { + let trimmed = val.trim(); + if !trimmed.is_empty() { + return Some(KeyResolution { + key: trimmed.to_string(), + source: "env", + }); + } + } + } + + let store = CredentialStore::new(); + if let Some(key) = store.get_api_key(provider.id) { + let trimmed = key.trim(); + if !trimmed.is_empty() { + return Some(KeyResolution { + key: trimmed.to_string(), + source: "store", + }); + } + } + + None +} + +pub async fn validate_api_key(provider_id: &str, api_key: &str) -> Result<(), String> { + let key = api_key.trim(); + if key.is_empty() && provider_id != "ollama" { + return Err("API key cannot be empty".to_string()); + } + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .map_err(|e| e.to_string())?; + + match provider_id { + "google" => { + let url = format!( + "https://generativelanguage.googleapis.com/v1beta/models?key={}", + key + ); + let res = client.get(&url).send().await.map_err(|e| e.to_string())?; + if res.status().is_success() { + Ok(()) + } else { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + Err(format!("Validation failed (HTTP {}): {}", status, body)) + } + } + "openai" => { + let res = client + .get("https://api.openai.com/v1/models") + .header("Authorization", format!("Bearer {}", key)) + .send() + .await + .map_err(|e| e.to_string())?; + if res.status().is_success() { + Ok(()) + } else { + Err(format!("Invalid OpenAI API key (HTTP {})", res.status())) + } + } + "anthropic" => { + let res = client + .get("https://api.anthropic.com/v1/models") + .header("x-api-key", key) + .header("anthropic-version", "2023-06-01") + .send() + .await + .map_err(|e| e.to_string())?; + if res.status().is_success() { + Ok(()) + } else { + Err(format!("Invalid Anthropic API key (HTTP {})", res.status())) + } + } + "openrouter" => { + let res = client + .get("https://openrouter.ai/api/v1/auth/key") + .header("Authorization", format!("Bearer {}", key)) + .send() + .await + .map_err(|e| e.to_string())?; + if res.status().is_success() { + Ok(()) + } else { + Err(format!("Invalid OpenRouter API key (HTTP {})", res.status())) + } + } + "groq" => { + let res = client + .get("https://api.groq.com/openai/v1/models") + .header("Authorization", format!("Bearer {}", key)) + .send() + .await + .map_err(|e| e.to_string())?; + if res.status().is_success() { + Ok(()) + } else { + Err(format!("Invalid Groq API key (HTTP {})", res.status())) + } + } + "deepseek" => { + let res = client + .get("https://api.deepseek.com/models") + .header("Authorization", format!("Bearer {}", key)) + .send() + .await + .map_err(|e| e.to_string())?; + if res.status().is_success() { + Ok(()) + } else { + Err(format!("Invalid DeepSeek API key (HTTP {})", res.status())) + } + } + "mistral" => { + let res = client + .get("https://api.mistral.ai/v1/models") + .header("Authorization", format!("Bearer {}", key)) + .send() + .await + .map_err(|e| e.to_string())?; + if res.status().is_success() { + Ok(()) + } else { + Err(format!("Invalid Mistral API key (HTTP {})", res.status())) + } + } + "ollama" => Ok(()), + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_load_all_models() { + let google_models = get_models_for_provider("google"); + assert!(google_models.len() >= 20, "Expected >=20 Google models, got {}", google_models.len()); + + let openai_models = get_models_for_provider("openai"); + assert!(openai_models.len() >= 40, "Expected >=40 OpenAI models, got {}", openai_models.len()); + + let openrouter_models = get_models_for_provider("openrouter"); + assert!(openrouter_models.len() >= 300, "Expected >=300 OpenRouter models, got {}", openrouter_models.len()); + + let all_models = get_all_models(); + assert_eq!(all_models.len(), 6001, "Expected 6001 models, got {}", all_models.len()); + + let all_providers = get_all_providers(); + assert_eq!(all_providers.len(), 206, "Expected 206 providers, got {}", all_providers.len()); + + // Test formatting helpers + assert_eq!(format_context(Some(32768)), "32k"); + assert_eq!(format_context(Some(1048576)), "1M"); + assert_eq!(format_context(Some(2000000)), "2M"); + assert_eq!(clean_cost(Some(0.4)), "$0.4"); + assert_eq!(clean_cost(Some(15.0)), "$15"); + assert_eq!(clean_cost(Some(0.11)), "$0.11"); + } +} diff --git a/packages/tui/src/config.rs b/packages/tui/src/config.rs new file mode 100644 index 00000000..482f518d --- /dev/null +++ b/packages/tui/src/config.rs @@ -0,0 +1,49 @@ +use std::fs; +use std::path::PathBuf; +use crate::credentials::get_codework_dir; +use crate::types::ModelConfig; +use crate::ui::theme; + +pub struct ConfigManager { + file_path: PathBuf, +} + +impl Default for ConfigManager { + fn default() -> Self { + Self::new() + } +} + +impl ConfigManager { + pub fn new() -> Self { + let dir = get_codework_dir(); + Self { + file_path: dir.join("config.json"), + } + } + + pub fn load(&self) -> Option { + if let Ok(content) = fs::read_to_string(&self.file_path) { + let config: Option = serde_json::from_str(&content).ok(); + if let Some(cfg) = &config { + if let Some(id) = &cfg.theme { + if let Some(idx) = theme::by_id(id) { + theme::set_active(idx); + } + } + } + config + } else { + None + } + } + + pub fn save(&self, config: &ModelConfig) -> std::io::Result<()> { + if let Some(parent) = self.file_path.parent() { + fs::create_dir_all(parent)?; + } + let data = serde_json::to_string_pretty(config).unwrap_or_else(|_| "{}".to_string()); + fs::write(&self.file_path, data)?; + Ok(()) + } +} diff --git a/packages/tui/src/credentials.rs b/packages/tui/src/credentials.rs new file mode 100644 index 00000000..5f9a5d80 --- /dev/null +++ b/packages/tui/src/credentials.rs @@ -0,0 +1,77 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +pub fn get_codework_dir() -> PathBuf { + // `CODEWORK_HOME_DIR` is the name the harness and CLI use, so a UI launched + // against a chosen home reads the same settings and credentials as the server + // the CLI started. `CODEWORK_HOME` is kept as an earlier spelling. + if let Ok(home) = std::env::var("CODEWORK_HOME_DIR") { + PathBuf::from(home) + } else if let Ok(home) = std::env::var("CODEWORK_HOME") { + PathBuf::from(home) + } else if let Some(home) = dirs::home_dir() { + home.join(".codework") + } else { + PathBuf::from(".codework") + } +} + +pub struct CredentialStore { + file_path: PathBuf, +} + +impl Default for CredentialStore { + fn default() -> Self { + Self::new() + } +} + +impl CredentialStore { + pub fn new() -> Self { + let dir = get_codework_dir(); + Self { + file_path: dir.join("credentials.json"), + } + } + + fn ensure_dir(&self) -> std::io::Result<()> { + if let Some(parent) = self.file_path.parent() { + fs::create_dir_all(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700)); + } + } + Ok(()) + } + + pub fn read_all(&self) -> HashMap { + if let Ok(content) = fs::read_to_string(&self.file_path) { + serde_json::from_str(&content).unwrap_or_default() + } else { + HashMap::new() + } + } + + pub fn get_api_key(&self, provider_id: &str) -> Option { + self.read_all().get(provider_id).cloned() + } + + pub fn set_api_key(&self, provider_id: &str, api_key: &str) -> std::io::Result<()> { + self.ensure_dir()?; + let mut all = self.read_all(); + all.insert(provider_id.to_string(), api_key.to_string()); + let data = serde_json::to_string_pretty(&all).unwrap_or_else(|_| "{}".to_string()); + fs::write(&self.file_path, data)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&self.file_path, fs::Permissions::from_mode(0o600)); + } + + Ok(()) + } +} diff --git a/packages/tui/src/diff.rs b/packages/tui/src/diff.rs new file mode 100644 index 00000000..9e209a6c --- /dev/null +++ b/packages/tui/src/diff.rs @@ -0,0 +1,317 @@ +//! Renders a unified diff (the `patch` field tools such as `edit` return in +//! their result `details`) into styled terminal lines. +//! +//! The renderer is deliberately tag-less: it only understands the standard +//! unified-diff grammar, so any tool that emits a patch can reuse it. It is pure +//! and returns `Line`s rather than touching a `Frame`, which keeps it testable. + +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use unicode_width::UnicodeWidthStr; + +use crate::ui::text::elide; +use crate::ui::theme::Theme; + +/// How many diff lines are shown before the "… +N more lines" footer. +pub const DEFAULT_MAX_DIFF_LINES: usize = 12; + +/// Added/removed counts for the whole patch, before any truncation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DiffSummary { + pub added: usize, + pub removed: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + FileHeader, + Hunk, + Added, + Removed, + Context, + NoNewline, +} + +struct ParsedLine { + kind: Kind, + text: String, + /// 1-based line number in the new file; `None` for removals and metadata. + line_no: Option, +} + +/// Parses the new-side start line out of a hunk header (`@@ -a,b +c,d @@`). +fn parse_new_start(header: &str) -> Option { + let rest = &header[header.find('+')? + 1..]; + let end = rest.find(|c| c == ' ' || c == ',').unwrap_or(rest.len()); + rest[..end].parse().ok() +} + +fn classify(line: &str) -> Kind { + // Order matters: `---`/`+++` must be checked before the `-`/`+` markers. + if line.starts_with("--- ") || line.starts_with("+++ ") { + Kind::FileHeader + } else if line.starts_with("@@") { + Kind::Hunk + } else if line.starts_with('+') { + Kind::Added + } else if line.starts_with('-') { + Kind::Removed + } else if line.starts_with('\\') { + Kind::NoNewline + } else { + Kind::Context + } +} + +fn parse(patch: &str) -> Vec { + let mut parsed = Vec::new(); + let mut new_line: Option = None; + + for raw in patch.lines() { + let kind = classify(raw); + let (line_no, text) = match kind { + Kind::FileHeader | Kind::Hunk | Kind::NoNewline => (None, raw.to_string()), + Kind::Added => { + let current = new_line; + new_line = new_line.map(|n| n + 1); + (current, raw.get(1..).unwrap_or("").to_string()) + } + Kind::Removed => (None, raw.get(1..).unwrap_or("").to_string()), + Kind::Context => { + let current = new_line; + new_line = new_line.map(|n| n + 1); + (current, raw.get(1..).unwrap_or("").to_string()) + } + }; + if kind == Kind::Hunk { + new_line = parse_new_start(raw); + } + parsed.push(ParsedLine { kind, text, line_no }); + } + + parsed +} + +/// Added/removed counts across an already-parsed patch. +fn summarize(parsed: &[ParsedLine]) -> DiffSummary { + let mut summary = DiffSummary::default(); + for line in parsed { + match line.kind { + Kind::Added => summary.added += 1, + Kind::Removed => summary.removed += 1, + _ => {} + } + } + summary +} + +/// Added/removed counts for the whole of `patch`, without building any styled +/// lines — for callers that only want the numbers (a tool-call summary). +pub fn counts(patch: &str) -> DiffSummary { + summarize(&parse(patch)) +} + +/// The new-file path from a patch's `+++` header, when present. +pub fn patch_path(patch: &str) -> Option { + patch + .lines() + .find_map(|line| line.strip_prefix("+++ ")) + .map(|path| path.trim().to_string()) +} + +/// Renders `patch` into at most `max_lines` styled lines (`0` means unlimited), +/// indented for a conversation pane. Returns the full added/removed summary. +pub fn render_diff(patch: &str, max_width: usize, max_lines: usize) -> (Vec>, DiffSummary) { + let parsed = parse(patch); + let summary = summarize(&parsed); + + let gutter_width = parsed + .iter() + .filter_map(|line| line.line_no) + .max() + .map(|n| n.to_string().len()) + .unwrap_or(0); + // gutter + space + marker + space + let prefix_width = gutter_width + 3; + let content_width = max_width.saturating_sub(prefix_width); + + let limit = if max_lines == 0 { + parsed.len() + } else { + max_lines.min(parsed.len()) + }; + + let mut lines: Vec> = Vec::new(); + + for line in parsed.iter().take(limit) { + match line.kind { + Kind::Hunk | Kind::FileHeader => { + let style = if line.kind == Kind::Hunk { + Style::default().fg(Theme::diff_hunk()).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Theme::diff_meta()).add_modifier(Modifier::BOLD) + }; + lines.push(Line::from(vec![Span::styled(elide(&line.text, max_width), style)])); + } + Kind::NoNewline => { + lines.push(Line::from(vec![ + Span::raw(" ".repeat(gutter_width + 1)), + Span::styled( + elide(&line.text, max_width.saturating_sub(gutter_width + 1)), + Style::default().fg(Theme::diff_meta()).add_modifier(Modifier::ITALIC), + ), + ])); + } + Kind::Added | Kind::Removed | Kind::Context => { + let (marker, fg, bg, marker_color) = match line.kind { + Kind::Added => ("+", Theme::success(), Some(Theme::diff_add_bg()), Theme::success()), + Kind::Removed => ("-", Theme::error(), Some(Theme::diff_del_bg()), Theme::error()), + _ => (" ", Theme::text_primary(), None, Theme::text_muted()), + }; + + let mut spans: Vec> = Vec::new(); + if gutter_width > 0 { + let number = line + .line_no + .map(|n| format!("{:>width$} ", n, width = gutter_width)) + .unwrap_or_else(|| " ".repeat(gutter_width + 1)); + spans.push(Span::styled(number, Style::default().fg(Theme::text_muted()))); + } + spans.push(Span::styled(marker, Style::default().fg(marker_color))); + spans.push(Span::raw(" ")); + + let shown = elide(&line.text, content_width); + let used = prefix_width + UnicodeWidthStr::width(shown.as_str()); + let content_style = match bg { + Some(background) => Style::default().fg(fg).bg(background), + None => Style::default().fg(fg), + }; + spans.push(Span::styled(shown, content_style)); + if let Some(background) = bg { + if used < max_width { + spans.push(Span::styled(" ".repeat(max_width - used), Style::default().bg(background))); + } + } + lines.push(Line::from(spans)); + } + } + } + + let remaining = parsed.len().saturating_sub(limit); + if remaining > 0 { + lines.push(Line::from(vec![ + Span::raw(" ".repeat(gutter_width + 1)), + Span::styled(format!("⋯ +{remaining} more lines"), Style::default().fg(Theme::diff_meta())), + ])); + } + + (lines, summary) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PATCH: &str = "--- src/a.ts\n+++ src/a.ts\n@@ -1,3 +1,3 @@\n let a = 1;\n-let b = 2;\n+let b = 3;\n return a + b;\n"; + + fn text_of(line: &Line) -> String { + line.spans.iter().map(|span| span.content.as_ref()).collect() + } + + fn joined(lines: &[Line]) -> String { + lines.iter().map(text_of).collect::>().join("\n") + } + + #[test] + fn counts_added_and_removed() { + let (_, summary) = render_diff(PATCH, 80, DEFAULT_MAX_DIFF_LINES); + assert_eq!(summary.added, 1); + assert_eq!(summary.removed, 1); + } + + #[test] + fn extracts_path_from_patch() { + assert_eq!(patch_path(PATCH).as_deref(), Some("src/a.ts")); + assert_eq!(patch_path(""), None); + } + + #[test] + fn assigns_new_side_line_numbers() { + let (lines, _) = render_diff(PATCH, 80, DEFAULT_MAX_DIFF_LINES); + let text = joined(&lines); + assert!(text.contains("1 let a = 1;"), "context line numbered from hunk start:\n{text}"); + assert!(text.contains("2 + let b = 3;"), "added line numbered on the new side:\n{text}"); + assert!(text.contains("3 return a + b;"), "numbering advances across lines:\n{text}"); + } + + #[test] + fn classifies_hunk_and_file_headers() { + let (lines, _) = render_diff(PATCH, 80, DEFAULT_MAX_DIFF_LINES); + let text = joined(&lines); + assert!(text.contains("--- src/a.ts")); + assert!(text.contains("+++ src/a.ts")); + assert!(text.contains("@@ -1,3 +1,3 @@")); + } + + #[test] + fn truncates_long_patches_with_footer() { + let mut patch = String::from("--- a\n+++ a\n@@ -1,20 +1,20 @@\n"); + for i in 0..20 { + patch.push_str(&format!("+line {i}\n")); + } + let (lines, summary) = render_diff(&patch, 80, 5); + assert_eq!(summary.added, 20, "summary counts the whole patch, not the visible slice"); + assert_eq!(lines.len(), 6, "5 visible lines plus the truncation footer"); + // 23 parsed lines (2 file headers + hunk header + 20 additions) - 5 shown. + assert!(text_of(&lines[5]).contains("+18 more lines"), "{}", text_of(&lines[5])); + } + + #[test] + fn renders_no_newline_marker() { + let patch = "--- a\n+++ a\n@@ -1 +1 @@\n-old\n\\ No newline at end of file\n+new\n"; + let (lines, _) = render_diff(patch, 80, DEFAULT_MAX_DIFF_LINES); + assert!(joined(&lines).contains("No newline at end of file")); + } + + #[test] + fn counts_added_and_removed_without_rendering() { + let summary = counts(PATCH); + assert_eq!(summary.added, 1); + assert_eq!(summary.removed, 1); + assert_eq!(counts(""), DiffSummary::default()); + } + + #[test] + fn truncates_lines_to_max_width() { + let long = "x".repeat(200); + let patch = format!("--- a\n+++ a\n@@ -1 +1 @@\n-{long}\n+{long}\n"); + let (lines, _) = render_diff(&patch, 40, DEFAULT_MAX_DIFF_LINES); + for line in &lines { + assert!( + UnicodeWidthStr::width(text_of(line).as_str()) <= 40, + "line exceeds width: {:?}", + text_of(line) + ); + } + } + + #[test] + fn malformed_hunk_header_does_not_panic() { + let patch = "--- a\n+++ a\n@@ broken @@\n+x\n"; + let (lines, _) = render_diff(patch, 80, DEFAULT_MAX_DIFF_LINES); + assert!(joined(&lines).contains('x')); + } + + #[test] + fn empty_patch_is_empty() { + let (lines, summary) = render_diff("", 80, DEFAULT_MAX_DIFF_LINES); + assert!(lines.is_empty()); + assert_eq!(summary, DiffSummary::default()); + } + + #[test] + fn unlimited_max_lines_keeps_every_line() { + let (lines, _) = render_diff(PATCH, 80, 0); + assert!(lines.iter().all(|line| !text_of(line).contains("more lines"))); + } +} diff --git a/packages/tui/src/git.rs b/packages/tui/src/git.rs new file mode 100644 index 00000000..f1e90b97 --- /dev/null +++ b/packages/tui/src/git.rs @@ -0,0 +1,47 @@ +use std::path::Path; +use std::process::Command; + +#[allow(dead_code)] +pub fn get_git_branch() -> Option { + let output = Command::new("git") + .args(["branch", "--show-current"]) + .output() + .ok()?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !branch.is_empty() { + return Some(branch); + } + } + None +} + +#[allow(dead_code)] +pub fn get_repo_name() -> String { + if let Ok(cwd) = std::env::current_dir() { + if let Some(name) = cwd.file_name() { + return name.to_string_lossy().to_string(); + } + } + "workspace".to_string() +} + +#[allow(dead_code)] +pub fn get_formatted_location() -> String { + let repo = get_repo_name(); + if let Some(branch) = get_git_branch() { + format!("{} ({})", repo, branch) + } else { + repo + } +} + +pub fn truncate_path(path: &Path, max_len: usize) -> String { + let s = path.to_string_lossy(); + if s.len() <= max_len { + s.to_string() + } else { + format!("...{}", &s[s.len().saturating_sub(max_len - 3)..]) + } +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts new file mode 100644 index 00000000..955d9383 --- /dev/null +++ b/packages/tui/src/index.ts @@ -0,0 +1,77 @@ +/* @effect-diagnostics nodeBuiltinImport:off -- the launcher owns the child process and its terminal. */ +import { spawn } from "node:child_process"; +import { resolveBinary } from "./binary.ts"; + +export { LauncherError, platform, resolveBinary, type Platform } from "./binary.ts"; + +/** The environment variable the Rust TUI reads for the server endpoint. */ +export const ENV_SERVER = "CODEWORK_RPC_SERVER"; + +export interface LaunchOptions { + /** Arguments forwarded verbatim to the TUI, after the binary. */ + readonly args?: ReadonlyArray; + /** The server endpoint. Exported as {@link ENV_SERVER} for the child. */ + readonly server?: string; + /** Overrides the resolved binary; mainly for tests. */ + readonly binary?: string; + readonly cwd?: string; + readonly env?: NodeJS.ProcessEnv; +} + +/** + * Run the CodeWork TUI as a foreground child of this process. + * + * Terminal I/O is inherited rather than piped: the TUI owns the alternate + * screen, reads raw keystrokes and draws directly, so any buffering here would + * corrupt it. Signals the terminal sends to the foreground process group reach + * the child on their own; the ones a caller sends to this process explicitly are + * forwarded, so `kill` on the CLI does not orphan the UI. The resolved exit code + * becomes the caller's. + */ +export function launch(options: LaunchOptions = {}): Promise { + const binary = options.binary ?? resolveBinary(options.env ?? process.env); + const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env) }; + if (options.server !== undefined) env[ENV_SERVER] = options.server; + + const child = spawn(binary, [...(options.args ?? [])], { + stdio: "inherit", + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + env, + }); + + const forwarded: ReadonlyArray = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]; + const relay = (signal: NodeJS.Signals) => () => { + if (child.exitCode === null && child.signalCode === null) child.kill(signal); + }; + const handlers = forwarded.map((signal) => { + const handler = relay(signal); + process.on(signal, handler); + return [signal, handler] as const; + }); + + return new Promise((resolve, reject) => { + child.once("error", (cause) => { + cleanup(); + reject(cause); + }); + child.once("exit", (code, signal) => { + cleanup(); + // A child killed by a signal has no code; mirror the shell's 128 + signal + // convention so a caller sees a non-zero status rather than a false success. + resolve(code ?? (signal === null ? 0 : 128 + signalNumber(signal))); + }); + }); + + function cleanup() { + for (const [signal, handler] of handlers) process.off(signal, handler); + } +} + +const signalNumbers: Readonly> = { + SIGHUP: 1, + SIGINT: 2, + SIGQUIT: 3, + SIGTERM: 15, +}; + +const signalNumber = (signal: string): number => signalNumbers[signal] ?? 1; diff --git a/packages/tui/src/main.rs b/packages/tui/src/main.rs new file mode 100644 index 00000000..ba7c3307 --- /dev/null +++ b/packages/tui/src/main.rs @@ -0,0 +1,176 @@ +mod app; +mod catalog; +mod config; +mod credentials; +mod diff; +mod git; +mod markdown; +mod rpc; +mod tool; +mod types; +mod ui; + +use std::io::{self, Write}; +use std::time::Duration; + +use crossterm::event::{ + self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind, +}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::style::Style; +use ratatui::widgets::Block; +use ratatui::Terminal; +use tokio::time::interval; + +use app::{ActiveScreen, App}; +use ui::{render_model_browser, render_session, render_theme_picker, render_welcome}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Install panic hook to restore terminal on crash + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = disable_raw_mode(); + let mut out = io::stdout(); + let _ = execute!(out, LeaveAlternateScreen, DisableBracketedPaste); + let _ = out.write_all(b"\x1b]111\x07\x1b[0m"); + let _ = out.flush(); + original_hook(panic_info); + })); + + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + // Set terminal background to #1E1E2E (OSC 11), clear screen, and enter alternate screen + let _ = stdout.write_all(b"\x1b]11;#1E1E2E\x07\x1b[48;2;30;30;46m\x1b[2J\x1b[H"); + let _ = stdout.flush(); + execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + let mut app = App::new(); + let mut ticker = interval(Duration::from_millis(80)); + + // Dedicated OS thread reading events from standard input. + // Reading crossterm events on a dedicated thread avoids task cancellation drops + // during tokio::select!, eliminates multi-thread races on stdin, and ensures fast typing + // never loses characters. + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel(); + std::thread::spawn(move || { + loop { + match event::read() { + Ok(ev) => { + if event_tx.send(ev).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + loop { + terminal.draw(|f| { + let area = f.area(); + // Fill the entire terminal viewport with Deep Black (#0B0B0F) background + f.render_widget( + Block::default().style(Style::default().bg(ui::Theme::bg_app())), + area, + ); + match app.screen { + ActiveScreen::Welcome => { + render_welcome( + f, + area, + &app.welcome_input, + app.welcome_cursor, + app.welcome_dropdown_index, + app.active_config.as_ref(), + app.welcome_status_message.as_deref(), + &app.spinner, + ); + } + ActiveScreen::ModelFlow => { + render_model_browser(f, area, &app.model_browser, &app.spinner); + } + ActiveScreen::Session => { + if let Some(cfg) = &app.active_config { + render_session( + f, + area, + app.session_info.as_ref(), + &app.turns, + &app.session_stats, + cfg, + &app.session_input, + app.session_cursor, + app.session_scroll_offset, + app.session_dropdown_index, + &app.spinner, + app.is_streaming, + app.reconnecting, + ); + } + } + } + + if app.theme_picker_open { + render_theme_picker(f, area, app.theme_picker_index); + } + })?; + + tokio::select! { + _ = ticker.tick() => { + app.on_tick(); + } + Some(event) = event_rx.recv() => { + handle_event(&mut app, event); + // Drain any additional events buffered so fast typing / bursts / pastes don't lag or drop! + while let Ok(event) = event_rx.try_recv() { + handle_event(&mut app, event); + } + } + } + + if app.should_quit { + break; + } + } + + // Restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableBracketedPaste + )?; + terminal.show_cursor()?; + let mut out = io::stdout(); + let _ = out.write_all(b"\x1b]111\x07\x1b[0m"); + let _ = out.flush(); + + Ok(()) +} + +fn handle_event(app: &mut App, event: Event) { + match event { + Event::Key(key) => { + // Ignore key release events from terminals supporting keyboard protocols + if key.kind != KeyEventKind::Release { + app.handle_key(key); + } + } + Event::Mouse(mouse) => { + app.handle_mouse(mouse); + } + Event::Paste(text) => { + app.handle_paste(&text); + } + _ => {} + } +} diff --git a/packages/tui/src/markdown/block.rs b/packages/tui/src/markdown/block.rs new file mode 100644 index 00000000..76d27910 --- /dev/null +++ b/packages/tui/src/markdown/block.rs @@ -0,0 +1,281 @@ +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use unicode_width::UnicodeWidthStr; + +use crate::markdown::inline::parse_inline; +use crate::markdown::table::{is_table_row, render_table}; +use crate::ui::Theme; + +pub fn wrap_text(text: &str, max_width: usize) -> Vec { + if max_width == 0 || text.is_empty() { + return vec![text.to_string()]; + } + let mut lines = Vec::new(); + let words: Vec<&str> = text.split_whitespace().collect(); + if words.is_empty() { + return vec![String::new()]; + } + let mut current_line = String::new(); + let mut current_width = 0usize; + + for word in words { + let w_len = UnicodeWidthStr::width(word); + if current_line.is_empty() { + current_line.push_str(word); + current_width = w_len; + } else if current_width + 1 + w_len <= max_width { + current_line.push(' '); + current_line.push_str(word); + current_width += 1 + w_len; + } else { + lines.push(current_line); + current_line = word.to_string(); + current_width = w_len; + } + } + if !current_line.is_empty() { + lines.push(current_line); + } + lines +} + +pub fn render_markdown(text: &str, max_width: usize) -> Vec> { + let mut lines: Vec> = Vec::new(); + let raw_lines: Vec<&str> = text.lines().collect(); + let mut i = 0; + + let border_style = Style::default().fg(Theme::border()); + let code_bg = Theme::bg_surface(); + + while i < raw_lines.len() { + let line = raw_lines[i]; + let trimmed = line.trim(); + + // 1. Code Fence + if trimmed.starts_with("```") { + let lang = trimmed.trim_start_matches('`').trim(); + i += 1; + let mut code_lines = Vec::new(); + while i < raw_lines.len() { + if raw_lines[i].trim().starts_with("```") { + i += 1; + break; + } + code_lines.push(raw_lines[i]); + i += 1; + } + + // Top fence header + let lang_tag = if !lang.is_empty() { + format!("── [{}] ─", lang) + } else { + "──────".to_string() + }; + let fence_top = format!("┌{}{}", lang_tag, "─".repeat(max_width.saturating_sub(lang_tag.len() + 3))); + lines.push(Line::from(vec![Span::styled(fence_top, border_style)])); + + let max_code_w = max_width.saturating_sub(4).max(10); + for c_line in code_lines { + let display_code = if UnicodeWidthStr::width(c_line) > max_code_w { + let mut truncated = String::new(); + let mut cur = 0; + for ch in c_line.chars() { + let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if cur + ch_w <= max_code_w.saturating_sub(1) { + truncated.push(ch); + cur += ch_w; + } else { + break; + } + } + truncated.push('…'); + truncated + } else { + c_line.to_string() + }; + + let mut spans = vec![ + Span::styled("│ ", border_style), + Span::styled( + display_code.clone(), + Style::default().fg(Theme::success()).bg(code_bg), + ), + ]; + let line_len = UnicodeWidthStr::width(display_code.as_str()) + 2; + if line_len < max_width { + spans.push(Span::styled(" ".repeat(max_width - line_len), Style::default().bg(code_bg))); + } + spans.push(Span::styled(" │", border_style)); + lines.push(Line::from(spans)); + } + + // Bottom fence footer + let fence_bot = format!("└{}┘", "─".repeat(max_width.saturating_sub(2))); + lines.push(Line::from(vec![Span::styled(fence_bot, border_style)])); + continue; + } + + // 2. Table Block + if is_table_row(trimmed) { + let mut table_rows = Vec::new(); + while i < raw_lines.len() && is_table_row(raw_lines[i]) { + table_rows.push(raw_lines[i].to_string()); + i += 1; + } + let rendered = render_table(&table_rows, max_width); + lines.extend(rendered); + continue; + } + + // 3. Headings + if trimmed.starts_with("# ") { + let content = trimmed[2..].trim(); + lines.push(Line::from(vec![ + Span::styled("┃ ", Style::default().fg(Theme::primary()).add_modifier(Modifier::BOLD)), + Span::styled( + content.to_string(), + Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD), + ), + ])); + i += 1; + continue; + } + + if trimmed.starts_with("## ") { + let content = trimmed[3..].trim(); + lines.push(Line::from(vec![ + Span::styled("┃ ", Style::default().fg(Theme::primary()).add_modifier(Modifier::BOLD)), + Span::styled( + content.to_string(), + Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD), + ), + ])); + i += 1; + continue; + } + + if trimmed.starts_with("### ") { + let content = trimmed[4..].trim(); + lines.push(Line::from(vec![ + Span::styled("│ ", Style::default().fg(Theme::text_muted())), + Span::styled( + content.to_string(), + Style::default().fg(Theme::text_secondary()).add_modifier(Modifier::BOLD), + ), + ])); + i += 1; + continue; + } + + // 4. Blockquotes + if trimmed.starts_with('>') { + let content = trimmed.trim_start_matches('>').trim(); + let wrap_width = max_width.saturating_sub(3).max(10); + let wrapped = wrap_text(content, wrap_width); + for w_line in wrapped { + let mut spans = vec![Span::styled( + "│ ", + Style::default().fg(Theme::text_muted()), + )]; + spans.extend(parse_inline(&w_line)); + lines.push(Line::from(spans)); + } + i += 1; + continue; + } + + // 5. Unordered List + if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") { + let indent = line.len() - line.trim_start().len(); + let content = &trimmed[2..]; + let wrap_width = max_width.saturating_sub(indent + 2).max(10); + let wrapped = wrap_text(content, wrap_width); + for (w_idx, w_line) in wrapped.iter().enumerate() { + let mut spans = Vec::new(); + spans.push(Span::raw(" ".repeat(indent))); + if w_idx == 0 { + spans.push(Span::styled("• ", Style::default().fg(Theme::primary()))); + } else { + spans.push(Span::raw(" ")); + } + spans.extend(parse_inline(w_line)); + lines.push(Line::from(spans)); + } + i += 1; + continue; + } + + // 6. Ordered List + if let Some(dot_pos) = trimmed.find(". ") { + let num_part = &trimmed[..dot_pos]; + if num_part.chars().all(|c| c.is_ascii_digit()) && !num_part.is_empty() { + let indent = line.len() - line.trim_start().len(); + let prefix = format!("{}. ", num_part); + let prefix_len = prefix.len(); + let content = &trimmed[dot_pos + 2..]; + let wrap_width = max_width.saturating_sub(indent + prefix_len).max(10); + let wrapped = wrap_text(content, wrap_width); + for (w_idx, w_line) in wrapped.iter().enumerate() { + let mut spans = Vec::new(); + spans.push(Span::raw(" ".repeat(indent))); + if w_idx == 0 { + spans.push(Span::styled(prefix.clone(), Style::default().fg(Theme::primary()))); + } else { + spans.push(Span::raw(" ".repeat(prefix_len))); + } + spans.extend(parse_inline(w_line)); + lines.push(Line::from(spans)); + } + i += 1; + continue; + } + } + + // 7. Horizontal Rule + if trimmed == "---" || trimmed == "***" || trimmed == "___" { + let hr = "─".repeat(max_width.min(60)); + lines.push(Line::from(vec![Span::styled(hr, border_style)])); + i += 1; + continue; + } + + // 8. Empty line + if trimmed.is_empty() { + lines.push(Line::raw("")); + i += 1; + continue; + } + + // 9. Normal paragraph (word wrapped) + let wrapped = wrap_text(line, max_width); + for w_line in wrapped { + lines.push(Line::from(parse_inline(&w_line))); + } + i += 1; + } + + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wrap_text() { + let text = "CodeWork is an open-source coding agent harness for the terminal."; + let wrapped = wrap_text(text, 25); + assert!(wrapped.len() > 1); + for line in &wrapped { + assert!(unicode_width::UnicodeWidthStr::width(line.as_str()) <= 25); + } + } + + #[test] + fn test_render_markdown_wrapping() { + let text = "This is a long sentence that should definitely be wrapped to fit inside the terminal viewport width."; + let lines = render_markdown(text, 30); + assert!(lines.len() >= 3); + } +} + diff --git a/packages/tui/src/markdown/inline.rs b/packages/tui/src/markdown/inline.rs new file mode 100644 index 00000000..60fad3e9 --- /dev/null +++ b/packages/tui/src/markdown/inline.rs @@ -0,0 +1,126 @@ +use ratatui::style::{Modifier, Style}; +use ratatui::text::Span; + +use crate::ui::Theme; + +pub fn parse_inline(text: &str) -> Vec> { + let mut spans = Vec::new(); + let mut chars = text.chars().peekable(); + let mut buffer = String::new(); + + while let Some(&ch) = chars.peek() { + if ch == '`' { + if !buffer.is_empty() { + spans.push(Span::styled( + std::mem::take(&mut buffer), + Style::default().fg(Theme::text_primary()), + )); + } + chars.next(); // consume ` + let mut code = String::new(); + while let Some(c) = chars.next() { + if c == '`' { + break; + } + code.push(c); + } + spans.push(Span::styled( + code, + Style::default().fg(Theme::success()), + )); + } else if ch == '*' { + chars.next(); + if chars.peek() == Some(&'*') { + chars.next(); // double * (bold) + if !buffer.is_empty() { + spans.push(Span::styled( + std::mem::take(&mut buffer), + Style::default().fg(Theme::text_primary()), + )); + } + let mut bold_text = String::new(); + while let Some(c) = chars.next() { + if c == '*' && chars.peek() == Some(&'*') { + chars.next(); + break; + } + bold_text.push(c); + } + spans.push(Span::styled( + bold_text, + Style::default() + .fg(Theme::warning()) + .add_modifier(Modifier::BOLD), + )); + } else { + // single * (italic) + if !buffer.is_empty() { + spans.push(Span::styled( + std::mem::take(&mut buffer), + Style::default().fg(Theme::text_primary()), + )); + } + let mut italic_text = String::new(); + while let Some(c) = chars.next() { + if c == '*' { + break; + } + italic_text.push(c); + } + spans.push(Span::styled( + italic_text, + Style::default() + .fg(Theme::text_muted()) + .add_modifier(Modifier::ITALIC), + )); + } + } else if ch == '[' { + if !buffer.is_empty() { + spans.push(Span::styled( + std::mem::take(&mut buffer), + Style::default().fg(Theme::text_primary()), + )); + } + chars.next(); // consume [ + let mut link_text = String::new(); + while let Some(c) = chars.next() { + if c == ']' { + break; + } + link_text.push(c); + } + if chars.peek() == Some(&'(') { + chars.next(); // consume ( + let mut _url = String::new(); + while let Some(c) = chars.next() { + if c == ')' { + break; + } + _url.push(c); + } + spans.push(Span::styled( + link_text, + Style::default() + .fg(Theme::primary()) + .add_modifier(Modifier::UNDERLINED), + )); + } else { + buffer.push('['); + buffer.push_str(&link_text); + buffer.push(']'); + } + } else { + buffer.push(ch); + chars.next(); + } + } + + if !buffer.is_empty() { + spans.push(Span::styled( + buffer, + Style::default().fg(Theme::text_primary()), + )); + } + + spans +} diff --git a/packages/tui/src/markdown/mod.rs b/packages/tui/src/markdown/mod.rs new file mode 100644 index 00000000..b19b5b2b --- /dev/null +++ b/packages/tui/src/markdown/mod.rs @@ -0,0 +1,5 @@ +pub mod block; +pub mod inline; +pub mod table; + +pub use block::{render_markdown, wrap_text}; diff --git a/packages/tui/src/markdown/table.rs b/packages/tui/src/markdown/table.rs new file mode 100644 index 00000000..f8f2d6ad --- /dev/null +++ b/packages/tui/src/markdown/table.rs @@ -0,0 +1,237 @@ +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use unicode_width::UnicodeWidthStr; + +use crate::markdown::inline::parse_inline; +use crate::ui::Theme; + +pub fn is_table_row(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.starts_with('|') || (trimmed.contains('|') && trimmed.ends_with('|')) +} + +pub fn is_delimiter_row(line: &str) -> bool { + let trimmed = line.trim(); + if !is_table_row(trimmed) { + return false; + } + let cells = parse_cells(trimmed); + !cells.is_empty() + && cells.iter().all(|c| { + let s = c.trim(); + !s.is_empty() && s.chars().all(|ch| ch == '-' || ch == ':' || ch == ' ') + }) +} + +fn parse_cells(line: &str) -> Vec { + let mut trimmed = line.trim(); + if trimmed.starts_with('|') { + trimmed = &trimmed[1..]; + } + if trimmed.ends_with('|') { + trimmed = &trimmed[..trimmed.len().saturating_sub(1)]; + } + trimmed.split('|').map(|c| c.trim().to_string()).collect() +} + +fn spans_visual_width(spans: &[Span]) -> usize { + spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum() +} + +fn truncate_spans(spans: Vec>, target_w: usize) -> Vec> { + if target_w == 0 { + return Vec::new(); + } + let max_avail = target_w.saturating_sub(1); // leave 1 col for '…' + let mut result = Vec::new(); + let mut current_w = 0; + + for span in spans { + let span_w = UnicodeWidthStr::width(span.content.as_ref()); + if current_w + span_w <= max_avail { + current_w += span_w; + result.push(span); + } else { + let mut partial = String::new(); + for ch in span.content.chars() { + let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if current_w + ch_w <= max_avail { + partial.push(ch); + current_w += ch_w; + } else { + break; + } + } + if !partial.is_empty() { + result.push(Span::styled(partial, span.style)); + } + result.push(Span::styled("…", span.style)); + return result; + } + } + result.push(Span::raw("…")); + result +} + +pub fn render_table(rows: &[String], max_width: usize) -> Vec> { + if rows.is_empty() { + return Vec::new(); + } + + let parsed_rows: Vec> = rows.iter().map(|r| parse_cells(r)).collect(); + if parsed_rows.is_empty() { + return Vec::new(); + } + + let col_count = parsed_rows.iter().map(|r| r.len()).max().unwrap_or(0); + if col_count == 0 { + return Vec::new(); + } + + // Measure raw max width of each column based on visual text length + let mut col_widths = vec![4usize; col_count]; + for row in &parsed_rows { + for (i, cell) in row.iter().enumerate() { + if i < col_count { + let parsed = parse_inline(cell); + let w = spans_visual_width(&parsed); + if w > col_widths[i] { + col_widths[i] = w; + } + } + } + } + + // Overhead: Each column is " cell ", with border on each side: col_count * 3 + 1 + // E.g. for 2 columns: "│ col1 │ col2 │" has 2 * 3 + 1 = 7 border/padding chars. + let border_overhead = col_count * 3 + 1; + let avail_for_cells = max_width.saturating_sub(border_overhead).max(col_count * 6); + let total_cell_w: usize = col_widths.iter().sum(); + + if total_cell_w > avail_for_cells { + // Proportionally scale columns to fit within avail_for_cells + let mut new_widths = Vec::new(); + for &w in &col_widths { + let scaled = (w * avail_for_cells / total_cell_w).max(6); + new_widths.push(scaled); + } + col_widths = new_widths; + // Fine-tune if sum exceeds avail_for_cells + while col_widths.iter().sum::() > avail_for_cells { + if let Some(max_col) = col_widths.iter_mut().max() { + if *max_col > 6 { + *max_col -= 1; + } else { + break; + } + } + } + } + + let border_style = Style::default().fg(Theme::border()); + let header_style = Style::default() + .fg(Theme::primary()) + .add_modifier(Modifier::BOLD); + + let mut lines = Vec::new(); + + // Top border: ┌───┬───┐ + let mut top_border = String::from("┌"); + for (i, w) in col_widths.iter().enumerate() { + top_border.push_str(&"─".repeat(*w + 2)); + if i + 1 < col_count { + top_border.push('┬'); + } else { + top_border.push('┐'); + } + } + lines.push(Line::from(vec![Span::styled(top_border, border_style)])); + + let mut has_header = false; + for (row_idx, row) in parsed_rows.iter().enumerate() { + // Skip delimiter row + if row_idx == 1 && rows.get(1).map(|s| is_delimiter_row(s)).unwrap_or(false) { + // Render middle divider: ├───┼───┤ + let mut mid_border = String::from("├"); + for (i, w) in col_widths.iter().enumerate() { + mid_border.push_str(&"─".repeat(*w + 2)); + if i + 1 < col_count { + mid_border.push('┼'); + } else { + mid_border.push('┤'); + } + } + lines.push(Line::from(vec![Span::styled(mid_border, border_style)])); + has_header = true; + continue; + } + + let mut row_spans = Vec::new(); + row_spans.push(Span::styled("│", border_style)); + + for (col_idx, &w) in col_widths.iter().enumerate() { + let cell = row.get(col_idx).map(|s| s.as_str()).unwrap_or(""); + let is_header_row = row_idx == 0 && !has_header; + + let cell_spans = if is_header_row { + vec![Span::styled(cell.to_string(), header_style)] + } else { + parse_inline(cell) + }; + + let visual_w = spans_visual_width(&cell_spans); + let final_spans = if visual_w > w { + truncate_spans(cell_spans, w) + } else { + cell_spans + }; + + let final_w = spans_visual_width(&final_spans); + let pad = w.saturating_sub(final_w); + + row_spans.push(Span::raw(" ")); + row_spans.extend(final_spans); + row_spans.push(Span::raw(" ".repeat(pad + 1))); + row_spans.push(Span::styled("│", border_style)); + } + + lines.push(Line::from(row_spans)); + } + + // Bottom border: └───┴───┘ + let mut bottom_border = String::from("└"); + for (i, w) in col_widths.iter().enumerate() { + bottom_border.push_str(&"─".repeat(*w + 2)); + if i + 1 < col_count { + bottom_border.push('┴'); + } else { + bottom_border.push('┘'); + } + } + lines.push(Line::from(vec![Span::styled(bottom_border, border_style)])); + + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render_table_alignment() { + let rows = vec![ + "| Package | Role |".to_string(), + "|---|---|".to_string(), + "| `@codeworksh/cli` (packages/codework) | The user-facing command-line tool (codework). |".to_string(), + "| `@codeworksh/aikit` (packages/aikit) | Unified multi-provider LLM client SDK. |".to_string(), + ]; + let lines = render_table(&rows, 60); + assert!(!lines.is_empty()); + let expected_w = spans_visual_width(&lines[0].spans); + for line in &lines { + let line_w = spans_visual_width(&line.spans); + assert_eq!(line_w, expected_w, "All table lines must have identical visual width!"); + } + } +} + diff --git a/packages/tui/src/rpc/client.rs b/packages/tui/src/rpc/client.rs new file mode 100644 index 00000000..7f722736 --- /dev/null +++ b/packages/tui/src/rpc/client.rs @@ -0,0 +1,652 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tokio::sync::mpsc; +use tokio::time::sleep; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; + +use crate::rpc::protocol::{ + CreateSessionPayload, ExitPayload, InterruptSessionPayload, ModelRefPayload, + PromptSessionPayload, RpcAck, RpcRequest, RuntimePayload, ServerMessage, +}; +use crate::types::{SessionInfo, UsageReport}; + +pub const DEFAULT_SERVER_URL: &str = "ws://127.0.0.1:7433/rpc"; + +/// How long to wait between reconnection attempts, and how many to make before +/// giving the turn up. The server outlives this client, so a short window is +/// enough to cover a restart and far short of hanging the UI forever. +const RECONNECT_DELAY: Duration = Duration::from_secs(1); +const RECONNECT_ATTEMPTS: u32 = 30; + +/// The endpoint to connect to. +/// +/// The CLI owns the server lifecycle and names the endpoint it made ready; the +/// TUI only connects. The environment variable is how that endpoint travels, and +/// the default is the address an unmanaged `codework serve` uses. +pub fn server_url() -> String { + std::env::var("CODEWORK_RPC_SERVER").unwrap_or_else(|_| DEFAULT_SERVER_URL.to_string()) +} + +#[derive(Debug, Clone)] +pub enum StreamEvent { + SessionCreated(SessionInfo), + TextDelta(String), + ThinkingDelta(String), + ToolStarted { + call_id: String, + name: String, + label: Option, + /// The tool call's raw arguments — the command, path, or query. + arguments: Value, + }, + ToolSettled { + call_id: String, + name: Option, + is_error: bool, + /// The arguments the terminal part carries, repeated from `started`. + arguments: Value, + /// The tool's own success/failure struct. Arbitrary JSON: the shape is the + /// tool's to define, so it is interpreted downstream rather than here. + details: Option, + }, + Usage(UsageReport), + Succeeded, + Failed(String), + Interrupted(String), + /// The transport dropped mid-stream. Distinct from `Failed`: the work is not + /// necessarily over, only the socket is, so the UI shows reconnecting rather + /// than an error. + Disconnected, + /// The server answered again after a `Disconnected`. + Reconnected, +} + +pub struct RpcClient { + url: String, + next_id: AtomicU64, +} + +impl Default for RpcClient { + fn default() -> Self { + Self::new(server_url()) + } +} + +impl RpcClient { + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + next_id: AtomicU64::new(1), + } + } + + /// The endpoint this client connects to. + pub fn url(&self) -> &str { + &self.url + } + + fn next_request_id(&self) -> u64 { + self.next_id.fetch_add(1, Ordering::SeqCst) + } + + /// Whether this endpoint currently accepts a WebSocket connection. + pub async fn is_server_running(&self) -> bool { + match tokio::time::timeout(Duration::from_millis(1500), connect_async(&self.url)).await { + Ok(Ok(_)) => true, + _ => false, + } + } + + /// Wait, briefly, for the endpoint to answer. + /// + /// The CLI has already ensured a server before launching the UI, so this is + /// only a guard against connecting in the same instant it finishes binding. + /// It never starts a server: process startup belongs to the CLI alone. + pub async fn wait_for_server(&self) -> bool { + for _ in 0..25 { + if self.is_server_running().await { + return true; + } + sleep(Duration::from_millis(200)).await; + } + self.is_server_running().await + } + + pub async fn create_session( + &self, + provider: &str, + model_id: &str, + host_dir: Option, + thinking_level: Option, + ) -> Result { + let (ws_stream, _) = connect_async(&self.url) + .await + .map_err(|e| format!("Failed to connect to {}: {}", self.url, e))?; + + let (mut write, mut read) = ws_stream.split(); + let req_id = self.next_request_id(); + + let dir = host_dir.unwrap_or_else(|| { + std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()) + }); + + let payload = CreateSessionPayload { + host_dir: dir, + runtime: RuntimePayload { + model: ModelRefPayload { + provider: provider.to_string(), + id: model_id.to_string(), + }, + thinking_level, + }, + }; + + let req = RpcRequest::new(req_id, "session.create", payload); + let mut text = serde_json::to_string(&req).map_err(|e| e.to_string())?; + text.push('\n'); + + write + .send(Message::Text(text.into())) + .await + .map_err(|e| format!("Send error: {}", e))?; + + while let Some(msg_result) = read.next().await { + let msg = msg_result.map_err(|e| format!("Read error: {}", e))?; + if let Message::Text(txt) = msg { + for line in txt.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let parsed: ServerMessage = match serde_json::from_str(line) { + Ok(m) => m, + Err(_) => continue, + }; + + if let ServerMessage::Exit { request_id, exit } = parsed { + let matches = match request_id { + serde_json::Value::Number(n) => n.as_u64() == Some(req_id), + serde_json::Value::String(s) => s.parse::().ok() == Some(req_id), + _ => false, + }; + + if matches { + match exit { + ExitPayload::Success { value } => { + let id = value.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = value.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let directory = value.get("directory").and_then(|v| v.as_str()).unwrap_or("").to_string(); + return Ok(SessionInfo { id, title, directory }); + } + ExitPayload::Failure { cause } => { + return Err(format!("RPC Failure: {:?}", cause)); + } + } + } + } + } + } + } + + Err("Connection closed before response received".to_string()) + } + + pub async fn interrupt_session(&self, session_id: &str) -> Result { + let (ws_stream, _) = connect_async(&self.url) + .await + .map_err(|e| format!("Failed to connect: {}", e))?; + + let (mut write, mut read) = ws_stream.split(); + let req_id = self.next_request_id(); + + let req = RpcRequest::new( + req_id, + "session.interrupt", + InterruptSessionPayload { + session_id: session_id.to_string(), + }, + ); + let mut text = serde_json::to_string(&req).map_err(|e| e.to_string())?; + text.push('\n'); + + write + .send(Message::Text(text.into())) + .await + .map_err(|e| e.to_string())?; + + while let Some(msg_result) = read.next().await { + let msg = msg_result.map_err(|e| e.to_string())?; + if let Message::Text(txt) = msg { + for line in txt.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if let Ok(ServerMessage::Exit { exit, .. }) = serde_json::from_str::(line) { + if let ExitPayload::Success { value } = exit { + let interrupted = value.get("interrupted").and_then(|v| v.as_bool()).unwrap_or(false); + return Ok(interrupted); + } + } + } + } + } + Ok(false) + } + + /// Stream one prompt, reconnecting the socket when the transport drops. + /// + /// The session lives on the server, not this socket, so a dropped connection + /// is not the end of the turn: the stream is re-established and retried until + /// it terminates or the server has been gone longer than the grace period. + /// Every drop is announced as `Disconnected` so the UI can show reconnecting, + /// and the first event after a retry as `Reconnected` so it can clear it. + pub async fn prompt_session( + &self, + session_id: String, + prompt_text: String, + event_tx: mpsc::UnboundedSender, + ) -> Result<(), String> { + let mut attempt = 0u32; + loop { + match self + .stream_session_once(&session_id, &prompt_text, attempt > 0, &event_tx) + .await + { + Ok(true) => return Ok(()), + Ok(false) => { + attempt += 1; + if attempt > RECONNECT_ATTEMPTS { + let _ = event_tx.send(StreamEvent::Failed(format!( + "Lost the connection to {} and could not reconnect", + self.url + ))); + return Ok(()); + } + let _ = event_tx.send(StreamEvent::Disconnected); + + sleep(RECONNECT_DELAY).await; + } + Err(message) => { + let _ = event_tx.send(StreamEvent::Failed(message)); + return Ok(()); + } + } + } + } + + /// One connection's worth of a prompt. `Ok(true)` means the turn ended; + /// `Ok(false)` means the socket dropped and the caller should retry. + async fn stream_session_once( + &self, + session_id: &str, + prompt_text: &str, + is_retry: bool, + event_tx: &mpsc::UnboundedSender, + ) -> Result { + let (ws_stream, _) = match connect_async(&self.url).await { + Ok(stream) => stream, + // The server is not up yet: leave the retry to the reconnect loop. + Err(_) => return Ok(false), + }; + + let (mut write, mut read) = ws_stream.split(); + + // 1. Subscribe to events + let sub_id = self.next_request_id(); + let sub_req = RpcRequest::new(sub_id, "event.subscribe", serde_json::json!({})); + let mut sub_text = serde_json::to_string(&sub_req).map_err(|e| e.to_string())?; + sub_text.push('\n'); + write + .send(Message::Text(sub_text.into())) + .await + .map_err(|e| format!("Failed to subscribe to events: {}", e))?; + + // One stable id regardless of retries, so a prompt re-admitted on a new + // connection is recognized as the same message rather than a duplicate. + let prompt_message_id = format!("msg_{}", chrono::Utc::now().timestamp_millis()); + // A retry must not re-admit the prompt: it may still be running server-side, + // and re-sending it would collide. The subscription alone re-attaches us to + // the events for the work already in flight. + let mut prompt_dispatched = is_retry; + // On a retry, the first matching event is proof the socket is back. + let mut reconnected = is_retry; + + while let Some(msg_res) = read.next().await { + let msg = match msg_res { + Ok(m) => m, + // A read error is a dropped socket, not a dead turn: retry. + Err(_) => return Ok(false), + }; + + if let Message::Text(txt) = msg { + for line in txt.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let parsed: ServerMessage = match serde_json::from_str(line) { + Ok(m) => m, + Err(_) => continue, + }; + + match parsed { + ServerMessage::Chunk { request_id, values } => { + // Send Ack for chunk with \n + if let Some(id_num) = request_id.as_u64() { + let ack = RpcAck::new(id_num); + if let Ok(mut ack_json) = serde_json::to_string(&ack) { + ack_json.push('\n'); + let _ = write.send(Message::Text(ack_json.into())).await; + } + } + + for env in values { + // On server.connected, dispatch prompt + if env.event_type == "server.connected" && !prompt_dispatched { + prompt_dispatched = true; + let prompt_id = self.next_request_id(); + let prompt_req = RpcRequest::new( + prompt_id, + "session.prompt", + PromptSessionPayload { + session_id: session_id.to_string(), + text: prompt_text.to_string(), + delivery: "followUp", + id: prompt_message_id.clone(), + }, + ); + if let Ok(mut p_json) = serde_json::to_string(&prompt_req) { + p_json.push('\n'); + let _ = write.send(Message::Text(p_json.into())).await; + } + continue; + } + + // Verify sessionId matches + let data = match &env.data { + Some(d) => d, + None => continue, + }; + + let s_id = data.get("sessionId").and_then(|v| v.as_str()); + if s_id != Some(session_id) { + continue; + } + + // The first real event on a retried socket proves the + // connection is back; the UI can stop showing reconnecting. + if reconnected { + reconnected = false; + let _ = event_tx.send(StreamEvent::Reconnected); + } + + match env.event_type.as_str() { + "session.llm.text.delta" | "session.llm.text-delta" => { + if let Some(delta) = data.get("delta").and_then(|v| v.as_str()) { + let _ = event_tx.send(StreamEvent::TextDelta(delta.to_string())); + } + } + "session.llm.thinking.delta" | "session.llm.thinking-delta" => { + if let Some(delta) = data.get("delta").and_then(|v| v.as_str()) { + let _ = event_tx.send(StreamEvent::ThinkingDelta(delta.to_string())); + } + } + "session.tool.started" => { + let _ = event_tx.send(started_event(data)); + } + "session.tool.settled" => { + let _ = event_tx.send(settled_event(data)); + } + "session.llm.ended" => { + if let Some(msg) = data.get("message") { + if let Some(usage) = msg.get("usage") { + let total_tokens = usage.get("totalTokens").and_then(|v| v.as_u64()).unwrap_or(0); + let input_tokens = usage.get("input").and_then(|v| v.as_u64()).unwrap_or(0); + let output_tokens = usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0); + let cost = usage.get("cost").and_then(|c| c.get("total")).and_then(|v| v.as_f64()).unwrap_or(0.0); + let model = msg.get("responseModel").or_else(|| msg.get("model")).and_then(|v| v.as_str()).map(|s| s.to_string()); + + let _ = event_tx.send(StreamEvent::Usage(UsageReport { + total_tokens, + input_tokens, + output_tokens, + cost, + model, + })); + } + } + } + "session.execution.succeeded" => { + let _ = event_tx.send(StreamEvent::Succeeded); + return Ok(true); + } + "session.execution.failed" => { + let err_msg = data.get("error").and_then(|e| e.get("message")).and_then(|v| v.as_str()).unwrap_or("Execution failed"); + let _ = event_tx.send(StreamEvent::Failed(err_msg.to_string())); + return Ok(true); + } + "session.execution.interrupted" => { + let reason = data.get("reason").and_then(|v| v.as_str()).unwrap_or("Interrupted"); + let _ = event_tx.send(StreamEvent::Interrupted(reason.to_string())); + return Ok(true); + } + _ => {} + } + } + } + ServerMessage::Exit { .. } => {} + _ => {} + } + } + } + } + + // The socket ended without a terminal event: the server went away mid-turn. + Ok(false) + } +} + +/// Reads a `session.tool.started` payload. Every field is best-effort: a missing +/// or malformed one yields a plain `tool` line rather than an error. +fn started_event(data: &Value) -> StreamEvent { + let call_id = data.get("callID").and_then(|v| v.as_str()).unwrap_or("tool").to_string(); + let name = data.get("name").and_then(|v| v.as_str()).unwrap_or("tool").to_string(); + let label = data.get("label").and_then(|v| v.as_str()).map(str::to_string); + let arguments = data.get("arguments").cloned().unwrap_or(Value::Null); + StreamEvent::ToolStarted { + call_id, + name, + label, + arguments, + } +} + +/// Reads a `session.tool.settled` payload. The terminal `part` carries both the +/// call's `arguments` and its `result.details`, whose shape is the tool's own +/// (the `edit` tool's `patch` and `firstChangedLine`, a `read`'s line window, a +/// `bash`'s `exitCode`), so it is carried through opaquely. +fn settled_event(data: &Value) -> StreamEvent { + let call_id = data.get("callID").and_then(|v| v.as_str()).unwrap_or("tool").to_string(); + let part = data.get("part"); + let name = part + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()) + .map(str::to_string); + let arguments = part + .and_then(|p| p.get("arguments")) + .cloned() + .unwrap_or(Value::Null); + let result = part.and_then(|p| p.get("result")); + let is_error = result + .and_then(|r| r.get("isError")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let details = result.and_then(|r| r.get("details")).cloned(); + StreamEvent::ToolSettled { + call_id, + name, + is_error, + arguments, + details, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_a_started_call_with_its_arguments() { + let data = json!({ + "sessionId": "ses_1", + "messageId": "msg_1", + "callID": "call_1", + "name": "bash", + "label": "bash", + "arguments": { "command": "npm test", "timeout": 30 }, + }); + match started_event(&data) { + StreamEvent::ToolStarted { call_id, name, label, arguments } => { + assert_eq!(call_id, "call_1"); + assert_eq!(name, "bash"); + assert_eq!(label.as_deref(), Some("bash")); + assert_eq!(arguments.get("command").and_then(Value::as_str), Some("npm test")); + } + other => panic!("expected ToolStarted, got {other:?}"), + } + } + + #[test] + fn parses_a_settled_call_with_its_result_details() { + let data = json!({ + "sessionId": "ses_1", + "messageId": "msg_1", + "callID": "call_1", + "part": { + "type": "toolCall", + "callID": "call_1", + "name": "edit", + "arguments": { "path": "src/a.ts", "edits": [] }, + "status": "completed", + "time": { "start": 1, "end": 2 }, + "result": { + "content": [{ "type": "text", "text": "edited" }], + "isError": false, + "details": { "patch": "--- src/a.ts", "path": "src/a.ts", "firstChangedLine": 3 }, + }, + }, + }); + match settled_event(&data) { + StreamEvent::ToolSettled { call_id, name, is_error, arguments, details } => { + assert_eq!(call_id, "call_1"); + assert_eq!(name.as_deref(), Some("edit")); + assert!(!is_error); + // The arguments ride on the terminal part, so a settled call is titled + // from them even when no started event arrived. + assert_eq!(arguments.get("path").and_then(Value::as_str), Some("src/a.ts")); + let details = details.expect("details"); + assert_eq!(details.get("patch").and_then(Value::as_str), Some("--- src/a.ts")); + assert_eq!(details.get("firstChangedLine").and_then(Value::as_u64), Some(3)); + } + other => panic!("expected ToolSettled, got {other:?}"), + } + } + + #[test] + fn parses_an_error_settlement() { + let data = json!({ + "callID": "call_2", + "part": { + "name": "bash", + "arguments": { "command": "false" }, + "status": "error", + "result": { + "content": [{ "type": "text", "text": "boom" }], + "isError": true, + "details": { "output": "boom", "truncated": false, "exitCode": 1 }, + }, + }, + }); + match settled_event(&data) { + StreamEvent::ToolSettled { is_error, details, .. } => { + assert!(is_error); + assert_eq!(details.and_then(|d| d.get("exitCode").and_then(Value::as_u64)), Some(1)); + } + other => panic!("expected ToolSettled, got {other:?}"), + } + } + + #[test] + fn parses_a_bare_payload_without_arguments_or_details() { + // A plugin tool, or an older server: nothing to read, nothing to fail on. + match started_event(&json!({ "callID": "c", "name": "acme_echo" })) { + StreamEvent::ToolStarted { arguments, label, .. } => { + assert!(arguments.is_null()); + assert!(label.is_none()); + } + other => panic!("expected ToolStarted, got {other:?}"), + } + + let settled = json!({ + "callID": "c", + "part": { "name": "acme_echo", "status": "completed" }, + }); + match settled_event(&settled) { + StreamEvent::ToolSettled { arguments, details, is_error, name, .. } => { + assert!(arguments.is_null()); + assert!(details.is_none()); + assert!(!is_error); + assert_eq!(name.as_deref(), Some("acme_echo")); + } + other => panic!("expected ToolSettled, got {other:?}"), + } + } + + #[tokio::test] + #[ignore] + async fn test_rpc_flow() { + let client = RpcClient::new(DEFAULT_SERVER_URL); + if !client.is_server_running().await { + println!("Server not running, skipping test"); + return; + } + + println!("Creating session..."); + let session = client.create_session("google", "gemini-2.5-flash", None, None).await; + println!("Session result: {:?}", session); + let session = match session { + Ok(s) => s, + Err(e) => { + panic!("Failed to create session: {}", e); + } + }; + + println!("Prompting session {}...", session.id); + let (tx, mut rx) = mpsc::unbounded_channel(); + let c = RpcClient::new(DEFAULT_SERVER_URL); + let s_id = session.id.clone(); + tokio::spawn(async move { + let res = c.prompt_session(s_id, "hello".to_string(), tx).await; + println!("prompt_session finished: {:?}", res); + }); + + let mut events_count = 0; + while let Some(evt) = rx.recv().await { + println!("Got stream event: {:?}", evt); + events_count += 1; + if matches!(evt, StreamEvent::Succeeded | StreamEvent::Failed(_) | StreamEvent::Interrupted(_)) { + break; + } + } + println!("Total events received: {}", events_count); + } +} diff --git a/packages/tui/src/rpc/mod.rs b/packages/tui/src/rpc/mod.rs new file mode 100644 index 00000000..141bc5e3 --- /dev/null +++ b/packages/tui/src/rpc/mod.rs @@ -0,0 +1,4 @@ +pub mod client; +pub mod protocol; + +pub use client::{RpcClient, StreamEvent}; diff --git a/packages/tui/src/rpc/protocol.rs b/packages/tui/src/rpc/protocol.rs new file mode 100644 index 00000000..f1905a03 --- /dev/null +++ b/packages/tui/src/rpc/protocol.rs @@ -0,0 +1,132 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +pub struct RpcRequest { + pub _tag: &'static str, + pub id: u64, + pub tag: String, + pub payload: T, + pub headers: Vec<(String, String)>, +} + +impl RpcRequest { + pub fn new(id: u64, tag: impl Into, payload: T) -> Self { + Self { + _tag: "Request", + id, + tag: tag.into(), + payload, + headers: Vec::new(), + } + } +} + +#[derive(Debug, Serialize)] +pub struct RpcAck { + pub _tag: &'static str, + #[serde(rename = "requestId")] + pub request_id: u64, +} + +impl RpcAck { + pub fn new(request_id: u64) -> Self { + Self { + _tag: "Ack", + request_id, + } + } +} + +#[allow(dead_code)] +#[derive(Debug, Serialize)] +pub struct RpcInterrupt { + pub _tag: &'static str, + #[serde(rename = "requestId")] + pub request_id: u64, +} + +#[allow(dead_code)] +impl RpcInterrupt { + pub fn new(request_id: u64) -> Self { + Self { + _tag: "Interrupt", + request_id, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "_tag")] +pub enum ServerMessage { + Exit { + #[serde(rename = "requestId")] + request_id: serde_json::Value, + exit: ExitPayload, + }, + Chunk { + #[serde(rename = "requestId")] + request_id: serde_json::Value, + values: Vec, + }, + Defect { + #[allow(dead_code)] + defect: Option, + }, + #[serde(other)] + Other, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "_tag")] +pub enum ExitPayload { + Success { + value: serde_json::Value, + }, + Failure { + cause: Option, + }, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EventEnvelope { + #[allow(dead_code)] + pub id: Option, + #[serde(rename = "type")] + pub event_type: String, + pub data: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreateSessionPayload { + #[serde(rename = "hostDir")] + pub host_dir: String, + pub runtime: RuntimePayload, +} + +#[derive(Debug, Serialize)] +pub struct RuntimePayload { + pub model: ModelRefPayload, + #[serde(rename = "thinkingLevel", skip_serializing_if = "Option::is_none")] + pub thinking_level: Option, +} + +#[derive(Debug, Serialize)] +pub struct ModelRefPayload { + pub provider: String, + pub id: String, +} + +#[derive(Debug, Serialize)] +pub struct PromptSessionPayload { + #[serde(rename = "sessionId")] + pub session_id: String, + pub text: String, + pub delivery: &'static str, // "followUp" + pub id: String, +} + +#[derive(Debug, Serialize)] +pub struct InterruptSessionPayload { + #[serde(rename = "sessionId")] + pub session_id: String, +} diff --git a/packages/tui/src/tool.rs b/packages/tui/src/tool.rs new file mode 100644 index 00000000..b65d4dd3 --- /dev/null +++ b/packages/tui/src/tool.rs @@ -0,0 +1,597 @@ +//! Turns one tool call's raw payload — the tool name, its `arguments`, and the +//! result `details` the harness publishes — into the short human view the +//! transcript shows. +//! +//! Everything here is pure and resolved once, when the event arrives, so the +//! renderer never re-parses JSON per frame. The only tool-specific knowledge is +//! a small name switch; a tool whose arguments we cannot read (a plugin's, say) +//! degrades to its label rather than failing, and every field read is +//! best-effort because `details` is arbitrary tool-defined JSON. + +use serde_json::Value; + +use crate::diff; +use crate::types::ToolResult; + +/// Consecutive calls to one tool at or above this count collapse into one row. +pub const GROUP_MIN_RUN: usize = 3; + +/// How many distinct targets a collapsed run lists before `…`. +const RUN_TITLES_MAX: usize = 3; + +/// A path longer than this is shortened to its last two components. +const PATH_MAX_CHARS: usize = 40; + +/// What the call touched: `bash` → its command, `read`/`edit` → the path, +/// `search` → the query. `None` when the tool's arguments say nothing readable, +/// which is the normal case for a plugin tool. +pub fn target(name: &str, arguments: &Value) -> Option { + let arg = |key: &str| arguments.get(key).and_then(Value::as_str); + let derived = match name { + // The command is the single most useful thing to show for bash, and it is + // routinely multi-line: one line of layout is all the transcript has. + "bash" => arg("command").map(collapse_whitespace), + "read" | "edit" => arg("path").map(shorten_path), + "search" => arg("query").map(|query| { + if arg("mode") == Some("files") { + format!("files: {}", collapse_whitespace(query)) + } else { + collapse_whitespace(query) + } + }), + _ => None, + }; + derived.filter(|value| !value.is_empty()) +} + +/// Builds the display record for one settled call. +/// +/// `arguments` and `details` come straight off the wire and a missing or +/// unexpected field only ever means a plainer line, never an error. +pub fn derive( + name: &str, + label: Option<&str>, + arguments: &Value, + details: Option<&Value>, + is_error: bool, +) -> ToolResult { + let patch = string_field(details, "patch"); + let first_changed_line = details + .and_then(|details| details.get("firstChangedLine")) + .and_then(Value::as_u64); + let call_path = arguments.get("path").and_then(Value::as_str); + let summary = match &patch { + // An edit's summary *is* its diff: the counts are the whole point. + Some(patch) => Some(edit_summary(patch, call_path, first_changed_line)), + None => summary(name, details, is_error), + }; + + ToolResult { + name: name.to_string(), + target: target(name, arguments), + label: label.unwrap_or(name).to_string(), + summary, + is_error, + patch, + first_changed_line, + } +} + +/// Carries the outcome from a settle onto the record of the call that was +/// running, keeping the identity the running line already resolved: the terminal +/// part repeats the arguments but not the tool's declared label. +pub fn settle(mut running: ToolResult, settled: ToolResult) -> ToolResult { + running.is_error = settled.is_error; + running.summary = settled.summary; + running.patch = settled.patch; + running.first_changed_line = settled.first_changed_line; + running +} + +/// A short result summary — line window, exit code, match counts — or `None` +/// when the tool's details say nothing worth a line. +pub fn summary(name: &str, details: Option<&Value>, is_error: bool) -> Option { + let details = details?; + if is_error { + return failure_summary(details); + } + match name { + "read" => read_summary(details), + "bash" => bash_summary(details), + "search" => search_summary(details), + _ => None, + } +} + +/// One row of a turn's settled tool list. +#[derive(Debug, Clone)] +pub enum ToolRow<'a> { + /// A call rendered on its own: heading, plus a diff when it produced one. + Single(&'a ToolResult), + /// [`GROUP_MIN_RUN`] or more consecutive calls to the same tool. + Run { + name: String, + members: Vec<&'a ToolResult>, + /// How many of `members` failed. + failed: usize, + }, +} + +/// Collapses runs of consecutive calls to the same tool, so a long stretch of +/// `read`, `read`, `read` costs one line instead of twenty. +/// +/// A run is never collapsed when any of its calls produced a diff: hiding a +/// change behind a count would defeat the point of rendering diffs at all. +pub fn group(tools: &[ToolResult]) -> Vec> { + let mut rows = Vec::new(); + let mut index = 0; + while index < tools.len() { + let name = tools[index].name.as_str(); + let mut end = index + 1; + while end < tools.len() && tools[end].name.as_str() == name { + end += 1; + } + let run = &tools[index..end]; + if run.len() >= GROUP_MIN_RUN && run.iter().all(|tool| tool.patch.is_none()) { + rows.push(ToolRow::Run { + name: name.to_string(), + members: run.iter().collect(), + failed: run.iter().filter(|tool| tool.is_error).count(), + }); + } else { + rows.extend(run.iter().map(ToolRow::Single)); + } + index = end; + } + rows +} + +/// The distinct targets a collapsed run touched, in first-seen order, capped at +/// [`RUN_TITLES_MAX`] with a trailing `…` when more were involved. +pub fn run_targets(members: &[&ToolResult]) -> String { + let mut distinct: Vec<&str> = Vec::new(); + for member in members { + let target = member.target.as_deref().unwrap_or(&member.label); + if !distinct.contains(&target) { + distinct.push(target); + } + } + let shown = distinct.len().min(RUN_TITLES_MAX); + let mut text = distinct[..shown].join(", "); + if distinct.len() > shown { + text.push_str(", …"); + } + text +} + +fn string_field(details: Option<&Value>, key: &str) -> Option { + details?.get(key)?.as_str().map(str::to_string) +} + +fn number(details: &Value, key: &str) -> Option { + details.get(key).and_then(Value::as_u64) +} + +/// A declared failure: a message when the tool wrote one, otherwise whatever +/// structured fact it did leave behind. +fn failure_summary(details: &Value) -> Option { + if let Some(message) = string_field(Some(details), "message") { + return Some(collapse_whitespace(&message)); + } + if let Some(seconds) = number(details, "timeoutSeconds") { + return Some(format!("timed out after {seconds}s")); + } + if let Some(code) = number(details, "exitCode") { + return Some(format!("exit {code}")); + } + // `reason` for the read/search/edit tools, `error` for an argument or + // unknown-tool rejection: both arrive as snake_case identifiers. + string_field(Some(details), "reason") + .or_else(|| string_field(Some(details), "error")) + .map(|value| value.replace('_', " ")) +} + +fn read_summary(details: &Value) -> Option { + let start = number(details, "startLine")?; + let end = number(details, "endLine")?; + let total = number(details, "totalLines")?; + let mut text = if start == end { + format!("line {start} of {total}") + } else { + format!("{start}-{end} of {total}") + }; + if details.get("truncated").and_then(Value::as_bool) == Some(true) { + text.push_str(" · truncated"); + } + Some(text) +} + +fn bash_summary(details: &Value) -> Option { + let mut parts = Vec::new(); + if let Some(code) = number(details, "exitCode") { + parts.push(format!("exit {code}")); + } + // How much output there was is the other thing worth knowing: it is how a + // reader spots that a command said far more than the line shows. + if let Some(lines) = string_field(Some(details), "output") + .map(|output| output.lines().count()) + .filter(|lines| *lines > 1) + { + parts.push(format!("{lines} lines")); + } + if parts.is_empty() { + None + } else { + Some(parts.join(" · ")) + } +} + +fn search_summary(details: &Value) -> Option { + let files = number(details, "files")?; + let matches = number(details, "matches")?; + let files_label = if files == 1 { + "1 file".to_string() + } else { + format!("{files} files") + }; + let matches_label = if matches == 1 { + "1 match".to_string() + } else { + format!("{matches} matches") + }; + let mut text = format!("{files_label} · {matches_label}"); + if details.get("truncated").and_then(Value::as_bool) == Some(true) { + text.push_str(" · truncated"); + } else if details.get("hasMore").and_then(Value::as_bool) == Some(true) { + text.push_str(" · more"); + } + Some(text) +} + +/// `call_path` is the path the call declared, which the heading already shows; +/// repeating it here would say the same thing twice. +fn edit_summary(patch: &str, call_path: Option<&str>, first_changed_line: Option) -> String { + let counts = diff::counts(patch); + let mut parts = vec![format!("+{} −{}", counts.added, counts.removed)]; + if let Some(path) = diff::patch_path(patch) { + if call_path != Some(path.as_str()) { + parts.push(shorten_path(&path)); + } + } + if let Some(line) = first_changed_line { + parts.push(format!("line {line}")); + } + parts.join(" · ") +} + +/// Flattens all whitespace — including the newlines in a multi-line command — +/// so the value can sit on one row. +fn collapse_whitespace(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +/// Keeps the tail of a long path (`…/plugin/builtin/edit.ts`), which is the part +/// that identifies it, instead of letting the renderer cut the file name off. +fn shorten_path(path: &str) -> String { + if path.chars().count() <= PATH_MAX_CHARS { + return path.to_string(); + } + let mut tail: Vec<&str> = path.rsplitn(3, '/').collect(); + tail.truncate(2); + tail.reverse(); + let candidate = format!("…/{}", tail.join("/")); + if candidate.chars().count() <= PATH_MAX_CHARS { + return candidate; + } + format!("…/{}", tail.last().copied().unwrap_or(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn call(name: &str, target: &str) -> ToolResult { + ToolResult { + name: name.to_string(), + target: Some(target.to_string()), + label: name.to_string(), + summary: None, + is_error: false, + patch: None, + first_changed_line: None, + } + } + + #[test] + fn bash_target_is_its_command_on_one_line() { + let args = json!({ "command": "npm test \\\n --watch\t--run" }); + assert_eq!(target("bash", &args).as_deref(), Some("npm test \\ --watch --run")); + } + + #[test] + fn read_and_edit_targets_are_the_path() { + let args = json!({ "path": "src/app.ts" }); + assert_eq!(target("read", &args).as_deref(), Some("src/app.ts")); + assert_eq!(target("edit", &args).as_deref(), Some("src/app.ts")); + } + + #[test] + fn search_target_marks_files_mode() { + assert_eq!(target("search", &json!({ "query": "useState" })).as_deref(), Some("useState")); + assert_eq!( + target("search", &json!({ "query": "*.test.ts", "mode": "files" })).as_deref(), + Some("files: *.test.ts") + ); + } + + #[test] + fn long_paths_keep_their_tail() { + let long = "packages/harness/src/plugin/builtin/tool/edit.ts"; + let shortened = target("read", &json!({ "path": long })).expect("a target"); + assert!(shortened.chars().count() <= PATH_MAX_CHARS, "{shortened}"); + assert!(shortened.ends_with("/tool/edit.ts"), "{shortened}"); + assert!(shortened.starts_with('…'), "{shortened}"); + } + + #[test] + fn unreadable_arguments_have_no_target() { + // A plugin tool, a tool with no arguments, a null value, a blank command. + assert_eq!(target("acme_echo", &json!({ "anything": 1 })), None); + assert_eq!(target("bash", &Value::Null), None); + assert_eq!(target("bash", &json!({ "command": "" })), None); + assert_eq!(target("bash", &json!({ "command": " \n " })), None); + } + + #[test] + fn heading_composes_the_name_and_target() { + let mut result = call("read", "src/app.ts"); + assert_eq!(result.heading(), "read src/app.ts"); + // With no readable target the label stands alone, as it did before. + result.target = None; + result.label = "echo".to_string(); + assert_eq!(result.heading(), "echo"); + } + + #[test] + fn derive_reads_the_line_window() { + let result = derive( + "read", + None, + &json!({ "path": "src/app.ts", "offset": 120, "limit": 140 }), + Some(&json!({ + "content": "1|…", + "truncated": false, + "path": "src/app.ts", + "startLine": 120, + "endLine": 260, + "totalLines": 431, + })), + false, + ); + assert_eq!(result.heading(), "read src/app.ts"); + assert_eq!(result.summary.as_deref(), Some("120-260 of 431")); + assert!(result.patch.is_none()); + } + + #[test] + fn read_summary_says_truncated() { + let details = json!({ + "truncated": true, + "startLine": 1, + "endLine": 200, + "totalLines": 900, + }); + assert_eq!( + summary("read", Some(&details), false).as_deref(), + Some("1-200 of 900 · truncated") + ); + } + + #[test] + fn bash_success_reports_exit_code_and_output_size() { + let details = json!({ "output": "a\nb\nc\n", "truncated": false, "exitCode": 0 }); + assert_eq!(summary("bash", Some(&details), false).as_deref(), Some("exit 0 · 3 lines")); + } + + #[test] + fn bash_single_line_output_reports_only_the_exit_code() { + let details = json!({ "output": "ok\n", "truncated": false, "exitCode": 0 }); + assert_eq!(summary("bash", Some(&details), false).as_deref(), Some("exit 0")); + } + + #[test] + fn search_summary_counts_files_and_matches() { + let details = json!({ + "matches": 17, + "files": 3, + "truncated": false, + "hasMore": false, + "backend": "ripgrep", + }); + assert_eq!( + summary("search", Some(&details), false).as_deref(), + Some("3 files · 17 matches") + ); + } + + #[test] + fn search_summary_pluralises_and_flags_more() { + let details = json!({ "matches": 1, "files": 1, "truncated": false, "hasMore": true }); + assert_eq!( + summary("search", Some(&details), false).as_deref(), + Some("1 file · 1 match · more") + ); + } + + #[test] + fn failure_summary_prefers_the_message() { + let details = json!({ + "path": "src/app.ts", + "reason": "no_match", + "message": "oldText not found\nin src/app.ts", + }); + assert_eq!( + summary("edit", Some(&details), true).as_deref(), + Some("oldText not found in src/app.ts") + ); + } + + #[test] + fn failure_summary_falls_back_to_structured_fields() { + let bash = json!({ "output": "boom", "truncated": false, "exitCode": 127 }); + assert_eq!(summary("bash", Some(&bash), true).as_deref(), Some("exit 127")); + + let timeout = json!({ "output": "", "truncated": false, "timeoutSeconds": 30 }); + assert_eq!(summary("bash", Some(&timeout), true).as_deref(), Some("timed out after 30s")); + + let rejected = json!({ "error": "invalid_arguments", "name": "read" }); + assert_eq!(summary("read", Some(&rejected), true).as_deref(), Some("invalid arguments")); + } + + #[test] + fn edit_summary_comes_from_the_patch_without_repeating_the_path() { + let patch = "--- src/a.ts\n+++ src/a.ts\n@@ -1,3 +1,3 @@\n let a = 1;\n-let b = 2;\n+let b = 3;\n"; + let result = derive( + "edit", + None, + &json!({ "path": "src/a.ts" }), + Some(&json!({ "patch": patch, "path": "src/a.ts", "editsApplied": 1, "created": false, "firstChangedLine": 2 })), + false, + ); + assert_eq!(result.heading(), "edit src/a.ts"); + assert_eq!(result.summary.as_deref(), Some("+1 −1 · line 2")); + assert_eq!(result.patch.as_deref(), Some(patch)); + assert_eq!(result.first_changed_line, Some(2)); + } + + #[test] + fn edit_summary_keeps_the_path_when_the_call_did_not_declare_one() { + let patch = "--- src/a.ts\n+++ src/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; + assert_eq!( + edit_summary(patch, None, None), + "+1 −1 · src/a.ts", + "with no heading path the summary must name the file" + ); + } + + #[test] + fn missing_details_plainly_degrade() { + let result = derive("read", None, &json!({ "path": "src/app.ts" }), None, false); + assert!(result.summary.is_none()); + assert!(result.patch.is_none()); + assert!(result.first_changed_line.is_none()); + + let unknown = derive("acme_echo", None, &Value::Null, Some(&json!({ "stdout": "hi" })), false); + assert_eq!(unknown.heading(), "acme_echo"); + assert!(unknown.summary.is_none()); + } + + #[test] + fn settle_keeps_the_running_identity_and_takes_the_outcome() { + // The terminal part omits `arguments`, so a settled record built from it + // alone would lose the target; the running record supplies it. + let running = derive("read", Some("read"), &json!({ "path": "src/app.ts" }), None, false); + + let ok = settle( + running.clone(), + derive( + "read", + None, + &Value::Null, + Some(&json!({ "startLine": 1, "endLine": 10, "totalLines": 10, "truncated": false })), + false, + ), + ); + assert_eq!(ok.heading(), "read src/app.ts"); + assert_eq!(ok.summary.as_deref(), Some("1-10 of 10")); + assert!(!ok.is_error); + + let failed = settle( + running, + derive( + "read", + None, + &Value::Null, + Some(&json!({ "path": "src/app.ts", "reason": "not_found", "message": "File not found: src/app.ts" })), + true, + ), + ); + assert_eq!(failed.heading(), "read src/app.ts"); + assert!(failed.is_error); + assert_eq!(failed.summary.as_deref(), Some("File not found: src/app.ts")); + } + + #[test] + fn collapses_a_run_of_three_or_more() { + let tools = vec![ + call("read", "a.rs"), + call("read", "b.rs"), + call("read", "c.rs"), + call("read", "d.rs"), + ]; + let rows = group(&tools); + assert_eq!(rows.len(), 1); + match &rows[0] { + ToolRow::Run { name, members, failed } => { + assert_eq!(name, "read"); + assert_eq!(members.len(), 4); + assert_eq!(*failed, 0); + assert_eq!(run_targets(members), "a.rs, b.rs, c.rs, …"); + } + other => panic!("expected a run, got {other:?}"), + } + } + + #[test] + fn keeps_short_runs_and_different_tools_separate() { + let tools = vec![call("read", "a.rs"), call("read", "b.rs"), call("bash", "c")]; + let rows = group(&tools); + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|row| matches!(row, ToolRow::Single(_)))); + } + + #[test] + fn never_collapses_a_run_containing_a_diff() { + let mut edited = call("edit", "src/a.ts"); + edited.patch = Some("--- src/a.ts\n+++ src/a.ts\n@@ -1 +1 @@\n-a\n+b\n".to_string()); + let tools = vec![call("edit", "b.ts"), edited, call("edit", "c.ts")]; + let rows = group(&tools); + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|row| matches!(row, ToolRow::Single(_)))); + } + + #[test] + fn a_run_reports_its_failures() { + let mut failed = call("bash", "npm test"); + failed.is_error = true; + let tools = vec![call("bash", "npm test"), failed, call("bash", "npm test")]; + let rows = group(&tools); + assert_eq!(rows.len(), 1); + match &rows[0] { + ToolRow::Run { failed, .. } => assert_eq!(*failed, 1), + other => panic!("expected a run, got {other:?}"), + } + } + + #[test] + fn run_targets_are_distinct_and_capped() { + let tools = vec![ + call("read", "a.rs"), + call("read", "a.rs"), + call("read", "b.rs"), + call("read", "c.rs"), + call("read", "d.rs"), + ]; + let rows = group(&tools); + match &rows[0] { + ToolRow::Run { members, .. } => assert_eq!(run_targets(members), "a.rs, b.rs, c.rs, …"), + other => panic!("expected a run, got {other:?}"), + } + } + + #[test] + fn grouping_an_empty_list_is_empty() { + assert!(group(&[]).is_empty()); + } +} diff --git a/packages/tui/src/types.rs b/packages/tui/src/types.rs new file mode 100644 index 00000000..71ea7904 --- /dev/null +++ b/packages/tui/src/types.rs @@ -0,0 +1,195 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelConfig { + pub provider: String, + pub model: String, + #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + /// Selected UI theme id (e.g. "tokyo-night"); absent means the default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelEntry { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(rename = "isDefault", default)] + pub is_default: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ModelCost { + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(rename = "cacheRead", skip_serializing_if = "Option::is_none")] + pub cache_read: Option, + #[serde(rename = "cacheWrite", skip_serializing_if = "Option::is_none")] + pub cache_write: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelDetails { + pub id: String, + pub name: String, + pub provider_id: String, + pub provider_name: String, + pub env_keys: Vec, + pub base_url: Option, + pub npm: Option, + pub reasoning: bool, + pub input_modalities: Vec, + pub cost: Option, + pub context_window: Option, + pub max_tokens: Option, + pub family: String, + pub status: String, + pub docs_url: Option, + pub released: Option, + pub updated: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderSummary { + pub id: String, + pub name: String, + pub model_count: usize, + pub env_keys: Vec, + pub base_url: Option, + pub npm: Option, + pub docs_url: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusedPanel { + Providers, + Models, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelSortMode { + Id, + Provider, + Cost, + Context, +} + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct ProviderDefinition { + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + pub env_keys: &'static [&'static str], + pub default_model: &'static str, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionInfo { + pub id: String, + pub title: String, + pub directory: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UsageReport { + #[serde(rename = "totalTokens", default)] + pub total_tokens: u64, + #[serde(rename = "inputTokens", default)] + pub input_tokens: u64, + #[serde(rename = "outputTokens", default)] + pub output_tokens: u64, + #[serde(default)] + pub cost: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +/// A tool call as the transcript shows it: one settled call, plus whatever its +/// result `details` exposed. +/// +/// `target` and `summary` are derived once, when the event arrives (see +/// `crate::tool`), so rendering never re-parses JSON per frame. `patch` is a +/// standard unified diff (the `edit` tool emits one); a tool that produces no +/// diff simply leaves it `None`. +#[derive(Debug, Clone)] +pub struct ToolResult { + /// Raw tool name: the row's identity, and what grouping keys on. + pub name: String, + /// What the call touched — its command, path, or query — read from the tool + /// call's arguments. `None` when they are unreadable, including for every + /// plugin tool whose argument shape we do not know. + pub target: Option, + /// Display fallback when there is no `target`: the tool's declared label. + pub label: String, + /// A short result summary: line window, exit code, match counts, +/− counts. + pub summary: Option, + pub is_error: bool, + pub patch: Option, + pub first_changed_line: Option, +} + +impl ToolResult { + /// The row's heading: the tool name plus what it touched, or just the label + /// when the arguments were unreadable. + pub fn heading(&self) -> String { + match &self.target { + Some(target) => format!("{} {}", self.name, target), + None => self.label.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub struct ConversationTurn { + #[allow(dead_code)] + pub id: String, + pub prompt: String, + pub response: String, + pub thinking: String, + pub streaming: bool, + pub start_time: i64, + pub end_time: Option, + pub tokens: u64, + pub tokens_per_sec: f64, + pub model_name: String, + pub error: Option, + /// The tool call that is currently running, titled from its arguments. + pub active_tool: Option, + pub active_call_id: Option, + pub completed_tools: Vec, +} + +impl ConversationTurn { + pub fn new(id: String, prompt: String, model_name: String) -> Self { + Self { + id, + prompt, + response: String::new(), + thinking: String::new(), + streaming: true, + start_time: chrono::Utc::now().timestamp_millis(), + end_time: None, + tokens: 0, + tokens_per_sec: 0.0, + model_name, + error: None, + active_tool: None, + active_call_id: None, + completed_tools: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct SessionStats { + pub total_tokens: u64, + pub cost: f64, + pub turns_count: usize, +} diff --git a/packages/tui/src/ui/input.rs b/packages/tui/src/ui/input.rs new file mode 100644 index 00000000..985f95e9 --- /dev/null +++ b/packages/tui/src/ui/input.rs @@ -0,0 +1,401 @@ +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; +use unicode_width::UnicodeWidthChar; + +use crate::ui::theme::Theme; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InputVisualLine { + pub text: String, + pub start_char_idx: usize, + pub char_count: usize, + pub cursor_col: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WrappedInput { + pub lines: Vec, + pub cursor_line: usize, + pub cursor_col: usize, +} + +pub fn wrap_input_with_cursor(input: &str, cursor: usize, max_width: usize) -> WrappedInput { + let max_width = max_width.max(10); + let total_chars = input.chars().count(); + let cursor = cursor.min(total_chars); + + if input.is_empty() { + return WrappedInput { + lines: vec![InputVisualLine { + text: String::new(), + start_char_idx: 0, + char_count: 0, + cursor_col: Some(0), + }], + cursor_line: 0, + cursor_col: 0, + }; + } + + let chars_with_idx: Vec<(char, usize)> = input.chars().enumerate().map(|(i, c)| (c, i)).collect(); + + // 1. Split into logical lines by newline '\n' + let mut raw_lines: Vec<(Vec<(char, usize)>, bool)> = Vec::new(); + let mut cur_line_chars: Vec<(char, usize)> = Vec::new(); + + for (c, idx) in chars_with_idx { + if c == '\n' { + raw_lines.push((std::mem::take(&mut cur_line_chars), true)); + } else { + cur_line_chars.push((c, idx)); + } + } + raw_lines.push((cur_line_chars, false)); + + // 2. Wrap each logical line into visual lines of visual width <= max_width + struct RawVisLine { + chars: Vec<(char, usize)>, + start_char_idx: usize, + ended_with_newline: bool, + } + + let mut vis_lines: Vec = Vec::new(); + let mut current_offset = 0usize; + + for (line_chars, ended_with_newline) in raw_lines { + if line_chars.is_empty() { + vis_lines.push(RawVisLine { + chars: Vec::new(), + start_char_idx: current_offset, + ended_with_newline, + }); + if ended_with_newline { + current_offset += 1; + } + continue; + } + + // Tokenize line into alternating word and whitespace tokens + let mut tokens: Vec> = Vec::new(); + let mut cur_token: Vec<(char, usize)> = Vec::new(); + let mut in_ws = false; + + for (c, idx) in line_chars { + let is_ws = c == ' ' || c == '\t'; + if cur_token.is_empty() { + in_ws = is_ws; + cur_token.push((c, idx)); + } else if in_ws == is_ws { + cur_token.push((c, idx)); + } else { + tokens.push(std::mem::take(&mut cur_token)); + in_ws = is_ws; + cur_token.push((c, idx)); + } + } + if !cur_token.is_empty() { + tokens.push(cur_token); + } + + let mut cur_vis: Vec<(char, usize)> = Vec::new(); + let mut cur_vis_width: usize = 0; + + for token in tokens { + let tok_width: usize = token + .iter() + .map(|(c, _)| UnicodeWidthChar::width(*c).unwrap_or(1)) + .sum(); + + if cur_vis.is_empty() { + if tok_width <= max_width { + cur_vis.extend(token); + cur_vis_width = tok_width; + } else { + for (c, idx) in token { + let cw = UnicodeWidthChar::width(c).unwrap_or(1); + if cur_vis_width + cw > max_width && !cur_vis.is_empty() { + let start_idx = cur_vis.first().unwrap().1; + vis_lines.push(RawVisLine { + chars: std::mem::take(&mut cur_vis), + start_char_idx: start_idx, + ended_with_newline: false, + }); + cur_vis_width = 0; + } + cur_vis.push((c, idx)); + cur_vis_width += cw; + } + } + } else if cur_vis_width + tok_width <= max_width { + cur_vis.extend(token); + cur_vis_width += tok_width; + } else { + let start_idx = cur_vis.first().unwrap().1; + vis_lines.push(RawVisLine { + chars: std::mem::take(&mut cur_vis), + start_char_idx: start_idx, + ended_with_newline: false, + }); + cur_vis_width = 0; + + if tok_width <= max_width { + cur_vis.extend(token); + cur_vis_width = tok_width; + } else { + for (c, idx) in token { + let cw = UnicodeWidthChar::width(c).unwrap_or(1); + if cur_vis_width + cw > max_width && !cur_vis.is_empty() { + let start_idx = cur_vis.first().unwrap().1; + vis_lines.push(RawVisLine { + chars: std::mem::take(&mut cur_vis), + start_char_idx: start_idx, + ended_with_newline: false, + }); + cur_vis_width = 0; + } + cur_vis.push((c, idx)); + cur_vis_width += cw; + } + } + } + } + + if !cur_vis.is_empty() { + let start_idx = cur_vis.first().unwrap().1; + let count = cur_vis.len(); + vis_lines.push(RawVisLine { + chars: cur_vis, + start_char_idx: start_idx, + ended_with_newline, + }); + current_offset = start_idx + count; + if ended_with_newline { + current_offset += 1; + } + } else if let Some(last) = vis_lines.last_mut() { + last.ended_with_newline = ended_with_newline; + if ended_with_newline { + current_offset += 1; + } + } + } + + if vis_lines.is_empty() { + vis_lines.push(RawVisLine { + chars: Vec::new(), + start_char_idx: 0, + ended_with_newline: false, + }); + } + + // 3. Find cursor line and column + let total_lines = vis_lines.len(); + let mut resolved_cursor_line = total_lines - 1; + let mut resolved_cursor_col = vis_lines[resolved_cursor_line].chars.len(); + + for (i, r_line) in vis_lines.iter().enumerate() { + let is_last = i == total_lines - 1; + let start = r_line.start_char_idx; + let count = r_line.chars.len(); + let ended_nl = r_line.ended_with_newline; + + if ended_nl { + if cursor >= start && cursor <= start + count { + resolved_cursor_line = i; + resolved_cursor_col = cursor - start; + break; + } + } else if is_last { + if cursor >= start { + resolved_cursor_line = i; + resolved_cursor_col = (cursor - start).min(count); + break; + } + } else if cursor >= start && cursor < start + count { + resolved_cursor_line = i; + resolved_cursor_col = cursor - start; + break; + } + } + + // 4. Construct output lines + let mut output_lines = Vec::with_capacity(total_lines); + for (i, r_line) in vis_lines.iter().enumerate() { + let text: String = r_line.chars.iter().map(|(c, _)| *c).collect(); + let char_count = r_line.chars.len(); + let cursor_col = if i == resolved_cursor_line { + Some(resolved_cursor_col) + } else { + None + }; + + output_lines.push(InputVisualLine { + text, + start_char_idx: r_line.start_char_idx, + char_count, + cursor_col, + }); + } + + WrappedInput { + lines: output_lines, + cursor_line: resolved_cursor_line, + cursor_col: resolved_cursor_col, + } +} + +pub fn cursor_up_in_input(input: &str, cursor: usize, max_width: usize) -> Option { + let wrapped = wrap_input_with_cursor(input, cursor, max_width); + if wrapped.cursor_line == 0 { + return None; + } + let target_line = wrapped.cursor_line - 1; + let target = &wrapped.lines[target_line]; + let new_col = wrapped.cursor_col.min(target.char_count); + Some(target.start_char_idx + new_col) +} + +pub fn cursor_down_in_input(input: &str, cursor: usize, max_width: usize) -> Option { + let wrapped = wrap_input_with_cursor(input, cursor, max_width); + if wrapped.cursor_line + 1 >= wrapped.lines.len() { + return None; + } + let target_line = wrapped.cursor_line + 1; + let target = &wrapped.lines[target_line]; + let new_col = wrapped.cursor_col.min(target.char_count); + Some(target.start_char_idx + new_col) +} + +pub fn render_line_spans( + line: &InputVisualLine, + cursor_color: Color, + cursor_visible: bool, +) -> Vec> { + let plain_text = |text: String| Span::styled(text, Style::default().fg(Theme::text_primary())); + + match line.cursor_col { + // Caret is on this line but in its "off" blink phase: draw the text + // untouched so the caret cell goes blank without shifting the layout. + Some(_) if !cursor_visible => vec![plain_text(line.text.clone())], + None => vec![plain_text(line.text.clone())], + Some(col) => { + let char_count = line.char_count; + let col = col.min(char_count); + let chars: Vec = line.text.chars().collect(); + + if col >= char_count { + vec![ + plain_text(line.text.clone()), + Span::styled("█", Style::default().fg(cursor_color)), + ] + } else { + let before: String = chars[..col].iter().collect(); + let cur_char = chars[col]; + let after: String = chars[col + 1..].iter().collect(); + vec![ + plain_text(before), + Span::styled( + cur_char.to_string(), + Style::default() + .fg(Theme::bg_card()) + .bg(cursor_color) + .add_modifier(Modifier::BOLD), + ), + plain_text(after), + ] + } + } + } +} + +#[allow(dead_code)] +pub fn render_input_with_cursor(input: &str, cursor: usize) -> Vec> { + let line = InputVisualLine { + text: input.to_string(), + start_char_idx: 0, + char_count: input.chars().count(), + cursor_col: Some(cursor), + }; + render_line_spans(&line, Theme::accent(), true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_input() { + let wrapped = wrap_input_with_cursor("", 0, 40); + assert_eq!(wrapped.lines.len(), 1); + assert_eq!(wrapped.cursor_line, 0); + assert_eq!(wrapped.cursor_col, 0); + assert_eq!(wrapped.lines[0].text, ""); + assert_eq!(wrapped.lines[0].cursor_col, Some(0)); + } + + #[test] + fn test_short_single_line_cursor_positions() { + let text = "hello"; + let w0 = wrap_input_with_cursor(text, 0, 20); + assert_eq!(w0.cursor_line, 0); + assert_eq!(w0.cursor_col, 0); + assert_eq!(w0.lines[0].cursor_col, Some(0)); + + let w2 = wrap_input_with_cursor(text, 2, 20); + assert_eq!(w2.cursor_line, 0); + assert_eq!(w2.cursor_col, 2); + + let w5 = wrap_input_with_cursor(text, 5, 20); + assert_eq!(w5.cursor_line, 0); + assert_eq!(w5.cursor_col, 5); + } + + #[test] + fn test_soft_wrap_words() { + let text = "hello world how are you"; + let wrapped = wrap_input_with_cursor(text, text.len(), 12); + assert!(wrapped.lines.len() >= 2); + let reconstructed: String = wrapped.lines.iter().map(|l| l.text.as_str()).collect(); + assert_eq!(reconstructed, text); + assert_eq!(wrapped.cursor_line, wrapped.lines.len() - 1); + assert_eq!(wrapped.cursor_col, wrapped.lines.last().unwrap().char_count); + } + + #[test] + fn test_long_word_split() { + let text = "abcdefghijklmnopqrstuvwxyz"; + let wrapped = wrap_input_with_cursor(text, 15, 10); + assert_eq!(wrapped.lines.len(), 3); + assert_eq!(wrapped.lines[0].text, "abcdefghij"); + assert_eq!(wrapped.lines[1].text, "klmnopqrst"); + assert_eq!(wrapped.lines[2].text, "uvwxyz"); + assert_eq!(wrapped.cursor_line, 1); + assert_eq!(wrapped.cursor_col, 5); + } + + #[test] + fn test_explicit_newlines() { + let text = "line1\nline2\nline3"; + let wrapped = wrap_input_with_cursor(text, 7, 30); + assert_eq!(wrapped.lines.len(), 3); + assert_eq!(wrapped.lines[0].text, "line1"); + assert_eq!(wrapped.lines[1].text, "line2"); + assert_eq!(wrapped.lines[2].text, "line3"); + // Index 7 is 'i' in "line2" (l=6, i=7) + assert_eq!(wrapped.cursor_line, 1); + assert_eq!(wrapped.cursor_col, 1); + } + + #[test] + fn test_cursor_up_down_navigation() { + let text = "first line\nsecond long line\nthird"; + let max_w = 40; + // Cursor at column 5 on second line ("second long line" starts at 11, col 5 is at 16) + let cur = 16; + let up = cursor_up_in_input(text, cur, max_w); + assert_eq!(up, Some(5)); // col 5 on "first line" (start 0 + 5) + + let down = cursor_down_in_input(text, cur, max_w); + assert_eq!(down, Some(28 + 5)); // third line starts at 28, length 5 + } +} diff --git a/packages/tui/src/ui/logo.rs b/packages/tui/src/ui/logo.rs new file mode 100644 index 00000000..e34e89c1 --- /dev/null +++ b/packages/tui/src/ui/logo.rs @@ -0,0 +1,125 @@ +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +use crate::ui::spinner::SPINNER_FRAMES; + +pub const LOGO_LINES: [&str; 5] = [ + "▄██████▄ ▄██ ▄██▄", + "██▀ ███ ████", + "██ ███ █ ████", + "██▄ ██████████", + "▀██████▀ ▀█▀ ▀█▀", +]; + +pub const LOGO_GRADIENT: [Color; 9] = [ + Color::Rgb(243, 139, 168), // Pink #F38BA8 + Color::Rgb(235, 160, 172), // Yellow-ish #EBA0AC + Color::Rgb(215, 175, 215), // Mid Flamingo-Lavender + Color::Rgb(180, 190, 254), // Active Lavender #B4BEFE + Color::Rgb(158, 185, 252), // Mid Lavender-Blue + Color::Rgb(137, 180, 250), // Blue accent #89B4FA + Color::Rgb(126, 189, 243), // Mid Blue-Cyan + Color::Rgb(115, 199, 236), // Cyan #73C7EC + Color::Rgb(166, 227, 161), // Green #A6E3A1 +]; + +pub const LOGO_SPARKLE_FRAMES: &[&str] = &["✦", "✧", "✶", "✷", "✸", "✶", "✧", "✦"]; + +#[allow(dead_code)] +pub fn render_logo_lines(pad_left: usize) -> Vec> { + render_animated_logo_lines(pad_left, 0) +} + +pub fn render_animated_logo_lines(pad_left: usize, tick: usize) -> Vec> { + let mut result = Vec::new(); + let prefix = " ".repeat(pad_left); + for line in LOGO_LINES { + let chars: Vec = line.chars().collect(); + let mut spans = Vec::new(); + if !prefix.is_empty() { + spans.push(Span::raw(prefix.clone())); + } + for (col_idx, &ch) in chars.iter().enumerate() { + let base_idx = ((col_idx as f32 / chars.len() as f32) * LOGO_GRADIENT.len() as f32) as usize; + let color_idx = (base_idx + tick) % LOGO_GRADIENT.len(); + let color = LOGO_GRADIENT[color_idx]; + spans.push(Span::styled(ch.to_string(), Style::default().fg(color))); + } + result.push(Line::from(spans)); + } + result +} + +pub fn render_header_logo(tick: usize, is_streaming: bool) -> Vec> { + let mut spans = Vec::new(); + + // 1. Animated Logo Icon + let icon_frame = if is_streaming { + SPINNER_FRAMES[tick % SPINNER_FRAMES.len()] + } else { + LOGO_SPARKLE_FRAMES[(tick / 2) % LOGO_SPARKLE_FRAMES.len()] + }; + + let icon_color = LOGO_GRADIENT[tick % LOGO_GRADIENT.len()]; + spans.push(Span::styled( + format!("{} ", icon_frame), + Style::default().fg(icon_color).add_modifier(Modifier::BOLD), + )); + + // 2. Animated Wordmark "codework" with flowing gradient wave + let word = "codework"; + for (i, ch) in word.chars().enumerate() { + let color_idx = (i + tick) % LOGO_GRADIENT.len(); + let color = LOGO_GRADIENT[color_idx]; + spans.push(Span::styled( + ch.to_string(), + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); + } + spans.push(Span::raw(" ")); + + spans +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_logo_dimensions() { + assert_eq!(LOGO_LINES.len(), 5); + for line in LOGO_LINES { + assert_eq!(line.chars().count(), 20); + } + } + + #[test] + fn test_render_logo_lines() { + let rendered = render_logo_lines(2); + assert_eq!(rendered.len(), 5); + assert_eq!(rendered[0].spans[0].content, " "); + } + + #[test] + fn test_render_animated_logo_lines() { + let rendered_tick0 = render_animated_logo_lines(0, 0); + let rendered_tick1 = render_animated_logo_lines(0, 1); + assert_eq!(rendered_tick0.len(), 5); + assert_eq!(rendered_tick1.len(), 5); + // Colors should shift between ticks + assert_ne!(rendered_tick0[0].spans[0].style.fg, rendered_tick1[0].spans[0].style.fg); + } + + #[test] + fn test_render_header_logo() { + let spans_idle = render_header_logo(0, false); + assert!(!spans_idle.is_empty()); + assert_eq!(spans_idle[0].content, "✦ "); + // Wordmark should spell "codework" + let word: String = spans_idle[1..=8].iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(word, "codework"); + + let spans_streaming = render_header_logo(0, true); + assert_eq!(spans_streaming[0].content, "⠋ "); + } +} diff --git a/packages/tui/src/ui/mod.rs b/packages/tui/src/ui/mod.rs new file mode 100644 index 00000000..680d08f7 --- /dev/null +++ b/packages/tui/src/ui/mod.rs @@ -0,0 +1,21 @@ +pub mod input; +pub mod logo; +pub mod model; +pub mod session; +pub mod spinner; +pub mod text; +pub mod theme; +pub mod theme_picker; +pub mod welcome; + +#[allow(unused_imports)] +pub use input::{ + cursor_down_in_input, cursor_up_in_input, render_input_with_cursor, render_line_spans, + wrap_input_with_cursor, InputVisualLine, WrappedInput, +}; +pub use model::render_model_browser; +pub use session::render_session; +pub use theme_picker::render_theme_picker; +pub use theme::Theme; +#[allow(unused_imports)] +pub use welcome::{commands, render_welcome, CommandItem}; diff --git a/packages/tui/src/ui/model.rs b/packages/tui/src/ui/model.rs new file mode 100644 index 00000000..6ffb3369 --- /dev/null +++ b/packages/tui/src/ui/model.rs @@ -0,0 +1,999 @@ +use std::time::Instant; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; + +use crate::catalog::{ + format_context, format_cost_table, format_rate, get_all_models, + get_all_providers, +}; +use crate::types::{FocusedPanel, ModelDetails, ModelSortMode, ProviderSummary}; +use crate::ui::spinner::Spinner; +use crate::ui::theme::Theme; + +#[derive(Debug, Clone)] +pub struct ModelBrowserState { + pub focused_panel: FocusedPanel, + pub provider_index: usize, + pub provider_scroll_offset: usize, + pub provider_search: String, + pub model_index: usize, + pub model_scroll_offset: usize, + pub model_search: String, + pub search_query: String, + pub is_searching: bool, + pub sort_mode: ModelSortMode, + pub show_help: bool, + pub status_notification: Option<(String, Instant)>, + + // API key entry modal (when model selected requires key) + pub api_key_modal_open: bool, + pub api_key_input: String, + pub pending_model: Option, + pub validating: bool, + pub validation_error: Option, +} + +impl Default for ModelBrowserState { + fn default() -> Self { + Self::new() + } +} + +impl ModelBrowserState { + pub fn new() -> Self { + Self { + focused_panel: FocusedPanel::Models, + provider_index: 0, + provider_scroll_offset: 0, + provider_search: String::new(), + model_index: 0, + model_scroll_offset: 0, + model_search: String::new(), + search_query: String::new(), + is_searching: false, + sort_mode: ModelSortMode::Id, + show_help: false, + status_notification: None, + api_key_modal_open: false, + api_key_input: String::new(), + pending_model: None, + validating: false, + validation_error: None, + } + } + + pub fn set_notification(&mut self, msg: String) { + self.status_notification = Some((msg, Instant::now())); + } + + pub fn active_notification(&self) -> Option<&str> { + if let Some((msg, time)) = &self.status_notification { + if time.elapsed().as_secs() < 3 { + return Some(msg); + } + } + None + } + + pub fn filtered_providers(&self) -> Vec<&'static ProviderSummary> { + let all = get_all_providers(); + let q = self.provider_search.trim().to_lowercase(); + if q.is_empty() { + all.iter().collect() + } else { + all.iter() + .filter(|p| { + p.id.to_lowercase().contains(&q) + || p.name.to_lowercase().contains(&q) + }) + .collect() + } + } + + pub fn selected_provider_id(&self) -> Option<&str> { + let filtered = self.filtered_providers(); + if self.provider_index == 0 { + None + } else { + filtered.get(self.provider_index - 1).map(|p| p.id.as_str()) + } + } + + pub fn filtered_models(&self) -> Vec<&'static ModelDetails> { + let all = get_all_models(); + let selected_prov = self.selected_provider_id(); + let q = if !self.model_search.trim().is_empty() { + self.model_search.trim().to_lowercase() + } else { + self.search_query.trim().to_lowercase() + }; + + let mut models: Vec<&'static ModelDetails> = all + .iter() + .filter(|m| { + if let Some(prov_id) = selected_prov { + if m.provider_id != prov_id { + return false; + } + } + if q.is_empty() { + true + } else { + m.id.to_lowercase().contains(&q) + || m.name.to_lowercase().contains(&q) + || m.provider_id.to_lowercase().contains(&q) + || m.family.to_lowercase().contains(&q) + } + }) + .collect(); + + match self.sort_mode { + ModelSortMode::Id => { + models.sort_by(|a, b| a.id.cmp(&b.id)); + } + ModelSortMode::Provider => { + models.sort_by(|a, b| a.provider_id.cmp(&b.provider_id).then_with(|| a.id.cmp(&b.id))); + } + ModelSortMode::Context => { + models.sort_by(|a, b| { + b.context_window + .unwrap_or(0) + .cmp(&a.context_window.unwrap_or(0)) + .then_with(|| a.id.cmp(&b.id)) + }); + } + ModelSortMode::Cost => { + models.sort_by(|a, b| { + let cost_a = a.cost.as_ref().and_then(|c| c.input).unwrap_or(999999.0); + let cost_b = b.cost.as_ref().and_then(|c| c.input).unwrap_or(999999.0); + cost_a + .partial_cmp(&cost_b) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.id.cmp(&b.id)) + }); + } + } + + models + } + + pub fn current_model(&self) -> Option<&'static ModelDetails> { + let models = self.filtered_models(); + models.get(self.model_index).copied() + } + + pub fn provider_for_details(&self) -> Option<&'static ProviderSummary> { + let providers = get_all_providers(); + if let Some(prov_id) = self.selected_provider_id() { + providers.iter().find(|p| p.id == prov_id) + } else if let Some(m) = self.current_model() { + providers.iter().find(|p| p.id == m.provider_id) + } else { + providers.first() + } + } +} + +pub fn render_model_browser( + f: &mut Frame, + area: Rect, + state: &ModelBrowserState, + spinner: &Spinner, +) { + if area.width < 50 || area.height < 15 { + // Fallback for extremely cramped terminal + let msg = Paragraph::new("Terminal too small for AI Model Browser (min 50x15)"); + f.render_widget(msg, area); + return; + } + + let cursor_visible = spinner.cursor_visible(); + + // 1. Header (1 line) + let header_area = Rect::new(area.x, area.y, area.width, 1); + let mut header_spans = vec![ + Span::styled("models", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)), + Span::styled(" - AI Model Browser", Style::default().fg(Theme::text_muted())), + ]; + + if state.is_searching { + header_spans.push(Span::raw(" ")); + let search_target = match state.focused_panel { + FocusedPanel::Providers => "providers", + FocusedPanel::Models => "models", + }; + let current_query = match state.focused_panel { + FocusedPanel::Providers => &state.provider_search, + FocusedPanel::Models => { + if !state.model_search.is_empty() { + &state.model_search + } else { + &state.search_query + } + } + }; + header_spans.push(Span::styled(format!("/ search {}: ", search_target), Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD))); + header_spans.push(Span::styled(current_query, Style::default().fg(Theme::text_primary()))); + header_spans.push(if cursor_visible { + Span::styled("█", Style::default().fg(Theme::activity())) + } else { + Span::raw(" ") + }); + header_spans.push(Span::styled(" (Enter: browse, Esc: clear)", Style::default().fg(Theme::text_muted()))); + } else { + let mut filter_tags = Vec::new(); + if !state.provider_search.is_empty() { + filter_tags.push(format!("Provider: \"{}\"", state.provider_search)); + } + let m_query = if !state.model_search.is_empty() { + &state.model_search + } else { + &state.search_query + }; + if !m_query.is_empty() { + filter_tags.push(format!("Model: \"{}\"", m_query)); + } + + if !filter_tags.is_empty() { + header_spans.push(Span::raw(" ")); + header_spans.push(Span::styled(format!("[Filter: {}]", filter_tags.join(", ")), Style::default().fg(Theme::activity()))); + } else if let Some(msg) = state.active_notification() { + header_spans.push(Span::raw(" ")); + header_spans.push(Span::styled(format!("✓ {}", msg), Style::default().fg(Theme::success()))); + } + } + f.render_widget(Paragraph::new(Line::from(header_spans)), header_area); + + // 2. Footer (1 line at bottom) + let footer_area = Rect::new(area.x, area.y + area.height.saturating_sub(1), area.width, 1); + let yellow = Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD); + let muted = Style::default().fg(Theme::text_muted()); + + let footer_line = Line::from(vec![ + Span::styled("q", yellow), Span::styled(" quit ", muted), + Span::styled("↑/↓", yellow), Span::styled(" nav ", muted), + Span::styled("Tab", yellow), Span::styled(" switch ", muted), + Span::styled("/", yellow), Span::styled(" search ", muted), + Span::styled("s", yellow), Span::styled(" sort ", muted), + Span::styled("c", yellow), Span::styled(" copy (prov/model) ", muted), + Span::styled("Enter", yellow), Span::styled(" select", muted), + Span::raw(" ".repeat((area.width as usize).saturating_sub(76))), + Span::styled("? help", yellow), + ]); + f.render_widget(Paragraph::new(footer_line), footer_area); + + // 3. Body Layout: split vertically into Top (Providers, Models) and Bottom (Provider, Details) + let body_area = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(2)); + + // Dynamic bottom height based on total terminal height + let total_h = body_area.height; + let bottom_height = if total_h >= 34 { + 13u16 + } else if total_h >= 28 { + 12u16 + } else if total_h >= 22 { + 10u16 + } else { + (total_h * 40 / 100).clamp(7, 9) + }; + let top_height = total_h.saturating_sub(bottom_height); + + let top_area = Rect::new(body_area.x, body_area.y, body_area.width, top_height); + let bottom_area = Rect::new(body_area.x, body_area.y + top_height, body_area.width, bottom_height); + + // Responsive left column width based on total terminal width + let total_w = body_area.width; + let left_width = if total_w >= 160 { + (total_w * 26 / 100).clamp(36, 46) + } else if total_w >= 120 { + (total_w * 28 / 100).clamp(32, 40) + } else if total_w >= 90 { + (total_w * 30 / 100).clamp(28, 34) + } else { + (total_w * 32 / 100).clamp(22, 28) + }; + let right_width = total_w.saturating_sub(left_width); + + let top_left_area = Rect::new(top_area.x, top_area.y, left_width, top_area.height); + let top_right_area = Rect::new(top_area.x + left_width, top_area.y, right_width, top_area.height); + + let bottom_left_area = Rect::new(bottom_area.x, bottom_area.y, left_width, bottom_area.height); + let bottom_right_area = Rect::new(bottom_area.x + left_width, bottom_area.y, right_width, bottom_area.height); + + // ---------------------------------------------------- + // Panel 1: Providers (Top-Left) + // ---------------------------------------------------- + let providers_focused = state.focused_panel == FocusedPanel::Providers; + let prov_border_style = if providers_focused { + Style::default().fg(Theme::sky()) + } else { + Style::default().fg(Theme::border_box()) + }; + let prov_title_style = if providers_focused { + Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Theme::text_secondary()) + }; + + let all_providers = get_all_providers(); + let filtered_providers = state.filtered_providers(); + let total_models = get_all_models().len(); + + let prov_title = if !state.provider_search.is_empty() { + format!(" Providers ({}/{}) [\"{}\"] ", filtered_providers.len(), all_providers.len(), state.provider_search) + } else { + format!(" Providers ({}) ", all_providers.len()) + }; + + let prov_block = Block::default() + .title(Span::styled(prov_title, prov_title_style)) + .borders(Borders::ALL) + .border_style(prov_border_style); + f.render_widget(prov_block, top_left_area); + + let prov_inner = Rect::new( + top_left_area.x + 1, + top_left_area.y + 1, + top_left_area.width.saturating_sub(2), + top_left_area.height.saturating_sub(2), + ); + + let total_provider_items = 1 + filtered_providers.len(); + let visible_prov_rows = prov_inner.height as usize; + + let mut prov_lines = Vec::new(); + let prov_start = state.provider_scroll_offset; + let prov_end = (prov_start + visible_prov_rows).min(total_provider_items); + let prov_inner_w = prov_inner.width as usize; + + if filtered_providers.is_empty() && !state.provider_search.is_empty() { + let is_selected = state.provider_index == 0; + let prefix = if is_selected { "> " } else { " " }; + prov_lines.push(Line::from(vec![ + Span::styled(prefix, if is_selected { Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::text_muted()) }), + Span::styled("All", if is_selected { Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::text_primary()) }), + Span::raw(" "), + Span::styled(format!("({})", total_models), if is_selected { Style::default().fg(Theme::activity()) } else { Style::default().fg(Theme::text_muted()) }), + ])); + prov_lines.push(Line::from(Span::styled( + format!(" (no providers match \"{}\")", state.provider_search), + Style::default().fg(Theme::text_muted()), + ))); + } else { + for idx in prov_start..prov_end { + let is_selected = idx == state.provider_index; + let (prefix, id_str, count_str) = if idx == 0 { + let pfx = if is_selected { "> " } else { " " }; + (pfx, "All".to_string(), format!("({})", total_models)) + } else { + let p = &filtered_providers[idx - 1]; + let pfx = if is_selected { "> " } else { " " }; + (pfx, p.id.clone(), format!("({})", p.model_count)) + }; + + // Ensure the count (e.g. "(103)") is NEVER clipped! Truncate ID with "..." if needed + let avail_id_w = prov_inner_w.saturating_sub(prefix.len() + count_str.len() + 1); + let display_id = if id_str.len() > avail_id_w && avail_id_w >= 4 { + format!("{}...", &id_str[..avail_id_w.saturating_sub(3)]) + } else { + id_str + }; + + let name_style = if is_selected { + Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Theme::text_primary()) + }; + + let count_style = if is_selected { + Style::default().fg(Theme::activity()) + } else { + Style::default().fg(Theme::text_muted()) + }; + + prov_lines.push(Line::from(vec![ + Span::styled(prefix, if is_selected { Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD) } else { Style::default().fg(Theme::text_muted()) }), + Span::styled(display_id, name_style), + Span::raw(" "), + Span::styled(count_str, count_style), + ])); + } + } + f.render_widget(Paragraph::new(prov_lines), prov_inner); + + // ---------------------------------------------------- + // Panel 2: Models (Top-Right) + // ---------------------------------------------------- + let models_focused = state.focused_panel == FocusedPanel::Models; + let models_border_style = if models_focused { + Style::default().fg(Theme::sky()) + } else { + Style::default().fg(Theme::border_box()) + }; + let models_title_style = if models_focused { + Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Theme::text_secondary()) + }; + + let filtered_models = state.filtered_models(); + let sort_tag = match state.sort_mode { + ModelSortMode::Id => "", + ModelSortMode::Provider => " [sorted by provider]", + ModelSortMode::Context => " [sorted by context]", + ModelSortMode::Cost => " [sorted by cost]", + }; + let models_title = if let Some(p_id) = state.selected_provider_id() { + format!(" Models ({}: {}){} ", p_id, filtered_models.len(), sort_tag) + } else { + format!(" Models ({}){} ", filtered_models.len(), sort_tag) + }; + + let models_block = Block::default() + .title(Span::styled(models_title, models_title_style)) + .borders(Borders::ALL) + .border_style(models_border_style); + f.render_widget(models_block, top_right_area); + + let models_inner = Rect::new( + top_right_area.x + 1, + top_right_area.y + 1, + top_right_area.width.saturating_sub(2), + top_right_area.height.saturating_sub(2), + ); + + // Exact responsive column width allocation without any horizontal overflow + let inner_w = models_inner.width as usize; + let prefix_w = 2usize; + let (prov_w, cost_w, ctx_w) = if inner_w >= 115 { + (24usize, 16usize, 8usize) + } else if inner_w >= 95 { + (20usize, 15usize, 8usize) + } else if inner_w >= 75 { + (16usize, 14usize, 8usize) + } else if inner_w >= 60 { + (14usize, 12usize, 7usize) + } else { + (11usize, 10usize, 6usize) + }; + + // Exactly 3 spaces between the 4 columns: [prefix:2][ID] [Prov] [Cost] [Ctx] + let non_id_w = prefix_w + 3 + prov_w + cost_w + ctx_w; + let id_w = inner_w.saturating_sub(non_id_w).max(10); + + let mut model_lines = Vec::new(); + + // Table Header + let table_header = format!( + " {:cost_w$} {:>ctx_w$}", + "Model ID", + "Provider", + "Cost", + "Context", + id_w = id_w, + prov_w = prov_w, + cost_w = cost_w, + ctx_w = ctx_w, + ); + model_lines.push(Line::from(Span::styled( + table_header, + Style::default().fg(Theme::text_muted()), + ))); + + // Model rows + let visible_model_rows = models_inner.height.saturating_sub(1) as usize; + let model_start = state.model_scroll_offset; + let model_end = (model_start + visible_model_rows).min(filtered_models.len()); + + for idx in model_start..model_end { + if let Some(m) = filtered_models.get(idx) { + let is_selected = idx == state.model_index; + let prefix = if is_selected { "> " } else { " " }; + + // Truncate Model ID if longer than column + let mut id_display = m.id.clone(); + if id_display.len() > id_w { + id_display = format!("{}...", &id_display[..id_w.saturating_sub(3)]); + } + + // Truncate Provider if longer than column + let mut prov_display = m.provider_id.clone(); + if prov_display.len() > prov_w { + prov_display = format!("{}...", &prov_display[..prov_w.saturating_sub(3)]); + } + + let cost_display = format_cost_table(m.cost.as_ref()); + let context_display = format_context(m.context_window); + + if is_selected { + let row_str = format!( + "{}{:cost_w$} {:>ctx_w$}", + prefix, + id_display, + prov_display, + cost_display, + context_display, + id_w = id_w, + prov_w = prov_w, + cost_w = cost_w, + ctx_w = ctx_w, + ); + model_lines.push(Line::from(Span::styled( + row_str, + Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD), + ))); + } else { + model_lines.push(Line::from(vec![ + Span::raw(prefix), + Span::styled(format!("{:cost_w$}", cost_display, cost_w = cost_w), Style::default().fg(Theme::text_muted())), + Span::raw(" "), + Span::styled(format!("{:>ctx_w$}", context_display, ctx_w = ctx_w), Style::default().fg(Theme::text_secondary())), + ])); + } + } + } + f.render_widget(Paragraph::new(model_lines), models_inner); + + // ---------------------------------------------------- + // Panel 3: Provider (Bottom-Left) + // ---------------------------------------------------- + let prov_bottom_block = Block::default() + .title(Span::styled(" Provider ", Style::default().fg(Theme::text_secondary()))) + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::border_box())); + f.render_widget(prov_bottom_block, bottom_left_area); + + let bottom_left_inner = Rect::new( + bottom_left_area.x + 1, + bottom_left_area.y + 1, + bottom_left_area.width.saturating_sub(2), + bottom_left_area.height.saturating_sub(2), + ); + + let prov_info = state.provider_for_details(); + let mut prov_detail_lines = Vec::new(); + let bottom_left_w = bottom_left_inner.width as usize; + let bottom_left_h = bottom_left_inner.height as usize; + + if let Some(p) = prov_info { + prov_detail_lines.push(Line::from(Span::styled( + &p.name, + Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD), + ))); + + if bottom_left_h >= 8 { + prov_detail_lines.push(Line::raw("")); + } + + let max_field_val_w = bottom_left_w.saturating_sub(7); + + let truncate_field = |val: &str| -> String { + if val.len() > max_field_val_w && max_field_val_w > 4 { + format!("{}...", &val[..max_field_val_w.saturating_sub(3)]) + } else { + val.to_string() + } + }; + + let docs = p.docs_url.as_deref().unwrap_or("-"); + prov_detail_lines.push(Line::from(vec![ + Span::styled("Docs: ", Style::default().fg(Theme::text_muted())), + Span::styled(truncate_field(docs), Style::default().fg(Theme::text_secondary())), + ])); + + let api = p.base_url.as_deref().unwrap_or("-"); + prov_detail_lines.push(Line::from(vec![ + Span::styled("API: ", Style::default().fg(Theme::text_muted())), + Span::styled(truncate_field(api), Style::default().fg(Theme::text_secondary())), + ])); + + let npm = p.npm.as_deref().unwrap_or("-"); + prov_detail_lines.push(Line::from(vec![ + Span::styled("NPM: ", Style::default().fg(Theme::text_muted())), + Span::styled(truncate_field(npm), Style::default().fg(Theme::text_secondary())), + ])); + + let env = if !p.env_keys.is_empty() { + p.env_keys.join(", ") + } else { + "-".to_string() + }; + prov_detail_lines.push(Line::from(vec![ + Span::styled("Env: ", Style::default().fg(Theme::text_muted())), + Span::styled(truncate_field(&env), Style::default().fg(Theme::text_secondary())), + ])); + + if bottom_left_h >= 8 { + prov_detail_lines.push(Line::raw("")); + } + + prov_detail_lines.push(Line::from(vec![ + Span::styled("o", yellow), + Span::styled(" open docs ", muted), + Span::styled("c", yellow), + Span::styled(" copy env", muted), + ])); + } + f.render_widget(Paragraph::new(prov_detail_lines), bottom_left_inner); + + // ---------------------------------------------------- + // Panel 4: Details (Bottom-Right) + // ---------------------------------------------------- + let details_block = Block::default() + .title(Span::styled(" Details ", Style::default().fg(Theme::text_secondary()))) + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::border_box())); + f.render_widget(details_block, bottom_right_area); + + let details_inner = Rect::new( + bottom_right_area.x + 1, + bottom_right_area.y + 1, + bottom_right_area.width.saturating_sub(2), + bottom_right_area.height.saturating_sub(2), + ); + + let details_inner_w = details_inner.width as usize; + let details_inner_h = details_inner.height as usize; + let col1_w = (details_inner_w / 2).clamp(24, 38); + + let mut details_lines = Vec::new(); + if let Some(m) = state.current_model() { + // Line 0: Name (ID) with safe truncation + let id_tag = format!("({})", m.id); + let id_avail = details_inner_w.saturating_sub(m.name.len() + 3); + let id_display = if id_tag.len() > id_avail && id_avail > 6 { + format!("({}...)", &m.id[..id_avail.saturating_sub(5)]) + } else { + id_tag + }; + details_lines.push(Line::from(vec![ + Span::styled(&m.name, Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::styled(id_display, Style::default().fg(Theme::text_muted())), + ])); + + // Line 1: Provider & Family aligned in grid + details_lines.push(Line::from(vec![ + Span::styled("Provider: ", Style::default().fg(Theme::text_muted())), + Span::styled(&m.provider_id, Style::default().fg(Theme::sky())), + Span::raw(" ".repeat(col1_w.saturating_sub(10 + m.provider_id.len()).max(2))), + Span::styled("Family: ", Style::default().fg(Theme::text_muted())), + Span::styled(&m.family, Style::default().fg(Theme::text_primary())), + ])); + + // Line 2: Status + details_lines.push(Line::from(vec![ + Span::styled("Status: ", Style::default().fg(Theme::text_muted())), + Span::styled("active", Style::default().fg(Theme::success())), + ])); + + if details_inner_h >= 11 { + details_lines.push(Line::raw("")); + } + + // Line 4: Context, Input Limit, Output Limit with clean column spacing + let ctx_str = format_context(m.context_window); + let out_limit_str = format_context(m.max_tokens); + let c1_context = format!("Context: {:<7} Input: -", ctx_str); + let c1_context_pad = col1_w.saturating_sub(c1_context.len()).max(2); + details_lines.push(Line::from(vec![ + Span::styled("Context: ", Style::default().fg(Theme::text_muted())), + Span::styled(format!("{:<7} ", ctx_str), Style::default().fg(Theme::text_primary())), + Span::styled("Input: ", Style::default().fg(Theme::text_muted())), + Span::styled("-", Style::default().fg(Theme::text_primary())), + Span::raw(" ".repeat(c1_context_pad)), + Span::styled("Output: ", Style::default().fg(Theme::text_muted())), + Span::styled(out_limit_str, Style::default().fg(Theme::text_primary())), + ])); + + // Line 5: Input & Output Pricing with clean column spacing + let in_rate = format_rate(m.cost.as_ref().and_then(|c| c.input)); + let out_rate = format_rate(m.cost.as_ref().and_then(|c| c.output)); + let in_rate_str = format!("Input: {}", in_rate); + let in_rate_pad = col1_w.saturating_sub(in_rate_str.len()).max(2); + details_lines.push(Line::from(vec![ + Span::styled("Input: ", Style::default().fg(Theme::text_muted())), + Span::styled(in_rate, Style::default().fg(Theme::text_primary())), + Span::raw(" ".repeat(in_rate_pad)), + Span::styled("Output: ", Style::default().fg(Theme::text_muted())), + Span::styled(out_rate, Style::default().fg(Theme::text_primary())), + ])); + + // Line 6: Cache Read & Write with clean column spacing (no more glue!) + let cache_r_rate = format_rate(m.cost.as_ref().and_then(|c| c.cache_read)); + let cache_w_rate = format_rate(m.cost.as_ref().and_then(|c| c.cache_write)); + let cr_str = format!("Cache Read: {}", cache_r_rate); + let cr_pad = col1_w.saturating_sub(cr_str.len()).max(2); + details_lines.push(Line::from(vec![ + Span::styled("Cache Read: ", Style::default().fg(Theme::text_muted())), + Span::styled(cache_r_rate, Style::default().fg(Theme::text_primary())), + Span::raw(" ".repeat(cr_pad)), + Span::styled("Cache Write: ", Style::default().fg(Theme::text_muted())), + Span::styled(cache_w_rate, Style::default().fg(Theme::text_primary())), + ])); + + if details_inner_h >= 11 { + details_lines.push(Line::raw("")); + } + + // Line 8: Capabilities + let caps = if m.reasoning { + "reasoning, tools, temperature" + } else { + "tools, temperature" + }; + details_lines.push(Line::from(vec![ + Span::styled("Capabilities: ", Style::default().fg(Theme::text_muted())), + Span::styled(caps, Style::default().fg(Theme::text_primary())), + ])); + + // Line 9: Modalities + let modalities_str = if m.input_modalities.len() > 1 { + format!("{} -> text", m.input_modalities.join(", ")) + } else { + "text -> text".to_string() + }; + details_lines.push(Line::from(vec![ + Span::styled("Modalities: ", Style::default().fg(Theme::text_muted())), + Span::styled(modalities_str, Style::default().fg(Theme::text_primary())), + ])); + + // Line 10: Released & Knowledge with clean spacing + let rel = m.released.as_deref().unwrap_or("-"); + let rel_str = format!("Released: {}", rel); + let rel_pad = col1_w.saturating_sub(rel_str.len()).max(2); + details_lines.push(Line::from(vec![ + Span::styled("Released: ", Style::default().fg(Theme::text_muted())), + Span::styled(rel, Style::default().fg(Theme::text_primary())), + Span::raw(" ".repeat(rel_pad)), + Span::styled("Knowledge: ", Style::default().fg(Theme::text_muted())), + Span::styled("-", Style::default().fg(Theme::text_primary())), + ])); + + // Line 11: Updated + let upd = m.updated.as_deref().unwrap_or("-"); + details_lines.push(Line::from(vec![ + Span::styled("Updated: ", Style::default().fg(Theme::text_muted())), + Span::styled(upd, Style::default().fg(Theme::text_primary())), + ])); + } + f.render_widget(Paragraph::new(details_lines), details_inner); + + // ---------------------------------------------------- + // Overlay: API Key Configuration Modal + // ---------------------------------------------------- + if state.api_key_modal_open { + let card_w = 66u16.min(area.width.saturating_sub(4)); + let card_h = 13u16.min(area.height.saturating_sub(2)); + let modal_x = (area.width.saturating_sub(card_w)) / 2; + let modal_y = (area.height.saturating_sub(card_h)) / 2; + let modal_area = Rect::new(modal_x, modal_y, card_w, card_h); + + f.render_widget(Clear, modal_area); + + let prov_name = state.pending_model.as_ref().map(|m| m.provider_name.as_str()).unwrap_or("Provider"); + let modal_block = Block::default() + .title(Span::styled( + format!(" Configure API Key: {} ", prov_name), + Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::border())); + f.render_widget(modal_block, modal_area); + + let modal_inner = Rect::new( + modal_area.x + 2, + modal_area.y + 1, + modal_area.width.saturating_sub(4), + modal_area.height.saturating_sub(2), + ); + + let mut lines = Vec::new(); + if let Some(m) = &state.pending_model { + lines.push(Line::from(vec![ + Span::styled("Model: ", Style::default().fg(Theme::text_muted())), + Span::styled(&m.name, Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD)), + Span::styled(format!(" ({})", m.id), Style::default().fg(Theme::text_muted())), + ])); + let env_desc = if !m.env_keys.is_empty() { + m.env_keys.join(" or ") + } else { + "API key".to_string() + }; + lines.push(Line::from(vec![ + Span::styled("Environment key: ", Style::default().fg(Theme::text_muted())), + Span::styled(env_desc, Style::default().fg(Theme::accent())), + ])); + lines.push(Line::raw("")); + } + + if state.validating { + lines.push(Line::from(vec![ + Span::styled(format!("{} ", spinner.current()), Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD)), + Span::styled("Validating API key with provider...", Style::default().fg(Theme::activity())), + ])); + } else { + lines.push(Line::from(Span::styled("Enter your API key below:", Style::default().fg(Theme::text_primary())))); + let masked = "•".repeat(state.api_key_input.len()); + lines.push(Line::from(vec![ + Span::styled("API Key: ", Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD)), + Span::styled(masked, Style::default().fg(Theme::text_primary())), + if cursor_visible { + Span::styled("█", Style::default().fg(Theme::accent())) + } else { + Span::raw(" ") + }, + ])); + + if let Some(err) = &state.validation_error { + lines.push(Line::from(vec![ + Span::styled("✗ ", Style::default().fg(Theme::error()).add_modifier(Modifier::BOLD)), + Span::styled(err, Style::default().fg(Theme::error())), + ])); + } else { + lines.push(Line::raw("")); + } + + lines.push(Line::from(Span::styled("Press Enter to validate & save, Esc to cancel", Style::default().fg(Theme::text_muted())))); + } + + f.render_widget(Paragraph::new(lines), modal_inner); + } + + // ---------------------------------------------------- + // Overlay: Help Modal + // ---------------------------------------------------- + if state.show_help { + let help_w = 64u16.min(area.width.saturating_sub(4)); + let help_h = 16u16.min(area.height.saturating_sub(2)); + let help_x = (area.width.saturating_sub(help_w)) / 2; + let help_y = (area.height.saturating_sub(help_h)) / 2; + let help_area = Rect::new(help_x, help_y, help_w, help_h); + + f.render_widget(Clear, help_area); + + let help_block = Block::default() + .title(Span::styled(" AI Model Browser - Help ", Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD))) + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::border())); + f.render_widget(help_block, help_area); + + let help_inner = Rect::new( + help_area.x + 2, + help_area.y + 1, + help_area.width.saturating_sub(4), + help_area.height.saturating_sub(2), + ); + + let shortcuts = [ + ("Tab / Shift+Tab", "Switch focus between Providers and Models"), + ("↑ / ↓ (or k / j)", "Navigate items in the focused list"), + ("PgUp / PgDn", "Scroll list by page"), + ("/", "Filter providers or models depending on focus"), + ("s", "Cycle sort mode: ID, Provider, Context, Cost"), + ("c", "Copy selected model ID or provider env key to clipboard"), + ("o", "Open active provider documentation in browser"), + ("Enter", "Select model (validates credentials & updates active model)"), + ("q / Esc", "Exit browser / cancel search"), + ]; + + let mut lines = Vec::new(); + for (key_str, desc) in shortcuts { + lines.push(Line::from(vec![ + Span::styled(format!("{:<18}", key_str), Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD)), + Span::styled(desc, Style::default().fg(Theme::text_primary())), + ])); + } + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled("Press any key or Esc to close help", Style::default().fg(Theme::text_muted())))); + + f.render_widget(Paragraph::new(lines), help_inner); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + #[test] + fn test_model_browser_filtering_and_sorting() { + let mut state = ModelBrowserState::new(); + let initial_models = state.filtered_models(); + assert_eq!(initial_models.len(), 6001, "Expected all 6001 models by default"); + + // Filter by search query + state.search_query = "claude-3-7-sonnet".to_string(); + let claude_models = state.filtered_models(); + assert!(!claude_models.is_empty(), "Expected claude models to match"); + for m in &claude_models { + assert!( + m.id.to_lowercase().contains("claude-3-7-sonnet") + || m.name.to_lowercase().contains("claude-3-7-sonnet") + ); + } + + // Test sorting by context + state.search_query.clear(); + state.sort_mode = ModelSortMode::Context; + let sorted_by_ctx = state.filtered_models(); + let first_ctx = sorted_by_ctx.first().unwrap().context_window.unwrap_or(0); + let last_ctx = sorted_by_ctx.last().unwrap().context_window.unwrap_or(0); + assert!(first_ctx >= last_ctx, "Expected descending context window"); + } + + #[test] + fn test_model_browser_provider_details() { + let mut state = ModelBrowserState::new(); + // Index 0 is "All", provider_for_details should match first model's provider + let prov = state.provider_for_details(); + assert!(prov.is_some(), "Expected provider details to be present"); + + // Set provider_index to 1 (first alphabetically sorted provider) + state.provider_index = 1; + let prov1 = state.provider_for_details().unwrap(); + let all_provs = get_all_providers(); + assert_eq!(prov1.id, all_provs[0].id); + } + + #[test] + fn test_render_model_browser_no_panic() { + let resolutions = [(60, 18), (80, 24), (100, 30), (120, 35), (140, 40), (180, 50)]; + let state = ModelBrowserState::new(); + let spinner = Spinner::new(); + + for (w, h) in resolutions { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + render_model_browser(f, f.area(), &state, &spinner); + }) + .unwrap(); + } + } + + #[test] + fn test_provider_search_filtering() { + let mut state = ModelBrowserState::new(); + assert_eq!(state.filtered_providers().len(), 206); + + // Search for anthropic + state.provider_search = "anthropic".to_string(); + let filtered = state.filtered_providers(); + assert!(!filtered.is_empty()); + assert!(filtered.iter().any(|p| p.id == "anthropic")); + for p in &filtered { + assert!( + p.id.to_lowercase().contains("anthropic") + || p.name.to_lowercase().contains("anthropic") + ); + } + + // When provider_index is set to the index of anthropic + let anthropic_idx = filtered.iter().position(|p| p.id == "anthropic").unwrap() + 1; + state.provider_index = anthropic_idx; + assert_eq!(state.selected_provider_id(), Some("anthropic")); + + // Models should only contain anthropic models + let models = state.filtered_models(); + assert!(!models.is_empty()); + for m in &models { + assert_eq!(m.provider_id, "anthropic"); + } + + // Non-existent search + state.provider_search = "nonexistent_provider_xyz".to_string(); + assert!(state.filtered_providers().is_empty()); + assert_eq!(state.selected_provider_id(), None); + } +} + diff --git a/packages/tui/src/ui/session.rs b/packages/tui/src/ui/session.rs new file mode 100644 index 00000000..babf37e7 --- /dev/null +++ b/packages/tui/src/ui/session.rs @@ -0,0 +1,764 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; +use unicode_width::UnicodeWidthStr; + +use crate::markdown::{render_markdown, wrap_text}; +use crate::tool; +use crate::types::{ConversationTurn, ModelConfig, SessionInfo, SessionStats, ToolResult}; +use crate::ui::logo::{render_animated_logo_lines, render_header_logo}; +use crate::ui::spinner::Spinner; +use crate::ui::text::elide; +use crate::ui::theme::Theme; +use crate::ui::welcome::{commands, CommandItem}; + +pub fn render_session( + f: &mut Frame, + area: Rect, + session_info: Option<&SessionInfo>, + turns: &[ConversationTurn], + stats: &SessionStats, + config: &ModelConfig, + input_buffer: &str, + cursor_pos: usize, + scroll_offset: usize, + dropdown_index: usize, + spinner: &Spinner, + is_streaming: bool, + reconnecting: bool, +) { + let pad_top = if area.height >= 12 { 1u16 } else { 0u16 }; + let cursor_visible = spinner.cursor_visible(); + let content_area = Rect::new( + area.x, + area.y + pad_top, + area.width, + area.height.saturating_sub(pad_top), + ); + + let sidebar_width = (content_area.width / 4).clamp(24, 32); + let main_width = content_area.width.saturating_sub(sidebar_width + 1); + + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(main_width), + Constraint::Length(1), + Constraint::Length(sidebar_width), + ]) + .split(content_area); + + let max_input_text_width = (main_width as usize).saturating_sub(6).max(10); + let wrapped_input = crate::ui::input::wrap_input_with_cursor(input_buffer, cursor_pos, max_input_text_width); + let max_visible_lines = 8usize; + let num_input_lines = if is_streaming { + 1 + } else { + wrapped_input.lines.len().clamp(1, max_visible_lines) + }; + let input_area_height = (num_input_lines + 2) as u16; + + let main_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Top header (title, divider, padding) + Constraint::Min(5), // Conversation turns + Constraint::Length(input_area_height), // Input area (top line, text lines, bottom line) + ]) + .split(chunks[0]); + + let border_style = Style::default().fg(Theme::border()); + + // 1. Main Header with 2-space padding + let sess_title = session_info + .map(|s| s.title.as_str()) + .unwrap_or("CodeWork Session"); + let sess_dir = session_info + .map(|s| s.directory.as_str()) + .unwrap_or("active session"); + + let max_dir_len = (main_width as usize).saturating_sub(32).clamp(12, 40); + let formatted_dir = crate::git::truncate_path(std::path::Path::new(sess_dir), max_dir_len); + let mut title_spans = vec![Span::raw(" ")]; + title_spans.extend(render_header_logo(spinner.tick_count(), is_streaming)); + title_spans.push(Span::styled(format!("· {} ", sess_title), Style::default().fg(Theme::text_primary()))); + title_spans.push(Span::styled(format!("({})", formatted_dir), Style::default().fg(Theme::text_muted()))); + + let header_lines = vec![ + Line::from(title_spans), + Line::from(vec![ + Span::raw(" "), + Span::styled("─".repeat((main_width as usize).saturating_sub(3)), border_style), + ]), + Line::raw(""), + ]; + f.render_widget(Paragraph::new(header_lines), main_chunks[0]); + + // 2. Conversation Render Lines (inside conv_area with 2-column padding) + let conv_area = Rect::new( + main_chunks[1].x + 2, + main_chunks[1].y, + main_chunks[1].width.saturating_sub(4), + main_chunks[1].height, + ); + let max_text_width = conv_area.width as usize; + let mut conv_lines: Vec> = Vec::new(); + + for turn in turns { + // Prompt Box with border and background color + conv_lines.extend(render_user_prompt_box(&turn.prompt, max_text_width)); + conv_lines.push(Line::raw("")); + + // Thinking indicator + if !turn.thinking.is_empty() { + let spin_char = if turn.streaming { spinner.current() } else { "●" }; + conv_lines.push(Line::from(vec![ + Span::styled(format!("{} ", spin_char), Style::default().fg(Theme::activity())), + Span::styled("Thinking", Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD)), + ])); + + let wrap_think_w = max_text_width.saturating_sub(4).max(10); + for t_line in turn.thinking.lines().take(8) { + let wrapped = wrap_text(t_line, wrap_think_w); + for w_line in wrapped { + conv_lines.push(Line::from(vec![ + Span::styled(" │ ", Style::default().fg(Theme::text_muted())), + Span::styled(w_line, Style::default().fg(Theme::text_muted()).add_modifier(Modifier::ITALIC)), + ])); + } + } + conv_lines.push(Line::raw("")); + } + + // Active Tool, titled from the arguments it was called with + if let Some(running) = &turn.active_tool { + conv_lines.push(Line::from(vec![ + Span::styled(format!("{} ", spinner.current()), Style::default().fg(Theme::activity())), + Span::styled(running.heading(), Style::default().fg(Theme::text_primary())), + ])); + } + + // Completed Tools: titled from their arguments, runs collapsed, and an + // inline diff whenever the tool produced a patch + conv_lines.extend(render_tool_rows(&turn.completed_tools, max_text_width)); + + if turn.active_tool.is_some() || !turn.completed_tools.is_empty() { + conv_lines.push(Line::raw("")); + } + + // Response markdown (word-wrapped and formatted) + if !turn.response.is_empty() { + let rendered = render_markdown(&turn.response, max_text_width); + conv_lines.extend(rendered); + conv_lines.push(Line::raw("")); + } + + // Error line + if let Some(err) = &turn.error { + conv_lines.push(Line::from(vec![ + Span::styled("✗ ", Style::default().fg(Theme::error()).add_modifier(Modifier::BOLD)), + Span::styled(err.clone(), Style::default().fg(Theme::error())), + ])); + conv_lines.push(Line::raw("")); + } + + // Turn Footer + let elapsed_sec = if let Some(end) = turn.end_time { + (end - turn.start_time) as f64 / 1000.0 + } else { + (chrono::Utc::now().timestamp_millis() - turn.start_time) as f64 / 1000.0 + }; + + let footer_spans = vec![ + Span::styled("● ", Style::default().fg(Theme::accent())), + Span::styled("Build ", Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD)), + Span::styled(format!("{} ", turn.model_name), Style::default().fg(Theme::model())), + Span::styled(format!("{:.1}s ", elapsed_sec), Style::default().fg(Theme::text_muted())), + Span::styled(format!("{:.1} tok/s", turn.tokens_per_sec), Style::default().fg(Theme::text_muted())), + ]; + conv_lines.push(Line::from(footer_spans)); + conv_lines.push(Line::raw("")); + } + + let total_lines = conv_lines.len(); + let viewport_height = conv_area.height as usize; + + let max_scroll = total_lines.saturating_sub(viewport_height); + let clamped_offset = scroll_offset.min(max_scroll); + let effective_scroll = max_scroll.saturating_sub(clamped_offset); + + let chat_paragraph = Paragraph::new(conv_lines).scroll((effective_scroll as u16, 0)); + f.render_widget(chat_paragraph, conv_area); + + // Bottom Input Section: line on top, input text line, line on bottom + let top_divider_line = if clamped_offset > 0 { + let tag = format!(" [↑ History +{} lines (End to bottom)] ", clamped_offset); + let rem = (main_width as usize).saturating_sub(tag.len() + 3); + let left_dashes = rem / 2; + let right_dashes = rem.saturating_sub(left_dashes); + Line::from(vec![ + Span::raw(" "), + Span::styled("─".repeat(left_dashes), border_style), + Span::styled(tag, Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD)), + Span::styled("─".repeat(right_dashes), border_style), + ]) + } else { + Line::from(vec![ + Span::raw(" "), + Span::styled("─".repeat((main_width as usize).saturating_sub(3)), border_style), + ]) + }; + + let mut input_widget_lines = Vec::new(); + input_widget_lines.push(top_divider_line); + + if reconnecting { + // The turn is still running server-side; this says the socket is being + // re-established, not that the work failed. + input_widget_lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("{} ", spinner.current()), Style::default().fg(Theme::activity())), + Span::styled("Reconnecting to server...", Style::default().fg(Theme::activity()).add_modifier(Modifier::BOLD)), + ])); + } else if is_streaming { + input_widget_lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("{} ", spinner.current()), Style::default().fg(Theme::activity())), + Span::styled("Generating response... (Ctrl+C to interrupt)", Style::default().fg(Theme::text_muted())), + ])); + } else { + let total_input_lines = wrapped_input.lines.len(); + let scroll_top = if wrapped_input.cursor_line >= max_visible_lines { + wrapped_input.cursor_line + 1 - max_visible_lines + } else { + 0 + }; + let visible_slice = &wrapped_input.lines[scroll_top..(scroll_top + max_visible_lines).min(total_input_lines)]; + + for (idx, line) in visible_slice.iter().enumerate() { + let line_idx = scroll_top + idx; + let mut spans = if line_idx == 0 { + vec![ + Span::raw(" "), + Span::styled("❯ ", Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD)), + ] + } else { + vec![ + Span::raw(" "), + ] + }; + spans.extend(crate::ui::input::render_line_spans(line, Theme::accent(), cursor_visible)); + input_widget_lines.push(Line::from(spans)); + } + } + + let bottom_divider_line = Line::from(vec![ + Span::raw(" "), + Span::styled("─".repeat((main_width as usize).saturating_sub(3)), border_style), + ]); + input_widget_lines.push(bottom_divider_line); + + f.render_widget(Paragraph::new(input_widget_lines), main_chunks[2]); + + // Command Dropdown Popup in Session + let is_dropdown_open = !is_streaming && input_buffer.starts_with('/') && !input_buffer.contains(' '); + if is_dropdown_open { + let commands = commands(); + let matching_cmds: Vec<&CommandItem> = commands + .iter() + .filter(|cmd| cmd.name.starts_with(input_buffer)) + .collect(); + + let num_rows = matching_cmds.len().max(1) as u16; + let popup_h = (num_rows + 2).min(main_chunks[1].height); + let popup_w = (main_width as u16).saturating_sub(4).max(20); + let popup_x = main_chunks[2].x + 2; + let popup_y = main_chunks[2].y.saturating_sub(popup_h); + + let popup_rect = Rect::new(popup_x, popup_y, popup_w, popup_h); + + f.render_widget(Clear, popup_rect); + + let popup_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::sky())) + .title(Span::styled(" Commands ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD))) + .title_bottom(Span::styled( + " ↑/↓ navigate • Tab complete • Enter select • Esc dismiss ", + Style::default().fg(Theme::text_muted()), + )) + .style(Style::default().bg(Theme::bg_surface())); + + f.render_widget(popup_block, popup_rect); + + let inner_rect = Rect::new( + popup_rect.x + 1, + popup_rect.y + 1, + popup_rect.width.saturating_sub(2), + popup_rect.height.saturating_sub(2), + ); + + let mut cmd_lines = Vec::new(); + if matching_cmds.is_empty() { + cmd_lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("No matching commands", Style::default().fg(Theme::text_muted())), + ])); + } else { + for (idx, cmd) in matching_cmds.iter().enumerate() { + let is_selected = idx == dropdown_index; + if is_selected { + cmd_lines.push(Line::from(vec![ + Span::styled("▶ ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)), + Span::styled(format!("{} ", cmd.icon), Style::default().fg(cmd.icon_color).add_modifier(Modifier::BOLD)), + Span::styled( + format!("{:<10}", cmd.name), + Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD), + ), + Span::styled(cmd.description, Style::default().fg(Theme::text_primary())), + ])); + } else { + cmd_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("{} ", cmd.icon), Style::default().fg(cmd.icon_color)), + Span::styled( + format!("{:<10}", cmd.name), + Style::default().fg(Theme::text_secondary()), + ), + Span::styled(cmd.description, Style::default().fg(Theme::text_muted())), + ])); + } + } + } + + f.render_widget(Paragraph::new(cmd_lines), inner_rect); + } + + // Vertical Divider + let vert_div = (0..chunks[1].height) + .map(|_| Line::from(Span::styled("│", border_style))) + .collect::>(); + f.render_widget(Paragraph::new(vert_div), chunks[1]); + + // 3. Right Sidebar with padding + let sidebar_area = Rect::new( + chunks[2].x + 2, + chunks[2].y, + chunks[2].width.saturating_sub(3), + chunks[2].height, + ); + + let sidebar_block = Block::default() + .borders(Borders::NONE); + + let mut sidebar_lines = Vec::new(); + + // Codework logo at the top of the right sidebar + if sidebar_area.height >= 18 { + let logo_pad = (sidebar_area.width as usize).saturating_sub(20) / 2; + sidebar_lines.extend(render_animated_logo_lines(logo_pad, spinner.tick_count())); + sidebar_lines.push(Line::raw("")); + } + + sidebar_lines.push(Line::from(Span::styled("MODEL", Style::default().fg(Theme::text_muted()).add_modifier(Modifier::BOLD)))); + sidebar_lines.push(Line::from(vec![ + Span::styled(format!("{} ", config.provider), Style::default().fg(Theme::text_primary())), + Span::styled(&config.model, Style::default().fg(Theme::model()).add_modifier(Modifier::BOLD)), + ])); + sidebar_lines.push(Line::raw("")); + + sidebar_lines.push(Line::from(Span::styled("CONTEXT", Style::default().fg(Theme::text_muted()).add_modifier(Modifier::BOLD)))); + let pct = (stats.total_tokens as f64 / 1_000_000.0 * 100.0).clamp(0.0, 100.0); + let filled = ((pct / 10.0).round() as usize).min(10); + let empty = 10 - filled; + let bar = format!("[{}{}] {:.1}%", "█".repeat(filled), "░".repeat(empty), pct); + sidebar_lines.push(Line::from(Span::styled(bar, Style::default().fg(Theme::accent())))); + sidebar_lines.push(Line::from(Span::styled( + format!("{} / 1.0M tokens", stats.total_tokens), + Style::default().fg(Theme::text_muted()), + ))); + sidebar_lines.push(Line::raw("")); + + sidebar_lines.push(Line::from(Span::styled("METRICS", Style::default().fg(Theme::text_muted()).add_modifier(Modifier::BOLD)))); + sidebar_lines.push(Line::from(vec![ + Span::styled("Turns: ", Style::default().fg(Theme::text_muted())), + Span::styled(format!("{}", stats.turns_count), Style::default().fg(Theme::text_primary())), + ])); + sidebar_lines.push(Line::from(vec![ + Span::styled("Cost: ", Style::default().fg(Theme::text_muted())), + Span::styled(format!("${:.4}", stats.cost), Style::default().fg(Theme::success())), + ])); + sidebar_lines.push(Line::raw("")); + + sidebar_lines.push(Line::from(Span::styled("CONTROLS", Style::default().fg(Theme::text_muted()).add_modifier(Modifier::BOLD)))); + sidebar_lines.push(Line::from(Span::styled("Ctrl+C Interrupt / Exit", Style::default().fg(Theme::text_secondary())))); + sidebar_lines.push(Line::from(Span::styled("↑ / ↓ Scroll chat", Style::default().fg(Theme::text_secondary())))); + sidebar_lines.push(Line::from(Span::styled("Home/End Top / Bottom", Style::default().fg(Theme::text_secondary())))); + sidebar_lines.push(Line::from(Span::styled("Esc Return to home", Style::default().fg(Theme::text_secondary())))); + + f.render_widget(Paragraph::new(sidebar_lines).block(sidebar_block), sidebar_area); +} + +/// Renders a turn's settled calls, collapsing runs of consecutive calls to the +/// same tool into one row so a long stretch of `read` costs a line, not twenty. +pub fn render_tool_rows(tools: &[ToolResult], max_text_width: usize) -> Vec> { + let mut lines = Vec::new(); + for row in tool::group(tools) { + match row { + tool::ToolRow::Single(result) => lines.extend(render_tool_result(result, max_text_width)), + tool::ToolRow::Run { name, members, failed } => { + lines.push(render_tool_run(&name, &members, failed, max_text_width)); + } + } + } + lines +} + +/// One collapsed run: `✓ read ×7 a.rs, b.rs, src/c.rs, …`. +fn render_tool_run( + name: &str, + members: &[&ToolResult], + failed: usize, + max_text_width: usize, +) -> Line<'static> { + // A run with any failure takes the error colour, so a wall of green never + // hides the one call that did not work. + let (icon, color) = if failed > 0 { + ("✗ ", Theme::error()) + } else { + ("✓ ", Theme::success()) + }; + let label = format!("{name} ×{}", members.len()); + + let mut spans = vec![ + Span::styled(icon, Style::default().fg(color).add_modifier(Modifier::BOLD)), + Span::styled(label.clone(), Style::default().fg(color)), + ]; + + let mut meta = tool::run_targets(members); + if failed > 0 { + if !meta.is_empty() { + meta.push_str(" · "); + } + meta.push_str(&format!("{failed} failed")); + } + if !meta.is_empty() { + let used = 2 + UnicodeWidthStr::width(label.as_str()) + 2; + spans.push(Span::raw(" ")); + spans.push(Span::styled( + elide(&meta, max_text_width.saturating_sub(used)), + Style::default().fg(Theme::text_muted()), + )); + } + + Line::from(spans) +} + +/// Renders one settled tool call: a status line headed by the tool and what it +/// touched, plus a truncated inline unified diff when it returned a `patch`. +pub fn render_tool_result(tool: &ToolResult, max_text_width: usize) -> Vec> { + let (icon, color) = if tool.is_error { + ("✗ ", Theme::error()) + } else { + ("✓ ", Theme::success()) + }; + + // The summary is the outcome and is never dropped; the heading gives way to + // it when the pane is too narrow for both. + let summary_width = tool + .summary + .as_ref() + .map(|summary| UnicodeWidthStr::width(summary.as_str()) + 2) + .unwrap_or(0); + + let mut header = vec![ + Span::styled(icon, Style::default().fg(color).add_modifier(Modifier::BOLD)), + Span::styled( + elide(&tool.heading(), max_text_width.saturating_sub(2 + summary_width)), + Style::default().fg(color), + ), + ]; + if let Some(summary) = &tool.summary { + header.push(Span::raw(" ")); + header.push(Span::styled(summary.clone(), Style::default().fg(Theme::text_muted()))); + } + + let Some(patch) = &tool.patch else { + return vec![Line::from(header)]; + }; + + let (diff_lines, _) = crate::diff::render_diff( + patch, + max_text_width.saturating_sub(2), + crate::diff::DEFAULT_MAX_DIFF_LINES, + ); + + let mut lines = vec![Line::from(header)]; + for mut diff_line in diff_lines { + diff_line.spans.insert(0, Span::raw(" ")); + lines.push(diff_line); + } + lines +} + +#[allow(dead_code)] +pub fn render_input_with_cursor(input: &str, cursor: usize) -> Vec> { + crate::ui::input::render_input_with_cursor(input, cursor) +} + +pub fn render_user_prompt_box(prompt: &str, max_text_width: usize) -> Vec> { + let box_bg = Theme::bg_user_msg(); + let border_color = Theme::border_user_msg(); + let wrap_w = max_text_width.saturating_sub(6).max(10); + + let mut prompt_lines = Vec::new(); + for raw_line in prompt.lines() { + let wrapped = wrap_text(raw_line, wrap_w); + if wrapped.is_empty() { + prompt_lines.push(String::new()); + } else { + prompt_lines.extend(wrapped); + } + } + if prompt_lines.is_empty() { + prompt_lines.push(prompt.to_string()); + } + + // Full width user message box across the entire conversation area + let box_w = max_text_width; + let avail_w = box_w.saturating_sub(4); + + let mut lines = Vec::new(); + + // 1. Top border: ╭── ❯ ──────────────────────╮ + let top_prefix = "╭── "; + let top_icon = "❯ "; + let top_right = "╮"; + let bar_len = box_w.saturating_sub(7); + + lines.push(Line::from(vec![ + Span::styled(top_prefix, Style::default().fg(border_color).bg(box_bg)), + Span::styled( + top_icon, + Style::default().fg(Theme::primary()).bg(box_bg).add_modifier(Modifier::BOLD), + ), + Span::styled("─".repeat(bar_len), Style::default().fg(border_color).bg(box_bg)), + Span::styled(top_right, Style::default().fg(border_color).bg(box_bg)), + ])); + + // 2. Middle lines: │ │ + for p_line in prompt_lines { + let line_w = UnicodeWidthStr::width(p_line.as_str()); + let (display_text, actual_w) = if line_w > avail_w { + let mut truncated = String::new(); + let mut cur_w = 0; + for ch in p_line.chars() { + let ch_w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if cur_w + ch_w + 1 <= avail_w { + truncated.push(ch); + cur_w += ch_w; + } else { + break; + } + } + truncated.push('…'); + let tw = UnicodeWidthStr::width(truncated.as_str()); + (truncated, tw) + } else { + (p_line, line_w) + }; + let pad = avail_w.saturating_sub(actual_w); + lines.push(Line::from(vec![ + Span::styled("│ ", Style::default().fg(border_color).bg(box_bg)), + Span::styled( + display_text, + Style::default().fg(Theme::text_primary()).bg(box_bg).add_modifier(Modifier::BOLD), + ), + Span::styled(" ".repeat(pad), Style::default().bg(box_bg)), + Span::styled(" │", Style::default().fg(border_color).bg(box_bg)), + ])); + } + + // 3. Bottom border: ╰────────────────────────╯ + let bot_bar_len = box_w.saturating_sub(2); + lines.push(Line::from(vec![ + Span::styled("╰", Style::default().fg(border_color).bg(box_bg)), + Span::styled("─".repeat(bot_bar_len), Style::default().fg(border_color).bg(box_bg)), + Span::styled("╯", Style::default().fg(border_color).bg(box_bg)), + ])); + + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line_visual_width(line: &Line) -> usize { + line.spans.iter().map(|s| UnicodeWidthStr::width(s.content.as_ref())).sum() + } + + #[test] + fn test_render_user_prompt_box_single_line() { + let lines = render_user_prompt_box("Hello CodeWork", 60); + assert_eq!(lines.len(), 3); + let expected_w = line_visual_width(&lines[0]); + assert_eq!(expected_w, 60, "Box width must equal full max_text_width"); + for line in &lines { + assert_eq!(line_visual_width(line), expected_w, "All lines in user prompt box must have equal visual width"); + } + } + + #[test] + fn test_render_user_prompt_box_multiline() { + let lines = render_user_prompt_box("Line 1\nLine 2 is longer\nLine 3", 60); + assert_eq!(lines.len(), 5); + let expected_w = line_visual_width(&lines[0]); + assert_eq!(expected_w, 60, "Box width must equal full max_text_width"); + for line in &lines { + assert_eq!(line_visual_width(line), expected_w, "All lines in multiline box must have equal visual width"); + } + } + + #[test] + fn test_render_user_prompt_box_has_background() { + let lines = render_user_prompt_box("check background color", 50); + for line in &lines { + for span in &line.spans { + assert_eq!(span.style.bg, Some(Theme::bg_user_msg())); + } + } + } + + fn tool_text(lines: &[Line]) -> String { + lines + .iter() + .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::()) + .collect::>() + .join("\n") + } + + fn edit_result(patch: &str) -> ToolResult { + crate::tool::derive( + "edit", + None, + &serde_json::json!({ "path": "src/a.ts" }), + Some(&serde_json::json!({ "patch": patch, "path": "src/a.ts", "firstChangedLine": 2 })), + false, + ) + } + + fn read_call(target: &str) -> ToolResult { + crate::tool::derive("read", None, &serde_json::json!({ "path": target }), None, false) + } + + fn headings(lines: &[Line]) -> usize { + lines + .iter() + .filter(|line| line.spans.first().map(|span| span.content.as_ref()) == Some("✓ ")) + .count() + } + + #[test] + fn test_render_tool_result_with_patch() { + let patch = "--- src/a.ts\n+++ src/a.ts\n@@ -1,3 +1,3 @@\n let a = 1;\n-let b = 2;\n+let b = 3;\n"; + let lines = render_tool_result(&edit_result(patch), 60); + let text = tool_text(&lines); + assert!(text.contains("edit"), "{text}"); + assert!(text.contains("+1 −1"), "{text}"); + assert!(text.contains("src/a.ts"), "{text}"); + assert!(text.contains("line 2"), "{text}"); + assert!(text.contains("- let b = 2;"), "{text}"); + assert!(text.contains("+ let b = 3;"), "{text}"); + } + + #[test] + fn test_render_tool_result_tints_added_and_removed() { + let patch = "--- a\n+++ a\n@@ -1 +1 @@\n-old\n+new\n"; + let lines = render_tool_result(&edit_result(patch), 60); + let spans = || lines.iter().flat_map(|l| l.spans.iter()); + let added = spans() + .find(|s| s.content.as_ref() == "new") + .expect("added content span"); + assert_eq!(added.style.bg, Some(Theme::diff_add_bg())); + let removed = spans() + .find(|s| s.content.as_ref() == "old") + .expect("removed content span"); + assert_eq!(removed.style.bg, Some(Theme::diff_del_bg())); + } + + #[test] + fn test_render_tool_result_without_patch_is_single_line() { + let lines = render_tool_result(&read_call("src/a.ts"), 60); + assert_eq!(lines.len(), 1); + assert!(tool_text(&lines).contains("read src/a.ts")); + } + + #[test] + fn test_render_tool_result_keeps_the_summary_when_narrow() { + let long = "packages/harness/src/plugin/builtin/tool/something-very-long/edit.ts"; + let tool = crate::tool::derive( + "read", + None, + &serde_json::json!({ "path": long }), + Some(&serde_json::json!({ + "startLine": 1, + "endLine": 10, + "totalLines": 99, + "truncated": false, + })), + false, + ); + let lines = render_tool_result(&tool, 30); + let text = tool_text(&lines); + assert!(text.contains("1-10 of 99"), "the outcome must survive elision: {text}"); + assert!(UnicodeWidthStr::width(text.as_str()) <= 30, "{text}"); + } + + #[test] + fn test_render_tool_rows_collapses_a_run() { + let tools = vec![read_call("a.rs"), read_call("b.rs"), read_call("c.rs")]; + let lines = render_tool_rows(&tools, 60); + assert_eq!(lines.len(), 1, "three reads collapse into one row"); + let text = tool_text(&lines); + assert!(text.contains("read ×3"), "{text}"); + assert!(text.contains("a.rs, b.rs, c.rs"), "{text}"); + } + + #[test] + fn test_render_tool_rows_keeps_a_short_run_separate() { + let tools = vec![read_call("a.rs"), read_call("b.rs")]; + let lines = render_tool_rows(&tools, 60); + assert_eq!(lines.len(), 2); + assert_eq!(headings(&lines), 2); + } + + #[test] + fn test_render_tool_rows_marks_failures_in_a_run() { + let mut failed = read_call("b.rs"); + failed.is_error = true; + let tools = vec![read_call("a.rs"), failed, read_call("c.rs")]; + let lines = render_tool_rows(&tools, 60); + assert_eq!(lines.len(), 1); + let text = tool_text(&lines); + assert!(text.contains("read ×3"), "{text}"); + assert!(text.contains("1 failed"), "{text}"); + let label = lines[0] + .spans + .iter() + .find(|span| span.content.contains("read ×3")) + .expect("the run label"); + assert_eq!(label.style.fg, Some(Theme::error())); + } + + #[test] + fn test_render_tool_rows_never_collapses_a_run_of_edits() { + let patch = "--- src/a.ts\n+++ src/a.ts\n@@ -1 +1 @@\n-a\n+b\n"; + let tools = vec![edit_result(patch), edit_result(patch), edit_result(patch)]; + let lines = render_tool_rows(&tools, 60); + assert_eq!(headings(&lines), 3, "each edit keeps its own heading and diff"); + assert!(!tool_text(&lines).contains('×')); + } +} diff --git a/packages/tui/src/ui/spinner.rs b/packages/tui/src/ui/spinner.rs new file mode 100644 index 00000000..f38dc90b --- /dev/null +++ b/packages/tui/src/ui/spinner.rs @@ -0,0 +1,49 @@ +pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// Number of 80ms ticks per caret blink phase (~480ms on / ~480ms off). +const BLINK_TICKS: usize = 6; + +#[derive(Debug, Clone, Default)] +pub struct Spinner { + frame: usize, + tick_count: usize, + blink_tick: usize, +} + +impl Spinner { + pub fn new() -> Self { + Self { + frame: 0, + tick_count: 0, + blink_tick: 0, + } + } + + pub fn tick(&mut self) { + self.frame = (self.frame + 1) % SPINNER_FRAMES.len(); + self.tick_count = self.tick_count.wrapping_add(1); + self.blink_tick = self.blink_tick.wrapping_add(1); + } + + pub fn current(&self) -> &'static str { + SPINNER_FRAMES[self.frame] + } + + + pub fn tick_count(&self) -> usize { + self.tick_count + } + + /// Whether the text caret should currently be drawn. + /// Toggles on a fixed cadence driven by the render tick. + pub fn cursor_visible(&self) -> bool { + (self.blink_tick / BLINK_TICKS) % 2 == 0 + } + + /// Restart the caret blink cycle. Call on user input so the caret stays + /// solid while typing instead of vanishing mid-keystroke. + pub fn reset_blink(&mut self) { + self.blink_tick = 0; + } +} + diff --git a/packages/tui/src/ui/text.rs b/packages/tui/src/ui/text.rs new file mode 100644 index 00000000..b5cdce5f --- /dev/null +++ b/packages/tui/src/ui/text.rs @@ -0,0 +1,60 @@ +//! Width-aware text helpers shared by the transcript renderers. + +use unicode_width::UnicodeWidthStr; + +/// Truncates `text` to at most `max_width` display columns, appending `…` when +/// it does not fit. +/// +/// Width is measured in Unicode columns rather than bytes or characters, so a +/// line of CJK text or emoji stays inside the pane instead of overflowing it. +pub fn elide(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(text) <= max_width { + return text.to_string(); + } + let mut out = String::new(); + let mut width = 0usize; + for ch in text.chars() { + let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + // +1 reserves the column the ellipsis itself needs. + if width + ch_width + 1 > max_width { + break; + } + out.push(ch); + width += ch_width; + } + out.push('…'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_text_that_fits() { + assert_eq!(elide("src/app.ts", 40), "src/app.ts"); + assert_eq!(elide("src/app.ts", 10), "src/app.ts"); + } + + #[test] + fn adds_an_ellipsis_when_it_cannot_fit() { + assert_eq!(elide("abcdefghij", 5), "abcd…"); + } + + #[test] + fn zero_width_yields_nothing() { + assert_eq!(elide("anything", 0), ""); + } + + #[test] + fn measures_display_columns_not_characters() { + // Each glyph is two columns, so five of them plus the ellipsis fill exactly + // eleven — a byte- or char-based cut would have overflowed the pane. + let elided = elide("你好你好你好", 11); + assert_eq!(elided, "你好你好你…"); + assert_eq!(UnicodeWidthStr::width(elided.as_str()), 11); + } +} diff --git a/packages/tui/src/ui/theme.rs b/packages/tui/src/ui/theme.rs new file mode 100644 index 00000000..5affeee1 --- /dev/null +++ b/packages/tui/src/ui/theme.rs @@ -0,0 +1,416 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +use ratatui::style::Color; + +/// One full palette. Every palette fills the same role set, so any palette can +/// be swapped in at runtime without touching call sites. Add a palette to +/// [`PALETTES`] and it shows up in the `/theme` picker automatically. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Palette { + pub id: &'static str, + pub label: &'static str, + pub dark: bool, + pub bg_app: Color, + pub bg_surface: Color, + pub bg_card: Color, + pub bg_user_msg: Color, + pub border: Color, + pub border_box: Color, + pub border_active: Color, + pub border_user_msg: Color, + pub primary: Color, + pub model: Color, + pub accent: Color, + pub secondary: Color, + pub text_secondary: Color, + pub sky: Color, + pub hint: Color, + pub pink: Color, + pub error: Color, + pub success: Color, + pub code_accent: Color, + pub warning: Color, + pub activity: Color, + pub bold_accent: Color, + pub text_primary: Color, + pub text_muted: Color, + pub diff_add_bg: Color, + pub diff_del_bg: Color, + pub diff_hunk: Color, + pub diff_meta: Color, +} + +/// Catppuccin Mocha — the original CodeWork look. +const CATPPUCCIN_MOCHA: Palette = Palette { + id: "catppuccin-mocha", + label: "Catppuccin Mocha", + dark: true, + bg_app: Color::Rgb(30, 30, 46), + bg_surface: Color::Rgb(37, 37, 56), + bg_card: Color::Rgb(37, 37, 56), + bg_user_msg: Color::Rgb(49, 50, 68), + border: Color::Rgb(108, 112, 134), + border_box: Color::Rgb(108, 112, 134), + border_active: Color::Rgb(180, 190, 254), + border_user_msg: Color::Rgb(180, 190, 254), + primary: Color::Rgb(180, 190, 254), + model: Color::Rgb(180, 190, 254), + accent: Color::Rgb(137, 180, 250), + secondary: Color::Rgb(137, 180, 250), + text_secondary: Color::Rgb(137, 180, 250), + sky: Color::Rgb(115, 199, 236), + hint: Color::Rgb(115, 199, 236), + pink: Color::Rgb(243, 139, 168), + error: Color::Rgb(243, 139, 168), + success: Color::Rgb(166, 227, 161), + code_accent: Color::Rgb(166, 227, 161), + warning: Color::Rgb(235, 160, 172), + activity: Color::Rgb(235, 160, 172), + bold_accent: Color::Rgb(235, 160, 172), + text_primary: Color::Rgb(166, 173, 200), + text_muted: Color::Rgb(108, 112, 134), + diff_add_bg: Color::Rgb(30, 46, 36), + diff_del_bg: Color::Rgb(52, 31, 40), + diff_hunk: Color::Rgb(115, 199, 236), + diff_meta: Color::Rgb(108, 112, 134), +}; + +/// Tokyo Night — deep blue-black with violet accents. +const TOKYO_NIGHT: Palette = Palette { + id: "tokyo-night", + label: "Tokyo Night", + dark: true, + bg_app: Color::Rgb(26, 27, 38), + bg_surface: Color::Rgb(31, 33, 46), + bg_card: Color::Rgb(31, 33, 46), + bg_user_msg: Color::Rgb(41, 46, 66), + border: Color::Rgb(41, 46, 66), + border_box: Color::Rgb(41, 46, 66), + border_active: Color::Rgb(125, 207, 255), + border_user_msg: Color::Rgb(125, 207, 255), + primary: Color::Rgb(187, 154, 247), + model: Color::Rgb(125, 207, 255), + accent: Color::Rgb(125, 207, 255), + secondary: Color::Rgb(125, 207, 255), + text_secondary: Color::Rgb(125, 207, 255), + sky: Color::Rgb(125, 207, 255), + hint: Color::Rgb(125, 207, 255), + pink: Color::Rgb(247, 118, 142), + error: Color::Rgb(247, 118, 142), + success: Color::Rgb(158, 206, 106), + code_accent: Color::Rgb(158, 206, 106), + warning: Color::Rgb(224, 175, 104), + activity: Color::Rgb(224, 175, 104), + bold_accent: Color::Rgb(224, 175, 104), + text_primary: Color::Rgb(192, 202, 245), + text_muted: Color::Rgb(86, 95, 137), + diff_add_bg: Color::Rgb(32, 44, 33), + diff_del_bg: Color::Rgb(51, 34, 46), + diff_hunk: Color::Rgb(125, 207, 255), + diff_meta: Color::Rgb(86, 95, 137), +}; + +/// Gruvbox Dark — warm earth tones. +const GRUVBOX_DARK: Palette = Palette { + id: "gruvbox-dark", + label: "Gruvbox Dark", + dark: true, + bg_app: Color::Rgb(40, 40, 40), + bg_surface: Color::Rgb(50, 50, 50), + bg_card: Color::Rgb(50, 50, 50), + bg_user_msg: Color::Rgb(60, 56, 54), + border: Color::Rgb(124, 111, 100), + border_box: Color::Rgb(124, 111, 100), + border_active: Color::Rgb(184, 187, 38), + border_user_msg: Color::Rgb(184, 187, 38), + primary: Color::Rgb(250, 189, 47), + model: Color::Rgb(184, 187, 38), + accent: Color::Rgb(131, 165, 152), + secondary: Color::Rgb(131, 165, 152), + text_secondary: Color::Rgb(131, 165, 152), + sky: Color::Rgb(131, 165, 152), + hint: Color::Rgb(131, 165, 152), + pink: Color::Rgb(251, 73, 52), + error: Color::Rgb(251, 73, 52), + success: Color::Rgb(184, 187, 38), + code_accent: Color::Rgb(184, 187, 38), + warning: Color::Rgb(254, 128, 25), + activity: Color::Rgb(254, 128, 25), + bold_accent: Color::Rgb(254, 128, 25), + text_primary: Color::Rgb(235, 219, 178), + text_muted: Color::Rgb(146, 131, 116), + diff_add_bg: Color::Rgb(50, 54, 38), + diff_del_bg: Color::Rgb(58, 42, 38), + diff_hunk: Color::Rgb(131, 165, 152), + diff_meta: Color::Rgb(146, 131, 116), +}; + +/// Catppuccin Latte — Catppuccin's light variant. +const CATPPUCCIN_LATTE: Palette = Palette { + id: "catppuccin-latte", + label: "Catppuccin Latte", + dark: false, + bg_app: Color::Rgb(239, 241, 245), + bg_surface: Color::Rgb(230, 233, 239), + bg_card: Color::Rgb(230, 233, 239), + bg_user_msg: Color::Rgb(204, 208, 216), + border: Color::Rgb(140, 143, 160), + border_box: Color::Rgb(140, 143, 160), + border_active: Color::Rgb(30, 102, 245), + border_user_msg: Color::Rgb(30, 102, 245), + primary: Color::Rgb(114, 135, 253), + model: Color::Rgb(114, 135, 253), + accent: Color::Rgb(4, 165, 229), + secondary: Color::Rgb(4, 165, 229), + text_secondary: Color::Rgb(4, 165, 229), + sky: Color::Rgb(4, 165, 229), + hint: Color::Rgb(4, 165, 229), + pink: Color::Rgb(210, 15, 57), + error: Color::Rgb(210, 15, 57), + success: Color::Rgb(64, 160, 43), + code_accent: Color::Rgb(64, 160, 43), + warning: Color::Rgb(223, 142, 29), + activity: Color::Rgb(223, 142, 29), + bold_accent: Color::Rgb(223, 142, 29), + text_primary: Color::Rgb(92, 99, 112), + text_muted: Color::Rgb(140, 143, 160), + diff_add_bg: Color::Rgb(214, 236, 216), + diff_del_bg: Color::Rgb(245, 213, 218), + diff_hunk: Color::Rgb(4, 165, 229), + diff_meta: Color::Rgb(140, 143, 160), +}; + +/// GitHub Light — the classic clean light theme. +const GITHUB_LIGHT: Palette = Palette { + id: "github-light", + label: "GitHub Light", + dark: false, + bg_app: Color::Rgb(255, 255, 255), + bg_surface: Color::Rgb(246, 248, 250), + bg_card: Color::Rgb(246, 248, 250), + bg_user_msg: Color::Rgb(234, 238, 242), + border: Color::Rgb(208, 215, 222), + border_box: Color::Rgb(208, 215, 222), + border_active: Color::Rgb(9, 105, 218), + border_user_msg: Color::Rgb(9, 105, 218), + primary: Color::Rgb(135, 86, 228), + model: Color::Rgb(9, 105, 218), + accent: Color::Rgb(9, 105, 218), + secondary: Color::Rgb(9, 105, 218), + text_secondary: Color::Rgb(9, 105, 218), + sky: Color::Rgb(9, 105, 218), + hint: Color::Rgb(9, 105, 218), + pink: Color::Rgb(207, 34, 46), + error: Color::Rgb(207, 34, 46), + success: Color::Rgb(26, 127, 55), + code_accent: Color::Rgb(26, 127, 55), + warning: Color::Rgb(154, 103, 0), + activity: Color::Rgb(154, 103, 0), + bold_accent: Color::Rgb(154, 103, 0), + text_primary: Color::Rgb(31, 35, 40), + text_muted: Color::Rgb(101, 108, 118), + diff_add_bg: Color::Rgb(218, 244, 224), + diff_del_bg: Color::Rgb(255, 224, 227), + diff_hunk: Color::Rgb(9, 105, 218), + diff_meta: Color::Rgb(101, 108, 118), +}; + +/// Solarized Light — the timeless warm light theme. +const SOLARIZED_LIGHT: Palette = Palette { + id: "solarized-light", + label: "Solarized Light", + dark: false, + bg_app: Color::Rgb(253, 246, 227), + bg_surface: Color::Rgb(238, 232, 213), + bg_card: Color::Rgb(238, 232, 213), + bg_user_msg: Color::Rgb(221, 215, 195), + border: Color::Rgb(147, 161, 161), + border_box: Color::Rgb(147, 161, 161), + border_active: Color::Rgb(38, 139, 210), + border_user_msg: Color::Rgb(38, 139, 210), + primary: Color::Rgb(133, 153, 0), + model: Color::Rgb(38, 139, 210), + accent: Color::Rgb(38, 139, 210), + secondary: Color::Rgb(38, 139, 210), + text_secondary: Color::Rgb(38, 139, 210), + sky: Color::Rgb(38, 139, 210), + hint: Color::Rgb(38, 139, 210), + pink: Color::Rgb(220, 50, 47), + error: Color::Rgb(220, 50, 47), + success: Color::Rgb(133, 153, 0), + code_accent: Color::Rgb(133, 153, 0), + warning: Color::Rgb(203, 75, 22), + activity: Color::Rgb(203, 75, 22), + bold_accent: Color::Rgb(203, 75, 22), + text_primary: Color::Rgb(101, 123, 131), + text_muted: Color::Rgb(147, 161, 161), + diff_add_bg: Color::Rgb(230, 240, 213), + diff_del_bg: Color::Rgb(250, 229, 224), + diff_hunk: Color::Rgb(38, 139, 210), + diff_meta: Color::Rgb(147, 161, 161), +}; + +/// The 6 built-in themes: 3 dark, 3 light. +pub const PALETTES: &[Palette] = &[ + CATPPUCCIN_MOCHA, + TOKYO_NIGHT, + GRUVBOX_DARK, + CATPPUCCIN_LATTE, + GITHUB_LIGHT, + SOLARIZED_LIGHT, +]; + +static ACTIVE: AtomicUsize = AtomicUsize::new(0); + +/// Switch the active palette by index; returns `false` when out of range. +pub fn set_active(index: usize) -> bool { + if index >= PALETTES.len() { + return false; + } + ACTIVE.store(index, Ordering::Relaxed); + true +} + +/// Index of the active palette into [`PALETTES`]. +pub fn active_index() -> usize { + ACTIVE.load(Ordering::Relaxed) +} + +pub fn active() -> &'static Palette { + &PALETTES[ACTIVE.load(Ordering::Relaxed)] +} + +/// Look up a palette by id, e.g. to restore a persisted choice. +pub fn by_id(id: &str) -> Option { + PALETTES.iter().position(|p| p.id == id) +} + +/// Legacy role names used across the codebase, resolved against the active +/// palette. `Theme::bg_app()` etc. read naturally next to the old +/// `Theme::BG_APP` constants they replaced. +pub struct Theme; + +#[allow(dead_code)] +impl Theme { + pub fn bg_app() -> Color { + active().bg_app + } + pub fn bg_surface() -> Color { + active().bg_surface + } + pub fn bg_card() -> Color { + active().bg_card + } + pub fn bg_user_msg() -> Color { + active().bg_user_msg + } + pub fn border() -> Color { + active().border + } + pub fn border_box() -> Color { + active().border_box + } + pub fn border_active() -> Color { + active().border_active + } + pub fn border_user_msg() -> Color { + active().border_user_msg + } + pub fn primary() -> Color { + active().primary + } + pub fn model() -> Color { + active().model + } + pub fn accent() -> Color { + active().accent + } + pub fn secondary() -> Color { + active().secondary + } + pub fn text_secondary() -> Color { + active().text_secondary + } + pub fn sky() -> Color { + active().sky + } + pub fn hint() -> Color { + active().hint + } + pub fn pink() -> Color { + active().pink + } + pub fn error() -> Color { + active().error + } + pub fn success() -> Color { + active().success + } + pub fn code_accent() -> Color { + active().code_accent + } + pub fn warning() -> Color { + active().warning + } + pub fn activity() -> Color { + active().activity + } + pub fn bold_accent() -> Color { + active().bold_accent + } + pub fn text_primary() -> Color { + active().text_primary + } + pub fn text_muted() -> Color { + active().text_muted + } + pub fn diff_add_bg() -> Color { + active().diff_add_bg + } + pub fn diff_del_bg() -> Color { + active().diff_del_bg + } + pub fn diff_hunk() -> Color { + active().diff_hunk + } + pub fn diff_meta() -> Color { + active().diff_meta + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn six_palettes_split_dark_light() { + assert_eq!(PALETTES.len(), 6); + assert_eq!(PALETTES.iter().filter(|p| p.dark).count(), 3); + assert_eq!(PALETTES.iter().filter(|p| !p.dark).count(), 3); + } + + #[test] + fn ids_are_unique_and_lookup_works() { + for (i, p) in PALETTES.iter().enumerate() { + assert_eq!(by_id(p.id), Some(i)); + } + assert_eq!(by_id("nope"), None); + } + + /// One sequential test: the active palette is process-global, so parallel + /// tests touching it would race. + #[test] + fn set_active_switches_roles_and_rejects_out_of_range() { + let original = active_index(); + assert!(set_active(2)); + assert_eq!(active_index(), 2); + assert_eq!(Theme::bg_app(), GRUVBOX_DARK.bg_app); + assert!(!set_active(6)); + assert_eq!(active_index(), 2); + + assert!(set_active(by_id("solarized-light").unwrap())); + assert_eq!(Theme::text_primary(), SOLARIZED_LIGHT.text_primary); + + assert!(set_active(original)); + } +} diff --git a/packages/tui/src/ui/theme_picker.rs b/packages/tui/src/ui/theme_picker.rs new file mode 100644 index 00000000..1a5dbcef --- /dev/null +++ b/packages/tui/src/ui/theme_picker.rs @@ -0,0 +1,105 @@ +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; + +use crate::ui::theme::{self, Theme}; + +/// The `/theme` picker: a centered overlay listing all 6 palettes. Moving the +/// cursor switches the live palette, so the entire UI recolors behind it as a +/// preview before you commit with Enter. +pub fn render_theme_picker(f: &mut Frame, area: Rect, cursor: usize) { + let palettes = theme::PALETTES; + let width = 46u16.min(area.width.saturating_sub(4)); + let height = (palettes.len() as u16 + 6).min(area.height.saturating_sub(2)); + if width == 0 || height < 3 { + return; + } + + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + let rect = Rect::new(x, y, width, height); + + f.render_widget(Clear, rect); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::border_active())) + .title(Span::styled( + " Theme ", + Style::default() + .fg(Theme::border_active()) + .add_modifier(Modifier::BOLD), + )) + .title_bottom(Span::styled( + " ↑/↓ preview • Enter apply • Esc revert ", + Style::default().fg(Theme::text_muted()), + )) + .style(Style::default().bg(Theme::bg_surface())); + f.render_widget(block, rect); + + let inner = Rect::new(x + 1, y + 1, width.saturating_sub(2), height.saturating_sub(2)); + let mut lines = Vec::with_capacity(palettes.len() + 1); + lines.push(Line::from("")); + + for (idx, palette) in palettes.iter().enumerate() { + let is_selected = idx == cursor; + let is_active = idx == theme::active_index(); + + let cursor_span = if is_selected { + Span::styled( + "▶ ", + Style::default() + .fg(Theme::border_active()) + .add_modifier(Modifier::BOLD), + ) + } else { + Span::raw(" ") + }; + + // A two-cell swatch in the palette's own background gives an at-a-glance + // preview even for themes that only differ in subtle tones. + let swatch = Span::styled(" ", Style::default().bg(palette.bg_app)); + let mode_tag = if palette.dark { "dark" } else { "light" }; + + let name_style = if is_selected { + Style::default() + .fg(Theme::text_primary()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Theme::text_secondary()) + }; + + lines.push(Line::from(vec![ + cursor_span, + swatch, + Span::raw(" "), + Span::styled(format!("{:<18}", palette.label), name_style), + Span::styled( + format!("({mode_tag})"), + Style::default().fg(Theme::text_muted()), + ), + Span::raw(" "), + Span::styled( + if is_active { "● current" } else { "" }, + Style::default() + .fg(Theme::success()) + .add_modifier(Modifier::BOLD), + ), + ])); + } + + f.render_widget(Paragraph::new(lines), inner); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn six_palettes_available_for_picker() { + assert_eq!(theme::PALETTES.len(), 6); + assert_eq!(theme::PALETTES[0].id, "catppuccin-mocha"); + } +} diff --git a/packages/tui/src/ui/welcome.rs b/packages/tui/src/ui/welcome.rs new file mode 100644 index 00000000..f850a106 --- /dev/null +++ b/packages/tui/src/ui/welcome.rs @@ -0,0 +1,512 @@ +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::types::ModelConfig; +use crate::ui::logo::{render_animated_logo_lines, LOGO_GRADIENT, LOGO_SPARKLE_FRAMES}; +use crate::ui::spinner::Spinner; +use crate::ui::theme::Theme; + +#[derive(Debug, Clone, Copy)] +pub struct CommandItem { + pub name: &'static str, + pub icon: &'static str, + pub description: &'static str, + pub icon_color: Color, +} + +/// Built as a function (not a `const`) so icon colors resolve against the +/// active palette — a `const` would freeze the startup theme's colors. +pub fn commands() -> Vec { + vec![ + CommandItem { + name: "/help", + icon: "?", + description: "Show available commands and shortcuts", + icon_color: Theme::sky(), + }, + CommandItem { + name: "/clear", + icon: "↺", + description: "Clear conversation history and screen", + icon_color: Theme::warning(), + }, + CommandItem { + name: "/model", + icon: "✦", + description: "Switch or view active LLM model", + icon_color: Theme::primary(), + }, + CommandItem { + name: "/theme", + icon: "◑", + description: "Change the color theme", + icon_color: Theme::pink(), + }, + CommandItem { + name: "/session", + icon: "◷", + description: "List or resume recent sessions", + icon_color: Theme::secondary(), + }, + CommandItem { + name: "/compact", + icon: "⇥", + description: "Compact current conversation context", + icon_color: Theme::success(), + }, + CommandItem { + name: "/exit", + icon: "✕", + description: "Exit CodeWork TUI", + icon_color: Theme::error(), + }, + ] +} + +pub fn render_welcome( + f: &mut Frame, + area: Rect, + input: &str, + cursor_pos: usize, + dropdown_index: usize, + active_config: Option<&ModelConfig>, + status_message: Option<&str>, + spinner: &Spinner, +) { + let card_width = 78u16.min(area.width.saturating_sub(2)); + let cursor_visible = spinner.cursor_visible(); + let is_dropdown_open = input.starts_with('/') && !input.contains(' '); + + let commands = commands(); + let matching_cmds: Vec<&CommandItem> = if is_dropdown_open { + commands + .iter() + .filter(|cmd| cmd.name.starts_with(input)) + .collect() + } else { + Vec::new() + }; + + let max_welcome_text_w = (card_width as usize).saturating_sub(6).max(10); + let wrapped_welcome = crate::ui::input::wrap_input_with_cursor(input, cursor_pos, max_welcome_text_w); + let max_visible_welcome = 5usize; + let num_welcome_lines = if input.is_empty() { + 1 + } else { + wrapped_welcome.lines.len().clamp(1, max_visible_welcome) + }; + + // Calculate Card 2 (Bottom Card) height + let card2_height = if is_dropdown_open { + let rows = matching_cmds.len().max(1) as u16; + // 1 (top border) + 1 (header "Commands") + rows + 1 (empty line) + num_lines + 1 (bottom border) + 4 + rows + num_welcome_lines as u16 + } else { + 2 + num_welcome_lines as u16 + }; + + // Total content height + let total_height = 17 + 1 + 2 + 1 + card2_height + 1; + + let start_x = (area.width.saturating_sub(card_width)) / 2; + let start_y = (area.height.saturating_sub(total_height)) / 2; + + // 1. Top Card (Main Status / Welcome Card) + let top_h = 17u16.min(area.height.saturating_sub(start_y)); + let top_rect = Rect::new(start_x, start_y, card_width, top_h); + + let top_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::border())) + .style(Style::default().bg(Theme::bg_surface())); + f.render_widget(top_block, top_rect); + + if top_h >= 5 { + let inner_w = card_width.saturating_sub(2); + let inner_x = start_x + 1; + let inner_y = start_y + 1; + + // Header bar inside card + let sparkle_frame = LOGO_SPARKLE_FRAMES[(spinner.tick_count() / 2) % LOGO_SPARKLE_FRAMES.len()]; + let sparkle_color = LOGO_GRADIENT[spinner.tick_count() % LOGO_GRADIENT.len()]; + let left_part_len = 2 + "codework v0.0.1".len(); + let right_part = "open-source harness"; + let header_pad = (inner_w as usize).saturating_sub(left_part_len + right_part.len()); + + let header_line = Line::from(vec![ + Span::styled( + format!("{} ", sparkle_frame), + Style::default().fg(sparkle_color).add_modifier(Modifier::BOLD), + ), + Span::styled( + "codework ", + Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD), + ), + Span::styled("v0.0.1", Style::default().fg(Theme::text_muted())), + Span::raw(" ".repeat(header_pad)), + Span::styled(right_part, Style::default().fg(Theme::text_muted())), + ]); + let divider_line = Line::from(Span::styled( + "─".repeat(inner_w as usize), + Style::default().fg(Theme::border()), + )); + f.render_widget( + Paragraph::new(vec![header_line, divider_line]), + Rect::new(inner_x, inner_y, inner_w, 2), + ); + + // Columns area + let content_y = inner_y + 2; + let content_h = top_h.saturating_sub(4); + let left_w = 28u16.min(inner_w); + + // Left Column: Headline + Logo + Model info + let mut left_lines = Vec::new(); + left_lines.push(Line::from(Span::styled( + "The Open-Source", + Style::default().fg(Theme::text_primary()).add_modifier(Modifier::BOLD), + ))); + left_lines.push(Line::from(Span::styled( + "Coding Agent Harness", + Style::default().fg(Theme::accent()), + ))); + left_lines.push(Line::raw("")); + + // CW Logo with animated gradient + left_lines.extend(render_animated_logo_lines(0, spinner.tick_count())); + + left_lines.push(Line::raw("")); + left_lines.push(Line::raw("")); + + let model_str = active_config + .map(|c| format!("{}:{}", c.provider, c.model)) + .unwrap_or_else(|| "codework:default".to_string()); + left_lines.push(Line::from(Span::styled( + model_str, + Style::default().fg(Theme::model()), + ))); + left_lines.push(Line::from(Span::styled( + "plugins • sandboxed", + Style::default().fg(Theme::text_muted()), + ))); + + f.render_widget( + Paragraph::new(left_lines).alignment(Alignment::Center), + Rect::new(inner_x, content_y, left_w, content_h), + ); + + // Vertical divider + if inner_w > left_w { + let div_lines: Vec = (0..content_h) + .map(|_| Line::from(Span::styled("│", Style::default().fg(Theme::border())))) + .collect(); + f.render_widget( + Paragraph::new(div_lines), + Rect::new(inner_x + left_w, content_y, 1, content_h), + ); + } + + // Right Column: Agent Loop, Plugins, Platform + let right_x = inner_x + left_w + 1; + let right_w = inner_w.saturating_sub(left_w + 1); + if right_w > 0 { + let div_width = right_w.saturating_sub(4) as usize; + let right_lines = vec![ + Line::from(vec![ + Span::raw(" "), + Span::styled( + "Agent Loop & Tools", + Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("Understand ", Style::default().fg(Theme::text_secondary())), + Span::styled("search & read codebase", Style::default().fg(Theme::text_muted())), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("Modify ", Style::default().fg(Theme::text_secondary())), + Span::styled("edit files & apply diffs", Style::default().fg(Theme::text_muted())), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("Execute ", Style::default().fg(Theme::text_secondary())), + Span::styled("sandboxed commands & shell", Style::default().fg(Theme::text_muted())), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("Debug ", Style::default().fg(Theme::text_secondary())), + Span::styled("autonomous test & iterate", Style::default().fg(Theme::text_muted())), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("─".repeat(div_width), Style::default().fg(Theme::border())), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled( + "Plugins & MCP", + Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("Plugin SDK ", Style::default().fg(Theme::text_secondary())), + Span::styled("custom tools, drivers & hooks", Style::default().fg(Theme::text_muted())), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("MCP Ready ", Style::default().fg(Theme::text_secondary())), + Span::styled("plug in external tool servers", Style::default().fg(Theme::text_muted())), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("─".repeat(div_width), Style::default().fg(Theme::border())), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled( + "Developer Platform", + Style::default().fg(Theme::accent()).add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("Sandboxed ", Style::default().fg(Theme::text_secondary())), + Span::styled("isolated execution", Style::default().fg(Theme::text_muted())), + ]), + Line::from(vec![ + Span::raw(" "), + Span::styled("Open Source", Style::default().fg(Theme::text_secondary())), + Span::styled("build & run your agent", Style::default().fg(Theme::text_muted())), + ]), + ]; + f.render_widget( + Paragraph::new(right_lines), + Rect::new(right_x, content_y, right_w, content_h), + ); + } + } + + // 2. Supporting Copy + let copy_y = start_y + top_h + 1; + if copy_y + 2 <= area.height { + let copy_lines = vec![ + Line::from(vec![ + Span::styled( + "Codework: ", + Style::default().fg(Theme::pink()), + ), + Span::styled( + "Build, run, and customize AI coding agents with plugins to", + Style::default().fg(Theme::text_muted()).add_modifier(Modifier::ITALIC), + ), + ]), + Line::from(vec![ + Span::styled( + "understand code, edit files, execute commands, and debug autonomously.", + Style::default().fg(Theme::text_muted()).add_modifier(Modifier::ITALIC), + ), + ]), + ]; + f.render_widget(Paragraph::new(copy_lines), Rect::new(start_x, copy_y, card_width, 2)); + } + + // 3. Unified Input and Commands Box + let bot_y = copy_y + 3; + let bot_h = card2_height.min(area.height.saturating_sub(bot_y)); + if bot_h >= 3 { + let bot_border_color = if is_dropdown_open { + Theme::sky() + } else { + Theme::border_box() + }; + + let bot_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(bot_border_color)) + .style(Style::default().bg(Theme::bg_surface())); + let bot_inner = Rect::new(start_x + 1, bot_y + 1, card_width.saturating_sub(2), bot_h.saturating_sub(2)); + + f.render_widget(bot_block, Rect::new(start_x, bot_y, card_width, bot_h)); + + let mut bot_lines = Vec::new(); + if is_dropdown_open { + bot_lines.push(Line::from(Span::styled( + "Commands", + Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD), + ))); + + if matching_cmds.is_empty() { + bot_lines.push(Line::from(Span::styled( + "No matching commands", + Style::default().fg(Theme::text_muted()), + ))); + } else { + for (idx, cmd) in matching_cmds.iter().enumerate() { + let is_selected = idx == dropdown_index; + if is_selected { + bot_lines.push(Line::from(vec![ + Span::styled("▶ ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)), + Span::styled(format!("{} ", cmd.icon), Style::default().fg(cmd.icon_color).add_modifier(Modifier::BOLD)), + Span::styled( + format!("{:<10}", cmd.name), + Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD), + ), + Span::styled(cmd.description, Style::default().fg(Theme::text_primary())), + ])); + } else { + bot_lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(format!("{} ", cmd.icon), Style::default().fg(cmd.icon_color)), + Span::styled( + format!("{:<10}", cmd.name), + Style::default().fg(Theme::text_secondary()), + ), + Span::styled(cmd.description, Style::default().fg(Theme::text_muted())), + ])); + } + } + } + + bot_lines.push(Line::raw("")); + + let total_w_lines = wrapped_welcome.lines.len(); + let scroll_top = if wrapped_welcome.cursor_line >= max_visible_welcome { + wrapped_welcome.cursor_line + 1 - max_visible_welcome + } else { + 0 + }; + let visible_slice = &wrapped_welcome.lines[scroll_top..(scroll_top + max_visible_welcome).min(total_w_lines)]; + for (i, line) in visible_slice.iter().enumerate() { + let l_idx = scroll_top + i; + let prefix = if l_idx == 0 { + Span::styled("> ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)) + } else { + Span::raw(" ") + }; + let mut spans = vec![prefix]; + spans.extend(crate::ui::input::render_line_spans(line, Theme::sky(), cursor_visible)); + bot_lines.push(Line::from(spans)); + } + } else { + if input.is_empty() { + let mut spans = vec![ + Span::styled("> ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)), + Span::styled( + "Type a message or / for commands...", + Style::default().fg(Theme::text_muted()), + ), + ]; + spans.push(if cursor_visible { + Span::styled("█", Style::default().fg(Theme::sky())) + } else { + Span::raw(" ") + }); + bot_lines.push(Line::from(spans)); + } else { + let total_w_lines = wrapped_welcome.lines.len(); + let scroll_top = if wrapped_welcome.cursor_line >= max_visible_welcome { + wrapped_welcome.cursor_line + 1 - max_visible_welcome + } else { + 0 + }; + let visible_slice = &wrapped_welcome.lines[scroll_top..(scroll_top + max_visible_welcome).min(total_w_lines)]; + for (i, line) in visible_slice.iter().enumerate() { + let l_idx = scroll_top + i; + let prefix = if l_idx == 0 { + Span::styled("> ", Style::default().fg(Theme::sky()).add_modifier(Modifier::BOLD)) + } else { + Span::raw(" ") + }; + let mut spans = vec![prefix]; + spans.extend(crate::ui::input::render_line_spans(line, Theme::sky(), cursor_visible)); + bot_lines.push(Line::from(spans)); + } + } + } + + f.render_widget(Paragraph::new(bot_lines), bot_inner); + } + + // 4. Status Bar / Footer + let footer_y = bot_y + bot_h; + if footer_y < area.height { + let left_text = if is_dropdown_open { + "↑/↓ to navigate • Enter to select • Esc to dismiss" + } else { + "Press Enter to submit • Esc to exit" + }; + let left_span = Span::styled(left_text, Style::default().fg(Theme::text_muted())); + + let (right_spans, right_len) = if let Some(msg) = status_message { + ( + vec![Span::styled( + msg.to_string(), + Style::default().fg(Theme::activity()), + )], + msg.len(), + ) + } else if let Some(cfg) = active_config { + let text = format!("✓ Connected: {} ({})", cfg.provider, cfg.model); + ( + vec![ + Span::styled("✓ ", Style::default().fg(Theme::success())), + Span::styled("Connected: ", Style::default().fg(Theme::text_muted())), + Span::styled(&cfg.provider, Style::default().fg(Theme::model())), + Span::styled(" (", Style::default().fg(Theme::text_muted())), + Span::styled(&cfg.model, Style::default().fg(Theme::model())), + Span::styled(")", Style::default().fg(Theme::text_muted())), + ], + text.len(), + ) + } else { + ( + vec![Span::styled( + "No model connected (/model)", + Style::default().fg(Theme::text_muted()), + )], + "No model connected (/model)".len(), + ) + }; + + let footer_pad = (card_width as usize).saturating_sub(left_text.len() + right_len); + let mut footer_spans = vec![left_span]; + footer_spans.push(Span::raw(" ".repeat(footer_pad))); + footer_spans.extend(right_spans); + + f.render_widget( + Paragraph::new(Line::from(footer_spans)), + Rect::new(start_x, footer_y, card_width, 1), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_commands_have_valid_icons() { + for cmd in commands() { + assert!(!cmd.name.is_empty(), "command name must not be empty"); + assert!(cmd.name.starts_with('/'), "command name must start with /"); + assert!(!cmd.icon.is_empty(), "command icon must not be empty"); + assert!(!cmd.description.is_empty(), "description must not be empty"); + } + } + + #[test] + fn test_commands_filter_prefix() { + let all = commands(); + let matching: Vec<&CommandItem> = all.iter().filter(|c| c.name.starts_with("/m")).collect(); + assert_eq!(matching.len(), 1); + assert_eq!(matching[0].name, "/model"); + assert_eq!(matching[0].icon, "✦"); + } +} + diff --git a/packages/tui/tsconfig.json b/packages/tui/tsconfig.json new file mode 100644 index 00000000..a302d400 --- /dev/null +++ b/packages/tui/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../node_modules/@effect/tsgo/schema.json", + "extends": "../../tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "../../.cache/typescript/tui.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/packages/tui/vite.config.ts b/packages/tui/vite.config.ts new file mode 100644 index 00000000..f80ba4d6 --- /dev/null +++ b/packages/tui/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from "vite-plus"; + +const ignoredPaths = ["dist/**", "**/dist/**", "target/**", "node_modules/**", "**/node_modules/**"]; + +// The launcher is the npm package; the Rust crate beside it is packaged into the +// platform packages and never touched by the JS build. +export default defineConfig({ + pack: { + entry: ["src/index.ts"], + format: ["esm"], + outDir: "dist/pack", + sourcemap: true, + clean: true, + // A launcher with no importable API; the CLI calls it, it does not type it. + dts: false, + }, + lint: { + ignorePatterns: ignoredPaths, + options: { + typeAware: true, + typeCheck: true, + }, + }, + fmt: { + ignorePatterns: ignoredPaths, + printWidth: 120, + useTabs: true, + tabWidth: 3, + sortPackageJson: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f28a0e03..e8c31792 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -456,6 +456,9 @@ importers: '@codeworksh/harness': specifier: workspace:* version: link:../harness + '@codeworksh/tui': + specifier: workspace:* + version: link:../tui '@effect/platform-node': specifier: 4.0.1 version: 4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)) @@ -572,6 +575,18 @@ importers: specifier: 4.0.1 version: 4.0.1 + packages/tui: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 26.6.4 + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite-plus: + specifier: 'catalog:' + version: 0.3.1(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.1) + sites/www: dependencies: '@astrojs/markdown-remark': @@ -643,7 +658,7 @@ importers: version: 19.3.0(@types/react@19.3.0) alchemy: specifier: 2.0.0-beta.81 - version: 2.0.0-beta.81(@effect/platform-bun@4.0.1(effect@4.0.1))(@effect/platform-node@4.0.1(effect@4.0.1))(@types/node@26.5.1)(@types/react@19.3.0)(effect@4.0.1)(hono@4.11.4)(typescript@6.0.3)(vite@8.3.0(@types/node@26.5.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))(ws@8.22.0) + version: 2.0.0-beta.81(@effect/platform-bun@4.0.1(effect@4.0.1))(@effect/platform-node@4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@26.5.1)(@types/react@19.3.0)(effect@4.0.1)(hono@4.11.4)(typescript@6.0.3)(vite@8.3.0(@types/node@26.5.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))(ws@8.22.0) effect: specifier: 4.0.1 version: 4.0.1 @@ -8111,7 +8126,7 @@ snapshots: '@ai-sdk/provider-utils': 5.0.40(zod@4.6.5) zod: 4.6.5 - '@alchemy.run/cloudflare-runtime@2.0.0-beta.81(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.1))(@effect/platform-bun@4.0.1(effect@4.0.1))(@effect/platform-node@4.0.1(effect@4.0.1))(@types/node@26.5.1)(effect@4.0.1)(rolldown@1.2.5)(typescript@6.0.3)(vite@8.3.0(@types/node@26.5.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))': + '@alchemy.run/cloudflare-runtime@2.0.0-beta.81(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.1))(@effect/platform-bun@4.0.1(effect@4.0.1))(@effect/platform-node@4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@26.5.1)(effect@4.0.1)(rolldown@1.2.5)(typescript@6.0.3)(vite@8.3.0(@types/node@26.5.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))': dependencies: '@alchemy.run/node-utils': 2.0.0-beta.81 '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260918.1) @@ -12119,9 +12134,9 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@2.0.0-beta.81(@effect/platform-bun@4.0.1(effect@4.0.1))(@effect/platform-node@4.0.1(effect@4.0.1))(@types/node@26.5.1)(@types/react@19.3.0)(effect@4.0.1)(hono@4.11.4)(typescript@6.0.3)(vite@8.3.0(@types/node@26.5.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))(ws@8.22.0): + alchemy@2.0.0-beta.81(@effect/platform-bun@4.0.1(effect@4.0.1))(@effect/platform-node@4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@26.5.1)(@types/react@19.3.0)(effect@4.0.1)(hono@4.11.4)(typescript@6.0.3)(vite@8.3.0(@types/node@26.5.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1))(ws@8.22.0): dependencies: - '@alchemy.run/cloudflare-runtime': 2.0.0-beta.81(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.1))(@effect/platform-bun@4.0.1(effect@4.0.1))(@effect/platform-node@4.0.1(effect@4.0.1))(@types/node@26.5.1)(effect@4.0.1)(rolldown@1.2.5)(typescript@6.0.3)(vite@8.3.0(@types/node@26.5.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)) + '@alchemy.run/cloudflare-runtime': 2.0.0-beta.81(@distilled.cloud/cloudflare@1.0.0-rc.13(effect@4.0.1))(@effect/platform-bun@4.0.1(effect@4.0.1))(@effect/platform-node@4.0.1(effect@4.0.1)(redis@6.2.1(@opentelemetry/api@1.9.1)))(@types/node@26.5.1)(effect@4.0.1)(rolldown@1.2.5)(typescript@6.0.3)(vite@8.3.0(@types/node@26.5.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.1)) '@alchemy.run/floci': 2.0.0-beta.81(effect@4.0.1) '@alchemy.run/node-utils': 2.0.0-beta.81 '@alchemy.run/sigil': 0.1.0-alpha.1(@types/react@19.3.0) diff --git a/vite.config.ts b/vite.config.ts index 78310d10..d5de164b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -51,6 +51,10 @@ const aliases = [ find: "@codeworksh/harness", replacement: fileURLToPath(new URL("./packages/harness/src/index.ts", import.meta.url)), }, + { + find: "@codeworksh/tui", + replacement: fileURLToPath(new URL("./packages/tui/src/index.ts", import.meta.url)), + }, ]; // Packages written against Effect. Kept as a list so lint overrides and the From 4bc731e318e428d2266c062db54ef7526cb3d90e Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Mon, 12 Oct 2026 00:41:03 +0530 Subject: [PATCH 2/4] feat(cli): background server for codework tui `codework tui` now finds or starts a background server and attaches the Rust TUI to it: - Discovery reads /service.json and trusts it only when the endpoint answers `server.info` with the registered pid. A server from another CLI version is replaced. Otherwise a detached `codework serve --service` is started, logging to /log/server.log, and startup fails fast with the reason from that log. - Concurrent starts converge on one server: the loser adopts the winner, and a contender that is not adopted is stopped with the caller's scope. - A registered server rewrites a missing service.json and shuts down when another server takes it over, so no server runs undiscoverable. - While the TUI runs, the CLI restarts a server that dies on the same port so the TUI's reconnect lands. - The TUI is spawned through Effect's ChildProcessSpawner in the CLI's scope, so SIGINT/SIGTERM to `codework tui` now stop the UI instead of orphaning it. Adds `codework service start|restart|status|stop`, moves the RPC route to /rpc/v1, and shows the cause of a failed `serve` (e.g. EADDRINUSE) instead of a bare tag. The TUI launcher package now only resolves the binary, Unix only. Co-Authored-By: Claude Opus 5.5 --- packages/codework/README.md | 10 +- packages/codework/src/cli/cmd/cmd.ts | 50 +++- .../codework/src/cli/cmd/handlers/serve.ts | 40 ++- .../src/cli/cmd/handlers/service/restart.ts | 17 ++ .../src/cli/cmd/handlers/service/start.ts | 16 + .../src/cli/cmd/handlers/service/status.ts | 16 + .../src/cli/cmd/handlers/service/stop.ts | 14 + packages/codework/src/cli/cmd/handlers/tui.ts | 120 +++++--- packages/codework/src/cli/error.ts | 6 +- packages/codework/src/cli/service.ts | 33 +++ packages/codework/src/index.ts | 6 + packages/codework/src/server/contract.ts | 3 + packages/codework/src/server/discovery.ts | 280 +++++++++++++----- packages/codework/src/server/health.ts | 16 +- packages/codework/src/server/identity.ts | 5 +- packages/codework/src/server/registration.ts | 49 +-- packages/codework/src/server/server.ts | 4 +- .../test/__artifacts__/service.lifecycle.json | 37 +++ .../test/__artifacts__/tui.managed.json | 17 ++ packages/codework/test/cli.test.ts | 2 +- packages/codework/test/server.test.ts | 10 +- packages/codework/test/tui.test.ts | 207 ++++++++++--- packages/tui/README.md | 64 ++-- packages/tui/src/binary.ts | 17 +- packages/tui/src/index.ts | 77 +---- packages/tui/src/rpc/client.rs | 2 +- sites/www/content/docs/cli/reference.mdx | 46 ++- sites/www/content/docs/integrations/rpc.mdx | 10 +- 28 files changed, 816 insertions(+), 358 deletions(-) create mode 100644 packages/codework/src/cli/cmd/handlers/service/restart.ts create mode 100644 packages/codework/src/cli/cmd/handlers/service/start.ts create mode 100644 packages/codework/src/cli/cmd/handlers/service/status.ts create mode 100644 packages/codework/src/cli/cmd/handlers/service/stop.ts create mode 100644 packages/codework/src/cli/service.ts create mode 100644 packages/codework/test/__artifacts__/service.lifecycle.json create mode 100644 packages/codework/test/__artifacts__/tui.managed.json diff --git a/packages/codework/README.md b/packages/codework/README.md index 965348ba..cd6d47ea 100644 --- a/packages/codework/README.md +++ b/packages/codework/README.md @@ -49,7 +49,7 @@ codework run --sandbox-driver daytona --sandbox-provider-id "Continu #### Flags: -- `--server ` — Connect to a running RPC server, e.g. `ws://127.0.0.1:7433/rpc`. +- `--server ` — Connect to a running RPC server, e.g. `ws://127.0.0.1:7433/rpc/v1`. - `-s, --session ` — Continue an existing session. - `-C, --cwd ` — Working directory for a new session. - `--sandbox-driver ` — Sandbox driver (`local`, `daytona`, `vercel`, `memory`, `sqldb`). Default: `local`. @@ -69,9 +69,9 @@ Start a persistent server that owns sessions and sandboxes: ```bash codework serve --host 127.0.0.1 --port 7433 -codework run --server ws://127.0.0.1:7433/rpc "Inspect the failing tests" -codework run --server ws://127.0.0.1:7433/rpc --session "Now fix them" -codework run --server ws://127.0.0.1:7433/rpc --sandbox-id "Start another session in this sandbox" +codework run --server ws://127.0.0.1:7433/rpc/v1 "Inspect the failing tests" +codework run --server ws://127.0.0.1:7433/rpc/v1 --session "Now fix them" +codework run --server ws://127.0.0.1:7433/rpc/v1 --sandbox-id "Start another session in this sandbox" ``` The default address is `127.0.0.1:7433`; `--port 0` picks an available port and prints the actual URL. Set `--home`, `--database`, and `--user-config-dir` on the server. Model credentials, plugins, and working directories are resolved by the server. A remote run does not boot a local harness. @@ -105,7 +105,7 @@ codework auth refresh --openai-codex codework auth logout --github-copilot # Authenticate the home owned by a running RPC server -codework auth login --openai-codex --server ws://127.0.0.1:7433/rpc +codework auth login --openai-codex --server ws://127.0.0.1:7433/rpc/v1 ``` `--home` keeps authentication alongside the rest of that Codework installation; `--auth-file` overrides the credential diff --git a/packages/codework/src/cli/cmd/cmd.ts b/packages/codework/src/cli/cmd/cmd.ts index aaaf7e00..ee228820 100644 --- a/packages/codework/src/cli/cmd/cmd.ts +++ b/packages/codework/src/cli/cmd/cmd.ts @@ -23,13 +23,26 @@ const AuthParams = { Flag.withDescription("Use GitHub Copilot OAuth (device flow)"), Flag.withDefault(false), ), - server: Flag.String("server").pipe(Flag.withDescription("RPC server URL (ws://host:port/rpc)"), Flag.optional), + server: Flag.String("server").pipe(Flag.withDescription("RPC server URL (ws://host:port/rpc/v1)"), Flag.optional), authFile: Flag.String("auth-file").pipe( Flag.withDescription("Credential file (defaults to /aikit/auth.json)"), Flag.optional, ), }; +/** Where the background server binds when `tui` or `service` has to start one. */ +const ServerAddress = { + host: Flag.String("host").pipe( + Flag.withDescription("Host interface the background server binds"), + Flag.withDefault("127.0.0.1"), + ), + port: Flag.Int("port").pipe( + Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 65535 }))), + Flag.withDescription("Port the background server listens on"), + Flag.withDefault(7433), + ), +}; + const JsonFlag = Flag.Boolean("json").pipe( Flag.withDescription("Print machine-readable output"), Flag.withDefault(false), @@ -82,7 +95,7 @@ export const Cmd = Spec.make("codework", { description: "Sign in with a device code, no local port", }, { - command: "codework auth login --openai-codex --server ws://127.0.0.1:7433/rpc", + command: "codework auth login --openai-codex --server ws://127.0.0.1:7433/rpc/v1", description: "Sign in the running server", }, ], @@ -108,7 +121,7 @@ export const Cmd = Spec.make("codework", { description: "Start or continue an agent session", params: { server: Flag.String("server").pipe( - Flag.withDescription("RPC server URL (ws://host:port/rpc)"), + Flag.withDescription("RPC server URL (ws://host:port/rpc/v1)"), Flag.optional, ), prompt: Argument.String("prompt").pipe(Argument.withDescription("Prompt for the agent")), @@ -162,21 +175,13 @@ export const Cmd = Spec.make("codework", { ], }), Spec.make("tui", { - description: "Launch the CodeWork terminal UI against a managed server", + description: "Launch the CodeWork terminal UI against the background server", params: { server: Flag.String("server").pipe( - Flag.withDescription("RPC server URL to use instead of a managed one (ws://host:port/rpc)"), + Flag.withDescription("RPC server URL to use instead of the background server (ws://host:port/rpc/v1)"), Flag.optional, ), - host: Flag.String("host").pipe( - Flag.withDescription("Host interface a managed server binds"), - Flag.withDefault("127.0.0.1"), - ), - port: Flag.Int("port").pipe( - Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 65535 }))), - Flag.withDescription("Port a managed server listens on"), - Flag.withDefault(7433), - ), + ...ServerAddress, args: Argument.variadic( Argument.String("args").pipe(Argument.withDescription("Arguments forwarded to the terminal UI")), ), @@ -184,7 +189,7 @@ export const Cmd = Spec.make("codework", { examples: [ { command: "codework tui", description: "Start or reuse the local server and open the terminal UI" }, { - command: "codework tui --server ws://127.0.0.1:7433/rpc", + command: "codework tui --server ws://127.0.0.1:7433/rpc/v1", description: "Attach the terminal UI to a server you started yourself", }, ], @@ -201,12 +206,27 @@ export const Cmd = Spec.make("codework", { Flag.withDescription("Port to listen on (0 for an ephemeral port)"), Flag.withDefault(7433), ), + service: Flag.Boolean("service").pipe( + Flag.withDescription( + "Run as the background server `codework tui` manages, logging to /log/server.log", + ), + Flag.withDefault(false), + ), }, examples: [ { command: "codework serve", description: "Start the RPC server on 127.0.0.1:7433" }, { command: "codework serve --port 0", description: "Choose an available local port" }, ], }), + Spec.make("service", { + description: "Manage the background server", + commands: [ + Spec.make("start", { description: "Start the background server", params: ServerAddress }), + Spec.make("restart", { description: "Restart the background server", params: ServerAddress }), + Spec.make("status", { description: "Show background server status" }), + Spec.make("stop", { description: "Stop the background server" }), + ], + }), Spec.make("session", { description: "Manage sessions", commands: [ diff --git a/packages/codework/src/cli/cmd/handlers/serve.ts b/packages/codework/src/cli/cmd/handlers/serve.ts index 19423fad..abca6911 100644 --- a/packages/codework/src/cli/cmd/handlers/serve.ts +++ b/packages/codework/src/cli/cmd/handlers/serve.ts @@ -1,25 +1,39 @@ -import { Effect, Layer } from "effect"; +import { Global } from "@codeworksh/harness/effect"; +import { Effect, FileSystem, Layer, Logger } from "effect"; import { Runtime } from "../../../framework/runtime.ts"; +import { Registration } from "../../../server/registration.ts"; import { Server } from "../../../server/server.ts"; import { renderError } from "../../error.ts"; import { harnessOptions } from "../../harness.ts"; import { writeError } from "../../output.ts"; import { Cmd } from "../cmd.ts"; +const failed = Effect.sync(() => { + process.exitCode = 1; +}); + +const report = (error: unknown) => writeError(renderError(error)).pipe(Effect.andThen(failed)); + export default Runtime.handler( Cmd.commands.serve, - Effect.fn("CLI.serve")(function* ({ host, port }) { + Effect.fn("CLI.serve")(function* ({ host, port, service }) { const shared = yield* Cmd.spec; - yield* Layer.launch(Server.layer({ host, port, harness: { ...harnessOptions(shared), watchModels: true } })).pipe( - Effect.catch((error) => - writeError(renderError(error)).pipe( - Effect.andThen( - Effect.sync(() => { - process.exitCode = 1; - }), - ), - ), - ), - ); + const harness = { ...harnessOptions(shared), watchModels: true }; + const serve = Layer.launch(Server.layer({ host, port, harness })); + if (!service) return yield* serve.pipe(Effect.catch(report)); + + // A background server has no terminal: its log, and the reason it stopped, go to a + // file the CLI that started it reads back when startup fails. + const background = Effect.gen(function* () { + const paths = yield* Global.resolve(harness.home === undefined ? {} : { home: harness.home }); + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(paths.log, { recursive: true }); + const logger = Logger.formatSimple.pipe(Logger.toFile(Registration.log(paths.log), { flag: "w" })); + yield* serve.pipe( + Effect.catch((error) => Effect.logError(renderError(error).trim()).pipe(Effect.andThen(failed))), + Effect.provide(Logger.layer([logger])), + ); + }); + yield* background.pipe(Effect.catch(report)); }), ); diff --git a/packages/codework/src/cli/cmd/handlers/service/restart.ts b/packages/codework/src/cli/cmd/handlers/service/restart.ts new file mode 100644 index 00000000..4ca83c77 --- /dev/null +++ b/packages/codework/src/cli/cmd/handlers/service/restart.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect"; +import { Runtime } from "../../../../framework/runtime.ts"; +import { Discovery } from "../../../../server/discovery.ts"; +import { reportFailure } from "../../../error.ts"; +import { writeOut } from "../../../output.ts"; +import { Service } from "../../../service.ts"; +import { Cmd } from "../../cmd.ts"; + +export default Runtime.handler( + Cmd.commands.service.commands.restart, + Effect.fn("CLI.service.restart")(function* ({ host, port }) { + const options = yield* Service.options(yield* Cmd.spec, { host, port }); + yield* Discovery.stop(options.home); + const server = yield* Discovery.ensure({ ...options, replace: true }); + yield* writeOut(`${server.url}\n`); + }, Effect.catch(reportFailure)), +); diff --git a/packages/codework/src/cli/cmd/handlers/service/start.ts b/packages/codework/src/cli/cmd/handlers/service/start.ts new file mode 100644 index 00000000..6d32dfb1 --- /dev/null +++ b/packages/codework/src/cli/cmd/handlers/service/start.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect"; +import { Runtime } from "../../../../framework/runtime.ts"; +import { Discovery } from "../../../../server/discovery.ts"; +import { reportFailure } from "../../../error.ts"; +import { writeOut } from "../../../output.ts"; +import { Service } from "../../../service.ts"; +import { Cmd } from "../../cmd.ts"; + +export default Runtime.handler( + Cmd.commands.service.commands.start, + Effect.fn("CLI.service.start")(function* ({ host, port }) { + const options = yield* Service.options(yield* Cmd.spec, { host, port }); + const server = yield* Discovery.ensure({ ...options, replace: true }); + yield* writeOut(`${server.url}\n`); + }, Effect.catch(reportFailure)), +); diff --git a/packages/codework/src/cli/cmd/handlers/service/status.ts b/packages/codework/src/cli/cmd/handlers/service/status.ts new file mode 100644 index 00000000..c70e77fd --- /dev/null +++ b/packages/codework/src/cli/cmd/handlers/service/status.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect"; +import { Runtime } from "../../../../framework/runtime.ts"; +import { Discovery } from "../../../../server/discovery.ts"; +import { reportFailure } from "../../../error.ts"; +import { writeOut } from "../../../output.ts"; +import { Service } from "../../../service.ts"; +import { Cmd } from "../../cmd.ts"; + +export default Runtime.handler( + Cmd.commands.service.commands.status, + Effect.fn("CLI.service.status")(function* () { + const paths = yield* Service.home(yield* Cmd.spec); + const server = yield* Discovery.discover(paths.home); + yield* writeOut(`${server?.url ?? "stopped"}\n`); + }, Effect.catch(reportFailure)), +); diff --git a/packages/codework/src/cli/cmd/handlers/service/stop.ts b/packages/codework/src/cli/cmd/handlers/service/stop.ts new file mode 100644 index 00000000..0f4bbc2c --- /dev/null +++ b/packages/codework/src/cli/cmd/handlers/service/stop.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect"; +import { Runtime } from "../../../../framework/runtime.ts"; +import { Discovery } from "../../../../server/discovery.ts"; +import { reportFailure } from "../../../error.ts"; +import { Service } from "../../../service.ts"; +import { Cmd } from "../../cmd.ts"; + +export default Runtime.handler( + Cmd.commands.service.commands.stop, + Effect.fn("CLI.service.stop")(function* () { + const paths = yield* Service.home(yield* Cmd.spec); + yield* Discovery.stop(paths.home); + }, Effect.catch(reportFailure)), +); diff --git a/packages/codework/src/cli/cmd/handlers/tui.ts b/packages/codework/src/cli/cmd/handlers/tui.ts index 5530238d..fbce2615 100644 --- a/packages/codework/src/cli/cmd/handlers/tui.ts +++ b/packages/codework/src/cli/cmd/handlers/tui.ts @@ -1,23 +1,75 @@ import { Effect, Option } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { Runtime } from "../../../framework/runtime.ts"; import { Discovery } from "../../../server/discovery.ts"; import { Health } from "../../../server/health.ts"; +import { Identity } from "../../../server/identity.ts"; import { InvalidInputError, renderError } from "../../error.ts"; import { writeError } from "../../output.ts"; +import { Service } from "../../service.ts"; import { Cmd } from "../cmd.ts"; -interface Shared { - readonly home: Option.Option; - readonly database: Option.Option; - readonly userConfigDir: Option.Option; -} +/** + * Adopt a server the user started, only if it answers `server.info`: the UI must + * never launch against an endpoint that is not a CodeWork server. A different + * build is allowed, with a warning, because the user chose it. + */ +const attach = (url: string) => + Effect.gen(function* () { + const info = yield* Health.probe(url); + if (info === undefined) return yield* new InvalidInputError({ message: `no CodeWork server answered at ${url}` }); + if (info.version !== Identity.current.version) { + yield* Effect.sync(() => + process.stderr.write( + `warning: server at ${url} runs ${info.version}; this client is ${Identity.current.version}\n`, + ), + ); + } + return url; + }); -/** The server-owned flags a managed server needs, in the order `serve` parses them. */ -const serverFlags = (shared: Shared): ReadonlyArray => [ - ...(Option.isSome(shared.home) ? ["--home", shared.home.value] : []), - ...(Option.isSome(shared.database) ? ["--database", shared.database.value] : []), - ...(Option.isSome(shared.userConfigDir) ? ["--user-config-dir", shared.userConfigDir.value] : []), -]; +/** Start or reuse the local server, and keep it up while the TUI runs. */ +const managed = (options: Discovery.Options) => + Effect.gen(function* () { + const resolved = yield* Discovery.ensure({ + ...options, + // An upgraded CLI must not drive a server from an older build. + replace: true, + onStart: (reason) => + void process.stderr.write( + reason === "version-mismatch" + ? "Restarting CodeWork server (version mismatch)...\n" + : `Starting CodeWork server on ${options.host}:${options.port}...\n`, + ), + }); + // The TUI only reconnects; restoring a server that dies under it is ours to do. + yield* Discovery.keepAlive(resolved, options).pipe(Effect.forkScoped); + return resolved.url; + }); + +/** + * Run the TUI in the foreground, on this terminal, for as long as this command + * runs. It stays in this process group (`detached: false`) so it keeps the + * terminal; stdio is inherited because it draws and reads raw keys itself. The + * scope is what ties their lifetimes: when this command is interrupted (SIGINT, + * SIGTERM), closing it terminates the TUI rather than leaving it on the screen. + */ +const run = (binary: string, args: ReadonlyArray, env: Record) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const tui = yield* spawner.spawn( + ChildProcess.make(binary, [...args], { + env, + extendEnv: true, + detached: false, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }), + ); + // A TUI killed by a signal has no exit code; it still must not read as success. + return yield* tui.exitCode.pipe(Effect.orElseSucceed(() => 1)); + }).pipe(Effect.scoped); export default Runtime.handler( Cmd.commands.tui, @@ -31,39 +83,27 @@ export default Runtime.handler( }); } - const endpoint = yield* Option.isSome(server) - ? // A supplied server is adopted only if it answers `server.info`; the UI must - // never launch against an endpoint that is not actually a CodeWork server. - Health.probe(server.value).pipe( - Effect.flatMap((info) => - info === undefined - ? Effect.fail( - new InvalidInputError({ message: `no CodeWork server answered at ${server.value}` }), - ) - : Effect.succeed(server.value), - ), - ) - : Discovery.ensure({ - home: Discovery.home(Option.getOrUndefined(shared.home)), - host, - port, - args: serverFlags(shared), - env: process.env, - onStart: () => void process.stderr.write(`Starting CodeWork server on ${host}:${port}...\n`), - }).pipe(Effect.map((resolved) => resolved.url)); - - // The TUI reads its own config and credentials, so it must resolve the same home - // the managed server used; otherwise the UI would show a different installation. - const env: NodeJS.ProcessEnv = { ...process.env }; - if (Option.isSome(shared.home)) env["CODEWORK_HOME_DIR"] = shared.home.value; + const options = yield* Service.options(shared, { host, port }); + const url = yield* Option.isSome(server) ? attach(server.value) : managed(options); - // Imported lazily: the launcher must stay off the path of every other command. + // Imported lazily: binary resolution must stay off the path of every other command. const launcher = yield* Effect.promise(() => import("@codeworksh/tui")); - const forwarded: ReadonlyArray = args; - return yield* Effect.promise(() => launcher.launch({ server: endpoint, args: forwarded, env })); + const binary = yield* Effect.try({ + try: () => launcher.resolveBinary(), + catch: (error) => new InvalidInputError({ message: (error as Error).message }), + }); + return yield* run(binary, args, { + [launcher.ENV_SERVER]: url, + // The TUI reads its own config and credentials, so it must resolve the same home + // the server uses; otherwise the UI would show a different installation. + CODEWORK_HOME_DIR: options.home, + }); }); - const code = yield* program.pipe(Effect.catch((error) => writeError(renderError(error)).pipe(Effect.as(1)))); + const code = yield* program.pipe( + Effect.scoped, + Effect.catch((error) => writeError(renderError(error)).pipe(Effect.as(1))), + ); yield* Effect.sync(() => { process.exitCode = code; }); diff --git a/packages/codework/src/cli/error.ts b/packages/codework/src/cli/error.ts index ac4e9c6e..0f889e16 100644 --- a/packages/codework/src/cli/error.ts +++ b/packages/codework/src/cli/error.ts @@ -88,7 +88,11 @@ const providerHint = (error: Runner.ProviderError): string | undefined => { const unknownMessage = (error: unknown): string => { if (error instanceof Error && error.message.trim().length > 0) return error.message; if (typeof error === "string" && error.trim().length > 0) return error; - if (typeof error === "object" && error !== null && "_tag" in error) return String(error._tag); + if (typeof error === "object" && error !== null && "_tag" in error) { + // A bare tag such as `ServeError` hides the reason; its cause carries it (`EADDRINUSE`). + const cause = "cause" in error ? error.cause : undefined; + return cause instanceof Error ? `${String(error._tag)}: ${cause.message}` : String(error._tag); + } return "the command failed for an unknown reason"; }; diff --git a/packages/codework/src/cli/service.ts b/packages/codework/src/cli/service.ts new file mode 100644 index 00000000..02ae74b1 --- /dev/null +++ b/packages/codework/src/cli/service.ts @@ -0,0 +1,33 @@ +import { Global } from "@codeworksh/harness/effect"; +import { Effect, Option } from "effect"; +import type { Discovery } from "../server/discovery.ts"; + +interface Shared { + readonly userConfigDir: Option.Option; + readonly home: Option.Option; + readonly database: Option.Option; +} + +/** The CodeWork home the shared flags name, resolved the way the harness resolves it. */ +export const home = (shared: Shared) => Global.resolve(Option.isSome(shared.home) ? { home: shared.home.value } : {}); + +/** + * How `tui` and `service` find or start the background server: the home whose + * `service.json` names it, and the server-owned flags it is started with, in the + * order `serve` parses them. + */ +export const options = (shared: Shared, server: { readonly host: string; readonly port: number }) => + home(shared).pipe( + Effect.map((paths): Discovery.Options => ({ + home: paths.home, + log: paths.log, + ...server, + args: [ + ...(Option.isSome(shared.home) ? ["--home", shared.home.value] : []), + ...(Option.isSome(shared.database) ? ["--database", shared.database.value] : []), + ...(Option.isSome(shared.userConfigDir) ? ["--user-config-dir", shared.userConfigDir.value] : []), + ], + })), + ); + +export * as Service from "./service.ts"; diff --git a/packages/codework/src/index.ts b/packages/codework/src/index.ts index b1811ec4..00ecfae5 100644 --- a/packages/codework/src/index.ts +++ b/packages/codework/src/index.ts @@ -19,6 +19,12 @@ const Handlers = Runtime.handlers(Cmd, { run: () => import("./cli/cmd/handlers/run.ts"), tui: () => import("./cli/cmd/handlers/tui.ts"), serve: () => import("./cli/cmd/handlers/serve.ts"), + service: { + start: () => import("./cli/cmd/handlers/service/start.ts"), + restart: () => import("./cli/cmd/handlers/service/restart.ts"), + status: () => import("./cli/cmd/handlers/service/status.ts"), + stop: () => import("./cli/cmd/handlers/service/stop.ts"), + }, session: { link: () => import("./cli/cmd/handlers/session/link.ts"), }, diff --git a/packages/codework/src/server/contract.ts b/packages/codework/src/server/contract.ts index 6698ab73..4517e3d9 100644 --- a/packages/codework/src/server/contract.ts +++ b/packages/codework/src/server/contract.ts @@ -14,6 +14,9 @@ import { Schema } from "effect"; import { Rpc, RpcGroup } from "effect/rpc"; import { EncodingError, EventEnvelope } from "./envelope.ts"; +/** The HTTP path the RPC endpoint is served on; the version moves with breaking wire changes. */ +export const path = "/rpc/v1"; + export const SandboxInfo = Schema.Struct({ id: Sandbox.SandboxInstance.ID, driver: Schema.String, diff --git a/packages/codework/src/server/discovery.ts b/packages/codework/src/server/discovery.ts index 301fa833..c3c6459e 100644 --- a/packages/codework/src/server/discovery.ts +++ b/packages/codework/src/server/discovery.ts @@ -1,28 +1,14 @@ -/* @effect-diagnostics nodeBuiltinImport:off -- a detached managed server is started through node. */ -import { Clock, Effect, FileSystem, Schema } from "effect"; -import { spawn } from "node:child_process"; -import { homedir } from "node:os"; -import { isAbsolute, join, resolve } from "node:path"; +import { type Duration, Effect, FileSystem, Option, Ref, Schedule, Schema } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { Health } from "./health.ts"; +import { Identity } from "./identity.ts"; import { Registration } from "./registration.ts"; -/** - * The CodeWork home a managed server and its registration share. - * - * `--home` wins, then `CODEWORK_HOME_DIR` (the harness default), then - * `~/.codework`. `~` expands because the CLI accepts it and the harness does - * too; keeping the two spellings identical is what lets discovery find the file - * the server wrote. - */ -export const home = (explicit?: string): string => { - const value = explicit ?? process.env["CODEWORK_HOME_DIR"] ?? join(homedir(), ".codework"); - const expanded = value === "~" ? homedir() : value.startsWith("~/") ? join(homedir(), value.slice(2)) : value; - return isAbsolute(expanded) ? expanded : resolve(expanded); -}; - -/** What a caller gets back: the endpoint to connect a TUI or `run` to. */ +/** A server a client can connect to, and the process and port behind it. */ export interface Endpoint { readonly url: string; + readonly pid: number; + readonly port: number; } /** A managed server could not be produced, in the words the CLI prints. */ @@ -30,97 +16,233 @@ export class ServerStartError extends Schema.TaggedError()("Di message: Schema.String, }) {} +export interface Options { + /** The CodeWork home whose `service.json` names the server. */ + readonly home: string; + /** The home's log directory, where a background server writes `server.log`. */ + readonly log: string; + readonly host: string; + readonly port: number; + /** Server-owned flags placed before `serve`. */ + readonly args: ReadonlyArray; +} + +const START_TIMEOUT = "20 seconds"; +const RACE_GRACE = "2 seconds"; +const STOP_TIMEOUT = "5 seconds"; + const decode = Schema.decodeUnknownEffect(Registration.infoJson); const read = (path: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const text = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => undefined)); - if (text === undefined) return undefined; - return yield* decode(text).pipe(Effect.orElseSucceed(() => undefined)); + const text = yield* fs.readFileString(path).pipe(Effect.option); + if (Option.isNone(text)) return undefined; + return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined)); }); +const remove = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path); + }).pipe(Effect.ignore); + +/** Whether `pid` is a running process. EPERM means it exists but belongs to someone else. */ +const alive = (pid: number) => + Effect.try({ try: () => process.kill(pid, 0), catch: (error) => (error as NodeJS.ErrnoException).code }).pipe( + Effect.as(true), + Effect.catch((code) => Effect.succeed(code === "EPERM")), + ); + +/** Whether `pid` exits within `timeout`. */ +const exits = (pid: number, timeout: typeof STOP_TIMEOUT) => + alive(pid).pipe( + Effect.repeat({ while: (running) => running, schedule: Schedule.spaced("50 millis") }), + Effect.timeoutOption(timeout), + Effect.map(Option.isSome), + ); + /** - * Reuse a registered server if it answers; otherwise retire the stale file. - * - * The registration is a hint, not proof: only a live `server.info` answer with - * matching pid and version is trusted, and a file nothing answers for is removed - * so it cannot mislead the next caller. + * The registered server, with `live` set when the process answering its endpoint + * is the one that registered. The file is a hint, never proof: a different server + * on a reused port must not pass for it. */ -const reuse = (path: string) => +const registered = (path: string) => Effect.gen(function* () { const info = yield* read(path); if (info === undefined) return undefined; - if (yield* Health.matches(info)) return { url: info.url } satisfies Endpoint; - yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.remove(path); - }).pipe(Effect.ignore); - return undefined; + const live = yield* Health.probe(info.url); + return { info, live: live?.pid === info.pid ? live : undefined }; + }); + +const endpoint = (info: Registration.Info): Endpoint => ({ url: info.url, pid: info.pid, port: info.port }); + +/** The running server registered for `home`, with the build it reports. */ +export const discover = (home: string) => + registered(Registration.file(home)).pipe( + Effect.map((found) => + found?.live === undefined ? undefined : { ...endpoint(found.info), version: found.live.version }, + ), + ); + +/** + * Stop the server registered for `home`, returning it, or undefined when none + * answers. Only a server that confirms it is the registrant is signalled: a pid + * on file may since have been reused by an unrelated process. + */ +export const stop = (home: string) => + Effect.gen(function* () { + const path = Registration.file(home); + const found = yield* registered(path); + if (found === undefined) return undefined; + if (found.live === undefined) { + if (!(yield* alive(found.info.pid))) yield* remove(path); + return undefined; + } + yield* terminate(path, found.info.pid); + return endpoint(found.info); }); /** - * Ensure a healthy local server for `home`, reusing the registered one when it - * answers and starting a managed server when it does not. + * Ensure a healthy local server for `home`: reuse the registered one, replace it + * when `replace` is set and it runs a different build, or start a background one. * - * The managed server is a detached process that outlives this caller, so the - * next invocation reuses it rather than starting another. When two callers race, - * both may start a contender, but only the one that binds the port can register; - * the loser exits and both callers adopt the winner, because discovery only ever - * believes a registration it can confirm over the wire. + * The background server is detached and outlives this caller, so the next + * invocation reuses it. When two callers race, only the contender that binds the + * port registers; the loser exits and both adopt the winner, because a caller + * only ever believes a registration it can confirm over the wire. */ -export const ensure = (options: { - readonly home: string; - readonly host: string; - readonly port: number; - readonly args: ReadonlyArray; - readonly env: NodeJS.ProcessEnv; - readonly onStart?: () => void; -}) => +export const ensure = ( + options: Options & { + readonly replace: boolean; + readonly onStart?: (reason: "missing" | "version-mismatch") => void; + }, +) => Effect.gen(function* () { const path = Registration.file(options.home); - const existing = yield* reuse(path); - if (existing !== undefined) return existing; + const found = yield* registered(path); + if (found?.live !== undefined) { + if (!options.replace || found.live.version === Identity.current.version) return endpoint(found.info); + yield* Effect.sync(() => options.onStart?.("version-mismatch")); + yield* terminate(path, found.info.pid); + } else { + // A file nothing answers for, left by a crash, must not mislead the next caller. + if (found !== undefined && !(yield* alive(found.info.pid))) yield* remove(path); + yield* Effect.sync(() => options.onStart?.("missing")); + } + return yield* start(path, options); + }); - yield* Effect.sync(() => options.onStart?.()); - yield* startDetached(options); +/** The registered server once it answers, polled every 200ms for up to `timeout`. */ +const ready = (path: string, timeout: Duration.Input, pending: Effect.Effect) => + Effect.gen(function* () { + const next = yield* registered(path); + if (next?.live !== undefined) return Option.some(endpoint(next.info)); + // A live registrant that does not answer yet is another caller's server starting up. + const waiting = (yield* pending) || (next !== undefined && (yield* alive(next.info.pid))); + return waiting ? Option.none() : yield* Effect.fail("exited" as const); + }).pipe( + Effect.repeat({ until: Option.isSome, schedule: Schedule.spaced("200 millis") }), + Effect.timeoutOption(timeout), + Effect.map(Option.flatten), + Effect.orElseSucceed(() => Option.none()), + ); - const deadline = (yield* Clock.currentTimeMillis) + START_TIMEOUT_MS; - while ((yield* Clock.currentTimeMillis) < deadline) { - yield* Effect.sleep("250 millis"); - const info = yield* read(path); - if (info !== undefined && (yield* Health.matches(info))) return { url: info.url } satisfies Endpoint; +/** + * Start a background server and wait for it to register, quoting the reason + * from its log when it cannot. + * + * The contender stays tied to this scope until it is the server adopted: a + * timeout, an interrupt, or another caller winning the race stops it rather than + * leaving an unmanaged server behind. + */ +const start = (path: string, options: Options) => + Effect.gen(function* () { + const contender = yield* spawn(options); + const won = yield* ready(path, START_TIMEOUT, contender.isRunning.pipe(Effect.orElseSucceed(() => false))); + // A contender that lost the bind race exits before the winner may have filed its + // registration; give the winner a moment before calling the start a failure. + const server = Option.isSome(won) ? won : yield* ready(path, RACE_GRACE, Effect.succeed(true)); + if (Option.isSome(server)) { + if (server.value.pid === contender.pid) yield* contender.unref.pipe(Effect.asVoid); + return server.value; } + const log = Registration.log(options.log); + const reason = yield* lastLine(log); return yield* new ServerStartError({ - message: - `could not start a CodeWork server on ${options.host}:${options.port}. ` + - "Run `codework serve` to see why, or point `codework tui --server` at a running server.", + message: [ + `could not start a CodeWork server on ${options.host}:${options.port}`, + ...(reason === undefined ? [] : [`reason: ${reason}`]), + `log: ${log}`, + "hint: pass --port to use another port, or point `codework tui --server` at a running server", + ].join("\n"), }); + }).pipe(Effect.scoped); + +/** + * Keep the session's server running for as long as the caller lives. + * + * The TUI only reconnects; it cannot start a server. If the one it uses goes + * away, this brings one back on the same port so the reconnect lands. It never + * replaces a running server: another invocation may have just upgraded it. + */ +export const keepAlive = (initial: Endpoint, options: Options) => + Effect.gen(function* () { + const current = yield* Ref.make(initial); + yield* Effect.gen(function* () { + const server = yield* Ref.get(current); + if (yield* alive(server.pid)) return; + const next = yield* ensure({ ...options, port: server.port, replace: false }).pipe( + Effect.orElseSucceed(() => server), + ); + yield* Ref.set(current, next); + }).pipe(Effect.repeat(Schedule.spaced("1 second"))); + }); + +/** Ask `pid` to shut down, escalating to SIGKILL, and retire its registration. */ +const terminate = (path: string, pid: number) => + Effect.gen(function* () { + const signal = (name: NodeJS.Signals) => Effect.try(() => process.kill(pid, name)).pipe(Effect.ignore); + yield* signal("SIGTERM"); + if (!(yield* exits(pid, STOP_TIMEOUT))) { + yield* signal("SIGKILL"); + yield* exits(pid, STOP_TIMEOUT); + } + // A server removes its own file on a clean exit; this covers the SIGKILL path. + const info = yield* read(path); + if (info?.pid === pid) yield* remove(path); }); -const START_TIMEOUT_MS = 20_000; +const lastLine = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const text = yield* fs.readFileString(path).pipe(Effect.orElseSucceed(() => "")); + return text + .split("\n") + .map((line) => line.trim()) + .findLast((line) => line.length > 0); + }); /** - * Spawns `codework serve` detached from this process, inheriting the caller's - * environment so the child resolves the same home, catalog and credentials. - * `unref` lets the caller exit without waiting on or signalling the server. + * Spawn `codework serve --service` detached, inheriting this environment so it + * resolves the same home, catalog and credentials. It writes its own log, so no + * stdio is tied to this short-lived caller. It stays referenced, and so stopped + * with the scope, until `start` adopts it. */ -const startDetached = (options: { - readonly host: string; - readonly port: number; - readonly args: ReadonlyArray; - readonly env: NodeJS.ProcessEnv; -}) => - Effect.sync(() => { +const spawn = (options: Options) => + Effect.gen(function* () { const [file, ...base] = Registration.command(); - if (file === undefined) throw new Error("could not resolve the codework entrypoint"); - const child = spawn( - file, - [...base, ...options.args, "serve", "--host", options.host, "--port", String(options.port)], - { detached: true, stdio: "ignore", env: options.env }, + if (file === undefined) return yield* Effect.die(new Error("could not resolve the codework entrypoint")); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make( + file, + [...base, ...options.args, "serve", "--service", "--host", options.host, "--port", String(options.port)], + { detached: true, stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ), ); - child.unref(); + return handle; }); export * as Discovery from "./discovery.ts"; diff --git a/packages/codework/src/server/health.ts b/packages/codework/src/server/health.ts index 24b24c72..3aca9f9f 100644 --- a/packages/codework/src/server/health.ts +++ b/packages/codework/src/server/health.ts @@ -14,18 +14,10 @@ export const probe = (url: string) => return yield* Effect.gen(function* () { const rpc = yield* Client.make; return yield* rpc["server.info"]({}).pipe(Effect.timeout("1 second")); - }).pipe(Effect.provideContext(context), Effect.orElseSucceed(() => undefined)); + }).pipe( + Effect.provideContext(context), + Effect.orElseSucceed(() => undefined), + ); }).pipe(Effect.scoped); -/** - * Whether the process behind `url` is the exact one the registration named. - * - * The pid is the point: a different server on a reused port, or an older build, - * must not pass as the one that registered, or discovery would adopt a stranger. - */ -export const matches = (info: { readonly url: string; readonly pid: number; readonly version: string }) => - probe(info.url).pipe( - Effect.map((live) => live !== undefined && live.pid === info.pid && live.version === info.version), - ); - export * as Health from "./health.ts"; diff --git a/packages/codework/src/server/identity.ts b/packages/codework/src/server/identity.ts index 1ea06d23..0553af99 100644 --- a/packages/codework/src/server/identity.ts +++ b/packages/codework/src/server/identity.ts @@ -15,6 +15,9 @@ export interface Interface { export class Service extends Context.Service()("@codeworksh/cli/server/identity/Service") {} -export const layer = Layer.succeed(Service, Service.of({ pid: process.pid, version: pkg.version })); +/** This process: a server answers with it, a client compares a server's version against it. */ +export const current: Interface = { pid: process.pid, version: pkg.version }; + +export const layer = Layer.succeed(Service, Service.of(current)); export * as Identity from "./identity.ts"; diff --git a/packages/codework/src/server/registration.ts b/packages/codework/src/server/registration.ts index 779ab079..4a7c270d 100644 --- a/packages/codework/src/server/registration.ts +++ b/packages/codework/src/server/registration.ts @@ -1,7 +1,4 @@ -/* @effect-diagnostics nodeBuiltinImport:off -- the registration is a file at a fixed host path. */ -import { dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { Effect, FileSystem, Layer, Schema } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Schedule, Schema } from "effect"; /** * How a running server advertises itself so later invocations can find it @@ -17,7 +14,7 @@ export const Info = Schema.Struct({ pid: Schema.Int, /** The build serving this endpoint; a mismatch means a different binary. */ version: Schema.String, - /** The `ws://host:port/rpc` endpoint a client connects to. */ + /** The `ws://host:port/rpc/v1` endpoint a client connects to. */ url: Schema.String, /** The TCP port the server bound, for a caller that would rather reuse it than default. */ port: Schema.Int, @@ -29,11 +26,15 @@ export const infoJson = Schema.fromJsonString(Info); /** The registration file for a CodeWork home: `/service.json`. */ export const file = (home: string) => `${home}/service.json`; +/** Where a background (`serve --service`) server writes its log: `/server.log`, truncated per start. */ +export const log = (logDir: string) => `${logDir}/server.log`; + const decode = Schema.decodeUnknownEffect(infoJson); export const write = Effect.fn("ServerRegistration.write")(function* (path: string, info: Info) { const fs = yield* FileSystem.FileSystem; - yield* fs.makeDirectory(dirname(path), { recursive: true }); + const paths = yield* Path.Path; + yield* fs.makeDirectory(paths.dirname(path), { recursive: true }); const encoded = yield* Schema.encodeEffect(infoJson)(info); // Write beside the target and rename, so a reader never sees a half-written // file. 0600 because a future registration may carry a credential. @@ -46,26 +47,40 @@ export const write = Effect.fn("ServerRegistration.write")(function* (path: stri * Publishes a registration for as long as this server lives, removing it on the * way out if it is still the one on file. A file left by a crash is overwritten * by the next server to start, and retired by a probe that cannot confirm it. + * + * One home has one discoverable server. Every few seconds the file is checked: + * a missing or unreadable file is written again, and a file naming another + * server means this one lost discovery for good, so it shuts down rather than + * run on where nothing can find or stop it. */ export const register = (options: { readonly home: string; readonly info: Info }) => Layer.effectDiscard( Effect.gen(function* () { const path = file(options.home); - yield* write(path, options.info); const fs = yield* FileSystem.FileSystem; + const current = fs.readFileString(path).pipe(Effect.flatMap(decode), Effect.option); + const owns = (found: Info) => found.pid === options.info.pid && found.url === options.info.url; + + yield* write(path, options.info); yield* Effect.addFinalizer(() => - // Remove the file only while it is still ours; a newer server that took - // over must keep the registration that names it. - fs.readFileString(path).pipe( - Effect.flatMap(decode), - Effect.flatMap((found) => - found.pid === options.info.pid && found.url === options.info.url - ? fs.remove(path) - : Effect.void, - ), + current.pipe( + Effect.flatMap((found) => (Option.isSome(found) && owns(found.value) ? fs.remove(path) : Effect.void)), Effect.ignore, ), ); + // Forked after the finalizer, so shutdown stops the watch before the file is removed. + yield* current.pipe( + Effect.flatMap((found) => + Option.isNone(found) + ? write(path, options.info).pipe(Effect.ignore, Effect.as(true)) + : Effect.succeed(owns(found.value)), + ), + Effect.repeat({ while: (ours) => ours, schedule: Schedule.spaced("5 seconds") }), + Effect.andThen(Effect.logWarning(`${path} names another server; shutting down`)), + // The same graceful path `codework service stop` takes. + Effect.andThen(Effect.sync(() => process.kill(process.pid, "SIGTERM"))), + Effect.forkScoped, + ); }), ); @@ -79,7 +94,7 @@ export const register = (options: { readonly home: string; readonly info: Info } export const command = (): ReadonlyArray => { const entry = process.argv[1]; if (entry === undefined) return [process.execPath]; - const runtime = fileURLToPath(import.meta.url).endsWith(".ts") ? process.execArgv : []; + const runtime = import.meta.url.endsWith(".ts") ? process.execArgv : []; return [process.execPath, ...runtime, entry]; }; diff --git a/packages/codework/src/server/server.ts b/packages/codework/src/server/server.ts index 51ebca50..34da205a 100644 --- a/packages/codework/src/server/server.ts +++ b/packages/codework/src/server/server.ts @@ -62,7 +62,7 @@ const announce = Layer.unwrap( const identity = yield* Identity.Service; // A ws server always binds TCP; the guard keeps the logger honest if that ever changes. const address = server.address; - const url = `${NetAddress.formatUrlUnsafe(address, "ws")}/rpc`; + const url = `${NetAddress.formatUrlUnsafe(address, "ws")}${Contract.path}`; yield* Effect.log(`codework serve listening on ${url}`); if (!NetAddress.isInetAddress(address)) return Layer.empty; return Registration.register({ @@ -73,7 +73,7 @@ const announce = Layer.unwrap( ); export const layer = (options: { host: string; port: number; harness: Harness.Options }) => { - const rpc = RpcServer.layerHttp({ group: Contract.Api, path: "/rpc", protocol: "websocket" }).pipe( + const rpc = RpcServer.layerHttp({ group: Contract.Api, path: Contract.path, protocol: "websocket" }).pipe( Layer.provide(Handlers.layer.pipe(Layer.provide(EventFeed.layer))), ); const app = HttpRouter.serve(rpc, { disableListenLog: true }).pipe( diff --git a/packages/codework/test/__artifacts__/service.lifecycle.json b/packages/codework/test/__artifacts__/service.lifecycle.json new file mode 100644 index 00000000..80951f9c --- /dev/null +++ b/packages/codework/test/__artifacts__/service.lifecycle.json @@ -0,0 +1,37 @@ +[ + { + "command": "status", + "stdout": "stopped\n", + "registered": false + }, + { + "command": "start --port 7607", + "stdout": "ws://127.0.0.1:7607/rpc/v1\n", + "registered": true + }, + { + "command": "status", + "stdout": "ws://127.0.0.1:7607/rpc/v1\n", + "registered": true + }, + { + "command": "start --port 7607", + "stdout": "ws://127.0.0.1:7607/rpc/v1\n", + "registered": true + }, + { + "command": "restart --port 7607", + "stdout": "ws://127.0.0.1:7607/rpc/v1\n", + "registered": true + }, + { + "command": "stop", + "stdout": "", + "registered": false + }, + { + "command": "status", + "stdout": "stopped\n", + "registered": false + } +] diff --git a/packages/codework/test/__artifacts__/tui.managed.json b/packages/codework/test/__artifacts__/tui.managed.json new file mode 100644 index 00000000..7d3020d6 --- /dev/null +++ b/packages/codework/test/__artifacts__/tui.managed.json @@ -0,0 +1,17 @@ +{ + "serviceJson": { + "pid": "", + "version": "0.1.0", + "url": "ws://127.0.0.1:7601/rpc/v1", + "port": 7601 + }, + "tui": { + "server": "ws://127.0.0.1:7601/rpc/v1", + "home": "", + "args": [ + "hello", + "world" + ], + "cwd": "" + } +} diff --git a/packages/codework/test/cli.test.ts b/packages/codework/test/cli.test.ts index 3867bc59..d35ba421 100644 --- a/packages/codework/test/cli.test.ts +++ b/packages/codework/test/cli.test.ts @@ -181,7 +181,7 @@ describe("codework CLI", () => { it("documents remote runs and rejects server-owned flags on the client", () => { expect(run("run", "--help").stdout).toContain("--server"); - const result = run("run", "--server", "ws://127.0.0.1:1/rpc", "--database", ":memory:", "hello"); + const result = run("run", "--server", "ws://127.0.0.1:1/rpc/v1", "--database", ":memory:", "hello"); expect(result.status).toBe(1); expect(result.stdout).toContain("belong on codework serve"); }); diff --git a/packages/codework/test/server.test.ts b/packages/codework/test/server.test.ts index 0ca365f3..bf262537 100644 --- a/packages/codework/test/server.test.ts +++ b/packages/codework/test/server.test.ts @@ -406,7 +406,7 @@ const websocket = (options: Harness.Options = {}) => { const connectedClient = Effect.gen(function* () { const server = yield* HttpServer.HttpServer; if (server.address._tag === "UnixPathAddress") return yield* Effect.die("Expected TCP listener"); - const context = yield* Layer.build(Client.layer(`ws://127.0.0.1:${server.address.port}/rpc`)); + const context = yield* Layer.build(Client.layer(`ws://127.0.0.1:${server.address.port}/rpc/v1`)); return yield* Client.make.pipe(Effect.provideContext(context)); }); @@ -513,7 +513,7 @@ describe("WebSocket client", () => { if (server.address._tag === "UnixPathAddress") return yield* Effect.die("Expected TCP listener"); const port = server.address.port; const result = yield* Effect.promise(() => - execCli(["auth", "status", "--openai-codex", "--server", `ws://127.0.0.1:${port}/rpc`], process.cwd()), + execCli(["auth", "status", "--openai-codex", "--server", `ws://127.0.0.1:${port}/rpc/v1`], process.cwd()), ); expect(result.status).toBe(1); @@ -526,7 +526,7 @@ describe("WebSocket client", () => { return Effect.gen(function* () { const server = yield* HttpServer.HttpServer; if (server.address._tag === "UnixPathAddress") return yield* Effect.die("Expected TCP listener"); - const url = `ws://127.0.0.1:${server.address.port}/rpc`; + const url = `ws://127.0.0.1:${server.address.port}/rpc/v1`; const rpc = yield* connectedClient; const session = yield* rpc["session.create"]({}); @@ -555,7 +555,7 @@ describe("WebSocket client", () => { return Effect.gen(function* () { const server = yield* HttpServer.HttpServer; if (server.address._tag === "UnixPathAddress") return yield* Effect.die("Expected TCP listener"); - const url = `ws://127.0.0.1:${server.address.port}/rpc`; + const url = `ws://127.0.0.1:${server.address.port}/rpc/v1`; const succeeded = yield* Effect.promise(() => execCli(["plugin", "reload", "--server", url], root)); expect(succeeded.status).toBe(0); @@ -691,7 +691,7 @@ describe("WebSocket client", () => { fileURLToPath(new URL("../src/index.ts", import.meta.url)), "run", "--server", - `ws://127.0.0.1:${port}/rpc`, + `ws://127.0.0.1:${port}/rpc/v1`, "--sandbox-id", sandbox.id, "--provider", diff --git a/packages/codework/test/tui.test.ts b/packages/codework/test/tui.test.ts index 8cba4cb5..92181b78 100644 --- a/packages/codework/test/tui.test.ts +++ b/packages/codework/test/tui.test.ts @@ -1,7 +1,7 @@ /* @effect-diagnostics nodeBuiltinImport:off -- this suite spawns the CLI and a fake TUI as child processes. */ -/* @effect-diagnostics globalTimers:off -- polling a spawned process from the test host is not Effect code. */ +/* @effect-diagnostics globalTimers:off globalDate:off -- polling a spawned process from the test host is not Effect code. */ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -33,8 +33,10 @@ const report = (dir: string) => " home: process.env.CODEWORK_HOME_DIR,", " args: process.argv.slice(2),", ` cwd: process.cwd(),`, - "}) + \"\\n\");", - "process.exit(Number(process.env.FAKE_TUI_EXIT ?? 0));", + '}) + "\\n");', + // Held open like a real session until the test signals it. + "if (process.env.FAKE_TUI_HOLD) setInterval(() => {}, 1000);", + "else process.exit(Number(process.env.FAKE_TUI_EXIT ?? 0));", ].join("\n"), ); @@ -48,7 +50,8 @@ const home = () => { }; afterEach(() => { - for (const server of servers.splice(0)) server.kill("SIGKILL"); + // SIGTERM, not SIGKILL: the CLI forwards it, so a held fake TUI exits with it. + for (const server of servers.splice(0)) server.kill("SIGTERM"); // A managed server is detached and outlives its CLI, so it must be stopped by // the pid in its registration or it would hold the port for the next case. for (const dir of homes.splice(0)) { @@ -91,8 +94,9 @@ const readReport = async (child: ChildProcess): Promise> let buffer = ""; child.stderr?.on("data", (chunk) => { buffer += String(chunk); - const line = buffer.split("\n")[0]; - if (line !== undefined && line.startsWith("{")) { + // The CLI may print a startup notice first; the report is the JSON line. + const line = buffer.split("\n").find((candidate) => candidate.startsWith("{") && candidate.endsWith("}")); + if (line !== undefined) { try { resolve(JSON.parse(line)); } catch (error) { @@ -106,6 +110,35 @@ const readReport = async (child: ChildProcess): Promise> }); }); +const registration = (dir: string) => + JSON.parse(readFileSync(join(dir, "service.json"), "utf8")) as { + url: string; + port: number; + pid: number; + version: string; + }; + +/** `codework service ...` against `dir`, asserting it succeeds quietly. */ +const service = (dir: string, ...args: ReadonlyArray) => { + const result = spawnSync( + process.execPath, + ["--conditions=development", cli, "--home", dir, "--database", ":memory:", "service", ...args], + { encoding: "utf8", env: { ...process.env, HOME: dir, CODEWORK_MODELS_FILE: models }, timeout: 30_000 }, + ); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + return result.stdout; +}; + +const dead = (pid: number) => { + try { + process.kill(pid, 0); + return false; + } catch { + return true; + } +}; + const exitCode = (child: ChildProcess) => new Promise((resolve) => child.once("exit", (code) => resolve(code ?? -1))); @@ -125,7 +158,7 @@ describe("codework tui", () => { it("rejects server-owned flags when --server names a running server", () => { const result = spawnSync( process.execPath, - ["--conditions=development", cli, "--home", home(), "tui", "--server", "ws://127.0.0.1:1/rpc"], + ["--conditions=development", cli, "--home", home(), "tui", "--server", "ws://127.0.0.1:1/rpc/v1"], { encoding: "utf8" }, ); @@ -136,31 +169,41 @@ describe("codework tui", () => { it("fails clearly when --server does not answer as a CodeWork server", () => { const result = spawnSync( process.execPath, - ["--conditions=development", cli, "tui", "--server", "ws://127.0.0.1:1/rpc"], + ["--conditions=development", cli, "tui", "--server", "ws://127.0.0.1:1/rpc/v1"], { encoding: "utf8" }, ); expect(result.status).toBe(1); - expect(result.stderr).toContain("no CodeWork server answered at ws://127.0.0.1:1/rpc"); + expect(result.stderr).toContain("no CodeWork server answered at ws://127.0.0.1:1/rpc/v1"); }); it("starts a managed server, registers it, and launches the TUI against its endpoint", async () => { const dir = home(); const binary = report(dir); const child = hold(dir, { CODEWORK_TUI_BIN: binary }, ["tui", "--port", "7601", "hello", "world"]); + const reported = readReport(child); - const endpoint = join(dir, "service.json"); - await waitFor(() => existsSync(endpoint)); - const registration = JSON.parse(readFileSync(endpoint, "utf8")) as { url: string; port: number; pid: number }; - expect(registration.port).toBe(7601); - expect(registration.url).toBe("ws://127.0.0.1:7601/rpc"); - expect(registration.pid).toBeGreaterThan(0); + await waitFor(() => existsSync(join(dir, "service.json"))); + const info = registration(dir); + expect(info.pid).toBeGreaterThan(0); - const message = await readReport(child); - expect(message.server).toBe("ws://127.0.0.1:7601/rpc"); + const message = await reported; + // What the server files and what the TUI is handed: the discovery contract. + await expect( + JSON.stringify( + { + serviceJson: { ...info, pid: "" }, + tui: { + ...message, + home: message.home === dir ? "" : message.home, + cwd: realpathSync(String(message.cwd)) === realpathSync(dir) ? "" : message.cwd, + }, + }, + null, + "\t", + ) + "\n", + ).toMatchFileSnapshot("./__artifacts__/tui.managed.json"); expect(message.home).toBe(dir); - // Positional arguments after the command reach the binary untouched. - expect(message.args).toEqual(["hello", "world"]); child.kill("SIGKILL"); }, 30_000); @@ -170,16 +213,20 @@ describe("codework tui", () => { const binary = report(dir); const first = hold(dir, { CODEWORK_TUI_BIN: binary }, ["tui", "--port", "7602"]); const firstMessage = await readReport(first); - expect(firstMessage.server).toBe("ws://127.0.0.1:7602/rpc"); + expect(firstMessage.server).toBe("ws://127.0.0.1:7602/rpc/v1"); first.kill("SIGKILL"); // Let the first CLI release the port before the second attaches. await new Promise((resolve) => setTimeout(resolve, 300)); + const started = registration(dir).pid; const second = spawnCli(dir, { CODEWORK_TUI_BIN: binary }, ["tui", "--port", "7602"]); + let stderr = ""; + second.stderr.on("data", (chunk) => (stderr += String(chunk))); const secondMessage = await readReport(second); - expect(secondMessage.server).toBe("ws://127.0.0.1:7602/rpc"); - // No "Starting CodeWork server" on the reuse path: the same registration was adopted. - expect(second.stderr.read()).toBeNull(); + expect(secondMessage.server).toBe("ws://127.0.0.1:7602/rpc/v1"); + // The same server was adopted: same pid, and no startup notice. + expect(registration(dir).pid).toBe(started); + expect(stderr).not.toContain("Starting CodeWork server"); }, 30_000); it("forwards the TUI's exit status as the command's own", async () => { @@ -207,19 +254,111 @@ describe("codework tui", () => { const binary = report(dir); // Occupy the port with something that is not a CodeWork server, so the managed // server's bind fails and the health probe never succeeds. - const blocker = spawn(process.execPath, ["-e", "require('node:net').createServer().listen(7605, '127.0.0.1')"], { - stdio: "ignore", - }); + const blocker = spawn( + process.execPath, + ["-e", "require('node:net').createServer().listen(7605, '127.0.0.1', () => console.log('listening'))"], + { stdio: ["ignore", "pipe", "inherit"] }, + ); servers.push(blocker); - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise((resolve) => blocker.stdout.once("data", resolve)); - const result = spawnSync(process.execPath, ["--conditions=development", cli, "--home", dir, "tui", "--port", "7605"], { - encoding: "utf8", - env: { ...process.env, HOME: dir, CODEWORK_MODELS_FILE: models, CODEWORK_TUI_BIN: binary }, - timeout: 30_000, - }); + const result = spawnSync( + process.execPath, + ["--conditions=development", cli, "--home", dir, "tui", "--port", "7605"], + { + encoding: "utf8", + env: { ...process.env, HOME: dir, CODEWORK_MODELS_FILE: models, CODEWORK_TUI_BIN: binary }, + timeout: 30_000, + }, + ); expect(result.status).toBe(1); - expect(result.stderr).toContain("could not start a CodeWork server"); + expect(result.stderr).toContain("could not start a CodeWork server on 127.0.0.1:7605"); + // The server's own reason comes back from its log, without waiting out the start timeout. + expect(result.stderr).toContain("EADDRINUSE"); + expect(result.stderr).toContain(`log: ${join(dir, "log", "server.log")}`); + }, 40_000); + + it("restarts the server on the same endpoint if it dies while the TUI runs", async () => { + const dir = home(); + const binary = report(dir); + const child = hold(dir, { CODEWORK_TUI_BIN: binary, FAKE_TUI_HOLD: "1" }, ["tui", "--port", "7606"]); + await readReport(child); + const first = registration(dir); + + process.kill(first.pid, "SIGKILL"); + await waitFor(() => { + try { + return registration(dir).pid !== first.pid; + } catch { + return false; + } + }); + + const second = registration(dir); + expect(second.url).toBe(first.url); + // `status` only names a server that answers as the registrant: the replacement serves. + expect(service(dir, "status")).toBe(`${first.url}\n`); + expect(child.exitCode).toBeNull(); }, 40_000); }); + +describe("codework service", () => { + it("adopts one server when two callers start it at once", async () => { + const dir = home(); + const start = () => + new Promise((resolve) => { + const child = spawnCli(dir, {}, ["service", "start", "--port", "7608"]); + let stdout = ""; + child.stdout.on("data", (chunk) => (stdout += String(chunk))); + child.once("exit", () => resolve(stdout)); + }); + + const [a, b] = await Promise.all([start(), start()]); + expect(a).toBe("ws://127.0.0.1:7608/rpc/v1\n"); + expect(b).toBe(a); + expect(service(dir, "status")).toBe(a); + }, 60_000); + + it("stops a server whose registration another server took over", async () => { + const dir = home(); + service(dir, "start", "--port", "7609"); + const first = registration(dir).pid; + // A second server for the same home on another port files its own registration. + const other = spawnCli(dir, {}, ["serve", "--port", "7610"]); + servers.push(other); + await waitFor(() => registration(dir).pid !== first); + + await waitFor(() => dead(first), 15_000); + expect(service(dir, "status")).toBe("ws://127.0.0.1:7610/rpc/v1\n"); + }, 60_000); + + it("starts, reuses, restarts and stops the background server", async () => { + const dir = home(); + const steps: Array<{ readonly command: string; readonly stdout: string; readonly registered: boolean }> = []; + const step = (...args: ReadonlyArray) => { + const stdout = service(dir, ...args); + steps.push({ command: args.join(" "), stdout, registered: existsSync(join(dir, "service.json")) }); + return stdout; + }; + + step("status"); + step("start", "--port", "7607"); + const started = registration(dir).pid; + step("status"); + step("start", "--port", "7607"); + // A second start adopts the running server rather than replacing it. + expect(registration(dir).pid).toBe(started); + step("restart", "--port", "7607"); + expect(registration(dir).pid).not.toBe(started); + expect(dead(started)).toBe(true); + const restarted = registration(dir).pid; + step("stop"); + step("status"); + + expect(dead(restarted)).toBe(true); + await expect(JSON.stringify(steps, null, "\t") + "\n").toMatchFileSnapshot( + "./__artifacts__/service.lifecycle.json", + ); + }, 60_000); +}); diff --git a/packages/tui/README.md b/packages/tui/README.md index 6fa5695d..f2aaf9a4 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -10,13 +10,13 @@ A fast and lightweight terminal interface for [CodeWork](https://www.codework.sh - **Fast and Lightweight** — Starts up instantly and uses minimal memory. - **Live Streaming** — Watch the AI think, stream answers, and run tools (like running terminal commands or editing files) as it happens. -- **Side-by-Side View** — - - **Chat screen:** See your conversation, AI replies, tool results, and response speed (`tok/s`). - - **Sidebar:** Keep an eye on your current model, context window usage, tokens used, cost, and active Git branch. +- **Side-by-Side View** — + - **Chat screen:** See your conversation, AI replies, tool results, and response speed (`tok/s`). + - **Sidebar:** Keep an eye on your current model, context window usage, tokens used, cost, and active Git branch. - **Easy Model Picker (`/model`)** — Browse models (Anthropic, OpenAI, Google, OpenRouter, Groq), check pricing, and enter API keys directly in the terminal. - **Neat Markdown Formatting** — Code blocks, lists, bold text, links, and tables look clean and readable in the terminal. - **Cancel Anytime** — Press `Esc` or `Ctrl+C` while the AI is responding to safely stop it without losing your conversation or quitting the app. -- **Auto-Connect** — Automatically connects to the CodeWork background server, starting it for you if it isn't running yet. +- **Auto-Connect** — `codework tui` starts or reuses the CodeWork background server, then connects the UI to it. --- @@ -29,7 +29,9 @@ A fast and lightweight terminal interface for [CodeWork](https://www.codework.sh ### Running the App -You can run the app directly using Cargo: +`codework tui` is the normal way in: the CLI starts or reuses the background server (registered in `/service.json`) and launches this binary with `CODEWORK_RPC_SERVER` set. From a checkout it picks up a `target/{release,debug}` build. + +To run the binary directly against a server you started with `codework serve`, use Cargo: ```bash # Run in development mode @@ -56,14 +58,14 @@ pnpm --filter @codeworksh/tui start Type `/` in the prompt input to open the command menu: -| Command | What it does | -| :--- | :--- | -| `/help` | Show available commands and shortcuts | -| `/model` | Pick an AI model or enter API keys | -| `/session` | View, switch, or resume past sessions | +| Command | What it does | +| :--------- | :------------------------------------------ | +| `/help` | Show available commands and shortcuts | +| `/model` | Pick an AI model or enter API keys | +| `/session` | View, switch, or resume past sessions | | `/compact` | Shorten conversation history to save tokens | -| `/clear` | Clear the current chat screen | -| `/exit` | Exit the app | +| `/clear` | Clear the current chat screen | +| `/exit` | Exit the app | --- @@ -71,27 +73,27 @@ Type `/` in the prompt input to open the command menu: ### General & Chat -| Shortcut | Action | -| :--- | :--- | -| `Enter` | Send message / run command | -| `Esc` | Close popup, cancel current action, or stop the AI | -| `Ctrl+C` | Stop the AI response / exit app when idle | -| `PageUp` / `PageDown` | Scroll chat up or down by a full page | -| `Ctrl+U` / `Ctrl+D` | Scroll chat up or down by half a page | -| `Home` / `End` | Jump to the very top or bottom of the chat | -| `Up` / `Down` | Move through slash commands (or scroll lines when input is empty) | +| Shortcut | Action | +| :-------------------- | :---------------------------------------------------------------- | +| `Enter` | Send message / run command | +| `Esc` | Close popup, cancel current action, or stop the AI | +| `Ctrl+C` | Stop the AI response / exit app when idle | +| `PageUp` / `PageDown` | Scroll chat up or down by a full page | +| `Ctrl+U` / `Ctrl+D` | Scroll chat up or down by half a page | +| `Home` / `End` | Jump to the very top or bottom of the chat | +| `Up` / `Down` | Move through slash commands (or scroll lines when input is empty) | ### Model Picker (`/model`) -| Shortcut | Action | -| :--- | :--- | -| `Tab` / `Shift+Tab` | Move between Providers, Models, and Details columns | -| `Up` / `Down` (or `j` / `k`) | Move through the list | -| `/` | Search models or providers | -| `s` | Change sort order (by name, context size, cost) | -| `c` / `y` | Copy the selected model ID to clipboard | -| `o` | Open provider website in your browser | -| `Enter` | Select the model (asks for an API key if needed) | +| Shortcut | Action | +| :--------------------------- | :-------------------------------------------------- | +| `Tab` / `Shift+Tab` | Move between Providers, Models, and Details columns | +| `Up` / `Down` (or `j` / `k`) | Move through the list | +| `/` | Search models or providers | +| `s` | Change sort order (by name, context size, cost) | +| `c` / `y` | Copy the selected model ID to clipboard | +| `o` | Open provider website in your browser | +| `Enter` | Select the model (asks for an API key if needed) | --- @@ -110,6 +112,7 @@ You can provide API keys in two simple ways: ``` Your configuration and keys are saved in your home folder: + - **`~/.codework/config.json`** — Your selected model and provider. - **`~/.codework/credentials.json`** — Your securely stored API keys. @@ -125,4 +128,3 @@ Your configuration and keys are saved in your home folder: ## License MIT © [CodeWork](https://codework.sh) - diff --git a/packages/tui/src/binary.ts b/packages/tui/src/binary.ts index 7a65632f..6de2d740 100644 --- a/packages/tui/src/binary.ts +++ b/packages/tui/src/binary.ts @@ -8,7 +8,7 @@ const require = createRequire(import.meta.url); /** The Rust binary, by the package that publishes a prebuilt for this platform. */ export interface Platform { - readonly os: "darwin" | "linux" | "win32"; + readonly os: "darwin" | "linux"; readonly arch: "arm64" | "x64"; } @@ -16,18 +16,18 @@ export interface Platform { export function platform(): Platform | undefined { const os = process.platform; const arch = process.arch; - if (os !== "darwin" && os !== "linux" && os !== "win32") return undefined; + if (os !== "darwin" && os !== "linux") return undefined; if (arch !== "arm64" && arch !== "x64") return undefined; return { os, arch }; } const packageFor = (value: Platform) => `@codeworksh/tui-${value.os}-${value.arch}`; -const binaryName = (value: Platform) => (value.os === "win32" ? "codework-tui.exe" : "codework-tui"); +const binaryName = "codework-tui"; /** A source-tree build, used when neither an override nor a platform package is present. */ -function devBuild(name: string): string | undefined { +function devBuild(): string | undefined { for (const profile of ["release", "debug"]) { - const candidate = fileURLToPath(new URL(`../target/${profile}/${name}`, import.meta.url)); + const candidate = fileURLToPath(new URL(`../target/${profile}/${binaryName}`, import.meta.url)); if (existsSync(candidate)) return candidate; } return undefined; @@ -56,19 +56,20 @@ export function resolveBinary(env: NodeJS.ProcessEnv = process.env): string { } const current = platform(); - const name = current === undefined ? "codework-tui" : binaryName(current); if (current !== undefined) { try { // Resolve through the package's own manifest so the binary path is not part of // the platform package's public `exports` surface. const manifest = require.resolve(`${packageFor(current)}/package.json`); - return join(dirname(manifest), "bin", name); + // In a checkout the platform package is a workspace link with no binary staged. + const binary = join(dirname(manifest), "bin", binaryName); + if (existsSync(binary)) return binary; } catch { // Fall through to the source-tree build below. } } - const dev = devBuild(name); + const dev = devBuild(); if (dev !== undefined) return dev; throw new LauncherError( diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 955d9383..73422ff5 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -1,77 +1,8 @@ -/* @effect-diagnostics nodeBuiltinImport:off -- the launcher owns the child process and its terminal. */ -import { spawn } from "node:child_process"; -import { resolveBinary } from "./binary.ts"; - +/** + * Finds the CodeWork TUI binary. Running it is the CLI's job: the child must + * share the CLI's lifetime and terminal, which the CLI's own runtime manages. + */ export { LauncherError, platform, resolveBinary, type Platform } from "./binary.ts"; /** The environment variable the Rust TUI reads for the server endpoint. */ export const ENV_SERVER = "CODEWORK_RPC_SERVER"; - -export interface LaunchOptions { - /** Arguments forwarded verbatim to the TUI, after the binary. */ - readonly args?: ReadonlyArray; - /** The server endpoint. Exported as {@link ENV_SERVER} for the child. */ - readonly server?: string; - /** Overrides the resolved binary; mainly for tests. */ - readonly binary?: string; - readonly cwd?: string; - readonly env?: NodeJS.ProcessEnv; -} - -/** - * Run the CodeWork TUI as a foreground child of this process. - * - * Terminal I/O is inherited rather than piped: the TUI owns the alternate - * screen, reads raw keystrokes and draws directly, so any buffering here would - * corrupt it. Signals the terminal sends to the foreground process group reach - * the child on their own; the ones a caller sends to this process explicitly are - * forwarded, so `kill` on the CLI does not orphan the UI. The resolved exit code - * becomes the caller's. - */ -export function launch(options: LaunchOptions = {}): Promise { - const binary = options.binary ?? resolveBinary(options.env ?? process.env); - const env: NodeJS.ProcessEnv = { ...(options.env ?? process.env) }; - if (options.server !== undefined) env[ENV_SERVER] = options.server; - - const child = spawn(binary, [...(options.args ?? [])], { - stdio: "inherit", - ...(options.cwd === undefined ? {} : { cwd: options.cwd }), - env, - }); - - const forwarded: ReadonlyArray = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"]; - const relay = (signal: NodeJS.Signals) => () => { - if (child.exitCode === null && child.signalCode === null) child.kill(signal); - }; - const handlers = forwarded.map((signal) => { - const handler = relay(signal); - process.on(signal, handler); - return [signal, handler] as const; - }); - - return new Promise((resolve, reject) => { - child.once("error", (cause) => { - cleanup(); - reject(cause); - }); - child.once("exit", (code, signal) => { - cleanup(); - // A child killed by a signal has no code; mirror the shell's 128 + signal - // convention so a caller sees a non-zero status rather than a false success. - resolve(code ?? (signal === null ? 0 : 128 + signalNumber(signal))); - }); - }); - - function cleanup() { - for (const [signal, handler] of handlers) process.off(signal, handler); - } -} - -const signalNumbers: Readonly> = { - SIGHUP: 1, - SIGINT: 2, - SIGQUIT: 3, - SIGTERM: 15, -}; - -const signalNumber = (signal: string): number => signalNumbers[signal] ?? 1; diff --git a/packages/tui/src/rpc/client.rs b/packages/tui/src/rpc/client.rs index 7f722736..87686d45 100644 --- a/packages/tui/src/rpc/client.rs +++ b/packages/tui/src/rpc/client.rs @@ -14,7 +14,7 @@ use crate::rpc::protocol::{ }; use crate::types::{SessionInfo, UsageReport}; -pub const DEFAULT_SERVER_URL: &str = "ws://127.0.0.1:7433/rpc"; +pub const DEFAULT_SERVER_URL: &str = "ws://127.0.0.1:7433/rpc/v1"; /// How long to wait between reconnection attempts, and how many to make before /// giving the turn up. The server outlives this client, so a short window is diff --git a/sites/www/content/docs/cli/reference.mdx b/sites/www/content/docs/cli/reference.mdx index d27da90b..74597d33 100644 --- a/sites/www/content/docs/cli/reference.mdx +++ b/sites/www/content/docs/cli/reference.mdx @@ -25,22 +25,27 @@ Run `codework --help` or `codework --help` to see this from your termi | `codework plugin update [spec]` | Fetch newer revisions | | `codework plugin reload` | Re-import plugins in a running server | | `codework session link ` | Anchor a session to a project folder, through a running server | +| `codework tui` | Open the terminal UI, starting the background server if needed | +| `codework service start` | Start the background server and print its URL | +| `codework service restart` | Restart the background server and print its URL | +| `codework service status` | Print the background server's URL, or `stopped` | +| `codework service stop` | Stop the background server | | `codework serve` | Start the [RPC server](/docs/integrations/rpc) | | `codework acp` | Start the [ACP server](/docs/integrations/acp) on stdio | ## `codework run` -| Flag | Description | -| ---------------------------- | ----------------------------------------------------------------------------------- | -| `-s`, `--session ` | Continue an existing session | -| `-C`, `--cwd ` | Working directory for a new session | -| `--provider ` | Model catalog provider ID | -| `--model ` | Model ID | -| `--thinking ` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | -| `--sandbox-driver ` | Sandbox driver for a new session (default `local`) | -| `--sandbox-id ` | Reuse an existing sandbox instance | -| `--sandbox-provider-id ` | Attach to an existing remote sandbox by its provider ID | -| `--server ` | Run against an [RPC server](/docs/integrations/rpc), e.g. `ws://127.0.0.1:7433/rpc` | +| Flag | Description | +| ---------------------------- | -------------------------------------------------------------------------------------- | +| `-s`, `--session ` | Continue an existing session | +| `-C`, `--cwd ` | Working directory for a new session | +| `--provider ` | Model catalog provider ID | +| `--model ` | Model ID | +| `--thinking ` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | +| `--sandbox-driver ` | Sandbox driver for a new session (default `local`) | +| `--sandbox-id ` | Reuse an existing sandbox instance | +| `--sandbox-provider-id ` | Attach to an existing remote sandbox by its provider ID | +| `--server ` | Run against an [RPC server](/docs/integrations/rpc), e.g. `ws://127.0.0.1:7433/rpc/v1` | ## `codework auth` @@ -73,12 +78,23 @@ Run `codework --help` or `codework --help` to see this from your termi | `--provider ` | `models` | List only this provider's models | | `--force` | `refresh` | Download the catalog even if it's fresh | +## `codework tui` and `codework service` + +`codework tui` reuses the background server named in `/service.json`, or starts one, then connects the terminal UI to it. The server keeps running after the UI exits; `codework service stop` stops it. A server from an older CLI version is replaced. Its log is `/log/server.log`. + +| Flag | Applies to | Description | +| ---------------- | ------------------------- | ------------------------------------------------------------- | +| `--host` | `tui`, `start`, `restart` | Interface a new background server binds (default `127.0.0.1`) | +| `--port` | `tui`, `start`, `restart` | Port a new background server listens on (default `7433`) | +| `--server ` | `tui` | Use this RPC server instead of the background server | + ## `codework serve` -| Flag | Description | -| -------- | ------------------------------------------------------------ | -| `--host` | Interface to bind (default `127.0.0.1`) | -| `--port` | Port to listen on, or `0` for any free port (default `7433`) | +| Flag | Description | +| ----------- | ---------------------------------------------------------------- | +| `--host` | Interface to bind (default `127.0.0.1`) | +| `--port` | Port to listen on, or `0` for any free port (default `7433`) | +| `--service` | Run as the background server, logging to `/log/server.log` | ## Global Flags diff --git a/sites/www/content/docs/integrations/rpc.mdx b/sites/www/content/docs/integrations/rpc.mdx index 87805278..7b31794e 100644 --- a/sites/www/content/docs/integrations/rpc.mdx +++ b/sites/www/content/docs/integrations/rpc.mdx @@ -18,7 +18,7 @@ Use it when you want CodeWork to outlive a terminal, to run it on another machin codework serve ``` -The server listens on `ws://127.0.0.1:7433/rpc` and prints the address when it's ready. +The server listens on `ws://127.0.0.1:7433/rpc/v1` and prints the address when it's ready. | Flag | Description | | -------- | ------------------------------------------------------------ | @@ -38,9 +38,9 @@ The server listens on `ws://127.0.0.1:7433/rpc` and prints the address when it's Point any `run`, `auth`, `session`, or `plugin reload` command at the server: ```bash -codework run --server ws://127.0.0.1:7433/rpc "Inspect the failing tests" -codework run --server ws://127.0.0.1:7433/rpc --session "Now fix them" -codework auth login --openai-codex --server ws://127.0.0.1:7433/rpc +codework run --server ws://127.0.0.1:7433/rpc/v1 "Inspect the failing tests" +codework run --server ws://127.0.0.1:7433/rpc/v1 --session "Now fix them" +codework auth login --openai-codex --server ws://127.0.0.1:7433/rpc/v1 codework plugin reload ``` @@ -57,7 +57,7 @@ codework session link --unlink ## Protocol -The endpoint speaks [Effect RPC](https://effect.website) over WebSocket at `/rpc`, with newline-delimited JSON framing. The contract lives in [`packages/codework/src/server/contract.ts`](https://github.com/codeworksh/codework/blob/main/packages/codework/src/server/contract.ts). +The endpoint speaks [Effect RPC](https://effect.website) over WebSocket at `/rpc/v1`, with newline-delimited JSON framing. The contract lives in [`packages/codework/src/server/contract.ts`](https://github.com/codeworksh/codework/blob/main/packages/codework/src/server/contract.ts). {/* TODO: document request/response shapes and a minimal client example once the contract is published as a package. */} From c619dece6e719f7dc65dfa984394cf2840b80972 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Mon, 12 Oct 2026 00:41:03 +0530 Subject: [PATCH 3/4] ci(tui): build and publish prebuilt TUI binaries on canary `@codeworksh/cli` depends on `@codeworksh/tui`, which nothing published, so canary could not release the CLI. - `.github/workflows/tui.yml` (reusable) builds the TUI for macOS arm64/x64 and Linux arm64/x64, Linux as static musl binaries, and uploads one artifact per platform package. - Four platform packages, `@codeworksh/tui--`, live under packages/tui/npm as workspace packages with `os`/`cpu` set; their build stages the downloaded binary. `@codeworksh/tui` depends on them as optional workspace dependencies, so publish pins them like any other workspace dependency. - canary.yml runs the build first, then publishes in order aikit, plugin, harness, the platform packages, tui, cli. - dev.yml runs `cargo check` on the TUI. - `publish.mjs` knows the tui targets and carries `os`/`cpu`; `pnpm bump:tui` moves all five versions together. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/canary.yml | 9 ++++ .github/workflows/dev.yml | 13 +++++ .github/workflows/tui.yml | 59 ++++++++++++++++++++++ CLAUDE.md | 2 +- package.json | 4 ++ packages/tui/bump.config.ts | 10 ++++ packages/tui/npm/darwin-arm64/README.md | 3 ++ packages/tui/npm/darwin-arm64/package.json | 24 +++++++++ packages/tui/npm/darwin-x64/README.md | 3 ++ packages/tui/npm/darwin-x64/package.json | 24 +++++++++ packages/tui/npm/linux-arm64/README.md | 3 ++ packages/tui/npm/linux-arm64/package.json | 24 +++++++++ packages/tui/npm/linux-x64/README.md | 3 ++ packages/tui/npm/linux-x64/package.json | 24 +++++++++ packages/tui/package.json | 30 +++++++---- packages/tui/scripts/stage.mjs | 20 ++++++++ pnpm-lock.yaml | 26 +++++++++- pnpm-workspace.yaml | 1 + scripts/canary.mjs | 19 +++++-- scripts/publish.mjs | 13 ++++- 20 files changed, 296 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/tui.yml create mode 100644 packages/tui/bump.config.ts create mode 100644 packages/tui/npm/darwin-arm64/README.md create mode 100644 packages/tui/npm/darwin-arm64/package.json create mode 100644 packages/tui/npm/darwin-x64/README.md create mode 100644 packages/tui/npm/darwin-x64/package.json create mode 100644 packages/tui/npm/linux-arm64/README.md create mode 100644 packages/tui/npm/linux-arm64/package.json create mode 100644 packages/tui/npm/linux-x64/README.md create mode 100644 packages/tui/npm/linux-x64/package.json create mode 100644 packages/tui/scripts/stage.mjs diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index a2e3acc6..9bd4aab5 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -15,7 +15,11 @@ concurrency: cancel-in-progress: false jobs: + tui: + uses: ./.github/workflows/tui.yml + publish: + needs: tui runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -30,5 +34,10 @@ jobs: - run: vp run @codeworksh/aikit#build - run: vp check packages scripts .github - run: vp run --filter './packages/*' --concurrency-limit 2 typecheck + # One directory per platform package; each package's build stages its binary from here. + - uses: actions/download-artifact@v4 + with: + pattern: tui-* + path: packages/tui/dist/bin - name: Publish all packages under canary run: node scripts/canary.mjs diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index a9192036..09b55c21 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -33,3 +33,16 @@ jobs: - run: vp check packages scripts .github - run: vp run --filter './packages/*' --concurrency-limit 2 typecheck - run: vp run --filter './packages/*' --concurrency-limit 2 build + + tui: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/tui + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/tui + - run: cargo check --locked diff --git a/.github/workflows/tui.yml b/.github/workflows/tui.yml new file mode 100644 index 00000000..ec45f577 --- /dev/null +++ b/.github/workflows/tui.yml @@ -0,0 +1,59 @@ +name: TUI Binaries + +# Builds the Rust TUI for every published platform and uploads one artifact per +# platform package, named after it (`tui--`). Publishing workflows call +# this and stage the artifacts under packages/tui/dist/bin before `publish.mjs`. +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: build (${{ matrix.package }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: true + matrix: + include: + - package: tui-darwin-arm64 + runner: macos-14 + target: aarch64-apple-darwin + - package: tui-darwin-x64 + runner: macos-14 + target: x86_64-apple-darwin + # Linux binaries link musl statically, so one build runs on any distro. + - package: tui-linux-arm64 + runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + - package: tui-linux-x64 + runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + defaults: + run: + working-directory: packages/tui + steps: + - uses: actions/checkout@v6 + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y musl-tools + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + workspaces: packages/tui + key: ${{ matrix.target }} + - run: cargo build --release --locked --target ${{ matrix.target }} + - name: Inspect binary + run: file target/${{ matrix.target }}/release/codework-tui + - if: runner.os == 'Linux' + name: Require a static binary + run: file target/${{ matrix.target }}/release/codework-tui | grep -E "static(-pie)? linked|statically linked" + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.package }} + path: packages/tui/target/${{ matrix.target }}/release/codework-tui + if-no-files-found: error + retention-days: 7 diff --git a/CLAUDE.md b/CLAUDE.md index fbd552f5..bdcbe0c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,4 +87,4 @@ Flow: 3. `git push && git push --tag`. 4. `pnpm release:aikit:dry` to preview the tarball, then `pnpm release:aikit` to publish. Use `pnpm release:aikit:dev` for a prerelease under the `dev` dist-tag. -A workspace dependency is published at its manifest version, which must already be on the registry. A dependency with only dev builds (e.g. plugin, when releasing harness) is pinned explicitly, as a version or a dist-tag resolved at publish time — there is no implicit fallback: `pnpm release:harness:dev --dep @codeworksh/plugin@dev`. Release in dependency order: aikit → plugin → harness → cli. +A workspace dependency is published at its manifest version, which must already be on the registry. A dependency with only dev builds (e.g. plugin, when releasing harness) is pinned explicitly, as a version or a dist-tag resolved at publish time — there is no implicit fallback: `pnpm release:harness:dev --dep @codeworksh/plugin@dev`. Release in dependency order: aikit → plugin → harness → tui platform packages (`tui--`) → tui → cli. The TUI's platform packages carry prebuilt binaries built by `.github/workflows/tui.yml` (macOS arm64/x64, Linux arm64/x64 static musl; Unix only) and ship in lockstep with `@codeworksh/tui`; `pnpm bump:tui` moves all five versions. Canary (push to `canary`) builds and publishes the whole chain. diff --git a/package.json b/package.json index 61d7a8f4..c6978f7c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "bump:cli": "pnpm --filter @codeworksh/cli run bump", "bump:harness": "pnpm --filter @codeworksh/harness run bump", "bump:plugin": "pnpm --filter @codeworksh/plugin run bump", + "bump:tui": "pnpm --filter @codeworksh/tui run bump", "check": "vp check", "check:packages": "vp run -r check", "dev:desktop": "pnpm --filter @codeworksh/desktop dev", @@ -36,6 +37,9 @@ "release:plugin": "pnpm --filter @codeworksh/plugin run release", "release:plugin:dev": "pnpm --filter @codeworksh/plugin run release:dev", "release:plugin:dry": "pnpm --filter @codeworksh/plugin run release:dry", + "release:tui": "pnpm --filter @codeworksh/tui run release", + "release:tui:dev": "pnpm --filter @codeworksh/tui run release:dev", + "release:tui:dry": "pnpm --filter @codeworksh/tui run release:dry", "test": "vp test", "test:packages": "vp run -r test", "typecheck": "vp run -r --concurrency-limit 2 typecheck" diff --git a/packages/tui/bump.config.ts b/packages/tui/bump.config.ts new file mode 100644 index 00000000..30795f89 --- /dev/null +++ b/packages/tui/bump.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "bumpp"; + +// Release config for @codeworksh/tui, loaded automatically by `bumpp`. The platform +// packages ship in lockstep with the launcher, so all five versions move together. +export default defineConfig({ + files: ["package.json", "npm/*/package.json"], + tag: "@codeworksh/tui@%s", + commit: "release: @codeworksh/tui@%s", + push: false, +}); diff --git a/packages/tui/npm/darwin-arm64/README.md b/packages/tui/npm/darwin-arm64/README.md new file mode 100644 index 00000000..50ceb71b --- /dev/null +++ b/packages/tui/npm/darwin-arm64/README.md @@ -0,0 +1,3 @@ +# @codeworksh/tui-darwin-arm64 + +The prebuilt CodeWork terminal UI for macOS on Apple silicon. Installed automatically with `@codeworksh/cli`; run it with `codework tui`. diff --git a/packages/tui/npm/darwin-arm64/package.json b/packages/tui/npm/darwin-arm64/package.json new file mode 100644 index 00000000..b293a8c3 --- /dev/null +++ b/packages/tui/npm/darwin-arm64/package.json @@ -0,0 +1,24 @@ +{ + "name": "@codeworksh/tui-darwin-arm64", + "version": "0.0.1", + "description": "The CodeWork terminal UI binary for macOS on Apple silicon", + "homepage": "https://www.codework.sh", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/codeworksh/codework.git", + "directory": "packages/tui/npm/darwin-arm64" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "node ../../scripts/stage.mjs" + } +} diff --git a/packages/tui/npm/darwin-x64/README.md b/packages/tui/npm/darwin-x64/README.md new file mode 100644 index 00000000..4397ac51 --- /dev/null +++ b/packages/tui/npm/darwin-x64/README.md @@ -0,0 +1,3 @@ +# @codeworksh/tui-darwin-x64 + +The prebuilt CodeWork terminal UI for macOS on Intel. Installed automatically with `@codeworksh/cli`; run it with `codework tui`. diff --git a/packages/tui/npm/darwin-x64/package.json b/packages/tui/npm/darwin-x64/package.json new file mode 100644 index 00000000..b5188dcc --- /dev/null +++ b/packages/tui/npm/darwin-x64/package.json @@ -0,0 +1,24 @@ +{ + "name": "@codeworksh/tui-darwin-x64", + "version": "0.0.1", + "description": "The CodeWork terminal UI binary for macOS on Intel", + "homepage": "https://www.codework.sh", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/codeworksh/codework.git", + "directory": "packages/tui/npm/darwin-x64" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "node ../../scripts/stage.mjs" + } +} diff --git a/packages/tui/npm/linux-arm64/README.md b/packages/tui/npm/linux-arm64/README.md new file mode 100644 index 00000000..d9616de6 --- /dev/null +++ b/packages/tui/npm/linux-arm64/README.md @@ -0,0 +1,3 @@ +# @codeworksh/tui-linux-arm64 + +The prebuilt CodeWork terminal UI for Linux on arm64 (static, musl). Installed automatically with `@codeworksh/cli`; run it with `codework tui`. diff --git a/packages/tui/npm/linux-arm64/package.json b/packages/tui/npm/linux-arm64/package.json new file mode 100644 index 00000000..bf9a3708 --- /dev/null +++ b/packages/tui/npm/linux-arm64/package.json @@ -0,0 +1,24 @@ +{ + "name": "@codeworksh/tui-linux-arm64", + "version": "0.0.1", + "description": "The CodeWork terminal UI binary for Linux on arm64 (static, musl)", + "homepage": "https://www.codework.sh", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/codeworksh/codework.git", + "directory": "packages/tui/npm/linux-arm64" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "node ../../scripts/stage.mjs" + } +} diff --git a/packages/tui/npm/linux-x64/README.md b/packages/tui/npm/linux-x64/README.md new file mode 100644 index 00000000..d67cb65b --- /dev/null +++ b/packages/tui/npm/linux-x64/README.md @@ -0,0 +1,3 @@ +# @codeworksh/tui-linux-x64 + +The prebuilt CodeWork terminal UI for Linux on x64 (static, musl). Installed automatically with `@codeworksh/cli`; run it with `codework tui`. diff --git a/packages/tui/npm/linux-x64/package.json b/packages/tui/npm/linux-x64/package.json new file mode 100644 index 00000000..0d21a9f2 --- /dev/null +++ b/packages/tui/npm/linux-x64/package.json @@ -0,0 +1,24 @@ +{ + "name": "@codeworksh/tui-linux-x64", + "version": "0.0.1", + "description": "The CodeWork terminal UI binary for Linux on x64 (static, musl)", + "homepage": "https://www.codework.sh", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/codeworksh/codework.git", + "directory": "packages/tui/npm/linux-x64" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "node ../../scripts/stage.mjs" + } +} diff --git a/packages/tui/package.json b/packages/tui/package.json index e36ac757..e4c348a2 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -9,14 +9,19 @@ "tui" ], "homepage": "https://www.codework.sh", - "author": "Prem (https://github.com/prem315)", "license": "MIT", - "type": "module", + "author": "Prem (https://github.com/prem315)", + "repository": { + "type": "git", + "url": "git+https://github.com/codeworksh/codework.git", + "directory": "packages/tui" + }, "files": [ "dist", "src", "README.md" ], + "type": "module", "exports": { ".": { "development": "./src/index.ts", @@ -30,20 +35,23 @@ "build:rust": "cargo build --release", "check": "vp check", "check:rust": "cargo check", - "package:binaries": "node scripts/package.mjs", "start": "cargo run", - "typecheck": "tsc --noEmit" - }, - "optionalDependencies": { - "@codeworksh/tui-darwin-arm64": "0.0.1", - "@codeworksh/tui-darwin-x64": "0.0.1", - "@codeworksh/tui-linux-arm64": "0.0.1", - "@codeworksh/tui-linux-x64": "0.0.1", - "@codeworksh/tui-win32-x64": "0.0.1" + "typecheck": "tsc --noEmit", + "bump": "bumpp", + "release": "node ../../scripts/publish.mjs tui", + "release:dev": "node ../../scripts/publish.mjs tui --dev", + "release:dry": "node ../../scripts/publish.mjs tui --dry-run" }, "devDependencies": { "@types/node": "catalog:", + "bumpp": "^12.3.0", "typescript": "catalog:", "vite-plus": "catalog:" + }, + "optionalDependencies": { + "@codeworksh/tui-darwin-arm64": "workspace:*", + "@codeworksh/tui-darwin-x64": "workspace:*", + "@codeworksh/tui-linux-arm64": "workspace:*", + "@codeworksh/tui-linux-x64": "workspace:*" } } diff --git a/packages/tui/scripts/stage.mjs b/packages/tui/scripts/stage.mjs new file mode 100644 index 00000000..538258e8 --- /dev/null +++ b/packages/tui/scripts/stage.mjs @@ -0,0 +1,20 @@ +// Stages a platform package's binary for publishing, run from its directory by `build`. +// CI downloads each target's build to `packages/tui/dist/bin//codework-tui` +// (the artifact is named after the unscoped package), and `scripts/publish.mjs` +// publishes `dist/pack`. +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const manifest = JSON.parse(readFileSync("package.json", "utf8")); +const name = manifest.name.split("/")[1]; +const source = resolve("../../dist/bin", name, "codework-tui"); +if (!existsSync(source)) { + console.error(`Missing ${source}: binaries are built by the TUI workflow (.github/workflows/tui.yml)`); + process.exit(1); +} + +mkdirSync("dist/pack/bin", { recursive: true }); +const target = resolve("dist/pack/bin/codework-tui"); +copyFileSync(source, target); +// Artifact downloads drop the executable bit. +chmodSync(target, 0o755); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8c31792..8cef4678 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,7 +169,7 @@ catalogs: version: 0.45.0 '@types/node': specifier: ^26.5.1 - version: 26.5.1 + version: 26.6.4 typescript: specifier: 7.0.2 version: 7.0.2 @@ -580,12 +580,36 @@ importers: '@types/node': specifier: 'catalog:' version: 26.6.4 + bumpp: + specifier: ^12.3.0 + version: 12.3.0 typescript: specifier: 'catalog:' version: 7.0.2 vite-plus: specifier: 'catalog:' version: 0.3.1(@opentelemetry/api@1.9.1)(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(typescript@7.0.2)(yaml@2.9.1) + optionalDependencies: + '@codeworksh/tui-darwin-arm64': + specifier: workspace:* + version: link:npm/darwin-arm64 + '@codeworksh/tui-darwin-x64': + specifier: workspace:* + version: link:npm/darwin-x64 + '@codeworksh/tui-linux-arm64': + specifier: workspace:* + version: link:npm/linux-arm64 + '@codeworksh/tui-linux-x64': + specifier: workspace:* + version: link:npm/linux-x64 + + packages/tui/npm/darwin-arm64: {} + + packages/tui/npm/darwin-x64: {} + + packages/tui/npm/linux-arm64: {} + + packages/tui/npm/linux-x64: {} sites/www: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b111be98..3fb2946e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - apps/* - packages/* + - packages/tui/npm/* - extras/* - sites/* diff --git a/scripts/canary.mjs b/scripts/canary.mjs index d2a5bbcb..f98f2ba7 100644 --- a/scripts/canary.mjs +++ b/scripts/canary.mjs @@ -11,11 +11,22 @@ if (!run || !attempt || !sha) { } // Publish dependencies first and pin consumers to this run, rather than a moving dist-tag. -const packages = ["aikit", "plugin", "harness", "codework"].map((directory) => { +// The TUI platform packages precede the launcher that pins them, which precedes the CLI. +const packages = [ + "aikit", + "plugin", + "harness", + "tui/npm/darwin-arm64", + "tui/npm/darwin-x64", + "tui/npm/linux-arm64", + "tui/npm/linux-x64", + "tui", + "codework", +].map((directory) => { const manifest = JSON.parse(readFileSync(resolve(root, "packages", directory, "package.json"), "utf8")); const base = /^\d+\.\d+\.\d+/.exec(manifest.version)?.[0]; if (!base) throw new Error(`Invalid package version: ${manifest.version}`); - return { directory, manifest, version: `${base}-canary.${run}.${attempt}.g${sha.slice(0, 8)}` }; + return { manifest, version: `${base}-canary.${run}.${attempt}.g${sha.slice(0, 8)}` }; }); const uploadedVersions = new Map(); @@ -47,8 +58,8 @@ async function waitForPublishedVersion(name, version) { ); } -for (const { directory, manifest, version } of packages) { - const args = [resolve(root, "scripts/publish.mjs"), directory, "--publish-version", version, "--tag", "canary"]; +for (const { manifest, version } of packages) { + const args = [resolve(root, "scripts/publish.mjs"), manifest.name, "--publish-version", version, "--tag", "canary"]; const dependencies = { ...manifest.dependencies, ...manifest.peerDependencies, ...manifest.optionalDependencies }; for (const [name, range] of Object.entries(dependencies)) { if (typeof range !== "string" || !range.startsWith("workspace:")) continue; diff --git a/scripts/publish.mjs b/scripts/publish.mjs index bb30097a..4370f27c 100644 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -15,11 +15,19 @@ const workspaceMap = new Map([ ["@codeworksh/harness", "packages/harness"], ["plugin", "packages/plugin"], ["@codeworksh/plugin", "packages/plugin"], + ["tui", "packages/tui"], + ["@codeworksh/tui", "packages/tui"], + // One prebuilt binary per platform, in lockstep with the launcher; `build` stages the + // binary the TUI workflow produced. + ...["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"].flatMap((platform) => [ + [`tui-${platform}`, `packages/tui/npm/${platform}`], + [`@codeworksh/tui-${platform}`, `packages/tui/npm/${platform}`], + ]), ]); function usage() { console.error( - "Usage: node scripts/publish.mjs [--dev] [--stage] [--dep @]... [--uploaded-dep @]... [npm publish args]", + "Usage: node scripts/publish.mjs -|@codeworksh/> [--dev] [--stage] [--dep @]... [--uploaded-dep @]... [npm publish args]", ); process.exit(1); } @@ -384,6 +392,9 @@ async function createPublishManifest(manifest, version, dependencyPins, uploaded sideEffects: manifest.sideEffects, publishConfig: manifest.publishConfig, engines: manifest.engines, + // A platform package installs only where its binary runs. + os: manifest.os, + cpu: manifest.cpu, dependencies: await rewriteDependencyMap(manifest.dependencies, dependencyPins, uploadedDependencies), peerDependencies: await rewriteDependencyMap(manifest.peerDependencies, dependencyPins, uploadedDependencies), peerDependenciesMeta: manifest.peerDependenciesMeta, From 17311a075311fed549cd3e8adbfd8b6da75f1852 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Mon, 12 Oct 2026 00:53:44 +0530 Subject: [PATCH 4/4] ci(tui): build TUI binaries on pull requests Run the platform build matrix on pull requests into canary and dev that touch the TUI, so a broken target shows up in review instead of when canary publishes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tui.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/tui.yml b/.github/workflows/tui.yml index ec45f577..eeb91336 100644 --- a/.github/workflows/tui.yml +++ b/.github/workflows/tui.yml @@ -6,6 +6,12 @@ name: TUI Binaries on: workflow_call: workflow_dispatch: + # Catch a broken target in review rather than at publish time. + pull_request: + branches: [canary, dev] + paths: + - packages/tui/** + - .github/workflows/tui.yml permissions: contents: read