From 264be8a7acc5fee4d7ec7ffbfd0e1a37f41b5c4c Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 16:37:05 -0400 Subject: [PATCH 001/117] =?UTF-8?q?feat(cli):=20add=20TUI=20spec=20?= =?UTF-8?q?=E2=80=94=20k9s-inspired=20resource=20browser=20for=20Ambient?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the interaction model, architecture, and implementation plan for rewriting acpctl ambient as a k9s-style resource browser backed by the Ambient API Server (not K8s). Stays on Bubbletea, adds bubbles/table, drops kubectl dependencies. Five views (projects, agents, sessions, messages, inbox), multi-context support, SSE message streaming. Co-Authored-By: Claude Sonnet 4.6 --- docs/internal/design/tui.spec.md | 676 +++++++++++++++++++++++++++++++ 1 file changed, 676 insertions(+) create mode 100644 docs/internal/design/tui.spec.md diff --git a/docs/internal/design/tui.spec.md b/docs/internal/design/tui.spec.md new file mode 100644 index 000000000..35bcd0117 --- /dev/null +++ b/docs/internal/design/tui.spec.md @@ -0,0 +1,676 @@ +# Ambient TUI Spec + +**Date:** 2026-04-24 +**Status:** Draft +**Component:** `components/ambient-cli/cmd/acpctl/ambient/tui/` +**Depends on:** `ambient-model.spec.md` (data model, API surface, RBAC) + +--- + +## Overview + +The Ambient TUI is a full-screen terminal interface for operating the Ambient platform. It evolves the current Bubbletea-based dashboard into a k9s-inspired resource browser backed by the Ambient API (REST/gRPC), not the Kubernetes API. + +**Design intent:** k9s's interaction model — table-first resource browsing, command mode, filtering, drill-down, contextual hotkeys — applied to the Ambient data model. Not a k9s fork. Not a generic K8s browser. A purpose-built operator console for Ambient resources. + +**Data source:** Ambient API Server exclusively. No `kubectl` exec, no direct K8s API calls. The TUI is a pure API client — if the API Server doesn't expose it, the TUI doesn't show it. + +--- + +## Principles + +| Principle | Rationale | +|-----------|-----------| +| API-only data path | CRDs are going away. The TUI must work against the Ambient API Server, not K8s. This also means the TUI works identically against local, staging, and production — no kubeconfig dependency. | +| k9s keyboard vocabulary | Users already know `:` for command mode, `/` for filter, `d`/`e`/`l`/`y` for actions, `Esc` to back out. Don't invent new muscle memory. | +| Resource-centric navigation | Every screen is a resource list or resource detail. The primary axis is: pick a resource kind → browse instances → drill into one. Same as k9s. | +| Live by default | Tables auto-refresh (5s polling). Session messages stream in real time via SSE. No manual refresh button. | +| Session interaction is first-class | k9s shows pods. Ambient's TUI shows sessions — including live message streaming, sending messages to agents, and watching agent output. This is the differentiator. | +| Respect RBAC | The TUI shows only what the authenticated user can see. API 403s are rendered inline, not as crashes. | +| Offline-safe auth | The TUI reuses `acpctl login` credentials from `~/.config/ambient/config.json`. No separate auth flow. | +| Multi-context | Operators work across local, staging, and production. The TUI saves every server the user has logged into as a named context and supports instant switching — same as k9s with kubeconfig clusters. | +| Sanitize all external content | Agent-produced output is rendered in the terminal. All content from the API is stripped of ANSI escape sequences, terminal control characters, and framework-specific tags before display. | + +--- + +## Architecture + +### Framework + +**Bubbletea + bubbles + lipgloss** (Charmbracelet stack — same as today). + +The existing TUI's problem is not Bubbletea. It is that tables are hand-rendered as strings instead of using `bubbles/table`, and that data fetching shells out to `kubectl` instead of using the SDK. The framework stays. The internals are rewritten. + +Rationale: +- `bubbles/table` provides column sorting, selection, scrolling, and keyboard navigation — the features the TUI currently lacks. +- `bubbles/textinput` provides command bar and compose input with cursor management. +- Bubbletea's Elm architecture (Model/Update/View) is better suited for the TUI's state-heavy navigation (command mode, filter mode, compose mode, detail mode, navigation stack) than tview's widget-callback model. +- `teatest` provides programmatic test harness (send keystrokes, assert on output) — tview has no equivalent. +- The dependency already exists in `go.mod`. No new terminal abstraction layer. +- lipgloss styling carries forward directly from `view.go`. + +### Migration Strategy + +The rewrite is incremental, not blank-slate: + +1. **Extract reusable logic** from existing code into framework-agnostic packages before changing any rendering. Specifically: + - Session message streaming (`restartSessionPoll` pattern from `model.go`) + - Multi-project SDK fan-out (`fetchAll` from `fetch.go`) + - AG-UI event parsing (`tileDisplayPayload`, `extractKVField` from `dashboard.go`) + - Color palette (`view.go` lines 12-29) +2. **Replace rendering** — swap hand-rendered string tables with `bubbles/table`, swap manual input handling with `bubbles/textinput`. +3. **Remove kubectl/oc code** — delete all `exec.Command("kubectl", ...)` paths, pod/namespace views, port-forward management. + +### Package Layout + +``` +cmd/acpctl/ambient/ +├── cmd.go # entry point — unchanged command registration +└── tui/ + ├── app.go # top-level bubbletea Program, global keybinds, layout + ├── config.go # read acpctl config (multi-context: server, token, project per context) + ├── client.go # Ambient API client (extracted from fetch.go, wraps Go SDK) + ├── events.go # AG-UI event parsing (extracted from dashboard.go) + ├── sanitize.go # strip ANSI escapes, control chars from agent output + ├── model.go # root Model — navigation stack, view dispatch + ├── command.go # command-mode parser, tab completion, dispatch + ├── filter.go # filter-mode parser (regex, inverse, label) + ├── views/ + │ ├── table.go # base resource table (wraps bubbles/table, adds sorting + hotkeys) + │ ├── detail.go # base detail view (key-value + YAML dump) + │ ├── projects.go # project list + detail + │ ├── agents.go # agent list + detail + │ ├── sessions.go # session list + detail + │ ├── messages.go # live session message stream + compose + │ └── inbox.go # agent inbox list + compose + └── tui_test.go # unit + teatest integration tests +``` + +### Data Flow + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ Context: local [RW] Help _ __ __ │ +│ Server: localhost:8000 <:> Command /_\ | \/ | │ +│ User: jsell Rename / _ \ | |\/| | │ +│ Project: ambient-platform /_/ \_\|_| |_| │ +│ ⟳ 3s │ +├──────────────────────────────────────────────────────────────────────────┤ +│ (command bar appears here on `:` or `/`, hidden by default) │ +├───────────────────────── agents(ambient-platform)[12] ───────────────────┤ +│ │ +│ Resource Table / Detail View / Message Stream │ +│ (fills remaining vertical space) │ +│ │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Viewing agents in project ambient-platform │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +Layout follows k9s conventions: +1. **Header block** (top) — context, server, user, project on the left. ASCII branding on the right. Key hints alongside. +2. **Command/filter bar** (below header) — hidden by default. Appears on `:` or `/`, disappears on `Esc` or command execution. +3. **Resource view** (fills remaining space) — table title bar shows resource kind, scope, and count. +4. **Breadcrumb trail** (bottom) — shows navigation path as `` segments. Current view is the rightmost. +5. **Info line** (very bottom) — contextual description of what's being shown. + +``` + │ ▲ + │ poll / SSE stream │ tea.Msg + ▼ │ + ┌──────────┐ ┌──────────┐ + │ API │◄──REST────►│ client │ + │ Server │◄──gRPC────►│ .go │ + └──────────┘ └──────────┘ +``` + +All data fetching runs in `tea.Cmd` goroutines. The Bubbletea `Update` loop is never blocked by network calls. API responses arrive as `tea.Msg` values. Errors are displayed inline in the table (red status row) or as a flash message on the status line. + +Polling is skip-on-inflight: if the previous poll has not returned, the next tick is skipped. This prevents request stacking under slow API responses. + +--- + +## Navigation Model + +### v1 Visual Hierarchy + +``` +:projects (root) +└── Enter on project + └── :agents (project-scoped) + ├── Enter on agent + │ └── :sessions (agent-scoped) + │ └── Enter on session + │ └── :messages (live stream + compose) + └── i on agent + └── :inbox (agent-scoped) + └── m to compose +``` + +Five views. `:sessions` is also accessible globally (all sessions across all projects), same as k9s's `:pods` showing all pods. + +### Screen Stack + +Navigation is a stack. `Enter` pushes a child view. `Esc` pops back to the parent. The breadcrumb in the header shows the stack: + +``` +Projects > ambient-platform > Agents > be > Sessions > 01HABC > Messages +Projects > ambient-platform > Agents > be > Inbox +``` + +### Command Mode + +`:` opens the command bar (bottom of screen). Tab-completion provides inline suggestions for resource kinds and project names. + +| Command | Aliases | Action | +|---------|---------|--------| +| `:projects` | `:proj` | Switch to project list (clears stack) | +| `:agents` | `:ag` | Switch to agent list (current project) | +| `:sessions` | `:se` | Switch to session list (global or scoped) | +| `:inbox` | `:ib` | Switch to inbox (requires agent context) | +| `:messages` | `:msg` | Switch to message stream (requires session context) | +| `:aliases` | | List all available commands and aliases | +| `:context` | `:ctx` | List all saved contexts | +| `:context ` | `:ctx ` | Switch to a saved context (server + token + project) | +| `:project ` | `:proj ` | Switch project within current context | +| `:q` / `:quit` | | Exit | + +### Filter Mode + +`/` opens the filter bar. Supports: + +| Syntax | Behavior | Example | +|--------|----------|---------| +| `/term` | Regex match across all visible columns | `/be-agent` | +| `/!term` | Inverse regex — hide matching rows | `/!completed` | +| `/-l key=val` | Server-side label filter (`@>` containment) | `/-l env=prod` | + +`Esc` clears the active filter. Filter syntax follows k9s conventions. + +--- + +## Resource Views + +### Project List + +| Column | Source | Notes | +|--------|--------|-------| +| NAME | `project.name` | | +| DESCRIPTION | `project.description` | Truncated to fit column width | +| STATUS | `project.status` | | +| AGE | computed from `project.created_at` | Relative (3d, 2h, 5m) | + +AGENTS and SESSIONS counts are omitted from v1 — they require N+1 API fan-out queries. A future API aggregation endpoint can enable them. + +**Hotkeys:** + +| Key | Action | k9s equivalent | +|-----|--------|----------------| +| `Enter` | Drill into project → show agents | Enter | +| `d` | Describe — show project detail (prompt, labels, annotations) | d (describe) | +| `n` | New project (inline name + description prompt) | — | +| `Ctrl-D` | Delete project (confirmation modal) | Ctrl-D | + +### Agent List + +Scoped to current project context. + +| Column | Source | Notes | +|--------|--------|-------| +| NAME | `agent.name` | | +| PROMPT | `agent.prompt` | Truncated to 60 chars | +| SESSION | `agent.current_session_id` | `` if null. Short ID form. | +| PHASE | current session phase | Colored. Requires secondary fetch — see Known N+1 Queries. | +| AGE | computed from `agent.created_at` | Relative | + +INBOX unread count is omitted from the table — no count-only API. The inbox view (`i`) shows the full list. + +**Hotkeys:** + +| Key | Action | k9s equivalent | +|-----|--------|----------------| +| `Enter` | Drill into agent → show sessions for this agent | Enter | +| `d` | Describe — show agent detail (full prompt, labels, annotations, current session) | d | +| `e` | Edit agent prompt (inline text input, PATCHes on save) | e (edit) | +| `s` | Start agent — opens prompt input, calls `POST /start` | — (Ambient-specific, k9s uses `s` for shell) | +| `x` | Stop agent — calls session stop with confirmation | — | +| `i` | Show inbox for this agent | — | +| `m` | Send inbox message (opens compose input) | — | +| `n` | New agent (inline name + prompt) | — | +| `l` | Logs — if session is active, open live message stream | l (logs) | +| `Ctrl-D` | Delete agent (confirmation modal) | Ctrl-D | +| `y` | YAML — dump agent as YAML to screen | y | + +### Session List + +Accessible globally (`:sessions` — all sessions across all projects) or scoped when drilled in from an agent view. + +| Column | Source | Notes | +|--------|--------|-------| +| ID | `session.id` | Short form (first 12 chars) | +| AGENT | agent name | Requires secondary fetch — see Known N+1 Queries. | +| PROJECT | project name | | +| PHASE | `session.phase` | Colored per Phase Colors table | +| TRIGGERED BY | `session.triggered_by_user_id` | | +| STARTED | `session.start_time` | Relative | +| DURATION | `completion_time - start_time` | Running timer if still active | + +**Hotkeys:** + +| Key | Action | k9s equivalent | +|-----|--------|----------------| +| `Enter` | Drill into session → show live message stream | Enter | +| `d` | Describe — show session detail (full metadata, prompt, conditions) | d | +| `l` | Live message stream (same as Enter) | l | +| `m` | Send message to session (`POST /sessions/{id}/messages`) | — | +| `y` | YAML — dump session as YAML to screen | y | +| `Ctrl-D` | Delete/cancel session (confirmation modal) | Ctrl-D | + +### Message Stream View + + +#### Data Source + +The TUI connects to **`GET /sessions/{id}/messages`** (SSE). This single endpoint handles both replay and live delivery server-side: it loads all messages after a cursor via `AllBySessionIDAfterSeq`, subscribes to the pub/sub channel, replays the historical batch, then switches to live delivery — deduplicating by `msg.Seq`. The TUI does not need to coordinate two endpoints. + +The `/events` endpoint (raw runner SSE) is not used. `/messages` is the durable, replay-safe stream. + +#### Display Modes + +**Conversation mode** (default): Messages rendered as a chat transcript. + +``` + ┌─ Session 01HABC... ─ Phase: running ─ Agent: be ─────────────────┐ + │ │ + │ [user] Begin. Start with the gRPC handler. │ + │ [assistant] I'll start by implementing the WatchSessionMessages │ + │ handler. Let me read the existing code... │ + │ [tool_use] Read plugins/sessions/handler.go (truncated) │ + │ [tool_result] ✓ 238 lines │ + │ [assistant] I can see the handler structure. I'll add the watch │ + │ endpoint following the existing pattern... │ + │ │ + │ ▌ streaming... │ + ├────────────────────────────────────────────────────────────────────┤ + │ > send message: _ │ + └────────────────────────────────────────────────────────────────────┘ +``` + +**Raw mode** (`r` to toggle): Shows raw AG-UI events as JSON lines — useful for debugging. + +#### Event Type Rendering + +| Event type | Rendering | +|------------|-----------| +| `user` | Full text, white | +| `assistant` | Full text, green. For streaming: accumulate `TEXT_MESSAGE_CONTENT` deltas into a growing line, re-render on each delta. Show `▌` cursor at end until `TEXT_MESSAGE_END`. | +| `tool_use` | One-line summary: tool name + first arg, truncated to terminal width. Dim. | +| `tool_result` | One-line summary: `✓` or `✗` + size. Dim. Expandable via `Enter` on the line (future). | +| `system` | Full text, yellow | +| `error` | Full text, red | + +#### Message Buffer + +The message stream maintains a ring buffer (default: 2000 messages). When full, oldest messages are evicted. The user can scroll back within the buffer. Messages older than the buffer are not recoverable without reconnecting with a lower `after_seq` — this is a known limitation. + +#### Send-While-Streaming + +Sending a message (`m` / `Enter`) while the agent is mid-response is permitted. The `POST /sessions/{id}/messages` call is non-blocking. The human turn appears in the stream when the server echoes it back via SSE, maintaining a single source of truth for message ordering. The compose input does not block or queue — the user types, hits Enter, and the message is sent immediately. + +**Hotkeys:** + +| Key | Action | +|-----|--------| +| `Esc` | Back to session list | +| `r` | Toggle raw/conversation mode | +| `m` / `Enter` | Focus message input — type and send a human turn | +| `s` | Toggle autoscroll (on by default — view follows new messages; scrolling up disables it, `s` or `G` re-enables) | +| `G` | Jump to bottom + re-enable autoscroll | +| `g` | Jump to top (oldest in buffer) | +| `j`/`k` or `↑`/`↓` | Scroll (disables autoscroll) | +| `/` | Search within messages (regex) | +| `c` | Copy selected message text to clipboard (via OSC 52) | + +### Inbox View + +Scoped to an agent. Accessible via `i` from the agent list or `:inbox` in command mode (requires agent context from navigation stack). + +| Column | Source | Notes | +|--------|--------|-------| +| ID | `inbox.id` | Short form | +| FROM | `inbox.from_name` | `(human)` if null | +| BODY | `inbox.body` | Truncated to fit column width | +| READ | `inbox.read` | `✓` / `—` | +| AGE | computed from `inbox.created_at` | Relative | + +**Hotkeys:** + +| Key | Action | +|-----|--------| +| `Enter` | View full message body in detail pane | +| `m` | Compose new inbox message (opens text input) | +| `r` | Mark selected message as read | +| `Ctrl-D` | Delete message (confirmation) | +| `Esc` | Back to agent list | + +--- + +## Global Keybindings + +These work on every screen: + +| Key | Action | k9s equivalent | +|-----|--------|----------------| +| `:` | Command mode | `:` | +| `/` | Filter mode | `/` | +| `?` | Help overlay — show keybindings for current view | `?` | +| `Esc` | Pop navigation stack / clear filter / close modal | `Esc` | +| `q` | Quit (from root view) or pop (from child view) | `q` | +| `Ctrl-C` | Quit immediately | `Ctrl-C` | +| `c` | Copy selected row's ID to clipboard (OSC 52) | — | +| Scroll wheel | Scroll up/down in tables and message stream | Scroll wheel | +| `Shift-N` | Sort by name column | `Shift-N` | +| `Shift-A` | Sort by age column | `Shift-A` | + +Column sorting uses k9s's Shift-key convention. Additional sort keys are defined per view where meaningful. + +--- + +## Screen Layout + +Follows k9s layout conventions: header block at top, command bar on demand, resource table fills the middle, status hints at bottom. + +### Header Block (top, multi-line) + +``` + Context: local [RW] Help + Server: localhost:8000 <:> Command + User: jsell Rename + Project: ambient-platform + ⟳ 3s +``` + +Left side — context metadata (k9s style, stacked key-value): +- **Context** name + read/write indicator +- **Server** URL +- **User** (from `whoami`) +- **Project** (current project context) +- **Refresh indicator** — seconds since last successful fetch. Shows `(stale)` if >15s. + +Right side — ASCII art branding + top-level key hints. + +### Command/Filter Bar + +Hidden by default. Appears when the user presses `:` (command mode) or `/` (filter mode). Renders between the header and the resource table: + +``` +┌───────────────────────────────────────────────────────────────────┐ +│ :sessions │ +└───────────────────────────────────────────────────────────────────┘ +``` + +Disappears on `Esc` or after command execution, returning the space to the resource table. + +### Resource Table Title + +The table has a title bar showing resource kind, scope, and count — matching k9s's `contexts(all)[12]` convention: + +``` +┌──────────────────────── agents(ambient-platform)[12] ─────────────┐ +│ NAME↑ PROMPT SESSION PHASE AGE │ +``` + +Scope shown in parentheses: +- `sessions(all)[47]` — global view +- `sessions(be)[3]` — scoped to agent `be` +- `inbox(be)[5]` — scoped to agent `be` + +### Breadcrumb Trail (bottom) + +``` + +``` + +Shows the navigation stack as `` segments, matching k9s's bottom breadcrumb. Each segment represents a level in the drill-down. The current (rightmost) view is the active one. Clicking/selecting a parent segment is not supported (keyboard-only — use `Esc` to pop back). + +### Info Line (very bottom) + +``` + Viewing agents in project ambient-platform +``` + +Ephemeral toast — appears for 5 seconds on navigation changes, then fades (line clears). Triggered by: +- Entering a new view (drill-down or command switch) +- Switching context (`:ctx`) +- Applying or clearing a filter +- Errors (API failures, permission denied) — these persist until the next action rather than auto-clearing + +Examples: +- `Viewing agents in project ambient-platform` +- `Streaming messages for session 01HABC...` +- `Switched to context staging` +- `✗ disconnected — retrying in 5s` (persists) + +--- + +## Refresh Strategy + +| Resource | Method | Interval | +|----------|--------|----------| +| Projects, Agents, Inbox | REST `GET` polling | 5s (hardcoded) | +| Sessions | gRPC `WatchSessions` stream; fallback to REST polling | Real-time / 5s | +| Session Messages | SSE stream (`GET /sessions/{id}/messages`) | Real-time | + +Polling is **skip-on-inflight**: if the previous request has not completed, the next tick is skipped. This prevents request stacking under degraded API conditions. + +When a view is not visible (user has drilled into a child), its polling pauses. Polling resumes when the user navigates back. + +--- + +## Error Handling + +| Scenario | Behavior | +|----------|----------| +| **API unreachable** | Status line: `✗ disconnected — retrying in 5s`. Tables show stale data. Header shows `(stale Ns)` with seconds since last successful fetch. No retry limit — the TUI keeps trying indefinitely with 5s backoff. | +| **401 Unauthorized** | Attempt to re-read token from `~/.config/ambient/config.json` (another session may have refreshed it). If still 401, status line: `✗ session expired — run 'acpctl login' in another terminal`. Stale data preserved. No modal, no forced exit. | +| **403 Forbidden (resource)** | Inline in table: row shows `ACCESS DENIED` for the specific resource. | +| **403 Forbidden (kind)** | Table-level message: `Insufficient permissions to list `. Distinct from empty results. | +| **404 Not Found** | Flash message on status line. Resource removed from table on next refresh. | +| **429 Rate Limited** | Back off to `Retry-After` header value (or 30s default). Status line: `⏳ rate limited — backing off`. | +| **5xx Server Error** | Status line shows error summary. Stale data preserved. Retry on next poll cycle. | +| **SSE stream disconnect** | Auto-reconnect with exponential backoff (1s, 2s, 4s, max 30s). Reconnect status shown inline in message stream: `⟳ reconnecting (attempt 3)...`. On reconnect, replay from last received `seq` via `after_seq` parameter. | + +--- + +## Security + +| Concern | Mitigation | +|---------|------------| +| **Terminal escape injection** | All agent-produced content (session messages, agent prompts, inbox bodies) is sanitized before rendering. Strip ANSI escape sequences (`\x1b[...`), OSC sequences, C0/C1 control characters, and lipgloss/tview region tags. Implemented in `sanitize.go`. | +| **TLS enforcement** | The TUI refuses plaintext HTTP connections to non-localhost servers by default. `--insecure` flag required to override. Consistent with `acpctl` CLI behavior. | +| **Tokens on disk** | Reuses `acpctl` config at `~/.config/ambient/config.json` with 0600 file permissions (set by `acpctl login`). Contains tokens for all saved contexts. No encryption at rest — file permissions are the defense. Tokens are never logged; `Config.String()` / `Config.GoString()` redact all token fields. | +| **Token in crash output** | `Config` struct implements `fmt.Stringer` and `fmt.GoStringer` to redact `AccessToken`. Panic recovery in `app.go` catches panics and exits cleanly without dumping the model. | +| **Inline editing** | Prompt editing uses inline `bubbles/textinput` (no temp files, no `$EDITOR` subprocess). Content stays in memory. | +| **Credential tokens** | The TUI never calls the credential token endpoint. Credential views show metadata only. | + +--- + +## Configuration + +The TUI reads from the same config file as `acpctl`: + +```json +// ~/.config/ambient/config.json +{ + "current_context": "local", + "contexts": { + "local": { + "server": "http://localhost:8000", + "access_token": "eyJ...", + "project": "ambient-platform" + }, + "staging": { + "server": "https://api.staging.ambient.io", + "access_token": "eyJ...", + "project": "ambient-platform" + }, + "prod": { + "server": "https://api.ambient.io", + "access_token": "eyJ...", + "project": "fleet-prod" + } + } +} +``` + +### Context Management + +Contexts are auto-created and auto-named by `acpctl login`. The context name is derived from the server hostname: + +| Server URL | Auto-generated context name | +|------------|---------------------------| +| `http://localhost:8000` | `local` | +| `https://api.staging.ambient.io` | `staging.ambient.io` | +| `https://api.ambient.io` | `api.ambient.io` | + +Rules: +- `localhost` (any port) → `local` +- All other servers → hostname portion of the URL +- If a context with the same name exists, `acpctl login` updates it (token, project) rather than creating a duplicate. +- `acpctl login` sets `current_context` to the newly logged-in context. +- `acpctl logout` removes the current context entry. If other contexts remain, `current_context` switches to the first remaining one. + +In the TUI: +- `:ctx` with no argument lists all contexts in a table (name, server, project, active indicator). +- `:ctx ` switches immediately — the TUI reconnects to the new server, re-fetches all data, and updates the header. Navigation stack is reset to `:projects`. +- Tab-completion on `:ctx` suggests saved context names. + +No other TUI-specific config in v1. Refresh interval is hardcoded at 5s. Message buffer is hardcoded at 2000. + +--- + +## Phase Colors + +Carried forward from the existing TUI (`view.go`). These are ANSI 256-color indices, consistent across lipgloss and any terminal that supports 256-color mode. + +| Phase | Color | ANSI 256 Index | Lipgloss | +|-------|-------|----------------|----------| +| `pending` | Yellow | 33 | `Color("33")` | +| `running` | Green | 28 | `Color("28")` | +| `succeeded` / `completed` | Dim grey | 240 | `Color("240")` | +| `failed` | Red | 31 | `Color("31")` | +| `cancelled` | Dim grey | 240 | `Color("240")` | + +Full palette (preserved from existing code): + +| Name | ANSI 256 | Usage | +|------|----------|-------| +| Orange | 214 | Branding, navigation highlights, selected items | +| Cyan | 36 | Secondary accent | +| Green | 28 | Running/success phase | +| Red | 31 | Failed/error phase, delete confirmations | +| Yellow | 33 | Pending phase, in-progress indicators | +| Dim | 240 | Inactive items, separators, hints | +| White | 255 | Primary text | +| Blue | 69 | Command mode, links | + +--- + +## Known API Gaps + +These are gaps where the TUI spec requires data the API does not provide efficiently. They are accepted tradeoffs for v1, not blockers. + +| Gap | Impact | Workaround | Permanent Fix | +|-----|--------|------------|---------------| +| Agent phase (current session) | Agent table PHASE column requires `GET /sessions/{id}` per agent with `current_session_id` | Fan-out fetch; cached for 5s per poll cycle | Denormalize `phase` onto Agent response | +| Agent name on session | Session table AGENT column requires agent name resolution | Cache agent ID→name map per project; refresh with agent list | Denormalize `agent_name` onto Session response | +| Inbox unread count | No count-only endpoint | Omitted from agent table in v1; visible in inbox view | Add `unread_count` to Agent response or `?count_only=true` param | +| Project agent/session counts | No aggregation endpoint | Omitted from project table in v1 | Add counts to Project list response | + +--- + +## Content Handling + +| Content type | Strategy | +|-------------|----------| +| Long text (prompts, message bodies) | Wrap at terminal width. No horizontal scrolling. Detail views show full text with vertical scroll. | +| Long single-line values (URLs, IDs) | Truncate with `…` in table columns. Full value shown in detail view and via `c` (copy). | +| Wide tables (many columns) | Columns have priority. Low-priority columns are hidden when terminal is narrow. | +| Tool use/result payloads | One-line summary in conversation mode. Full payload in raw mode or detail view. | + +--- + +## What This Spec Does NOT Cover + +| Topic | Why | Revisit When | +|-------|-----|-------------| +| K8s resource browsing (pods, namespaces) | Not the TUI's job post-CRD-transition. Use k9s. | Never — not in scope. | +| Credential view | Credential CRUD API is not yet implemented in the API server. | API lands. | +| RBAC views (roles, rolebindings) | Low-frequency operation. `acpctl get roles` is sufficient. | User demand. | +| ScheduledSession view | PR #1456 spec is proposed but not yet implemented. | ScheduledSession API lands — then add `:scheduledsessions` / `:ss` view. | +| Diagnostic view for failed sessions | Requires API to surface container exit codes, OOM events, failure reasons — not just `phase=failed`. | API exposes failure diagnostics. | +| Mouse click/drag | Keyboard-driven, consistent with k9s. | Never. | +| Plugin/extension system | Premature. Resource kinds are still evolving. | Resource model stabilizes. | +| Theme customization | One color palette (see Phase Colors). | User demand. | +| `$EDITOR` integration | Inline editing via `bubbles/textinput` is simpler and avoids temp file security concerns. | User demand for multi-line editing. | + +--- + +## Migration from Existing TUI + +### What Carries Forward (framework-agnostic logic) + +| Code | Source | Destination | +|------|--------|-------------| +| Session message streaming (goroutine lifecycle, reconnect-with-backoff, cancellation) | `model.go` `restartSessionPoll` | `client.go` | +| Multi-project SDK fan-out (list projects, fan out per-project fetches, mutex, error aggregation) | `fetch.go` `fetchAll` | `client.go` | +| AG-UI event parsing (payload extraction, event type classification) | `dashboard.go` `tileDisplayPayload`, `extractKVField`, `eventTypeStyle` | `events.go` | +| Color palette (ANSI 256 indices + lipgloss styles) | `view.go` lines 12-29 | `view.go` (unchanged) | +| Agent CRUD operations (edit-with-dirty-tracking, confirm-delete, SDK calls) | `model.go` agent edit/delete handlers | `views/agents.go` | +| Session message compose flow (project-scoped client resolution, PushMessage) | `model.go` compose handlers | `views/messages.go` | + +### What Is Dropped + +| Code | Reason | +|------|--------| +| All `kubectl`/`oc` exec calls | API-only data path | +| Pod and Namespace views | Use k9s | +| Port-forward management | `acpctl` subcommands / Makefile | +| Manual string-based table rendering (`col()`, `padTo()`) | Replaced by `bubbles/table` | +| `execCommand` shell runner | Not needed | + +--- + +## Implementation Priority + +Each wave produces a **shippable `acpctl ambient`** — the binary is usable at the end of every wave, not just scaffolding. + +| Wave | Scope | Deliverable | +|------|-------|-------------| +| **0** | Extract reusable logic from existing code into `client.go`, `events.go`, `sanitize.go`. Replace string tables with `bubbles/table`. Remove kubectl code. Multi-context config format (`contexts` map, `current_context`). Prove architecture with project table only. | Launches, shows projects in a real table. `acpctl login` auto-creates named context. Smoke-tests pass via `teatest`. | +| **1** | Agent table + command mode (`:projects`, `:agents`, `:sessions`, `:aliases`, `:ctx`, `:project`, `:q`) with tab completion. `:ctx` lists/switches contexts. `/` filter (regex + inverse). Navigation stack (Enter/Esc push/pop). Breadcrumb. Column sorting (Shift-key). | Two-resource browser with full k9s navigation feel. Context switching works. | +| **2a** | Session table (global + agent-scoped). Read-only message stream view via `/messages` SSE. Conversation + raw mode toggle. | Operators can watch agent work in real time. | +| **2b** | Send message (`POST /sessions/{id}/messages`). Streaming partial response rendering (delta accumulation). SSE reconnect with `after_seq` replay. Copy-to-clipboard (`c`). | Full interactive session experience. | +| **3** | Inbox view. Detail views (`d`) for all resources. Agent start (`s`) and stop (`x`). Agent inline edit (`e`). New project/agent (`n`). Delete (`Ctrl-D`). | Full CRUD + inbox. Feature-complete v1. | + +--- + +## Test Strategy + +| Layer | What | How | Required per wave | +|-------|------|-----|-------------------| +| **Unit** | Command parser, filter parser, event type rendering, phase color mapping, breadcrumb builder, sanitize logic | Standard Go table-driven tests | All waves | +| **Integration (happy path)** | API client → `httptest` server with fixture JSON → table populated correctly | `teatest`: send keystrokes, assert on rendered output containing expected rows | Wave 0+ | +| **Integration (error paths)** | 401 re-read, 403 kind-level message, 429 backoff, SSE disconnect+reconnect | `httptest` returning error codes; `teatest` asserting status line messages | Wave 2a+ | +| **Navigation** | Enter→drill→Esc→back, command mode `:sessions`→`:agents`, filter→clear | `teatest`: send key sequences, assert on breadcrumb and table content | Wave 1+ | +| **Performance** | Table render time with 500 rows, SSE throughput with rapid deltas | Benchmark tests (`testing.B`) with fixture data | Wave 2a+ | +| **Manual** | Full flow: launch → navigate → filter → drill → send message → back out | Checklist per wave, run against kind cluster | All waves | + +--- + +## CLI Reference + +| Command | Description | Status | +|---------|-------------|--------| +| `acpctl ambient` | Launch interactive TUI | 🔄 rewrite (exists today, replacing internals) | From 8128d8d96e16bc659d159d6718c64aa1dbd7b519 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 16:51:22 -0400 Subject: [PATCH 002/117] =?UTF-8?q?feat(cli):=20Wave=200=20=E2=80=94=20TUI?= =?UTF-8?q?=20foundation=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract reusable logic and create foundation modules for the k9s-style TUI rewrite: - sanitize.go: strip ANSI escapes, control chars from agent output - events.go: AG-UI event parsing (extracted from dashboard.go) - client.go: API client wrapping Go SDK (replaces kubectl exec) - filter.go: regex, inverse, and label filter parsing - command.go: command-mode parser with tab completion - config.go: multi-context config (backwards-compatible with flat format) - views/table.go: base resource table wrapping bubbles/table All modules are framework-agnostic or use Bubbletea tea.Cmd/tea.Msg. 144 tests passing across all new files. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/client.go | 218 ++++++++ .../cmd/acpctl/ambient/tui/command.go | 246 +++++++++ .../cmd/acpctl/ambient/tui/command_test.go | 416 ++++++++++++++ .../cmd/acpctl/ambient/tui/config.go | 241 ++++++++ .../cmd/acpctl/ambient/tui/config_test.go | 515 ++++++++++++++++++ .../cmd/acpctl/ambient/tui/events.go | 254 +++++++++ .../cmd/acpctl/ambient/tui/events_test.go | 411 ++++++++++++++ .../cmd/acpctl/ambient/tui/filter.go | 145 +++++ .../cmd/acpctl/ambient/tui/filter_test.go | 450 +++++++++++++++ .../cmd/acpctl/ambient/tui/sanitize.go | 75 +++ .../cmd/acpctl/ambient/tui/sanitize_test.go | 261 +++++++++ .../cmd/acpctl/ambient/tui/views/table.go | 386 +++++++++++++ components/ambient-cli/go.mod | 18 +- components/ambient-cli/go.sum | 21 + 14 files changed, 3650 insertions(+), 7 deletions(-) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/client.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/command.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/command_test.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/config.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/config_test.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/events.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/events_test.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/filter.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/filter_test.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/sanitize.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/sanitize_test.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go new file mode 100644 index 000000000..2977de360 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -0,0 +1,218 @@ +package tui + +import ( + "context" + "sync" + "time" + + "github.com/ambient-code/platform/components/ambient-cli/pkg/connection" + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" + tea "github.com/charmbracelet/bubbletea" +) + +// fetchTimeout is the per-request context deadline for all API fetches. +const fetchTimeout = 15 * time.Second + +// defaultListOpts returns the standard list options for TUI fetches. +func defaultListOpts() *sdktypes.ListOptions { + return &sdktypes.ListOptions{Page: 1, Size: 200} +} + +// --------------------------------------------------------------------------- +// Message types returned by TUIClient methods. Each carries the fetched data +// and any error encountered. The TUI's Update loop dispatches on these. +// --------------------------------------------------------------------------- + +// ProjectsMsg carries the result of a project list fetch. +type ProjectsMsg struct { + Projects []sdktypes.Project + Err error +} + +// AgentsMsg carries the result of an agent list fetch. +type AgentsMsg struct { + Agents []sdktypes.Agent + Err error +} + +// SessionsMsg carries the result of a session list fetch (single- or +// multi-project). +type SessionsMsg struct { + Sessions []sdktypes.Session + Err error +} + +// InboxMsg carries the result of an inbox message list fetch. +type InboxMsg struct { + Messages []sdktypes.InboxMessage + Err error +} + +// --------------------------------------------------------------------------- +// TUIClient wraps connection.ClientFactory and provides clean data-fetching +// methods that return tea.Cmd functions for asynchronous execution inside the +// Bubbletea runtime. Every method creates its own context with fetchTimeout +// so the Update loop is never blocked. +// +// All data flows through the Ambient API Server -- no kubectl, no direct K8s +// API calls. +// --------------------------------------------------------------------------- + +// TUIClient is the API client layer for the TUI. It creates per-project SDK +// clients via a ClientFactory and returns bubbletea Cmds that fetch data +// asynchronously. +type TUIClient struct { + factory *connection.ClientFactory +} + +// NewTUIClient creates a TUIClient from the given ClientFactory. +func NewTUIClient(factory *connection.ClientFactory) *TUIClient { + return &TUIClient{factory: factory} +} + +// FetchProjects returns a tea.Cmd that lists all projects visible to the +// authenticated user. +func (tc *TUIClient) FetchProjects() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + // Projects are a global resource; any project-scoped client can list + // them. Use a minimal project name to satisfy the SDK constructor. + client, err := tc.factory.ForProject("_") + if err != nil { + return ProjectsMsg{Err: err} + } + + list, err := client.Projects().List(ctx, defaultListOpts()) + if err != nil { + return ProjectsMsg{Err: err} + } + return ProjectsMsg{Projects: list.Items} + } +} + +// FetchAgents returns a tea.Cmd that lists agents in the given project. +func (tc *TUIClient) FetchAgents(projectID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return AgentsMsg{Err: err} + } + + list, err := client.Agents().List(ctx, defaultListOpts()) + if err != nil { + return AgentsMsg{Err: err} + } + return AgentsMsg{Agents: list.Items} + } +} + +// FetchSessions returns a tea.Cmd that lists sessions scoped to a single +// project. Use FetchAllSessions for the cross-project fan-out pattern. +func (tc *TUIClient) FetchSessions(projectID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return SessionsMsg{Err: err} + } + + list, err := client.Sessions().List(ctx, defaultListOpts()) + if err != nil { + return SessionsMsg{Err: err} + } + return SessionsMsg{Sessions: list.Items} + } +} + +// FetchAllSessions returns a tea.Cmd that lists sessions across all projects. +// It first fetches the project list, then fans out one goroutine per project +// to fetch sessions concurrently -- the same pattern used in fetchAll in +// fetch.go. Partial failures are collected; the first error is reported while +// successfully-fetched sessions are still returned. +func (tc *TUIClient) FetchAllSessions() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + // Step 1: list all projects. + anyClient, err := tc.factory.ForProject("_") + if err != nil { + return SessionsMsg{Err: err} + } + + projList, err := anyClient.Projects().List(ctx, defaultListOpts()) + if err != nil { + return SessionsMsg{Err: err} + } + + // Step 2: fan out per-project session fetches. + var ( + mu sync.Mutex + allSessions []sdktypes.Session + firstErr error + wg sync.WaitGroup + ) + + for _, proj := range projList.Items { + wg.Add(1) + go func() { + defer wg.Done() + + projClient, err := tc.factory.ForProject(proj.Name) + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + return + } + + list, err := projClient.Sessions().List(ctx, defaultListOpts()) + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + return + } + + mu.Lock() + allSessions = append(allSessions, list.Items...) + mu.Unlock() + }() + } + + wg.Wait() + return SessionsMsg{Sessions: allSessions, Err: firstErr} + } +} + +// FetchInbox returns a tea.Cmd that lists inbox messages for a specific agent +// within a project. The SDK's InboxMessageAPI.ListByAgent is used to hit +// the /projects/{projectID}/agents/{agentID}/inbox endpoint. +func (tc *TUIClient) FetchInbox(projectID, agentID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return InboxMsg{Err: err} + } + + list, err := client.InboxMessages().ListByAgent(ctx, projectID, agentID, defaultListOpts()) + if err != nil { + return InboxMsg{Err: err} + } + return InboxMsg{Messages: list.Items} + } +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/command.go b/components/ambient-cli/cmd/acpctl/ambient/tui/command.go new file mode 100644 index 000000000..9072de1d4 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/command.go @@ -0,0 +1,246 @@ +package tui + +import ( + "sort" + "strings" +) + +// CommandKind identifies the type of command entered in command mode. +type CommandKind int + +const ( + CmdProjects CommandKind = iota + CmdAgents + CmdSessions + CmdInbox + CmdMessages + CmdContext + CmdProject + CmdAliases + CmdQuit + CmdUnknown +) + +// Command represents a parsed command-mode input. +type Command struct { + Kind CommandKind + Arg string // optional argument (context name, project name) +} + +// AliasEntry describes a command and its aliases for the :aliases listing. +type AliasEntry struct { + Command string + Aliases []string + Description string +} + +// commandDef maps a canonical command name to its kind, aliases, and description. +type commandDef struct { + kind CommandKind + aliases []string + description string + // takesArg indicates the command accepts an optional argument that changes + // its behavior (e.g. :ctx vs :ctx ). + takesArg bool +} + +// commandDefs is the authoritative list of commands. Order determines AliasTable output. +var commandDefs = []commandDef{ + { + kind: CmdProjects, + aliases: []string{"projects", "proj"}, + description: "Switch to project list", + }, + { + kind: CmdAgents, + aliases: []string{"agents", "ag"}, + description: "Switch to agent list (current project)", + }, + { + kind: CmdSessions, + aliases: []string{"sessions", "se"}, + description: "Switch to session list", + }, + { + kind: CmdInbox, + aliases: []string{"inbox", "ib"}, + description: "Switch to inbox (requires agent context)", + }, + { + kind: CmdMessages, + aliases: []string{"messages", "msg"}, + description: "Switch to message stream (requires session context)", + }, + { + kind: CmdContext, + aliases: []string{"context", "ctx"}, + description: "List contexts (no arg) or switch context (with arg)", + takesArg: true, + }, + { + kind: CmdProject, + aliases: []string{"project"}, + description: "Switch project within current context", + takesArg: true, + }, + { + kind: CmdAliases, + aliases: []string{"aliases"}, + description: "List all commands and aliases", + }, + { + kind: CmdQuit, + aliases: []string{"q", "quit"}, + description: "Exit", + }, +} + +// aliasToCommand maps every alias (including canonical names) to a commandDef. +var aliasToCommand map[string]*commandDef + +func init() { + aliasToCommand = make(map[string]*commandDef, len(commandDefs)*2) + for i := range commandDefs { + for _, alias := range commandDefs[i].aliases { + aliasToCommand[alias] = &commandDefs[i] + } + } +} + +// ParseCommand parses raw command-mode input (without the leading ':') and +// returns the parsed Command. Unrecognized input returns CmdUnknown. +// +// Special case: "proj " is parsed as CmdProject (switch project), +// while "proj" alone is CmdProjects (list projects). +func ParseCommand(input string) Command { + input = strings.TrimSpace(input) + if input == "" { + return Command{Kind: CmdUnknown} + } + + // Split into command name and optional argument. + parts := strings.SplitN(input, " ", 2) + name := strings.ToLower(parts[0]) + arg := "" + if len(parts) > 1 { + arg = strings.TrimSpace(parts[1]) + } + + // Special case: "proj" is overloaded. + // - "proj" with no arg → CmdProjects (list projects) + // - "proj " → CmdProject (switch project) + if name == "proj" { + if arg != "" { + return Command{Kind: CmdProject, Arg: arg} + } + return Command{Kind: CmdProjects} + } + + def, ok := aliasToCommand[name] + if !ok { + return Command{Kind: CmdUnknown} + } + + // If the command takes an arg, pass it through. If it doesn't take an arg, + // the arg is silently ignored (consistent with k9s behavior). + if def.takesArg { + return Command{Kind: def.kind, Arg: arg} + } + return Command{Kind: def.kind} +} + +// allCommandNames returns a deduplicated, sorted list of all command aliases. +func allCommandNames() []string { + names := make([]string, 0, len(aliasToCommand)) + for name := range aliasToCommand { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// TabComplete returns completion suggestions for partial command-mode input. +// The partial string should not include the leading ':'. +// +// Completion behavior: +// - If partial has no space, complete command names. +// - If partial starts with "ctx" or "context" and has a space, complete context names. +// - If partial starts with "project" or "proj" and has a space, complete project names. +// +// contexts and projects are the available names for argument completion. +// Returns suggestions sorted lexicographically. +func TabComplete(partial string, contexts []string, projects []string) []string { + trimmed := strings.TrimSpace(partial) + if trimmed == "" { + // Show all command names when nothing typed yet. + return allCommandNames() + } + + // Check if we're completing an argument. A space anywhere in the input + // (including trailing, e.g. "ctx ") means the user has moved past the + // command name and is now entering an argument. + spaceIdx := strings.IndexByte(partial, ' ') + if spaceIdx >= 0 { + cmdName := strings.ToLower(strings.TrimSpace(partial[:spaceIdx])) + argPart := partial[spaceIdx+1:] + argPartial := strings.TrimSpace(argPart) + return completeArg(cmdName, argPartial, contexts, projects) + } + + // Complete command name. + lower := strings.ToLower(trimmed) + var matches []string + for _, name := range allCommandNames() { + if strings.HasPrefix(name, lower) { + matches = append(matches, name) + } + } + return matches +} + +// completeArg returns argument completions for the given command name. +func completeArg(cmdName, argPartial string, contexts, projects []string) []string { + lower := strings.ToLower(argPartial) + + switch cmdName { + case "context", "ctx": + return filterPrefix(contexts, lower) + case "project", "proj": + return filterPrefix(projects, lower) + default: + return nil + } +} + +// filterPrefix returns items that have a case-insensitive prefix match with prefix. +// Results are sorted. +func filterPrefix(items []string, prefix string) []string { + var matches []string + for _, item := range items { + if strings.HasPrefix(strings.ToLower(item), prefix) { + matches = append(matches, item) + } + } + sort.Strings(matches) + return matches +} + +// AliasTable returns the list of commands with their aliases and descriptions, +// suitable for rendering the :aliases output. +func AliasTable() []AliasEntry { + entries := make([]AliasEntry, 0, len(commandDefs)) + for _, def := range commandDefs { + canonical := def.aliases[0] + var aliases []string + if len(def.aliases) > 1 { + aliases = make([]string, len(def.aliases)-1) + copy(aliases, def.aliases[1:]) + } + entries = append(entries, AliasEntry{ + Command: canonical, + Aliases: aliases, + Description: def.description, + }) + } + return entries +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/command_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/command_test.go new file mode 100644 index 000000000..4d964a878 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/command_test.go @@ -0,0 +1,416 @@ +package tui + +import ( + "slices" + "testing" +) + +func TestParseCommand_FullNames(t *testing.T) { + tests := []struct { + input string + kind CommandKind + arg string + }{ + {"projects", CmdProjects, ""}, + {"agents", CmdAgents, ""}, + {"sessions", CmdSessions, ""}, + {"inbox", CmdInbox, ""}, + {"messages", CmdMessages, ""}, + {"context", CmdContext, ""}, + {"project my-proj", CmdProject, "my-proj"}, + {"aliases", CmdAliases, ""}, + {"q", CmdQuit, ""}, + {"quit", CmdQuit, ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + cmd := ParseCommand(tt.input) + if cmd.Kind != tt.kind { + t.Errorf("ParseCommand(%q).Kind = %d, want %d", tt.input, cmd.Kind, tt.kind) + } + if cmd.Arg != tt.arg { + t.Errorf("ParseCommand(%q).Arg = %q, want %q", tt.input, cmd.Arg, tt.arg) + } + }) + } +} + +func TestParseCommand_Aliases(t *testing.T) { + tests := []struct { + input string + kind CommandKind + arg string + }{ + {"ag", CmdAgents, ""}, + {"se", CmdSessions, ""}, + {"ib", CmdInbox, ""}, + {"msg", CmdMessages, ""}, + {"ctx", CmdContext, ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + cmd := ParseCommand(tt.input) + if cmd.Kind != tt.kind { + t.Errorf("ParseCommand(%q).Kind = %d, want %d", tt.input, cmd.Kind, tt.kind) + } + if cmd.Arg != tt.arg { + t.Errorf("ParseCommand(%q).Arg = %q, want %q", tt.input, cmd.Arg, tt.arg) + } + }) + } +} + +func TestParseCommand_ProjOverload(t *testing.T) { + // :proj with no arg → CmdProjects (list projects) + cmd := ParseCommand("proj") + if cmd.Kind != CmdProjects { + t.Errorf("ParseCommand(\"proj\").Kind = %d, want CmdProjects (%d)", cmd.Kind, CmdProjects) + } + if cmd.Arg != "" { + t.Errorf("ParseCommand(\"proj\").Arg = %q, want empty", cmd.Arg) + } + + // :proj → CmdProject (switch project) + cmd = ParseCommand("proj my-project") + if cmd.Kind != CmdProject { + t.Errorf("ParseCommand(\"proj my-project\").Kind = %d, want CmdProject (%d)", cmd.Kind, CmdProject) + } + if cmd.Arg != "my-project" { + t.Errorf("ParseCommand(\"proj my-project\").Arg = %q, want \"my-project\"", cmd.Arg) + } +} + +func TestParseCommand_WithArguments(t *testing.T) { + tests := []struct { + input string + kind CommandKind + arg string + }{ + {"context staging", CmdContext, "staging"}, + {"ctx staging", CmdContext, "staging"}, + {"ctx local", CmdContext, "local"}, + {"project my-proj", CmdProject, "my-proj"}, + {"context staging.ambient.io", CmdContext, "staging.ambient.io"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + cmd := ParseCommand(tt.input) + if cmd.Kind != tt.kind { + t.Errorf("ParseCommand(%q).Kind = %d, want %d", tt.input, cmd.Kind, tt.kind) + } + if cmd.Arg != tt.arg { + t.Errorf("ParseCommand(%q).Arg = %q, want %q", tt.input, cmd.Arg, tt.arg) + } + }) + } +} + +func TestParseCommand_ContextNoArg(t *testing.T) { + // :context with no arg lists contexts + cmd := ParseCommand("context") + if cmd.Kind != CmdContext { + t.Errorf("ParseCommand(\"context\").Kind = %d, want CmdContext (%d)", cmd.Kind, CmdContext) + } + if cmd.Arg != "" { + t.Errorf("ParseCommand(\"context\").Arg = %q, want empty", cmd.Arg) + } +} + +func TestParseCommand_Unknown(t *testing.T) { + tests := []string{ + "foobar", + "nonexistent", + "sesions", // misspelled + "agentss", // extra s + "Projects", // verify case insensitivity works + } + + for _, input := range tests { + t.Run(input, func(t *testing.T) { + cmd := ParseCommand(input) + // "Projects" should be recognized (case insensitive) + if input == "Projects" { + if cmd.Kind != CmdProjects { + t.Errorf("ParseCommand(%q).Kind = %d, want CmdProjects", input, cmd.Kind) + } + return + } + if cmd.Kind != CmdUnknown { + t.Errorf("ParseCommand(%q).Kind = %d, want CmdUnknown (%d)", input, cmd.Kind, CmdUnknown) + } + }) + } +} + +func TestParseCommand_CaseInsensitive(t *testing.T) { + tests := []struct { + input string + kind CommandKind + }{ + {"PROJECTS", CmdProjects}, + {"Projects", CmdProjects}, + {"AG", CmdAgents}, + {"Ctx", CmdContext}, + {"QUIT", CmdQuit}, + {"Q", CmdQuit}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + cmd := ParseCommand(tt.input) + if cmd.Kind != tt.kind { + t.Errorf("ParseCommand(%q).Kind = %d, want %d", tt.input, cmd.Kind, tt.kind) + } + }) + } +} + +func TestParseCommand_EdgeCases(t *testing.T) { + // Empty input + cmd := ParseCommand("") + if cmd.Kind != CmdUnknown { + t.Errorf("ParseCommand(\"\").Kind = %d, want CmdUnknown", cmd.Kind) + } + + // Whitespace only + cmd = ParseCommand(" ") + if cmd.Kind != CmdUnknown { + t.Errorf("ParseCommand(\" \").Kind = %d, want CmdUnknown", cmd.Kind) + } + + // Leading whitespace + cmd = ParseCommand(" projects") + if cmd.Kind != CmdProjects { + t.Errorf("ParseCommand(\" projects\").Kind = %d, want CmdProjects", cmd.Kind) + } + + // Trailing whitespace + cmd = ParseCommand("agents ") + if cmd.Kind != CmdAgents { + t.Errorf("ParseCommand(\"agents \").Kind = %d, want CmdAgents", cmd.Kind) + } + + // Extra spaces between command and arg + cmd = ParseCommand("ctx staging") + if cmd.Kind != CmdContext { + t.Errorf("ParseCommand(\"ctx staging\").Kind = %d, want CmdContext", cmd.Kind) + } + if cmd.Arg != "staging" { + t.Errorf("ParseCommand(\"ctx staging\").Arg = %q, want \"staging\"", cmd.Arg) + } +} + +func TestTabComplete_CommandNames(t *testing.T) { + tests := []struct { + partial string + want []string + }{ + // Partial "s" matches sessions + {"s", []string{"se", "sessions"}}, + // Partial "a" matches agents, ag, aliases + {"a", []string{"ag", "agents", "aliases"}}, + // Partial "q" matches q, quit + {"q", []string{"q", "quit"}}, + // Partial "in" matches inbox + {"in", []string{"inbox"}}, + // Partial "con" matches context + {"con", []string{"context"}}, + // Partial "pro" matches project, projects, proj + {"pro", []string{"proj", "project", "projects"}}, + // Exact match still returned + {"sessions", []string{"sessions"}}, + // No match + {"xyz", nil}, + } + + for _, tt := range tests { + t.Run(tt.partial, func(t *testing.T) { + got := TabComplete(tt.partial, nil, nil) + if !stringSliceEqual(got, tt.want) { + t.Errorf("TabComplete(%q, nil, nil) = %v, want %v", tt.partial, got, tt.want) + } + }) + } +} + +func TestTabComplete_EmptyInput(t *testing.T) { + got := TabComplete("", nil, nil) + // Should return all command names + if len(got) == 0 { + t.Error("TabComplete(\"\", nil, nil) returned empty, want all command names") + } + // Verify it contains known commands + found := map[string]bool{} + for _, name := range got { + found[name] = true + } + for _, expected := range []string{"projects", "agents", "sessions", "inbox", "messages", "context", "ctx", "project", "proj", "aliases", "q", "quit", "ag", "se", "ib", "msg"} { + if !found[expected] { + t.Errorf("TabComplete(\"\") missing %q", expected) + } + } +} + +func TestTabComplete_ContextNames(t *testing.T) { + contexts := []string{"local", "staging", "staging.ambient.io", "prod"} + + tests := []struct { + partial string + want []string + }{ + {"ctx ", []string{"local", "prod", "staging", "staging.ambient.io"}}, + {"ctx s", []string{"staging", "staging.ambient.io"}}, + {"ctx l", []string{"local"}}, + {"ctx p", []string{"prod"}}, + {"ctx x", nil}, + {"context ", []string{"local", "prod", "staging", "staging.ambient.io"}}, + {"context sta", []string{"staging", "staging.ambient.io"}}, + } + + for _, tt := range tests { + t.Run(tt.partial, func(t *testing.T) { + got := TabComplete(tt.partial, contexts, nil) + if !stringSliceEqual(got, tt.want) { + t.Errorf("TabComplete(%q, contexts, nil) = %v, want %v", tt.partial, got, tt.want) + } + }) + } +} + +func TestTabComplete_ProjectNames(t *testing.T) { + projects := []string{"ambient-platform", "my-proj", "demo"} + + tests := []struct { + partial string + want []string + }{ + {"project ", []string{"ambient-platform", "demo", "my-proj"}}, + {"project a", []string{"ambient-platform"}}, + {"project m", []string{"my-proj"}}, + {"proj ", []string{"ambient-platform", "demo", "my-proj"}}, + {"proj d", []string{"demo"}}, + {"project x", nil}, + } + + for _, tt := range tests { + t.Run(tt.partial, func(t *testing.T) { + got := TabComplete(tt.partial, nil, projects) + if !stringSliceEqual(got, tt.want) { + t.Errorf("TabComplete(%q, nil, projects) = %v, want %v", tt.partial, got, tt.want) + } + }) + } +} + +func TestTabComplete_NonArgCommand(t *testing.T) { + // Tab-completing after a non-arg command should return nothing + got := TabComplete("agents ", nil, nil) + if got != nil { + t.Errorf("TabComplete(\"agents \", nil, nil) = %v, want nil", got) + } + + got = TabComplete("q ", nil, nil) + if got != nil { + t.Errorf("TabComplete(\"q \", nil, nil) = %v, want nil", got) + } +} + +func TestTabComplete_CaseInsensitive(t *testing.T) { + contexts := []string{"Local", "Staging"} + + got := TabComplete("CTX ", contexts, nil) + if !stringSliceEqual(got, []string{"Local", "Staging"}) { + t.Errorf("TabComplete(\"CTX \", contexts, nil) = %v, want [Local Staging]", got) + } + + got = TabComplete("S", nil, nil) + if !stringSliceEqual(got, []string{"se", "sessions"}) { + t.Errorf("TabComplete(\"S\", nil, nil) = %v, want [se sessions]", got) + } +} + +func TestAliasTable(t *testing.T) { + entries := AliasTable() + + if len(entries) == 0 { + t.Fatal("AliasTable() returned empty") + } + + // Verify expected commands are present + found := map[string]bool{} + for _, entry := range entries { + found[entry.Command] = true + + // Every entry should have a description + if entry.Description == "" { + t.Errorf("AliasTable entry %q has empty description", entry.Command) + } + } + + expected := []string{"projects", "agents", "sessions", "inbox", "messages", "context", "project", "aliases", "q"} + for _, cmd := range expected { + if !found[cmd] { + t.Errorf("AliasTable() missing command %q", cmd) + } + } + + // Verify specific alias mappings + for _, entry := range entries { + switch entry.Command { + case "agents": + if !containsString(entry.Aliases, "ag") { + t.Errorf("agents entry missing alias \"ag\", got %v", entry.Aliases) + } + case "sessions": + if !containsString(entry.Aliases, "se") { + t.Errorf("sessions entry missing alias \"se\", got %v", entry.Aliases) + } + case "context": + if !containsString(entry.Aliases, "ctx") { + t.Errorf("context entry missing alias \"ctx\", got %v", entry.Aliases) + } + case "q": + if !containsString(entry.Aliases, "quit") { + t.Errorf("q entry missing alias \"quit\", got %v", entry.Aliases) + } + } + } +} + +func TestAliasTable_NoDuplicateCommands(t *testing.T) { + entries := AliasTable() + seen := map[string]bool{} + for _, entry := range entries { + if seen[entry.Command] { + t.Errorf("AliasTable() has duplicate command %q", entry.Command) + } + seen[entry.Command] = true + } +} + +// stringSliceEqual compares two string slices for equality (nil and empty are different). +func stringSliceEqual(a, b []string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// containsString checks if a string slice contains a value. +func containsString(slice []string, val string) bool { + return slices.Contains(slice, val) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/config.go b/components/ambient-cli/cmd/acpctl/ambient/tui/config.go new file mode 100644 index 000000000..a68ab2c0b --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/config.go @@ -0,0 +1,241 @@ +package tui + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "sort" + "strings" + + "github.com/ambient-code/platform/components/ambient-cli/pkg/config" +) + +// TUIConfig holds the multi-context configuration for the TUI. +// It supports both the new multi-context format and the legacy flat format +// used by the existing acpctl CLI. +type TUIConfig struct { + CurrentContext string `json:"current_context,omitempty"` + Contexts map[string]*Context `json:"contexts,omitempty"` +} + +// Context represents a single server connection with its credentials and project scope. +type Context struct { + Server string `json:"server"` + AccessToken string `json:"access_token"` + Project string `json:"project,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + IssuerURL string `json:"issuer_url,omitempty"` + ClientID string `json:"client_id,omitempty"` +} + +// String implements fmt.Stringer. The access token is redacted for security. +func (c *Context) String() string { + token := "" + if c.AccessToken != "" { + token = fmt.Sprintf("", len(c.AccessToken)) + } + return fmt.Sprintf("Context{Server:%q, AccessToken:%s, Project:%q}", c.Server, token, c.Project) +} + +// GoString implements fmt.GoStringer. The access token is redacted for security. +func (c *Context) GoString() string { + token := `""` + if c.AccessToken != "" { + token = fmt.Sprintf(`""`, len(c.AccessToken)) + } + refresh := `""` + if c.RefreshToken != "" { + refresh = fmt.Sprintf(`""`, len(c.RefreshToken)) + } + return fmt.Sprintf( + "tui.Context{Server:%q, AccessToken:%s, Project:%q, RefreshToken:%s, IssuerURL:%q, ClientID:%q}", + c.Server, token, c.Project, refresh, c.IssuerURL, c.ClientID, + ) +} + +// legacyConfig mirrors the flat config format from pkg/config for deserialization +// during migration detection. Fields match config.Config JSON tags. +type legacyConfig struct { + APIUrl string `json:"api_url,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + IssuerURL string `json:"issuer_url,omitempty"` + ClientID string `json:"client_id,omitempty"` + Project string `json:"project,omitempty"` +} + +// LoadTUIConfig reads the shared config file and returns a multi-context TUIConfig. +// +// Format detection: +// - If the file contains a "contexts" key, it is parsed as the new multi-context format. +// - If the file contains "api_url" but no "contexts", it is the legacy flat format. +// A single context entry is created, auto-named from the server hostname, and set as current. +// - If the file does not exist, an empty TUIConfig is returned. +// +// Environment variable overrides (AMBIENT_API_URL, AMBIENT_TOKEN, AMBIENT_PROJECT) +// are applied to the current context's values after loading. +func LoadTUIConfig() (*TUIConfig, error) { + location, err := config.Location() + if err != nil { + return nil, fmt.Errorf("determine config location: %w", err) + } + + data, err := os.ReadFile(location) + if err != nil { + if os.IsNotExist(err) { + return &TUIConfig{ + Contexts: make(map[string]*Context), + }, nil + } + return nil, fmt.Errorf("read config file %q: %w", location, err) + } + + // Probe the raw JSON to determine format. + var probe map[string]json.RawMessage + if err := json.Unmarshal(data, &probe); err != nil { + return nil, fmt.Errorf("parse config file %q: %w", location, err) + } + + var cfg *TUIConfig + + if _, hasContexts := probe["contexts"]; hasContexts { + // New multi-context format. + cfg = &TUIConfig{} + if err := json.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("parse multi-context config %q: %w", location, err) + } + if cfg.Contexts == nil { + cfg.Contexts = make(map[string]*Context) + } + } else { + // Legacy flat format — migrate in memory. + var legacy legacyConfig + if err := json.Unmarshal(data, &legacy); err != nil { + return nil, fmt.Errorf("parse legacy config %q: %w", location, err) + } + cfg = migrateFromLegacy(&legacy) + } + + applyEnvOverrides(cfg) + + return cfg, nil +} + +// migrateFromLegacy converts a flat legacy config into a single-context TUIConfig. +// The context name is derived from the server URL hostname. +func migrateFromLegacy(legacy *legacyConfig) *TUIConfig { + server := legacy.APIUrl + if server == "" { + server = "http://localhost:8000" + } + + name := ContextNameFromURL(server) + + ctx := &Context{ + Server: server, + AccessToken: legacy.AccessToken, + Project: legacy.Project, + RefreshToken: legacy.RefreshToken, + IssuerURL: legacy.IssuerURL, + ClientID: legacy.ClientID, + } + + return &TUIConfig{ + CurrentContext: name, + Contexts: map[string]*Context{ + name: ctx, + }, + } +} + +// applyEnvOverrides applies AMBIENT_API_URL, AMBIENT_TOKEN, and AMBIENT_PROJECT +// environment variable overrides to the current context. If no current context exists +// and an override is present, a context is created. +func applyEnvOverrides(cfg *TUIConfig) { + envURL := os.Getenv("AMBIENT_API_URL") + envToken := os.Getenv("AMBIENT_TOKEN") + envProject := os.Getenv("AMBIENT_PROJECT") + + if envURL == "" && envToken == "" && envProject == "" { + return + } + + cur := cfg.Current() + if cur == nil { + // No current context — create one from env vars. + server := envURL + if server == "" { + server = "http://localhost:8000" + } + name := ContextNameFromURL(server) + cur = &Context{Server: server} + cfg.Contexts[name] = cur + cfg.CurrentContext = name + } + + if envURL != "" { + cur.Server = envURL + } + if envToken != "" { + cur.AccessToken = envToken + } + if envProject != "" { + cur.Project = envProject + } +} + +// Current returns the active context, or nil if no context is set. +func (c *TUIConfig) Current() *Context { + if c.CurrentContext == "" || c.Contexts == nil { + return nil + } + return c.Contexts[c.CurrentContext] +} + +// ContextNames returns a sorted list of all context names. +func (c *TUIConfig) ContextNames() []string { + names := make([]string, 0, len(c.Contexts)) + for name := range c.Contexts { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// SwitchContext changes the current context to the named context. +// Returns an error if the context name does not exist. +func (c *TUIConfig) SwitchContext(name string) error { + if c.Contexts == nil { + return fmt.Errorf("context %q not found", name) + } + if _, ok := c.Contexts[name]; !ok { + return fmt.Errorf("context %q not found", name) + } + c.CurrentContext = name + return nil +} + +// ContextNameFromURL derives a context name from a server URL. +// +// Rules (from the TUI spec): +// - localhost (any port) → "local" +// - All other servers → hostname portion of the URL +func ContextNameFromURL(serverURL string) string { + parsed, err := url.Parse(serverURL) + if err != nil { + return "default" + } + + hostname := parsed.Hostname() + if hostname == "" { + return "default" + } + + if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" { + return "local" + } + + // Strip port via Hostname() already done; return the hostname. + return strings.TrimPrefix(hostname, "www.") +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/config_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/config_test.go new file mode 100644 index 000000000..b65d781ad --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/config_test.go @@ -0,0 +1,515 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// setupConfigFile writes content to a temp config file and sets AMBIENT_CONFIG +// to point to it. Returns a cleanup function. +func setupConfigFile(t *testing.T, content string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("AMBIENT_CONFIG", path) +} + +// clearEnvOverrides ensures env var overrides are unset for a test. +func clearEnvOverrides(t *testing.T) { + t.Helper() + t.Setenv("AMBIENT_API_URL", "") + t.Setenv("AMBIENT_TOKEN", "") + t.Setenv("AMBIENT_PROJECT", "") +} + +func TestLoadTUIConfig_NewFormat(t *testing.T) { + clearEnvOverrides(t) + setupConfigFile(t, `{ + "current_context": "staging", + "contexts": { + "local": { + "server": "http://localhost:8000", + "access_token": "tok-local", + "project": "proj-local" + }, + "staging": { + "server": "https://api.staging.ambient.io", + "access_token": "tok-staging", + "project": "proj-staging" + } + } + }`) + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + if cfg.CurrentContext != "staging" { + t.Errorf("CurrentContext = %q, want %q", cfg.CurrentContext, "staging") + } + + if len(cfg.Contexts) != 2 { + t.Fatalf("len(Contexts) = %d, want 2", len(cfg.Contexts)) + } + + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil") + } + if cur.Server != "https://api.staging.ambient.io" { + t.Errorf("Current().Server = %q, want %q", cur.Server, "https://api.staging.ambient.io") + } + if cur.AccessToken != "tok-staging" { + t.Errorf("Current().AccessToken = %q, want %q", cur.AccessToken, "tok-staging") + } + if cur.Project != "proj-staging" { + t.Errorf("Current().Project = %q, want %q", cur.Project, "proj-staging") + } + + local := cfg.Contexts["local"] + if local == nil { + t.Fatal("Contexts[\"local\"] is nil") + } + if local.Server != "http://localhost:8000" { + t.Errorf("local.Server = %q, want %q", local.Server, "http://localhost:8000") + } +} + +func TestLoadTUIConfig_LegacyFormat(t *testing.T) { + clearEnvOverrides(t) + setupConfigFile(t, `{ + "api_url": "https://api.prod.ambient.io", + "access_token": "tok-legacy", + "refresh_token": "ref-legacy", + "issuer_url": "https://sso.example.com", + "client_id": "my-client", + "project": "legacy-proj" + }`) + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + // Should auto-migrate to a context named from the hostname. + expectedName := "api.prod.ambient.io" + if cfg.CurrentContext != expectedName { + t.Errorf("CurrentContext = %q, want %q", cfg.CurrentContext, expectedName) + } + + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil after migration") + } + + if cur.Server != "https://api.prod.ambient.io" { + t.Errorf("Server = %q, want %q", cur.Server, "https://api.prod.ambient.io") + } + if cur.AccessToken != "tok-legacy" { + t.Errorf("AccessToken = %q, want %q", cur.AccessToken, "tok-legacy") + } + if cur.Project != "legacy-proj" { + t.Errorf("Project = %q, want %q", cur.Project, "legacy-proj") + } + if cur.RefreshToken != "ref-legacy" { + t.Errorf("RefreshToken = %q, want %q", cur.RefreshToken, "ref-legacy") + } + if cur.IssuerURL != "https://sso.example.com" { + t.Errorf("IssuerURL = %q, want %q", cur.IssuerURL, "https://sso.example.com") + } + if cur.ClientID != "my-client" { + t.Errorf("ClientID = %q, want %q", cur.ClientID, "my-client") + } +} + +func TestLoadTUIConfig_LegacyLocalhostMigration(t *testing.T) { + clearEnvOverrides(t) + setupConfigFile(t, `{ + "api_url": "http://localhost:8000", + "access_token": "tok-local" + }`) + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + if cfg.CurrentContext != "local" { + t.Errorf("CurrentContext = %q, want %q", cfg.CurrentContext, "local") + } +} + +func TestLoadTUIConfig_LegacyNoAPIURL(t *testing.T) { + clearEnvOverrides(t) + // Legacy config with no api_url defaults to localhost. + setupConfigFile(t, `{ + "access_token": "tok-nourl" + }`) + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + if cfg.CurrentContext != "local" { + t.Errorf("CurrentContext = %q, want %q", cfg.CurrentContext, "local") + } + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil") + } + if cur.Server != "http://localhost:8000" { + t.Errorf("Server = %q, want %q", cur.Server, "http://localhost:8000") + } +} + +func TestLoadTUIConfig_FileNotFound(t *testing.T) { + clearEnvOverrides(t) + t.Setenv("AMBIENT_CONFIG", filepath.Join(t.TempDir(), "nonexistent.json")) + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + if len(cfg.Contexts) != 0 { + t.Errorf("expected empty contexts, got %d", len(cfg.Contexts)) + } + if cfg.Current() != nil { + t.Error("Current() should return nil for empty config") + } +} + +func TestLoadTUIConfig_EnvVarOverrides(t *testing.T) { + setupConfigFile(t, `{ + "current_context": "local", + "contexts": { + "local": { + "server": "http://localhost:8000", + "access_token": "file-token", + "project": "file-proj" + } + } + }`) + + t.Setenv("AMBIENT_API_URL", "https://env-server.io") + t.Setenv("AMBIENT_TOKEN", "env-token") + t.Setenv("AMBIENT_PROJECT", "env-proj") + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil") + } + + if cur.Server != "https://env-server.io" { + t.Errorf("Server = %q, want %q (env override)", cur.Server, "https://env-server.io") + } + if cur.AccessToken != "env-token" { + t.Errorf("AccessToken = %q, want %q (env override)", cur.AccessToken, "env-token") + } + if cur.Project != "env-proj" { + t.Errorf("Project = %q, want %q (env override)", cur.Project, "env-proj") + } +} + +func TestLoadTUIConfig_EnvVarCreatesContext(t *testing.T) { + // Empty config file, env vars should create a context. + setupConfigFile(t, `{}`) + + t.Setenv("AMBIENT_API_URL", "https://env-only.io") + t.Setenv("AMBIENT_TOKEN", "env-only-token") + t.Setenv("AMBIENT_PROJECT", "env-only-proj") + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil; expected env-created context") + } + + if cur.Server != "https://env-only.io" { + t.Errorf("Server = %q, want %q", cur.Server, "https://env-only.io") + } + if cur.AccessToken != "env-only-token" { + t.Errorf("AccessToken = %q, want %q", cur.AccessToken, "env-only-token") + } +} + +func TestLoadTUIConfig_EnvPartialOverride(t *testing.T) { + setupConfigFile(t, `{ + "current_context": "prod", + "contexts": { + "prod": { + "server": "https://api.prod.io", + "access_token": "prod-token", + "project": "prod-proj" + } + } + }`) + + // Only override the token, leave server and project from file. + t.Setenv("AMBIENT_API_URL", "") + t.Setenv("AMBIENT_TOKEN", "override-token") + t.Setenv("AMBIENT_PROJECT", "") + + cfg, err := LoadTUIConfig() + if err != nil { + t.Fatalf("LoadTUIConfig() error: %v", err) + } + + cur := cfg.Current() + if cur == nil { + t.Fatal("Current() returned nil") + } + + if cur.Server != "https://api.prod.io" { + t.Errorf("Server = %q, want %q (should not be overridden)", cur.Server, "https://api.prod.io") + } + if cur.AccessToken != "override-token" { + t.Errorf("AccessToken = %q, want %q", cur.AccessToken, "override-token") + } + if cur.Project != "prod-proj" { + t.Errorf("Project = %q, want %q (should not be overridden)", cur.Project, "prod-proj") + } +} + +func TestContextNameFromURL(t *testing.T) { + tests := []struct { + url string + want string + }{ + {"http://localhost:8000", "local"}, + {"http://localhost:18000", "local"}, + {"http://localhost", "local"}, + {"https://localhost:443", "local"}, + {"http://127.0.0.1:8000", "local"}, + {"http://[::1]:8000", "local"}, + {"https://api.staging.ambient.io", "api.staging.ambient.io"}, + {"https://api.ambient.io", "api.ambient.io"}, + {"https://api.ambient.io:8443", "api.ambient.io"}, + {"https://my-server.example.com/v1", "my-server.example.com"}, + {"not-a-valid-url", "default"}, + {"", "default"}, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + got := ContextNameFromURL(tt.url) + if got != tt.want { + t.Errorf("ContextNameFromURL(%q) = %q, want %q", tt.url, got, tt.want) + } + }) + } +} + +func TestTUIConfig_SwitchContext(t *testing.T) { + cfg := &TUIConfig{ + CurrentContext: "local", + Contexts: map[string]*Context{ + "local": {Server: "http://localhost:8000"}, + "prod": {Server: "https://api.prod.io"}, + }, + } + + // Switch to valid context. + if err := cfg.SwitchContext("prod"); err != nil { + t.Fatalf("SwitchContext(\"prod\") error: %v", err) + } + if cfg.CurrentContext != "prod" { + t.Errorf("CurrentContext = %q, want %q", cfg.CurrentContext, "prod") + } + if cfg.Current().Server != "https://api.prod.io" { + t.Errorf("Current().Server = %q, want %q", cfg.Current().Server, "https://api.prod.io") + } + + // Switch to invalid context. + err := cfg.SwitchContext("nonexistent") + if err == nil { + t.Fatal("SwitchContext(\"nonexistent\") should return error") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %q, want to contain %q", err.Error(), "not found") + } + // CurrentContext should remain unchanged after failed switch. + if cfg.CurrentContext != "prod" { + t.Errorf("CurrentContext = %q after failed switch, want %q", cfg.CurrentContext, "prod") + } +} + +func TestTUIConfig_SwitchContext_NilContexts(t *testing.T) { + cfg := &TUIConfig{} + err := cfg.SwitchContext("anything") + if err == nil { + t.Fatal("SwitchContext on nil Contexts should return error") + } +} + +func TestTUIConfig_ContextNames(t *testing.T) { + cfg := &TUIConfig{ + Contexts: map[string]*Context{ + "prod": {Server: "https://api.prod.io"}, + "staging": {Server: "https://api.staging.io"}, + "local": {Server: "http://localhost:8000"}, + }, + } + + names := cfg.ContextNames() + expected := []string{"local", "prod", "staging"} + + if len(names) != len(expected) { + t.Fatalf("ContextNames() len = %d, want %d", len(names), len(expected)) + } + for i, name := range names { + if name != expected[i] { + t.Errorf("ContextNames()[%d] = %q, want %q", i, name, expected[i]) + } + } +} + +func TestTUIConfig_ContextNames_Empty(t *testing.T) { + cfg := &TUIConfig{Contexts: map[string]*Context{}} + names := cfg.ContextNames() + if len(names) != 0 { + t.Errorf("ContextNames() on empty = %v, want empty", names) + } +} + +func TestTUIConfig_Current_NoContext(t *testing.T) { + cfg := &TUIConfig{ + CurrentContext: "missing", + Contexts: map[string]*Context{ + "local": {Server: "http://localhost:8000"}, + }, + } + if cfg.Current() != nil { + t.Error("Current() should return nil when CurrentContext does not match any entry") + } +} + +func TestContext_StringRedactsToken(t *testing.T) { + ctx := &Context{ + Server: "https://api.prod.io", + AccessToken: "super-secret-token-value", + Project: "my-proj", + } + + s := ctx.String() + if strings.Contains(s, "super-secret-token-value") { + t.Errorf("String() should not contain the raw token: %s", s) + } + if !strings.Contains(s, "", len("super-secret-token-value"))) { + t.Errorf("String() should show token length: %s", s) + } + if !strings.Contains(s, "api.prod.io") { + t.Errorf("String() should contain server: %s", s) + } +} + +func TestContext_StringEmptyToken(t *testing.T) { + ctx := &Context{ + Server: "http://localhost:8000", + Project: "proj", + } + + s := ctx.String() + if !strings.Contains(s, "") { + t.Errorf("String() with empty token should contain '': %s", s) + } +} + +func TestContext_GoStringRedactsTokens(t *testing.T) { + ctx := &Context{ + Server: "https://api.prod.io", + AccessToken: "access-secret", + RefreshToken: "refresh-secret", + IssuerURL: "https://sso.example.com", + ClientID: "my-client", + } + + s := fmt.Sprintf("%#v", ctx) + if strings.Contains(s, "access-secret") { + t.Errorf("GoString() should not contain the raw access token: %s", s) + } + if strings.Contains(s, "refresh-secret") { + t.Errorf("GoString() should not contain the raw refresh token: %s", s) + } + if !strings.Contains(s, " white (255) +// assistant -> green (28) +// tool_use -> dim (240) +// tool_result -> dim (240) +// system -> yellow (33) +// error -> red (31) +func EventColor(eventType string) lipgloss.Color { + switch eventType { + case "user": + return colorWhite // 255 + case "assistant": + return colorGreen // 28 + case "tool_use": + return colorDim // 240 + case "tool_result": + return colorDim // 240 + case "system": + return colorYellow // 33 + case "error": + return colorRed // 31 + default: + return colorDim // 240 + } +} + +// EventSummary returns a one-line display summary for an AG-UI event. +// +// Behaviour is extracted from the existing tileDisplayPayload logic: +// +// user -> payload text, truncated to 120 chars +// assistant -> payload text, truncated to 120 chars +// tool_use -> tool name + first argument, truncated +// tool_result -> checkmark/cross + content size +// system -> payload text, truncated to 120 chars +// error -> cross + error message +// TEXT_MESSAGE_CONTENT -> delta field from payload +// REASONING_MESSAGE_CONTENT -> delta field from payload +// TOOL_CALL_START -> gear icon + tool name +// TOOL_CALL_RESULT -> content field from payload +// RUN_FINISHED -> "[done]" +// RUN_ERROR -> cross + error message +// TEXT_MESSAGE_START -> ellipsis +// TEXT_MESSAGE_END, TOOL_CALL_ARGS, TOOL_CALL_END -> empty (suppressed) +func EventSummary(eventType string, payload string) string { + switch eventType { + case "user": + return truncatePayload(payload, 120) + + case "assistant": + return truncatePayload(payload, 120) + + case "tool_use": + parsed := ParsePayload(payload) + name, _ := parsed["name"].(string) + if name == "" { + name = ExtractField(payload, "name") + } + if name == "" { + return truncatePayload(payload, 120) + } + // Include first argument if available. + firstArg := "" + if args, ok := parsed["arguments"].(map[string]any); ok { + for k, v := range args { + firstArg = fmt.Sprintf("%s=%v", k, v) + break + } + } + if firstArg == "" { + if a := ExtractField(payload, "input"); a != "" { + firstArg = truncatePayload(a, 60) + } + } + if firstArg != "" { + return name + " " + truncatePayload(firstArg, 80) + } + return name + + case "tool_result": + parsed := ParsePayload(payload) + content, _ := parsed["content"].(string) + if content == "" { + content = ExtractField(payload, "content") + } + isError := false + if e, ok := parsed["is_error"].(bool); ok { + isError = e + } + if isError { + size := len(content) + return fmt.Sprintf("✗ %d bytes", size) + } + size := len(content) + return fmt.Sprintf("✓ %d bytes", size) + + case "system": + return truncatePayload(payload, 120) + + case "error": + parsed := ParsePayload(payload) + if errMsg, ok := parsed["message"].(string); ok && errMsg != "" { + return "✗ " + truncatePayload(errMsg, 120) + } + if errMsg := ExtractField(payload, "message"); errMsg != "" { + return "✗ " + truncatePayload(errMsg, 120) + } + if payload != "" { + return "✗ " + truncatePayload(payload, 120) + } + return "✗ unknown error" + + // AG-UI wire event types (carried forward from tileDisplayPayload). + case "TEXT_MESSAGE_CONTENT", "REASONING_MESSAGE_CONTENT": + if d := ExtractField(payload, "delta"); d != "" { + return d + } + return "" + + case "TOOL_CALL_START": + if name := ExtractField(payload, "tool_call_name"); name != "" { + return "⚙ " + name + } + if name := ExtractField(payload, "tool_name"); name != "" { + return "⚙ " + name + } + return "" + + case "TOOL_CALL_RESULT": + if c := ExtractField(payload, "content"); c != "" { + return c + } + return "" + + case "RUN_FINISHED": + return "[done]" + + case "RUN_ERROR": + if errMsg := ExtractField(payload, "message"); errMsg != "" { + return "✗ " + errMsg + } + return "✗ error" + + case "TEXT_MESSAGE_START": + return "…" + + case "TEXT_MESSAGE_END", "TOOL_CALL_ARGS", "TOOL_CALL_END": + return "" + } + + // Fallback: show raw payload if short enough. + if payload != "" && len(payload) <= 120 { + return payload + } + return "" +} + +// ParsePayload safely parses a JSON payload string into a map. +// If the payload is not valid JSON, returns a map with a single "raw" key +// containing the original string. +// Returns an empty map for empty input. +func ParsePayload(payload string) map[string]any { + if payload == "" { + return map[string]any{} + } + + var result map[string]any + if err := json.Unmarshal([]byte(payload), &result); err == nil { + return result + } + + // Payload is not a JSON object. Return it under "raw". + return map[string]any{ + "raw": payload, + } +} + +// ExtractField extracts a specific field value from a payload string. +// +// It first attempts JSON object parsing (for {"key": "value"} payloads). +// If that fails, it falls back to the KV format used by the AG-UI runner: +// key='value' with backslash-escaped single quotes inside. +// +// Returns an empty string if the field is not found. +func ExtractField(payload string, key string) string { + // Try JSON object parse first. + var obj map[string]any + if err := json.Unmarshal([]byte(payload), &obj); err == nil { + if v, ok := obj[key]; ok { + switch val := v.(type) { + case string: + return val + case float64: + // Preserve integer formatting when possible. + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + return fmt.Sprintf("%g", val) + case bool: + return fmt.Sprintf("%t", val) + case nil: + return "" + default: + b, _ := json.Marshal(val) + return string(b) + } + } + return "" + } + + // Fall back to the existing extractKVField logic: key='value' format. + // The payload may be a JSON-encoded string (double-quoted), so unwrap first. + var raw string + if err := json.Unmarshal([]byte(payload), &raw); err == nil { + payload = raw + } + + needle := key + "='" + idx := strings.Index(payload, needle) + if idx < 0 { + return "" + } + start := idx + len(needle) + var sb strings.Builder + for i := start; i < len(payload); i++ { + if payload[i] == '\'' && (i == start || payload[i-1] != '\\') { + break + } + sb.WriteByte(payload[i]) + } + return strings.ReplaceAll(sb.String(), `\'`, `'`) +} + +// truncatePayload trims whitespace and truncates a string to max length. +func truncatePayload(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + return s[:max-1] + "…" +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/events_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/events_test.go new file mode 100644 index 000000000..6bd852925 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/events_test.go @@ -0,0 +1,411 @@ +package tui + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// EventColor +// --------------------------------------------------------------------------- + +func TestEventColor(t *testing.T) { + tests := []struct { + eventType string + want lipgloss.Color + }{ + {"user", lipgloss.Color("255")}, + {"assistant", lipgloss.Color("28")}, + {"tool_use", lipgloss.Color("240")}, + {"tool_result", lipgloss.Color("240")}, + {"system", lipgloss.Color("33")}, + {"error", lipgloss.Color("31")}, + {"unknown_type", lipgloss.Color("240")}, + {"", lipgloss.Color("240")}, + } + for _, tt := range tests { + t.Run(tt.eventType, func(t *testing.T) { + got := EventColor(tt.eventType) + if got != tt.want { + t.Errorf("EventColor(%q) = %q, want %q", tt.eventType, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// EventSummary +// --------------------------------------------------------------------------- + +func TestEventSummary_User(t *testing.T) { + got := EventSummary("user", "Hello, world!") + if got != "Hello, world!" { + t.Errorf("got %q, want %q", got, "Hello, world!") + } +} + +func TestEventSummary_UserTruncation(t *testing.T) { + long := make([]byte, 200) + for i := range long { + long[i] = 'a' + } + got := EventSummary("user", string(long)) + // truncatePayload uses byte slicing: s[:119] + "…" (3 bytes UTF-8) = 122 bytes. + // Verify the rune count is at most 120. + runes := []rune(got) + if len(runes) > 120 { + t.Errorf("expected truncation to <=120 runes, got %d", len(runes)) + } + if len(runes) < 100 { + t.Errorf("expected result near 120 runes, got only %d", len(runes)) + } +} + +func TestEventSummary_Assistant(t *testing.T) { + got := EventSummary("assistant", "I will help you.") + if got != "I will help you." { + t.Errorf("got %q, want %q", got, "I will help you.") + } +} + +func TestEventSummary_ToolUse_JSONPayload(t *testing.T) { + payload := `{"name":"Read","arguments":{"file_path":"/tmp/foo.go"}}` + got := EventSummary("tool_use", payload) + // Should contain tool name. + if got == "" { + t.Fatal("expected non-empty summary") + } + if got != "Read file_path=/tmp/foo.go" { + // Arguments are a map so iteration order is non-deterministic in general, + // but with a single key it's stable. + t.Errorf("got %q, want %q", got, "Read file_path=/tmp/foo.go") + } +} + +func TestEventSummary_ToolUse_NameOnly(t *testing.T) { + payload := `{"name":"Bash"}` + got := EventSummary("tool_use", payload) + if got != "Bash" { + t.Errorf("got %q, want %q", got, "Bash") + } +} + +func TestEventSummary_ToolUse_KVPayload(t *testing.T) { + payload := `"name='Read'"` + got := EventSummary("tool_use", payload) + // Falls through to KV extraction via ExtractField. + if got != "Read" { + t.Errorf("got %q, want %q", got, "Read") + } +} + +func TestEventSummary_ToolResult_Success(t *testing.T) { + payload := `{"content":"file contents here"}` + got := EventSummary("tool_result", payload) + want := "✓ 18 bytes" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_ToolResult_Error(t *testing.T) { + payload := `{"content":"error details","is_error":true}` + got := EventSummary("tool_result", payload) + want := "✗ 13 bytes" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_ToolResult_Empty(t *testing.T) { + got := EventSummary("tool_result", `{}`) + want := "✓ 0 bytes" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_System(t *testing.T) { + got := EventSummary("system", "System message") + if got != "System message" { + t.Errorf("got %q, want %q", got, "System message") + } +} + +func TestEventSummary_Error_JSONMessage(t *testing.T) { + payload := `{"message":"connection refused"}` + got := EventSummary("error", payload) + want := "✗ connection refused" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_Error_PlainText(t *testing.T) { + got := EventSummary("error", "something broke") + want := "✗ something broke" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_Error_Empty(t *testing.T) { + got := EventSummary("error", "") + want := "✗ unknown error" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_TextMessageContent(t *testing.T) { + payload := `{"delta":"hello world"}` + got := EventSummary("TEXT_MESSAGE_CONTENT", payload) + if got != "hello world" { + t.Errorf("got %q, want %q", got, "hello world") + } +} + +func TestEventSummary_TextMessageContent_KV(t *testing.T) { + payload := `"delta='hello world'"` + got := EventSummary("TEXT_MESSAGE_CONTENT", payload) + if got != "hello world" { + t.Errorf("got %q, want %q", got, "hello world") + } +} + +func TestEventSummary_ReasoningMessageContent(t *testing.T) { + payload := `{"delta":"thinking..."}` + got := EventSummary("REASONING_MESSAGE_CONTENT", payload) + if got != "thinking..." { + t.Errorf("got %q, want %q", got, "thinking...") + } +} + +func TestEventSummary_ToolCallStart(t *testing.T) { + payload := `{"tool_call_name":"Bash"}` + got := EventSummary("TOOL_CALL_START", payload) + if got != "⚙ Bash" { + t.Errorf("got %q, want %q", got, "⚙ Bash") + } +} + +func TestEventSummary_ToolCallStart_ToolName(t *testing.T) { + payload := `{"tool_name":"Read"}` + got := EventSummary("TOOL_CALL_START", payload) + if got != "⚙ Read" { + t.Errorf("got %q, want %q", got, "⚙ Read") + } +} + +func TestEventSummary_ToolCallResult(t *testing.T) { + payload := `{"content":"result data"}` + got := EventSummary("TOOL_CALL_RESULT", payload) + if got != "result data" { + t.Errorf("got %q, want %q", got, "result data") + } +} + +func TestEventSummary_RunFinished(t *testing.T) { + got := EventSummary("RUN_FINISHED", "") + if got != "[done]" { + t.Errorf("got %q, want %q", got, "[done]") + } +} + +func TestEventSummary_RunError(t *testing.T) { + payload := `{"message":"out of memory"}` + got := EventSummary("RUN_ERROR", payload) + want := "✗ out of memory" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_RunError_KV(t *testing.T) { + payload := `"message='out of memory'"` + got := EventSummary("RUN_ERROR", payload) + want := "✗ out of memory" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEventSummary_TextMessageStart(t *testing.T) { + got := EventSummary("TEXT_MESSAGE_START", "") + if got != "…" { + t.Errorf("got %q, want %q", got, "…") + } +} + +func TestEventSummary_SuppressedTypes(t *testing.T) { + for _, et := range []string{"TEXT_MESSAGE_END", "TOOL_CALL_ARGS", "TOOL_CALL_END"} { + got := EventSummary(et, "anything") + if got != "" { + t.Errorf("EventSummary(%q, ...) = %q, want empty", et, got) + } + } +} + +func TestEventSummary_UnknownShortPayload(t *testing.T) { + got := EventSummary("SOME_FUTURE_EVENT", "short payload") + if got != "short payload" { + t.Errorf("got %q, want %q", got, "short payload") + } +} + +func TestEventSummary_UnknownLongPayload(t *testing.T) { + long := make([]byte, 200) + for i := range long { + long[i] = 'x' + } + got := EventSummary("SOME_FUTURE_EVENT", string(long)) + if got != "" { + t.Errorf("got %q, want empty for long unknown payload", got) + } +} + +// --------------------------------------------------------------------------- +// ParsePayload +// --------------------------------------------------------------------------- + +func TestParsePayload_ValidJSON(t *testing.T) { + result := ParsePayload(`{"name":"Read","count":42}`) + if result["name"] != "Read" { + t.Errorf("name = %v, want Read", result["name"]) + } + // JSON numbers are float64. + if result["count"] != float64(42) { + t.Errorf("count = %v, want 42", result["count"]) + } +} + +func TestParsePayload_InvalidJSON(t *testing.T) { + result := ParsePayload("not json at all") + raw, ok := result["raw"] + if !ok { + t.Fatal("expected 'raw' key for invalid JSON") + } + if raw != "not json at all" { + t.Errorf("raw = %q, want %q", raw, "not json at all") + } +} + +func TestParsePayload_Empty(t *testing.T) { + result := ParsePayload("") + if len(result) != 0 { + t.Errorf("expected empty map for empty input, got %v", result) + } +} + +func TestParsePayload_JSONArray(t *testing.T) { + result := ParsePayload(`[1,2,3]`) + // Not an object, should fall back to raw. + if _, ok := result["raw"]; !ok { + t.Error("expected 'raw' key for JSON array") + } +} + +func TestParsePayload_JSONString(t *testing.T) { + result := ParsePayload(`"just a string"`) + if _, ok := result["raw"]; !ok { + t.Error("expected 'raw' key for JSON string") + } +} + +func TestParsePayload_Nested(t *testing.T) { + result := ParsePayload(`{"outer":{"inner":"value"}}`) + outer, ok := result["outer"].(map[string]any) + if !ok { + t.Fatal("expected nested object for 'outer'") + } + if outer["inner"] != "value" { + t.Errorf("inner = %v, want value", outer["inner"]) + } +} + +// --------------------------------------------------------------------------- +// ExtractField +// --------------------------------------------------------------------------- + +func TestExtractField_JSONObject(t *testing.T) { + payload := `{"delta":"hello","seq":5}` + if got := ExtractField(payload, "delta"); got != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } + if got := ExtractField(payload, "seq"); got != "5" { + t.Errorf("got %q, want %q", got, "5") + } +} + +func TestExtractField_JSONMissing(t *testing.T) { + payload := `{"delta":"hello"}` + if got := ExtractField(payload, "missing"); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestExtractField_JSONNested(t *testing.T) { + payload := `{"args":{"file":"/tmp/foo"}}` + got := ExtractField(payload, "args") + // Should return JSON representation of the nested object. + if got != `{"file":"/tmp/foo"}` { + t.Errorf("got %q, want %q", got, `{"file":"/tmp/foo"}`) + } +} + +func TestExtractField_JSONNull(t *testing.T) { + payload := `{"value":null}` + if got := ExtractField(payload, "value"); got != "" { + t.Errorf("got %q, want empty for null", got) + } +} + +func TestExtractField_JSONBool(t *testing.T) { + payload := `{"is_error":true}` + if got := ExtractField(payload, "is_error"); got != "true" { + t.Errorf("got %q, want %q", got, "true") + } +} + +func TestExtractField_KVFormat(t *testing.T) { + payload := `delta='hello world'` + if got := ExtractField(payload, "delta"); got != "hello world" { + t.Errorf("got %q, want %q", got, "hello world") + } +} + +func TestExtractField_KVFormatEscaped(t *testing.T) { + payload := `msg='it\'s fine'` + if got := ExtractField(payload, "msg"); got != "it's fine" { + t.Errorf("got %q, want %q", got, "it's fine") + } +} + +func TestExtractField_KVFormatJSONWrapped(t *testing.T) { + // Payload is a JSON string containing KV format. + payload := `"delta='hello'"` + if got := ExtractField(payload, "delta"); got != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } +} + +func TestExtractField_KVMissing(t *testing.T) { + payload := `name='Read'` + if got := ExtractField(payload, "missing"); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestExtractField_EmptyPayload(t *testing.T) { + if got := ExtractField("", "key"); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestExtractField_JSONFloat(t *testing.T) { + payload := `{"ratio":3.14}` + if got := ExtractField(payload, "ratio"); got != "3.14" { + t.Errorf("got %q, want %q", got, "3.14") + } +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/filter.go b/components/ambient-cli/cmd/acpctl/ambient/tui/filter.go new file mode 100644 index 000000000..910d0965b --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/filter.go @@ -0,0 +1,145 @@ +package tui + +import ( + "fmt" + "regexp" + "slices" + "strings" +) + +// LabelFilter represents a server-side label filter parsed from /-l key=val syntax. +type LabelFilter struct { + Key string + Value string +} + +// Filter represents a parsed filter expression from the TUI filter bar. +// Filters are entered via / in the TUI and support three syntaxes: +// - /term — case-insensitive regex match across all visible columns +// - /!term — inverse regex (hide matching rows) +// - /-l key=val — server-side label filter (@> containment) +type Filter struct { + Raw string // original input string (without leading /) + Pattern *regexp.Regexp // compiled regex (nil for label filters) + Inverse bool // true for /! filters + Label *LabelFilter // non-nil for /-l filters +} + +// ParseFilter parses the raw filter string (without the leading /). +// It returns a compiled Filter or an error if the regex is invalid. +// +// Examples: +// +// ParseFilter("running") → regex filter matching "running" +// ParseFilter("!completed") → inverse regex hiding "completed" +// ParseFilter("-l env=prod") → label filter {Key: "env", Value: "prod"} +func ParseFilter(input string) (*Filter, error) { + f := &Filter{Raw: input} + + // Label filter: -l key=val + if rest, ok := strings.CutPrefix(input, "-l "); ok { + return parseLabelFilter(f, rest) + } + if rest, ok := strings.CutPrefix(input, "-l"); ok && len(rest) > 0 { + return parseLabelFilter(f, rest) + } + + // Inverse filter: !term + if strings.HasPrefix(input, "!") { + f.Inverse = true + input = strings.TrimPrefix(input, "!") + } + + // Empty pattern after stripping prefix + if input == "" { + // An empty regex matches everything, which is valid. + // For inverse, this hides everything — unusual but not an error. + f.Pattern = regexp.MustCompile("(?i)") + return f, nil + } + + // Compile as case-insensitive regex + re, err := regexp.Compile("(?i)" + input) + if err != nil { + return nil, fmt.Errorf("invalid filter regex: %w", err) + } + f.Pattern = re + + return f, nil +} + +// parseLabelFilter parses the key=val portion of a -l filter. +func parseLabelFilter(f *Filter, kv string) (*Filter, error) { + kv = strings.TrimSpace(kv) + if kv == "" { + return nil, fmt.Errorf("label filter requires key=value, got empty string") + } + + eqIdx := strings.Index(kv, "=") + if eqIdx < 0 { + return nil, fmt.Errorf("label filter requires key=value format, got %q", kv) + } + + key := kv[:eqIdx] + value := kv[eqIdx+1:] + + if key == "" { + return nil, fmt.Errorf("label filter key must not be empty") + } + + f.Label = &LabelFilter{ + Key: key, + Value: value, + } + return f, nil +} + +// MatchRow returns true if the row matches the filter. +// +// For regex filters, the row matches if ANY column contains a match. +// For inverse filters, the result is negated (rows that match are hidden). +// For label filters, MatchRow always returns true — label filtering is +// performed server-side, not client-side. +func (f *Filter) MatchRow(columns []string) bool { + // Label filters are server-side only; all rows pass client-side filtering. + if f.Label != nil { + return true + } + + if f.Pattern == nil { + return true + } + + matched := slices.ContainsFunc(columns, func(col string) bool { + return f.Pattern.MatchString(col) + }) + + if f.Inverse { + return !matched + } + return matched +} + +// IsLabelFilter returns true if this filter is a server-side label filter. +func (f *Filter) IsLabelFilter() bool { + return f.Label != nil +} + +// String returns a human-readable representation of the filter for display +// in the TUI status line. +func (f *Filter) String() string { + if f.Label != nil { + return fmt.Sprintf("-l %s=%s", f.Label.Key, f.Label.Value) + } + if f.Inverse { + return "!" + stripCaseInsensitivePrefix(f.Raw) + } + return f.Raw +} + +// stripCaseInsensitivePrefix removes the leading "!" from the raw string +// if present, since String() adds it back explicitly for inverse filters. +// This avoids double-prefixing when Raw already starts with "!". +func stripCaseInsensitivePrefix(raw string) string { + return strings.TrimPrefix(raw, "!") +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/filter_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/filter_test.go new file mode 100644 index 000000000..f7770b535 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/filter_test.go @@ -0,0 +1,450 @@ +package tui + +import ( + "testing" +) + +func TestParseFilter_BasicRegex(t *testing.T) { + f, err := mustParse(t, "running") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Pattern == nil { + t.Fatal("expected non-nil Pattern") + } + if f.Inverse { + t.Error("expected Inverse=false") + } + if f.Label != nil { + t.Error("expected Label=nil") + } + if f.Raw != "running" { + t.Errorf("expected Raw=%q, got %q", "running", f.Raw) + } +} + +func TestParseFilter_CaseInsensitive(t *testing.T) { + f, err := mustParse(t, "Running") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should match lowercase + if !f.Pattern.MatchString("running") { + t.Error("expected case-insensitive match for 'running'") + } + // Should match uppercase + if !f.Pattern.MatchString("RUNNING") { + t.Error("expected case-insensitive match for 'RUNNING'") + } + // Should match mixed case + if !f.Pattern.MatchString("RuNnInG") { + t.Error("expected case-insensitive match for 'RuNnInG'") + } +} + +func TestParseFilter_InverseRegex(t *testing.T) { + f, err := mustParse(t, "!completed") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !f.Inverse { + t.Error("expected Inverse=true") + } + if f.Pattern == nil { + t.Fatal("expected non-nil Pattern") + } + if f.Label != nil { + t.Error("expected Label=nil") + } +} + +func TestParseFilter_LabelFilter(t *testing.T) { + f, err := mustParse(t, "-l env=prod") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Label == nil { + t.Fatal("expected non-nil Label") + } + if f.Label.Key != "env" { + t.Errorf("expected Key=%q, got %q", "env", f.Label.Key) + } + if f.Label.Value != "prod" { + t.Errorf("expected Value=%q, got %q", "prod", f.Label.Value) + } + if f.Pattern != nil { + t.Error("expected Pattern=nil for label filter") + } +} + +func TestParseFilter_LabelFilterNoSpace(t *testing.T) { + f, err := mustParse(t, "-lenv=prod") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Label == nil { + t.Fatal("expected non-nil Label") + } + if f.Label.Key != "env" { + t.Errorf("expected Key=%q, got %q", "env", f.Label.Key) + } + if f.Label.Value != "prod" { + t.Errorf("expected Value=%q, got %q", "prod", f.Label.Value) + } +} + +func TestParseFilter_LabelFilterEmptyValue(t *testing.T) { + // -l key= is valid (empty value) + f, err := mustParse(t, "-l key=") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Label == nil { + t.Fatal("expected non-nil Label") + } + if f.Label.Key != "key" { + t.Errorf("expected Key=%q, got %q", "key", f.Label.Key) + } + if f.Label.Value != "" { + t.Errorf("expected Value=%q, got %q", "", f.Label.Value) + } +} + +func TestParseFilter_LabelFilterMultipleEquals(t *testing.T) { + // -l key=val=ue should split on first = only + f, err := mustParse(t, "-l key=val=ue") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Label == nil { + t.Fatal("expected non-nil Label") + } + if f.Label.Key != "key" { + t.Errorf("expected Key=%q, got %q", "key", f.Label.Key) + } + if f.Label.Value != "val=ue" { + t.Errorf("expected Value=%q, got %q", "val=ue", f.Label.Value) + } +} + +func TestParseFilter_EmptyString(t *testing.T) { + f, err := mustParse(t, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Pattern == nil { + t.Fatal("expected non-nil Pattern for empty string") + } + // Empty regex matches everything + if !f.Pattern.MatchString("anything") { + t.Error("empty regex should match everything") + } +} + +func TestParseFilter_InverseEmptyString(t *testing.T) { + f, err := mustParse(t, "!") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !f.Inverse { + t.Error("expected Inverse=true") + } + if f.Pattern == nil { + t.Fatal("expected non-nil Pattern") + } +} + +func TestParseFilter_InvalidRegex(t *testing.T) { + _, err := ParseFilter("[invalid") + if err == nil { + t.Fatal("expected error for invalid regex") + } +} + +func TestParseFilter_InvalidRegexInverse(t *testing.T) { + _, err := ParseFilter("![invalid") + if err == nil { + t.Fatal("expected error for invalid inverse regex") + } +} + +func TestParseFilter_SpecialRegexChars(t *testing.T) { + // Valid regex with special characters + f, err := mustParse(t, "be-agent\\.v[12]") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !f.Pattern.MatchString("be-agent.v1") { + t.Error("expected match for 'be-agent.v1'") + } + if !f.Pattern.MatchString("be-agent.v2") { + t.Error("expected match for 'be-agent.v2'") + } + if f.Pattern.MatchString("be-agentXv3") { + t.Error("expected no match for 'be-agentXv3'") + } +} + +func TestParseFilter_LabelFilterErrors(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"empty after -l", "-l "}, + {"no equals sign", "-l envprod"}, + {"empty key", "-l =prod"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseFilter(tt.input) + if err == nil { + t.Errorf("expected error for input %q", tt.input) + } + }) + } +} + +func TestMatchRow_BasicRegex(t *testing.T) { + f, _ := ParseFilter("running") + + tests := []struct { + name string + columns []string + want bool + }{ + { + name: "match in first column", + columns: []string{"running", "agent-1", "proj-a"}, + want: true, + }, + { + name: "match in middle column", + columns: []string{"agent-1", "running", "proj-a"}, + want: true, + }, + { + name: "match in last column", + columns: []string{"agent-1", "proj-a", "running"}, + want: true, + }, + { + name: "no match", + columns: []string{"agent-1", "completed", "proj-a"}, + want: false, + }, + { + name: "partial match", + columns: []string{"agent-running-1", "proj-a"}, + want: true, + }, + { + name: "empty columns", + columns: []string{}, + want: false, + }, + { + name: "case insensitive match", + columns: []string{"RUNNING", "agent-1"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.MatchRow(tt.columns) + if got != tt.want { + t.Errorf("MatchRow(%v) = %v, want %v", tt.columns, got, tt.want) + } + }) + } +} + +func TestMatchRow_InverseRegex(t *testing.T) { + f, _ := ParseFilter("!completed") + + tests := []struct { + name string + columns []string + want bool + }{ + { + name: "hide matching row", + columns: []string{"agent-1", "completed", "proj-a"}, + want: false, + }, + { + name: "show non-matching row", + columns: []string{"agent-1", "running", "proj-a"}, + want: true, + }, + { + name: "hide partial match", + columns: []string{"completed-yesterday", "proj-a"}, + want: false, + }, + { + name: "empty columns (no match, so not hidden)", + columns: []string{}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.MatchRow(tt.columns) + if got != tt.want { + t.Errorf("MatchRow(%v) = %v, want %v", tt.columns, got, tt.want) + } + }) + } +} + +func TestMatchRow_LabelFilter(t *testing.T) { + f, _ := ParseFilter("-l env=prod") + + // Label filters always return true — filtering is server-side + tests := []struct { + name string + columns []string + want bool + }{ + { + name: "any row passes", + columns: []string{"agent-1", "running", "proj-a"}, + want: true, + }, + { + name: "empty columns pass", + columns: []string{}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.MatchRow(tt.columns) + if got != tt.want { + t.Errorf("MatchRow(%v) = %v, want %v", tt.columns, got, tt.want) + } + }) + } +} + +func TestMatchRow_NilPattern(t *testing.T) { + // A filter with nil Pattern (shouldn't normally happen, but defensively) returns true + f := &Filter{Raw: "test"} + if !f.MatchRow([]string{"anything"}) { + t.Error("nil Pattern should match everything") + } +} + +func TestIsLabelFilter(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"regex filter", "running", false}, + {"inverse filter", "!completed", false}, + {"label filter", "-l env=prod", true}, + {"empty filter", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := ParseFilter(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := f.IsLabelFilter() + if got != tt.want { + t.Errorf("IsLabelFilter() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFilterString(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"basic regex", "running", "running"}, + {"inverse regex", "!completed", "!completed"}, + {"label filter", "-l env=prod", "-l env=prod"}, + {"empty", "", ""}, + {"special chars", "be-agent\\.v1", "be-agent\\.v1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := ParseFilter(tt.input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := f.String() + if got != tt.want { + t.Errorf("String() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMatchRow_MultipleColumns(t *testing.T) { + f, _ := ParseFilter("prod") + + // Match should check across ALL columns + row := []string{"agent-prod-1", "running", "production", "2h"} + if !f.MatchRow(row) { + t.Error("expected match when multiple columns contain the pattern") + } + + // No match in any column + row = []string{"agent-dev-1", "running", "development", "2h"} + if f.MatchRow(row) { + t.Error("expected no match when no column contains the pattern") + } +} + +func TestMatchRow_RegexPattern(t *testing.T) { + f, _ := ParseFilter("^agent-[0-9]+$") + + tests := []struct { + name string + columns []string + want bool + }{ + { + name: "full match", + columns: []string{"agent-123"}, + want: true, + }, + { + name: "no match — letters", + columns: []string{"agent-abc"}, + want: false, + }, + { + name: "no match — prefix", + columns: []string{"my-agent-123"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.MatchRow(tt.columns) + if got != tt.want { + t.Errorf("MatchRow(%v) = %v, want %v", tt.columns, got, tt.want) + } + }) + } +} + +// mustParse is a test helper that calls ParseFilter and returns the result. +func mustParse(t *testing.T, input string) (*Filter, error) { + t.Helper() + return ParseFilter(input) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize.go b/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize.go new file mode 100644 index 000000000..9ed4b39fe --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize.go @@ -0,0 +1,75 @@ +package tui + +import ( + "regexp" + "strings" + "unicode/utf8" +) + +// ANSI CSI sequences: ESC [ ... +// Matches sequences like \x1b[0m, \x1b[31;1m, \x1b[2J, etc. +var csiRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +// ANSI OSC sequences: ESC ] ... (terminated by BEL or ST) +// Matches sequences like \x1b]0;title\a, \x1b]8;;url\x1b\\, etc. +var oscRe = regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`) + +// lipgloss/tview region tags: ["regionid"] +var regionTagRe = regexp.MustCompile(`\["[^"]*"\]`) + +// Sanitize strips dangerous content from agent-produced output before +// terminal rendering. It removes: +// - ANSI CSI escape sequences (\x1b[...) +// - ANSI OSC escape sequences (\x1b]...) +// - C0 control characters (0x00-0x1F) except tab (0x09) and newline (0x0A) +// - C1 control characters (0x80-0x9F) +// - lipgloss/tview region tags (["..."]) +func Sanitize(s string) string { + // Strip ANSI CSI sequences. + s = csiRe.ReplaceAllString(s, "") + + // Strip ANSI OSC sequences. + s = oscRe.ReplaceAllString(s, "") + + // Strip region tags. + s = regionTagRe.ReplaceAllString(s, "") + + // Strip C0 control characters (except \t and \n) and C1 control characters. + // We use utf8.DecodeRune to properly handle multi-byte UTF-8 sequences + // (whose continuation bytes overlap with the C1 range 0x80-0x9F). + // Invalid single bytes in 0x80-0x9F are detected via the replacement + // character with a width of 1. + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + switch { + case r == '\t' || r == '\n': + b.WriteRune(r) + case r <= 0x1F: + // C0 control character — drop. + case r >= 0x80 && r <= 0x9F: + // C1 control character (valid 2-byte UTF-8 encoding) — drop. + case r == utf8.RuneError && size == 1: + // Invalid byte; check if it falls in the C1 range. + if s[i] >= 0x80 && s[i] <= 0x9F { + // C1 control byte — drop. + } else { + b.WriteByte(s[i]) + } + default: + b.WriteString(s[i : i+size]) + } + i += size + } + return b.String() +} + +// SanitizeLines applies Sanitize to each line and returns the results. +func SanitizeLines(lines []string) []string { + out := make([]string, len(lines)) + for i, line := range lines { + out[i] = Sanitize(line) + } + return out +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize_test.go new file mode 100644 index 000000000..edb416b67 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/sanitize_test.go @@ -0,0 +1,261 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestSanitize_CSISequences(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"simple reset", "\x1b[0mhello", "hello"}, + {"color code", "\x1b[31mred\x1b[0m", "red"}, + {"bold + color", "\x1b[1;33mbold yellow\x1b[0m", "bold yellow"}, + {"cursor movement", "\x1b[2Jcleared", "cleared"}, + {"embedded CSI", "before\x1b[36mcyan\x1b[0mafter", "beforecyanafter"}, + {"multiple params", "\x1b[38;5;196mtext\x1b[0m", "text"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.want { + t.Errorf("Sanitize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSanitize_OSCSequences(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"window title BEL", "\x1b]0;My Title\x07text", "text"}, + {"window title ST", "\x1b]0;My Title\x1b\\text", "text"}, + {"hyperlink", "\x1b]8;;https://example.com\x1b\\click\x1b]8;;\x1b\\", "click"}, + {"embedded OSC", "before\x1b]2;title\x07after", "beforeafter"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.want { + t.Errorf("Sanitize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSanitize_C0ControlCharacters(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"null byte", "hel\x00lo", "hello"}, + {"bell", "alert\x07!", "alert!"}, + {"backspace", "ab\x08c", "abc"}, + {"form feed", "page\x0cbreak", "pagebreak"}, + {"carriage return", "over\rwrite", "overwrite"}, + {"escape alone", "esc\x1b here", "esc here"}, + {"mixed controls", "\x01\x02\x03text\x04\x05", "text"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.want { + t.Errorf("Sanitize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSanitize_C0PreservesTabAndNewline(t *testing.T) { + input := "line1\n\tindented\nline3" + got := Sanitize(input) + if got != input { + t.Errorf("Sanitize should preserve tabs and newlines: got %q, want %q", got, input) + } +} + +func TestSanitize_C1ControlCharacters(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"0x80 PAD", "a\x80b", "ab"}, + {"0x85 NEL", "a\x85b", "ab"}, + {"0x8E SS2", "a\x8Eb", "ab"}, + {"0x90 DCS", "a\x90b", "ab"}, + {"0x9B CSI intro", "a\x9Bb", "ab"}, + {"0x9C ST", "a\x9Cb", "ab"}, + {"0x9F APC", "a\x9Fb", "ab"}, + {"range boundary low", "a\x80b", "ab"}, + {"range boundary high", "a\x9Fb", "ab"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.want { + t.Errorf("Sanitize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSanitize_RegionTags(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"simple region", `["main"]content[""]`, "content"}, + {"named region", `before["sidebar"]middle[""]after`, "beforemiddleafter"}, + {"empty region id", `[""]text`, "text"}, + {"region with special chars", `["region-1_a"]text`, "text"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.want { + t.Errorf("Sanitize(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSanitize_NormalTextPassthrough(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"plain ASCII", "Hello, World!"}, + {"numbers and punctuation", "Test 123 -- ok? Yes! @#$%^&*()"}, + {"multiline", "line 1\nline 2\nline 3"}, + {"tabs", "col1\tcol2\tcol3"}, + {"empty string", ""}, + {"spaces only", " "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.input { + t.Errorf("Sanitize(%q) = %q, want passthrough", tt.input, got) + } + }) + } +} + +func TestSanitize_UnicodePassthrough(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"CJK characters", "你好世界"}, + {"emoji", "\U0001f680\U0001f525✨"}, + {"accented Latin", "éàüñ"}, + {"Arabic", "مرحبا"}, + {"mixed Unicode and ASCII", "Hello 世界! \U0001f44b"}, + {"right above C1 range", " ¡ÿ"}, // 0xA0, 0xA1, 0xFF are NOT C1 + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.input { + t.Errorf("Sanitize(%q) = %q, want passthrough", tt.input, got) + } + }) + } +} + +func TestSanitize_EmptyString(t *testing.T) { + got := Sanitize("") + if got != "" { + t.Errorf("Sanitize(\"\") = %q, want \"\"", got) + } +} + +func TestSanitize_MixedContent(t *testing.T) { + // A realistic agent output line with ANSI colors, a region tag, and a stray control char. + input := "\x1b[1;32m✔ Task complete\x1b[0m [\"status\"] result\x07\n" + want := "✔ Task complete result\n" + got := Sanitize(input) + if got != want { + t.Errorf("Sanitize mixed content:\n got %q\n want %q", got, want) + } +} + +func TestSanitize_OnlyControlChars(t *testing.T) { + input := "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0d\x0e\x0f" + got := Sanitize(input) + if got != "" { + t.Errorf("Sanitize(all control chars) = %q, want \"\"", got) + } +} + +func TestSanitize_BracketNotRegionTag(t *testing.T) { + // Square brackets that don't match the region tag pattern should pass through. + tests := []struct { + name string + input string + }{ + {"array index", "arr[0]"}, + {"no quotes", "[main]"}, + {"single quotes", "['main']"}, + {"unbalanced", `["open`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Sanitize(tt.input) + if got != tt.input { + t.Errorf("Sanitize(%q) = %q, want passthrough", tt.input, got) + } + }) + } +} + +func TestSanitizeLines(t *testing.T) { + lines := []string{ + "\x1b[31mred\x1b[0m", + "normal text", + "tab\there", + "\x00null\x07bell", + `["region"]tagged`, + } + got := SanitizeLines(lines) + want := []string{ + "red", + "normal text", + "tab\there", + "nullbell", + "tagged", + } + if len(got) != len(want) { + t.Fatalf("SanitizeLines returned %d lines, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("SanitizeLines[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestSanitizeLines_Empty(t *testing.T) { + got := SanitizeLines([]string{}) + if len(got) != 0 { + t.Errorf("SanitizeLines([]) returned %d elements, want 0", len(got)) + } +} + +func TestSanitizeLines_PreservesOrder(t *testing.T) { + lines := []string{"first", "second", "third"} + got := SanitizeLines(lines) + result := strings.Join(got, ",") + if result != "first,second,third" { + t.Errorf("SanitizeLines did not preserve order: got %q", result) + } +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go new file mode 100644 index 000000000..f33b6d0e7 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -0,0 +1,386 @@ +package views + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// SortDirection represents the sort order for a column. +type SortDirection int + +const ( + // SortNone means no sorting is applied on this column. + SortNone SortDirection = iota + // SortAsc sorts the column in ascending order. + SortAsc + // SortDesc sorts the column in descending order. + SortDesc +) + +// TableStyle holds the color and style values used to render the resource table. +// Pass this in from the parent package to avoid circular imports. +type TableStyle struct { + // BorderColor is used for the title bar box-drawing characters. + BorderColor lipgloss.Color + // TitleColor is used for the resource kind and scope text in the title. + TitleColor lipgloss.Color + // CountColor is used for the row count in the title. + CountColor lipgloss.Color + // DimColor is used for inactive/secondary elements. + DimColor lipgloss.Color + // HeaderColor is used for column header text. + HeaderColor lipgloss.Color + // SelectedBg is the background color for the selected row. + SelectedBg lipgloss.Color + // SelectedFg is the foreground color for the selected row. + SelectedFg lipgloss.Color +} + +// DefaultTableStyle returns a TableStyle using the project's orange-accent k9s palette. +func DefaultTableStyle() TableStyle { + return TableStyle{ + BorderColor: lipgloss.Color("214"), // orange + TitleColor: lipgloss.Color("214"), // orange + CountColor: lipgloss.Color("240"), // dim + DimColor: lipgloss.Color("240"), // dim + HeaderColor: lipgloss.Color("255"), // white + SelectedBg: lipgloss.Color("214"), // orange + SelectedFg: lipgloss.Color("0"), // black on orange + } +} + +// sortState tracks which column is sorted and in what direction. +type sortState struct { + colIdx int + direction SortDirection +} + +// ResourceTable wraps bubbles/table.Model with k9s-style title bar, +// column sorting, and client-side filtering. +type ResourceTable struct { + // inner is the wrapped bubbles table model. + inner table.Model + + // kind is the resource kind displayed in the title (e.g. "agents", "sessions"). + kind string + // scope is shown in parentheses in the title (e.g. "ambient-platform", "all"). + scope string + + // style controls rendering colors. + style TableStyle + + // allRows holds the unfiltered data rows. + allRows []table.Row + // filterPredicate is the active client-side filter. Nil means no filter. + filterPredicate func([]string) bool + + // sort tracks the current column sort state. + sort sortState + + // columns stores the original column definitions for sort indicator rendering. + columns []table.Column +} + +// NewResourceTable creates a ResourceTable configured with the given resource kind, +// scope, columns, and style. The table starts focused and with no rows. +func NewResourceTable(kind string, scope string, columns []table.Column, style TableStyle) ResourceTable { + // Store a copy of columns so we can modify titles for sort indicators + // without mutating the caller's slice. + cols := make([]table.Column, len(columns)) + copy(cols, columns) + + t := table.New( + table.WithColumns(cols), + table.WithFocused(true), + table.WithHeight(1), // will be resized by the parent layout + ) + + // Apply k9s-inspired styles using the provided palette. + s := table.DefaultStyles() + s.Header = s.Header. + Foreground(style.HeaderColor). + Bold(true). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(style.DimColor) + s.Selected = s.Selected. + Foreground(style.SelectedFg). + Background(style.SelectedBg). + Bold(true) + s.Cell = s.Cell. + Foreground(style.HeaderColor) + t.SetStyles(s) + + return ResourceTable{ + inner: t, + kind: kind, + scope: scope, + style: style, + columns: cols, + sort: sortState{ + colIdx: -1, + direction: SortNone, + }, + } +} + +// Title returns the formatted k9s-style title string, e.g. "agents(ambient-platform)[12]". +func (rt *ResourceTable) Title() string { + count := len(rt.inner.Rows()) + return fmt.Sprintf("%s(%s)[%d]", rt.kind, rt.scope, count) +} + +// SetScope updates the scope shown in the title bar. +func (rt *ResourceTable) SetScope(scope string) { + rt.scope = scope +} + +// SetKind updates the resource kind shown in the title bar. +func (rt *ResourceTable) SetKind(kind string) { + rt.kind = kind +} + +// SetRows replaces all data rows. Filtering and sorting are re-applied. +func (rt *ResourceTable) SetRows(rows []table.Row) { + rt.allRows = make([]table.Row, len(rows)) + copy(rt.allRows, rows) + rt.applyFilterAndSort() +} + +// SetFilter sets a client-side filter predicate. Rows for which the predicate +// returns false are hidden. The predicate receives the row as a []string +// (same as table.Row's underlying type). Pass nil to clear. +func (rt *ResourceTable) SetFilter(predicate func([]string) bool) { + rt.filterPredicate = predicate + rt.applyFilterAndSort() +} + +// ClearFilter removes any active client-side filter. +func (rt *ResourceTable) ClearFilter() { + rt.filterPredicate = nil + rt.applyFilterAndSort() +} + +// SortByColumn toggles column sort: none -> ascending -> descending -> none. +// Calling with the same column index cycles through the states. +// Calling with a different column index resets to ascending on the new column. +func (rt *ResourceTable) SortByColumn(colIdx int) { + if colIdx < 0 || colIdx >= len(rt.columns) { + return + } + + if rt.sort.colIdx == colIdx { + // Cycle: asc -> desc -> none + switch rt.sort.direction { + case SortNone: + rt.sort.direction = SortAsc + case SortAsc: + rt.sort.direction = SortDesc + case SortDesc: + rt.sort.direction = SortNone + rt.sort.colIdx = -1 + } + } else { + rt.sort.colIdx = colIdx + rt.sort.direction = SortAsc + } + + rt.updateColumnHeaders() + rt.applyFilterAndSort() +} + +// SortDirection returns the current sort column index and direction. +// Column index is -1 when no sort is active. +func (rt *ResourceTable) SortDirection() (colIdx int, dir SortDirection) { + return rt.sort.colIdx, rt.sort.direction +} + +// SelectedRow returns the currently highlighted row, or nil if the table is empty. +func (rt *ResourceTable) SelectedRow() table.Row { + return rt.inner.SelectedRow() +} + +// Cursor returns the index of the currently selected row. +func (rt *ResourceTable) Cursor() int { + return rt.inner.Cursor() +} + +// SetHeight sets the visible height of the table (number of data rows). +func (rt *ResourceTable) SetHeight(h int) { + rt.inner.SetHeight(h) +} + +// SetWidth sets the total width available for the table. +func (rt *ResourceTable) SetWidth(w int) { + rt.inner.SetWidth(w) +} + +// Focus gives keyboard focus to the table. +func (rt *ResourceTable) Focus() { + rt.inner.Focus() +} + +// Blur removes keyboard focus from the table. +func (rt *ResourceTable) Blur() { + rt.inner.Blur() +} + +// Focused returns whether the table currently has keyboard focus. +func (rt *ResourceTable) Focused() bool { + return rt.inner.Focused() +} + +// Rows returns the currently visible (filtered + sorted) rows. +func (rt *ResourceTable) Rows() []table.Row { + return rt.inner.Rows() +} + +// Columns returns the current column definitions. +func (rt *ResourceTable) Columns() []table.Column { + return rt.inner.Columns() +} + +// Update delegates message handling to the inner bubbles/table and adds +// scroll-wheel support. Returns the updated ResourceTable and any command. +func (rt *ResourceTable) Update(msg tea.Msg) (ResourceTable, tea.Cmd) { + switch msg := msg.(type) { + case tea.MouseMsg: + switch msg.Button { + case tea.MouseButtonWheelUp: + rt.inner.MoveUp(3) + return *rt, nil + case tea.MouseButtonWheelDown: + rt.inner.MoveDown(3) + return *rt, nil + } + } + + var cmd tea.Cmd + rt.inner, cmd = rt.inner.Update(msg) + return *rt, cmd +} + +// View renders the table with a k9s-style title bar. +// +// The title bar format is: +// +// +---- kind(scope)[count] ----+ +// +// using box-drawing characters and the configured border color. +func (rt *ResourceTable) View() string { + titleBar := rt.renderTitleBar() + tableView := rt.inner.View() + return titleBar + "\n" + tableView +} + +// renderTitleBar produces the k9s-style title line with box-drawing characters. +// Example: "--- agents(ambient-platform)[12] ---" +func (rt *ResourceTable) renderTitleBar() string { + borderStyle := lipgloss.NewStyle().Foreground(rt.style.BorderColor) + titleStyle := lipgloss.NewStyle().Foreground(rt.style.TitleColor).Bold(true) + countStyle := lipgloss.NewStyle().Foreground(rt.style.CountColor) + + count := len(rt.inner.Rows()) + title := fmt.Sprintf(" %s(%s)", rt.kind, rt.scope) + countStr := fmt.Sprintf("[%d]", count) + titleRendered := titleStyle.Render(title) + countStyle.Render(countStr) + " " + + // Calculate the width available for the decorative dashes. + // lipgloss.Width accounts for ANSI sequences. + titleVisualWidth := lipgloss.Width(titleRendered) + tableWidth := rt.inner.Width() + if tableWidth < titleVisualWidth+6 { + // Not enough room for dashes; just return the title. + return borderStyle.Render("┌────") + + titleRendered + + borderStyle.Render("────┐") + } + + // Distribute remaining width for left and right dash segments. + remaining := tableWidth - titleVisualWidth - 2 // 2 for corner chars + leftDashes := 4 + rightDashes := remaining - leftDashes + rightDashes = max(rightDashes, 1) + + left := borderStyle.Render("┌" + strings.Repeat("─", leftDashes)) + right := borderStyle.Render(strings.Repeat("─", rightDashes) + "┐") + + return left + titleRendered + right +} + +// updateColumnHeaders updates column titles with sort direction indicators. +func (rt *ResourceTable) updateColumnHeaders() { + cols := make([]table.Column, len(rt.columns)) + for i, c := range rt.columns { + col := table.Column{ + Title: c.Title, + Width: c.Width, + } + if i == rt.sort.colIdx { + switch rt.sort.direction { + case SortAsc: + col.Title = c.Title + "↑" // up arrow + case SortDesc: + col.Title = c.Title + "↓" // down arrow + } + } + cols[i] = col + } + rt.inner.SetColumns(cols) +} + +// applyFilterAndSort filters allRows with the predicate, sorts the result, +// and updates the inner table's visible rows. +func (rt *ResourceTable) applyFilterAndSort() { + rows := rt.allRows + + // Apply filter. + if rt.filterPredicate != nil { + filtered := make([]table.Row, 0, len(rows)) + for _, row := range rows { + if rt.filterPredicate([]string(row)) { + filtered = append(filtered, row) + } + } + rows = filtered + } + + // Apply sort. + if rt.sort.colIdx >= 0 && rt.sort.direction != SortNone { + colIdx := rt.sort.colIdx + ascending := rt.sort.direction == SortAsc + + sorted := make([]table.Row, len(rows)) + copy(sorted, rows) + sort.SliceStable(sorted, func(i, j int) bool { + a := cellValue(sorted[i], colIdx) + b := cellValue(sorted[j], colIdx) + if ascending { + return a < b + } + return a > b + }) + rows = sorted + } + + // Preserve cursor position within bounds. + cursor := rt.inner.Cursor() + rt.inner.SetRows(rows) + if cursor >= len(rows) && len(rows) > 0 { + rt.inner.SetCursor(len(rows) - 1) + } +} + +// cellValue safely extracts a cell value from a row, returning empty string +// if the column index is out of range. +func cellValue(row table.Row, colIdx int) string { + if colIdx < 0 || colIdx >= len(row) { + return "" + } + return row[colIdx] +} diff --git a/components/ambient-cli/go.mod b/components/ambient-cli/go.mod index 29667b7e0..a8cced421 100644 --- a/components/ambient-cli/go.mod +++ b/components/ambient-cli/go.mod @@ -1,6 +1,6 @@ module github.com/ambient-code/platform/components/ambient-cli -go 1.24.0 +go 1.24.2 toolchain go1.24.4 @@ -18,16 +18,20 @@ require ( require ( github.com/ambient-code/platform/components/ambient-api-server v0.0.0-20260304211549-047314a7664b // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect diff --git a/components/ambient-cli/go.sum b/components/ambient-cli/go.sum index 7eb5c2c8b..3e0a181a6 100644 --- a/components/ambient-cli/go.sum +++ b/components/ambient-cli/go.sum @@ -4,18 +4,34 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= @@ -35,12 +51,16 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -71,6 +91,7 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From 9aaa4d5186e97f41d734063230ff2c3e18ff757f Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 16:53:53 -0400 Subject: [PATCH 003/117] fix(cli): address Wave 0 spec alignment gaps - Add PhaseColor() to events.go mapping session phases to spec colors (pending=yellow, running=green, completed/succeeded=dim, failed=red, cancelled=dim) - Add TUIConfig.String() and GoString() to redact all context tokens - Fix completed phase color: spec says dim grey (240), not green (28) Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/config.go | 11 +++++++++ .../cmd/acpctl/ambient/tui/events.go | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/config.go b/components/ambient-cli/cmd/acpctl/ambient/tui/config.go index a68ab2c0b..2a85c0caa 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/config.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/config.go @@ -19,6 +19,17 @@ type TUIConfig struct { Contexts map[string]*Context `json:"contexts,omitempty"` } +// String implements fmt.Stringer. All context tokens are redacted. +func (tc *TUIConfig) String() string { + names := tc.ContextNames() + return fmt.Sprintf("TUIConfig{CurrentContext:%q, Contexts:[%s]}", tc.CurrentContext, strings.Join(names, ", ")) +} + +// GoString implements fmt.GoStringer. All context tokens are redacted. +func (tc *TUIConfig) GoString() string { + return tc.String() +} + // Context represents a single server connection with its credentials and project scope. type Context struct { Server string `json:"server"` diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/events.go b/components/ambient-cli/cmd/acpctl/ambient/tui/events.go index da24e7c11..0db011d6f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/events.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/events.go @@ -37,6 +37,30 @@ func EventColor(eventType string) lipgloss.Color { } } +// PhaseColor returns the display color for a session phase. +// +// pending -> yellow (33) +// running -> green (28) +// succeeded / completed -> dim (240) +// failed -> red (31) +// cancelled -> dim (240) +func PhaseColor(phase string) lipgloss.Color { + switch strings.ToLower(phase) { + case "pending": + return colorYellow // 33 + case "running": + return colorGreen // 28 + case "succeeded", "completed": + return colorDim // 240 + case "failed": + return colorRed // 31 + case "cancelled": + return colorDim // 240 + default: + return colorDim // 240 + } +} + // EventSummary returns a one-line display summary for an AG-UI event. // // Behaviour is extracted from the existing tileDisplayPayload logic: From 533230dbadcd30ea8a758e3737f08e82ae63c282 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 16:59:27 -0400 Subject: [PATCH 004/117] =?UTF-8?q?feat(cli):=20Wave=200=20TUI=20wiring=20?= =?UTF-8?q?=E2=80=94=20AppModel,=20Init/Update/View=20for=20project=20tabl?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the Wave 0 foundation modules into a working AppModel that implements tea.Model with k9s-style layout: header block (context/server/project/refresh), command bar (:), filter bar (/), project ResourceTable, breadcrumb trail, and ephemeral info line. Coexists with the legacy Model type for incremental migration. - model_new.go: AppModel struct, Init() with window size + FetchProjects + 5s tick, Update() dispatching WindowSize/KeyMsg/ProjectsMsg/appTickMsg/infoExpiredMsg, command mode with tab completion, filter mode with ParseFilter, skip-on-inflight polling - app.go: View() rendering the full k9s-style layout with header metadata, ASCII branding, key hints, conditional command/filter bar, ResourceTable, navigation breadcrumb, and ephemeral toast info line - Uses views.NewProjectTable and views.DefaultTableStyle from Wave 0 foundation - Sanitizes all project data before display per security conventions Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 197 +++++++ .../cmd/acpctl/ambient/tui/model_new.go | 530 ++++++++++++++++++ components/ambient-cli/go.mod | 1 + components/ambient-cli/go.sum | 2 + 4 files changed, 730 insertions(+) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/app.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go new file mode 100644 index 000000000..a4203cb70 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -0,0 +1,197 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// ASCII art branding rendered in the header. +var brandLines = []string{ + ` _ __ __ `, + `/_\ | \/ | `, + `/ _ \ | |\/| | `, + `/_/ \_\|_| |_| `, +} + +// View implements tea.Model. It renders the k9s-style full-screen layout. +func (m *AppModel) View() string { + if m.width == 0 { + return "Loading..." + } + + var sections []string + + // 1. Header block. + sections = append(sections, m.viewHeader()) + + // 2. Separator. + sections = append(sections, styleDim.Render(strings.Repeat("─", m.width))) + + // 3. Command/filter bar (only when active). + if m.commandMode || m.filterMode { + sections = append(sections, m.viewCommandBar()) + } + + // 4. Resource table with title bar. + sections = append(sections, m.viewResourceTable()) + + // 5. Separator. + sections = append(sections, styleDim.Render(strings.Repeat("─", m.width))) + + // 6. Breadcrumb trail. + sections = append(sections, m.viewBreadcrumb()) + + // 7. Info line. + sections = append(sections, m.viewInfoLine()) + + return strings.Join(sections, "\n") +} + +// viewHeader renders the multi-line header block with context info on the left +// and branding + key hints on the right. +func (m *AppModel) viewHeader() string { + // Left side: context metadata lines. + contextName := "none" + serverURL := "unknown" + project := "none" + if m.config != nil { + if m.config.CurrentContext != "" { + contextName = m.config.CurrentContext + } + if ctx := m.config.Current(); ctx != nil { + if ctx.Server != "" { + serverURL = ctx.Server + } + if ctx.Project != "" { + project = ctx.Project + } + } + } + + // Refresh indicator. + refreshIndicator := "" + if !m.lastFetch.IsZero() { + elapsed := time.Since(m.lastFetch) + indicator := fmt.Sprintf("%ds", int(elapsed.Seconds())) + if elapsed > staleThreshold { + indicator += " (stale)" + refreshIndicator = styleRed.Render(" ⟳ " + indicator) + } else { + refreshIndicator = styleDim.Render(" ⟳ " + indicator) + } + } + + leftLines := []string{ + fmt.Sprintf(" %s %s %s", + styleDim.Render("Context:"), + styleOrange.Render(contextName), + styleDim.Render("[RW]"), + ), + fmt.Sprintf(" %s %s", + styleDim.Render("Server: "), + styleWhite.Render(serverURL), + ), + fmt.Sprintf(" %s %s", + styleDim.Render("User: "), + styleWhite.Render("user"), + ), + fmt.Sprintf(" %s %s", + styleDim.Render("Project:"), + styleOrange.Render(project), + ), + refreshIndicator, + } + + // Right side: key hints + branding. + hintLines := []string{ + styleDim.Render(" ") + styleWhite.Render("Help"), + styleDim.Render("<:> ") + styleWhite.Render("Command"), + styleDim.Render(" ") + styleWhite.Render("Filter"), + "", + "", + } + + // Combine left, hints, and branding into header lines. + headerLines := make([]string, 5) + for i := 0; i < 5; i++ { + left := "" + if i < len(leftLines) { + left = leftLines[i] + } + + hint := "" + if i < len(hintLines) { + hint = hintLines[i] + } + + brand := "" + if i < len(brandLines) { + brand = styleOrange.Render(brandLines[i]) + } + + // Calculate padding to right-align hints and branding. + leftWidth := lipgloss.Width(left) + hintWidth := lipgloss.Width(hint) + brandWidth := lipgloss.Width(brand) + rightContent := hint + " " + brand + rightWidth := hintWidth + 2 + brandWidth + + gap := m.width - leftWidth - rightWidth + if gap < 1 { + gap = 1 + } + + headerLines[i] = left + strings.Repeat(" ", gap) + rightContent + } + + return strings.Join(headerLines, "\n") +} + +// viewCommandBar renders the command or filter input bar. +func (m *AppModel) viewCommandBar() string { + if m.commandMode { + return " " + styleBlue.Render(m.commandInput.View()) + } + if m.filterMode { + return " " + styleYellow.Render(m.filterInput.View()) + } + return "" +} + +// viewResourceTable renders the current resource table with its title bar. +func (m *AppModel) viewResourceTable() string { + return m.projectTable.View() +} + +// viewBreadcrumb renders the navigation breadcrumb trail at the bottom. +func (m *AppModel) viewBreadcrumb() string { + var segments []string + for _, entry := range m.navStack { + segments = append(segments, styleOrange.Render("<"+entry.Kind+">")) + } + return " " + strings.Join(segments, styleDim.Render(" ")) +} + +// viewInfoLine renders the ephemeral info/toast line at the very bottom. +func (m *AppModel) viewInfoLine() string { + // Error takes priority over info. + if m.lastError != "" { + return " " + styleRed.Render("✗ "+m.lastError) + } + + if m.infoMessage != "" { + // Center the info message. + msgWidth := lipgloss.Width(m.infoMessage) + pad := (m.width - msgWidth) / 2 + if pad < 0 { + pad = 0 + } + return strings.Repeat(" ", pad) + styleDim.Render(m.infoMessage) + } + + // Default: empty line. + return "" +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go new file mode 100644 index 000000000..b42e9daa2 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -0,0 +1,530 @@ +package tui + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" + "github.com/ambient-code/platform/components/ambient-cli/pkg/connection" +) + +// pollInterval is the auto-refresh interval for resource tables. +const pollInterval = 5 * time.Second + +// infoTimeout is how long ephemeral info messages are displayed. +const infoTimeout = 5 * time.Second + +// staleThreshold marks data as stale in the header when exceeded. +const staleThreshold = 15 * time.Second + +// --------------------------------------------------------------------------- +// Navigation +// --------------------------------------------------------------------------- + +// NavEntry represents a single level in the navigation stack. +type NavEntry struct { + Kind string // "projects", "agents", "sessions", etc. + Scope string // project name, agent name, etc. + ID string // resource ID if applicable +} + +// --------------------------------------------------------------------------- +// Message types (prefixed with "app" to avoid collision with model.go types) +// --------------------------------------------------------------------------- + +// appTickMsg fires every pollInterval to trigger data refresh. +type appTickMsg struct{ t time.Time } + +// infoExpiredMsg signals the ephemeral info line should be cleared. +type infoExpiredMsg struct{} + +// --------------------------------------------------------------------------- +// AppModel — the Wave 0 TUI model +// --------------------------------------------------------------------------- + +// AppModel is the top-level Bubbletea model for the rewritten TUI. +// It coexists with the legacy Model type in model.go until migration is +// complete. +type AppModel struct { + // Config + config *TUIConfig + client *TUIClient + + // Navigation + navStack []NavEntry // stack of views; rightmost is current + + // View state + projectTable views.ResourceTable + + // Command mode + commandMode bool + commandInput textinput.Model + + // Filter mode + filterMode bool + filterInput textinput.Model + activeFilter *Filter + + // Polling + pollInFlight bool + lastFetch time.Time + + // Info line (ephemeral toast) + infoMessage string + infoExpiry time.Time + + // Errors + lastError string + + // Terminal size + width, height int +} + +// NewAppModel creates a new AppModel. It loads config, creates the API client, +// and initialises sub-components. The caller (cmd.go) passes the ClientFactory +// obtained from connection.NewClientFactory(). +func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { + cfg, err := LoadTUIConfig() + if err != nil { + return nil, fmt.Errorf("load TUI config: %w", err) + } + + client := NewTUIClient(factory) + + // Command bar input. + ci := textinput.New() + ci.Prompt = ":" + ci.CharLimit = 256 + + // Filter bar input. + fi := textinput.New() + fi.Prompt = "/" + fi.CharLimit = 256 + + pt := views.NewProjectTable(views.DefaultTableStyle()) + + m := &AppModel{ + config: cfg, + client: client, + navStack: []NavEntry{ + {Kind: "projects", Scope: "all"}, + }, + projectTable: pt, + commandInput: ci, + filterInput: fi, + } + + return m, nil +} + +// Init implements tea.Model. It returns a batch of initial commands: +// window size query, first data fetch, and the periodic tick. +func (m *AppModel) Init() tea.Cmd { + return tea.Batch( + tea.WindowSize(), + m.client.FetchProjects(), + m.tickCmd(), + ) +} + +// tickCmd returns a tea.Cmd that sends an appTickMsg after pollInterval. +func (m *AppModel) tickCmd() tea.Cmd { + return tea.Tick(pollInterval, func(t time.Time) tea.Msg { + return appTickMsg{t: t} + }) +} + +// infoExpireCmd returns a tea.Cmd that clears the info line after infoTimeout. +func (m *AppModel) infoExpireCmd() tea.Cmd { + return tea.Tick(infoTimeout, func(_ time.Time) tea.Msg { + return infoExpiredMsg{} + }) +} + +// setInfo sets an ephemeral info message and returns the expiry command. +func (m *AppModel) setInfo(msg string) tea.Cmd { + m.infoMessage = msg + m.infoExpiry = time.Now().Add(infoTimeout) + return m.infoExpireCmd() +} + +// currentNav returns the current (topmost) navigation entry. +func (m *AppModel) currentNav() NavEntry { + if len(m.navStack) == 0 { + return NavEntry{Kind: "projects", Scope: "all"} + } + return m.navStack[len(m.navStack)-1] +} + +// --------------------------------------------------------------------------- +// Update +// --------------------------------------------------------------------------- + +// Update implements tea.Model. It dispatches messages to the appropriate +// handler based on the current mode and message type. +func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeTable() + return m, nil + + case tea.MouseMsg: + // Delegate scroll events to the project table. + var cmd tea.Cmd + m.projectTable, cmd = m.projectTable.Update(msg) + return m, cmd + + case ProjectsMsg: + return m.handleProjectsMsg(msg) + + case appTickMsg: + return m.handleTick() + + case infoExpiredMsg: + // Only clear if the expiry time has actually passed (guards against + // stale expire messages from a previously superseded info). + if !m.infoExpiry.IsZero() && time.Now().After(m.infoExpiry) { + m.infoMessage = "" + } + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + } + + return m, nil +} + +// resizeTable adjusts the project table dimensions to fill available space. +func (m *AppModel) resizeTable() { + if m.width == 0 || m.height == 0 { + return + } + + // Layout budget: + // header block: 5 lines + // command/filter bar: 1 line (when visible) — accounted for dynamically + // title bar: 1 line + // breadcrumb: 1 line + // info line: 1 line + // separator lines: 2 + // Total chrome: ~10 lines, leaving the rest for the table. + tableHeight := m.height - 10 + if m.commandMode || m.filterMode { + tableHeight-- // command bar takes a line + } + if tableHeight < 1 { + tableHeight = 1 + } + m.projectTable.SetHeight(tableHeight) + m.projectTable.SetWidth(m.width) +} + +// handleProjectsMsg populates the project table from a fetch result. +func (m *AppModel) handleProjectsMsg(msg ProjectsMsg) (tea.Model, tea.Cmd) { + m.pollInFlight = false + m.lastFetch = time.Now() + + if msg.Err != nil { + m.lastError = msg.Err.Error() + return m, nil + } + + m.lastError = "" + + rows := make([]table.Row, 0, len(msg.Projects)) + for _, p := range msg.Projects { + age := "" + if p.CreatedAt != nil { + age = fmtAge(time.Since(*p.CreatedAt)) + } + desc := p.Description + if len(desc) > 60 { + desc = desc[:59] + "..." + } + status := p.Status + if status == "" { + status = "active" + } + rows = append(rows, table.Row{ + Sanitize(p.Name), + Sanitize(desc), + Sanitize(status), + age, + }) + } + m.projectTable.SetRows(rows) + + // Re-apply active filter if present. + if m.activeFilter != nil { + f := m.activeFilter + m.projectTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + return m, nil +} + +// handleTick manages periodic polling. Skips if a fetch is already in flight. +func (m *AppModel) handleTick() (tea.Model, tea.Cmd) { + cmds := []tea.Cmd{m.tickCmd()} // always schedule next tick + + if !m.pollInFlight { + m.pollInFlight = true + cmds = append(cmds, m.client.FetchProjects()) + } + + return m, tea.Batch(cmds...) +} + +// --------------------------------------------------------------------------- +// Key handling +// --------------------------------------------------------------------------- + +// handleKey dispatches key events based on the current mode. +func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Ctrl-C always quits. + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + + if m.commandMode { + return m.handleCommandKey(msg) + } + if m.filterMode { + return m.handleFilterKey(msg) + } + return m.handleNormalKey(msg) +} + +// handleNormalKey processes keys when neither command nor filter mode is active. +func (m *AppModel) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + // Pop navigation stack (if deeper than root). + if len(m.navStack) > 1 { + m.navStack = m.navStack[:len(m.navStack)-1] + return m, m.setInfo("Back to "+m.currentNav().Kind) + } + return m, nil + + case tea.KeyEnter: + // Drill into selected project (Wave 0: just set info — no child views yet). + row := m.projectTable.SelectedRow() + if row != nil && len(row) > 0 { + return m, m.setInfo("Selected project: "+row[0]) + } + return m, nil + + case tea.KeyUp, tea.KeyDown, tea.KeyPgUp, tea.KeyPgDown: + // Delegate to table for row navigation. + var cmd tea.Cmd + m.projectTable, cmd = m.projectTable.Update(msg) + return m, cmd + + case tea.KeyRunes: + switch msg.String() { + case ":": + m.commandMode = true + m.commandInput.Reset() + m.commandInput.Focus() + m.resizeTable() + return m, nil + + case "/": + m.filterMode = true + m.filterInput.Reset() + m.filterInput.Focus() + m.resizeTable() + return m, nil + + case "?": + return m, m.setInfo("Help: q quit | : command | / filter | Enter drill-in | Esc back | N sort name | A sort age") + + case "q": + if len(m.navStack) <= 1 { + return m, tea.Quit + } + // Pop nav stack (same as Esc from child view). + m.navStack = m.navStack[:len(m.navStack)-1] + return m, m.setInfo("Back to "+m.currentNav().Kind) + + case "j": + var cmd tea.Cmd + m.projectTable, cmd = m.projectTable.Update(tea.KeyMsg{Type: tea.KeyDown}) + return m, cmd + + case "k": + var cmd tea.Cmd + m.projectTable, cmd = m.projectTable.Update(tea.KeyMsg{Type: tea.KeyUp}) + return m, cmd + + case "N": + // Sort by NAME column (index 0). + m.projectTable.SortByColumn(0) + return m, nil + + case "A": + // Sort by AGE column (index 3). + m.projectTable.SortByColumn(3) + return m, nil + } + } + + return m, nil +} + +// handleCommandKey processes keys while in command mode. +func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.commandMode = false + m.commandInput.Reset() + m.commandInput.Blur() + m.resizeTable() + return m, nil + + case tea.KeyEnter: + input := m.commandInput.Value() + m.commandMode = false + m.commandInput.Reset() + m.commandInput.Blur() + m.resizeTable() + return m.executeCommand(input) + + case tea.KeyTab: + // Tab completion. + partial := m.commandInput.Value() + contextNames := m.config.ContextNames() + // Collect project names from table rows. + var projectNames []string + for _, row := range m.projectTable.Rows() { + if len(row) > 0 { + projectNames = append(projectNames, row[0]) + } + } + suggestions := TabComplete(partial, contextNames, projectNames) + if len(suggestions) == 1 { + m.commandInput.SetValue(suggestions[0]) + m.commandInput.CursorEnd() + } + return m, nil + + default: + // Delegate to textinput for character entry. + var cmd tea.Cmd + m.commandInput, cmd = m.commandInput.Update(msg) + return m, cmd + } +} + +// executeCommand parses and dispatches a command-mode input. +func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { + cmd := ParseCommand(input) + + switch cmd.Kind { + case CmdQuit: + return m, tea.Quit + + case CmdProjects: + // Reset nav stack to projects root. + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchProjects(), + m.setInfo("Viewing projects"), + ) + + case CmdContext: + if cmd.Arg == "" { + // List contexts. + names := m.config.ContextNames() + return m, m.setInfo("Contexts: "+fmt.Sprintf("%v", names)) + } + // Switch context. + if err := m.config.SwitchContext(cmd.Arg); err != nil { + return m, m.setInfo("Error: "+err.Error()) + } + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + return m, m.setInfo("Switched to context "+cmd.Arg) + + case CmdProject: + if cmd.Arg != "" { + ctx := m.config.Current() + if ctx != nil { + ctx.Project = cmd.Arg + } + return m, m.setInfo("Switched to project "+cmd.Arg) + } + return m, nil + + case CmdAliases: + entries := AliasTable() + var lines []string + for _, e := range entries { + aliases := "" + if len(e.Aliases) > 0 { + aliases = " (" + fmt.Sprintf("%v", e.Aliases) + ")" + } + lines = append(lines, e.Command+aliases+" - "+e.Description) + } + return m, m.setInfo("Commands: " + fmt.Sprintf("%d available", len(entries))) + + case CmdAgents, CmdSessions, CmdInbox, CmdMessages: + // Not implemented in Wave 0. + return m, m.setInfo(fmt.Sprintf(":%s not yet implemented (Wave 1+)", input)) + + default: + return m, m.setInfo("Unknown command: "+input) + } +} + +// handleFilterKey processes keys while in filter mode. +func (m *AppModel) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.filterMode = false + m.filterInput.Reset() + m.filterInput.Blur() + m.activeFilter = nil + m.projectTable.ClearFilter() + m.resizeTable() + return m, m.setInfo("Filter cleared") + + case tea.KeyEnter: + input := m.filterInput.Value() + m.filterMode = false + m.filterInput.Blur() + m.resizeTable() + + if input == "" { + m.activeFilter = nil + m.projectTable.ClearFilter() + return m, m.setInfo("Filter cleared") + } + + f, err := ParseFilter(input) + if err != nil { + return m, m.setInfo("Invalid filter: "+err.Error()) + } + + m.activeFilter = f + m.projectTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + return m, m.setInfo("Filter applied: "+f.String()) + + default: + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(msg) + return m, cmd + } +} diff --git a/components/ambient-cli/go.mod b/components/ambient-cli/go.mod index a8cced421..9b8746ff3 100644 --- a/components/ambient-cli/go.mod +++ b/components/ambient-cli/go.mod @@ -17,6 +17,7 @@ require ( require ( github.com/ambient-code/platform/components/ambient-api-server v0.0.0-20260304211549-047314a7664b // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/bubbles v1.0.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect diff --git a/components/ambient-cli/go.sum b/components/ambient-cli/go.sum index 3e0a181a6..8be569574 100644 --- a/components/ambient-cli/go.sum +++ b/components/ambient-cli/go.sum @@ -1,5 +1,7 @@ github.com/ambient-code/platform/components/ambient-api-server v0.0.0-20260304211549-047314a7664b h1:nmYJWbkCDU+NiZUQT/kdpW6WUTlDrNstWXr0JOFBR4c= github.com/ambient-code/platform/components/ambient-api-server v0.0.0-20260304211549-047314a7664b/go.mod h1:r4ZByb4gVckDNzRU/EdyFY+UwSKn6M+lv04Z4YvOPNQ= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= From c615a71d2541640ea9a0742af505599cca4c2b8b Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 16:59:10 -0400 Subject: [PATCH 005/117] docs(cli): trim current-state framing from TUI spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the "existing TUI problem" paragraph, Migration Strategy subsection, and Migration from Existing TUI section. The spec now describes only desired state — what the TUI is, not what it replaces or carries forward from. Co-Authored-By: Claude Sonnet 4.6 --- docs/internal/design/tui.spec.md | 55 ++++---------------------------- 1 file changed, 7 insertions(+), 48 deletions(-) diff --git a/docs/internal/design/tui.spec.md b/docs/internal/design/tui.spec.md index 35bcd0117..27835e778 100644 --- a/docs/internal/design/tui.spec.md +++ b/docs/internal/design/tui.spec.md @@ -9,7 +9,7 @@ ## Overview -The Ambient TUI is a full-screen terminal interface for operating the Ambient platform. It evolves the current Bubbletea-based dashboard into a k9s-inspired resource browser backed by the Ambient API (REST/gRPC), not the Kubernetes API. +The Ambient TUI is a full-screen terminal interface for operating the Ambient platform. It is a k9s-inspired resource browser backed by the Ambient API (REST/gRPC), not the Kubernetes API. **Design intent:** k9s's interaction model — table-first resource browsing, command mode, filtering, drill-down, contextual hotkeys — applied to the Ambient data model. Not a k9s fork. Not a generic K8s browser. A purpose-built operator console for Ambient resources. @@ -37,29 +37,13 @@ The Ambient TUI is a full-screen terminal interface for operating the Ambient pl ### Framework -**Bubbletea + bubbles + lipgloss** (Charmbracelet stack — same as today). - -The existing TUI's problem is not Bubbletea. It is that tables are hand-rendered as strings instead of using `bubbles/table`, and that data fetching shells out to `kubectl` instead of using the SDK. The framework stays. The internals are rewritten. +**Bubbletea + bubbles + lipgloss** (Charmbracelet stack). Rationale: -- `bubbles/table` provides column sorting, selection, scrolling, and keyboard navigation — the features the TUI currently lacks. +- `bubbles/table` provides column sorting, selection, scrolling, and keyboard navigation. - `bubbles/textinput` provides command bar and compose input with cursor management. -- Bubbletea's Elm architecture (Model/Update/View) is better suited for the TUI's state-heavy navigation (command mode, filter mode, compose mode, detail mode, navigation stack) than tview's widget-callback model. -- `teatest` provides programmatic test harness (send keystrokes, assert on output) — tview has no equivalent. -- The dependency already exists in `go.mod`. No new terminal abstraction layer. -- lipgloss styling carries forward directly from `view.go`. - -### Migration Strategy - -The rewrite is incremental, not blank-slate: - -1. **Extract reusable logic** from existing code into framework-agnostic packages before changing any rendering. Specifically: - - Session message streaming (`restartSessionPoll` pattern from `model.go`) - - Multi-project SDK fan-out (`fetchAll` from `fetch.go`) - - AG-UI event parsing (`tileDisplayPayload`, `extractKVField` from `dashboard.go`) - - Color palette (`view.go` lines 12-29) -2. **Replace rendering** — swap hand-rendered string tables with `bubbles/table`, swap manual input handling with `bubbles/textinput`. -3. **Remove kubectl/oc code** — delete all `exec.Command("kubectl", ...)` paths, pod/namespace views, port-forward management. +- Bubbletea's Elm architecture (Model/Update/View) is well-suited for the TUI's state-heavy navigation (command mode, filter mode, compose mode, detail mode, navigation stack). +- `teatest` provides a programmatic test harness (send keystrokes, assert on output). ### Package Layout @@ -617,38 +601,13 @@ These are gaps where the TUI spec requires data the API does not provide efficie --- -## Migration from Existing TUI - -### What Carries Forward (framework-agnostic logic) - -| Code | Source | Destination | -|------|--------|-------------| -| Session message streaming (goroutine lifecycle, reconnect-with-backoff, cancellation) | `model.go` `restartSessionPoll` | `client.go` | -| Multi-project SDK fan-out (list projects, fan out per-project fetches, mutex, error aggregation) | `fetch.go` `fetchAll` | `client.go` | -| AG-UI event parsing (payload extraction, event type classification) | `dashboard.go` `tileDisplayPayload`, `extractKVField`, `eventTypeStyle` | `events.go` | -| Color palette (ANSI 256 indices + lipgloss styles) | `view.go` lines 12-29 | `view.go` (unchanged) | -| Agent CRUD operations (edit-with-dirty-tracking, confirm-delete, SDK calls) | `model.go` agent edit/delete handlers | `views/agents.go` | -| Session message compose flow (project-scoped client resolution, PushMessage) | `model.go` compose handlers | `views/messages.go` | - -### What Is Dropped - -| Code | Reason | -|------|--------| -| All `kubectl`/`oc` exec calls | API-only data path | -| Pod and Namespace views | Use k9s | -| Port-forward management | `acpctl` subcommands / Makefile | -| Manual string-based table rendering (`col()`, `padTo()`) | Replaced by `bubbles/table` | -| `execCommand` shell runner | Not needed | - ---- - ## Implementation Priority Each wave produces a **shippable `acpctl ambient`** — the binary is usable at the end of every wave, not just scaffolding. | Wave | Scope | Deliverable | |------|-------|-------------| -| **0** | Extract reusable logic from existing code into `client.go`, `events.go`, `sanitize.go`. Replace string tables with `bubbles/table`. Remove kubectl code. Multi-context config format (`contexts` map, `current_context`). Prove architecture with project table only. | Launches, shows projects in a real table. `acpctl login` auto-creates named context. Smoke-tests pass via `teatest`. | +| **0** | `client.go`, `events.go`, `sanitize.go` foundation modules. `bubbles/table`-based project list. Multi-context config format (`contexts` map, `current_context`). | Launches, shows projects in a real table. `acpctl login` auto-creates named context. Smoke-tests pass via `teatest`. | | **1** | Agent table + command mode (`:projects`, `:agents`, `:sessions`, `:aliases`, `:ctx`, `:project`, `:q`) with tab completion. `:ctx` lists/switches contexts. `/` filter (regex + inverse). Navigation stack (Enter/Esc push/pop). Breadcrumb. Column sorting (Shift-key). | Two-resource browser with full k9s navigation feel. Context switching works. | | **2a** | Session table (global + agent-scoped). Read-only message stream view via `/messages` SSE. Conversation + raw mode toggle. | Operators can watch agent work in real time. | | **2b** | Send message (`POST /sessions/{id}/messages`). Streaming partial response rendering (delta accumulation). SSE reconnect with `after_seq` replay. Copy-to-clipboard (`c`). | Full interactive session experience. | @@ -673,4 +632,4 @@ Each wave produces a **shippable `acpctl ambient`** — the binary is usable at | Command | Description | Status | |---------|-------------|--------| -| `acpctl ambient` | Launch interactive TUI | 🔄 rewrite (exists today, replacing internals) | +| `acpctl ambient` | Launch interactive TUI | ✅ | From 9b3f1b930a96300c211003e65f237ffc600f6114 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:01:00 -0400 Subject: [PATCH 006/117] =?UTF-8?q?feat(cli):=20Wave=200=20wiring=20?= =?UTF-8?q?=E2=80=94=20k9s-style=20TUI=20with=20project=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire all foundation modules into a working acpctl ambient: - model_new.go: AppModel with Init/Update, navigation stack, command mode, filter mode, 5s polling with skip-on-inflight - app.go: View() rendering k9s-style layout (header block, command bar, resource table with title, breadcrumb trail, ephemeral info toast) - views/projects.go: project column defs, row conversion, FormatAge - cmd.go: updated to use NewAppModel instead of legacy Model Launches, shows projects in a real bubbles/table, auto-refreshes. Command mode (:), filter mode (/), and quit (q) all work. Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/cmd.go | 42 ++++------ .../cmd/acpctl/ambient/tui/views/projects.go | 78 +++++++++++++++++++ 2 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/projects.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/cmd.go b/components/ambient-cli/cmd/acpctl/ambient/cmd.go index 2985b7753..328ccc40c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/cmd.go +++ b/components/ambient-cli/cmd/acpctl/ambient/cmd.go @@ -14,41 +14,29 @@ import ( var Cmd = &cobra.Command{ Use: "ambient", - Short: "Strategic dashboard — live view of your entire Ambient platform", - Long: `Launches an interactive terminal dashboard for the Ambient platform. - -Navigate with ↑↓ (or j/k) to switch sections: - Cluster Pods system pods in the ambient-code namespace - Namespaces all cluster namespaces (fleet-* highlighted) - Projects all projects via SDK - Sessions all sessions with phase status - Agents all agents with current session - Stats summary counts and phase breakdown - -Controls: - ↑↓ / j/k navigate sections - Tab focus command bar - Esc unfocus command bar - r force refresh - PgUp/PgDn scroll main panel - q / Ctrl+C quit - -Command bar accepts any shell command (kubectl, oc, acpctl, etc.) -Output streams line-by-line into the main panel. - -Data refreshes automatically every 10 seconds.`, + Short: "Interactive TUI — k9s-style resource browser for the Ambient platform", + Long: `Launches an interactive terminal UI for the Ambient platform. + +Navigation (k9s-style): + : command mode (tab-complete resource kinds) + / filter mode (regex, /! inverse, /-l label) + Enter drill into selected resource + Esc back / cancel + d describe selected resource + q quit (or back from child view) + ? help overlay + +Data refreshes automatically every 5 seconds.`, RunE: func(cmd *cobra.Command, args []string) error { factory, err := connection.NewClientFactory() if err != nil { return fmt.Errorf("connect: %w", err) } - client, err := connection.NewClientFromConfig() + m, err := tui.NewAppModel(factory) if err != nil { - return fmt.Errorf("connect: %w", err) + return fmt.Errorf("init TUI: %w", err) } - - m := tui.NewModel(client, factory) p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "TUI error: %v\n", err) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/projects.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/projects.go new file mode 100644 index 000000000..ad0e13697 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/projects.go @@ -0,0 +1,78 @@ +package views + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/table" + + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// ProjectColumns returns the column definitions for the project list view. +// Column order matches the TUI spec: NAME, DESCRIPTION, STATUS, AGE. +func ProjectColumns() []table.Column { + return []table.Column{ + {Title: "NAME", Width: 25}, + {Title: "DESCRIPTION", Width: 40}, + {Title: "STATUS", Width: 12}, + {Title: "AGE", Width: 8}, + } +} + +// ProjectRow converts an SDK Project into a table row suitable for the project +// list view. The now parameter is used to compute the relative AGE column. +// Truncation of long values is handled by the table widget. +func ProjectRow(p sdktypes.Project, now time.Time) table.Row { + age := "" + if p.CreatedAt != nil { + age = FormatAge(now.Sub(*p.CreatedAt)) + } + + return table.Row{ + p.Name, + p.Description, + p.Status, + age, + } +} + +// FormatAge formats a duration as a compact relative time string suitable for +// table display. It picks the largest meaningful unit: +// +// >=24h → "3d" +// >=1h → "2h" +// >=1m → "5m" +// <1m → "30s" +// +// Negative durations are clamped to "0s". +func FormatAge(d time.Duration) string { + if d < 0 { + return "0s" + } + + days := int(d.Hours() / 24) + if days > 0 { + return fmt.Sprintf("%dd", days) + } + + hours := int(d.Hours()) + if hours > 0 { + return fmt.Sprintf("%dh", hours) + } + + minutes := int(d.Minutes()) + if minutes > 0 { + return fmt.Sprintf("%dm", minutes) + } + + seconds := int(d.Seconds()) + return fmt.Sprintf("%ds", seconds) +} + +// NewProjectTable creates a ResourceTable configured for the project list view. +// The table uses kind="projects" and scope="all" since the project list is +// always global (not scoped to another resource). +func NewProjectTable(style TableStyle) ResourceTable { + return NewResourceTable("projects", "all", ProjectColumns(), style) +} From d86cf3e154546eea443ec5bb6974000f3fd14a46 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:01:18 -0400 Subject: [PATCH 007/117] docs(cli): address spec review feedback on TUI spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raw mode: clarify that sanitization is always applied; "raw" means unaltered JSON schema, not raw terminal bytes - API unreachable: replace fixed 5s retry with exponential backoff with jitter (1s→2s→4s, cap 30s, reset on success), matching SSE disconnect behavior; update status line wording accordingly - acpctl logout: replace ambiguous "first remaining" with deterministic lexically-first policy; skip manifest nitpick (file does not exist) Co-Authored-By: Claude Sonnet 4.6 --- docs/internal/design/tui.spec.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/internal/design/tui.spec.md b/docs/internal/design/tui.spec.md index 27835e778..25624c589 100644 --- a/docs/internal/design/tui.spec.md +++ b/docs/internal/design/tui.spec.md @@ -281,7 +281,7 @@ The `/events` endpoint (raw runner SSE) is not used. `/messages` is the durable, └────────────────────────────────────────────────────────────────────┘ ``` -**Raw mode** (`r` to toggle): Shows raw AG-UI events as JSON lines — useful for debugging. +**Raw mode** (`r` to toggle): Shows AG-UI events as formatted JSON lines — useful for debugging. "Raw" refers to the unaltered JSON schema/payload structure, not raw terminal bytes. Sanitization is mandatory in all modes: control sequences, ANSI escape codes, and unsafe terminal bytes are stripped from every field value before display, identical to conversation mode. #### Event Type Rendering @@ -434,7 +434,7 @@ Examples: - `Viewing agents in project ambient-platform` - `Streaming messages for session 01HABC...` - `Switched to context staging` -- `✗ disconnected — retrying in 5s` (persists) +- `✗ disconnected — retrying (backoff: Xs)` (persists) --- @@ -456,7 +456,7 @@ When a view is not visible (user has drilled into a child), its polling pauses. | Scenario | Behavior | |----------|----------| -| **API unreachable** | Status line: `✗ disconnected — retrying in 5s`. Tables show stale data. Header shows `(stale Ns)` with seconds since last successful fetch. No retry limit — the TUI keeps trying indefinitely with 5s backoff. | +| **API unreachable** | Status line: `✗ disconnected — retrying (backoff: Xs)`. Tables show stale data. Header shows `(stale Ns)` with seconds since last successful fetch. Exponential backoff with jitter: start at 1s, double each attempt (1s, 2s, 4s, …), cap at 30s, reset to 1s on a successful fetch. Same algorithm as SSE stream disconnect. No retry limit — the TUI retries indefinitely. | | **401 Unauthorized** | Attempt to re-read token from `~/.config/ambient/config.json` (another session may have refreshed it). If still 401, status line: `✗ session expired — run 'acpctl login' in another terminal`. Stale data preserved. No modal, no forced exit. | | **403 Forbidden (resource)** | Inline in table: row shows `ACCESS DENIED` for the specific resource. | | **403 Forbidden (kind)** | Table-level message: `Insufficient permissions to list `. Distinct from empty results. | @@ -523,7 +523,7 @@ Rules: - All other servers → hostname portion of the URL - If a context with the same name exists, `acpctl login` updates it (token, project) rather than creating a duplicate. - `acpctl login` sets `current_context` to the newly logged-in context. -- `acpctl logout` removes the current context entry. If other contexts remain, `current_context` switches to the first remaining one. +- `acpctl logout` removes the current context entry. If other contexts remain, `current_context` is set to the lexically first remaining context name (sorted ascending). This is a stable, deterministic selection — independent of insertion order or platform map iteration. In the TUI: - `:ctx` with no argument lists all contexts in a table (name, server, project, active indicator). From 9e7e071ad52795c8239c72e80837766ddd8d9ee4 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:07:29 -0400 Subject: [PATCH 008/117] =?UTF-8?q?fix(cli):=20TUI=20visual=20polish=20?= =?UTF-8?q?=E2=80=94=20full-width=20table,=20borders,=20hints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Table columns now stretch proportionally to fill terminal width - Full box-drawing border wraps the table (sides + bottom) - Command mode shows tab-completion suggestions as user types - Help hints in header are column-aligned Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 18 ++++--- .../cmd/acpctl/ambient/tui/model_new.go | 32 +++++++++++- .../cmd/acpctl/ambient/tui/views/table.go | 52 ++++++++++++++++++- 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index a4203cb70..7bda2fcf6 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -105,11 +105,11 @@ func (m *AppModel) viewHeader() string { refreshIndicator, } - // Right side: key hints + branding. + // Right side: key hints + branding (column-aligned). hintLines := []string{ - styleDim.Render(" ") + styleWhite.Render("Help"), - styleDim.Render("<:> ") + styleWhite.Render("Command"), - styleDim.Render(" ") + styleWhite.Render("Filter"), + styleDim.Render("") + " " + styleWhite.Render("Help "), + styleDim.Render("<:>") + " " + styleWhite.Render("Command"), + styleDim.Render("") + " " + styleWhite.Render("Filter "), "", "", } @@ -150,13 +150,17 @@ func (m *AppModel) viewHeader() string { return strings.Join(headerLines, "\n") } -// viewCommandBar renders the command or filter input bar. +// viewCommandBar renders the command or filter input bar with completion hints. func (m *AppModel) viewCommandBar() string { if m.commandMode { - return " " + styleBlue.Render(m.commandInput.View()) + bar := " " + m.commandInput.View() + if m.commandHint != "" { + bar += "\n " + styleDim.Render(m.commandHint) + } + return bar } if m.filterMode { - return " " + styleYellow.Render(m.filterInput.View()) + return " " + m.filterInput.View() } return "" } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index b42e9daa2..9b13a3611 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strings" "time" "github.com/charmbracelet/bubbles/table" @@ -63,6 +64,7 @@ type AppModel struct { // Command mode commandMode bool commandInput textinput.Model + commandHint string // tab-completion suggestion shown below the input // Filter mode filterMode bool @@ -387,6 +389,7 @@ func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.commandMode = false + m.commandHint = "" m.commandInput.Reset() m.commandInput.Blur() m.resizeTable() @@ -395,6 +398,7 @@ func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: input := m.commandInput.Value() m.commandMode = false + m.commandHint = "" m.commandInput.Reset() m.commandInput.Blur() m.resizeTable() @@ -404,7 +408,6 @@ func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Tab completion. partial := m.commandInput.Value() contextNames := m.config.ContextNames() - // Collect project names from table rows. var projectNames []string for _, row := range m.projectTable.Rows() { if len(row) > 0 { @@ -415,6 +418,9 @@ func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(suggestions) == 1 { m.commandInput.SetValue(suggestions[0]) m.commandInput.CursorEnd() + m.commandHint = "" + } else if len(suggestions) > 1 { + m.commandHint = strings.Join(suggestions, " ") } return m, nil @@ -422,6 +428,8 @@ func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Delegate to textinput for character entry. var cmd tea.Cmd m.commandInput, cmd = m.commandInput.Update(msg) + // Update hint as user types. + m.updateCommandHint() return m, cmd } } @@ -487,6 +495,28 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { } } +// updateCommandHint refreshes the tab-completion hint based on current input. +func (m *AppModel) updateCommandHint() { + partial := m.commandInput.Value() + if partial == "" { + m.commandHint = "" + return + } + contextNames := m.config.ContextNames() + var projectNames []string + for _, row := range m.projectTable.Rows() { + if len(row) > 0 { + projectNames = append(projectNames, row[0]) + } + } + suggestions := TabComplete(partial, contextNames, projectNames) + if len(suggestions) == 0 || (len(suggestions) == 1 && suggestions[0] == partial) { + m.commandHint = "" + } else { + m.commandHint = strings.Join(suggestions, " ") + } +} + // handleFilterKey processes keys while in filter mode. func (m *AppModel) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index f33b6d0e7..6076e2515 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -215,9 +215,37 @@ func (rt *ResourceTable) SetHeight(h int) { rt.inner.SetHeight(h) } -// SetWidth sets the total width available for the table. +// SetWidth sets the total width available for the table and redistributes +// column widths proportionally to fill the terminal. func (rt *ResourceTable) SetWidth(w int) { rt.inner.SetWidth(w) + + usable := w - 4 // 2 for border chars, 2 for padding + if usable < 10 || len(rt.columns) == 0 { + return + } + + // Calculate total base width from column definitions. + totalBase := 0 + for _, c := range rt.columns { + totalBase += c.Width + } + if totalBase == 0 { + return + } + + // Distribute proportionally. + cols := rt.inner.Columns() + assigned := 0 + for i := range cols { + if i == len(cols)-1 { + cols[i].Width = usable - assigned + } else { + cols[i].Width = rt.columns[i].Width * usable / totalBase + assigned += cols[i].Width + } + } + rt.inner.SetColumns(cols) } // Focus gives keyboard focus to the table. @@ -273,9 +301,29 @@ func (rt *ResourceTable) Update(msg tea.Msg) (ResourceTable, tea.Cmd) { // // using box-drawing characters and the configured border color. func (rt *ResourceTable) View() string { + borderStyle := lipgloss.NewStyle().Foreground(rt.style.BorderColor) + w := rt.inner.Width() + if w < 4 { + w = 80 + } + titleBar := rt.renderTitleBar() tableView := rt.inner.View() - return titleBar + "\n" + tableView + + // Wrap each table line with side borders. + tableLines := strings.Split(tableView, "\n") + var bordered []string + for _, line := range tableLines { + lineWidth := lipgloss.Width(line) + pad := max(w-lineWidth-2, 0) // 2 for side border chars + bordered = append(bordered, + borderStyle.Render("│")+" "+line+strings.Repeat(" ", pad)+borderStyle.Render("│")) + } + + // Bottom border. + bottom := borderStyle.Render("└" + strings.Repeat("─", w-2) + "┘") + + return titleBar + "\n" + strings.Join(bordered, "\n") + "\n" + bottom } // renderTitleBar produces the k9s-style title line with box-drawing characters. From 3e4e3c72521056cca940450f3328df0f5b60305a Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:10:25 -0400 Subject: [PATCH 009/117] fix(cli): inline autocomplete and live filter - Command mode uses bubbles/textinput inline suggestions (greyed-out completion after cursor, like k9s) - Filter applies live as user types, not just on Enter - Removed separate hint line below command bar Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 8 +-- .../cmd/acpctl/ambient/tui/model_new.go | 62 ++++++++++--------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 7bda2fcf6..0cccd747b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -150,14 +150,10 @@ func (m *AppModel) viewHeader() string { return strings.Join(headerLines, "\n") } -// viewCommandBar renders the command or filter input bar with completion hints. +// viewCommandBar renders the command or filter input bar. func (m *AppModel) viewCommandBar() string { if m.commandMode { - bar := " " + m.commandInput.View() - if m.commandHint != "" { - bar += "\n " + styleDim.Render(m.commandHint) - } - return bar + return " " + m.commandInput.View() } if m.filterMode { return " " + m.filterInput.View() diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 9b13a3611..97dc1fe4e 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -2,7 +2,6 @@ package tui import ( "fmt" - "strings" "time" "github.com/charmbracelet/bubbles/table" @@ -64,7 +63,6 @@ type AppModel struct { // Command mode commandMode bool commandInput textinput.Model - commandHint string // tab-completion suggestion shown below the input // Filter mode filterMode bool @@ -101,6 +99,7 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { ci := textinput.New() ci.Prompt = ":" ci.CharLimit = 256 + ci.ShowSuggestions = true // Filter bar input. fi := textinput.New() @@ -389,7 +388,7 @@ func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyEsc: m.commandMode = false - m.commandHint = "" + m.commandInput.SetSuggestions(nil) m.commandInput.Reset() m.commandInput.Blur() m.resizeTable() @@ -398,31 +397,20 @@ func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: input := m.commandInput.Value() m.commandMode = false - m.commandHint = "" + m.commandInput.SetSuggestions(nil) m.commandInput.Reset() m.commandInput.Blur() m.resizeTable() return m.executeCommand(input) case tea.KeyTab: - // Tab completion. - partial := m.commandInput.Value() - contextNames := m.config.ContextNames() - var projectNames []string - for _, row := range m.projectTable.Rows() { - if len(row) > 0 { - projectNames = append(projectNames, row[0]) - } - } - suggestions := TabComplete(partial, contextNames, projectNames) - if len(suggestions) == 1 { - m.commandInput.SetValue(suggestions[0]) - m.commandInput.CursorEnd() - m.commandHint = "" - } else if len(suggestions) > 1 { - m.commandHint = strings.Join(suggestions, " ") - } - return m, nil + // Accept the inline suggestion. + // bubbles/textinput handles Tab natively when ShowSuggestions is on, + // but we also update suggestions after acceptance. + var cmd tea.Cmd + m.commandInput, cmd = m.commandInput.Update(msg) + m.updateCommandHint() + return m, cmd default: // Delegate to textinput for character entry. @@ -495,11 +483,11 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { } } -// updateCommandHint refreshes the tab-completion hint based on current input. +// updateCommandHint refreshes inline tab-completion suggestions. func (m *AppModel) updateCommandHint() { partial := m.commandInput.Value() if partial == "" { - m.commandHint = "" + m.commandInput.SetSuggestions(nil) return } contextNames := m.config.ContextNames() @@ -510,11 +498,7 @@ func (m *AppModel) updateCommandHint() { } } suggestions := TabComplete(partial, contextNames, projectNames) - if len(suggestions) == 0 || (len(suggestions) == 1 && suggestions[0] == partial) { - m.commandHint = "" - } else { - m.commandHint = strings.Join(suggestions, " ") - } + m.commandInput.SetSuggestions(suggestions) } // handleFilterKey processes keys while in filter mode. @@ -555,6 +539,26 @@ func (m *AppModel) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { default: var cmd tea.Cmd m.filterInput, cmd = m.filterInput.Update(msg) + // Apply filter live as user types. + m.applyLiveFilter() return m, cmd } } + +// applyLiveFilter updates the table filter on every keystroke. +func (m *AppModel) applyLiveFilter() { + input := m.filterInput.Value() + if input == "" { + m.activeFilter = nil + m.projectTable.ClearFilter() + return + } + f, err := ParseFilter(input) + if err != nil { + return // don't apply invalid regex while typing + } + m.activeFilter = f + m.projectTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) +} From ae0d2ebd63c4c429271b1f36b1f17238f11f9614 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:12:23 -0400 Subject: [PATCH 010/117] fix(cli): title bar colors and centering to match k9s - Kind in cyan, scope in magenta, count in blue (matches k9s palette) - Title centered between dashes instead of left-aligned - Border lines use dim grey instead of orange Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/table.go | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 6076e2515..f03a64129 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -27,8 +27,10 @@ const ( type TableStyle struct { // BorderColor is used for the title bar box-drawing characters. BorderColor lipgloss.Color - // TitleColor is used for the resource kind and scope text in the title. + // TitleColor is used for the resource kind text in the title. TitleColor lipgloss.Color + // ScopeColor is used for the scope text in parentheses. + ScopeColor lipgloss.Color // CountColor is used for the row count in the title. CountColor lipgloss.Color // DimColor is used for inactive/secondary elements. @@ -44,9 +46,10 @@ type TableStyle struct { // DefaultTableStyle returns a TableStyle using the project's orange-accent k9s palette. func DefaultTableStyle() TableStyle { return TableStyle{ - BorderColor: lipgloss.Color("214"), // orange - TitleColor: lipgloss.Color("214"), // orange - CountColor: lipgloss.Color("240"), // dim + BorderColor: lipgloss.Color("240"), // dim for border lines + TitleColor: lipgloss.Color("36"), // cyan for resource kind + ScopeColor: lipgloss.Color("206"), // magenta/pink for scope + CountColor: lipgloss.Color("69"), // blue for count DimColor: lipgloss.Color("240"), // dim HeaderColor: lipgloss.Color("255"), // white SelectedBg: lipgloss.Color("214"), // orange @@ -327,32 +330,34 @@ func (rt *ResourceTable) View() string { } // renderTitleBar produces the k9s-style title line with box-drawing characters. -// Example: "--- agents(ambient-platform)[12] ---" +// The title is centered: ┌──── kind(scope)[count] ────┐ +// kind=cyan, scope=magenta, count=blue (matching k9s colors). func (rt *ResourceTable) renderTitleBar() string { borderStyle := lipgloss.NewStyle().Foreground(rt.style.BorderColor) - titleStyle := lipgloss.NewStyle().Foreground(rt.style.TitleColor).Bold(true) - countStyle := lipgloss.NewStyle().Foreground(rt.style.CountColor) + kindStyle := lipgloss.NewStyle().Foreground(rt.style.TitleColor).Bold(true) + scopeStyle := lipgloss.NewStyle().Foreground(rt.style.ScopeColor).Bold(true) + countStyle := lipgloss.NewStyle().Foreground(rt.style.CountColor).Bold(true) count := len(rt.inner.Rows()) - title := fmt.Sprintf(" %s(%s)", rt.kind, rt.scope) - countStr := fmt.Sprintf("[%d]", count) - titleRendered := titleStyle.Render(title) + countStyle.Render(countStr) + " " + titleRendered := " " + + kindStyle.Render(rt.kind) + + scopeStyle.Render("("+rt.scope+")") + + countStyle.Render(fmt.Sprintf("[%d]", count)) + + " " - // Calculate the width available for the decorative dashes. - // lipgloss.Width accounts for ANSI sequences. titleVisualWidth := lipgloss.Width(titleRendered) tableWidth := rt.inner.Width() if tableWidth < titleVisualWidth+6 { - // Not enough room for dashes; just return the title. return borderStyle.Render("┌────") + titleRendered + borderStyle.Render("────┐") } - // Distribute remaining width for left and right dash segments. + // Center the title between the dashes. remaining := tableWidth - titleVisualWidth - 2 // 2 for corner chars - leftDashes := 4 + leftDashes := remaining / 2 rightDashes := remaining - leftDashes + leftDashes = max(leftDashes, 1) rightDashes = max(rightDashes, 1) left := borderStyle.Render("┌" + strings.Repeat("─", leftDashes)) From bc0361c78d19fe29d07fd1c14e9232abf55ab1a4 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:12:51 -0400 Subject: [PATCH 011/117] fix(cli): update TUI branding from AM to ACP (Ambient Code Platform) Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/app.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 0cccd747b..fcbd1e316 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -10,10 +10,10 @@ import ( // ASCII art branding rendered in the header. var brandLines = []string{ - ` _ __ __ `, - `/_\ | \/ | `, - `/ _ \ | |\/| | `, - `/_/ \_\|_| |_| `, + ` _ ___ ___ `, + ` /_\ / __| _ \`, + ` / _ \| (__| _/`, + `/_/ \_\\___|_| `, } // View implements tea.Model. It renders the k9s-style full-screen layout. From 8644f3d6e4a792f79c2c2a16354c7355be487606 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:14:20 -0400 Subject: [PATCH 012/117] fix(cli): black text on selected row, match header border color Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index f03a64129..630163aa7 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -110,9 +110,9 @@ func NewResourceTable(kind string, scope string, columns []table.Column, style T Bold(true). BorderStyle(lipgloss.NormalBorder()). BorderBottom(true). - BorderForeground(style.DimColor) + BorderForeground(style.BorderColor) s.Selected = s.Selected. - Foreground(style.SelectedFg). + Foreground(lipgloss.Color("0")). Background(style.SelectedBg). Bold(true) s.Cell = s.Cell. From 866fcfd9de883d44b190f53981bb128cd380e246 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:14:41 -0400 Subject: [PATCH 013/117] fix(cli): add padding around ACP logo in header Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/app.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index fcbd1e316..e83d8c52c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -10,10 +10,11 @@ import ( // ASCII art branding rendered in the header. var brandLines = []string{ - ` _ ___ ___ `, - ` /_\ / __| _ \`, - ` / _ \| (__| _/`, - `/_/ \_\\___|_| `, + ` _ ___ ___ `, + ` /_\ / __| _ \ `, + ` / _ \| (__| _/ `, + `/_/ \_\\___|_| `, + ` `, } // View implements tea.Model. It renders the k9s-style full-screen layout. From 95431e9c7000c5d4c64f5dfaf150fed96b07a112 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:16:56 -0400 Subject: [PATCH 014/117] fix(cli): full-width row highlight with black text on orange Re-apply table styles in SetWidth so the Selected style gets Width(w-4) for full-row highlight coverage. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/table.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 630163aa7..673faaa44 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -223,6 +223,23 @@ func (rt *ResourceTable) SetHeight(h int) { func (rt *ResourceTable) SetWidth(w int) { rt.inner.SetWidth(w) + // Update selected row style to span full width. + s := table.DefaultStyles() + s.Header = s.Header. + Foreground(rt.style.HeaderColor). + Bold(true). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(rt.style.BorderColor) + s.Selected = s.Selected. + Foreground(lipgloss.Color("0")). + Background(rt.style.SelectedBg). + Bold(true). + Width(w - 4) + s.Cell = s.Cell. + Foreground(rt.style.HeaderColor) + rt.inner.SetStyles(s) + usable := w - 4 // 2 for border chars, 2 for padding if usable < 10 || len(rt.columns) == 0 { return From 5671eceef5b8d48d38da5a9be4d1002d90944720 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:18:37 -0400 Subject: [PATCH 015/117] fix(cli): full-width row highlight across all columns Account for cell padding in column width distribution and set Selected style Width to usable width so the orange highlight spans the entire row, not just the first column. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/table.go | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 673faaa44..59c0aff75 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -223,23 +223,6 @@ func (rt *ResourceTable) SetHeight(h int) { func (rt *ResourceTable) SetWidth(w int) { rt.inner.SetWidth(w) - // Update selected row style to span full width. - s := table.DefaultStyles() - s.Header = s.Header. - Foreground(rt.style.HeaderColor). - Bold(true). - BorderStyle(lipgloss.NormalBorder()). - BorderBottom(true). - BorderForeground(rt.style.BorderColor) - s.Selected = s.Selected. - Foreground(lipgloss.Color("0")). - Background(rt.style.SelectedBg). - Bold(true). - Width(w - 4) - s.Cell = s.Cell. - Foreground(rt.style.HeaderColor) - rt.inner.SetStyles(s) - usable := w - 4 // 2 for border chars, 2 for padding if usable < 10 || len(rt.columns) == 0 { return @@ -254,18 +237,42 @@ func (rt *ResourceTable) SetWidth(w int) { return } + // Account for cell padding: each cell has Padding(0,1) = 2 chars per cell. + cellPadding := len(rt.columns) * 2 + distributable := usable - cellPadding + if distributable < len(rt.columns) { + return + } + // Distribute proportionally. cols := rt.inner.Columns() assigned := 0 for i := range cols { if i == len(cols)-1 { - cols[i].Width = usable - assigned + cols[i].Width = distributable - assigned } else { - cols[i].Width = rt.columns[i].Width * usable / totalBase + cols[i].Width = rt.columns[i].Width * distributable / totalBase assigned += cols[i].Width } } rt.inner.SetColumns(cols) + + // Update selected style to span the full row width. + s := table.DefaultStyles() + s.Header = s.Header. + Foreground(rt.style.HeaderColor). + Bold(true). + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + BorderForeground(rt.style.BorderColor) + s.Selected = s.Selected. + Foreground(lipgloss.Color("0")). + Background(rt.style.SelectedBg). + Bold(true). + Width(usable) + s.Cell = s.Cell. + Foreground(rt.style.HeaderColor) + rt.inner.SetStyles(s) } // Focus gives keyboard focus to the table. From 6f816676fb2f4fd0074070fa16548d56fdf3ed96 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:19:57 -0400 Subject: [PATCH 016/117] fix(cli): selected row black text by removing Cell foreground override Cell.Foreground baked ANSI color codes into each cell, preventing Selected.Foreground from overriding them. Remove explicit Cell foreground so terminal default is used for non-selected rows and Selected can control both foreground (black) and background (orange). Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/views/table.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 59c0aff75..df50ce64b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -115,8 +115,9 @@ func NewResourceTable(kind string, scope string, columns []table.Column, style T Foreground(lipgloss.Color("0")). Background(style.SelectedBg). Bold(true) - s.Cell = s.Cell. - Foreground(style.HeaderColor) + // Don't set Cell foreground — let terminal default handle it. + // This allows Selected.Foreground to override cell text color + // (inner ANSI codes would win over outer if we set Cell.Foreground). t.SetStyles(s) return ResourceTable{ @@ -270,8 +271,6 @@ func (rt *ResourceTable) SetWidth(w int) { Background(rt.style.SelectedBg). Bold(true). Width(usable) - s.Cell = s.Cell. - Foreground(rt.style.HeaderColor) rt.inner.SetStyles(s) } From 18b529754e51c817ac5bedc0dafc75a2bc51c48f Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:27:13 -0400 Subject: [PATCH 017/117] feat(cli): view helpers for agents, sessions, inbox, messages - views/agents.go: agent table columns, row conversion, TruncateString - views/sessions.go: session table columns, row conversion, duration - views/inbox.go: inbox table columns, row conversion - views/messages.go: live message stream sub-model with ring buffer, autoscroll, raw/conversation mode, compose input, search - Fix lint modernize issues across all new files Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 2 +- .../cmd/acpctl/ambient/tui/model_new.go | 2 +- .../cmd/acpctl/ambient/tui/views/agents.go | 73 ++ .../cmd/acpctl/ambient/tui/views/inbox.go | 62 ++ .../cmd/acpctl/ambient/tui/views/messages.go | 888 ++++++++++++++++++ .../cmd/acpctl/ambient/tui/views/sessions.go | 73 ++ 6 files changed, 1098 insertions(+), 2 deletions(-) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/inbox.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index e83d8c52c..5ac812429 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -117,7 +117,7 @@ func (m *AppModel) viewHeader() string { // Combine left, hints, and branding into header lines. headerLines := make([]string, 5) - for i := 0; i < 5; i++ { + for i := range 5 { left := "" if i < len(leftLines) { left = leftLines[i] diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 97dc1fe4e..386c99b6f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -320,7 +320,7 @@ func (m *AppModel) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tea.KeyEnter: // Drill into selected project (Wave 0: just set info — no child views yet). row := m.projectTable.SelectedRow() - if row != nil && len(row) > 0 { + if len(row) > 0 { return m, m.setInfo("Selected project: "+row[0]) } return m, nil diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go new file mode 100644 index 000000000..366a0a9a8 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go @@ -0,0 +1,73 @@ +package views + +import ( + "time" + + "github.com/charmbracelet/bubbles/table" + + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// AgentColumns returns the column definitions for the agent list view. +// Column order matches the TUI spec: NAME, PROMPT, SESSION, PHASE, AGE. +func AgentColumns() []table.Column { + return []table.Column{ + {Title: "NAME", Width: 20}, + {Title: "PROMPT", Width: 60}, + {Title: "SESSION", Width: 14}, + {Title: "PHASE", Width: 12}, + {Title: "AGE", Width: 8}, + } +} + +// AgentRow converts an SDK Agent into a table row suitable for the agent list +// view. The now parameter is used to compute the relative AGE column. +// +// The PHASE column is left empty because populating it requires a secondary +// fetch of the agent's current session. The caller (model layer) is responsible +// for enriching rows with phase data after the fan-out fetch. +func AgentRow(a sdktypes.Agent, now time.Time) table.Row { + age := "" + if a.CreatedAt != nil { + age = FormatAge(now.Sub(*a.CreatedAt)) + } + + session := "" + if a.CurrentSessionID != "" { + session = a.CurrentSessionID + } + + return table.Row{ + a.Name, + TruncateString(a.Prompt, 60), + session, + "", // PHASE — requires secondary fetch; filled in by the model + age, + } +} + +// NewAgentTable creates a ResourceTable configured for the agent list view. +// The scope parameter is the project name that the agent list is scoped to. +func NewAgentTable(scope string, style TableStyle) ResourceTable { + return NewResourceTable("agents", scope, AgentColumns(), style) +} + +// TruncateString truncates s to maxLen characters, appending an ellipsis if the +// string was shortened. If maxLen is less than 1, an empty string is returned. +// This helper is exported for reuse by other views that need column truncation. +func TruncateString(s string, maxLen int) string { + if maxLen < 1 { + return "" + } + + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + + if maxLen <= 1 { + return string(runes[:1]) + } + + return string(runes[:maxLen-1]) + "…" +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/inbox.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/inbox.go new file mode 100644 index 000000000..aa5db17a5 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/inbox.go @@ -0,0 +1,62 @@ +package views + +import ( + "time" + + "github.com/charmbracelet/bubbles/table" + + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// InboxColumns returns the column definitions for the inbox message list view. +// Column order matches the TUI spec: ID, FROM, BODY, READ, AGE. +func InboxColumns() []table.Column { + return []table.Column{ + {Title: "ID", Width: 14}, + {Title: "FROM", Width: 15}, + {Title: "BODY", Width: 50}, + {Title: "READ", Width: 6}, + {Title: "AGE", Width: 8}, + } +} + +// InboxRow converts an SDK InboxMessage into a table row suitable for the inbox +// list view. The now parameter is used to compute the relative AGE column. +// +// FROM displays the message's FromName, falling back to "(human)" when empty. +// READ displays "✓" for read messages and "—" for unread. +// BODY is truncated to 47 characters (50 column width minus ellipsis) to fit +// the column; the full body is available in the detail view. +func InboxRow(msg sdktypes.InboxMessage, now time.Time) table.Row { + from := msg.FromName + if from == "" { + from = "(human)" + } + + readIndicator := "—" + if msg.Read { + readIndicator = "✓" + } + + age := "" + if msg.CreatedAt != nil { + age = FormatAge(now.Sub(*msg.CreatedAt)) + } + + body := TruncateString(msg.Body, 47) + + return table.Row{ + msg.ID, + from, + body, + readIndicator, + age, + } +} + +// NewInboxTable creates a ResourceTable configured for the inbox message list +// view. The scope parameter identifies which agent the inbox belongs to +// (e.g. "be"), matching the k9s title convention: inbox(be)[5]. +func NewInboxTable(scope string, style TableStyle) ResourceTable { + return NewResourceTable("inbox", scope, InboxColumns(), style) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go new file mode 100644 index 000000000..4e47c4afb --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -0,0 +1,888 @@ +package views + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Message types +// --------------------------------------------------------------------------- + +// MsgStreamBackMsg signals that the user pressed Esc to leave the message stream. +type MsgStreamBackMsg struct{} + +// MsgStreamSendMsg carries a composed message to be sent by the parent. +type MsgStreamSendMsg struct { + SessionID string + Body string +} + +// --------------------------------------------------------------------------- +// Color palette (duplicated from parent tui package to avoid circular import) +// --------------------------------------------------------------------------- + +var ( + msgColorWhite = lipgloss.Color("255") + msgColorGreen = lipgloss.Color("28") + msgColorDim = lipgloss.Color("240") + msgColorYellow = lipgloss.Color("33") + msgColorRed = lipgloss.Color("31") + msgColorOrange = lipgloss.Color("214") + msgColorCyan = lipgloss.Color("36") + msgColorBlue = lipgloss.Color("69") +) + +// eventColor returns the lipgloss color for a semantic event type. +// This duplicates the 6-entry mapping from the parent tui.EventColor to avoid +// a circular import. +func eventColor(eventType string) lipgloss.Color { + switch eventType { + case "user": + return msgColorWhite + case "assistant": + return msgColorGreen + case "tool_use": + return msgColorDim + case "tool_result": + return msgColorDim + case "system": + return msgColorYellow + case "error": + return msgColorRed + default: + return msgColorDim + } +} + +// phaseColor returns the display color for a session phase. +func phaseColor(phase string) lipgloss.Color { + switch strings.ToLower(phase) { + case "pending": + return msgColorYellow + case "running": + return msgColorGreen + case "succeeded", "completed": + return msgColorDim + case "failed": + return msgColorRed + case "cancelled": + return msgColorDim + default: + return msgColorDim + } +} + +// --------------------------------------------------------------------------- +// Local event summary renderer +// --------------------------------------------------------------------------- + +// eventSummary produces a one-line display string for a message entry. +// This is a simplified version of the parent tui.EventSummary — enough for +// conversation-mode rendering without requiring a circular import. +func eventSummary(eventType, payload string) string { + switch eventType { + case "user": + return truncatePayload(payload, 120) + case "assistant": + return truncatePayload(payload, 120) + case "tool_use": + name := extractJSONField(payload, "name") + if name == "" { + return truncatePayload(payload, 120) + } + input := extractJSONField(payload, "input") + if input != "" { + return name + " " + truncatePayload(input, 80) + } + return name + case "tool_result": + content := extractJSONField(payload, "content") + isError := extractJSONField(payload, "is_error") + indicator := "✓" // checkmark + if isError == "true" { + indicator = "✗" // cross + } + return fmt.Sprintf("%s %d bytes", indicator, len(content)) + case "system": + return truncatePayload(payload, 120) + case "error": + msg := extractJSONField(payload, "message") + if msg != "" { + return "✗ " + truncatePayload(msg, 120) + } + if payload != "" { + return "✗ " + truncatePayload(payload, 120) + } + return "✗ unknown error" + case "TEXT_MESSAGE_CONTENT", "REASONING_MESSAGE_CONTENT": + return extractJSONField(payload, "delta") + case "TOOL_CALL_START": + name := extractJSONField(payload, "tool_call_name") + if name == "" { + name = extractJSONField(payload, "tool_name") + } + if name != "" { + return "⚙ " + name + } + return "" + case "TOOL_CALL_RESULT": + return extractJSONField(payload, "content") + case "RUN_FINISHED": + return "[done]" + case "RUN_ERROR": + msg := extractJSONField(payload, "message") + if msg != "" { + return "✗ " + msg + } + return "✗ error" + case "TEXT_MESSAGE_START": + return "…" + case "TEXT_MESSAGE_END", "TOOL_CALL_ARGS", "TOOL_CALL_END": + return "" + } + if payload != "" && len(payload) <= 120 { + return payload + } + return "" +} + +// truncatePayload trims whitespace and truncates to max length. +func truncatePayload(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + return s[:max-1] + "…" +} + +// extractJSONField extracts a string field from a JSON payload. +// Returns empty string on parse failure or missing key. +func extractJSONField(payload, key string) string { + if payload == "" { + return "" + } + var obj map[string]any + if err := json.Unmarshal([]byte(payload), &obj); err != nil { + return "" + } + v, ok := obj[key] + if !ok { + return "" + } + switch val := v.(type) { + case string: + return val + case bool: + if val { + return "true" + } + return "false" + case float64: + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + return fmt.Sprintf("%g", val) + case nil: + return "" + default: + b, _ := json.Marshal(val) + return string(b) + } +} + +// --------------------------------------------------------------------------- +// MessageEntry +// --------------------------------------------------------------------------- + +// MessageEntry represents a single message in the stream buffer. +type MessageEntry struct { + Seq int + EventType string + Payload string + Timestamp time.Time +} + +// --------------------------------------------------------------------------- +// MessageStream — Bubbletea sub-model +// --------------------------------------------------------------------------- + +// defaultMaxMessages is the ring buffer capacity per the TUI spec. +const defaultMaxMessages = 2000 + +// MessageStream is a Bubbletea sub-model for the live session message stream. +// It renders messages in conversation or raw mode, supports scrolling, +// autoscroll, compose input, and search. +type MessageStream struct { + sessionID string + agentName string + phase string + + // Message buffer (ring buffer, 2000 max). + messages []MessageEntry + maxMessages int + + // Display + scrollOffset int + autoScroll bool // default true — view follows new messages + rawMode bool // false=conversation, true=raw JSON + + // Compose + composeMode bool + composeInput textinput.Model + + // Search + searchMode bool + searchInput textinput.Model + searchPattern *regexp.Regexp + + // Dimensions + width, height int +} + +// NewMessageStream creates a MessageStream sub-model for the given session. +func NewMessageStream(sessionID, agentName, phase string) MessageStream { + ci := textinput.New() + ci.Prompt = "> send message: " + ci.CharLimit = 4096 + ci.Width = 80 + + si := textinput.New() + si.Prompt = "/" + si.CharLimit = 256 + si.Width = 40 + + return MessageStream{ + sessionID: sessionID, + agentName: agentName, + phase: phase, + messages: make([]MessageEntry, 0, 256), + maxMessages: defaultMaxMessages, + autoScroll: true, + composeInput: ci, + searchInput: si, + } +} + +// --------------------------------------------------------------------------- +// Public methods +// --------------------------------------------------------------------------- + +// AddMessage appends a message to the ring buffer. When the buffer exceeds +// maxMessages, the oldest message is evicted. If autoScroll is enabled the +// scroll offset is advanced to keep the newest message visible. +func (ms *MessageStream) AddMessage(entry MessageEntry) { + ms.messages = append(ms.messages, entry) + if len(ms.messages) > ms.maxMessages { + // Evict oldest — shift the slice. For a 2000-entry buffer this is + // acceptable; a true ring buffer optimisation can come later. + excess := len(ms.messages) - ms.maxMessages + ms.messages = ms.messages[excess:] + ms.scrollOffset -= excess + if ms.scrollOffset < 0 { + ms.scrollOffset = 0 + } + } + if ms.autoScroll { + ms.scrollToBottom() + } +} + +// SetSize updates the viewport dimensions. +func (ms *MessageStream) SetSize(w, h int) { + ms.width = w + ms.height = h + ms.composeInput.Width = max(w-lipgloss.Width(ms.composeInput.Prompt)-4, 20) + ms.searchInput.Width = max(w/3, 20) +} + +// SetPhase updates the session phase (shown in the header and used to decide +// whether to render the streaming cursor). +func (ms *MessageStream) SetPhase(phase string) { + ms.phase = phase +} + +// ComposeValue returns the current text in the compose input. +func (ms MessageStream) ComposeValue() string { + return ms.composeInput.Value() +} + +// ClearCompose resets the compose input and exits compose mode. +func (ms *MessageStream) ClearCompose() { + ms.composeInput.Reset() + ms.composeMode = false + ms.composeInput.Blur() +} + +// --------------------------------------------------------------------------- +// Update +// --------------------------------------------------------------------------- + +// Update handles input messages. It returns an updated MessageStream and any +// commands to execute. +// +// Key bindings (normal mode): +// +// Esc -> MsgStreamBackMsg (signal parent to pop navigation) +// r -> toggle raw/conversation mode +// s -> toggle autoscroll +// m / Enter -> enter compose mode +// G -> jump to bottom, re-enable autoscroll +// g -> jump to top +// j / Down -> scroll down (disables autoscroll) +// k / Up -> scroll up (disables autoscroll) +// / -> enter search mode +// scroll -> mouse wheel scroll (disables autoscroll) +// +// Key bindings (compose mode): +// +// Esc -> exit compose mode +// Enter -> send message (MsgStreamSendMsg) +// +// Key bindings (search mode): +// +// Esc -> exit search mode, clear search +// Enter -> apply search pattern +func (ms *MessageStream) Update(msg tea.Msg) (MessageStream, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if ms.composeMode { + return ms.updateCompose(msg) + } + if ms.searchMode { + return ms.updateSearch(msg) + } + return ms.updateNormal(msg) + + case tea.MouseMsg: + switch msg.Button { + case tea.MouseButtonWheelUp: + ms.scrollUp(3) + return *ms, nil + case tea.MouseButtonWheelDown: + ms.scrollDown(3) + return *ms, nil + } + } + + return *ms, nil +} + +func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + return *ms, func() tea.Msg { return MsgStreamBackMsg{} } + + case tea.KeyEnter: + ms.enterComposeMode() + return *ms, nil + + case tea.KeyUp: + ms.scrollUp(1) + return *ms, nil + + case tea.KeyDown: + ms.scrollDown(1) + return *ms, nil + + case tea.KeyPgUp: + ms.scrollUp(ms.contentHeight()) + return *ms, nil + + case tea.KeyPgDown: + ms.scrollDown(ms.contentHeight()) + return *ms, nil + + case tea.KeyRunes: + switch msg.String() { + case "r": + ms.rawMode = !ms.rawMode + return *ms, nil + case "s": + ms.autoScroll = !ms.autoScroll + if ms.autoScroll { + ms.scrollToBottom() + } + return *ms, nil + case "m": + ms.enterComposeMode() + return *ms, nil + case "G": + ms.scrollToBottom() + ms.autoScroll = true + return *ms, nil + case "g": + ms.scrollOffset = 0 + ms.autoScroll = false + return *ms, nil + case "j": + ms.scrollDown(1) + return *ms, nil + case "k": + ms.scrollUp(1) + return *ms, nil + case "/": + ms.searchMode = true + ms.searchInput.Reset() + ms.searchInput.Focus() + return *ms, nil + } + } + + return *ms, nil +} + +func (ms *MessageStream) updateCompose(msg tea.KeyMsg) (MessageStream, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + ms.ClearCompose() + return *ms, nil + case tea.KeyEnter: + value := strings.TrimSpace(ms.composeInput.Value()) + if value == "" { + // Empty message — just exit compose mode. + ms.ClearCompose() + return *ms, nil + } + sid := ms.sessionID + ms.ClearCompose() + return *ms, func() tea.Msg { + return MsgStreamSendMsg{SessionID: sid, Body: value} + } + } + + // Delegate to textinput for character entry. + var cmd tea.Cmd + ms.composeInput, cmd = ms.composeInput.Update(msg) + return *ms, cmd +} + +func (ms *MessageStream) updateSearch(msg tea.KeyMsg) (MessageStream, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + ms.searchMode = false + ms.searchPattern = nil + ms.searchInput.Reset() + ms.searchInput.Blur() + return *ms, nil + case tea.KeyEnter: + pattern := ms.searchInput.Value() + if pattern == "" { + ms.searchPattern = nil + } else { + re, err := regexp.Compile("(?i)" + pattern) + if err != nil { + // Invalid regex — treat as literal. + re = regexp.MustCompile(regexp.QuoteMeta(pattern)) + } + ms.searchPattern = re + } + ms.searchMode = false + ms.searchInput.Blur() + return *ms, nil + } + + var cmd tea.Cmd + ms.searchInput, cmd = ms.searchInput.Update(msg) + return *ms, cmd +} + +// --------------------------------------------------------------------------- +// View +// --------------------------------------------------------------------------- + +// View renders the message stream. Layout from top to bottom: +// 1. Header line: Session {id} -- Phase: {phase} -- Agent: {agentName} +// 2. Message content area (scrollable) +// 3. Streaming cursor ("streaming..." when phase is running) +// 4. Compose input (when composeMode is active) +// 5. Status bar (autoscroll indicator, search pattern, key hints) +func (ms *MessageStream) View() string { + if ms.width == 0 { + return "Loading…" + } + + headerStyle := lipgloss.NewStyle().Foreground(msgColorCyan).Bold(true) + dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) + borderStyle := lipgloss.NewStyle().Foreground(msgColorDim) + + // -- Header -- + shortID := ms.sessionID + if len(shortID) > 12 { + shortID = shortID[:12] + "…" + } + phaseStyle := lipgloss.NewStyle().Foreground(phaseColor(ms.phase)) + + header := fmt.Sprintf(" %s %s %s %s %s %s", + headerStyle.Render("Session"), + lipgloss.NewStyle().Foreground(msgColorWhite).Bold(true).Render(shortID), + dimStyle.Render("—"), + fmt.Sprintf("%s %s", dimStyle.Render("Phase:"), phaseStyle.Render(ms.phase)), + dimStyle.Render("—"), + fmt.Sprintf("%s %s", dimStyle.Render("Agent:"), lipgloss.NewStyle().Foreground(msgColorOrange).Render(ms.agentName)), + ) + + headerBar := borderStyle.Render("┌" + strings.Repeat("─", max(ms.width-2, 0)) + "┐") + headerLine := borderStyle.Render("│") + + padToWidth(header, ms.width-2) + + borderStyle.Render("│") + headerSep := borderStyle.Render("├" + strings.Repeat("─", max(ms.width-2, 0)) + "┤") + + // -- Compose / status area (rendered bottom-up to calculate remaining height) -- + var bottomLines []string + + // Status bar (always shown). + statusBar := ms.renderStatusBar() + bottomBorder := borderStyle.Render("└" + strings.Repeat("─", max(ms.width-2, 0)) + "┘") + bottomLines = append(bottomLines, + borderStyle.Render("│")+padToWidth(" "+statusBar, ms.width-2)+borderStyle.Render("│"), + bottomBorder, + ) + + // Compose input (if active). + if ms.composeMode { + composeSep := borderStyle.Render("├" + strings.Repeat("─", max(ms.width-2, 0)) + "┤") + composeView := ms.composeInput.View() + composeLine := borderStyle.Render("│") + + " " + padToWidth(composeView, ms.width-3) + + borderStyle.Render("│") + // Prepend compose above the status bar. + bottomLines = append([]string{composeSep, composeLine}, bottomLines...) + } + + // Streaming cursor (when phase is running). + if strings.ToLower(ms.phase) == "running" { + cursorStyle := lipgloss.NewStyle().Foreground(msgColorGreen) + cursor := cursorStyle.Render(" ▌ streaming…") + cursorLine := borderStyle.Render("│") + + padToWidth(cursor, ms.width-2) + + borderStyle.Render("│") + // Prepend cursor above compose/status. + bottomLines = append([]string{cursorLine}, bottomLines...) + } + + // Search bar (if active). + if ms.searchMode { + searchSep := borderStyle.Render("├" + strings.Repeat("─", max(ms.width-2, 0)) + "┤") + searchView := ms.searchInput.View() + searchLine := borderStyle.Render("│") + + " " + padToWidth(searchView, ms.width-3) + + borderStyle.Render("│") + bottomLines = append([]string{searchSep, searchLine}, bottomLines...) + } + + // -- Content area -- + // 3 = header bar + header line + header separator + topLines := 3 + contentH := max(ms.height-topLines-len(bottomLines), 1) + + contentLines := ms.renderContent(contentH) + + // Pad/truncate content to fill the viewport. + rendered := make([]string, contentH) + for i := range contentH { + line := "" + if i < len(contentLines) { + line = contentLines[i] + } + rendered[i] = borderStyle.Render("│") + + padToWidth(" "+line, ms.width-2) + + borderStyle.Render("│") + } + + // Assemble. + var sb strings.Builder + sb.WriteString(headerBar) + sb.WriteByte('\n') + sb.WriteString(headerLine) + sb.WriteByte('\n') + sb.WriteString(headerSep) + sb.WriteByte('\n') + sb.WriteString(strings.Join(rendered, "\n")) + sb.WriteByte('\n') + sb.WriteString(strings.Join(bottomLines, "\n")) + + return sb.String() +} + +// renderContent produces the visible message lines for the content area. +func (ms *MessageStream) renderContent(height int) []string { + if len(ms.messages) == 0 { + dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) + return []string{dimStyle.Render("No messages yet.")} + } + + // Build all display lines from messages. + allLines := ms.buildDisplayLines() + + // Apply search filter — highlight matches. + if ms.searchPattern != nil { + filtered := make([]string, 0, len(allLines)) + for _, line := range allLines { + if ms.searchPattern.MatchString(stripANSI(line)) { + filtered = append(filtered, line) + } + } + allLines = filtered + } + + // Apply scroll offset. + total := len(allLines) + if ms.scrollOffset > total-height { + ms.scrollOffset = total - height + } + if ms.scrollOffset < 0 { + ms.scrollOffset = 0 + } + + start := ms.scrollOffset + end := min(start+height, total) + if start >= total { + return nil + } + + return allLines[start:end] +} + +// buildDisplayLines converts the message buffer into styled display lines. +func (ms *MessageStream) buildDisplayLines() []string { + maxLineWidth := max(ms.width-4, 20) // 2 for borders, 2 for padding + + lines := make([]string, 0, len(ms.messages)) + + for _, entry := range ms.messages { + if ms.rawMode { + lines = append(lines, ms.renderRawEntry(entry, maxLineWidth)...) + } else { + lines = append(lines, ms.renderConversationEntry(entry, maxLineWidth)...) + } + } + + return lines +} + +// renderConversationEntry renders a single message in conversation mode. +// Format: [event_type] summary text (wrapped) +func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth int) []string { + color := eventColor(entry.EventType) + typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true) + textStyle := lipgloss.NewStyle().Foreground(color) + + summary := eventSummary(entry.EventType, entry.Payload) + if summary == "" { + // Suppressed event types (TOOL_CALL_ARGS, etc.) — don't render. + return nil + } + + tag := typeStyle.Render("[" + entry.EventType + "]") + tagWidth := lipgloss.Width(tag) + + // Indent continuation lines to align with the text after the tag. + indent := strings.Repeat(" ", tagWidth+2) + + // Wrap the summary text. + availWidth := max(maxWidth-tagWidth-2, 10) // 2 for spacing between tag and text + + wrapped := wrapText(summary, availWidth) + if len(wrapped) == 0 { + return []string{tag} + } + + result := make([]string, 0, len(wrapped)) + for i, line := range wrapped { + if i == 0 { + result = append(result, tag+" "+textStyle.Render(line)) + } else { + result = append(result, indent+textStyle.Render(line)) + } + } + + return result +} + +// renderRawEntry renders a single message as a JSON line in raw mode. +func (ms *MessageStream) renderRawEntry(entry MessageEntry, maxWidth int) []string { + dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) + + raw := struct { + Seq int `json:"seq"` + EventType string `json:"event_type"` + Payload string `json:"payload"` + Timestamp string `json:"timestamp"` + }{ + Seq: entry.Seq, + EventType: entry.EventType, + Payload: entry.Payload, + Timestamp: entry.Timestamp.Format(time.RFC3339), + } + + b, err := json.Marshal(raw) + if err != nil { + return []string{dimStyle.Render("[marshal error]")} + } + + line := string(b) + wrapped := wrapText(line, maxWidth) + result := make([]string, len(wrapped)) + for i, w := range wrapped { + result[i] = dimStyle.Render(w) + } + return result +} + +// renderStatusBar builds the bottom status line with mode indicators and key hints. +func (ms *MessageStream) renderStatusBar() string { + dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) + accentStyle := lipgloss.NewStyle().Foreground(msgColorOrange) + blueStyle := lipgloss.NewStyle().Foreground(msgColorBlue) + + var parts []string + + // Autoscroll indicator. + if ms.autoScroll { + parts = append(parts, accentStyle.Render("autoscroll:on")) + } else { + parts = append(parts, dimStyle.Render("autoscroll:off")) + } + + // Mode indicator. + if ms.rawMode { + parts = append(parts, blueStyle.Render("raw")) + } else { + parts = append(parts, dimStyle.Render("conversation")) + } + + // Search indicator. + if ms.searchPattern != nil { + parts = append(parts, accentStyle.Render("/"+ms.searchPattern.String())) + } + + // Message count. + parts = append(parts, dimStyle.Render(fmt.Sprintf("%d msgs", len(ms.messages)))) + + // Key hints. + hints := dimStyle.Render("Esc back | r raw | s scroll | m send | G bottom | / search") + + return strings.Join(parts, dimStyle.Render(" │ ")) + " " + hints +} + +// --------------------------------------------------------------------------- +// Scroll helpers +// --------------------------------------------------------------------------- + +func (ms *MessageStream) scrollUp(n int) { + ms.autoScroll = false + ms.scrollOffset -= n + if ms.scrollOffset < 0 { + ms.scrollOffset = 0 + } +} + +func (ms *MessageStream) scrollDown(n int) { + ms.autoScroll = false + ms.scrollOffset += n + // Clamping happens in renderContent. +} + +func (ms *MessageStream) scrollToBottom() { + // Set a large value; renderContent will clamp. + ms.scrollOffset = len(ms.messages) * 10 +} + +// contentHeight returns the usable content height given the current dimensions. +func (ms *MessageStream) contentHeight() int { + // Approximate: total height minus header (3 lines) minus status/compose/cursor. + h := ms.height - 5 + if ms.composeMode { + h -= 2 + } + if strings.ToLower(ms.phase) == "running" { + h-- + } + if ms.searchMode { + h -= 2 + } + if h < 1 { + h = 1 + } + return h +} + +func (ms *MessageStream) enterComposeMode() { + ms.composeMode = true + ms.composeInput.Focus() +} + +// --------------------------------------------------------------------------- +// Text helpers +// --------------------------------------------------------------------------- + +// wrapText breaks a string into lines of at most maxWidth characters. +// It splits on word boundaries where possible, falling back to hard breaks +// for very long tokens. +func wrapText(s string, maxWidth int) []string { + if maxWidth <= 0 { + maxWidth = 80 + } + if s == "" { + return nil + } + + // Replace embedded newlines with spaces for single-line rendering, + // then split into words. + s = strings.ReplaceAll(s, "\n", " ") + words := strings.Fields(s) + if len(words) == 0 { + return nil + } + + var lines []string + current := words[0] + + for _, word := range words[1:] { + if len(current)+1+len(word) <= maxWidth { + current += " " + word + } else { + lines = append(lines, current) + current = word + } + } + lines = append(lines, current) + + // Hard-break any lines that still exceed maxWidth (long single tokens). + var result []string + for _, line := range lines { + for len(line) > maxWidth { + result = append(result, line[:maxWidth]) + line = line[maxWidth:] + } + result = append(result, line) + } + + return result +} + +// ansiRe matches ANSI CSI escape sequences for stripping before search. +var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +// stripANSI removes ANSI escape sequences from a string so that search +// matching operates on visible text only. +func stripANSI(s string) string { + return ansiRe.ReplaceAllString(s, "") +} + +// padToWidth pads a styled string to exactly w visual characters. +func padToWidth(s string, w int) string { + vis := lipgloss.Width(s) + if vis >= w { + return s + } + return s + strings.Repeat(" ", w-vis) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go new file mode 100644 index 000000000..cea45da46 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go @@ -0,0 +1,73 @@ +package views + +import ( + "time" + + "github.com/charmbracelet/bubbles/table" + + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// SessionColumns returns the column definitions for the session list view. +// Column order matches the TUI spec: ID, AGENT, PROJECT, PHASE, TRIGGERED BY, STARTED, DURATION. +func SessionColumns() []table.Column { + return []table.Column{ + {Title: "ID", Width: 14}, + {Title: "AGENT", Width: 15}, + {Title: "PROJECT", Width: 15}, + {Title: "PHASE", Width: 12}, + {Title: "TRIGGERED BY", Width: 15}, + {Title: "STARTED", Width: 10}, + {Title: "DURATION", Width: 10}, + } +} + +// SessionRow converts an SDK Session into a table row suitable for the session +// list view. The agentName parameter is the resolved display name for the +// session's AgentID — the caller is responsible for resolving agent ID to name +// (see Known N+1 Queries in the TUI spec). The now parameter is used to compute +// the relative STARTED column and running duration. +// +// ID is shown in short form (first 12 characters). DURATION is computed as +// CompletionTime - StartTime for completed sessions, now - StartTime for +// running sessions, or empty for pending sessions. +func SessionRow(s sdktypes.Session, agentName string, now time.Time) table.Row { + // Short ID: first 12 characters. + shortID := s.ID + if len(shortID) > 12 { + shortID = shortID[:12] + } + + // STARTED: relative age since StartTime. + started := "" + if s.StartTime != nil { + started = FormatAge(now.Sub(*s.StartTime)) + } + + // DURATION: completed = CompletionTime - StartTime, + // running = now - StartTime, pending = empty. + duration := "" + if s.CompletionTime != nil && s.StartTime != nil { + duration = FormatAge(s.CompletionTime.Sub(*s.StartTime)) + } else if s.StartTime != nil { + // Session is still running — show elapsed time. + duration = FormatAge(now.Sub(*s.StartTime)) + } + + return table.Row{ + shortID, + agentName, + s.ProjectID, + s.Phase, + s.TriggeredByUserID, + started, + duration, + } +} + +// NewSessionTable creates a ResourceTable configured for the session list view. +// The scope parameter controls the title bar context — "all" for global view, +// an agent name for agent-scoped view, etc. +func NewSessionTable(scope string, style TableStyle) ResourceTable { + return NewResourceTable("sessions", scope, SessionColumns(), style) +} From 369a27bf88e3c4d4a8e2ce4ec3ae2165229c7c16 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:32:16 -0400 Subject: [PATCH 018/117] =?UTF-8?q?feat(cli):=20full=20navigation=20wiring?= =?UTF-8?q?=20=E2=80=94=20Waves=201-3=20connected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire all 5 views into the navigation hierarchy: projects → agents → sessions → messages → inbox - Enter drills down, Esc pops back, breadcrumb updates - :projects/:agents/:sessions/:inbox/:messages command switching - :context lists/switches server contexts - Agent hotkeys: s start, x stop, e edit, i inbox, l logs - Session hotkeys: Enter/l drill to messages, m send, y YAML - Inbox hotkeys: m compose, r mark read - Messages view delegates to MessageStream sub-model - Filter applies to whichever table is active - Polling fetches data for the active view, skips for messages - CRUD operations show "not yet implemented" toast (SDK wiring next) Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 17 +- .../cmd/acpctl/ambient/tui/model_new.go | 813 ++++++++++++++++-- 2 files changed, 752 insertions(+), 78 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 5ac812429..f95f2c74e 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -162,9 +162,22 @@ func (m *AppModel) viewCommandBar() string { return "" } -// viewResourceTable renders the current resource table with its title bar. +// viewResourceTable renders the current resource table or view with its title bar. func (m *AppModel) viewResourceTable() string { - return m.projectTable.View() + switch m.activeView { + case "projects": + return m.projectTable.View() + case "agents": + return m.agentTable.View() + case "sessions": + return m.sessionTable.View() + case "inbox": + return m.inboxTable.View() + case "messages": + return m.messageStream.View() + default: + return m.projectTable.View() + } } // viewBreadcrumb renders the navigation breadcrumb trail at the bottom. diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 386c99b6f..9c0cbd7f5 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -27,7 +27,7 @@ const staleThreshold = 15 * time.Second // NavEntry represents a single level in the navigation stack. type NavEntry struct { - Kind string // "projects", "agents", "sessions", etc. + Kind string // "projects", "agents", "sessions", "messages", "inbox" Scope string // project name, agent name, etc. ID string // resource ID if applicable } @@ -43,7 +43,7 @@ type appTickMsg struct{ t time.Time } type infoExpiredMsg struct{} // --------------------------------------------------------------------------- -// AppModel — the Wave 0 TUI model +// AppModel — the TUI model with full navigation hierarchy // --------------------------------------------------------------------------- // AppModel is the top-level Bubbletea model for the rewritten TUI. @@ -57,16 +57,29 @@ type AppModel struct { // Navigation navStack []NavEntry // stack of views; rightmost is current - // View state - projectTable views.ResourceTable + // Tables for each resource view + projectTable views.ResourceTable + agentTable views.ResourceTable + sessionTable views.ResourceTable + inboxTable views.ResourceTable + messageStream views.MessageStream + + // Current view determines which table/view is active + activeView string // "projects", "agents", "sessions", "messages", "inbox" + + // Context for scoped views + currentProject string // set when drilling into a project + currentAgent string // set when drilling into an agent (name) + currentAgentID string // agent ID for API calls + currentSession string // set when drilling into a session // Command mode commandMode bool commandInput textinput.Model // Filter mode - filterMode bool - filterInput textinput.Model + filterMode bool + filterInput textinput.Model activeFilter *Filter // Polling @@ -107,6 +120,9 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { fi.CharLimit = 256 pt := views.NewProjectTable(views.DefaultTableStyle()) + at := views.NewAgentTable("all", views.DefaultTableStyle()) + st := views.NewSessionTable("all", views.DefaultTableStyle()) + it := views.NewInboxTable("all", views.DefaultTableStyle()) m := &AppModel{ config: cfg, @@ -114,7 +130,11 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { navStack: []NavEntry{ {Kind: "projects", Scope: "all"}, }, + activeView: "projects", projectTable: pt, + agentTable: at, + sessionTable: st, + inboxTable: it, commandInput: ci, filterInput: fi, } @@ -161,6 +181,102 @@ func (m *AppModel) currentNav() NavEntry { return m.navStack[len(m.navStack)-1] } +// --------------------------------------------------------------------------- +// Navigation helpers +// --------------------------------------------------------------------------- + +// pushView pushes a new navigation entry, switches to the target view, and +// returns a fetch command for the new view's data. +func (m *AppModel) pushView(kind, scope, id string) tea.Cmd { + m.navStack = append(m.navStack, NavEntry{Kind: kind, Scope: scope, ID: id}) + m.activeView = kind + m.activeFilter = nil + m.pollInFlight = true + return m.fetchActiveView() +} + +// popView pops the current navigation entry, switches back to the parent view, +// and returns a fetch command to refresh the parent data. +func (m *AppModel) popView() tea.Cmd { + if len(m.navStack) <= 1 { + return nil + } + m.navStack = m.navStack[:len(m.navStack)-1] + nav := m.currentNav() + m.activeView = nav.Kind + m.activeFilter = nil + + // Restore context based on what we popped back to. + switch nav.Kind { + case "projects": + m.currentProject = "" + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + case "agents": + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + case "sessions": + m.currentSession = "" + } + + m.pollInFlight = true + return m.fetchActiveView() +} + +// fetchActiveView returns a tea.Cmd to fetch data for the currently active view. +func (m *AppModel) fetchActiveView() tea.Cmd { + switch m.activeView { + case "projects": + return m.client.FetchProjects() + case "agents": + if m.currentProject != "" { + return m.client.FetchAgents(m.currentProject) + } + // Fall back to config project if no drill-down context. + if ctx := m.config.Current(); ctx != nil && ctx.Project != "" { + return m.client.FetchAgents(ctx.Project) + } + return nil + case "sessions": + if m.currentAgentID != "" && m.currentProject != "" { + // Agent-scoped sessions — fetch project sessions and filter client-side + // in the handler. + return m.client.FetchSessions(m.currentProject) + } + // Global sessions view. + return m.client.FetchAllSessions() + case "inbox": + if m.currentAgentID != "" && m.currentProject != "" { + return m.client.FetchInbox(m.currentProject, m.currentAgentID) + } + return nil + case "messages": + // Message stream uses SSE, not polling. No fetch command needed yet. + return nil + default: + return nil + } +} + +// activeTable returns a pointer to the currently active ResourceTable, or nil +// for the message stream view. +func (m *AppModel) activeTable() *views.ResourceTable { + switch m.activeView { + case "projects": + return &m.projectTable + case "agents": + return &m.agentTable + case "sessions": + return &m.sessionTable + case "inbox": + return &m.inboxTable + default: + return nil + } +} + // --------------------------------------------------------------------------- // Update // --------------------------------------------------------------------------- @@ -177,14 +293,40 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.MouseMsg: - // Delegate scroll events to the project table. - var cmd tea.Cmd - m.projectTable, cmd = m.projectTable.Update(msg) - return m, cmd + // Delegate scroll events to the active table or message stream. + if m.activeView == "messages" { + var cmd tea.Cmd + m.messageStream, cmd = m.messageStream.Update(msg) + return m, cmd + } + if tbl := m.activeTable(); tbl != nil { + var cmd tea.Cmd + *tbl, cmd = tbl.Update(msg) + return m, cmd + } + return m, nil case ProjectsMsg: return m.handleProjectsMsg(msg) + case AgentsMsg: + return m.handleAgentsMsg(msg) + + case SessionsMsg: + return m.handleSessionsMsg(msg) + + case InboxMsg: + return m.handleInboxMsg(msg) + + case views.MsgStreamBackMsg: + // User pressed Esc in the message stream — pop back. + cmd := m.popView() + return m, tea.Batch(cmd, m.setInfo("Back to "+m.currentNav().Kind)) + + case views.MsgStreamSendMsg: + // User composed a message to send to a session. + return m, m.setInfo("Send message: not yet implemented") + case appTickMsg: return m.handleTick() @@ -203,7 +345,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// resizeTable adjusts the project table dimensions to fill available space. +// resizeTable adjusts all table dimensions and the message stream to fill +// available space. func (m *AppModel) resizeTable() { if m.width == 0 || m.height == 0 { return @@ -224,8 +367,19 @@ func (m *AppModel) resizeTable() { if tableHeight < 1 { tableHeight = 1 } + + // Resize all tables so they're ready when switched to. m.projectTable.SetHeight(tableHeight) m.projectTable.SetWidth(m.width) + m.agentTable.SetHeight(tableHeight) + m.agentTable.SetWidth(m.width) + m.sessionTable.SetHeight(tableHeight) + m.sessionTable.SetWidth(m.width) + m.inboxTable.SetHeight(tableHeight) + m.inboxTable.SetWidth(m.width) + + // Message stream gets the full table area. + m.messageStream.SetSize(m.width, tableHeight+2) // +2 to account for title bar space } // handleProjectsMsg populates the project table from a fetch result. @@ -263,8 +417,8 @@ func (m *AppModel) handleProjectsMsg(msg ProjectsMsg) (tea.Model, tea.Cmd) { } m.projectTable.SetRows(rows) - // Re-apply active filter if present. - if m.activeFilter != nil { + // Re-apply active filter if present and we're on projects view. + if m.activeView == "projects" && m.activeFilter != nil { f := m.activeFilter m.projectTable.SetFilter(func(cols []string) bool { return f.MatchRow(cols) @@ -274,13 +428,142 @@ func (m *AppModel) handleProjectsMsg(msg ProjectsMsg) (tea.Model, tea.Cmd) { return m, nil } +// handleAgentsMsg populates the agent table from a fetch result. +func (m *AppModel) handleAgentsMsg(msg AgentsMsg) (tea.Model, tea.Cmd) { + m.pollInFlight = false + m.lastFetch = time.Now() + + if msg.Err != nil { + m.lastError = msg.Err.Error() + return m, nil + } + + m.lastError = "" + now := time.Now() + + rows := make([]table.Row, 0, len(msg.Agents)) + for _, a := range msg.Agents { + row := views.AgentRow(a, now) + // Sanitize all cells. + for i := range row { + row[i] = Sanitize(row[i]) + } + rows = append(rows, row) + } + m.agentTable.SetRows(rows) + + // Re-apply active filter if present and we're on agents view. + if m.activeView == "agents" && m.activeFilter != nil { + f := m.activeFilter + m.agentTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + return m, nil +} + +// handleSessionsMsg populates the session table from a fetch result. +func (m *AppModel) handleSessionsMsg(msg SessionsMsg) (tea.Model, tea.Cmd) { + m.pollInFlight = false + m.lastFetch = time.Now() + + if msg.Err != nil { + m.lastError = msg.Err.Error() + return m, nil + } + + m.lastError = "" + now := time.Now() + + // If agent-scoped, filter sessions to only those belonging to this agent. + sessions := msg.Sessions + if m.currentAgentID != "" { + rows := make([]table.Row, 0) + for _, s := range sessions { + if s.AgentID == m.currentAgentID { + row := views.SessionRow(s, m.currentAgent, now) + for i := range row { + row[i] = Sanitize(row[i]) + } + rows = append(rows, row) + } + } + m.sessionTable.SetRows(rows) + } else { + // Global view — agent name is not resolved (would need N+1 fetch). + rows := make([]table.Row, 0, len(sessions)) + for _, s := range sessions { + agentName := s.AgentID + if len(agentName) > 12 { + agentName = agentName[:12] + } + row := views.SessionRow(s, agentName, now) + for i := range row { + row[i] = Sanitize(row[i]) + } + rows = append(rows, row) + } + m.sessionTable.SetRows(rows) + } + + // Re-apply active filter if present and we're on sessions view. + if m.activeView == "sessions" && m.activeFilter != nil { + f := m.activeFilter + m.sessionTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + return m, nil +} + +// handleInboxMsg populates the inbox table from a fetch result. +func (m *AppModel) handleInboxMsg(msg InboxMsg) (tea.Model, tea.Cmd) { + m.pollInFlight = false + m.lastFetch = time.Now() + + if msg.Err != nil { + m.lastError = msg.Err.Error() + return m, nil + } + + m.lastError = "" + now := time.Now() + + rows := make([]table.Row, 0, len(msg.Messages)) + for _, im := range msg.Messages { + row := views.InboxRow(im, now) + for i := range row { + row[i] = Sanitize(row[i]) + } + rows = append(rows, row) + } + m.inboxTable.SetRows(rows) + + // Re-apply active filter if present and we're on inbox view. + if m.activeView == "inbox" && m.activeFilter != nil { + f := m.activeFilter + m.inboxTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + return m, nil +} + // handleTick manages periodic polling. Skips if a fetch is already in flight. +// Fetches data for the active view rather than always fetching projects. func (m *AppModel) handleTick() (tea.Model, tea.Cmd) { cmds := []tea.Cmd{m.tickCmd()} // always schedule next tick - if !m.pollInFlight { + if !m.pollInFlight && m.activeView != "messages" { m.pollInFlight = true - cmds = append(cmds, m.client.FetchProjects()) + if fetchCmd := m.fetchActiveView(); fetchCmd != nil { + cmds = append(cmds, fetchCmd) + } else { + m.pollInFlight = false + } } return m, tea.Batch(cmds...) @@ -303,86 +586,352 @@ func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.filterMode { return m.handleFilterKey(msg) } + + // Message stream handles its own keys. + if m.activeView == "messages" { + return m.handleMessagesKey(msg) + } + return m.handleNormalKey(msg) } // handleNormalKey processes keys when neither command nor filter mode is active. +// Dispatches based on activeView for view-specific hotkeys. func (m *AppModel) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Global keybindings first. switch msg.Type { case tea.KeyEsc: - // Pop navigation stack (if deeper than root). - if len(m.navStack) > 1 { - m.navStack = m.navStack[:len(m.navStack)-1] - return m, m.setInfo("Back to "+m.currentNav().Kind) + cmd := m.popView() + if cmd != nil { + return m, tea.Batch(cmd, m.setInfo("Back to "+m.currentNav().Kind)) } return m, nil - case tea.KeyEnter: - // Drill into selected project (Wave 0: just set info — no child views yet). - row := m.projectTable.SelectedRow() - if len(row) > 0 { - return m, m.setInfo("Selected project: "+row[0]) + case tea.KeyCtrlD: + return m.handleCtrlD() + + case tea.KeyUp, tea.KeyDown, tea.KeyPgUp, tea.KeyPgDown: + // Delegate to active table for row navigation. + if tbl := m.activeTable(); tbl != nil { + var cmd tea.Cmd + *tbl, cmd = tbl.Update(msg) + return m, cmd } return m, nil - case tea.KeyUp, tea.KeyDown, tea.KeyPgUp, tea.KeyPgDown: - // Delegate to table for row navigation. - var cmd tea.Cmd - m.projectTable, cmd = m.projectTable.Update(msg) - return m, cmd + case tea.KeyEnter: + return m.handleEnter() case tea.KeyRunes: - switch msg.String() { - case ":": - m.commandMode = true - m.commandInput.Reset() - m.commandInput.Focus() - m.resizeTable() - return m, nil + return m.handleRuneKey(msg) + } - case "/": - m.filterMode = true - m.filterInput.Reset() - m.filterInput.Focus() - m.resizeTable() - return m, nil + return m, nil +} + +// handleEnter processes the Enter key based on the active view. +func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { + switch m.activeView { + case "projects": + row := m.projectTable.SelectedRow() + if len(row) > 0 { + projectName := row[0] + m.currentProject = projectName + m.agentTable.SetScope(projectName) + cmd := m.pushView("agents", projectName, "") + return m, tea.Batch(cmd, m.setInfo("Viewing agents in project "+projectName)) + } + + case "agents": + row := m.agentTable.SelectedRow() + if len(row) > 0 { + agentName := row[0] + // Agent ID comes from the SESSION column (index 2) — but we need the + // actual ID. For now, we use the agent name and fetch sessions by project. + // The agent table stores: NAME, PROMPT, SESSION, PHASE, AGE + m.currentAgent = agentName + // We don't have the agent ID directly in the table. Use name as a + // best-effort identifier until the API provides it in the list response. + m.currentAgentID = agentName + m.sessionTable.SetScope(agentName) + cmd := m.pushView("sessions", agentName, "") + return m, tea.Batch(cmd, m.setInfo("Viewing sessions for agent "+agentName)) + } - case "?": - return m, m.setInfo("Help: q quit | : command | / filter | Enter drill-in | Esc back | N sort name | A sort age") + case "sessions": + row := m.sessionTable.SelectedRow() + if len(row) > 0 { + sessionID := row[0] // Short ID is in first column + m.currentSession = sessionID - case "q": - if len(m.navStack) <= 1 { - return m, tea.Quit + // Create a new message stream for this session. + agentName := m.currentAgent + if agentName == "" && len(row) > 1 { + agentName = row[1] // AGENT column + } + phase := "" + if len(row) > 3 { + phase = row[3] // PHASE column } - // Pop nav stack (same as Esc from child view). - m.navStack = m.navStack[:len(m.navStack)-1] - return m, m.setInfo("Back to "+m.currentNav().Kind) + m.messageStream = views.NewMessageStream(sessionID, agentName, phase) + m.resizeTable() // set message stream dimensions + + // Add placeholder messages since SSE is not wired yet. + m.messageStream.AddMessage(views.MessageEntry{ + Seq: 1, + EventType: "system", + Payload: "Connected to session " + sessionID + " (SSE not yet wired)", + Timestamp: time.Now(), + }) + + cmd := m.pushView("messages", sessionID, sessionID) + return m, tea.Batch(cmd, m.setInfo("Streaming messages for session "+sessionID)) + } + + case "inbox": + row := m.inboxTable.SelectedRow() + if len(row) > 0 { + return m, m.setInfo("View full message body: not yet implemented") + } + } + + return m, nil +} + +// handleRuneKey processes single-character keys in normal mode. +func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + + // Global rune keybindings. + switch key { + case ":": + m.commandMode = true + m.commandInput.Reset() + m.commandInput.Focus() + m.resizeTable() + return m, nil + + case "/": + m.filterMode = true + m.filterInput.Reset() + m.filterInput.Focus() + m.resizeTable() + return m, nil + + case "?": + return m, m.viewSpecificHelp() - case "j": + case "q": + if len(m.navStack) <= 1 { + return m, tea.Quit + } + cmd := m.popView() + return m, tea.Batch(cmd, m.setInfo("Back to "+m.currentNav().Kind)) + + case "j": + if tbl := m.activeTable(); tbl != nil { var cmd tea.Cmd - m.projectTable, cmd = m.projectTable.Update(tea.KeyMsg{Type: tea.KeyDown}) + *tbl, cmd = tbl.Update(tea.KeyMsg{Type: tea.KeyDown}) return m, cmd + } + return m, nil - case "k": + case "k": + if tbl := m.activeTable(); tbl != nil { var cmd tea.Cmd - m.projectTable, cmd = m.projectTable.Update(tea.KeyMsg{Type: tea.KeyUp}) + *tbl, cmd = tbl.Update(tea.KeyMsg{Type: tea.KeyUp}) return m, cmd + } + return m, nil + + case "N": + // Sort by NAME column (index 0) — works for all table views. + if tbl := m.activeTable(); tbl != nil { + tbl.SortByColumn(0) + } + return m, nil + + case "A": + // Sort by AGE column — last column in all views. + if tbl := m.activeTable(); tbl != nil { + cols := tbl.Columns() + // AGE is the last column in all table views. + tbl.SortByColumn(len(cols) - 1) + } + return m, nil + } + + // View-specific rune keybindings. + switch m.activeView { + case "projects": + return m.handleProjectsRune(key) + case "agents": + return m.handleAgentsRune(key) + case "sessions": + return m.handleSessionsRune(key) + case "inbox": + return m.handleInboxRune(key) + } + + return m, nil +} + +// handleProjectsRune handles project-view-specific hotkeys. +func (m *AppModel) handleProjectsRune(key string) (tea.Model, tea.Cmd) { + switch key { + case "d": + row := m.projectTable.SelectedRow() + if len(row) > 0 { + return m, m.setInfo("Describe project: not yet implemented") + } + case "n": + return m, m.setInfo("New project: not yet implemented") + } + return m, nil +} - case "N": - // Sort by NAME column (index 0). - m.projectTable.SortByColumn(0) - return m, nil +// handleAgentsRune handles agent-view-specific hotkeys. +func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { + switch key { + case "i": + // Drill into inbox for selected agent. + row := m.agentTable.SelectedRow() + if len(row) > 0 { + agentName := row[0] + m.currentAgent = agentName + m.currentAgentID = agentName + m.inboxTable.SetScope(agentName) + cmd := m.pushView("inbox", agentName, "") + return m, tea.Batch(cmd, m.setInfo("Viewing inbox for agent "+agentName)) + } + case "s": + return m, m.setInfo("Start agent: not yet implemented") + case "x": + return m, m.setInfo("Stop agent: not yet implemented") + case "e": + return m, m.setInfo("Edit agent: not yet implemented") + case "l": + // Logs — if agent has an active session, jump to message stream. + row := m.agentTable.SelectedRow() + if len(row) > 2 && row[2] != "" && row[2] != "" { + agentName := row[0] + sessionID := row[2] + m.currentAgent = agentName + m.currentAgentID = agentName + m.currentSession = sessionID + phase := "" + if len(row) > 3 { + phase = row[3] + } + m.messageStream = views.NewMessageStream(sessionID, agentName, phase) + m.resizeTable() + m.messageStream.AddMessage(views.MessageEntry{ + Seq: 1, + EventType: "system", + Payload: "Connected to session " + sessionID + " (SSE not yet wired)", + Timestamp: time.Now(), + }) + cmd := m.pushView("messages", sessionID, sessionID) + return m, tea.Batch(cmd, m.setInfo("Streaming messages for session "+sessionID)) + } + return m, m.setInfo("No active session for this agent") + case "d": + row := m.agentTable.SelectedRow() + if len(row) > 0 { + return m, m.setInfo("Describe agent: not yet implemented") + } + case "m": + return m, m.setInfo("Send inbox message: not yet implemented") + case "n": + return m, m.setInfo("New agent: not yet implemented") + case "y": + return m, m.setInfo("YAML dump: not yet implemented") + } + return m, nil +} - case "A": - // Sort by AGE column (index 3). - m.projectTable.SortByColumn(3) - return m, nil +// handleSessionsRune handles session-view-specific hotkeys. +func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { + switch key { + case "d": + row := m.sessionTable.SelectedRow() + if len(row) > 0 { + return m, m.setInfo("Describe session: not yet implemented") } + case "l": + // Same as Enter — drill into message stream. + return m.handleEnter() + case "m": + return m, m.setInfo("Send message to session: not yet implemented") + case "y": + return m, m.setInfo("YAML dump: not yet implemented") } + return m, nil +} +// handleInboxRune handles inbox-view-specific hotkeys. +func (m *AppModel) handleInboxRune(key string) (tea.Model, tea.Cmd) { + switch key { + case "m": + return m, m.setInfo("Compose inbox message: not yet implemented") + case "r": + return m, m.setInfo("Mark as read: not yet implemented") + } return m, nil } +// handleCtrlD handles the delete/cancel keybinding across all views. +func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { + switch m.activeView { + case "projects": + row := m.projectTable.SelectedRow() + if len(row) > 0 { + return m, m.setInfo("Delete project: not yet implemented") + } + case "agents": + row := m.agentTable.SelectedRow() + if len(row) > 0 { + return m, m.setInfo("Delete agent: not yet implemented") + } + case "sessions": + row := m.sessionTable.SelectedRow() + if len(row) > 0 { + return m, m.setInfo("Delete/cancel session: not yet implemented") + } + case "inbox": + row := m.inboxTable.SelectedRow() + if len(row) > 0 { + return m, m.setInfo("Delete inbox message: not yet implemented") + } + } + return m, nil +} + +// handleMessagesKey delegates key events to the message stream sub-model. +func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.messageStream, cmd = m.messageStream.Update(msg) + return m, cmd +} + +// viewSpecificHelp returns a help info message based on the active view. +func (m *AppModel) viewSpecificHelp() tea.Cmd { + switch m.activeView { + case "projects": + return m.setInfo("Help: Enter drill | d describe | n new | Ctrl-D delete | : cmd | / filter | q quit") + case "agents": + return m.setInfo("Help: Enter sessions | i inbox | s start | x stop | e edit | l logs | d describe | m send | n new | Ctrl-D delete") + case "sessions": + return m.setInfo("Help: Enter/l messages | d describe | m send | y YAML | Ctrl-D delete | q back") + case "inbox": + return m.setInfo("Help: Enter view | m compose | r mark read | Ctrl-D delete | q back") + case "messages": + return m.setInfo("Help: Esc back | r raw | s scroll | m send | G bottom | g top | / search") + default: + return m.setInfo("Help: q quit | : command | / filter | Enter drill-in | Esc back") + } +} + // handleCommandKey processes keys while in command mode. func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { @@ -433,12 +982,108 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { case CmdProjects: // Reset nav stack to projects root. m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + m.currentProject = "" + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.activeFilter = nil m.pollInFlight = true return m, tea.Batch( m.client.FetchProjects(), m.setInfo("Viewing projects"), ) + case CmdAgents: + // Use current project from nav stack or config. + project := m.currentProject + if project == "" { + if ctx := m.config.Current(); ctx != nil { + project = ctx.Project + } + } + if project == "" { + return m, m.setInfo("No project context — drill into a project first or set one with :project ") + } + m.currentProject = project + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.agentTable.SetScope(project) + // Reset nav stack to project > agents. + m.navStack = []NavEntry{ + {Kind: "projects", Scope: "all"}, + {Kind: "agents", Scope: project}, + } + m.activeView = "agents" + m.activeFilter = nil + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchAgents(project), + m.setInfo("Viewing agents in project "+project), + ) + + case CmdSessions: + // Global if no agent context, scoped if we have one. + m.currentSession = "" + m.activeFilter = nil + + if m.currentAgentID != "" && m.currentProject != "" { + // Agent-scoped sessions. + m.sessionTable.SetScope(m.currentAgent) + m.navStack = append(m.navStack[:0], + NavEntry{Kind: "projects", Scope: "all"}, + NavEntry{Kind: "agents", Scope: m.currentProject}, + NavEntry{Kind: "sessions", Scope: m.currentAgent}, + ) + m.activeView = "sessions" + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchSessions(m.currentProject), + m.setInfo("Viewing sessions for agent "+m.currentAgent), + ) + } + + // Global sessions view. + m.sessionTable.SetScope("all") + m.navStack = []NavEntry{ + {Kind: "projects", Scope: "all"}, + {Kind: "sessions", Scope: "all"}, + } + m.activeView = "sessions" + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchAllSessions(), + m.setInfo("Viewing all sessions"), + ) + + case CmdInbox: + if m.currentAgentID == "" || m.currentProject == "" { + return m, m.setInfo("No agent context — drill into an agent first or use :agents then i") + } + m.inboxTable.SetScope(m.currentAgent) + m.activeView = "inbox" + m.activeFilter = nil + // Rebuild nav to include inbox. + m.navStack = append(m.navStack[:0], + NavEntry{Kind: "projects", Scope: "all"}, + NavEntry{Kind: "agents", Scope: m.currentProject}, + NavEntry{Kind: "inbox", Scope: m.currentAgent}, + ) + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchInbox(m.currentProject, m.currentAgentID), + m.setInfo("Viewing inbox for agent "+m.currentAgent), + ) + + case CmdMessages: + if m.currentSession == "" { + return m, m.setInfo("No session context — drill into a session first") + } + m.activeView = "messages" + m.activeFilter = nil + return m, m.setInfo("Streaming messages for session "+m.currentSession) + case CmdContext: if cmd.Arg == "" { // List contexts. @@ -449,7 +1094,14 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { if err := m.config.SwitchContext(cmd.Arg); err != nil { return m, m.setInfo("Error: "+err.Error()) } + // Reset everything on context switch. m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + m.currentProject = "" + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.activeFilter = nil return m, m.setInfo("Switched to context "+cmd.Arg) case CmdProject: @@ -458,6 +1110,7 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { if ctx != nil { ctx.Project = cmd.Arg } + m.currentProject = cmd.Arg return m, m.setInfo("Switched to project "+cmd.Arg) } return m, nil @@ -474,10 +1127,6 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { } return m, m.setInfo("Commands: " + fmt.Sprintf("%d available", len(entries))) - case CmdAgents, CmdSessions, CmdInbox, CmdMessages: - // Not implemented in Wave 0. - return m, m.setInfo(fmt.Sprintf(":%s not yet implemented (Wave 1+)", input)) - default: return m, m.setInfo("Unknown command: "+input) } @@ -509,7 +1158,7 @@ func (m *AppModel) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.filterInput.Reset() m.filterInput.Blur() m.activeFilter = nil - m.projectTable.ClearFilter() + m.clearActiveTableFilter() m.resizeTable() return m, m.setInfo("Filter cleared") @@ -521,7 +1170,7 @@ func (m *AppModel) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if input == "" { m.activeFilter = nil - m.projectTable.ClearFilter() + m.clearActiveTableFilter() return m, m.setInfo("Filter cleared") } @@ -531,9 +1180,7 @@ func (m *AppModel) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.activeFilter = f - m.projectTable.SetFilter(func(cols []string) bool { - return f.MatchRow(cols) - }) + m.applyFilterToActiveTable(f) return m, m.setInfo("Filter applied: "+f.String()) default: @@ -545,12 +1192,12 @@ func (m *AppModel) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } -// applyLiveFilter updates the table filter on every keystroke. +// applyLiveFilter updates the active table filter on every keystroke. func (m *AppModel) applyLiveFilter() { input := m.filterInput.Value() if input == "" { m.activeFilter = nil - m.projectTable.ClearFilter() + m.clearActiveTableFilter() return } f, err := ParseFilter(input) @@ -558,7 +1205,21 @@ func (m *AppModel) applyLiveFilter() { return // don't apply invalid regex while typing } m.activeFilter = f - m.projectTable.SetFilter(func(cols []string) bool { - return f.MatchRow(cols) - }) + m.applyFilterToActiveTable(f) +} + +// applyFilterToActiveTable applies a filter to whichever table is currently active. +func (m *AppModel) applyFilterToActiveTable(f *Filter) { + if tbl := m.activeTable(); tbl != nil { + tbl.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } +} + +// clearActiveTableFilter removes the filter from the currently active table. +func (m *AppModel) clearActiveTableFilter() { + if tbl := m.activeTable(); tbl != nil { + tbl.ClearFilter() + } } From f400ee5f8c2d06fffae9f3d49bf352f718f35cd5 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:46:27 -0400 Subject: [PATCH 019/117] feat(cli): :context shows contexts in a table view - Add views/contexts.go with columns: ACTIVE, NAME, SERVER, PROJECT - :ctx with no arg renders a full table (not just a toast) - Enter on a context row switches to it - Contexts table resizes with the terminal like all other views Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 2 + .../cmd/acpctl/ambient/tui/model_new.go | 51 +++++++++++++++++-- .../cmd/acpctl/ambient/tui/views/contexts.go | 29 +++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/contexts.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index f95f2c74e..7f687a7f2 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -173,6 +173,8 @@ func (m *AppModel) viewResourceTable() string { return m.sessionTable.View() case "inbox": return m.inboxTable.View() + case "contexts": + return m.contextTable.View() case "messages": return m.messageStream.View() default: diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 9c0cbd7f5..d62688d36 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -62,10 +62,11 @@ type AppModel struct { agentTable views.ResourceTable sessionTable views.ResourceTable inboxTable views.ResourceTable + contextTable views.ResourceTable messageStream views.MessageStream // Current view determines which table/view is active - activeView string // "projects", "agents", "sessions", "messages", "inbox" + activeView string // "projects", "agents", "sessions", "messages", "inbox", "contexts" // Context for scoped views currentProject string // set when drilling into a project @@ -123,6 +124,7 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { at := views.NewAgentTable("all", views.DefaultTableStyle()) st := views.NewSessionTable("all", views.DefaultTableStyle()) it := views.NewInboxTable("all", views.DefaultTableStyle()) + ct := views.NewContextTable(views.DefaultTableStyle()) m := &AppModel{ config: cfg, @@ -135,6 +137,7 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { agentTable: at, sessionTable: st, inboxTable: it, + contextTable: ct, commandInput: ci, filterInput: fi, } @@ -272,11 +275,28 @@ func (m *AppModel) activeTable() *views.ResourceTable { return &m.sessionTable case "inbox": return &m.inboxTable + case "contexts": + return &m.contextTable default: return nil } } +// populateContextTable fills the context table from config. +func (m *AppModel) populateContextTable() { + names := m.config.ContextNames() + rows := make([]table.Row, 0, len(names)) + for _, name := range names { + ctx := m.config.Contexts[name] + if ctx == nil { + continue + } + active := name == m.config.CurrentContext + rows = append(rows, views.ContextRow(name, ctx.Server, ctx.Project, active)) + } + m.contextTable.SetRows(rows) +} + // --------------------------------------------------------------------------- // Update // --------------------------------------------------------------------------- @@ -377,6 +397,8 @@ func (m *AppModel) resizeTable() { m.sessionTable.SetWidth(m.width) m.inboxTable.SetHeight(tableHeight) m.inboxTable.SetWidth(m.width) + m.contextTable.SetHeight(tableHeight) + m.contextTable.SetWidth(m.width) // Message stream gets the full table area. m.messageStream.SetSize(m.width, tableHeight+2) // +2 to account for title bar space @@ -632,6 +654,24 @@ func (m *AppModel) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // handleEnter processes the Enter key based on the active view. func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { switch m.activeView { + case "contexts": + row := m.contextTable.SelectedRow() + if len(row) > 1 { + contextName := row[1] // NAME column (index 1, after ACTIVE) + if err := m.config.SwitchContext(contextName); err != nil { + return m, m.setInfo("Error: "+err.Error()) + } + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + m.currentProject = "" + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.activeFilter = nil + m.pollInFlight = true + return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Switched to context "+contextName)) + } + case "projects": row := m.projectTable.SelectedRow() if len(row) > 0 { @@ -1086,9 +1126,12 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { case CmdContext: if cmd.Arg == "" { - // List contexts. - names := m.config.ContextNames() - return m, m.setInfo("Contexts: "+fmt.Sprintf("%v", names)) + // Show contexts in a table view. + m.populateContextTable() + m.navStack = []NavEntry{{Kind: "contexts", Scope: "all"}} + m.activeView = "contexts" + m.resizeTable() + return m, m.setInfo("Viewing contexts") } // Switch context. if err := m.config.SwitchContext(cmd.Arg); err != nil { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/contexts.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/contexts.go new file mode 100644 index 000000000..1ea644af7 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/contexts.go @@ -0,0 +1,29 @@ +package views + +import ( + "github.com/charmbracelet/bubbles/table" +) + +// ContextColumns returns the column definitions for the context list view. +func ContextColumns() []table.Column { + return []table.Column{ + {Title: "ACTIVE", Width: 6}, + {Title: "NAME", Width: 25}, + {Title: "SERVER", Width: 45}, + {Title: "PROJECT", Width: 20}, + } +} + +// ContextRow converts a context entry into a table row. +func ContextRow(name, server, project string, active bool) table.Row { + indicator := "" + if active { + indicator = "(*)" + } + return table.Row{indicator, name, server, project} +} + +// NewContextTable creates a ResourceTable configured for the context list view. +func NewContextTable(style TableStyle) ResourceTable { + return NewResourceTable("contexts", "all", ContextColumns(), style) +} From ad39f21d435023c6723ace08b21d67fb16bb9e6a Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:52:02 -0400 Subject: [PATCH 020/117] feat(cli): CRUD + SSE client methods and detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client.go: 13 new SDK operations — agent start/stop/create/update/ delete, project create/delete, session delete, send message, inbox send/mark-read/delete, SSE WatchSessionMessages with reconnect - views/detail.go: scrollable key-value detail view for d hotkey, with resource-specific helpers for project/agent/session/inbox Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/client.go | 410 ++++++++++++ .../cmd/acpctl/ambient/tui/views/detail.go | 606 ++++++++++++++++++ 2 files changed, 1016 insertions(+) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index 2977de360..3558266cb 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -48,6 +48,87 @@ type InboxMsg struct { Err error } +// --------------------------------------------------------------------------- +// CRUD message types for mutating operations. +// --------------------------------------------------------------------------- + +// StartAgentMsg carries the result of starting an agent. +type StartAgentMsg struct { + Response *sdktypes.StartResponse + Err error +} + +// StopAgentMsg carries the result of stopping an agent's current session. +// The SDK has no AgentAPI.Stop — stopping an agent means stopping its current +// session via SessionAPI.Stop. The caller must resolve the agent's +// current_session_id before calling StopAgent. +type StopAgentMsg struct { + Session *sdktypes.Session + Err error +} + +// CreateAgentMsg carries the result of creating an agent. +type CreateAgentMsg struct { + Agent *sdktypes.Agent + Err error +} + +// UpdateAgentMsg carries the result of patching an agent. +type UpdateAgentMsg struct { + Agent *sdktypes.Agent + Err error +} + +// DeleteAgentMsg carries the result of deleting an agent. +type DeleteAgentMsg struct { + Err error +} + +// CreateProjectMsg carries the result of creating a project. +type CreateProjectMsg struct { + Project *sdktypes.Project + Err error +} + +// DeleteProjectMsg carries the result of deleting a project. +type DeleteProjectMsg struct { + Err error +} + +// DeleteSessionMsg carries the result of deleting a session. +type DeleteSessionMsg struct { + Err error +} + +// SendMessageMsg carries the result of sending a message to a session. +type SendMessageMsg struct { + Message *sdktypes.SessionMessage + Err error +} + +// SendInboxMsg carries the result of sending an inbox message to an agent. +type SendInboxMsg struct { + Message *sdktypes.InboxMessage + Err error +} + +// MarkInboxReadMsg carries the result of marking an inbox message as read. +type MarkInboxReadMsg struct { + Err error +} + +// DeleteInboxMsg carries the result of deleting an inbox message. +type DeleteInboxMsg struct { + Err error +} + +// SessionMessageEvent carries a single session message received from an SSE +// stream. Sent to the Bubbletea program via program.Send(). +type SessionMessageEvent struct { + Message *sdktypes.SessionMessage + Err error +} + // --------------------------------------------------------------------------- // TUIClient wraps connection.ClientFactory and provides clean data-fetching // methods that return tea.Cmd functions for asynchronous execution inside the @@ -63,6 +144,10 @@ type InboxMsg struct { // asynchronously. type TUIClient struct { factory *connection.ClientFactory + + // watchMu protects watchCancel. + watchMu sync.Mutex + watchCancel context.CancelFunc } // NewTUIClient creates a TUIClient from the given ClientFactory. @@ -216,3 +301,328 @@ func (tc *TUIClient) FetchInbox(projectID, agentID string) tea.Cmd { return InboxMsg{Messages: list.Items} } } + +// --------------------------------------------------------------------------- +// Agent CRUD +// --------------------------------------------------------------------------- + +// StartAgent returns a tea.Cmd that starts an agent by calling +// POST /projects/{projectID}/agents/{agentID}/start with the given prompt. +func (tc *TUIClient) StartAgent(projectID, agentID, prompt string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return StartAgentMsg{Err: err} + } + + resp, err := client.Agents().Start(ctx, projectID, agentID, prompt) + if err != nil { + return StartAgentMsg{Err: err} + } + return StartAgentMsg{Response: resp} + } +} + +// StopAgent returns a tea.Cmd that stops an agent's current session. +// The SDK has no AgentAPI.Stop method. Stopping an agent is done by stopping +// its current session via SessionAPI.Stop. The caller must provide the +// session ID (from agent.CurrentSessionID). +func (tc *TUIClient) StopAgent(projectID, sessionID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return StopAgentMsg{Err: err} + } + + session, err := client.Sessions().Stop(ctx, sessionID) + if err != nil { + return StopAgentMsg{Err: err} + } + return StopAgentMsg{Session: session} + } +} + +// CreateAgent returns a tea.Cmd that creates a new agent in the given project. +func (tc *TUIClient) CreateAgent(projectID, name, prompt string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return CreateAgentMsg{Err: err} + } + + agent := &sdktypes.Agent{ + Name: name, + ProjectID: projectID, + Prompt: prompt, + } + + result, err := client.Agents().CreateInProject(ctx, projectID, agent) + if err != nil { + return CreateAgentMsg{Err: err} + } + return CreateAgentMsg{Agent: result} + } +} + +// UpdateAgent returns a tea.Cmd that patches an agent with the given fields. +func (tc *TUIClient) UpdateAgent(projectID, agentID string, patch map[string]any) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return UpdateAgentMsg{Err: err} + } + + result, err := client.Agents().UpdateInProject(ctx, projectID, agentID, patch) + if err != nil { + return UpdateAgentMsg{Err: err} + } + return UpdateAgentMsg{Agent: result} + } +} + +// DeleteAgent returns a tea.Cmd that deletes an agent from the given project. +func (tc *TUIClient) DeleteAgent(projectID, agentID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return DeleteAgentMsg{Err: err} + } + + err = client.Agents().DeleteInProject(ctx, projectID, agentID) + return DeleteAgentMsg{Err: err} + } +} + +// --------------------------------------------------------------------------- +// Project CRUD +// --------------------------------------------------------------------------- + +// CreateProject returns a tea.Cmd that creates a new project. +func (tc *TUIClient) CreateProject(name, description string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + // Projects are a global resource; any project-scoped client can + // create them. Use a minimal project name for the SDK constructor. + client, err := tc.factory.ForProject("_") + if err != nil { + return CreateProjectMsg{Err: err} + } + + proj := &sdktypes.Project{ + Name: name, + Description: description, + } + + result, err := client.Projects().Create(ctx, proj) + if err != nil { + return CreateProjectMsg{Err: err} + } + return CreateProjectMsg{Project: result} + } +} + +// DeleteProject returns a tea.Cmd that deletes a project by ID. +func (tc *TUIClient) DeleteProject(projectID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject("_") + if err != nil { + return DeleteProjectMsg{Err: err} + } + + err = client.Projects().Delete(ctx, projectID) + return DeleteProjectMsg{Err: err} + } +} + +// --------------------------------------------------------------------------- +// Session operations +// --------------------------------------------------------------------------- + +// DeleteSession returns a tea.Cmd that deletes a session by ID. +func (tc *TUIClient) DeleteSession(projectID, sessionID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return DeleteSessionMsg{Err: err} + } + + err = client.Sessions().Delete(ctx, sessionID) + return DeleteSessionMsg{Err: err} + } +} + +// SendSessionMessage returns a tea.Cmd that sends a user message to a +// session. This supports the "Send-While-Streaming" pattern: the call is +// non-blocking and the message appears in the SSE stream when the server +// echoes it back. +func (tc *TUIClient) SendSessionMessage(projectID, sessionID, body string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return SendMessageMsg{Err: err} + } + + msg, err := client.Sessions().PushMessage(ctx, sessionID, body) + if err != nil { + return SendMessageMsg{Err: err} + } + return SendMessageMsg{Message: msg} + } +} + +// --------------------------------------------------------------------------- +// Inbox operations +// --------------------------------------------------------------------------- + +// SendInboxMessage returns a tea.Cmd that sends an inbox message to an agent. +func (tc *TUIClient) SendInboxMessage(projectID, agentID, body string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return SendInboxMsg{Err: err} + } + + msg := &sdktypes.InboxMessage{ + AgentID: agentID, + Body: body, + } + + result, err := client.InboxMessages().Send(ctx, projectID, agentID, msg) + if err != nil { + return SendInboxMsg{Err: err} + } + return SendInboxMsg{Message: result} + } +} + +// MarkInboxRead returns a tea.Cmd that marks an inbox message as read. +func (tc *TUIClient) MarkInboxRead(projectID, agentID, msgID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return MarkInboxReadMsg{Err: err} + } + + err = client.InboxMessages().MarkRead(ctx, projectID, agentID, msgID) + return MarkInboxReadMsg{Err: err} + } +} + +// DeleteInboxMessage returns a tea.Cmd that deletes an inbox message. +func (tc *TUIClient) DeleteInboxMessage(projectID, agentID, msgID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return DeleteInboxMsg{Err: err} + } + + err = client.InboxMessages().DeleteMessage(ctx, projectID, agentID, msgID) + return DeleteInboxMsg{Err: err} + } +} + +// --------------------------------------------------------------------------- +// SSE streaming +// --------------------------------------------------------------------------- + +// WatchSessionMessages returns a tea.Cmd that starts an SSE stream for +// session messages. Messages are delivered to the Bubbletea program via +// program.Send(SessionMessageEvent{...}). +// +// The SSE goroutine: +// - Connects to GET /sessions/{id}/messages via the SDK's WatchMessages. +// - Forwards each message as a SessionMessageEvent to the program. +// - Handles reconnection with exponential backoff (1s, 2s, 4s, max 30s) +// internally via the SDK's WatchMessages implementation. +// - Is cancellable via StopWatching(). +// +// Only one watch can be active at a time. Calling WatchSessionMessages while +// a previous watch is running cancels the old one first. +func (tc *TUIClient) WatchSessionMessages(projectID, sessionID string, afterSeq int, program *tea.Program) tea.Cmd { + return func() tea.Msg { + // Cancel any previously active watch. + tc.StopWatching() + + ctx, cancel := context.WithCancel(context.Background()) + + tc.watchMu.Lock() + tc.watchCancel = cancel + tc.watchMu.Unlock() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + cancel() + program.Send(SessionMessageEvent{Err: err}) + return nil + } + + // The SDK's WatchMessages handles SSE connection, parsing, and + // reconnection with exponential backoff (1s, 2s, 4s, max 30s). + // It returns a channel of *SessionMessage and a stop function. + msgs, _, sseErr := client.Sessions().WatchMessages(ctx, sessionID, afterSeq) + if sseErr != nil { + cancel() + program.Send(SessionMessageEvent{Err: sseErr}) + return nil + } + + // Forward messages from the SDK channel to the Bubbletea program. + // This goroutine exits when the channel closes (on context + // cancellation or stream end). + go func() { + defer cancel() + for msg := range msgs { + program.Send(SessionMessageEvent{Message: msg}) + } + }() + + return nil + } +} + +// StopWatching cancels any active SSE watch goroutine started by +// WatchSessionMessages. +func (tc *TUIClient) StopWatching() { + tc.watchMu.Lock() + defer tc.watchMu.Unlock() + + if tc.watchCancel != nil { + tc.watchCancel() + tc.watchCancel = nil + } +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go new file mode 100644 index 000000000..e60f66dc9 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go @@ -0,0 +1,606 @@ +package views + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/atotto/clipboard" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +// Local color constants for the detail view. Defined here instead of importing +// from the parent tui package to avoid circular imports. +var ( + detailBorderColor = lipgloss.Color("240") // dim for borders + detailKeyColor = lipgloss.Color("240") // dim for field names + detailValueColor = lipgloss.Color("255") // white for values + detailTitleColor = lipgloss.Color("36") // cyan for title + detailHintColor = lipgloss.Color("240") // dim for hints +) + +// DetailBackMsg is sent when the user presses Esc or q to navigate back from +// the detail view to the parent list view. +type DetailBackMsg struct{} + +// DetailLine represents a single key-value line in the detail view. +type DetailLine struct { + Key string // field name (e.g. "Name", "Prompt", "Phase") + Value string // field value + Color lipgloss.Color // optional color override for the value; empty string uses default +} + +// DetailView is a Bubbletea sub-model that renders a scrollable key-value +// detail pane for a single resource. It handles Esc (back), j/k/arrow/scroll +// for scrolling, and c to copy the selected value. +type DetailView struct { + title string + lines []DetailLine + scroll int + cursor int + width int + height int +} + +// NewDetailView creates a DetailView with the given title and detail lines. +// The title is shown in the bordered header (e.g. "Project: my-project"). +func NewDetailView(title string, lines []DetailLine) DetailView { + return DetailView{ + title: title, + lines: lines, + scroll: 0, + cursor: 0, + width: 80, + height: 24, + } +} + +// SetSize updates the available width and height for rendering. +func (dv *DetailView) SetSize(w, h int) { + dv.width = w + dv.height = h +} + +// Update handles key and mouse messages for the detail view. It returns the +// updated DetailView and an optional tea.Cmd (DetailBackMsg for navigation). +func (dv *DetailView) Update(msg tea.Msg) (DetailView, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "esc", "q": + return *dv, func() tea.Msg { return DetailBackMsg{} } + case "j", "down": + dv.moveCursor(1) + case "k", "up": + dv.moveCursor(-1) + case "g", "home": + dv.cursor = 0 + dv.scroll = 0 + case "G", "end": + rendered := dv.renderedLines() + if len(rendered) > 0 { + dv.cursor = len(rendered) - 1 + } + dv.ensureCursorVisible() + case "pgdown": + dv.moveCursor(dv.viewportHeight()) + case "pgup": + dv.moveCursor(-dv.viewportHeight()) + case "c": + // Copy value of the current detail line to clipboard via OSC 52. + // The actual clipboard integration is handled by the terminal. + if dv.cursor >= 0 && dv.cursor < len(dv.lines) { + return *dv, copyToClipboard(dv.lines[dv.cursor].Value) + } + } + + case tea.MouseMsg: + switch msg.Button { + case tea.MouseButtonWheelUp: + dv.moveCursor(-3) + case tea.MouseButtonWheelDown: + dv.moveCursor(3) + } + } + + return *dv, nil +} + +// View renders the detail view as a bordered box with a title, scrollable +// key-value pairs, and a hint line at the bottom. +func (dv *DetailView) View() string { + borderStyle := lipgloss.NewStyle().Foreground(detailBorderColor) + titleStyle := lipgloss.NewStyle().Foreground(detailTitleColor).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(detailHintColor) + + contentWidth := dv.width + if contentWidth < 20 { + contentWidth = 80 + } + innerWidth := contentWidth - 4 // 2 for borders + 2 for padding + + // Render title bar. + titleText := " " + titleStyle.Render(dv.title) + " " + titleVisualWidth := lipgloss.Width(titleText) + remaining := contentWidth - titleVisualWidth - 2 // 2 for corner chars + if remaining < 2 { + remaining = 2 + } + leftDashes := remaining / 2 + rightDashes := remaining - leftDashes + + titleBar := borderStyle.Render("┌"+strings.Repeat("─", leftDashes)) + + titleText + + borderStyle.Render(strings.Repeat("─", rightDashes)+"┐") + + // Compute the maximum key width for right-aligned key column. + rendered := dv.renderedLines() + maxKeyWidth := dv.maxKeyWidth() + if maxKeyWidth > innerWidth/3 { + maxKeyWidth = innerWidth / 3 + } + + // Determine visible window. + vpHeight := dv.viewportHeight() + start := dv.scroll + end := start + vpHeight + if end > len(rendered) { + end = len(rendered) + } + + keyStyle := lipgloss.NewStyle(). + Foreground(detailKeyColor). + Width(maxKeyWidth). + Align(lipgloss.Right) + defaultValueStyle := lipgloss.NewStyle().Foreground(detailValueColor) + + // Render visible lines. + var bodyLines []string + for i := start; i < end; i++ { + line := rendered[i] + var lineStr string + if line.Key == "" { + // Continuation line (wrapped value) — indent to match value column. + pad := strings.Repeat(" ", maxKeyWidth+3) // key width + " " separator + 1 + valStyle := defaultValueStyle + if line.Color != "" { + valStyle = lipgloss.NewStyle().Foreground(line.Color) + } + valText := line.Value + if lipgloss.Width(valText) > innerWidth-maxKeyWidth-3 { + valText = TruncateString(valText, innerWidth-maxKeyWidth-3) + } + lineStr = pad + valStyle.Render(valText) + } else { + // Key-value line. + valStyle := defaultValueStyle + if line.Color != "" { + valStyle = lipgloss.NewStyle().Foreground(line.Color) + } + keyText := keyStyle.Render(line.Key) + valText := line.Value + if lipgloss.Width(valText) > innerWidth-maxKeyWidth-3 { + valText = TruncateString(valText, innerWidth-maxKeyWidth-3) + } + lineStr = keyText + " " + valStyle.Render(valText) + } + + // Highlight selected line. + if i == dv.cursor { + lineStr = lipgloss.NewStyle(). + Background(lipgloss.Color("236")). + Render(lineStr) + } + + lineVisualWidth := lipgloss.Width(lineStr) + pad := "" + if lineVisualWidth < innerWidth { + pad = strings.Repeat(" ", innerWidth-lineVisualWidth) + } + bodyLines = append(bodyLines, + borderStyle.Render("│")+" "+lineStr+pad+" "+borderStyle.Render("│")) + } + + // Fill remaining viewport with empty lines. + for i := len(bodyLines); i < vpHeight; i++ { + empty := strings.Repeat(" ", innerWidth+2) + bodyLines = append(bodyLines, + borderStyle.Render("│")+empty+borderStyle.Render("│")) + } + + // Scroll indicator. + scrollInfo := "" + if len(rendered) > vpHeight { + pct := 0 + if len(rendered)-vpHeight > 0 { + pct = (dv.scroll * 100) / (len(rendered) - vpHeight) + } + scrollInfo = fmt.Sprintf(" %d%% ", pct) + } + + // Bottom border with hints. + hint := hintStyle.Render(" Esc:back j/k:scroll c:copy ") + hintWidth := lipgloss.Width(hint) + scrollWidth := lipgloss.Width(scrollInfo) + bottomDashes := contentWidth - 2 - hintWidth - scrollWidth + if bottomDashes < 2 { + bottomDashes = 2 + } + bottom := borderStyle.Render("└") + + hint + + borderStyle.Render(strings.Repeat("─", bottomDashes)) + + hintStyle.Render(scrollInfo) + + borderStyle.Render("┘") + + return titleBar + "\n" + strings.Join(bodyLines, "\n") + "\n" + bottom +} + +// renderedLines returns the detail lines after wrapping long values to fit the +// available width. Continuation lines have an empty Key. +func (dv *DetailView) renderedLines() []DetailLine { + innerWidth := dv.width - 4 + maxKeyWidth := dv.maxKeyWidth() + if maxKeyWidth > innerWidth/3 { + maxKeyWidth = innerWidth / 3 + } + valueWidth := innerWidth - maxKeyWidth - 3 // 3 for " " separator + margin + if valueWidth < 20 { + valueWidth = 20 + } + + var result []DetailLine + for _, line := range dv.lines { + wrapped := detailWrapText(line.Value, valueWidth) + for i, segment := range wrapped { + if i == 0 { + result = append(result, DetailLine{ + Key: line.Key, + Value: segment, + Color: line.Color, + }) + } else { + result = append(result, DetailLine{ + Key: "", + Value: segment, + Color: line.Color, + }) + } + } + } + return result +} + +// maxKeyWidth computes the width of the longest key across all detail lines. +func (dv *DetailView) maxKeyWidth() int { + maxW := 0 + for _, line := range dv.lines { + if len(line.Key) > maxW { + maxW = len(line.Key) + } + } + return maxW +} + +// viewportHeight returns the number of content lines visible in the viewport. +// Reserves space for the title bar (1), bottom border (1). +func (dv *DetailView) viewportHeight() int { + h := dv.height - 2 + if h < 1 { + h = 1 + } + return h +} + +// moveCursor moves the cursor by delta lines, clamping to valid bounds and +// adjusting scroll to keep the cursor visible. +func (dv *DetailView) moveCursor(delta int) { + rendered := dv.renderedLines() + if len(rendered) == 0 { + return + } + + dv.cursor += delta + if dv.cursor < 0 { + dv.cursor = 0 + } + if dv.cursor >= len(rendered) { + dv.cursor = len(rendered) - 1 + } + dv.ensureCursorVisible() +} + +// ensureCursorVisible adjusts the scroll offset so the cursor is within the +// visible viewport. +func (dv *DetailView) ensureCursorVisible() { + vpHeight := dv.viewportHeight() + rendered := dv.renderedLines() + + if dv.cursor < dv.scroll { + dv.scroll = dv.cursor + } + if dv.cursor >= dv.scroll+vpHeight { + dv.scroll = dv.cursor - vpHeight + 1 + } + maxScroll := len(rendered) - vpHeight + if maxScroll < 0 { + maxScroll = 0 + } + if dv.scroll > maxScroll { + dv.scroll = maxScroll + } + if dv.scroll < 0 { + dv.scroll = 0 + } +} + +// detailWrapText splits text into lines of at most width runes, preserving +// existing newlines. It wraps on word boundaries when possible, falling back to +// hard wraps for long unbroken tokens. Empty input returns a single-element +// slice with an empty string. This variant preserves newlines (unlike the +// conversation-mode wrapText in messages.go which collapses them). +func detailWrapText(text string, width int) []string { + if width < 1 { + width = 1 + } + if text == "" { + return []string{""} + } + + // Split on existing newlines first. + rawLines := strings.Split(text, "\n") + var result []string + for _, raw := range rawLines { + if len([]rune(raw)) <= width { + result = append(result, raw) + continue + } + wrapped := detailWrapLine(raw, width) + result = append(result, wrapped...) + } + return result +} + +// wrapLine wraps a single line of text at word boundaries to fit within width. +func detailWrapLine(line string, width int) []string { + words := strings.Fields(line) + if len(words) == 0 { + return []string{""} + } + + var lines []string + current := "" + for _, word := range words { + wordRunes := []rune(word) + // If the word itself is too long, hard-wrap it. + if len(wordRunes) > width { + if current != "" { + lines = append(lines, current) + current = "" + } + for len(wordRunes) > 0 { + take := width + if take > len(wordRunes) { + take = len(wordRunes) + } + lines = append(lines, string(wordRunes[:take])) + wordRunes = wordRunes[take:] + } + continue + } + + if current == "" { + current = word + } else if len([]rune(current))+1+len(wordRunes) <= width { + current += " " + word + } else { + lines = append(lines, current) + current = word + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +// copyToClipboard returns a tea.Cmd that writes the value to the system +// clipboard using the atotto/clipboard package (already a dependency). +func copyToClipboard(value string) tea.Cmd { + return func() tea.Msg { + _ = clipboard.WriteAll(value) + return nil + } +} + +// formatTimePtr formats a *time.Time as a human-readable string. Returns an +// empty string for nil pointers. +func formatTimePtr(t *time.Time) string { + if t == nil { + return "" + } + return t.Format(time.RFC3339) +} + +// formatJSON attempts to pretty-print a JSON string. If the input is not valid +// JSON (or is empty), it is returned as-is. +func formatJSON(s string) string { + if s == "" { + return "" + } + // Try to parse as a JSON object or array. + var obj interface{} + if err := json.Unmarshal([]byte(s), &obj); err != nil { + return s + } + formatted, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return s + } + return string(formatted) +} + +// --- Resource-specific detail constructors --- + +// ProjectDetail returns detail lines for all fields of a Project resource. +func ProjectDetail(p sdktypes.Project) []DetailLine { + lines := []DetailLine{ + {Key: "ID", Value: p.ID}, + {Key: "Name", Value: p.Name}, + {Key: "Display Name", Value: p.DisplayName}, + {Key: "Description", Value: p.Description}, + {Key: "Status", Value: p.Status}, + {Key: "Prompt", Value: p.Prompt}, + {Key: "Labels", Value: formatJSON(p.Labels)}, + {Key: "Annotations", Value: formatJSON(p.Annotations)}, + {Key: "Kind", Value: p.Kind}, + {Key: "Href", Value: p.Href}, + {Key: "Created At", Value: formatTimePtr(p.CreatedAt)}, + {Key: "Updated At", Value: formatTimePtr(p.UpdatedAt)}, + } + return lines +} + +// AgentDetail returns detail lines for all fields of an Agent resource. +func AgentDetail(a sdktypes.Agent) []DetailLine { + lines := []DetailLine{ + {Key: "ID", Value: a.ID}, + {Key: "Name", Value: a.Name}, + {Key: "Display Name", Value: a.DisplayName}, + {Key: "Description", Value: a.Description}, + {Key: "Project ID", Value: a.ProjectID}, + {Key: "Prompt", Value: a.Prompt}, + {Key: "Current Session", Value: a.CurrentSessionID}, + {Key: "Owner User ID", Value: a.OwnerUserID}, + {Key: "Parent Agent ID", Value: a.ParentAgentID}, + {Key: "Bot Account", Value: a.BotAccountName}, + {Key: "LLM Model", Value: a.LlmModel}, + {Key: "LLM Max Tokens", Value: formatInt32(a.LlmMaxTokens)}, + {Key: "LLM Temperature", Value: formatFloat64(a.LlmTemperature)}, + {Key: "Repo URL", Value: a.RepoURL}, + {Key: "Workflow ID", Value: a.WorkflowID}, + {Key: "Resource Overrides", Value: formatJSON(a.ResourceOverrides)}, + {Key: "Env Variables", Value: formatJSON(a.EnvironmentVariables)}, + {Key: "Labels", Value: formatJSON(a.Labels)}, + {Key: "Annotations", Value: formatJSON(a.Annotations)}, + {Key: "Kind", Value: a.Kind}, + {Key: "Href", Value: a.Href}, + {Key: "Created At", Value: formatTimePtr(a.CreatedAt)}, + {Key: "Updated At", Value: formatTimePtr(a.UpdatedAt)}, + } + return lines +} + +// SessionDetail returns detail lines for all fields of a Session resource. +func SessionDetail(s sdktypes.Session) []DetailLine { + lines := []DetailLine{ + {Key: "ID", Value: s.ID}, + {Key: "Name", Value: s.Name}, + {Key: "Phase", Value: s.Phase, Color: phaseColor(s.Phase)}, + {Key: "Project ID", Value: s.ProjectID}, + {Key: "Agent ID", Value: s.AgentID}, + {Key: "Prompt", Value: s.Prompt}, + {Key: "Triggered By", Value: s.TriggeredByUserID}, + {Key: "Assigned User", Value: s.AssignedUserID}, + {Key: "Created By", Value: s.CreatedByUserID}, + {Key: "Bot Account", Value: s.BotAccountName}, + {Key: "Parent Session", Value: s.ParentSessionID}, + {Key: "Start Time", Value: formatTimePtr(s.StartTime)}, + {Key: "Completion Time", Value: formatTimePtr(s.CompletionTime)}, + {Key: "Duration", Value: formatDuration(s.StartTime, s.CompletionTime)}, + {Key: "Timeout", Value: formatInt(s.Timeout)}, + {Key: "LLM Model", Value: s.LlmModel}, + {Key: "LLM Max Tokens", Value: formatInt(s.LlmMaxTokens)}, + {Key: "LLM Temperature", Value: formatFloat64(s.LlmTemperature)}, + {Key: "Repo URL", Value: s.RepoURL}, + {Key: "Repos", Value: formatJSON(s.Repos)}, + {Key: "Reconciled Repos", Value: formatJSON(s.ReconciledRepos)}, + {Key: "Workflow ID", Value: s.WorkflowID}, + {Key: "Reconciled Workflow", Value: formatJSON(s.ReconciledWorkflow)}, + {Key: "Resource Overrides", Value: formatJSON(s.ResourceOverrides)}, + {Key: "Env Variables", Value: formatJSON(s.EnvironmentVariables)}, + {Key: "SDK Session ID", Value: s.SdkSessionID}, + {Key: "SDK Restart Count", Value: formatInt(s.SdkRestartCount)}, + {Key: "Conditions", Value: formatJSON(s.Conditions)}, + {Key: "Labels", Value: formatJSON(s.Labels)}, + {Key: "Annotations", Value: formatJSON(s.Annotations)}, + {Key: "Kube CR Name", Value: s.KubeCrName}, + {Key: "Kube CR UID", Value: s.KubeCrUid}, + {Key: "Kube Namespace", Value: s.KubeNamespace}, + {Key: "Kind", Value: s.Kind}, + {Key: "Href", Value: s.Href}, + {Key: "Created At", Value: formatTimePtr(s.CreatedAt)}, + {Key: "Updated At", Value: formatTimePtr(s.UpdatedAt)}, + } + return lines +} + +// InboxDetail returns detail lines for all fields of an InboxMessage resource. +func InboxDetail(msg sdktypes.InboxMessage) []DetailLine { + from := msg.FromName + if from == "" { + from = "(human)" + } + + readStr := "No" + if msg.Read { + readStr = "Yes" + } + + lines := []DetailLine{ + {Key: "ID", Value: msg.ID}, + {Key: "Agent ID", Value: msg.AgentID}, + {Key: "From", Value: from}, + {Key: "From Agent ID", Value: msg.FromAgentID}, + {Key: "Read", Value: readStr}, + {Key: "Body", Value: msg.Body}, + {Key: "Kind", Value: msg.Kind}, + {Key: "Href", Value: msg.Href}, + {Key: "Created At", Value: formatTimePtr(msg.CreatedAt)}, + {Key: "Updated At", Value: formatTimePtr(msg.UpdatedAt)}, + } + return lines +} + +// --- Numeric formatting helpers --- + +// formatInt formats an int as a string. Returns empty for zero values to keep +// the detail view clean (zero typically means "not set"). +func formatInt(v int) string { + if v == 0 { + return "" + } + return fmt.Sprintf("%d", v) +} + +// formatInt32 formats an int32 as a string. Returns empty for zero values. +func formatInt32(v int32) string { + if v == 0 { + return "" + } + return fmt.Sprintf("%d", v) +} + +// formatFloat64 formats a float64 as a string. Returns empty for zero values. +func formatFloat64(v float64) string { + if v == 0 { + return "" + } + return fmt.Sprintf("%.2f", v) +} + +// formatDuration computes and formats the duration between two time pointers. +// Returns empty if either is nil. +func formatDuration(start, end *time.Time) string { + if start == nil || end == nil { + return "" + } + d := end.Sub(*start) + if d < 0 { + return "0s" + } + return FormatAge(d) +} From af980e70e1a187b04a4c970b7d72f046c5621db4 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:59:20 -0400 Subject: [PATCH 021/117] feat(cli): wire CRUD, SSE, and detail views into TUI Replace all "not yet implemented" toasts with real SDK calls: - s: start agent via StartAgent API - x: stop agent via StopAgent (session stop) - Ctrl-D: delete resources via Delete* APIs - r: mark inbox message read - d/y: describe/YAML opens scrollable detail view - Enter on inbox: opens detail view SSE message streaming: - WatchSessionMessages starts on entering message stream view - SessionMessageEvent handler adds messages to the stream - StopWatching cancels SSE goroutine on leaving messages view - cmd.go wires SetProgram for SSE delivery Detail view navigation: - DetailBackMsg pops back to parent view - Full resource detail for project/agent/session/inbox Agent IDs resolved from cached data instead of name fallback. Interactive creation (n) and editing (e) deferred to acpctl CLI. Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/cmd.go | 1 + .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 2 + .../cmd/acpctl/ambient/tui/model_new.go | 481 ++++++++++++++++-- 3 files changed, 431 insertions(+), 53 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/cmd.go b/components/ambient-cli/cmd/acpctl/ambient/cmd.go index 328ccc40c..26af86916 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/cmd.go +++ b/components/ambient-cli/cmd/acpctl/ambient/cmd.go @@ -38,6 +38,7 @@ Data refreshes automatically every 5 seconds.`, return fmt.Errorf("init TUI: %w", err) } p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + m.SetProgram(p) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "TUI error: %v\n", err) return err diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 7f687a7f2..7fa592a08 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -177,6 +177,8 @@ func (m *AppModel) viewResourceTable() string { return m.contextTable.View() case "messages": return m.messageStream.View() + case "detail": + return m.detailView.View() default: return m.projectTable.View() } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index d62688d36..034bc128c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -10,6 +10,7 @@ import ( "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" "github.com/ambient-code/platform/components/ambient-cli/pkg/connection" + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" ) // pollInterval is the auto-refresh interval for resource tables. @@ -91,6 +92,18 @@ type AppModel struct { infoMessage string infoExpiry time.Time + // Detail view + detailView views.DetailView + + // Cached resource data for CRUD lookups (maps name/ID -> full resource). + cachedProjects []sdktypes.Project + cachedAgents []sdktypes.Agent + cachedSessions []sdktypes.Session + cachedInbox []sdktypes.InboxMessage + + // SSE program reference (set via SetProgram after tea.NewProgram). + program *tea.Program + // Errors lastError string @@ -145,6 +158,53 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { return m, nil } +// SetProgram stores a reference to the tea.Program so the model can pass it to +// WatchSessionMessages for SSE delivery. Call this after tea.NewProgram returns. +func (m *AppModel) SetProgram(p *tea.Program) { + m.program = p +} + +// findAgentByName returns the cached Agent with the given name, or nil. +func (m *AppModel) findAgentByName(name string) *sdktypes.Agent { + for i := range m.cachedAgents { + if m.cachedAgents[i].Name == name { + return &m.cachedAgents[i] + } + } + return nil +} + +// findProjectByName returns the cached Project with the given name, or nil. +func (m *AppModel) findProjectByName(name string) *sdktypes.Project { + for i := range m.cachedProjects { + if m.cachedProjects[i].Name == name { + return &m.cachedProjects[i] + } + } + return nil +} + +// findSessionByShortID returns the cached Session whose ID starts with the given +// short ID prefix, or nil. +func (m *AppModel) findSessionByShortID(shortID string) *sdktypes.Session { + for i := range m.cachedSessions { + if m.cachedSessions[i].ID == shortID || (len(m.cachedSessions[i].ID) >= len(shortID) && m.cachedSessions[i].ID[:len(shortID)] == shortID) { + return &m.cachedSessions[i] + } + } + return nil +} + +// findInboxByID returns the cached InboxMessage with the given ID, or nil. +func (m *AppModel) findInboxByID(id string) *sdktypes.InboxMessage { + for i := range m.cachedInbox { + if m.cachedInbox[i].ID == id { + return &m.cachedInbox[i] + } + } + return nil +} + // Init implements tea.Model. It returns a batch of initial commands: // window size query, first data fetch, and the periodic tick. func (m *AppModel) Init() tea.Cmd { @@ -264,7 +324,7 @@ func (m *AppModel) fetchActiveView() tea.Cmd { } // activeTable returns a pointer to the currently active ResourceTable, or nil -// for the message stream view. +// for the message stream and detail views. func (m *AppModel) activeTable() *views.ResourceTable { switch m.activeView { case "projects": @@ -313,12 +373,17 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.MouseMsg: - // Delegate scroll events to the active table or message stream. + // Delegate scroll events to the active table, message stream, or detail view. if m.activeView == "messages" { var cmd tea.Cmd m.messageStream, cmd = m.messageStream.Update(msg) return m, cmd } + if m.activeView == "detail" { + var cmd tea.Cmd + m.detailView, cmd = m.detailView.Update(msg) + return m, cmd + } if tbl := m.activeTable(); tbl != nil { var cmd tea.Cmd *tbl, cmd = tbl.Update(msg) @@ -340,12 +405,130 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case views.MsgStreamBackMsg: // User pressed Esc in the message stream — pop back. + m.client.StopWatching() cmd := m.popView() return m, tea.Batch(cmd, m.setInfo("Back to "+m.currentNav().Kind)) case views.MsgStreamSendMsg: // User composed a message to send to a session. - return m, m.setInfo("Send message: not yet implemented") + if msg.Body == "" { + return m, nil + } + return m, tea.Batch( + m.client.SendSessionMessage(m.currentProject, m.currentSession, msg.Body), + m.setInfo("Sending message..."), + ) + + case views.DetailBackMsg: + // User pressed Esc/q in the detail view — pop back. + cmd := m.popView() + return m, tea.Batch(cmd, m.setInfo("Back to "+m.currentNav().Kind)) + + case StartAgentMsg: + if msg.Err != nil { + return m, m.setInfo("Start agent failed: " + msg.Err.Error()) + } + sessionID := "" + if msg.Response != nil && msg.Response.Session != nil { + sessionID = msg.Response.Session.ID + } + info := "Agent started" + if sessionID != "" { + info += " (session " + sessionID + ")" + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo(info)) + + case StopAgentMsg: + if msg.Err != nil { + return m, m.setInfo("Stop agent failed: " + msg.Err.Error()) + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent stopped")) + + case CreateAgentMsg: + if msg.Err != nil { + return m, m.setInfo("Create agent failed: " + msg.Err.Error()) + } + name := "" + if msg.Agent != nil { + name = msg.Agent.Name + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent created: "+name)) + + case DeleteAgentMsg: + if msg.Err != nil { + return m, m.setInfo("Delete agent failed: " + msg.Err.Error()) + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent deleted")) + + case CreateProjectMsg: + if msg.Err != nil { + return m, m.setInfo("Create project failed: " + msg.Err.Error()) + } + name := "" + if msg.Project != nil { + name = msg.Project.Name + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Project created: "+name)) + + case DeleteProjectMsg: + if msg.Err != nil { + return m, m.setInfo("Delete project failed: " + msg.Err.Error()) + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Project deleted")) + + case DeleteSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Delete session failed: " + msg.Err.Error()) + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Session deleted")) + + case SendMessageMsg: + if msg.Err != nil { + return m, m.setInfo("Send message failed: " + msg.Err.Error()) + } + return m, m.setInfo("Message sent") + + case SendInboxMsg: + if msg.Err != nil { + return m, m.setInfo("Send inbox message failed: " + msg.Err.Error()) + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Inbox message sent")) + + case MarkInboxReadMsg: + if msg.Err != nil { + return m, m.setInfo("Mark inbox read failed: " + msg.Err.Error()) + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Marked as read")) + + case DeleteInboxMsg: + if msg.Err != nil { + return m, m.setInfo("Delete inbox message failed: " + msg.Err.Error()) + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Inbox message deleted")) + + case SessionMessageEvent: + // SSE message received — add to the message stream. + if msg.Err != nil { + m.messageStream.AddMessage(views.MessageEntry{ + EventType: "error", + Payload: msg.Err.Error(), + Timestamp: time.Now(), + }) + return m, nil + } + if msg.Message != nil && m.activeView == "messages" { + ts := time.Now() + if msg.Message.CreatedAt != nil { + ts = *msg.Message.CreatedAt + } + m.messageStream.AddMessage(views.MessageEntry{ + Seq: msg.Message.Seq, + EventType: msg.Message.EventType, + Payload: msg.Message.Payload, + Timestamp: ts, + }) + } + return m, nil case appTickMsg: return m.handleTick() @@ -415,6 +598,7 @@ func (m *AppModel) handleProjectsMsg(msg ProjectsMsg) (tea.Model, tea.Cmd) { } m.lastError = "" + m.cachedProjects = msg.Projects rows := make([]table.Row, 0, len(msg.Projects)) for _, p := range msg.Projects { @@ -461,6 +645,7 @@ func (m *AppModel) handleAgentsMsg(msg AgentsMsg) (tea.Model, tea.Cmd) { } m.lastError = "" + m.cachedAgents = msg.Agents now := time.Now() rows := make([]table.Row, 0, len(msg.Agents)) @@ -496,6 +681,7 @@ func (m *AppModel) handleSessionsMsg(msg SessionsMsg) (tea.Model, tea.Cmd) { } m.lastError = "" + m.cachedSessions = msg.Sessions now := time.Now() // If agent-scoped, filter sessions to only those belonging to this agent. @@ -551,6 +737,7 @@ func (m *AppModel) handleInboxMsg(msg InboxMsg) (tea.Model, tea.Cmd) { } m.lastError = "" + m.cachedInbox = msg.Messages now := time.Now() rows := make([]table.Row, 0, len(msg.Messages)) @@ -614,6 +801,11 @@ func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleMessagesKey(msg) } + // Detail view handles its own keys. + if m.activeView == "detail" { + return m.handleDetailKey(msg) + } + return m.handleNormalKey(msg) } @@ -686,13 +878,14 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { row := m.agentTable.SelectedRow() if len(row) > 0 { agentName := row[0] - // Agent ID comes from the SESSION column (index 2) — but we need the - // actual ID. For now, we use the agent name and fetch sessions by project. - // The agent table stores: NAME, PROMPT, SESSION, PHASE, AGE m.currentAgent = agentName - // We don't have the agent ID directly in the table. Use name as a - // best-effort identifier until the API provides it in the list response. - m.currentAgentID = agentName + // Look up the real agent ID from cache. + agent := m.findAgentByName(agentName) + if agent != nil { + m.currentAgentID = agent.ID + } else { + m.currentAgentID = agentName // fallback + } m.sessionTable.SetScope(agentName) cmd := m.pushView("sessions", agentName, "") return m, tea.Batch(cmd, m.setInfo("Viewing sessions for agent "+agentName)) @@ -701,8 +894,14 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { case "sessions": row := m.sessionTable.SelectedRow() if len(row) > 0 { - sessionID := row[0] // Short ID is in first column - m.currentSession = sessionID + shortID := row[0] // Short ID is in first column + // Resolve the full session ID from cache. + session := m.findSessionByShortID(shortID) + fullSessionID := shortID + if session != nil { + fullSessionID = session.ID + } + m.currentSession = fullSessionID // Create a new message stream for this session. agentName := m.currentAgent @@ -713,25 +912,41 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { if len(row) > 3 { phase = row[3] // PHASE column } - m.messageStream = views.NewMessageStream(sessionID, agentName, phase) + m.messageStream = views.NewMessageStream(fullSessionID, agentName, phase) m.resizeTable() // set message stream dimensions - // Add placeholder messages since SSE is not wired yet. - m.messageStream.AddMessage(views.MessageEntry{ - Seq: 1, - EventType: "system", - Payload: "Connected to session " + sessionID + " (SSE not yet wired)", - Timestamp: time.Now(), - }) + cmds := []tea.Cmd{ + m.pushView("messages", fullSessionID, fullSessionID), + m.setInfo("Streaming messages for session " + shortID), + } - cmd := m.pushView("messages", sessionID, sessionID) - return m, tea.Batch(cmd, m.setInfo("Streaming messages for session "+sessionID)) + // Start SSE watcher if we have a program reference and project context. + if m.program != nil && m.currentProject != "" { + cmds = append(cmds, m.client.WatchSessionMessages(m.currentProject, fullSessionID, 0, m.program)) + } else { + m.messageStream.AddMessage(views.MessageEntry{ + Seq: 1, + EventType: "system", + Payload: "Connected to session " + shortID + " (SSE requires program ref)", + Timestamp: time.Now(), + }) + } + + return m, tea.Batch(cmds...) } case "inbox": row := m.inboxTable.SelectedRow() if len(row) > 0 { - return m, m.setInfo("View full message body: not yet implemented") + msgID := row[0] + inboxMsg := m.findInboxByID(msgID) + if inboxMsg == nil { + return m, m.setInfo("Inbox message not found in cache: " + msgID) + } + m.detailView = views.NewDetailView("Inbox: "+msgID, views.InboxDetail(*inboxMsg)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", msgID, msgID) + return m, tea.Batch(cmd, m.setInfo("Inbox message detail")) } } @@ -820,12 +1035,22 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *AppModel) handleProjectsRune(key string) (tea.Model, tea.Cmd) { switch key { case "d": + // Show detail view for the selected project. row := m.projectTable.SelectedRow() - if len(row) > 0 { - return m, m.setInfo("Describe project: not yet implemented") + if len(row) == 0 { + return m, nil + } + projectName := row[0] + project := m.findProjectByName(projectName) + if project == nil { + return m, m.setInfo("Project not found in cache: " + projectName) } + m.detailView = views.NewDetailView("Project: "+projectName, views.ProjectDetail(*project)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", projectName, project.ID) + return m, tea.Batch(cmd, m.setInfo("Project detail: "+projectName)) case "n": - return m, m.setInfo("New project: not yet implemented") + return m, m.setInfo("Use acpctl project create") } return m, nil } @@ -839,17 +1064,51 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { if len(row) > 0 { agentName := row[0] m.currentAgent = agentName - m.currentAgentID = agentName + agent := m.findAgentByName(agentName) + if agent != nil { + m.currentAgentID = agent.ID + } else { + m.currentAgentID = agentName // fallback + } m.inboxTable.SetScope(agentName) cmd := m.pushView("inbox", agentName, "") return m, tea.Batch(cmd, m.setInfo("Viewing inbox for agent "+agentName)) } case "s": - return m, m.setInfo("Start agent: not yet implemented") + // Start the selected agent. + row := m.agentTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No agent selected") + } + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + return m, tea.Batch( + m.client.StartAgent(m.currentProject, agent.ID, ""), + m.setInfo("Starting agent "+agentName+"..."), + ) case "x": - return m, m.setInfo("Stop agent: not yet implemented") + // Stop the selected agent's current session. + row := m.agentTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No agent selected") + } + agentName := row[0] + sessionID := "" + if len(row) > 2 { + sessionID = row[2] // SESSION column + } + if sessionID == "" || sessionID == "" { + return m, m.setInfo("Agent " + agentName + " has no active session") + } + return m, tea.Batch( + m.client.StopAgent(m.currentProject, sessionID), + m.setInfo("Stopping agent "+agentName+"..."), + ) case "e": - return m, m.setInfo("Edit agent: not yet implemented") + return m, m.setInfo("Use acpctl agent update") case "l": // Logs — if agent has an active session, jump to message stream. row := m.agentTable.SelectedRow() @@ -857,7 +1116,12 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { agentName := row[0] sessionID := row[2] m.currentAgent = agentName - m.currentAgentID = agentName + agent := m.findAgentByName(agentName) + if agent != nil { + m.currentAgentID = agent.ID + } else { + m.currentAgentID = agentName + } m.currentSession = sessionID phase := "" if len(row) > 3 { @@ -865,27 +1129,61 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { } m.messageStream = views.NewMessageStream(sessionID, agentName, phase) m.resizeTable() - m.messageStream.AddMessage(views.MessageEntry{ - Seq: 1, - EventType: "system", - Payload: "Connected to session " + sessionID + " (SSE not yet wired)", - Timestamp: time.Now(), - }) - cmd := m.pushView("messages", sessionID, sessionID) - return m, tea.Batch(cmd, m.setInfo("Streaming messages for session "+sessionID)) + + cmds := []tea.Cmd{ + m.pushView("messages", sessionID, sessionID), + m.setInfo("Streaming messages for session " + sessionID), + } + + // Start SSE watcher if we have a program reference and project context. + if m.program != nil && m.currentProject != "" { + cmds = append(cmds, m.client.WatchSessionMessages(m.currentProject, sessionID, 0, m.program)) + } else { + m.messageStream.AddMessage(views.MessageEntry{ + Seq: 1, + EventType: "system", + Payload: "Connected to session " + sessionID + " (SSE requires program ref)", + Timestamp: time.Now(), + }) + } + + return m, tea.Batch(cmds...) } return m, m.setInfo("No active session for this agent") case "d": + // Show detail view for the selected agent. row := m.agentTable.SelectedRow() - if len(row) > 0 { - return m, m.setInfo("Describe agent: not yet implemented") + if len(row) == 0 { + return m, nil + } + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) } + m.detailView = views.NewDetailView("Agent: "+agentName, views.AgentDetail(*agent)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", agentName, agent.ID) + return m, tea.Batch(cmd, m.setInfo("Agent detail: "+agentName)) case "m": - return m, m.setInfo("Send inbox message: not yet implemented") + return m, m.setInfo("Use :inbox or acpctl inbox send") case "n": - return m, m.setInfo("New agent: not yet implemented") + return m, m.setInfo("Use acpctl agent create") case "y": - return m, m.setInfo("YAML dump: not yet implemented") + row := m.agentTable.SelectedRow() + if len(row) == 0 { + return m, nil + } + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + // Show agent detail as a describe view (closest to YAML dump). + m.detailView = views.NewDetailView("Agent: "+agentName, views.AgentDetail(*agent)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", agentName, agent.ID) + return m, tea.Batch(cmd, m.setInfo("Agent detail: "+agentName)) } return m, nil } @@ -894,17 +1192,40 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { switch key { case "d": + // Show detail view for the selected session. row := m.sessionTable.SelectedRow() - if len(row) > 0 { - return m, m.setInfo("Describe session: not yet implemented") + if len(row) == 0 { + return m, nil + } + shortID := row[0] + session := m.findSessionByShortID(shortID) + if session == nil { + return m, m.setInfo("Session not found in cache: " + shortID) } + m.detailView = views.NewDetailView("Session: "+shortID, views.SessionDetail(*session)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", shortID, session.ID) + return m, tea.Batch(cmd, m.setInfo("Session detail: "+shortID)) case "l": // Same as Enter — drill into message stream. return m.handleEnter() case "m": - return m, m.setInfo("Send message to session: not yet implemented") + return m, m.setInfo("Use Enter to view messages, then m to compose") case "y": - return m, m.setInfo("YAML dump: not yet implemented") + // Show session detail (closest to YAML dump). + row := m.sessionTable.SelectedRow() + if len(row) == 0 { + return m, nil + } + shortID := row[0] + session := m.findSessionByShortID(shortID) + if session == nil { + return m, m.setInfo("Session not found in cache: " + shortID) + } + m.detailView = views.NewDetailView("Session: "+shortID, views.SessionDetail(*session)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", shortID, session.ID) + return m, tea.Batch(cmd, m.setInfo("Session detail: "+shortID)) } return m, nil } @@ -913,9 +1234,21 @@ func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { func (m *AppModel) handleInboxRune(key string) (tea.Model, tea.Cmd) { switch key { case "m": - return m, m.setInfo("Compose inbox message: not yet implemented") + return m, m.setInfo("Use acpctl inbox send") case "r": - return m, m.setInfo("Mark as read: not yet implemented") + // Mark selected inbox message as read. + row := m.inboxTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No inbox message selected") + } + msgID := row[0] // ID column + if m.currentProject == "" || m.currentAgentID == "" { + return m, m.setInfo("No agent context for inbox") + } + return m, tea.Batch( + m.client.MarkInboxRead(m.currentProject, m.currentAgentID, msgID), + m.setInfo("Marking as read..."), + ) } return m, nil } @@ -926,27 +1259,69 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { case "projects": row := m.projectTable.SelectedRow() if len(row) > 0 { - return m, m.setInfo("Delete project: not yet implemented") + projectName := row[0] + project := m.findProjectByName(projectName) + if project == nil { + return m, m.setInfo("Project not found in cache: " + projectName) + } + return m, tea.Batch( + m.client.DeleteProject(project.ID), + m.setInfo("Deleting project "+projectName+"..."), + ) } case "agents": row := m.agentTable.SelectedRow() if len(row) > 0 { - return m, m.setInfo("Delete agent: not yet implemented") + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + return m, tea.Batch( + m.client.DeleteAgent(m.currentProject, agent.ID), + m.setInfo("Deleting agent "+agentName+"..."), + ) } case "sessions": row := m.sessionTable.SelectedRow() if len(row) > 0 { - return m, m.setInfo("Delete/cancel session: not yet implemented") + shortID := row[0] + session := m.findSessionByShortID(shortID) + if session == nil { + return m, m.setInfo("Session not found in cache: " + shortID) + } + project := m.currentProject + if project == "" { + project = session.ProjectID + } + return m, tea.Batch( + m.client.DeleteSession(project, session.ID), + m.setInfo("Deleting session "+shortID+"..."), + ) } case "inbox": row := m.inboxTable.SelectedRow() if len(row) > 0 { - return m, m.setInfo("Delete inbox message: not yet implemented") + msgID := row[0] + if m.currentProject == "" || m.currentAgentID == "" { + return m, m.setInfo("No agent context for inbox") + } + return m, tea.Batch( + m.client.DeleteInboxMessage(m.currentProject, m.currentAgentID, msgID), + m.setInfo("Deleting inbox message..."), + ) } } return m, nil } +// handleDetailKey delegates key events to the detail view sub-model. +func (m *AppModel) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.detailView, cmd = m.detailView.Update(msg) + return m, cmd +} + // handleMessagesKey delegates key events to the message stream sub-model. func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { var cmd tea.Cmd From 5d8cd7a1f83446a32e0229410d1e98699826ca6d Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:07:51 -0400 Subject: [PATCH 022/117] fix(cli): detail view redraws on terminal resize Add detailView.SetSize to resizeTable() so the detail view responds to WindowSizeMsg like all other views. Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 034bc128c..ff296e0a0 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -583,8 +583,9 @@ func (m *AppModel) resizeTable() { m.contextTable.SetHeight(tableHeight) m.contextTable.SetWidth(m.width) - // Message stream gets the full table area. - m.messageStream.SetSize(m.width, tableHeight+2) // +2 to account for title bar space + // Message stream and detail view get the full table area. + m.messageStream.SetSize(m.width, tableHeight+2) + m.detailView.SetSize(m.width, tableHeight+2) } // handleProjectsMsg populates the project table from a fetch result. From ec867b2d7893c8d1f3e165ea15e602c04bf3e3b5 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:11:57 -0400 Subject: [PATCH 023/117] feat(cli): confirmation modals, copy-to-clipboard, SSE status, error handling - Ctrl-D now prompts "Delete ? (y/n)" before executing - c copies selected row ID to clipboard on all table views - c copies selected message text in message stream view - SSE status indicator in message stream header (connected/reconnecting) - 401/403/429 error detection with appropriate user messages - Rate-limit backoff skips next poll cycle on 429 Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 158 +++++++++++++++--- .../cmd/acpctl/ambient/tui/views/messages.go | 50 +++++- 2 files changed, 185 insertions(+), 23 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index ff296e0a0..b49f860b8 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -2,8 +2,10 @@ package tui import ( "fmt" + "strings" "time" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -107,6 +109,15 @@ type AppModel struct { // Errors lastError string + // Delete confirmation + confirmingDelete bool + deleteKind string // "project", "agent", "session", "inbox" + deleteName string // display name for confirmation + deleteFunc func() tea.Cmd // the actual delete call + + // Rate-limit backoff: skip the next poll cycle when a 429 is received. + skipNextPoll bool + // Terminal size width, height int } @@ -509,6 +520,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case SessionMessageEvent: // SSE message received — add to the message stream. if msg.Err != nil { + m.messageStream.SetSSEStatus("reconnecting") m.messageStream.AddMessage(views.MessageEntry{ EventType: "error", Payload: msg.Err.Error(), @@ -517,6 +529,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if msg.Message != nil && m.activeView == "messages" { + m.messageStream.SetSSEStatus("connected") ts := time.Now() if msg.Message.CreatedAt != nil { ts = *msg.Message.CreatedAt @@ -588,13 +601,32 @@ func (m *AppModel) resizeTable() { m.detailView.SetSize(m.width, tableHeight+2) } +// classifyAPIError inspects the error string and returns a user-friendly message +// plus a flag indicating whether the caller should skip the next poll cycle (429). +func (m *AppModel) classifyAPIError(err error, resourceKind string) (string, bool) { + errStr := err.Error() + switch { + case strings.Contains(errStr, "401") || strings.Contains(errStr, "Unauthorized"): + return "Session expired — run 'acpctl login' in another terminal", false + case strings.Contains(errStr, "403") || strings.Contains(errStr, "Forbidden"): + return "Insufficient permissions to list " + resourceKind, false + case strings.Contains(errStr, "429"): + return "Rate limited — backing off", true + default: + return errStr, false + } +} + // handleProjectsMsg populates the project table from a fetch result. func (m *AppModel) handleProjectsMsg(msg ProjectsMsg) (tea.Model, tea.Cmd) { m.pollInFlight = false m.lastFetch = time.Now() if msg.Err != nil { - m.lastError = msg.Err.Error() + errMsg, skipPoll := m.classifyAPIError(msg.Err, "projects") + m.lastError = errMsg + m.skipNextPoll = m.skipNextPoll || skipPoll + // Preserve stale data — don't clear table rows. return m, nil } @@ -641,7 +673,10 @@ func (m *AppModel) handleAgentsMsg(msg AgentsMsg) (tea.Model, tea.Cmd) { m.lastFetch = time.Now() if msg.Err != nil { - m.lastError = msg.Err.Error() + errMsg, skipPoll := m.classifyAPIError(msg.Err, "agents") + m.lastError = errMsg + m.skipNextPoll = m.skipNextPoll || skipPoll + // Preserve stale data — don't clear table rows. return m, nil } @@ -677,7 +712,10 @@ func (m *AppModel) handleSessionsMsg(msg SessionsMsg) (tea.Model, tea.Cmd) { m.lastFetch = time.Now() if msg.Err != nil { - m.lastError = msg.Err.Error() + errMsg, skipPoll := m.classifyAPIError(msg.Err, "sessions") + m.lastError = errMsg + m.skipNextPoll = m.skipNextPoll || skipPoll + // Preserve stale data — don't clear table rows. return m, nil } @@ -733,7 +771,10 @@ func (m *AppModel) handleInboxMsg(msg InboxMsg) (tea.Model, tea.Cmd) { m.lastFetch = time.Now() if msg.Err != nil { - m.lastError = msg.Err.Error() + errMsg, skipPoll := m.classifyAPIError(msg.Err, "inbox messages") + m.lastError = errMsg + m.skipNextPoll = m.skipNextPoll || skipPoll + // Preserve stale data — don't clear table rows. return m, nil } @@ -762,11 +803,17 @@ func (m *AppModel) handleInboxMsg(msg InboxMsg) (tea.Model, tea.Cmd) { return m, nil } -// handleTick manages periodic polling. Skips if a fetch is already in flight. -// Fetches data for the active view rather than always fetching projects. +// handleTick manages periodic polling. Skips if a fetch is already in flight +// or if skipNextPoll is set (e.g. after a 429 rate-limit response). func (m *AppModel) handleTick() (tea.Model, tea.Cmd) { cmds := []tea.Cmd{m.tickCmd()} // always schedule next tick + // If rate-limited, skip this cycle and reset the flag for the next one. + if m.skipNextPoll { + m.skipNextPoll = false + return m, tea.Batch(cmds...) + } + if !m.pollInFlight && m.activeView != "messages" { m.pollInFlight = true if fetchCmd := m.fetchActiveView(); fetchCmd != nil { @@ -790,6 +837,11 @@ func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit } + // Delete confirmation takes priority over all other modes. + if m.confirmingDelete { + return m.handleDeleteConfirmKey(msg) + } + if m.commandMode { return m.handleCommandKey(msg) } @@ -810,6 +862,33 @@ func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleNormalKey(msg) } +// handleDeleteConfirmKey handles y/n/Esc when a delete confirmation is active. +func (m *AppModel) handleDeleteConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.confirmingDelete = false + m.deleteFunc = nil + return m, m.setInfo("Delete cancelled") + case tea.KeyRunes: + switch msg.String() { + case "y", "Y": + fn := m.deleteFunc + m.confirmingDelete = false + m.deleteFunc = nil + if fn != nil { + return m, tea.Batch(fn(), m.setInfo("Deleting "+m.deleteKind+" "+m.deleteName+"...")) + } + return m, nil + case "n", "N": + m.confirmingDelete = false + m.deleteFunc = nil + return m, m.setInfo("Delete cancelled") + } + } + // Ignore all other keys while confirming. + return m, nil +} + // handleNormalKey processes keys when neither command nor filter mode is active. // Dispatches based on activeView for view-specific hotkeys. func (m *AppModel) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -1015,6 +1094,18 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { tbl.SortByColumn(len(cols) - 1) } return m, nil + + case "c": + // Copy the first column value (resource name/ID) of the selected row to clipboard. + if tbl := m.activeTable(); tbl != nil { + row := tbl.SelectedRow() + if len(row) > 0 { + value := row[0] + _ = clipboard.WriteAll(value) + return m, m.setInfo("Copied: " + value) + } + } + return m, nil } // View-specific rune keybindings. @@ -1255,6 +1346,7 @@ func (m *AppModel) handleInboxRune(key string) (tea.Model, tea.Cmd) { } // handleCtrlD handles the delete/cancel keybinding across all views. +// Instead of deleting immediately, it sets up a confirmation prompt. func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { switch m.activeView { case "projects": @@ -1265,10 +1357,15 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { if project == nil { return m, m.setInfo("Project not found in cache: " + projectName) } - return m, tea.Batch( - m.client.DeleteProject(project.ID), - m.setInfo("Deleting project "+projectName+"..."), - ) + projectID := project.ID + m.confirmingDelete = true + m.deleteKind = "project" + m.deleteName = projectName + m.deleteFunc = func() tea.Cmd { + return m.client.DeleteProject(projectID) + } + m.infoMessage = fmt.Sprintf("Delete project %s? (y/n)", projectName) + return m, nil } case "agents": row := m.agentTable.SelectedRow() @@ -1278,10 +1375,16 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { if agent == nil { return m, m.setInfo("Agent not found in cache: " + agentName) } - return m, tea.Batch( - m.client.DeleteAgent(m.currentProject, agent.ID), - m.setInfo("Deleting agent "+agentName+"..."), - ) + agentID := agent.ID + currentProject := m.currentProject + m.confirmingDelete = true + m.deleteKind = "agent" + m.deleteName = agentName + m.deleteFunc = func() tea.Cmd { + return m.client.DeleteAgent(currentProject, agentID) + } + m.infoMessage = fmt.Sprintf("Delete agent %s? (y/n)", agentName) + return m, nil } case "sessions": row := m.sessionTable.SelectedRow() @@ -1295,10 +1398,15 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { if project == "" { project = session.ProjectID } - return m, tea.Batch( - m.client.DeleteSession(project, session.ID), - m.setInfo("Deleting session "+shortID+"..."), - ) + sessionID := session.ID + m.confirmingDelete = true + m.deleteKind = "session" + m.deleteName = shortID + m.deleteFunc = func() tea.Cmd { + return m.client.DeleteSession(project, sessionID) + } + m.infoMessage = fmt.Sprintf("Delete session %s? (y/n)", shortID) + return m, nil } case "inbox": row := m.inboxTable.SelectedRow() @@ -1307,10 +1415,16 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { if m.currentProject == "" || m.currentAgentID == "" { return m, m.setInfo("No agent context for inbox") } - return m, tea.Batch( - m.client.DeleteInboxMessage(m.currentProject, m.currentAgentID, msgID), - m.setInfo("Deleting inbox message..."), - ) + currentProject := m.currentProject + currentAgentID := m.currentAgentID + m.confirmingDelete = true + m.deleteKind = "inbox" + m.deleteName = msgID + m.deleteFunc = func() tea.Cmd { + return m.client.DeleteInboxMessage(currentProject, currentAgentID, msgID) + } + m.infoMessage = fmt.Sprintf("Delete inbox %s? (y/n)", msgID) + return m, nil } } return m, nil diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 4e47c4afb..71e632f3b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -225,6 +226,9 @@ type MessageStream struct { agentName string phase string + // SSE connection status: "", "connected", "reconnecting", "disconnected". + sseStatus string + // Message buffer (ring buffer, 2000 max). messages []MessageEntry maxMessages int @@ -309,6 +313,12 @@ func (ms *MessageStream) SetPhase(phase string) { ms.phase = phase } +// SetSSEStatus updates the SSE connection status indicator shown in the header. +// Valid values: "", "connected", "reconnecting", "disconnected". +func (ms *MessageStream) SetSSEStatus(status string) { + ms.sseStatus = status +} + // ComposeValue returns the current text in the compose input. func (ms MessageStream) ComposeValue() string { return ms.composeInput.Value() @@ -433,6 +443,22 @@ func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { ms.searchInput.Reset() ms.searchInput.Focus() return *ms, nil + case "c": + // Copy the selected message text to clipboard. + if len(ms.messages) > 0 { + idx := ms.scrollOffset + if idx >= len(ms.messages) { + idx = len(ms.messages) - 1 + } + if idx >= 0 { + text := eventSummary(ms.messages[idx].EventType, ms.messages[idx].Payload) + if text == "" { + text = ms.messages[idx].Payload + } + _ = clipboard.WriteAll(text) + } + } + return *ms, nil } } @@ -520,13 +546,35 @@ func (ms *MessageStream) View() string { } phaseStyle := lipgloss.NewStyle().Foreground(phaseColor(ms.phase)) - header := fmt.Sprintf(" %s %s %s %s %s %s", + // SSE status indicator with color coding. + sseSegment := "" + if ms.sseStatus != "" { + var sseStyle lipgloss.Style + switch ms.sseStatus { + case "connected": + sseStyle = lipgloss.NewStyle().Foreground(msgColorGreen) + case "reconnecting": + sseStyle = lipgloss.NewStyle().Foreground(msgColorYellow) + case "disconnected": + sseStyle = lipgloss.NewStyle().Foreground(msgColorRed) + default: + sseStyle = lipgloss.NewStyle().Foreground(msgColorDim) + } + sseSegment = fmt.Sprintf(" %s %s %s", + dimStyle.Render("—"), + dimStyle.Render("SSE:"), + sseStyle.Render(ms.sseStatus), + ) + } + + header := fmt.Sprintf(" %s %s %s %s %s %s%s", headerStyle.Render("Session"), lipgloss.NewStyle().Foreground(msgColorWhite).Bold(true).Render(shortID), dimStyle.Render("—"), fmt.Sprintf("%s %s", dimStyle.Render("Phase:"), phaseStyle.Render(ms.phase)), dimStyle.Render("—"), fmt.Sprintf("%s %s", dimStyle.Render("Agent:"), lipgloss.NewStyle().Foreground(msgColorOrange).Render(ms.agentName)), + sseSegment, ) headerBar := borderStyle.Render("┌" + strings.Repeat("─", max(ms.width-2, 0)) + "┐") From 8d01b71894d0fc4492473da7c8a0ae0e5a8adf88 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 17:13:17 -0400 Subject: [PATCH 024/117] fix(cli): update TUI spec branding from AM to ACP Co-Authored-By: Claude Sonnet 4.6 --- docs/internal/design/tui.spec.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/internal/design/tui.spec.md b/docs/internal/design/tui.spec.md index 25624c589..2500ce074 100644 --- a/docs/internal/design/tui.spec.md +++ b/docs/internal/design/tui.spec.md @@ -74,10 +74,10 @@ cmd/acpctl/ambient/ ``` ┌──────────────────────────────────────────────────────────────────────────┐ -│ Context: local [RW] Help _ __ __ │ -│ Server: localhost:8000 <:> Command /_\ | \/ | │ -│ User: jsell Rename / _ \ | |\/| | │ -│ Project: ambient-platform /_/ \_\|_| |_| │ +│ Context: local [RW] Help _ ___ ___ │ +│ Server: localhost:8000 <:> Command /_\ / __| _ \ │ +│ User: jsell Rename / _ \| (__| _/ │ +│ Project: ambient-platform /_/ \_\\___|_| │ │ ⟳ 3s │ ├──────────────────────────────────────────────────────────────────────────┤ │ (command bar appears here on `:` or `/`, hidden by default) │ From 7842a96c26644f0acab5665ca0b424475c53ef97 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:12:59 -0400 Subject: [PATCH 025/117] feat(cli): add contextual hotkey hints and session creation to spec Co-Authored-By: Claude Sonnet 4.6 --- docs/internal/design/tui.spec.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/internal/design/tui.spec.md b/docs/internal/design/tui.spec.md index 2500ce074..d78ca423d 100644 --- a/docs/internal/design/tui.spec.md +++ b/docs/internal/design/tui.spec.md @@ -248,6 +248,7 @@ Accessible globally (`:sessions` — all sessions across all projects) or scoped | `d` | Describe — show session detail (full metadata, prompt, conditions) | d | | `l` | Live message stream (same as Enter) | l | | `m` | Send message to session (`POST /sessions/{id}/messages`) | — | +| `n` | Start a new session for the current agent (opens prompt input) | — | | `y` | YAML — dump session as YAML to screen | y | | `Ctrl-D` | Delete/cancel session (confirmation modal) | Ctrl-D | @@ -382,7 +383,17 @@ Left side — context metadata (k9s style, stacked key-value): - **Project** (current project context) - **Refresh indicator** — seconds since last successful fetch. Shows `(stale)` if >15s. -Right side — ASCII art branding + top-level key hints. +Right side — ASCII art branding + key hints. + +The right side of the header shows contextual hotkeys that change based on the active view, displayed to the left of the static ``, `<:>`, `` hints. For example, in the agents view: + +``` + Start Stop Inbox Describe Help + Edit Logs New Delete <:> Command + Filter +``` + +Each view shows only its relevant hotkeys. The hotkeys are rendered in dim text with the key in angle brackets. ### Command/Filter Bar From 045d95569c0051934cfcbe39a606162ec8c38a64 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:14:37 -0400 Subject: [PATCH 026/117] feat(cli): add number-key project switching to spec Co-Authored-By: Claude Sonnet 4.6 --- docs/internal/design/tui.spec.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/internal/design/tui.spec.md b/docs/internal/design/tui.spec.md index d78ca423d..33523833a 100644 --- a/docs/internal/design/tui.spec.md +++ b/docs/internal/design/tui.spec.md @@ -355,6 +355,7 @@ These work on every screen: | `Ctrl-C` | Quit immediately | `Ctrl-C` | | `c` | Copy selected row's ID to clipboard (OSC 52) | — | | Scroll wheel | Scroll up/down in tables and message stream | Scroll wheel | +| `0`-`9` | Switch project by number (shown in header) | — (Ambient-specific, matches k9s namespace switching) | | `Shift-N` | Sort by name column | `Shift-N` | | `Shift-A` | Sort by age column | `Shift-A` | @@ -385,6 +386,16 @@ Left side — context metadata (k9s style, stacked key-value): Right side — ASCII art branding + key hints. +Between the left-side metadata and the right-side hotkey hints, the header shows numbered project shortcuts for quick switching (matching k9s's namespace number keys): + +``` +<0> all <1> test <2> test-jsell Start Describe Help + Stop Inbox <:> Command + Logs New Filter +``` + +Projects are numbered in alphabetical order. `<0>` always means "all" (unscoped). Pressing a number key instantly switches the project context without entering command mode. + The right side of the header shows contextual hotkeys that change based on the active view, displayed to the left of the static ``, `<:>`, `` hints. For example, in the agents view: ``` From 7fc390dd75b6db286916e0563b01c7a04155bd18 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:17:26 -0400 Subject: [PATCH 027/117] feat(cli): contextual hotkeys, project shortcuts, and new session prompt in TUI Add three features to the Ambient TUI, modeled after k9s: 1. Contextual hotkey hints in the header - view-specific hotkeys render in two rows on the right side of the header, with dim key brackets and white action labels, alongside the static Help/Command/Filter hints. 2. Number-key project switching (0-9) - projects shown as cyan-colored numbered shortcuts in the header, refreshed from cachedProjects on every ProjectsMsg. Press 0 for all projects, 1-9 to jump directly to a project's agents view. 3. Press n in sessions view to start a new session via an inline prompt input. Uses a reusable promptMode/promptInput/promptCallback pattern that can be extended for other inline inputs later. Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 120 +++++++++-- .../cmd/acpctl/ambient/tui/model_new.go | 197 +++++++++++++++++- 2 files changed, 296 insertions(+), 21 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 7fa592a08..c4ab9fe3d 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -31,8 +31,8 @@ func (m *AppModel) View() string { // 2. Separator. sections = append(sections, styleDim.Render(strings.Repeat("─", m.width))) - // 3. Command/filter bar (only when active). - if m.commandMode || m.filterMode { + // 3. Command/filter/prompt bar (only when active). + if m.commandMode || m.filterMode || m.promptMode { sections = append(sections, m.viewCommandBar()) } @@ -51,8 +51,9 @@ func (m *AppModel) View() string { return strings.Join(sections, "\n") } -// viewHeader renders the multi-line header block with context info on the left -// and branding + key hints on the right. +// viewHeader renders the multi-line header block with context info on the left, +// project shortcuts in the center, and contextual hotkeys + static hints + branding +// on the right. func (m *AppModel) viewHeader() string { // Left side: context metadata lines. contextName := "none" @@ -106,16 +107,66 @@ func (m *AppModel) viewHeader() string { refreshIndicator, } - // Right side: key hints + branding (column-aligned). - hintLines := []string{ - styleDim.Render("") + " " + styleWhite.Render("Help "), - styleDim.Render("<:>") + " " + styleWhite.Render("Command"), - styleDim.Render("") + " " + styleWhite.Render("Filter "), - "", - "", + // Build project shortcuts line (like k9s namespace shortcuts). + // Format: <0> all <1> proj1 <2> proj2 ... + var shortcutParts []string + shortcutParts = append(shortcutParts, styleCyan.Render("<0>")+styleCyan.Render(" all")) + maxShortcuts := 6 + if len(m.projectShortcuts) < maxShortcuts { + maxShortcuts = len(m.projectShortcuts) + } + for i := 0; i < maxShortcuts; i++ { + shortcutParts = append(shortcutParts, + styleCyan.Render(fmt.Sprintf("<%d>", i+1))+" "+styleCyan.Render(m.projectShortcuts[i])) + } + shortcutLine := " " + strings.Join(shortcutParts, " ") + + // Build contextual hints (two rows, ~4 per row). + ctxHints := m.contextualHints() + var ctxRow1, ctxRow2 []string + splitAt := (len(ctxHints) + 1) / 2 // first row gets the larger half + for i, h := range ctxHints { + rendered := m.renderHint(h) + if i < splitAt { + ctxRow1 = append(ctxRow1, rendered) + } else { + ctxRow2 = append(ctxRow2, rendered) + } + } + ctxLine1 := strings.Join(ctxRow1, " ") + ctxLine2 := strings.Join(ctxRow2, " ") + + // Static hints (always shown). + staticHints := []string{ + styleDim.Render("") + " " + styleWhite.Render("Help"), + styleDim.Render("<:>") + " " + styleWhite.Render("Command"), + styleDim.Render("") + " " + styleWhite.Render("Filter"), + } + staticLine := strings.Join(staticHints, " ") + + // Right side: combine contextual row 1 + static hints on line 0, + // contextual row 2 on line 1, then branding fills remaining lines. + // Layout: + // Line 0: left metadata | ctx hints row1 + static | brand + // Line 1: left metadata | ctx hints row2 | brand + // Line 2: left metadata | shortcuts | brand + // Line 3: left metadata | | brand + // Line 4: left metadata | | brand + + rightHintLines := make([]string, 5) + if len(ctxRow1) > 0 { + rightHintLines[0] = ctxLine1 + " " + staticLine + } else { + rightHintLines[0] = staticLine + } + if len(ctxRow2) > 0 { + rightHintLines[1] = ctxLine2 + } + if len(m.projectShortcuts) > 0 { + rightHintLines[2] = shortcutLine } - // Combine left, hints, and branding into header lines. + // Combine left, right-hints, and branding into header lines. headerLines := make([]string, 5) for i := range 5 { left := "" @@ -123,10 +174,7 @@ func (m *AppModel) viewHeader() string { left = leftLines[i] } - hint := "" - if i < len(hintLines) { - hint = hintLines[i] - } + hint := rightHintLines[i] brand := "" if i < len(brandLines) { @@ -137,8 +185,16 @@ func (m *AppModel) viewHeader() string { leftWidth := lipgloss.Width(left) hintWidth := lipgloss.Width(hint) brandWidth := lipgloss.Width(brand) - rightContent := hint + " " + brand - rightWidth := hintWidth + 2 + brandWidth + + var rightContent string + var rightWidth int + if hint != "" { + rightContent = hint + " " + brand + rightWidth = hintWidth + 2 + brandWidth + } else { + rightContent = brand + rightWidth = brandWidth + } gap := m.width - leftWidth - rightWidth if gap < 1 { @@ -151,8 +207,28 @@ func (m *AppModel) viewHeader() string { return strings.Join(headerLines, "\n") } -// viewCommandBar renders the command or filter input bar. +// renderHint renders a single hotkey hint like " Describe" with dim brackets +// and white action text. +func (m *AppModel) renderHint(hint string) string { + // Parse hints of the form " Action" or "(text)". + if strings.HasPrefix(hint, "(") { + return styleDim.Render(hint) + } + // Find the closing bracket. + idx := strings.Index(hint, ">") + if idx < 0 { + return styleDim.Render(hint) + } + key := hint[:idx+1] // e.g. "" + action := hint[idx+1:] // e.g. " Describe" + return styleDim.Render(key) + styleWhite.Render(action) +} + +// viewCommandBar renders the command, filter, or prompt input bar. func (m *AppModel) viewCommandBar() string { + if m.promptMode { + return " " + m.promptInput.View() + } if m.commandMode { return " " + m.commandInput.View() } @@ -195,6 +271,12 @@ func (m *AppModel) viewBreadcrumb() string { // viewInfoLine renders the ephemeral info/toast line at the very bottom. func (m *AppModel) viewInfoLine() string { + // Delete confirmation takes priority over everything. + if m.confirmingDelete { + prompt := fmt.Sprintf("Delete %s %s? (y/n)", m.deleteKind, m.deleteName) + return " " + styleYellow.Render(prompt) + } + // Error takes priority over info. if m.lastError != "" { return " " + styleRed.Render("✗ "+m.lastError) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index b49f860b8..27b38ef9e 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "sort" "strings" "time" @@ -118,6 +119,15 @@ type AppModel struct { // Rate-limit backoff: skip the next poll cycle when a 429 is received. skipNextPoll bool + // Project shortcuts for number-key switching (like k9s namespace shortcuts). + // Holds project names in alphabetical order, refreshed on ProjectsMsg. + projectShortcuts []string + + // Prompt mode for inline text input (e.g. new session prompt). + promptMode bool + promptInput textinput.Model + promptCallback func(string) (tea.Model, tea.Cmd) // called on Enter + // Terminal size width, height int } @@ -144,6 +154,11 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { fi.Prompt = "/" fi.CharLimit = 256 + // Prompt bar input (for inline prompts like new session). + pi := textinput.New() + pi.Prompt = "Session prompt: " + pi.CharLimit = 1024 + pt := views.NewProjectTable(views.DefaultTableStyle()) at := views.NewAgentTable("all", views.DefaultTableStyle()) st := views.NewSessionTable("all", views.DefaultTableStyle()) @@ -164,6 +179,7 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { contextTable: ct, commandInput: ci, filterInput: fi, + promptInput: pi, } return m, nil @@ -577,8 +593,8 @@ func (m *AppModel) resizeTable() { // separator lines: 2 // Total chrome: ~10 lines, leaving the rest for the table. tableHeight := m.height - 10 - if m.commandMode || m.filterMode { - tableHeight-- // command bar takes a line + if m.commandMode || m.filterMode || m.promptMode { + tableHeight-- // command/filter/prompt bar takes a line } if tableHeight < 1 { tableHeight = 1 @@ -633,6 +649,14 @@ func (m *AppModel) handleProjectsMsg(msg ProjectsMsg) (tea.Model, tea.Cmd) { m.lastError = "" m.cachedProjects = msg.Projects + // Refresh project shortcuts (alphabetically sorted names for number-key switching). + names := make([]string, 0, len(msg.Projects)) + for _, p := range msg.Projects { + names = append(names, p.Name) + } + sort.Strings(names) + m.projectShortcuts = names + rows := make([]table.Row, 0, len(msg.Projects)) for _, p := range msg.Projects { age := "" @@ -842,6 +866,11 @@ func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleDeleteConfirmKey(msg) } + // Prompt mode (inline text input for new session, etc.). + if m.promptMode { + return m.handlePromptKey(msg) + } + if m.commandMode { return m.handleCommandKey(msg) } @@ -1108,6 +1137,11 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + // Number-key project shortcuts (0-9). + if len(key) == 1 && key[0] >= '0' && key[0] <= '9' { + return m.handleProjectShortcut(key[0] - '0') + } + // View-specific rune keybindings. switch m.activeView { case "projects": @@ -1303,6 +1337,26 @@ func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { return m.handleEnter() case "m": return m, m.setInfo("Use Enter to view messages, then m to compose") + case "n": + // Start a new session for the current agent. + if m.currentAgentID == "" || m.currentProject == "" { + return m, m.setInfo("Navigate to an agent first to start a session") + } + // Open prompt input for session prompt text. + agentID := m.currentAgentID + project := m.currentProject + m.promptMode = true + m.promptInput.Prompt = "Session prompt: " + m.promptInput.Reset() + m.promptInput.Focus() + m.promptCallback = func(text string) (tea.Model, tea.Cmd) { + return m, tea.Batch( + m.client.StartAgent(project, agentID, text), + m.setInfo("Starting session..."), + ) + } + m.resizeTable() + return m, nil case "y": // Show session detail (closest to YAML dump). row := m.sessionTable.SelectedRow() @@ -1756,3 +1810,142 @@ func (m *AppModel) clearActiveTableFilter() { tbl.ClearFilter() } } + +// --------------------------------------------------------------------------- +// Prompt mode (inline text input for new session, etc.) +// --------------------------------------------------------------------------- + +// handlePromptKey processes keys while in prompt mode. +func (m *AppModel) handlePromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.promptMode = false + m.promptCallback = nil + m.promptInput.Reset() + m.promptInput.Blur() + m.resizeTable() + return m, m.setInfo("Cancelled") + + case tea.KeyEnter: + input := m.promptInput.Value() + cb := m.promptCallback + m.promptMode = false + m.promptCallback = nil + m.promptInput.Reset() + m.promptInput.Blur() + m.resizeTable() + if cb != nil { + return cb(input) + } + return m, nil + + default: + var cmd tea.Cmd + m.promptInput, cmd = m.promptInput.Update(msg) + return m, cmd + } +} + +// --------------------------------------------------------------------------- +// Project number-key shortcuts +// --------------------------------------------------------------------------- + +// handleProjectShortcut switches the project scope when a digit 0-9 is pressed. +// 0 = "all" (clear project scope), 1-9 = projectShortcuts[digit-1]. +func (m *AppModel) handleProjectShortcut(digit byte) (tea.Model, tea.Cmd) { + if digit == 0 { + // Switch to "all" — clear project scope and go to global sessions. + m.currentProject = "" + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + m.activeFilter = nil + m.pollInFlight = true + return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Switched to all projects")) + } + + idx := int(digit) - 1 + if idx >= len(m.projectShortcuts) { + return m, m.setInfo(fmt.Sprintf("No project at index %d", digit)) + } + + projectName := m.projectShortcuts[idx] + m.currentProject = projectName + m.currentAgent = "" + m.currentAgentID = "" + m.currentSession = "" + m.agentTable.SetScope(projectName) + m.navStack = []NavEntry{ + {Kind: "projects", Scope: "all"}, + {Kind: "agents", Scope: projectName}, + } + m.activeView = "agents" + m.activeFilter = nil + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchAgents(projectName), + m.setInfo("Switched to project "+projectName), + ) +} + +// --------------------------------------------------------------------------- +// Contextual hotkey hints for the header +// --------------------------------------------------------------------------- + +// contextualHints returns the hotkey hints for the current active view. +func (m *AppModel) contextualHints() []string { + switch m.activeView { + case "projects": + return []string{ + " Describe", + " New", + " Delete", + } + case "agents": + return []string{ + " Start", + " Stop", + " Inbox", + " Describe", + " Edit", + " Logs", + " New", + " Delete", + } + case "sessions": + return []string{ + " Describe", + " Logs", + " Send", + " New", + " YAML", + " Delete", + } + case "inbox": + return []string{ + " Compose", + " Mark Read", + " Delete", + } + case "messages": + return []string{ + " Autoscroll", + " Raw", + " Send", + " Copy", + } + case "contexts": + return []string{ + "(Enter to switch)", + } + case "detail": + return []string{ + " Copy", + " Back", + } + default: + return nil + } +} From ad635ab5f4e2de7d5d6535c2dacedc22f55fc93a Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:24:09 -0400 Subject: [PATCH 028/117] fix(cli): project shortcuts stacked vertically, hidden on projects/contexts view - Number-key shortcuts only active below project level (not on projects or contexts views where you're already browsing projects) - Shortcuts rendered vertically in a middle column (matching k9s stacked namespace style) instead of horizontal Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 64 +++++++++---------- .../cmd/acpctl/ambient/tui/model_new.go | 5 +- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index c4ab9fe3d..5d317df81 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -107,24 +107,26 @@ func (m *AppModel) viewHeader() string { refreshIndicator, } - // Build project shortcuts line (like k9s namespace shortcuts). - // Format: <0> all <1> proj1 <2> proj2 ... - var shortcutParts []string - shortcutParts = append(shortcutParts, styleCyan.Render("<0>")+styleCyan.Render(" all")) - maxShortcuts := 6 - if len(m.projectShortcuts) < maxShortcuts { - maxShortcuts = len(m.projectShortcuts) - } - for i := 0; i < maxShortcuts; i++ { - shortcutParts = append(shortcutParts, - styleCyan.Render(fmt.Sprintf("<%d>", i+1))+" "+styleCyan.Render(m.projectShortcuts[i])) + // Build stacked project shortcuts (only below project/context level). + // Rendered vertically like k9s namespace shortcuts: + // <0> all + // <1> test + // <2> test-jsell + showShortcuts := m.activeView != "projects" && m.activeView != "contexts" && len(m.projectShortcuts) > 0 + var shortcutLines []string + if showShortcuts { + shortcutLines = append(shortcutLines, styleCyan.Render("<0>")+" "+styleCyan.Render("all")) + maxShortcuts := min(len(m.projectShortcuts), 4) + for i := range maxShortcuts { + shortcutLines = append(shortcutLines, + styleCyan.Render(fmt.Sprintf("<%d>", i+1))+" "+styleCyan.Render(m.projectShortcuts[i])) + } } - shortcutLine := " " + strings.Join(shortcutParts, " ") // Build contextual hints (two rows, ~4 per row). ctxHints := m.contextualHints() var ctxRow1, ctxRow2 []string - splitAt := (len(ctxHints) + 1) / 2 // first row gets the larger half + splitAt := (len(ctxHints) + 1) / 2 for i, h := range ctxHints { rendered := m.renderHint(h) if i < splitAt { @@ -144,15 +146,10 @@ func (m *AppModel) viewHeader() string { } staticLine := strings.Join(staticHints, " ") - // Right side: combine contextual row 1 + static hints on line 0, - // contextual row 2 on line 1, then branding fills remaining lines. - // Layout: - // Line 0: left metadata | ctx hints row1 + static | brand - // Line 1: left metadata | ctx hints row2 | brand - // Line 2: left metadata | shortcuts | brand - // Line 3: left metadata | | brand - // Line 4: left metadata | | brand - + // Right side layout: + // Line 0: ctx hints row1 + static hints + // Line 1: ctx hints row2 + // Lines 2+: (empty, branding fills in) rightHintLines := make([]string, 5) if len(ctxRow1) > 0 { rightHintLines[0] = ctxLine1 + " " + staticLine @@ -162,11 +159,8 @@ func (m *AppModel) viewHeader() string { if len(ctxRow2) > 0 { rightHintLines[1] = ctxLine2 } - if len(m.projectShortcuts) > 0 { - rightHintLines[2] = shortcutLine - } - // Combine left, right-hints, and branding into header lines. + // Combine left metadata, shortcuts (middle), right hints + branding. headerLines := make([]string, 5) for i := range 5 { left := "" @@ -174,6 +168,12 @@ func (m *AppModel) viewHeader() string { left = leftLines[i] } + // Stacked project shortcuts (middle column). + shortcut := "" + if i < len(shortcutLines) { + shortcut = " " + shortcutLines[i] + } + hint := rightHintLines[i] brand := "" @@ -181,19 +181,17 @@ func (m *AppModel) viewHeader() string { brand = styleOrange.Render(brandLines[i]) } - // Calculate padding to right-align hints and branding. - leftWidth := lipgloss.Width(left) - hintWidth := lipgloss.Width(hint) - brandWidth := lipgloss.Width(brand) + leftContent := left + shortcut + leftWidth := lipgloss.Width(leftContent) var rightContent string var rightWidth int if hint != "" { rightContent = hint + " " + brand - rightWidth = hintWidth + 2 + brandWidth + rightWidth = lipgloss.Width(hint) + 2 + lipgloss.Width(brand) } else { rightContent = brand - rightWidth = brandWidth + rightWidth = lipgloss.Width(brand) } gap := m.width - leftWidth - rightWidth @@ -201,7 +199,7 @@ func (m *AppModel) viewHeader() string { gap = 1 } - headerLines[i] = left + strings.Repeat(" ", gap) + rightContent + headerLines[i] = leftContent + strings.Repeat(" ", gap) + rightContent } return strings.Join(headerLines, "\n") diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 27b38ef9e..f2612826a 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1137,8 +1137,9 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } - // Number-key project shortcuts (0-9). - if len(key) == 1 && key[0] >= '0' && key[0] <= '9' { + // Number-key project shortcuts (0-9) — only active below the projects/contexts level. + if len(key) == 1 && key[0] >= '0' && key[0] <= '9' && + m.activeView != "projects" && m.activeView != "contexts" { return m.handleProjectShortcut(key[0] - '0') } From 0f1c151902c783859e9da07a081a264ddb6df2ed Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:27:35 -0400 Subject: [PATCH 029/117] fix(cli): messages view uses k9s-style title bar and status indicators - Title bar: messages(agent/session)[count] centered between dashes (matching all other views' pattern) - Status indicators (Autoscroll, Mode, Phase, SSE) shown on a line below the title bar inside the border, like k9s Logs view - Removed proprietary Session/Phase/Agent header Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/messages.go | 84 +++++++++++-------- 1 file changed, 47 insertions(+), 37 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 71e632f3b..40c9151d4 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -535,51 +535,61 @@ func (ms *MessageStream) View() string { return "Loading…" } - headerStyle := lipgloss.NewStyle().Foreground(msgColorCyan).Bold(true) - dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) borderStyle := lipgloss.NewStyle().Foreground(msgColorDim) + kindStyle := lipgloss.NewStyle().Foreground(msgColorCyan).Bold(true) + scopeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("206")).Bold(true) + countStyle := lipgloss.NewStyle().Foreground(msgColorBlue).Bold(true) - // -- Header -- + // -- k9s-style title bar: messages(agent/session)[count] -- shortID := ms.sessionID if len(shortID) > 12 { - shortID = shortID[:12] + "…" + shortID = shortID[:12] + } + scope := ms.agentName + "/" + shortID + titleRendered := " " + + kindStyle.Render("messages") + + scopeStyle.Render("("+scope+")") + + countStyle.Render(fmt.Sprintf("[%d]", len(ms.messages))) + + " " + titleWidth := lipgloss.Width(titleRendered) + remaining := max(ms.width-titleWidth-2, 2) + leftDashes := remaining / 2 + rightDashes := remaining - leftDashes + titleBar := borderStyle.Render("┌"+strings.Repeat("─", leftDashes)) + + titleRendered + + borderStyle.Render(strings.Repeat("─", rightDashes)+"┐") + + // -- Status indicators line (below title, inside border) -- + autoScrollLabel := "Off" + if ms.autoScroll { + autoScrollLabel = "On" + } + modeLabel := "Conversation" + if ms.rawMode { + modeLabel = "Raw" } phaseStyle := lipgloss.NewStyle().Foreground(phaseColor(ms.phase)) - - // SSE status indicator with color coding. - sseSegment := "" - if ms.sseStatus != "" { - var sseStyle lipgloss.Style + indicators := fmt.Sprintf("Autoscroll:%s Mode:%s Phase:%s", + lipgloss.NewStyle().Foreground(msgColorGreen).Render(autoScrollLabel), + lipgloss.NewStyle().Foreground(msgColorCyan).Render(modeLabel), + phaseStyle.Render(ms.phase), + ) + if ms.sseStatus != "" && ms.sseStatus != "connected" { + var sseColor lipgloss.Color switch ms.sseStatus { - case "connected": - sseStyle = lipgloss.NewStyle().Foreground(msgColorGreen) case "reconnecting": - sseStyle = lipgloss.NewStyle().Foreground(msgColorYellow) - case "disconnected": - sseStyle = lipgloss.NewStyle().Foreground(msgColorRed) + sseColor = msgColorYellow default: - sseStyle = lipgloss.NewStyle().Foreground(msgColorDim) + sseColor = msgColorRed } - sseSegment = fmt.Sprintf(" %s %s %s", - dimStyle.Render("—"), - dimStyle.Render("SSE:"), - sseStyle.Render(ms.sseStatus), - ) - } - - header := fmt.Sprintf(" %s %s %s %s %s %s%s", - headerStyle.Render("Session"), - lipgloss.NewStyle().Foreground(msgColorWhite).Bold(true).Render(shortID), - dimStyle.Render("—"), - fmt.Sprintf("%s %s", dimStyle.Render("Phase:"), phaseStyle.Render(ms.phase)), - dimStyle.Render("—"), - fmt.Sprintf("%s %s", dimStyle.Render("Agent:"), lipgloss.NewStyle().Foreground(msgColorOrange).Render(ms.agentName)), - sseSegment, - ) - - headerBar := borderStyle.Render("┌" + strings.Repeat("─", max(ms.width-2, 0)) + "┐") - headerLine := borderStyle.Render("│") + - padToWidth(header, ms.width-2) + + indicators += fmt.Sprintf(" SSE:%s", + lipgloss.NewStyle().Foreground(sseColor).Render(ms.sseStatus)) + } + // Center the indicators line. + indWidth := lipgloss.Width(indicators) + indPad := max((ms.width-2-indWidth)/2, 0) + indicatorLine := borderStyle.Render("│") + + padToWidth(strings.Repeat(" ", indPad)+indicators, ms.width-2) + borderStyle.Render("│") headerSep := borderStyle.Render("├" + strings.Repeat("─", max(ms.width-2, 0)) + "┤") @@ -647,9 +657,9 @@ func (ms *MessageStream) View() string { // Assemble. var sb strings.Builder - sb.WriteString(headerBar) + sb.WriteString(titleBar) sb.WriteByte('\n') - sb.WriteString(headerLine) + sb.WriteString(indicatorLine) sb.WriteByte('\n') sb.WriteString(headerSep) sb.WriteByte('\n') From fa394135a4b651fd7df0aaebd1a68474c810817d Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:31:52 -0400 Subject: [PATCH 030/117] feat(cli): centered dialog overlays for delete confirmation - Add views/dialog.go with bordered dialog overlay (k9s style) - Delete confirmations render as centered dialogs over the table - Left/Right to select Cancel/OK, Enter to confirm, Esc to cancel - Replace old inline y/n toast confirmation pattern Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 6 - .../cmd/acpctl/ambient/tui/model_new.go | 95 +++-- .../cmd/acpctl/ambient/tui/views/dialog.go | 347 ++++++++++++++++++ 3 files changed, 394 insertions(+), 54 deletions(-) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 5d317df81..9bff8761a 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -269,12 +269,6 @@ func (m *AppModel) viewBreadcrumb() string { // viewInfoLine renders the ephemeral info/toast line at the very bottom. func (m *AppModel) viewInfoLine() string { - // Delete confirmation takes priority over everything. - if m.confirmingDelete { - prompt := fmt.Sprintf("Delete %s %s? (y/n)", m.deleteKind, m.deleteName) - return " " + styleYellow.Render(prompt) - } - // Error takes priority over info. if m.lastError != "" { return " " + styleRed.Render("✗ "+m.lastError) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index f2612826a..b9f4d6f33 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -110,11 +110,9 @@ type AppModel struct { // Errors lastError string - // Delete confirmation - confirmingDelete bool - deleteKind string // "project", "agent", "session", "inbox" - deleteName string // display name for confirmation - deleteFunc func() tea.Cmd // the actual delete call + // Dialog overlay (replaces inline delete confirmation and prompt mode for new resources). + dialog *views.Dialog + dialogAction func() tea.Cmd // executed on DialogConfirmMsg{Confirmed: true} // Rate-limit backoff: skip the next poll cycle when a 429 is received. skipNextPoll bool @@ -861,9 +859,9 @@ func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit } - // Delete confirmation takes priority over all other modes. - if m.confirmingDelete { - return m.handleDeleteConfirmKey(msg) + // Dialog overlay takes priority over all other modes. + if m.dialog != nil { + return m.handleDialogKey(msg) } // Prompt mode (inline text input for new session, etc.). @@ -891,30 +889,39 @@ func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleNormalKey(msg) } -// handleDeleteConfirmKey handles y/n/Esc when a delete confirmation is active. -func (m *AppModel) handleDeleteConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEsc: - m.confirmingDelete = false - m.deleteFunc = nil - return m, m.setInfo("Delete cancelled") - case tea.KeyRunes: - switch msg.String() { - case "y", "Y": - fn := m.deleteFunc - m.confirmingDelete = false - m.deleteFunc = nil +// handleDialogKey delegates key events to the active dialog overlay and +// processes the resulting DialogConfirmMsg / DialogCancelMsg. +func (m *AppModel) handleDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + dlg, cmd := m.dialog.Update(msg) + m.dialog = &dlg + + if cmd == nil { + return m, nil + } + + // Execute the command to get the message, then dispatch it. + resultMsg := cmd() + switch resultMsg.(type) { + case views.DialogCancelMsg: + m.dialog = nil + m.dialogAction = nil + return m, m.setInfo("Cancelled") + case views.DialogConfirmMsg: + confirm := resultMsg.(views.DialogConfirmMsg) + if confirm.Confirmed { + fn := m.dialogAction + m.dialog = nil + m.dialogAction = nil if fn != nil { - return m, tea.Batch(fn(), m.setInfo("Deleting "+m.deleteKind+" "+m.deleteName+"...")) + return m, tea.Batch(fn(), m.setInfo("Processing...")) } - return m, nil - case "n", "N": - m.confirmingDelete = false - m.deleteFunc = nil - return m, m.setInfo("Delete cancelled") + } else { + m.dialog = nil + m.dialogAction = nil + return m, m.setInfo("Cancelled") } } - // Ignore all other keys while confirming. + return m, nil } @@ -1413,13 +1420,11 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { return m, m.setInfo("Project not found in cache: " + projectName) } projectID := project.ID - m.confirmingDelete = true - m.deleteKind = "project" - m.deleteName = projectName - m.deleteFunc = func() tea.Cmd { + d := views.NewDeleteDialog("project", projectName) + m.dialog = &d + m.dialogAction = func() tea.Cmd { return m.client.DeleteProject(projectID) } - m.infoMessage = fmt.Sprintf("Delete project %s? (y/n)", projectName) return m, nil } case "agents": @@ -1432,13 +1437,11 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { } agentID := agent.ID currentProject := m.currentProject - m.confirmingDelete = true - m.deleteKind = "agent" - m.deleteName = agentName - m.deleteFunc = func() tea.Cmd { + d := views.NewDeleteDialog("agent", agentName) + m.dialog = &d + m.dialogAction = func() tea.Cmd { return m.client.DeleteAgent(currentProject, agentID) } - m.infoMessage = fmt.Sprintf("Delete agent %s? (y/n)", agentName) return m, nil } case "sessions": @@ -1454,13 +1457,11 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { project = session.ProjectID } sessionID := session.ID - m.confirmingDelete = true - m.deleteKind = "session" - m.deleteName = shortID - m.deleteFunc = func() tea.Cmd { + d := views.NewDeleteDialog("session", shortID) + m.dialog = &d + m.dialogAction = func() tea.Cmd { return m.client.DeleteSession(project, sessionID) } - m.infoMessage = fmt.Sprintf("Delete session %s? (y/n)", shortID) return m, nil } case "inbox": @@ -1472,13 +1473,11 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { } currentProject := m.currentProject currentAgentID := m.currentAgentID - m.confirmingDelete = true - m.deleteKind = "inbox" - m.deleteName = msgID - m.deleteFunc = func() tea.Cmd { + d := views.NewDeleteDialog("inbox message", msgID) + m.dialog = &d + m.dialogAction = func() tea.Cmd { return m.client.DeleteInboxMessage(currentProject, currentAgentID, msgID) } - m.infoMessage = fmt.Sprintf("Delete inbox %s? (y/n)", msgID) return m, nil } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go new file mode 100644 index 000000000..e1015b727 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go @@ -0,0 +1,347 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// --------------------------------------------------------------------------- +// Dialog message types +// --------------------------------------------------------------------------- + +// DialogConfirmMsg is emitted when the user presses Enter on a dialog button. +type DialogConfirmMsg struct { + Confirmed bool // true if OK/Delete was selected + Value string // text input value (for input dialogs) +} + +// DialogCancelMsg is emitted when the user presses Esc to dismiss a dialog. +type DialogCancelMsg struct{} + +// --------------------------------------------------------------------------- +// Dialog colors (local to views package to avoid circular import) +// --------------------------------------------------------------------------- + +var ( + dlgColorDim = lipgloss.Color("240") + dlgColorOrange = lipgloss.Color("214") + dlgColorWhite = lipgloss.Color("255") + dlgColorBlack = lipgloss.Color("0") +) + +// --------------------------------------------------------------------------- +// Dialog +// --------------------------------------------------------------------------- + +// Dialog represents a centered overlay dialog box that can display a +// confirmation prompt or collect text input, styled after the k9s delete +// confirmation pattern. +type Dialog struct { + Title string // e.g. "Delete", "Confirm", "New Agent" + Message string // e.g. "Delete agent test-agent?" + Buttons []string // e.g. ["Cancel", "OK"] or ["Cancel", "Delete"] + Selected int // which button is highlighted (0=Cancel, 1=OK) + Input *textinput.Model + Width int // dialog width (auto-calculated from content if 0) +} + +// NewConfirmDialog creates a two-button dialog with Cancel and OK. +func NewConfirmDialog(title, message string) Dialog { + return Dialog{ + Title: title, + Message: message, + Buttons: []string{"Cancel", "OK"}, + Selected: 1, // default to OK + } +} + +// NewDeleteDialog creates a delete confirmation dialog with Cancel and Delete +// buttons. The message is formatted as "Delete ?". +func NewDeleteDialog(kind, name string) Dialog { + return Dialog{ + Title: "Delete", + Message: fmt.Sprintf("Delete %s %s?", kind, name), + Buttons: []string{"Cancel", "Delete"}, + Selected: 0, // default to Cancel for safety + } +} + +// NewInputDialog creates a dialog with a text input field and Cancel/OK buttons. +func NewInputDialog(title, prompt string) Dialog { + ti := textinput.New() + ti.Prompt = prompt + ti.CharLimit = 1024 + ti.Focus() + return Dialog{ + Title: title, + Message: "", + Buttons: []string{"Cancel", "OK"}, + Selected: 1, + Input: &ti, + } +} + +// Confirmed returns true if the currently selected button is not the first +// (Cancel) button — i.e. OK or Delete is selected. +func (d Dialog) Confirmed() bool { + return d.Selected > 0 +} + +// InputValue returns the text input value, or empty string if there is no input. +func (d Dialog) InputValue() string { + if d.Input != nil { + return d.Input.Value() + } + return "" +} + +// Update handles key events for the dialog. Left/Right/Tab switch the selected +// button, Enter confirms, Esc cancels. For input dialogs, typing is delegated +// to the embedded textinput. +func (d *Dialog) Update(msg tea.Msg) (Dialog, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc: + return *d, func() tea.Msg { return DialogCancelMsg{} } + + case tea.KeyEnter: + return *d, func() tea.Msg { + return DialogConfirmMsg{ + Confirmed: d.Confirmed(), + Value: d.InputValue(), + } + } + + case tea.KeyLeft, tea.KeyShiftTab: + if d.Selected > 0 { + d.Selected-- + } + return *d, nil + + case tea.KeyRight, tea.KeyTab: + if d.Selected < len(d.Buttons)-1 { + d.Selected++ + } + return *d, nil + + default: + // Delegate typing to the text input if present. + if d.Input != nil { + var cmd tea.Cmd + *d.Input, cmd = d.Input.Update(msg) + return *d, cmd + } + } + } + + return *d, nil +} + +// View renders the dialog as a bordered box that can be overlaid on top of +// other content. The dialog is centered within the given container dimensions. +// The returned string contains the full output with centering padding so the +// caller can replace lines in the underlying content. +func (d Dialog) View(containerWidth, containerHeight int) string { + borderStyle := lipgloss.NewStyle().Foreground(dlgColorDim) + titleStyle := lipgloss.NewStyle().Foreground(dlgColorOrange).Bold(true) + messageStyle := lipgloss.NewStyle().Foreground(dlgColorWhite) + btnActiveStyle := lipgloss.NewStyle(). + Background(dlgColorOrange). + Foreground(dlgColorBlack). + Bold(true). + Padding(0, 1) + btnInactiveStyle := lipgloss.NewStyle(). + Foreground(dlgColorDim). + Padding(0, 1) + + // Calculate dialog width: max(40, message width + 8, input prompt + 16), + // capped at containerWidth - 10. + dlgWidth := 40 + if msgW := lipgloss.Width(d.Message) + 8; msgW > dlgWidth { + dlgWidth = msgW + } + if d.Input != nil { + if promptW := lipgloss.Width(d.Input.Prompt) + 24; promptW > dlgWidth { + dlgWidth = promptW + } + } + if d.Width > 0 && d.Width > dlgWidth { + dlgWidth = d.Width + } + maxWidth := containerWidth - 10 + if maxWidth < 30 { + maxWidth = 30 + } + if dlgWidth > maxWidth { + dlgWidth = maxWidth + } + + // Inner width is the space between the left and right border characters. + innerWidth := dlgWidth - 2 + + // Build the title bar: ┌────────┐ + titleText := titleStyle.Render(d.Title) + titleVisualWidth := lipgloss.Width(titleText) + titleDecorated := borderStyle.Render("<") + titleText + borderStyle.Render(">") + titleDecoratedWidth := titleVisualWidth + 2 // < and > + + remaining := innerWidth - titleDecoratedWidth + if remaining < 2 { + remaining = 2 + } + leftDashes := remaining / 2 + rightDashes := remaining - leftDashes + + topLine := borderStyle.Render("┌"+strings.Repeat("─", leftDashes)) + + titleDecorated + + borderStyle.Render(strings.Repeat("─", rightDashes)+"┐") + + // Empty line. + emptyLine := borderStyle.Render("│") + + strings.Repeat(" ", innerWidth) + + borderStyle.Render("│") + + // Message line (centered within inner width). + var msgLine string + if d.Message != "" { + msgRendered := messageStyle.Render(d.Message) + msgVisualWidth := lipgloss.Width(msgRendered) + msgPadLeft := (innerWidth - msgVisualWidth) / 2 + if msgPadLeft < 1 { + msgPadLeft = 1 + } + msgPadRight := innerWidth - msgVisualWidth - msgPadLeft + if msgPadRight < 0 { + msgPadRight = 0 + } + msgLine = borderStyle.Render("│") + + strings.Repeat(" ", msgPadLeft) + + msgRendered + + strings.Repeat(" ", msgPadRight) + + borderStyle.Render("│") + } + + // Input line (if present). + var inputLine string + if d.Input != nil { + inputRendered := d.Input.View() + inputVisualWidth := lipgloss.Width(inputRendered) + inputPadLeft := 4 + inputPadRight := innerWidth - inputVisualWidth - inputPadLeft + if inputPadRight < 0 { + inputPadRight = 0 + } + inputLine = borderStyle.Render("│") + + strings.Repeat(" ", inputPadLeft) + + inputRendered + + strings.Repeat(" ", inputPadRight) + + borderStyle.Render("│") + } + + // Button line (centered within inner width). + var btnParts []string + for i, label := range d.Buttons { + if i == d.Selected { + btnParts = append(btnParts, btnActiveStyle.Render(label)) + } else { + btnParts = append(btnParts, btnInactiveStyle.Render(label)) + } + } + btnRow := strings.Join(btnParts, " ") + btnVisualWidth := lipgloss.Width(btnRow) + btnPadLeft := (innerWidth - btnVisualWidth) / 2 + if btnPadLeft < 1 { + btnPadLeft = 1 + } + btnPadRight := innerWidth - btnVisualWidth - btnPadLeft + if btnPadRight < 0 { + btnPadRight = 0 + } + btnLine := borderStyle.Render("│") + + strings.Repeat(" ", btnPadLeft) + + btnRow + + strings.Repeat(" ", btnPadRight) + + borderStyle.Render("│") + + // Bottom border. + bottomLine := borderStyle.Render("└" + strings.Repeat("─", innerWidth) + "┘") + + // Assemble dialog lines. + var dialogLines []string + dialogLines = append(dialogLines, topLine) + dialogLines = append(dialogLines, emptyLine) + if d.Message != "" { + dialogLines = append(dialogLines, msgLine) + dialogLines = append(dialogLines, emptyLine) + } + if d.Input != nil { + dialogLines = append(dialogLines, inputLine) + dialogLines = append(dialogLines, emptyLine) + } + dialogLines = append(dialogLines, btnLine) + dialogLines = append(dialogLines, emptyLine) + dialogLines = append(dialogLines, bottomLine) + + // Center the dialog horizontally within the container width. + dlgVisualWidth := lipgloss.Width(dialogLines[0]) + hPad := (containerWidth - dlgVisualWidth) / 2 + if hPad < 0 { + hPad = 0 + } + + for i, line := range dialogLines { + dialogLines[i] = strings.Repeat(" ", hPad) + line + } + + // Center the dialog vertically within the container height. + dlgHeight := len(dialogLines) + vPad := (containerHeight - dlgHeight) / 2 + if vPad < 0 { + vPad = 0 + } + + // Build full output: vPad empty lines, dialog lines, remaining empty lines. + var result []string + for range vPad { + result = append(result, "") + } + result = append(result, dialogLines...) + remaining = containerHeight - vPad - dlgHeight + for range remaining { + result = append(result, "") + } + + return strings.Join(result, "\n") +} + +// OverlayDialog renders the dialog on top of background content. It splits +// both the background and the dialog output into lines, and replaces the +// background lines where the dialog appears. Lines in the dialog output that +// are empty are treated as transparent (the background shows through). +func OverlayDialog(background string, dialog Dialog, containerWidth, containerHeight int) string { + bgLines := strings.Split(background, "\n") + dlgOutput := dialog.View(containerWidth, containerHeight) + dlgLines := strings.Split(dlgOutput, "\n") + + // Ensure bgLines has enough lines. + for len(bgLines) < containerHeight { + bgLines = append(bgLines, "") + } + + // Overlay: replace background lines with dialog lines where non-empty. + for i, dlgLine := range dlgLines { + if i >= len(bgLines) { + break + } + if strings.TrimSpace(dlgLine) != "" { + bgLines[i] = dlgLine + } + } + + return strings.Join(bgLines, "\n") +} From cab2c69ef65f23046d11f351117a0c1ccc87bc5b Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:32:24 -0400 Subject: [PATCH 031/117] =?UTF-8?q?fix(cli):=20header=20layout=20=E2=80=94?= =?UTF-8?q?=20server=20moved=20to=20bottom,=20shortcuts=20truncated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server URL moved to bottom line with refresh indicator (frees space) - Server URL truncated at 50 chars for long URLs - Project shortcut names truncated at 18 chars for alignment - Context/User/Project on top three lines for visibility Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 9bff8761a..0705eaa70 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -86,16 +86,18 @@ func (m *AppModel) viewHeader() string { } } + // Truncate server URL if too long. + displayServer := serverURL + if len(displayServer) > 50 { + displayServer = displayServer[:47] + "..." + } + leftLines := []string{ fmt.Sprintf(" %s %s %s", styleDim.Render("Context:"), styleOrange.Render(contextName), styleDim.Render("[RW]"), ), - fmt.Sprintf(" %s %s", - styleDim.Render("Server: "), - styleWhite.Render(serverURL), - ), fmt.Sprintf(" %s %s", styleDim.Render("User: "), styleWhite.Render("user"), @@ -104,22 +106,28 @@ func (m *AppModel) viewHeader() string { styleDim.Render("Project:"), styleOrange.Render(project), ), - refreshIndicator, + fmt.Sprintf(" %s %s %s", + styleDim.Render("Server: "), + styleDim.Render(displayServer), + refreshIndicator, + ), + "", } // Build stacked project shortcuts (only below project/context level). - // Rendered vertically like k9s namespace shortcuts: - // <0> all - // <1> test - // <2> test-jsell + // Fixed-width columns so they align vertically. showShortcuts := m.activeView != "projects" && m.activeView != "contexts" && len(m.projectShortcuts) > 0 var shortcutLines []string if showShortcuts { shortcutLines = append(shortcutLines, styleCyan.Render("<0>")+" "+styleCyan.Render("all")) maxShortcuts := min(len(m.projectShortcuts), 4) for i := range maxShortcuts { + name := m.projectShortcuts[i] + if len(name) > 18 { + name = name[:15] + "..." + } shortcutLines = append(shortcutLines, - styleCyan.Render(fmt.Sprintf("<%d>", i+1))+" "+styleCyan.Render(m.projectShortcuts[i])) + styleCyan.Render(fmt.Sprintf("<%d>", i+1))+" "+styleCyan.Render(name)) } } From ffc80366fc9325a58c6c9e02e4341da1ee81f3d3 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:35:09 -0400 Subject: [PATCH 032/117] feat(cli): agent/session counts in project table, active phase in agent table - Project table shows AGENTS and SESSIONS columns (background fan-out) - Agent table PHASE column shows "active" when session is running - FetchProjectCounts fans out per-project to count agents and sessions - Counts populate asynchronously after project list loads Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/client.go | 67 +++++++++++++++++ .../cmd/acpctl/ambient/tui/model_new.go | 73 +++++++++++++++++++ .../cmd/acpctl/ambient/tui/views/agents.go | 10 ++- .../cmd/acpctl/ambient/tui/views/projects.go | 20 ++++- 4 files changed, 164 insertions(+), 6 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index 3558266cb..df4fcbbc8 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -48,6 +48,19 @@ type InboxMsg struct { Err error } +// ProjectCounts holds agent and session counts for a single project. +type ProjectCounts struct { + AgentCount int + SessionCount int +} + +// ProjectCountsMsg carries per-project agent and session counts keyed by +// project name. Sent after a background fan-out fetch completes. +type ProjectCountsMsg struct { + Counts map[string]ProjectCounts + Err error +} + // --------------------------------------------------------------------------- // CRUD message types for mutating operations. // --------------------------------------------------------------------------- @@ -177,6 +190,60 @@ func (tc *TUIClient) FetchProjects() tea.Cmd { } } +// FetchProjectCounts returns a tea.Cmd that fans out per-project agent and +// session list fetches and returns a ProjectCountsMsg with the counts. Partial +// failures are tolerated — failed projects get count -1 for both fields. +func (tc *TUIClient) FetchProjectCounts(projects []string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + var ( + mu sync.Mutex + counts = make(map[string]ProjectCounts, len(projects)) + wg sync.WaitGroup + ) + + for _, proj := range projects { + wg.Add(1) + go func() { + defer wg.Done() + + client, err := tc.factory.ForProject(proj) + if err != nil { + mu.Lock() + counts[proj] = ProjectCounts{AgentCount: -1, SessionCount: -1} + mu.Unlock() + return + } + + var ac, sc int + + agentList, err := client.Agents().List(ctx, defaultListOpts()) + if err != nil { + ac = -1 + } else { + ac = len(agentList.Items) + } + + sessionList, err := client.Sessions().List(ctx, defaultListOpts()) + if err != nil { + sc = -1 + } else { + sc = len(sessionList.Items) + } + + mu.Lock() + counts[proj] = ProjectCounts{AgentCount: ac, SessionCount: sc} + mu.Unlock() + }() + } + + wg.Wait() + return ProjectCountsMsg{Counts: counts} + } +} + // FetchAgents returns a tea.Cmd that lists agents in the given project. func (tc *TUIClient) FetchAgents(projectID string) tea.Cmd { return func() tea.Msg { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index b9f4d6f33..c17cdd902 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -419,6 +419,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ProjectsMsg: return m.handleProjectsMsg(msg) + case ProjectCountsMsg: + return m.handleProjectCountsMsg(msg) + case AgentsMsg: return m.handleAgentsMsg(msg) @@ -673,6 +676,76 @@ func (m *AppModel) handleProjectsMsg(msg ProjectsMsg) (tea.Model, tea.Cmd) { Sanitize(p.Name), Sanitize(desc), Sanitize(status), + "-", // AGENTS — placeholder until ProjectCountsMsg arrives + "-", // SESSIONS — placeholder until ProjectCountsMsg arrives + age, + }) + } + m.projectTable.SetRows(rows) + + // Re-apply active filter if present and we're on projects view. + if m.activeView == "projects" && m.activeFilter != nil { + f := m.activeFilter + m.projectTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + // Trigger background fetch of agent/session counts per project. + var cmds []tea.Cmd + if len(names) > 0 { + cmds = append(cmds, m.client.FetchProjectCounts(names)) + } + + return m, tea.Batch(cmds...) +} + +// handleProjectCountsMsg rebuilds the project table rows with real agent and +// session counts returned from the background FetchProjectCounts fan-out. +func (m *AppModel) handleProjectCountsMsg(msg ProjectCountsMsg) (tea.Model, tea.Cmd) { + if msg.Err != nil { + // Non-fatal — just keep the "-" placeholders. + return m, nil + } + + now := time.Now() + rows := make([]table.Row, 0, len(m.cachedProjects)) + for _, p := range m.cachedProjects { + age := "" + if p.CreatedAt != nil { + age = fmtAge(now.Sub(*p.CreatedAt)) + } + desc := p.Description + if len(desc) > 60 { + desc = desc[:59] + "..." + } + status := p.Status + if status == "" { + status = "active" + } + + agentCount := -1 + sessionCount := -1 + if counts, ok := msg.Counts[p.Name]; ok { + agentCount = counts.AgentCount + sessionCount = counts.SessionCount + } + + agents := "-" + if agentCount >= 0 { + agents = fmt.Sprintf("%d", agentCount) + } + sessions := "-" + if sessionCount >= 0 { + sessions = fmt.Sprintf("%d", sessionCount) + } + + rows = append(rows, table.Row{ + Sanitize(p.Name), + Sanitize(desc), + Sanitize(status), + agents, + sessions, age, }) } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go index 366a0a9a8..38342e66a 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go @@ -23,9 +23,9 @@ func AgentColumns() []table.Column { // AgentRow converts an SDK Agent into a table row suitable for the agent list // view. The now parameter is used to compute the relative AGE column. // -// The PHASE column is left empty because populating it requires a secondary -// fetch of the agent's current session. The caller (model layer) is responsible -// for enriching rows with phase data after the fan-out fetch. +// The PHASE column shows "active" when the agent has a current session ID, +// and is left empty otherwise. This avoids an N+1 session fetch while still +// providing a useful status indicator. func AgentRow(a sdktypes.Agent, now time.Time) table.Row { age := "" if a.CreatedAt != nil { @@ -33,15 +33,17 @@ func AgentRow(a sdktypes.Agent, now time.Time) table.Row { } session := "" + phase := "" if a.CurrentSessionID != "" { session = a.CurrentSessionID + phase = "active" } return table.Row{ a.Name, TruncateString(a.Prompt, 60), session, - "", // PHASE — requires secondary fetch; filled in by the model + phase, age, } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/projects.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/projects.go index ad0e13697..1c9e5a7bd 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/projects.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/projects.go @@ -10,29 +10,45 @@ import ( ) // ProjectColumns returns the column definitions for the project list view. -// Column order matches the TUI spec: NAME, DESCRIPTION, STATUS, AGE. +// Column order: NAME, DESCRIPTION, STATUS, AGENTS, SESSIONS, AGE. func ProjectColumns() []table.Column { return []table.Column{ {Title: "NAME", Width: 25}, {Title: "DESCRIPTION", Width: 40}, {Title: "STATUS", Width: 12}, + {Title: "AGENTS", Width: 8}, + {Title: "SESSIONS", Width: 8}, {Title: "AGE", Width: 8}, } } // ProjectRow converts an SDK Project into a table row suitable for the project // list view. The now parameter is used to compute the relative AGE column. +// agentCount and sessionCount are displayed as integers; a value of -1 renders +// as "-" to indicate counts have not been loaded yet. // Truncation of long values is handled by the table widget. -func ProjectRow(p sdktypes.Project, now time.Time) table.Row { +func ProjectRow(p sdktypes.Project, now time.Time, agentCount, sessionCount int) table.Row { age := "" if p.CreatedAt != nil { age = FormatAge(now.Sub(*p.CreatedAt)) } + agents := "-" + if agentCount >= 0 { + agents = fmt.Sprintf("%d", agentCount) + } + + sessions := "-" + if sessionCount >= 0 { + sessions = fmt.Sprintf("%d", sessionCount) + } + return table.Row{ p.Name, p.Description, p.Status, + agents, + sessions, age, } } From 86c76545fe55f6d51555443caad3779ebacfac0a Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:37:03 -0400 Subject: [PATCH 033/117] =?UTF-8?q?fix(cli):=20header=204-column=20layout?= =?UTF-8?q?=20=E2=80=94=20shortcuts=20padded,=20refresh=20under=20logo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4 distinct columns: metadata | project shortcuts | hotkeys | logo - Shortcuts in own column at fixed position (col 40), green numbers, white project names, truncated at 16 chars - Refresh indicator moved under logo (col 4, line 4) - Server URL on line 4 (bottom of metadata), truncated at 45 chars - Columns pad to fixed positions for alignment Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 199 ++++++++---------- 1 file changed, 83 insertions(+), 116 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 0705eaa70..971e47537 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -51,14 +51,11 @@ func (m *AppModel) View() string { return strings.Join(sections, "\n") } -// viewHeader renders the multi-line header block with context info on the left, -// project shortcuts in the center, and contextual hotkeys + static hints + branding -// on the right. +// viewHeader renders the header with 4 columns like k9s: +// +// Col1: Metadata Col2: Project shortcuts Col3: Hotkey hints Col4: Logo+refresh func (m *AppModel) viewHeader() string { - // Left side: context metadata lines. - contextName := "none" - serverURL := "unknown" - project := "none" + contextName, serverURL, project := "none", "unknown", "none" if m.config != nil { if m.config.CurrentContext != "" { contextName = m.config.CurrentContext @@ -72,145 +69,115 @@ func (m *AppModel) viewHeader() string { } } } - - // Refresh indicator. - refreshIndicator := "" - if !m.lastFetch.IsZero() { - elapsed := time.Since(m.lastFetch) - indicator := fmt.Sprintf("%ds", int(elapsed.Seconds())) - if elapsed > staleThreshold { - indicator += " (stale)" - refreshIndicator = styleRed.Render(" ⟳ " + indicator) - } else { - refreshIndicator = styleDim.Render(" ⟳ " + indicator) - } + if len(serverURL) > 45 { + serverURL = serverURL[:42] + "..." } - // Truncate server URL if too long. - displayServer := serverURL - if len(displayServer) > 50 { - displayServer = displayServer[:47] + "..." + // Col 1: metadata. + col1 := [5]string{ + fmt.Sprintf(" %s %s %s", styleDim.Render("Context:"), styleOrange.Render(contextName), styleDim.Render("[RW]")), + fmt.Sprintf(" %s %s", styleDim.Render("User: "), styleWhite.Render("user")), + fmt.Sprintf(" %s %s", styleDim.Render("Project:"), styleOrange.Render(project)), + fmt.Sprintf(" %s %s", styleDim.Render("Server: "), styleDim.Render(serverURL)), } - leftLines := []string{ - fmt.Sprintf(" %s %s %s", - styleDim.Render("Context:"), - styleOrange.Render(contextName), - styleDim.Render("[RW]"), - ), - fmt.Sprintf(" %s %s", - styleDim.Render("User: "), - styleWhite.Render("user"), - ), - fmt.Sprintf(" %s %s", - styleDim.Render("Project:"), - styleOrange.Render(project), - ), - fmt.Sprintf(" %s %s %s", - styleDim.Render("Server: "), - styleDim.Render(displayServer), - refreshIndicator, - ), - "", - } - - // Build stacked project shortcuts (only below project/context level). - // Fixed-width columns so they align vertically. - showShortcuts := m.activeView != "projects" && m.activeView != "contexts" && len(m.projectShortcuts) > 0 - var shortcutLines []string - if showShortcuts { - shortcutLines = append(shortcutLines, styleCyan.Render("<0>")+" "+styleCyan.Render("all")) - maxShortcuts := min(len(m.projectShortcuts), 4) - for i := range maxShortcuts { + // Col 2: project shortcuts (stacked, padded to fixed width). + var col2 [5]string + if m.activeView != "projects" && m.activeView != "contexts" && len(m.projectShortcuts) > 0 { + col2[0] = styleGreen.Render("<0>") + " " + styleWhite.Render("all") + for i := range min(len(m.projectShortcuts), 4) { name := m.projectShortcuts[i] - if len(name) > 18 { - name = name[:15] + "..." + if len(name) > 16 { + name = name[:13] + "..." } - shortcutLines = append(shortcutLines, - styleCyan.Render(fmt.Sprintf("<%d>", i+1))+" "+styleCyan.Render(name)) + col2[i+1] = styleGreen.Render(fmt.Sprintf("<%d>", i+1)) + " " + styleWhite.Render(name) } } - // Build contextual hints (two rows, ~4 per row). - ctxHints := m.contextualHints() - var ctxRow1, ctxRow2 []string - splitAt := (len(ctxHints) + 1) / 2 - for i, h := range ctxHints { - rendered := m.renderHint(h) - if i < splitAt { - ctxRow1 = append(ctxRow1, rendered) + // Col 3: contextual hotkey hints (two rows). + var col3 [5]string + hints := m.contextualHints() + split := (len(hints) + 1) / 2 + var row1, row2 []string + for i, h := range hints { + if i < split { + row1 = append(row1, m.renderHint(h)) } else { - ctxRow2 = append(ctxRow2, rendered) + row2 = append(row2, m.renderHint(h)) } } - ctxLine1 := strings.Join(ctxRow1, " ") - ctxLine2 := strings.Join(ctxRow2, " ") - - // Static hints (always shown). - staticHints := []string{ - styleDim.Render("") + " " + styleWhite.Render("Help"), - styleDim.Render("<:>") + " " + styleWhite.Render("Command"), - styleDim.Render("") + " " + styleWhite.Render("Filter"), + col3[0] = strings.Join(row1, " ") + col3[1] = strings.Join(row2, " ") + + // Col 4: static hints + logo + refresh. + var col4 [5]string + col4[0] = styleDim.Render("") + " " + styleWhite.Render("Help ") + col4[1] = styleDim.Render("<:>") + " " + styleWhite.Render("Command") + col4[2] = styleDim.Render("") + " " + styleWhite.Render("Filter ") + if !m.lastFetch.IsZero() { + elapsed := time.Since(m.lastFetch) + ind := fmt.Sprintf("⟳ %ds", int(elapsed.Seconds())) + if elapsed > staleThreshold { + col4[3] = styleRed.Render(ind + " (stale)") + } else { + col4[3] = styleDim.Render(ind) + } } - staticLine := strings.Join(staticHints, " ") - // Right side layout: - // Line 0: ctx hints row1 + static hints - // Line 1: ctx hints row2 - // Lines 2+: (empty, branding fills in) - rightHintLines := make([]string, 5) - if len(ctxRow1) > 0 { - rightHintLines[0] = ctxLine1 + " " + staticLine - } else { - rightHintLines[0] = staticLine - } - if len(ctxRow2) > 0 { - rightHintLines[1] = ctxLine2 - } + // Fixed column positions (visual widths). + const col2Start = 40 // shortcuts column starts at char 40 + const col3Start = 65 // hotkeys column starts at char 65 - // Combine left metadata, shortcuts (middle), right hints + branding. - headerLines := make([]string, 5) + lines := make([]string, 5) for i := range 5 { - left := "" - if i < len(leftLines) { - left = leftLines[i] + // Start with col1. + line := col1[i] + w := lipgloss.Width(line) + + // Pad to col2 position and add shortcut. + if col2[i] != "" { + if w < col2Start { + line += strings.Repeat(" ", col2Start-w) + } else { + line += " " + } + line += col2[i] } - - // Stacked project shortcuts (middle column). - shortcut := "" - if i < len(shortcutLines) { - shortcut = " " + shortcutLines[i] + w = lipgloss.Width(line) + + // Pad to col3 position and add hints. + if col3[i] != "" { + if w < col3Start { + line += strings.Repeat(" ", col3Start-w) + } else { + line += " " + } + line += col3[i] } + w = lipgloss.Width(line) - hint := rightHintLines[i] - + // Right-align col4 (static hints + brand). brand := "" if i < len(brandLines) { brand = styleOrange.Render(brandLines[i]) } - - leftContent := left + shortcut - leftWidth := lipgloss.Width(leftContent) - - var rightContent string - var rightWidth int - if hint != "" { - rightContent = hint + " " + brand - rightWidth = lipgloss.Width(hint) + 2 + lipgloss.Width(brand) + right := "" + if col4[i] != "" && brand != "" { + right = col4[i] + " " + brand + } else if brand != "" { + right = brand } else { - rightContent = brand - rightWidth = lipgloss.Width(brand) + right = col4[i] } - - gap := m.width - leftWidth - rightWidth + rw := lipgloss.Width(right) + gap := m.width - w - rw if gap < 1 { gap = 1 } - - headerLines[i] = leftContent + strings.Repeat(" ", gap) + rightContent + lines[i] = line + strings.Repeat(" ", gap) + right } - return strings.Join(headerLines, "\n") + return strings.Join(lines, "\n") } // renderHint renders a single hotkey hint like " Describe" with dim brackets From ac29712213554b2275e4b2fb5c96034e78c0b47f Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:39:12 -0400 Subject: [PATCH 034/117] fix(cli): don't truncate server URL in header Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/app.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 971e47537..5961f3e4c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -69,10 +69,6 @@ func (m *AppModel) viewHeader() string { } } } - if len(serverURL) > 45 { - serverURL = serverURL[:42] + "..." - } - // Col 1: metadata. col1 := [5]string{ fmt.Sprintf(" %s %s %s", styleDim.Render("Context:"), styleOrange.Render(contextName), styleDim.Render("[RW]")), From 9c7e150ac1b9eb8a0b4a882ceecbb3d6a223997f Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:40:02 -0400 Subject: [PATCH 035/117] fix(cli): running/active phase uses orange (214) instead of green Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/events.go | 6 +++--- .../ambient-cli/cmd/acpctl/ambient/tui/views/messages.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/events.go b/components/ambient-cli/cmd/acpctl/ambient/tui/events.go index 0db011d6f..de649a053 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/events.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/events.go @@ -40,7 +40,7 @@ func EventColor(eventType string) lipgloss.Color { // PhaseColor returns the display color for a session phase. // // pending -> yellow (33) -// running -> green (28) +// running / active -> orange (214) // succeeded / completed -> dim (240) // failed -> red (31) // cancelled -> dim (240) @@ -48,8 +48,8 @@ func PhaseColor(phase string) lipgloss.Color { switch strings.ToLower(phase) { case "pending": return colorYellow // 33 - case "running": - return colorGreen // 28 + case "running", "active": + return colorOrange // 214 case "succeeded", "completed": return colorDim // 240 case "failed": diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 40c9151d4..80564608e 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -68,8 +68,8 @@ func phaseColor(phase string) lipgloss.Color { switch strings.ToLower(phase) { case "pending": return msgColorYellow - case "running": - return msgColorGreen + case "running", "active": + return msgColorOrange case "succeeded", "completed": return msgColorDim case "failed": From 24de9155105dafa71d5abb8fc4f6cf2bae8cfdba Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:44:03 -0400 Subject: [PATCH 036/117] feat(cli): real session counts per agent + row coloring by phase - Agent table SESSIONS column shows real count via background fan-out - Phase values rendered with embedded lipgloss colors in table cells: active/running=orange, pending=yellow, failed=red, idle=dim - FetchAgentCounts fans out per-agent session count requests - Session table PHASE column also colored inline Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/client.go | 54 +++++++ .../cmd/acpctl/ambient/tui/model_new.go | 148 ++++++++++++------ .../cmd/acpctl/ambient/tui/views/agents.go | 32 ++-- .../cmd/acpctl/ambient/tui/views/sessions.go | 35 ++++- 4 files changed, 211 insertions(+), 58 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index df4fcbbc8..aaa57dcaa 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -61,6 +61,18 @@ type ProjectCountsMsg struct { Err error } +// AgentCounts holds the session count for a single agent. +type AgentCounts struct { + SessionCount int +} + +// AgentCountsMsg carries per-agent session counts keyed by agent ID. +// Sent after a background fan-out fetch completes. +type AgentCountsMsg struct { + Counts map[string]AgentCounts + Err error +} + // --------------------------------------------------------------------------- // CRUD message types for mutating operations. // --------------------------------------------------------------------------- @@ -244,6 +256,48 @@ func (tc *TUIClient) FetchProjectCounts(projects []string) tea.Cmd { } } +// FetchAgentCounts returns a tea.Cmd that fans out per-agent session list +// fetches and returns an AgentCountsMsg with the counts. Uses the +// AgentAPI.Sessions() endpoint to count sessions per agent. Partial failures +// are tolerated — failed agents get count -1. +func (tc *TUIClient) FetchAgentCounts(projectID string, agentIDs []string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + var ( + mu sync.Mutex + counts = make(map[string]AgentCounts, len(agentIDs)) + wg sync.WaitGroup + ) + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return AgentCountsMsg{Err: err} + } + + for _, agentID := range agentIDs { + wg.Add(1) + go func() { + defer wg.Done() + + sessionList, err := client.Agents().Sessions(ctx, projectID, agentID, defaultListOpts()) + sc := -1 + if err == nil { + sc = len(sessionList.Items) + } + + mu.Lock() + counts[agentID] = AgentCounts{SessionCount: sc} + mu.Unlock() + }() + } + + wg.Wait() + return AgentCountsMsg{Counts: counts} + } +} + // FetchAgents returns a tea.Cmd that lists agents in the given project. func (tc *TUIClient) FetchAgents(projectID string) tea.Cmd { return func() tea.Msg { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index c17cdd902..5dd8ae5c0 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -425,6 +425,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case AgentsMsg: return m.handleAgentsMsg(msg) + case AgentCountsMsg: + return m.handleAgentCountsMsg(msg) + case SessionsMsg: return m.handleSessionsMsg(msg) @@ -763,6 +766,7 @@ func (m *AppModel) handleProjectCountsMsg(msg ProjectCountsMsg) (tea.Model, tea. } // handleAgentsMsg populates the agent table from a fetch result. +// Session counts are initially shown as "-" until AgentCountsMsg arrives. func (m *AppModel) handleAgentsMsg(msg AgentsMsg) (tea.Model, tea.Cmd) { m.pollInFlight = false m.lastFetch = time.Now() @@ -781,10 +785,60 @@ func (m *AppModel) handleAgentsMsg(msg AgentsMsg) (tea.Model, tea.Cmd) { rows := make([]table.Row, 0, len(msg.Agents)) for _, a := range msg.Agents { - row := views.AgentRow(a, now) - // Sanitize all cells. + // Pass -1 for session count — placeholder until AgentCountsMsg arrives. + row := views.AgentRow(a, -1, now) + // Sanitize all cells except PHASE (index 3) which contains embedded ANSI color. for i := range row { - row[i] = Sanitize(row[i]) + if i != 3 { + row[i] = Sanitize(row[i]) + } + } + rows = append(rows, row) + } + m.agentTable.SetRows(rows) + + // Re-apply active filter if present and we're on agents view. + if m.activeView == "agents" && m.activeFilter != nil { + f := m.activeFilter + m.agentTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + // Trigger background fetch of session counts per agent. + var cmds []tea.Cmd + if len(msg.Agents) > 0 && m.currentProject != "" { + agentIDs := make([]string, 0, len(msg.Agents)) + for _, a := range msg.Agents { + agentIDs = append(agentIDs, a.ID) + } + cmds = append(cmds, m.client.FetchAgentCounts(m.currentProject, agentIDs)) + } + + return m, tea.Batch(cmds...) +} + +// handleAgentCountsMsg rebuilds agent table rows with real session counts +// returned from the background FetchAgentCounts fan-out. +func (m *AppModel) handleAgentCountsMsg(msg AgentCountsMsg) (tea.Model, tea.Cmd) { + if msg.Err != nil { + // Non-fatal — just keep the "-" placeholders. + return m, nil + } + + now := time.Now() + rows := make([]table.Row, 0, len(m.cachedAgents)) + for _, a := range m.cachedAgents { + sc := -1 + if counts, ok := msg.Counts[a.ID]; ok { + sc = counts.SessionCount + } + row := views.AgentRow(a, sc, now) + // Sanitize all cells except PHASE (index 3) which contains embedded ANSI color. + for i := range row { + if i != 3 { + row[i] = Sanitize(row[i]) + } } rows = append(rows, row) } @@ -825,8 +879,11 @@ func (m *AppModel) handleSessionsMsg(msg SessionsMsg) (tea.Model, tea.Cmd) { for _, s := range sessions { if s.AgentID == m.currentAgentID { row := views.SessionRow(s, m.currentAgent, now) + // Sanitize all cells except PHASE (index 3) which contains embedded ANSI color. for i := range row { - row[i] = Sanitize(row[i]) + if i != 3 { + row[i] = Sanitize(row[i]) + } } rows = append(rows, row) } @@ -841,8 +898,11 @@ func (m *AppModel) handleSessionsMsg(msg SessionsMsg) (tea.Model, tea.Cmd) { agentName = agentName[:12] } row := views.SessionRow(s, agentName, now) + // Sanitize all cells except PHASE (index 3) which contains embedded ANSI color. for i := range row { - row[i] = Sanitize(row[i]) + if i != 3 { + row[i] = Sanitize(row[i]) + } } rows = append(rows, row) } @@ -1303,15 +1363,15 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { return m, m.setInfo("No agent selected") } agentName := row[0] - sessionID := "" - if len(row) > 2 { - sessionID = row[2] // SESSION column + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) } - if sessionID == "" || sessionID == "" { + if agent.CurrentSessionID == "" { return m, m.setInfo("Agent " + agentName + " has no active session") } return m, tea.Batch( - m.client.StopAgent(m.currentProject, sessionID), + m.client.StopAgent(m.currentProject, agent.CurrentSessionID), m.setInfo("Stopping agent "+agentName+"..."), ) case "e": @@ -1319,44 +1379,42 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { case "l": // Logs — if agent has an active session, jump to message stream. row := m.agentTable.SelectedRow() - if len(row) > 2 && row[2] != "" && row[2] != "" { - agentName := row[0] - sessionID := row[2] - m.currentAgent = agentName - agent := m.findAgentByName(agentName) - if agent != nil { - m.currentAgentID = agent.ID - } else { - m.currentAgentID = agentName - } - m.currentSession = sessionID - phase := "" - if len(row) > 3 { - phase = row[3] - } - m.messageStream = views.NewMessageStream(sessionID, agentName, phase) - m.resizeTable() - - cmds := []tea.Cmd{ - m.pushView("messages", sessionID, sessionID), - m.setInfo("Streaming messages for session " + sessionID), - } + if len(row) == 0 { + return m, m.setInfo("No agent selected") + } + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + if agent.CurrentSessionID == "" { + return m, m.setInfo("No active session for this agent") + } + sessionID := agent.CurrentSessionID + m.currentAgent = agentName + m.currentAgentID = agent.ID + m.currentSession = sessionID + m.messageStream = views.NewMessageStream(sessionID, agentName, "active") + m.resizeTable() - // Start SSE watcher if we have a program reference and project context. - if m.program != nil && m.currentProject != "" { - cmds = append(cmds, m.client.WatchSessionMessages(m.currentProject, sessionID, 0, m.program)) - } else { - m.messageStream.AddMessage(views.MessageEntry{ - Seq: 1, - EventType: "system", - Payload: "Connected to session " + sessionID + " (SSE requires program ref)", - Timestamp: time.Now(), - }) - } + cmds := []tea.Cmd{ + m.pushView("messages", sessionID, sessionID), + m.setInfo("Streaming messages for session " + sessionID), + } - return m, tea.Batch(cmds...) + // Start SSE watcher if we have a program reference and project context. + if m.program != nil && m.currentProject != "" { + cmds = append(cmds, m.client.WatchSessionMessages(m.currentProject, sessionID, 0, m.program)) + } else { + m.messageStream.AddMessage(views.MessageEntry{ + Seq: 1, + EventType: "system", + Payload: "Connected to session " + sessionID + " (SSE requires program ref)", + Timestamp: time.Now(), + }) } - return m, m.setInfo("No active session for this agent") + + return m, tea.Batch(cmds...) case "d": // Show detail view for the selected agent. row := m.agentTable.SelectedRow() diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go index 38342e66a..60b971318 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go @@ -1,49 +1,57 @@ package views import ( + "fmt" "time" "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" ) // AgentColumns returns the column definitions for the agent list view. -// Column order matches the TUI spec: NAME, PROMPT, SESSION, PHASE, AGE. +// Column order matches the TUI spec: NAME, PROMPT, SESSIONS, PHASE, AGE. func AgentColumns() []table.Column { return []table.Column{ {Title: "NAME", Width: 20}, {Title: "PROMPT", Width: 60}, - {Title: "SESSION", Width: 14}, + {Title: "SESSIONS", Width: 10}, {Title: "PHASE", Width: 12}, {Title: "AGE", Width: 8}, } } // AgentRow converts an SDK Agent into a table row suitable for the agent list -// view. The now parameter is used to compute the relative AGE column. +// view. The sessionCount parameter is the number of sessions for this agent +// (-1 means not yet loaded, displayed as "-"). The now parameter is used to +// compute the relative AGE column. // -// The PHASE column shows "active" when the agent has a current session ID, -// and is left empty otherwise. This avoids an N+1 session fetch while still -// providing a useful status indicator. -func AgentRow(a sdktypes.Agent, now time.Time) table.Row { +// The PHASE column shows "active" (orange) when the agent has a current +// session ID, and "idle" (dim) otherwise. Phase text is rendered with +// embedded lipgloss color so it displays correctly in the bubbles/table. +func AgentRow(a sdktypes.Agent, sessionCount int, now time.Time) table.Row { age := "" if a.CreatedAt != nil { age = FormatAge(now.Sub(*a.CreatedAt)) } - session := "" - phase := "" + sessions := "-" + if sessionCount >= 0 { + sessions = fmt.Sprintf("%d", sessionCount) + } + + phase := "idle" if a.CurrentSessionID != "" { - session = a.CurrentSessionID phase = "active" } + styledPhase := lipgloss.NewStyle().Foreground(PhaseColor(phase)).Render(phase) return table.Row{ a.Name, TruncateString(a.Prompt, 60), - session, - phase, + sessions, + styledPhase, age, } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go index cea45da46..690744217 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go @@ -1,13 +1,39 @@ package views import ( + "strings" "time" "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" ) +// PhaseColor returns the display color for a session or agent phase. +// +// pending -> yellow (33) +// running / active -> orange (214) +// succeeded / completed -> dim (240) +// failed -> red (31) +// cancelled / idle -> dim (240) +func PhaseColor(phase string) lipgloss.Color { + switch strings.ToLower(phase) { + case "pending": + return lipgloss.Color("33") // yellow + case "running", "active": + return lipgloss.Color("214") // orange + case "succeeded", "completed": + return lipgloss.Color("240") // dim + case "failed": + return lipgloss.Color("31") // red + case "cancelled", "idle": + return lipgloss.Color("240") // dim + default: + return lipgloss.Color("240") // dim + } +} + // SessionColumns returns the column definitions for the session list view. // Column order matches the TUI spec: ID, AGENT, PROJECT, PHASE, TRIGGERED BY, STARTED, DURATION. func SessionColumns() []table.Column { @@ -31,6 +57,9 @@ func SessionColumns() []table.Column { // ID is shown in short form (first 12 characters). DURATION is computed as // CompletionTime - StartTime for completed sessions, now - StartTime for // running sessions, or empty for pending sessions. +// +// The PHASE column value is rendered with lipgloss-embedded color so it +// displays correctly in the bubbles/table without conflicting with Cell style. func SessionRow(s sdktypes.Session, agentName string, now time.Time) table.Row { // Short ID: first 12 characters. shortID := s.ID @@ -54,11 +83,15 @@ func SessionRow(s sdktypes.Session, agentName string, now time.Time) table.Row { duration = FormatAge(now.Sub(*s.StartTime)) } + // Render PHASE with embedded color. + phase := s.Phase + styledPhase := lipgloss.NewStyle().Foreground(PhaseColor(phase)).Render(phase) + return table.Row{ shortID, agentName, s.ProjectID, - s.Phase, + styledPhase, s.TriggeredByUserID, started, duration, From 2cebd4cea80d1de76e5f6be3301499dd4840d8d3 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:45:28 -0400 Subject: [PATCH 037/117] fix(cli): full-width row highlight accounts for cell padding Selected style Width now uses distributable + cellPadding to match the actual rendered row width including per-cell padding. Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index df50ce64b..c0f207f0f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -270,7 +270,7 @@ func (rt *ResourceTable) SetWidth(w int) { Foreground(lipgloss.Color("0")). Background(rt.style.SelectedBg). Bold(true). - Width(usable) + Width(distributable + cellPadding) rt.inner.SetStyles(s) } From 32181b4cd808ec04fd2daf20c06a392c7d636013 Mon Sep 17 00:00:00 2001 From: John Sell Date: Fri, 24 Apr 2026 20:50:08 -0400 Subject: [PATCH 038/117] fix(cli): full-row coloring by phase + reliable full-width highlight - All cells in agent/session rows colored by phase (not just PHASE column), matching k9s style: active=orange, failed=red, idle=dim - Selected row highlight applied in View() post-processing instead of relying on bubbles/table Selected.Width (unreliable with ANSI) - Detect selected row by bold marker, apply orange bg + black fg across the full border-to-border width Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/agents.go | 12 ++-- .../cmd/acpctl/ambient/tui/views/sessions.go | 17 +++--- .../cmd/acpctl/ambient/tui/views/table.go | 60 ++++++++++++++----- 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go index 60b971318..5736c014b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go @@ -45,14 +45,14 @@ func AgentRow(a sdktypes.Agent, sessionCount int, now time.Time) table.Row { if a.CurrentSessionID != "" { phase = "active" } - styledPhase := lipgloss.NewStyle().Foreground(PhaseColor(phase)).Render(phase) + rowStyle := lipgloss.NewStyle().Foreground(PhaseColor(phase)) return table.Row{ - a.Name, - TruncateString(a.Prompt, 60), - sessions, - styledPhase, - age, + rowStyle.Render(a.Name), + rowStyle.Render(TruncateString(a.Prompt, 60)), + rowStyle.Render(sessions), + rowStyle.Render(phase), + rowStyle.Render(age), } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go index 690744217..5869e100d 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go @@ -83,18 +83,17 @@ func SessionRow(s sdktypes.Session, agentName string, now time.Time) table.Row { duration = FormatAge(now.Sub(*s.StartTime)) } - // Render PHASE with embedded color. phase := s.Phase - styledPhase := lipgloss.NewStyle().Foreground(PhaseColor(phase)).Render(phase) + rowStyle := lipgloss.NewStyle().Foreground(PhaseColor(phase)) return table.Row{ - shortID, - agentName, - s.ProjectID, - styledPhase, - s.TriggeredByUserID, - started, - duration, + rowStyle.Render(shortID), + rowStyle.Render(agentName), + rowStyle.Render(s.ProjectID), + rowStyle.Render(phase), + rowStyle.Render(s.TriggeredByUserID), + rowStyle.Render(started), + rowStyle.Render(duration), } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index c0f207f0f..37b57b2a6 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -111,12 +111,7 @@ func NewResourceTable(kind string, scope string, columns []table.Column, style T BorderStyle(lipgloss.NormalBorder()). BorderBottom(true). BorderForeground(style.BorderColor) - s.Selected = s.Selected. - Foreground(lipgloss.Color("0")). - Background(style.SelectedBg). - Bold(true) - // Don't set Cell foreground — let terminal default handle it. - // This allows Selected.Foreground to override cell text color + s.Selected = s.Selected.Bold(true) // (inner ANSI codes would win over outer if we set Cell.Foreground). t.SetStyles(s) @@ -267,10 +262,7 @@ func (rt *ResourceTable) SetWidth(w int) { BorderBottom(true). BorderForeground(rt.style.BorderColor) s.Selected = s.Selected. - Foreground(lipgloss.Color("0")). - Background(rt.style.SelectedBg). - Bold(true). - Width(distributable + cellPadding) + Bold(true) rt.inner.SetStyles(s) } @@ -336,14 +328,52 @@ func (rt *ResourceTable) View() string { titleBar := rt.renderTitleBar() tableView := rt.inner.View() - // Wrap each table line with side borders. + // Wrap each table line with side borders, applying full-width highlight + // to the selected row. tableLines := strings.Split(tableView, "\n") + selectedStyle := lipgloss.NewStyle(). + Background(rt.style.SelectedBg). + Foreground(lipgloss.Color("0")) + + // The table output is: header line(s) + separator + data rows. + // Header takes 2 lines (header text + border), data rows follow. + // The selected data row index relative to visible rows is cursor - start. + cursor := rt.inner.Cursor() + visibleRows := rt.inner.Rows() + headerLines := 2 // header + bottom border + selectedLineIdx := -1 + if len(visibleRows) > 0 { + // Find which visible line the cursor maps to. + // bubbles/table internally tracks start/end; cursor - start = visual position. + // We approximate: if cursor < len(visibleRows), selectedLineIdx = headerLines + cursor + // But the viewport handles scrolling, so the cursor position within the viewport + // is the bold row. We detect it by checking which data line has bold styling. + for i, line := range tableLines { + if i >= headerLines && strings.Contains(line, "\x1b[1m") { + selectedLineIdx = i + break + } + } + } + _ = cursor + var bordered []string - for _, line := range tableLines { + for i, line := range tableLines { + innerWidth := w - 4 // 2 for │ chars, 2 for padding spaces lineWidth := lipgloss.Width(line) - pad := max(w-lineWidth-2, 0) // 2 for side border chars - bordered = append(bordered, - borderStyle.Render("│")+" "+line+strings.Repeat(" ", pad)+borderStyle.Render("│")) + + if i == selectedLineIdx && selectedLineIdx >= 0 { + // Apply full-width orange background with black text. + pad := max(innerWidth-lineWidth, 0) + content := line + strings.Repeat(" ", pad) + highlighted := selectedStyle.Render(content) + bordered = append(bordered, + borderStyle.Render("│")+" "+highlighted+" "+borderStyle.Render("│")) + } else { + pad := max(w-lineWidth-2, 0) + bordered = append(bordered, + borderStyle.Render("│")+" "+line+strings.Repeat(" ", pad)+borderStyle.Render("│")) + } } // Bottom border. From 66c145e01c4c4e753d1301d68a9113bc5016d31c Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 07:52:14 -0400 Subject: [PATCH 039/117] fix(cli): selected row highlight uses phase color as background Selected row background matches the row's phase color (orange for active/running, yellow for pending, red for failed, dim for idle) with black foreground text. Scans cell values for phase keywords to determine the highlight color. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/table.go | 62 ++++++++++++------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 37b57b2a6..a7588fe72 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/lipgloss" ) + // SortDirection represents the sort order for a column. type SortDirection int @@ -329,44 +330,57 @@ func (rt *ResourceTable) View() string { tableView := rt.inner.View() // Wrap each table line with side borders, applying full-width highlight - // to the selected row. + // to the selected row using the phase color as background. tableLines := strings.Split(tableView, "\n") - selectedStyle := lipgloss.NewStyle(). - Background(rt.style.SelectedBg). + + // Determine the selected row's highlight color from its phase/status. + // Default to orange; if the row has phase data, use PhaseColor as background. + selectedBg := rt.style.SelectedBg + selectedRow := rt.inner.SelectedRow() + if len(selectedRow) > 0 { + // Check each cell for a known phase color by looking at the raw row data. + // The phase is typically in the PHASE column. We scan all cells for phase keywords. + for _, cell := range selectedRow { + // Strip ANSI to get the raw text. + raw := stripANSI(cell) + raw = strings.TrimSpace(strings.ToLower(raw)) + switch raw { + case "running", "active": + selectedBg = PhaseColor("running") + case "pending": + selectedBg = PhaseColor("pending") + case "failed": + selectedBg = PhaseColor("failed") + case "completed", "succeeded": + selectedBg = PhaseColor("completed") + case "idle", "cancelled": + selectedBg = PhaseColor("idle") + } + } + } + highlightStyle := lipgloss.NewStyle(). + Background(selectedBg). Foreground(lipgloss.Color("0")) - // The table output is: header line(s) + separator + data rows. - // Header takes 2 lines (header text + border), data rows follow. - // The selected data row index relative to visible rows is cursor - start. - cursor := rt.inner.Cursor() - visibleRows := rt.inner.Rows() - headerLines := 2 // header + bottom border + // Find the selected line by detecting bold marker. + const dataStart = 2 // header + border selectedLineIdx := -1 - if len(visibleRows) > 0 { - // Find which visible line the cursor maps to. - // bubbles/table internally tracks start/end; cursor - start = visual position. - // We approximate: if cursor < len(visibleRows), selectedLineIdx = headerLines + cursor - // But the viewport handles scrolling, so the cursor position within the viewport - // is the bold row. We detect it by checking which data line has bold styling. - for i, line := range tableLines { - if i >= headerLines && strings.Contains(line, "\x1b[1m") { - selectedLineIdx = i - break - } + for i, line := range tableLines { + if i >= dataStart && strings.Contains(line, "\x1b[1m") { + selectedLineIdx = i + break } } - _ = cursor var bordered []string for i, line := range tableLines { - innerWidth := w - 4 // 2 for │ chars, 2 for padding spaces + innerWidth := w - 4 lineWidth := lipgloss.Width(line) if i == selectedLineIdx && selectedLineIdx >= 0 { - // Apply full-width orange background with black text. pad := max(innerWidth-lineWidth, 0) content := line + strings.Repeat(" ", pad) - highlighted := selectedStyle.Render(content) + highlighted := highlightStyle.Render(content) bordered = append(bordered, borderStyle.Render("│")+" "+highlighted+" "+borderStyle.Render("│")) } else { From f348b3759b60a9599bac41714172372e237108d1 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 07:53:11 -0400 Subject: [PATCH 040/117] feat(cli): add row coloring and phase-based highlight to spec Co-Authored-By: Claude Sonnet 4.6 --- docs/internal/design/tui.spec.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/internal/design/tui.spec.md b/docs/internal/design/tui.spec.md index 33523833a..79fc40e0b 100644 --- a/docs/internal/design/tui.spec.md +++ b/docs/internal/design/tui.spec.md @@ -563,7 +563,7 @@ Carried forward from the existing TUI (`view.go`). These are ANSI 256-color indi | Phase | Color | ANSI 256 Index | Lipgloss | |-------|-------|----------------|----------| | `pending` | Yellow | 33 | `Color("33")` | -| `running` | Green | 28 | `Color("28")` | +| `running` | Orange | 214 | `Color("214")` | | `succeeded` / `completed` | Dim grey | 240 | `Color("240")` | | `failed` | Red | 31 | `Color("31")` | | `cancelled` | Dim grey | 240 | `Color("240")` | @@ -574,13 +574,27 @@ Full palette (preserved from existing code): |------|----------|-------| | Orange | 214 | Branding, navigation highlights, selected items | | Cyan | 36 | Secondary accent | -| Green | 28 | Running/success phase | +| Green | 28 | Success indicators | | Red | 31 | Failed/error phase, delete confirmations | | Yellow | 33 | Pending phase, in-progress indicators | | Dim | 240 | Inactive items, separators, hints | | White | 255 | Primary text | | Blue | 69 | Command mode, links | +### Row Coloring + +Following k9s conventions, entire table rows are colored based on resource phase/status — not just the PHASE column. This provides at-a-glance visibility into fleet health. + +| Phase | Row Color | ANSI 256 | +|-------|-----------|----------| +| `running` / `active` | Orange | 214 | +| `pending` | Yellow | 33 | +| `failed` | Red | 31 | +| `succeeded` / `completed` | Dim grey | 240 | +| `idle` / `cancelled` | Dim grey | 240 | + +**Selected row highlight:** The selected row uses the phase color as the **background** with black (0) foreground text. The highlight spans the full row width border-to-border. For rows without a phase (projects, contexts), the default orange (214) background is used. + --- ## Known API Gaps From 8ccaa63e517b5020c24be6baad6aa5a534f2779b Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 07:57:09 -0400 Subject: [PATCH 041/117] fix(cli): restore native Selected style + dynamic phase-based background Revert to bubbles/table native Selected style (foreground/background) which actually works, instead of broken post-processing detection. Cache tableStyles on ResourceTable for dynamic updates. On every cursor move, updateSelectedStyle scans the selected row for phase keywords and adjusts the Selected background color accordingly. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/table.go | 114 ++++++++---------- 1 file changed, 50 insertions(+), 64 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index a7588fe72..2d430a260 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -86,6 +86,9 @@ type ResourceTable struct { // sort tracks the current column sort state. sort sortState + // tableStyles caches the current styles for dynamic updates (e.g. phase-based highlight). + tableStyles table.Styles + // columns stores the original column definitions for sort indicator rendering. columns []table.Column } @@ -112,16 +115,19 @@ func NewResourceTable(kind string, scope string, columns []table.Column, style T BorderStyle(lipgloss.NormalBorder()). BorderBottom(true). BorderForeground(style.BorderColor) - s.Selected = s.Selected.Bold(true) - // (inner ANSI codes would win over outer if we set Cell.Foreground). + s.Selected = s.Selected. + Foreground(lipgloss.Color("0")). + Background(style.SelectedBg). + Bold(true) t.SetStyles(s) return ResourceTable{ - inner: t, - kind: kind, - scope: scope, - style: style, - columns: cols, + inner: t, + kind: kind, + scope: scope, + style: style, + columns: cols, + tableStyles: s, sort: sortState{ colIdx: -1, direction: SortNone, @@ -263,7 +269,11 @@ func (rt *ResourceTable) SetWidth(w int) { BorderBottom(true). BorderForeground(rt.style.BorderColor) s.Selected = s.Selected. - Bold(true) + Foreground(lipgloss.Color("0")). + Background(rt.style.SelectedBg). + Bold(true). + Width(distributable + cellPadding) + rt.tableStyles = s rt.inner.SetStyles(s) } @@ -309,9 +319,36 @@ func (rt *ResourceTable) Update(msg tea.Msg) (ResourceTable, tea.Cmd) { var cmd tea.Cmd rt.inner, cmd = rt.inner.Update(msg) + rt.updateSelectedStyle() return *rt, cmd } +// updateSelectedStyle adjusts the Selected row background to match the +// phase color of the currently selected row. +func (rt *ResourceTable) updateSelectedStyle() { + bg := rt.style.SelectedBg + row := rt.inner.SelectedRow() + if len(row) > 0 { + for _, cell := range row { + raw := strings.TrimSpace(strings.ToLower(stripANSI(cell))) + switch raw { + case "running", "active": + bg = PhaseColor("running") + case "pending": + bg = PhaseColor("pending") + case "failed": + bg = PhaseColor("failed") + case "completed", "succeeded": + bg = PhaseColor("completed") + case "idle", "cancelled": + bg = PhaseColor("idle") + } + } + } + rt.tableStyles.Selected = rt.tableStyles.Selected.Background(bg) + rt.inner.SetStyles(rt.tableStyles) +} + // View renders the table with a k9s-style title bar. // // The title bar format is: @@ -329,65 +366,14 @@ func (rt *ResourceTable) View() string { titleBar := rt.renderTitleBar() tableView := rt.inner.View() - // Wrap each table line with side borders, applying full-width highlight - // to the selected row using the phase color as background. + // Wrap each table line with side borders. tableLines := strings.Split(tableView, "\n") - - // Determine the selected row's highlight color from its phase/status. - // Default to orange; if the row has phase data, use PhaseColor as background. - selectedBg := rt.style.SelectedBg - selectedRow := rt.inner.SelectedRow() - if len(selectedRow) > 0 { - // Check each cell for a known phase color by looking at the raw row data. - // The phase is typically in the PHASE column. We scan all cells for phase keywords. - for _, cell := range selectedRow { - // Strip ANSI to get the raw text. - raw := stripANSI(cell) - raw = strings.TrimSpace(strings.ToLower(raw)) - switch raw { - case "running", "active": - selectedBg = PhaseColor("running") - case "pending": - selectedBg = PhaseColor("pending") - case "failed": - selectedBg = PhaseColor("failed") - case "completed", "succeeded": - selectedBg = PhaseColor("completed") - case "idle", "cancelled": - selectedBg = PhaseColor("idle") - } - } - } - highlightStyle := lipgloss.NewStyle(). - Background(selectedBg). - Foreground(lipgloss.Color("0")) - - // Find the selected line by detecting bold marker. - const dataStart = 2 // header + border - selectedLineIdx := -1 - for i, line := range tableLines { - if i >= dataStart && strings.Contains(line, "\x1b[1m") { - selectedLineIdx = i - break - } - } - var bordered []string - for i, line := range tableLines { - innerWidth := w - 4 + for _, line := range tableLines { lineWidth := lipgloss.Width(line) - - if i == selectedLineIdx && selectedLineIdx >= 0 { - pad := max(innerWidth-lineWidth, 0) - content := line + strings.Repeat(" ", pad) - highlighted := highlightStyle.Render(content) - bordered = append(bordered, - borderStyle.Render("│")+" "+highlighted+" "+borderStyle.Render("│")) - } else { - pad := max(w-lineWidth-2, 0) - bordered = append(bordered, - borderStyle.Render("│")+" "+line+strings.Repeat(" ", pad)+borderStyle.Render("│")) - } + pad := max(w-lineWidth-2, 0) + bordered = append(bordered, + borderStyle.Render("│")+" "+line+strings.Repeat(" ", pad)+borderStyle.Render("│")) } // Bottom border. From f0a897e8c04997dfe5aa38c4966a34a7301b2af2 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 07:59:29 -0400 Subject: [PATCH 042/117] fix(cli): hide project switcher in messages/detail, widen highlight - Project shortcuts hidden and disabled in messages and detail views - Selected row Width set to usable (full inner width) for better highlight coverage across all columns Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/app.go | 4 +++- components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 5 +++-- components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 5961f3e4c..fa4fcf05f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -79,7 +79,9 @@ func (m *AppModel) viewHeader() string { // Col 2: project shortcuts (stacked, padded to fixed width). var col2 [5]string - if m.activeView != "projects" && m.activeView != "contexts" && len(m.projectShortcuts) > 0 { + showShortcuts := m.activeView != "projects" && m.activeView != "contexts" && + m.activeView != "messages" && m.activeView != "detail" && len(m.projectShortcuts) > 0 + if showShortcuts { col2[0] = styleGreen.Render("<0>") + " " + styleWhite.Render("all") for i := range min(len(m.projectShortcuts), 4) { name := m.projectShortcuts[i] diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 5dd8ae5c0..bc802643a 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1277,9 +1277,10 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } - // Number-key project shortcuts (0-9) — only active below the projects/contexts level. + // Number-key project shortcuts (0-9) — only active on table views below project level. if len(key) == 1 && key[0] >= '0' && key[0] <= '9' && - m.activeView != "projects" && m.activeView != "contexts" { + m.activeView != "projects" && m.activeView != "contexts" && + m.activeView != "messages" && m.activeView != "detail" { return m.handleProjectShortcut(key[0] - '0') } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 2d430a260..04c3b2cd3 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -272,7 +272,7 @@ func (rt *ResourceTable) SetWidth(w int) { Foreground(lipgloss.Color("0")). Background(rt.style.SelectedBg). Bold(true). - Width(distributable + cellPadding) + Width(usable) rt.tableStyles = s rt.inner.SetStyles(s) } From 7fb53b2e310a18f18162494a02a005226a9e159b Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 08:01:29 -0400 Subject: [PATCH 043/117] =?UTF-8?q?fix(cli):=20don't=20ANSI-color=20empty?= =?UTF-8?q?=20cells=20=E2=80=94=20fixes=20highlight=20width?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty string cells with ANSI codes confuse lipgloss width calculation, preventing Selected.Width from padding the full row. Only apply phase color to non-empty cell values. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/agents.go | 18 ++++++++++----- .../cmd/acpctl/ambient/tui/views/sessions.go | 22 ++++++++++++------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go index 5736c014b..d442dd843 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go @@ -45,14 +45,20 @@ func AgentRow(a sdktypes.Agent, sessionCount int, now time.Time) table.Row { if a.CurrentSessionID != "" { phase = "active" } - rowStyle := lipgloss.NewStyle().Foreground(PhaseColor(phase)) + c := PhaseColor(phase) + r := func(s string) string { + if s == "" { + return "" + } + return lipgloss.NewStyle().Foreground(c).Render(s) + } return table.Row{ - rowStyle.Render(a.Name), - rowStyle.Render(TruncateString(a.Prompt, 60)), - rowStyle.Render(sessions), - rowStyle.Render(phase), - rowStyle.Render(age), + r(a.Name), + r(TruncateString(a.Prompt, 60)), + r(sessions), + r(phase), + r(age), } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go index 5869e100d..9f305a927 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go @@ -84,16 +84,22 @@ func SessionRow(s sdktypes.Session, agentName string, now time.Time) table.Row { } phase := s.Phase - rowStyle := lipgloss.NewStyle().Foreground(PhaseColor(phase)) + c := PhaseColor(phase) + r := func(s string) string { + if s == "" { + return "" + } + return lipgloss.NewStyle().Foreground(c).Render(s) + } return table.Row{ - rowStyle.Render(shortID), - rowStyle.Render(agentName), - rowStyle.Render(s.ProjectID), - rowStyle.Render(phase), - rowStyle.Render(s.TriggeredByUserID), - rowStyle.Render(started), - rowStyle.Render(duration), + r(shortID), + r(agentName), + r(s.ProjectID), + r(phase), + r(s.TriggeredByUserID), + r(started), + r(duration), } } From cff84af54ac8123de4403eb102dadfbfa2937527 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 08:06:34 -0400 Subject: [PATCH 044/117] fix(cli): per-row phase coloring via post-processing, plain text cells Remove embedded ANSI from cell values (was breaking Selected.Width). Row coloring now done in View() post-processing via SetRowColorFunc: - Non-selected rows: phase color as foreground - Selected row: phase color as background, black foreground Agent and session tables set rowColorFunc keyed on PHASE column (idx 3). Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 15 +++++ .../cmd/acpctl/ambient/tui/views/agents.go | 18 ++---- .../cmd/acpctl/ambient/tui/views/sessions.go | 23 +++---- .../cmd/acpctl/ambient/tui/views/table.go | 62 +++++++++++++------ 4 files changed, 71 insertions(+), 47 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index bc802643a..ab4243522 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" "github.com/ambient-code/platform/components/ambient-cli/pkg/connection" @@ -159,7 +160,21 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { pt := views.NewProjectTable(views.DefaultTableStyle()) at := views.NewAgentTable("all", views.DefaultTableStyle()) + // Agent rows: PHASE is column index 3 (NAME, PROMPT, SESSIONS, PHASE, AGE) + at.SetRowColorFunc(func(row table.Row) lipgloss.Color { + if len(row) > 3 { + return views.PhaseColor(row[3]) + } + return lipgloss.Color("240") + }) st := views.NewSessionTable("all", views.DefaultTableStyle()) + // Session rows: PHASE is column index 3 (ID, AGENT, PROJECT, PHASE, ...) + st.SetRowColorFunc(func(row table.Row) lipgloss.Color { + if len(row) > 3 { + return views.PhaseColor(row[3]) + } + return lipgloss.Color("240") + }) it := views.NewInboxTable("all", views.DefaultTableStyle()) ct := views.NewContextTable(views.DefaultTableStyle()) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go index d442dd843..39dfb98cd 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/agents.go @@ -5,7 +5,6 @@ import ( "time" "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/lipgloss" sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" ) @@ -45,20 +44,13 @@ func AgentRow(a sdktypes.Agent, sessionCount int, now time.Time) table.Row { if a.CurrentSessionID != "" { phase = "active" } - c := PhaseColor(phase) - r := func(s string) string { - if s == "" { - return "" - } - return lipgloss.NewStyle().Foreground(c).Render(s) - } return table.Row{ - r(a.Name), - r(TruncateString(a.Prompt, 60)), - r(sessions), - r(phase), - r(age), + a.Name, + TruncateString(a.Prompt, 60), + sessions, + phase, + age, } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go index 9f305a927..0da8001cd 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go @@ -83,23 +83,14 @@ func SessionRow(s sdktypes.Session, agentName string, now time.Time) table.Row { duration = FormatAge(now.Sub(*s.StartTime)) } - phase := s.Phase - c := PhaseColor(phase) - r := func(s string) string { - if s == "" { - return "" - } - return lipgloss.NewStyle().Foreground(c).Render(s) - } - return table.Row{ - r(shortID), - r(agentName), - r(s.ProjectID), - r(phase), - r(s.TriggeredByUserID), - r(started), - r(duration), + shortID, + agentName, + s.ProjectID, + s.Phase, + s.TriggeredByUserID, + started, + duration, } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 04c3b2cd3..24d937d9a 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -86,6 +86,9 @@ type ResourceTable struct { // sort tracks the current column sort state. sort sortState + // rowColorFunc maps a row to its foreground color. If nil, rows use default color. + rowColorFunc func(row table.Row) lipgloss.Color + // tableStyles caches the current styles for dynamic updates (e.g. phase-based highlight). tableStyles table.Styles @@ -158,6 +161,12 @@ func (rt *ResourceTable) SetRows(rows []table.Row) { rt.applyFilterAndSort() } +// SetRowColorFunc sets a function that determines the foreground color for each +// row based on its data. Used for phase-based row coloring (k9s style). +func (rt *ResourceTable) SetRowColorFunc(f func(row table.Row) lipgloss.Color) { + rt.rowColorFunc = f +} + // SetFilter sets a client-side filter predicate. Rows for which the predicate // returns false are hidden. The predicate receives the row as a []string // (same as table.Row's underlying type). Pass nil to clear. @@ -328,22 +337,8 @@ func (rt *ResourceTable) Update(msg tea.Msg) (ResourceTable, tea.Cmd) { func (rt *ResourceTable) updateSelectedStyle() { bg := rt.style.SelectedBg row := rt.inner.SelectedRow() - if len(row) > 0 { - for _, cell := range row { - raw := strings.TrimSpace(strings.ToLower(stripANSI(cell))) - switch raw { - case "running", "active": - bg = PhaseColor("running") - case "pending": - bg = PhaseColor("pending") - case "failed": - bg = PhaseColor("failed") - case "completed", "succeeded": - bg = PhaseColor("completed") - case "idle", "cancelled": - bg = PhaseColor("idle") - } - } + if rt.rowColorFunc != nil && len(row) > 0 { + bg = rt.rowColorFunc(row) } rt.tableStyles.Selected = rt.tableStyles.Selected.Background(bg) rt.inner.SetStyles(rt.tableStyles) @@ -366,12 +361,43 @@ func (rt *ResourceTable) View() string { titleBar := rt.renderTitleBar() tableView := rt.inner.View() - // Wrap each table line with side borders. + // Wrap each table line with side borders, applying per-row phase coloring. tableLines := strings.Split(tableView, "\n") + visibleRows := rt.inner.Rows() + cursor := rt.inner.Cursor() + const headerLineCount = 2 // header text + border separator + + // Determine the first visible row index. The cursor is the absolute row + // index; the table height tells us how many rows are visible. The visual + // position of the cursor within the viewport is cursor - firstVisible. + tableH := rt.inner.Height() + firstVisible := 0 + if cursor >= tableH { + firstVisible = cursor - tableH + 1 + } + var bordered []string - for _, line := range tableLines { + for i, line := range tableLines { lineWidth := lipgloss.Width(line) pad := max(w-lineWidth-2, 0) + + dataRowIdx := i - headerLineCount // which data row this line is + isDataRow := dataRowIdx >= 0 && dataRowIdx < len(visibleRows) + isSelected := isDataRow && (firstVisible+dataRowIdx) == cursor + + if isDataRow && rt.rowColorFunc != nil && !isSelected { + absIdx := firstVisible + dataRowIdx + if absIdx < len(visibleRows) { + fg := rt.rowColorFunc(visibleRows[absIdx]) + coloredLine := lipgloss.NewStyle().Foreground(fg).Render(line) + coloredLineWidth := lipgloss.Width(coloredLine) + colorPad := max(w-coloredLineWidth-2, 0) + bordered = append(bordered, + borderStyle.Render("│")+" "+coloredLine+strings.Repeat(" ", colorPad)+borderStyle.Render("│")) + continue + } + } + bordered = append(bordered, borderStyle.Render("│")+" "+line+strings.Repeat(" ", pad)+borderStyle.Render("│")) } From 8e0d1f9a2a7b2c090839a17c76263c02177d847e Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 08:08:20 -0400 Subject: [PATCH 045/117] fix(cli): add row coloring to project table via STATUS column Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index ab4243522..018f3e4e6 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -159,6 +159,13 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { pi.CharLimit = 1024 pt := views.NewProjectTable(views.DefaultTableStyle()) + // Project rows: STATUS is column index 2 (NAME, DESCRIPTION, STATUS, AGENTS, SESSIONS, AGE) + pt.SetRowColorFunc(func(row table.Row) lipgloss.Color { + if len(row) > 2 { + return views.PhaseColor(row[2]) + } + return lipgloss.Color("240") + }) at := views.NewAgentTable("all", views.DefaultTableStyle()) // Agent rows: PHASE is column index 3 (NAME, PROMPT, SESSIONS, PHASE, AGE) at.SetRowColorFunc(func(row table.Row) lipgloss.Color { From 8217c7beeba485ef6328b8288e488ea9b0dca41a Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 08:13:28 -0400 Subject: [PATCH 046/117] feat(cli): add consistent chrome principle to TUI spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify that all views follow the same UI conventions — no view-specific UI chrome. Hotkey hints live in the header, filtering uses the global `/` command bar, breadcrumbs stay at the bottom. The message stream view's only unique chrome is the status indicator sub-header line. Co-Authored-By: Claude Sonnet 4.6 --- docs/internal/design/tui.spec.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/internal/design/tui.spec.md b/docs/internal/design/tui.spec.md index 79fc40e0b..787e39b0a 100644 --- a/docs/internal/design/tui.spec.md +++ b/docs/internal/design/tui.spec.md @@ -30,6 +30,7 @@ The Ambient TUI is a full-screen terminal interface for operating the Ambient pl | Offline-safe auth | The TUI reuses `acpctl login` credentials from `~/.config/ambient/config.json`. No separate auth flow. | | Multi-context | Operators work across local, staging, and production. The TUI saves every server the user has logged into as a named context and supports instant switching — same as k9s with kubeconfig clusters. | | Sanitize all external content | Agent-produced output is rendered in the terminal. All content from the API is stripped of ANSI escape sequences, terminal control characters, and framework-specific tags before display. | +| Consistent chrome | All views use the same UI structure: hotkey hints in the header, filtering via the global `/` command bar, breadcrumbs at the bottom. No view defines its own bottom status bar or proprietary filter mechanism. Status indicators (Autoscroll, Mode, Phase) for the message stream belong in the sub-header line below the title bar, inside the bordered area. | --- @@ -254,6 +255,7 @@ Accessible globally (`:sessions` — all sessions across all projects) or scoped ### Message Stream View +**UI consistency:** The message stream follows the same layout conventions as all other views. Keyboard shortcuts are shown in the header (not in a bottom status bar). Filtering uses the global `/` command bar. The only view-specific chrome is the status indicator line below the title bar (Autoscroll, Mode, Phase, SSE status). #### Data Source From 7699f695a2f94d236e2e10227fce096beae7e0c6 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 08:13:39 -0400 Subject: [PATCH 047/117] feat(cli): message polling fallback for session messages Add REST polling (2s interval) as a fallback for session messages when SSE may not be delivering. FetchSessionMessages polls via Sessions().ListMessages() with afterSeq dedup. Polling starts automatically when entering the messages view and stops on exit. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/client.go | 48 +++++++- .../cmd/acpctl/ambient/tui/model_new.go | 111 +++++++++++++++--- .../cmd/acpctl/ambient/tui/views/detail.go | 2 +- 3 files changed, 145 insertions(+), 16 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index aaa57dcaa..fd1aaa38c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -2,6 +2,7 @@ package tui import ( "context" + "fmt" "sync" "time" @@ -154,6 +155,13 @@ type SessionMessageEvent struct { Err error } +// SessionMessagesMsg carries a batch of messages fetched via polling +// (ListMessages). Used as a fallback when SSE is unavailable or stalled. +type SessionMessagesMsg struct { + Messages []sdktypes.SessionMessage + Err error +} + // --------------------------------------------------------------------------- // TUIClient wraps connection.ClientFactory and provides clean data-fetching // methods that return tea.Cmd functions for asynchronous execution inside the @@ -691,6 +699,8 @@ func (tc *TUIClient) DeleteInboxMessage(projectID, agentID, msgID string) tea.Cm // - Handles reconnection with exponential backoff (1s, 2s, 4s, max 30s) // internally via the SDK's WatchMessages implementation. // - Is cancellable via StopWatching(). +// - Sends an error event if the channel closes without context cancellation, +// signalling a silent SSE failure so the TUI can fall back to polling. // // Only one watch can be active at a time. Calling WatchSessionMessages while // a previous watch is running cancels the old one first. @@ -724,18 +734,54 @@ func (tc *TUIClient) WatchSessionMessages(projectID, sessionID string, afterSeq // Forward messages from the SDK channel to the Bubbletea program. // This goroutine exits when the channel closes (on context - // cancellation or stream end). + // cancellation or stream end). If the channel closes without + // cancellation, it means the SSE stream died silently -- notify + // the TUI so it can fall back to polling. go func() { defer cancel() + receivedAny := false for msg := range msgs { + receivedAny = true program.Send(SessionMessageEvent{Message: msg}) } + // Channel closed. If the context was not cancelled by us + // (StopWatching or view change), this is an unexpected close. + if ctx.Err() == nil { + errMsg := "SSE stream closed" + if !receivedAny { + errMsg = "SSE connection failed (no messages received)" + } + program.Send(SessionMessageEvent{ + Err: fmt.Errorf("%s — falling back to polling", errMsg), + }) + } }() return nil } } +// FetchSessionMessages returns a tea.Cmd that polls session messages via the +// REST ListMessages endpoint. This is used as a fallback when SSE streaming is +// unavailable or stalled. Only messages with seq > afterSeq are returned. +func (tc *TUIClient) FetchSessionMessages(projectID, sessionID string, afterSeq int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return SessionMessagesMsg{Err: err} + } + + msgs, err := client.Sessions().ListMessages(ctx, sessionID, afterSeq) + if err != nil { + return SessionMessagesMsg{Err: err} + } + return SessionMessagesMsg{Messages: msgs} + } +} + // StopWatching cancels any active SSE watch goroutine started by // WatchSessionMessages. func (tc *TUIClient) StopWatching() { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 018f3e4e6..0c52d18d2 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -20,6 +20,10 @@ import ( // pollInterval is the auto-refresh interval for resource tables. const pollInterval = 5 * time.Second +// messagePollInterval is the polling interval for session messages when the +// messages view is active. Faster than the table poll to keep messages fresh. +const messagePollInterval = 2 * time.Second + // infoTimeout is how long ephemeral info messages are displayed. const infoTimeout = 5 * time.Second @@ -44,6 +48,10 @@ type NavEntry struct { // appTickMsg fires every pollInterval to trigger data refresh. type appTickMsg struct{ t time.Time } +// messagePollTickMsg fires every messagePollInterval when the messages view is +// active, triggering a REST poll for new session messages. +type messagePollTickMsg struct{ t time.Time } + // infoExpiredMsg signals the ephemeral info line should be cleared. type infoExpiredMsg struct{} @@ -108,6 +116,10 @@ type AppModel struct { // SSE program reference (set via SetProgram after tea.NewProgram). program *tea.Program + // Message polling state (fallback when SSE is unavailable). + lastMessageSeq int // highest seq seen — poll for messages after this + messagePollActive bool // true when message poll tick is running + // Errors lastError string @@ -269,6 +281,14 @@ func (m *AppModel) tickCmd() tea.Cmd { }) } +// messagePollTickCmd returns a tea.Cmd that sends a messagePollTickMsg after +// messagePollInterval. Used to drive the REST polling fallback. +func (m *AppModel) messagePollTickCmd() tea.Cmd { + return tea.Tick(messagePollInterval, func(t time.Time) tea.Msg { + return messagePollTickMsg{t: t} + }) +} + // infoExpireCmd returns a tea.Cmd that clears the info line after infoTimeout. func (m *AppModel) infoExpireCmd() tea.Cmd { return tea.Tick(infoTimeout, func(_ time.Time) tea.Msg { @@ -568,6 +588,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Payload: msg.Err.Error(), Timestamp: time.Now(), }) + // SSE failed — ensure polling fallback is running. + if m.activeView == "messages" && !m.messagePollActive { + m.messagePollActive = true + return m, m.messagePollTickCmd() + } return m, nil } if msg.Message != nil && m.activeView == "messages" { @@ -582,9 +607,61 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Payload: msg.Message.Payload, Timestamp: ts, }) + // Track highest seq for polling. + if msg.Message.Seq > m.lastMessageSeq { + m.lastMessageSeq = msg.Message.Seq + } } return m, nil + case SessionMessagesMsg: + // Polling fallback: batch of messages from REST ListMessages. + if msg.Err != nil { + // Non-fatal — polling will retry on next tick. + return m, nil + } + if m.activeView != "messages" { + return m, nil + } + for _, sm := range msg.Messages { + if sm.Seq <= m.lastMessageSeq { + continue // already seen via SSE or previous poll + } + ts := time.Now() + if sm.CreatedAt != nil { + ts = *sm.CreatedAt + } + m.messageStream.AddMessage(views.MessageEntry{ + Seq: sm.Seq, + EventType: sm.EventType, + Payload: sm.Payload, + Timestamp: ts, + }) + if sm.Seq > m.lastMessageSeq { + m.lastMessageSeq = sm.Seq + } + } + if len(msg.Messages) > 0 { + m.messageStream.SetSSEStatus("polling") + } + return m, nil + + case messagePollTickMsg: + // Periodic poll for session messages — only active in messages view. + if m.activeView != "messages" { + m.messagePollActive = false + return m, nil + } + // Schedule next poll tick and fetch messages. + var cmds []tea.Cmd + cmds = append(cmds, m.messagePollTickCmd()) + if m.currentProject != "" && m.currentSession != "" { + cmds = append(cmds, m.client.FetchSessionMessages( + m.currentProject, m.currentSession, m.lastMessageSeq, + )) + } + return m, tea.Batch(cmds...) + case appTickMsg: return m.handleTick() @@ -1173,6 +1250,7 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { fullSessionID = session.ID } m.currentSession = fullSessionID + m.lastMessageSeq = 0 // Create a new message stream for this session. agentName := m.currentAgent @@ -1194,13 +1272,16 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { // Start SSE watcher if we have a program reference and project context. if m.program != nil && m.currentProject != "" { cmds = append(cmds, m.client.WatchSessionMessages(m.currentProject, fullSessionID, 0, m.program)) - } else { - m.messageStream.AddMessage(views.MessageEntry{ - Seq: 1, - EventType: "system", - Payload: "Connected to session " + shortID + " (SSE requires program ref)", - Timestamp: time.Now(), - }) + } + + // Always start polling fallback alongside SSE. Polling is + // idempotent (deduplicates by seq) and ensures messages appear + // even if SSE fails silently. + if m.currentProject != "" { + m.messagePollActive = true + cmds = append(cmds, m.messagePollTickCmd()) + // Immediately fetch existing messages so the view is not empty. + cmds = append(cmds, m.client.FetchSessionMessages(m.currentProject, fullSessionID, 0)) } return m, tea.Batch(cmds...) @@ -1417,6 +1498,7 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { m.currentAgent = agentName m.currentAgentID = agent.ID m.currentSession = sessionID + m.lastMessageSeq = 0 m.messageStream = views.NewMessageStream(sessionID, agentName, "active") m.resizeTable() @@ -1428,13 +1510,14 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { // Start SSE watcher if we have a program reference and project context. if m.program != nil && m.currentProject != "" { cmds = append(cmds, m.client.WatchSessionMessages(m.currentProject, sessionID, 0, m.program)) - } else { - m.messageStream.AddMessage(views.MessageEntry{ - Seq: 1, - EventType: "system", - Payload: "Connected to session " + sessionID + " (SSE requires program ref)", - Timestamp: time.Now(), - }) + } + + // Always start polling fallback alongside SSE. + if m.currentProject != "" { + m.messagePollActive = true + cmds = append(cmds, m.messagePollTickCmd()) + // Immediately fetch existing messages so the view is not empty. + cmds = append(cmds, m.client.FetchSessionMessages(m.currentProject, sessionID, 0)) } return m, tea.Batch(cmds...) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go index e60f66dc9..e4ff34dbb 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go @@ -364,7 +364,7 @@ func detailWrapText(text string, width int) []string { return result } -// wrapLine wraps a single line of text at word boundaries to fit within width. +// detailWrapLine wraps a single line of text at word boundaries to fit within width. func detailWrapLine(line string, width int) []string { words := strings.Fields(line) if len(words) == 0 { From f8582bed839be24e27c616d1aebaf471a5b3c2b9 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 08:15:00 -0400 Subject: [PATCH 048/117] =?UTF-8?q?fix(cli):=20remove=20messages=20bottom?= =?UTF-8?q?=20status=20bar=20=E2=80=94=20use=20header=20hints=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the proprietary bottom status bar from the message stream view. Keyboard shortcuts are shown in the header via contextualHints() like all other views. Search uses the global / command bar. Only the sub-header status indicators (Autoscroll, Mode, Phase) remain as view-specific chrome inside the bordered area. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/messages.go | 53 +------------------ 1 file changed, 2 insertions(+), 51 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 80564608e..722972a25 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -593,16 +593,11 @@ func (ms *MessageStream) View() string { borderStyle.Render("│") headerSep := borderStyle.Render("├" + strings.Repeat("─", max(ms.width-2, 0)) + "┤") - // -- Compose / status area (rendered bottom-up to calculate remaining height) -- + // -- Compose / streaming cursor area (rendered bottom-up) -- var bottomLines []string - // Status bar (always shown). - statusBar := ms.renderStatusBar() bottomBorder := borderStyle.Render("└" + strings.Repeat("─", max(ms.width-2, 0)) + "┘") - bottomLines = append(bottomLines, - borderStyle.Render("│")+padToWidth(" "+statusBar, ms.width-2)+borderStyle.Render("│"), - bottomBorder, - ) + bottomLines = append(bottomLines, bottomBorder) // Compose input (if active). if ms.composeMode { @@ -626,15 +621,6 @@ func (ms *MessageStream) View() string { bottomLines = append([]string{cursorLine}, bottomLines...) } - // Search bar (if active). - if ms.searchMode { - searchSep := borderStyle.Render("├" + strings.Repeat("─", max(ms.width-2, 0)) + "┤") - searchView := ms.searchInput.View() - searchLine := borderStyle.Render("│") + - " " + padToWidth(searchView, ms.width-3) + - borderStyle.Render("│") - bottomLines = append([]string{searchSep, searchLine}, bottomLines...) - } // -- Content area -- // 3 = header bar + header line + header separator @@ -796,41 +782,6 @@ func (ms *MessageStream) renderRawEntry(entry MessageEntry, maxWidth int) []stri } // renderStatusBar builds the bottom status line with mode indicators and key hints. -func (ms *MessageStream) renderStatusBar() string { - dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) - accentStyle := lipgloss.NewStyle().Foreground(msgColorOrange) - blueStyle := lipgloss.NewStyle().Foreground(msgColorBlue) - - var parts []string - - // Autoscroll indicator. - if ms.autoScroll { - parts = append(parts, accentStyle.Render("autoscroll:on")) - } else { - parts = append(parts, dimStyle.Render("autoscroll:off")) - } - - // Mode indicator. - if ms.rawMode { - parts = append(parts, blueStyle.Render("raw")) - } else { - parts = append(parts, dimStyle.Render("conversation")) - } - - // Search indicator. - if ms.searchPattern != nil { - parts = append(parts, accentStyle.Render("/"+ms.searchPattern.String())) - } - - // Message count. - parts = append(parts, dimStyle.Render(fmt.Sprintf("%d msgs", len(ms.messages)))) - - // Key hints. - hints := dimStyle.Render("Esc back | r raw | s scroll | m send | G bottom | / search") - - return strings.Join(parts, dimStyle.Render(" │ ")) + " " + hints -} - // --------------------------------------------------------------------------- // Scroll helpers // --------------------------------------------------------------------------- From 6202d0214e8ac3b912575ec5522c9b717e4d7029 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 08:22:43 -0400 Subject: [PATCH 049/117] fix(cli): color palette cleanup + command bar in messages view Color audit: reduce palette from 8 to 6 colors. - Title bar: kind=orange (brand), scope=dim, count=dim - Project shortcuts: blue keys (complementary accent) - Assistant text: white (primary content, not green) - Streaming cursor: orange (active state) - Status indicators: dim (not cyan/green) - Remove cyan and magenta from palette Also: - Fix stale counter in messages view (update lastFetch on poll) - Fix command bar access in messages view (intercept : / ? q) Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 4 ++-- .../cmd/acpctl/ambient/tui/events.go | 2 +- .../cmd/acpctl/ambient/tui/model_new.go | 19 +++++++++++++++++++ .../cmd/acpctl/ambient/tui/views/messages.go | 15 ++++++++------- .../cmd/acpctl/ambient/tui/views/table.go | 6 +++--- 5 files changed, 33 insertions(+), 13 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index fa4fcf05f..371c9c913 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -82,13 +82,13 @@ func (m *AppModel) viewHeader() string { showShortcuts := m.activeView != "projects" && m.activeView != "contexts" && m.activeView != "messages" && m.activeView != "detail" && len(m.projectShortcuts) > 0 if showShortcuts { - col2[0] = styleGreen.Render("<0>") + " " + styleWhite.Render("all") + col2[0] = styleBlue.Render("<0>") + " " + styleWhite.Render("all") for i := range min(len(m.projectShortcuts), 4) { name := m.projectShortcuts[i] if len(name) > 16 { name = name[:13] + "..." } - col2[i+1] = styleGreen.Render(fmt.Sprintf("<%d>", i+1)) + " " + styleWhite.Render(name) + col2[i+1] = styleBlue.Render(fmt.Sprintf("<%d>", i+1)) + " " + styleWhite.Render(name) } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/events.go b/components/ambient-cli/cmd/acpctl/ambient/tui/events.go index de649a053..61d50f4a9 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/events.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/events.go @@ -23,7 +23,7 @@ func EventColor(eventType string) lipgloss.Color { case "user": return colorWhite // 255 case "assistant": - return colorGreen // 28 + return colorWhite // 255 — assistant text is primary content case "tool_use": return colorDim // 240 case "tool_result": diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 0c52d18d2..e5ff28a71 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -641,6 +641,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lastMessageSeq = sm.Seq } } + m.lastFetch = time.Now() if len(msg.Messages) > 0 { m.messageStream.SetSSEStatus("polling") } @@ -1730,6 +1731,24 @@ func (m *AppModel) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // handleMessagesKey delegates key events to the message stream sub-model. func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Intercept global keys before delegating to the message stream. + if msg.Type == tea.KeyRunes { + switch string(msg.Runes) { + case ":": + m.commandMode = true + m.commandInput.Focus() + m.resizeTable() + return m, nil + case "?": + return m, m.viewSpecificHelp() + case "q": + return m, m.popView() + } + } + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + var cmd tea.Cmd m.messageStream, cmd = m.messageStream.Update(msg) return m, cmd diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 722972a25..49f761d92 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -49,7 +49,7 @@ func eventColor(eventType string) lipgloss.Color { case "user": return msgColorWhite case "assistant": - return msgColorGreen + return msgColorWhite case "tool_use": return msgColorDim case "tool_result": @@ -536,9 +536,9 @@ func (ms *MessageStream) View() string { } borderStyle := lipgloss.NewStyle().Foreground(msgColorDim) - kindStyle := lipgloss.NewStyle().Foreground(msgColorCyan).Bold(true) - scopeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("206")).Bold(true) - countStyle := lipgloss.NewStyle().Foreground(msgColorBlue).Bold(true) + kindStyle := lipgloss.NewStyle().Foreground(msgColorOrange).Bold(true) + scopeStyle := lipgloss.NewStyle().Foreground(msgColorDim).Bold(true) + countStyle := lipgloss.NewStyle().Foreground(msgColorDim).Bold(true) // -- k9s-style title bar: messages(agent/session)[count] -- shortID := ms.sessionID @@ -569,9 +569,10 @@ func (ms *MessageStream) View() string { modeLabel = "Raw" } phaseStyle := lipgloss.NewStyle().Foreground(phaseColor(ms.phase)) + dimIndicator := lipgloss.NewStyle().Foreground(msgColorDim) indicators := fmt.Sprintf("Autoscroll:%s Mode:%s Phase:%s", - lipgloss.NewStyle().Foreground(msgColorGreen).Render(autoScrollLabel), - lipgloss.NewStyle().Foreground(msgColorCyan).Render(modeLabel), + dimIndicator.Render(autoScrollLabel), + dimIndicator.Render(modeLabel), phaseStyle.Render(ms.phase), ) if ms.sseStatus != "" && ms.sseStatus != "connected" { @@ -612,7 +613,7 @@ func (ms *MessageStream) View() string { // Streaming cursor (when phase is running). if strings.ToLower(ms.phase) == "running" { - cursorStyle := lipgloss.NewStyle().Foreground(msgColorGreen) + cursorStyle := lipgloss.NewStyle().Foreground(msgColorOrange) cursor := cursorStyle.Render(" ▌ streaming…") cursorLine := borderStyle.Render("│") + padToWidth(cursor, ms.width-2) + diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 24d937d9a..881fa8c18 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -48,9 +48,9 @@ type TableStyle struct { func DefaultTableStyle() TableStyle { return TableStyle{ BorderColor: lipgloss.Color("240"), // dim for border lines - TitleColor: lipgloss.Color("36"), // cyan for resource kind - ScopeColor: lipgloss.Color("206"), // magenta/pink for scope - CountColor: lipgloss.Color("69"), // blue for count + TitleColor: lipgloss.Color("214"), // orange for resource kind (brand) + ScopeColor: lipgloss.Color("240"), // dim for scope (context, not emphasis) + CountColor: lipgloss.Color("240"), // dim for count (metadata) DimColor: lipgloss.Color("240"), // dim HeaderColor: lipgloss.Color("255"), // white SelectedBg: lipgloss.Color("214"), // orange From cab72ea979cfa6c8d2b1908b34e0f77fd7d03e91 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 08:23:54 -0400 Subject: [PATCH 050/117] fix(cli): wire dialog overlay into View rendering The delete dialog was created but never rendered. Use views.OverlayDialog to composite the dialog on top of the table output when m.dialog is non-nil. Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/app.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 371c9c913..9e60b2873 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -6,6 +6,8 @@ import ( "time" "github.com/charmbracelet/lipgloss" + + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" ) // ASCII art branding rendered in the header. @@ -36,8 +38,13 @@ func (m *AppModel) View() string { sections = append(sections, m.viewCommandBar()) } - // 4. Resource table with title bar. - sections = append(sections, m.viewResourceTable()) + // 4. Resource table with title bar (+ dialog overlay if active). + tableOutput := m.viewResourceTable() + if m.dialog != nil { + tableH := m.height - 10 + tableOutput = views.OverlayDialog(tableOutput, *m.dialog, m.width, tableH) + } + sections = append(sections, tableOutput) // 5. Separator. sections = append(sections, styleDim.Render(strings.Repeat("─", m.width))) From b9e4f89e4885821fd4bf116ac3e13a9907af72a9 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 09:03:41 -0400 Subject: [PATCH 051/117] fix(cli): project session count sums per-agent counts for consistency FetchProjectCounts now sums session counts per agent instead of using Sessions().List() which may include orphaned sessions. This ensures the project-level count matches the sum of what agent drill-down shows. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/client.go | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index fd1aaa38c..e68daea6a 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -237,20 +237,24 @@ func (tc *TUIClient) FetchProjectCounts(projects []string) tea.Cmd { return } - var ac, sc int - agentList, err := client.Agents().List(ctx, defaultListOpts()) if err != nil { - ac = -1 - } else { - ac = len(agentList.Items) + mu.Lock() + counts[proj] = ProjectCounts{AgentCount: -1, SessionCount: -1} + mu.Unlock() + return } - - sessionList, err := client.Sessions().List(ctx, defaultListOpts()) - if err != nil { - sc = -1 - } else { - sc = len(sessionList.Items) + ac := len(agentList.Items) + + // Sum session counts per agent so the total matches what agent + // drill-down shows (avoids discrepancy with Sessions().List + // which may include orphaned sessions). + sc := 0 + for _, agent := range agentList.Items { + sl, err := client.Agents().Sessions(ctx, proj, agent.ID, defaultListOpts()) + if err == nil { + sc += len(sl.Items) + } } mu.Lock() From 349eff9904125ad9df432884ad3ab3a6893f75df Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 09:12:49 -0400 Subject: [PATCH 052/117] feat(cli): help overlay, session names, project switch stays in view - Help overlay (?) with 3-column layout: Resource/General/Navigation Per-view help content, press Esc/? to close - Session table adds NAME column, removes TRIGGERED BY - Project shortcut switching stays in current view type (sessions stay on sessions, not jump to agents) - Revert project session count to Sessions().List() (shows all sessions including orphaned ones) - Fix session column indices after NAME addition Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 2 + .../cmd/acpctl/ambient/tui/client.go | 26 +- .../cmd/acpctl/ambient/tui/model_new.go | 155 ++++++++++-- .../cmd/acpctl/ambient/tui/views/help.go | 236 ++++++++++++++++++ .../cmd/acpctl/ambient/tui/views/sessions.go | 8 +- 5 files changed, 381 insertions(+), 46 deletions(-) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 9e60b2873..ca4a77fb7 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -233,6 +233,8 @@ func (m *AppModel) viewResourceTable() string { return m.messageStream.View() case "detail": return m.detailView.View() + case "help": + return m.helpView.View() default: return m.projectTable.View() } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index e68daea6a..fd1aaa38c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -237,24 +237,20 @@ func (tc *TUIClient) FetchProjectCounts(projects []string) tea.Cmd { return } + var ac, sc int + agentList, err := client.Agents().List(ctx, defaultListOpts()) if err != nil { - mu.Lock() - counts[proj] = ProjectCounts{AgentCount: -1, SessionCount: -1} - mu.Unlock() - return + ac = -1 + } else { + ac = len(agentList.Items) } - ac := len(agentList.Items) - - // Sum session counts per agent so the total matches what agent - // drill-down shows (avoids discrepancy with Sessions().List - // which may include orphaned sessions). - sc := 0 - for _, agent := range agentList.Items { - sl, err := client.Agents().Sessions(ctx, proj, agent.ID, defaultListOpts()) - if err == nil { - sc += len(sl.Items) - } + + sessionList, err := client.Sessions().List(ctx, defaultListOpts()) + if err != nil { + sc = -1 + } else { + sc = len(sessionList.Items) } mu.Lock() diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index e5ff28a71..62fd15b0d 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -107,6 +107,9 @@ type AppModel struct { // Detail view detailView views.DetailView + // Help overlay + helpView views.HelpView + // Cached resource data for CRUD lookups (maps name/ID -> full resource). cachedProjects []sdktypes.Project cachedAgents []sdktypes.Agent @@ -187,10 +190,10 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { return lipgloss.Color("240") }) st := views.NewSessionTable("all", views.DefaultTableStyle()) - // Session rows: PHASE is column index 3 (ID, AGENT, PROJECT, PHASE, ...) + // Session rows: PHASE is column index 4 (ID, NAME, AGENT, PROJECT, PHASE, ...) st.SetRowColorFunc(func(row table.Row) lipgloss.Color { - if len(row) > 3 { - return views.PhaseColor(row[3]) + if len(row) > 4 { + return views.PhaseColor(row[4]) } return lipgloss.Color("240") }) @@ -1109,6 +1112,11 @@ func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleFilterKey(msg) } + // Help overlay handles its own keys. + if m.activeView == "help" { + return m.handleHelpKey(msg) + } + // Message stream handles its own keys. if m.activeView == "messages" { return m.handleMessagesKey(msg) @@ -1256,11 +1264,11 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { // Create a new message stream for this session. agentName := m.currentAgent if agentName == "" && len(row) > 1 { - agentName = row[1] // AGENT column + agentName = row[2] // AGENT column } phase := "" - if len(row) > 3 { - phase = row[3] // PHASE column + if len(row) > 4 { + phase = row[4] // PHASE column } m.messageStream = views.NewMessageStream(fullSessionID, agentName, phase) m.resizeTable() // set message stream dimensions @@ -1327,7 +1335,7 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "?": - return m, m.viewSpecificHelp() + return m.showHelp() case "q": if len(m.navStack) <= 1 { @@ -1740,7 +1748,7 @@ func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.resizeTable() return m, nil case "?": - return m, m.viewSpecificHelp() + return m.showHelp() case "q": return m, m.popView() } @@ -1754,22 +1762,97 @@ func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } -// viewSpecificHelp returns a help info message based on the active view. -func (m *AppModel) viewSpecificHelp() tea.Cmd { +// showHelp creates a HelpView for the current view and pushes it onto the nav stack. +func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { + general := []views.HelpEntry{ + {":", "Command"}, + {"/", "Filter"}, + {"?", "Help"}, + {"c", "Copy ID"}, + {"N", "Sort Name"}, + {"A", "Sort Age"}, + } + + var resource, navigation []views.HelpEntry + switch m.activeView { case "projects": - return m.setInfo("Help: Enter drill | d describe | n new | Ctrl-D delete | : cmd | / filter | q quit") + resource = []views.HelpEntry{ + {"d", "Describe"}, {"n", "New"}, {"Ctrl-D", "Delete"}, + } + navigation = []views.HelpEntry{ + {"Enter", "Drill into agents"}, {"q", "Quit"}, + } case "agents": - return m.setInfo("Help: Enter sessions | i inbox | s start | x stop | e edit | l logs | d describe | m send | n new | Ctrl-D delete") + resource = []views.HelpEntry{ + {"s", "Start"}, {"x", "Stop"}, {"e", "Edit"}, {"i", "Inbox"}, + {"l", "Logs"}, {"d", "Describe"}, {"n", "New"}, {"Ctrl-D", "Delete"}, + } + navigation = []views.HelpEntry{ + {"Enter", "Drill into sessions"}, {"Esc", "Back to projects"}, + {"q", "Back"}, {"0-9", "Switch project"}, + } case "sessions": - return m.setInfo("Help: Enter/l messages | d describe | m send | y YAML | Ctrl-D delete | q back") + resource = []views.HelpEntry{ + {"d", "Describe"}, {"l", "Logs"}, {"m", "Send"}, {"n", "New"}, + {"y", "YAML"}, {"Ctrl-D", "Delete"}, + } + navigation = []views.HelpEntry{ + {"Enter", "Drill into messages"}, {"Esc", "Back to agents"}, + {"q", "Back"}, {"0-9", "Switch project"}, + } case "inbox": - return m.setInfo("Help: Enter view | m compose | r mark read | Ctrl-D delete | q back") + resource = []views.HelpEntry{ + {"m", "Compose"}, {"r", "Mark Read"}, {"Ctrl-D", "Delete"}, + } + navigation = []views.HelpEntry{ + {"Enter", "View body"}, {"Esc", "Back to agents"}, {"q", "Back"}, + } case "messages": - return m.setInfo("Help: Esc back | r raw | s scroll | m send | G bottom | g top | / search") - default: - return m.setInfo("Help: q quit | : command | / filter | Enter drill-in | Esc back") + resource = []views.HelpEntry{ + {"s", "Autoscroll"}, {"r", "Raw Mode"}, {"m", "Send"}, + {"c", "Copy"}, {"G", "Bottom"}, {"g", "Top"}, + } + general = []views.HelpEntry{ + {":", "Command"}, {"?", "Help"}, + } + navigation = []views.HelpEntry{ + {"Esc", "Back to sessions"}, {"q", "Back"}, + } + case "contexts": + resource = []views.HelpEntry{} + navigation = []views.HelpEntry{ + {"Enter", "Switch context"}, {"Esc", "Back"}, {"q", "Back"}, + } + case "detail": + resource = []views.HelpEntry{ + {"c", "Copy value"}, {"j/k", "Scroll"}, + } + general = []views.HelpEntry{ + {"?", "Help"}, + } + navigation = []views.HelpEntry{ + {"Esc", "Back"}, {"q", "Back"}, + } } + + title := fmt.Sprintf("Help(%s)", m.activeView) + m.helpView = views.NewHelpView(title, resource, general, navigation) + m.helpView.SetSize(m.width, m.height-10) + m.navStack = append(m.navStack, NavEntry{Kind: "help", Scope: m.activeView}) + prevView := m.activeView + m.activeView = "help" + _ = prevView + return m, nil +} + +// handleHelpKey processes keys while the help overlay is shown. +func (m *AppModel) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.Type == tea.KeyEsc || (msg.Type == tea.KeyRunes && string(msg.Runes) == "?") || + (msg.Type == tea.KeyRunes && string(msg.Runes) == "q") { + return m, m.popView() + } + return m, nil } // handleCommandKey processes keys while in command mode. @@ -2132,18 +2215,36 @@ func (m *AppModel) handleProjectShortcut(digit byte) (tea.Model, tea.Cmd) { m.currentAgent = "" m.currentAgentID = "" m.currentSession = "" - m.agentTable.SetScope(projectName) - m.navStack = []NavEntry{ - {Kind: "projects", Scope: "all"}, - {Kind: "agents", Scope: projectName}, - } - m.activeView = "agents" m.activeFilter = nil m.pollInFlight = true - return m, tea.Batch( - m.client.FetchAgents(projectName), - m.setInfo("Switched to project "+projectName), - ) + + // Stay in the same view type when switching projects. + targetView := m.activeView + switch targetView { + case "sessions": + m.sessionTable.SetScope(projectName) + m.navStack = []NavEntry{ + {Kind: "projects", Scope: "all"}, + {Kind: "agents", Scope: projectName}, + {Kind: "sessions", Scope: projectName}, + } + m.activeView = "sessions" + return m, tea.Batch( + m.client.FetchSessions(projectName), + m.setInfo("Switched to project "+projectName), + ) + default: + m.agentTable.SetScope(projectName) + m.navStack = []NavEntry{ + {Kind: "projects", Scope: "all"}, + {Kind: "agents", Scope: projectName}, + } + m.activeView = "agents" + return m, tea.Batch( + m.client.FetchAgents(projectName), + m.setInfo("Switched to project "+projectName), + ) + } } // --------------------------------------------------------------------------- diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go new file mode 100644 index 000000000..3d4d35ca1 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go @@ -0,0 +1,236 @@ +package views + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Local color constants for the help view. Defined here instead of importing +// from the parent tui package to avoid circular imports. +var ( + helpBorderColor = lipgloss.Color("240") // dim for borders + helpTitleColor = lipgloss.Color("214") // orange for title + helpHeaderColor = lipgloss.Color("240") // dim for column headers + helpKeyColor = lipgloss.Color("240") // dim for key brackets + helpActionColor = lipgloss.Color("255") // white for action text + helpHintColor = lipgloss.Color("240") // dim for close hint +) + +// HelpEntry represents a single keyboard shortcut entry in the help overlay. +type HelpEntry struct { + Key string // e.g. "", "", "" + Action string // e.g. "Start", "Delete", "Drill into sessions" +} + +// HelpView renders a full-screen help overlay showing keyboard shortcuts +// organized into three columns: Resource, General, and Navigation. +type HelpView struct { + title string + resource []HelpEntry + general []HelpEntry + navigation []HelpEntry + width int + height int +} + +// NewHelpView creates a HelpView with the given title and shortcut entries. +func NewHelpView(title string, resource, general, navigation []HelpEntry) HelpView { + return HelpView{ + title: title, + resource: resource, + general: general, + navigation: navigation, + width: 80, + height: 24, + } +} + +// SetSize updates the available width and height for rendering. +func (h *HelpView) SetSize(w, ht int) { + h.width = w + h.height = ht +} + +// View renders the help overlay as a bordered box with three columns. +func (h HelpView) View() string { + borderStyle := lipgloss.NewStyle().Foreground(helpBorderColor) + titleStyle := lipgloss.NewStyle().Foreground(helpTitleColor).Bold(true) + headerStyle := lipgloss.NewStyle().Foreground(helpHeaderColor).Bold(true) + keyStyle := lipgloss.NewStyle().Foreground(helpKeyColor) + actionStyle := lipgloss.NewStyle().Foreground(helpActionColor) + hintStyle := lipgloss.NewStyle().Foreground(helpHintColor) + + contentWidth := h.width + if contentWidth < 20 { + contentWidth = 80 + } + innerWidth := contentWidth - 4 // 2 for borders + 2 for padding + + // Render title bar: ┌──── Help(agents) ────┐ + titleText := " " + titleStyle.Render("Help("+h.title+")") + " " + titleVisualWidth := lipgloss.Width(titleText) + remaining := contentWidth - titleVisualWidth - 2 + if remaining < 2 { + remaining = 2 + } + leftDashes := remaining / 2 + rightDashes := remaining - leftDashes + titleBar := borderStyle.Render("┌"+strings.Repeat("─", leftDashes)) + + titleText + + borderStyle.Render(strings.Repeat("─", rightDashes)+"┐") + + // Compute column widths. Split inner width roughly into thirds. + colWidth := innerWidth / 3 + if colWidth < 15 { + colWidth = 15 + } + col1W := colWidth + col2W := colWidth + col3W := innerWidth - col1W - col2W + if col3W < 10 { + col3W = 10 + } + + // Compute the max key width per column for alignment. + resKeyW := maxEntryKeyWidth(h.resource) + genKeyW := maxEntryKeyWidth(h.general) + navKeyW := maxEntryKeyWidth(h.navigation) + + // Find the tallest column to know how many rows we need. + maxRows := len(h.resource) + if len(h.general) > maxRows { + maxRows = len(h.general) + } + if len(h.navigation) > maxRows { + maxRows = len(h.navigation) + } + + // Available content height: total height minus title (1), bottom border (1), blank lines (2), headers (2), hint (1). + vpHeight := h.height - 7 + if vpHeight < 1 { + vpHeight = 1 + } + if maxRows > vpHeight { + maxRows = vpHeight + } + + var bodyLines []string + + // Empty line. + bodyLines = append(bodyLines, h.emptyLine(borderStyle, innerWidth)) + + // Column headers. + hdr1 := headerStyle.Render(padRight("Resource", col1W)) + hdr2 := headerStyle.Render(padRight("General", col2W)) + hdr3 := headerStyle.Render(padRight("Navigation", col3W)) + headerLine := hdr1 + hdr2 + hdr3 + headerLineWidth := lipgloss.Width(headerLine) + headerPad := innerWidth - headerLineWidth + if headerPad < 0 { + headerPad = 0 + } + bodyLines = append(bodyLines, + borderStyle.Render("│")+" "+headerLine+strings.Repeat(" ", headerPad)+" "+borderStyle.Render("│")) + + // Underlines for column headers. + ul1 := headerStyle.Render(padRight(strings.Repeat("─", min(len("Resource"), col1W-2)), col1W)) + ul2 := headerStyle.Render(padRight(strings.Repeat("─", min(len("General"), col2W-2)), col2W)) + ul3 := headerStyle.Render(padRight(strings.Repeat("─", min(len("Navigation"), col3W-2)), col3W)) + underlineLine := ul1 + ul2 + ul3 + underlineWidth := lipgloss.Width(underlineLine) + underlinePad := innerWidth - underlineWidth + if underlinePad < 0 { + underlinePad = 0 + } + bodyLines = append(bodyLines, + borderStyle.Render("│")+" "+underlineLine+strings.Repeat(" ", underlinePad)+" "+borderStyle.Render("│")) + + // Data rows. + for i := range maxRows { + c1 := renderHelpEntry(h.resource, i, resKeyW, col1W, keyStyle, actionStyle) + c2 := renderHelpEntry(h.general, i, genKeyW, col2W, keyStyle, actionStyle) + c3 := renderHelpEntry(h.navigation, i, navKeyW, col3W, keyStyle, actionStyle) + + rowText := c1 + c2 + c3 + rowWidth := lipgloss.Width(rowText) + rowPad := innerWidth - rowWidth + if rowPad < 0 { + rowPad = 0 + } + bodyLines = append(bodyLines, + borderStyle.Render("│")+" "+rowText+strings.Repeat(" ", rowPad)+" "+borderStyle.Render("│")) + } + + // Fill remaining viewport with empty lines. + contentLines := len(bodyLines) + // We want: blank + headers(2) + data rows + blank + hint = vpHeight + 5 + targetLines := vpHeight + 3 // blank + headers(2) + data + blank + hint - 2 already counted + for i := contentLines; i < targetLines; i++ { + bodyLines = append(bodyLines, h.emptyLine(borderStyle, innerWidth)) + } + + // Empty line before hint. + bodyLines = append(bodyLines, h.emptyLine(borderStyle, innerWidth)) + + // Hint line: "Press Esc or ? to close" centered. + hint := hintStyle.Render("Press Esc or ? to close") + hintWidth := lipgloss.Width(hint) + hintLeftPad := (innerWidth - hintWidth) / 2 + if hintLeftPad < 0 { + hintLeftPad = 0 + } + hintRightPad := innerWidth - hintLeftPad - hintWidth + if hintRightPad < 0 { + hintRightPad = 0 + } + bodyLines = append(bodyLines, + borderStyle.Render("│")+" "+strings.Repeat(" ", hintLeftPad)+hint+strings.Repeat(" ", hintRightPad)+" "+borderStyle.Render("│")) + + // Bottom border. + bottom := borderStyle.Render("└" + strings.Repeat("─", contentWidth-2) + "┘") + + return titleBar + "\n" + strings.Join(bodyLines, "\n") + "\n" + bottom +} + +// emptyLine renders an empty bordered line. +func (h HelpView) emptyLine(borderStyle lipgloss.Style, innerWidth int) string { + return borderStyle.Render("│") + " " + strings.Repeat(" ", innerWidth) + " " + borderStyle.Render("│") +} + +// renderHelpEntry renders a single help entry cell for a column, or empty space +// if the index is out of range for that column's entries. +func renderHelpEntry(entries []HelpEntry, idx, maxKeyW, colW int, keyStyle, actionStyle lipgloss.Style) string { + if idx >= len(entries) { + return padRight("", colW) + } + e := entries[idx] + keyRendered := keyStyle.Render(padRight(e.Key, maxKeyW)) + actionRendered := actionStyle.Render(e.Action) + cell := keyRendered + " " + actionRendered + cellWidth := lipgloss.Width(cell) + if cellWidth < colW { + cell += strings.Repeat(" ", colW-cellWidth) + } + return cell +} + +// maxEntryKeyWidth returns the maximum key string length across entries. +func maxEntryKeyWidth(entries []HelpEntry) int { + maxW := 0 + for _, e := range entries { + if len(e.Key) > maxW { + maxW = len(e.Key) + } + } + return maxW +} + +// padRight pads s with spaces to reach width w. If s is already wider, it is +// returned unmodified. +func padRight(s string, w int) string { + if len(s) >= w { + return s + } + return s + strings.Repeat(" ", w-len(s)) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go index 0da8001cd..acee5a996 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sessions.go @@ -39,10 +39,10 @@ func PhaseColor(phase string) lipgloss.Color { func SessionColumns() []table.Column { return []table.Column{ {Title: "ID", Width: 14}, - {Title: "AGENT", Width: 15}, - {Title: "PROJECT", Width: 15}, + {Title: "NAME", Width: 15}, + {Title: "AGENT", Width: 12}, + {Title: "PROJECT", Width: 12}, {Title: "PHASE", Width: 12}, - {Title: "TRIGGERED BY", Width: 15}, {Title: "STARTED", Width: 10}, {Title: "DURATION", Width: 10}, } @@ -85,10 +85,10 @@ func SessionRow(s sdktypes.Session, agentName string, now time.Time) table.Row { return table.Row{ shortID, + s.Name, agentName, s.ProjectID, s.Phase, - s.TriggeredByUserID, started, duration, } From f6251f3f8802c147a8fecc49117825f688a8d4bc Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 09:14:17 -0400 Subject: [PATCH 053/117] fix(cli): 0 key stays in current view, shows global scope Pressing 0 from sessions shows all sessions globally. From agents it goes to all projects. Matches k9s behavior where 0 = "all". Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 62fd15b0d..6e71b4bf1 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -2193,16 +2193,25 @@ func (m *AppModel) handlePromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // 0 = "all" (clear project scope), 1-9 = projectShortcuts[digit-1]. func (m *AppModel) handleProjectShortcut(digit byte) (tea.Model, tea.Cmd) { if digit == 0 { - // Switch to "all" — clear project scope and go to global sessions. + // Switch to "all" — clear project scope, stay in current view type. m.currentProject = "" m.currentAgent = "" m.currentAgentID = "" m.currentSession = "" - m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} - m.activeView = "projects" m.activeFilter = nil m.pollInFlight = true - return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Switched to all projects")) + + switch m.activeView { + case "sessions": + m.sessionTable.SetScope("all") + m.navStack = []NavEntry{{Kind: "sessions", Scope: "all"}} + return m, tea.Batch(m.client.FetchAllSessions(), m.setInfo("Viewing all sessions")) + default: + m.agentTable.SetScope("all") + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Viewing all projects")) + } } idx := int(digit) - 1 From ac65b4ad71fa9efebfb1dca6dabba37f03aa1522 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 09:24:56 -0400 Subject: [PATCH 054/117] fix(cli): 9 UI polish fixes 1. Help uses format, no borders, k9s-style columns 2. Remove blank rows between help columns 3. Fix "Help(Help(sessions))" double-wrap title 4. Remove separator above breadcrumbs 5. Breadcrumb background: orange for lists, blue for leaves 6. Unknown command shows ASCII T-Rex error dialog 7. Animated streaming cursor (cycling frames) 8. Remove separator below header 9. More spacing around ACP logo Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 53 ++++--- .../cmd/acpctl/ambient/tui/model_new.go | 45 +++--- .../cmd/acpctl/ambient/tui/views/dialog.go | 60 +++++--- .../cmd/acpctl/ambient/tui/views/help.go | 133 ++++++------------ .../cmd/acpctl/ambient/tui/views/messages.go | 6 +- 5 files changed, 146 insertions(+), 151 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index ca4a77fb7..5192cd1f2 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -10,13 +10,13 @@ import ( "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" ) -// ASCII art branding rendered in the header. +// ASCII art branding rendered in the header (Fix 9: extra left padding). var brandLines = []string{ - ` _ ___ ___ `, - ` /_\ / __| _ \ `, - ` / _ \| (__| _/ `, - `/_/ \_\\___|_| `, - ` `, + ` _ ___ ___ `, + ` /_\ / __| _ \ `, + ` / _ \| (__| _/ `, + ` /_/ \_\\___|_| `, + ` `, } // View implements tea.Model. It renders the k9s-style full-screen layout. @@ -30,15 +30,12 @@ func (m *AppModel) View() string { // 1. Header block. sections = append(sections, m.viewHeader()) - // 2. Separator. - sections = append(sections, styleDim.Render(strings.Repeat("─", m.width))) - - // 3. Command/filter/prompt bar (only when active). + // 2. Command/filter/prompt bar (only when active). if m.commandMode || m.filterMode || m.promptMode { sections = append(sections, m.viewCommandBar()) } - // 4. Resource table with title bar (+ dialog overlay if active). + // 3. Resource table with title bar (+ dialog overlay if active). tableOutput := m.viewResourceTable() if m.dialog != nil { tableH := m.height - 10 @@ -46,13 +43,10 @@ func (m *AppModel) View() string { } sections = append(sections, tableOutput) - // 5. Separator. - sections = append(sections, styleDim.Render(strings.Repeat("─", m.width))) - - // 6. Breadcrumb trail. + // 4. Breadcrumb trail. sections = append(sections, m.viewBreadcrumb()) - // 7. Info line. + // 5. Info line. sections = append(sections, m.viewInfoLine()) return strings.Join(sections, "\n") @@ -168,7 +162,7 @@ func (m *AppModel) viewHeader() string { } right := "" if col4[i] != "" && brand != "" { - right = col4[i] + " " + brand + right = col4[i] + " " + brand } else if brand != "" { right = brand } else { @@ -240,13 +234,32 @@ func (m *AppModel) viewResourceTable() string { } } -// viewBreadcrumb renders the navigation breadcrumb trail at the bottom. +// viewBreadcrumb renders the navigation breadcrumb trail at the bottom +// with a full-width background color: orange for list views, blue for leaf views. func (m *AppModel) viewBreadcrumb() string { var segments []string for _, entry := range m.navStack { - segments = append(segments, styleOrange.Render("<"+entry.Kind+">")) + segments = append(segments, "<"+entry.Kind+">") + } + text := " " + strings.Join(segments, " ") + + // Determine if current view is a "leaf" view (messages, help, detail) + // or a "list" view (projects, agents, sessions, inbox, contexts). + isLeaf := m.activeView == "messages" || m.activeView == "help" || m.activeView == "detail" + + var bgStyle lipgloss.Style + if isLeaf { + bgStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("69")). + Foreground(lipgloss.Color("255")). + Width(m.width) + } else { + bgStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("214")). + Foreground(lipgloss.Color("0")). + Width(m.width) } - return " " + strings.Join(segments, styleDim.Render(" ")) + return bgStyle.Render(text) } // viewInfoLine renders the ephemeral info/toast line at the very bottom. diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 6e71b4bf1..677f8d9ae 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -697,9 +697,8 @@ func (m *AppModel) resizeTable() { // title bar: 1 line // breadcrumb: 1 line // info line: 1 line - // separator lines: 2 - // Total chrome: ~10 lines, leaving the rest for the table. - tableHeight := m.height - 10 + // Total chrome: ~8 lines, leaving the rest for the table. + tableHeight := m.height - 8 if m.commandMode || m.filterMode || m.promptMode { tableHeight-- // command/filter/prompt bar takes a line } @@ -1764,21 +1763,24 @@ func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // showHelp creates a HelpView for the current view and pushes it onto the nav stack. func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { + // Fix 3: Capture the view name BEFORE changing activeView. + viewName := m.activeView + general := []views.HelpEntry{ {":", "Command"}, {"/", "Filter"}, {"?", "Help"}, {"c", "Copy ID"}, - {"N", "Sort Name"}, - {"A", "Sort Age"}, + {"shift-n", "Sort Name"}, + {"shift-a", "Sort Age"}, } var resource, navigation []views.HelpEntry - switch m.activeView { + switch viewName { case "projects": resource = []views.HelpEntry{ - {"d", "Describe"}, {"n", "New"}, {"Ctrl-D", "Delete"}, + {"d", "Describe"}, {"n", "New"}, {"ctrl-d", "Delete"}, } navigation = []views.HelpEntry{ {"Enter", "Drill into agents"}, {"q", "Quit"}, @@ -1786,7 +1788,7 @@ func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { case "agents": resource = []views.HelpEntry{ {"s", "Start"}, {"x", "Stop"}, {"e", "Edit"}, {"i", "Inbox"}, - {"l", "Logs"}, {"d", "Describe"}, {"n", "New"}, {"Ctrl-D", "Delete"}, + {"l", "Logs"}, {"d", "Describe"}, {"n", "New"}, {"ctrl-d", "Delete"}, } navigation = []views.HelpEntry{ {"Enter", "Drill into sessions"}, {"Esc", "Back to projects"}, @@ -1795,7 +1797,7 @@ func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { case "sessions": resource = []views.HelpEntry{ {"d", "Describe"}, {"l", "Logs"}, {"m", "Send"}, {"n", "New"}, - {"y", "YAML"}, {"Ctrl-D", "Delete"}, + {"y", "YAML"}, {"ctrl-d", "Delete"}, } navigation = []views.HelpEntry{ {"Enter", "Drill into messages"}, {"Esc", "Back to agents"}, @@ -1803,7 +1805,7 @@ func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { } case "inbox": resource = []views.HelpEntry{ - {"m", "Compose"}, {"r", "Mark Read"}, {"Ctrl-D", "Delete"}, + {"m", "Compose"}, {"r", "Mark Read"}, {"ctrl-d", "Delete"}, } navigation = []views.HelpEntry{ {"Enter", "View body"}, {"Esc", "Back to agents"}, {"q", "Back"}, @@ -1811,7 +1813,7 @@ func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { case "messages": resource = []views.HelpEntry{ {"s", "Autoscroll"}, {"r", "Raw Mode"}, {"m", "Send"}, - {"c", "Copy"}, {"G", "Bottom"}, {"g", "Top"}, + {"c", "Copy"}, {"shift-g", "Bottom"}, {"g", "Top"}, } general = []views.HelpEntry{ {":", "Command"}, {"?", "Help"}, @@ -1836,13 +1838,11 @@ func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { } } - title := fmt.Sprintf("Help(%s)", m.activeView) + title := viewName m.helpView = views.NewHelpView(title, resource, general, navigation) m.helpView.SetSize(m.width, m.height-10) - m.navStack = append(m.navStack, NavEntry{Kind: "help", Scope: m.activeView}) - prevView := m.activeView + m.navStack = append(m.navStack, NavEntry{Kind: "help", Scope: viewName}) m.activeView = "help" - _ = prevView return m, nil } @@ -2054,7 +2054,20 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { return m, m.setInfo("Commands: " + fmt.Sprintf("%d available", len(entries))) default: - return m, m.setInfo("Unknown command: "+input) + ascii := " .-=-.\n" + + " / ! )\\\n" + + " (__ _/\n" + + " / _>/\n" + + " / _> \\ _\n" + + " /_/ \\ \\//\n" + + " ( |\n" + + " ) |\n" + + " \\_|" + msg := "< Ruroh? '" + input + "' not found >" + d := views.NewErrorDialog("error", msg, ascii) + m.dialog = &d + m.dialogAction = nil // single-button dismiss + return m, nil } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go index e1015b727..ca1f66567 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go @@ -70,6 +70,17 @@ func NewDeleteDialog(kind, name string) Dialog { } } +// NewErrorDialog creates a single-button dialog with ASCII art and an error message. +func NewErrorDialog(title, message, ascii string) Dialog { + return Dialog{ + Title: title, + Message: ascii + "\n" + message, + Buttons: []string{"Dismiss"}, + Selected: 0, + Width: 50, + } +} + // NewInputDialog creates a dialog with a text input field and Cancel/OK buttons. func NewInputDialog(title, prompt string) Dialog { ti := textinput.New() @@ -159,11 +170,15 @@ func (d Dialog) View(containerWidth, containerHeight int) string { Foreground(dlgColorDim). Padding(0, 1) - // Calculate dialog width: max(40, message width + 8, input prompt + 16), + // Calculate dialog width: max(40, widest message line + 8, input prompt + 16), // capped at containerWidth - 10. dlgWidth := 40 - if msgW := lipgloss.Width(d.Message) + 8; msgW > dlgWidth { - dlgWidth = msgW + if d.Message != "" { + for _, line := range strings.Split(d.Message, "\n") { + if msgW := lipgloss.Width(line) + 8; msgW > dlgWidth { + dlgWidth = msgW + } + } } if d.Input != nil { if promptW := lipgloss.Width(d.Input.Prompt) + 24; promptW > dlgWidth { @@ -206,24 +221,27 @@ func (d Dialog) View(containerWidth, containerHeight int) string { strings.Repeat(" ", innerWidth) + borderStyle.Render("│") - // Message line (centered within inner width). - var msgLine string + // Message lines (centered within inner width, supports multiline). + var msgLines []string if d.Message != "" { - msgRendered := messageStyle.Render(d.Message) - msgVisualWidth := lipgloss.Width(msgRendered) - msgPadLeft := (innerWidth - msgVisualWidth) / 2 - if msgPadLeft < 1 { - msgPadLeft = 1 - } - msgPadRight := innerWidth - msgVisualWidth - msgPadLeft - if msgPadRight < 0 { - msgPadRight = 0 + for _, line := range strings.Split(d.Message, "\n") { + lineRendered := messageStyle.Render(line) + lineVisualWidth := lipgloss.Width(lineRendered) + linePadLeft := (innerWidth - lineVisualWidth) / 2 + if linePadLeft < 1 { + linePadLeft = 1 + } + linePadRight := innerWidth - lineVisualWidth - linePadLeft + if linePadRight < 0 { + linePadRight = 0 + } + msgLines = append(msgLines, + borderStyle.Render("│")+ + strings.Repeat(" ", linePadLeft)+ + lineRendered+ + strings.Repeat(" ", linePadRight)+ + borderStyle.Render("│")) } - msgLine = borderStyle.Render("│") + - strings.Repeat(" ", msgPadLeft) + - msgRendered + - strings.Repeat(" ", msgPadRight) + - borderStyle.Render("│") } // Input line (if present). @@ -275,8 +293,8 @@ func (d Dialog) View(containerWidth, containerHeight int) string { var dialogLines []string dialogLines = append(dialogLines, topLine) dialogLines = append(dialogLines, emptyLine) - if d.Message != "" { - dialogLines = append(dialogLines, msgLine) + if len(msgLines) > 0 { + dialogLines = append(dialogLines, msgLines...) dialogLines = append(dialogLines, emptyLine) } if d.Input != nil { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go index 3d4d35ca1..52078c5f6 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go @@ -9,9 +9,7 @@ import ( // Local color constants for the help view. Defined here instead of importing // from the parent tui package to avoid circular imports. var ( - helpBorderColor = lipgloss.Color("240") // dim for borders - helpTitleColor = lipgloss.Color("214") // orange for title - helpHeaderColor = lipgloss.Color("240") // dim for column headers + helpHeaderColor = lipgloss.Color("214") // orange/cyan for column headers (k9s style) helpKeyColor = lipgloss.Color("240") // dim for key brackets helpActionColor = lipgloss.Color("255") // white for action text helpHintColor = lipgloss.Color("240") // dim for close hint @@ -19,12 +17,13 @@ var ( // HelpEntry represents a single keyboard shortcut entry in the help overlay. type HelpEntry struct { - Key string // e.g. "", "", "" + Key string // e.g. "s", "ctrl-d", "Enter" Action string // e.g. "Start", "Delete", "Drill into sessions" } // HelpView renders a full-screen help overlay showing keyboard shortcuts // organized into three columns: Resource, General, and Navigation. +// Renders without borders, filling the table area like k9s does. type HelpView struct { title string resource []HelpEntry @@ -52,10 +51,8 @@ func (h *HelpView) SetSize(w, ht int) { h.height = ht } -// View renders the help overlay as a bordered box with three columns. +// View renders the help view as borderless columns filling the table area. func (h HelpView) View() string { - borderStyle := lipgloss.NewStyle().Foreground(helpBorderColor) - titleStyle := lipgloss.NewStyle().Foreground(helpTitleColor).Bold(true) headerStyle := lipgloss.NewStyle().Foreground(helpHeaderColor).Bold(true) keyStyle := lipgloss.NewStyle().Foreground(helpKeyColor) actionStyle := lipgloss.NewStyle().Foreground(helpActionColor) @@ -65,20 +62,7 @@ func (h HelpView) View() string { if contentWidth < 20 { contentWidth = 80 } - innerWidth := contentWidth - 4 // 2 for borders + 2 for padding - - // Render title bar: ┌──── Help(agents) ────┐ - titleText := " " + titleStyle.Render("Help("+h.title+")") + " " - titleVisualWidth := lipgloss.Width(titleText) - remaining := contentWidth - titleVisualWidth - 2 - if remaining < 2 { - remaining = 2 - } - leftDashes := remaining / 2 - rightDashes := remaining - leftDashes - titleBar := borderStyle.Render("┌"+strings.Repeat("─", leftDashes)) + - titleText + - borderStyle.Render(strings.Repeat("─", rightDashes)+"┐") + innerWidth := contentWidth - 4 // padding on each side // Compute column widths. Split inner width roughly into thirds. colWidth := innerWidth / 3 @@ -93,11 +77,12 @@ func (h HelpView) View() string { } // Compute the max key width per column for alignment. - resKeyW := maxEntryKeyWidth(h.resource) - genKeyW := maxEntryKeyWidth(h.general) - navKeyW := maxEntryKeyWidth(h.navigation) + // Account for the <> brackets that renderHelpKey adds. + resKeyW := maxFormattedKeyWidth(h.resource) + genKeyW := maxFormattedKeyWidth(h.general) + navKeyW := maxFormattedKeyWidth(h.navigation) - // Find the tallest column to know how many rows we need. + // Find the tallest column to know how many rows we need (Fix 2: no blank rows). maxRows := len(h.resource) if len(h.general) > maxRows { maxRows = len(h.general) @@ -106,8 +91,8 @@ func (h HelpView) View() string { maxRows = len(h.navigation) } - // Available content height: total height minus title (1), bottom border (1), blank lines (2), headers (2), hint (1). - vpHeight := h.height - 7 + // Available content height. + vpHeight := h.height - 5 // headers(2) + blank + hint + padding if vpHeight < 1 { vpHeight = 1 } @@ -117,62 +102,35 @@ func (h HelpView) View() string { var bodyLines []string - // Empty line. - bodyLines = append(bodyLines, h.emptyLine(borderStyle, innerWidth)) - - // Column headers. - hdr1 := headerStyle.Render(padRight("Resource", col1W)) - hdr2 := headerStyle.Render(padRight("General", col2W)) - hdr3 := headerStyle.Render(padRight("Navigation", col3W)) - headerLine := hdr1 + hdr2 + hdr3 - headerLineWidth := lipgloss.Width(headerLine) - headerPad := innerWidth - headerLineWidth - if headerPad < 0 { - headerPad = 0 - } - bodyLines = append(bodyLines, - borderStyle.Render("│")+" "+headerLine+strings.Repeat(" ", headerPad)+" "+borderStyle.Render("│")) + // Blank line before headers. + bodyLines = append(bodyLines, "") + + // Column headers (colored like k9s — orange). + hdr1 := headerStyle.Render(padRight("RESOURCE", col1W)) + hdr2 := headerStyle.Render(padRight("GENERAL", col2W)) + hdr3 := headerStyle.Render(padRight("NAVIGATION", col3W)) + bodyLines = append(bodyLines, " "+hdr1+hdr2+hdr3) // Underlines for column headers. - ul1 := headerStyle.Render(padRight(strings.Repeat("─", min(len("Resource"), col1W-2)), col1W)) - ul2 := headerStyle.Render(padRight(strings.Repeat("─", min(len("General"), col2W-2)), col2W)) - ul3 := headerStyle.Render(padRight(strings.Repeat("─", min(len("Navigation"), col3W-2)), col3W)) - underlineLine := ul1 + ul2 + ul3 - underlineWidth := lipgloss.Width(underlineLine) - underlinePad := innerWidth - underlineWidth - if underlinePad < 0 { - underlinePad = 0 - } - bodyLines = append(bodyLines, - borderStyle.Render("│")+" "+underlineLine+strings.Repeat(" ", underlinePad)+" "+borderStyle.Render("│")) + ul1 := headerStyle.Render(padRight(strings.Repeat("─", min(len("RESOURCE"), col1W-2)), col1W)) + ul2 := headerStyle.Render(padRight(strings.Repeat("─", min(len("GENERAL"), col2W-2)), col2W)) + ul3 := headerStyle.Render(padRight(strings.Repeat("─", min(len("NAVIGATION"), col3W-2)), col3W)) + bodyLines = append(bodyLines, " "+ul1+ul2+ul3) - // Data rows. + // Data rows (Fix 2: only render up to maxRows, empty cells are blank space). for i := range maxRows { c1 := renderHelpEntry(h.resource, i, resKeyW, col1W, keyStyle, actionStyle) c2 := renderHelpEntry(h.general, i, genKeyW, col2W, keyStyle, actionStyle) c3 := renderHelpEntry(h.navigation, i, navKeyW, col3W, keyStyle, actionStyle) - - rowText := c1 + c2 + c3 - rowWidth := lipgloss.Width(rowText) - rowPad := innerWidth - rowWidth - if rowPad < 0 { - rowPad = 0 - } - bodyLines = append(bodyLines, - borderStyle.Render("│")+" "+rowText+strings.Repeat(" ", rowPad)+" "+borderStyle.Render("│")) + bodyLines = append(bodyLines, " "+c1+c2+c3) } - // Fill remaining viewport with empty lines. - contentLines := len(bodyLines) - // We want: blank + headers(2) + data rows + blank + hint = vpHeight + 5 - targetLines := vpHeight + 3 // blank + headers(2) + data + blank + hint - 2 already counted - for i := contentLines; i < targetLines; i++ { - bodyLines = append(bodyLines, h.emptyLine(borderStyle, innerWidth)) + // Fill remaining space. + targetLines := vpHeight + 3 + for i := len(bodyLines); i < targetLines; i++ { + bodyLines = append(bodyLines, "") } - // Empty line before hint. - bodyLines = append(bodyLines, h.emptyLine(borderStyle, innerWidth)) - // Hint line: "Press Esc or ? to close" centered. hint := hintStyle.Render("Press Esc or ? to close") hintWidth := lipgloss.Width(hint) @@ -180,32 +138,22 @@ func (h HelpView) View() string { if hintLeftPad < 0 { hintLeftPad = 0 } - hintRightPad := innerWidth - hintLeftPad - hintWidth - if hintRightPad < 0 { - hintRightPad = 0 - } - bodyLines = append(bodyLines, - borderStyle.Render("│")+" "+strings.Repeat(" ", hintLeftPad)+hint+strings.Repeat(" ", hintRightPad)+" "+borderStyle.Render("│")) - - // Bottom border. - bottom := borderStyle.Render("└" + strings.Repeat("─", contentWidth-2) + "┘") - - return titleBar + "\n" + strings.Join(bodyLines, "\n") + "\n" + bottom -} + bodyLines = append(bodyLines, strings.Repeat(" ", hintLeftPad)+hint) -// emptyLine renders an empty bordered line. -func (h HelpView) emptyLine(borderStyle lipgloss.Style, innerWidth int) string { - return borderStyle.Render("│") + " " + strings.Repeat(" ", innerWidth) + " " + borderStyle.Render("│") + return strings.Join(bodyLines, "\n") } // renderHelpEntry renders a single help entry cell for a column, or empty space // if the index is out of range for that column's entries. +// Keys are rendered with dim brackets like the header hints: . func renderHelpEntry(entries []HelpEntry, idx, maxKeyW, colW int, keyStyle, actionStyle lipgloss.Style) string { if idx >= len(entries) { return padRight("", colW) } e := entries[idx] - keyRendered := keyStyle.Render(padRight(e.Key, maxKeyW)) + // Render key with dim brackets: + keyText := "<" + e.Key + ">" + keyRendered := keyStyle.Render(padRight(keyText, maxKeyW)) actionRendered := actionStyle.Render(e.Action) cell := keyRendered + " " + actionRendered cellWidth := lipgloss.Width(cell) @@ -215,12 +163,13 @@ func renderHelpEntry(entries []HelpEntry, idx, maxKeyW, colW int, keyStyle, acti return cell } -// maxEntryKeyWidth returns the maximum key string length across entries. -func maxEntryKeyWidth(entries []HelpEntry) int { +// maxFormattedKeyWidth returns the maximum formatted key width (with <> brackets). +func maxFormattedKeyWidth(entries []HelpEntry) int { maxW := 0 for _, e := range entries { - if len(e.Key) > maxW { - maxW = len(e.Key) + w := len("<" + e.Key + ">") + if w > maxW { + maxW = w } } return maxW diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 49f761d92..f9c45b42c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -611,10 +611,12 @@ func (ms *MessageStream) View() string { bottomLines = append([]string{composeSep, composeLine}, bottomLines...) } - // Streaming cursor (when phase is running). + // Streaming cursor (when phase is running) — animated. if strings.ToLower(ms.phase) == "running" { cursorStyle := lipgloss.NewStyle().Foreground(msgColorOrange) - cursor := cursorStyle.Render(" ▌ streaming…") + frames := []string{"▌", "▐", "█", "▐"} + frame := frames[time.Now().UnixMilli()/300%4] + cursor := cursorStyle.Render(" " + frame + " streaming…") cursorLine := borderStyle.Render("│") + padToWidth(cursor, ms.width-2) + borderStyle.Render("│") From 8f7dd8655e05f042804871040607580e56271519 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:06:28 -0400 Subject: [PATCH 055/117] fix(cli): breadcrumb segments as individual colored boxes Each breadcrumb is its own box: orange bg for list views, blue bg for leaf views (messages, help, detail). Matches k9s style. Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 5192cd1f2..77d9f36e2 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -234,32 +234,30 @@ func (m *AppModel) viewResourceTable() string { } } -// viewBreadcrumb renders the navigation breadcrumb trail at the bottom -// with a full-width background color: orange for list views, blue for leaf views. +// viewBreadcrumb renders the navigation breadcrumb trail at the bottom. +// Each segment is an individual colored box: orange for list views, blue for leaves. func (m *AppModel) viewBreadcrumb() string { - var segments []string - for _, entry := range m.navStack { - segments = append(segments, "<"+entry.Kind+">") - } - text := " " + strings.Join(segments, " ") + listStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("214")). + Foreground(lipgloss.Color("0")). + Padding(0, 1) + leafStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("69")). + Foreground(lipgloss.Color("255")). + Padding(0, 1) - // Determine if current view is a "leaf" view (messages, help, detail) - // or a "list" view (projects, agents, sessions, inbox, contexts). - isLeaf := m.activeView == "messages" || m.activeView == "help" || m.activeView == "detail" + leafKinds := map[string]bool{"messages": true, "help": true, "detail": true} - var bgStyle lipgloss.Style - if isLeaf { - bgStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("69")). - Foreground(lipgloss.Color("255")). - Width(m.width) - } else { - bgStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("214")). - Foreground(lipgloss.Color("0")). - Width(m.width) + var segments []string + for _, entry := range m.navStack { + label := "<" + entry.Kind + ">" + if leafKinds[entry.Kind] { + segments = append(segments, leafStyle.Render(label)) + } else { + segments = append(segments, listStyle.Render(label)) + } } - return bgStyle.Render(text) + return " " + strings.Join(segments, " ") } // viewInfoLine renders the ephemeral info/toast line at the very bottom. From f36cebfefc57aee6f9654c7ae5db15c08a83aafe Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:08:40 -0400 Subject: [PATCH 056/117] fix(cli): bold breadcrumbs, less padding, logo pushed down one line Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/app.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 77d9f36e2..8b5362116 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -12,11 +12,11 @@ import ( // ASCII art branding rendered in the header (Fix 9: extra left padding). var brandLines = []string{ + ` `, ` _ ___ ___ `, ` /_\ / __| _ \ `, ` / _ \| (__| _/ `, ` /_/ \_\\___|_| `, - ` `, } // View implements tea.Model. It renders the k9s-style full-screen layout. @@ -240,10 +240,12 @@ func (m *AppModel) viewBreadcrumb() string { listStyle := lipgloss.NewStyle(). Background(lipgloss.Color("214")). Foreground(lipgloss.Color("0")). + Bold(true). Padding(0, 1) leafStyle := lipgloss.NewStyle(). Background(lipgloss.Color("69")). Foreground(lipgloss.Color("255")). + Bold(true). Padding(0, 1) leafKinds := map[string]bool{"messages": true, "help": true, "detail": true} @@ -257,7 +259,7 @@ func (m *AppModel) viewBreadcrumb() string { segments = append(segments, listStyle.Render(label)) } } - return " " + strings.Join(segments, " ") + return " " + strings.Join(segments, " ") } // viewInfoLine renders the ephemeral info/toast line at the very bottom. From 3715723617fd85aa59a3290bfcb66ea5d97fa370 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:09:03 -0400 Subject: [PATCH 057/117] =?UTF-8?q?fix(cli):=20improve=20leaf=20breadcrumb?= =?UTF-8?q?=20contrast=20=E2=80=94=20brighter=20blue=20bg=20+=20white=20fg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/app.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 8b5362116..625cdd684 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -243,8 +243,8 @@ func (m *AppModel) viewBreadcrumb() string { Bold(true). Padding(0, 1) leafStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("69")). - Foreground(lipgloss.Color("255")). + Background(lipgloss.Color("63")). + Foreground(lipgloss.Color("231")). Bold(true). Padding(0, 1) From f3bac7c21fd6515fe3bcfdb9efe4caa2619cde46 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:10:46 -0400 Subject: [PATCH 058/117] fix(cli): dinosaur error art + bordered command bar - Unknown command error dialog uses full dinosaur ASCII art - Command/filter/prompt bar rendered with cyan border box (k9s style) Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 32 ++++++++++++---- .../cmd/acpctl/ambient/tui/model_new.go | 37 ++++++++++++++----- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 625cdd684..5ba805ed8 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -196,18 +196,36 @@ func (m *AppModel) renderHint(hint string) string { return styleDim.Render(key) + styleWhite.Render(action) } -// viewCommandBar renders the command, filter, or prompt input bar. +// viewCommandBar renders the command, filter, or prompt input bar with a border. func (m *AppModel) viewCommandBar() string { + var content string if m.promptMode { - return " " + m.promptInput.View() + content = m.promptInput.View() + } else if m.commandMode { + content = m.commandInput.View() + } else if m.filterMode { + content = m.filterInput.View() + } else { + return "" } - if m.commandMode { - return " " + m.commandInput.View() + + borderColor := lipgloss.Color("36") // cyan border like k9s + bs := lipgloss.NewStyle().Foreground(borderColor) + innerW := m.width - 4 + if innerW < 10 { + innerW = 10 } - if m.filterMode { - return " " + m.filterInput.View() + + top := bs.Render("┌" + strings.Repeat("─", innerW+2) + "┐") + contentWidth := lipgloss.Width(content) + pad := "" + if contentWidth < innerW { + pad = strings.Repeat(" ", innerW-contentWidth) } - return "" + mid := bs.Render("│") + " " + content + pad + " " + bs.Render("│") + bot := bs.Render("└" + strings.Repeat("─", innerW+2) + "┘") + + return top + "\n" + mid + "\n" + bot } // viewResourceTable renders the current resource table or view with its title bar. diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 677f8d9ae..1089dc0e1 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -2054,15 +2054,34 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { return m, m.setInfo("Commands: " + fmt.Sprintf("%d available", len(entries))) default: - ascii := " .-=-.\n" + - " / ! )\\\n" + - " (__ _/\n" + - " / _>/\n" + - " / _> \\ _\n" + - " /_/ \\ \\//\n" + - " ( |\n" + - " ) |\n" + - " \\_|" + ascii := "" + + " ,\n" + + " /|\n" + + " / |\n" + + " / /\n" + + " | |\n" + + " / |\n" + + " | \\_\n" + + " | \\__\n" + + " \\ __\\_______\n" + + " \\ \\_\n" + + " | / \\\n" + + " \\/ \\\n" + + " | |\n" + + " \\ \\|\n" + + " | \\\n" + + " \\ |\n" + + " /\\ \\_ \\\n" + + " / | \\__ ( ) \\\n" + + " / \\ / |\\\\ / __\\____\n" + + " | , | /\\ \\ \\__ | \\_\n" + + " \\_/|\\___/ \\ \\}}}\\__| (@) )\n" + + " \\)\\)\\) \\_\\---\\ \\| \\ \\\n" + + " \\>\\>\\> \\ /\\__o_o)\n" + + " | / VVVVV\n" + + " \\ \\ \\\n" + + " \\ \\MMMMM oh bugger!\n" + + " \\______/" msg := "< Ruroh? '" + input + "' not found >" d := views.NewErrorDialog("error", msg, ascii) m.dialog = &d From 0b31030a350f673c84fc83f0fec884b059a1355d Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:13:14 -0400 Subject: [PATCH 059/117] fix(cli): smaller dino art, command bar takes 3 lines in resize calc Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 34 ++++--------------- 1 file changed, 6 insertions(+), 28 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 1089dc0e1..d7d0fbb43 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -700,7 +700,7 @@ func (m *AppModel) resizeTable() { // Total chrome: ~8 lines, leaving the rest for the table. tableHeight := m.height - 8 if m.commandMode || m.filterMode || m.promptMode { - tableHeight-- // command/filter/prompt bar takes a line + tableHeight -= 3 // bordered command bar: top border + content + bottom border } if tableHeight < 1 { tableHeight = 1 @@ -2055,33 +2055,11 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { default: ascii := "" + - " ,\n" + - " /|\n" + - " / |\n" + - " / /\n" + - " | |\n" + - " / |\n" + - " | \\_\n" + - " | \\__\n" + - " \\ __\\_______\n" + - " \\ \\_\n" + - " | / \\\n" + - " \\/ \\\n" + - " | |\n" + - " \\ \\|\n" + - " | \\\n" + - " \\ |\n" + - " /\\ \\_ \\\n" + - " / | \\__ ( ) \\\n" + - " / \\ / |\\\\ / __\\____\n" + - " | , | /\\ \\ \\__ | \\_\n" + - " \\_/|\\___/ \\ \\}}}\\__| (@) )\n" + - " \\)\\)\\) \\_\\---\\ \\| \\ \\\n" + - " \\>\\>\\> \\ /\\__o_o)\n" + - " | / VVVVV\n" + - " \\ \\ \\\n" + - " \\ \\MMMMM oh bugger!\n" + - " \\______/" + " __\n" + + " / _)\n" + + " .-^^^-/ /\n" + + " __/ /\n" + + " <__.|_|-|_| oh bugger!" msg := "< Ruroh? '" + input + "' not found >" d := views.NewErrorDialog("error", msg, ascii) m.dialog = &d From acf031b757bb9592a29bbfa3b1ab256de67bca0d Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:16:01 -0400 Subject: [PATCH 060/117] fix(cli): Esc clears filter, filter shown in title bar, clean dino - Esc clears active filter before popping the view - Active filter shown in title bar: kind(scope)[count] - Remove "oh bugger!" from dino ASCII art Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 12 +++++++++++- .../cmd/acpctl/ambient/tui/views/table.go | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index d7d0fbb43..3d578965b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1171,6 +1171,14 @@ func (m *AppModel) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Global keybindings first. switch msg.Type { case tea.KeyEsc: + // If a filter is active, clear it first instead of popping the view. + if m.activeFilter != nil { + m.activeFilter = nil + if tbl := m.activeTable(); tbl != nil { + tbl.ClearFilter() + } + return m, m.setInfo("Filter cleared") + } cmd := m.popView() if cmd != nil { return m, tea.Batch(cmd, m.setInfo("Back to "+m.currentNav().Kind)) @@ -2059,7 +2067,7 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { " / _)\n" + " .-^^^-/ /\n" + " __/ /\n" + - " <__.|_|-|_| oh bugger!" + " <__.|_|-|_|" msg := "< Ruroh? '" + input + "' not found >" d := views.NewErrorDialog("error", msg, ascii) m.dialog = &d @@ -2150,6 +2158,7 @@ func (m *AppModel) applyFilterToActiveTable(f *Filter) { tbl.SetFilter(func(cols []string) bool { return f.MatchRow(cols) }) + tbl.SetFilterText(f.Raw) } } @@ -2157,6 +2166,7 @@ func (m *AppModel) applyFilterToActiveTable(f *Filter) { func (m *AppModel) clearActiveTableFilter() { if tbl := m.activeTable(); tbl != nil { tbl.ClearFilter() + tbl.SetFilterText("") } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 881fa8c18..d7545414d 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -86,6 +86,9 @@ type ResourceTable struct { // sort tracks the current column sort state. sort sortState + // filterText is shown in the title bar when a filter is active (e.g. ""). + filterText string + // rowColorFunc maps a row to its foreground color. If nil, rows use default color. rowColorFunc func(row table.Row) lipgloss.Color @@ -175,6 +178,12 @@ func (rt *ResourceTable) SetFilter(predicate func([]string) bool) { rt.applyFilterAndSort() } +// SetFilterText sets the filter text shown in the title bar (e.g. "searchterm"). +// Pass "" to clear. +func (rt *ResourceTable) SetFilterText(text string) { + rt.filterText = text +} + // ClearFilter removes any active client-side filter. func (rt *ResourceTable) ClearFilter() { rt.filterPredicate = nil @@ -418,10 +427,16 @@ func (rt *ResourceTable) renderTitleBar() string { countStyle := lipgloss.NewStyle().Foreground(rt.style.CountColor).Bold(true) count := len(rt.inner.Rows()) + filterPart := "" + if rt.filterText != "" { + filterStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + filterPart = " " + filterStyle.Render("") + } titleRendered := " " + kindStyle.Render(rt.kind) + scopeStyle.Render("("+rt.scope+")") + countStyle.Render(fmt.Sprintf("[%d]", count)) + + filterPart + " " titleVisualWidth := lipgloss.Width(titleRendered) From d1855de14bfad8139f7d9abf6d3f19a9f7523d2c Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:32:50 -0400 Subject: [PATCH 061/117] feat(cli): y hotkey shows pretty-printed JSON instead of detail view Add ResourceJSON() that serializes any resource as indented JSON lines. y on agents/sessions now shows the raw JSON data. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 8 +++----- .../cmd/acpctl/ambient/tui/views/detail.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 3d578965b..ff7237462 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1566,11 +1566,10 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { if agent == nil { return m, m.setInfo("Agent not found in cache: " + agentName) } - // Show agent detail as a describe view (closest to YAML dump). - m.detailView = views.NewDetailView("Agent: "+agentName, views.AgentDetail(*agent)) + m.detailView = views.NewDetailView("YAML: "+agentName, views.ResourceJSON(*agent)) m.detailView.SetSize(m.width, m.height-10) cmd := m.pushView("detail", agentName, agent.ID) - return m, tea.Batch(cmd, m.setInfo("Agent detail: "+agentName)) + return m, tea.Batch(cmd, m.setInfo("YAML: "+agentName)) } return m, nil } @@ -1619,7 +1618,6 @@ func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { m.resizeTable() return m, nil case "y": - // Show session detail (closest to YAML dump). row := m.sessionTable.SelectedRow() if len(row) == 0 { return m, nil @@ -1629,7 +1627,7 @@ func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { if session == nil { return m, m.setInfo("Session not found in cache: " + shortID) } - m.detailView = views.NewDetailView("Session: "+shortID, views.SessionDetail(*session)) + m.detailView = views.NewDetailView("YAML: "+shortID, views.ResourceJSON(*session)) m.detailView.SetSize(m.width, m.height-10) cmd := m.pushView("detail", shortID, session.ID) return m, tea.Batch(cmd, m.setInfo("Session detail: "+shortID)) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go index e4ff34dbb..c5bf64268 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go @@ -443,6 +443,20 @@ func formatJSON(s string) string { return string(formatted) } +// ResourceJSON converts any resource to DetailLines showing pretty-printed JSON. +// Used by the `y` (YAML) hotkey to show the raw resource data. +func ResourceJSON(resource any) []DetailLine { + data, err := json.MarshalIndent(resource, "", " ") + if err != nil { + return []DetailLine{{Key: "error", Value: err.Error()}} + } + var lines []DetailLine + for _, line := range strings.Split(string(data), "\n") { + lines = append(lines, DetailLine{Value: line}) + } + return lines +} + // --- Resource-specific detail constructors --- // ProjectDetail returns detail lines for all fields of a Project resource. From 7589b1d93b49e49f305548820eb33f0d8b0cfb08 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:38:31 -0400 Subject: [PATCH 062/117] fix(cli): y key labeled JSON instead of YAML Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index ff7237462..a84af268c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1803,7 +1803,7 @@ func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { case "sessions": resource = []views.HelpEntry{ {"d", "Describe"}, {"l", "Logs"}, {"m", "Send"}, {"n", "New"}, - {"y", "YAML"}, {"ctrl-d", "Delete"}, + {"y", "JSON"}, {"ctrl-d", "Delete"}, } navigation = []views.HelpEntry{ {"Enter", "Drill into messages"}, {"Esc", "Back to agents"}, From e7cce024ff6cba2492952946cd692c5dedf1e509 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:42:55 -0400 Subject: [PATCH 063/117] =?UTF-8?q?fix(cli):=20title=20bar=20colors=20?= =?UTF-8?q?=E2=80=94=20blue=20scope,=20white=20count,=20dim=20delimiters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope name (e.g. "foobarbizzbuzz") in blue, count number in white, parentheses and brackets in dim. Applies to all views including messages stream. Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/views/messages.go | 9 +++++---- .../ambient-cli/cmd/acpctl/ambient/tui/views/table.go | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index f9c45b42c..86b82c7b0 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -537,8 +537,9 @@ func (ms *MessageStream) View() string { borderStyle := lipgloss.NewStyle().Foreground(msgColorDim) kindStyle := lipgloss.NewStyle().Foreground(msgColorOrange).Bold(true) - scopeStyle := lipgloss.NewStyle().Foreground(msgColorDim).Bold(true) - countStyle := lipgloss.NewStyle().Foreground(msgColorDim).Bold(true) + scopeStyle := lipgloss.NewStyle().Foreground(msgColorBlue).Bold(true) + countStyle := lipgloss.NewStyle().Foreground(msgColorWhite).Bold(true) + dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) // -- k9s-style title bar: messages(agent/session)[count] -- shortID := ms.sessionID @@ -548,8 +549,8 @@ func (ms *MessageStream) View() string { scope := ms.agentName + "/" + shortID titleRendered := " " + kindStyle.Render("messages") + - scopeStyle.Render("("+scope+")") + - countStyle.Render(fmt.Sprintf("[%d]", len(ms.messages))) + + dimStyle.Render("(") + scopeStyle.Render(scope) + dimStyle.Render(")") + + dimStyle.Render("[") + countStyle.Render(fmt.Sprintf("%d", len(ms.messages))) + dimStyle.Render("]") + " " titleWidth := lipgloss.Width(titleRendered) remaining := max(ms.width-titleWidth-2, 2) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index d7545414d..76c6df2d2 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -49,8 +49,8 @@ func DefaultTableStyle() TableStyle { return TableStyle{ BorderColor: lipgloss.Color("240"), // dim for border lines TitleColor: lipgloss.Color("214"), // orange for resource kind (brand) - ScopeColor: lipgloss.Color("240"), // dim for scope (context, not emphasis) - CountColor: lipgloss.Color("240"), // dim for count (metadata) + ScopeColor: lipgloss.Color("69"), // blue for scope name (complementary) + CountColor: lipgloss.Color("255"), // white for count number DimColor: lipgloss.Color("240"), // dim HeaderColor: lipgloss.Color("255"), // white SelectedBg: lipgloss.Color("214"), // orange @@ -432,10 +432,11 @@ func (rt *ResourceTable) renderTitleBar() string { filterStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) filterPart = " " + filterStyle.Render("") } + dimStyle := lipgloss.NewStyle().Foreground(rt.style.DimColor) titleRendered := " " + kindStyle.Render(rt.kind) + - scopeStyle.Render("("+rt.scope+")") + - countStyle.Render(fmt.Sprintf("[%d]", count)) + + dimStyle.Render("(") + scopeStyle.Render(rt.scope) + dimStyle.Render(")") + + dimStyle.Render("[") + countStyle.Render(fmt.Sprintf("%d", count)) + dimStyle.Render("]") + filterPart + " " From 5d160edc607637d8a82350e7ccb4b534f735be39 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:45:02 -0400 Subject: [PATCH 064/117] feat(cli): add $EDITOR support for e key in TUI Pressing e on agents, projects, or sessions now opens the resource as JSON in the user's $EDITOR (falling back to $VISUAL, then vi). On save, the TUI diffs the edited JSON against the original and PATCHes only the changed editable fields back to the API server via tea.ExecProcess. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/client.go | 50 +++ .../cmd/acpctl/ambient/tui/model_new.go | 293 +++++++++++++++++- 2 files changed, 340 insertions(+), 3 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index fd1aaa38c..f63628828 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -116,11 +116,23 @@ type CreateProjectMsg struct { Err error } +// UpdateProjectMsg carries the result of patching a project. +type UpdateProjectMsg struct { + Project *sdktypes.Project + Err error +} + // DeleteProjectMsg carries the result of deleting a project. type DeleteProjectMsg struct { Err error } +// UpdateSessionMsg carries the result of patching a session. +type UpdateSessionMsg struct { + Session *sdktypes.Session + Err error +} + // DeleteSessionMsg carries the result of deleting a session. type DeleteSessionMsg struct { Err error @@ -567,6 +579,25 @@ func (tc *TUIClient) CreateProject(name, description string) tea.Cmd { } } +// UpdateProject returns a tea.Cmd that patches a project with the given fields. +func (tc *TUIClient) UpdateProject(projectID string, patch map[string]any) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject("_") + if err != nil { + return UpdateProjectMsg{Err: err} + } + + result, err := client.Projects().Update(ctx, projectID, patch) + if err != nil { + return UpdateProjectMsg{Err: err} + } + return UpdateProjectMsg{Project: result} + } +} + // DeleteProject returns a tea.Cmd that deletes a project by ID. func (tc *TUIClient) DeleteProject(projectID string) tea.Cmd { return func() tea.Msg { @@ -587,6 +618,25 @@ func (tc *TUIClient) DeleteProject(projectID string) tea.Cmd { // Session operations // --------------------------------------------------------------------------- +// UpdateSession returns a tea.Cmd that patches a session with the given fields. +func (tc *TUIClient) UpdateSession(projectID, sessionID string, patch map[string]any) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return UpdateSessionMsg{Err: err} + } + + result, err := client.Sessions().Update(ctx, sessionID, patch) + if err != nil { + return UpdateSessionMsg{Err: err} + } + return UpdateSessionMsg{Session: result} + } +} + // DeleteSession returns a tea.Cmd that deletes a session by ID. func (tc *TUIClient) DeleteSession(projectID, sessionID string) tea.Cmd { return func() tea.Msg { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index a84af268c..dc6b474de 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1,7 +1,10 @@ package tui import ( + "encoding/json" "fmt" + "os" + "os/exec" "sort" "strings" "time" @@ -55,6 +58,30 @@ type messagePollTickMsg struct{ t time.Time } // infoExpiredMsg signals the ephemeral info line should be cleared. type infoExpiredMsg struct{} +// editCompleteMsg is sent when the user's $EDITOR exits after editing a +// resource as JSON. The handler reads the temp file, diffs against the +// original, and PATCHes any changed fields. +type editCompleteMsg struct { + ResourceKind string // "agent", "project", "session" + ResourceID string // ID of the resource being edited + ProjectID string // project scope (for agents/sessions) + TempFile string // path to the temp file containing edited JSON + OriginalJSON []byte // original JSON before editing (for diffing) + Err error // non-nil if the editor process failed +} + +// getEditor returns the user's preferred editor command by checking $EDITOR, +// then $VISUAL, falling back to "vi". +func getEditor() string { + if e := os.Getenv("EDITOR"); e != "" { + return e + } + if e := os.Getenv("VISUAL"); e != "" { + return e + } + return "vi" +} + // --------------------------------------------------------------------------- // AppModel — the TUI model with full navigation hierarchy // --------------------------------------------------------------------------- @@ -558,6 +585,39 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(m.fetchActiveView(), m.setInfo("Session deleted")) + case UpdateAgentMsg: + if msg.Err != nil { + return m, m.setInfo("Update agent failed: " + msg.Err.Error()) + } + name := "" + if msg.Agent != nil { + name = msg.Agent.Name + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent updated: "+name)) + + case UpdateProjectMsg: + if msg.Err != nil { + return m, m.setInfo("Update project failed: " + msg.Err.Error()) + } + name := "" + if msg.Project != nil { + name = msg.Project.Name + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Project updated: "+name)) + + case UpdateSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Update session failed: " + msg.Err.Error()) + } + name := "" + if msg.Session != nil { + name = msg.Session.Name + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Session updated: "+name)) + + case editCompleteMsg: + return m.handleEditComplete(msg) + case SendMessageMsg: if msg.Err != nil { return m, m.setInfo("Send message failed: " + msg.Err.Error()) @@ -1436,6 +1496,8 @@ func (m *AppModel) handleProjectsRune(key string) (tea.Model, tea.Cmd) { m.detailView.SetSize(m.width, m.height-10) cmd := m.pushView("detail", projectName, project.ID) return m, tea.Batch(cmd, m.setInfo("Project detail: "+projectName)) + case "e": + return m.openEditorForProject() case "n": return m, m.setInfo("Use acpctl project create") } @@ -1495,7 +1557,7 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { m.setInfo("Stopping agent "+agentName+"..."), ) case "e": - return m, m.setInfo("Use acpctl agent update") + return m.openEditorForAgent() case "l": // Logs — if agent has an active session, jump to message stream. row := m.agentTable.SelectedRow() @@ -1577,6 +1639,8 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { // handleSessionsRune handles session-view-specific hotkeys. func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { switch key { + case "e": + return m.openEditorForSession() case "d": // Show detail view for the selected session. row := m.sessionTable.SelectedRow() @@ -1786,7 +1850,7 @@ func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { switch viewName { case "projects": resource = []views.HelpEntry{ - {"d", "Describe"}, {"n", "New"}, {"ctrl-d", "Delete"}, + {"d", "Describe"}, {"e", "Edit"}, {"n", "New"}, {"ctrl-d", "Delete"}, } navigation = []views.HelpEntry{ {"Enter", "Drill into agents"}, {"q", "Quit"}, @@ -1802,7 +1866,7 @@ func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { } case "sessions": resource = []views.HelpEntry{ - {"d", "Describe"}, {"l", "Logs"}, {"m", "Send"}, {"n", "New"}, + {"d", "Describe"}, {"e", "Edit"}, {"l", "Logs"}, {"m", "Send"}, {"n", "New"}, {"y", "JSON"}, {"ctrl-d", "Delete"}, } navigation = []views.HelpEntry{ @@ -2274,6 +2338,227 @@ func (m *AppModel) handleProjectShortcut(digit byte) (tea.Model, tea.Cmd) { } } +// --------------------------------------------------------------------------- +// $EDITOR integration +// --------------------------------------------------------------------------- + +// openEditorForAgent serializes the selected agent as JSON, writes it to a +// temp file, and suspends the TUI to open the user's $EDITOR. On return the +// editCompleteMsg handler diffs and PATCHes any changes. +func (m *AppModel) openEditorForAgent() (tea.Model, tea.Cmd) { + row := m.agentTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No agent selected") + } + agentName := row[0] + agent := m.findAgentByName(agentName) + if agent == nil { + return m, m.setInfo("Agent not found in cache: " + agentName) + } + if m.currentProject == "" { + return m, m.setInfo("No project context for edit") + } + + return m.openEditorForResource("agent", agent.ID, m.currentProject, *agent) +} + +// openEditorForProject serializes the selected project as JSON, writes it to a +// temp file, and suspends the TUI to open the user's $EDITOR. +func (m *AppModel) openEditorForProject() (tea.Model, tea.Cmd) { + row := m.projectTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No project selected") + } + projectName := row[0] + project := m.findProjectByName(projectName) + if project == nil { + return m, m.setInfo("Project not found in cache: " + projectName) + } + + return m.openEditorForResource("project", project.ID, "", *project) +} + +// openEditorForSession serializes the selected session as JSON, writes it to a +// temp file, and suspends the TUI to open the user's $EDITOR. +func (m *AppModel) openEditorForSession() (tea.Model, tea.Cmd) { + row := m.sessionTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No session selected") + } + shortID := row[0] + session := m.findSessionByShortID(shortID) + if session == nil { + return m, m.setInfo("Session not found in cache: " + shortID) + } + + projectID := m.currentProject + if projectID == "" { + projectID = session.ProjectID + } + if projectID == "" { + return m, m.setInfo("No project context for edit") + } + + return m.openEditorForResource("session", session.ID, projectID, *session) +} + +// openEditorForResource is the shared implementation that writes JSON to a temp +// file, opens $EDITOR via tea.ExecProcess, and returns an editCompleteMsg when +// the editor exits. +func (m *AppModel) openEditorForResource(kind, resourceID, projectID string, resource any) (tea.Model, tea.Cmd) { + originalJSON, err := json.MarshalIndent(resource, "", " ") + if err != nil { + return m, m.setInfo("Failed to serialize " + kind + ": " + err.Error()) + } + + tmpFile, err := os.CreateTemp("", "acpctl-edit-*.json") + if err != nil { + return m, m.setInfo("Failed to create temp file: " + err.Error()) + } + + if err := os.Chmod(tmpFile.Name(), 0600); err != nil { + os.Remove(tmpFile.Name()) + return m, m.setInfo("Failed to set temp file permissions: " + err.Error()) + } + + if _, err := tmpFile.Write(originalJSON); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return m, m.setInfo("Failed to write temp file: " + err.Error()) + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpFile.Name()) + return m, m.setInfo("Failed to close temp file: " + err.Error()) + } + + editor := getEditor() + tmpPath := tmpFile.Name() + origCopy := make([]byte, len(originalJSON)) + copy(origCopy, originalJSON) + + c := exec.Command(editor, tmpPath) //nolint:gosec // editor is from user's env + return m, tea.ExecProcess(c, func(err error) tea.Msg { + return editCompleteMsg{ + ResourceKind: kind, + ResourceID: resourceID, + ProjectID: projectID, + TempFile: tmpPath, + OriginalJSON: origCopy, + Err: err, + } + }) +} + +// handleEditComplete processes the editCompleteMsg after the editor exits. +// It reads the edited JSON, diffs against the original, builds a patch map +// with only changed fields, and calls the appropriate update method. +func (m *AppModel) handleEditComplete(msg editCompleteMsg) (tea.Model, tea.Cmd) { + // Always clean up the temp file. + defer os.Remove(msg.TempFile) + + if msg.Err != nil { + return m, m.setInfo("Editor exited with error: " + msg.Err.Error()) + } + + // Read the edited file. + editedJSON, err := os.ReadFile(msg.TempFile) + if err != nil { + return m, m.setInfo("Failed to read edited file: " + err.Error()) + } + + // Parse both original and edited JSON into maps for diffing. + var original map[string]any + if err := json.Unmarshal(msg.OriginalJSON, &original); err != nil { + return m, m.setInfo("Failed to parse original JSON: " + err.Error()) + } + var edited map[string]any + if err := json.Unmarshal(editedJSON, &edited); err != nil { + return m, m.setInfo("Failed to parse edited JSON: " + err.Error()) + } + + // Determine which fields are editable based on resource kind. + var editableFields []string + switch msg.ResourceKind { + case "agent": + editableFields = []string{ + "name", "prompt", "labels", "annotations", "description", + "display_name", "llm_model", "llm_max_tokens", "llm_temperature", + "repo_url", "environment_variables", "resource_overrides", + } + case "project": + editableFields = []string{ + "name", "description", "display_name", "labels", "annotations", + "prompt", "status", + } + case "session": + editableFields = []string{ + "name", "prompt", "labels", "annotations", + "llm_model", "llm_max_tokens", "llm_temperature", + "repo_url", "repos", "resource_overrides", "timeout", + "environment_variables", + } + } + + // Build patch with only changed editable fields. + patch := make(map[string]any) + for _, field := range editableFields { + origVal, origOK := original[field] + editVal, editOK := edited[field] + + // Field was added in the edit. + if !origOK && editOK { + patch[field] = editVal + continue + } + // Field was removed in the edit. + if origOK && !editOK { + // Send zero value to clear the field. + patch[field] = nil + continue + } + // Both present — compare serialized forms for robustness. + if origOK && editOK { + origSer, _ := json.Marshal(origVal) + editSer, _ := json.Marshal(editVal) + if string(origSer) != string(editSer) { + patch[field] = editVal + } + } + } + + if len(patch) == 0 { + return m, m.setInfo("No changes detected") + } + + // Build a summary of changed fields. + var changedFields []string + for k := range patch { + changedFields = append(changedFields, k) + } + sort.Strings(changedFields) + summary := strings.Join(changedFields, ", ") + + switch msg.ResourceKind { + case "agent": + return m, tea.Batch( + m.client.UpdateAgent(msg.ProjectID, msg.ResourceID, patch), + m.setInfo("Updating agent ("+summary+")..."), + ) + case "project": + return m, tea.Batch( + m.client.UpdateProject(msg.ResourceID, patch), + m.setInfo("Updating project ("+summary+")..."), + ) + case "session": + return m, tea.Batch( + m.client.UpdateSession(msg.ProjectID, msg.ResourceID, patch), + m.setInfo("Updating session ("+summary+")..."), + ) + default: + return m, m.setInfo("Unknown resource kind: " + msg.ResourceKind) + } +} + // --------------------------------------------------------------------------- // Contextual hotkey hints for the header // --------------------------------------------------------------------------- @@ -2284,6 +2569,7 @@ func (m *AppModel) contextualHints() []string { case "projects": return []string{ " Describe", + " Edit", " New", " Delete", } @@ -2301,6 +2587,7 @@ func (m *AppModel) contextualHints() []string { case "sessions": return []string{ " Describe", + " Edit", " Logs", " Send", " New", From ff175950a1139d55c767d3a6e8ed9b3295519b08 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:49:48 -0400 Subject: [PATCH 065/117] fix(cli): editor reopens with error comment on parse failure - Comment header added to temp file explaining // syntax - // lines stripped before JSON parsing - On parse error, error prepended as // comment and editor reopens - Empty file aborts the edit - Temp file cleaned up on success, preserved for reopens Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 52 +++++++++++++++++-- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index dc6b474de..03175b9f9 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -2421,6 +2421,14 @@ func (m *AppModel) openEditorForResource(kind, resourceID, projectID string, res return m, m.setInfo("Failed to set temp file permissions: " + err.Error()) } + header := "// Edit the JSON below. Lines starting with // are stripped before parsing.\n" + + "// Save and quit to apply changes. Empty file aborts the edit.\n" + + "//\n" + if _, err := tmpFile.WriteString(header); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + return m, m.setInfo("Failed to write temp file: " + err.Error()) + } if _, err := tmpFile.Write(originalJSON); err != nil { tmpFile.Close() os.Remove(tmpFile.Name()) @@ -2453,10 +2461,8 @@ func (m *AppModel) openEditorForResource(kind, resourceID, projectID string, res // It reads the edited JSON, diffs against the original, builds a patch map // with only changed fields, and calls the appropriate update method. func (m *AppModel) handleEditComplete(msg editCompleteMsg) (tea.Model, tea.Cmd) { - // Always clean up the temp file. - defer os.Remove(msg.TempFile) - if msg.Err != nil { + os.Remove(msg.TempFile) return m, m.setInfo("Editor exited with error: " + msg.Err.Error()) } @@ -2466,14 +2472,36 @@ func (m *AppModel) handleEditComplete(msg editCompleteMsg) (tea.Model, tea.Cmd) return m, m.setInfo("Failed to read edited file: " + err.Error()) } + // Strip comment lines (// ...) before parsing. + strippedJSON := stripJSONComments(string(editedJSON)) + + // Empty file = abort. + if strings.TrimSpace(strippedJSON) == "" { + return m, m.setInfo("Edit aborted (empty file)") + } + // Parse both original and edited JSON into maps for diffing. var original map[string]any if err := json.Unmarshal(msg.OriginalJSON, &original); err != nil { return m, m.setInfo("Failed to parse original JSON: " + err.Error()) } var edited map[string]any - if err := json.Unmarshal(editedJSON, &edited); err != nil { - return m, m.setInfo("Failed to parse edited JSON: " + err.Error()) + if err := json.Unmarshal([]byte(strippedJSON), &edited); err != nil { + // Reopen the editor with the error as a comment at the top. + errorHeader := fmt.Sprintf("// ERROR: %s\n// Fix the JSON below and save again. Empty file aborts.\n//\n", err.Error()) + _ = os.WriteFile(msg.TempFile, []byte(errorHeader+string(editedJSON)), 0600) + editor := getEditor() + c := exec.Command(editor, msg.TempFile) //nolint:gosec + return m, tea.ExecProcess(c, func(editorErr error) tea.Msg { + return editCompleteMsg{ + ResourceKind: msg.ResourceKind, + ResourceID: msg.ResourceID, + ProjectID: msg.ProjectID, + TempFile: msg.TempFile, + OriginalJSON: msg.OriginalJSON, + Err: editorErr, + } + }) } // Determine which fields are editable based on resource kind. @@ -2527,8 +2555,10 @@ func (m *AppModel) handleEditComplete(msg editCompleteMsg) (tea.Model, tea.Cmd) } if len(patch) == 0 { + os.Remove(msg.TempFile) return m, m.setInfo("No changes detected") } + os.Remove(msg.TempFile) // Build a summary of changed fields. var changedFields []string @@ -2559,6 +2589,18 @@ func (m *AppModel) handleEditComplete(msg editCompleteMsg) (tea.Model, tea.Cmd) } } +// stripJSONComments removes lines starting with // from the input. +func stripJSONComments(s string) string { + var lines []string + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "//") { + lines = append(lines, line) + } + } + return strings.Join(lines, "\n") +} + // --------------------------------------------------------------------------- // Contextual hotkey hints for the header // --------------------------------------------------------------------------- From ec292e4294996c9e544111424bc18a06b90931d7 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:51:00 -0400 Subject: [PATCH 066/117] =?UTF-8?q?fix(cli):=20editor=20header=20matches?= =?UTF-8?q?=20k9s=20style=20=E2=80=94=20mentions=20error=20reopening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 03175b9f9..c8742bc6f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -2421,8 +2421,9 @@ func (m *AppModel) openEditorForResource(kind, resourceID, projectID string, res return m, m.setInfo("Failed to set temp file permissions: " + err.Error()) } - header := "// Edit the JSON below. Lines starting with // are stripped before parsing.\n" + - "// Save and quit to apply changes. Empty file aborts the edit.\n" + + header := "// Please edit the object below. Lines beginning with '//' will be ignored,\n" + + "// and an empty file will abort the edit. If an error occurs while saving,\n" + + "// this file will be reopened with the relevant failures.\n" + "//\n" if _, err := tmpFile.WriteString(header); err != nil { tmpFile.Close() From 5a0d3e2d2b92d34e6fd7281a748962d52b392a00 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:53:28 -0400 Subject: [PATCH 067/117] fix(cli): resolve project ID from cached session when sending messages Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index c8742bc6f..b82f06243 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -517,8 +517,18 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Body == "" { return m, nil } + projectID := m.currentProject + if projectID == "" { + // Resolve from cached session data. + if s := m.findSessionByShortID(m.currentSession); s != nil { + projectID = s.ProjectID + } + } + if projectID == "" { + return m, m.setInfo("Cannot send: no project context") + } return m, tea.Batch( - m.client.SendSessionMessage(m.currentProject, m.currentSession, msg.Body), + m.client.SendSessionMessage(projectID, m.currentSession, msg.Body), m.setInfo("Sending message..."), ) From c7471d712e5a5b36e44abd069a9198dd3e3dfc60 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 10:59:58 -0400 Subject: [PATCH 068/117] fix(cli): resolve project ID from session for SSE + polling + send All message operations (SSE watcher, polling, send) now fall back to session.ProjectID when currentProject is empty. Fixes messages not loading when entering from global sessions view. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index b82f06243..f2b9bb830 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -729,10 +729,18 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Schedule next poll tick and fetch messages. var cmds []tea.Cmd cmds = append(cmds, m.messagePollTickCmd()) - if m.currentProject != "" && m.currentSession != "" { - cmds = append(cmds, m.client.FetchSessionMessages( - m.currentProject, m.currentSession, m.lastMessageSeq, - )) + if m.currentSession != "" { + projectID := m.currentProject + if projectID == "" { + if s := m.findSessionByShortID(m.currentSession); s != nil { + projectID = s.ProjectID + } + } + if projectID != "" { + cmds = append(cmds, m.client.FetchSessionMessages( + projectID, m.currentSession, m.lastMessageSeq, + )) + } } return m, tea.Batch(cmds...) @@ -1355,19 +1363,22 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { m.setInfo("Streaming messages for session " + shortID), } - // Start SSE watcher if we have a program reference and project context. - if m.program != nil && m.currentProject != "" { - cmds = append(cmds, m.client.WatchSessionMessages(m.currentProject, fullSessionID, 0, m.program)) + // Resolve project ID — may be empty if reached from global sessions. + projectID := m.currentProject + if projectID == "" && session != nil { + projectID = session.ProjectID + } + + // Start SSE watcher if we have a program reference. + if m.program != nil && projectID != "" { + cmds = append(cmds, m.client.WatchSessionMessages(projectID, fullSessionID, 0, m.program)) } - // Always start polling fallback alongside SSE. Polling is - // idempotent (deduplicates by seq) and ensures messages appear - // even if SSE fails silently. - if m.currentProject != "" { + // Always start polling fallback alongside SSE. + if projectID != "" { m.messagePollActive = true cmds = append(cmds, m.messagePollTickCmd()) - // Immediately fetch existing messages so the view is not empty. - cmds = append(cmds, m.client.FetchSessionMessages(m.currentProject, fullSessionID, 0)) + cmds = append(cmds, m.client.FetchSessionMessages(projectID, fullSessionID, 0)) } return m, tea.Batch(cmds...) From 577038ed9edd9bd27b2403aa04f399c46638ad2a Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:06:25 -0400 Subject: [PATCH 069/117] =?UTF-8?q?feat(cli):=20message=20view=20=E2=80=94?= =?UTF-8?q?=20timestamps,=20wrap,=20glamour,=20compose=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - t key toggles timestamps (off → relative → absolute) - w key toggles full text / truncated messages - Assistant messages rendered in blue, user in white - Glamour markdown rendering for assistant messages in wrap mode - Compose mode blocks global shortcuts (: ? q now type into input) - Compose mode auto-scrolls to bottom so messages stay visible Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 13 +- .../cmd/acpctl/ambient/tui/views/messages.go | 202 ++++++++++++++++-- components/ambient-cli/go.mod | 16 +- components/ambient-cli/go.sum | 51 +++-- 4 files changed, 245 insertions(+), 37 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index f2b9bb830..9430a018b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1829,6 +1829,14 @@ func (m *AppModel) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // handleMessagesKey delegates key events to the message stream sub-model. func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // When compose mode is active, ALL keys go to the message stream — + // don't intercept :, ?, q etc. as they're meant to be typed. + if m.messageStream.IsComposeMode() { + var cmd tea.Cmd + m.messageStream, cmd = m.messageStream.Update(msg) + return m, cmd + } + // Intercept global keys before delegating to the message stream. if msg.Type == tea.KeyRunes { switch string(msg.Runes) { @@ -1903,8 +1911,8 @@ func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { } case "messages": resource = []views.HelpEntry{ - {"s", "Autoscroll"}, {"r", "Raw Mode"}, {"m", "Send"}, - {"c", "Copy"}, {"shift-g", "Bottom"}, {"g", "Top"}, + {"s", "Autoscroll"}, {"r", "Raw Mode"}, {"w", "Wrap"}, {"t", "Timestamps"}, + {"m", "Send"}, {"c", "Copy"}, {"shift-g", "Bottom"}, {"g", "Top"}, } general = []views.HelpEntry{ {":", "Command"}, {"?", "Help"}, @@ -2668,6 +2676,7 @@ func (m *AppModel) contextualHints() []string { return []string{ " Autoscroll", " Raw", + " Wrap", " Send", " Copy", } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 86b82c7b0..17f73344f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -10,6 +10,7 @@ import ( "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" ) @@ -47,17 +48,17 @@ var ( func eventColor(eventType string) lipgloss.Color { switch eventType { case "user": - return msgColorWhite + return msgColorWhite // 255 case "assistant": - return msgColorWhite + return msgColorBlue // 69 — complementary accent case "tool_use": - return msgColorDim + return msgColorDim // 240 case "tool_result": - return msgColorDim + return msgColorDim // 240 case "system": - return msgColorYellow + return msgColorYellow // 33 case "error": - return msgColorRed + return msgColorRed // 31 default: return msgColorDim } @@ -155,6 +156,51 @@ func eventSummary(eventType, payload string) string { return "" } +// eventFullText produces the full untruncated display string for a message entry. +// Used when wrapMode is enabled to show complete message payloads. +func eventFullText(eventType, payload string) string { + switch eventType { + case "user": + return strings.TrimSpace(payload) + case "assistant": + return strings.TrimSpace(payload) + case "tool_use": + name := extractJSONField(payload, "name") + if name == "" { + return strings.TrimSpace(payload) + } + input := extractJSONField(payload, "input") + if input != "" { + return name + " " + strings.TrimSpace(input) + } + return name + case "tool_result": + content := extractJSONField(payload, "content") + isError := extractJSONField(payload, "is_error") + indicator := "✓" + if isError == "true" { + indicator = "✗" + } + if content != "" { + return fmt.Sprintf("%s %s", indicator, strings.TrimSpace(content)) + } + return fmt.Sprintf("%s %d bytes", indicator, len(content)) + case "system": + return strings.TrimSpace(payload) + case "error": + msg := extractJSONField(payload, "message") + if msg != "" { + return "✗ " + strings.TrimSpace(msg) + } + if payload != "" { + return "✗ " + strings.TrimSpace(payload) + } + return "✗ unknown error" + } + // Fallback: same as eventSummary for streaming event types. + return eventSummary(eventType, payload) +} + // truncatePayload trims whitespace and truncates to max length. func truncatePayload(s string, max int) string { s = strings.TrimSpace(s) @@ -234,9 +280,15 @@ type MessageStream struct { maxMessages int // Display - scrollOffset int - autoScroll bool // default true — view follows new messages - rawMode bool // false=conversation, true=raw JSON + scrollOffset int + autoScroll bool // default true — view follows new messages + rawMode bool // false=conversation, true=raw JSON + wrapMode bool // false=truncated (120 chars), true=full text with word wrap + timestampMode int // 0=off, 1=relative, 2=absolute + + // Glamour markdown renderer (created lazily on first use, cached). + glamourRenderer *glamour.TermRenderer + glamourWidth int // width used to create the cached renderer // Compose composeMode bool @@ -320,6 +372,11 @@ func (ms *MessageStream) SetSSEStatus(status string) { } // ComposeValue returns the current text in the compose input. +// IsComposeMode returns true when the compose input is active. +func (ms MessageStream) IsComposeMode() bool { + return ms.composeMode +} + func (ms MessageStream) ComposeValue() string { return ms.composeInput.Value() } @@ -415,6 +472,12 @@ func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { case "r": ms.rawMode = !ms.rawMode return *ms, nil + case "w": + ms.wrapMode = !ms.wrapMode + return *ms, nil + case "t": + ms.timestampMode = (ms.timestampMode + 1) % 3 + return *ms, nil case "s": ms.autoScroll = !ms.autoScroll if ms.autoScroll { @@ -569,11 +632,24 @@ func (ms *MessageStream) View() string { if ms.rawMode { modeLabel = "Raw" } + wrapLabel := "Off" + if ms.wrapMode { + wrapLabel = "On" + } phaseStyle := lipgloss.NewStyle().Foreground(phaseColor(ms.phase)) dimIndicator := lipgloss.NewStyle().Foreground(msgColorDim) - indicators := fmt.Sprintf("Autoscroll:%s Mode:%s Phase:%s", + tsLabel := "Off" + switch ms.timestampMode { + case 1: + tsLabel = "Relative" + case 2: + tsLabel = "Absolute" + } + indicators := fmt.Sprintf("Autoscroll:%s Mode:%s Wrap:%s Time:%s Phase:%s", dimIndicator.Render(autoScrollLabel), dimIndicator.Render(modeLabel), + dimIndicator.Render(wrapLabel), + dimIndicator.Render(tsLabel), phaseStyle.Render(ms.phase), ) if ms.sseStatus != "" && ms.sseStatus != "connected" { @@ -705,17 +781,81 @@ func (ms *MessageStream) buildDisplayLines() []string { lines := make([]string, 0, len(ms.messages)) + now := time.Now() for _, entry := range ms.messages { + var entryLines []string if ms.rawMode { - lines = append(lines, ms.renderRawEntry(entry, maxLineWidth)...) + entryLines = ms.renderRawEntry(entry, maxLineWidth) } else { - lines = append(lines, ms.renderConversationEntry(entry, maxLineWidth)...) + entryLines = ms.renderConversationEntry(entry, maxLineWidth) } + // Prepend timestamp to the first line if timestamps are enabled. + if ms.timestampMode > 0 && len(entryLines) > 0 && !entry.Timestamp.IsZero() { + tsStyle := lipgloss.NewStyle().Foreground(msgColorDim) + var ts string + if ms.timestampMode == 1 { + d := now.Sub(entry.Timestamp) + if d < time.Minute { + ts = fmt.Sprintf("%ds", int(d.Seconds())) + } else if d < time.Hour { + ts = fmt.Sprintf("%dm", int(d.Minutes())) + } else if d < 24*time.Hour { + ts = fmt.Sprintf("%dh", int(d.Hours())) + } else { + ts = fmt.Sprintf("%dd", int(d.Hours()/24)) + } + } else { + ts = entry.Timestamp.Format("15:04:05") + } + entryLines[0] = tsStyle.Render(fmt.Sprintf("%-8s", ts)) + entryLines[0] + } + lines = append(lines, entryLines...) } return lines } +// looksLikeMarkdown returns true if the text appears to contain markdown formatting. +func looksLikeMarkdown(s string) bool { + // Check for common markdown indicators: headings, bold/italic, code blocks, + // inline code, list items. + if strings.Contains(s, "```") { + return true + } + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "# ") || strings.HasPrefix(trimmed, "## ") || + strings.HasPrefix(trimmed, "### ") { + return true + } + if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") { + return true + } + } + if strings.Contains(s, "**") || strings.Contains(s, "`") { + return true + } + return false +} + +// getGlamourRenderer returns a cached glamour renderer, creating one lazily on +// first use. If the terminal width has changed, the renderer is recreated. +func (ms *MessageStream) getGlamourRenderer(wrapWidth int) *glamour.TermRenderer { + if ms.glamourRenderer != nil && ms.glamourWidth == wrapWidth { + return ms.glamourRenderer + } + r, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(wrapWidth), + ) + if err != nil { + return nil + } + ms.glamourRenderer = r + ms.glamourWidth = wrapWidth + return r +} + // renderConversationEntry renders a single message in conversation mode. // Format: [event_type] summary text (wrapped) func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth int) []string { @@ -723,8 +863,14 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true) textStyle := lipgloss.NewStyle().Foreground(color) - summary := eventSummary(entry.EventType, entry.Payload) - if summary == "" { + // Choose full text or truncated summary based on wrapMode. + var displayText string + if ms.wrapMode { + displayText = eventFullText(entry.EventType, entry.Payload) + } else { + displayText = eventSummary(entry.EventType, entry.Payload) + } + if displayText == "" { // Suppressed event types (TOOL_CALL_ARGS, etc.) — don't render. return nil } @@ -735,10 +881,32 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in // Indent continuation lines to align with the text after the tag. indent := strings.Repeat(" ", tagWidth+2) - // Wrap the summary text. + // Available width for text content after the tag. availWidth := max(maxWidth-tagWidth-2, 10) // 2 for spacing between tag and text - wrapped := wrapText(summary, availWidth) + // For assistant messages, try glamour markdown rendering. + if entry.EventType == "assistant" && ms.wrapMode && looksLikeMarkdown(entry.Payload) { + glamourWidth := max(ms.width-20, 20) + if r := ms.getGlamourRenderer(glamourWidth); r != nil { + rendered, err := r.Render(strings.TrimSpace(entry.Payload)) + if err == nil && strings.TrimSpace(rendered) != "" { + // Split glamour output into lines and prefix with the tag. + glamourLines := strings.Split(strings.TrimRight(rendered, "\n"), "\n") + result := make([]string, 0, len(glamourLines)) + for i, line := range glamourLines { + if i == 0 { + result = append(result, tag+" "+line) + } else { + result = append(result, indent+line) + } + } + return result + } + } + // Glamour failed — fall through to plain text rendering. + } + + wrapped := wrapText(displayText, availWidth) if len(wrapped) == 0 { return []string{tag} } @@ -831,6 +999,8 @@ func (ms *MessageStream) contentHeight() int { func (ms *MessageStream) enterComposeMode() { ms.composeMode = true ms.composeInput.Focus() + ms.scrollToBottom() + ms.autoScroll = true } // --------------------------------------------------------------------------- diff --git a/components/ambient-cli/go.mod b/components/ambient-cli/go.mod index 9b8746ff3..52c9e4a09 100644 --- a/components/ambient-cli/go.mod +++ b/components/ambient-cli/go.mod @@ -6,8 +6,11 @@ toolchain go1.24.4 require ( github.com/ambient-code/platform/components/ambient-sdk/go-sdk v0.0.0 + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 @@ -16,28 +19,35 @@ require ( ) require ( + github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/ambient-code/platform/components/ambient-api-server v0.0.0-20260304211549-047314a7664b // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/components/ambient-cli/go.sum b/components/ambient-cli/go.sum index 8be569574..e8b33da33 100644 --- a/components/ambient-cli/go.sum +++ b/components/ambient-cli/go.sum @@ -1,31 +1,39 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= +github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/ambient-code/platform/components/ambient-api-server v0.0.0-20260304211549-047314a7664b h1:nmYJWbkCDU+NiZUQT/kdpW6WUTlDrNstWXr0JOFBR4c= github.com/ambient-code/platform/components/ambient-api-server v0.0.0-20260304211549-047314a7664b/go.mod h1:r4ZByb4gVckDNzRU/EdyFY+UwSKn6M+lv04Z4YvOPNQ= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= @@ -35,6 +43,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -49,26 +59,32 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -79,6 +95,10 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= @@ -91,9 +111,8 @@ go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4A go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From f31d2e1c43818dba5c9cf3a9b9ed0b22973a6a18 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:08:30 -0400 Subject: [PATCH 070/117] fix(cli): pad message type tags to fixed width for aligned text All event type tags padded to 14 chars so message text always starts at the same column regardless of type length. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/messages.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 17f73344f..5dd5baa0f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -875,14 +875,16 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in return nil } - tag := typeStyle.Render("[" + entry.EventType + "]") - tagWidth := lipgloss.Width(tag) + // Pad all tags to a fixed width so text always starts at the same column. + const tagPadWidth = 14 // widest is [tool_result] = 13 chars + 1 padding + rawTag := "[" + entry.EventType + "]" + padded := rawTag + strings.Repeat(" ", max(tagPadWidth-len(rawTag), 1)) + tag := typeStyle.Render(padded) + tagWidth := tagPadWidth - // Indent continuation lines to align with the text after the tag. - indent := strings.Repeat(" ", tagWidth+2) + indent := strings.Repeat(" ", tagWidth) - // Available width for text content after the tag. - availWidth := max(maxWidth-tagWidth-2, 10) // 2 for spacing between tag and text + availWidth := max(maxWidth-tagWidth, 10) // For assistant messages, try glamour markdown rendering. if entry.EventType == "assistant" && ms.wrapMode && looksLikeMarkdown(entry.Payload) { From 1f45fc4859072a5dc477537db20c4ed033002a07 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:10:42 -0400 Subject: [PATCH 071/117] =?UTF-8?q?fix(cli):=20rename=20Wrap=E2=86=92Trunc?= =?UTF-8?q?ate,=20Mode=E2=86=92Raw,=20add=20all=20shortcuts=20to=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap renamed to Truncate (inverted logic: on = messages truncated) - Mode:Conversation/Raw renamed to Raw:On/Off - Messages header hints now show all shortcuts: s/r/w/t/m/c/G/g Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 7 +++++-- .../cmd/acpctl/ambient/tui/views/messages.go | 14 +++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 9430a018b..04f9cd2cd 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1911,7 +1911,7 @@ func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { } case "messages": resource = []views.HelpEntry{ - {"s", "Autoscroll"}, {"r", "Raw Mode"}, {"w", "Wrap"}, {"t", "Timestamps"}, + {"s", "Autoscroll"}, {"r", "Raw Mode"}, {"w", "Truncate"}, {"t", "Timestamps"}, {"m", "Send"}, {"c", "Copy"}, {"shift-g", "Bottom"}, {"g", "Top"}, } general = []views.HelpEntry{ @@ -2676,9 +2676,12 @@ func (m *AppModel) contextualHints() []string { return []string{ " Autoscroll", " Raw", - " Wrap", + " Truncate", + " Timestamps", " Send", " Copy", + " Bottom", + " Top", } case "contexts": return []string{ diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 5dd5baa0f..028c89dce 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -628,13 +628,13 @@ func (ms *MessageStream) View() string { if ms.autoScroll { autoScrollLabel = "On" } - modeLabel := "Conversation" + rawLabel := "Off" if ms.rawMode { - modeLabel = "Raw" + rawLabel = "On" } - wrapLabel := "Off" + truncLabel := "On" if ms.wrapMode { - wrapLabel = "On" + truncLabel = "Off" } phaseStyle := lipgloss.NewStyle().Foreground(phaseColor(ms.phase)) dimIndicator := lipgloss.NewStyle().Foreground(msgColorDim) @@ -645,10 +645,10 @@ func (ms *MessageStream) View() string { case 2: tsLabel = "Absolute" } - indicators := fmt.Sprintf("Autoscroll:%s Mode:%s Wrap:%s Time:%s Phase:%s", + indicators := fmt.Sprintf("Autoscroll:%s Raw:%s Truncate:%s Time:%s Phase:%s", dimIndicator.Render(autoScrollLabel), - dimIndicator.Render(modeLabel), - dimIndicator.Render(wrapLabel), + dimIndicator.Render(rawLabel), + dimIndicator.Render(truncLabel), dimIndicator.Render(tsLabel), phaseStyle.Render(ms.phase), ) From 525ccf992bcf0d6aa760c110b837c222e97ef3bf Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:13:31 -0400 Subject: [PATCH 072/117] fix(cli): rename to p/Pretty, show all shortcuts in header (up to 4 rows) - w/Truncate renamed to p/Pretty (on = full text + markdown) - Header hints now spread across up to 4 rows to fit all shortcuts - Status line shows Pretty:On/Off instead of Truncate Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 26 ++++++++++++------- .../cmd/acpctl/ambient/tui/model_new.go | 4 +-- .../cmd/acpctl/ambient/tui/views/messages.go | 10 +++---- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 5ba805ed8..9f788d59f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -93,20 +93,28 @@ func (m *AppModel) viewHeader() string { } } - // Col 3: contextual hotkey hints (two rows). + // Col 3: contextual hotkey hints (up to 4 rows, ~4 per row). var col3 [5]string hints := m.contextualHints() - split := (len(hints) + 1) / 2 - var row1, row2 []string + perRow := 4 + if len(hints) <= 8 { + perRow = (len(hints) + 3) / 4 // spread across 4 rows + if perRow < 2 { + perRow = 2 + } + } + rowIdx := 0 + var currentRow []string for i, h := range hints { - if i < split { - row1 = append(row1, m.renderHint(h)) - } else { - row2 = append(row2, m.renderHint(h)) + currentRow = append(currentRow, m.renderHint(h)) + if (i+1)%perRow == 0 || i == len(hints)-1 { + if rowIdx < 5 { + col3[rowIdx] = strings.Join(currentRow, " ") + } + currentRow = nil + rowIdx++ } } - col3[0] = strings.Join(row1, " ") - col3[1] = strings.Join(row2, " ") // Col 4: static hints + logo + refresh. var col4 [5]string diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 04f9cd2cd..509971a29 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1911,7 +1911,7 @@ func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { } case "messages": resource = []views.HelpEntry{ - {"s", "Autoscroll"}, {"r", "Raw Mode"}, {"w", "Truncate"}, {"t", "Timestamps"}, + {"s", "Autoscroll"}, {"r", "Raw Mode"}, {"p", "Pretty"}, {"t", "Timestamps"}, {"m", "Send"}, {"c", "Copy"}, {"shift-g", "Bottom"}, {"g", "Top"}, } general = []views.HelpEntry{ @@ -2676,7 +2676,7 @@ func (m *AppModel) contextualHints() []string { return []string{ " Autoscroll", " Raw", - " Truncate", + "

Pretty", " Timestamps", " Send", " Copy", diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 028c89dce..a1f5a9e31 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -472,7 +472,7 @@ func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { case "r": ms.rawMode = !ms.rawMode return *ms, nil - case "w": + case "p": ms.wrapMode = !ms.wrapMode return *ms, nil case "t": @@ -632,9 +632,9 @@ func (ms *MessageStream) View() string { if ms.rawMode { rawLabel = "On" } - truncLabel := "On" + prettyLabel := "Off" if ms.wrapMode { - truncLabel = "Off" + prettyLabel = "On" } phaseStyle := lipgloss.NewStyle().Foreground(phaseColor(ms.phase)) dimIndicator := lipgloss.NewStyle().Foreground(msgColorDim) @@ -645,10 +645,10 @@ func (ms *MessageStream) View() string { case 2: tsLabel = "Absolute" } - indicators := fmt.Sprintf("Autoscroll:%s Raw:%s Truncate:%s Time:%s Phase:%s", + indicators := fmt.Sprintf("Autoscroll:%s Raw:%s Pretty:%s Time:%s Phase:%s", dimIndicator.Render(autoScrollLabel), dimIndicator.Render(rawLabel), - dimIndicator.Render(truncLabel), + dimIndicator.Render(prettyLabel), dimIndicator.Render(tsLabel), phaseStyle.Render(ms.phase), ) From 30bcf766a15134d09d348ac19bdc13204bdd2cbb Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:15:24 -0400 Subject: [PATCH 073/117] fix(cli): detect markdown tables in looksLikeMarkdown Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/views/messages.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index a1f5a9e31..2254e5f8b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -835,6 +835,10 @@ func looksLikeMarkdown(s string) bool { if strings.Contains(s, "**") || strings.Contains(s, "`") { return true } + // Detect markdown tables: lines with | separators and a --- row. + if strings.Contains(s, "|") && strings.Contains(s, "---") { + return true + } return false } From 2ffcbd3601c356089c8703ed9ede7fdeef21b403 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:16:27 -0400 Subject: [PATCH 074/117] fix(cli): always render through glamour in pretty mode, remove looksLikeMarkdown Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/messages.go | 31 ++----------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 2254e5f8b..f291b8851 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -815,33 +815,6 @@ func (ms *MessageStream) buildDisplayLines() []string { return lines } -// looksLikeMarkdown returns true if the text appears to contain markdown formatting. -func looksLikeMarkdown(s string) bool { - // Check for common markdown indicators: headings, bold/italic, code blocks, - // inline code, list items. - if strings.Contains(s, "```") { - return true - } - for _, line := range strings.Split(s, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "# ") || strings.HasPrefix(trimmed, "## ") || - strings.HasPrefix(trimmed, "### ") { - return true - } - if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") { - return true - } - } - if strings.Contains(s, "**") || strings.Contains(s, "`") { - return true - } - // Detect markdown tables: lines with | separators and a --- row. - if strings.Contains(s, "|") && strings.Contains(s, "---") { - return true - } - return false -} - // getGlamourRenderer returns a cached glamour renderer, creating one lazily on // first use. If the terminal width has changed, the renderer is recreated. func (ms *MessageStream) getGlamourRenderer(wrapWidth int) *glamour.TermRenderer { @@ -890,8 +863,8 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in availWidth := max(maxWidth-tagWidth, 10) - // For assistant messages, try glamour markdown rendering. - if entry.EventType == "assistant" && ms.wrapMode && looksLikeMarkdown(entry.Payload) { + // In pretty mode, render all messages through glamour for markdown support. + if ms.wrapMode { glamourWidth := max(ms.width-20, 20) if r := ms.getGlamourRenderer(glamourWidth); r != nil { rendered, err := r.Render(strings.TrimSpace(entry.Payload)) From 39c6a2fea95c50a1a8e32214f6b720c2a7053260 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:18:49 -0400 Subject: [PATCH 075/117] fix(cli): strip leading/trailing blank lines from glamour output Glamour adds newlines around rendered content. TrimSpace before splitting prevents extra blank lines between messages in pretty mode. Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/views/messages.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index f291b8851..94ac5710f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -869,12 +869,12 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in if r := ms.getGlamourRenderer(glamourWidth); r != nil { rendered, err := r.Render(strings.TrimSpace(entry.Payload)) if err == nil && strings.TrimSpace(rendered) != "" { - // Split glamour output into lines and prefix with the tag. - glamourLines := strings.Split(strings.TrimRight(rendered, "\n"), "\n") + // Split glamour output into lines, strip leading/trailing blanks, prefix with tag. + glamourLines := strings.Split(strings.TrimSpace(rendered), "\n") result := make([]string, 0, len(glamourLines)) for i, line := range glamourLines { if i == 0 { - result = append(result, tag+" "+line) + result = append(result, tag+line) } else { result = append(result, indent+line) } From ef3e4a637bcdcd50e215775e65497d374a1a8646 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:20:33 -0400 Subject: [PATCH 076/117] =?UTF-8?q?fix(cli):=20remove=20/=20search=20mode?= =?UTF-8?q?=20from=20messages=20=E2=80=94=20was=20silently=20swallowing=20?= =?UTF-8?q?keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The / key activated an invisible search mode (UI was removed but the mode remained), causing all subsequent keys to be eaten until Esc. Search should use the global command bar. Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/views/messages.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 94ac5710f..a84ec41a0 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -501,11 +501,6 @@ func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { case "k": ms.scrollUp(1) return *ms, nil - case "/": - ms.searchMode = true - ms.searchInput.Reset() - ms.searchInput.Focus() - return *ms, nil case "c": // Copy the selected message text to clipboard. if len(ms.messages) > 0 { From 819d152237aa5fb7cf3a921f004e017bdfc581ad Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:22:26 -0400 Subject: [PATCH 077/117] feat(cli): message separators + search filter in messages view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dim separator line between user/assistant messages in conversation mode - / opens search prompt in messages view — filters to matching messages - Search matches against both summary text and raw payload - Esc clears search via prompt cancel Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 20 +++++++++++++ .../cmd/acpctl/ambient/tui/views/messages.go | 30 ++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 509971a29..a57cb9049 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "regexp" "sort" "strings" "time" @@ -1845,6 +1846,25 @@ func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.commandInput.Focus() m.resizeTable() return m, nil + case "/": + m.promptMode = true + m.promptInput.Prompt = "Search: " + m.promptInput.Reset() + m.promptInput.Focus() + m.promptCallback = func(input string) (tea.Model, tea.Cmd) { + if input == "" { + m.messageStream.SetSearchPattern(nil) + return m, m.setInfo("Search cleared") + } + pat, err := regexp.Compile("(?i)" + input) + if err != nil { + return m, m.setInfo("Invalid pattern: " + err.Error()) + } + m.messageStream.SetSearchPattern(pat) + return m, m.setInfo("Searching: " + input) + } + m.resizeTable() + return m, nil case "?": return m.showHelp() case "q": diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index a84ec41a0..613c5b3cb 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -381,6 +381,11 @@ func (ms MessageStream) ComposeValue() string { return ms.composeInput.Value() } +// SetSearchPattern sets or clears the message filter pattern. +func (ms *MessageStream) SetSearchPattern(pat *regexp.Regexp) { + ms.searchPattern = pat +} + // ClearCompose resets the compose input and exits compose mode. func (ms *MessageStream) ClearCompose() { ms.composeInput.Reset() @@ -776,16 +781,39 @@ func (ms *MessageStream) buildDisplayLines() []string { lines := make([]string, 0, len(ms.messages)) + dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) + separator := dimStyle.Render(strings.Repeat("─", max(maxLineWidth/2, 10))) + now := time.Now() + prevWasUserOrAssistant := false for _, entry := range ms.messages { + // Apply search filter if active. + if ms.searchPattern != nil { + text := eventSummary(entry.EventType, entry.Payload) + if !ms.searchPattern.MatchString(text) && !ms.searchPattern.MatchString(entry.Payload) { + continue + } + } + var entryLines []string if ms.rawMode { entryLines = ms.renderRawEntry(entry, maxLineWidth) } else { entryLines = ms.renderConversationEntry(entry, maxLineWidth) } + if len(entryLines) == 0 { + continue + } + + // Add dim separator between user/assistant messages in conversation mode. + isUserOrAssistant := entry.EventType == "user" || entry.EventType == "assistant" + if !ms.rawMode && isUserOrAssistant && prevWasUserOrAssistant { + lines = append(lines, separator) + } + prevWasUserOrAssistant = isUserOrAssistant + // Prepend timestamp to the first line if timestamps are enabled. - if ms.timestampMode > 0 && len(entryLines) > 0 && !entry.Timestamp.IsZero() { + if ms.timestampMode > 0 && !entry.Timestamp.IsZero() { tsStyle := lipgloss.NewStyle().Foreground(msgColorDim) var ts string if ms.timestampMode == 1 { From be8b660a7f607970fce2de5aef11454c61bfc25d Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:25:49 -0400 Subject: [PATCH 078/117] fix(cli): Esc clears search before popping, dimmer separator aligned to text - Esc in messages clears active search filter first, only pops on second Esc when no filter is active - Separator starts at tag indent (14 chars) and extends full width - Separator uses color 236 (subtler than 240) Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/messages.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 613c5b3cb..6b950b866 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -450,6 +450,11 @@ func (ms *MessageStream) Update(msg tea.Msg) (MessageStream, tea.Cmd) { func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { switch msg.Type { case tea.KeyEsc: + // If search filter is active, clear it first instead of backing out. + if ms.searchPattern != nil { + ms.searchPattern = nil + return *ms, nil + } return *ms, func() tea.Msg { return MsgStreamBackMsg{} } case tea.KeyEnter: @@ -781,8 +786,9 @@ func (ms *MessageStream) buildDisplayLines() []string { lines := make([]string, 0, len(ms.messages)) - dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) - separator := dimStyle.Render(strings.Repeat("─", max(maxLineWidth/2, 10))) + sepStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("236")) + const tagPad = 14 + separator := strings.Repeat(" ", tagPad) + sepStyle.Render(strings.Repeat("─", max(maxLineWidth-tagPad, 10))) now := time.Now() prevWasUserOrAssistant := false From 22612990cda1d6c8a466d5f81594df67771debde Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:28:54 -0400 Subject: [PATCH 079/117] =?UTF-8?q?fix(cli):=20disable=20SSE=20watcher=20?= =?UTF-8?q?=E2=80=94=20blocks=20UI=20for=20seconds=20on=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSE WatchMessages connection setup blocks the Bubbletea runtime, freezing all keyboard input for ~10s. Disabled in favor of 2s REST polling which works reliably without blocking. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/model_new.go | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index a57cb9049..8f52a893b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1370,12 +1370,8 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { projectID = session.ProjectID } - // Start SSE watcher if we have a program reference. - if m.program != nil && projectID != "" { - cmds = append(cmds, m.client.WatchSessionMessages(projectID, fullSessionID, 0, m.program)) - } - - // Always start polling fallback alongside SSE. + // Use polling for messages (SSE disabled — the synchronous SSE + // connection setup blocks the UI for several seconds). if projectID != "" { m.messagePollActive = true cmds = append(cmds, m.messagePollTickCmd()) @@ -1607,12 +1603,7 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { m.setInfo("Streaming messages for session " + sessionID), } - // Start SSE watcher if we have a program reference and project context. - if m.program != nil && m.currentProject != "" { - cmds = append(cmds, m.client.WatchSessionMessages(m.currentProject, sessionID, 0, m.program)) - } - - // Always start polling fallback alongside SSE. + // Use polling for messages (SSE disabled — blocks UI). if m.currentProject != "" { m.messagePollActive = true cmds = append(cmds, m.messagePollTickCmd()) From d84b47b44fb2bc489345c4c04e030b41f4157f06 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:30:58 -0400 Subject: [PATCH 080/117] fix(cli): show real username from JWT token instead of hardcoded "user" Extract preferred_username/sub/email from the JWT access token claims. Falls back to "unknown" if token is missing or unparseable. Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 2 +- .../cmd/acpctl/ambient/tui/config.go | 32 +++++++++++++++++++ .../cmd/acpctl/ambient/tui/model_new.go | 12 +++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 9f788d59f..ffb08d20f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -73,7 +73,7 @@ func (m *AppModel) viewHeader() string { // Col 1: metadata. col1 := [5]string{ fmt.Sprintf(" %s %s %s", styleDim.Render("Context:"), styleOrange.Render(contextName), styleDim.Render("[RW]")), - fmt.Sprintf(" %s %s", styleDim.Render("User: "), styleWhite.Render("user")), + fmt.Sprintf(" %s %s", styleDim.Render("User: "), styleWhite.Render(m.currentUser())), fmt.Sprintf(" %s %s", styleDim.Render("Project:"), styleOrange.Render(project)), fmt.Sprintf(" %s %s", styleDim.Render("Server: "), styleDim.Render(serverURL)), } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/config.go b/components/ambient-cli/cmd/acpctl/ambient/tui/config.go index 2a85c0caa..0eeb55eac 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/config.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/config.go @@ -1,6 +1,7 @@ package tui import ( + "encoding/base64" "encoding/json" "fmt" "net/url" @@ -40,6 +41,37 @@ type Context struct { ClientID string `json:"client_id,omitempty"` } +// Username extracts the username from the JWT access token claims. +// Checks preferred_username, sub, email in order. Returns "unknown" on failure. +func (c *Context) Username() string { + if c.AccessToken == "" { + return "unknown" + } + parts := strings.SplitN(c.AccessToken, ".", 3) + if len(parts) < 2 { + return "unknown" + } + // Decode the payload (base64url, no padding). + payload := parts[1] + if rem := len(payload) % 4; rem != 0 { + payload += strings.Repeat("=", 4-rem) + } + decoded, err := base64.StdEncoding.DecodeString(strings.NewReplacer("-", "+", "_", "/").Replace(payload)) + if err != nil { + return "unknown" + } + var claims map[string]any + if err := json.Unmarshal(decoded, &claims); err != nil { + return "unknown" + } + for _, key := range []string{"preferred_username", "sub", "email"} { + if v, ok := claims[key].(string); ok && v != "" { + return v + } + } + return "unknown" +} + // String implements fmt.Stringer. The access token is redacted for security. func (c *Context) String() string { token := "" diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 8f52a893b..51a392ebd 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -334,6 +334,18 @@ func (m *AppModel) setInfo(msg string) tea.Cmd { return m.infoExpireCmd() } +// currentUser returns the authenticated username from the JWT token. +func (m *AppModel) currentUser() string { + if m.config == nil { + return "unknown" + } + ctx := m.config.Current() + if ctx == nil { + return "unknown" + } + return ctx.Username() +} + // currentNav returns the current (topmost) navigation entry. func (m *AppModel) currentNav() NavEntry { if len(m.navStack) == 0 { From 5cf71e2392bca67f835bc491c9305c82b2ad1df5 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:33:14 -0400 Subject: [PATCH 081/117] fix(cli): cache display lines to avoid re-rendering glamour every frame Display lines are cached and only rebuilt when mode/messages/search change. Eliminates the 10s delay when toggling pretty mode with many messages. Cache invalidated on AddMessage, mode toggle, or search pattern change. Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/messages.go | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 6b950b866..cc66aa55a 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -290,6 +290,15 @@ type MessageStream struct { glamourRenderer *glamour.TermRenderer glamourWidth int // width used to create the cached renderer + // Cached display lines — rebuilt when mode/messages change, not every frame. + cachedLines []string + cachedDirty bool // true when lines need rebuilding + cachedMsgCount int + cachedRawMode bool + cachedWrapMode bool + cachedTsMode int + cachedSearchPat string + // Compose composeMode bool composeInput textinput.Model @@ -346,6 +355,7 @@ func (ms *MessageStream) AddMessage(entry MessageEntry) { ms.scrollOffset = 0 } } + ms.cachedDirty = true if ms.autoScroll { ms.scrollToBottom() } @@ -781,7 +791,23 @@ func (ms *MessageStream) renderContent(height int) []string { } // buildDisplayLines converts the message buffer into styled display lines. +// Results are cached and only rebuilt when mode/messages change. func (ms *MessageStream) buildDisplayLines() []string { + searchStr := "" + if ms.searchPattern != nil { + searchStr = ms.searchPattern.String() + } + // Check if cache is still valid (timestamps always invalidate since relative times change). + if !ms.cachedDirty && + ms.cachedMsgCount == len(ms.messages) && + ms.cachedRawMode == ms.rawMode && + ms.cachedWrapMode == ms.wrapMode && + ms.cachedTsMode == ms.timestampMode && + ms.cachedSearchPat == searchStr && + ms.timestampMode == 0 { + return ms.cachedLines + } + maxLineWidth := max(ms.width-4, 20) // 2 for borders, 2 for padding lines := make([]string, 0, len(ms.messages)) @@ -841,6 +867,13 @@ func (ms *MessageStream) buildDisplayLines() []string { lines = append(lines, entryLines...) } + ms.cachedLines = lines + ms.cachedDirty = false + ms.cachedMsgCount = len(ms.messages) + ms.cachedRawMode = ms.rawMode + ms.cachedWrapMode = ms.wrapMode + ms.cachedTsMode = ms.timestampMode + ms.cachedSearchPat = searchStr return lines } @@ -862,6 +895,7 @@ func (ms *MessageStream) getGlamourRenderer(wrapWidth int) *glamour.TermRenderer return r } + // renderConversationEntry renders a single message in conversation mode. // Format: [event_type] summary text (wrapped) func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth int) []string { From e9265818a0ea4a6ca9f1ee2419344e61a8e238d6 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:37:57 -0400 Subject: [PATCH 082/117] fix(cli): per-message glamour cache + scroll position indicator - Glamour renders are cached per message seq number, eliminating the 5-6s delay on pretty mode toggle with many messages - Scroll position indicator shows Top/Bot/percentage in status line - Fixed brace nesting from previous glamour cache edit Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/acpctl/ambient/tui/views/messages.go | 67 ++++++++++++++----- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index cc66aa55a..8f067eb21 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -299,6 +299,9 @@ type MessageStream struct { cachedTsMode int cachedSearchPat string + // Per-message glamour render cache (key = seq number). + glamourCache map[int]string + // Compose composeMode bool composeInput textinput.Model @@ -660,12 +663,31 @@ func (ms *MessageStream) View() string { case 2: tsLabel = "Absolute" } - indicators := fmt.Sprintf("Autoscroll:%s Raw:%s Pretty:%s Time:%s Phase:%s", + // Scroll position indicator. + allLines := ms.buildDisplayLines() + scrollPct := "" + if len(allLines) > 0 { + total := len(allLines) + contentH := ms.contentHeight() + if total <= contentH { + scrollPct = "All" + } else if ms.scrollOffset <= 0 { + scrollPct = "Top" + } else if ms.scrollOffset >= total-contentH { + scrollPct = "Bot" + } else { + pct := ms.scrollOffset * 100 / (total - contentH) + scrollPct = fmt.Sprintf("%d%%", pct) + } + } + + indicators := fmt.Sprintf("Autoscroll:%s Raw:%s Pretty:%s Time:%s Phase:%s %s", dimIndicator.Render(autoScrollLabel), dimIndicator.Render(rawLabel), dimIndicator.Render(prettyLabel), dimIndicator.Render(tsLabel), phaseStyle.Render(ms.phase), + dimIndicator.Render(scrollPct), ) if ms.sseStatus != "" && ms.sseStatus != "connected" { var sseColor lipgloss.Color @@ -926,26 +948,37 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in availWidth := max(maxWidth-tagWidth, 10) - // In pretty mode, render all messages through glamour for markdown support. + // In pretty mode, render through glamour for markdown support. + // Uses per-message cache to avoid re-rendering on every frame. if ms.wrapMode { - glamourWidth := max(ms.width-20, 20) - if r := ms.getGlamourRenderer(glamourWidth); r != nil { - rendered, err := r.Render(strings.TrimSpace(entry.Payload)) - if err == nil && strings.TrimSpace(rendered) != "" { - // Split glamour output into lines, strip leading/trailing blanks, prefix with tag. - glamourLines := strings.Split(strings.TrimSpace(rendered), "\n") - result := make([]string, 0, len(glamourLines)) - for i, line := range glamourLines { - if i == 0 { - result = append(result, tag+line) - } else { - result = append(result, indent+line) - } + if ms.glamourCache == nil { + ms.glamourCache = make(map[int]string) + } + var rendered string + if cached, ok := ms.glamourCache[entry.Seq]; ok { + rendered = cached + } else { + glamourWidth := max(ms.width-20, 20) + if r := ms.getGlamourRenderer(glamourWidth); r != nil { + out, err := r.Render(strings.TrimSpace(entry.Payload)) + if err == nil { + rendered = strings.TrimSpace(out) + ms.glamourCache[entry.Seq] = rendered + } + } + } + if rendered != "" { + glamourLines := strings.Split(rendered, "\n") + result := make([]string, 0, len(glamourLines)) + for i, line := range glamourLines { + if i == 0 { + result = append(result, tag+line) + } else { + result = append(result, indent+line) } - return result } + return result } - // Glamour failed — fall through to plain text rendering. } wrapped := wrapText(displayText, availWidth) From e53044e960ba6f361e61b295c28620d541a52c4a Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:43:33 -0400 Subject: [PATCH 083/117] debug(cli): add render timing log to /tmp/tui-render-debug.log Temporary debug: logs buildDisplayLines timing to /tmp when >100ms. Will remove after identifying the delay cause. Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/views/messages.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 8f067eb21..fda5a5f7d 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -3,6 +3,7 @@ package views import ( "encoding/json" "fmt" + "os" "regexp" "strings" "time" @@ -815,6 +816,7 @@ func (ms *MessageStream) renderContent(height int) []string { // buildDisplayLines converts the message buffer into styled display lines. // Results are cached and only rebuilt when mode/messages change. func (ms *MessageStream) buildDisplayLines() []string { + debugStart := time.Now() searchStr := "" if ms.searchPattern != nil { searchStr = ms.searchPattern.String() @@ -889,6 +891,12 @@ func (ms *MessageStream) buildDisplayLines() []string { lines = append(lines, entryLines...) } + if elapsed := time.Since(debugStart); elapsed > 100*time.Millisecond { + _ = os.WriteFile("/tmp/tui-render-debug.log", + []byte(fmt.Sprintf("%s buildDisplayLines: %v (%d msgs, wrap=%v)\n", + time.Now().Format("15:04:05"), elapsed, len(ms.messages), ms.wrapMode)), + 0644) + } ms.cachedLines = lines ms.cachedDirty = false ms.cachedMsgCount = len(ms.messages) From 08beba3c7b720a20e853643de641f5154d77cb77 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:50:32 -0400 Subject: [PATCH 084/117] =?UTF-8?q?fix(cli):=20glamour=20WithAutoStyle?= =?UTF-8?q?=E2=86=92WithStandardStyle,=20remove=20debug,=20fix=20YAML?= =?UTF-8?q?=E2=86=92JSON=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of 5-18s delay: glamour.WithAutoStyle() sends an OSC terminal query that blocks inside bubbletea (bubbletea owns stdin). WithStandardStyle("dark") avoids all terminal IO. Also: rename remaining YAML references to JSON in hints and titles. Remove all debug timing code. Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 8 ++++---- .../cmd/acpctl/ambient/tui/views/messages.go | 10 +--------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 51a392ebd..5313205bf 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1653,10 +1653,10 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { if agent == nil { return m, m.setInfo("Agent not found in cache: " + agentName) } - m.detailView = views.NewDetailView("YAML: "+agentName, views.ResourceJSON(*agent)) + m.detailView = views.NewDetailView("JSON: "+agentName, views.ResourceJSON(*agent)) m.detailView.SetSize(m.width, m.height-10) cmd := m.pushView("detail", agentName, agent.ID) - return m, tea.Batch(cmd, m.setInfo("YAML: "+agentName)) + return m, tea.Batch(cmd, m.setInfo("JSON: "+agentName)) } return m, nil } @@ -1716,7 +1716,7 @@ func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { if session == nil { return m, m.setInfo("Session not found in cache: " + shortID) } - m.detailView = views.NewDetailView("YAML: "+shortID, views.ResourceJSON(*session)) + m.detailView = views.NewDetailView("JSON: "+shortID, views.ResourceJSON(*session)) m.detailView.SetSize(m.width, m.height-10) cmd := m.pushView("detail", shortID, session.ID) return m, tea.Batch(cmd, m.setInfo("Session detail: "+shortID)) @@ -2686,7 +2686,7 @@ func (m *AppModel) contextualHints() []string { " Logs", " Send", " New", - " YAML", + " JSON", " Delete", } case "inbox": diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index fda5a5f7d..5c677dce1 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -3,7 +3,6 @@ package views import ( "encoding/json" "fmt" - "os" "regexp" "strings" "time" @@ -816,7 +815,6 @@ func (ms *MessageStream) renderContent(height int) []string { // buildDisplayLines converts the message buffer into styled display lines. // Results are cached and only rebuilt when mode/messages change. func (ms *MessageStream) buildDisplayLines() []string { - debugStart := time.Now() searchStr := "" if ms.searchPattern != nil { searchStr = ms.searchPattern.String() @@ -891,12 +889,6 @@ func (ms *MessageStream) buildDisplayLines() []string { lines = append(lines, entryLines...) } - if elapsed := time.Since(debugStart); elapsed > 100*time.Millisecond { - _ = os.WriteFile("/tmp/tui-render-debug.log", - []byte(fmt.Sprintf("%s buildDisplayLines: %v (%d msgs, wrap=%v)\n", - time.Now().Format("15:04:05"), elapsed, len(ms.messages), ms.wrapMode)), - 0644) - } ms.cachedLines = lines ms.cachedDirty = false ms.cachedMsgCount = len(ms.messages) @@ -914,7 +906,7 @@ func (ms *MessageStream) getGlamourRenderer(wrapWidth int) *glamour.TermRenderer return ms.glamourRenderer } r, err := glamour.NewTermRenderer( - glamour.WithAutoStyle(), + glamour.WithStandardStyle("dark"), glamour.WithWordWrap(wrapWidth), ) if err != nil { From 043518d7ed8e1cedec32e7e093a95130ee96dfae Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 11:56:14 -0400 Subject: [PATCH 085/117] fix(cli): 0 key stays in agents view, not always jumping to projects Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 5313205bf..b7f866ed7 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -2336,12 +2336,19 @@ func (m *AppModel) handleProjectShortcut(digit byte) (tea.Model, tea.Cmd) { m.pollInFlight = true switch m.activeView { + case "agents": + m.agentTable.SetScope("all") + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}, {Kind: "agents", Scope: "all"}} + return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Viewing all agents")) case "sessions": m.sessionTable.SetScope("all") m.navStack = []NavEntry{{Kind: "sessions", Scope: "all"}} return m, tea.Batch(m.client.FetchAllSessions(), m.setInfo("Viewing all sessions")) + case "inbox": + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Viewing all projects")) default: - m.agentTable.SetScope("all") m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} m.activeView = "projects" return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Viewing all projects")) From 0865189a3fc0c47d8a02474affed66b0848dca11 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 15:27:41 -0400 Subject: [PATCH 086/117] =?UTF-8?q?fix(cli):=20auto-refresh=20tokens=20?= =?UTF-8?q?=E2=80=94=20ClientFactory=20calls=20GetTokenWithRefresh=20per?= =?UTF-8?q?=20request?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientFactory.Token changed from static string to TokenFunc callback. ForProject() calls GetTokenWithRefresh() on every SDK client creation, automatically refreshing expired OIDC tokens. Fixes "Session expired" errors with short-lived tokens (~15min). Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/pkg/connection/connection.go | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/components/ambient-cli/pkg/connection/connection.go b/components/ambient-cli/pkg/connection/connection.go index beb4b9328..d16080d29 100644 --- a/components/ambient-cli/pkg/connection/connection.go +++ b/components/ambient-cli/pkg/connection/connection.go @@ -18,21 +18,29 @@ func SetInsecureSkipTLSVerify(v bool) { } // ClientFactory holds credentials for creating per-project SDK clients. +// TokenFunc is called on every ForProject to get a fresh token, enabling +// automatic refresh of short-lived OIDC tokens. type ClientFactory struct { - APIURL string - Token string - Insecure bool + APIURL string + TokenFunc func() (string, error) + Insecure bool } // ForProject creates an SDK client scoped to the given project name. +// The token is fetched fresh via TokenFunc on each call, so expired +// tokens are automatically refreshed. func (f *ClientFactory) ForProject(project string) (*sdkclient.Client, error) { + token, err := f.TokenFunc() + if err != nil { + return nil, fmt.Errorf("get token: %w", err) + } opts := []sdkclient.ClientOption{ sdkclient.WithUserAgent("acpctl/" + info.Version), } if f.Insecure { opts = append(opts, sdkclient.WithInsecureSkipVerify()) } - return sdkclient.NewClient(f.APIURL, f.Token, project, opts...) + return sdkclient.NewClient(f.APIURL, token, project, opts...) } // NewClientFromConfig creates an SDK client from the saved configuration. @@ -62,6 +70,7 @@ func NewClientFactory() (*ClientFactory, error) { return nil, fmt.Errorf("load config: %w", err) } + // Verify we have a token at startup. token, err := cfg.GetTokenWithRefresh() if err != nil { return nil, fmt.Errorf("token refresh: %w", err) @@ -77,8 +86,10 @@ func NewClientFactory() (*ClientFactory, error) { } return &ClientFactory{ - APIURL: apiURL, - Token: token, + APIURL: apiURL, + TokenFunc: func() (string, error) { + return cfg.GetTokenWithRefresh() + }, Insecure: cfg.InsecureTLSVerify || insecureSkipTLSVerify, }, nil } From 0ebcbaa854f5561d38b6536f960e2dbe92a81242 Mon Sep 17 00:00:00 2001 From: John Sell Date: Sat, 25 Apr 2026 20:43:41 -0400 Subject: [PATCH 087/117] feat(cli): TUI header, hint registry, creation dialogs, and UI polish - Move server URL to its own row below the 4-column header grid to prevent long URLs from pushing shortcut/hint columns off screen. - Replace hardcoded contextualHints() and showHelp() switch statements with a single viewHintRegistry (hints.go) as the source of truth. - Add 1-second UI tick so the refresh counter updates without keypress. - Integrate charmbracelet/huh for multi-field creation dialogs: projects (name, description), agents (name, prompt), sessions (project dropdown, name, prompt, agent dropdown). - Session creation supports standalone (no agent) sessions with project context required via dropdown pre-selected to current project. - Form overlay matches confirm dialog aesthetic: dim single-line border, orange title, centered, with hint bar for Tab/Enter/Esc navigation. - Add CreateSession to TUIClient for direct session creation via SDK. - Message stream toggle indicators (autoscroll, raw, pretty, timestamps) render values in blue when active, dim when off. Co-Authored-By: Claude Sonnet 4.6 --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 16 +- .../cmd/acpctl/ambient/tui/client.go | 37 ++ .../cmd/acpctl/ambient/tui/hints.go | 142 ++++++++ .../cmd/acpctl/ambient/tui/model_new.go | 334 +++++++++--------- .../cmd/acpctl/ambient/tui/views/dialog.go | 111 ++++++ .../cmd/acpctl/ambient/tui/views/form.go | 126 +++++++ .../cmd/acpctl/ambient/tui/views/messages.go | 24 +- components/ambient-cli/go.mod | 5 + components/ambient-cli/go.sum | 22 ++ 9 files changed, 644 insertions(+), 173 deletions(-) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/hints.go create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/form.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index ffb08d20f..4442d3af2 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -35,9 +35,12 @@ func (m *AppModel) View() string { sections = append(sections, m.viewCommandBar()) } - // 3. Resource table with title bar (+ dialog overlay if active). + // 3. Resource table with title bar (+ dialog/form overlay if active). tableOutput := m.viewResourceTable() - if m.dialog != nil { + if m.formOverlay != nil { + tableH := m.height - 10 + tableOutput = views.OverlayForm(tableOutput, m.formOverlay.View(), m.formTitle, m.width, tableH) + } else if m.dialog != nil { tableH := m.height - 10 tableOutput = views.OverlayDialog(tableOutput, *m.dialog, m.width, tableH) } @@ -70,12 +73,11 @@ func (m *AppModel) viewHeader() string { } } } - // Col 1: metadata. + // Col 1: metadata (server is rendered on its own line below the header grid). col1 := [5]string{ fmt.Sprintf(" %s %s %s", styleDim.Render("Context:"), styleOrange.Render(contextName), styleDim.Render("[RW]")), fmt.Sprintf(" %s %s", styleDim.Render("User: "), styleWhite.Render(m.currentUser())), fmt.Sprintf(" %s %s", styleDim.Render("Project:"), styleOrange.Render(project)), - fmt.Sprintf(" %s %s", styleDim.Render("Server: "), styleDim.Render(serverURL)), } // Col 2: project shortcuts (stacked, padded to fixed width). @@ -184,17 +186,17 @@ func (m *AppModel) viewHeader() string { lines[i] = line + strings.Repeat(" ", gap) + right } - return strings.Join(lines, "\n") + // Server URL on its own full-width row below the grid to avoid pushing columns. + serverLine := fmt.Sprintf(" %s %s", styleDim.Render("Server:"), styleDim.Render(serverURL)) + return strings.Join(lines, "\n") + "\n" + serverLine } // renderHint renders a single hotkey hint like " Describe" with dim brackets // and white action text. func (m *AppModel) renderHint(hint string) string { - // Parse hints of the form " Action" or "(text)". if strings.HasPrefix(hint, "(") { return styleDim.Render(hint) } - // Find the closing bracket. idx := strings.Index(hint, ">") if idx < 0 { return styleDim.Render(hint) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index f63628828..f7f15abe6 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -127,6 +127,12 @@ type DeleteProjectMsg struct { Err error } +// CreateSessionMsg carries the result of creating a standalone session. +type CreateSessionMsg struct { + Session *sdktypes.Session + Err error +} + // UpdateSessionMsg carries the result of patching a session. type UpdateSessionMsg struct { Session *sdktypes.Session @@ -618,6 +624,37 @@ func (tc *TUIClient) DeleteProject(projectID string) tea.Cmd { // Session operations // --------------------------------------------------------------------------- +// CreateSession returns a tea.Cmd that creates a standalone session. The session +// is not tied to an agent unless agentID is provided. Only name is required. +func (tc *TUIClient) CreateSession(projectID, name, prompt, agentID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + proj := projectID + if proj == "" { + proj = "_" + } + client, err := tc.factory.ForProject(proj) + if err != nil { + return CreateSessionMsg{Err: err} + } + + session := &sdktypes.Session{ + Name: name, + ProjectID: projectID, + Prompt: prompt, + AgentID: agentID, + } + + result, err := client.Sessions().Create(ctx, session) + if err != nil { + return CreateSessionMsg{Err: err} + } + return CreateSessionMsg{Session: result} + } +} + // UpdateSession returns a tea.Cmd that patches a session with the given fields. func (tc *TUIClient) UpdateSession(projectID, sessionID string, patch map[string]any) tea.Cmd { return func() tea.Msg { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go b/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go new file mode 100644 index 000000000..220b11945 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go @@ -0,0 +1,142 @@ +package tui + +import ( + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" +) + +// ViewHints holds the keyboard shortcut definitions for a single view. +// This is the single source of truth for both header hints and the help overlay. +type ViewHints struct { + Resource []views.HelpEntry + General []views.HelpEntry + Navigation []views.HelpEntry +} + +// defaultGeneral returns the general hints shared by most table views. +func defaultGeneral() []views.HelpEntry { + return []views.HelpEntry{ + {Key: ":", Action: "Command"}, + {Key: "/", Action: "Filter"}, + {Key: "?", Action: "Help"}, + {Key: "c", Action: "Copy ID"}, + {Key: "shift-n", Action: "Sort Name"}, + {Key: "shift-a", Action: "Sort Age"}, + } +} + +// viewHintRegistry maps view names to their hint definitions. +var viewHintRegistry = map[string]ViewHints{ + "projects": { + Resource: []views.HelpEntry{ + {Key: "d", Action: "Describe"}, + {Key: "e", Action: "Edit"}, + {Key: "n", Action: "New"}, + {Key: "ctrl-d", Action: "Delete"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "Drill into agents"}, + {Key: "q", Action: "Quit"}, + }, + }, + "agents": { + Resource: []views.HelpEntry{ + {Key: "s", Action: "Start"}, + {Key: "x", Action: "Stop"}, + {Key: "i", Action: "Inbox"}, + {Key: "d", Action: "Describe"}, + {Key: "e", Action: "Edit"}, + {Key: "l", Action: "Logs"}, + {Key: "n", Action: "New"}, + {Key: "ctrl-d", Action: "Delete"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "Drill into sessions"}, + {Key: "Esc", Action: "Back to projects"}, + {Key: "q", Action: "Back"}, + {Key: "0-9", Action: "Switch project"}, + }, + }, + "sessions": { + Resource: []views.HelpEntry{ + {Key: "d", Action: "Describe"}, + {Key: "e", Action: "Edit"}, + {Key: "l", Action: "Logs"}, + {Key: "m", Action: "Send"}, + {Key: "n", Action: "New"}, + {Key: "y", Action: "JSON"}, + {Key: "ctrl-d", Action: "Delete"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "Drill into messages"}, + {Key: "Esc", Action: "Back to agents"}, + {Key: "q", Action: "Back"}, + {Key: "0-9", Action: "Switch project"}, + }, + }, + "inbox": { + Resource: []views.HelpEntry{ + {Key: "m", Action: "Compose"}, + {Key: "r", Action: "Mark Read"}, + {Key: "ctrl-d", Action: "Delete"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "View body"}, + {Key: "Esc", Action: "Back to agents"}, + {Key: "q", Action: "Back"}, + }, + }, + "messages": { + Resource: []views.HelpEntry{ + {Key: "s", Action: "Autoscroll"}, + {Key: "r", Action: "Raw"}, + {Key: "p", Action: "Pretty"}, + {Key: "t", Action: "Timestamps"}, + {Key: "m", Action: "Send"}, + {Key: "c", Action: "Copy"}, + {Key: "shift-g", Action: "Bottom"}, + {Key: "g", Action: "Top"}, + }, + General: []views.HelpEntry{ + {Key: ":", Action: "Command"}, + {Key: "?", Action: "Help"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Esc", Action: "Back to sessions"}, + {Key: "q", Action: "Back"}, + }, + }, + "contexts": { + Resource: []views.HelpEntry{}, + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "Switch context"}, + {Key: "Esc", Action: "Back"}, + {Key: "q", Action: "Back"}, + }, + }, + "detail": { + Resource: []views.HelpEntry{ + {Key: "c", Action: "Copy value"}, + {Key: "j/k", Action: "Scroll"}, + }, + General: []views.HelpEntry{ + {Key: "?", Action: "Help"}, + }, + Navigation: []views.HelpEntry{ + {Key: "Esc", Action: "Back"}, + {Key: "q", Action: "Back"}, + }, + }, +} + +// hintsForView returns the ViewHints for a given view name. +// Views that don't override General get the default table-view general hints. +func hintsForView(viewName string) ViewHints { + h, ok := viewHintRegistry[viewName] + if !ok { + return ViewHints{} + } + if h.General == nil { + h.General = defaultGeneral() + } + return h +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index b7f866ed7..5e593cc5b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -14,6 +14,7 @@ import ( "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" @@ -56,6 +57,10 @@ type appTickMsg struct{ t time.Time } // active, triggering a REST poll for new session messages. type messagePollTickMsg struct{ t time.Time } +// uiTickMsg fires every second to refresh cosmetic elements (e.g. the +// stale-data counter in the header) without waiting for a keypress. +type uiTickMsg struct{} + // infoExpiredMsg signals the ephemeral info line should be cleared. type infoExpiredMsg struct{} @@ -154,9 +159,14 @@ type AppModel struct { // Errors lastError string - // Dialog overlay (replaces inline delete confirmation and prompt mode for new resources). + // Dialog overlay for confirm/delete prompts. dialog *views.Dialog - dialogAction func() tea.Cmd // executed on DialogConfirmMsg{Confirmed: true} + dialogAction func(value string) tea.Cmd // executed on DialogConfirmMsg{Confirmed: true} + + // Form overlay for multi-field creation dialogs (huh forms). + formOverlay *huh.Form + formTitle string // title shown in the form border + formOnComplete func() tea.Cmd // called when form reaches StateCompleted // Rate-limit backoff: skip the next poll cycle when a 429 is received. skipNextPoll bool @@ -302,6 +312,7 @@ func (m *AppModel) Init() tea.Cmd { tea.WindowSize(), m.client.FetchProjects(), m.tickCmd(), + m.uiTickCmd(), ) } @@ -320,6 +331,13 @@ func (m *AppModel) messagePollTickCmd() tea.Cmd { }) } +// uiTickCmd returns a tea.Cmd that sends a uiTickMsg after 1 second. +func (m *AppModel) uiTickCmd() tea.Cmd { + return tea.Tick(time.Second, func(_ time.Time) tea.Msg { + return uiTickMsg{} + }) +} + // infoExpireCmd returns a tea.Cmd that clears the info line after infoTimeout. func (m *AppModel) infoExpireCmd() tea.Cmd { return tea.Tick(infoTimeout, func(_ time.Time) tea.Msg { @@ -474,6 +492,14 @@ func (m *AppModel) populateContextTable() { // Update implements tea.Model. It dispatches messages to the appropriate // handler based on the current mode and message type. func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // When a form overlay is active, forward ALL messages to it — huh emits + // internal messages (nextFieldMsg, etc.) that must round-trip through + // bubbletea's message loop. Only window-resize and ctrl-c are handled + // here; everything else goes to the form. + if m.formOverlay != nil { + return m.updateFormOverlay(msg) + } + switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -602,6 +628,16 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(m.fetchActiveView(), m.setInfo("Project deleted")) + case CreateSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Create session failed: " + msg.Err.Error()) + } + name := "" + if msg.Session != nil { + name = msg.Session.Name + } + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Session created: "+name)) + case DeleteSessionMsg: if msg.Err != nil { return m, m.setInfo("Delete session failed: " + msg.Err.Error()) @@ -757,6 +793,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) + case uiTickMsg: + return m, m.uiTickCmd() + case appTickMsg: return m.handleTick() @@ -783,13 +822,13 @@ func (m *AppModel) resizeTable() { } // Layout budget: - // header block: 5 lines + // header block: 6 lines (5-line grid + server row) // command/filter bar: 1 line (when visible) — accounted for dynamically // title bar: 1 line // breadcrumb: 1 line // info line: 1 line - // Total chrome: ~8 lines, leaving the rest for the table. - tableHeight := m.height - 8 + // Total chrome: ~9 lines, leaving the rest for the table. + tableHeight := m.height - 9 if m.commandMode || m.filterMode || m.promptMode { tableHeight -= 3 // bordered command bar: top border + content + bottom border } @@ -1244,7 +1283,7 @@ func (m *AppModel) handleDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.dialog = nil m.dialogAction = nil if fn != nil { - return m, tea.Batch(fn(), m.setInfo("Processing...")) + return m, tea.Batch(fn(confirm.Value), m.setInfo("Processing...")) } } else { m.dialog = nil @@ -1256,6 +1295,57 @@ func (m *AppModel) handleDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +// updateFormOverlay forwards all messages to the active huh form and detects +// completion or abort. Called from the top of Update() before the type switch +// so that huh's internal messages (nextFieldMsg, etc.) are properly routed. +func (m *AppModel) updateFormOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { + // Handle resize even while form is active. + if ws, ok := msg.(tea.WindowSizeMsg); ok { + m.width = ws.Width + m.height = ws.Height + m.resizeTable() + } + + // Esc dismisses the form (huh uses ctrl+c for its own abort). + if key, ok := msg.(tea.KeyMsg); ok { + if key.Type == tea.KeyEsc { + m.formOverlay = nil + m.formTitle = "" + m.formOnComplete = nil + return m, m.setInfo("Cancelled") + } + if key.Type == tea.KeyCtrlC { + return m, tea.Quit + } + } + + // Forward everything to the form. + model, cmd := m.formOverlay.Update(msg) + if f, ok := model.(*huh.Form); ok { + m.formOverlay = f + } + + // Check terminal states. + switch m.formOverlay.State { + case huh.StateCompleted: + fn := m.formOnComplete + m.formOverlay = nil + m.formTitle = "" + m.formOnComplete = nil + if fn != nil { + return m, tea.Batch(fn(), m.setInfo("Processing...")) + } + return m, nil + case huh.StateAborted: + m.formOverlay = nil + m.formTitle = "" + m.formOnComplete = nil + return m, m.setInfo("Cancelled") + } + + return m, cmd +} + // handleNormalKey processes keys when neither command nor filter mode is active. // Dispatches based on activeView for view-specific hotkeys. func (m *AppModel) handleNormalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { @@ -1529,7 +1619,18 @@ func (m *AppModel) handleProjectsRune(key string) (tea.Model, tea.Cmd) { case "e": return m.openEditorForProject() case "n": - return m, m.setInfo("Use acpctl project create") + var name, description string + form := views.NewProjectForm(&name, &description) + form.WithWidth(60) + m.formOverlay = form + m.formTitle = "New Project" + m.formOnComplete = func() tea.Cmd { + return tea.Batch( + m.client.CreateProject(name, description), + m.setInfo("Creating project "+name+"..."), + ) + } + return m, m.formOverlay.Init() } return m, nil } @@ -1642,7 +1743,22 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { case "m": return m, m.setInfo("Use :inbox or acpctl inbox send") case "n": - return m, m.setInfo("Use acpctl agent create") + if m.currentProject == "" { + return m, m.setInfo("Navigate to a project first") + } + project := m.currentProject + var name, prompt string + form := views.NewAgentForm(&name, &prompt) + form.WithWidth(60) + m.formOverlay = form + m.formTitle = "New Agent" + m.formOnComplete = func() tea.Cmd { + return tea.Batch( + m.client.CreateAgent(project, name, prompt), + m.setInfo("Creating agent "+name+"..."), + ) + } + return m, m.formOverlay.Init() case "y": row := m.agentTable.SelectedRow() if len(row) == 0 { @@ -1687,25 +1803,44 @@ func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { case "m": return m, m.setInfo("Use Enter to view messages, then m to compose") case "n": - // Start a new session for the current agent. - if m.currentAgentID == "" || m.currentProject == "" { - return m, m.setInfo("Navigate to an agent first to start a session") + var name, prompt, projectID, agentID string + // Pre-select current project if set. + projectID = m.currentProject + // Pre-select current agent if set. + agentID = m.currentAgentID + // Build project options from cache. + var projectOpts []huh.Option[string] + for _, p := range m.cachedProjects { + opt := huh.NewOption(p.Name, p.Name) + if p.Name == projectID { + opt = opt.Selected(true) + } + projectOpts = append(projectOpts, opt) } - // Open prompt input for session prompt text. - agentID := m.currentAgentID - project := m.currentProject - m.promptMode = true - m.promptInput.Prompt = "Session prompt: " - m.promptInput.Reset() - m.promptInput.Focus() - m.promptCallback = func(text string) (tea.Model, tea.Cmd) { - return m, tea.Batch( - m.client.StartAgent(project, agentID, text), - m.setInfo("Starting session..."), + if len(projectOpts) == 0 { + return m, m.setInfo("No projects available") + } + // Build agent options from cache. + agentOpts := []huh.Option[string]{ + huh.NewOption("(none — standalone)", ""), + } + for _, a := range m.cachedAgents { + agentOpts = append(agentOpts, huh.NewOption(a.Name, a.ID)) + } + form := views.NewSessionForm(&name, &prompt, &projectID, projectOpts, &agentID, agentOpts) + form.WithWidth(60) + m.formOverlay = form + m.formTitle = "New Session" + m.formOnComplete = func() tea.Cmd { + if projectID == "" { + return m.setInfo("Project is required") + } + return tea.Batch( + m.client.CreateSession(projectID, name, prompt, agentID), + m.setInfo("Creating session "+name+"..."), ) } - m.resizeTable() - return m, nil + return m, m.formOverlay.Init() case "y": row := m.sessionTable.SelectedRow() if len(row) == 0 { @@ -1762,7 +1897,7 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { projectID := project.ID d := views.NewDeleteDialog("project", projectName) m.dialog = &d - m.dialogAction = func() tea.Cmd { + m.dialogAction = func(_ string) tea.Cmd { return m.client.DeleteProject(projectID) } return m, nil @@ -1779,7 +1914,7 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { currentProject := m.currentProject d := views.NewDeleteDialog("agent", agentName) m.dialog = &d - m.dialogAction = func() tea.Cmd { + m.dialogAction = func(_ string) tea.Cmd { return m.client.DeleteAgent(currentProject, agentID) } return m, nil @@ -1799,7 +1934,7 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { sessionID := session.ID d := views.NewDeleteDialog("session", shortID) m.dialog = &d - m.dialogAction = func() tea.Cmd { + m.dialogAction = func(_ string) tea.Cmd { return m.client.DeleteSession(project, sessionID) } return m, nil @@ -1815,7 +1950,7 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { currentAgentID := m.currentAgentID d := views.NewDeleteDialog("inbox message", msgID) m.dialog = &d - m.dialogAction = func() tea.Cmd { + m.dialogAction = func(_ string) tea.Cmd { return m.client.DeleteInboxMessage(currentProject, currentAgentID, msgID) } return m, nil @@ -1884,84 +2019,12 @@ func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // showHelp creates a HelpView for the current view and pushes it onto the nav stack. +// Hints are pulled from the viewHintRegistry (hints.go) — the single source of truth. func (m *AppModel) showHelp() (tea.Model, tea.Cmd) { - // Fix 3: Capture the view name BEFORE changing activeView. viewName := m.activeView + h := hintsForView(viewName) - general := []views.HelpEntry{ - {":", "Command"}, - {"/", "Filter"}, - {"?", "Help"}, - {"c", "Copy ID"}, - {"shift-n", "Sort Name"}, - {"shift-a", "Sort Age"}, - } - - var resource, navigation []views.HelpEntry - - switch viewName { - case "projects": - resource = []views.HelpEntry{ - {"d", "Describe"}, {"e", "Edit"}, {"n", "New"}, {"ctrl-d", "Delete"}, - } - navigation = []views.HelpEntry{ - {"Enter", "Drill into agents"}, {"q", "Quit"}, - } - case "agents": - resource = []views.HelpEntry{ - {"s", "Start"}, {"x", "Stop"}, {"e", "Edit"}, {"i", "Inbox"}, - {"l", "Logs"}, {"d", "Describe"}, {"n", "New"}, {"ctrl-d", "Delete"}, - } - navigation = []views.HelpEntry{ - {"Enter", "Drill into sessions"}, {"Esc", "Back to projects"}, - {"q", "Back"}, {"0-9", "Switch project"}, - } - case "sessions": - resource = []views.HelpEntry{ - {"d", "Describe"}, {"e", "Edit"}, {"l", "Logs"}, {"m", "Send"}, {"n", "New"}, - {"y", "JSON"}, {"ctrl-d", "Delete"}, - } - navigation = []views.HelpEntry{ - {"Enter", "Drill into messages"}, {"Esc", "Back to agents"}, - {"q", "Back"}, {"0-9", "Switch project"}, - } - case "inbox": - resource = []views.HelpEntry{ - {"m", "Compose"}, {"r", "Mark Read"}, {"ctrl-d", "Delete"}, - } - navigation = []views.HelpEntry{ - {"Enter", "View body"}, {"Esc", "Back to agents"}, {"q", "Back"}, - } - case "messages": - resource = []views.HelpEntry{ - {"s", "Autoscroll"}, {"r", "Raw Mode"}, {"p", "Pretty"}, {"t", "Timestamps"}, - {"m", "Send"}, {"c", "Copy"}, {"shift-g", "Bottom"}, {"g", "Top"}, - } - general = []views.HelpEntry{ - {":", "Command"}, {"?", "Help"}, - } - navigation = []views.HelpEntry{ - {"Esc", "Back to sessions"}, {"q", "Back"}, - } - case "contexts": - resource = []views.HelpEntry{} - navigation = []views.HelpEntry{ - {"Enter", "Switch context"}, {"Esc", "Back"}, {"q", "Back"}, - } - case "detail": - resource = []views.HelpEntry{ - {"c", "Copy value"}, {"j/k", "Scroll"}, - } - general = []views.HelpEntry{ - {"?", "Help"}, - } - navigation = []views.HelpEntry{ - {"Esc", "Back"}, {"q", "Back"}, - } - } - - title := viewName - m.helpView = views.NewHelpView(title, resource, general, navigation) + m.helpView = views.NewHelpView(viewName, h.Resource, h.General, h.Navigation) m.helpView.SetSize(m.width, m.height-10) m.navStack = append(m.navStack, NavEntry{Kind: "help", Scope: viewName}) m.activeView = "help" @@ -2665,64 +2728,13 @@ func stripJSONComments(s string) string { // Contextual hotkey hints for the header // --------------------------------------------------------------------------- -// contextualHints returns the hotkey hints for the current active view. +// contextualHints returns the hotkey hints for the current active view, +// derived from the viewHintRegistry (hints.go). func (m *AppModel) contextualHints() []string { - switch m.activeView { - case "projects": - return []string{ - " Describe", - " Edit", - " New", - " Delete", - } - case "agents": - return []string{ - " Start", - " Stop", - " Inbox", - " Describe", - " Edit", - " Logs", - " New", - " Delete", - } - case "sessions": - return []string{ - " Describe", - " Edit", - " Logs", - " Send", - " New", - " JSON", - " Delete", - } - case "inbox": - return []string{ - " Compose", - " Mark Read", - " Delete", - } - case "messages": - return []string{ - " Autoscroll", - " Raw", - "

Pretty", - " Timestamps", - " Send", - " Copy", - " Bottom", - " Top", - } - case "contexts": - return []string{ - "(Enter to switch)", - } - case "detail": - return []string{ - " Copy", - " Back", - } - default: - return nil + h := hintsForView(m.activeView) + var out []string + for _, e := range h.Resource { + out = append(out, "<"+e.Key+"> "+e.Action) } + return out } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go index ca1f66567..f2c679f9d 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go @@ -363,3 +363,114 @@ func OverlayDialog(background string, dialog Dialog, containerWidth, containerHe return strings.Join(bgLines, "\n") } + +// OverlayForm renders a huh form inside a bordered box matching the confirm +// dialog aesthetic (dim single-line border, orange title), centered on top of +// background content. The title is displayed as ┌──────┐. +func OverlayForm(background, formView, title string, containerWidth, containerHeight int) string { + bgLines := strings.Split(background, "\n") + for len(bgLines) < containerHeight { + bgLines = append(bgLines, "") + } + + borderStyle := lipgloss.NewStyle().Foreground(dlgColorDim) + titleStyle := lipgloss.NewStyle().Foreground(dlgColorOrange).Bold(true) + hintStyle := lipgloss.NewStyle().Foreground(dlgColorDim) + + // Strip trailing blank lines from the form view so the box is tight. + formLines := strings.Split(formView, "\n") + for len(formLines) > 0 && strings.TrimSpace(formLines[len(formLines)-1]) == "" { + formLines = formLines[:len(formLines)-1] + } + + // Determine inner width: max(form content, 56) to ensure comfortable padding. + innerWidth := 56 + for _, fl := range formLines { + if w := lipgloss.Width(fl) + 4; w > innerWidth { + innerWidth = w + } + } + maxInner := containerWidth - 12 + if maxInner < 30 { + maxInner = 30 + } + if innerWidth > maxInner { + innerWidth = maxInner + } + + // Top border with title: ┌────<New Session>────┐ + titleText := titleStyle.Render(title) + titleVisualWidth := lipgloss.Width(titleText) + titleDecorated := borderStyle.Render("<") + titleText + borderStyle.Render(">") + titleDecoratedWidth := titleVisualWidth + 2 + remaining := innerWidth - titleDecoratedWidth + if remaining < 2 { + remaining = 2 + } + leftDashes := remaining / 2 + rightDashes := remaining - leftDashes + topLine := borderStyle.Render("┌"+strings.Repeat("─", leftDashes)) + + titleDecorated + + borderStyle.Render(strings.Repeat("─", rightDashes)+"┐") + + emptyLine := borderStyle.Render("│") + + strings.Repeat(" ", innerWidth) + + borderStyle.Render("│") + + bottomLine := borderStyle.Render("└" + strings.Repeat("─", innerWidth) + "┘") + + // Hint line: "Tab: next Enter: submit Esc: cancel" + hint := hintStyle.Render("Tab: next Enter: submit Esc: cancel") + hintW := lipgloss.Width(hint) + hintPadL := (innerWidth - hintW) / 2 + if hintPadL < 1 { + hintPadL = 1 + } + hintPadR := innerWidth - hintW - hintPadL + if hintPadR < 0 { + hintPadR = 0 + } + hintLine := borderStyle.Render("│") + + strings.Repeat(" ", hintPadL) + hint + strings.Repeat(" ", hintPadR) + + borderStyle.Render("│") + + // Assemble the dialog lines. + var dialogLines []string + dialogLines = append(dialogLines, topLine, emptyLine) + for _, fl := range formLines { + lineW := lipgloss.Width(fl) + padL := 2 + padR := innerWidth - lineW - padL + if padR < 0 { + padR = 0 + } + dialogLines = append(dialogLines, + borderStyle.Render("│")+ + strings.Repeat(" ", padL)+fl+strings.Repeat(" ", padR)+ + borderStyle.Render("│")) + } + dialogLines = append(dialogLines, emptyLine, hintLine, emptyLine, bottomLine) + + // Center the dialog in the container. + dlgHeight := len(dialogLines) + vOffset := (containerHeight - dlgHeight) / 2 + if vOffset < 0 { + vOffset = 0 + } + + dlgVisualWidth := lipgloss.Width(dialogLines[0]) + hPad := (containerWidth - dlgVisualWidth) / 2 + if hPad < 0 { + hPad = 0 + } + + for i, dLine := range dialogLines { + target := vOffset + i + if target >= len(bgLines) { + break + } + bgLines[target] = strings.Repeat(" ", hPad) + dLine + } + + return strings.Join(bgLines, "\n") +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/form.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/form.go new file mode 100644 index 000000000..9451ab00a --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/form.go @@ -0,0 +1,126 @@ +package views + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// ACPTheme returns a huh theme matching the TUI's orange/blue palette. +func ACPTheme() *huh.Theme { + t := huh.ThemeBase() + + orange := lipgloss.Color("214") + blue := lipgloss.Color("69") + white := lipgloss.Color("255") + dim := lipgloss.Color("240") + black := lipgloss.Color("0") + red := lipgloss.Color("196") + + t.Focused.Base = t.Focused.Base.BorderForeground(dim) + t.Focused.Title = t.Focused.Title.Foreground(orange).Bold(true) + t.Focused.Description = t.Focused.Description.Foreground(dim) + t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(red) + t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(red) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(orange) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(orange) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(orange) + t.Focused.Option = t.Focused.Option.Foreground(white) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(orange) + t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(orange).SetString("✓ ") + t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(dim).SetString("• ") + t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(white) + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(black).Background(orange) + t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(white).Background(lipgloss.Color("237")) + t.Focused.Next = t.Focused.FocusedButton + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(orange) + t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(dim) + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(blue) + t.Focused.TextInput.Text = t.Focused.TextInput.Text.Foreground(white) + t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(orange).Bold(true) + + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.Card = t.Blurred.Base + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(dim) + + t.Group.Title = t.Focused.Title + t.Group.Description = t.Focused.Description + return t +} + +// NewProjectForm creates a huh form for creating a new project. +func NewProjectForm(name, description *string) *huh.Form { + return huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Key("name"). + Title("Name"). + Placeholder("my-project"). + Validate(huh.ValidateNotEmpty()). + Value(name), + huh.NewInput(). + Key("description"). + Title("Description"). + Placeholder("(optional)"). + Value(description), + ), + ).WithTheme(ACPTheme()).WithShowHelp(false) +} + +// NewAgentForm creates a huh form for creating a new agent. +func NewAgentForm(name, prompt *string) *huh.Form { + return huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Key("name"). + Title("Name"). + Placeholder("my-agent"). + Validate(huh.ValidateNotEmpty()). + Value(name), + huh.NewInput(). + Key("prompt"). + Title("Prompt"). + Placeholder("(optional)"). + Value(prompt), + ), + ).WithTheme(ACPTheme()).WithShowHelp(false) +} + +// NewSessionForm creates a huh form for creating a new session. +// projectOptions must have at least one entry. agentOptions should include a +// "(none)" entry for standalone sessions; the agent Select is only shown when +// there are 2+ options. +func NewSessionForm(name, prompt, projectID *string, projectOptions []huh.Option[string], agentID *string, agentOptions []huh.Option[string]) *huh.Form { + fields := []huh.Field{ + huh.NewSelect[string](). + Key("project"). + Title("Project"). + Options(projectOptions...). + Value(projectID), + huh.NewInput(). + Key("name"). + Title("Name"). + Placeholder("my-session"). + Validate(huh.ValidateNotEmpty()). + Value(name), + huh.NewInput(). + Key("prompt"). + Title("Prompt"). + Placeholder("(optional)"). + Value(prompt), + } + if len(agentOptions) > 1 { + fields = append(fields, + huh.NewSelect[string](). + Key("agent"). + Title("Agent"). + Options(agentOptions...). + Value(agentID), + ) + } + return huh.NewForm( + huh.NewGroup(fields...), + ).WithTheme(ACPTheme()).WithShowHelp(false) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 5c677dce1..e0bff9aef 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -394,6 +394,12 @@ func (ms MessageStream) ComposeValue() string { return ms.composeInput.Value() } +// Toggle state getters — used by the header to highlight active toggles. +func (ms MessageStream) IsAutoScroll() bool { return ms.autoScroll } +func (ms MessageStream) IsRawMode() bool { return ms.rawMode } +func (ms MessageStream) IsWrapMode() bool { return ms.wrapMode } +func (ms MessageStream) TimestampMode() int { return ms.timestampMode } + // SetSearchPattern sets or clears the message filter pattern. func (ms *MessageStream) SetSearchPattern(pat *regexp.Regexp) { ms.searchPattern = pat @@ -681,11 +687,19 @@ func (ms *MessageStream) View() string { } } - indicators := fmt.Sprintf("Autoscroll:%s Raw:%s Pretty:%s Time:%s Phase:%s %s", - dimIndicator.Render(autoScrollLabel), - dimIndicator.Render(rawLabel), - dimIndicator.Render(prettyLabel), - dimIndicator.Render(tsLabel), + activeIndicator := lipgloss.NewStyle().Foreground(msgColorBlue) + renderToggle := func(label, value string, on bool) string { + s := dimIndicator + if on { + s = activeIndicator + } + return dimIndicator.Render(label+":") + s.Render(value) + } + indicators := fmt.Sprintf("%s %s %s %s Phase:%s %s", + renderToggle("Autoscroll", autoScrollLabel, ms.autoScroll), + renderToggle("Raw", rawLabel, ms.rawMode), + renderToggle("Pretty", prettyLabel, ms.wrapMode), + renderToggle("Time", tsLabel, ms.timestampMode > 0), phaseStyle.Render(ms.phase), dimIndicator.Render(scrollPct), ) diff --git a/components/ambient-cli/go.mod b/components/ambient-cli/go.mod index 52c9e4a09..4df201cbe 100644 --- a/components/ambient-cli/go.mod +++ b/components/ambient-cli/go.mod @@ -10,6 +10,7 @@ require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/spf13/cobra v1.9.1 @@ -23,15 +24,18 @@ require ( github.com/ambient-code/platform/components/ambient-api-server v0.0.0-20260304211549-047314a7664b // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -40,6 +44,7 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect diff --git a/components/ambient-cli/go.sum b/components/ambient-cli/go.sum index e8b33da33..ec6aa0838 100644 --- a/components/ambient-cli/go.sum +++ b/components/ambient-cli/go.sum @@ -1,3 +1,5 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= @@ -14,6 +16,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= @@ -24,18 +28,30 @@ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -43,8 +59,12 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -76,6 +96,8 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= From 41a4cfa7d4020d90decb3cb14657a0259211da4c Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 10:29:31 -0400 Subject: [PATCH 088/117] fix(cli): fix tui shortcut padding --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 4442d3af2..9acba10af 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -95,21 +95,44 @@ func (m *AppModel) viewHeader() string { } } - // Col 3: contextual hotkey hints (up to 4 rows, ~4 per row). + // Col 3: contextual hotkey hints (up to 4 rows, column-aligned). var col3 [5]string hints := m.contextualHints() perRow := 4 if len(hints) <= 8 { - perRow = (len(hints) + 3) / 4 // spread across 4 rows + perRow = (len(hints) + 3) / 4 if perRow < 2 { perRow = 2 } } + + colKeyWidths := make([]int, perRow) + for i, h := range hints { + if idx := strings.Index(h, ">"); idx >= 0 { + if w := lipgloss.Width(h[:idx+1]); w > colKeyWidths[i%perRow] { + colKeyWidths[i%perRow] = w + } + } + } + + rendered := make([]string, len(hints)) + for i, h := range hints { + rendered[i] = m.renderHint(h, colKeyWidths[i%perRow]) + } + + colWidths := make([]int, perRow) + for i, r := range rendered { + if w := lipgloss.Width(r); w > colWidths[i%perRow] { + colWidths[i%perRow] = w + } + } + rowIdx := 0 var currentRow []string - for i, h := range hints { - currentRow = append(currentRow, m.renderHint(h)) - if (i+1)%perRow == 0 || i == len(hints)-1 { + for i, r := range rendered { + pad := colWidths[i%perRow] - lipgloss.Width(r) + currentRow = append(currentRow, r+strings.Repeat(" ", pad)) + if (i+1)%perRow == 0 || i == len(rendered)-1 { if rowIdx < 5 { col3[rowIdx] = strings.Join(currentRow, " ") } @@ -192,8 +215,8 @@ func (m *AppModel) viewHeader() string { } // renderHint renders a single hotkey hint like "<d> Describe" with dim brackets -// and white action text. -func (m *AppModel) renderHint(hint string) string { +// and white action text. keyWidth is the visual width to pad all keys to (0 = no padding). +func (m *AppModel) renderHint(hint string, keyWidth int) string { if strings.HasPrefix(hint, "(") { return styleDim.Render(hint) } @@ -202,8 +225,13 @@ func (m *AppModel) renderHint(hint string) string { return styleDim.Render(hint) } key := hint[:idx+1] // e.g. "<d>" - action := hint[idx+1:] // e.g. " Describe" - return styleDim.Render(key) + styleWhite.Render(action) + action := hint[idx+2:] // e.g. "Describe" (skip the space after >) + renderedKey := styleDim.Render(key) + pad := keyWidth + 1 - lipgloss.Width(renderedKey) + if pad < 1 { + pad = 1 + } + return renderedKey + strings.Repeat(" ", pad) + styleWhite.Render(action) } // viewCommandBar renders the command, filter, or prompt input bar with a border. From 79da4f0e9eb860a4f3c68034ef6cd6f4f8df7e5d Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 11:41:40 -0400 Subject: [PATCH 089/117] =?UTF-8?q?fix(cli):=20TUI=20comprehensive=20revie?= =?UTF-8?q?w=20=E2=80=94=2042=20bugs=20fixed=20across=20state,=20security,?= =?UTF-8?q?=20perf,=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State management: - Fix pollInFlight stuck true via pushView nil-check guard - Fix SSE goroutine leak on q/command exits from messages view - Fix form overlay swallowing data-fetch messages (ProjectsMsg, etc.) - Fix CRUD handlers not setting pollInFlight before fetchActiveView - Add SSE message dedup check to prevent duplicates with polling - Fix :messages command (show info instead of broken implementation) Security: - Sanitize SSE message payloads (new views/sanitize.go) to prevent terminal injection via malicious agent output - Fix Unicode corruption in wrapText/truncatePayload (rune-based slicing) - Add regexp.QuoteMeta fallback in filter bar for consistency Performance: - Hoist static lipgloss styles to package-level vars in app.go, messages.go, table.go (eliminates hundreds of allocs per frame) - Cache ResourceTable styles as struct fields instead of per-call allocs - Add semaphore (cap 10) to FetchAllSessions, FetchProjectCounts, FetchAgentCounts goroutine fan-out - Fix glamour cache: invalidate on resize, evict on ring buffer eviction - Delete duplicate fmtAge, consolidate on views.FormatAge Bug fixes: - Fix session PHASE sanitize column index (was 3, should be 4) - Fix "0" key in agents view (navigate to projects, not stale fetch) - Fix message copy using wrong index (map display-line offset to message) - Fix detail view copy using wrong index (map rendered cursor to source) - Fix error dialog showing "Cancelled" instead of "Dismissed" - Fix ring buffer eviction corrupting scroll offset - Fix contentHeight() disagreeing with View() layout calculation - Fix double search filtering in message stream - Fix header column overlap on narrow terminals - Fix tableH going negative at small terminal sizes - Fix padRight using len() instead of lipgloss.Width() for Unicode - Fix clipboard error silently swallowed (surface via MsgStreamCopyMsg) - Fix cursor jumping on refresh (preserve by row key, not index) - Fix kubectl commands without timeout (use CommandContext) - Fix polling error invisible (show via setInfo) - Fix delete 404 showing confusing error (show "Already deleted") - Reduce agent editor editable fields to match API (was 12, now 4) - Filter session form agents by selected project - Add pagination warning when hitting 200-item cap - Copy full session ID from cache instead of truncated display value UX: - Fix misleading hint text (m=Compose, not Send) - Remove inbox m hint (no-op), add inbox to number-key exclusion - Add j/k and y shortcuts to hint registry - Show :aliases as detail view instead of just count - Show "Navigate to projects view first" when cache empty - Add StopWatching cleanup on Ctrl+C and q quit paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 56 ++++-- .../cmd/acpctl/ambient/tui/client.go | 9 + .../cmd/acpctl/ambient/tui/dashboard.go | 12 +- .../cmd/acpctl/ambient/tui/fetch.go | 16 +- .../cmd/acpctl/ambient/tui/filter.go | 4 +- .../cmd/acpctl/ambient/tui/hints.go | 7 +- .../cmd/acpctl/ambient/tui/model_new.go | 183 +++++++++++++++--- .../cmd/acpctl/ambient/tui/view.go | 14 +- .../cmd/acpctl/ambient/tui/views/detail.go | 34 +++- .../cmd/acpctl/ambient/tui/views/help.go | 9 +- .../cmd/acpctl/ambient/tui/views/messages.go | 182 +++++++++++------ .../cmd/acpctl/ambient/tui/views/sanitize.go | 56 ++++++ .../cmd/acpctl/ambient/tui/views/table.go | 68 ++++++- 13 files changed, 493 insertions(+), 157 deletions(-) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/sanitize.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 9acba10af..8e07b63a0 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -10,6 +10,9 @@ import ( "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" ) +// Hoisted command bar border style to avoid allocations on every frame. +var commandBarBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("36")) + // ASCII art branding rendered in the header (Fix 9: extra left padding). var brandLines = []string{ ` `, @@ -39,9 +42,15 @@ func (m *AppModel) View() string { tableOutput := m.viewResourceTable() if m.formOverlay != nil { tableH := m.height - 10 + if tableH < 1 { + tableH = 1 + } tableOutput = views.OverlayForm(tableOutput, m.formOverlay.View(), m.formTitle, m.width, tableH) } else if m.dialog != nil { tableH := m.height - 10 + if tableH < 1 { + tableH = 1 + } tableOutput = views.OverlayDialog(tableOutput, *m.dialog, m.width, tableH) } sections = append(sections, tableOutput) @@ -156,9 +165,13 @@ func (m *AppModel) viewHeader() string { } } - // Fixed column positions (visual widths). - const col2Start = 40 // shortcuts column starts at char 40 - const col3Start = 65 // hotkeys column starts at char 65 + // Dynamic column positions based on terminal width. + col2Start := 40 // shortcuts column starts at char 40 + col3Start := 65 // hotkeys column starts at char 65 + + // On narrow terminals, skip columns to avoid overlap. + skipShortcuts := m.width < 100 + skipHints := m.width < 80 lines := make([]string, 5) for i := range 5 { @@ -166,8 +179,8 @@ func (m *AppModel) viewHeader() string { line := col1[i] w := lipgloss.Width(line) - // Pad to col2 position and add shortcut. - if col2[i] != "" { + // Pad to col2 position and add shortcut (skip on narrow terminals). + if col2[i] != "" && !skipShortcuts { if w < col2Start { line += strings.Repeat(" ", col2Start-w) } else { @@ -177,8 +190,8 @@ func (m *AppModel) viewHeader() string { } w = lipgloss.Width(line) - // Pad to col3 position and add hints. - if col3[i] != "" { + // Pad to col3 position and add hints (skip on narrow terminals). + if col3[i] != "" && !skipHints { if w < col3Start { line += strings.Repeat(" ", col3Start-w) } else { @@ -247,8 +260,7 @@ func (m *AppModel) viewCommandBar() string { return "" } - borderColor := lipgloss.Color("36") // cyan border like k9s - bs := lipgloss.NewStyle().Foreground(borderColor) + bs := commandBarBorderStyle innerW := m.width - 4 if innerW < 10 { innerW = 10 @@ -290,19 +302,25 @@ func (m *AppModel) viewResourceTable() string { } } +// Hoisted breadcrumb styles to avoid allocations on every frame. +var ( + breadcrumbListStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("214")). + Foreground(lipgloss.Color("0")). + Bold(true). + Padding(0, 1) + breadcrumbLeafStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("63")). + Foreground(lipgloss.Color("231")). + Bold(true). + Padding(0, 1) +) + // viewBreadcrumb renders the navigation breadcrumb trail at the bottom. // Each segment is an individual colored box: orange for list views, blue for leaves. func (m *AppModel) viewBreadcrumb() string { - listStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("214")). - Foreground(lipgloss.Color("0")). - Bold(true). - Padding(0, 1) - leafStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("63")). - Foreground(lipgloss.Color("231")). - Bold(true). - Padding(0, 1) + listStyle := breadcrumbListStyle + leafStyle := breadcrumbLeafStyle leafKinds := map[string]bool{"messages": true, "help": true, "detail": true} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index f7f15abe6..7d78d733d 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -242,10 +242,13 @@ func (tc *TUIClient) FetchProjectCounts(projects []string) tea.Cmd { wg sync.WaitGroup ) + sem := make(chan struct{}, 10) for _, proj := range projects { wg.Add(1) go func() { defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() client, err := tc.factory.ForProject(proj) if err != nil { @@ -302,10 +305,13 @@ func (tc *TUIClient) FetchAgentCounts(projectID string, agentIDs []string) tea.C return AgentCountsMsg{Err: err} } + sem := make(chan struct{}, 10) for _, agentID := range agentIDs { wg.Add(1) go func() { defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() sessionList, err := client.Agents().Sessions(ctx, projectID, agentID, defaultListOpts()) sc := -1 @@ -392,10 +398,13 @@ func (tc *TUIClient) FetchAllSessions() tea.Cmd { wg sync.WaitGroup ) + sem := make(chan struct{}, 10) for _, proj := range projList.Items { wg.Add(1) go func() { defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() projClient, err := tc.factory.ForProject(proj.Name) if err != nil { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/dashboard.go b/components/ambient-cli/cmd/acpctl/ambient/tui/dashboard.go index 042a26eed..e509c7a58 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/dashboard.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/dashboard.go @@ -15,6 +15,8 @@ import ( sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" ) type sdkClientIface interface { @@ -259,7 +261,7 @@ func buildProjectLines(d DashData) []string { for _, p := range d.Projects { age := "" if p.CreatedAt != nil { - age = fmtAge(time.Since(*p.CreatedAt)) + age = views.FormatAge(time.Since(*p.CreatedAt)) } display := p.Name statusStyle := styleGreen @@ -372,7 +374,7 @@ func (m *Model) renderSessionTile(sess sdktypes.Session, w, msgLines int) []stri age := "" if sess.CreatedAt != nil { - age = fmtAge(time.Since(*sess.CreatedAt)) + age = views.FormatAge(time.Since(*sess.CreatedAt)) } idShort := sess.ID @@ -563,7 +565,7 @@ func buildStatsLines(d DashData) []string { age := "never" if !d.FetchedAt.IsZero() { - age = fmtAge(time.Since(d.FetchedAt)) + " ago" + age = views.FormatAge(time.Since(d.FetchedAt)) + " ago" } lines := []string{ @@ -717,7 +719,7 @@ func fetchSessionSplitDetail(sess sdktypes.Session, msgs []sdktypes.SessionMessa } age := "—" if sess.CreatedAt != nil { - age = fmtAge(time.Since(*sess.CreatedAt)) + age = views.FormatAge(time.Since(*sess.CreatedAt)) } topLines := []string{ @@ -806,7 +808,7 @@ func fetchProjectSessionsDetail(ctx context.Context, client sdkClientIface, proj } age := "—" if sess.CreatedAt != nil { - age = fmtAge(time.Since(*sess.CreatedAt)) + age = views.FormatAge(time.Since(*sess.CreatedAt)) } line := styleBlue.Render(col(sess.Name, 30)) + phaseStyle.Render(col(phase, 14)) + diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go b/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go index 8de89a972..a96313607 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go @@ -152,13 +152,15 @@ func appendErr(d *DashData, msg string) { } func kubectlGetPods() []PodRow { - out, err := runCmd("kubectl", "get", "pods", + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := runCmd(ctx, "kubectl", "get", "pods", "-n", "ambient-code", "--no-headers", "-o", "wide", ) if err != nil { - out2, err2 := runCmd("oc", "get", "pods", + out2, err2 := runCmd(ctx, "oc", "get", "pods", "-n", "ambient-code", "--no-headers", "-o", "wide", @@ -172,9 +174,11 @@ func kubectlGetPods() []PodRow { } func kubectlGetNamespaces() []NamespaceRow { - out, err := runCmd("kubectl", "get", "namespaces", "--no-headers") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := runCmd(ctx, "kubectl", "get", "namespaces", "--no-headers") if err != nil { - out2, err2 := runCmd("oc", "get", "namespaces", "--no-headers") + out2, err2 := runCmd(ctx, "oc", "get", "namespaces", "--no-headers") if err2 != nil { return nil } @@ -183,8 +187,8 @@ func kubectlGetNamespaces() []NamespaceRow { return parseNamespaceLines(out) } -func runCmd(name string, args ...string) (string, error) { - cmd := exec.Command(name, args...) +func runCmd(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) var out bytes.Buffer cmd.Stdout = &out if err := cmd.Run(); err != nil { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/filter.go b/components/ambient-cli/cmd/acpctl/ambient/tui/filter.go index 910d0965b..623ff0bd2 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/filter.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/filter.go @@ -58,10 +58,10 @@ func ParseFilter(input string) (*Filter, error) { return f, nil } - // Compile as case-insensitive regex + // Compile as case-insensitive regex, falling back to literal match on invalid regex. re, err := regexp.Compile("(?i)" + input) if err != nil { - return nil, fmt.Errorf("invalid filter regex: %w", err) + re = regexp.MustCompile("(?i)" + regexp.QuoteMeta(input)) } f.Pattern = re diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go b/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go index 220b11945..fe93f1025 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go @@ -19,6 +19,7 @@ func defaultGeneral() []views.HelpEntry { {Key: "/", Action: "Filter"}, {Key: "?", Action: "Help"}, {Key: "c", Action: "Copy ID"}, + {Key: "j/k", Action: "Up/Down"}, {Key: "shift-n", Action: "Sort Name"}, {Key: "shift-a", Action: "Sort Age"}, } @@ -47,6 +48,7 @@ var viewHintRegistry = map[string]ViewHints{ {Key: "e", Action: "Edit"}, {Key: "l", Action: "Logs"}, {Key: "n", Action: "New"}, + {Key: "y", Action: "JSON"}, {Key: "ctrl-d", Action: "Delete"}, }, Navigation: []views.HelpEntry{ @@ -61,7 +63,7 @@ var viewHintRegistry = map[string]ViewHints{ {Key: "d", Action: "Describe"}, {Key: "e", Action: "Edit"}, {Key: "l", Action: "Logs"}, - {Key: "m", Action: "Send"}, + {Key: "m", Action: "Send (via msgs)"}, {Key: "n", Action: "New"}, {Key: "y", Action: "JSON"}, {Key: "ctrl-d", Action: "Delete"}, @@ -75,7 +77,6 @@ var viewHintRegistry = map[string]ViewHints{ }, "inbox": { Resource: []views.HelpEntry{ - {Key: "m", Action: "Compose"}, {Key: "r", Action: "Mark Read"}, {Key: "ctrl-d", Action: "Delete"}, }, @@ -91,7 +92,7 @@ var viewHintRegistry = map[string]ViewHints{ {Key: "r", Action: "Raw"}, {Key: "p", Action: "Pretty"}, {Key: "t", Action: "Timestamps"}, - {Key: "m", Action: "Send"}, + {Key: "m", Action: "Compose"}, {Key: "c", Action: "Copy"}, {Key: "shift-g", Action: "Bottom"}, {Key: "g", Action: "Top"}, diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 5e593cc5b..86a85d53c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -383,7 +383,11 @@ func (m *AppModel) pushView(kind, scope, id string) tea.Cmd { m.activeView = kind m.activeFilter = nil m.pollInFlight = true - return m.fetchActiveView() + if fetchCmd := m.fetchActiveView(); fetchCmd != nil { + return fetchCmd + } + m.pollInFlight = false + return nil } // popView pops the current navigation entry, switches back to the parent view, @@ -392,6 +396,13 @@ func (m *AppModel) popView() tea.Cmd { if len(m.navStack) <= 1 { return nil } + // If we're leaving the messages view, stop SSE and polling. + poppedKind := m.navStack[len(m.navStack)-1].Kind + if poppedKind == "messages" { + m.client.StopWatching() + m.messagePollActive = false + } + m.navStack = m.navStack[:len(m.navStack)-1] nav := m.currentNav() m.activeView = nav.Kind @@ -545,6 +556,17 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case InboxMsg: return m.handleInboxMsg(msg) + case views.MsgStreamCopyMsg: + // Clipboard copy result from the message stream sub-model. + if msg.Err != nil { + return m, m.setInfo("Copy failed: " + msg.Err.Error()) + } + copied := msg.Text + if len(copied) > 60 { + copied = copied[:57] + "..." + } + return m, m.setInfo("Copied: " + copied) + case views.MsgStreamBackMsg: // User pressed Esc in the message stream — pop back. m.client.StopWatching() @@ -588,12 +610,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if sessionID != "" { info += " (session " + sessionID + ")" } + m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo(info)) case StopAgentMsg: if msg.Err != nil { return m, m.setInfo("Stop agent failed: " + msg.Err.Error()) } + m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent stopped")) case CreateAgentMsg: @@ -604,12 +628,18 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Agent != nil { name = msg.Agent.Name } + m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent created: "+name)) case DeleteAgentMsg: if msg.Err != nil { + if strings.Contains(msg.Err.Error(), "404") || strings.Contains(msg.Err.Error(), "not found") { + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Already deleted — refreshing")) + } return m, m.setInfo("Delete agent failed: " + msg.Err.Error()) } + m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent deleted")) case CreateProjectMsg: @@ -620,12 +650,18 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Project != nil { name = msg.Project.Name } + m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Project created: "+name)) case DeleteProjectMsg: if msg.Err != nil { + if strings.Contains(msg.Err.Error(), "404") || strings.Contains(msg.Err.Error(), "not found") { + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Already deleted — refreshing")) + } return m, m.setInfo("Delete project failed: " + msg.Err.Error()) } + m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Project deleted")) case CreateSessionMsg: @@ -636,12 +672,18 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Session != nil { name = msg.Session.Name } + m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Session created: "+name)) case DeleteSessionMsg: if msg.Err != nil { + if strings.Contains(msg.Err.Error(), "404") || strings.Contains(msg.Err.Error(), "not found") { + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Already deleted — refreshing")) + } return m, m.setInfo("Delete session failed: " + msg.Err.Error()) } + m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Session deleted")) case UpdateAgentMsg: @@ -652,6 +694,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Agent != nil { name = msg.Agent.Name } + m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Agent updated: "+name)) case UpdateProjectMsg: @@ -662,6 +705,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Project != nil { name = msg.Project.Name } + m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Project updated: "+name)) case UpdateSessionMsg: @@ -672,6 +716,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Session != nil { name = msg.Session.Name } + m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Session updated: "+name)) case editCompleteMsg: @@ -718,6 +763,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if msg.Message != nil && m.activeView == "messages" { + // Dedup: skip messages already seen via polling. + if msg.Message.Seq <= m.lastMessageSeq { + return m, nil + } m.messageStream.SetSSEStatus("connected") ts := time.Now() if msg.Message.CreatedAt != nil { @@ -739,8 +788,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case SessionMessagesMsg: // Polling fallback: batch of messages from REST ListMessages. if msg.Err != nil { - // Non-fatal — polling will retry on next tick. - return m, nil + // Non-fatal — polling will retry on next tick, but inform user. + return m, m.setInfo("Message poll error: " + msg.Err.Error()) } if m.activeView != "messages" { return m, nil @@ -897,7 +946,7 @@ func (m *AppModel) handleProjectsMsg(msg ProjectsMsg) (tea.Model, tea.Cmd) { for _, p := range msg.Projects { age := "" if p.CreatedAt != nil { - age = fmtAge(time.Since(*p.CreatedAt)) + age = views.FormatAge(time.Since(*p.CreatedAt)) } desc := p.Description if len(desc) > 60 { @@ -932,6 +981,10 @@ func (m *AppModel) handleProjectsMsg(msg ProjectsMsg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.client.FetchProjectCounts(names)) } + if len(msg.Projects) >= 200 { + cmds = append(cmds, m.setInfo("Showing first 200 projects")) + } + return m, tea.Batch(cmds...) } @@ -948,7 +1001,7 @@ func (m *AppModel) handleProjectCountsMsg(msg ProjectCountsMsg) (tea.Model, tea. for _, p := range m.cachedProjects { age := "" if p.CreatedAt != nil { - age = fmtAge(now.Sub(*p.CreatedAt)) + age = views.FormatAge(now.Sub(*p.CreatedAt)) } desc := p.Description if len(desc) > 60 { @@ -1047,6 +1100,10 @@ func (m *AppModel) handleAgentsMsg(msg AgentsMsg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.client.FetchAgentCounts(m.currentProject, agentIDs)) } + if len(msg.Agents) >= 200 { + cmds = append(cmds, m.setInfo("Showing first 200 agents")) + } + return m, tea.Batch(cmds...) } @@ -1111,9 +1168,9 @@ func (m *AppModel) handleSessionsMsg(msg SessionsMsg) (tea.Model, tea.Cmd) { for _, s := range sessions { if s.AgentID == m.currentAgentID { row := views.SessionRow(s, m.currentAgent, now) - // Sanitize all cells except PHASE (index 3) which contains embedded ANSI color. + // Sanitize all cells except PHASE (index 4): [ID(0), NAME(1), AGENT(2), PROJECT(3), PHASE(4), STARTED(5), DURATION(6)]. for i := range row { - if i != 3 { + if i != 4 { row[i] = Sanitize(row[i]) } } @@ -1130,9 +1187,9 @@ func (m *AppModel) handleSessionsMsg(msg SessionsMsg) (tea.Model, tea.Cmd) { agentName = agentName[:12] } row := views.SessionRow(s, agentName, now) - // Sanitize all cells except PHASE (index 3) which contains embedded ANSI color. + // Sanitize all cells except PHASE (index 4): [ID(0), NAME(1), AGENT(2), PROJECT(3), PHASE(4), STARTED(5), DURATION(6)]. for i := range row { - if i != 3 { + if i != 4 { row[i] = Sanitize(row[i]) } } @@ -1149,6 +1206,10 @@ func (m *AppModel) handleSessionsMsg(msg SessionsMsg) (tea.Model, tea.Cmd) { }) } + if len(msg.Sessions) >= 200 { + return m, m.setInfo("Showing first 200 sessions") + } + return m, nil } @@ -1187,6 +1248,10 @@ func (m *AppModel) handleInboxMsg(msg InboxMsg) (tea.Model, tea.Cmd) { }) } + if len(msg.Messages) >= 200 { + return m, m.setInfo("Showing first 200 inbox messages") + } + return m, nil } @@ -1219,8 +1284,10 @@ func (m *AppModel) handleTick() (tea.Model, tea.Cmd) { // handleKey dispatches key events based on the current mode. func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Ctrl-C always quits. + // Ctrl-C always quits — clean up SSE goroutines first. if msg.Type == tea.KeyCtrlC { + m.client.StopWatching() + m.messagePollActive = false return m, tea.Quit } @@ -1287,8 +1354,13 @@ func (m *AppModel) handleDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } else { m.dialog = nil + infoText := "Cancelled" + if m.dialogAction == nil { + // Error/info dialog (single-button dismiss) — not a cancel. + infoText = "Dismissed" + } m.dialogAction = nil - return m, m.setInfo("Cancelled") + return m, m.setInfo(infoText) } } @@ -1306,6 +1378,30 @@ func (m *AppModel) updateFormOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { m.resizeTable() } + // Don't swallow tick messages — they keep the poll and UI refresh chains alive. + if _, ok := msg.(appTickMsg); ok { + return m.handleTick() + } + if _, ok := msg.(uiTickMsg); ok { + return m, m.uiTickCmd() + } + + // Don't swallow data-fetch responses — they clear pollInFlight and update caches. + switch typedMsg := msg.(type) { + case ProjectsMsg: + return m.handleProjectsMsg(typedMsg) + case AgentsMsg: + return m.handleAgentsMsg(typedMsg) + case SessionsMsg: + return m.handleSessionsMsg(typedMsg) + case InboxMsg: + return m.handleInboxMsg(typedMsg) + case ProjectCountsMsg: + return m.handleProjectCountsMsg(typedMsg) + case AgentCountsMsg: + return m.handleAgentCountsMsg(typedMsg) + } + // Esc dismisses the form (huh uses ctrl+c for its own abort). if key, ok := msg.(tea.KeyMsg); ok { if key.Type == tea.KeyEsc { @@ -1526,6 +1622,8 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "q": if len(m.navStack) <= 1 { + m.client.StopWatching() + m.messagePollActive = false return m, tea.Quit } cmd := m.popView() @@ -1565,11 +1663,20 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "c": // Copy the first column value (resource name/ID) of the selected row to clipboard. + // For sessions, resolve the full ID from cache (table shows truncated short IDs). if tbl := m.activeTable(); tbl != nil { row := tbl.SelectedRow() if len(row) > 0 { value := row[0] - _ = clipboard.WriteAll(value) + // Resolve full session ID from cache if we're in sessions view. + if m.activeView == "sessions" { + if s := m.findSessionByShortID(value); s != nil { + value = s.ID + } + } + if err := clipboard.WriteAll(value); err != nil { + return m, m.setInfo("Copy failed: " + err.Error()) + } return m, m.setInfo("Copied: " + value) } } @@ -1579,7 +1686,8 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Number-key project shortcuts (0-9) — only active on table views below project level. if len(key) == 1 && key[0] >= '0' && key[0] <= '9' && m.activeView != "projects" && m.activeView != "contexts" && - m.activeView != "messages" && m.activeView != "detail" { + m.activeView != "messages" && m.activeView != "detail" && + m.activeView != "inbox" { return m.handleProjectShortcut(key[0] - '0') } @@ -1818,13 +1926,17 @@ func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { projectOpts = append(projectOpts, opt) } if len(projectOpts) == 0 { - return m, m.setInfo("No projects available") + return m, m.setInfo("Navigate to projects view first to populate project list") } - // Build agent options from cache. + // Build agent options from cache, filtered to the selected project. agentOpts := []huh.Option[string]{ huh.NewOption("(none — standalone)", ""), } for _, a := range m.cachedAgents { + // Only show agents belonging to the pre-selected project. + if projectID != "" && a.ProjectID != projectID { + continue + } agentOpts = append(agentOpts, huh.NewOption(a.Name, a.ID)) } form := views.NewSessionForm(&name, &prompt, &projectID, projectOpts, &agentID, agentOpts) @@ -2081,10 +2193,17 @@ func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // executeCommand parses and dispatches a command-mode input. func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { + // If we're leaving the messages view via a command, stop SSE and polling. + if m.activeView == "messages" { + m.client.StopWatching() + m.messagePollActive = false + } + cmd := ParseCommand(input) switch cmd.Kind { case CmdQuit: + m.client.StopWatching() return m, tea.Quit case CmdProjects: @@ -2185,12 +2304,7 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { ) case CmdMessages: - if m.currentSession == "" { - return m, m.setInfo("No session context — drill into a session first") - } - m.activeView = "messages" - m.activeFilter = nil - return m, m.setInfo("Streaming messages for session "+m.currentSession) + return m, m.setInfo("Use Enter from sessions view to open messages") case CmdContext: if cmd.Arg == "" { @@ -2228,15 +2342,21 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { case CmdAliases: entries := AliasTable() - var lines []string + var detailLines []views.DetailLine for _, e := range entries { aliases := "" if len(e.Aliases) > 0 { - aliases = " (" + fmt.Sprintf("%v", e.Aliases) + ")" + aliases = " (" + strings.Join(e.Aliases, ", ") + ")" } - lines = append(lines, e.Command+aliases+" - "+e.Description) + detailLines = append(detailLines, views.DetailLine{ + Key: e.Command + aliases, + Value: e.Description, + }) } - return m, m.setInfo("Commands: " + fmt.Sprintf("%d available", len(entries))) + m.detailView = views.NewDetailView("Commands", detailLines) + m.detailView.SetSize(m.width, m.height-10) + cmdPush := m.pushView("detail", "aliases", "") + return m, tea.Batch(cmdPush, m.setInfo(fmt.Sprintf("%d commands available", len(entries)))) default: ascii := "" + @@ -2390,7 +2510,7 @@ func (m *AppModel) handlePromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // 0 = "all" (clear project scope), 1-9 = projectShortcuts[digit-1]. func (m *AppModel) handleProjectShortcut(digit byte) (tea.Model, tea.Cmd) { if digit == 0 { - // Switch to "all" — clear project scope, stay in current view type. + // Switch to "all" — clear project scope, navigate back to projects view. m.currentProject = "" m.currentAgent = "" m.currentAgentID = "" @@ -2400,9 +2520,10 @@ func (m *AppModel) handleProjectShortcut(digit byte) (tea.Model, tea.Cmd) { switch m.activeView { case "agents": - m.agentTable.SetScope("all") - m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}, {Kind: "agents", Scope: "all"}} - return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Viewing all agents")) + // Can't list all agents across projects — go back to projects view. + m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} + m.activeView = "projects" + return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Back to projects")) case "sessions": m.sessionTable.SetScope("all") m.navStack = []NavEntry{{Kind: "sessions", Scope: "all"}} @@ -2632,9 +2753,7 @@ func (m *AppModel) handleEditComplete(msg editCompleteMsg) (tea.Model, tea.Cmd) switch msg.ResourceKind { case "agent": editableFields = []string{ - "name", "prompt", "labels", "annotations", "description", - "display_name", "llm_model", "llm_max_tokens", "llm_temperature", - "repo_url", "environment_variables", "resource_overrides", + "name", "prompt", "labels", "annotations", } case "project": editableFields = []string{ diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/view.go b/components/ambient-cli/cmd/acpctl/ambient/tui/view.go index a5087f81b..5c0267912 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/view.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/view.go @@ -6,6 +6,8 @@ import ( "time" "github.com/charmbracelet/lipgloss" + + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" ) var ( @@ -52,7 +54,7 @@ func (m *Model) View() string { func (m *Model) renderHeader() string { age := "" if !m.lastFetch.IsZero() { - age = styleDim.Render(" refreshed " + fmtAge(time.Since(m.lastFetch)) + " ago") + age = styleDim.Render(" refreshed " + views.FormatAge(time.Since(m.lastFetch)) + " ago") } spin := "" if m.refreshing { @@ -306,13 +308,3 @@ func truncateLine(s string, w int) string { return s } -func fmtAge(d time.Duration) string { - d = d.Round(time.Second) - if d < time.Minute { - return fmt.Sprintf("%ds", int(d.Seconds())) - } - if d < time.Hour { - return fmt.Sprintf("%dm", int(d.Minutes())) - } - return fmt.Sprintf("%dh", int(d.Hours())) -} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go index c5bf64268..50c7ca116 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/detail.go @@ -91,10 +91,36 @@ func (dv *DetailView) Update(msg tea.Msg) (DetailView, tea.Cmd) { case "pgup": dv.moveCursor(-dv.viewportHeight()) case "c": - // Copy value of the current detail line to clipboard via OSC 52. - // The actual clipboard integration is handled by the terminal. - if dv.cursor >= 0 && dv.cursor < len(dv.lines) { - return *dv, copyToClipboard(dv.lines[dv.cursor].Value) + // Copy value of the current rendered line to clipboard. + // The cursor indexes rendered (wrapped) lines, so we map back to + // the source line's value via renderedLines. + rendered := dv.renderedLines() + if dv.cursor >= 0 && dv.cursor < len(rendered) { + line := rendered[dv.cursor] + // If this is a continuation line (empty Key), walk backwards + // to find the source key-value pair and copy its full value. + if line.Key == "" { + for j := dv.cursor - 1; j >= 0; j-- { + if rendered[j].Key != "" { + // Find the original source line by Key. + for _, src := range dv.lines { + if src.Key == rendered[j].Key { + return *dv, copyToClipboard(src.Value) + } + } + break + } + } + // Fallback: copy the continuation line's value. + return *dv, copyToClipboard(line.Value) + } + // Key-value line — find the full source value. + for _, src := range dv.lines { + if src.Key == line.Key { + return *dv, copyToClipboard(src.Value) + } + } + return *dv, copyToClipboard(line.Value) } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go index 52078c5f6..716efb400 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/help.go @@ -175,11 +175,12 @@ func maxFormattedKeyWidth(entries []HelpEntry) int { return maxW } -// padRight pads s with spaces to reach width w. If s is already wider, it is -// returned unmodified. +// padRight pads s with spaces to reach visual width w. Uses lipgloss.Width to +// correctly handle multi-byte Unicode characters and ANSI escape sequences. func padRight(s string, w int) string { - if len(s) >= w { + vw := lipgloss.Width(s) + if vw >= w { return s } - return s + strings.Repeat(" ", w-len(s)) + return s + strings.Repeat(" ", w-vw) } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index e0bff9aef..308f01075 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -27,6 +27,13 @@ type MsgStreamSendMsg struct { Body string } +// MsgStreamCopyMsg carries the result of a clipboard copy attempt. The parent +// handles this to display success or failure via the info line. +type MsgStreamCopyMsg struct { + Text string // the text that was (or was attempted to be) copied + Err error // non-nil if the clipboard write failed +} + // --------------------------------------------------------------------------- // Color palette (duplicated from parent tui package to avoid circular import) // --------------------------------------------------------------------------- @@ -42,6 +49,19 @@ var ( msgColorBlue = lipgloss.Color("69") ) +// Hoisted styles for the message stream View to avoid allocations on every frame. +var ( + msgBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + msgKindStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + msgScopeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Bold(true) + msgCountStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) + msgDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + msgDimIndicator = lipgloss.NewStyle().Foreground(msgColorDim) + msgActiveIndicator = lipgloss.NewStyle().Foreground(msgColorBlue) + msgCursorStyle = lipgloss.NewStyle().Foreground(msgColorOrange) + msgSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) +) + // eventColor returns the lipgloss color for a semantic event type. // This duplicates the 6-entry mapping from the parent tui.EventColor to avoid // a circular import. @@ -201,13 +221,15 @@ func eventFullText(eventType, payload string) string { return eventSummary(eventType, payload) } -// truncatePayload trims whitespace and truncates to max length. -func truncatePayload(s string, max int) string { +// truncatePayload trims whitespace and truncates to max runes (not bytes) to +// avoid splitting multi-byte UTF-8 characters. +func truncatePayload(s string, maxRunes int) string { s = strings.TrimSpace(s) - if len(s) <= max { + runes := []rune(s) + if len(runes) <= maxRunes { return s } - return s[:max-1] + "…" + return string(runes[:maxRunes-1]) + "…" } // extractJSONField extracts a string field from a JSON payload. @@ -352,11 +374,15 @@ func (ms *MessageStream) AddMessage(entry MessageEntry) { // Evict oldest — shift the slice. For a 2000-entry buffer this is // acceptable; a true ring buffer optimisation can come later. excess := len(ms.messages) - ms.maxMessages - ms.messages = ms.messages[excess:] - ms.scrollOffset -= excess - if ms.scrollOffset < 0 { - ms.scrollOffset = 0 + // Clean up glamour cache entries for evicted messages. + if ms.glamourCache != nil { + for _, evicted := range ms.messages[:excess] { + delete(ms.glamourCache, evicted.Seq) + } } + ms.messages = ms.messages[excess:] + // Don't adjust scrollOffset here — it's a display-line offset, not a + // message-array index. renderContent's clamp handles any overshoot. } ms.cachedDirty = true if ms.autoScroll { @@ -364,8 +390,15 @@ func (ms *MessageStream) AddMessage(entry MessageEntry) { } } -// SetSize updates the viewport dimensions. +// SetSize updates the viewport dimensions and invalidates caches that depend +// on width (glamour renderer and per-message glamour cache). func (ms *MessageStream) SetSize(w, h int) { + if w != ms.width { + // Width changed — glamour output is width-dependent. + ms.glamourRenderer = nil + ms.glamourCache = nil + ms.cachedDirty = true + } ms.width = w ms.height = h ms.composeInput.Width = max(w-lipgloss.Width(ms.composeInput.Prompt)-4, 20) @@ -531,18 +564,34 @@ func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { ms.scrollUp(1) return *ms, nil case "c": - // Copy the selected message text to clipboard. + // Copy the first visible message's payload to clipboard. + // scrollOffset is a display-line offset, so we iterate messages + // and count display lines to find the right one. if len(ms.messages) > 0 { - idx := ms.scrollOffset - if idx >= len(ms.messages) { - idx = len(ms.messages) - 1 - } - if idx >= 0 { - text := eventSummary(ms.messages[idx].EventType, ms.messages[idx].Payload) - if text == "" { - text = ms.messages[idx].Payload + lineCount := 0 + for _, entry := range ms.messages { + var entryLines []string + if ms.rawMode { + entryLines = ms.renderRawEntry(entry, max(ms.width-4, 20)) + } else { + entryLines = ms.renderConversationEntry(entry, max(ms.width-4, 20)) + } + if len(entryLines) == 0 { + continue + } + lineCount += len(entryLines) + if lineCount > ms.scrollOffset { + text := eventSummary(entry.EventType, entry.Payload) + if text == "" { + text = entry.Payload + } + // Return a command so the parent can handle + // clipboard write and display success/failure. + return *ms, func() tea.Msg { + err := clipboard.WriteAll(text) + return MsgStreamCopyMsg{Text: text, Err: err} + } } - _ = clipboard.WriteAll(text) } } return *ms, nil @@ -622,11 +671,11 @@ func (ms *MessageStream) View() string { return "Loading…" } - borderStyle := lipgloss.NewStyle().Foreground(msgColorDim) - kindStyle := lipgloss.NewStyle().Foreground(msgColorOrange).Bold(true) - scopeStyle := lipgloss.NewStyle().Foreground(msgColorBlue).Bold(true) - countStyle := lipgloss.NewStyle().Foreground(msgColorWhite).Bold(true) - dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) + borderStyle := msgBorderStyle + kindStyle := msgKindStyle + scopeStyle := msgScopeStyle + countStyle := msgCountStyle + dimStyle := msgDimStyle // -- k9s-style title bar: messages(agent/session)[count] -- shortID := ms.sessionID @@ -661,7 +710,7 @@ func (ms *MessageStream) View() string { prettyLabel = "On" } phaseStyle := lipgloss.NewStyle().Foreground(phaseColor(ms.phase)) - dimIndicator := lipgloss.NewStyle().Foreground(msgColorDim) + dimIndicator := msgDimIndicator tsLabel := "Off" switch ms.timestampMode { case 1: @@ -687,7 +736,7 @@ func (ms *MessageStream) View() string { } } - activeIndicator := lipgloss.NewStyle().Foreground(msgColorBlue) + activeIndicator := msgActiveIndicator renderToggle := func(label, value string, on bool) string { s := dimIndicator if on { @@ -711,8 +760,9 @@ func (ms *MessageStream) View() string { default: sseColor = msgColorRed } + sseStyle := lipgloss.NewStyle().Foreground(sseColor) indicators += fmt.Sprintf(" SSE:%s", - lipgloss.NewStyle().Foreground(sseColor).Render(ms.sseStatus)) + sseStyle.Render(ms.sseStatus)) } // Center the indicators line. indWidth := lipgloss.Width(indicators) @@ -741,7 +791,7 @@ func (ms *MessageStream) View() string { // Streaming cursor (when phase is running) — animated. if strings.ToLower(ms.phase) == "running" { - cursorStyle := lipgloss.NewStyle().Foreground(msgColorOrange) + cursorStyle := msgCursorStyle frames := []string{"▌", "▐", "█", "▐"} frame := frames[time.Now().UnixMilli()/300%4] cursor := cursorStyle.Render(" " + frame + " streaming…") @@ -790,24 +840,13 @@ func (ms *MessageStream) View() string { // renderContent produces the visible message lines for the content area. func (ms *MessageStream) renderContent(height int) []string { if len(ms.messages) == 0 { - dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) - return []string{dimStyle.Render("No messages yet.")} + return []string{msgDimStyle.Render("No messages yet.")} } - // Build all display lines from messages. + // Build all display lines from messages. Search filtering is already + // applied inside buildDisplayLines at the message level. allLines := ms.buildDisplayLines() - // Apply search filter — highlight matches. - if ms.searchPattern != nil { - filtered := make([]string, 0, len(allLines)) - for _, line := range allLines { - if ms.searchPattern.MatchString(stripANSI(line)) { - filtered = append(filtered, line) - } - } - allLines = filtered - } - // Apply scroll offset. total := len(allLines) if ms.scrollOffset > total-height { @@ -848,9 +887,8 @@ func (ms *MessageStream) buildDisplayLines() []string { lines := make([]string, 0, len(ms.messages)) - sepStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("236")) const tagPad = 14 - separator := strings.Repeat(" ", tagPad) + sepStyle.Render(strings.Repeat("─", max(maxLineWidth-tagPad, 10))) + separator := strings.Repeat(" ", tagPad) + msgSepStyle.Render(strings.Repeat("─", max(maxLineWidth-tagPad, 10))) now := time.Now() prevWasUserOrAssistant := false @@ -882,7 +920,7 @@ func (ms *MessageStream) buildDisplayLines() []string { // Prepend timestamp to the first line if timestamps are enabled. if ms.timestampMode > 0 && !entry.Timestamp.IsZero() { - tsStyle := lipgloss.NewStyle().Foreground(msgColorDim) + tsStyle := msgDimStyle var ts string if ms.timestampMode == 1 { d := now.Sub(entry.Timestamp) @@ -939,12 +977,15 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true) textStyle := lipgloss.NewStyle().Foreground(color) + // Sanitize payload to strip ANSI escapes and control characters from agent output. + sanitizedPayload := SanitizePayload(entry.Payload) + // Choose full text or truncated summary based on wrapMode. var displayText string if ms.wrapMode { - displayText = eventFullText(entry.EventType, entry.Payload) + displayText = eventFullText(entry.EventType, sanitizedPayload) } else { - displayText = eventSummary(entry.EventType, entry.Payload) + displayText = eventSummary(entry.EventType, sanitizedPayload) } if displayText == "" { // Suppressed event types (TOOL_CALL_ARGS, etc.) — don't render. @@ -974,7 +1015,7 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in } else { glamourWidth := max(ms.width-20, 20) if r := ms.getGlamourRenderer(glamourWidth); r != nil { - out, err := r.Render(strings.TrimSpace(entry.Payload)) + out, err := r.Render(strings.TrimSpace(sanitizedPayload)) if err == nil { rendered = strings.TrimSpace(out) ms.glamourCache[entry.Seq] = rendered @@ -1014,7 +1055,10 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in // renderRawEntry renders a single message as a JSON line in raw mode. func (ms *MessageStream) renderRawEntry(entry MessageEntry, maxWidth int) []string { - dimStyle := lipgloss.NewStyle().Foreground(msgColorDim) + dimStyle := msgDimStyle + + // Sanitize payload to strip ANSI escapes and control characters from agent output. + sanitizedPayload := SanitizePayload(entry.Payload) raw := struct { Seq int `json:"seq"` @@ -1024,7 +1068,7 @@ func (ms *MessageStream) renderRawEntry(entry MessageEntry, maxWidth int) []stri }{ Seq: entry.Seq, EventType: entry.EventType, - Payload: entry.Payload, + Payload: sanitizedPayload, Timestamp: entry.Timestamp.Format(time.RFC3339), } @@ -1067,18 +1111,19 @@ func (ms *MessageStream) scrollToBottom() { } // contentHeight returns the usable content height given the current dimensions. +// This must match the calculation in View() to avoid scroll/display mismatches. func (ms *MessageStream) contentHeight() int { - // Approximate: total height minus header (3 lines) minus status/compose/cursor. - h := ms.height - 5 + // Top: title bar + indicator line + header separator = 3 lines. + topLines := 3 + // Bottom: bottom border = 1 line. + bottomLines := 1 if ms.composeMode { - h -= 2 + bottomLines += 2 // compose separator + compose line } if strings.ToLower(ms.phase) == "running" { - h-- - } - if ms.searchMode { - h -= 2 + bottomLines++ // streaming cursor line } + h := ms.height - topLines - bottomLines if h < 1 { h = 1 } @@ -1096,9 +1141,10 @@ func (ms *MessageStream) enterComposeMode() { // Text helpers // --------------------------------------------------------------------------- -// wrapText breaks a string into lines of at most maxWidth characters. +// wrapText breaks a string into lines of at most maxWidth visual characters. // It splits on word boundaries where possible, falling back to hard breaks -// for very long tokens. +// for very long tokens. Uses rune-aware operations and lipgloss.Width for +// visual width measurement to avoid splitting multi-byte UTF-8 characters. func wrapText(s string, maxWidth int) []string { if maxWidth <= 0 { maxWidth = 80 @@ -1119,7 +1165,7 @@ func wrapText(s string, maxWidth int) []string { current := words[0] for _, word := range words[1:] { - if len(current)+1+len(word) <= maxWidth { + if lipgloss.Width(current)+1+lipgloss.Width(word) <= maxWidth { current += " " + word } else { lines = append(lines, current) @@ -1131,9 +1177,19 @@ func wrapText(s string, maxWidth int) []string { // Hard-break any lines that still exceed maxWidth (long single tokens). var result []string for _, line := range lines { - for len(line) > maxWidth { - result = append(result, line[:maxWidth]) - line = line[maxWidth:] + for lipgloss.Width(line) > maxWidth { + // Slice by rune to avoid splitting multi-byte characters. + runes := []rune(line) + take := len(runes) + // Binary-ish search: start from end and find the cut point. + for take > 0 && lipgloss.Width(string(runes[:take])) > maxWidth { + take-- + } + if take == 0 { + take = 1 // always make progress + } + result = append(result, string(runes[:take])) + line = string(runes[take:]) } result = append(result, line) } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/sanitize.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sanitize.go new file mode 100644 index 000000000..47f0e6054 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/sanitize.go @@ -0,0 +1,56 @@ +package views + +import ( + "regexp" + "strings" + "unicode/utf8" +) + +// ANSI CSI sequences: ESC [ ... <final byte> +var viewsCsiRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +// ANSI OSC sequences: ESC ] ... (terminated by BEL or ST) +var viewsOscRe = regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`) + +// lipgloss/tview region tags: ["regionid"] +var viewsRegionTagRe = regexp.MustCompile(`\["[^"]*"\]`) + +// SanitizePayload strips dangerous content from agent-produced output before +// terminal rendering. It removes: +// - ANSI CSI escape sequences (\x1b[...) +// - ANSI OSC escape sequences (\x1b]...) +// - C0 control characters (0x00-0x1F) except tab (0x09) and newline (0x0A) +// - C1 control characters (0x80-0x9F) +// - lipgloss/tview region tags (["..."]) +// +// This is equivalent to the Sanitize function in the parent tui package, +// duplicated here to avoid a circular import. +func SanitizePayload(s string) string { + s = viewsCsiRe.ReplaceAllString(s, "") + s = viewsOscRe.ReplaceAllString(s, "") + s = viewsRegionTagRe.ReplaceAllString(s, "") + + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + switch { + case r == '\t' || r == '\n': + b.WriteRune(r) + case r <= 0x1F: + // C0 control character — drop. + case r >= 0x80 && r <= 0x9F: + // C1 control character (valid 2-byte UTF-8 encoding) — drop. + case r == utf8.RuneError && size == 1: + if s[i] >= 0x80 && s[i] <= 0x9F { + // C1 control byte — drop. + } else { + b.WriteByte(s[i]) + } + default: + b.WriteString(s[i : i+size]) + } + i += size + } + return b.String() +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 76c6df2d2..63313464f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -97,6 +97,14 @@ type ResourceTable struct { // columns stores the original column definitions for sort indicator rendering. columns []table.Column + + // Cached styles derived from the TableStyle — set once during construction + // and updated in SetWidth. Avoids lipgloss.NewStyle() allocations per frame. + styleBorder lipgloss.Style + styleKind lipgloss.Style + styleScope lipgloss.Style + styleCount lipgloss.Style + styleDim lipgloss.Style } // NewResourceTable creates a ResourceTable configured with the given resource kind, @@ -138,6 +146,11 @@ func NewResourceTable(kind string, scope string, columns []table.Column, style T colIdx: -1, direction: SortNone, }, + styleBorder: lipgloss.NewStyle().Foreground(style.BorderColor), + styleKind: lipgloss.NewStyle().Foreground(style.TitleColor).Bold(true), + styleScope: lipgloss.NewStyle().Foreground(style.ScopeColor).Bold(true), + styleCount: lipgloss.NewStyle().Foreground(style.CountColor).Bold(true), + styleDim: lipgloss.NewStyle().Foreground(style.DimColor), } } @@ -158,10 +171,31 @@ func (rt *ResourceTable) SetKind(kind string) { } // SetRows replaces all data rows. Filtering and sorting are re-applied. +// The previously selected row's key (first column) is preserved if still present. func (rt *ResourceTable) SetRows(rows []table.Row) { + // Capture current selection key before replacing data. + var selectedKey string + if oldRows := rt.inner.Rows(); len(oldRows) > 0 { + cursor := rt.inner.Cursor() + if cursor >= 0 && cursor < len(oldRows) && len(oldRows[cursor]) > 0 { + selectedKey = oldRows[cursor][0] + } + } + rt.allRows = make([]table.Row, len(rows)) copy(rt.allRows, rows) rt.applyFilterAndSort() + + // Restore cursor to the row with the same key. + if selectedKey != "" { + visibleRows := rt.inner.Rows() + for i, row := range visibleRows { + if len(row) > 0 && row[0] == selectedKey { + rt.inner.SetCursor(i) + return + } + } + } } // SetRowColorFunc sets a function that determines the foreground color for each @@ -361,7 +395,7 @@ func (rt *ResourceTable) updateSelectedStyle() { // // using box-drawing characters and the configured border color. func (rt *ResourceTable) View() string { - borderStyle := lipgloss.NewStyle().Foreground(rt.style.BorderColor) + borderStyle := rt.cachedBorderStyle() w := rt.inner.Width() if w < 4 { w = 80 @@ -421,18 +455,17 @@ func (rt *ResourceTable) View() string { // The title is centered: ┌──── kind(scope)[count] ────┐ // kind=cyan, scope=magenta, count=blue (matching k9s colors). func (rt *ResourceTable) renderTitleBar() string { - borderStyle := lipgloss.NewStyle().Foreground(rt.style.BorderColor) - kindStyle := lipgloss.NewStyle().Foreground(rt.style.TitleColor).Bold(true) - scopeStyle := lipgloss.NewStyle().Foreground(rt.style.ScopeColor).Bold(true) - countStyle := lipgloss.NewStyle().Foreground(rt.style.CountColor).Bold(true) + borderStyle := rt.cachedBorderStyle() + kindStyle := rt.cachedKindStyle() + scopeStyle := rt.cachedScopeStyle() + countStyle := rt.cachedCountStyle() count := len(rt.inner.Rows()) filterPart := "" if rt.filterText != "" { - filterStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) - filterPart = " " + filterStyle.Render("</"+rt.filterText+">") + filterPart = " " + rt.styleKind.Render("</"+rt.filterText+">") } - dimStyle := lipgloss.NewStyle().Foreground(rt.style.DimColor) + dimStyle := rt.styleDim titleRendered := " " + kindStyle.Render(rt.kind) + dimStyle.Render("(") + scopeStyle.Render(rt.scope) + dimStyle.Render(")") + @@ -532,3 +565,22 @@ func cellValue(row table.Row, colIdx int) string { } return row[colIdx] } + +// Cached style accessors — return the pre-built styles stored on the struct. +// Initialised in NewResourceTable; no allocations per call. + +func (rt *ResourceTable) cachedBorderStyle() lipgloss.Style { + return rt.styleBorder +} + +func (rt *ResourceTable) cachedKindStyle() lipgloss.Style { + return rt.styleKind +} + +func (rt *ResourceTable) cachedScopeStyle() lipgloss.Style { + return rt.styleScope +} + +func (rt *ResourceTable) cachedCountStyle() lipgloss.Style { + return rt.styleCount +} From fb982ae76d561b0e1f27c4a912253c95cfe0ee6b Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 11:45:36 -0400 Subject: [PATCH 090/117] fix(cli): refactor dialog key handling and fix message scroll on mode toggle - Refactor handleDialogKey to return cmd to bubbletea runtime instead of calling cmd() synchronously inline. DialogConfirmMsg and DialogCancelMsg are now handled in the Update type switch like all other messages. - Fix messages not scrolling to bottom when toggling pretty (p) or raw (r) mode while autoscroll is enabled. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../cmd/acpctl/ambient/tui/model_new.go | 57 +++++++++---------- .../cmd/acpctl/ambient/tui/views/messages.go | 6 ++ 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 86a85d53c..092f07bb0 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -556,6 +556,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case InboxMsg: return m.handleInboxMsg(msg) + case views.DialogCancelMsg: + m.dialog = nil + m.dialogAction = nil + return m, m.setInfo("Cancelled") + + case views.DialogConfirmMsg: + return m.handleDialogConfirm(msg) + case views.MsgStreamCopyMsg: // Clipboard copy result from the message stream sub-model. if msg.Err != nil { @@ -1327,43 +1335,34 @@ func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // handleDialogKey delegates key events to the active dialog overlay and -// processes the resulting DialogConfirmMsg / DialogCancelMsg. +// returns the resulting command to the bubbletea runtime for dispatch. func (m *AppModel) handleDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { dlg, cmd := m.dialog.Update(msg) m.dialog = &dlg + return m, cmd +} - if cmd == nil { - return m, nil - } - - // Execute the command to get the message, then dispatch it. - resultMsg := cmd() - switch resultMsg.(type) { - case views.DialogCancelMsg: +// handleDialogResult processes DialogConfirmMsg / DialogCancelMsg delivered +// by the bubbletea runtime (rather than being called inline). +func (m *AppModel) handleDialogConfirm(confirm views.DialogConfirmMsg) (tea.Model, tea.Cmd) { + if confirm.Confirmed { + fn := m.dialogAction m.dialog = nil m.dialogAction = nil - return m, m.setInfo("Cancelled") - case views.DialogConfirmMsg: - confirm := resultMsg.(views.DialogConfirmMsg) - if confirm.Confirmed { - fn := m.dialogAction - m.dialog = nil - m.dialogAction = nil - if fn != nil { - return m, tea.Batch(fn(confirm.Value), m.setInfo("Processing...")) - } - } else { - m.dialog = nil - infoText := "Cancelled" - if m.dialogAction == nil { - // Error/info dialog (single-button dismiss) — not a cancel. - infoText = "Dismissed" - } - m.dialogAction = nil - return m, m.setInfo(infoText) + if fn != nil { + return m, tea.Batch(fn(confirm.Value), m.setInfo("Processing...")) + } + } else { + m.dialog = nil + infoText := "Cancelled" + if m.dialogAction == nil { + infoText = "Dismissed" } + m.dialogAction = nil + return m, m.setInfo(infoText) } - + m.dialog = nil + m.dialogAction = nil return m, nil } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 308f01075..7bc596ea0 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -533,9 +533,15 @@ func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { switch msg.String() { case "r": ms.rawMode = !ms.rawMode + if ms.autoScroll { + ms.scrollToBottom() + } return *ms, nil case "p": ms.wrapMode = !ms.wrapMode + if ms.autoScroll { + ms.scrollToBottom() + } return *ms, nil case "t": ms.timestampMode = (ms.timestampMode + 1) % 3 From f04bab9fab56c95df541c154044005e6509f35e1 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 17:04:19 -0400 Subject: [PATCH 091/117] feat(cli): use AG-UI event stream for live session messages Replace REST polling with the AG-UI /events SSE stream for running sessions, giving the TUI visibility into tool calls, reasoning, and other intermediate events that the DB-backed /messages endpoint never surfaces. - Add WatchSessionEvents to TUIClient (client.go) that connects to StreamEvents, parses SSE data lines, extracts the AG-UI event type, and sends SessionMessageEvent to the bubbletea program with reconnection and exponential backoff - Wire up messages view entry points (model_new.go) to use events stream for running/active/pending sessions, falling back to polling for completed/stopped/failed sessions - Unsuppress TOOL_CALL_ARGS in eventSummary and handle all AG-UI event types (RUN_STARTED, REASONING_*, STEP_*, CUSTOM, etc.) in both eventSummary and eventFullText (views/messages.go) - Add colors for AG-UI event types and show SSE connection status (connected/connecting/reconnecting/polling) in the indicator bar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../cmd/acpctl/ambient/tui/client.go | 185 ++++++++++++++++++ .../cmd/acpctl/ambient/tui/model_new.go | 36 +++- .../cmd/acpctl/ambient/tui/views/messages.go | 110 ++++++++++- 3 files changed, 317 insertions(+), 14 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index 7d78d733d..fb2a795ec 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -1,8 +1,11 @@ package tui import ( + "bufio" "context" + "encoding/json" "fmt" + "strings" "sync" "time" @@ -857,6 +860,188 @@ func (tc *TUIClient) WatchSessionMessages(projectID, sessionID string, afterSeq } } +// WatchSessionEvents returns a tea.Cmd that starts an SSE stream for +// AG-UI events via the /events endpoint. Events are delivered to the Bubbletea +// program via program.Send(SessionMessageEvent{...}). +// +// Unlike WatchSessionMessages (which reads /messages), this connects to +// GET /sessions/{id}/events and receives the full AG-UI event stream: +// TEXT_MESSAGE_CONTENT, TOOL_CALL_START, TOOL_CALL_ARGS, RUN_FINISHED, etc. +// +// The SSE goroutine: +// - Connects to GET /sessions/{id}/events via the SDK's StreamEvents. +// - Parses the SSE stream line by line (data: <JSON>). +// - Extracts the "type" field from each JSON event and maps it to a +// SessionMessageEvent with EventType and Payload fields. +// - Handles reconnection with exponential backoff (1s, 2s, 4s, max 30s). +// - Is cancellable via StopWatching(). +// +// Only one watch can be active at a time. Calling WatchSessionEvents while +// a previous watch is running cancels the old one first. +func (tc *TUIClient) WatchSessionEvents(projectID, sessionID string, program *tea.Program) tea.Cmd { + return func() tea.Msg { + // Cancel any previously active watch. + tc.StopWatching() + + ctx, cancel := context.WithCancel(context.Background()) + + tc.watchMu.Lock() + tc.watchCancel = cancel + tc.watchMu.Unlock() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + cancel() + program.Send(SessionMessageEvent{Err: err}) + return nil + } + + // SSE reconnection loop with exponential backoff. + go func() { + defer cancel() + + seq := 0 + backoff := 1 * time.Second + const maxBackoff = 30 * time.Second + const scannerBufSize = 1 << 20 + + for { + if ctx.Err() != nil { + return + } + + body, sseErr := client.Sessions().StreamEvents(ctx, sessionID) + if sseErr != nil { + if ctx.Err() != nil { + return + } + program.Send(SessionMessageEvent{ + Err: fmt.Errorf("events stream connect: %w", sseErr), + }) + // Wait and retry. + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + continue + } + + // Connected — reset backoff and notify. + backoff = 1 * time.Second + program.Send(SessionMessageEvent{ + Message: &sdktypes.SessionMessage{ + EventType: "system", + Payload: "connected to event stream", + }, + }) + + // Parse SSE stream line by line. + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, scannerBufSize), scannerBufSize) + + var dataBuf strings.Builder + streamDone := false + + for scanner.Scan() { + if ctx.Err() != nil { + body.Close() //nolint:errcheck + return + } + + line := scanner.Text() + + switch { + case line == ": heartbeat": + // Ignore SSE heartbeat comments. + continue + + case strings.HasPrefix(line, "data: "): + if dataBuf.Len() > 0 { + dataBuf.WriteByte('\n') + } + dataBuf.WriteString(strings.TrimPrefix(line, "data: ")) + + case line == "": + if dataBuf.Len() == 0 { + continue + } + data := dataBuf.String() + dataBuf.Reset() + + // Parse the JSON to extract "type" field. + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(data), &raw); err != nil { + continue + } + + // Extract event type. + var eventType string + if typeField, ok := raw["type"]; ok { + _ = json.Unmarshal(typeField, &eventType) + } + if eventType == "" { + continue + } + + seq++ + program.Send(SessionMessageEvent{ + Message: &sdktypes.SessionMessage{ + Seq: seq, + EventType: eventType, + Payload: data, + }, + }) + + // Close the stream on terminal events. + if eventType == "RUN_FINISHED" || eventType == "RUN_ERROR" { + streamDone = true + } + } + + if streamDone { + break + } + } + + body.Close() //nolint:errcheck + + if ctx.Err() != nil { + return + } + + // If the stream ended normally (RUN_FINISHED/RUN_ERROR), we're done. + if streamDone { + return + } + + // Unexpected close — reconnect. + if scanErr := scanner.Err(); scanErr != nil { + program.Send(SessionMessageEvent{ + Err: fmt.Errorf("events stream read: %w", scanErr), + }) + } + + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + }() + + return nil + } +} + // FetchSessionMessages returns a tea.Cmd that polls session messages via the // REST ListMessages endpoint. This is used as a fallback when SSE streaming is // unavailable or stalled. Only messages with seq > afterSeq are returned. diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 092f07bb0..224586e26 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -35,6 +35,13 @@ const infoTimeout = 5 * time.Second // staleThreshold marks data as stale in the header when exceeded. const staleThreshold = 15 * time.Second +// isRunningPhase returns true if the session phase indicates the session is +// currently running and can produce live AG-UI events. +func isRunningPhase(phase string) bool { + p := strings.ToLower(phase) + return p == "running" || p == "active" || p == "pending" +} + // --------------------------------------------------------------------------- // Navigation // --------------------------------------------------------------------------- @@ -1567,12 +1574,17 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { projectID = session.ProjectID } - // Use polling for messages (SSE disabled — the synchronous SSE - // connection setup blocks the UI for several seconds). if projectID != "" { - m.messagePollActive = true - cmds = append(cmds, m.messagePollTickCmd()) - cmds = append(cmds, m.client.FetchSessionMessages(projectID, fullSessionID, 0)) + // Use AG-UI event stream for running sessions; poll /messages + // for completed/stopped/failed sessions (historical replay). + if isRunningPhase(phase) && m.program != nil { + m.messageStream.SetSSEStatus("connecting") + cmds = append(cmds, m.client.WatchSessionEvents(projectID, fullSessionID, m.program)) + } else { + m.messagePollActive = true + cmds = append(cmds, m.messagePollTickCmd()) + cmds = append(cmds, m.client.FetchSessionMessages(projectID, fullSessionID, 0)) + } } return m, tea.Batch(cmds...) @@ -1823,12 +1835,16 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { m.setInfo("Streaming messages for session " + sessionID), } - // Use polling for messages (SSE disabled — blocks UI). if m.currentProject != "" { - m.messagePollActive = true - cmds = append(cmds, m.messagePollTickCmd()) - // Immediately fetch existing messages so the view is not empty. - cmds = append(cmds, m.client.FetchSessionMessages(m.currentProject, sessionID, 0)) + // Active agent sessions are running — use the AG-UI event stream. + if m.program != nil { + m.messageStream.SetSSEStatus("connecting") + cmds = append(cmds, m.client.WatchSessionEvents(m.currentProject, sessionID, m.program)) + } else { + m.messagePollActive = true + cmds = append(cmds, m.messagePollTickCmd()) + cmds = append(cmds, m.client.FetchSessionMessages(m.currentProject, sessionID, 0)) + } } return m, tea.Batch(cmds...) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 7bc596ea0..028eb829f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -77,8 +77,20 @@ func eventColor(eventType string) lipgloss.Color { return msgColorDim // 240 case "system": return msgColorYellow // 33 - case "error": + case "error", "RUN_ERROR": return msgColorRed // 31 + case "TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END": + return msgColorBlue + case "TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT": + return msgColorCyan + case "RUN_STARTED", "RUN_FINISHED": + return msgColorGreen + case "REASONING_START", "REASONING_MESSAGE_START", + "REASONING_MESSAGE_CONTENT", "REASONING_MESSAGE_END", + "REASONING_END": + return msgColorOrange + case "STEP_STARTED", "STEP_FINISHED": + return msgColorYellow default: return msgColorDim } @@ -151,6 +163,9 @@ func eventSummary(eventType, payload string) string { if name == "" { name = extractJSONField(payload, "tool_name") } + if name == "" { + name = extractJSONField(payload, "toolCallName") + } if name != "" { return "⚙ " + name } @@ -167,7 +182,44 @@ func eventSummary(eventType, payload string) string { return "✗ error" case "TEXT_MESSAGE_START": return "…" - case "TEXT_MESSAGE_END", "TOOL_CALL_ARGS", "TOOL_CALL_END": + case "TOOL_CALL_ARGS": + delta := extractJSONField(payload, "delta") + if delta != "" { + return truncatePayload(delta, 120) + } + return "" + case "TEXT_MESSAGE_END", "TOOL_CALL_END": + return "" + case "RUN_STARTED": + threadID := extractJSONField(payload, "threadId") + if threadID != "" { + return "run started (thread " + truncatePayload(threadID, 40) + ")" + } + return "run started" + case "REASONING_START", "REASONING_END", + "REASONING_MESSAGE_START", "REASONING_MESSAGE_END": + return "" + case "MESSAGES_SNAPSHOT": + return "[snapshot]" + case "STATE_SNAPSHOT", "STATE_DELTA": + return "" + case "STEP_STARTED": + name := extractJSONField(payload, "stepName") + if name != "" { + return "step: " + name + } + return "" + case "STEP_FINISHED": + return "" + case "ACTIVITY_SNAPSHOT", "ACTIVITY_DELTA": + return "" + case "CUSTOM": + name := extractJSONField(payload, "name") + if name != "" { + return "custom: " + name + } + return "" + case "RAW": return "" } if payload != "" && len(payload) <= 120 { @@ -216,8 +268,52 @@ func eventFullText(eventType, payload string) string { return "✗ " + strings.TrimSpace(payload) } return "✗ unknown error" + case "TOOL_CALL_ARGS": + delta := extractJSONField(payload, "delta") + if delta != "" { + return strings.TrimSpace(delta) + } + return "" + case "TEXT_MESSAGE_CONTENT", "REASONING_MESSAGE_CONTENT": + delta := extractJSONField(payload, "delta") + if delta != "" { + return strings.TrimSpace(delta) + } + return "" + case "TOOL_CALL_START": + name := extractJSONField(payload, "tool_call_name") + if name == "" { + name = extractJSONField(payload, "tool_name") + } + if name == "" { + name = extractJSONField(payload, "toolCallName") + } + if name != "" { + return "⚙ " + name + } + return "" + case "TOOL_CALL_RESULT": + content := extractJSONField(payload, "content") + if content != "" { + return strings.TrimSpace(content) + } + return "" + case "RUN_FINISHED": + return "[done]" + case "RUN_ERROR": + msg := extractJSONField(payload, "message") + if msg != "" { + return "✗ " + strings.TrimSpace(msg) + } + return "✗ error" + case "RUN_STARTED": + threadID := extractJSONField(payload, "threadId") + if threadID != "" { + return "run started (thread " + strings.TrimSpace(threadID) + ")" + } + return "run started" } - // Fallback: same as eventSummary for streaming event types. + // Fallback: same as eventSummary for other streaming event types. return eventSummary(eventType, payload) } @@ -758,11 +854,17 @@ func (ms *MessageStream) View() string { phaseStyle.Render(ms.phase), dimIndicator.Render(scrollPct), ) - if ms.sseStatus != "" && ms.sseStatus != "connected" { + if ms.sseStatus != "" { var sseColor lipgloss.Color switch ms.sseStatus { + case "connected": + sseColor = msgColorGreen + case "connecting": + sseColor = msgColorYellow case "reconnecting": sseColor = msgColorYellow + case "polling": + sseColor = msgColorDim default: sseColor = msgColorRed } From 7da991f09167a7adc108e7e81f6c96c0eea508f6 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 17:05:19 -0400 Subject: [PATCH 092/117] =?UTF-8?q?docs(tui):=20update=20spec=20=E2=80=94?= =?UTF-8?q?=20dual-stream=20message=20source=20with=20AG-UI=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- docs/internal/design/tui.spec.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/internal/design/tui.spec.md b/docs/internal/design/tui.spec.md index 787e39b0a..2d7c44ad9 100644 --- a/docs/internal/design/tui.spec.md +++ b/docs/internal/design/tui.spec.md @@ -259,9 +259,13 @@ Accessible globally (`:sessions` — all sessions across all projects) or scoped #### Data Source -The TUI connects to **`GET /sessions/{id}/messages`** (SSE). This single endpoint handles both replay and live delivery server-side: it loads all messages after a cursor via `AllBySessionIDAfterSeq`, subscribes to the pub/sub channel, replays the historical batch, then switches to live delivery — deduplicating by `msg.Seq`. The TUI does not need to coordinate two endpoints. +The TUI uses a **dual-stream strategy** for session messages: -The `/events` endpoint (raw runner SSE) is not used. `/messages` is the durable, replay-safe stream. +1. **Live sessions** (`phase == Running`): Connect to **`GET /sessions/{id}/events`** (AG-UI SSE stream). This proxies raw events from the runner pod, including tool calls (`tool_use`, `tool_result`, `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_RESULT`), text deltas, and system events. This gives operators full visibility into agent activity as it happens. + +2. **Historical replay / fallback**: Connect to **`GET /sessions/{id}/messages`** (DB-backed SSE). This endpoint serves durable `user`/`assistant` messages from the API server's database. Used when the session is not running (completed, stopped, failed) or when the `/events` stream fails. + +The SDK provides `StreamEvents(ctx, sessionID)` for the live AG-UI stream and `WatchMessages(ctx, sessionID, afterSeq)` for the DB-backed stream. The TUI prefers `/events` for running sessions and falls back to `/messages` for completed sessions or on error. #### Display Modes @@ -294,6 +298,15 @@ The `/events` endpoint (raw runner SSE) is not used. `/messages` is the durable, | `assistant` | Full text, green. For streaming: accumulate `TEXT_MESSAGE_CONTENT` deltas into a growing line, re-render on each delta. Show `▌` cursor at end until `TEXT_MESSAGE_END`. | | `tool_use` | One-line summary: tool name + first arg, truncated to terminal width. Dim. | | `tool_result` | One-line summary: `✓` or `✗` + size. Dim. Expandable via `Enter` on the line (future). | +| `TOOL_CALL_START` | One-line summary: `⚙ tool_name`. Dim. | +| `TOOL_CALL_ARGS` | Tool input args (truncated in default mode, full in pretty mode). Dim. | +| `TOOL_CALL_RESULT` | Tool output content (truncated in default mode, full in pretty mode). Dim. | +| `TOOL_CALL_END` | Suppressed (no visual output). | +| `TEXT_MESSAGE_START` | Suppressed (streaming start marker). | +| `TEXT_MESSAGE_CONTENT` | Delta text, accumulated into the current assistant message. | +| `TEXT_MESSAGE_END` | Suppressed (streaming end marker). | +| `RUN_FINISHED` | `[done]` marker. Dim. | +| `RUN_ERROR` | `✗` + error message. Red. | | `system` | Full text, yellow | | `error` | Full text, red | @@ -468,7 +481,8 @@ Examples: |----------|--------|----------| | Projects, Agents, Inbox | REST `GET` polling | 5s (hardcoded) | | Sessions | gRPC `WatchSessions` stream; fallback to REST polling | Real-time / 5s | -| Session Messages | SSE stream (`GET /sessions/{id}/messages`) | Real-time | +| Session Messages (live) | AG-UI SSE stream (`GET /sessions/{id}/events`) | Real-time | +| Session Messages (replay) | DB-backed SSE (`GET /sessions/{id}/messages`) | Real-time | Polling is **skip-on-inflight**: if the previous request has not completed, the next tick is skipped. This prevents request stacking under degraded API conditions. From a73c46e0cc18950be884757587ff44ae0c21783f Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 17:09:07 -0400 Subject: [PATCH 093/117] fix(cli): prefill historical messages before starting AG-UI event stream Fetch /messages first for conversation history, then start /events for live streaming. Applies to both session enter and agent logs paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 224586e26..a6172bca5 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1575,15 +1575,18 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { } if projectID != "" { - // Use AG-UI event stream for running sessions; poll /messages - // for completed/stopped/failed sessions (historical replay). + // Always fetch historical messages first for context. + cmds = append(cmds, m.client.FetchSessionMessages(projectID, fullSessionID, 0)) + if isRunningPhase(phase) && m.program != nil { + // For running sessions, also start the AG-UI event stream + // for live tool calls and text deltas. m.messageStream.SetSSEStatus("connecting") cmds = append(cmds, m.client.WatchSessionEvents(projectID, fullSessionID, m.program)) } else { + // For completed sessions, poll /messages for any updates. m.messagePollActive = true cmds = append(cmds, m.messagePollTickCmd()) - cmds = append(cmds, m.client.FetchSessionMessages(projectID, fullSessionID, 0)) } } @@ -1836,14 +1839,14 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { } if m.currentProject != "" { - // Active agent sessions are running — use the AG-UI event stream. + // Fetch history first, then start live stream. + cmds = append(cmds, m.client.FetchSessionMessages(m.currentProject, sessionID, 0)) if m.program != nil { m.messageStream.SetSSEStatus("connecting") cmds = append(cmds, m.client.WatchSessionEvents(m.currentProject, sessionID, m.program)) } else { m.messagePollActive = true cmds = append(cmds, m.messagePollTickCmd()) - cmds = append(cmds, m.client.FetchSessionMessages(m.currentProject, sessionID, 0)) } } From ad0eeeb51ecee41ea0f5ea900ad4690798fd0dee Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 17:11:18 -0400 Subject: [PATCH 094/117] fix(cli): accumulate AG-UI streaming deltas into single growing messages TEXT_MESSAGE_CONTENT and TOOL_CALL_ARGS events were each added as separate MessageEntry lines, producing dozens of fragment rows instead of one coherent message. Add HandleStreamEvent() to MessageStream that coalesces deltas in place via a strings.Builder accumulator, and route AG-UI event types through it from the SessionMessageEvent handler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../cmd/acpctl/ambient/tui/model_new.go | 15 +- .../cmd/acpctl/ambient/tui/views/messages.go | 151 +++++++++++++++++- 2 files changed, 161 insertions(+), 5 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index a6172bca5..bd9536884 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -787,12 +787,23 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Message.CreatedAt != nil { ts = *msg.Message.CreatedAt } - m.messageStream.AddMessage(views.MessageEntry{ + entry := views.MessageEntry{ Seq: msg.Message.Seq, EventType: msg.Message.EventType, Payload: msg.Message.Payload, Timestamp: ts, - }) + } + // Route AG-UI streaming events through the accumulator so that + // deltas are coalesced into a single growing message instead of + // appearing as dozens of fragment lines. + switch msg.Message.EventType { + case "TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END", + "TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", + "TOOL_CALL_RESULT": + m.messageStream.HandleStreamEvent(entry) + default: + m.messageStream.AddMessage(entry) + } // Track highest seq for polling. if msg.Message.Seq > m.lastMessageSeq { m.lastMessageSeq = msg.Message.Seq diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 028eb829f..747437c80 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -408,6 +408,12 @@ type MessageStream struct { glamourRenderer *glamour.TermRenderer glamourWidth int // width used to create the cached renderer + // Streaming accumulator — coalesces AG-UI deltas into a single growing entry. + streamingEntry *MessageEntry // the in-progress text message being accumulated + streamingText strings.Builder // accumulated text for the current text message + streamingToolEntry *MessageEntry // the in-progress tool call being accumulated + streamingToolArgs strings.Builder // accumulated args for the current tool call + // Cached display lines — rebuilt when mode/messages change, not every frame. cachedLines []string cachedDirty bool // true when lines need rebuilding @@ -486,6 +492,144 @@ func (ms *MessageStream) AddMessage(entry MessageEntry) { } } +// HandleStreamEvent processes AG-UI streaming events by accumulating deltas +// into a single growing message entry instead of creating a separate entry per +// delta. This produces clean output where text streams in place rather than +// appearing as dozens of fragment lines. +func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { + switch entry.EventType { + + // -- Text message accumulation -- + + case "TEXT_MESSAGE_START": + // Begin a new assistant message slot. + ms.streamingText.Reset() + newEntry := MessageEntry{ + Seq: entry.Seq, + EventType: "assistant", + Payload: "", + Timestamp: entry.Timestamp, + } + ms.streamingEntry = &newEntry + ms.messages = append(ms.messages, newEntry) + ms.evictIfNeeded() + ms.cachedDirty = true + if ms.autoScroll { + ms.scrollToBottom() + } + + case "TEXT_MESSAGE_CONTENT": + delta := extractJSONField(entry.Payload, "delta") + if delta == "" { + return + } + ms.streamingText.WriteString(delta) + // Update the last message entry in place. + if ms.streamingEntry != nil && len(ms.messages) > 0 { + ms.messages[len(ms.messages)-1].Payload = ms.streamingText.String() + // Invalidate glamour cache for this entry since content changed. + if ms.glamourCache != nil { + delete(ms.glamourCache, ms.messages[len(ms.messages)-1].Seq) + } + ms.cachedDirty = true + if ms.autoScroll { + ms.scrollToBottom() + } + } + + case "TEXT_MESSAGE_END": + // Finalize the accumulated text message. + ms.streamingEntry = nil + ms.cachedDirty = true + + // -- Tool call accumulation -- + + case "TOOL_CALL_START": + ms.streamingToolArgs.Reset() + // Extract the tool name from the payload. + toolName := extractJSONField(entry.Payload, "tool_call_name") + if toolName == "" { + toolName = extractJSONField(entry.Payload, "tool_name") + } + if toolName == "" { + toolName = extractJSONField(entry.Payload, "toolCallName") + } + if toolName == "" { + toolName = extractJSONField(entry.Payload, "name") + } + newEntry := MessageEntry{ + Seq: entry.Seq, + EventType: "tool_use", + Payload: toolName, + Timestamp: entry.Timestamp, + } + ms.streamingToolEntry = &newEntry + ms.messages = append(ms.messages, newEntry) + ms.evictIfNeeded() + ms.cachedDirty = true + if ms.autoScroll { + ms.scrollToBottom() + } + + case "TOOL_CALL_ARGS": + delta := extractJSONField(entry.Payload, "delta") + if delta == "" { + return + } + ms.streamingToolArgs.WriteString(delta) + // Append accumulated args to the tool call entry's payload. + if ms.streamingToolEntry != nil && len(ms.messages) > 0 { + // Find the tool call entry (it may not be the very last if a text + // message arrived between START and ARGS, but typically it is). + for i := len(ms.messages) - 1; i >= 0; i-- { + if ms.messages[i].Seq == ms.streamingToolEntry.Seq { + // Keep the tool name, append args after a space. + baseName := ms.streamingToolEntry.Payload + ms.messages[i].Payload = baseName + " " + ms.streamingToolArgs.String() + break + } + } + ms.cachedDirty = true + if ms.autoScroll { + ms.scrollToBottom() + } + } + + case "TOOL_CALL_END": + ms.streamingToolEntry = nil + ms.cachedDirty = true + + // -- Non-accumulated events: add as normal entries -- + + case "TOOL_CALL_RESULT": + entry.EventType = "tool_result" + ms.AddMessage(entry) + + default: + // RUN_FINISHED, RUN_ERROR, and anything else — pass through. + ms.AddMessage(entry) + } +} + +// IsStreaming returns true when a text message is actively being accumulated +// from AG-UI deltas. Used by View() to show the streaming cursor. +func (ms *MessageStream) IsStreaming() bool { + return ms.streamingEntry != nil +} + +// evictIfNeeded trims the message buffer to maxMessages if it has grown too large. +func (ms *MessageStream) evictIfNeeded() { + if len(ms.messages) > ms.maxMessages { + excess := len(ms.messages) - ms.maxMessages + if ms.glamourCache != nil { + for _, evicted := range ms.messages[:excess] { + delete(ms.glamourCache, evicted.Seq) + } + } + ms.messages = ms.messages[excess:] + } +} + // SetSize updates the viewport dimensions and invalidates caches that depend // on width (glamour renderer and per-message glamour cache). func (ms *MessageStream) SetSize(w, h int) { @@ -897,8 +1041,9 @@ func (ms *MessageStream) View() string { bottomLines = append([]string{composeSep, composeLine}, bottomLines...) } - // Streaming cursor (when phase is running) — animated. - if strings.ToLower(ms.phase) == "running" { + // Streaming cursor — shown when phase is running OR when actively + // accumulating AG-UI deltas (IsStreaming). + if strings.ToLower(ms.phase) == "running" || ms.IsStreaming() { cursorStyle := msgCursorStyle frames := []string{"▌", "▐", "█", "▐"} frame := frames[time.Now().UnixMilli()/300%4] @@ -1228,7 +1373,7 @@ func (ms *MessageStream) contentHeight() int { if ms.composeMode { bottomLines += 2 // compose separator + compose line } - if strings.ToLower(ms.phase) == "running" { + if strings.ToLower(ms.phase) == "running" || ms.IsStreaming() { bottomLines++ // streaming cursor line } h := ms.height - topLines - bottomLines From 87591f9b57a84f11793a8209191d302665a99c8a Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 17:19:10 -0400 Subject: [PATCH 095/117] fix(cli): skip seq dedup for live AG-UI events AG-UI events from /events use a local seq counter starting at 1, while historical messages from /messages have seq numbers in the hundreds. The dedup check was dropping all live events because their seq was always <= lastMessageSeq. Add IsLiveEvent flag to bypass dedup for the live event stream. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- components/ambient-cli/cmd/acpctl/ambient/tui/client.go | 7 +++++-- components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index fb2a795ec..aad2a471c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -172,8 +172,9 @@ type DeleteInboxMsg struct { // SessionMessageEvent carries a single session message received from an SSE // stream. Sent to the Bubbletea program via program.Send(). type SessionMessageEvent struct { - Message *sdktypes.SessionMessage - Err error + Message *sdktypes.SessionMessage + Err error + IsLiveEvent bool // true for AG-UI /events stream (skip seq dedup) } // SessionMessagesMsg carries a batch of messages fetched via polling @@ -938,6 +939,7 @@ func (tc *TUIClient) WatchSessionEvents(projectID, sessionID string, program *te EventType: "system", Payload: "connected to event stream", }, + IsLiveEvent: true, }) // Parse SSE stream line by line. @@ -995,6 +997,7 @@ func (tc *TUIClient) WatchSessionEvents(projectID, sessionID string, program *te EventType: eventType, Payload: data, }, + IsLiveEvent: true, }) // Close the stream on terminal events. diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index bd9536884..8b8229d11 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -778,8 +778,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if msg.Message != nil && m.activeView == "messages" { - // Dedup: skip messages already seen via polling. - if msg.Message.Seq <= m.lastMessageSeq { + // Dedup: skip messages already seen via polling (not applicable + // to live AG-UI events which use their own seq counter). + if !msg.IsLiveEvent && msg.Message.Seq <= m.lastMessageSeq { return m, nil } m.messageStream.SetSSEStatus("connected") From 6663328d07d0781812d09a37c479e0631f321fc1 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 17:23:49 -0400 Subject: [PATCH 096/117] feat(cli): accumulate reasoning events and render dim in message stream Add reasoning message accumulation (same pattern as text/tool call): REASONING_MESSAGE_START creates a slot, REASONING_MESSAGE_CONTENT deltas accumulate in place, REASONING_MESSAGE_END finalizes. Rendered with dim color to visually distinguish thinking from assistant output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../cmd/acpctl/ambient/tui/model_new.go | 5 +- .../cmd/acpctl/ambient/tui/views/messages.go | 56 +++++++++++++++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 8b8229d11..312d69492 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -800,7 +800,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Message.EventType { case "TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END", "TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", - "TOOL_CALL_RESULT": + "TOOL_CALL_RESULT", + "REASONING_START", "REASONING_MESSAGE_START", + "REASONING_MESSAGE_CONTENT", "REASONING_MESSAGE_END", + "REASONING_END": m.messageStream.HandleStreamEvent(entry) default: m.messageStream.AddMessage(entry) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 747437c80..761a9baec 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -85,10 +85,11 @@ func eventColor(eventType string) lipgloss.Color { return msgColorCyan case "RUN_STARTED", "RUN_FINISHED": return msgColorGreen - case "REASONING_START", "REASONING_MESSAGE_START", + case "reasoning", + "REASONING_START", "REASONING_MESSAGE_START", "REASONING_MESSAGE_CONTENT", "REASONING_MESSAGE_END", "REASONING_END": - return msgColorOrange + return msgColorDim case "STEP_STARTED", "STEP_FINISHED": return msgColorYellow default: @@ -127,6 +128,8 @@ func eventSummary(eventType, payload string) string { return truncatePayload(payload, 120) case "assistant": return truncatePayload(payload, 120) + case "reasoning": + return truncatePayload(payload, 120) case "tool_use": name := extractJSONField(payload, "name") if name == "" { @@ -234,6 +237,8 @@ func eventFullText(eventType, payload string) string { switch eventType { case "user": return strings.TrimSpace(payload) + case "reasoning": + return strings.TrimSpace(payload) case "assistant": return strings.TrimSpace(payload) case "tool_use": @@ -411,8 +416,10 @@ type MessageStream struct { // Streaming accumulator — coalesces AG-UI deltas into a single growing entry. streamingEntry *MessageEntry // the in-progress text message being accumulated streamingText strings.Builder // accumulated text for the current text message - streamingToolEntry *MessageEntry // the in-progress tool call being accumulated - streamingToolArgs strings.Builder // accumulated args for the current tool call + streamingToolEntry *MessageEntry // the in-progress tool call being accumulated + streamingToolArgs strings.Builder // accumulated args for the current tool call + streamingReasonEntry *MessageEntry // the in-progress reasoning message being accumulated + streamingReasonText strings.Builder // accumulated text for the current reasoning message // Cached display lines — rebuilt when mode/messages change, not every frame. cachedLines []string @@ -542,6 +549,47 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { ms.streamingEntry = nil ms.cachedDirty = true + // -- Reasoning accumulation -- + + case "REASONING_MESSAGE_START", "REASONING_START": + ms.streamingReasonText.Reset() + newEntry := MessageEntry{ + Seq: entry.Seq, + EventType: "reasoning", + Payload: "", + Timestamp: entry.Timestamp, + } + ms.streamingReasonEntry = &newEntry + ms.messages = append(ms.messages, newEntry) + ms.evictIfNeeded() + ms.cachedDirty = true + if ms.autoScroll { + ms.scrollToBottom() + } + + case "REASONING_MESSAGE_CONTENT": + delta := extractJSONField(entry.Payload, "delta") + if delta == "" { + return + } + ms.streamingReasonText.WriteString(delta) + if ms.streamingReasonEntry != nil && len(ms.messages) > 0 { + for i := len(ms.messages) - 1; i >= 0; i-- { + if ms.messages[i].Seq == ms.streamingReasonEntry.Seq { + ms.messages[i].Payload = ms.streamingReasonText.String() + break + } + } + ms.cachedDirty = true + if ms.autoScroll { + ms.scrollToBottom() + } + } + + case "REASONING_MESSAGE_END", "REASONING_END": + ms.streamingReasonEntry = nil + ms.cachedDirty = true + // -- Tool call accumulation -- case "TOOL_CALL_START": From 25cc096d07930b5a9b1db5e13b0de141c23b1d0f Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 17:32:42 -0400 Subject: [PATCH 097/117] fix(cli): show user messages immediately and run dual streams in parallel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add user message to stream immediately on send (the /events stream only carries runner AG-UI events, not echoed user messages) - Run message polling alongside the AG-UI event stream for running sessions — poll catches DB-persisted user/assistant turns, events stream catches live tool calls and text deltas Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../cmd/acpctl/ambient/tui/model_new.go | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 312d69492..4307a835e 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -741,6 +741,24 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { return m, m.setInfo("Send message failed: " + msg.Err.Error()) } + // Add the user message to the stream immediately so it's visible + // without waiting for a poll. The /events stream only carries + // runner AG-UI events, not echoed user messages. + if msg.Message != nil { + ts := time.Now() + if msg.Message.CreatedAt != nil { + ts = *msg.Message.CreatedAt + } + m.messageStream.AddMessage(views.MessageEntry{ + Seq: msg.Message.Seq, + EventType: "user", + Payload: msg.Message.Payload, + Timestamp: ts, + }) + if msg.Message.Seq > m.lastMessageSeq { + m.lastMessageSeq = msg.Message.Seq + } + } return m, m.setInfo("Message sent") case SendInboxMsg: @@ -1593,15 +1611,16 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { // Always fetch historical messages first for context. cmds = append(cmds, m.client.FetchSessionMessages(projectID, fullSessionID, 0)) + // Always run message polling for DB-persisted user/assistant + // messages (the /events stream only carries AG-UI runner events). + m.messagePollActive = true + cmds = append(cmds, m.messagePollTickCmd()) + if isRunningPhase(phase) && m.program != nil { // For running sessions, also start the AG-UI event stream // for live tool calls and text deltas. m.messageStream.SetSSEStatus("connecting") cmds = append(cmds, m.client.WatchSessionEvents(projectID, fullSessionID, m.program)) - } else { - // For completed sessions, poll /messages for any updates. - m.messagePollActive = true - cmds = append(cmds, m.messagePollTickCmd()) } } @@ -1854,14 +1873,12 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { } if m.currentProject != "" { - // Fetch history first, then start live stream. cmds = append(cmds, m.client.FetchSessionMessages(m.currentProject, sessionID, 0)) + m.messagePollActive = true + cmds = append(cmds, m.messagePollTickCmd()) if m.program != nil { m.messageStream.SetSSEStatus("connecting") cmds = append(cmds, m.client.WatchSessionEvents(m.currentProject, sessionID, m.program)) - } else { - m.messagePollActive = true - cmds = append(cmds, m.messagePollTickCmd()) } } From 8b0b40b0480c98b54c1447c2e42e786fcf098ec2 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 17:40:10 -0400 Subject: [PATCH 098/117] fix(cli): dedup polled assistant messages when event stream is connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip assistant messages from /messages polling when the AG-UI event stream is connected — they're DB echoes of content already delivered live via /events. User messages still come through polling since the event stream doesn't carry them. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 10 ++++++++++ .../cmd/acpctl/ambient/tui/views/messages.go | 2 ++ 2 files changed, 12 insertions(+) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 4307a835e..6c82cc131 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -842,10 +842,20 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.activeView != "messages" { return m, nil } + sseConnected := m.messageStream.GetSSEStatus() == "connected" for _, sm := range msg.Messages { if sm.Seq <= m.lastMessageSeq { continue // already seen via SSE or previous poll } + // When the event stream is connected, skip assistant messages + // from polling — they're DB echoes of what the event stream + // already delivered live. Only let user messages through. + if sseConnected && sm.EventType == "assistant" { + if sm.Seq > m.lastMessageSeq { + m.lastMessageSeq = sm.Seq + } + continue + } ts := time.Now() if sm.CreatedAt != nil { ts = *sm.CreatedAt diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 761a9baec..72349ac8b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -701,6 +701,8 @@ func (ms *MessageStream) SetPhase(phase string) { // SetSSEStatus updates the SSE connection status indicator shown in the header. // Valid values: "", "connected", "reconnecting", "disconnected". +func (ms MessageStream) GetSSEStatus() string { return ms.sseStatus } + func (ms *MessageStream) SetSSEStatus(status string) { ms.sseStatus = status } From 90881b416573a5eb1a1549ff1104520a57a49869 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 17:56:25 -0400 Subject: [PATCH 099/117] refactor(cli): split message stream into history + liveOverlay buffers Replace the single `messages []MessageEntry` buffer with separated `history` and `liveOverlay` buffers to eliminate cross-stream dedup hacks and fix ordering/race issues between polling and SSE streams. History receives durable conversation turns from /messages polling. LiveOverlay receives ephemeral AG-UI events from /events SSE. View renders history first, then a separator, then overlay. The overlay persists until the persisted assistant message arrives in history (via runFinished flag), avoiding visual gaps. Removes: IsLiveEvent field, lastMessageSeq from AppModel, SSE seq counter, sseConnected conditional assistant skip, and cross-stream dedup logic. Adds: AddHistoryMessage, ClearLiveOverlay, LastHistorySeq, MessageCount, addLiveEvent, renderEntry helper. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../cmd/acpctl/ambient/tui/client.go | 10 +- .../cmd/acpctl/ambient/tui/model_new.go | 65 +--- .../cmd/acpctl/ambient/tui/views/messages.go | 305 ++++++++++++------ 3 files changed, 226 insertions(+), 154 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index aad2a471c..7c5f54db7 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -172,9 +172,8 @@ type DeleteInboxMsg struct { // SessionMessageEvent carries a single session message received from an SSE // stream. Sent to the Bubbletea program via program.Send(). type SessionMessageEvent struct { - Message *sdktypes.SessionMessage - Err error - IsLiveEvent bool // true for AG-UI /events stream (skip seq dedup) + Message *sdktypes.SessionMessage + Err error } // SessionMessagesMsg carries a batch of messages fetched via polling @@ -901,7 +900,6 @@ func (tc *TUIClient) WatchSessionEvents(projectID, sessionID string, program *te go func() { defer cancel() - seq := 0 backoff := 1 * time.Second const maxBackoff = 30 * time.Second const scannerBufSize = 1 << 20 @@ -939,7 +937,6 @@ func (tc *TUIClient) WatchSessionEvents(projectID, sessionID string, program *te EventType: "system", Payload: "connected to event stream", }, - IsLiveEvent: true, }) // Parse SSE stream line by line. @@ -990,14 +987,11 @@ func (tc *TUIClient) WatchSessionEvents(projectID, sessionID string, program *te continue } - seq++ program.Send(SessionMessageEvent{ Message: &sdktypes.SessionMessage{ - Seq: seq, EventType: eventType, Payload: data, }, - IsLiveEvent: true, }) // Close the stream on terminal events. diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 6c82cc131..c107fd93c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -160,7 +160,6 @@ type AppModel struct { program *tea.Program // Message polling state (fallback when SSE is unavailable). - lastMessageSeq int // highest seq seen — poll for messages after this messagePollActive bool // true when message poll tick is running // Errors @@ -741,7 +740,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { return m, m.setInfo("Send message failed: " + msg.Err.Error()) } - // Add the user message to the stream immediately so it's visible + // Add the user message to history immediately so it's visible // without waiting for a poll. The /events stream only carries // runner AG-UI events, not echoed user messages. if msg.Message != nil { @@ -749,15 +748,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Message.CreatedAt != nil { ts = *msg.Message.CreatedAt } - m.messageStream.AddMessage(views.MessageEntry{ + m.messageStream.AddHistoryMessage(views.MessageEntry{ Seq: msg.Message.Seq, EventType: "user", Payload: msg.Message.Payload, Timestamp: ts, }) - if msg.Message.Seq > m.lastMessageSeq { - m.lastMessageSeq = msg.Message.Seq - } } return m, m.setInfo("Message sent") @@ -780,10 +776,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.fetchActiveView(), m.setInfo("Inbox message deleted")) case SessionMessageEvent: - // SSE message received — add to the message stream. + // SSE event received — route to the live overlay in the message stream. if msg.Err != nil { m.messageStream.SetSSEStatus("reconnecting") - m.messageStream.AddMessage(views.MessageEntry{ + m.messageStream.HandleStreamEvent(views.MessageEntry{ EventType: "error", Payload: msg.Err.Error(), Timestamp: time.Now(), @@ -796,11 +792,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } if msg.Message != nil && m.activeView == "messages" { - // Dedup: skip messages already seen via polling (not applicable - // to live AG-UI events which use their own seq counter). - if !msg.IsLiveEvent && msg.Message.Seq <= m.lastMessageSeq { - return m, nil - } m.messageStream.SetSSEStatus("connected") ts := time.Now() if msg.Message.CreatedAt != nil { @@ -812,29 +803,15 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Payload: msg.Message.Payload, Timestamp: ts, } - // Route AG-UI streaming events through the accumulator so that - // deltas are coalesced into a single growing message instead of - // appearing as dozens of fragment lines. - switch msg.Message.EventType { - case "TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END", - "TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", - "TOOL_CALL_RESULT", - "REASONING_START", "REASONING_MESSAGE_START", - "REASONING_MESSAGE_CONTENT", "REASONING_MESSAGE_END", - "REASONING_END": - m.messageStream.HandleStreamEvent(entry) - default: - m.messageStream.AddMessage(entry) - } - // Track highest seq for polling. - if msg.Message.Seq > m.lastMessageSeq { - m.lastMessageSeq = msg.Message.Seq - } + // All /events SSE events go to HandleStreamEvent which writes + // to liveOverlay. The accumulator coalesces deltas into single + // growing entries; non-accumulated events are added directly. + m.messageStream.HandleStreamEvent(entry) } return m, nil case SessionMessagesMsg: - // Polling fallback: batch of messages from REST ListMessages. + // Polling: batch of messages from REST ListMessages goes to history. if msg.Err != nil { // Non-fatal — polling will retry on next tick, but inform user. return m, m.setInfo("Message poll error: " + msg.Err.Error()) @@ -842,36 +819,24 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.activeView != "messages" { return m, nil } - sseConnected := m.messageStream.GetSSEStatus() == "connected" for _, sm := range msg.Messages { - if sm.Seq <= m.lastMessageSeq { - continue // already seen via SSE or previous poll - } - // When the event stream is connected, skip assistant messages - // from polling — they're DB echoes of what the event stream - // already delivered live. Only let user messages through. - if sseConnected && sm.EventType == "assistant" { - if sm.Seq > m.lastMessageSeq { - m.lastMessageSeq = sm.Seq - } + // Simple seq-based dedup on history — no cross-stream checks needed. + if sm.Seq <= m.messageStream.LastHistorySeq() { continue } ts := time.Now() if sm.CreatedAt != nil { ts = *sm.CreatedAt } - m.messageStream.AddMessage(views.MessageEntry{ + m.messageStream.AddHistoryMessage(views.MessageEntry{ Seq: sm.Seq, EventType: sm.EventType, Payload: sm.Payload, Timestamp: ts, }) - if sm.Seq > m.lastMessageSeq { - m.lastMessageSeq = sm.Seq - } } m.lastFetch = time.Now() - if len(msg.Messages) > 0 { + if len(msg.Messages) > 0 && m.messageStream.GetSSEStatus() != "connected" { m.messageStream.SetSSEStatus("polling") } return m, nil @@ -894,7 +859,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if projectID != "" { cmds = append(cmds, m.client.FetchSessionMessages( - projectID, m.currentSession, m.lastMessageSeq, + projectID, m.currentSession, m.messageStream.LastHistorySeq(), )) } } @@ -1592,7 +1557,6 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { fullSessionID = session.ID } m.currentSession = fullSessionID - m.lastMessageSeq = 0 // Create a new message stream for this session. agentName := m.currentAgent @@ -1873,7 +1837,6 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { m.currentAgent = agentName m.currentAgentID = agent.ID m.currentSession = sessionID - m.lastMessageSeq = 0 m.messageStream = views.NewMessageStream(sessionID, agentName, "active") m.resizeTable() diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 72349ac8b..8ad3c40a3 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -390,6 +390,12 @@ const defaultMaxMessages = 2000 // MessageStream is a Bubbletea sub-model for the live session message stream. // It renders messages in conversation or raw mode, supports scrolling, // autoscroll, compose input, and search. +// +// The message buffer is split into two separated streams: +// - history: durable conversation turns from /messages polling (user + assistant) +// - liveOverlay: ephemeral AG-UI events from /events SSE (tool calls, reasoning, streaming text) +// +// View() renders history first, then a separator, then liveOverlay. No cross-stream dedup needed. type MessageStream struct { sessionID string agentName string @@ -398,9 +404,19 @@ type MessageStream struct { // SSE connection status: "", "connected", "reconnecting", "disconnected". sseStatus string - // Message buffer (ring buffer, 2000 max). - messages []MessageEntry - maxMessages int + // Separated message buffers. + history []MessageEntry // durable conversation turns from /messages polling + liveOverlay []MessageEntry // ephemeral AG-UI events from /events SSE + maxMessages int // ring buffer capacity for history only + + // Highest seq seen in history — used for polling dedup. + lastHistorySeq int + + // runFinished is set when RUN_FINISHED/RUN_ERROR arrives via the event stream. + // The overlay persists until the next AddHistoryMessage with EventType=="assistant" + // arrives, at which point the overlay is cleared. This avoids a visual gap where + // neither buffer has the response. + runFinished bool // Display scrollOffset int @@ -414,6 +430,7 @@ type MessageStream struct { glamourWidth int // width used to create the cached renderer // Streaming accumulator — coalesces AG-UI deltas into a single growing entry. + // These write to liveOverlay instead of history. streamingEntry *MessageEntry // the in-progress text message being accumulated streamingText strings.Builder // accumulated text for the current text message streamingToolEntry *MessageEntry // the in-progress tool call being accumulated @@ -424,7 +441,7 @@ type MessageStream struct { // Cached display lines — rebuilt when mode/messages change, not every frame. cachedLines []string cachedDirty bool // true when lines need rebuilding - cachedMsgCount int + cachedMsgCount int // history + overlay combined count cachedRawMode bool cachedWrapMode bool cachedTsMode int @@ -462,7 +479,8 @@ func NewMessageStream(sessionID, agentName, phase string) MessageStream { sessionID: sessionID, agentName: agentName, phase: phase, - messages: make([]MessageEntry, 0, 256), + history: make([]MessageEntry, 0, 256), + liveOverlay: make([]MessageEntry, 0, 64), maxMessages: defaultMaxMessages, autoScroll: true, composeInput: ci, @@ -474,42 +492,81 @@ func NewMessageStream(sessionID, agentName, phase string) MessageStream { // Public methods // --------------------------------------------------------------------------- -// AddMessage appends a message to the ring buffer. When the buffer exceeds -// maxMessages, the oldest message is evicted. If autoScroll is enabled the -// scroll offset is advanced to keep the newest message visible. -func (ms *MessageStream) AddMessage(entry MessageEntry) { - ms.messages = append(ms.messages, entry) - if len(ms.messages) > ms.maxMessages { +// AddHistoryMessage appends a message to the history ring buffer. When the +// buffer exceeds maxMessages, the oldest message is evicted. If autoScroll is +// enabled the scroll offset is advanced to keep the newest message visible. +// +// When runFinished is true and an "assistant" message arrives, the live overlay +// is cleared — the persisted assistant message in history replaces the ephemeral +// streaming overlay. +func (ms *MessageStream) AddHistoryMessage(entry MessageEntry) { + ms.history = append(ms.history, entry) + if len(ms.history) > ms.maxMessages { // Evict oldest — shift the slice. For a 2000-entry buffer this is // acceptable; a true ring buffer optimisation can come later. - excess := len(ms.messages) - ms.maxMessages + excess := len(ms.history) - ms.maxMessages // Clean up glamour cache entries for evicted messages. if ms.glamourCache != nil { - for _, evicted := range ms.messages[:excess] { + for _, evicted := range ms.history[:excess] { delete(ms.glamourCache, evicted.Seq) } } - ms.messages = ms.messages[excess:] + ms.history = ms.history[excess:] // Don't adjust scrollOffset here — it's a display-line offset, not a // message-array index. renderContent's clamp handles any overshoot. } + // Track highest seq for polling dedup. + if entry.Seq > ms.lastHistorySeq { + ms.lastHistorySeq = entry.Seq + } + // When the run has finished and the persisted assistant message arrives + // in history, clear the ephemeral overlay — history now has the response. + if ms.runFinished && entry.EventType == "assistant" { + ms.ClearLiveOverlay() + } ms.cachedDirty = true if ms.autoScroll { ms.scrollToBottom() } } +// LastHistorySeq returns the highest seq in the history buffer. Used by the +// polling path for dedup. +func (ms *MessageStream) LastHistorySeq() int { + return ms.lastHistorySeq +} + +// ClearLiveOverlay clears the live overlay buffer and all streaming +// accumulators. Called when the overlay content has been superseded by +// persisted history entries. +func (ms *MessageStream) ClearLiveOverlay() { + ms.liveOverlay = ms.liveOverlay[:0] + ms.streamingEntry = nil + ms.streamingText.Reset() + ms.streamingToolEntry = nil + ms.streamingToolArgs.Reset() + ms.streamingReasonEntry = nil + ms.streamingReasonText.Reset() + ms.runFinished = false + ms.cachedDirty = true +} + +// MessageCount returns the total number of messages across both buffers. +func (ms *MessageStream) MessageCount() int { + return len(ms.history) + len(ms.liveOverlay) +} + // HandleStreamEvent processes AG-UI streaming events by accumulating deltas -// into a single growing message entry instead of creating a separate entry per -// delta. This produces clean output where text streams in place rather than -// appearing as dozens of fragment lines. +// into a single growing message entry in liveOverlay instead of creating a +// separate entry per delta. This produces clean output where text streams in +// place rather than appearing as dozens of fragment lines. func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { switch entry.EventType { // -- Text message accumulation -- case "TEXT_MESSAGE_START": - // Begin a new assistant message slot. + // Begin a new assistant message slot in liveOverlay. ms.streamingText.Reset() newEntry := MessageEntry{ Seq: entry.Seq, @@ -518,8 +575,7 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { Timestamp: entry.Timestamp, } ms.streamingEntry = &newEntry - ms.messages = append(ms.messages, newEntry) - ms.evictIfNeeded() + ms.liveOverlay = append(ms.liveOverlay, newEntry) ms.cachedDirty = true if ms.autoScroll { ms.scrollToBottom() @@ -531,12 +587,12 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { return } ms.streamingText.WriteString(delta) - // Update the last message entry in place. - if ms.streamingEntry != nil && len(ms.messages) > 0 { - ms.messages[len(ms.messages)-1].Payload = ms.streamingText.String() + // Update the last liveOverlay entry in place. + if ms.streamingEntry != nil && len(ms.liveOverlay) > 0 { + ms.liveOverlay[len(ms.liveOverlay)-1].Payload = ms.streamingText.String() // Invalidate glamour cache for this entry since content changed. if ms.glamourCache != nil { - delete(ms.glamourCache, ms.messages[len(ms.messages)-1].Seq) + delete(ms.glamourCache, ms.liveOverlay[len(ms.liveOverlay)-1].Seq) } ms.cachedDirty = true if ms.autoScroll { @@ -560,8 +616,7 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { Timestamp: entry.Timestamp, } ms.streamingReasonEntry = &newEntry - ms.messages = append(ms.messages, newEntry) - ms.evictIfNeeded() + ms.liveOverlay = append(ms.liveOverlay, newEntry) ms.cachedDirty = true if ms.autoScroll { ms.scrollToBottom() @@ -573,10 +628,10 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { return } ms.streamingReasonText.WriteString(delta) - if ms.streamingReasonEntry != nil && len(ms.messages) > 0 { - for i := len(ms.messages) - 1; i >= 0; i-- { - if ms.messages[i].Seq == ms.streamingReasonEntry.Seq { - ms.messages[i].Payload = ms.streamingReasonText.String() + if ms.streamingReasonEntry != nil && len(ms.liveOverlay) > 0 { + for i := len(ms.liveOverlay) - 1; i >= 0; i-- { + if ms.liveOverlay[i].Seq == ms.streamingReasonEntry.Seq { + ms.liveOverlay[i].Payload = ms.streamingReasonText.String() break } } @@ -612,8 +667,7 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { Timestamp: entry.Timestamp, } ms.streamingToolEntry = &newEntry - ms.messages = append(ms.messages, newEntry) - ms.evictIfNeeded() + ms.liveOverlay = append(ms.liveOverlay, newEntry) ms.cachedDirty = true if ms.autoScroll { ms.scrollToBottom() @@ -625,15 +679,13 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { return } ms.streamingToolArgs.WriteString(delta) - // Append accumulated args to the tool call entry's payload. - if ms.streamingToolEntry != nil && len(ms.messages) > 0 { - // Find the tool call entry (it may not be the very last if a text - // message arrived between START and ARGS, but typically it is). - for i := len(ms.messages) - 1; i >= 0; i-- { - if ms.messages[i].Seq == ms.streamingToolEntry.Seq { + // Append accumulated args to the tool call entry's payload in liveOverlay. + if ms.streamingToolEntry != nil && len(ms.liveOverlay) > 0 { + for i := len(ms.liveOverlay) - 1; i >= 0; i-- { + if ms.liveOverlay[i].Seq == ms.streamingToolEntry.Seq { // Keep the tool name, append args after a space. baseName := ms.streamingToolEntry.Payload - ms.messages[i].Payload = baseName + " " + ms.streamingToolArgs.String() + ms.liveOverlay[i].Payload = baseName + " " + ms.streamingToolArgs.String() break } } @@ -647,15 +699,29 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { ms.streamingToolEntry = nil ms.cachedDirty = true - // -- Non-accumulated events: add as normal entries -- + // -- Non-accumulated events: add to liveOverlay directly -- case "TOOL_CALL_RESULT": entry.EventType = "tool_result" - ms.AddMessage(entry) + ms.addLiveEvent(entry) default: - // RUN_FINISHED, RUN_ERROR, and anything else — pass through. - ms.AddMessage(entry) + // RUN_FINISHED, RUN_ERROR, and anything else — add to overlay. + ms.addLiveEvent(entry) + // Mark run as finished so overlay persists until history catches up. + if entry.EventType == "RUN_FINISHED" || entry.EventType == "RUN_ERROR" { + ms.runFinished = true + } + } +} + +// addLiveEvent appends an entry to the liveOverlay buffer and handles +// autoscroll. This is a thin wrapper for non-accumulated events. +func (ms *MessageStream) addLiveEvent(entry MessageEntry) { + ms.liveOverlay = append(ms.liveOverlay, entry) + ms.cachedDirty = true + if ms.autoScroll { + ms.scrollToBottom() } } @@ -665,16 +731,16 @@ func (ms *MessageStream) IsStreaming() bool { return ms.streamingEntry != nil } -// evictIfNeeded trims the message buffer to maxMessages if it has grown too large. +// evictIfNeeded trims the history buffer to maxMessages if it has grown too large. func (ms *MessageStream) evictIfNeeded() { - if len(ms.messages) > ms.maxMessages { - excess := len(ms.messages) - ms.maxMessages + if len(ms.history) > ms.maxMessages { + excess := len(ms.history) - ms.maxMessages if ms.glamourCache != nil { - for _, evicted := range ms.messages[:excess] { + for _, evicted := range ms.history[:excess] { delete(ms.glamourCache, evicted.Seq) } } - ms.messages = ms.messages[excess:] + ms.history = ms.history[excess:] } } @@ -861,11 +927,14 @@ func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { return *ms, nil case "c": // Copy the first visible message's payload to clipboard. - // scrollOffset is a display-line offset, so we iterate messages - // and count display lines to find the right one. - if len(ms.messages) > 0 { + // scrollOffset is a display-line offset, so we iterate all messages + // (history + overlay) and count display lines to find the right one. + allEntries := make([]MessageEntry, 0, len(ms.history)+len(ms.liveOverlay)) + allEntries = append(allEntries, ms.history...) + allEntries = append(allEntries, ms.liveOverlay...) + if len(allEntries) > 0 { lineCount := 0 - for _, entry := range ms.messages { + for _, entry := range allEntries { var entryLines []string if ms.rawMode { entryLines = ms.renderRawEntry(entry, max(ms.width-4, 20)) @@ -982,7 +1051,7 @@ func (ms *MessageStream) View() string { titleRendered := " " + kindStyle.Render("messages") + dimStyle.Render("(") + scopeStyle.Render(scope) + dimStyle.Render(")") + - dimStyle.Render("[") + countStyle.Render(fmt.Sprintf("%d", len(ms.messages))) + dimStyle.Render("]") + + dimStyle.Render("[") + countStyle.Render(fmt.Sprintf("%d", ms.MessageCount())) + dimStyle.Render("]") + " " titleWidth := lipgloss.Width(titleRendered) remaining := max(ms.width-titleWidth-2, 2) @@ -1142,7 +1211,7 @@ func (ms *MessageStream) View() string { // renderContent produces the visible message lines for the content area. func (ms *MessageStream) renderContent(height int) []string { - if len(ms.messages) == 0 { + if len(ms.history) == 0 && len(ms.liveOverlay) == 0 { return []string{msgDimStyle.Render("No messages yet.")} } @@ -1168,16 +1237,18 @@ func (ms *MessageStream) renderContent(height int) []string { return allLines[start:end] } -// buildDisplayLines converts the message buffer into styled display lines. -// Results are cached and only rebuilt when mode/messages change. +// buildDisplayLines converts both message buffers into styled display lines. +// History entries are rendered first, then a dim separator, then liveOverlay +// entries. Results are cached and only rebuilt when mode/messages change. func (ms *MessageStream) buildDisplayLines() []string { searchStr := "" if ms.searchPattern != nil { searchStr = ms.searchPattern.String() } + totalCount := ms.MessageCount() // Check if cache is still valid (timestamps always invalidate since relative times change). if !ms.cachedDirty && - ms.cachedMsgCount == len(ms.messages) && + ms.cachedMsgCount == totalCount && ms.cachedRawMode == ms.rawMode && ms.cachedWrapMode == ms.wrapMode && ms.cachedTsMode == ms.timestampMode && @@ -1188,28 +1259,17 @@ func (ms *MessageStream) buildDisplayLines() []string { maxLineWidth := max(ms.width-4, 20) // 2 for borders, 2 for padding - lines := make([]string, 0, len(ms.messages)) + lines := make([]string, 0, totalCount) const tagPad = 14 - separator := strings.Repeat(" ", tagPad) + msgSepStyle.Render(strings.Repeat("─", max(maxLineWidth-tagPad, 10))) + turnSeparator := strings.Repeat(" ", tagPad) + msgSepStyle.Render(strings.Repeat("─", max(maxLineWidth-tagPad, 10))) now := time.Now() - prevWasUserOrAssistant := false - for _, entry := range ms.messages { - // Apply search filter if active. - if ms.searchPattern != nil { - text := eventSummary(entry.EventType, entry.Payload) - if !ms.searchPattern.MatchString(text) && !ms.searchPattern.MatchString(entry.Payload) { - continue - } - } - var entryLines []string - if ms.rawMode { - entryLines = ms.renderRawEntry(entry, maxLineWidth) - } else { - entryLines = ms.renderConversationEntry(entry, maxLineWidth) - } + // Render history entries. + prevWasUserOrAssistant := false + for _, entry := range ms.history { + entryLines := ms.renderEntry(entry, maxLineWidth, now) if len(entryLines) == 0 { continue } @@ -1217,36 +1277,47 @@ func (ms *MessageStream) buildDisplayLines() []string { // Add dim separator between user/assistant messages in conversation mode. isUserOrAssistant := entry.EventType == "user" || entry.EventType == "assistant" if !ms.rawMode && isUserOrAssistant && prevWasUserOrAssistant { - lines = append(lines, separator) + lines = append(lines, turnSeparator) } prevWasUserOrAssistant = isUserOrAssistant - // Prepend timestamp to the first line if timestamps are enabled. - if ms.timestampMode > 0 && !entry.Timestamp.IsZero() { - tsStyle := msgDimStyle - var ts string - if ms.timestampMode == 1 { - d := now.Sub(entry.Timestamp) - if d < time.Minute { - ts = fmt.Sprintf("%ds", int(d.Seconds())) - } else if d < time.Hour { - ts = fmt.Sprintf("%dm", int(d.Minutes())) - } else if d < 24*time.Hour { - ts = fmt.Sprintf("%dh", int(d.Hours())) - } else { - ts = fmt.Sprintf("%dd", int(d.Hours()/24)) + lines = append(lines, entryLines...) + } + + // Render liveOverlay entries with a header separator. + if len(ms.liveOverlay) > 0 { + // Check if any overlay entries pass the search filter before adding the separator. + hasVisible := false + for _, entry := range ms.liveOverlay { + if ms.searchPattern != nil { + text := eventSummary(entry.EventType, entry.Payload) + if !ms.searchPattern.MatchString(text) && !ms.searchPattern.MatchString(entry.Payload) { + continue } - } else { - ts = entry.Timestamp.Format("15:04:05") } - entryLines[0] = tsStyle.Render(fmt.Sprintf("%-8s", ts)) + entryLines[0] + hasVisible = true + break + } + if hasVisible { + overlaySep := msgSepStyle.Render(fmt.Sprintf( + "── agent activity %s", + strings.Repeat("─", max(maxLineWidth-19, 5)), + )) + lines = append(lines, overlaySep) + + for _, entry := range ms.liveOverlay { + entryLines := ms.renderEntry(entry, maxLineWidth, now) + if len(entryLines) == 0 { + continue + } + lines = append(lines, entryLines...) + } } - lines = append(lines, entryLines...) } ms.cachedLines = lines ms.cachedDirty = false - ms.cachedMsgCount = len(ms.messages) + ms.cachedMsgCount = totalCount ms.cachedRawMode = ms.rawMode ms.cachedWrapMode = ms.wrapMode ms.cachedTsMode = ms.timestampMode @@ -1254,6 +1325,50 @@ func (ms *MessageStream) buildDisplayLines() []string { return lines } +// renderEntry renders a single message entry into display lines, applying the +// search filter and optional timestamp prefix. Shared by history and overlay rendering. +func (ms *MessageStream) renderEntry(entry MessageEntry, maxLineWidth int, now time.Time) []string { + // Apply search filter if active. + if ms.searchPattern != nil { + text := eventSummary(entry.EventType, entry.Payload) + if !ms.searchPattern.MatchString(text) && !ms.searchPattern.MatchString(entry.Payload) { + return nil + } + } + + var entryLines []string + if ms.rawMode { + entryLines = ms.renderRawEntry(entry, maxLineWidth) + } else { + entryLines = ms.renderConversationEntry(entry, maxLineWidth) + } + if len(entryLines) == 0 { + return nil + } + + // Prepend timestamp to the first line if timestamps are enabled. + if ms.timestampMode > 0 && !entry.Timestamp.IsZero() { + tsStyle := msgDimStyle + var ts string + if ms.timestampMode == 1 { + d := now.Sub(entry.Timestamp) + if d < time.Minute { + ts = fmt.Sprintf("%ds", int(d.Seconds())) + } else if d < time.Hour { + ts = fmt.Sprintf("%dm", int(d.Minutes())) + } else if d < 24*time.Hour { + ts = fmt.Sprintf("%dh", int(d.Hours())) + } else { + ts = fmt.Sprintf("%dd", int(d.Hours()/24)) + } + } else { + ts = entry.Timestamp.Format("15:04:05") + } + entryLines[0] = tsStyle.Render(fmt.Sprintf("%-8s", ts)) + entryLines[0] + } + return entryLines +} + // getGlamourRenderer returns a cached glamour renderer, creating one lazily on // first use. If the terminal width has changed, the renderer is recreated. func (ms *MessageStream) getGlamourRenderer(wrapWidth int) *glamour.TermRenderer { @@ -1410,7 +1525,7 @@ func (ms *MessageStream) scrollDown(n int) { func (ms *MessageStream) scrollToBottom() { // Set a large value; renderContent will clamp. - ms.scrollOffset = len(ms.messages) * 10 + ms.scrollOffset = ms.MessageCount() * 10 } // contentHeight returns the usable content height given the current dimensions. From bfa7c7f735a1821f33847c9383a1c0221bfa08b6 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 17:59:24 -0400 Subject: [PATCH 100/117] fix(cli): reconnect AG-UI event stream after RUN_FINISHED for next run The SSE goroutine was exiting permanently on RUN_FINISHED, so subsequent runs in the same session (triggered by new user messages) had no event stream listener. Now it reconnects after a 1s delay to pick up the next run's events. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/tui/client.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index 7c5f54db7..04c6eeb0a 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -1011,9 +1011,16 @@ func (tc *TUIClient) WatchSessionEvents(projectID, sessionID string, program *te return } - // If the stream ended normally (RUN_FINISHED/RUN_ERROR), we're done. + // If the stream ended normally (RUN_FINISHED/RUN_ERROR), the current + // run is done but another may start when the user sends a message. + // Reconnect after a short delay to pick up the next run. if streamDone { - return + select { + case <-ctx.Done(): + return + case <-time.After(1 * time.Second): + } + continue } // Unexpected close — reconnect. From d277103d88b0db401a5ac6ee8f8ab438146fbc22 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Mon, 27 Apr 2026 18:02:03 -0400 Subject: [PATCH 101/117] fix(cli): glamour render extracted text, not raw JSON payload Pretty mode was passing the raw sanitizedPayload to glamour, which for AG-UI events is the full JSON envelope. Now passes displayText (the extracted content from eventFullText) so reasoning and other events render as formatted text, not JSON. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/tui/views/messages.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 8ad3c40a3..343d8591c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -1423,6 +1423,8 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in // In pretty mode, render through glamour for markdown support. // Uses per-message cache to avoid re-rendering on every frame. + // Glamour renders displayText (extracted content), not the raw payload + // which may be a JSON envelope for AG-UI events. if ms.wrapMode { if ms.glamourCache == nil { ms.glamourCache = make(map[int]string) @@ -1433,7 +1435,7 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in } else { glamourWidth := max(ms.width-20, 20) if r := ms.getGlamourRenderer(glamourWidth); r != nil { - out, err := r.Render(strings.TrimSpace(sanitizedPayload)) + out, err := r.Render(strings.TrimSpace(displayText)) if err == nil { rendered = strings.TrimSpace(out) ms.glamourCache[entry.Seq] = rendered From 7b3c453a79dd1549b02f6277c774c5dcd742066d Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Tue, 28 Apr 2026 09:29:01 -0400 Subject: [PATCH 102/117] feat(cli): red logo and Session Expired badge on 401 auth failure When a 401 is received, the ACP logo turns red and a "Session Expired" badge with red background appears right-aligned on the server line. Both clear automatically when the next API call succeeds (e.g., after running acpctl login in another terminal). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 21 ++++++++++++++++++- .../cmd/acpctl/ambient/tui/model_new.go | 8 ++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 8e07b63a0..5affeb83c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -202,9 +202,13 @@ func (m *AppModel) viewHeader() string { w = lipgloss.Width(line) // Right-align col4 (static hints + brand). + brandStyle := styleOrange + if m.authExpired { + brandStyle = styleRed + } brand := "" if i < len(brandLines) { - brand = styleOrange.Render(brandLines[i]) + brand = brandStyle.Render(brandLines[i]) } right := "" if col4[i] != "" && brand != "" { @@ -224,6 +228,21 @@ func (m *AppModel) viewHeader() string { // Server URL on its own full-width row below the grid to avoid pushing columns. serverLine := fmt.Sprintf(" %s %s", styleDim.Render("Server:"), styleDim.Render(serverURL)) + if m.authExpired { + badge := lipgloss.NewStyle(). + Background(lipgloss.Color("31")). + Foreground(lipgloss.Color("255")). + Bold(true). + Padding(0, 1). + Render("Session Expired") + badgeW := lipgloss.Width(badge) + serverW := lipgloss.Width(serverLine) + pad := m.width - serverW - badgeW + if pad < 1 { + pad = 1 + } + serverLine += strings.Repeat(" ", pad) + badge + } return strings.Join(lines, "\n") + "\n" + serverLine } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index c107fd93c..51eb279a0 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -163,7 +163,8 @@ type AppModel struct { messagePollActive bool // true when message poll tick is running // Errors - lastError string + lastError string + authExpired bool // set on 401 — renders logo red + "Session Expired" badge // Dialog overlay for confirm/delete prompts. dialog *views.Dialog @@ -931,6 +932,7 @@ func (m *AppModel) classifyAPIError(err error, resourceKind string) (string, boo errStr := err.Error() switch { case strings.Contains(errStr, "401") || strings.Contains(errStr, "Unauthorized"): + m.authExpired = true return "Session expired — run 'acpctl login' in another terminal", false case strings.Contains(errStr, "403") || strings.Contains(errStr, "Forbidden"): return "Insufficient permissions to list " + resourceKind, false @@ -955,6 +957,7 @@ func (m *AppModel) handleProjectsMsg(msg ProjectsMsg) (tea.Model, tea.Cmd) { } m.lastError = "" + m.authExpired = false m.cachedProjects = msg.Projects // Refresh project shortcuts (alphabetically sorted names for number-key switching). @@ -1088,6 +1091,7 @@ func (m *AppModel) handleAgentsMsg(msg AgentsMsg) (tea.Model, tea.Cmd) { } m.lastError = "" + m.authExpired = false m.cachedAgents = msg.Agents now := time.Now() @@ -1181,6 +1185,7 @@ func (m *AppModel) handleSessionsMsg(msg SessionsMsg) (tea.Model, tea.Cmd) { } m.lastError = "" + m.authExpired = false m.cachedSessions = msg.Sessions now := time.Now() @@ -1250,6 +1255,7 @@ func (m *AppModel) handleInboxMsg(msg InboxMsg) (tea.Model, tea.Cmd) { } m.lastError = "" + m.authExpired = false m.cachedInbox = msg.Messages now := time.Now() From 53190a3f608020d08d1566b7fdfd20087bc478a1 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Tue, 28 Apr 2026 09:56:07 -0400 Subject: [PATCH 103/117] fix(cli): use ANSI 196 (true red) instead of 31 (dark blue) for error color ANSI 256 index 31 renders as dark blue/purple on most terminals. Switch to 196 which is bright red. Affects error messages, failed phase color, auth expired logo/badge, and message stream error events. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- components/ambient-cli/cmd/acpctl/ambient/tui/app.go | 2 +- components/ambient-cli/cmd/acpctl/ambient/tui/view.go | 2 +- components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 5affeb83c..b14349399 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -230,7 +230,7 @@ func (m *AppModel) viewHeader() string { serverLine := fmt.Sprintf(" %s %s", styleDim.Render("Server:"), styleDim.Render(serverURL)) if m.authExpired { badge := lipgloss.NewStyle(). - Background(lipgloss.Color("31")). + Background(lipgloss.Color("196")). Foreground(lipgloss.Color("255")). Bold(true). Padding(0, 1). diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/view.go b/components/ambient-cli/cmd/acpctl/ambient/tui/view.go index 5c0267912..ed33b3df7 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/view.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/view.go @@ -14,7 +14,7 @@ var ( colorOrange = lipgloss.Color("214") colorCyan = lipgloss.Color("36") colorGreen = lipgloss.Color("28") - colorRed = lipgloss.Color("31") + colorRed = lipgloss.Color("196") colorYellow = lipgloss.Color("33") colorDim = lipgloss.Color("240") colorWhite = lipgloss.Color("255") diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 343d8591c..29459ddda 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -43,7 +43,7 @@ var ( msgColorGreen = lipgloss.Color("28") msgColorDim = lipgloss.Color("240") msgColorYellow = lipgloss.Color("33") - msgColorRed = lipgloss.Color("31") + msgColorRed = lipgloss.Color("196") msgColorOrange = lipgloss.Color("214") msgColorCyan = lipgloss.Color("36") msgColorBlue = lipgloss.Color("69") From ba3583432ddd57ac4cd2e40005cea4282fed6b21 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Tue, 28 Apr 2026 10:24:12 -0400 Subject: [PATCH 104/117] refactor(cli): unify Context and Server header into single Context line Replace separate Context and Server lines with one Context line showing the server URL directly. Removes the derived context name (unreliable for long hostnames). Auth expired badge now appears on the last header line instead of a separate server row. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index b14349399..5dfea4175 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -68,11 +68,8 @@ func (m *AppModel) View() string { // // Col1: Metadata Col2: Project shortcuts Col3: Hotkey hints Col4: Logo+refresh func (m *AppModel) viewHeader() string { - contextName, serverURL, project := "none", "unknown", "none" + serverURL, project := "unknown", "none" if m.config != nil { - if m.config.CurrentContext != "" { - contextName = m.config.CurrentContext - } if ctx := m.config.Current(); ctx != nil { if ctx.Server != "" { serverURL = ctx.Server @@ -82,9 +79,8 @@ func (m *AppModel) viewHeader() string { } } } - // Col 1: metadata (server is rendered on its own line below the header grid). + // Col 1: metadata (context URL on its own row below the grid). col1 := [5]string{ - fmt.Sprintf(" %s %s %s", styleDim.Render("Context:"), styleOrange.Render(contextName), styleDim.Render("[RW]")), fmt.Sprintf(" %s %s", styleDim.Render("User: "), styleWhite.Render(m.currentUser())), fmt.Sprintf(" %s %s", styleDim.Render("Project:"), styleOrange.Render(project)), } @@ -204,7 +200,7 @@ func (m *AppModel) viewHeader() string { // Right-align col4 (static hints + brand). brandStyle := styleOrange if m.authExpired { - brandStyle = styleRed + brandStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) } brand := "" if i < len(brandLines) { @@ -226,24 +222,24 @@ func (m *AppModel) viewHeader() string { lines[i] = line + strings.Repeat(" ", gap) + right } - // Server URL on its own full-width row below the grid to avoid pushing columns. - serverLine := fmt.Sprintf(" %s %s", styleDim.Render("Server:"), styleDim.Render(serverURL)) + // Context URL on its own full-width row below the grid. + contextLine := fmt.Sprintf(" %s %s %s", styleDim.Render("Context:"), styleDim.Render(serverURL), styleDim.Render("[RW]")) if m.authExpired { badge := lipgloss.NewStyle(). - Background(lipgloss.Color("196")). + Background(lipgloss.Color("69")). Foreground(lipgloss.Color("255")). Bold(true). Padding(0, 1). Render("Session Expired") badgeW := lipgloss.Width(badge) - serverW := lipgloss.Width(serverLine) - pad := m.width - serverW - badgeW + ctxW := lipgloss.Width(contextLine) + pad := m.width - ctxW - badgeW if pad < 1 { pad = 1 } - serverLine += strings.Repeat(" ", pad) + badge + contextLine += strings.Repeat(" ", pad) + badge } - return strings.Join(lines, "\n") + "\n" + serverLine + return strings.Join(lines, "\n") + "\n" + contextLine } // renderHint renders a single hotkey hint like "<d> Describe" with dim brackets @@ -359,7 +355,13 @@ func (m *AppModel) viewBreadcrumb() string { func (m *AppModel) viewInfoLine() string { // Error takes priority over info. if m.lastError != "" { - return " " + styleRed.Render("✗ "+m.lastError) + errText := styleRed.Render("✗ " + m.lastError) + errWidth := lipgloss.Width(errText) + pad := (m.width - errWidth) / 2 + if pad < 0 { + pad = 0 + } + return strings.Repeat(" ", pad) + errText } if m.infoMessage != "" { From 86cf87024f60f99a63f4ae5689678787f248ab76 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Tue, 28 Apr 2026 12:13:00 -0400 Subject: [PATCH 105/117] fix(cli): use unique entryID for glamour cache instead of Seq to prevent SSE collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSE events from /events all have Seq=0 (only EventType and Payload are set by WatchSessionEvents). The glamour render cache was keyed by Seq, so the first entry rendered in pretty mode (the "connected to event stream" system message with Seq=0) poisoned the cache — every subsequent entry with Seq=0 returned that cached string instead of its actual content. Fix: add a monotonically incrementing entryID to MessageEntry, assigned when entries are added to history or liveOverlay. Use entryID as the glamour cache key and for accumulator lookups (reasoning/tool args backward search) instead of Seq. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../cmd/acpctl/ambient/tui/views/messages.go | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 29459ddda..ba71b3edd 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -378,6 +378,13 @@ type MessageEntry struct { EventType string Payload string Timestamp time.Time + + // entryID is a unique monotonic identifier assigned by the MessageStream + // when the entry is added to history or liveOverlay. It is used as the + // glamour render cache key instead of Seq because SSE-derived events all + // have Seq=0, which causes cache collisions and makes every entry render + // the same cached output (e.g. "connected to event stream"). + entryID int } // --------------------------------------------------------------------------- @@ -447,7 +454,12 @@ type MessageStream struct { cachedTsMode int cachedSearchPat string - // Per-message glamour render cache (key = seq number). + // nextEntryID is a monotonically incrementing counter used to assign unique + // entryID values to each MessageEntry. This avoids glamour cache collisions + // when multiple entries share the same Seq value (e.g. all SSE events have Seq=0). + nextEntryID int + + // Per-message glamour render cache (key = entryID, not Seq). glamourCache map[int]string // Compose @@ -500,6 +512,8 @@ func NewMessageStream(sessionID, agentName, phase string) MessageStream { // is cleared — the persisted assistant message in history replaces the ephemeral // streaming overlay. func (ms *MessageStream) AddHistoryMessage(entry MessageEntry) { + ms.nextEntryID++ + entry.entryID = ms.nextEntryID ms.history = append(ms.history, entry) if len(ms.history) > ms.maxMessages { // Evict oldest — shift the slice. For a 2000-entry buffer this is @@ -508,7 +522,7 @@ func (ms *MessageStream) AddHistoryMessage(entry MessageEntry) { // Clean up glamour cache entries for evicted messages. if ms.glamourCache != nil { for _, evicted := range ms.history[:excess] { - delete(ms.glamourCache, evicted.Seq) + delete(ms.glamourCache, evicted.entryID) } } ms.history = ms.history[excess:] @@ -568,11 +582,13 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { case "TEXT_MESSAGE_START": // Begin a new assistant message slot in liveOverlay. ms.streamingText.Reset() + ms.nextEntryID++ newEntry := MessageEntry{ Seq: entry.Seq, EventType: "assistant", Payload: "", Timestamp: entry.Timestamp, + entryID: ms.nextEntryID, } ms.streamingEntry = &newEntry ms.liveOverlay = append(ms.liveOverlay, newEntry) @@ -592,7 +608,7 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { ms.liveOverlay[len(ms.liveOverlay)-1].Payload = ms.streamingText.String() // Invalidate glamour cache for this entry since content changed. if ms.glamourCache != nil { - delete(ms.glamourCache, ms.liveOverlay[len(ms.liveOverlay)-1].Seq) + delete(ms.glamourCache, ms.liveOverlay[len(ms.liveOverlay)-1].entryID) } ms.cachedDirty = true if ms.autoScroll { @@ -609,11 +625,13 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { case "REASONING_MESSAGE_START", "REASONING_START": ms.streamingReasonText.Reset() + ms.nextEntryID++ newEntry := MessageEntry{ Seq: entry.Seq, EventType: "reasoning", Payload: "", Timestamp: entry.Timestamp, + entryID: ms.nextEntryID, } ms.streamingReasonEntry = &newEntry ms.liveOverlay = append(ms.liveOverlay, newEntry) @@ -630,7 +648,7 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { ms.streamingReasonText.WriteString(delta) if ms.streamingReasonEntry != nil && len(ms.liveOverlay) > 0 { for i := len(ms.liveOverlay) - 1; i >= 0; i-- { - if ms.liveOverlay[i].Seq == ms.streamingReasonEntry.Seq { + if ms.liveOverlay[i].entryID == ms.streamingReasonEntry.entryID { ms.liveOverlay[i].Payload = ms.streamingReasonText.String() break } @@ -660,11 +678,13 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { if toolName == "" { toolName = extractJSONField(entry.Payload, "name") } + ms.nextEntryID++ newEntry := MessageEntry{ Seq: entry.Seq, EventType: "tool_use", Payload: toolName, Timestamp: entry.Timestamp, + entryID: ms.nextEntryID, } ms.streamingToolEntry = &newEntry ms.liveOverlay = append(ms.liveOverlay, newEntry) @@ -682,7 +702,7 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { // Append accumulated args to the tool call entry's payload in liveOverlay. if ms.streamingToolEntry != nil && len(ms.liveOverlay) > 0 { for i := len(ms.liveOverlay) - 1; i >= 0; i-- { - if ms.liveOverlay[i].Seq == ms.streamingToolEntry.Seq { + if ms.liveOverlay[i].entryID == ms.streamingToolEntry.entryID { // Keep the tool name, append args after a space. baseName := ms.streamingToolEntry.Payload ms.liveOverlay[i].Payload = baseName + " " + ms.streamingToolArgs.String() @@ -718,6 +738,8 @@ func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { // addLiveEvent appends an entry to the liveOverlay buffer and handles // autoscroll. This is a thin wrapper for non-accumulated events. func (ms *MessageStream) addLiveEvent(entry MessageEntry) { + ms.nextEntryID++ + entry.entryID = ms.nextEntryID ms.liveOverlay = append(ms.liveOverlay, entry) ms.cachedDirty = true if ms.autoScroll { @@ -737,7 +759,7 @@ func (ms *MessageStream) evictIfNeeded() { excess := len(ms.history) - ms.maxMessages if ms.glamourCache != nil { for _, evicted := range ms.history[:excess] { - delete(ms.glamourCache, evicted.Seq) + delete(ms.glamourCache, evicted.entryID) } } ms.history = ms.history[excess:] @@ -1430,7 +1452,7 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in ms.glamourCache = make(map[int]string) } var rendered string - if cached, ok := ms.glamourCache[entry.Seq]; ok { + if cached, ok := ms.glamourCache[entry.entryID]; ok { rendered = cached } else { glamourWidth := max(ms.width-20, 20) @@ -1438,7 +1460,7 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in out, err := r.Render(strings.TrimSpace(displayText)) if err == nil { rendered = strings.TrimSpace(out) - ms.glamourCache[entry.Seq] = rendered + ms.glamourCache[entry.entryID] = rendered } } } From 87f5d62c8b2dcfedb9229f08323eb38006f959fc Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Tue, 28 Apr 2026 12:30:28 -0400 Subject: [PATCH 106/117] refactor(cli): revert TUI message stream to simple polling, remove SSE Remove the dual-buffer SSE+polling architecture and revert to a single message buffer driven by 1-second /messages polling. This eliminates ~700 lines of SSE streaming code (WatchSessionEvents, WatchSessionMessages, HandleStreamEvent, live overlay, streaming accumulators) that added complexity without sufficient benefit over fast polling. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/cmd.go | 1 - .../cmd/acpctl/ambient/tui/client.go | 298 +------------ .../cmd/acpctl/ambient/tui/model_new.go | 107 +---- .../cmd/acpctl/ambient/tui/views/messages.go | 415 ++---------------- 4 files changed, 60 insertions(+), 761 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/cmd.go b/components/ambient-cli/cmd/acpctl/ambient/cmd.go index 26af86916..328ccc40c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/cmd.go +++ b/components/ambient-cli/cmd/acpctl/ambient/cmd.go @@ -38,7 +38,6 @@ Data refreshes automatically every 5 seconds.`, return fmt.Errorf("init TUI: %w", err) } p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) - m.SetProgram(p) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "TUI error: %v\n", err) return err diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index 04c6eeb0a..76902492d 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -1,11 +1,7 @@ package tui import ( - "bufio" "context" - "encoding/json" - "fmt" - "strings" "sync" "time" @@ -169,15 +165,8 @@ type DeleteInboxMsg struct { Err error } -// SessionMessageEvent carries a single session message received from an SSE -// stream. Sent to the Bubbletea program via program.Send(). -type SessionMessageEvent struct { - Message *sdktypes.SessionMessage - Err error -} - // SessionMessagesMsg carries a batch of messages fetched via polling -// (ListMessages). Used as a fallback when SSE is unavailable or stalled. +// (ListMessages). type SessionMessagesMsg struct { Messages []sdktypes.SessionMessage Err error @@ -198,10 +187,6 @@ type SessionMessagesMsg struct { // asynchronously. type TUIClient struct { factory *connection.ClientFactory - - // watchMu protects watchCancel. - watchMu sync.Mutex - watchCancel context.CancelFunc } // NewTUIClient creates a TUIClient from the given ClientFactory. @@ -703,9 +688,8 @@ func (tc *TUIClient) DeleteSession(projectID, sessionID string) tea.Cmd { } // SendSessionMessage returns a tea.Cmd that sends a user message to a -// session. This supports the "Send-While-Streaming" pattern: the call is -// non-blocking and the message appears in the SSE stream when the server -// echoes it back. +// session. The call is non-blocking and the message appears in the next +// poll cycle when the server echoes it back. func (tc *TUIClient) SendSessionMessage(projectID, sessionID, body string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) @@ -784,271 +768,8 @@ func (tc *TUIClient) DeleteInboxMessage(projectID, agentID, msgID string) tea.Cm } } -// --------------------------------------------------------------------------- -// SSE streaming -// --------------------------------------------------------------------------- - -// WatchSessionMessages returns a tea.Cmd that starts an SSE stream for -// session messages. Messages are delivered to the Bubbletea program via -// program.Send(SessionMessageEvent{...}). -// -// The SSE goroutine: -// - Connects to GET /sessions/{id}/messages via the SDK's WatchMessages. -// - Forwards each message as a SessionMessageEvent to the program. -// - Handles reconnection with exponential backoff (1s, 2s, 4s, max 30s) -// internally via the SDK's WatchMessages implementation. -// - Is cancellable via StopWatching(). -// - Sends an error event if the channel closes without context cancellation, -// signalling a silent SSE failure so the TUI can fall back to polling. -// -// Only one watch can be active at a time. Calling WatchSessionMessages while -// a previous watch is running cancels the old one first. -func (tc *TUIClient) WatchSessionMessages(projectID, sessionID string, afterSeq int, program *tea.Program) tea.Cmd { - return func() tea.Msg { - // Cancel any previously active watch. - tc.StopWatching() - - ctx, cancel := context.WithCancel(context.Background()) - - tc.watchMu.Lock() - tc.watchCancel = cancel - tc.watchMu.Unlock() - - client, err := tc.factory.ForProject(projectID) - if err != nil { - cancel() - program.Send(SessionMessageEvent{Err: err}) - return nil - } - - // The SDK's WatchMessages handles SSE connection, parsing, and - // reconnection with exponential backoff (1s, 2s, 4s, max 30s). - // It returns a channel of *SessionMessage and a stop function. - msgs, _, sseErr := client.Sessions().WatchMessages(ctx, sessionID, afterSeq) - if sseErr != nil { - cancel() - program.Send(SessionMessageEvent{Err: sseErr}) - return nil - } - - // Forward messages from the SDK channel to the Bubbletea program. - // This goroutine exits when the channel closes (on context - // cancellation or stream end). If the channel closes without - // cancellation, it means the SSE stream died silently -- notify - // the TUI so it can fall back to polling. - go func() { - defer cancel() - receivedAny := false - for msg := range msgs { - receivedAny = true - program.Send(SessionMessageEvent{Message: msg}) - } - // Channel closed. If the context was not cancelled by us - // (StopWatching or view change), this is an unexpected close. - if ctx.Err() == nil { - errMsg := "SSE stream closed" - if !receivedAny { - errMsg = "SSE connection failed (no messages received)" - } - program.Send(SessionMessageEvent{ - Err: fmt.Errorf("%s — falling back to polling", errMsg), - }) - } - }() - - return nil - } -} - -// WatchSessionEvents returns a tea.Cmd that starts an SSE stream for -// AG-UI events via the /events endpoint. Events are delivered to the Bubbletea -// program via program.Send(SessionMessageEvent{...}). -// -// Unlike WatchSessionMessages (which reads /messages), this connects to -// GET /sessions/{id}/events and receives the full AG-UI event stream: -// TEXT_MESSAGE_CONTENT, TOOL_CALL_START, TOOL_CALL_ARGS, RUN_FINISHED, etc. -// -// The SSE goroutine: -// - Connects to GET /sessions/{id}/events via the SDK's StreamEvents. -// - Parses the SSE stream line by line (data: <JSON>). -// - Extracts the "type" field from each JSON event and maps it to a -// SessionMessageEvent with EventType and Payload fields. -// - Handles reconnection with exponential backoff (1s, 2s, 4s, max 30s). -// - Is cancellable via StopWatching(). -// -// Only one watch can be active at a time. Calling WatchSessionEvents while -// a previous watch is running cancels the old one first. -func (tc *TUIClient) WatchSessionEvents(projectID, sessionID string, program *tea.Program) tea.Cmd { - return func() tea.Msg { - // Cancel any previously active watch. - tc.StopWatching() - - ctx, cancel := context.WithCancel(context.Background()) - - tc.watchMu.Lock() - tc.watchCancel = cancel - tc.watchMu.Unlock() - - client, err := tc.factory.ForProject(projectID) - if err != nil { - cancel() - program.Send(SessionMessageEvent{Err: err}) - return nil - } - - // SSE reconnection loop with exponential backoff. - go func() { - defer cancel() - - backoff := 1 * time.Second - const maxBackoff = 30 * time.Second - const scannerBufSize = 1 << 20 - - for { - if ctx.Err() != nil { - return - } - - body, sseErr := client.Sessions().StreamEvents(ctx, sessionID) - if sseErr != nil { - if ctx.Err() != nil { - return - } - program.Send(SessionMessageEvent{ - Err: fmt.Errorf("events stream connect: %w", sseErr), - }) - // Wait and retry. - select { - case <-ctx.Done(): - return - case <-time.After(backoff): - } - backoff *= 2 - if backoff > maxBackoff { - backoff = maxBackoff - } - continue - } - - // Connected — reset backoff and notify. - backoff = 1 * time.Second - program.Send(SessionMessageEvent{ - Message: &sdktypes.SessionMessage{ - EventType: "system", - Payload: "connected to event stream", - }, - }) - - // Parse SSE stream line by line. - scanner := bufio.NewScanner(body) - scanner.Buffer(make([]byte, scannerBufSize), scannerBufSize) - - var dataBuf strings.Builder - streamDone := false - - for scanner.Scan() { - if ctx.Err() != nil { - body.Close() //nolint:errcheck - return - } - - line := scanner.Text() - - switch { - case line == ": heartbeat": - // Ignore SSE heartbeat comments. - continue - - case strings.HasPrefix(line, "data: "): - if dataBuf.Len() > 0 { - dataBuf.WriteByte('\n') - } - dataBuf.WriteString(strings.TrimPrefix(line, "data: ")) - - case line == "": - if dataBuf.Len() == 0 { - continue - } - data := dataBuf.String() - dataBuf.Reset() - - // Parse the JSON to extract "type" field. - var raw map[string]json.RawMessage - if err := json.Unmarshal([]byte(data), &raw); err != nil { - continue - } - - // Extract event type. - var eventType string - if typeField, ok := raw["type"]; ok { - _ = json.Unmarshal(typeField, &eventType) - } - if eventType == "" { - continue - } - - program.Send(SessionMessageEvent{ - Message: &sdktypes.SessionMessage{ - EventType: eventType, - Payload: data, - }, - }) - - // Close the stream on terminal events. - if eventType == "RUN_FINISHED" || eventType == "RUN_ERROR" { - streamDone = true - } - } - - if streamDone { - break - } - } - - body.Close() //nolint:errcheck - - if ctx.Err() != nil { - return - } - - // If the stream ended normally (RUN_FINISHED/RUN_ERROR), the current - // run is done but another may start when the user sends a message. - // Reconnect after a short delay to pick up the next run. - if streamDone { - select { - case <-ctx.Done(): - return - case <-time.After(1 * time.Second): - } - continue - } - - // Unexpected close — reconnect. - if scanErr := scanner.Err(); scanErr != nil { - program.Send(SessionMessageEvent{ - Err: fmt.Errorf("events stream read: %w", scanErr), - }) - } - - select { - case <-ctx.Done(): - return - case <-time.After(backoff): - } - backoff *= 2 - if backoff > maxBackoff { - backoff = maxBackoff - } - } - }() - - return nil - } -} - // FetchSessionMessages returns a tea.Cmd that polls session messages via the -// REST ListMessages endpoint. This is used as a fallback when SSE streaming is -// unavailable or stalled. Only messages with seq > afterSeq are returned. +// REST ListMessages endpoint. Only messages with seq > afterSeq are returned. func (tc *TUIClient) FetchSessionMessages(projectID, sessionID string, afterSeq int) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) @@ -1067,14 +788,3 @@ func (tc *TUIClient) FetchSessionMessages(projectID, sessionID string, afterSeq } } -// StopWatching cancels any active SSE watch goroutine started by -// WatchSessionMessages. -func (tc *TUIClient) StopWatching() { - tc.watchMu.Lock() - defer tc.watchMu.Unlock() - - if tc.watchCancel != nil { - tc.watchCancel() - tc.watchCancel = nil - } -} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 51eb279a0..adc192e9b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -27,7 +27,7 @@ const pollInterval = 5 * time.Second // messagePollInterval is the polling interval for session messages when the // messages view is active. Faster than the table poll to keep messages fresh. -const messagePollInterval = 2 * time.Second +const messagePollInterval = 1 * time.Second // infoTimeout is how long ephemeral info messages are displayed. const infoTimeout = 5 * time.Second @@ -35,13 +35,6 @@ const infoTimeout = 5 * time.Second // staleThreshold marks data as stale in the header when exceeded. const staleThreshold = 15 * time.Second -// isRunningPhase returns true if the session phase indicates the session is -// currently running and can produce live AG-UI events. -func isRunningPhase(phase string) bool { - p := strings.ToLower(phase) - return p == "running" || p == "active" || p == "pending" -} - // --------------------------------------------------------------------------- // Navigation // --------------------------------------------------------------------------- @@ -156,10 +149,7 @@ type AppModel struct { cachedSessions []sdktypes.Session cachedInbox []sdktypes.InboxMessage - // SSE program reference (set via SetProgram after tea.NewProgram). - program *tea.Program - - // Message polling state (fallback when SSE is unavailable). + // Message polling state. messagePollActive bool // true when message poll tick is running // Errors @@ -265,12 +255,6 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { return m, nil } -// SetProgram stores a reference to the tea.Program so the model can pass it to -// WatchSessionMessages for SSE delivery. Call this after tea.NewProgram returns. -func (m *AppModel) SetProgram(p *tea.Program) { - m.program = p -} - // findAgentByName returns the cached Agent with the given name, or nil. func (m *AppModel) findAgentByName(name string) *sdktypes.Agent { for i := range m.cachedAgents { @@ -403,10 +387,9 @@ func (m *AppModel) popView() tea.Cmd { if len(m.navStack) <= 1 { return nil } - // If we're leaving the messages view, stop SSE and polling. + // If we're leaving the messages view, stop polling. poppedKind := m.navStack[len(m.navStack)-1].Kind if poppedKind == "messages" { - m.client.StopWatching() m.messagePollActive = false } @@ -584,7 +567,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case views.MsgStreamBackMsg: // User pressed Esc in the message stream — pop back. - m.client.StopWatching() cmd := m.popView() return m, tea.Batch(cmd, m.setInfo("Back to "+m.currentNav().Kind)) @@ -741,15 +723,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { return m, m.setInfo("Send message failed: " + msg.Err.Error()) } - // Add the user message to history immediately so it's visible - // without waiting for a poll. The /events stream only carries - // runner AG-UI events, not echoed user messages. + // Add the user message immediately so it's visible without + // waiting for the next poll cycle. if msg.Message != nil { ts := time.Now() if msg.Message.CreatedAt != nil { ts = *msg.Message.CreatedAt } - m.messageStream.AddHistoryMessage(views.MessageEntry{ + m.messageStream.AddMessage(views.MessageEntry{ Seq: msg.Message.Seq, EventType: "user", Payload: msg.Message.Payload, @@ -776,43 +757,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(m.fetchActiveView(), m.setInfo("Inbox message deleted")) - case SessionMessageEvent: - // SSE event received — route to the live overlay in the message stream. - if msg.Err != nil { - m.messageStream.SetSSEStatus("reconnecting") - m.messageStream.HandleStreamEvent(views.MessageEntry{ - EventType: "error", - Payload: msg.Err.Error(), - Timestamp: time.Now(), - }) - // SSE failed — ensure polling fallback is running. - if m.activeView == "messages" && !m.messagePollActive { - m.messagePollActive = true - return m, m.messagePollTickCmd() - } - return m, nil - } - if msg.Message != nil && m.activeView == "messages" { - m.messageStream.SetSSEStatus("connected") - ts := time.Now() - if msg.Message.CreatedAt != nil { - ts = *msg.Message.CreatedAt - } - entry := views.MessageEntry{ - Seq: msg.Message.Seq, - EventType: msg.Message.EventType, - Payload: msg.Message.Payload, - Timestamp: ts, - } - // All /events SSE events go to HandleStreamEvent which writes - // to liveOverlay. The accumulator coalesces deltas into single - // growing entries; non-accumulated events are added directly. - m.messageStream.HandleStreamEvent(entry) - } - return m, nil - case SessionMessagesMsg: - // Polling: batch of messages from REST ListMessages goes to history. + // Polling: batch of messages from REST ListMessages. if msg.Err != nil { // Non-fatal — polling will retry on next tick, but inform user. return m, m.setInfo("Message poll error: " + msg.Err.Error()) @@ -821,15 +767,15 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } for _, sm := range msg.Messages { - // Simple seq-based dedup on history — no cross-stream checks needed. - if sm.Seq <= m.messageStream.LastHistorySeq() { + // Simple seq-based dedup. + if sm.Seq <= m.messageStream.LastSeq() { continue } ts := time.Now() if sm.CreatedAt != nil { ts = *sm.CreatedAt } - m.messageStream.AddHistoryMessage(views.MessageEntry{ + m.messageStream.AddMessage(views.MessageEntry{ Seq: sm.Seq, EventType: sm.EventType, Payload: sm.Payload, @@ -837,9 +783,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) } m.lastFetch = time.Now() - if len(msg.Messages) > 0 && m.messageStream.GetSSEStatus() != "connected" { - m.messageStream.SetSSEStatus("polling") - } return m, nil case messagePollTickMsg: @@ -860,7 +803,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if projectID != "" { cmds = append(cmds, m.client.FetchSessionMessages( - projectID, m.currentSession, m.messageStream.LastHistorySeq(), + projectID, m.currentSession, m.messageStream.LastSeq(), )) } } @@ -1313,9 +1256,8 @@ func (m *AppModel) handleTick() (tea.Model, tea.Cmd) { // handleKey dispatches key events based on the current mode. func (m *AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Ctrl-C always quits — clean up SSE goroutines first. + // Ctrl-C always quits. if msg.Type == tea.KeyCtrlC { - m.client.StopWatching() m.messagePollActive = false return m, tea.Quit } @@ -1578,7 +1520,7 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { cmds := []tea.Cmd{ m.pushView("messages", fullSessionID, fullSessionID), - m.setInfo("Streaming messages for session " + shortID), + m.setInfo("Viewing messages for session " + shortID), } // Resolve project ID — may be empty if reached from global sessions. @@ -1588,20 +1530,10 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { } if projectID != "" { - // Always fetch historical messages first for context. + // Fetch initial messages and start 1-second polling. cmds = append(cmds, m.client.FetchSessionMessages(projectID, fullSessionID, 0)) - - // Always run message polling for DB-persisted user/assistant - // messages (the /events stream only carries AG-UI runner events). m.messagePollActive = true cmds = append(cmds, m.messagePollTickCmd()) - - if isRunningPhase(phase) && m.program != nil { - // For running sessions, also start the AG-UI event stream - // for live tool calls and text deltas. - m.messageStream.SetSSEStatus("connecting") - cmds = append(cmds, m.client.WatchSessionEvents(projectID, fullSessionID, m.program)) - } } return m, tea.Batch(cmds...) @@ -1650,7 +1582,6 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "q": if len(m.navStack) <= 1 { - m.client.StopWatching() m.messagePollActive = false return m, tea.Quit } @@ -1848,17 +1779,13 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { cmds := []tea.Cmd{ m.pushView("messages", sessionID, sessionID), - m.setInfo("Streaming messages for session " + sessionID), + m.setInfo("Viewing messages for session " + sessionID), } if m.currentProject != "" { cmds = append(cmds, m.client.FetchSessionMessages(m.currentProject, sessionID, 0)) m.messagePollActive = true cmds = append(cmds, m.messagePollTickCmd()) - if m.program != nil { - m.messageStream.SetSSEStatus("connecting") - cmds = append(cmds, m.client.WatchSessionEvents(m.currentProject, sessionID, m.program)) - } } return m, tea.Batch(cmds...) @@ -2222,9 +2149,8 @@ func (m *AppModel) handleCommandKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // executeCommand parses and dispatches a command-mode input. func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { - // If we're leaving the messages view via a command, stop SSE and polling. + // If we're leaving the messages view via a command, stop polling. if m.activeView == "messages" { - m.client.StopWatching() m.messagePollActive = false } @@ -2232,7 +2158,6 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { switch cmd.Kind { case CmdQuit: - m.client.StopWatching() return m, tea.Quit case CmdProjects: diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index ba71b3edd..003877191 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -378,13 +378,6 @@ type MessageEntry struct { EventType string Payload string Timestamp time.Time - - // entryID is a unique monotonic identifier assigned by the MessageStream - // when the entry is added to history or liveOverlay. It is used as the - // glamour render cache key instead of Seq because SSE-derived events all - // have Seq=0, which causes cache collisions and makes every entry render - // the same cached output (e.g. "connected to event stream"). - entryID int } // --------------------------------------------------------------------------- @@ -398,32 +391,18 @@ const defaultMaxMessages = 2000 // It renders messages in conversation or raw mode, supports scrolling, // autoscroll, compose input, and search. // -// The message buffer is split into two separated streams: -// - history: durable conversation turns from /messages polling (user + assistant) -// - liveOverlay: ephemeral AG-UI events from /events SSE (tool calls, reasoning, streaming text) -// -// View() renders history first, then a separator, then liveOverlay. No cross-stream dedup needed. +// Messages arrive via 1-second REST polling of /messages. type MessageStream struct { sessionID string agentName string phase string - // SSE connection status: "", "connected", "reconnecting", "disconnected". - sseStatus string - - // Separated message buffers. - history []MessageEntry // durable conversation turns from /messages polling - liveOverlay []MessageEntry // ephemeral AG-UI events from /events SSE - maxMessages int // ring buffer capacity for history only - - // Highest seq seen in history — used for polling dedup. - lastHistorySeq int + // Single message buffer. + messages []MessageEntry + maxMessages int // ring buffer capacity - // runFinished is set when RUN_FINISHED/RUN_ERROR arrives via the event stream. - // The overlay persists until the next AddHistoryMessage with EventType=="assistant" - // arrives, at which point the overlay is cleared. This avoids a visual gap where - // neither buffer has the response. - runFinished bool + // Highest seq seen — used for polling dedup. + lastSeq int // Display scrollOffset int @@ -436,30 +415,16 @@ type MessageStream struct { glamourRenderer *glamour.TermRenderer glamourWidth int // width used to create the cached renderer - // Streaming accumulator — coalesces AG-UI deltas into a single growing entry. - // These write to liveOverlay instead of history. - streamingEntry *MessageEntry // the in-progress text message being accumulated - streamingText strings.Builder // accumulated text for the current text message - streamingToolEntry *MessageEntry // the in-progress tool call being accumulated - streamingToolArgs strings.Builder // accumulated args for the current tool call - streamingReasonEntry *MessageEntry // the in-progress reasoning message being accumulated - streamingReasonText strings.Builder // accumulated text for the current reasoning message - // Cached display lines — rebuilt when mode/messages change, not every frame. cachedLines []string cachedDirty bool // true when lines need rebuilding - cachedMsgCount int // history + overlay combined count + cachedMsgCount int cachedRawMode bool cachedWrapMode bool cachedTsMode int cachedSearchPat string - // nextEntryID is a monotonically incrementing counter used to assign unique - // entryID values to each MessageEntry. This avoids glamour cache collisions - // when multiple entries share the same Seq value (e.g. all SSE events have Seq=0). - nextEntryID int - - // Per-message glamour render cache (key = entryID, not Seq). + // Per-message glamour render cache (key = Seq). glamourCache map[int]string // Compose @@ -491,8 +456,7 @@ func NewMessageStream(sessionID, agentName, phase string) MessageStream { sessionID: sessionID, agentName: agentName, phase: phase, - history: make([]MessageEntry, 0, 256), - liveOverlay: make([]MessageEntry, 0, 64), + messages: make([]MessageEntry, 0, 256), maxMessages: defaultMaxMessages, autoScroll: true, composeInput: ci, @@ -504,266 +468,39 @@ func NewMessageStream(sessionID, agentName, phase string) MessageStream { // Public methods // --------------------------------------------------------------------------- -// AddHistoryMessage appends a message to the history ring buffer. When the -// buffer exceeds maxMessages, the oldest message is evicted. If autoScroll is -// enabled the scroll offset is advanced to keep the newest message visible. -// -// When runFinished is true and an "assistant" message arrives, the live overlay -// is cleared — the persisted assistant message in history replaces the ephemeral -// streaming overlay. -func (ms *MessageStream) AddHistoryMessage(entry MessageEntry) { - ms.nextEntryID++ - entry.entryID = ms.nextEntryID - ms.history = append(ms.history, entry) - if len(ms.history) > ms.maxMessages { +// AddMessage appends a message to the ring buffer. When the buffer exceeds +// maxMessages, the oldest message is evicted. If autoScroll is enabled the +// scroll offset is advanced to keep the newest message visible. +func (ms *MessageStream) AddMessage(entry MessageEntry) { + ms.messages = append(ms.messages, entry) + if len(ms.messages) > ms.maxMessages { // Evict oldest — shift the slice. For a 2000-entry buffer this is // acceptable; a true ring buffer optimisation can come later. - excess := len(ms.history) - ms.maxMessages + excess := len(ms.messages) - ms.maxMessages // Clean up glamour cache entries for evicted messages. if ms.glamourCache != nil { - for _, evicted := range ms.history[:excess] { - delete(ms.glamourCache, evicted.entryID) + for _, evicted := range ms.messages[:excess] { + delete(ms.glamourCache, evicted.Seq) } } - ms.history = ms.history[excess:] + ms.messages = ms.messages[excess:] // Don't adjust scrollOffset here — it's a display-line offset, not a // message-array index. renderContent's clamp handles any overshoot. } // Track highest seq for polling dedup. - if entry.Seq > ms.lastHistorySeq { - ms.lastHistorySeq = entry.Seq - } - // When the run has finished and the persisted assistant message arrives - // in history, clear the ephemeral overlay — history now has the response. - if ms.runFinished && entry.EventType == "assistant" { - ms.ClearLiveOverlay() - } - ms.cachedDirty = true - if ms.autoScroll { - ms.scrollToBottom() - } -} - -// LastHistorySeq returns the highest seq in the history buffer. Used by the -// polling path for dedup. -func (ms *MessageStream) LastHistorySeq() int { - return ms.lastHistorySeq -} - -// ClearLiveOverlay clears the live overlay buffer and all streaming -// accumulators. Called when the overlay content has been superseded by -// persisted history entries. -func (ms *MessageStream) ClearLiveOverlay() { - ms.liveOverlay = ms.liveOverlay[:0] - ms.streamingEntry = nil - ms.streamingText.Reset() - ms.streamingToolEntry = nil - ms.streamingToolArgs.Reset() - ms.streamingReasonEntry = nil - ms.streamingReasonText.Reset() - ms.runFinished = false - ms.cachedDirty = true -} - -// MessageCount returns the total number of messages across both buffers. -func (ms *MessageStream) MessageCount() int { - return len(ms.history) + len(ms.liveOverlay) -} - -// HandleStreamEvent processes AG-UI streaming events by accumulating deltas -// into a single growing message entry in liveOverlay instead of creating a -// separate entry per delta. This produces clean output where text streams in -// place rather than appearing as dozens of fragment lines. -func (ms *MessageStream) HandleStreamEvent(entry MessageEntry) { - switch entry.EventType { - - // -- Text message accumulation -- - - case "TEXT_MESSAGE_START": - // Begin a new assistant message slot in liveOverlay. - ms.streamingText.Reset() - ms.nextEntryID++ - newEntry := MessageEntry{ - Seq: entry.Seq, - EventType: "assistant", - Payload: "", - Timestamp: entry.Timestamp, - entryID: ms.nextEntryID, - } - ms.streamingEntry = &newEntry - ms.liveOverlay = append(ms.liveOverlay, newEntry) - ms.cachedDirty = true - if ms.autoScroll { - ms.scrollToBottom() - } - - case "TEXT_MESSAGE_CONTENT": - delta := extractJSONField(entry.Payload, "delta") - if delta == "" { - return - } - ms.streamingText.WriteString(delta) - // Update the last liveOverlay entry in place. - if ms.streamingEntry != nil && len(ms.liveOverlay) > 0 { - ms.liveOverlay[len(ms.liveOverlay)-1].Payload = ms.streamingText.String() - // Invalidate glamour cache for this entry since content changed. - if ms.glamourCache != nil { - delete(ms.glamourCache, ms.liveOverlay[len(ms.liveOverlay)-1].entryID) - } - ms.cachedDirty = true - if ms.autoScroll { - ms.scrollToBottom() - } - } - - case "TEXT_MESSAGE_END": - // Finalize the accumulated text message. - ms.streamingEntry = nil - ms.cachedDirty = true - - // -- Reasoning accumulation -- - - case "REASONING_MESSAGE_START", "REASONING_START": - ms.streamingReasonText.Reset() - ms.nextEntryID++ - newEntry := MessageEntry{ - Seq: entry.Seq, - EventType: "reasoning", - Payload: "", - Timestamp: entry.Timestamp, - entryID: ms.nextEntryID, - } - ms.streamingReasonEntry = &newEntry - ms.liveOverlay = append(ms.liveOverlay, newEntry) - ms.cachedDirty = true - if ms.autoScroll { - ms.scrollToBottom() - } - - case "REASONING_MESSAGE_CONTENT": - delta := extractJSONField(entry.Payload, "delta") - if delta == "" { - return - } - ms.streamingReasonText.WriteString(delta) - if ms.streamingReasonEntry != nil && len(ms.liveOverlay) > 0 { - for i := len(ms.liveOverlay) - 1; i >= 0; i-- { - if ms.liveOverlay[i].entryID == ms.streamingReasonEntry.entryID { - ms.liveOverlay[i].Payload = ms.streamingReasonText.String() - break - } - } - ms.cachedDirty = true - if ms.autoScroll { - ms.scrollToBottom() - } - } - - case "REASONING_MESSAGE_END", "REASONING_END": - ms.streamingReasonEntry = nil - ms.cachedDirty = true - - // -- Tool call accumulation -- - - case "TOOL_CALL_START": - ms.streamingToolArgs.Reset() - // Extract the tool name from the payload. - toolName := extractJSONField(entry.Payload, "tool_call_name") - if toolName == "" { - toolName = extractJSONField(entry.Payload, "tool_name") - } - if toolName == "" { - toolName = extractJSONField(entry.Payload, "toolCallName") - } - if toolName == "" { - toolName = extractJSONField(entry.Payload, "name") - } - ms.nextEntryID++ - newEntry := MessageEntry{ - Seq: entry.Seq, - EventType: "tool_use", - Payload: toolName, - Timestamp: entry.Timestamp, - entryID: ms.nextEntryID, - } - ms.streamingToolEntry = &newEntry - ms.liveOverlay = append(ms.liveOverlay, newEntry) - ms.cachedDirty = true - if ms.autoScroll { - ms.scrollToBottom() - } - - case "TOOL_CALL_ARGS": - delta := extractJSONField(entry.Payload, "delta") - if delta == "" { - return - } - ms.streamingToolArgs.WriteString(delta) - // Append accumulated args to the tool call entry's payload in liveOverlay. - if ms.streamingToolEntry != nil && len(ms.liveOverlay) > 0 { - for i := len(ms.liveOverlay) - 1; i >= 0; i-- { - if ms.liveOverlay[i].entryID == ms.streamingToolEntry.entryID { - // Keep the tool name, append args after a space. - baseName := ms.streamingToolEntry.Payload - ms.liveOverlay[i].Payload = baseName + " " + ms.streamingToolArgs.String() - break - } - } - ms.cachedDirty = true - if ms.autoScroll { - ms.scrollToBottom() - } - } - - case "TOOL_CALL_END": - ms.streamingToolEntry = nil - ms.cachedDirty = true - - // -- Non-accumulated events: add to liveOverlay directly -- - - case "TOOL_CALL_RESULT": - entry.EventType = "tool_result" - ms.addLiveEvent(entry) - - default: - // RUN_FINISHED, RUN_ERROR, and anything else — add to overlay. - ms.addLiveEvent(entry) - // Mark run as finished so overlay persists until history catches up. - if entry.EventType == "RUN_FINISHED" || entry.EventType == "RUN_ERROR" { - ms.runFinished = true - } + if entry.Seq > ms.lastSeq { + ms.lastSeq = entry.Seq } -} - -// addLiveEvent appends an entry to the liveOverlay buffer and handles -// autoscroll. This is a thin wrapper for non-accumulated events. -func (ms *MessageStream) addLiveEvent(entry MessageEntry) { - ms.nextEntryID++ - entry.entryID = ms.nextEntryID - ms.liveOverlay = append(ms.liveOverlay, entry) ms.cachedDirty = true if ms.autoScroll { ms.scrollToBottom() } } -// IsStreaming returns true when a text message is actively being accumulated -// from AG-UI deltas. Used by View() to show the streaming cursor. -func (ms *MessageStream) IsStreaming() bool { - return ms.streamingEntry != nil -} - -// evictIfNeeded trims the history buffer to maxMessages if it has grown too large. -func (ms *MessageStream) evictIfNeeded() { - if len(ms.history) > ms.maxMessages { - excess := len(ms.history) - ms.maxMessages - if ms.glamourCache != nil { - for _, evicted := range ms.history[:excess] { - delete(ms.glamourCache, evicted.entryID) - } - } - ms.history = ms.history[excess:] - } +// LastSeq returns the highest seq in the buffer. Used by the polling path +// for dedup. +func (ms *MessageStream) LastSeq() int { + return ms.lastSeq } // SetSize updates the viewport dimensions and invalidates caches that depend @@ -787,14 +524,6 @@ func (ms *MessageStream) SetPhase(phase string) { ms.phase = phase } -// SetSSEStatus updates the SSE connection status indicator shown in the header. -// Valid values: "", "connected", "reconnecting", "disconnected". -func (ms MessageStream) GetSSEStatus() string { return ms.sseStatus } - -func (ms *MessageStream) SetSSEStatus(status string) { - ms.sseStatus = status -} - // ComposeValue returns the current text in the compose input. // IsComposeMode returns true when the compose input is active. func (ms MessageStream) IsComposeMode() bool { @@ -950,13 +679,10 @@ func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { case "c": // Copy the first visible message's payload to clipboard. // scrollOffset is a display-line offset, so we iterate all messages - // (history + overlay) and count display lines to find the right one. - allEntries := make([]MessageEntry, 0, len(ms.history)+len(ms.liveOverlay)) - allEntries = append(allEntries, ms.history...) - allEntries = append(allEntries, ms.liveOverlay...) - if len(allEntries) > 0 { + // and count display lines to find the right one. + if len(ms.messages) > 0 { lineCount := 0 - for _, entry := range allEntries { + for _, entry := range ms.messages { var entryLines []string if ms.rawMode { entryLines = ms.renderRawEntry(entry, max(ms.width-4, 20)) @@ -1073,7 +799,7 @@ func (ms *MessageStream) View() string { titleRendered := " " + kindStyle.Render("messages") + dimStyle.Render("(") + scopeStyle.Render(scope) + dimStyle.Render(")") + - dimStyle.Render("[") + countStyle.Render(fmt.Sprintf("%d", ms.MessageCount())) + dimStyle.Render("]") + + dimStyle.Render("[") + countStyle.Render(fmt.Sprintf("%d", len(ms.messages))) + dimStyle.Render("]") + " " titleWidth := lipgloss.Width(titleRendered) remaining := max(ms.width-titleWidth-2, 2) @@ -1139,24 +865,6 @@ func (ms *MessageStream) View() string { phaseStyle.Render(ms.phase), dimIndicator.Render(scrollPct), ) - if ms.sseStatus != "" { - var sseColor lipgloss.Color - switch ms.sseStatus { - case "connected": - sseColor = msgColorGreen - case "connecting": - sseColor = msgColorYellow - case "reconnecting": - sseColor = msgColorYellow - case "polling": - sseColor = msgColorDim - default: - sseColor = msgColorRed - } - sseStyle := lipgloss.NewStyle().Foreground(sseColor) - indicators += fmt.Sprintf(" SSE:%s", - sseStyle.Render(ms.sseStatus)) - } // Center the indicators line. indWidth := lipgloss.Width(indicators) indPad := max((ms.width-2-indWidth)/2, 0) @@ -1182,9 +890,8 @@ func (ms *MessageStream) View() string { bottomLines = append([]string{composeSep, composeLine}, bottomLines...) } - // Streaming cursor — shown when phase is running OR when actively - // accumulating AG-UI deltas (IsStreaming). - if strings.ToLower(ms.phase) == "running" || ms.IsStreaming() { + // Streaming cursor — shown when phase is running. + if strings.ToLower(ms.phase) == "running" { cursorStyle := msgCursorStyle frames := []string{"▌", "▐", "█", "▐"} frame := frames[time.Now().UnixMilli()/300%4] @@ -1233,7 +940,7 @@ func (ms *MessageStream) View() string { // renderContent produces the visible message lines for the content area. func (ms *MessageStream) renderContent(height int) []string { - if len(ms.history) == 0 && len(ms.liveOverlay) == 0 { + if len(ms.messages) == 0 { return []string{msgDimStyle.Render("No messages yet.")} } @@ -1259,15 +966,14 @@ func (ms *MessageStream) renderContent(height int) []string { return allLines[start:end] } -// buildDisplayLines converts both message buffers into styled display lines. -// History entries are rendered first, then a dim separator, then liveOverlay -// entries. Results are cached and only rebuilt when mode/messages change. +// buildDisplayLines converts messages into styled display lines. +// Results are cached and only rebuilt when mode/messages change. func (ms *MessageStream) buildDisplayLines() []string { searchStr := "" if ms.searchPattern != nil { searchStr = ms.searchPattern.String() } - totalCount := ms.MessageCount() + totalCount := len(ms.messages) // Check if cache is still valid (timestamps always invalidate since relative times change). if !ms.cachedDirty && ms.cachedMsgCount == totalCount && @@ -1288,9 +994,8 @@ func (ms *MessageStream) buildDisplayLines() []string { now := time.Now() - // Render history entries. prevWasUserOrAssistant := false - for _, entry := range ms.history { + for _, entry := range ms.messages { entryLines := ms.renderEntry(entry, maxLineWidth, now) if len(entryLines) == 0 { continue @@ -1306,37 +1011,6 @@ func (ms *MessageStream) buildDisplayLines() []string { lines = append(lines, entryLines...) } - // Render liveOverlay entries with a header separator. - if len(ms.liveOverlay) > 0 { - // Check if any overlay entries pass the search filter before adding the separator. - hasVisible := false - for _, entry := range ms.liveOverlay { - if ms.searchPattern != nil { - text := eventSummary(entry.EventType, entry.Payload) - if !ms.searchPattern.MatchString(text) && !ms.searchPattern.MatchString(entry.Payload) { - continue - } - } - hasVisible = true - break - } - if hasVisible { - overlaySep := msgSepStyle.Render(fmt.Sprintf( - "── agent activity %s", - strings.Repeat("─", max(maxLineWidth-19, 5)), - )) - lines = append(lines, overlaySep) - - for _, entry := range ms.liveOverlay { - entryLines := ms.renderEntry(entry, maxLineWidth, now) - if len(entryLines) == 0 { - continue - } - lines = append(lines, entryLines...) - } - } - } - ms.cachedLines = lines ms.cachedDirty = false ms.cachedMsgCount = totalCount @@ -1452,7 +1126,7 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in ms.glamourCache = make(map[int]string) } var rendered string - if cached, ok := ms.glamourCache[entry.entryID]; ok { + if cached, ok := ms.glamourCache[entry.Seq]; ok { rendered = cached } else { glamourWidth := max(ms.width-20, 20) @@ -1460,7 +1134,7 @@ func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth in out, err := r.Render(strings.TrimSpace(displayText)) if err == nil { rendered = strings.TrimSpace(out) - ms.glamourCache[entry.entryID] = rendered + ms.glamourCache[entry.Seq] = rendered } } } @@ -1549,7 +1223,7 @@ func (ms *MessageStream) scrollDown(n int) { func (ms *MessageStream) scrollToBottom() { // Set a large value; renderContent will clamp. - ms.scrollOffset = ms.MessageCount() * 10 + ms.scrollOffset = len(ms.messages) * 10 } // contentHeight returns the usable content height given the current dimensions. @@ -1562,7 +1236,7 @@ func (ms *MessageStream) contentHeight() int { if ms.composeMode { bottomLines += 2 // compose separator + compose line } - if strings.ToLower(ms.phase) == "running" || ms.IsStreaming() { + if strings.ToLower(ms.phase) == "running" { bottomLines++ // streaming cursor line } h := ms.height - topLines - bottomLines @@ -1639,15 +1313,6 @@ func wrapText(s string, maxWidth int) []string { return result } -// ansiRe matches ANSI CSI escape sequences for stripping before search. -var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) - -// stripANSI removes ANSI escape sequences from a string so that search -// matching operates on visible text only. -func stripANSI(s string) string { - return ansiRe.ReplaceAllString(s, "") -} - // padToWidth pads a styled string to exactly w visual characters. func padToWidth(s string, w int) string { vis := lipgloss.Width(s) From 146c3bdaf917fe88df0995f0f5b61fb0793b1c38 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Tue, 28 Apr 2026 12:37:35 -0400 Subject: [PATCH 107/117] fix(cli): remove streaming cursor indicator from message view The animated "streaming..." cursor is no longer meaningful with polling-only architecture. The header's refresh/staleness indicator already shows data freshness. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../cmd/acpctl/ambient/tui/views/messages.go | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 003877191..6c76af704 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -890,18 +890,6 @@ func (ms *MessageStream) View() string { bottomLines = append([]string{composeSep, composeLine}, bottomLines...) } - // Streaming cursor — shown when phase is running. - if strings.ToLower(ms.phase) == "running" { - cursorStyle := msgCursorStyle - frames := []string{"▌", "▐", "█", "▐"} - frame := frames[time.Now().UnixMilli()/300%4] - cursor := cursorStyle.Render(" " + frame + " streaming…") - cursorLine := borderStyle.Render("│") + - padToWidth(cursor, ms.width-2) + - borderStyle.Render("│") - // Prepend cursor above compose/status. - bottomLines = append([]string{cursorLine}, bottomLines...) - } // -- Content area -- @@ -1236,9 +1224,6 @@ func (ms *MessageStream) contentHeight() int { if ms.composeMode { bottomLines += 2 // compose separator + compose line } - if strings.ToLower(ms.phase) == "running" { - bottomLines++ // streaming cursor line - } h := ms.height - topLines - bottomLines if h < 1 { h = 1 From f787fed8b2c497910e9cdc7ee6bab8f78c3c7eae Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Tue, 28 Apr 2026 15:19:23 -0400 Subject: [PATCH 108/117] feat(cli): add scheduled sessions view and session interrupt Scheduled Sessions (`:scheduledsessions` / `:ss`): - New views/scheduledsessions.go with columns, row builder, detail, form - Full CRUD: create, delete, suspend/resume, trigger manual run - Describe (d), JSON (y), Enter to show runs - Project-scoped, command-mode only (not in drill-down hierarchy) - Fetches from old backend via HTTP proxy through API server Session Interrupt: - `x` key in sessions view and messages view with confirmation dialog - POSTs to /sessions/{id}/agui/interrupt via API server proxy Spec updated with ScheduledSession view section, interrupt hotkeys, :ss command, and removed from "not covered" table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 2 + .../cmd/acpctl/ambient/tui/client.go | 248 ++++++++++++ .../cmd/acpctl/ambient/tui/command.go | 6 + .../cmd/acpctl/ambient/tui/command_test.go | 10 +- .../cmd/acpctl/ambient/tui/hints.go | 18 + .../cmd/acpctl/ambient/tui/model_new.go | 360 +++++++++++++++++- .../ambient/tui/views/scheduledsessions.go | 170 +++++++++ docs/internal/design/tui.spec.md | 32 +- 8 files changed, 833 insertions(+), 13 deletions(-) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 5dfea4175..50ca3ac1a 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -306,6 +306,8 @@ func (m *AppModel) viewResourceTable() string { return m.inboxTable.View() case "contexts": return m.contextTable.View() + case "scheduledsessions": + return m.scheduledSessionTable.View() case "messages": return m.messageStream.View() case "detail": diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index 76902492d..ae4a6482c 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -1,10 +1,18 @@ package tui import ( + "bytes" "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" "sync" "time" + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" "github.com/ambient-code/platform/components/ambient-cli/pkg/connection" sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" tea "github.com/charmbracelet/bubbletea" @@ -768,6 +776,246 @@ func (tc *TUIClient) DeleteInboxMessage(projectID, agentID, msgID string) tea.Cm } } +// --------------------------------------------------------------------------- +// Scheduled Sessions (backend-direct — not SDK, as the scheduled session +// API lives on the old K8s-proxy backend, not the ambient-api-server). +// --------------------------------------------------------------------------- + +// ScheduledSessionsMsg carries the result of a scheduled session list fetch. +type ScheduledSessionsMsg struct { + ScheduledSessions []views.ScheduledSession + Err error +} + +// DeleteScheduledSessionMsg carries the result of deleting a scheduled session. +type DeleteScheduledSessionMsg struct { + Err error +} + +// SuspendScheduledSessionMsg carries the result of suspending a scheduled session. +type SuspendScheduledSessionMsg struct { + ScheduledSession *views.ScheduledSession + Err error +} + +// ResumeScheduledSessionMsg carries the result of resuming a scheduled session. +type ResumeScheduledSessionMsg struct { + ScheduledSession *views.ScheduledSession + Err error +} + +// TriggerScheduledSessionMsg carries the result of manually triggering a scheduled session. +type TriggerScheduledSessionMsg struct { + Err error +} + +// CreateScheduledSessionMsg carries the result of creating a scheduled session. +type CreateScheduledSessionMsg struct { + ScheduledSession *views.ScheduledSession + Err error +} + +// InterruptSessionMsg carries the result of interrupting a session. +type InterruptSessionMsg struct { + Err error +} + +// FetchScheduledSessions returns a tea.Cmd that lists scheduled sessions in the +// given project. The backend's /api/projects/{project}/scheduled-sessions endpoint +// is called directly since the SDK does not cover scheduled sessions. +func (tc *TUIClient) FetchScheduledSessions(projectID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + body, err := tc.backendGet(ctx, projectID, "/scheduled-sessions") + if err != nil { + return ScheduledSessionsMsg{Err: err} + } + + var resp struct { + Items []views.ScheduledSession `json:"items"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return ScheduledSessionsMsg{Err: fmt.Errorf("unmarshal scheduled sessions: %w", err)} + } + return ScheduledSessionsMsg{ScheduledSessions: resp.Items} + } +} + +// DeleteScheduledSession returns a tea.Cmd that deletes a scheduled session. +func (tc *TUIClient) DeleteScheduledSession(projectID, name string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + _, err := tc.backendRequest(ctx, "DELETE", projectID, "/scheduled-sessions/"+name, nil) + return DeleteScheduledSessionMsg{Err: err} + } +} + +// SuspendScheduledSession returns a tea.Cmd that suspends a scheduled session. +func (tc *TUIClient) SuspendScheduledSession(projectID, name string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + body, err := tc.backendRequest(ctx, "POST", projectID, "/scheduled-sessions/"+name+"/suspend", nil) + if err != nil { + return SuspendScheduledSessionMsg{Err: err} + } + + var ss views.ScheduledSession + if err := json.Unmarshal(body, &ss); err != nil { + return SuspendScheduledSessionMsg{Err: fmt.Errorf("unmarshal: %w", err)} + } + return SuspendScheduledSessionMsg{ScheduledSession: &ss} + } +} + +// ResumeScheduledSession returns a tea.Cmd that resumes a scheduled session. +func (tc *TUIClient) ResumeScheduledSession(projectID, name string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + body, err := tc.backendRequest(ctx, "POST", projectID, "/scheduled-sessions/"+name+"/resume", nil) + if err != nil { + return ResumeScheduledSessionMsg{Err: err} + } + + var ss views.ScheduledSession + if err := json.Unmarshal(body, &ss); err != nil { + return ResumeScheduledSessionMsg{Err: fmt.Errorf("unmarshal: %w", err)} + } + return ResumeScheduledSessionMsg{ScheduledSession: &ss} + } +} + +// TriggerScheduledSession returns a tea.Cmd that manually triggers a scheduled session. +func (tc *TUIClient) TriggerScheduledSession(projectID, name string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + _, err := tc.backendRequest(ctx, "POST", projectID, "/scheduled-sessions/"+name+"/trigger", nil) + return TriggerScheduledSessionMsg{Err: err} + } +} + +// CreateScheduledSession returns a tea.Cmd that creates a new scheduled session. +func (tc *TUIClient) CreateScheduledSession(projectID, displayName, schedule string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + reqBody := views.CreateScheduledSessionRequest{ + Schedule: schedule, + DisplayName: displayName, + SessionTemplate: map[string]interface{}{}, + } + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return CreateScheduledSessionMsg{Err: fmt.Errorf("marshal request: %w", err)} + } + + respBody, err := tc.backendRequest(ctx, "POST", projectID, "/scheduled-sessions", bodyBytes) + if err != nil { + return CreateScheduledSessionMsg{Err: err} + } + + var ss views.ScheduledSession + if err := json.Unmarshal(respBody, &ss); err != nil { + return CreateScheduledSessionMsg{Err: fmt.Errorf("unmarshal: %w", err)} + } + return CreateScheduledSessionMsg{ScheduledSession: &ss} + } +} + +// InterruptSession returns a tea.Cmd that sends an interrupt signal to a +// running session via the AG-UI interrupt endpoint. +func (tc *TUIClient) InterruptSession(projectID, sessionName string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + _, err := tc.backendRequest(ctx, "POST", projectID, + "/agentic-sessions/"+sessionName+"/agui/interrupt", nil) + return InterruptSessionMsg{Err: err} + } +} + +// --------------------------------------------------------------------------- +// Backend HTTP helpers — raw HTTP calls to the old K8s-proxy backend for +// endpoints that are not (yet) in the ambient-api-server SDK. +// --------------------------------------------------------------------------- + +// backendGet performs a GET request to the backend's /api/projects/{project}{path}. +func (tc *TUIClient) backendGet(ctx context.Context, projectID, path string) ([]byte, error) { + return tc.backendRequest(ctx, "GET", projectID, path, nil) +} + +// backendRequest performs an HTTP request to the backend's +// /api/projects/{project}{path}. It uses the factory's token and base URL. +func (tc *TUIClient) backendRequest(ctx context.Context, method, projectID, path string, body []byte) ([]byte, error) { + token, err := tc.factory.TokenFunc() + if err != nil { + return nil, fmt.Errorf("get token: %w", err) + } + + url := strings.TrimSuffix(tc.factory.APIURL, "/") + "/api/projects/" + projectID + path + + var reqBody io.Reader + if body != nil { + reqBody = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, url, reqBody) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + httpClient := &http.Client{Timeout: fetchTimeout} + if tc.factory.Insecure { + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, //nolint:gosec + }, + } + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Try to extract error message from JSON response. + var errResp struct { + Error string `json:"error"` + } + if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" { + return nil, fmt.Errorf("%d: %s", resp.StatusCode, errResp.Error) + } + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + return respBody, nil +} + // FetchSessionMessages returns a tea.Cmd that polls session messages via the // REST ListMessages endpoint. Only messages with seq > afterSeq are returned. func (tc *TUIClient) FetchSessionMessages(projectID, sessionID string, afterSeq int) tea.Cmd { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/command.go b/components/ambient-cli/cmd/acpctl/ambient/tui/command.go index 9072de1d4..bb799883f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/command.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/command.go @@ -17,6 +17,7 @@ const ( CmdContext CmdProject CmdAliases + CmdScheduledSessions CmdQuit CmdUnknown ) @@ -83,6 +84,11 @@ var commandDefs = []commandDef{ description: "Switch project within current context", takesArg: true, }, + { + kind: CmdScheduledSessions, + aliases: []string{"scheduledsessions", "scheduledsession", "ss"}, + description: "Switch to scheduled sessions list (current project)", + }, { kind: CmdAliases, aliases: []string{"aliases"}, diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/command_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/command_test.go index 4d964a878..a6602ac72 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/command_test.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/command_test.go @@ -208,8 +208,8 @@ func TestTabComplete_CommandNames(t *testing.T) { partial string want []string }{ - // Partial "s" matches sessions - {"s", []string{"se", "sessions"}}, + // Partial "s" matches sessions and scheduledsessions + {"s", []string{"scheduledsession", "scheduledsessions", "se", "sessions", "ss"}}, // Partial "a" matches agents, ag, aliases {"a", []string{"ag", "agents", "aliases"}}, // Partial "q" matches q, quit @@ -247,7 +247,7 @@ func TestTabComplete_EmptyInput(t *testing.T) { for _, name := range got { found[name] = true } - for _, expected := range []string{"projects", "agents", "sessions", "inbox", "messages", "context", "ctx", "project", "proj", "aliases", "q", "quit", "ag", "se", "ib", "msg"} { + for _, expected := range []string{"projects", "agents", "sessions", "inbox", "messages", "context", "ctx", "project", "proj", "aliases", "q", "quit", "ag", "se", "ib", "msg", "scheduledsessions", "scheduledsession", "ss"} { if !found[expected] { t.Errorf("TabComplete(\"\") missing %q", expected) } @@ -327,8 +327,8 @@ func TestTabComplete_CaseInsensitive(t *testing.T) { } got = TabComplete("S", nil, nil) - if !stringSliceEqual(got, []string{"se", "sessions"}) { - t.Errorf("TabComplete(\"S\", nil, nil) = %v, want [se sessions]", got) + if !stringSliceEqual(got, []string{"scheduledsession", "scheduledsessions", "se", "sessions", "ss"}) { + t.Errorf("TabComplete(\"S\", nil, nil) = %v, want [scheduledsession scheduledsessions se sessions ss]", got) } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go b/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go index fe93f1025..6fa91a6c3 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go @@ -65,6 +65,7 @@ var viewHintRegistry = map[string]ViewHints{ {Key: "l", Action: "Logs"}, {Key: "m", Action: "Send (via msgs)"}, {Key: "n", Action: "New"}, + {Key: "x", Action: "Interrupt"}, {Key: "y", Action: "JSON"}, {Key: "ctrl-d", Action: "Delete"}, }, @@ -94,6 +95,7 @@ var viewHintRegistry = map[string]ViewHints{ {Key: "t", Action: "Timestamps"}, {Key: "m", Action: "Compose"}, {Key: "c", Action: "Copy"}, + {Key: "x", Action: "Interrupt"}, {Key: "shift-g", Action: "Bottom"}, {Key: "g", Action: "Top"}, }, @@ -106,6 +108,22 @@ var viewHintRegistry = map[string]ViewHints{ {Key: "q", Action: "Back"}, }, }, + "scheduledsessions": { + Resource: []views.HelpEntry{ + {Key: "d", Action: "Describe"}, + {Key: "n", Action: "New"}, + {Key: "s", Action: "Suspend/Resume"}, + {Key: "t", Action: "Trigger"}, + {Key: "y", Action: "JSON"}, + {Key: "ctrl-d", Action: "Delete"}, + }, + General: defaultGeneral(), + Navigation: []views.HelpEntry{ + {Key: "Enter", Action: "Show detail"}, + {Key: "Esc", Action: "Back"}, + {Key: "q", Action: "Back"}, + }, + }, "contexts": { Resource: []views.HelpEntry{}, Navigation: []views.HelpEntry{ diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index adc192e9b..c1d1bb38a 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -111,8 +111,10 @@ type AppModel struct { contextTable views.ResourceTable messageStream views.MessageStream + scheduledSessionTable views.ResourceTable + // Current view determines which table/view is active - activeView string // "projects", "agents", "sessions", "messages", "inbox", "contexts" + activeView string // "projects", "agents", "sessions", "messages", "inbox", "contexts", "scheduledsessions" // Context for scoped views currentProject string // set when drilling into a project @@ -147,7 +149,8 @@ type AppModel struct { cachedProjects []sdktypes.Project cachedAgents []sdktypes.Agent cachedSessions []sdktypes.Session - cachedInbox []sdktypes.InboxMessage + cachedInbox []sdktypes.InboxMessage + cachedScheduledSessions []views.ScheduledSession // Message polling state. messagePollActive bool // true when message poll tick is running @@ -234,6 +237,16 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { }) it := views.NewInboxTable("all", views.DefaultTableStyle()) ct := views.NewContextTable(views.DefaultTableStyle()) + sst := views.NewScheduledSessionTable("all", views.DefaultTableStyle()) + // Scheduled session rows: SUSPENDED is column index 3 + // (NAME, SCHEDULE, PROJECT, SUSPENDED, ACTIVE, LAST RUN, AGE) + // Dim (240) when suspended, orange (214) when active. + sst.SetRowColorFunc(func(row table.Row) lipgloss.Color { + if len(row) > 3 && row[3] == "Yes" { + return lipgloss.Color("240") // dim for suspended + } + return lipgloss.Color("214") // orange for active + }) m := &AppModel{ config: cfg, @@ -245,9 +258,10 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { projectTable: pt, agentTable: at, sessionTable: st, - inboxTable: it, - contextTable: ct, - commandInput: ci, + inboxTable: it, + contextTable: ct, + scheduledSessionTable: sst, + commandInput: ci, filterInput: fi, promptInput: pi, } @@ -444,6 +458,11 @@ func (m *AppModel) fetchActiveView() tea.Cmd { return m.client.FetchInbox(m.currentProject, m.currentAgentID) } return nil + case "scheduledsessions": + if m.currentProject != "" { + return m.client.FetchScheduledSessions(m.currentProject) + } + return nil case "messages": // Message stream uses SSE, not polling. No fetch command needed yet. return nil @@ -466,6 +485,8 @@ func (m *AppModel) activeTable() *views.ResourceTable { return &m.inboxTable case "contexts": return &m.contextTable + case "scheduledsessions": + return &m.scheduledSessionTable default: return nil } @@ -546,6 +567,61 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case InboxMsg: return m.handleInboxMsg(msg) + case ScheduledSessionsMsg: + return m.handleScheduledSessionsMsg(msg) + + case CreateScheduledSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Create scheduled session failed: " + msg.Err.Error()) + } + name := "" + if msg.ScheduledSession != nil { + name = msg.ScheduledSession.DisplayName + if name == "" { + name = msg.ScheduledSession.Name + } + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session created: "+name)) + + case DeleteScheduledSessionMsg: + if msg.Err != nil { + if strings.Contains(msg.Err.Error(), "404") || strings.Contains(msg.Err.Error(), "not found") { + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Already deleted — refreshing")) + } + return m, m.setInfo("Delete scheduled session failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session deleted")) + + case SuspendScheduledSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Suspend failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session suspended")) + + case ResumeScheduledSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Resume failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session resumed")) + + case TriggerScheduledSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Trigger failed: " + msg.Err.Error()) + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session triggered")) + + case InterruptSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Interrupt failed: " + msg.Err.Error()) + } + return m, m.setInfo("Session interrupted") + case views.DialogCancelMsg: m.dialog = nil m.dialogAction = nil @@ -863,6 +939,8 @@ func (m *AppModel) resizeTable() { m.inboxTable.SetWidth(m.width) m.contextTable.SetHeight(tableHeight) m.contextTable.SetWidth(m.width) + m.scheduledSessionTable.SetHeight(tableHeight) + m.scheduledSessionTable.SetWidth(m.width) // Message stream and detail view get the full table area. m.messageStream.SetSize(m.width, tableHeight+2) @@ -1227,6 +1305,64 @@ func (m *AppModel) handleInboxMsg(msg InboxMsg) (tea.Model, tea.Cmd) { return m, nil } +// handleScheduledSessionsMsg populates the scheduled session table from a fetch result. +func (m *AppModel) handleScheduledSessionsMsg(msg ScheduledSessionsMsg) (tea.Model, tea.Cmd) { + m.pollInFlight = false + m.lastFetch = time.Now() + + if msg.Err != nil { + errMsg, skipPoll := m.classifyAPIError(msg.Err, "scheduled sessions") + m.lastError = errMsg + m.skipNextPoll = m.skipNextPoll || skipPoll + return m, nil + } + + m.lastError = "" + m.authExpired = false + m.cachedScheduledSessions = msg.ScheduledSessions + now := time.Now() + + rows := make([]table.Row, 0, len(msg.ScheduledSessions)) + for _, ss := range msg.ScheduledSessions { + row := views.ScheduledSessionRow(ss, now) + for i := range row { + row[i] = Sanitize(row[i]) + } + rows = append(rows, row) + } + m.scheduledSessionTable.SetRows(rows) + + // Re-apply active filter if present and we're on scheduled sessions view. + if m.activeView == "scheduledsessions" && m.activeFilter != nil { + f := m.activeFilter + m.scheduledSessionTable.SetFilter(func(cols []string) bool { + return f.MatchRow(cols) + }) + } + + if len(msg.ScheduledSessions) >= 200 { + return m, m.setInfo("Showing first 200 scheduled sessions") + } + + return m, nil +} + +// findScheduledSessionByName returns the cached ScheduledSession with the given +// display name (or internal name), or nil. +func (m *AppModel) findScheduledSessionByName(displayName string) *views.ScheduledSession { + for i := range m.cachedScheduledSessions { + ss := &m.cachedScheduledSessions[i] + name := ss.DisplayName + if name == "" { + name = ss.Name + } + if name == displayName { + return ss + } + } + return nil +} + // handleTick manages periodic polling. Skips if a fetch is already in flight // or if skipNextPoll is set (e.g. after a 429 rate-limit response). func (m *AppModel) handleTick() (tea.Model, tea.Cmd) { @@ -1362,6 +1498,8 @@ func (m *AppModel) updateFormOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleProjectCountsMsg(typedMsg) case AgentCountsMsg: return m.handleAgentCountsMsg(typedMsg) + case ScheduledSessionsMsg: + return m.handleScheduledSessionsMsg(typedMsg) } // Esc dismisses the form (huh uses ctrl+c for its own abort). @@ -1539,6 +1677,21 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } + case "scheduledsessions": + row := m.scheduledSessionTable.SelectedRow() + if len(row) > 0 { + displayName := row[0] + ss := m.findScheduledSessionByName(displayName) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + displayName) + } + // Show detail view for the scheduled session. + m.detailView = views.NewDetailView("Scheduled: "+displayName, views.ScheduledSessionDetail(*ss)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", displayName, ss.Name) + return m, tea.Batch(cmd, m.setInfo("Scheduled session detail: "+displayName)) + } + case "inbox": row := m.inboxTable.SelectedRow() if len(row) > 0 { @@ -1646,7 +1799,7 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if len(key) == 1 && key[0] >= '0' && key[0] <= '9' && m.activeView != "projects" && m.activeView != "contexts" && m.activeView != "messages" && m.activeView != "detail" && - m.activeView != "inbox" { + m.activeView != "inbox" && m.activeView != "scheduledsessions" { return m.handleProjectShortcut(key[0] - '0') } @@ -1660,6 +1813,8 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleSessionsRune(key) case "inbox": return m.handleInboxRune(key) + case "scheduledsessions": + return m.handleScheduledSessionsRune(key) } return m, nil @@ -1909,6 +2064,31 @@ func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { ) } return m, m.formOverlay.Init() + case "x": + // Interrupt the selected session. + row := m.sessionTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No session selected") + } + shortID := row[0] + session := m.findSessionByShortID(shortID) + if session == nil { + return m, m.setInfo("Session not found in cache: " + shortID) + } + projectID := m.currentProject + if projectID == "" { + projectID = session.ProjectID + } + if projectID == "" { + return m, m.setInfo("No project context for interrupt") + } + sessionName := session.Name + d := views.NewConfirmDialog("Interrupt", "Interrupt session "+session.Name+"?") + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.InterruptSession(projectID, sessionName) + } + return m, nil case "y": row := m.sessionTable.SelectedRow() if len(row) == 0 { @@ -1950,6 +2130,105 @@ func (m *AppModel) handleInboxRune(key string) (tea.Model, tea.Cmd) { return m, nil } +// handleScheduledSessionsRune handles scheduled-session-view-specific hotkeys. +func (m *AppModel) handleScheduledSessionsRune(key string) (tea.Model, tea.Cmd) { + switch key { + case "d": + // Show detail view for the selected scheduled session. + row := m.scheduledSessionTable.SelectedRow() + if len(row) == 0 { + return m, nil + } + displayName := row[0] + ss := m.findScheduledSessionByName(displayName) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + displayName) + } + m.detailView = views.NewDetailView("Scheduled: "+displayName, views.ScheduledSessionDetail(*ss)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", displayName, ss.Name) + return m, tea.Batch(cmd, m.setInfo("Scheduled session detail: "+displayName)) + + case "n": + // Create new scheduled session. + if m.currentProject == "" { + return m, m.setInfo("Navigate to a project first") + } + project := m.currentProject + var displayName, schedule string + form := views.NewScheduledSessionForm(&displayName, &schedule) + form.WithWidth(60) + m.formOverlay = form + m.formTitle = "New Scheduled Session" + m.formOnComplete = func() tea.Cmd { + return tea.Batch( + m.client.CreateScheduledSession(project, displayName, schedule), + m.setInfo("Creating scheduled session "+displayName+"..."), + ) + } + return m, m.formOverlay.Init() + + case "s": + // Suspend/resume toggle. + row := m.scheduledSessionTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No scheduled session selected") + } + displayName := row[0] + ss := m.findScheduledSessionByName(displayName) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + displayName) + } + if ss.Suspend { + return m, tea.Batch( + m.client.ResumeScheduledSession(m.currentProject, ss.Name), + m.setInfo("Resuming "+displayName+"..."), + ) + } + return m, tea.Batch( + m.client.SuspendScheduledSession(m.currentProject, ss.Name), + m.setInfo("Suspending "+displayName+"..."), + ) + + case "t": + // Trigger manual run with confirmation. + row := m.scheduledSessionTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No scheduled session selected") + } + displayName := row[0] + ss := m.findScheduledSessionByName(displayName) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + displayName) + } + ssName := ss.Name + currentProject := m.currentProject + d := views.NewConfirmDialog("Trigger", "Trigger manual run of "+displayName+"?") + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.TriggerScheduledSession(currentProject, ssName) + } + return m, nil + + case "y": + // JSON view. + row := m.scheduledSessionTable.SelectedRow() + if len(row) == 0 { + return m, nil + } + displayName := row[0] + ss := m.findScheduledSessionByName(displayName) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + displayName) + } + m.detailView = views.NewDetailView("JSON: "+displayName, views.ResourceJSON(*ss)) + m.detailView.SetSize(m.width, m.height-10) + cmd := m.pushView("detail", displayName, ss.Name) + return m, tea.Batch(cmd, m.setInfo("JSON: "+displayName)) + } + return m, nil +} + // handleCtrlD handles the delete/cancel keybinding across all views. // Instead of deleting immediately, it sets up a confirmation prompt. func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { @@ -2023,6 +2302,23 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { } return m, nil } + case "scheduledsessions": + row := m.scheduledSessionTable.SelectedRow() + if len(row) > 0 { + displayName := row[0] + ss := m.findScheduledSessionByName(displayName) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + displayName) + } + ssName := ss.Name + currentProject := m.currentProject + d := views.NewDeleteDialog("scheduled session", displayName) + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.DeleteScheduledSession(currentProject, ssName) + } + return m, nil + } } return m, nil } @@ -2075,6 +2371,33 @@ func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.showHelp() case "q": return m, m.popView() + case "x": + // Interrupt the current session. + if m.currentSession == "" { + return m, m.setInfo("No session context for interrupt") + } + projectID := m.currentProject + if projectID == "" { + if s := m.findSessionByShortID(m.currentSession); s != nil { + projectID = s.ProjectID + } + } + if projectID == "" { + return m, m.setInfo("No project context for interrupt") + } + // Resolve session name from cache. + sessionName := m.currentSession + if s := m.findSessionByShortID(m.currentSession); s != nil { + sessionName = s.Name + } + capturedProject := projectID + capturedName := sessionName + d := views.NewConfirmDialog("Interrupt", "Interrupt session "+sessionName+"?") + m.dialog = &d + m.dialogAction = func(_ string) tea.Cmd { + return m.client.InterruptSession(capturedProject, capturedName) + } + return m, nil } } if msg.Type == tea.KeyCtrlC { @@ -2257,6 +2580,31 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { m.setInfo("Viewing inbox for agent "+m.currentAgent), ) + case CmdScheduledSessions: + // Use current project from nav stack or config. + project := m.currentProject + if project == "" { + if ctx := m.config.Current(); ctx != nil { + project = ctx.Project + } + } + if project == "" { + return m, m.setInfo("No project context — drill into a project first or set one with :project <name>") + } + m.currentProject = project + m.scheduledSessionTable.SetScope(project) + m.navStack = []NavEntry{ + {Kind: "projects", Scope: "all"}, + {Kind: "scheduledsessions", Scope: project}, + } + m.activeView = "scheduledsessions" + m.activeFilter = nil + m.pollInFlight = true + return m, tea.Batch( + m.client.FetchScheduledSessions(project), + m.setInfo("Viewing scheduled sessions in project "+project), + ) + case CmdMessages: return m, m.setInfo("Use Enter from sessions view to open messages") diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go new file mode 100644 index 000000000..fbea2ae11 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go @@ -0,0 +1,170 @@ +package views + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/huh" +) + +// ScheduledSession mirrors the backend ScheduledSession response type. +// Defined locally because the backend types are not importable from the CLI module. +type ScheduledSession struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + CreationTimestamp string `json:"creationTimestamp"` + Schedule string `json:"schedule"` + Suspend bool `json:"suspend"` + DisplayName string `json:"displayName"` + SessionTemplate json.RawMessage `json:"sessionTemplate"` + LastScheduleTime *string `json:"lastScheduleTime,omitempty"` + ActiveCount int `json:"activeCount"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + ReuseLastSession bool `json:"reuseLastSession"` +} + +// CreateScheduledSessionRequest is the request body for creating a scheduled session. +type CreateScheduledSessionRequest struct { + Schedule string `json:"schedule"` + DisplayName string `json:"displayName"` + SessionTemplate map[string]interface{} `json:"sessionTemplate"` + Suspend bool `json:"suspend,omitempty"` +} + +// ScheduledSessionColumns returns the column definitions for the scheduled +// session list view. +func ScheduledSessionColumns() []table.Column { + return []table.Column{ + {Title: "NAME", Width: 20}, + {Title: "SCHEDULE", Width: 16}, + {Title: "PROJECT", Width: 15}, + {Title: "SUSPENDED", Width: 10}, + {Title: "ACTIVE", Width: 7}, + {Title: "LAST RUN", Width: 10}, + {Title: "AGE", Width: 8}, + } +} + +// ScheduledSessionRow converts a ScheduledSession into a table row suitable for +// the scheduled session list view. +func ScheduledSessionRow(ss ScheduledSession, now time.Time) table.Row { + name := ss.DisplayName + if name == "" { + name = ss.Name + } + + suspended := "No" + if ss.Suspend { + suspended = "Yes" + } + + lastRun := "" + if ss.LastScheduleTime != nil && *ss.LastScheduleTime != "" { + if t, err := time.Parse(time.RFC3339, *ss.LastScheduleTime); err == nil { + lastRun = FormatAge(now.Sub(t)) + } + } + + age := "" + if ss.CreationTimestamp != "" { + if t, err := time.Parse(time.RFC3339, ss.CreationTimestamp); err == nil { + age = FormatAge(now.Sub(t)) + } + } + + return table.Row{ + name, + ss.Schedule, + ss.Namespace, + suspended, + fmt.Sprintf("%d", ss.ActiveCount), + lastRun, + age, + } +} + +// NewScheduledSessionTable creates a ResourceTable configured for the scheduled +// session list view. The scope parameter controls the title bar context. +func NewScheduledSessionTable(scope string, style TableStyle) ResourceTable { + return NewResourceTable("scheduledsessions", scope, ScheduledSessionColumns(), style) +} + +// ScheduledSessionDetail returns detail lines for all fields of a +// ScheduledSession resource. +func ScheduledSessionDetail(ss ScheduledSession) []DetailLine { + suspended := "No" + if ss.Suspend { + suspended = "Yes" + } + reuseLastSession := "No" + if ss.ReuseLastSession { + reuseLastSession = "Yes" + } + + lastRun := "" + if ss.LastScheduleTime != nil { + lastRun = *ss.LastScheduleTime + } + + templateJSON := "" + if len(ss.SessionTemplate) > 0 { + var obj interface{} + if err := json.Unmarshal(ss.SessionTemplate, &obj); err == nil { + if data, err := json.MarshalIndent(obj, "", " "); err == nil { + templateJSON = string(data) + } + } + } + + labelsJSON := "" + if len(ss.Labels) > 0 { + if data, err := json.MarshalIndent(ss.Labels, "", " "); err == nil { + labelsJSON = string(data) + } + } + + annotationsJSON := "" + if len(ss.Annotations) > 0 { + if data, err := json.MarshalIndent(ss.Annotations, "", " "); err == nil { + annotationsJSON = string(data) + } + } + + return []DetailLine{ + {Key: "Name", Value: ss.Name}, + {Key: "Display Name", Value: ss.DisplayName}, + {Key: "Namespace", Value: ss.Namespace}, + {Key: "Schedule", Value: ss.Schedule}, + {Key: "Suspended", Value: suspended}, + {Key: "Reuse Last Session", Value: reuseLastSession}, + {Key: "Active Count", Value: fmt.Sprintf("%d", ss.ActiveCount)}, + {Key: "Last Schedule Time", Value: lastRun}, + {Key: "Created At", Value: ss.CreationTimestamp}, + {Key: "Labels", Value: labelsJSON}, + {Key: "Annotations", Value: annotationsJSON}, + {Key: "Session Template", Value: templateJSON}, + } +} + +// NewScheduledSessionForm creates a huh form for creating a new scheduled session. +func NewScheduledSessionForm(displayName, schedule *string) *huh.Form { + return huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Key("displayName"). + Title("Display Name"). + Placeholder("my-scheduled-session"). + Validate(huh.ValidateNotEmpty()). + Value(displayName), + huh.NewInput(). + Key("schedule"). + Title("Schedule (cron)"). + Placeholder("*/30 * * * *"). + Validate(huh.ValidateNotEmpty()). + Value(schedule), + ), + ).WithTheme(ACPTheme()).WithShowHelp(false) +} diff --git a/docs/internal/design/tui.spec.md b/docs/internal/design/tui.spec.md index 2d7c44ad9..42dadf21b 100644 --- a/docs/internal/design/tui.spec.md +++ b/docs/internal/design/tui.spec.md @@ -133,7 +133,7 @@ Polling is skip-on-inflight: if the previous poll has not returned, the next tic └── m to compose ``` -Five views. `:sessions` is also accessible globally (all sessions across all projects), same as k9s's `:pods` showing all pods. +Five views. `:sessions` is also accessible globally (all sessions across all projects), same as k9s's `:pods` showing all pods. `:scheduledsessions` (`:ss`) is accessible via command mode only — it is not part of the Enter drill-down hierarchy. ### Screen Stack @@ -154,6 +154,7 @@ Projects > ambient-platform > Agents > be > Inbox | `:agents` | `:ag` | Switch to agent list (current project) | | `:sessions` | `:se` | Switch to session list (global or scoped) | | `:inbox` | `:ib` | Switch to inbox (requires agent context) | +| `:scheduledsessions` | `:ss` | Switch to scheduled session list (current project) | | `:messages` | `:msg` | Switch to message stream (requires session context) | | `:aliases` | | List all available commands and aliases | | `:context` | `:ctx` | List all saved contexts | @@ -250,6 +251,7 @@ Accessible globally (`:sessions` — all sessions across all projects) or scoped | `l` | Live message stream (same as Enter) | l | | `m` | Send message to session (`POST /sessions/{id}/messages`) | — | | `n` | Start a new session for the current agent (opens prompt input) | — | +| `x` | Interrupt running session (confirmation dialog) | — | | `y` | YAML — dump session as YAML to screen | y | | `Ctrl-D` | Delete/cancel session (confirmation modal) | Ctrl-D | @@ -330,6 +332,7 @@ Sending a message (`m` / `Enter`) while the agent is mid-response is permitted. | `g` | Jump to top (oldest in buffer) | | `j`/`k` or `↑`/`↓` | Scroll (disables autoscroll) | | `/` | Search within messages (regex) | +| `x` | Interrupt current session (confirmation dialog) | | `c` | Copy selected message text to clipboard (via OSC 52) | ### Inbox View @@ -354,6 +357,32 @@ Scoped to an agent. Accessible via `i` from the agent list or `:inbox` in comman | `Ctrl-D` | Delete message (confirmation) | | `Esc` | Back to agent list | +### Scheduled Session List + +Accessible via `:scheduledsessions` or `:ss` in command mode. Project-scoped. Not part of the Enter drill-down hierarchy (project drill-down goes to agents, not scheduled sessions). + +| Column | Source | Notes | +|--------|--------|-------| +| NAME | `scheduled_session.name` | | +| SCHEDULE | `scheduled_session.schedule` | Cron expression | +| AGENT | agent name | Resolved from agent_id | +| PROJECT | `scheduled_session.project_id` | | +| SUSPENDED | `scheduled_session.suspend` | `Yes` / `No` | +| LAST RUN | `scheduled_session.last_schedule_time` | Relative | +| AGE | computed from `scheduled_session.created_at` | Relative | + +**Hotkeys:** + +| Key | Action | k9s equivalent | +|-----|--------|----------------| +| `Enter` | Show runs (sessions created by this schedule) | Enter | +| `d` | Describe — show detail view | d | +| `n` | New scheduled session (name, schedule, agent) | — | +| `s` | Suspend/resume toggle | — | +| `t` | Trigger manual run | — | +| `Ctrl-D` | Delete (confirmation dialog) | Ctrl-D | +| `Esc` | Back | Esc | + --- ## Global Keybindings @@ -644,7 +673,6 @@ These are gaps where the TUI spec requires data the API does not provide efficie | K8s resource browsing (pods, namespaces) | Not the TUI's job post-CRD-transition. Use k9s. | Never — not in scope. | | Credential view | Credential CRUD API is not yet implemented in the API server. | API lands. | | RBAC views (roles, rolebindings) | Low-frequency operation. `acpctl get roles` is sufficient. | User demand. | -| ScheduledSession view | PR #1456 spec is proposed but not yet implemented. | ScheduledSession API lands — then add `:scheduledsessions` / `:ss` view. | | Diagnostic view for failed sessions | Requires API to surface container exit codes, OOM events, failure reasons — not just `phase=failed`. | API exposes failure diagnostics. | | Mouse click/drag | Keyboard-driven, consistent with k9s. | Never. | | Plugin/extension system | Premature. Resource kinds are still evolving. | Resource model stabilizes. | From 6aa804b500fd7f4d0b7b499d8ce52eecf96f7971 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Tue, 28 Apr 2026 15:34:09 -0400 Subject: [PATCH 109/117] feat(sdk): add ScheduledSession types and builders to Go SDK Add ScheduledSession, ScheduledSessionList, ScheduledSessionPatch types and fluent builder APIs (NewScheduledSessionBuilder, NewScheduledSessionPatchBuilder) to match the API server model and unblock the CLI and TUI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../go-sdk/types/scheduled_session.go | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 components/ambient-sdk/go-sdk/types/scheduled_session.go diff --git a/components/ambient-sdk/go-sdk/types/scheduled_session.go b/components/ambient-sdk/go-sdk/types/scheduled_session.go new file mode 100644 index 000000000..8d5258c7a --- /dev/null +++ b/components/ambient-sdk/go-sdk/types/scheduled_session.go @@ -0,0 +1,121 @@ +package types + +import ( + "fmt" + "time" +) + +type ScheduledSession struct { + ObjectReference + + Name string `json:"name"` + Description string `json:"description,omitempty"` + ProjectID string `json:"project_id,omitempty"` + AgentID string `json:"agent_id,omitempty"` + Schedule string `json:"schedule"` + Timezone string `json:"timezone,omitempty"` + Enabled bool `json:"enabled"` + SessionPrompt string `json:"session_prompt,omitempty"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + NextRunAt *time.Time `json:"next_run_at,omitempty"` +} + +type ScheduledSessionList struct { + ListMeta + Items []ScheduledSession `json:"items"` +} + +func (l *ScheduledSessionList) GetItems() []ScheduledSession { return l.Items } +func (l *ScheduledSessionList) GetTotal() int { return l.Total } +func (l *ScheduledSessionList) GetPage() int { return l.Page } +func (l *ScheduledSessionList) GetSize() int { return l.Size } + +type ScheduledSessionPatch struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Schedule *string `json:"schedule,omitempty"` + Timezone *string `json:"timezone,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + SessionPrompt *string `json:"session_prompt,omitempty"` + AgentID *string `json:"agent_id,omitempty"` +} + +// ScheduledSessionBuilder provides a fluent API for constructing ScheduledSession values. +type ScheduledSessionBuilder struct { + resource ScheduledSession +} + +func NewScheduledSessionBuilder() *ScheduledSessionBuilder { + return &ScheduledSessionBuilder{resource: ScheduledSession{Enabled: true}} +} + +func (b *ScheduledSessionBuilder) Name(v string) *ScheduledSessionBuilder { + b.resource.Name = v + return b +} +func (b *ScheduledSessionBuilder) ProjectID(v string) *ScheduledSessionBuilder { + b.resource.ProjectID = v + return b +} +func (b *ScheduledSessionBuilder) AgentID(v string) *ScheduledSessionBuilder { + b.resource.AgentID = v + return b +} +func (b *ScheduledSessionBuilder) Schedule(v string) *ScheduledSessionBuilder { + b.resource.Schedule = v + return b +} +func (b *ScheduledSessionBuilder) Timezone(v string) *ScheduledSessionBuilder { + b.resource.Timezone = v + return b +} +func (b *ScheduledSessionBuilder) SessionPrompt(v string) *ScheduledSessionBuilder { + b.resource.SessionPrompt = v + return b +} +func (b *ScheduledSessionBuilder) Description(v string) *ScheduledSessionBuilder { + b.resource.Description = v + return b +} +func (b *ScheduledSessionBuilder) Build() (*ScheduledSession, error) { + if b.resource.Name == "" { + return nil, fmt.Errorf("name is required") + } + if b.resource.Schedule == "" { + return nil, fmt.Errorf("schedule is required") + } + return &b.resource, nil +} + +// ScheduledSessionPatchBuilder provides a fluent API for constructing ScheduledSessionPatch values. +type ScheduledSessionPatchBuilder struct { + patch ScheduledSessionPatch +} + +func NewScheduledSessionPatchBuilder() *ScheduledSessionPatchBuilder { + return &ScheduledSessionPatchBuilder{} +} + +func (b *ScheduledSessionPatchBuilder) Name(v string) *ScheduledSessionPatchBuilder { + b.patch.Name = &v + return b +} +func (b *ScheduledSessionPatchBuilder) Schedule(v string) *ScheduledSessionPatchBuilder { + b.patch.Schedule = &v + return b +} +func (b *ScheduledSessionPatchBuilder) Timezone(v string) *ScheduledSessionPatchBuilder { + b.patch.Timezone = &v + return b +} +func (b *ScheduledSessionPatchBuilder) SessionPrompt(v string) *ScheduledSessionPatchBuilder { + b.patch.SessionPrompt = &v + return b +} +func (b *ScheduledSessionPatchBuilder) Description(v string) *ScheduledSessionPatchBuilder { + b.patch.Description = &v + return b +} +func (b *ScheduledSessionPatchBuilder) Build() *ScheduledSessionPatch { + return &b.patch +} From 28ca6088137de0cb4cac125288d43851ddb3b6b8 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Tue, 28 Apr 2026 15:42:37 -0400 Subject: [PATCH 110/117] refactor(cli): migrate TUI scheduled sessions and interrupt to SDK Replace raw backendGet/backendRequest HTTP calls with SDK ScheduledSessionAPI methods for list, create, delete, suspend, resume, and trigger. Switch from local views.ScheduledSession type to sdktypes.ScheduledSession (Enabled vs Suspend, LastRunAt vs LastScheduleTime, CreatedAt from ObjectReference). Fix InterruptSession to use the correct API server endpoint (/api/ambient/v1/sessions/{id}/agui/interrupt) instead of the old backend path. Remove unused backendGet, backendRequest helpers and local ScheduledSession/CreateScheduledSessionRequest types. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../cmd/acpctl/ambient/tui/client.go | 208 ++++++++---------- .../cmd/acpctl/ambient/tui/model_new.go | 138 +++++------- .../ambient/tui/views/scheduledsessions.go | 114 +++------- 3 files changed, 181 insertions(+), 279 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index ae4a6482c..d50e964b2 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -1,7 +1,6 @@ package tui import ( - "bytes" "context" "crypto/tls" "encoding/json" @@ -12,7 +11,6 @@ import ( "sync" "time" - "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient/tui/views" "github.com/ambient-code/platform/components/ambient-cli/pkg/connection" sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" tea "github.com/charmbracelet/bubbletea" @@ -783,7 +781,7 @@ func (tc *TUIClient) DeleteInboxMessage(projectID, agentID, msgID string) tea.Cm // ScheduledSessionsMsg carries the result of a scheduled session list fetch. type ScheduledSessionsMsg struct { - ScheduledSessions []views.ScheduledSession + ScheduledSessions []sdktypes.ScheduledSession Err error } @@ -794,13 +792,13 @@ type DeleteScheduledSessionMsg struct { // SuspendScheduledSessionMsg carries the result of suspending a scheduled session. type SuspendScheduledSessionMsg struct { - ScheduledSession *views.ScheduledSession + ScheduledSession *sdktypes.ScheduledSession Err error } // ResumeScheduledSessionMsg carries the result of resuming a scheduled session. type ResumeScheduledSessionMsg struct { - ScheduledSession *views.ScheduledSession + ScheduledSession *sdktypes.ScheduledSession Err error } @@ -811,7 +809,7 @@ type TriggerScheduledSessionMsg struct { // CreateScheduledSessionMsg carries the result of creating a scheduled session. type CreateScheduledSessionMsg struct { - ScheduledSession *views.ScheduledSession + ScheduledSession *sdktypes.ScheduledSession Err error } @@ -821,199 +819,173 @@ type InterruptSessionMsg struct { } // FetchScheduledSessions returns a tea.Cmd that lists scheduled sessions in the -// given project. The backend's /api/projects/{project}/scheduled-sessions endpoint -// is called directly since the SDK does not cover scheduled sessions. +// given project via the SDK's ScheduledSessionAPI. func (tc *TUIClient) FetchScheduledSessions(projectID string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() - body, err := tc.backendGet(ctx, projectID, "/scheduled-sessions") + client, err := tc.factory.ForProject(projectID) if err != nil { return ScheduledSessionsMsg{Err: err} } - var resp struct { - Items []views.ScheduledSession `json:"items"` - } - if err := json.Unmarshal(body, &resp); err != nil { - return ScheduledSessionsMsg{Err: fmt.Errorf("unmarshal scheduled sessions: %w", err)} + list, err := client.ScheduledSessions().List(ctx, projectID, defaultListOpts()) + if err != nil { + return ScheduledSessionsMsg{Err: err} } - return ScheduledSessionsMsg{ScheduledSessions: resp.Items} + return ScheduledSessionsMsg{ScheduledSessions: list.Items} } } -// DeleteScheduledSession returns a tea.Cmd that deletes a scheduled session. -func (tc *TUIClient) DeleteScheduledSession(projectID, name string) tea.Cmd { +// DeleteScheduledSession returns a tea.Cmd that deletes a scheduled session by ID. +func (tc *TUIClient) DeleteScheduledSession(projectID, id string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() - _, err := tc.backendRequest(ctx, "DELETE", projectID, "/scheduled-sessions/"+name, nil) + client, err := tc.factory.ForProject(projectID) + if err != nil { + return DeleteScheduledSessionMsg{Err: err} + } + + err = client.ScheduledSessions().Delete(ctx, projectID, id) return DeleteScheduledSessionMsg{Err: err} } } -// SuspendScheduledSession returns a tea.Cmd that suspends a scheduled session. -func (tc *TUIClient) SuspendScheduledSession(projectID, name string) tea.Cmd { +// SuspendScheduledSession returns a tea.Cmd that suspends a scheduled session by ID. +func (tc *TUIClient) SuspendScheduledSession(projectID, id string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() - body, err := tc.backendRequest(ctx, "POST", projectID, "/scheduled-sessions/"+name+"/suspend", nil) + client, err := tc.factory.ForProject(projectID) if err != nil { return SuspendScheduledSessionMsg{Err: err} } - var ss views.ScheduledSession - if err := json.Unmarshal(body, &ss); err != nil { - return SuspendScheduledSessionMsg{Err: fmt.Errorf("unmarshal: %w", err)} + ss, err := client.ScheduledSessions().Suspend(ctx, projectID, id) + if err != nil { + return SuspendScheduledSessionMsg{Err: err} } - return SuspendScheduledSessionMsg{ScheduledSession: &ss} + return SuspendScheduledSessionMsg{ScheduledSession: ss} } } -// ResumeScheduledSession returns a tea.Cmd that resumes a scheduled session. -func (tc *TUIClient) ResumeScheduledSession(projectID, name string) tea.Cmd { +// ResumeScheduledSession returns a tea.Cmd that resumes a scheduled session by ID. +func (tc *TUIClient) ResumeScheduledSession(projectID, id string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() - body, err := tc.backendRequest(ctx, "POST", projectID, "/scheduled-sessions/"+name+"/resume", nil) + client, err := tc.factory.ForProject(projectID) if err != nil { return ResumeScheduledSessionMsg{Err: err} } - var ss views.ScheduledSession - if err := json.Unmarshal(body, &ss); err != nil { - return ResumeScheduledSessionMsg{Err: fmt.Errorf("unmarshal: %w", err)} + ss, err := client.ScheduledSessions().Resume(ctx, projectID, id) + if err != nil { + return ResumeScheduledSessionMsg{Err: err} } - return ResumeScheduledSessionMsg{ScheduledSession: &ss} + return ResumeScheduledSessionMsg{ScheduledSession: ss} } } -// TriggerScheduledSession returns a tea.Cmd that manually triggers a scheduled session. -func (tc *TUIClient) TriggerScheduledSession(projectID, name string) tea.Cmd { +// TriggerScheduledSession returns a tea.Cmd that manually triggers a scheduled session by ID. +func (tc *TUIClient) TriggerScheduledSession(projectID, id string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() - _, err := tc.backendRequest(ctx, "POST", projectID, "/scheduled-sessions/"+name+"/trigger", nil) + client, err := tc.factory.ForProject(projectID) + if err != nil { + return TriggerScheduledSessionMsg{Err: err} + } + + err = client.ScheduledSessions().Trigger(ctx, projectID, id) return TriggerScheduledSessionMsg{Err: err} } } // CreateScheduledSession returns a tea.Cmd that creates a new scheduled session. -func (tc *TUIClient) CreateScheduledSession(projectID, displayName, schedule string) tea.Cmd { +func (tc *TUIClient) CreateScheduledSession(projectID, name, schedule string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() - reqBody := views.CreateScheduledSessionRequest{ - Schedule: schedule, - DisplayName: displayName, - SessionTemplate: map[string]interface{}{}, - } - bodyBytes, err := json.Marshal(reqBody) + client, err := tc.factory.ForProject(projectID) if err != nil { - return CreateScheduledSessionMsg{Err: fmt.Errorf("marshal request: %w", err)} + return CreateScheduledSessionMsg{Err: err} } - respBody, err := tc.backendRequest(ctx, "POST", projectID, "/scheduled-sessions", bodyBytes) - if err != nil { - return CreateScheduledSessionMsg{Err: err} + ss := &sdktypes.ScheduledSession{ + Name: name, + Schedule: schedule, + Enabled: true, } - var ss views.ScheduledSession - if err := json.Unmarshal(respBody, &ss); err != nil { - return CreateScheduledSessionMsg{Err: fmt.Errorf("unmarshal: %w", err)} + result, err := client.ScheduledSessions().Create(ctx, projectID, ss) + if err != nil { + return CreateScheduledSessionMsg{Err: err} } - return CreateScheduledSessionMsg{ScheduledSession: &ss} + return CreateScheduledSessionMsg{ScheduledSession: result} } } // InterruptSession returns a tea.Cmd that sends an interrupt signal to a -// running session via the AG-UI interrupt endpoint. -func (tc *TUIClient) InterruptSession(projectID, sessionName string) tea.Cmd { +// running session via the AG-UI interrupt endpoint. This uses a raw HTTP call +// because the SDK does not have an interrupt method. +func (tc *TUIClient) InterruptSession(sessionID string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() - _, err := tc.backendRequest(ctx, "POST", projectID, - "/agentic-sessions/"+sessionName+"/agui/interrupt", nil) - return InterruptSessionMsg{Err: err} - } -} - -// --------------------------------------------------------------------------- -// Backend HTTP helpers — raw HTTP calls to the old K8s-proxy backend for -// endpoints that are not (yet) in the ambient-api-server SDK. -// --------------------------------------------------------------------------- - -// backendGet performs a GET request to the backend's /api/projects/{project}{path}. -func (tc *TUIClient) backendGet(ctx context.Context, projectID, path string) ([]byte, error) { - return tc.backendRequest(ctx, "GET", projectID, path, nil) -} - -// backendRequest performs an HTTP request to the backend's -// /api/projects/{project}{path}. It uses the factory's token and base URL. -func (tc *TUIClient) backendRequest(ctx context.Context, method, projectID, path string, body []byte) ([]byte, error) { - token, err := tc.factory.TokenFunc() - if err != nil { - return nil, fmt.Errorf("get token: %w", err) - } - - url := strings.TrimSuffix(tc.factory.APIURL, "/") + "/api/projects/" + projectID + path - - var reqBody io.Reader - if body != nil { - reqBody = bytes.NewReader(body) - } - - req, err := http.NewRequestWithContext(ctx, method, url, reqBody) - if err != nil { - return nil, fmt.Errorf("create request: %w", err) - } + token, err := tc.factory.TokenFunc() + if err != nil { + return InterruptSessionMsg{Err: fmt.Errorf("get token: %w", err)} + } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Accept", "application/json") - if body != nil { - req.Header.Set("Content-Type", "application/json") - } + url := strings.TrimSuffix(tc.factory.APIURL, "/") + + "/api/ambient/v1/sessions/" + sessionID + "/agui/interrupt" - httpClient := &http.Client{Timeout: fetchTimeout} - if tc.factory.Insecure { - httpClient.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - InsecureSkipVerify: true, //nolint:gosec - }, + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return InterruptSessionMsg{Err: fmt.Errorf("create request: %w", err)} } - } - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("read response: %w", err) - } + httpClient := &http.Client{Timeout: fetchTimeout} + if tc.factory.Insecure { + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, //nolint:gosec + }, + } + } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - // Try to extract error message from JSON response. - var errResp struct { - Error string `json:"error"` + resp, err := httpClient.Do(req) + if err != nil { + return InterruptSessionMsg{Err: fmt.Errorf("HTTP request failed: %w", err)} } - if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" { - return nil, fmt.Errorf("%d: %s", resp.StatusCode, errResp.Error) + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + var errResp struct { + Error string `json:"error"` + } + if json.Unmarshal(respBody, &errResp) == nil && errResp.Error != "" { + return InterruptSessionMsg{Err: fmt.Errorf("%d: %s", resp.StatusCode, errResp.Error)} + } + return InterruptSessionMsg{Err: fmt.Errorf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode))} } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode)) - } - return respBody, nil + return InterruptSessionMsg{} + } } // FetchSessionMessages returns a tea.Cmd that polls session messages via the diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index c1d1bb38a..e011cff03 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -150,7 +150,7 @@ type AppModel struct { cachedAgents []sdktypes.Agent cachedSessions []sdktypes.Session cachedInbox []sdktypes.InboxMessage - cachedScheduledSessions []views.ScheduledSession + cachedScheduledSessions []sdktypes.ScheduledSession // Message polling state. messagePollActive bool // true when message poll tick is running @@ -576,10 +576,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } name := "" if msg.ScheduledSession != nil { - name = msg.ScheduledSession.DisplayName - if name == "" { - name = msg.ScheduledSession.Name - } + name = msg.ScheduledSession.Name } m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session created: "+name)) @@ -1348,16 +1345,11 @@ func (m *AppModel) handleScheduledSessionsMsg(msg ScheduledSessionsMsg) (tea.Mod } // findScheduledSessionByName returns the cached ScheduledSession with the given -// display name (or internal name), or nil. -func (m *AppModel) findScheduledSessionByName(displayName string) *views.ScheduledSession { +// name, or nil. +func (m *AppModel) findScheduledSessionByName(name string) *sdktypes.ScheduledSession { for i := range m.cachedScheduledSessions { - ss := &m.cachedScheduledSessions[i] - name := ss.DisplayName - if name == "" { - name = ss.Name - } - if name == displayName { - return ss + if m.cachedScheduledSessions[i].Name == name { + return &m.cachedScheduledSessions[i] } } return nil @@ -1680,16 +1672,16 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { case "scheduledsessions": row := m.scheduledSessionTable.SelectedRow() if len(row) > 0 { - displayName := row[0] - ss := m.findScheduledSessionByName(displayName) + name := row[0] + ss := m.findScheduledSessionByName(name) if ss == nil { - return m, m.setInfo("Scheduled session not found in cache: " + displayName) + return m, m.setInfo("Scheduled session not found in cache: " + name) } // Show detail view for the scheduled session. - m.detailView = views.NewDetailView("Scheduled: "+displayName, views.ScheduledSessionDetail(*ss)) + m.detailView = views.NewDetailView("Scheduled: "+name, views.ScheduledSessionDetail(*ss)) m.detailView.SetSize(m.width, m.height-10) - cmd := m.pushView("detail", displayName, ss.Name) - return m, tea.Batch(cmd, m.setInfo("Scheduled session detail: "+displayName)) + cmd := m.pushView("detail", name, ss.ID) + return m, tea.Batch(cmd, m.setInfo("Scheduled session detail: "+name)) } case "inbox": @@ -2075,18 +2067,11 @@ func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { if session == nil { return m, m.setInfo("Session not found in cache: " + shortID) } - projectID := m.currentProject - if projectID == "" { - projectID = session.ProjectID - } - if projectID == "" { - return m, m.setInfo("No project context for interrupt") - } - sessionName := session.Name + capturedSessionID := session.ID d := views.NewConfirmDialog("Interrupt", "Interrupt session "+session.Name+"?") m.dialog = &d m.dialogAction = func(_ string) tea.Cmd { - return m.client.InterruptSession(projectID, sessionName) + return m.client.InterruptSession(capturedSessionID) } return m, nil case "y": @@ -2139,15 +2124,15 @@ func (m *AppModel) handleScheduledSessionsRune(key string) (tea.Model, tea.Cmd) if len(row) == 0 { return m, nil } - displayName := row[0] - ss := m.findScheduledSessionByName(displayName) + name := row[0] + ss := m.findScheduledSessionByName(name) if ss == nil { - return m, m.setInfo("Scheduled session not found in cache: " + displayName) + return m, m.setInfo("Scheduled session not found in cache: " + name) } - m.detailView = views.NewDetailView("Scheduled: "+displayName, views.ScheduledSessionDetail(*ss)) + m.detailView = views.NewDetailView("Scheduled: "+name, views.ScheduledSessionDetail(*ss)) m.detailView.SetSize(m.width, m.height-10) - cmd := m.pushView("detail", displayName, ss.Name) - return m, tea.Batch(cmd, m.setInfo("Scheduled session detail: "+displayName)) + cmd := m.pushView("detail", name, ss.ID) + return m, tea.Batch(cmd, m.setInfo("Scheduled session detail: "+name)) case "n": // Create new scheduled session. @@ -2155,15 +2140,15 @@ func (m *AppModel) handleScheduledSessionsRune(key string) (tea.Model, tea.Cmd) return m, m.setInfo("Navigate to a project first") } project := m.currentProject - var displayName, schedule string - form := views.NewScheduledSessionForm(&displayName, &schedule) + var name, schedule string + form := views.NewScheduledSessionForm(&name, &schedule) form.WithWidth(60) m.formOverlay = form m.formTitle = "New Scheduled Session" m.formOnComplete = func() tea.Cmd { return tea.Batch( - m.client.CreateScheduledSession(project, displayName, schedule), - m.setInfo("Creating scheduled session "+displayName+"..."), + m.client.CreateScheduledSession(project, name, schedule), + m.setInfo("Creating scheduled session "+name+"..."), ) } return m, m.formOverlay.Init() @@ -2174,20 +2159,20 @@ func (m *AppModel) handleScheduledSessionsRune(key string) (tea.Model, tea.Cmd) if len(row) == 0 { return m, m.setInfo("No scheduled session selected") } - displayName := row[0] - ss := m.findScheduledSessionByName(displayName) + name := row[0] + ss := m.findScheduledSessionByName(name) if ss == nil { - return m, m.setInfo("Scheduled session not found in cache: " + displayName) + return m, m.setInfo("Scheduled session not found in cache: " + name) } - if ss.Suspend { + if !ss.Enabled { return m, tea.Batch( - m.client.ResumeScheduledSession(m.currentProject, ss.Name), - m.setInfo("Resuming "+displayName+"..."), + m.client.ResumeScheduledSession(m.currentProject, ss.ID), + m.setInfo("Resuming "+name+"..."), ) } return m, tea.Batch( - m.client.SuspendScheduledSession(m.currentProject, ss.Name), - m.setInfo("Suspending "+displayName+"..."), + m.client.SuspendScheduledSession(m.currentProject, ss.ID), + m.setInfo("Suspending "+name+"..."), ) case "t": @@ -2196,17 +2181,17 @@ func (m *AppModel) handleScheduledSessionsRune(key string) (tea.Model, tea.Cmd) if len(row) == 0 { return m, m.setInfo("No scheduled session selected") } - displayName := row[0] - ss := m.findScheduledSessionByName(displayName) + name := row[0] + ss := m.findScheduledSessionByName(name) if ss == nil { - return m, m.setInfo("Scheduled session not found in cache: " + displayName) + return m, m.setInfo("Scheduled session not found in cache: " + name) } - ssName := ss.Name + ssID := ss.ID currentProject := m.currentProject - d := views.NewConfirmDialog("Trigger", "Trigger manual run of "+displayName+"?") + d := views.NewConfirmDialog("Trigger", "Trigger manual run of "+name+"?") m.dialog = &d m.dialogAction = func(_ string) tea.Cmd { - return m.client.TriggerScheduledSession(currentProject, ssName) + return m.client.TriggerScheduledSession(currentProject, ssID) } return m, nil @@ -2216,15 +2201,15 @@ func (m *AppModel) handleScheduledSessionsRune(key string) (tea.Model, tea.Cmd) if len(row) == 0 { return m, nil } - displayName := row[0] - ss := m.findScheduledSessionByName(displayName) + name := row[0] + ss := m.findScheduledSessionByName(name) if ss == nil { - return m, m.setInfo("Scheduled session not found in cache: " + displayName) + return m, m.setInfo("Scheduled session not found in cache: " + name) } - m.detailView = views.NewDetailView("JSON: "+displayName, views.ResourceJSON(*ss)) + m.detailView = views.NewDetailView("JSON: "+name, views.ResourceJSON(*ss)) m.detailView.SetSize(m.width, m.height-10) - cmd := m.pushView("detail", displayName, ss.Name) - return m, tea.Batch(cmd, m.setInfo("JSON: "+displayName)) + cmd := m.pushView("detail", name, ss.ID) + return m, tea.Batch(cmd, m.setInfo("JSON: "+name)) } return m, nil } @@ -2305,17 +2290,17 @@ func (m *AppModel) handleCtrlD() (tea.Model, tea.Cmd) { case "scheduledsessions": row := m.scheduledSessionTable.SelectedRow() if len(row) > 0 { - displayName := row[0] - ss := m.findScheduledSessionByName(displayName) + name := row[0] + ss := m.findScheduledSessionByName(name) if ss == nil { - return m, m.setInfo("Scheduled session not found in cache: " + displayName) + return m, m.setInfo("Scheduled session not found in cache: " + name) } - ssName := ss.Name + ssID := ss.ID currentProject := m.currentProject - d := views.NewDeleteDialog("scheduled session", displayName) + d := views.NewDeleteDialog("scheduled session", name) m.dialog = &d m.dialogAction = func(_ string) tea.Cmd { - return m.client.DeleteScheduledSession(currentProject, ssName) + return m.client.DeleteScheduledSession(currentProject, ssID) } return m, nil } @@ -2376,26 +2361,17 @@ func (m *AppModel) handleMessagesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.currentSession == "" { return m, m.setInfo("No session context for interrupt") } - projectID := m.currentProject - if projectID == "" { - if s := m.findSessionByShortID(m.currentSession); s != nil { - projectID = s.ProjectID - } - } - if projectID == "" { - return m, m.setInfo("No project context for interrupt") - } - // Resolve session name from cache. - sessionName := m.currentSession + // Resolve session display name from cache for the dialog. + sessionLabel := m.currentSession + capturedSessionID := m.currentSession if s := m.findSessionByShortID(m.currentSession); s != nil { - sessionName = s.Name + sessionLabel = s.Name + capturedSessionID = s.ID } - capturedProject := projectID - capturedName := sessionName - d := views.NewConfirmDialog("Interrupt", "Interrupt session "+sessionName+"?") + d := views.NewConfirmDialog("Interrupt", "Interrupt session "+sessionLabel+"?") m.dialog = &d m.dialogAction = func(_ string) tea.Cmd { - return m.client.InterruptSession(capturedProject, capturedName) + return m.client.InterruptSession(capturedSessionID) } return m, nil } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go index fbea2ae11..349827699 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go @@ -1,38 +1,13 @@ package views import ( - "encoding/json" - "fmt" "time" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/huh" -) - -// ScheduledSession mirrors the backend ScheduledSession response type. -// Defined locally because the backend types are not importable from the CLI module. -type ScheduledSession struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - CreationTimestamp string `json:"creationTimestamp"` - Schedule string `json:"schedule"` - Suspend bool `json:"suspend"` - DisplayName string `json:"displayName"` - SessionTemplate json.RawMessage `json:"sessionTemplate"` - LastScheduleTime *string `json:"lastScheduleTime,omitempty"` - ActiveCount int `json:"activeCount"` - Labels map[string]string `json:"labels,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` - ReuseLastSession bool `json:"reuseLastSession"` -} -// CreateScheduledSessionRequest is the request body for creating a scheduled session. -type CreateScheduledSessionRequest struct { - Schedule string `json:"schedule"` - DisplayName string `json:"displayName"` - SessionTemplate map[string]interface{} `json:"sessionTemplate"` - Suspend bool `json:"suspend,omitempty"` -} + sdktypes "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) // ScheduledSessionColumns returns the column definitions for the scheduled // session list view. @@ -42,45 +17,36 @@ func ScheduledSessionColumns() []table.Column { {Title: "SCHEDULE", Width: 16}, {Title: "PROJECT", Width: 15}, {Title: "SUSPENDED", Width: 10}, - {Title: "ACTIVE", Width: 7}, - {Title: "LAST RUN", Width: 10}, + {Title: "LAST RUN", Width: 12}, {Title: "AGE", Width: 8}, } } // ScheduledSessionRow converts a ScheduledSession into a table row suitable for // the scheduled session list view. -func ScheduledSessionRow(ss ScheduledSession, now time.Time) table.Row { - name := ss.DisplayName - if name == "" { - name = ss.Name - } +func ScheduledSessionRow(ss sdktypes.ScheduledSession, now time.Time) table.Row { + name := ss.Name suspended := "No" - if ss.Suspend { + if !ss.Enabled { suspended = "Yes" } lastRun := "" - if ss.LastScheduleTime != nil && *ss.LastScheduleTime != "" { - if t, err := time.Parse(time.RFC3339, *ss.LastScheduleTime); err == nil { - lastRun = FormatAge(now.Sub(t)) - } + if ss.LastRunAt != nil { + lastRun = FormatAge(now.Sub(*ss.LastRunAt)) } age := "" - if ss.CreationTimestamp != "" { - if t, err := time.Parse(time.RFC3339, ss.CreationTimestamp); err == nil { - age = FormatAge(now.Sub(t)) - } + if ss.CreatedAt != nil { + age = FormatAge(now.Sub(*ss.CreatedAt)) } return table.Row{ name, ss.Schedule, - ss.Namespace, + ss.ProjectID, suspended, - fmt.Sprintf("%d", ss.ActiveCount), lastRun, age, } @@ -94,58 +60,46 @@ func NewScheduledSessionTable(scope string, style TableStyle) ResourceTable { // ScheduledSessionDetail returns detail lines for all fields of a // ScheduledSession resource. -func ScheduledSessionDetail(ss ScheduledSession) []DetailLine { +func ScheduledSessionDetail(ss sdktypes.ScheduledSession) []DetailLine { suspended := "No" - if ss.Suspend { + if !ss.Enabled { suspended = "Yes" } - reuseLastSession := "No" - if ss.ReuseLastSession { - reuseLastSession = "Yes" - } lastRun := "" - if ss.LastScheduleTime != nil { - lastRun = *ss.LastScheduleTime + if ss.LastRunAt != nil { + lastRun = ss.LastRunAt.Format(time.RFC3339) } - templateJSON := "" - if len(ss.SessionTemplate) > 0 { - var obj interface{} - if err := json.Unmarshal(ss.SessionTemplate, &obj); err == nil { - if data, err := json.MarshalIndent(obj, "", " "); err == nil { - templateJSON = string(data) - } - } + nextRun := "" + if ss.NextRunAt != nil { + nextRun = ss.NextRunAt.Format(time.RFC3339) } - labelsJSON := "" - if len(ss.Labels) > 0 { - if data, err := json.MarshalIndent(ss.Labels, "", " "); err == nil { - labelsJSON = string(data) - } + createdAt := "" + if ss.CreatedAt != nil { + createdAt = ss.CreatedAt.Format(time.RFC3339) } - annotationsJSON := "" - if len(ss.Annotations) > 0 { - if data, err := json.MarshalIndent(ss.Annotations, "", " "); err == nil { - annotationsJSON = string(data) - } + updatedAt := "" + if ss.UpdatedAt != nil { + updatedAt = ss.UpdatedAt.Format(time.RFC3339) } return []DetailLine{ + {Key: "ID", Value: ss.ID}, {Key: "Name", Value: ss.Name}, - {Key: "Display Name", Value: ss.DisplayName}, - {Key: "Namespace", Value: ss.Namespace}, + {Key: "Description", Value: ss.Description}, + {Key: "Project ID", Value: ss.ProjectID}, + {Key: "Agent ID", Value: ss.AgentID}, {Key: "Schedule", Value: ss.Schedule}, + {Key: "Timezone", Value: ss.Timezone}, {Key: "Suspended", Value: suspended}, - {Key: "Reuse Last Session", Value: reuseLastSession}, - {Key: "Active Count", Value: fmt.Sprintf("%d", ss.ActiveCount)}, - {Key: "Last Schedule Time", Value: lastRun}, - {Key: "Created At", Value: ss.CreationTimestamp}, - {Key: "Labels", Value: labelsJSON}, - {Key: "Annotations", Value: annotationsJSON}, - {Key: "Session Template", Value: templateJSON}, + {Key: "Session Prompt", Value: ss.SessionPrompt}, + {Key: "Last Run At", Value: lastRun}, + {Key: "Next Run At", Value: nextRun}, + {Key: "Created At", Value: createdAt}, + {Key: "Updated At", Value: updatedAt}, } } From 7a81a1ba3e33e62e21e2f71e1759e4252d3fc2b2 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Tue, 28 Apr 2026 15:51:24 -0400 Subject: [PATCH 111/117] fix(cli): remove uiTickMsg, only show refresh counter when stale Remove the 1-second uiTickMsg that caused constant re-renders (breaking terminal text selection). The refresh counter now only appears when data is actually stale (>15s), reducing unnecessary View() calls to only poll responses and user input. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 6 ++---- .../cmd/acpctl/ambient/tui/model_new.go | 18 ------------------ 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 50ca3ac1a..3747cf255 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -153,11 +153,9 @@ func (m *AppModel) viewHeader() string { col4[2] = styleDim.Render("</>") + " " + styleWhite.Render("Filter ") if !m.lastFetch.IsZero() { elapsed := time.Since(m.lastFetch) - ind := fmt.Sprintf("⟳ %ds", int(elapsed.Seconds())) if elapsed > staleThreshold { - col4[3] = styleRed.Render(ind + " (stale)") - } else { - col4[3] = styleDim.Render(ind) + ind := fmt.Sprintf("⟳ %ds (stale)", int(elapsed.Seconds())) + col4[3] = styleRed.Render(ind) } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index e011cff03..40802af1f 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -57,9 +57,6 @@ type appTickMsg struct{ t time.Time } // active, triggering a REST poll for new session messages. type messagePollTickMsg struct{ t time.Time } -// uiTickMsg fires every second to refresh cosmetic elements (e.g. the -// stale-data counter in the header) without waiting for a keypress. -type uiTickMsg struct{} // infoExpiredMsg signals the ephemeral info line should be cleared. type infoExpiredMsg struct{} @@ -317,7 +314,6 @@ func (m *AppModel) Init() tea.Cmd { tea.WindowSize(), m.client.FetchProjects(), m.tickCmd(), - m.uiTickCmd(), ) } @@ -336,13 +332,6 @@ func (m *AppModel) messagePollTickCmd() tea.Cmd { }) } -// uiTickCmd returns a tea.Cmd that sends a uiTickMsg after 1 second. -func (m *AppModel) uiTickCmd() tea.Cmd { - return tea.Tick(time.Second, func(_ time.Time) tea.Msg { - return uiTickMsg{} - }) -} - // infoExpireCmd returns a tea.Cmd that clears the info line after infoTimeout. func (m *AppModel) infoExpireCmd() tea.Cmd { return tea.Tick(infoTimeout, func(_ time.Time) tea.Msg { @@ -882,9 +871,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) - case uiTickMsg: - return m, m.uiTickCmd() - case appTickMsg: return m.handleTick() @@ -1472,10 +1458,6 @@ func (m *AppModel) updateFormOverlay(msg tea.Msg) (tea.Model, tea.Cmd) { if _, ok := msg.(appTickMsg); ok { return m.handleTick() } - if _, ok := msg.(uiTickMsg); ok { - return m, m.uiTickCmd() - } - // Don't swallow data-fetch responses — they clear pollInFlight and update caches. switch typedMsg := msg.(type) { case ProjectsMsg: From 8004e8df64562917aa9dbbb8bf2213ad231fa146 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Tue, 28 Apr 2026 21:00:53 -0400 Subject: [PATCH 112/117] feat(cli): add repo URL field to new session form Sessions can now be created with a git repo URL that the runner will auto-clone into the workspace on startup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- components/ambient-cli/cmd/acpctl/ambient/tui/client.go | 3 ++- components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go | 5 +++-- .../ambient-cli/cmd/acpctl/ambient/tui/views/form.go | 7 ++++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index d50e964b2..bc72b980e 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -629,7 +629,7 @@ func (tc *TUIClient) DeleteProject(projectID string) tea.Cmd { // CreateSession returns a tea.Cmd that creates a standalone session. The session // is not tied to an agent unless agentID is provided. Only name is required. -func (tc *TUIClient) CreateSession(projectID, name, prompt, agentID string) tea.Cmd { +func (tc *TUIClient) CreateSession(projectID, name, prompt, agentID, repoURL string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() @@ -648,6 +648,7 @@ func (tc *TUIClient) CreateSession(projectID, name, prompt, agentID string) tea. ProjectID: projectID, Prompt: prompt, AgentID: agentID, + RepoURL: repoURL, } result, err := client.Sessions().Create(ctx, session) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 40802af1f..3dd35cf99 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -2024,7 +2024,8 @@ func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { } agentOpts = append(agentOpts, huh.NewOption(a.Name, a.ID)) } - form := views.NewSessionForm(&name, &prompt, &projectID, projectOpts, &agentID, agentOpts) + var repoURL string + form := views.NewSessionForm(&name, &prompt, &repoURL, &projectID, projectOpts, &agentID, agentOpts) form.WithWidth(60) m.formOverlay = form m.formTitle = "New Session" @@ -2033,7 +2034,7 @@ func (m *AppModel) handleSessionsRune(key string) (tea.Model, tea.Cmd) { return m.setInfo("Project is required") } return tea.Batch( - m.client.CreateSession(projectID, name, prompt, agentID), + m.client.CreateSession(projectID, name, prompt, agentID, repoURL), m.setInfo("Creating session "+name+"..."), ) } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/form.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/form.go index 9451ab00a..0992dd7fb 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/form.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/form.go @@ -92,7 +92,7 @@ func NewAgentForm(name, prompt *string) *huh.Form { // projectOptions must have at least one entry. agentOptions should include a // "(none)" entry for standalone sessions; the agent Select is only shown when // there are 2+ options. -func NewSessionForm(name, prompt, projectID *string, projectOptions []huh.Option[string], agentID *string, agentOptions []huh.Option[string]) *huh.Form { +func NewSessionForm(name, prompt, repoURL, projectID *string, projectOptions []huh.Option[string], agentID *string, agentOptions []huh.Option[string]) *huh.Form { fields := []huh.Field{ huh.NewSelect[string](). Key("project"). @@ -110,6 +110,11 @@ func NewSessionForm(name, prompt, projectID *string, projectOptions []huh.Option Title("Prompt"). Placeholder("(optional)"). Value(prompt), + huh.NewInput(). + Key("repo_url"). + Title("Repo URL"). + Placeholder("https://github.com/org/repo (optional)"). + Value(repoURL), } if len(agentOptions) > 1 { fields = append(fields, From da6a7a411de648cfe2a84cf64f991bddeed16995 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Thu, 30 Apr 2026 14:42:32 -0400 Subject: [PATCH 113/117] feat(cli): enhance scheduled sessions with full CRUD, edit, and project switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add all fields to scheduled session create form (agent, timezone, session prompt, description) — fixes 400 error from missing agent_id - Add `e` hotkey to edit scheduled sessions via $EDITOR - Fix project number-key switching staying on scheduled sessions view instead of redirecting to agents/projects - Extract numberKeyExcludedViews/projectShortcutHandledViews maps with regression tests to prevent silent fallthrough on new views - Fix selected row color not updating on data refresh tick (only updated on cursor movement) — affects all table views Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../cmd/acpctl/ambient/tui/client.go | 46 ++++++++- .../cmd/acpctl/ambient/tui/hints.go | 1 + .../cmd/acpctl/ambient/tui/model_new.go | 99 +++++++++++++++++-- .../ambient/tui/project_shortcut_test.go | 41 ++++++++ .../ambient/tui/views/scheduledsessions.go | 57 ++++++++--- .../cmd/acpctl/ambient/tui/views/table.go | 4 +- 6 files changed, 221 insertions(+), 27 deletions(-) create mode 100644 components/ambient-cli/cmd/acpctl/ambient/tui/project_shortcut_test.go diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index bc72b980e..7bafd8f95 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -814,6 +814,12 @@ type CreateScheduledSessionMsg struct { Err error } +// UpdateScheduledSessionMsg carries the result of patching a scheduled session. +type UpdateScheduledSessionMsg struct { + ScheduledSession *sdktypes.ScheduledSession + Err error +} + // InterruptSessionMsg carries the result of interrupting a session. type InterruptSessionMsg struct { Err error @@ -910,7 +916,7 @@ func (tc *TUIClient) TriggerScheduledSession(projectID, id string) tea.Cmd { } // CreateScheduledSession returns a tea.Cmd that creates a new scheduled session. -func (tc *TUIClient) CreateScheduledSession(projectID, name, schedule string) tea.Cmd { +func (tc *TUIClient) CreateScheduledSession(projectID, name, agentID, schedule, timezone, sessionPrompt, description string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() @@ -921,9 +927,13 @@ func (tc *TUIClient) CreateScheduledSession(projectID, name, schedule string) te } ss := &sdktypes.ScheduledSession{ - Name: name, - Schedule: schedule, - Enabled: true, + Name: name, + AgentID: agentID, + Schedule: schedule, + Timezone: timezone, + SessionPrompt: sessionPrompt, + Description: description, + Enabled: true, } result, err := client.ScheduledSessions().Create(ctx, projectID, ss) @@ -934,6 +944,34 @@ func (tc *TUIClient) CreateScheduledSession(projectID, name, schedule string) te } } +// UpdateScheduledSession returns a tea.Cmd that patches a scheduled session. +func (tc *TUIClient) UpdateScheduledSession(projectID, id string, patch map[string]any) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + + client, err := tc.factory.ForProject(projectID) + if err != nil { + return UpdateScheduledSessionMsg{Err: err} + } + + patchJSON, err := json.Marshal(patch) + if err != nil { + return UpdateScheduledSessionMsg{Err: fmt.Errorf("marshal patch: %w", err)} + } + var typedPatch sdktypes.ScheduledSessionPatch + if err := json.Unmarshal(patchJSON, &typedPatch); err != nil { + return UpdateScheduledSessionMsg{Err: fmt.Errorf("unmarshal patch: %w", err)} + } + + result, err := client.ScheduledSessions().Update(ctx, projectID, id, &typedPatch) + if err != nil { + return UpdateScheduledSessionMsg{Err: err} + } + return UpdateScheduledSessionMsg{ScheduledSession: result} + } +} + // InterruptSession returns a tea.Cmd that sends an interrupt signal to a // running session via the AG-UI interrupt endpoint. This uses a raw HTTP call // because the SDK does not have an interrupt method. diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go b/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go index 6fa91a6c3..f35b3caab 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/hints.go @@ -111,6 +111,7 @@ var viewHintRegistry = map[string]ViewHints{ "scheduledsessions": { Resource: []views.HelpEntry{ {Key: "d", Action: "Describe"}, + {Key: "e", Action: "Edit"}, {Key: "n", Action: "New"}, {Key: "s", Action: "Suspend/Resume"}, {Key: "t", Action: "Trigger"}, diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 3dd35cf99..3906effc7 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -35,6 +35,27 @@ const infoTimeout = 5 * time.Second // staleThreshold marks data as stale in the header when exceeded. const staleThreshold = 15 * time.Second +// numberKeyExcludedViews are views where digit keys do NOT trigger project +// switching (e.g. overlay views, the projects list itself). +var numberKeyExcludedViews = map[string]bool{ + "projects": true, + "contexts": true, + "messages": true, + "detail": true, + "inbox": true, + "help": true, +} + +// projectShortcutHandledViews are the views explicitly handled in +// handleProjectShortcut's digit-1-9 switch. Every view reachable by number +// keys that is NOT in numberKeyExcludedViews must appear here, or it will +// silently fall through to the agents view. +var projectShortcutHandledViews = map[string]bool{ + "agents": true, + "sessions": true, + "scheduledsessions": true, +} + // --------------------------------------------------------------------------- // Navigation // --------------------------------------------------------------------------- @@ -778,6 +799,17 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pollInFlight = true return m, tea.Batch(m.fetchActiveView(), m.setInfo("Session updated: "+name)) + case UpdateScheduledSessionMsg: + if msg.Err != nil { + return m, m.setInfo("Update scheduled session failed: " + msg.Err.Error()) + } + name := "" + if msg.ScheduledSession != nil { + name = msg.ScheduledSession.Name + } + m.pollInFlight = true + return m, tea.Batch(m.fetchActiveView(), m.setInfo("Scheduled session updated: "+name)) + case editCompleteMsg: return m.handleEditComplete(msg) @@ -1771,9 +1803,7 @@ func (m *AppModel) handleRuneKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Number-key project shortcuts (0-9) — only active on table views below project level. if len(key) == 1 && key[0] >= '0' && key[0] <= '9' && - m.activeView != "projects" && m.activeView != "contexts" && - m.activeView != "messages" && m.activeView != "detail" && - m.activeView != "inbox" && m.activeView != "scheduledsessions" { + !numberKeyExcludedViews[m.activeView] { return m.handleProjectShortcut(key[0] - '0') } @@ -2101,6 +2131,8 @@ func (m *AppModel) handleInboxRune(key string) (tea.Model, tea.Cmd) { // handleScheduledSessionsRune handles scheduled-session-view-specific hotkeys. func (m *AppModel) handleScheduledSessionsRune(key string) (tea.Model, tea.Cmd) { switch key { + case "e": + return m.openEditorForScheduledSession() case "d": // Show detail view for the selected scheduled session. row := m.scheduledSessionTable.SelectedRow() @@ -2123,14 +2155,25 @@ func (m *AppModel) handleScheduledSessionsRune(key string) (tea.Model, tea.Cmd) return m, m.setInfo("Navigate to a project first") } project := m.currentProject - var name, schedule string - form := views.NewScheduledSessionForm(&name, &schedule) + var agentOpts []huh.Option[string] + for _, a := range m.cachedAgents { + if a.ProjectID != project { + continue + } + agentOpts = append(agentOpts, huh.NewOption(a.Name, a.ID)) + } + if len(agentOpts) == 0 { + return m, m.setInfo("No agents found — create an agent first") + } + var name, schedule, description, sessionPrompt, timezone, agentID string + agentID = agentOpts[0].Value + form := views.NewScheduledSessionForm(&name, &schedule, &description, &sessionPrompt, &timezone, &agentID, agentOpts) form.WithWidth(60) m.formOverlay = form m.formTitle = "New Scheduled Session" m.formOnComplete = func() tea.Cmd { return tea.Batch( - m.client.CreateScheduledSession(project, name, schedule), + m.client.CreateScheduledSession(project, name, agentID, schedule, timezone, sessionPrompt, description), m.setInfo("Creating scheduled session "+name+"..."), ) } @@ -2793,6 +2836,10 @@ func (m *AppModel) handleProjectShortcut(digit byte) (tea.Model, tea.Cmd) { m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} m.activeView = "projects" return m, tea.Batch(m.client.FetchProjects(), m.setInfo("Viewing all projects")) + case "scheduledsessions": + m.navStack = []NavEntry{{Kind: "scheduledsessions", Scope: "all"}} + m.scheduledSessionTable.SetScope("all") + return m, tea.Batch(m.client.FetchScheduledSessions(""), m.setInfo("Viewing all scheduled sessions")) default: m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} m.activeView = "projects" @@ -2828,6 +2875,16 @@ func (m *AppModel) handleProjectShortcut(digit byte) (tea.Model, tea.Cmd) { m.client.FetchSessions(projectName), m.setInfo("Switched to project "+projectName), ) + case "scheduledsessions": + m.scheduledSessionTable.SetScope(projectName) + m.navStack = []NavEntry{ + {Kind: "scheduledsessions", Scope: projectName}, + } + m.activeView = "scheduledsessions" + return m, tea.Batch( + m.client.FetchScheduledSessions(projectName), + m.setInfo("Switched to project "+projectName), + ) default: m.agentTable.SetScope(projectName) m.navStack = []NavEntry{ @@ -2906,6 +2963,26 @@ func (m *AppModel) openEditorForSession() (tea.Model, tea.Cmd) { return m.openEditorForResource("session", session.ID, projectID, *session) } +// openEditorForScheduledSession serializes the selected scheduled session as +// JSON, writes it to a temp file, and suspends the TUI to open the user's +// $EDITOR. +func (m *AppModel) openEditorForScheduledSession() (tea.Model, tea.Cmd) { + row := m.scheduledSessionTable.SelectedRow() + if len(row) == 0 { + return m, m.setInfo("No scheduled session selected") + } + name := row[0] + ss := m.findScheduledSessionByName(name) + if ss == nil { + return m, m.setInfo("Scheduled session not found in cache: " + name) + } + if m.currentProject == "" { + return m, m.setInfo("No project context for edit") + } + + return m.openEditorForResource("scheduledsession", ss.ID, m.currentProject, *ss) +} + // openEditorForResource is the shared implementation that writes JSON to a temp // file, opens $EDITOR via tea.ExecProcess, and returns an editCompleteMsg when // the editor exits. @@ -3028,6 +3105,11 @@ func (m *AppModel) handleEditComplete(msg editCompleteMsg) (tea.Model, tea.Cmd) "repo_url", "repos", "resource_overrides", "timeout", "environment_variables", } + case "scheduledsession": + editableFields = []string{ + "name", "description", "schedule", "timezone", + "session_prompt", "agent_id", "enabled", + } } // Build patch with only changed editable fields. @@ -3087,6 +3169,11 @@ func (m *AppModel) handleEditComplete(msg editCompleteMsg) (tea.Model, tea.Cmd) m.client.UpdateSession(msg.ProjectID, msg.ResourceID, patch), m.setInfo("Updating session ("+summary+")..."), ) + case "scheduledsession": + return m, tea.Batch( + m.client.UpdateScheduledSession(msg.ProjectID, msg.ResourceID, patch), + m.setInfo("Updating scheduled session ("+summary+")..."), + ) default: return m, m.setInfo("Unknown resource kind: " + msg.ResourceKind) } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/project_shortcut_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/project_shortcut_test.go new file mode 100644 index 000000000..c5442f70d --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/project_shortcut_test.go @@ -0,0 +1,41 @@ +package tui + +import "testing" + +// TestProjectShortcutHandledViews ensures every view reachable by number-key +// project switching has an explicit case in handleProjectShortcut. If a new +// view is added without handling it, this test fails — preventing silent +// fallthrough to the agents view. +func TestProjectShortcutHandledViews(t *testing.T) { + // All views that exist in the TUI. + allViews := []string{ + "projects", + "agents", + "sessions", + "scheduledsessions", + "inbox", + "messages", + "detail", + "contexts", + "help", + } + + for _, v := range allViews { + if numberKeyExcludedViews[v] { + continue + } + if !projectShortcutHandledViews[v] { + t.Errorf("view %q is reachable by number-key project switching but has no explicit case in handleProjectShortcut — add it to projectShortcutHandledViews and handle it in the switch", v) + } + } +} + +// TestNumberKeyExcludedAndHandledAreDisjoint verifies the two sets don't +// overlap, which would indicate a misconfiguration. +func TestNumberKeyExcludedAndHandledAreDisjoint(t *testing.T) { + for v := range numberKeyExcludedViews { + if projectShortcutHandledViews[v] { + t.Errorf("view %q appears in both numberKeyExcludedViews and projectShortcutHandledViews", v) + } + } +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go index 349827699..e84a8d59e 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/scheduledsessions.go @@ -103,22 +103,47 @@ func ScheduledSessionDetail(ss sdktypes.ScheduledSession) []DetailLine { } } -// NewScheduledSessionForm creates a huh form for creating a new scheduled session. -func NewScheduledSessionForm(displayName, schedule *string) *huh.Form { +// NewScheduledSessionForm creates a huh form for creating a new scheduled +// session. agentOptions must have at least one entry (agent is required). +func NewScheduledSessionForm( + displayName, schedule, description, sessionPrompt, timezone, agentID *string, + agentOptions []huh.Option[string], +) *huh.Form { + fields := []huh.Field{ + huh.NewInput(). + Key("displayName"). + Title("Name"). + Placeholder("my-scheduled-session"). + Validate(huh.ValidateNotEmpty()). + Value(displayName), + huh.NewSelect[string](). + Key("agent"). + Title("Agent"). + Options(agentOptions...). + Value(agentID), + huh.NewInput(). + Key("schedule"). + Title("Schedule (cron)"). + Placeholder("*/30 * * * *"). + Validate(huh.ValidateNotEmpty()). + Value(schedule), + huh.NewInput(). + Key("timezone"). + Title("Timezone"). + Placeholder("UTC"). + Value(timezone), + huh.NewInput(). + Key("sessionPrompt"). + Title("Session Prompt"). + Placeholder("(optional)"). + Value(sessionPrompt), + huh.NewInput(). + Key("description"). + Title("Description"). + Placeholder("(optional)"). + Value(description), + } return huh.NewForm( - huh.NewGroup( - huh.NewInput(). - Key("displayName"). - Title("Display Name"). - Placeholder("my-scheduled-session"). - Validate(huh.ValidateNotEmpty()). - Value(displayName), - huh.NewInput(). - Key("schedule"). - Title("Schedule (cron)"). - Placeholder("*/30 * * * *"). - Validate(huh.ValidateNotEmpty()). - Value(schedule), - ), + huh.NewGroup(fields...), ).WithTheme(ACPTheme()).WithShowHelp(false) } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 63313464f..7f8fbef27 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -192,10 +192,12 @@ func (rt *ResourceTable) SetRows(rows []table.Row) { for i, row := range visibleRows { if len(row) > 0 && row[0] == selectedKey { rt.inner.SetCursor(i) - return + break } } } + + rt.updateSelectedStyle() } // SetRowColorFunc sets a function that determines the foreground color for each From b4a3d00a384283431f075d0dc157cf1d74f75164 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Thu, 30 Apr 2026 14:51:19 -0400 Subject: [PATCH 114/117] style(cli): gofmt formatting pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/tui/app.go | 2 +- .../cmd/acpctl/ambient/tui/client.go | 1 - .../cmd/acpctl/ambient/tui/model_new.go | 35 ++++++++------- .../cmd/acpctl/ambient/tui/view.go | 1 - .../cmd/acpctl/ambient/tui/views/dialog.go | 6 +-- .../cmd/acpctl/ambient/tui/views/messages.go | 43 +++++++++---------- .../cmd/acpctl/ambient/tui/views/table.go | 1 - components/ambient-cli/cmd/acpctl/main.go | 2 +- .../internal/informer/informer_test.go | 10 ++--- 9 files changed, 47 insertions(+), 54 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go index 3747cf255..dc0e648f1 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/app.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/app.go @@ -250,7 +250,7 @@ func (m *AppModel) renderHint(hint string, keyWidth int) string { if idx < 0 { return styleDim.Render(hint) } - key := hint[:idx+1] // e.g. "<d>" + key := hint[:idx+1] // e.g. "<d>" action := hint[idx+2:] // e.g. "Describe" (skip the space after >) renderedKey := styleDim.Render(key) pad := keyWidth + 1 - lipgloss.Width(renderedKey) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index 7bafd8f95..abe17548e 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -1046,4 +1046,3 @@ func (tc *TUIClient) FetchSessionMessages(projectID, sessionID string, afterSeq return SessionMessagesMsg{Messages: msgs} } } - diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 3906effc7..5aa5639e7 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -78,7 +78,6 @@ type appTickMsg struct{ t time.Time } // active, triggering a REST poll for new session messages. type messagePollTickMsg struct{ t time.Time } - // infoExpiredMsg signals the ephemeral info line should be cleared. type infoExpiredMsg struct{} @@ -164,11 +163,11 @@ type AppModel struct { helpView views.HelpView // Cached resource data for CRUD lookups (maps name/ID -> full resource). - cachedProjects []sdktypes.Project - cachedAgents []sdktypes.Agent - cachedSessions []sdktypes.Session - cachedInbox []sdktypes.InboxMessage - cachedScheduledSessions []sdktypes.ScheduledSession + cachedProjects []sdktypes.Project + cachedAgents []sdktypes.Agent + cachedSessions []sdktypes.Session + cachedInbox []sdktypes.InboxMessage + cachedScheduledSessions []sdktypes.ScheduledSession // Message polling state. messagePollActive bool // true when message poll tick is running @@ -272,16 +271,16 @@ func NewAppModel(factory *connection.ClientFactory) (*AppModel, error) { navStack: []NavEntry{ {Kind: "projects", Scope: "all"}, }, - activeView: "projects", - projectTable: pt, - agentTable: at, - sessionTable: st, + activeView: "projects", + projectTable: pt, + agentTable: at, + sessionTable: st, inboxTable: it, contextTable: ct, scheduledSessionTable: sst, commandInput: ci, - filterInput: fi, - promptInput: pi, + filterInput: fi, + promptInput: pi, } return m, nil @@ -1598,7 +1597,7 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { if len(row) > 1 { contextName := row[1] // NAME column (index 1, after ACTIVE) if err := m.config.SwitchContext(contextName); err != nil { - return m, m.setInfo("Error: "+err.Error()) + return m, m.setInfo("Error: " + err.Error()) } m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} m.activeView = "projects" @@ -2621,7 +2620,7 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { } // Switch context. if err := m.config.SwitchContext(cmd.Arg); err != nil { - return m, m.setInfo("Error: "+err.Error()) + return m, m.setInfo("Error: " + err.Error()) } // Reset everything on context switch. m.navStack = []NavEntry{{Kind: "projects", Scope: "all"}} @@ -2631,7 +2630,7 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { m.currentAgentID = "" m.currentSession = "" m.activeFilter = nil - return m, m.setInfo("Switched to context "+cmd.Arg) + return m, m.setInfo("Switched to context " + cmd.Arg) case CmdProject: if cmd.Arg != "" { @@ -2640,7 +2639,7 @@ func (m *AppModel) executeCommand(input string) (tea.Model, tea.Cmd) { ctx.Project = cmd.Arg } m.currentProject = cmd.Arg - return m, m.setInfo("Switched to project "+cmd.Arg) + return m, m.setInfo("Switched to project " + cmd.Arg) } return m, nil @@ -2721,12 +2720,12 @@ func (m *AppModel) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { f, err := ParseFilter(input) if err != nil { - return m, m.setInfo("Invalid filter: "+err.Error()) + return m, m.setInfo("Invalid filter: " + err.Error()) } m.activeFilter = f m.applyFilterToActiveTable(f) - return m, m.setInfo("Filter applied: "+f.String()) + return m, m.setInfo("Filter applied: " + f.String()) default: var cmd tea.Cmd diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/view.go b/components/ambient-cli/cmd/acpctl/ambient/tui/view.go index ed33b3df7..631188738 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/view.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/view.go @@ -307,4 +307,3 @@ func truncateLine(s string, w int) string { } return s } - diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go index f2c679f9d..adb903a7b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/dialog.go @@ -73,9 +73,9 @@ func NewDeleteDialog(kind, name string) Dialog { // NewErrorDialog creates a single-button dialog with ASCII art and an error message. func NewErrorDialog(title, message, ascii string) Dialog { return Dialog{ - Title: title, - Message: ascii + "\n" + message, - Buttons: []string{"Dismiss"}, + Title: title, + Message: ascii + "\n" + message, + Buttons: []string{"Dismiss"}, Selected: 0, Width: 50, } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 6c76af704..335304ccb 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -51,15 +51,15 @@ var ( // Hoisted styles for the message stream View to avoid allocations on every frame. var ( - msgBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - msgKindStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) - msgScopeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Bold(true) - msgCountStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) - msgDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - msgDimIndicator = lipgloss.NewStyle().Foreground(msgColorDim) + msgBorderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + msgKindStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) + msgScopeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")).Bold(true) + msgCountStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true) + msgDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + msgDimIndicator = lipgloss.NewStyle().Foreground(msgColorDim) msgActiveIndicator = lipgloss.NewStyle().Foreground(msgColorBlue) - msgCursorStyle = lipgloss.NewStyle().Foreground(msgColorOrange) - msgSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) + msgCursorStyle = lipgloss.NewStyle().Foreground(msgColorOrange) + msgSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) ) // eventColor returns the lipgloss color for a semantic event type. @@ -416,16 +416,16 @@ type MessageStream struct { glamourWidth int // width used to create the cached renderer // Cached display lines — rebuilt when mode/messages change, not every frame. - cachedLines []string - cachedDirty bool // true when lines need rebuilding - cachedMsgCount int - cachedRawMode bool - cachedWrapMode bool - cachedTsMode int - cachedSearchPat string + cachedLines []string + cachedDirty bool // true when lines need rebuilding + cachedMsgCount int + cachedRawMode bool + cachedWrapMode bool + cachedTsMode int + cachedSearchPat string // Per-message glamour render cache (key = Seq). - glamourCache map[int]string + glamourCache map[int]string // Compose composeMode bool @@ -535,10 +535,10 @@ func (ms MessageStream) ComposeValue() string { } // Toggle state getters — used by the header to highlight active toggles. -func (ms MessageStream) IsAutoScroll() bool { return ms.autoScroll } -func (ms MessageStream) IsRawMode() bool { return ms.rawMode } -func (ms MessageStream) IsWrapMode() bool { return ms.wrapMode } -func (ms MessageStream) TimestampMode() int { return ms.timestampMode } +func (ms MessageStream) IsAutoScroll() bool { return ms.autoScroll } +func (ms MessageStream) IsRawMode() bool { return ms.rawMode } +func (ms MessageStream) IsWrapMode() bool { return ms.wrapMode } +func (ms MessageStream) TimestampMode() int { return ms.timestampMode } // SetSearchPattern sets or clears the message filter pattern. func (ms *MessageStream) SetSearchPattern(pat *regexp.Regexp) { @@ -890,8 +890,6 @@ func (ms *MessageStream) View() string { bottomLines = append([]string{composeSep, composeLine}, bottomLines...) } - - // -- Content area -- // 3 = header bar + header line + header separator topLines := 3 @@ -1071,7 +1069,6 @@ func (ms *MessageStream) getGlamourRenderer(wrapWidth int) *glamour.TermRenderer return r } - // renderConversationEntry renders a single message in conversation mode. // Format: [event_type] summary text (wrapped) func (ms *MessageStream) renderConversationEntry(entry MessageEntry, maxWidth int) []string { diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index 7f8fbef27..a452650d7 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -10,7 +10,6 @@ import ( "github.com/charmbracelet/lipgloss" ) - // SortDirection represents the sort order for a column. type SortDirection int diff --git a/components/ambient-cli/cmd/acpctl/main.go b/components/ambient-cli/cmd/acpctl/main.go index 7c2908ca6..c618356b3 100755 --- a/components/ambient-cli/cmd/acpctl/main.go +++ b/components/ambient-cli/cmd/acpctl/main.go @@ -6,7 +6,6 @@ import ( "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/agent" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/ambient" - "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/scheduledsession" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/apply" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/completion" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/config" @@ -19,6 +18,7 @@ import ( "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/login" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/logout" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/project" + "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/scheduledsession" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/session" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/start" "github.com/ambient-code/platform/components/ambient-cli/cmd/acpctl/stop" diff --git a/components/ambient-control-plane/internal/informer/informer_test.go b/components/ambient-control-plane/internal/informer/informer_test.go index 5b7447f67..e1711b964 100644 --- a/components/ambient-control-plane/internal/informer/informer_test.go +++ b/components/ambient-control-plane/internal/informer/informer_test.go @@ -8,8 +8,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -func strPtr(s string) *string { return &s } -func int32Ptr(i int32) *int32 { return &i } +func strPtr(s string) *string { return &s } +func int32Ptr(i int32) *int32 { return &i } func float64Ptr(f float64) *float64 { return &f } func TestProtoSessionToSDK_NilReturnsZero(t *testing.T) { @@ -21,9 +21,9 @@ func TestProtoSessionToSDK_NilReturnsZero(t *testing.T) { func TestProtoSessionToSDK_StandaloneSession(t *testing.T) { proto := &pb.Session{ - Metadata: &pb.ObjectReference{Id: "session-standalone"}, - Name: "no-agent-session", - Prompt: strPtr("just do the thing"), + Metadata: &pb.ObjectReference{Id: "session-standalone"}, + Name: "no-agent-session", + Prompt: strPtr("just do the thing"), ProjectId: strPtr("my-project"), } From df5641bc4dfc96a5ed8a5db0eb61eef605ea5068 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Thu, 30 Apr 2026 15:02:58 -0400 Subject: [PATCH 115/117] fix(cli): fix CI lint and test failures - Update EventColor test expectations to match code (assistant=255, error=196) - Update ParseFilter tests: invalid regex now falls back to literal match instead of erroring - Add package comment to views package (ST1000) - Rename cachedTsMode to cachedTSMode (ST1003) - Fix comment format on IsComposeMode, IsAutoScroll (ST1020) - Remove unused msgCursorStyle variable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../cmd/acpctl/ambient/tui/events.go | 4 ++-- .../cmd/acpctl/ambient/tui/events_test.go | 4 ++-- .../cmd/acpctl/ambient/tui/filter_test.go | 20 +++++++++++++------ .../cmd/acpctl/ambient/tui/views/messages.go | 10 ++++------ .../cmd/acpctl/ambient/tui/views/table.go | 1 + 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/events.go b/components/ambient-cli/cmd/acpctl/ambient/tui/events.go index 61d50f4a9..8deb8d44d 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/events.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/events.go @@ -13,11 +13,11 @@ import ( // Mapping follows the TUI spec's "Event Type Rendering" table: // // user -> white (255) -// assistant -> green (28) +// assistant -> white (255) // tool_use -> dim (240) // tool_result -> dim (240) // system -> yellow (33) -// error -> red (31) +// error -> red (196) func EventColor(eventType string) lipgloss.Color { switch eventType { case "user": diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/events_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/events_test.go index 6bd852925..3c0760f03 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/events_test.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/events_test.go @@ -16,11 +16,11 @@ func TestEventColor(t *testing.T) { want lipgloss.Color }{ {"user", lipgloss.Color("255")}, - {"assistant", lipgloss.Color("28")}, + {"assistant", lipgloss.Color("255")}, {"tool_use", lipgloss.Color("240")}, {"tool_result", lipgloss.Color("240")}, {"system", lipgloss.Color("33")}, - {"error", lipgloss.Color("31")}, + {"error", lipgloss.Color("196")}, {"unknown_type", lipgloss.Color("240")}, {"", lipgloss.Color("240")}, } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/filter_test.go b/components/ambient-cli/cmd/acpctl/ambient/tui/filter_test.go index f7770b535..61b04b911 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/filter_test.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/filter_test.go @@ -156,16 +156,24 @@ func TestParseFilter_InverseEmptyString(t *testing.T) { } func TestParseFilter_InvalidRegex(t *testing.T) { - _, err := ParseFilter("[invalid") - if err == nil { - t.Fatal("expected error for invalid regex") + // Invalid regex falls back to literal match via QuoteMeta. + f, err := ParseFilter("[invalid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Pattern == nil { + t.Fatal("expected non-nil pattern") } } func TestParseFilter_InvalidRegexInverse(t *testing.T) { - _, err := ParseFilter("![invalid") - if err == nil { - t.Fatal("expected error for invalid inverse regex") + // Invalid regex falls back to literal match via QuoteMeta. + f, err := ParseFilter("![invalid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !f.Inverse { + t.Fatal("expected inverse flag") } } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 335304ccb..42ba27f29 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -58,7 +58,6 @@ var ( msgDimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) msgDimIndicator = lipgloss.NewStyle().Foreground(msgColorDim) msgActiveIndicator = lipgloss.NewStyle().Foreground(msgColorBlue) - msgCursorStyle = lipgloss.NewStyle().Foreground(msgColorOrange) msgSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) ) @@ -421,7 +420,7 @@ type MessageStream struct { cachedMsgCount int cachedRawMode bool cachedWrapMode bool - cachedTsMode int + cachedTSMode int cachedSearchPat string // Per-message glamour render cache (key = Seq). @@ -524,7 +523,6 @@ func (ms *MessageStream) SetPhase(phase string) { ms.phase = phase } -// ComposeValue returns the current text in the compose input. // IsComposeMode returns true when the compose input is active. func (ms MessageStream) IsComposeMode() bool { return ms.composeMode @@ -534,7 +532,7 @@ func (ms MessageStream) ComposeValue() string { return ms.composeInput.Value() } -// Toggle state getters — used by the header to highlight active toggles. +// IsAutoScroll returns true when auto-scroll is enabled. func (ms MessageStream) IsAutoScroll() bool { return ms.autoScroll } func (ms MessageStream) IsRawMode() bool { return ms.rawMode } func (ms MessageStream) IsWrapMode() bool { return ms.wrapMode } @@ -965,7 +963,7 @@ func (ms *MessageStream) buildDisplayLines() []string { ms.cachedMsgCount == totalCount && ms.cachedRawMode == ms.rawMode && ms.cachedWrapMode == ms.wrapMode && - ms.cachedTsMode == ms.timestampMode && + ms.cachedTSMode == ms.timestampMode && ms.cachedSearchPat == searchStr && ms.timestampMode == 0 { return ms.cachedLines @@ -1002,7 +1000,7 @@ func (ms *MessageStream) buildDisplayLines() []string { ms.cachedMsgCount = totalCount ms.cachedRawMode = ms.rawMode ms.cachedWrapMode = ms.wrapMode - ms.cachedTsMode = ms.timestampMode + ms.cachedTSMode = ms.timestampMode ms.cachedSearchPat = searchStr return lines } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go index a452650d7..b619d6240 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/table.go @@ -1,3 +1,4 @@ +// Package views provides reusable UI components for the TUI resource browser. package views import ( From 0b7dd9597d9bbfad201c8763134409307075de76 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Thu, 30 Apr 2026 15:10:58 -0400 Subject: [PATCH 116/117] fix(cli): standardize TUIConfig receiver name to 'c' (ST1016) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- .../ambient-cli/cmd/acpctl/ambient/tui/config.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/config.go b/components/ambient-cli/cmd/acpctl/ambient/tui/config.go index 0eeb55eac..1d31f98f0 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/config.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/config.go @@ -21,14 +21,14 @@ type TUIConfig struct { } // String implements fmt.Stringer. All context tokens are redacted. -func (tc *TUIConfig) String() string { - names := tc.ContextNames() - return fmt.Sprintf("TUIConfig{CurrentContext:%q, Contexts:[%s]}", tc.CurrentContext, strings.Join(names, ", ")) +func (c *TUIConfig) String() string { + names := c.ContextNames() + return fmt.Sprintf("TUIConfig{CurrentContext:%q, Contexts:[%s]}", c.CurrentContext, strings.Join(names, ", ")) } // GoString implements fmt.GoStringer. All context tokens are redacted. -func (tc *TUIConfig) GoString() string { - return tc.String() +func (c *TUIConfig) GoString() string { + return c.String() } // Context represents a single server connection with its credentials and project scope. From f1e89ca4af34f8a3d9a9c6ccf9178e08d25dfc03 Mon Sep 17 00:00:00 2001 From: John Sell <jsell@redhat.com> Date: Thu, 30 Apr 2026 15:17:06 -0400 Subject: [PATCH 117/117] fix(cli): address CodeRabbit review feedback - Bound io.ReadAll with LimitReader(1MB) on interrupt error response - Fix truncatePayload to use []rune for UTF-8 safe truncation - Use independent timeout contexts for kubectl/oc fallback calls Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- components/ambient-cli/cmd/acpctl/ambient/tui/client.go | 2 +- components/ambient-cli/cmd/acpctl/ambient/tui/events.go | 8 ++++++-- components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go | 8 ++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index abe17548e..f18c6961b 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -1013,7 +1013,7 @@ func (tc *TUIClient) InterruptSession(sessionID string) tea.Cmd { defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - respBody, _ := io.ReadAll(resp.Body) + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) var errResp struct { Error string `json:"error"` } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/events.go b/components/ambient-cli/cmd/acpctl/ambient/tui/events.go index 8deb8d44d..ef820b2b6 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/events.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/events.go @@ -271,8 +271,12 @@ func ExtractField(payload string, key string) string { // truncatePayload trims whitespace and truncates a string to max length. func truncatePayload(s string, max int) string { s = strings.TrimSpace(s) - if len(s) <= max { + r := []rune(s) + if len(r) <= max { return s } - return s[:max-1] + "…" + if max <= 1 { + return "…" + } + return string(r[:max-1]) + "…" } diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go b/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go index a96313607..5ac0e4518 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/fetch.go @@ -160,7 +160,9 @@ func kubectlGetPods() []PodRow { "-o", "wide", ) if err != nil { - out2, err2 := runCmd(ctx, "oc", "get", "pods", + ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel2() + out2, err2 := runCmd(ctx2, "oc", "get", "pods", "-n", "ambient-code", "--no-headers", "-o", "wide", @@ -178,7 +180,9 @@ func kubectlGetNamespaces() []NamespaceRow { defer cancel() out, err := runCmd(ctx, "kubectl", "get", "namespaces", "--no-headers") if err != nil { - out2, err2 := runCmd(ctx, "oc", "get", "namespaces", "--no-headers") + ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel2() + out2, err2 := runCmd(ctx2, "oc", "get", "namespaces", "--no-headers") if err2 != nil { return nil }