Skip to content

Commit e25e737

Browse files
authored
feat(agents): add custom agents in-app + install-aware self-updates (#50)
Add custom agents via the TUI (A), and update installed agents in-app (u/U/x/i) with a command derived from how each was installed (npm/bun/brew/uv/pipx + system PMs pacman/AUR/apt/dnf). Detection-first resolution with per-backend serialization for update-all.
1 parent 288c017 commit e25e737

19 files changed

Lines changed: 3095 additions & 30 deletions

File tree

.claude/rules/tui-agents-tab.md

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,181 @@ Selected row: Yellow + BOLD (applied via `ListItem::style`, overrides per-span s
176176

177177
---
178178

179+
## 7b. Add Agent Modal
180+
181+
Popup for adding a **custom** agent without hand-editing `config.toml` (opened
182+
with `A` — capital, since lowercase `a` opens the tracker picker). Minimal
183+
two-field form; writes a `CustomAgent` to `config.agents.custom`.
184+
185+
- **State** (`AgentsApp`): `show_add_form: bool`, `add_form: AddAgentForm`
186+
(`{ name, repo, field: AddAgentField (Name|Repo), error: Option<String> }`).
187+
- **Size**: `centered_rect_fixed(min(54, screen_width - 4), min(11, screen_height - 4))`.
188+
- **Border**: `Color::Cyan`. **Title**: `" Add Agent "`.
189+
- **Bottom title**: `" Tab: next field | Enter: save | Esc: cancel "` (centered).
190+
- **Fields**: `Name:` and `Repo:` (each ` {label:<7}` gutter). Active field
191+
label is `Cyan`+BOLD with a trailing `SLOW_BLINK` `_` cursor; inactive label
192+
is `Gray`. An empty inactive Repo shows a `DarkGray` `owner/name` placeholder.
193+
- **Keys** (`handle_add_agent_keys`, intercepts all so `q`/`?` don't leak):
194+
`Tab`/`Up`/`Down` toggle field, `Backspace`, `Char(c)` types into the active
195+
field, `Enter` saves, `Esc` cancels.
196+
- **Save** (`add_agent_save`): trims; requires a non-empty name and a valid
197+
`owner/name` slug (`is_valid_repo_slug`: exactly one `/`, non-empty halves,
198+
`[A-Za-z0-9._-]` only). Id is derived as `name.to_lowercase().replace(' ', "-")`
199+
— identical to how `AgentsApp::new` derives ids for config-loaded custom
200+
agents, so a restart re-resolves the same id. **Collision** with an existing
201+
entry id → inline error, form stays open. On success: push `CustomAgent`,
202+
`config.set_tracked(id, true)` (so it persists as tracked), `config.save()`
203+
(rolled back in-memory if the write fails), build a tracked `Loading`
204+
`AgentEntry`, re-sort entries by name, and queue the GitHub fetch via
205+
`App.pending_fetches` (same path the tracker's "newly tracked" uses). Minimal
206+
custom agents carry no `version_command`, so `detect_installed` short-circuits
207+
(no shell-out). Validation errors set `add_form.error` and return before any
208+
`config.save()` (filesystem-free).
209+
- **Load-time detection**: `AgentsApp::new` re-runs `detect_custom_agent` for any
210+
config-loaded custom agent whose stored `binary` is `None` (added before
211+
detection existed, or not installed at add time). So an agent installed *after*
212+
it was added (or added pre-detection, like a bun-installed `eve`) shows up as
213+
installed on the next launch without re-adding. Detection is best-effort and
214+
not persisted back to config (cheap re-probe per launch).
215+
- **Footer**: ` A ` (Yellow) + `add`. Help: `A — Add a new agent (name + repo)`.
216+
217+
---
218+
219+
## 7c. Update Action (in-app self-update)
220+
221+
Runs an agent's **verified** self-update command as a background subprocess —
222+
no TUI suspension, mirrors the GitHub-fetch async pattern.
223+
224+
- **Registry field**: `Agent.update_command: Vec<String>` (`data/agents.json`,
225+
`#[serde(default, skip_serializing_if = "Vec::is_empty")]`) — an argv vector,
226+
**no shell**. Only populated with commands verified from each tool's official
227+
docs (the 9 CLI agents; IDEs/extensions have none). `AgentEntry::update_command()`
228+
returns `Option<&[String]>` (None = no update action). Custom agents
229+
(`CustomAgent::to_agent`) get no update command.
230+
- **Keys**: `u` = update the selected agent; `U` = update **all** agents with
231+
`update_available()` && a verified updater; `x` = cancel the selected agent's
232+
in-flight update. `u`/`U` open a **confirm modal** first (update mutates the
233+
user's system; refresh only reads).
234+
- **`u` gates on installed**: `request_update_selected` refuses (status-bar
235+
message) when the selected agent has no detected install (`installed.version`
236+
is `None`) — nothing to update. `U` is already gated via `update_available()`.
237+
- **Confirm modal** (`draw_update_confirm_modal`): Cyan border,
238+
`centered_rect_fixed(66, …)`, title ` Update Agent(s) `. Lists each target's
239+
`name` + `$ {argv joined}` in Yellow + `(via <method>)` (`UpdateTarget.method`).
240+
Bottom hint is target-count/`needs_terminal`-dependent: single **needs_terminal**
241+
(sudo/AUR) → ` Enter: interactive | Esc: cancel ` (background can't answer a sudo
242+
prompt, so `Enter` routes to interactive); single background-safe → ` Enter:
243+
background | i: interactive | Esc: cancel `; multi → ` Enter: run | Esc: cancel `.
244+
`Enter``ConfirmUpdate` (background) or `ConfirmUpdateInteractive` when the single
245+
target `needs_terminal` (decided in `handle_update_confirm_keys(code, single_interactive)`),
246+
`i``ConfirmUpdateInteractive` (suspend-and-run, single only), `Esc`/`q`
247+
`CancelUpdate`. `request_update_selected` errors when the agent has no updater or
248+
is already running; `request_update_all` errors when none qualify (with a specific
249+
`"N need interactive — use u then i"` message when the only candidates were
250+
`needs_terminal`, which `U` excludes since it runs in the background).
251+
- **Command resolution** is **detection-first**: how the binary was installed is the
252+
source of truth; the registry `update_command` is the fallback. All I/O
253+
(`canonicalize`, ownership query, AUR-helper lookup) runs once at **detect time**
254+
(`detect.rs::resolve_install_facts`) and is stored on `InstalledInfo`
255+
(`method`/`package`/`aur_helper`), so `AgentEntry::resolved_update_command` is
256+
**pure**. Detection is two-tier: a path heuristic on the *canonicalized* path
257+
(`infer_install_method` → bun/npm/pnpm/yarn/homebrew/uv/pipx/cargo, with the
258+
package/formula/tool parsed from the path via `package_from_canonical_path` /
259+
`formula_from_cellar_path`), then — only for an unrecognized binary in a system dir
260+
(`/usr/bin` etc.) — a **system-package ownership query** (`system_package_owner`)
261+
gated on `/etc/os-release` family (`classify_distro`; never "which pacman is on
262+
PATH" — on Debian that's the arcade game): `pacman -Qo` / `dpkg -S` / `rpm -qf`
263+
`Pacman`/`Apt`/`Dnf` + owner. For pacman, `pacman -Qm` distinguishes foreign(AUR)
264+
vs official and `detect_aur_helper` picks `paru`>`yay`; a foreign package with no
265+
helper is left package-less (surfaced as `(via pacman)` but non-updatable).
266+
`resolved_update_command` priority: **(1)** system-PM (`Pacman`/`Apt`/`Dnf`) →
267+
`derive_pm_command`*precedes the self-updater* (a self-updater would desync the
268+
package DB), and never falls through to it (missing package → no update). **(2)**
269+
self-updater (registry argv[0] == cli_binary) + path → pin argv[0] to the detected
270+
path. **(3)** registry PM command → Homebrew `brew upgrade <formula>` (stored
271+
formula) or a JS-PM swap (`bun add -g <pkg>`) from the stored method. **(4)** no
272+
registry command + a detected language PM + derived package → `derive_pm_command`
273+
(custom agents added in-app; bun/pnpm/yarn are best-effort — work when the global
274+
bin symlinks into `node_modules`, `None` for a wrapper script). **(5)** else `None`.
275+
The **CLI** (`models agents`) uses `detect_installed_cli` (skips the ownership
276+
subprocess — it never runs updates).
277+
- **Execution** (`spawn_agent_update` in `tui/mod.rs`): `tokio::process::Command`
278+
(needs tokio features `process` + `io-util`), `stdin` null, stdout+stderr
279+
piped and streamed **line-by-line** over an `mpsc<UpdateEvent>` channel
280+
(`Output`/`Finished`/`Redetected`). The child is put in its **own process
281+
group** (`process_group(0)`, Unix) so a tool that opens `/dev/tty` for a prompt
282+
(sudo) is a background-group reader → SIGTTIN-stopped (caught by the timeout)
283+
rather than stealing the TUI's keystrokes / corrupting the screen. Bounded by a
284+
**5-minute timeout** (`tokio::time::timeout``start_kill`) since there's no
285+
TTY for prompts.
286+
All output is flushed (reader handles awaited) before `Finished`. On success,
287+
`detect_installed` re-runs via `spawn_blocking``Redetected` updates
288+
`AgentEntry.installed` so the dot flips without a restart. The confirmed
289+
`(id, argv)` pairs flow `App.pending_updates` → drained in the loop (mirrors
290+
`pending_fetches`); the agent is looked up at drain time for the re-detect.
291+
- **Per-backend serialization** (`U`/update-all runs concurrently): the drain loop
292+
keys a `HashMap<String, Arc<tokio::sync::Mutex<()>>>` (`update_gates`) by the
293+
update command's **program basename** (`argv[0]``npm`/`bun`/`brew`/…) and hands
294+
each spawn its gate. `spawn_agent_update` acquires the gate (`lock_owned`) before
295+
running the child and holds it for the whole run, so updates that share a package
296+
manager (same global prefix / Homebrew's global lock) **queue instead of racing**,
297+
while distinct backends — and distinct self-updaters, which get unique keys — stay
298+
**parallel**. The gate is acquired inside the same `tokio::select!` as cancel, so
299+
an update cancelled (`x`) while queued behind another never starts its command.
300+
Gates live for the session (a handful of keys). A solo `u` also gates, so it
301+
serializes against an in-flight batch of the same backend.
302+
- **State** (`AgentsApp`): `show_update_confirm`, `update_targets: Vec<UpdateTarget>`,
303+
`update_states: HashMap<id, AgentUpdateState>` (`Running`/`Succeeded`/`Failed`),
304+
`update_logs: HashMap<id, Vec<String>>` (capped at `UPDATE_LOG_CAP` = 200,
305+
oldest dropped). `confirm_update` marks each target `Running`, resets its log,
306+
and returns the spawn list. `push_update_output`/`finish_update`/`apply_redetected`
307+
apply the channel events.
308+
- **Rendering**: list status dot shows `` **Magenta** while `Running` (distinct
309+
from the Yellow GitHub-fetch spinner). Detail panel gains an `Update:` section
310+
(state line + trailing ≤12 output lines, DarkGray) whenever the selected agent
311+
has update state/logs. Status bar: `{name} updated` / `{name} update failed —
312+
see detail panel`.
313+
- **Cleanup of finished results**: a finished update's state/log auto-expires so
314+
it doesn't sit in the detail panel forever — success after **6s** (the dot
315+
already reflects the new version), failure after **30s** (long enough to read
316+
the error + the manual-run command). Tracked in the main loop's
317+
`update_clear_at` (removed when a fresh run starts for that agent;
318+
`AgentsApp::clear_update` does the removal, gated on the state not being
319+
`Running`). `x` on a finished (non-running) agent **dismisses** it immediately
320+
(`has_finished_update``clear_update`).
321+
- **Failure/no-TTY path**: npm-prefix updaters (gemini-cli, qwen-code) can fail
322+
without a writable global prefix; the captured stderr + non-zero exit surface
323+
in the detail log and the `Failed` state. On `Failed`, the detail Update
324+
section adds a `Run manually: $ <cmd>` line (the bare registry command) so the
325+
user can run it in their own shell — where they have full interactivity for any
326+
prompt. openclaw's updater restarts its daemon (heaviest side effects) —
327+
included, shown verbatim in the confirm modal.
328+
- **Interactive (suspend-and-run) path** — the `u` confirm modal offers `i`
329+
(single-agent only; `U`/update-all stays background). `i`
330+
`Message::ConfirmUpdateInteractive``AgentsApp::confirm_update_interactive`
331+
(marks the agent `Running`, returns `(id, argv)`) → `App.pending_interactive_update`
332+
→ drained in `run_app` by `run_interactive_update`: it **suspends the TUI**
333+
(`disable_raw_mode` + `LeaveAlternateScreen` + `DisableMouseCapture`), runs the
334+
updater with **inherited stdio** (`std::process::Command::status()` — fully
335+
interactive: prompts, sudo, menus), waits for a keypress, then **unconditionally
336+
restores** (`enable_raw_mode` + `EnterAlternateScreen` + `EnableMouseCapture` +
337+
`terminal.clear()`) and re-detects the version. Runs synchronously on the
338+
terminal-owning thread; restore uses no `?` so a child error can't wedge the
339+
screen (the panic hook + `run()` end-cleanup are extra nets). Output goes to the
340+
real terminal (not captured), so the detail log shows just the summary line.
341+
`run_interactive_update` manipulates the real terminal → not unit-tested; the
342+
decision branch (`confirm_update_interactive` single vs multi) is.
343+
- **Cancel a running update** (`x``Message::RequestCancelUpdate`): the main
344+
loop holds a per-agent `oneshot::Sender<()>` (`cancel_signals`, created when the
345+
update spawns, removed on `Finished`). `x` on a `Running` agent fires it →
346+
`spawn_agent_update`'s `tokio::select!` (child exit / 5-min timeout / cancel)
347+
takes the cancel branch → `start_kill` + reap → `Finished(false, "✗ update
348+
cancelled")`. This frees the user to immediately re-run with `i` (interactive),
349+
which is the recovery path when a background update turns out to need input.
350+
- **Footer**: ` u ` update, ` U ` update all, ` x ` cancel. Help: `u`/`U`/`x`.
351+
352+
---
353+
179354
## 8. Mouse
180355

181356
`handle_agents_mouse` (in `agents/app.rs`); see style guide §12 for the shared pattern.
@@ -185,3 +360,4 @@ Selected row: Yellow + BOLD (applied via `ListItem::style`, overrides per-span s
185360
- **Click:** agent row → focus List + `select_agent_at_index`; detail → focus Details only.
186361
- **Wheel (focus-then-scroll):** over the list → prev/next agent; over the detail → adjust `detail_scroll` (a `u16`, clamped at render).
187362
- The list renders into the **real** `agent_list_state` so `offset()` is valid while scrolled (fixed the `ListState` copy gotcha — see CLAUDE.md).
363+
- **Modal mouse:** `modal_popup_open` returns true for `show_picker`, `show_add_form`, **and** `show_update_confirm` so clicks/wheel can't leak to the panels behind. The Add Agent form and the update-confirm modal have no selectable rows — clicks and wheel over them are swallowed (`handle_modal_popup_click`/`handle_modal_popup_mouse` return `None` when `show_add_form || show_update_confirm`).

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ mise run fmt && mise run clippy && mise run test
2222
### Tabs
2323
- **Models Tab** (`src/tui/models/`) — browse models from models.dev API with 3-column layout (20% providers | 45% model list | 35% detail panel), RTFO capability indicators, adaptive provider panel
2424
- **Benchmarks Tab** (`src/tui/benchmarks/`) — compare model benchmarks across 4 data sources (Artificial Analysis, Epoch AI, Arena, LLM Stats) via a data-source switcher (`{`/`}`; state-preserving — search/filters/sort intent and id-matched compare selections carry across sources). All views are registry-driven from per-source metric definitions shipped in the data files (no hardcoded field names). Browse/compare modes, H2H table, scatter plot, radar chart views, an `i` glossary popup with curated per-benchmark descriptions, an `a`-cycled comparator cell in the detail panel (field avg / peer avg / rank), and `r` in-app refresh of the active source (stale-while-revalidate)
25-
- **Agents Tab** (`src/tui/agents/`) — track AI coding assistants with GitHub integration
25+
- **Agents Tab** (`src/tui/agents/`) — track AI coding assistants with GitHub integration; add custom agents in-app (`A` → name + `owner/repo`, writes `config.agents.custom`) and update agents in the background with a command **derived from how each is installed** (`u` selected / `U` all / `x` cancel / `i` interactive suspend-and-run, behind a confirm modal). Detection is the source of truth (path heuristic + system-package ownership query for AUR/apt/dnf) with the registry `Agent.update_command` as fallback; `U` runs backends in parallel but serializes same-backend updates. See `.claude/rules/tui-agents-tab.md` §7b/§7c
2626
- **Status Tab** (`src/tui/status/`) — live provider health monitoring with detail view for incidents, components, and scheduled maintenance
2727

2828
### Data Flow

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,14 @@ clap_complete = "4"
3535
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "blocking"] }
3636

3737
# Async runtime
38-
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros", "time"] }
38+
tokio = { version = "1", features = [
39+
"rt-multi-thread",
40+
"sync",
41+
"macros",
42+
"time",
43+
"process",
44+
"io-util",
45+
] }
3946

4047
# Serialization
4148
serde = { version = "1", features = ["derive"] }

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Three-column layout with providers, model list, and rich detail panel. RTFO capa
8787

8888
![Agents tab](public/assets/agents-screenshot.png)
8989

90-
Curated catalog of 12+ agents with automatic version detection, GitHub release tracking, styled changelogs with search and match navigation, and live service health from provider status pages.
90+
Curated catalog of 12+ agents with automatic version detection, GitHub release tracking, styled changelogs with search and match navigation, and live service health from provider status pages. Add your own agents without leaving the TUI (`A`), and update installed ones in-app (`u` one / `U` all) — the update command is derived from how each tool was actually installed (npm, bun, Homebrew, uv, pipx, or a system package manager like pacman/AUR, apt, dnf).
9191

9292
[Agents wiki page](https://github.com/reyamira/models/wiki/Agents) &#8226; CLI: `agents status`, `agents <tool>`, `agents latest`, `agents list-sources`
9393

data/agents.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"open_source": false,
1919
"cli_binary": "claude",
2020
"version_command": ["--version"],
21+
"update_command": ["claude", "update"],
2122
"version_regex": "([0-9]+\\.[0-9]+\\.[0-9]+)",
2223
"config_files": ["~/.claude/"],
2324
"homepage": "https://claude.ai/code",
@@ -60,6 +61,7 @@
6061
"open_source": true,
6162
"cli_binary": "goose",
6263
"version_command": ["--version"],
64+
"update_command": ["goose", "update"],
6365
"version_regex": "([0-9]+\\.[0-9]+\\.[0-9]+)",
6466
"config_files": ["~/.config/goose/"],
6567
"homepage": "https://github.com/block/goose",
@@ -101,6 +103,7 @@
101103
"open_source": true,
102104
"cli_binary": "gemini",
103105
"version_command": ["--version"],
106+
"update_command": ["npm", "install", "-g", "@google/gemini-cli@latest"],
104107
"version_regex": "([0-9]+\\.[0-9]+\\.[0-9]+)",
105108
"config_files": ["~/.gemini/"],
106109
"homepage": "https://geminicli.com",
@@ -121,6 +124,7 @@
121124
"open_source": true,
122125
"cli_binary": "opencode",
123126
"version_command": ["--version"],
127+
"update_command": ["opencode", "upgrade"],
124128
"version_regex": "([0-9]+\\.[0-9]+\\.[0-9]+)",
125129
"config_files": ["~/.opencode/"],
126130
"homepage": "https://opencode.ai",
@@ -141,6 +145,7 @@
141145
"open_source": true,
142146
"cli_binary": "codex",
143147
"version_command": ["--version"],
148+
"update_command": ["codex", "update"],
144149
"version_regex": "([0-9]+\\.[0-9]+\\.[0-9]+)",
145150
"config_files": [],
146151
"homepage": "https://developers.openai.com/codex/cli/",
@@ -181,6 +186,7 @@
181186
"open_source": true,
182187
"cli_binary": "qwen",
183188
"version_command": ["--version"],
189+
"update_command": ["npm", "install", "-g", "@qwen-code/qwen-code@latest"],
184190
"version_regex": "([0-9]+\\.[0-9]+\\.[0-9]+)",
185191
"config_files": [],
186192
"homepage": "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
@@ -201,6 +207,7 @@
201207
"open_source": true,
202208
"cli_binary": "kimi",
203209
"version_command": ["--version"],
210+
"update_command": ["uv", "tool", "upgrade", "kimi-cli", "--no-cache"],
204211
"version_regex": "([0-9]+\\.[0-9]+\\.[0-9]+)",
205212
"config_files": [],
206213
"homepage": "https://moonshotai.github.io/kimi-cli/",
@@ -221,6 +228,7 @@
221228
"open_source": true,
222229
"cli_binary": "openclaw",
223230
"version_command": ["--version"],
231+
"update_command": ["openclaw", "update"],
224232
"version_regex": "([0-9]+\\.[0-9]+\\.[0-9]+)",
225233
"config_files": [],
226234
"homepage": "https://openclaw.ai",
@@ -241,6 +249,7 @@
241249
"open_source": true,
242250
"cli_binary": "pi",
243251
"version_command": ["--version"],
252+
"update_command": ["pi", "update", "--self"],
244253
"version_regex": "([0-9]+\\.[0-9]+\\.[0-9]+)",
245254
"config_files": [],
246255
"homepage": "https://pi.dev",

0 commit comments

Comments
 (0)