From 41fe0788a7033c323b107c16a95387c2cc86b124 Mon Sep 17 00:00:00 2001 From: "nhat.nguyenhong" Date: Wed, 16 Sep 2026 14:11:07 +0000 Subject: [PATCH] add /cc-zdr, /cc-debug, /cc-status slash commands --- AGENTS.md | 17 ++++++++++ README.md | 45 +++++++++++++++++++++---- package.json | 4 +++ src/events.ts | 15 +++++---- src/log.ts | 28 ++++++++++++---- src/model.ts | 89 +++++++++++++++++++++++++++++++++--------------- src/toggles.ts | 61 +++++++++++++++++++++++++++++++++ src/tui.ts | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 305 insertions(+), 45 deletions(-) create mode 100644 src/toggles.ts create mode 100644 src/tui.ts diff --git a/AGENTS.md b/AGENTS.md index 0796215..f915c6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,9 @@ src/transform.ts LanguageModelV3CallOptions -> /alpha/generate envelope (return src/events.ts Async iterator over the NDJSON/SSE response body. src/usage.ts finish event -> LanguageModelV3Usage; finish-reason unification. src/redact.ts Credential scrubbing for error surfaces. +src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file. +src/toggles.ts Shared toggle file (~/.config/opencode/commandcode-toggles.json). +src/tui.ts opencode TUI plugin exposing /cc-zdr, /cc-debug, /cc-status. src/constants.ts Defaults, paths, headers, passthrough params, static config block. scripts/smoke.mjs Live end-to-end check. scripts/sync-models.mjs Catalog -> provider.commandcode.models generator (with vision probing). @@ -113,6 +116,20 @@ These are load-bearing. Breaking one causes silent failures in opencode. `provider.commandcode.models` in `opencode.json` and never asks a custom `file://` provider to discover models (only internal providers can register `discoverModels`). Refresh the map with `npm run sync-models`; do not expect a discovery hook in `src/` to populate it. +12. **The TUI plugin lives outside the provider entrypoint.** `src/tui.ts` compiles to + `dist/tui.js`, is exposed as `./tui` in `package.json`, and is installed with + `opencode plugin `, which writes a `tui.json` `plugin` entry. Verified on opencode + 1.18.31: the module's **default export must be `{ id, tui }`** (a bare `tui` named export is + imported but never invoked), `id` is required, and `tui` is `async (api) => {}`. Commands + register via `api.keymap.registerLayer({ commands })` with `{ name, run, title, desc, + category, namespace: "palette", slashName }`. +13. **Slash commands and `Ctrl+P` share one registry.** In opencode 1.x the slash menu queries + the same `namespace: "palette"` commands the palette lists (both filter out `hidden: true`). + A slash-only entry is not expressible; registering `/cc-*` also adds them to `Ctrl+P`. +14. **Toggles are resolved per request, never at load.** `zdr` and `debug` are read in + `model.ts`/`log.ts` on every call from the toggle file / env, so `/cc-*` changes take effect + without restarting opencode. Precedence for `zdr`: `providerOptions.commandcode.zdr` > + `x-cmd-zdr` header > toggle file > `COMMANDCODE_ZDR`. Do not reintroduce load-time consts. ## Change workflow diff --git a/README.md b/README.md index a69acb9..2b47e44 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,8 @@ unauthenticated and CommandCode will reject them. Set `COMMANDCODE_DEBUG=1` to write a trace of every request and stream event to a log file. Silent (and no file is created) unless enabled. Every line is passed through `redact()` so -credentials never reach disk. +credentials never reach disk. It can also be flipped at runtime with `/cc-debug` (see +[Runtime toggles](#runtime-toggles)). ```powershell $env:COMMANDCODE_DEBUG = "1" @@ -166,13 +167,40 @@ useful for diagnosing model selection, retry, tool-call, and finish-reason issue ## ZDR header toggle The `x-cmd-zdr: 1` request header is **omitted by default** because it is rejected by some -models. Set `COMMANDCODE_ZDR=1` (or `true`/`yes`) when launching opencode to opt back in. The -value is read once at provider load, so restart opencode after changing it. Per-provider -overrides still win: `headers: { "x-cmd-zdr": "..." }` in `opencode.json` takes precedence over -the environment variable. +models. It can be enabled with the `COMMANDCODE_ZDR` environment variable, the `/cc-zdr` slash +command, or `providerOptions.commandcode.zdr`. The value is resolved per request, so toggling it +does not require restarting opencode. + +Precedence, highest first: + +1. `providerOptions.commandcode.zdr` (`true`/`false`), e.g. a model variant or agent option. +2. An explicit `x-cmd-zdr` header in `opencode.json` `options.headers` or the call's `headers`. +3. The toggle file (`zdr` key — see [Runtime toggles](#runtime-toggles)). +4. `COMMANDCODE_ZDR=1|true|yes` in the environment. + +With tracing enabled, the trace logs a `zdr on|off` line per request so the toggle state is +observable. + +## Runtime toggles + +`/cc-zdr` and `/cc-debug` flip the `zdr` and `debug` values in a small JSON file the provider +reads on every request, so both settings change without restarting opencode. `/cc-status` shows +the current state. The file is `~/.config/opencode/commandcode-toggles.json` (overridable with +`COMMANDCODE_TOGGLES_FILE`); the environment variables above are used when a key is absent. + +These commands come from a small TUI plugin shipped in this package (`src/tui.ts`, built to +`dist/tui.js`). Register it once: + +```powershell +opencode plugin file:///C:/DevTools/pienv/ccprovider/dist/tui.js +``` + +That writes a `tui.json` `plugin` entry (project-local `.opencode/tui.json`, or global with +`--global`). Restart opencode after installing; then type `/cc-` for autocomplete. Note that in +opencode 1.x the slash menu and the `Ctrl+P` palette read the same command registry, so these +entries appear in both. If you run opencode against a remote server, the TUI-side file is not +visible to the provider on the server host — use the env vars or `providerOptions` there. -With `COMMANDCODE_DEBUG` enabled, the trace logs a `zdr on|off` line per request so the toggle -state is observable. ## Features @@ -192,6 +220,7 @@ state is observable. | `reasoning_effort` | Yes — via `providerOptions.commandcode` | | Retry with backoff | Yes — 429/5xx and network errors, honouring `Retry-After` | | Credential redaction | Yes — error bodies are scrubbed before surfacing | +| Runtime toggles | Yes — `/cc-zdr` and `/cc-debug` flip the shared toggle file without a restart | ### `tool_choice` handling @@ -225,6 +254,8 @@ src/events.ts NDJSON/SSE line iterator over the upstream response body. src/usage.ts finish event -> V3 usage; finish-reason unification. src/redact.ts Credential scrubbing for error surfaces. src/log.ts Opt-in tracing (COMMANDCODE_DEBUG) to a log file. +src/toggles.ts Shared toggle file read by the provider and written by the TUI plugin. +src/tui.ts opencode TUI plugin registering /cc-zdr, /cc-debug, /cc-status. src/constants.ts Defaults, header names, passthrough params, static config block. scripts/smoke.mjs Live end-to-end check against api.commandcode.ai. scripts/sync-models.mjs Generate provider.commandcode.models from the live catalog. diff --git a/package.json b/package.json index 6df06f6..33be08a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,10 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./tui": { + "import": "./dist/tui.js", + "types": "./dist/tui.d.ts" } }, "files": [ diff --git a/src/events.ts b/src/events.ts index b6dcded..441ab2a 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,7 +1,10 @@ /** CommandCode streams NDJSON; tolerate SSE-style `data:` prefixes and `[DONE]` sentinels. */ -import { debug } from "./log.js"; +import { debugWhen, isDebugEnabled } from "./log.js"; -export async function* iterateEvents(body: ReadableStream): AsyncGenerator> { +export async function* iterateEvents( + body: ReadableStream, + dbg: boolean = isDebugEnabled(), +): AsyncGenerator> { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ""; @@ -16,7 +19,7 @@ export async function* iterateEvents(body: ReadableStream): AsyncGen while ((newline = buffer.indexOf("\n")) >= 0) { const raw = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); - const event = parseLine(raw); + const event = parseLine(raw, dbg); if (event) yield event; } } @@ -24,7 +27,7 @@ export async function* iterateEvents(body: ReadableStream): AsyncGen // flush any trailing line without a newline buffer += decoder.decode(); if (buffer.trim()) { - const event = parseLine(buffer); + const event = parseLine(buffer, dbg); if (event) yield event; } } finally { @@ -32,7 +35,7 @@ export async function* iterateEvents(body: ReadableStream): AsyncGen } } -function parseLine(raw: string): Record | null { +function parseLine(raw: string, dbg: boolean): Record | null { let line = raw.trim(); if (!line || line.startsWith(":") || line.startsWith("event:")) return null; if (line.startsWith("data:")) line = line.slice(5).trim(); @@ -41,7 +44,7 @@ function parseLine(raw: string): Record | null { const parsed = JSON.parse(line); return parsed && typeof parsed === "object" ? parsed : null; } catch { - debug("events", "parse-skip", `line=${line.slice(0, 512)}`); + debugWhen(dbg, "events", "parse-skip", `line=${line.slice(0, 512)}`); return null; } } diff --git a/src/log.ts b/src/log.ts index 1eb1246..a8d46a1 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,12 +1,13 @@ -// Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1; silent (and no file -// created) by default. Uses only Node built-ins so the built output stays dependency-free. +// Opt-in tracing to a log file. Gated on COMMANDCODE_DEBUG=1 or the shared toggle file; +// silent (and no file created) by default. Uses only Node built-ins so the built output +// stays dependency-free. import { appendFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { redact } from "./redact.js"; +import { debugFromEnvOrFile } from "./toggles.js"; -const ENABLED = /^(1|true|yes)$/i.test(process.env["COMMANDCODE_DEBUG"] ?? ""); const FILE = process.env["COMMANDCODE_DEBUG_FILE"] ?? join(tmpdir(), "commandcode-debug.log"); function serialize(value: unknown): string { @@ -18,9 +19,7 @@ function serialize(value: unknown): string { } } -/** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */ -export function debug(scope: string, ...args: unknown[]): void { - if (!ENABLED) return; +function emit(scope: string, args: unknown[]): void { const parts = args.map(serialize); const line = `[commandcode] ${new Date().toISOString()} [${scope}] ${parts.join(" ")}`; try { @@ -34,3 +33,20 @@ export function debug(scope: string, ...args: unknown[]): void { } } } + +/** Effective default tracing state (env var or toggle file). Re-read on every call. */ +export function isDebugEnabled(): boolean { + return debugFromEnvOrFile(); +} + +/** Write a trace line (appended) if tracing is enabled. Values are redacted before write. */ +export function debug(scope: string, ...args: unknown[]): void { + if (!debugFromEnvOrFile()) return; + emit(scope, args); +} + +/** Trace with a per-request override, so providerOptions/history state is not global. */ +export function debugWhen(enabled: boolean, scope: string, ...args: unknown[]): void { + if (!enabled) return; + emit(scope, args); +} diff --git a/src/model.ts b/src/model.ts index 9519337..989bc5d 100644 --- a/src/model.ts +++ b/src/model.ts @@ -18,14 +18,38 @@ import { GENERATE_PATH, } from "./constants.js"; import { iterateEvents } from "./events.js"; -import { debug } from "./log.js"; +import { debugWhen, isDebugEnabled } from "./log.js"; import { redact } from "./redact.js"; +import { zdrFromEnvOrFile } from "./toggles.js"; import { transform } from "./transform.js"; import { finishReasonFrom, usageFromFinish, type FinishEvent } from "./usage.js"; // x-cmd-zdr breaks some models; off by default (server.py never sends it). -// COMMANDCODE_ZDR=1|true|yes opts back in. Read at load, like COMMANDCODE_DEBUG. -const ZDR_ENABLED = /^(1|true|yes)$/i.test(process.env["COMMANDCODE_ZDR"] ?? ""); +// Precedence: providerOptions.commandcode.zdr > x-cmd-zdr header > toggle file > COMMANDCODE_ZDR. +// "undefined" means the header is omitted entirely. +function providerScoped(options: LanguageModelV3CallOptions): Record | undefined { + return options.providerOptions?.["commandcode"] as Record | undefined; +} + +function truthyHeader(value: string): boolean { + return !/^(0|false|no|off)$/i.test(value); +} + +function resolveZdr( + options: LanguageModelV3CallOptions, + providerHeaders: Record, +): string | undefined { + const scoped = providerScoped(options)?.["zdr"]; + if (typeof scoped === "boolean") return scoped ? "1" : "0"; + const header = headerValue({ ...providerHeaders, ...(options.headers ?? {}) }, "x-cmd-zdr"); + if (header !== undefined) return truthyHeader(header) ? "1" : "0"; + return zdrFromEnvOrFile() ? "1" : undefined; +} + +function resolveDebug(options: LanguageModelV3CallOptions): boolean { + const scoped = providerScoped(options)?.["debug"]; + return typeof scoped === "boolean" ? scoped : isDebugEnabled(); +} export type CommandCodeOptions = { name?: string; @@ -47,7 +71,7 @@ type ResolvedOptions = { retryMaxDelay: number; }; -function headerValue(headers: Record, key: string): string | undefined { +function headerValue(headers: Record, key: string): string | undefined { const wanted = key.toLowerCase(); for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === wanted) return v; return undefined; @@ -135,7 +159,11 @@ class CommandCodeLanguageModel implements LanguageModelV3 { supportedUrls: Record = {}; - private requestHeaders(extra?: Record): Record { + private requestHeaders( + extra: Record | undefined, + zdr: string | undefined, + dbg: boolean, + ): Record { const headers: Record = { "Content-Type": "application/json", "x-command-code-version": this.opts.ccVersion, @@ -143,24 +171,28 @@ class CommandCodeLanguageModel implements LanguageModelV3 { "x-project-slug": "project", "x-taste-learning": "true", "x-co-flag": "false", - ...(ZDR_ENABLED ? { "x-cmd-zdr": "1" } : {}), ...this.opts.headers, }; - debug("fetch", "zdr", ZDR_ENABLED ? "on" : "off"); + if (zdr !== undefined) headers["x-cmd-zdr"] = zdr; + debugWhen(dbg, "fetch", "zdr", zdr === "1" ? "on" : "off"); const auth = bearer(this.opts.apiKey); if (auth) headers["Authorization"] = auth; for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined) headers[k] = v; return headers; } - private async fetchWithRetry(body: string, options: LanguageModelV3CallOptions): Promise { - const headers = this.requestHeaders(options.headers); + private async fetchWithRetry( + body: string, + options: LanguageModelV3CallOptions, + dbg: boolean, + ): Promise { + const headers = this.requestHeaders(options.headers, resolveZdr(options, this.opts.headers), dbg); let lastError: unknown; const url = `${this.opts.baseURL}${GENERATE_PATH}`; for (let attempt = 0; attempt <= this.opts.maxRetries; attempt++) { if (options.abortSignal?.aborted) { - debug("fetch", "aborted", `attempt=${attempt}`); + debugWhen(dbg, "fetch", "aborted", `attempt=${attempt}`); throw options.abortSignal.reason ?? new Error("Aborted"); } const started = Date.now(); @@ -175,7 +207,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 { } catch (error) { lastError = error; const wait = retryDelay(attempt, null, this.opts.retryMaxDelay); - debug( + debugWhen( + dbg, "fetch", "network-error", `url=${url}`, @@ -194,7 +227,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 { if (isRetryable(status) && attempt < this.opts.maxRetries) { const wait = retryDelay(attempt, response.headers.get("retry-after"), this.opts.retryMaxDelay); if (wait >= 0) { - debug( + debugWhen( + dbg, "fetch", "retry", `url=${url}`, @@ -212,14 +246,14 @@ class CommandCodeLanguageModel implements LanguageModelV3 { continue; } } - debug("fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`); + debugWhen(dbg, "fetch", "response", `url=${url}`, `attempt=${attempt}`, `status=${status}`, `elapsedMs=${Date.now() - started}`); return response; } throw lastError instanceof Error ? lastError : new Error("Upstream unreachable"); } - private async errorFrom(response: Response): Promise { + private async errorFrom(response: Response, dbg: boolean): Promise { let body = ""; try { body = await response.text(); @@ -236,18 +270,19 @@ class CommandCodeLanguageModel implements LanguageModelV3 { /* keep raw body */ } const surfaced = redact(message.slice(0, 2000) || `Upstream returned ${response.status}`); - debug("error", `status=${response.status}`, `body=${surfaced}`); + debugWhen(dbg, "error", `status=${response.status}`, `body=${surfaced}`); return new Error(surfaced); } async doStream(options: LanguageModelV3CallOptions): Promise { + const dbg = resolveDebug(options); const body = transform(options, this.modelId); - debug("doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`); - const response = await this.fetchWithRetry(body, options); - if (!response.ok || !response.body) throw await this.errorFrom(response); - debug("doStream", "ok", `status=${response.status}`); + debugWhen(dbg, "doStream", `model=${this.modelId}`, `bodyBytes=${Buffer.byteLength(body, "utf8")}`); + const response = await this.fetchWithRetry(body, options, dbg); + if (!response.ok || !response.body) throw await this.errorFrom(response, dbg); + debugWhen(dbg, "doStream", "ok", `status=${response.status}`); - const stream = toReadableStream(this.streamParts(response.body)); + const stream = toReadableStream(this.streamParts(response.body, dbg)); return { stream, request: { body: JSON.parse(body) as unknown }, @@ -256,6 +291,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 { } async doGenerate(options: LanguageModelV3CallOptions): Promise { + const dbg = resolveDebug(options); const { stream, request, response } = await this.doStream(options); let text = ""; @@ -302,7 +338,8 @@ class CommandCodeLanguageModel implements LanguageModelV3 { if (text) content.push({ type: "text", text }); content.push(...toolCalls); - debug( + debugWhen( + dbg, "doGenerate", "done", `model=${this.modelId}`, @@ -316,7 +353,7 @@ class CommandCodeLanguageModel implements LanguageModelV3 { return { content, finishReason, usage, warnings, request, response }; } - private async *streamParts(body: ReadableStream): AsyncGenerator { + private async *streamParts(body: ReadableStream, dbg: boolean): AsyncGenerator { yield { type: "stream-start", warnings: [] }; const textId = "text-0"; @@ -329,9 +366,9 @@ class CommandCodeLanguageModel implements LanguageModelV3 { let finishReason: LanguageModelV3FinishReason | undefined; let errored = false; - for await (const evt of iterateEvents(body)) { + for await (const evt of iterateEvents(body, dbg)) { const payload = JSON.stringify(evt); - debug("stream", `event=${evt.type}`, `payload=${payload && payload.length > 4096 ? payload.slice(0, 4096) + "…" : payload}`); + debugWhen(dbg, "stream", `event=${evt.type}`, `payload=${payload && payload.length > 4096 ? payload.slice(0, 4096) + "…" : payload}`); switch (evt.type) { case "text-start": if (!textOpen) { @@ -422,12 +459,12 @@ class CommandCodeLanguageModel implements LanguageModelV3 { } if (errored) { - debug("stream", "terminal", "errored"); + debugWhen(dbg, "stream", "terminal", "errored"); return; } if (textOpen) yield { type: "text-end", id: textId }; if (reasoningOpen) yield { type: "reasoning-end", id: reasoningId }; - debug("stream", "terminal", `finish=${finishReason ? (finishReason.unified ?? "?") : "synthesized"}`, `usage=${JSON.stringify(usage ?? zeroUsage())}`); + debugWhen(dbg, "stream", "terminal", `finish=${finishReason ? (finishReason.unified ?? "?") : "synthesized"}`, `usage=${JSON.stringify(usage ?? zeroUsage())}`); yield { type: "finish", usage: usage ?? zeroUsage(), diff --git a/src/toggles.ts b/src/toggles.ts new file mode 100644 index 0000000..9bb92e6 --- /dev/null +++ b/src/toggles.ts @@ -0,0 +1,61 @@ +// Runtime toggle state shared between the provider (server side) and the slash-command +// TUI plugin (client side) through a small JSON flag file. Node built-ins only so the +// built provider stays dependency-free; never throws on a missing or malformed file. + +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export type Toggles = { zdr?: boolean; debug?: boolean }; + +export type ToggleName = keyof Toggles; + +export function togglesPath(): string { + const override = process.env["COMMANDCODE_TOGGLES_FILE"]; + if (override) return override; + const configHome = process.env["XDG_CONFIG_HOME"] ?? join(homedir(), ".config"); + return join(configHome, "opencode", "commandcode-toggles.json"); +} + +export function readToggles(): Toggles { + try { + const parsed = JSON.parse(readFileSync(togglesPath(), "utf8")) as Record; + if (!parsed || typeof parsed !== "object") return {}; + const out: Toggles = {}; + if (typeof parsed["zdr"] === "boolean") out.zdr = parsed["zdr"]; + if (typeof parsed["debug"] === "boolean") out.debug = parsed["debug"]; + return out; + } catch { + return {}; + } +} + +export function writeToggles(patch: Toggles): Toggles { + const merged = { ...readToggles(), ...patch }; + const path = togglesPath(); + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf8"); + renameSync(tmp, path); + return merged; +} + +export function toggle(name: ToggleName): Toggles { + const current = readToggles(); + return writeToggles({ [name]: current[name] !== true }); +} + +export function envFlag(name: string): boolean { + return /^(1|true|yes)$/i.test(process.env[name] ?? ""); +} + +/** Flag file wins over the environment; unset file falls back to the env var. */ +export function zdrFromEnvOrFile(): boolean { + const file = readToggles().zdr; + return typeof file === "boolean" ? file : envFlag("COMMANDCODE_ZDR"); +} + +export function debugFromEnvOrFile(): boolean { + const file = readToggles().debug; + return typeof file === "boolean" ? file : envFlag("COMMANDCODE_DEBUG"); +} diff --git a/src/tui.ts b/src/tui.ts new file mode 100644 index 0000000..b52177d --- /dev/null +++ b/src/tui.ts @@ -0,0 +1,91 @@ +// opencode TUI plugin exposing /cc-zdr, /cc-debug and /cc-status. It flips the shared +// toggle file (see ./toggles.ts) that the provider reads on every request, so a change +// takes effect without restarting opencode. +// +// opencode loads this module's default export (`{ id, tui }`) from a `tui.json` plugin +// entry; verified against @opencode-ai/plugin@1.18.31. The local structural types below +// keep this package runtime-dependency-free. Note: in this opencode version slash +// commands and the Ctrl+P palette read the same `namespace: "palette"` command list, so +// these entries appear in both; `hidden: true` would remove them from both. + +import { readToggles, toggle, type ToggleName } from "./toggles.js"; + +type ToastVariant = "info" | "success" | "warning" | "error"; + +type TuiCommand = { + name: string; + title?: string; + desc?: string; + category?: string; + namespace?: string; + slashName?: string; + slashAliases?: string[]; + suggested?: boolean; + hidden?: boolean; + enabled?: boolean | (() => boolean); + run: () => void | Promise; +}; + +type TuiApi = { + keymap: { registerLayer(layer: { commands?: readonly TuiCommand[] }): () => void }; + ui: { toast(input: { title?: string; message: string; variant?: ToastVariant; duration?: number }): void }; +}; + +const ID = "commandcode-toggles"; +const CATEGORY = "CommandCode"; + +function state(value: boolean | undefined): string { + return value === true ? "on" : "off"; +} + +function summary(): string { + const toggles = readToggles(); + return `zdr=${state(toggles.zdr)}, debug=${state(toggles.debug)}`; +} + +export const tui = async (api: TuiApi): Promise => { + const flip = (name: ToggleName): void => { + const next = toggle(name); + api.ui.toast({ + title: CATEGORY, + message: `${name} ${state(next[name])} (${summary()})`, + variant: next[name] === true ? "success" : "info", + }); + }; + + api.keymap.registerLayer({ + commands: [ + { + name: `${ID}.zdr`, + title: "CommandCode: toggle ZDR header", + desc: "Send the x-cmd-zdr header on requests", + category: CATEGORY, + namespace: "palette", + slashName: "cc-zdr", + run: () => flip("zdr"), + }, + { + name: `${ID}.debug`, + title: "CommandCode: toggle debug tracing", + desc: "Write a redacted request and stream trace to a log file", + category: CATEGORY, + namespace: "palette", + slashName: "cc-debug", + run: () => flip("debug"), + }, + { + name: `${ID}.status`, + title: "CommandCode: show toggle status", + desc: "Show the current zdr and debug state", + category: CATEGORY, + namespace: "palette", + slashName: "cc-status", + run: () => api.ui.toast({ title: CATEGORY, message: summary(), variant: "info" }), + }, + ], + }); +}; + +export const id = ID; + +export default { id: ID, tui };