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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
8 changes: 5 additions & 3 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/` 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 |
Expand All @@ -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**:

Expand Down
191 changes: 191 additions & 0 deletions src/commands/tui/__tests__/config-tab.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {};
const KEYS = [
"AGENTSYNC_VAULT_DIR",
"AGENTSYNC_KEY_PATH",
"AGENTSYNC_MACHINE",
"AGENTSYNC_MACHINE_FILE",
];

async function waitFor(cond: () => boolean): Promise<void> {
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<void> {
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();
});
});
27 changes: 26 additions & 1 deletion src/commands/tui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,6 +30,7 @@ const TAB_LABELS: Record<TabId, string> = {
machines: "Machines",
migrate: "Migrate",
activity: "Activity",
config: "Config",
};

const PALETTE = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -582,6 +591,10 @@ function renderActiveTab(
case "activity":
renderActivity(renderer, host, state);
break;
case "config":
ensureConfigLoaded(store);
renderConfig(renderer, host, state);
break;
}
}
}
Expand All @@ -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",
Expand Down Expand Up @@ -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)",
"",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
" Activity",
" c Clear log",
"",
Expand Down Expand Up @@ -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" },
];
}
}
39 changes: 38 additions & 1 deletion src/commands/tui/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -224,6 +252,7 @@ export interface AppState {
sync: SyncSlice;
machines: MachinesSlice;
migrate: MigrateSlice;
config: ConfigSlice;
inFlight: Record<string, OperationStatus>;
opSeq: number;
update: UpdateInfo;
Expand Down Expand Up @@ -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() },
Expand Down
Loading
Loading