diff --git a/CLAUDE.md b/CLAUDE.md index 8d1bdf2..69331ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,8 +101,9 @@ bun run check:act # run CI workflow locally via nektos/act `src/config/paths.ts` rather than hardcoding `~/.claude`, `~/.cursor`, etc. — this keeps the test harness and platform overrides working. - **TUI reuses command logic, never duplicates it**: the TUI wizards, the - Machines tab, and the Migrate tab call `performInit`, `performKeyAdd`, - `performKeyRotate`, `performMigrate`, `performVaultRemove`, and `performCopy` + Machines tab, the Migrate tab, and the Config tab call `performInit`, + `performKeyAdd`, `performKeyRotate`, `performMigrate`, `performVaultRemove`, + `performCopy`, and `performConfigSet`/`performConfigList`/`performKeyList` directly. Adding new TUI features must not fork business logic — encryption, reconciliation, sanitiser, and migration invariants live in one place. - **`destroy` never imports `AgentPaths`**: the agent-files-never-touched diff --git a/README.md b/README.md index f084d62..3a0f8fa 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The full quickstart, command reference, and architecture model live at the docum | Command | Why you run it | |---|---| -| *(bare)* / `tui` | Open the interactive TUI: vault browser, per-agent local view, push, browse machines and copy, and migrate. | +| *(bare)* / `tui` | Open the interactive TUI: vault browser, per-agent local view, push, browse machines and copy, migrate, and a Config tab to change settings. | | `init` | Create the local vault workspace, machine key, config, and initial remote state. | | `push` | Snapshot local agent configs, sanitise secrets, encrypt artefacts, and push to Git. | | `copy` | Restore an artefact (or subdir) from a machine's vault namespace to local disk (`copy self …` for your own). | diff --git a/docs/commands.md b/docs/commands.md index a6aa7e5..5edcc73 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -73,17 +73,18 @@ agentsync tui # explicit alias, same behaviour | Tab | What it shows | |---|---| -| 1 Dashboard | Daemon health (pid, uptime, last error), vault state, agent summary, init / key-rotate launchers. | +| 1 Dashboard | Daemon health (pid, uptime, **last successful sync**, last error, and a loud **stuck** warning when the vault diverged), vault state, agent summary, init / key-rotate launchers. | | 2 Sync | Per-artifact rows grouped by sync status (`local-changed`, `local-only`, `vault-only`, `unknown`, `synced`). Multi-select with `space`; push selected with `p`; bulk-remove any selected vault artifacts with `x` (y/n confirm) — rows with no vault copy (`local-only`) are ignored. Enter on a skill drills into its files with per-file diff. | | 3 Machines | The vault's `machines//` namespaces. Move with `↑`/`↓`; `enter` copies the selected machine's config to this machine (the same `performCopy` core as the CLI; never touches the vault). | | 4 Migrate | From / To / Type form (To and Type are multi-select with sub-cursor). Preview is mandatory before Apply enables. | | 5 Activity | Session-only ring buffer of TUI actions. | +| 6 Config | View and change vault config (agents enabled, `sync.*`, `claudePlugins.*`, `security.*`) with `↑`/`↓` to move, `space` to toggle a boolean, `←`/`→` to cycle an enum or adjust a number. Writes go through the same [`config`](#config) core (reconcile + commit + push). Also lists the recipients who can decrypt the vault, read-only. | **Global keys** (any tab): | Key | Action | |---|---| -| `1` – `5` | Jump to tab | +| `1` – `6` | Jump to tab | | `Tab` / `Shift-Tab` | Cycle tabs | | `p` | Push vault (honours selection in the Sync tab as a per-file allowlist) | | `r` | Refresh current tab | @@ -95,7 +96,8 @@ subcommands operate on. Push goes through the same daemon IPC the `status` and `daemon` subcommands use; the Machines tab calls the same `performCopy` core as `agentsync copy`; migrate calls the same planner as `agentsync migrate`; bulk removal calls the same `performVaultRemove` core that `agentsync skill remove` -delegates to, once per selected vault artifact. +delegates to, once per selected vault artifact; the Config tab writes through +the same `performConfigSet` core as `agentsync config set`. **Caveats**: diff --git a/src/commands/tui/__tests__/config-tab.test.ts b/src/commands/tui/__tests__/config-tab.test.ts new file mode 100644 index 0000000..7eba480 --- /dev/null +++ b/src/commands/tui/__tests__/config-tab.test.ts @@ -0,0 +1,191 @@ +/** + * Tests for the Config tab: pure cursor navigation and the edit handlers + * (toggle boolean, cycle enum, adjust number) writing through the shared + * `performConfigSet` core against a real seeded vault. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import type { KeyEvent } from "@opentui/core"; +import { loadConfig, resolveConfigPath } from "../../../config/loader"; +import { + createBareRepo, + createMachineFixture, + createTmpDir, + seedVaultRepo, + type TestMachineFixture, +} from "../../../test-helpers/fixtures"; +import { type ConfigRow, createInitialState } from "../state"; +import { createStore, type Store } from "../store"; +import { ensureConfigLoaded, onConfigKey } from "../tabs/config"; + +function key(name: string): KeyEvent { + return { name, sequence: name, ctrl: false, meta: false, shift: false } as unknown as KeyEvent; +} + +function readyStore(rows: ConfigRow[], cursor = 0): Store { + const store = createStore(createInitialState()); + store.dispatch((d) => { + d.activeTab = "config"; + d.config.phase = "ready"; + d.config.rows = rows; + d.config.cursor = cursor; + }); + return store; +} + +describe("onConfigKey — navigation", () => { + const rows: ConfigRow[] = [ + { key: "agents.claude", value: true, kind: "boolean" }, + { key: "agents.vscode", value: false, kind: "boolean" }, + { + key: "security.secretScan", + value: "standard", + kind: "enum", + options: ["standard", "strict", "off"], + }, + ]; + + test("down/up move the cursor within bounds", () => { + const store = readyStore(rows); + expect(onConfigKey(key("down"), store)).toBe(true); + expect(store.getState().config.cursor).toBe(1); + onConfigKey(key("down"), store); + onConfigKey(key("down"), store); // clamps at last + expect(store.getState().config.cursor).toBe(2); + onConfigKey(key("up"), store); + expect(store.getState().config.cursor).toBe(1); + }); + + test("keys are ignored until ready and non-empty", () => { + expect(onConfigKey(key("down"), createStore(createInitialState()))).toBe(false); + expect(onConfigKey(key("down"), readyStore([]))).toBe(false); + }); + + test("a read-only row does not consume the edit keys", () => { + const store = readyStore([{ key: "security.allowSecretValues", value: [], kind: "readonly" }]); + expect(onConfigKey(key("space"), store)).toBe(false); + expect(onConfigKey(key("left"), store)).toBe(false); + }); + + test("number left at the lower bound consumes the key but writes nothing", () => { + const store = readyStore([{ key: "sync.debounceMs", value: 50, kind: "number" }]); + expect(onConfigKey(key("left"), store)).toBe(true); + expect(store.getState().config.lastResult).toBeNull(); + }); +}); + +describe("Config tab against a real vault", () => { + let tmpDir: string; + let machine: TestMachineFixture; + const savedEnv: Record = {}; + const KEYS = [ + "AGENTSYNC_VAULT_DIR", + "AGENTSYNC_KEY_PATH", + "AGENTSYNC_MACHINE", + "AGENTSYNC_MACHINE_FILE", + ]; + + async function waitFor(cond: () => boolean): Promise { + for (let i = 0; i < 300 && !cond(); i++) await new Promise((r) => setTimeout(r, 10)); + } + + beforeEach(async () => { + tmpDir = await createTmpDir(); + const bare = await createBareRepo(tmpDir); + machine = await createMachineFixture(tmpDir, "config-tab"); + seedVaultRepo({ machine, bareRepoPath: bare }); + for (const k of KEYS) savedEnv[k] = process.env[k]; + process.env.AGENTSYNC_VAULT_DIR = machine.vaultDir; + process.env.AGENTSYNC_KEY_PATH = machine.keyPath; + process.env.AGENTSYNC_MACHINE = machine.machineName; + process.env.AGENTSYNC_MACHINE_FILE = machine.machineFilePath; + }); + + afterEach(async () => { + for (const k of KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + test("ensureConfigLoaded loads settable rows and recipients", async () => { + const store = createStore(createInitialState()); + ensureConfigLoaded(store); + await waitFor(() => store.getState().config.phase === "ready"); + + const c = store.getState().config; + expect(c.phase).toBe("ready"); + const keys = c.rows.map((r) => r.key); + expect(keys).toContain("agents.claude"); + expect(keys).toContain("security.secretScan"); + // No non-settable keys leak in. + expect(keys.some((k) => k.startsWith("remote") || k === "version")).toBe(false); + expect(c.rows.find((r) => r.key === "security.secretScan")?.kind).toBe("enum"); + expect(c.rows.find((r) => r.key === "sync.debounceMs")?.kind).toBe("number"); + expect(c.recipients.find((r) => r.name === "config-tab")?.isSelf).toBe(true); + }); + + async function loadAndFocus(store: Store, key: string): Promise { + ensureConfigLoaded(store); + await waitFor(() => store.getState().config.phase === "ready"); + const idx = store.getState().config.rows.findIndex((r) => r.key === key); + store.dispatch((d) => { + d.config.cursor = idx; + }); + } + + test("space toggles a boolean and persists it through performConfigSet", async () => { + const store = createStore(createInitialState()); + await loadAndFocus(store, "agents.vscode"); // default false + expect(onConfigKey(key("space"), store)).toBe(true); + await waitFor(() => store.getState().config.lastResult !== null); + + expect(store.getState().config.lastResult?.ok).toBe(true); + const config = await loadConfig(resolveConfigPath(machine.vaultDir)); + expect(config.agents.vscode).toBe(true); + }); + + test("right cycles an enum and persists it", async () => { + const store = createStore(createInitialState()); + await loadAndFocus(store, "security.secretScan"); // standard + expect(onConfigKey(key("right"), store)).toBe(true); + await waitFor(() => store.getState().config.lastResult !== null); + + const config = await loadConfig(resolveConfigPath(machine.vaultDir)); + expect(config.security.secretScan).toBe("strict"); + }); + + test("right adjusts a number within bounds and persists it", async () => { + const store = createStore(createInitialState()); + await loadAndFocus(store, "sync.debounceMs"); // default 300 + expect(onConfigKey(key("right"), store)).toBe(true); + await waitFor(() => store.getState().config.lastResult !== null); + + const config = await loadConfig(resolveConfigPath(machine.vaultDir)); + expect(config.sync.debounceMs).toBe(350); + }); + + test("left cycles an enum backwards and wraps to the last option", async () => { + const store = createStore(createInitialState()); + await loadAndFocus(store, "security.secretScan"); // standard (index 0) + expect(onConfigKey(key("left"), store)).toBe(true); + await waitFor(() => store.getState().config.lastResult !== null); + + const config = await loadConfig(resolveConfigPath(machine.vaultDir)); + expect(config.security.secretScan).toBe("off"); // wrapped to last + }); + + test("ensureConfigLoaded surfaces an error (not a crash) on an un-init vault", async () => { + const empty = join(tmpDir, "no-vault-here"); + await mkdir(empty, { recursive: true }); + process.env.AGENTSYNC_VAULT_DIR = empty; + + const store = createStore(createInitialState()); + ensureConfigLoaded(store); + await waitFor(() => store.getState().config.phase === "error"); + expect(store.getState().config.phase).toBe("error"); + expect(store.getState().config.error).not.toBeNull(); + }); +}); diff --git a/src/commands/tui/app.ts b/src/commands/tui/app.ts index 83877a8..1900f51 100644 --- a/src/commands/tui/app.ts +++ b/src/commands/tui/app.ts @@ -16,6 +16,7 @@ import { } from "./state"; import { createStore, type Store } from "./store"; import { renderActivity } from "./tabs/activity"; +import { ensureConfigLoaded, onConfigKey, renderConfig } from "./tabs/config"; import { renderDashboard } from "./tabs/dashboard"; import { ensureMachinesLoaded, onMachinesKey, renderMachines } from "./tabs/machines"; import { onMigrateKey, renderMigrate } from "./tabs/migrate"; @@ -29,6 +30,7 @@ const TAB_LABELS: Record = { machines: "Machines", migrate: "Migrate", activity: "Activity", + config: "Config", }; const PALETTE = { @@ -386,6 +388,9 @@ function delegateTabKey(key: KeyEvent, ctx: AppContext): void { case "machines": onMachinesKey(key, ctx.store); break; + case "config": + onConfigKey(key, ctx.store); + break; case "migrate": onMigrateKey(key, ctx.store); break; @@ -533,6 +538,10 @@ function actionLabelFor(key: KeyEvent, state: AppState): { key: string; label: s case "activity": if (name === "c") return { key: "c", label: "clear" }; break; + case "config": + if (name === "space") return { key: "space", label: "toggle" }; + if (name === "left" || name === "right") return { key: "← →", label: "change" }; + break; } return null; } @@ -582,6 +591,10 @@ function renderActiveTab( case "activity": renderActivity(renderer, host, state); break; + case "config": + ensureConfigLoaded(store); + renderConfig(renderer, host, state); + break; } } } @@ -590,7 +603,7 @@ function renderHelp(renderer: CliRenderer, host: BoxRenderable, state: AppState) const help = [ "", " Global keys", - " 1 – 5 Jump to tab", + " 1 – 6 Jump to tab", " Tab / Sh+Tab Cycle tabs", " p Push vault (direct)", " r Refresh current tab", @@ -647,6 +660,12 @@ function renderHelp(renderer: CliRenderer, host: BoxRenderable, state: AppState) " Shift-P Run preview", " Shift-A Apply (after a matching preview)", "", + " Config", + " ↑ / ↓ Move between settings", + " space Toggle a boolean setting", + " ← / → Cycle an enum / adjust a number", + " (changes reconcile + push to the vault)", + "", " Activity", " c Clear log", "", @@ -697,5 +716,11 @@ function contextActionsForTab(state: AppState): ContextAction[] { ]; case "activity": return [{ key: "c", label: "clear" }]; + case "config": + return [ + { key: "↑↓", label: "move" }, + { key: "space", label: "toggle" }, + { key: "← →", label: "change" }, + ]; } } diff --git a/src/commands/tui/state.ts b/src/commands/tui/state.ts index 7a265b7..18ccbb1 100644 --- a/src/commands/tui/state.ts +++ b/src/commands/tui/state.ts @@ -2,7 +2,7 @@ import type { DaemonStatus } from "../../config/schema"; import { detectInstallMethod, type InstallMethod } from "../../core/version-check"; import type { SyncRow } from "../status"; -export const TAB_IDS = ["dashboard", "sync", "machines", "migrate", "activity"] as const; +export const TAB_IDS = ["dashboard", "sync", "machines", "migrate", "activity", "config"] as const; export type TabId = (typeof TAB_IDS)[number]; export const AGENTS = ["claude", "cursor", "codex", "copilot", "vscode"] as const; @@ -36,6 +36,8 @@ export type OpKind = | "vault-rm" | "sync-load" | "machines-load" + | "config-load" + | "config-set" | "upgrade"; /** Background update-check result. Populated once the TUI's startup check @@ -212,6 +214,32 @@ export interface MachinesSlice { lastCopy: { machine: string; ok: boolean; message: string } | null; } +/** How a config value is edited in the Config tab. */ +export type ConfigRowKind = "boolean" | "enum" | "number" | "readonly"; + +/** One editable (or read-only) config row in the Config tab. */ +export interface ConfigRow { + /** Dotted config key (e.g. `agents.vscode`). */ + key: string; + value: unknown; + kind: ConfigRowKind; + /** Allowed values for an enum row (e.g. secretScan modes). */ + options?: readonly string[]; +} + +/** Config tab — view and change vault config through `performConfigSet`. */ +export interface ConfigSlice { + phase: LoadablePhase; + /** Settable rows, in display order. Read-only rows render but the cursor skips edits. */ + rows: ConfigRow[]; + /** Recipients who can decrypt the vault, surfaced read-only (`key list`). */ + recipients: { name: string; recipient: string; isSelf: boolean }[]; + cursor: number; + error: string | null; + /** Result of the most recent set, kept visible until the next one. */ + lastResult: { ok: boolean; message: string } | null; +} + export interface AppState { activeTab: TabId; daemon: DaemonState; @@ -224,6 +252,7 @@ export interface AppState { sync: SyncSlice; machines: MachinesSlice; migrate: MigrateSlice; + config: ConfigSlice; inFlight: Record; opSeq: number; update: UpdateInfo; @@ -279,6 +308,14 @@ export function createInitialState(): AppState { previewKey: null, appliedSignature: null, }, + config: { + phase: "idle", + rows: [], + recipients: [], + cursor: 0, + error: null, + lastResult: null, + }, inFlight: {}, opSeq: 0, update: { latest: null, available: false, method: detectInstallMethod() }, diff --git a/src/commands/tui/tabs/config.ts b/src/commands/tui/tabs/config.ts new file mode 100644 index 0000000..2e5da13 --- /dev/null +++ b/src/commands/tui/tabs/config.ts @@ -0,0 +1,263 @@ +import type { CliRenderer, KeyEvent } from "@opentui/core"; +import { BoxRenderable, TextRenderable } from "@opentui/core"; +import { peekVaultVersion, resolveConfigPath } from "../../../config/loader"; +import { performConfigList, performConfigSet } from "../../config"; +import { performKeyList } from "../../key"; +import { resolveRuntimeContext } from "../../shared"; +import { type AppState, type ConfigRow, setToast } from "../state"; +import type { Store } from "../store"; + +// Sections the Config tab lets you edit. Mirrors SETTABLE_PREFIXES in +// commands/config.ts; remote/version/recipients are not editable here. +const SETTABLE_PREFIXES = ["agents.", "sync.", "claudePlugins.", "security."]; +const SECRET_SCAN_OPTIONS = ["standard", "strict", "off"] as const; +const DEBOUNCE_STEP = 50; +const DEBOUNCE_MIN = 50; +const DEBOUNCE_MAX = 10_000; + +/** Classify a config key/value into an editable row kind. */ +function classify(key: string, value: unknown): ConfigRow { + if (key === "security.secretScan") { + return { key, value, kind: "enum", options: SECRET_SCAN_OPTIONS }; + } + if (key === "sync.debounceMs") return { key, value, kind: "number" }; + if (key === "security.allowSecretValues") return { key, value, kind: "readonly" }; + if (typeof value === "boolean") return { key, value, kind: "boolean" }; + return { key, value, kind: "readonly" }; +} + +/** + * Load the settable config rows and the recipient list once per tab visit. + * Reuses `performConfigList`/`performKeyList` so the TUI never forks the config + * logic. `peekVaultVersion` runs FIRST and throws on anything other than a v2 + * vault: `loadVaultConfigOrExit` (called by the perform* functions) calls + * `process.exit` on an absent/v1/unsupported vault, which would kill the whole + * TUI. Throwing a catchable error here keeps the tab in `phase: "error"`, which + * also means the edit handlers never run (no rows, not ready) so `setConfig` + * cannot reach `process.exit` either. + */ +export function ensureConfigLoaded(store: Store): void { + if (store.getState().config.phase !== "idle") return; + store.dispatch((d) => { + d.config.phase = "loading"; + d.config.error = null; + }); + store.runOperation( + "config-load", + "load config", + async () => { + const runtime = await resolveRuntimeContext(); + const probe = await peekVaultVersion(resolveConfigPath(runtime.vaultDir)); + if (probe.kind !== "v2") { + throw new Error( + probe.kind === "absent" + ? "Vault not initialized — run `agentsync init`." + : probe.kind === "v1" + ? "Vault uses the old layout — run `agentsync vault upgrade`." + : `Vault format v${probe.version} is newer than this agentsync — run \`agentsync upgrade\`.`, + ); + } + const [entries, recipients] = await Promise.all([performConfigList(), performKeyList()]); + return { entries, recipients }; + }, + { + onSuccess: (d, { entries, recipients }) => { + d.config.rows = entries + .filter((e) => SETTABLE_PREFIXES.some((p) => e.key.startsWith(p))) + .map((e) => classify(e.key, e.value)); + d.config.recipients = recipients; + d.config.cursor = Math.min(d.config.cursor, Math.max(0, d.config.rows.length - 1)); + d.config.phase = "ready"; + }, + onError: (d, err) => { + d.config.phase = "error"; + d.config.error = err.message; + }, + }, + ); +} + +/** Push one config change through the shared `performConfigSet` core. */ +function setConfig(store: Store, key: string, rawValue: string): void { + // performConfigSet reconciles + commits + pushes the shared git working tree, + // which is not concurrency-safe. Refuse a second edit while one is in flight + // rather than race two git operations on the same repo. + const busy = Object.values(store.getState().inFlight).some( + (op) => op.kind === "config-set" && op.phase === "running", + ); + if (busy) { + store.dispatch((d) => + setToast(d, "A config change is already in flight — wait for it to finish.", "info"), + ); + return; + } + store.runOperation( + "config-set", + `set ${key}`, + async () => { + const result = await performConfigSet(key, rawValue); + if (result.status !== "success") { + const why = + result.status === "invalid-value" ? result.error : `config set ${key}: ${result.status}`; + throw new Error(why); + } + return result.newValue; + }, + { + onSuccess: (d, newValue) => { + const row = d.config.rows.find((r) => r.key === key); + if (row) row.value = newValue; + d.config.lastResult = { ok: true, message: `${key} = ${formatValue(newValue)}` }; + }, + onError: (d, err) => { + d.config.lastResult = { ok: false, message: err.message }; + setToast(d, `config set failed: ${err.message}`, "error"); + }, + errorToastPrefix: "config", + }, + ); +} + +/** Handle Config-tab keys. Returns true when the key was consumed. */ +export function onConfigKey(key: KeyEvent, store: Store): boolean { + const c = store.getState().config; + if (c.phase !== "ready" || c.rows.length === 0) return false; + + if (key.name === "down") { + store.dispatch((d) => { + d.config.cursor = Math.min(d.config.cursor + 1, d.config.rows.length - 1); + }); + return true; + } + if (key.name === "up") { + store.dispatch((d) => { + d.config.cursor = Math.max(d.config.cursor - 1, 0); + }); + return true; + } + + const row = c.rows[c.cursor]; + if (!row) return false; + + if (key.name === "space" && row.kind === "boolean") { + setConfig(store, row.key, String(!(row.value === true))); + return true; + } + if ((key.name === "left" || key.name === "right") && row.kind === "enum" && row.options) { + const dir = key.name === "right" ? 1 : -1; + // When the current value isn't a known option, seed from the end the + // direction moves toward so the first press lands on a sensible option. + const found = row.options.indexOf(String(row.value)); + const idx = found === -1 ? (dir === 1 ? -1 : 0) : found; + const next = row.options[(idx + dir + row.options.length) % row.options.length]; + if (next !== undefined && next !== row.value) setConfig(store, row.key, next); + return true; + } + if ((key.name === "left" || key.name === "right") && row.kind === "number") { + const dir = key.name === "right" ? DEBOUNCE_STEP : -DEBOUNCE_STEP; + const current = typeof row.value === "number" ? row.value : DEBOUNCE_MIN; + const next = Math.min(DEBOUNCE_MAX, Math.max(DEBOUNCE_MIN, current + dir)); + if (next !== current) setConfig(store, row.key, String(next)); + return true; + } + return false; +} + +/** Render a config value for display. */ +function formatValue(value: unknown): string { + return typeof value === "string" ? value : JSON.stringify(value); +} + +/** Hint describing how the row under the cursor is edited. */ +function editHintFor(row: ConfigRow | undefined): string { + if (!row) return ""; + switch (row.kind) { + case "boolean": + return "space: toggle"; + case "enum": + return "← →: cycle"; + case "number": + return "← →: adjust"; + default: + return "read-only (edit via CLI)"; + } +} + +export function renderConfig(renderer: CliRenderer, host: BoxRenderable, state: AppState): void { + const c = state.config; + const wrapper = new BoxRenderable(renderer, { + flexDirection: "column", + width: "100%", + flexGrow: 1, + backgroundColor: "#11151a", + border: false, + }); + host.add(wrapper); + + let body: string; + if (c.phase === "loading" || c.phase === "idle") { + body = "\n Loading config…"; + } else if (c.phase === "error") { + body = `\n Could not load config: ${c.error ?? "unknown error"}\n (Run \`agentsync init\` first if this machine has no vault.)`; + } else if (c.rows.length === 0) { + body = "\n No editable settings found."; + } else { + const width = Math.max(...c.rows.map((r) => r.key.length)); + body = [ + "", + ...c.rows.map((r, i) => { + const marker = i === c.cursor ? "›" : " "; + const ro = r.kind === "readonly" ? " (read-only)" : ""; + return ` ${marker} ${r.key.padEnd(width)} = ${formatValue(r.value)}${ro}`; + }), + "", + ].join("\n"); + } + + const listBox = new BoxRenderable(renderer, { + flexGrow: 1, + width: "100%", + border: true, + borderColor: "#3b4252", + borderStyle: "single", + title: " Config (writes go to the vault, shared across machines) ", + backgroundColor: "#11151a", + }); + listBox.add(new TextRenderable(renderer, { content: body, fg: "#d8dee9", bg: "#11151a" })); + wrapper.add(listBox); + + // Recipients — who can decrypt the vault (read-only; `key list`). + const recipientsBody = + c.recipients.length > 0 + ? c.recipients + .map((r) => ` ${r.isSelf ? "*" : " "} ${r.name} ${r.recipient.slice(0, 20)}…`) + .join("\n") + : " (none)"; + const recipientsBox = new BoxRenderable(renderer, { + height: Math.min(8, c.recipients.length + 2), + width: "100%", + border: true, + borderColor: "#3b4252", + borderStyle: "single", + title: " Recipients — who can decrypt ('*' = this machine) ", + backgroundColor: "#11151a", + }); + recipientsBox.add( + new TextRenderable(renderer, { content: recipientsBody, fg: "#a9b3c0", bg: "#11151a" }), + ); + wrapper.add(recipientsBox); + + const cursorRow = c.phase === "ready" ? c.rows[c.cursor] : undefined; + const hint = c.lastResult + ? ` last: ${c.lastResult.ok ? "✓" : "✗"} ${c.lastResult.message}` + : ` ↑↓ move • ${editHintFor(cursorRow)} • changes reconcile + push to the vault`; + wrapper.add( + new TextRenderable(renderer, { + height: 2, + width: "100%", + fg: "#6c7886", + bg: "#11151a", + content: `\n${hint}`, + }), + ); +} diff --git a/src/commands/tui/tabs/dashboard.ts b/src/commands/tui/tabs/dashboard.ts index 7880f7f..9a09e10 100644 --- a/src/commands/tui/tabs/dashboard.ts +++ b/src/commands/tui/tabs/dashboard.ts @@ -37,6 +37,11 @@ export function renderDashboard(renderer: CliRenderer, host: BoxRenderable, stat const daemonPid = state.daemon.status?.pid ?? "—"; const daemonFails = state.daemon.status?.consecutiveFailures ?? 0; const daemonLastErr = state.daemon.status?.lastError ?? "—"; + const lastSuccessAt = state.daemon.status?.lastSuccessAt ?? null; + const stuck = state.daemon.status?.stuck ?? false; + const lastSync = lastSuccessAt + ? `${fmtDuration(Date.now() - Date.parse(lastSuccessAt))} ago` + : "never"; const uptime = state.daemon.online && state.daemon.pidObservedAt ? fmtDuration(Date.now() - state.daemon.pidObservedAt) @@ -47,16 +52,18 @@ export function renderDashboard(renderer: CliRenderer, host: BoxRenderable, stat ` status ${state.daemon.online ? "● running" : "○ stopped"}`, ` pid ${daemonPid}`, ` uptime ${uptime} (since this TUI started)`, + ` lastSync ${lastSync}`, ` fails ${daemonFails}`, ` lastErr ${daemonLastErr}`, ` inFlight ${running > 0 ? `${running} op(s) running` : "idle"}`, + ...(stuck ? [" ⚠ STUCK: vault diverged — reset the vault, auto-sync is paused"] : []), "", ].join("\n"); const daemonBox = new BoxRenderable(renderer, { - height: 10, + height: stuck ? 13 : 12, width: "100%", border: true, - borderColor: "#3b4252", + borderColor: stuck ? "#bf616a" : "#3b4252", borderStyle: "single", title: " Daemon ", backgroundColor: "#11151a", @@ -72,7 +79,7 @@ export function renderDashboard(renderer: CliRenderer, host: BoxRenderable, stat // Hint panel const hint = new TextRenderable(renderer, { - height: 6, + height: 7, width: "100%", fg: "#6c7886", bg: "#11151a", @@ -82,6 +89,7 @@ export function renderDashboard(renderer: CliRenderer, host: BoxRenderable, stat " [3] Machines browse other machines' namespaces and copy from them", " [4] Migrate translate config from one agent to another", " [5] Activity recent operation log", + " [6] Config toggle agents, sync, and security policy", ].join("\n"), }); wrapper.add(hint); diff --git a/src/config/schema.ts b/src/config/schema.ts index 5e61378..d755f78 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -122,9 +122,11 @@ export const DaemonStatusSchema = z.object({ consecutiveFailures: z.number().int().min(0), lastError: z.string().nullable(), // Health fields. Optional with defaults so an older daemon's IPC response - // (which omits them) still validates against a newer client. - lastSuccessAt: z.string().nullable().default(null), - startedAt: z.string().nullable().default(null), + // (which omits them) still validates against a newer client. Timestamps are + // validated as ISO datetimes so a malformed value fails safeParse (degrading + // to null) rather than reaching Date.parse as NaN in the dashboard/status. + lastSuccessAt: z.string().datetime().nullable().default(null), + startedAt: z.string().datetime().nullable().default(null), stuck: z.boolean().default(false), });