Skip to content
Open
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
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ Speculative batching (the runtime guessing that the model "should" have batched
- Tests are colocated with source: `build-prompt.test.ts` next to `build-prompt.ts`.
- Config lives in `src/config/` — read it before touching env vars.

## Mouse support

The TUI is clickable. Ink has no mouse layer, so this is built in `src/tui/mouse/`:

1. **Reporting** — `enableMouseTracking` writes `\x1b[?1000h\x1b[?1006h` (button events + SGR coordinates). 1002/1003 motion tracking is deliberately **not** requested: nothing in the UI hovers or drags, and motion reports are a constant wakeup stream. Paired with a `process.on("exit")` restore, like `alt-screen.ts`.
2. **Decoding** — `decodeMouseEvents` is a pure function over a stdin chunk returning `{ events, text, rest }`. It understands SGR and legacy X10, buffers a report split across two reads, and passes a lone trailing `ESC` straight through (buffering it would delay the Escape key by one keystroke).
3. **Stream split** — `createMouseStdin` reads the real TTY, hands Ink a `PassThrough` carrying only the keyboard bytes, and proxies `isTTY` / `setRawMode` / `ref` / `unref` to the real stdin. Without this the reports reach Ink's key parser and get typed into the chat buffer.
4. **Hit testing** — `MouseTargetRegistry` resolves a cell to a component. Ink exposes no absolute positions, but every node keeps its Yoga node, and `absoluteRect` sums `getComputedLeft/Top` up the parent chain — the same walk `render-node-to-output.ts` does when painting, so the rectangle is exactly where the node was drawn. Ancestors with `overflow: hidden` clip the result. Ties resolve innermost-first (higher layer, then smaller box, then later mount).
5. **Layers** — `MOUSE_LAYER_BASE` / `_PANEL` / `_MODAL`. `TuiApp` raises the registry floor to `_MODAL` whenever a modal, confirm or picker owns the keyboard (`isPanelModalOpen`, shared with `handleAppKey`), so a click cannot reach the list rendered behind a modal.

**Interaction contract.** First click selects, a second click on the selected row activates. Activation and the wheel are routed through each panel's existing `*-key-bindings.ts` handler with a synthetic Enter / arrow key (`synthetic-key.ts`), so the mouse can never disagree with the keyboard about what a row does. Clicking the prompt places the caret (`rowColToCursor`, clamped to the line length).

**The trade-off.** While reporting is on, the terminal stops doing its own drag-to-select (Apple Terminal has no Shift-bypass). Hence `tui.mouse` (config v38, default `true`), `--mouse` / `--no-mouse`, and `/mouse on|off` at runtime; `tui-command.ts` owns the live toggle and the config write. With mouse off the previous behaviour is intact: alternate-scroll (`\x1b[?1007h`) turns the wheel into cursor keys.

**Testing.** Escape sequences, decoder and stream split are unit-tested; `mouse-app.test.tsx` drives the real Ink tree by locating a label in the rendered frame and emitting a click at those coordinates. Ink commits frames on a ~30fps throttle, so tests must wait longer than one frame before clicking a freshly rendered target.

## Module map

| Folder | Responsibility |
Expand Down Expand Up @@ -180,6 +196,7 @@ Speculative batching (the runtime guessing that the model "should" have batched
| `src/channels/telegram/` | `TelegramChannel` (lifecycle + live-control), `inbound-handler` (slash commands + dispatch into `runTurn`), `outbound-sender` (chunked replies + 429 retry), `approval-bridge` (inline-keyboard approvals with 8-min auto-deny), `pairing-mode` (60s window for first-DM owner claim), `telegram-settings` (`config.json` + `.env` persistence), `telegram-bot-factory` (grammy adapter). The **only** module that imports `grammy`. See §"Telegram remote-control channel". |
| `src/tui/telegram/` | TUI "Telegram" tab: `telegram-panel-state` + `telegram-actions` + `telegram-panel-reducer` (pure UI state slice), `tui-telegram-orchestrator` (the only TUI module that touches `runtime.telegramChannel`), `telegram-key-bindings`, and the `telegram-panel` / `telegram-token-prompt` / `telegram-pairing-modal` components. See §"Telegram remote-control channel". |
| `src/mcp/` | MCP (Model Context Protocol) **client** subsystem. `McpManager` (lifecycle for N `McpClient` instances), `mcp-client` (the **only** file that imports `@modelcontextprotocol/sdk` — together with `mcp-sampling-handler` for SDK type shapes), `mcp-tool-adapter` (`McpToolMeta` → `ToolDefinition`), `mcp-resource-class` (per-server trust → `ResourceClass` resolver), `mcp-descriptor-builder` (rare-tier descriptors), `mcp-grammar-builder` (dynamic `mcp-server-tool` GBNF fragment), `mcp-sampling-handler` (forwards `sampling/createMessage` to `LlamaServerClient` with `slotId: -1`), `mcp-resource-tools` + `mcp-prompt-tools` (aggregate read-only `mcp.{resource,prompt}.*` tools dispatching by `server` arg). See §"MCP client". |
| `src/tui/mouse/` | TUI mouse layer: `mouse-tracking` (1000+1006 enable/disable), `parse-mouse-events` (SGR + legacy X10 decoder), `mouse-stdin` (splits mouse bytes out of the stream Ink reads), `mouse-registry` (Yoga-based hit testing), `mouse-context` / `mouse-list-row` (React glue + the shared click-to-select-then-activate row), `synthetic-key` (wheel/second-click → the panel's own key handler). See §"Mouse support". |

## Secrets and process environment

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ atomic-agent trace list --limit 10

Handy slash commands: `/help` lists every command, `/tools` lists the built-in tool families, `/run` switches run mode, `/model` jumps to the LLM panel and reopens the model picker for the active cloud provider, `/privacy` shows what leaves the machine (`/privacy analytics off` turns analytics off). The chat log scrolls with PgUp / PgDn (fn+arrows on macOS).

**Mouse.** The TUI is clickable: the Run / Observe / Manage bar and its sub-tabs, sidebar sessions and tasks, every list row (skills, tasks, memory, MCP, models, providers), the session / theme / slash pickers, approval buttons, tool cards, and the prompt itself — clicking in the input places the caret. A click selects a row, a second click on the selected row opens it, and the wheel scrolls the chat or walks the focused panel.

While mouse reporting is on the terminal hands clicks to the app, which means its own drag-to-select is unavailable (iTerm2, GNOME Terminal and Windows Terminal let you hold Shift to bypass; Apple Terminal does not). Turn it off whenever you want to select text: `/mouse off` in the app, `atomic-agent tui --no-mouse` for one run, or `"tui": { "mouse": false }` in `<stateDir>/config.json`. With mouse off, wheel scrolling still works through the terminal's alternate-scroll mode, exactly as before.

Cloud provider setup pulls each provider's full live model catalog, hundreds of models, instead of a short hardcoded list; OpenAI-compatible servers are asked for their own `/v1/models`. The picker filters as you type, and `/model` switches models mid-session.

</details>
Expand Down
31 changes: 31 additions & 0 deletions src/config/config-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,37 @@ describe("parseUserConfigFile", () => {
expect(parsed.tui.theme).toBe("auto");
});

it("enables tui.mouse by default when migrating from v37", () => {
const parsed = parseUserConfigFile({ version: 37 });
expect(parsed.version).toBe(USER_CONFIG_VERSION);
expect(parsed.tui.mouse).toBe(true);
});

it("preserves tui.mouse: false so an operator's opt-out survives", () => {
const parsed = parseUserConfigFile({
version: USER_CONFIG_VERSION,
tui: { theme: "auto", mouse: false },
});
expect(parsed.tui.mouse).toBe(false);
});

it("accepts the string forms parseBool understands for tui.mouse", () => {
const parsed = parseUserConfigFile({
version: USER_CONFIG_VERSION,
tui: { mouse: "off" },
});
expect(parsed.tui.mouse).toBe(false);
});

it("rejects a non-boolean tui.mouse", () => {
expect(() =>
parseUserConfigFile({
version: USER_CONFIG_VERSION,
tui: { mouse: 42 },
}),
).toThrow(/tui.mouse/);
});

it("rejects a non-string tui.theme", () => {
expect(() =>
parseUserConfigFile({
Expand Down
21 changes: 17 additions & 4 deletions src/config/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,12 +663,14 @@ export interface AtomicAgentConfig {
maxImagesPerCall: number;
};
/**
* TUI appearance. Mirrors `UserConfigFile.tui`. `theme` is `"auto"`
* (OSC 11 autodetect) or a registered theme name. Consumed by the TUI
* startup path; the rest of the runtime ignores it.
* TUI appearance and input. Mirrors `UserConfigFile.tui`. `theme` is
* `"auto"` (OSC 11 autodetect) or a registered theme name; `mouse`
* toggles terminal mouse reporting. Consumed by the TUI startup path;
* the rest of the runtime ignores it.
*/
tui: {
theme: string;
mouse: boolean;
};
/**
* Anonymous product analytics (PostHog). Mirrors
Expand Down Expand Up @@ -1359,9 +1361,16 @@ export interface UserConfigFile {
* the matching GitHub theme) or a registered theme name (e.g. `dracula`,
* `nord`). Persisted from the in-app `/theme` picker. Older files are
* transparently upgraded with `tui: { theme: "auto" }`.
*
* `mouse` (config v38, default `true`) turns terminal mouse reporting
* on: clicking panels, list rows, the nav bar and the prompt, plus
* wheel scrolling. Turning it off restores the terminal's own
* drag-to-select, which mouse reporting takes over — see `/mouse` and
* `--no-mouse`. Older files are upgraded with `mouse: true`.
*/
tui: {
theme: string;
mouse: boolean;
};
/**
* Anonymous product analytics (PostHog). Added in config v33. Older
Expand Down Expand Up @@ -1425,7 +1434,9 @@ export interface UserConfigFile {
// (`local` | `cloud` | `fusion`) plus the fusion cloud-share dial and
// the sub-runner target. Absence IS the v37 behaviour: it is an
// optional sub-key of an already-optional block, so no migration code
// exists; the bump only records the schema change.
// exists; the bump only records the schema change. The same bump adds
// `tui.mouse` (terminal mouse reporting, default `true`) — also
// defaulted in, so older files upgrade without a migration step.
export const USER_CONFIG_VERSION = 38 as const;

/**
Expand Down Expand Up @@ -1782,6 +1793,7 @@ export const USER_CONFIG_DEFAULTS: UserConfigFile = {
},
tui: {
theme: "auto",
mouse: true,
},
analytics: {
enabled: true,
Expand Down Expand Up @@ -3436,6 +3448,7 @@ export function parseUserConfigFile(raw: unknown): UserConfigFile {
tui.theme ?? USER_CONFIG_DEFAULTS.tui.theme,
"tui.theme",
),
mouse: parseBool(tui.mouse ?? USER_CONFIG_DEFAULTS.tui.mouse, "tui.mouse"),
},
analytics: {
enabled: parseBool(
Expand Down
1 change: 1 addition & 0 deletions src/config/load-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,7 @@ export function loadConfig(): AtomicAgentConfig {
},
tui: {
theme: user.tui.theme,
mouse: user.tui.mouse,
},
analytics: {
enabled: user.analytics.enabled,
Expand Down
Loading