From 4e685870bf7cfa8275c3924b67037435fbb525d3 Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Sat, 5 Sep 2026 00:39:05 +0300 Subject: [PATCH 1/2] feat(tui): integrations hub Adds an `Integrations` tab: the single place an operator puts third-party credentials. Composio is its first tenant; Telegram and Discord follow in their own PRs. Before this, every integration grew its own surface -- Telegram had a tab, LLM providers had a wizard, Composio had nothing -- so "where do I put my key" required already knowing which kind of thing a given service was. An integration declares itself as a descriptor: id, label, summary, docs URL, credential fields, and a status() projection. The hub renders any descriptor without a bespoke pane, so adding one is a descriptor file plus a registry line rather than a new TUI slice. Reachable as the `integrations` Manage tab, `/integrations`, or ctrl+g y (`i` was already Import's chord). Design notes: - Credentials live in /.env via setDotenvKey (0600, atomic), never in config.json, and process.env is updated in the same breath so the running process sees a new key without a restart. - A secret is never rendered in the clear except in the edit buffer being typed: values are masked and length-capped, starting an edit opens an empty buffer rather than seeding the stored value, and the buffer is cleared when the action settles. - Edit mode swallows the whole keyboard. `d`, `e` and `r` are bindings on this tab; inside the editor they are key material, and a paste that silently triggered "clear field" halfway through would be both baffling and destructive. - A re-sync clamps the cursor instead of resetting it, so a background refresh cannot move the operator's place mid-edit. - Changing the Composio key drops the cached tool-router session: a session belongs to the key that created it, and reusing it across a key swap would keep talking to the old account. - Field env vars are pinned to the shape setDotenvKey accepts, which would otherwise throw in front of the operator at save time. Slash-palette ranks from 26 up shift by one to seat `/integrations` after `/mcp`; the pinned palette and menu-children lists move with it. Verified against a live Composio key: with no key the tab reads "not configured" and the runtime has no MCP servers; saving the key mounts the server and flips the badge to "connected" with the value masked, no restart; a non-ASCII key is refused at entry with a readable message; clearing unmounts the server and returns the tab to "not configured". --- AGENTS.md | 15 ++ src/integrations/composio-integration.test.ts | 55 +++++ src/integrations/composio-integration.ts | 69 ++++++ src/integrations/index.ts | 22 ++ src/integrations/integration-descriptor.ts | 103 ++++++++ src/integrations/integration-registry.test.ts | 64 +++++ src/integrations/integration-registry.ts | 23 ++ src/integrations/integration-secrets.test.ts | 145 ++++++++++++ src/integrations/integration-secrets.ts | 90 +++++++ src/tui/agent-event-reducer.ts | 3 + src/tui/chat-orchestrator.ts | 3 + src/tui/components/debug-pane.tsx | 21 ++ .../components/integrations-panel.test.tsx | 97 ++++++++ .../components/integrations-panel.tsx | 223 ++++++++++++++++++ src/tui/integrations/index.ts | 20 ++ src/tui/integrations/integrations-actions.ts | 27 +++ .../integrations-key-bindings.test.ts | 176 ++++++++++++++ .../integrations/integrations-key-bindings.ts | 120 ++++++++++ .../integrations/integrations-orchestrator.ts | 144 +++++++++++ .../integrations-panel-reducer.test.ts | 142 +++++++++++ .../integrations-panel-reducer.ts | 97 ++++++++ .../integrations/integrations-panel-state.ts | 81 +++++++ src/tui/menu/menu-behaviour.test.ts | 3 +- src/tui/menu/menu-registry.test.ts | 6 + src/tui/menu/menu-registry.ts | 46 ++-- src/tui/section.ts | 1 + src/tui/tui-action.ts | 2 + src/tui/tui-app.tsx | 26 ++ src/tui/tui-command.ts | 6 + src/tui/tui-state.ts | 10 +- 30 files changed, 1824 insertions(+), 16 deletions(-) create mode 100644 src/integrations/composio-integration.test.ts create mode 100644 src/integrations/composio-integration.ts create mode 100644 src/integrations/index.ts create mode 100644 src/integrations/integration-descriptor.ts create mode 100644 src/integrations/integration-registry.test.ts create mode 100644 src/integrations/integration-registry.ts create mode 100644 src/integrations/integration-secrets.test.ts create mode 100644 src/integrations/integration-secrets.ts create mode 100644 src/tui/integrations/components/integrations-panel.test.tsx create mode 100644 src/tui/integrations/components/integrations-panel.tsx create mode 100644 src/tui/integrations/index.ts create mode 100644 src/tui/integrations/integrations-actions.ts create mode 100644 src/tui/integrations/integrations-key-bindings.test.ts create mode 100644 src/tui/integrations/integrations-key-bindings.ts create mode 100644 src/tui/integrations/integrations-orchestrator.ts create mode 100644 src/tui/integrations/integrations-panel-reducer.test.ts create mode 100644 src/tui/integrations/integrations-panel-reducer.ts create mode 100644 src/tui/integrations/integrations-panel-state.ts diff --git a/AGENTS.md b/AGENTS.md index 9d17b555..3408838d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1766,6 +1766,21 @@ Locked invariants (pinned by [src/tui/mcp/mcp-reducer.test.ts](src/tui/mcp/mcp-r 4. **The editor is disabled on the MCP tab while a modal is open.** `mcpTabBusy` in `app-key-bindings.ts` covers both `addModal !== null` (lets the `MultiLineEditor` capture every keystroke) and `removeConfirm !== null` (claims the `y`/`n` confirmation keys against the global nav cycler). 5. **Variant γ surface is opt-in but on by default.** Restarting the runtime is no longer required after add/remove — the prompt's `### tools` catalog and GBNF grammar are rebuilt on the next step. KV-cache for in-flight sessions is invalidated once per add/remove (the persona stays byte-stable; only the rendered tools block changes). +## Integrations hub + +The `Integrations` tab ([src/tui/integrations/](src/tui/integrations/)) is the single place an operator puts third-party credentials. Before it, every integration grew its own surface — Telegram had a tab, LLM providers had a wizard, Composio had nothing — so "where do I put my key" required already knowing which kind of thing a given service was. + +An integration declares itself as a **descriptor** ([src/integrations/integration-descriptor.ts](src/integrations/integration-descriptor.ts)): id, label, summary, docs URL, a list of credential fields, and a `status()` projection. The hub renders any descriptor without a bespoke pane, so adding an integration is a descriptor file plus one line in [integration-registry.ts](src/integrations/integration-registry.ts) — not a new TUI slice. + +Locked invariants (pinned by [src/integrations/integration-secrets.test.ts](src/integrations/integration-secrets.test.ts), [src/integrations/integration-registry.test.ts](src/integrations/integration-registry.test.ts), [src/tui/integrations/integrations-panel-reducer.test.ts](src/tui/integrations/integrations-panel-reducer.test.ts), [src/tui/integrations/integrations-key-bindings.test.ts](src/tui/integrations/integrations-key-bindings.test.ts)): + +1. **Credentials live in `/.env`, never in `config.json`.** `writeFieldValue` goes through `setDotenvKey` (0600, atomic) and updates `process.env` in the same breath, so the running process sees a new key without a restart. Field env vars must match `/^[A-Z_][A-Z0-9_]*$/` — the registry test pins this, because `setDotenvKey` would otherwise throw in front of the operator at save time. +2. **`IntegrationsOrchestrator` is the only module that touches credential storage or the live `McpManager` for this tab.** The reducer and component are pure; the key bindings only dispatch and call callbacks. +3. **A secret is never rendered in the clear except in the edit buffer being typed.** `displayFieldValue` masks and caps; starting an edit opens an *empty* buffer rather than seeding the stored value; `integrations_action_settled` clears the buffer so a key never lingers in UI state. +4. **Edit mode swallows the whole keyboard.** `d`, `e` and `r` are bindings on this tab; inside the editor they are key material. A paste that silently triggered "clear field" halfway through would be both baffling and destructive. +5. **A re-sync never yanks the cursor.** Rows are re-read on every refresh; the reducer clamps the selection instead of resetting it, so a background refresh cannot move the operator's place mid-edit. +6. **Changing a Composio key drops the cached tool-router session.** A session belongs to the key that created it; reusing it across a key swap would keep talking to the old account. The orchestrator unmounts, clears the cache, re-resolves, and remounts live. + ## Composio (hosted toolkits) [Composio](https://composio.dev) is a hosted catalogue of ~1500 SaaS toolkits (Gmail, Slack, Notion, Linear, Jira, …) that also brokers each app's OAuth. atomic-agent consumes it as **one more MCP server** rather than as a bespoke integration: a tool-router session yields a Streamable-HTTP MCP endpoint authenticated by a static `x-api-key` header, which is exactly the transport [src/mcp/](src/mcp/) already speaks. Code lives in [src/composio/](src/composio/); the cold-path wiring is a single `await resolveComposioServerConfig(...)` in [src/runtime/bootstrap.ts](src/runtime/bootstrap.ts) that appends at most one entry to the server list before `McpManager` is constructed. diff --git a/src/integrations/composio-integration.test.ts b/src/integrations/composio-integration.test.ts new file mode 100644 index 00000000..fcf02c08 --- /dev/null +++ b/src/integrations/composio-integration.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { composioIntegration } from "./composio-integration.js"; +import { COMPOSIO_API_KEY_ENV } from "../composio/index.js"; + +const KEY = "apiKey"; + +function ctx(present: string[], states?: Record) { + return { + presentFields: new Set(present), + configured: present.includes(KEY), + ...(states === undefined + ? {} + : { mcpServerStates: new Map(Object.entries(states)) }), + }; +} + +describe("composioIntegration", () => { + it("needs exactly one required field: the API key", () => { + const required = composioIntegration.fields.filter((f) => f.required); + expect(required).toHaveLength(1); + expect(required[0]?.envVar).toBe(COMPOSIO_API_KEY_ENV); + expect(required[0]?.secret).toBe(true); + }); + + it("says plainly that no key means no tools", () => { + const status = composioIntegration.status(ctx([])); + expect(status.level).toBe("not_configured"); + expect(status.detail).toMatch(/not loaded/); + }); + + it("reports connected once the MCP server is up", () => { + expect(composioIntegration.status(ctx([KEY], { composio: "up" }))).toEqual({ + level: "connected", + detail: "connected", + }); + }); + + it("distinguishes a saved key from a failed connection", () => { + // These must not look the same: one is "wait a moment", the other + // is "your key is wrong or Composio is down". + expect(composioIntegration.status(ctx([KEY])).level).toBe("configured"); + expect( + composioIntegration.status(ctx([KEY], { composio: "down" })).level, + ).toBe("error"); + }); + + it("rejects a non-ASCII key at entry", () => { + // The key is sent as an x-api-key header; a smart quote from a + // copy-paste would otherwise blow up opaquely inside fetch. + const validate = composioIntegration.fields[0]?.validate; + expect(validate?.("ak_plain_ascii")).toBeUndefined(); + expect(validate?.("ak_“fancy”")).toMatch(/ASCII/); + }); +}); diff --git a/src/integrations/composio-integration.ts b/src/integrations/composio-integration.ts new file mode 100644 index 00000000..a8a1e1ac --- /dev/null +++ b/src/integrations/composio-integration.ts @@ -0,0 +1,69 @@ +/** + * Composio as an Integrations-hub tenant. + * + * One required field — the API key — because that is genuinely the + * whole setup: everything past it (which apps, which OAuth, which + * tools) is negotiated inside the session at the moment the operator + * asks for something. See AGENTS.md §"Composio". + */ + +import { COMPOSIO_API_KEY_ENV, COMPOSIO_SERVER_NAME } from "../composio/index.js"; +import { isAsciiOnly } from "../llm/provider/openai/ascii-header-guard.js"; +import type { + IntegrationDescriptor, + IntegrationStatus, + IntegrationStatusContext, +} from "./integration-descriptor.js"; +import { isConfigured } from "./integration-descriptor.js"; + +const COMPOSIO_KEY_FIELD = "apiKey"; + +export const composioIntegration: IntegrationDescriptor = { + id: "composio", + label: "Composio", + summary: + "~1500 SaaS toolkits (Gmail, Slack, Notion, Linear, Jira…) with OAuth handled for you", + docsUrl: "https://composio.dev", + appliesLive: true, + fields: [ + { + key: COMPOSIO_KEY_FIELD, + label: "API key", + envVar: COMPOSIO_API_KEY_ENV, + secret: true, + required: true, + help: "Free tier: 100K tool calls/month. Get one at composio.dev.", + validate: (raw) => { + // The key is sent as an x-api-key header, and header values must + // be ASCII — catching it here names the problem, instead of + // letting fetch throw something opaque at connect time. + if (!isAsciiOnly(raw)) { + return "API key must be ASCII — check for a smart quote or stray character in the paste."; + } + return undefined; + }, + }, + ], + status(ctx: IntegrationStatusContext): IntegrationStatus { + const configured = isConfigured(composioIntegration, ctx.presentFields); + if (!configured) { + return { + level: "not_configured", + detail: "no key — Composio tools are not loaded", + }; + } + const state = ctx.mcpServerStates?.get(COMPOSIO_SERVER_NAME); + if (state === "up") { + return { level: "connected", detail: "connected" }; + } + if (state === "down") { + return { + level: "error", + detail: "key saved, but the connection failed — see the MCP tab", + }; + } + // Key present and no live signal yet: either the runtime has not + // reached the server, or it mounts on the next boot. + return { level: "configured", detail: "key saved" }; + }, +}; diff --git a/src/integrations/index.ts b/src/integrations/index.ts new file mode 100644 index 00000000..f2e36ada --- /dev/null +++ b/src/integrations/index.ts @@ -0,0 +1,22 @@ +/** + * Integrations hub — one place for every third-party credential. + * See AGENTS.md §"Integrations hub". + */ + +export { basicStatus, isConfigured } from "./integration-descriptor.js"; +export type { + IntegrationDescriptor, + IntegrationField, + IntegrationStatus, + IntegrationStatusContext, + IntegrationStatusLevel, +} from "./integration-descriptor.js"; +export { + IntegrationSecretError, + displayFieldValue, + presentFieldKeys, + readFieldValue, + writeFieldValue, +} from "./integration-secrets.js"; +export { findIntegration, listIntegrations } from "./integration-registry.js"; +export { composioIntegration } from "./composio-integration.js"; diff --git a/src/integrations/integration-descriptor.ts b/src/integrations/integration-descriptor.ts new file mode 100644 index 00000000..752efdd1 --- /dev/null +++ b/src/integrations/integration-descriptor.ts @@ -0,0 +1,103 @@ +/** + * The contract every third-party integration declares itself with. + * + * Before this existed, each integration grew its own settings surface: + * Telegram had a tab, LLM providers had a wizard, Composio had nothing. + * An operator looking for "where do I put my key" had to already know + * which of those a given service was. A descriptor moves that knowledge + * into data, so the Integrations hub can render any integration — + * present or future — without a bespoke pane, and adding one is a + * descriptor plus a registry line rather than a TUI slice. + * + * Descriptors are pure data plus two pure functions. Nothing here reads + * config, touches the filesystem, or talks to a runtime; the hub's + * orchestrator owns all of that. + */ + +/** One credential an integration needs. */ +export interface IntegrationField { + /** Stable id, unique within the integration. */ + key: string; + /** Human label, e.g. "API key". */ + label: string; + /** + * Env var this field is stored under in `/.env`. Secrets + * never enter `config.json`; this is the same split Telegram's bot + * token and the LLM provider keys already use. + */ + envVar: string; + /** Mask the value in the UI and never log it. */ + secret: boolean; + /** A field the integration cannot work without. */ + required: boolean; + /** Short hint rendered under the input. */ + help?: string; + /** + * Reject a bad value at entry. Returns an error message, or + * `undefined` when the value is acceptable. + */ + validate?: (raw: string) => string | undefined; +} + +export type IntegrationStatusLevel = + | "not_configured" + | "configured" + | "connected" + | "error"; + +export interface IntegrationStatus { + level: IntegrationStatusLevel; + /** One line shown next to the badge. */ + detail?: string; +} + +/** What the hub knows at render time, passed to `status()`. */ +export interface IntegrationStatusContext { + /** Field keys that currently resolve to a non-empty value. */ + presentFields: ReadonlySet; + /** Every required field has a value. */ + configured: boolean; + /** + * Live MCP server states by server name, for integrations that mount + * one. Absent when the runtime is not available (e.g. in tests). + */ + mcpServerStates?: ReadonlyMap; +} + +export interface IntegrationDescriptor { + /** Stable id, also the `/integrations ` selector. */ + id: string; + /** Display name, e.g. "Composio". */ + label: string; + /** One line explaining what connecting this buys the operator. */ + summary: string; + /** Where to get the credentials. */ + docsUrl?: string; + fields: readonly IntegrationField[]; + /** + * Whether a restart is needed for changes to take effect. The hub + * says so explicitly rather than leaving the operator to guess why + * nothing happened. + */ + appliesLive: boolean; + status: (ctx: IntegrationStatusContext) => IntegrationStatus; +} + +/** Default status: configured-or-not, with no runtime signal. */ +export function basicStatus(ctx: IntegrationStatusContext): IntegrationStatus { + return ctx.configured + ? { level: "configured" } + : { level: "not_configured" }; +} + +/** Every required field of `descriptor` that has a value. */ +export function isConfigured( + descriptor: IntegrationDescriptor, + presentFields: ReadonlySet, +): boolean { + const required = descriptor.fields.filter((f) => f.required); + // An integration with no required fields is never "configured" by + // omission — that would badge an untouched entry as ready to use. + if (required.length === 0) return false; + return required.every((f) => presentFields.has(f.key)); +} diff --git a/src/integrations/integration-registry.test.ts b/src/integrations/integration-registry.test.ts new file mode 100644 index 00000000..f33e6b68 --- /dev/null +++ b/src/integrations/integration-registry.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { findIntegration, listIntegrations } from "./integration-registry.js"; +import { isConfigured } from "./integration-descriptor.js"; + +describe("integration registry", () => { + it("lists Composio", () => { + expect(listIntegrations().map((i) => i.id)).toContain("composio"); + }); + + it("gives every integration a unique id", () => { + const ids = listIntegrations().map((i) => i.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("gives every field a unique key and env var within its integration", () => { + for (const integration of listIntegrations()) { + const keys = integration.fields.map((f) => f.key); + const envVars = integration.fields.map((f) => f.envVar); + expect(new Set(keys).size).toBe(keys.length); + expect(new Set(envVars).size).toBe(envVars.length); + } + }); + + it("names an env var the dotenv writer will accept", () => { + // setDotenvKey rejects anything outside /^[A-Z_][A-Z0-9_]*$/, and it + // throws at save time -- i.e. in front of the operator. + for (const integration of listIntegrations()) { + for (const field of integration.fields) { + expect(field.envVar).toMatch(/^[A-Z_][A-Z0-9_]*$/); + } + } + }); + + it("finds by id and returns undefined for an unknown one", () => { + expect(findIntegration("composio")?.label).toBe("Composio"); + expect(findIntegration("nope")).toBeUndefined(); + }); +}); + +describe("isConfigured", () => { + it("is false when a required field is missing", () => { + const composio = findIntegration("composio")!; + expect(isConfigured(composio, new Set())).toBe(false); + expect(isConfigured(composio, new Set(["apiKey"]))).toBe(true); + }); + + it("never reports an integration with no required fields as configured", () => { + // Otherwise an untouched entry would badge itself ready to use. + expect( + isConfigured( + { + id: "x", + label: "X", + summary: "", + appliesLive: true, + fields: [], + status: () => ({ level: "not_configured" }), + }, + new Set(), + ), + ).toBe(false); + }); +}); diff --git a/src/integrations/integration-registry.ts b/src/integrations/integration-registry.ts new file mode 100644 index 00000000..d2c05890 --- /dev/null +++ b/src/integrations/integration-registry.ts @@ -0,0 +1,23 @@ +/** + * The list of integrations the hub renders. + * + * A plain function rather than a module-level singleton, per + * AGENTS.md §"Layout rules" ("no global singletons — `getConfig()` is + * the only exception"). Adding an integration is one line here plus a + * descriptor file. + */ + +import { composioIntegration } from "./composio-integration.js"; +import type { IntegrationDescriptor } from "./integration-descriptor.js"; + +/** Every known integration, in display order. */ +export function listIntegrations(): readonly IntegrationDescriptor[] { + return [composioIntegration]; +} + +/** Look one up by id. `undefined` when nothing matches. */ +export function findIntegration( + id: string, +): IntegrationDescriptor | undefined { + return listIntegrations().find((i) => i.id === id); +} diff --git a/src/integrations/integration-secrets.test.ts b/src/integrations/integration-secrets.test.ts new file mode 100644 index 00000000..4e66e311 --- /dev/null +++ b/src/integrations/integration-secrets.test.ts @@ -0,0 +1,145 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + IntegrationSecretError, + displayFieldValue, + presentFieldKeys, + readFieldValue, + writeFieldValue, +} from "./integration-secrets.js"; +import type { + IntegrationDescriptor, + IntegrationField, +} from "./integration-descriptor.js"; + +const KEY_FIELD: IntegrationField = { + key: "apiKey", + label: "API key", + envVar: "TEST_INTEGRATION_KEY", + secret: true, + required: true, + validate: (raw) => (raw.startsWith("ok_") ? undefined : "must start with ok_"), +}; + +const PLAIN_FIELD: IntegrationField = { + key: "endpoint", + label: "Endpoint", + envVar: "TEST_INTEGRATION_ENDPOINT", + secret: false, + required: false, +}; + +const DESCRIPTOR: IntegrationDescriptor = { + id: "test", + label: "Test", + summary: "", + appliesLive: true, + fields: [KEY_FIELD, PLAIN_FIELD], + status: () => ({ level: "not_configured" }), +}; + +let stateDir: string; + +beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), "integration-secrets-")); +}); + +afterEach(() => { + rmSync(stateDir, { recursive: true, force: true }); +}); + +describe("readFieldValue", () => { + it("trims and returns a set value", () => { + expect(readFieldValue(KEY_FIELD, { TEST_INTEGRATION_KEY: " ok_1 " })).toBe( + "ok_1", + ); + }); + + it("reads an unset or blank value as absent", () => { + expect(readFieldValue(KEY_FIELD, {})).toBeUndefined(); + expect(readFieldValue(KEY_FIELD, { TEST_INTEGRATION_KEY: " " })).toBeUndefined(); + }); +}); + +describe("presentFieldKeys", () => { + it("reports only the fields that hold a value", () => { + const present = presentFieldKeys(DESCRIPTOR, { + TEST_INTEGRATION_KEY: "ok_1", + }); + expect([...present]).toEqual(["apiKey"]); + }); +}); + +describe("displayFieldValue", () => { + it("masks a secret so a screen-share never leaks it", () => { + const shown = displayFieldValue(KEY_FIELD, "ok_supersecret"); + expect(shown).toBe("•".repeat("ok_supersecret".length)); + expect(shown).not.toContain("supersecret"); + }); + + it("caps the mask so a long key cannot blow out the pane", () => { + const shown = displayFieldValue(KEY_FIELD, "x".repeat(100)); + expect(shown).toBe(`${"•".repeat(32)}+68`); + }); + + it("shows a non-secret value as-is and an unset value as a dash", () => { + expect(displayFieldValue(PLAIN_FIELD, "https://x.test")).toBe( + "https://x.test", + ); + expect(displayFieldValue(PLAIN_FIELD, undefined)).toBe("—"); + }); +}); + +describe("writeFieldValue", () => { + it("writes to /.env and updates the live process env", () => { + const env: NodeJS.ProcessEnv = {}; + writeFieldValue(stateDir, KEY_FIELD, "ok_live", env); + expect(readFileSync(join(stateDir, ".env"), "utf8")).toContain( + "TEST_INTEGRATION_KEY=ok_live", + ); + // Without the in-process update, the hub would report the key saved + // while every consumer still read the old value until a restart. + expect(env.TEST_INTEGRATION_KEY).toBe("ok_live"); + }); + + it("rejects a value the field's own validator refuses", () => { + const env: NodeJS.ProcessEnv = {}; + expect(() => writeFieldValue(stateDir, KEY_FIELD, "nope", env)).toThrow( + IntegrationSecretError, + ); + expect(env.TEST_INTEGRATION_KEY).toBeUndefined(); + }); + + it("rejects an empty value rather than storing a blank key", () => { + expect(() => writeFieldValue(stateDir, KEY_FIELD, " ", {})).toThrow( + /empty/, + ); + }); + + it("clears the key from both .env and the live env", () => { + const env: NodeJS.ProcessEnv = {}; + writeFieldValue(stateDir, KEY_FIELD, "ok_live", env); + writeFieldValue(stateDir, KEY_FIELD, null, env); + // Dropping the last key removes the file outright, so read it back + // defensively rather than assuming it survives. + const envPath = join(stateDir, ".env"); + const onDisk = existsSync(envPath) ? readFileSync(envPath, "utf8") : ""; + expect(onDisk).not.toContain("ok_live"); + expect(env.TEST_INTEGRATION_KEY).toBeUndefined(); + }); + + it("leaves a sibling key untouched when one is cleared", () => { + // The .env is shared with every other secret in the install -- + // clearing a Composio key must not take TELEGRAM_BOT_TOKEN with it. + const env: NodeJS.ProcessEnv = {}; + writeFieldValue(stateDir, KEY_FIELD, "ok_live", env); + writeFieldValue(stateDir, PLAIN_FIELD, "https://x.test", env); + writeFieldValue(stateDir, KEY_FIELD, null, env); + const onDisk = readFileSync(join(stateDir, ".env"), "utf8"); + expect(onDisk).toContain("TEST_INTEGRATION_ENDPOINT=https://x.test"); + expect(onDisk).not.toContain("ok_live"); + }); +}); diff --git a/src/integrations/integration-secrets.ts b/src/integrations/integration-secrets.ts new file mode 100644 index 00000000..3157396c --- /dev/null +++ b/src/integrations/integration-secrets.ts @@ -0,0 +1,90 @@ +/** + * Read / write integration credentials. + * + * Values live in `/.env` (0600, atomic writes) via the + * existing `setDotenvKey`, never in `config.json`. Reads go through + * `process.env`, which `loadDotenvFromStateDir` has already populated, + * so a value written here is visible to the next `getConfig()` consumer + * without a bespoke cache. + */ + +import { setDotenvKey } from "../config/dotenv-writer.js"; +import type { + IntegrationDescriptor, + IntegrationField, +} from "./integration-descriptor.js"; + +/** Read one field's current value, or `undefined` when unset/blank. */ +export function readFieldValue( + field: IntegrationField, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const raw = env[field.envVar]; + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** Field keys of `descriptor` that currently hold a value. */ +export function presentFieldKeys( + descriptor: IntegrationDescriptor, + env: NodeJS.ProcessEnv = process.env, +): Set { + const present = new Set(); + for (const field of descriptor.fields) { + if (readFieldValue(field, env) !== undefined) present.add(field.key); + } + return present; +} + +/** + * Render a value for display. Secrets become bullets so a shoulder-surf + * or a screen-share never leaks one; the length is capped so a long key + * cannot blow out the pane width. + */ +export function displayFieldValue( + field: IntegrationField, + value: string | undefined, +): string { + if (value === undefined) return "—"; + if (!field.secret) return value; + const masked = "•".repeat(Math.min(value.length, 32)); + return value.length > 32 ? `${masked}+${value.length - 32}` : masked; +} + +export class IntegrationSecretError extends Error { + constructor(message: string) { + super(message); + this.name = "IntegrationSecretError"; + } +} + +/** + * Persist a field value, or clear it when `value` is `null`. + * + * `process.env` is updated in the same breath so the live process sees + * the change without a restart — otherwise the hub would report a key + * as saved while every consumer still read the old one. + */ +export function writeFieldValue( + stateDir: string, + field: IntegrationField, + value: string | null, + env: NodeJS.ProcessEnv = process.env, +): void { + if (value === null) { + setDotenvKey(stateDir, field.envVar, null); + delete env[field.envVar]; + return; + } + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new IntegrationSecretError(`${field.label} is empty`); + } + const invalid = field.validate?.(trimmed); + if (invalid !== undefined) { + throw new IntegrationSecretError(invalid); + } + setDotenvKey(stateDir, field.envVar, trimmed); + env[field.envVar] = trimmed; +} diff --git a/src/tui/agent-event-reducer.ts b/src/tui/agent-event-reducer.ts index d1497bfb..c4ad8833 100644 --- a/src/tui/agent-event-reducer.ts +++ b/src/tui/agent-event-reducer.ts @@ -38,6 +38,7 @@ import { reduceLlmPanelAction } from "./llm-panel/llm-panel-reducer.js"; import { reduceFallbackPanelAction } from "./llm-panel/fallback/fallback-panel-reducer.js"; import { reduceTelegramAction } from "./telegram/telegram-panel-reducer.js"; import { reducePrivacyAction } from "./privacy/privacy-panel-reducer.js"; +import { reduceIntegrationsAction } from "./integrations/integrations-panel-reducer.js"; import type { TuiAction } from "./tui-action.js"; import type { RunOutcome, StreamingToolCall, TuiState } from "./tui-state.js"; @@ -76,6 +77,8 @@ export function reduceTuiState(state: TuiState, action: TuiAction): TuiState { if (telegramHandled !== null) return telegramHandled; const privacyHandled = reducePrivacyAction(state, action); if (privacyHandled !== null) return privacyHandled; + const integrationsHandled = reduceIntegrationsAction(state, action); + if (integrationsHandled !== null) return integrationsHandled; const composerSwitchHandled = reduceComposerSwitchAction(state, action); if (composerSwitchHandled !== null) return composerSwitchHandled; const uiHandled = reduceUiAction(state, action); diff --git a/src/tui/chat-orchestrator.ts b/src/tui/chat-orchestrator.ts index 8d77d517..69520d2d 100644 --- a/src/tui/chat-orchestrator.ts +++ b/src/tui/chat-orchestrator.ts @@ -45,6 +45,7 @@ import { ProvidersOrchestrator } from "./providers/providers-orchestrator.js"; import { FallbackOrchestrator } from "./llm-panel/fallback/fallback-orchestrator.js"; import { TuiTelegramOrchestrator } from "./telegram/tui-telegram-orchestrator.js"; import { PrivacyOrchestrator } from "./privacy/privacy-orchestrator.js"; +import { IntegrationsOrchestrator } from "./integrations/integrations-orchestrator.js"; import type { TuiEventBus } from "./tui-app.js"; import { formatAgentErrorForChat } from "./format-agent-error-for-chat.js"; import { @@ -195,6 +196,7 @@ export class ChatOrchestrator { public readonly llmHealth: LlmHealthPoller; public readonly telegram: TuiTelegramOrchestrator; public readonly privacy: PrivacyOrchestrator; + public readonly integrations: IntegrationsOrchestrator; constructor( private readonly runtime: AgentRuntime, @@ -234,6 +236,7 @@ export class ChatOrchestrator { }); this.telegram = new TuiTelegramOrchestrator(runtime, bus); this.privacy = new PrivacyOrchestrator(runtime, bus); + this.integrations = new IntegrationsOrchestrator(runtime, bus); // Tap the bus rather than the runtime handler: what the reducer was // offered is exactly what a switch-back may need to replay, session // tags included. `record` no-ops for sessions without a running diff --git a/src/tui/components/debug-pane.tsx b/src/tui/components/debug-pane.tsx index eaf5cbf0..48527aa7 100644 --- a/src/tui/components/debug-pane.tsx +++ b/src/tui/components/debug-pane.tsx @@ -27,6 +27,7 @@ import { MemoryPanel } from "./memory-panel.js"; import { ImportPanel } from "./import-panel.js"; import { TelegramPanel } from "../telegram/components/telegram-panel.js"; import { PrivacyPanel } from "../privacy/components/privacy-panel.js"; +import { IntegrationsPanel } from "../integrations/components/integrations-panel.js"; import { ProvidersPanel } from "./providers-panel.js"; interface DebugPaneProps { @@ -162,6 +163,7 @@ function buildManageTabs(state: TuiState): SubTab[] { { id: "skills", label: `Skills${suffix(state.skillsPanel.rows.length)}` }, { id: "memory", label: `Memory${suffix(state.memoryPanel.rows.length)}` }, { id: "mcp", label: `MCP${suffix(state.mcpPanel.rows.length)}` }, + { id: "integrations", label: integrationsTabLabel(state) }, { id: "llm", label: "LLM" }, { id: "telegram", label: telegramTabLabel(state) }, { id: "import", label: "Import" }, @@ -353,6 +355,13 @@ function ActiveDebugTab({ return ; case "privacy": return ; + case "integrations": + return ( + + ); default: return ; } @@ -368,6 +377,18 @@ function suffix(count: number): string { * operator scanning the Manage strip sees `Telegram (down)` without * entering the panel. */ +/** + * Integrations tab label with a configured-count suffix, so an operator + * can see at a glance whether anything is wired up without opening it. + */ +function integrationsTabLabel(state: TuiState): string { + const rows = state.integrationsPanel.rows; + const ready = rows.filter( + (r) => r.level === "configured" || r.level === "connected", + ).length; + return ready > 0 ? `Integrations (${ready})` : "Integrations"; +} + function telegramTabLabel(state: TuiState): string { const channelState = state.telegramPanel.channelState; if (channelState === "up") return "Telegram (up)"; diff --git a/src/tui/integrations/components/integrations-panel.test.tsx b/src/tui/integrations/components/integrations-panel.test.tsx new file mode 100644 index 00000000..0e352d28 --- /dev/null +++ b/src/tui/integrations/components/integrations-panel.test.tsx @@ -0,0 +1,97 @@ +import { render } from "ink-testing-library"; +import { describe, expect, it } from "vitest"; + +import { IntegrationsPanel } from "./integrations-panel.js"; +import { + createInitialIntegrationsPanelState, + type IntegrationRow, + type IntegrationsPanelState, +} from "../integrations-panel-state.js"; + +const COMPOSIO: IntegrationRow = { + id: "composio", + label: "Composio", + summary: "~1500 SaaS toolkits", + level: "not_configured", + detail: "no key — Composio tools are not loaded", + docsUrl: "https://composio.dev", + appliesLive: true, + fields: [ + { + key: "apiKey", + label: "API key", + display: "—", + present: false, + help: "Free tier: 100K tool calls/month.", + }, + ], +}; + +function flat(frame: string | undefined): string { + return (frame ?? "").replace(/\s+/g, " "); +} + +function panelOf( + overrides: Partial = {}, +): IntegrationsPanelState { + return { + ...createInitialIntegrationsPanelState(), + rows: [COMPOSIO], + ...overrides, + }; +} + +describe("IntegrationsPanel", () => { + it("lists an integration with its status and summary", () => { + const { lastFrame } = render(); + const out = flat(lastFrame()); + expect(out).toContain("Integrations"); + expect(out).toContain("Composio"); + expect(out).toContain("~1500 SaaS toolkits"); + expect(out).toContain("not loaded"); + expect(out).toContain("0/1 configured"); + }); + + it("shows a helpful hint when nothing is registered", () => { + const { lastFrame } = render( + , + ); + expect(flat(lastFrame())).toContain("no integrations available"); + }); + + it("shows the masked value and help text in detail mode", () => { + const row: IntegrationRow = { + ...COMPOSIO, + level: "connected", + detail: "connected", + fields: [{ ...COMPOSIO.fields[0]!, display: "••••••", present: true }], + }; + const { lastFrame } = render( + , + ); + const out = flat(lastFrame()); + expect(out).toContain("API key"); + expect(out).toContain("••••••"); + expect(out).toContain("connected"); + expect(out).toContain("Free tier"); + expect(out).toContain("e edit"); + }); + + it("renders the live edit buffer, not the stored value", () => { + const { lastFrame } = render( + , + ); + const out = flat(lastFrame()); + expect(out).toContain("ak_typed"); + expect(out).toContain("enter save"); + }); + + it("surfaces an error line", () => { + const { lastFrame } = render( + , + ); + expect(flat(lastFrame())).toContain("key rejected"); + }); +}); diff --git a/src/tui/integrations/components/integrations-panel.tsx b/src/tui/integrations/components/integrations-panel.tsx new file mode 100644 index 00000000..012490df --- /dev/null +++ b/src/tui/integrations/components/integrations-panel.tsx @@ -0,0 +1,223 @@ +import { Box, Text } from "ink"; +import type { ReactElement } from "react"; +import { theme } from "../../theme/theme.js"; +import type { + IntegrationRow, + IntegrationsPanelState, +} from "../integrations-panel-state.js"; +import type { IntegrationStatusLevel } from "../../../integrations/index.js"; + +export interface IntegrationsPanelProps { + panel: IntegrationsPanelState; + maxRows?: number; +} + +/** + * The Integrations tab: one place for every third-party credential. + * + * List mode shows every integration with a status badge; detail mode + * shows one integration's fields, masked, with edit / clear. Secrets are + * never rendered in the clear except in the edit buffer the operator is + * actively typing into. + */ +export function IntegrationsPanel({ + panel, + maxRows = 14, +}: IntegrationsPanelProps): ReactElement { + const row = panel.rows[panel.selected]; + return ( + +
+ {panel.lastError ? ( + + ! {panel.lastError} + + ) : null} + {panel.message ? ( + + {panel.message} + + ) : null} + {panel.rows.length === 0 ? ( + + no integrations available + + ) : panel.mode === "list" ? ( + + ) : ( + + )} + + {hint(panel)} + + + ); +} + +function Header({ panel }: { panel: IntegrationsPanelState }): ReactElement { + const configured = panel.rows.filter( + (r) => r.level === "configured" || r.level === "connected", + ).length; + return ( + + + Integrations + + + {" "} + {configured}/{panel.rows.length} configured + + {panel.busy ? {" "}… : null} + + ); +} + +function ListView({ + panel, + maxRows, +}: { + panel: IntegrationsPanelState; + maxRows: number; +}): ReactElement { + return ( + + {panel.rows.slice(0, maxRows).map((row, i) => ( + + + + {i === panel.selected ? "> " : " "} + + {row.label} + + {" "} + {badgeText(row)} + + + + {" "}{row.summary} + + + ))} + + ); +} + +function DetailView({ + panel, + row, +}: { + panel: IntegrationsPanelState; + row: IntegrationRow | undefined; +}): ReactElement { + if (!row) { + return ( + + nothing selected + + ); + } + return ( + + + {row.label} + + {" "} + {badgeText(row)} + + + {row.docsUrl ? ( + + {" "}{row.docsUrl} + + ) : null} + + {row.fields.map((field, i) => { + const active = i === panel.selectedField; + const editing = active && panel.mode === "edit"; + return ( + + + + {active ? "> " : " "} + + {field.label} + {" "} + {editing ? ( + + {panel.editBuffer} + + + ) : ( + + {field.display} + + )} + + {active && field.help ? ( + + {" "}{field.help} + + ) : null} + + ); + })} + + {!row.appliesLive ? ( + + + {" "}changes take effect after a restart + + + ) : null} + + ); +} + +function badgeText(row: IntegrationRow): string { + if (row.detail) return `· ${row.detail}`; + switch (row.level) { + case "connected": + return "· connected"; + case "configured": + return "· configured"; + case "error": + return "· error"; + default: + return "· not configured"; + } +} + +function badgeColor(level: IntegrationStatusLevel): string { + switch (level) { + case "connected": + return theme.colors.accentSoft; + case "configured": + return theme.colors.accentSoft; + case "error": + return theme.colors.error; + default: + return theme.colors.muted; + } +} + +function hint(panel: IntegrationsPanelState): string { + if (panel.mode === "edit") return "enter save · esc cancel"; + if (panel.mode === "detail") { + return "↑/↓ field · e edit · d clear · esc back"; + } + return "↑/↓ move · enter open · r refresh"; +} diff --git a/src/tui/integrations/index.ts b/src/tui/integrations/index.ts new file mode 100644 index 00000000..1456a380 --- /dev/null +++ b/src/tui/integrations/index.ts @@ -0,0 +1,20 @@ +/** + * TUI slice for the Integrations tab. See AGENTS.md §"Integrations hub". + */ + +export { + createInitialIntegrationsPanelState, + selectedField, + selectedRow, +} from "./integrations-panel-state.js"; +export type { + IntegrationFieldRow, + IntegrationRow, + IntegrationsPanelMode, + IntegrationsPanelState, +} from "./integrations-panel-state.js"; +export { isIntegrationsAction } from "./integrations-actions.js"; +export type { IntegrationsAction } from "./integrations-actions.js"; +export { reduceIntegrationsAction } from "./integrations-panel-reducer.js"; +export { handleIntegrationsTabKey } from "./integrations-key-bindings.js"; +export { IntegrationsOrchestrator } from "./integrations-orchestrator.js"; diff --git a/src/tui/integrations/integrations-actions.ts b/src/tui/integrations/integrations-actions.ts new file mode 100644 index 00000000..1d29aa51 --- /dev/null +++ b/src/tui/integrations/integrations-actions.ts @@ -0,0 +1,27 @@ +import type { IntegrationRow } from "./integrations-panel-state.js"; + +/** + * Reducer actions for the Integrations tab. The orchestrator and the + * keyboard layer emit these; the reducer folds them into + * `state.integrationsPanel`. The `integrations_` prefix lets the root + * reducer narrow without a runtime tag dictionary. + */ +export type IntegrationsAction = + | { type: "integrations_synced"; rows: readonly IntegrationRow[] } + | { type: "integrations_moved"; delta: number } + | { type: "integrations_field_moved"; delta: number } + | { type: "integrations_opened" } + | { type: "integrations_closed" } + | { type: "integrations_edit_started" } + | { type: "integrations_edit_changed"; value: string } + | { type: "integrations_edit_cancelled" } + | { type: "integrations_action_started" } + | { type: "integrations_action_settled"; message?: string; error?: string } + | { type: "integrations_message_cleared" }; + +/** Narrow runtime guard used by the root reducer to dispatch. */ +export function isIntegrationsAction(action: { + type: string; +}): action is IntegrationsAction { + return action.type.startsWith("integrations_"); +} diff --git a/src/tui/integrations/integrations-key-bindings.test.ts b/src/tui/integrations/integrations-key-bindings.test.ts new file mode 100644 index 00000000..faa1aea4 --- /dev/null +++ b/src/tui/integrations/integrations-key-bindings.test.ts @@ -0,0 +1,176 @@ +import type { Key } from "ink"; +import { describe, expect, it, vi } from "vitest"; + +import type { TuiAppCallbacks } from "../tui-app.js"; +import { + createInitialTuiState, + type TuiSessionInfo, + type TuiState, +} from "../tui-state.js"; +import { handleIntegrationsTabKey } from "./integrations-key-bindings.js"; +import type { + IntegrationRow, + IntegrationsPanelState, +} from "./integrations-panel-state.js"; + +const SESSION: TuiSessionInfo = { + sessionId: null, + workingDir: "/tmp", + llamaUrl: "http://127.0.0.1:8080", + browserChannel: "chrome", + browserHeadless: true, + approvalLevel: 1, + maxSteps: 10, + skillCount: 0, +}; + +function emptyKey(overrides: Partial = {}): Key { + return { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + ...overrides, + }; +} + +const ROW: IntegrationRow = { + id: "composio", + label: "Composio", + summary: "", + level: "not_configured", + appliesLive: true, + fields: [ + { key: "apiKey", label: "API key", display: "—", present: false }, + { key: "other", label: "Other", display: "x", present: true }, + ], +}; + +function stateWith(panel: Partial = {}): TuiState { + const state = createInitialTuiState(SESSION); + return { + ...state, + uiMode: "debug", + activeTab: "integrations", + integrationsPanel: { ...state.integrationsPanel, rows: [ROW], ...panel }, + }; +} + +function ctx(state: TuiState, callbacks: Partial = {}) { + const dispatch = vi.fn(); + return { + ctx: { state, dispatch, callbacks: callbacks as TuiAppCallbacks }, + dispatch, + }; +} + +describe("handleIntegrationsTabKey", () => { + it("declines every key when another tab is active", () => { + const state = { ...stateWith(), activeTab: "privacy" as const }; + const { ctx: c } = ctx(state); + expect(handleIntegrationsTabKey("j", emptyKey(), c)).toBe(false); + }); + + it("swallows keys while an action is in flight", () => { + // A second Enter during a save must not fire a duplicate write. + const { ctx: c, dispatch } = ctx(stateWith({ busy: true })); + expect(handleIntegrationsTabKey("e", emptyKey(), c)).toBe(true); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("moves and opens from the list", () => { + const { ctx: c, dispatch } = ctx(stateWith()); + handleIntegrationsTabKey("j", emptyKey(), c); + expect(dispatch).toHaveBeenCalledWith({ + type: "integrations_moved", + delta: 1, + }); + handleIntegrationsTabKey("", emptyKey({ return: true }), c); + expect(dispatch).toHaveBeenCalledWith({ type: "integrations_opened" }); + }); + + it("edits and clears from the detail view", () => { + const onIntegrationFieldClearRequested = vi.fn(); + const { ctx: c, dispatch } = ctx( + stateWith({ mode: "detail", selectedField: 1 }), + { onIntegrationFieldClearRequested }, + ); + handleIntegrationsTabKey("e", emptyKey(), c); + expect(dispatch).toHaveBeenCalledWith({ type: "integrations_edit_started" }); + handleIntegrationsTabKey("d", emptyKey(), c); + expect(onIntegrationFieldClearRequested).toHaveBeenCalledWith( + "composio", + "other", + ); + }); + + it("does not try to clear a field that has no value", () => { + const onIntegrationFieldClearRequested = vi.fn(); + const { ctx: c } = ctx(stateWith({ mode: "detail", selectedField: 0 }), { + onIntegrationFieldClearRequested, + }); + handleIntegrationsTabKey("d", emptyKey(), c); + expect(onIntegrationFieldClearRequested).not.toHaveBeenCalled(); + }); + + it("treats every printable key as key material while editing", () => { + // `d`, `e` and `r` are bindings elsewhere on this tab; inside the + // editor they are just characters, or pasting a key would trigger + // "clear field" halfway through. + const { ctx: c, dispatch } = ctx( + stateWith({ mode: "edit", editBuffer: "ak_" }), + ); + for (const ch of ["d", "e", "r"]) { + handleIntegrationsTabKey(ch, emptyKey(), c); + expect(dispatch).toHaveBeenCalledWith({ + type: "integrations_edit_changed", + value: `ak_${ch}`, + }); + } + }); + + it("consumes every key while editing so nothing leaks to the chat draft", () => { + const { ctx: c } = ctx(stateWith({ mode: "edit" })); + expect(handleIntegrationsTabKey("x", emptyKey(), c)).toBe(true); + expect(handleIntegrationsTabKey("", emptyKey({ tab: true }), c)).toBe(true); + }); + + it("backspaces and saves from the editor", () => { + const onIntegrationFieldSaveRequested = vi.fn(); + const { ctx: c, dispatch } = ctx( + stateWith({ mode: "edit", editBuffer: "ak_12" }), + { onIntegrationFieldSaveRequested }, + ); + handleIntegrationsTabKey("", emptyKey({ backspace: true }), c); + expect(dispatch).toHaveBeenCalledWith({ + type: "integrations_edit_changed", + value: "ak_1", + }); + handleIntegrationsTabKey("", emptyKey({ return: true }), c); + expect(onIntegrationFieldSaveRequested).toHaveBeenCalledWith( + "composio", + "apiKey", + "ak_12", + ); + }); + + it("cancels the editor on escape and leaves detail on escape", () => { + const { ctx: editing, dispatch: d1 } = ctx(stateWith({ mode: "edit" })); + handleIntegrationsTabKey("", emptyKey({ escape: true }), editing); + expect(d1).toHaveBeenCalledWith({ type: "integrations_edit_cancelled" }); + + const { ctx: detail, dispatch: d2 } = ctx(stateWith({ mode: "detail" })); + handleIntegrationsTabKey("", emptyKey({ escape: true }), detail); + expect(d2).toHaveBeenCalledWith({ type: "integrations_closed" }); + }); +}); diff --git a/src/tui/integrations/integrations-key-bindings.ts b/src/tui/integrations/integrations-key-bindings.ts new file mode 100644 index 00000000..2f5b75d3 --- /dev/null +++ b/src/tui/integrations/integrations-key-bindings.ts @@ -0,0 +1,120 @@ +import type { Key } from "ink"; +import type { TuiAction } from "../tui-action.js"; +import type { TuiAppCallbacks } from "../tui-app.js"; +import type { TuiState } from "../tui-state.js"; +import { selectedField, selectedRow } from "./integrations-panel-state.js"; + +export interface IntegrationsTabKeyContext { + state: TuiState; + dispatch: (action: TuiAction) => void; + callbacks: TuiAppCallbacks; +} + +/** + * Keyboard layer for the Integrations tab. Invoked by `TuiApp`'s global + * `useInput` after `handleAppKey` declined the key. Returns `true` when + * the key was consumed so the editor echo is suppressed. + * + * List mode: ↑/↓ or j/k move · Enter opens · r refreshes + * Detail mode: ↑/↓ move between fields · e edits · d clears · Esc back + * Edit mode: every printable key appends · Enter saves · Esc cancels + * + * Edit mode swallows the whole keyboard on purpose: an API key contains + * characters that are bindings everywhere else (`d`, `e`, `r`), and a + * paste that silently triggered "clear field" halfway through would be + * both baffling and destructive. + */ +export function handleIntegrationsTabKey( + input: string, + key: Key, + ctx: IntegrationsTabKeyContext, +): boolean { + const { state, dispatch, callbacks } = ctx; + if (state.uiMode !== "debug" || state.activeTab !== "integrations") { + return false; + } + const panel = state.integrationsPanel; + if (panel.busy) return true; + + if (panel.mode === "edit") { + if (key.escape) { + dispatch({ type: "integrations_edit_cancelled" }); + return true; + } + if (key.return) { + const row = selectedRow(panel); + const field = selectedField(panel); + if (row && field) { + void callbacks.onIntegrationFieldSaveRequested?.( + row.id, + field.key, + panel.editBuffer, + ); + } + return true; + } + if (key.backspace || key.delete) { + dispatch({ + type: "integrations_edit_changed", + value: panel.editBuffer.slice(0, -1), + }); + return true; + } + // Ignore the control keys Ink reports alongside an empty `input`; + // everything else is literal key material. + if (input.length > 0 && !key.ctrl && !key.meta) { + dispatch({ + type: "integrations_edit_changed", + value: panel.editBuffer + input, + }); + return true; + } + return true; + } + + if (panel.mode === "detail") { + if (key.escape) { + dispatch({ type: "integrations_closed" }); + return true; + } + if (key.upArrow || input === "k") { + dispatch({ type: "integrations_field_moved", delta: -1 }); + return true; + } + if (key.downArrow || input === "j") { + dispatch({ type: "integrations_field_moved", delta: 1 }); + return true; + } + if (input === "e") { + dispatch({ type: "integrations_edit_started" }); + return true; + } + if (input === "d") { + const row = selectedRow(panel); + const field = selectedField(panel); + if (row && field && field.present) { + void callbacks.onIntegrationFieldClearRequested?.(row.id, field.key); + } + return true; + } + return false; + } + + if (key.upArrow || input === "k") { + dispatch({ type: "integrations_moved", delta: -1 }); + return true; + } + if (key.downArrow || input === "j") { + dispatch({ type: "integrations_moved", delta: 1 }); + return true; + } + if (key.return) { + dispatch({ type: "integrations_opened" }); + return true; + } + if (input === "r") { + callbacks.onIntegrationsRefreshRequested?.(); + return true; + } + return false; +} diff --git a/src/tui/integrations/integrations-orchestrator.ts b/src/tui/integrations/integrations-orchestrator.ts new file mode 100644 index 00000000..09cdccfe --- /dev/null +++ b/src/tui/integrations/integrations-orchestrator.ts @@ -0,0 +1,144 @@ +import { + COMPOSIO_SERVER_NAME, + clearComposioSession, + resolveComposioServerConfig, +} from "../../composio/index.js"; +import { getConfig } from "../../config/index.js"; +import { + IntegrationSecretError, + displayFieldValue, + findIntegration, + listIntegrations, + presentFieldKeys, + readFieldValue, + writeFieldValue, +} from "../../integrations/index.js"; +import type { AgentRuntime } from "../../runtime/bootstrap.js"; +import type { TuiEventBus } from "../tui-app.js"; +import type { + IntegrationRow, + IntegrationFieldRow, +} from "./integrations-panel-state.js"; + +/** + * The only TUI module that touches credential storage and the live MCP + * manager on behalf of the Integrations tab. The reducer and component + * stay pure; every side effect (`.env` writes, `resetConfigCache`, + * live server mount/unmount) is funnelled through here — mirroring the + * other TUI orchestrators. + */ +export class IntegrationsOrchestrator { + constructor( + private readonly runtime: AgentRuntime, + private readonly bus: TuiEventBus & { emit(action: unknown): void }, + ) {} + + /** Rebuild every row from credential presence + live server state. */ + refresh(): void { + this.bus.emit({ type: "integrations_synced", rows: this.buildRows() }); + } + + private buildRows(): IntegrationRow[] { + const mcpServerStates = new Map(); + for (const status of this.runtime.mcpManager.listStatuses()) { + mcpServerStates.set(status.name, status.state); + } + return listIntegrations().map((descriptor) => { + const present = presentFieldKeys(descriptor); + const status = descriptor.status({ + presentFields: present, + configured: descriptor.fields + .filter((f) => f.required) + .every((f) => present.has(f.key)), + mcpServerStates, + }); + const fields: IntegrationFieldRow[] = descriptor.fields.map((field) => ({ + key: field.key, + label: field.label, + display: displayFieldValue(field, readFieldValue(field)), + present: present.has(field.key), + ...(field.help === undefined ? {} : { help: field.help }), + })); + return { + id: descriptor.id, + label: descriptor.label, + summary: descriptor.summary, + level: status.level, + ...(status.detail === undefined ? {} : { detail: status.detail }), + ...(descriptor.docsUrl === undefined + ? {} + : { docsUrl: descriptor.docsUrl }), + appliesLive: descriptor.appliesLive, + fields, + }; + }); + } + + /** Persist one field, then apply the change to the live runtime. */ + async saveField( + integrationId: string, + fieldKey: string, + value: string, + ): Promise { + await this.mutate(integrationId, fieldKey, value, "saved"); + } + + /** Clear one field, then unmount whatever it was powering. */ + async clearField(integrationId: string, fieldKey: string): Promise { + await this.mutate(integrationId, fieldKey, null, "cleared"); + } + + private async mutate( + integrationId: string, + fieldKey: string, + value: string | null, + verb: string, + ): Promise { + this.bus.emit({ type: "integrations_action_started" }); + try { + const descriptor = findIntegration(integrationId); + if (!descriptor) { + throw new IntegrationSecretError(`unknown integration ${integrationId}`); + } + const field = descriptor.fields.find((f) => f.key === fieldKey); + if (!field) { + throw new IntegrationSecretError(`unknown field ${fieldKey}`); + } + writeFieldValue(getConfig().paths.stateDir, field, value); + if (integrationId === "composio") { + await this.applyComposio(value !== null); + } + this.bus.emit({ + type: "integrations_action_settled", + message: `${descriptor.label} ${field.label} ${verb}`, + }); + } catch (err) { + this.bus.emit({ + type: "integrations_action_settled", + error: err instanceof Error ? err.message : String(err), + }); + } + this.refresh(); + } + + /** + * Mount or unmount the Composio MCP server without a restart. + * + * The cached tool-router session is dropped on every key change: a + * session belongs to the key that created it, so reusing it across a + * key swap would silently keep talking to the old account. + */ + private async applyComposio(configured: boolean): Promise { + const config = getConfig(); + await this.runtime.mcpManager.removeServerLive(COMPOSIO_SERVER_NAME); + clearComposioSession(config.paths.userConfigFile); + if (configured) { + const server = await resolveComposioServerConfig({ + composio: getConfig().composio, + userConfigFile: config.paths.userConfigFile, + }); + if (server) await this.runtime.mcpManager.addServerLive(server); + } + await this.runtime.refreshMcp?.(); + } +} diff --git a/src/tui/integrations/integrations-panel-reducer.test.ts b/src/tui/integrations/integrations-panel-reducer.test.ts new file mode 100644 index 00000000..bbff70d3 --- /dev/null +++ b/src/tui/integrations/integrations-panel-reducer.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; + +import { reduceIntegrationsAction } from "./integrations-panel-reducer.js"; +import { + createInitialIntegrationsPanelState, + type IntegrationRow, + type IntegrationsPanelState, +} from "./integrations-panel-state.js"; +import type { TuiState } from "../tui-state.js"; + +function rowOf(id: string, fields = 1): IntegrationRow { + return { + id, + label: id, + summary: "", + level: "not_configured", + appliesLive: true, + fields: Array.from({ length: fields }, (_, i) => ({ + key: `f${i}`, + label: `Field ${i}`, + display: "—", + present: false, + })), + }; +} + +function stateWith( + panel: Partial = {}, +): TuiState { + return { + integrationsPanel: { ...createInitialIntegrationsPanelState(), ...panel }, + } as unknown as TuiState; +} + +function panelAfter( + panel: Partial, + action: { type: string } & Record, +): IntegrationsPanelState { + const next = reduceIntegrationsAction(stateWith(panel), action); + return (next as TuiState).integrationsPanel; +} + +describe("reduceIntegrationsAction", () => { + it("declines actions from other slices", () => { + expect(reduceIntegrationsAction(stateWith(), { type: "privacy_synced" })).toBeNull(); + }); + + it("clamps the cursor when a sync shrinks the list", () => { + // Rows can shrink under the operator; jumping the selection out of + // range would crash the detail view. + const panel = panelAfter( + { selected: 5, rows: [rowOf("a"), rowOf("b"), rowOf("c")] }, + { type: "integrations_synced", rows: [rowOf("a")] }, + ); + expect(panel.selected).toBe(0); + }); + + it("keeps the cursor where it was across a re-sync", () => { + const rows = [rowOf("a"), rowOf("b"), rowOf("c")]; + const panel = panelAfter( + { selected: 2, rows }, + { type: "integrations_synced", rows }, + ); + expect(panel.selected).toBe(2); + }); + + it("moves the list cursor and clamps at both ends", () => { + const rows = [rowOf("a"), rowOf("b")]; + expect(panelAfter({ rows, selected: 0 }, { type: "integrations_moved", delta: -1 }).selected).toBe(0); + expect(panelAfter({ rows, selected: 0 }, { type: "integrations_moved", delta: 1 }).selected).toBe(1); + expect(panelAfter({ rows, selected: 1 }, { type: "integrations_moved", delta: 1 }).selected).toBe(1); + }); + + it("does not move the list cursor while in detail mode", () => { + const rows = [rowOf("a"), rowOf("b")]; + const panel = panelAfter( + { rows, selected: 0, mode: "detail" }, + { type: "integrations_moved", delta: 1 }, + ); + expect(panel.selected).toBe(0); + }); + + it("opens into detail and resets the field cursor", () => { + const panel = panelAfter( + { rows: [rowOf("a", 3)], selectedField: 2 }, + { type: "integrations_opened" }, + ); + expect(panel.mode).toBe("detail"); + expect(panel.selectedField).toBe(0); + }); + + it("refuses to open an empty list", () => { + expect(panelAfter({ rows: [] }, { type: "integrations_opened" }).mode).toBe( + "list", + ); + }); + + it("starts an edit with an empty buffer, never the current secret", () => { + // Seeding the buffer with the stored value would put the key back on + // screen in plain text, defeating the masking everywhere else. + const panel = panelAfter( + { rows: [rowOf("a")], mode: "detail" }, + { type: "integrations_edit_started" }, + ); + expect(panel.mode).toBe("edit"); + expect(panel.editBuffer).toBe(""); + }); + + it("appends to and cancels the edit buffer", () => { + const base = { rows: [rowOf("a")], mode: "edit" as const }; + expect( + panelAfter(base, { type: "integrations_edit_changed", value: "ak_1" }) + .editBuffer, + ).toBe("ak_1"); + const cancelled = panelAfter( + { ...base, editBuffer: "ak_1" }, + { type: "integrations_edit_cancelled" }, + ); + expect(cancelled.mode).toBe("detail"); + expect(cancelled.editBuffer).toBe(""); + }); + + it("clears the buffer when an action settles, so a key never lingers", () => { + const panel = panelAfter( + { rows: [rowOf("a")], mode: "edit", editBuffer: "ak_secret", busy: true }, + { type: "integrations_action_settled", message: "saved" }, + ); + expect(panel.busy).toBe(false); + expect(panel.mode).toBe("detail"); + expect(panel.editBuffer).toBe(""); + expect(panel.message).toBe("saved"); + }); + + it("replaces a stale error rather than stacking messages", () => { + const panel = panelAfter( + { lastError: "old", message: "old" }, + { type: "integrations_action_settled", message: "new" }, + ); + expect(panel.lastError).toBeNull(); + expect(panel.message).toBe("new"); + }); +}); diff --git a/src/tui/integrations/integrations-panel-reducer.ts b/src/tui/integrations/integrations-panel-reducer.ts new file mode 100644 index 00000000..b8149dc2 --- /dev/null +++ b/src/tui/integrations/integrations-panel-reducer.ts @@ -0,0 +1,97 @@ +import type { TuiState } from "../tui-state.js"; +import { + isIntegrationsAction, + type IntegrationsAction, +} from "./integrations-actions.js"; +import type { IntegrationsPanelState } from "./integrations-panel-state.js"; + +/** + * Reducer slice for `state.integrationsPanel`. Returns an updated + * `TuiState` when the action belongs to this slice, `null` otherwise so + * the root reducer can fall through. Pure: every side effect (`.env` + * writes, live MCP mount/unmount) lives in + * `integrations-orchestrator.ts`. + */ +export function reduceIntegrationsAction( + state: TuiState, + action: { type: string }, +): TuiState | null { + if (!isIntegrationsAction(action)) return null; + const panel = state.integrationsPanel; + const next = reducePanel(panel, action); + if (next === panel) return state; + return { ...state, integrationsPanel: next }; +} + +function reducePanel( + panel: IntegrationsPanelState, + action: IntegrationsAction, +): IntegrationsPanelState { + switch (action.type) { + case "integrations_synced": { + // A re-sync must not yank the cursor: rows can be re-ordered or + // shrink while the operator is looking at one, and jumping the + // selection under them loses their place mid-edit. + const selected = clamp(panel.selected, action.rows.length); + const fieldCount = action.rows[selected]?.fields.length ?? 0; + return { + ...panel, + rows: action.rows, + selected, + selectedField: clamp(panel.selectedField, fieldCount), + }; + } + case "integrations_moved": { + if (panel.mode !== "list") return panel; + const selected = clamp(panel.selected + action.delta, panel.rows.length); + if (selected === panel.selected) return panel; + return { ...panel, selected, selectedField: 0 }; + } + case "integrations_field_moved": { + if (panel.mode !== "detail") return panel; + const count = panel.rows[panel.selected]?.fields.length ?? 0; + const selectedField = clamp(panel.selectedField + action.delta, count); + if (selectedField === panel.selectedField) return panel; + return { ...panel, selectedField }; + } + case "integrations_opened": + if (panel.rows.length === 0) return panel; + return { ...panel, mode: "detail", selectedField: 0 }; + case "integrations_closed": + return { ...panel, mode: "list", editBuffer: "" }; + case "integrations_edit_started": + if (panel.mode !== "detail") return panel; + // Start empty rather than pre-filling the current value: a secret + // is masked everywhere else, and seeding the buffer with it would + // put the key back on screen in plain text. + return { ...panel, mode: "edit", editBuffer: "" }; + case "integrations_edit_changed": + if (panel.mode !== "edit") return panel; + return { ...panel, editBuffer: action.value }; + case "integrations_edit_cancelled": + return { ...panel, mode: "detail", editBuffer: "" }; + case "integrations_action_started": + return { ...panel, busy: true }; + case "integrations_action_settled": + return { + ...panel, + busy: false, + mode: panel.mode === "edit" ? "detail" : panel.mode, + editBuffer: "", + message: action.message ?? null, + lastError: action.error ?? null, + }; + case "integrations_message_cleared": + return { ...panel, message: null, lastError: null }; + default: + return panel; + } +} + +/** Clamp an index into `[0, length)`, or 0 when the list is empty. */ +function clamp(index: number, length: number): number { + if (length <= 0) return 0; + if (index < 0) return 0; + if (index >= length) return length - 1; + return index; +} diff --git a/src/tui/integrations/integrations-panel-state.ts b/src/tui/integrations/integrations-panel-state.ts new file mode 100644 index 00000000..fae8c802 --- /dev/null +++ b/src/tui/integrations/integrations-panel-state.ts @@ -0,0 +1,81 @@ +import type { IntegrationStatusLevel } from "../../integrations/index.js"; + +/** + * UI state for the "Integrations" tab — the one place an operator puts + * third-party credentials (Composio today; Telegram and Discord next). + * + * The orchestrator pushes rows in through `integrations_synced`; the + * reducer only folds actions and never touches `.env`, config or the + * runtime. + */ + +/** One field as rendered in the detail view. */ +export interface IntegrationFieldRow { + key: string; + label: string; + /** Already masked when the field is a secret — never the raw value. */ + display: string; + present: boolean; + help?: string; +} + +/** One integration as rendered in the list view. */ +export interface IntegrationRow { + id: string; + label: string; + summary: string; + level: IntegrationStatusLevel; + detail?: string; + docsUrl?: string; + appliesLive: boolean; + fields: readonly IntegrationFieldRow[]; +} + +export type IntegrationsPanelMode = "list" | "detail" | "edit"; + +export interface IntegrationsPanelState { + mode: IntegrationsPanelMode; + rows: readonly IntegrationRow[]; + /** Index into `rows`. Clamped by the reducer, never out of range. */ + selected: number; + /** Index into the selected row's `fields`, used in detail/edit. */ + selectedField: number; + /** + * In-progress value for the field being edited. Held in plain text + * because the operator has to be able to see what they typed before + * committing; it is masked the moment it is saved and is never + * logged or persisted anywhere but `/.env`. + */ + editBuffer: string; + /** True while a save / clear is in flight. */ + busy: boolean; + message: string | null; + lastError: string | null; +} + +export function createInitialIntegrationsPanelState(): IntegrationsPanelState { + return { + mode: "list", + rows: [], + selected: 0, + selectedField: 0, + editBuffer: "", + busy: false, + message: null, + lastError: null, + }; +} + +/** The row under the cursor, or `undefined` when the list is empty. */ +export function selectedRow( + state: IntegrationsPanelState, +): IntegrationRow | undefined { + return state.rows[state.selected]; +} + +/** The field under the cursor in detail/edit mode. */ +export function selectedField( + state: IntegrationsPanelState, +): IntegrationFieldRow | undefined { + return selectedRow(state)?.fields[state.selectedField]; +} diff --git a/src/tui/menu/menu-behaviour.test.ts b/src/tui/menu/menu-behaviour.test.ts index 6ec9182b..3599ec0f 100644 --- a/src/tui/menu/menu-behaviour.test.ts +++ b/src/tui/menu/menu-behaviour.test.ts @@ -60,7 +60,8 @@ describe("menu rows", () => { expect(selectMenuTitle(state)).toContain("Manage"); const labels = selectMenuItems(state).map((r) => r.node.label); expect(labels).toEqual([ - "Tasks", "Skills", "Memory", "MCP", "LLM", "Telegram", "Import", "Privacy", + "Tasks", "Skills", "Memory", "MCP", "Integrations", "LLM", "Telegram", + "Import", "Privacy", ]); }); diff --git a/src/tui/menu/menu-registry.test.ts b/src/tui/menu/menu-registry.test.ts index a05da31e..d0241a2b 100644 --- a/src/tui/menu/menu-registry.test.ts +++ b/src/tui/menu/menu-registry.test.ts @@ -157,6 +157,11 @@ const V0_2_2_SLASH_COMMANDS = [ description: "open MCP tab (configured servers + discovered tools / resources / prompts) · subcommands: `/mcp add` opens JSON-paste modal, `/mcp remove ` opens delete-confirm", }, + { + name: "integrations", + description: + "open Integrations tab \u2014 one place for third-party credentials (Composio API key, \u2026). Nothing is loaded until a key is set", + }, { name: "model", description: @@ -307,6 +312,7 @@ describe("menu registry", () => { "Skills", "Memory", "MCP", + "Integrations", "LLM", "Telegram", "Import", diff --git a/src/tui/menu/menu-registry.ts b/src/tui/menu/menu-registry.ts index b63242ba..78735397 100644 --- a/src/tui/menu/menu-registry.ts +++ b/src/tui/menu/menu-registry.ts @@ -227,7 +227,7 @@ export const MENU: readonly MenuNode[] = [ name: "tasks", description: "jump to the Tasks tab (Option 4 cron + ingress UI)", - rank: 27, + rank: 28, }, section: "manage", tab: "tasks", @@ -281,6 +281,24 @@ export const MENU: readonly MenuNode[] = [ tab: "mcp", parent: "go.manage", }, + { + kind: "place", + id: "go.manage.integrations", + label: "Integrations", + group: "go", + // `i` belongs to Import; `y` is the free letter closest to a + // mnemonic for "integrations" that no other node claims. + chord: "y", + slash: { + name: "integrations", + description: + "open Integrations tab — one place for third-party credentials (Composio API key, …). Nothing is loaded until a key is set", + rank: 26, + }, + section: "manage", + tab: "integrations", + parent: "go.manage", + }, { kind: "place", id: "go.manage.llm", @@ -307,7 +325,7 @@ export const MENU: readonly MenuNode[] = [ name: "telegram", description: "telegram tab · subcommands: enable | disable | start | stop | restart | pair | token", - rank: 29, + rank: 30, }, section: "manage", tab: "telegram", @@ -323,7 +341,7 @@ export const MENU: readonly MenuNode[] = [ name: "import", description: "open the Import tab (one-shot Hermes -> atomic-agent migration)", - rank: 30, + rank: 31, }, section: "manage", tab: "import", @@ -339,7 +357,7 @@ export const MENU: readonly MenuNode[] = [ name: "privacy", description: "open the Privacy tab (analytics opt-out + session grants) · subcommands: `/privacy analytics on|off`", - rank: 31, + rank: 32, }, section: "manage", tab: "privacy", @@ -409,7 +427,7 @@ export const MENU: readonly MenuNode[] = [ name: "context", description: "show where this session's context window went", - rank: 37, + rank: 38, }, }, { @@ -435,7 +453,7 @@ export const MENU: readonly MenuNode[] = [ description: "open chat model picker · subcommands: pull | use | status | ", aliases: ["models", "local"], - rank: 26, + rank: 27, }, }, { @@ -477,7 +495,7 @@ export const MENU: readonly MenuNode[] = [ name: "queue", description: "parked messages: `/queue` list | `/queue ` park one | `/queue clear` | `/queue mode` make Enter queue", - rank: 33, + rank: 34, }, }, { @@ -490,7 +508,7 @@ export const MENU: readonly MenuNode[] = [ description: "open a new terminal window running atomic-agent (ctrl+n)", aliases: ["newwindow"], - rank: 35, + rank: 36, }, }, { @@ -502,7 +520,7 @@ export const MENU: readonly MenuNode[] = [ name: "steer", description: "steer the running turn: `/steer ` one-shot | bare `/steer` makes Enter steer", - rank: 34, + rank: 35, }, }, { @@ -551,7 +569,7 @@ export const MENU: readonly MenuNode[] = [ name: "mouse", description: "mouse support on/off/status (off restores the terminal's drag-to-select)", - rank: 36, + rank: 37, }, }, { @@ -563,7 +581,7 @@ export const MENU: readonly MenuNode[] = [ name: "sidebar", description: "hide or show the session rail (the rail's « does the same)", - rank: 38, + rank: 39, }, }, { @@ -575,7 +593,7 @@ export const MENU: readonly MenuNode[] = [ name: "analytics", description: "toggle anonymous analytics: `/analytics on|off|status`", - rank: 32, + rank: 33, }, }, { @@ -599,7 +617,7 @@ export const MENU: readonly MenuNode[] = [ name: "task", description: "task subcommand: `/task new` | `/task cancel ` | `/task run `", - rank: 28, + rank: 29, }, }, { @@ -669,7 +687,7 @@ export const MENU: readonly MenuNode[] = [ // Last in the palette too: an empty `/` lists the registry in // rank order, and this is the entry that belongs at the bottom // of that list rather than fuzzy-matching next to `/update`. - rank: 99, + rank: 100, }, }, ]; diff --git a/src/tui/section.ts b/src/tui/section.ts index eabb907f..3be3e690 100644 --- a/src/tui/section.ts +++ b/src/tui/section.ts @@ -32,6 +32,7 @@ export const MANAGE_TABS: readonly TuiTab[] = [ "skills", "memory", "mcp", + "integrations", "llm", "telegram", "import", diff --git a/src/tui/tui-action.ts b/src/tui/tui-action.ts index c5161bec..6a0fc65a 100644 --- a/src/tui/tui-action.ts +++ b/src/tui/tui-action.ts @@ -13,6 +13,7 @@ import type { McpAction } from "./mcp/mcp-actions.js"; import type { ImportAction } from "./import/import-actions.js"; import type { TelegramAction } from "./telegram/telegram-actions.js"; import type { PrivacyAction } from "./privacy/privacy-actions.js"; +import type { IntegrationsAction } from "./integrations/integrations-actions.js"; import type { ProvidersAction } from "./providers/providers-actions.js"; import type { LlmPanelAction } from "./llm-panel/llm-panel-actions.js"; import type { OnboardingAction } from "./onboarding/onboarding-actions.js"; @@ -311,6 +312,7 @@ export type TuiAction = | McpAction | TelegramAction | PrivacyAction + | IntegrationsAction | ProvidersAction | OnboardingAction | UninstallAction diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index bc531809..dc8a9baf 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -123,6 +123,7 @@ import type { ImportFormState } from "./import/import-panel-state.js"; import { handleProvidersTabKey } from "./providers/providers-key-bindings.js"; import { handleTelegramTabKey } from "./telegram/telegram-key-bindings.js"; import { handlePrivacyTabKey } from "./privacy/privacy-key-bindings.js"; +import { handleIntegrationsTabKey } from "./integrations/integrations-key-bindings.js"; import { ContextMenuPopup, ContextMenuProvider } from "./context-menu/index.js"; import { createDragIntentTracker } from "./mouse/drag-intent.js"; import { MouseProvider } from "./mouse/mouse-context.js"; @@ -568,6 +569,19 @@ export interface TuiAppCallbacks { onAnalyticsSetEnabledRequested?(enabled: boolean): void | Promise; /** Privacy tab: re-read the persisted `analytics.enabled` snapshot. */ onPrivacyRefreshRequested?(): void; + /** Integrations tab: re-read credential presence + live server state. */ + onIntegrationsRefreshRequested?(): void; + /** Integrations tab: persist one credential field to `/.env`. */ + onIntegrationFieldSaveRequested?( + integrationId: string, + fieldKey: string, + value: string, + ): void | Promise; + /** Integrations tab: clear one credential field. */ + onIntegrationFieldClearRequested?( + integrationId: string, + fieldKey: string, + ): void | Promise; /** Import tab: run a dry-run preview of the Hermes import. */ onImportPreview?(form: ImportFormState): void; /** Import tab: execute the import (write sessions / tasks / secrets). */ @@ -741,6 +755,12 @@ export function TuiApp({ } }, [state.uiMode, state.activeTab, callbacks]); + useEffect(() => { + if (state.uiMode === "debug" && state.activeTab === "integrations") { + callbacks.onIntegrationsRefreshRequested?.(); + } + }, [state.uiMode, state.activeTab, callbacks]); + useEffect(() => { if ( state.uiMode === "debug" && @@ -834,6 +854,8 @@ export function TuiApp({ state.uiMode === "debug" && state.activeTab === "import"; const privacyTabActive = state.uiMode === "debug" && state.activeTab === "privacy"; + const integrationsTabActive = + state.uiMode === "debug" && state.activeTab === "integrations"; const terminalSize = useTerminalSize(); const sidebarVisible = state.uiMode === "chat" && @@ -945,6 +967,7 @@ export function TuiApp({ !telegramTabActive && !importTabActive && !privacyTabActive && + !integrationsTabActive && !sidebarFocused && !( localModelsTabActive && @@ -1015,6 +1038,9 @@ export function TuiApp({ if (telegramTabActive) return handleTelegramTabKey(input, key, ctx); if (importTabActive) return handleImportTabKey(input, key, ctx); if (privacyTabActive) return handlePrivacyTabKey(input, key, ctx); + if (integrationsTabActive) { + return handleIntegrationsTabKey(input, key, ctx); + } return null; }; diff --git a/src/tui/tui-command.ts b/src/tui/tui-command.ts index 4ce9e2b5..7fcdcd84 100644 --- a/src/tui/tui-command.ts +++ b/src/tui/tui-command.ts @@ -664,6 +664,12 @@ export async function tuiCommand(args: string[]): Promise { onAnalyticsSetEnabledRequested: (enabled) => orchestrator.privacy.setAnalyticsEnabled(enabled), onPrivacyRefreshRequested: () => orchestrator.privacy.refresh(), + onIntegrationsRefreshRequested: () => + orchestrator.integrations.refresh(), + onIntegrationFieldSaveRequested: (integrationId, fieldKey, value) => + orchestrator.integrations.saveField(integrationId, fieldKey, value), + onIntegrationFieldClearRequested: (integrationId, fieldKey) => + orchestrator.integrations.clearField(integrationId, fieldKey), onUpdateConfirmed: () => parsed.fakeUpdateVersion ? // The testing ground must never reach install.sh: the diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index 637fb804..661a677a 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -58,6 +58,10 @@ import { createInitialPrivacyPanelState, type PrivacyPanelState, } from "./privacy/privacy-panel-state.js"; +import { + createInitialIntegrationsPanelState, + type IntegrationsPanelState, +} from "./integrations/integrations-panel-state.js"; import { createInitialProvidersPanelState, type ProvidersPanelState, @@ -118,7 +122,8 @@ export type TuiTab = | "mcp" | "providers" | "import" - | "privacy"; + | "privacy" + | "integrations"; /** * Top-level UI mode: `chat` is the default single-scroll openclaw-style @@ -510,6 +515,8 @@ export interface TuiState { importPanel: ImportPanelState; /** State slice driving the Privacy tab (data-egress preferences). */ privacyPanel: PrivacyPanelState; + /** State slice driving the Integrations tab (third-party credentials). */ + integrationsPanel: IntegrationsPanelState; /** Cloud / local LLM provider registry (hot-swap active text provider). */ providersPanel: ProvidersPanelState; /** Unified operator LLM panel combining provider routing and local daemon state. */ @@ -762,6 +769,7 @@ export function createInitialTuiState( mcpPanel: createInitialMcpPanelState(), importPanel: createInitialImportPanelState(), privacyPanel: createInitialPrivacyPanelState(), + integrationsPanel: createInitialIntegrationsPanelState(), providersPanel: createInitialProvidersPanelState(), llmPanel, fallbackPanel: createInitialFallbackPanelState(), From 91ae456c0b53c0468532adb34972586b4c691d0e Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Sat, 5 Sep 2026 00:55:08 +0300 Subject: [PATCH 2/2] fix(tui): dispatch /integrations instead of "not yet implemented" Registering a MenuPlaceNode with a `slash` puts the command in the palette and binds its ctrl+g chord, but execution is a separate switch: without a case, typing /integrations answered "command /integrations not yet implemented" while still advertising itself in the palette. Found by driving the real TUI rather than by a test, so this also adds the generic guard that would have caught it: every command SLASH_COMMANDS advertises is dispatched and none falls through to "not yet implemented". --- .../commands/slash-command-handler.test.ts | 26 +++++++++++++++++++ src/tui/commands/slash-command-handler.ts | 6 +++++ 2 files changed, 32 insertions(+) diff --git a/src/tui/commands/slash-command-handler.test.ts b/src/tui/commands/slash-command-handler.test.ts index 83e02b8d..755d434b 100644 --- a/src/tui/commands/slash-command-handler.test.ts +++ b/src/tui/commands/slash-command-handler.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { dispatchSlashCommand } from "./slash-command-handler.js"; +import { SLASH_COMMANDS } from "./slash-commands.js"; describe("dispatchSlashCommand", () => { it("lists slash commands with descriptions for /help", () => { @@ -13,6 +14,31 @@ describe("dispatchSlashCommand", () => { expect(result.systemMessage).toContain("aliases: /exit"); }); + it("opens the Integrations tab for /integrations", () => { + // Registering a MenuPlaceNode with a `slash` only lists the command + // in the palette and binds its chord -- execution still needs a case + // here, and without one the command answers "not yet implemented". + const result = dispatchSlashCommand("/integrations"); + expect(result.systemMessage).toBeUndefined(); + expect(result.actions).toEqual([ + { type: "ui_mode_set", mode: "debug" }, + { type: "tab_changed", tab: "integrations" }, + { type: "integrations_message_cleared" }, + ]); + }); + + it("leaves no palette command without a dispatch case", () => { + // The generic guard for the bug above: every command the palette + // advertises must actually do something when typed. + for (const command of SLASH_COMMANDS) { + const result = dispatchSlashCommand(`/${command.name}`); + expect( + result.systemMessage ?? "", + `/${command.name} is listed but not dispatched`, + ).not.toContain("not yet implemented"); + } + }); + it("forwards non-slash input as a regular message", () => { const result = dispatchSlashCommand("hello world"); expect(result.forwardAsMessage).toBe(true); diff --git a/src/tui/commands/slash-command-handler.ts b/src/tui/commands/slash-command-handler.ts index 121ad895..f7b8618a 100644 --- a/src/tui/commands/slash-command-handler.ts +++ b/src/tui/commands/slash-command-handler.ts @@ -276,6 +276,12 @@ export function dispatchSlashCommand(buffer: string): SlashDispatchResult { return dispatchMemorySub(parsed.args); case "mcp": return dispatchMcpSub(parsed.args); + case "integrations": + return pureActions([ + { type: "ui_mode_set", mode: "debug" }, + { type: "tab_changed", tab: "integrations" }, + { type: "integrations_message_cleared" }, + ]); case "llm": return dispatchLlmSub(parsed.args); case "model":