diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 9e5dfdde5..3c49f1086 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -101,7 +101,7 @@ Then just talk to pi. By default, pi gives the model four tools: `read`, `write` ## Providers & Models -For each built-in provider, pi maintains a list of tool-capable models, updated with every release. Authenticate via subscription (`/login`) or API key, then select any model from that provider via `/model` (or Ctrl+L). +For each built-in provider, pi maintains a list of tool-capable models, updated with every release. Authenticate via subscription (`/login`) or API key, then select any model from that provider via `/model` (or CTRL+L). **Subscriptions:** - Anthropic Claude Pro/Max @@ -162,8 +162,8 @@ The editor can be temporarily replaced by other UI, like built-in `/settings` or |---------|-----| | File reference | Type `@` to fuzzy-search project files | | Path completion | Tab to complete paths | -| Multi-line | Shift+Enter (or Ctrl+Enter on Windows Terminal) | -| Images | Ctrl+V to paste (Alt+V on Windows), or drag onto terminal | +| Multi-line | SHIFT+Enter (or CTRL+Enter on Windows Terminal) | +| Images | CTRL+V to paste (ALT+V on Windows), or drag onto terminal | | Bash commands | `!command` runs and sends output to LLM, `!!command` runs without sending | Standard editing keybindings for delete word, undo, etc. See [docs/keybindings.md](docs/keybindings.md). @@ -176,7 +176,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist |---------|-------------| | `/login`, `/logout` | OAuth authentication | | `/model` | Switch models | -| `/scoped-models` | Enable/disable models for Ctrl+P cycling | +| `/scoped-models` | Enable/disable models for CTRL+P cycling | | `/settings` | Thinking level, theme, message delivery, transport | | `/resume` | Pick from previous sessions | | `/new` | Start a new session | @@ -202,26 +202,26 @@ See `/hotkeys` for the full list. Customize via `~/.pi/agent/keybindings.json`. | Key | Action | |-----|--------| -| Ctrl+C | Clear editor | -| Ctrl+C twice | Quit | +| CTRL+C | Clear editor | +| CTRL+C twice | Quit | | Escape | Cancel/abort | | Escape twice | Open `/tree` | -| Ctrl+L | Open model selector | -| Ctrl+P / Shift+Ctrl+P | Cycle scoped models forward/backward | -| Shift+Tab | Cycle thinking level | -| Ctrl+O | Collapse/expand tool output | -| Ctrl+T | Collapse/expand thinking blocks | +| CTRL+L | Open model selector | +| CTRL+P / SHIFT+CTRL+P | Cycle scoped models forward/backward | +| SHIFT+Tab | Cycle thinking level | +| CTRL+O | Collapse/expand tool output | +| CTRL+T | Collapse/expand thinking blocks | ### Message Queue Submit messages while the agent is working: - **Enter** queues a *steering* message, delivered after the current assistant turn finishes executing its tool calls -- **Alt+Enter** queues a *follow-up* message, delivered only after the agent finishes all work +- **ALT+Enter** queues a *follow-up* message, delivered only after the agent finishes all work - **Escape** aborts and restores queued messages to editor -- **Alt+Up** retrieves queued messages back to editor +- **ALT+Up** retrieves queued messages back to editor -On Windows Terminal, `Alt+Enter` is fullscreen by default. Remap it in [docs/terminal-setup.md](docs/terminal-setup.md) so pi can receive the follow-up shortcut. +On Windows Terminal, `ALT+Enter` is fullscreen by default. Remap it in [docs/terminal-setup.md](docs/terminal-setup.md) so pi can receive the follow-up shortcut. Configure delivery in [settings](docs/settings.md): `steeringMode` and `followUpMode` can be `"one-at-a-time"` (default, waits for response) or `"all"` (delivers all queued at once). `transport` selects provider transport preference (`"sse"`, `"websocket"`, or `"auto"`) for providers that support multiple transports. @@ -251,9 +251,9 @@ Use `/session` in interactive mode to see the current session ID before reusing

Tree View

-- Search by typing, fold/unfold and jump between branches with Ctrl+←/Ctrl+→ or Alt+←/Alt+→, page with ←/→ -- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all -- Press Shift+L to label entries as bookmarks and Shift+T to toggle label timestamps +- Search by typing, fold/unfold and jump between branches with CTRL+←/CTRL+→ or ALT+←/ALT+→, page with ←/→ +- Filter modes (CTRL+O): default → no-tools → user-only → labeled-only → all +- Press SHIFT+L to label entries as bookmarks and SHIFT+T to toggle label timestamps **`/fork`** - Create a new session file from a previous user message on the active branch. Opens a selector, copies the active path up to that point, and places the selected prompt in the editor for modification. @@ -410,22 +410,23 @@ pi config # enable/disable extensions, skills, pro Packages install to `~/.pi/agent/git/` (git) or global npm. Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`. -Create a package by adding a `pi` key to `package.json`: +Create a package by adding an app-name manifest key to `package.json` (`atomic` for this package). The legacy `pi` key is still accepted as a backwards-compatible shim: ```json { "name": "my-pi-package", "keywords": ["pi-package"], - "pi": { + "atomic": { "extensions": ["./extensions"], "skills": ["./skills"], "prompts": ["./prompts"], - "themes": ["./themes"] + "themes": ["./themes"], + "workflows": ["./workflows"] } } ``` -Without a `pi` manifest, pi auto-discovers from conventional directories (`extensions/`, `skills/`, `prompts/`, `themes/`). +Without a manifest, Atomic auto-discovers from conventional directories (`extensions/`, `skills/`, `prompts/`, `themes/`, `workflows/`). Workflow packages may also use a singular `workflow/` directory. See [docs/packages.md](docs/packages.md). @@ -532,7 +533,7 @@ cat README.md | pi -p "Summarize this text" | `--model ` | Model pattern or ID (supports `provider/id` and optional `:`) | | `--api-key ` | API key (overrides env vars) | | `--thinking ` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | -| `--models ` | Comma-separated patterns for Ctrl+P cycling | +| `--models ` | Comma-separated patterns for CTRL+P cycling | | `--list-models [search]` | List available models | ### Session Options @@ -634,7 +635,7 @@ pi --thinking high "Solve this complex problem" | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | | `PI_TELEMETRY` | Override install/update telemetry. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks | | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) | -| `VISUAL`, `EDITOR` | External editor for Ctrl+G | +| `VISUAL`, `EDITOR` | External editor for CTRL+G | --- diff --git a/packages/coding-agent/docs/development.md b/packages/coding-agent/docs/development.md index b654f4edf..9605b3efd 100644 --- a/packages/coding-agent/docs/development.md +++ b/packages/coding-agent/docs/development.md @@ -25,14 +25,14 @@ Configure via `package.json`: ```json { - "piConfig": { + "atomicConfig": { "name": "atomic", "configDir": ".atomic" } } ``` -Change `name`, `configDir`, and the `bin` field for your fork. Atomic sets these to `atomic`, `.atomic`, and the `atomic` executable. Affects CLI banner, config paths, and environment variable names. +Change `name`, `configDir`, and the `bin` field for your fork. The app-specific `Config` key is preferred; legacy `piConfig` remains a backwards-compatible shim. Atomic sets these to `atomic`, `.atomic`, and the `atomic` executable. Affects CLI banner, config paths, and environment variable names. ## Path Resolution diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 155e64b98..2d17d46e6 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -255,13 +255,13 @@ This pattern makes the fetched models available during normal startup and to `pi "zod": "^3.0.0", "chalk": "^5.0.0" }, - "pi": { + "atomic": { "extensions": ["./src/index.ts"] } } ``` -Run `npm install` in the extension directory, then imports from `node_modules/` work automatically. +The manifest key is the app name from `package.json` (`atomic` here); the legacy `pi` key is still accepted as a compatibility shim. Run `npm install` in the extension directory, then imports from `node_modules/` work automatically. ## Events @@ -323,14 +323,14 @@ user sends another prompt ◄───────────────── ├─► session_before_tree (can cancel or customize) └─► session_tree -/model or Ctrl+P (model selection/cycling) +/model or CTRL+P (model selection/cycling) ├─► thinking_level_select (if model change changes/clamps thinking level) └─► model_select thinking level changes (settings, keybinding, pi.setThinkingLevel()) └─► thinking_level_select -exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) +exit (CTRL+C, CTRL+D, SIGHUP, SIGTERM) └─► session_shutdown ``` @@ -635,7 +635,7 @@ Header availability depends on provider and transport. Providers that abstract H #### model_select -Fired when the model changes via `/model` command, model cycling (`Ctrl+P`), or session restore. +Fired when the model changes via `/model` command, model cycling (`CTRL+P`), or session restore. ```typescript pi.on("model_select", async (event, ctx) => { @@ -745,7 +745,7 @@ In parallel tool mode, `tool_result` and `tool_execution_end` may interleave in - Each handler sees the latest result after previous handler changes - Handlers can return partial patches (`content`, `details`, or `isError`); omitted fields keep their current values -Use `ctx.signal` for nested async work inside the handler. This lets Esc cancel model calls, `fetch()`, and other abort-aware operations started by the extension. +Use `ctx.signal` for nested async work inside the handler. This lets Escape cancel model calls, `fetch()`, and other abort-aware operations started by the extension. ```typescript import { isBashToolResult } from "@bastani/atomic"; @@ -2332,7 +2332,7 @@ For complex UI, use `ctx.ui.custom()`. This temporarily replaces the editor with import { Text, Component } from "@earendil-works/pi-tui"; const result = await ctx.ui.custom((tui, theme, keybindings, done) => { - const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1); + const text = new Text("Enter Confirm · Escape Cancel", 1, 1); text.onKey = (key) => { if (key === "return") done(true); diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index eaa26afc3..6e7afecdd 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -2,7 +2,7 @@ # Pi Packages -Pi packages bundle extensions, skills, prompt templates, and themes so you can share them through npm or git. A package can declare resources in `package.json` under the `pi` key, or use conventional directories. +Pi packages bundle extensions, skills, prompt templates, themes, and workflow definitions so you can share them through npm or git. A package can declare resources in `package.json` under the `pi` key, or use conventional directories. ## Table of Contents @@ -110,17 +110,18 @@ Local paths point to files or directories on disk and are added to settings with ## Creating a Pi Package -Add a `pi` manifest to `package.json` or use conventional directories. Include the `pi-package` keyword for discoverability. +Add an app manifest to `package.json` or use conventional directories. The manifest key is the configured app name (`atomic` here, from `atomicConfig.name`; legacy `piConfig.name` is also read). The legacy `pi` key remains supported as a backwards-compatible shim. Include the `pi-package` keyword for discoverability. ```json { "name": "my-package", "keywords": ["pi-package"], - "pi": { + "atomic": { "extensions": ["./extensions"], "skills": ["./skills"], "prompts": ["./prompts"], - "themes": ["./themes"] + "themes": ["./themes"], + "workflows": ["./workflows"] } } ``` @@ -135,7 +136,7 @@ The [package gallery](https://pi.dev/packages) displays packages tagged with `pi { "name": "my-package", "keywords": ["pi-package"], - "pi": { + "atomic": { "extensions": ["./extensions"], "video": "https://example.com/demo.mp4", "image": "https://example.com/screenshot.png" @@ -158,6 +159,7 @@ If no `pi` manifest is present, pi auto-discovers resources from these directori - `skills/` recursively finds `SKILL.md` folders and loads top-level `.md` files as skills - `prompts/` loads `.md` files - `themes/` loads `.json` files +- `workflows/` loads workflow SDK files (`.ts`, `.js`, `.mjs`, `.cjs`); `workflow/` is also accepted as a singular alias ## Dependencies @@ -175,7 +177,7 @@ Example: "shitty-extensions": "^1.0.1" }, "bundledDependencies": ["shitty-extensions"], - "pi": { + "atomic": { "extensions": ["extensions", "node_modules/shitty-extensions/extensions"], "skills": ["skills", "node_modules/shitty-extensions/skills"] } @@ -195,7 +197,8 @@ Filter what a package loads using the object form in settings: "extensions": ["extensions/*.ts", "!extensions/legacy.ts"], "skills": [], "prompts": ["prompts/review.md"], - "themes": ["+themes/legacy.json"] + "themes": ["+themes/legacy.json"], + "workflows": ["workflows/*.ts"] } ] } @@ -212,7 +215,7 @@ Filter what a package loads using the object form in settings: ## Enable and Disable Resources -Use `pi config` to enable or disable extensions, skills, prompt templates, and themes from installed packages and local directories. Works for both global (`~/.pi/agent`) and project (`.pi/`) scopes. +Use `pi config` to enable or disable extensions, skills, prompt templates, and themes from installed packages and local directories. Works for both global (`~/.pi/agent`) and project (`.pi/`) scopes. Workflow package filters can be configured in settings with `workflows` patterns. ## Scope and Deduplication diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index c60bfc4a9..ba0b88d4a 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -91,7 +91,7 @@ pi @README.md "Summarize this" pi @src/app.ts @src/app.test.ts "Review these together" ``` -Images can be pasted with Ctrl+V (Alt+V on Windows) or dragged into supported terminals. +Images can be pasted with CTRL+V (ALT+V on Windows) or dragged into supported terminals. ### Run shell commands @@ -105,7 +105,7 @@ The command output is sent to the model. Use `!!command` to run a command withou ### Switch models -Use `/model` or Ctrl+L to choose a model. Use Shift+Tab to cycle thinking level. Use Ctrl+P / Shift+Ctrl+P to cycle through scoped models. +Use `/model` or CTRL+L to choose a model. Use SHIFT+Tab to cycle thinking level. Use CTRL+P / SHIFT+CTRL+P to cycle through scoped models. ### Continue later diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index d0d9cd248..097cf9862 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -1400,7 +1400,7 @@ attachJsonlReader(agent.stdout, (line) => { // Send prompt agent.stdin.write(JSON.stringify({ type: "prompt", message: "Hello" }) + "\n"); -// Abort on Ctrl+C +// Abort on CTRL+C process.on("SIGINT", () => { agent.stdin.write(JSON.stringify({ type: "abort" }) + "\n"); }); diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index 005bed35f..c5d724e48 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -338,23 +338,25 @@ const { session } = await createAgentSession({ cwd: process.cwd(), // default // Global config directory - agentDir: "~/.pi/agent", // default (expands ~) + agentDir: "~/.atomic/agent", // default (expands ~) }); ``` +Atomic reads primary `.atomic` locations first and legacy `.pi` locations for compatibility when multiple config directories are supported. Passing an explicit `agentDir` makes that directory the user override. + `cwd` is used by `DefaultResourceLoader` for: -- Project extensions (`.pi/extensions/`) +- Project extensions (`.atomic/extensions/`, then legacy `.pi/extensions/`) - Project skills: - - `.pi/skills/` + - `.atomic/skills/`, then legacy `.pi/skills/` - `.agents/skills/` in `cwd` and ancestor directories (up to git repo root, or filesystem root when not in a repo) -- Project prompts (`.pi/prompts/`) +- Project prompts (`.atomic/prompts/`, then legacy `.pi/prompts/`) - Context files (`AGENTS.md` walking up from cwd) - Session directory naming `agentDir` is used by `DefaultResourceLoader` for: - Global extensions (`extensions/`) - Global skills: - - `skills/` under `agentDir` (for example `~/.pi/agent/skills/`) + - `skills/` under `agentDir` (for example `~/.atomic/agent/skills/`; legacy `~/.pi/agent/skills/` is also considered by default) - `~/.agents/skills/` - Global prompts (`prompts/`) - Global context file (`AGENTS.md`) @@ -389,7 +391,7 @@ const { session } = await createAgentSession({ model: opus, thinkingLevel: "medium", // off, minimal, low, medium, high, xhigh - // Models for cycling (Ctrl+P in interactive mode) + // Models for cycling (CTRL+P in interactive mode) scopedModels: [ { model: opus, thinkingLevel: "high" }, { model: haiku, thinkingLevel: "off" }, @@ -418,7 +420,8 @@ API key resolution priority (handled by AuthStorage): ```typescript import { AuthStorage, ModelRegistry } from "@bastani/atomic"; -// Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json +// Default: uses ~/.atomic/agent/auth.json and ~/.atomic/agent/models.json, +// with legacy ~/.pi/agent/* compatibility reads when available. const authStorage = AuthStorage.create(); const modelRegistry = ModelRegistry.create(authStorage); @@ -548,7 +551,7 @@ If you pass `tools`, include each custom or extension tool name you want enabled ### Extensions -Extensions are loaded by the `ResourceLoader`. `DefaultResourceLoader` discovers extensions from `~/.pi/agent/extensions/`, `.pi/extensions/`, and settings.json extension sources. +Extensions are loaded by the `ResourceLoader`. `DefaultResourceLoader` discovers extensions from `~/.atomic/agent/extensions/` and `.atomic/extensions/` first, then legacy `~/.pi/agent/extensions/` and `.pi/extensions/`, plus settings.json extension sources. ```typescript import { createAgentSession, DefaultResourceLoader } from "@bastani/atomic"; @@ -805,9 +808,9 @@ const { session } = await createAgentSession({ **Project-specific settings:** -Settings load from two locations and merge: -1. Global: `~/.pi/agent/settings.json` -2. Project: `/.pi/settings.json` +Settings load from Atomic-first locations and merge: +1. Global: `~/.atomic/agent/settings.json`, then legacy `~/.pi/agent/settings.json` +2. Project: `/.atomic/settings.json`, then legacy `/.pi/settings.json` Project overrides global. Nested objects merge keys. Setters modify global settings by default. diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md index d7c5ccf7f..2384837d1 100644 --- a/packages/coding-agent/docs/session-format.md +++ b/packages/coding-agent/docs/session-format.md @@ -14,7 +14,7 @@ Where `` is the working directory with `/` replaced by `-`. Sessions can be removed by deleting their `.jsonl` files under `~/.pi/agent/sessions/`. -Pi also supports deleting sessions interactively from `/resume` (select a session and press `Ctrl+D`, then confirm). When available, pi uses the `trash` CLI to avoid permanent deletion. +Pi also supports deleting sessions interactively from `/resume` (select a session and press `CTRL+D`, then confirm). When available, pi uses the `trash` CLI to avoid permanent deletion. ## Session Version diff --git a/packages/coding-agent/docs/sessions.md b/packages/coding-agent/docs/sessions.md index d262f8531..e1d02410c 100644 --- a/packages/coding-agent/docs/sessions.md +++ b/packages/coding-agent/docs/sessions.md @@ -40,11 +40,11 @@ For the JSONL file format and SessionManager API, see [Session Format](session-f In the picker you can: - search by typing -- toggle path display with Ctrl+P -- toggle sort mode with Ctrl+S -- filter to named sessions with Ctrl+N -- rename with Ctrl+R -- delete with Ctrl+D, then confirm +- toggle path display with CTRL+P +- toggle sort mode with CTRL+S +- filter to named sessions with CTRL+N +- rename with CTRL+R +- delete with CTRL+D, then confirm When available, pi uses the `trash` CLI for deletion instead of permanently removing files. @@ -82,12 +82,12 @@ Example shape: |-----|--------| | ↑/↓ | Navigate visible entries | | ←/→ | Page up/down | -| Ctrl+←/Ctrl+→ or Alt+←/Alt+→ | Fold/unfold or jump between branch segments | -| Shift+L | Set or clear a label on the selected entry | -| Shift+T | Toggle label timestamps | +| CTRL+←/CTRL+→ or ALT+←/ALT+→ | Fold/unfold or jump between branch segments | +| SHIFT+L | Set or clear a label on the selected entry | +| SHIFT+T | Toggle label timestamps | | Enter | Select entry | -| Escape/Ctrl+C | Cancel | -| Ctrl+O | Cycle filter mode | +| Escape/CTRL+C | Cancel | +| CTRL+O | Cycle filter mode | Filter modes are: default, no-tools, user-only, labeled-only, and all. Configure the default with `treeFilterMode` in [Settings](settings.md). diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index c8fbca9d9..098a34853 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -173,7 +173,7 @@ When multiple sources specify a session directory, precedence is `--session-dir` | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `enabledModels` | string[] | - | Model patterns for Ctrl+P cycling (same format as `--models` CLI flag) | +| `enabledModels` | string[] | - | Model patterns for CTRL+P cycling (same format as `--models` CLI flag) | ```json { diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index 74b0df2a9..b48be01e5 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -20,11 +20,11 @@ Older Claude Code versions may have added this Ghostty mapping: keybind = shift+enter=text:\n ``` -That mapping sends a raw linefeed byte. Inside pi, that is indistinguishable from `Ctrl+J`, so tmux and pi no longer see a real `shift+enter` key event. +That mapping sends a raw linefeed byte. Inside pi, that is indistinguishable from `CTRL+J`, so tmux and pi no longer see a real `shift+enter` key event. If Claude Code 2.x or newer is the only reason you added that mapping, you can remove it, unless you want to use Claude Code in tmux, where it still requires that Ghostty mapping. -If you want `Shift+Enter` to keep working in tmux via that remap, add `ctrl+j` to your pi `newLine` keybinding in `~/.pi/agent/keybindings.json`: +If you want `SHIFT+Enter` to keep working in tmux via that remap, add `ctrl+j` to your pi `newLine` keybinding in `~/.pi/agent/keybindings.json`: ```json { @@ -50,7 +50,7 @@ return config - Linux: `~/.config/Code/User/keybindings.json` - Windows: `%APPDATA%\\Code\\User\\keybindings.json` -Add to `keybindings.json` to enable `Shift+Enter` for multi-line input: +Add to `keybindings.json` to enable `SHIFT+Enter` for multi-line input: ```json { @@ -63,7 +63,7 @@ Add to `keybindings.json` to enable `Shift+Enter` for multi-line input: ## Windows Terminal -Add to `settings.json` (Ctrl+Shift+, or Settings → Open JSON file) to forward the modified Enter keys pi uses: +Add to `settings.json` (CTRL+SHIFT+, or Settings → Open JSON file) to forward the modified Enter keys pi uses: ```json { @@ -80,15 +80,15 @@ Add to `settings.json` (Ctrl+Shift+, or Settings → Open JSON file) to forward } ``` -- `Shift+Enter` inserts a new line. -- Windows Terminal binds `Alt+Enter` to fullscreen by default. That prevents pi from receiving `Alt+Enter` for follow-up queueing. -- Remapping `Alt+Enter` to `sendInput` forwards the real key chord to pi instead. +- `SHIFT+Enter` inserts a new line. +- Windows Terminal binds `ALT+Enter` to fullscreen by default. That prevents pi from receiving `ALT+Enter` for follow-up queueing. +- Remapping `ALT+Enter` to `sendInput` forwards the real key chord to pi instead. If you already have an `actions` array, add the objects to it. If the old fullscreen behavior persists, fully close and reopen Windows Terminal. ## xfce4-terminal, terminator -These terminals have limited escape sequence support. Modified Enter keys like `Ctrl+Enter` and `Shift+Enter` cannot be distinguished from plain `Enter`, preventing custom keybindings such as `submit: ["ctrl+enter"]` from working. +These terminals have limited escape sequence support. Modified Enter keys like `CTRL+Enter` and `SHIFT+Enter` cannot be distinguished from plain `Enter`, preventing custom keybindings such as `submit: ["ctrl+enter"]` from working. For the best experience, use a terminal that supports the Kitty keyboard protocol: - [Kitty](https://sw.kovidgoyal.net/kitty/) @@ -99,7 +99,7 @@ For the best experience, use a terminal that supports the Kitty keyboard protoco ## IntelliJ IDEA (Integrated Terminal) -The built-in terminal has limited escape sequence support. Shift+Enter cannot be distinguished from Enter in IntelliJ's terminal. +The built-in terminal has limited escape sequence support. SHIFT+Enter cannot be distinguished from Enter in IntelliJ's terminal. If you want the hardware cursor visible, set `PI_HARDWARE_CURSOR=1` before running pi (disabled by default for compatibility). diff --git a/packages/coding-agent/docs/tmux.md b/packages/coding-agent/docs/tmux.md index efe6ab571..759121d78 100644 --- a/packages/coding-agent/docs/tmux.md +++ b/packages/coding-agent/docs/tmux.md @@ -1,6 +1,6 @@ # tmux Setup -Pi works inside tmux, but tmux strips modifier information from certain keys by default. Without configuration, `Shift+Enter` and `Ctrl+Enter` are usually indistinguishable from plain `Enter`. +Pi works inside tmux, but tmux strips modifier information from certain keys by default. Without configuration, `SHIFT+Enter` and `CTRL+Enter` are usually indistinguishable from plain `Enter`. ## Recommended Configuration @@ -30,15 +30,15 @@ set -g extended-keys on tmux defaults to `extended-keys-format xterm`. When an application requests extended key reporting, modified keys are forwarded in xterm `modifyOtherKeys` format such as: -- `Ctrl+C` → `\x1b[27;5;99~` -- `Ctrl+D` → `\x1b[27;5;100~` -- `Ctrl+Enter` → `\x1b[27;5;13~` +- `CTRL+C` → `\x1b[27;5;99~` +- `CTRL+D` → `\x1b[27;5;100~` +- `CTRL+Enter` → `\x1b[27;5;13~` With `extended-keys-format csi-u`, the same keys are forwarded as: -- `Ctrl+C` → `\x1b[99;5u` -- `Ctrl+D` → `\x1b[100;5u` -- `Ctrl+Enter` → `\x1b[13;5u` +- `CTRL+C` → `\x1b[99;5u` +- `CTRL+D` → `\x1b[100;5u` +- `CTRL+Enter` → `\x1b[13;5u` Pi supports both formats, but `csi-u` is the recommended tmux setup. @@ -49,11 +49,11 @@ Without tmux extended keys, modified Enter keys collapse to legacy sequences: | Key | Without extkeys | With `csi-u` | |-----|-----------------|--------------| | Enter | `\r` | `\r` | -| Shift+Enter | `\r` | `\x1b[13;2u` | -| Ctrl+Enter | `\r` | `\x1b[13;5u` | +| SHIFT+Enter | `\r` | `\x1b[13;2u` | +| CTRL+Enter | `\r` | `\x1b[13;5u` | | Alt/Option+Enter | `\x1b\r` | `\x1b[13;3u` | -This affects the default keybindings (`Enter` to submit, `Shift+Enter` for newline) and any custom keybindings using modified Enter. +This affects the default keybindings (`Enter` to submit, `SHIFT+Enter` for newline) and any custom keybindings using modified Enter. ## Requirements diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index f5888d818..77a797a4d 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -274,7 +274,7 @@ handleInput(data: string) { } else if (matchesKey(data, Key.escape)) { this.onCancel?.(); } else if (matchesKey(data, Key.ctrl("c"))) { - // Ctrl+C + // CTRL+C } } ``` @@ -624,7 +624,7 @@ pi.registerCommand("pick", { container.addChild(selectList); // Help text - container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0)); + container.addChild(new Text(theme.fg("dim", "↑↓ Navigate • Enter Select • Escape Cancel"), 1, 0)); // Bottom border container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 487e72ac2..6e49ffd2e 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -21,11 +21,11 @@ The editor can be replaced temporarily by built-in UI such as `/settings` or by |---------|-----| | File reference | Type `@` to fuzzy-search project files | | Path completion | Press Tab to complete paths | -| Multi-line input | Shift+Enter, or Ctrl+Enter on Windows Terminal | -| Images | Paste with Ctrl+V, Alt+V on Windows, or drag into the terminal | +| Multi-line input | SHIFT+Enter, or CTRL+Enter on Windows Terminal | +| Images | Paste with CTRL+V, ALT+V on Windows, or drag into the terminal | | Shell command | `!command` runs and sends output to the model | | Hidden shell command | `!!command` runs without sending output to the model | -| External editor | Ctrl+G opens `$VISUAL` or `$EDITOR` | +| External editor | CTRL+G opens `$VISUAL` or `$EDITOR` | See [Keybindings](keybindings.md) for all shortcuts and customization. @@ -37,7 +37,7 @@ Type `/` in the editor to open command completion. Extensions can register custo |---------|-------------| | `/login`, `/logout` | Manage OAuth or API-key credentials | | `/model` | Switch models | -| `/scoped-models` | Enable/disable models for Ctrl+P cycling | +| `/scoped-models` | Enable/disable models for CTRL+P cycling | | `/settings` | Thinking level, theme, message delivery, transport | | `/resume` | Pick from previous sessions | | `/new` | Start a new session | @@ -60,11 +60,11 @@ Type `/` in the editor to open command completion. Extensions can register custo You can submit messages while the agent is still working: - **Enter** queues a steering message, delivered after the current assistant turn finishes executing its tool calls. -- **Alt+Enter** queues a follow-up message, delivered after the agent finishes all work. +- **ALT+Enter** queues a follow-up message, delivered after the agent finishes all work. - **Escape** aborts and restores queued messages to the editor. -- **Alt+Up** retrieves queued messages back to the editor. +- **ALT+Up** retrieves queued messages back to the editor. -On Windows Terminal, Alt+Enter is fullscreen by default. Remap it as described in [Terminal setup](terminal-setup.md) if you want pi to receive the shortcut. +On Windows Terminal, ALT+Enter is fullscreen by default. Remap it as described in [Terminal setup](terminal-setup.md) if you want pi to receive the shortcut. Configure delivery in [Settings](settings.md) with `steeringMode` and `followUpMode`. @@ -163,7 +163,7 @@ cat README.md | pi -p "Summarize this text" | `--model ` | Model pattern or ID; supports `provider/id` and optional `:` | | `--api-key ` | API key, overriding environment variables | | `--thinking ` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | -| `--models ` | Comma-separated patterns for Ctrl+P cycling | +| `--models ` | Comma-separated patterns for CTRL+P cycling | | `--list-models [search]` | List available models | ### Session Options @@ -266,7 +266,7 @@ pi --tools read,grep,find,ls -p "Review the code" | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | | `PI_TELEMETRY` | Override install/update telemetry: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks | | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache where supported | -| `VISUAL`, `EDITOR` | External editor for Ctrl+G | +| `VISUAL`, `EDITOR` | External editor for CTRL+G | ## Design Principles diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 88bf1fcb7..caae8db65 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -3,6 +3,11 @@ "version": "0.8.4", "description": "Atomic coding agent CLI with read, bash, edit, write tools and session management", "type": "module", + "atomicConfig": { + "name": "atomic", + "configDir": ".atomic", + "changelogUrl": "https://github.com/flora131/atomic/blob/main/packages/coding-agent/CHANGELOG.md" + }, "piConfig": { "name": "atomic", "configDir": ".atomic", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index df9521a12..45811ba22 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -236,7 +236,7 @@ ${chalk.bold("Options:")} --fork Fork specific session file or partial UUID into a new session --session-dir Directory for session storage and lookup --no-session Don't save session (ephemeral) - --models Comma-separated model patterns for Ctrl+P cycling + --models Comma-separated model patterns for ctrl+p cycling Supports globs (anthropic/*, *sonnet*) and fuzzy matching --no-tools, -nt Disable all tools by default (built-in and extension) --no-builtin-tools, -nbt Disable built-in tools by default but keep extension/custom tools enabled diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 73c04cd2c..a39a2c9ef 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -302,7 +302,7 @@ export function getUpdateInstruction(packageName: string): string { */ export function getPackageDir(): string { // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly). - // This runs before package.json/piConfig is read, so the env var name is hardcoded. + // This runs before package.json app config is read, so the env var name is hardcoded. const envDir = process.env.ATOMIC_PACKAGE_DIR ?? process.env.PI_PACKAGE_DIR; if (envDir) { if (envDir === "~") return homedir(); @@ -403,31 +403,49 @@ export function getBundledInteractiveAssetPath(name: string): string { } // ============================================================================= -// App Config (from package.json piConfig) +// App Config (from package.json Config, with piConfig as a legacy shim) // ============================================================================= -interface PackageJson { +interface AppConfig { + name?: string; + configDir?: string; + changelogUrl?: string; +} + +interface PackageJson extends Record { name?: string; version?: string; - piConfig?: { - name?: string; - configDir?: string; - changelogUrl?: string; - }; + piConfig?: AppConfig; } const pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson; -const piConfigName: string | undefined = pkg.piConfig?.name; +function appNameFromPackageName(packageName: string | undefined): string | undefined { + const localName = packageName?.split("/").pop()?.trim(); + return localName && localName.length > 0 ? localName : undefined; +} + +function readAppConfig(packageJson: PackageJson, appName: string | undefined): AppConfig | undefined { + if (appName) { + const appConfig = packageJson[`${appName}Config`]; + if (appConfig && typeof appConfig === "object" && !Array.isArray(appConfig)) { + return appConfig as AppConfig; + } + } + return packageJson.piConfig; +} + export const PACKAGE_NAME: string = pkg.name || "@bastani/atomic"; -export const APP_NAME: string = piConfigName || "pi"; -export const APP_TITLE: string = piConfigName ? APP_NAME : "π"; -export const CONFIG_DIR_NAME: string = pkg.piConfig?.configDir || ".pi"; +const packageAppName = appNameFromPackageName(PACKAGE_NAME); +const appConfig = readAppConfig(pkg, packageAppName); +export const APP_NAME: string = appConfig?.name || packageAppName || "pi"; +export const APP_TITLE: string = appConfig?.name !== undefined || APP_NAME !== "pi" ? APP_NAME : "π"; +export const CONFIG_DIR_NAME: string = appConfig?.configDir || (APP_NAME === "pi" ? ".pi" : `.${APP_NAME}`); export const LEGACY_CONFIG_DIR_NAME = ".pi"; export const CONFIG_DIR_NAMES: readonly string[] = CONFIG_DIR_NAME === LEGACY_CONFIG_DIR_NAME ? [CONFIG_DIR_NAME] : [CONFIG_DIR_NAME, LEGACY_CONFIG_DIR_NAME]; export const VERSION: string = pkg.version || "0.0.0"; -export const CHANGELOG_URL: string | undefined = pkg.piConfig?.changelogUrl?.trim() || undefined; +export const CHANGELOG_URL: string | undefined = appConfig?.changelogUrl?.trim() || undefined; const ENV_PREFIX = APP_NAME.toUpperCase(); export const LEGACY_ENV_PREFIX = "PI"; diff --git a/packages/coding-agent/src/core/agent-session-services.ts b/packages/coding-agent/src/core/agent-session-services.ts index 061399edb..810ffbf5c 100644 --- a/packages/coding-agent/src/core/agent-session-services.ts +++ b/packages/coding-agent/src/core/agent-session-services.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "@earendil-works/pi-ai"; import { getAgentDir } from "../config.js"; import { AuthStorage } from "./auth-storage.js"; import type { SessionStartEvent, ToolDefinition } from "./extensions/index.js"; @@ -49,9 +49,9 @@ export interface CreateAgentSessionFromServicesOptions { services: AgentSessionServices; sessionManager: SessionManager; sessionStartEvent?: SessionStartEvent; - model?: Model; + model?: Model; thinkingLevel?: ThinkingLevel; - scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; tools?: string[]; noTools?: CreateAgentSessionOptions["noTools"]; customTools?: ToolDefinition[]; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 9282f08d9..e387fdc6b 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -23,7 +23,7 @@ import type { AgentTool, ThinkingLevel, } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai"; +import type { Api, AssistantMessage, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai"; import { clampThinkingLevel, cleanupSessionResources, @@ -172,7 +172,7 @@ export interface AgentSessionConfig { settingsManager: SettingsManager; cwd: string; /** Models to cycle through with Ctrl+P (from --models flag) */ - scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; /** Resource loader for skills, prompts, themes, context files, system prompt */ resourceLoader: ResourceLoader; /** SDK custom tools registered outside extensions */ @@ -219,7 +219,7 @@ export interface PromptOptions { /** Result from cycleModel() */ export interface ModelCycleResult { - model: Model; + model: Model; thinkingLevel: ThinkingLevel; /** Whether cycling through scoped models (--models flag) or all available */ isScoped: boolean; @@ -266,7 +266,7 @@ export class AgentSession { readonly sessionManager: SessionManager; readonly settingsManager: SettingsManager; - private _scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + private _scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; // Event subscription state private _unsubscribeAgent?: () => void; @@ -361,7 +361,7 @@ export class AgentSession { return this._modelRegistry; } - private async _getRequiredRequestAuth(model: Model): Promise<{ + private async _getRequiredRequestAuth(model: Model): Promise<{ apiKey: string; headers?: Record; }> { @@ -786,7 +786,7 @@ export class AgentSession { } /** Current model (may be undefined if not yet selected) */ - get model(): Model | undefined { + get model(): Model | undefined { return this.agent.state.model; } @@ -897,12 +897,12 @@ export class AgentSession { } /** Scoped models for cycling (from --models flag) */ - get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }> { + get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }> { return this._scopedModels; } /** Update scoped models for cycling */ - setScopedModels(scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>): void { + setScopedModels(scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>): void { this._scopedModels = scopedModels; } @@ -1421,8 +1421,8 @@ export class AgentSession { // ========================================================================= private async _emitModelSelect( - nextModel: Model, - previousModel: Model | undefined, + nextModel: Model, + previousModel: Model | undefined, source: "set" | "cycle" | "restore", ): Promise { if (modelsAreEqual(previousModel, nextModel)) return; @@ -1439,7 +1439,7 @@ export class AgentSession { * Validates that auth is configured, saves to session and settings. * @throws Error if no auth is configured for the model */ - async setModel(model: Model): Promise { + async setModel(model: Model): Promise { if (!this._modelRegistry.hasConfiguredAuth(model)) { throw new Error(`No API key for ${model.provider}/${model.id}`); } diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 69925ddab..19e719d0b 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -6,7 +6,7 @@ */ import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; import { convertToLlm, @@ -64,7 +64,7 @@ export interface CollectEntriesResult { export interface GenerateBranchSummaryOptions { /** Model to use for summarization */ - model: Model; + model: Model; /** API key for the model */ apiKey: string; /** Request headers for the model */ diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 5075e1249..b446b9ab6 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -6,7 +6,7 @@ */ import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { AssistantMessage, Model, Usage } from "@earendil-works/pi-ai"; +import type { Api, AssistantMessage, Model, Usage } from "@earendil-works/pi-ai"; import { completeSimple } from "@earendil-works/pi-ai"; import { convertToLlm, @@ -529,7 +529,7 @@ Keep each section concise. Preserve exact file paths, function names, and error */ export async function generateSummary( currentMessages: AgentMessage[], - model: Model, + model: Model, reserveTokens: number, apiKey: string, headers?: Record, @@ -719,7 +719,7 @@ Be concise. Focus on what's needed to understand the kept suffix.`; */ export async function compact( preparation: CompactionPreparation, - model: Model, + model: Model, apiKey: string, headers?: Record, customInstructions?: string, @@ -804,7 +804,7 @@ export async function compact( */ async function generateTurnPrefixSummary( messages: AgentMessage[], - model: Model, + model: Model, reserveTokens: number, apiKey: string, headers?: Record, diff --git a/packages/coding-agent/src/core/export-html/tool-renderer.ts b/packages/coding-agent/src/core/export-html/tool-renderer.ts index ee4fcb865..17e1216e3 100644 --- a/packages/coding-agent/src/core/export-html/tool-renderer.ts +++ b/packages/coding-agent/src/core/export-html/tool-renderer.ts @@ -60,10 +60,10 @@ export function createToolHtmlRenderer(deps: ToolHtmlRendererDeps): ToolHtmlRend const renderedCallComponents = new Map(); const renderedResultComponents = new Map(); - const renderedStates = new Map(); + const renderedStates = new Map>(); const renderedArgs = new Map(); - const getState = (toolCallId: string): any => { + const getState = (toolCallId: string): Record => { let state = renderedStates.get(toolCallId); if (!state) { state = {}; diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index d485852e9..88acc7112 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -20,13 +20,14 @@ import { createJiti } from "jiti/static"; import * as _bundledTypebox from "typebox"; import * as _bundledTypeboxCompile from "typebox/compile"; import * as _bundledTypeboxValue from "typebox/value"; -import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.js"; +import { APP_NAME, CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.js"; // NOTE: This import works because loader.ts exports are NOT re-exported from index.ts, // avoiding a circular dependency. Extensions can import from the Atomic package // name (or upstream-compatible pi package names). import * as _bundledPiCodingAgent from "../../index.js"; import { createEventBus, type EventBus } from "../event-bus.js"; import type { ExecOptions } from "../exec.js"; +import type { ResolvedResource } from "../package-manager.js"; import { execCommand } from "../exec.js"; import { createSyntheticSourceInfo } from "../source-info.js"; import type { @@ -203,6 +204,7 @@ function createExtensionAPI( runtime: ExtensionRuntime, cwd: string, eventBus: EventBus, + workflowResources: ResolvedResource[] = [], ): ExtensionAPI { const api = { // Registration methods - write to extension @@ -265,6 +267,11 @@ function createExtensionAPI( return runtime.flagValues.get(name); }, + getWorkflowResources(): ResolvedResource[] { + runtime.assertActive(); + return [...workflowResources]; + }, + // Action methods - delegate to shared runtime sendMessage(message, options): void { runtime.assertActive(); @@ -394,6 +401,7 @@ async function loadExtension( cwd: string, eventBus: EventBus, runtime: ExtensionRuntime, + workflowResources: ResolvedResource[] = [], ): Promise<{ extension: Extension | null; error: string | null }> { const resolvedPath = resolvePath(extensionPath, cwd); @@ -404,7 +412,7 @@ async function loadExtension( } const extension = createExtension(extensionPath, resolvedPath); - const api = createExtensionAPI(extension, runtime, cwd, eventBus); + const api = createExtensionAPI(extension, runtime, cwd, eventBus, workflowResources); await factory(api); return { extension, error: null }; @@ -423,9 +431,10 @@ export async function loadExtensionFromFactory( eventBus: EventBus, runtime: ExtensionRuntime, extensionPath = "", + workflowResources: ResolvedResource[] = [], ): Promise { const extension = createExtension(extensionPath, extensionPath); - const api = createExtensionAPI(extension, runtime, cwd, eventBus); + const api = createExtensionAPI(extension, runtime, cwd, eventBus, workflowResources); await factory(api); return extension; } @@ -433,14 +442,19 @@ export async function loadExtensionFromFactory( /** * Load extensions from paths. */ -export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise { +export async function loadExtensions( + paths: string[], + cwd: string, + eventBus?: EventBus, + workflowResources: ResolvedResource[] = [], +): Promise { const extensions: Extension[] = []; const errors: Array<{ path: string; error: string }> = []; const resolvedEventBus = eventBus ?? createEventBus(); const runtime = createExtensionRuntime(); for (const extPath of paths) { - const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime); + const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime, workflowResources); if (error) { errors.push({ path: extPath, error }); @@ -466,14 +480,23 @@ interface PiManifest { prompts?: string[]; } +function manifestFromPackageJson(pkg: Record): PiManifest | null { + const appManifest = pkg[APP_NAME]; + if (appManifest && typeof appManifest === "object" && !Array.isArray(appManifest)) { + return appManifest as PiManifest; + } + const legacyManifest = pkg.pi; + if (legacyManifest && typeof legacyManifest === "object" && !Array.isArray(legacyManifest)) { + return legacyManifest as PiManifest; + } + return null; +} + function readPiManifest(packageJsonPath: string): PiManifest | null { try { const content = fs.readFileSync(packageJsonPath, "utf-8"); - const pkg = JSON.parse(content); - if (pkg.pi && typeof pkg.pi === "object") { - return pkg.pi as PiManifest; - } - return null; + const pkg = JSON.parse(content) as Record; + return manifestFromPackageJson(pkg); } catch { return null; } diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 686e77738..a43c1e6ff 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -3,7 +3,7 @@ */ import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { ImageContent, Model } from "@earendil-works/pi-ai"; +import type { Api, ImageContent, Model } from "@earendil-works/pi-ai"; import type { KeyId } from "@earendil-works/pi-tui"; import { type Theme, theme } from "../../modes/interactive/theme/theme.js"; import type { ResourceDiagnostic } from "../diagnostics.js"; @@ -211,6 +211,12 @@ const noOpUIContext: ExtensionUIContext = { addAutocompleteProvider: () => {}, setEditorComponent: () => {}, getEditorComponent: () => undefined, + getFooterDataProvider: () => ({ + getGitBranch: () => null, + getExtensionStatuses: () => new Map(), + getAvailableProviderCount: () => 1, + onBranchChange: () => () => {}, + }), get theme() { return theme; }, @@ -238,7 +244,7 @@ export class ExtensionRunner { private sessionManager: SessionManager; private modelRegistry: ModelRegistry; private errorListeners: Set = new Set(); - private getModel: () => Model | undefined = () => undefined; + private getModel: () => Model | undefined = () => undefined; private isIdleFn: () => boolean = () => true; private getSignalFn: () => AbortSignal | undefined = () => undefined; private waitForIdleFn: () => Promise = async () => {}; diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 87c39b5cd..b33042ccc 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -41,6 +41,7 @@ import type { } from "@earendil-works/pi-tui"; import type { Static, TSchema } from "typebox"; import type { Theme } from "../../modes/interactive/theme/theme.js"; +import type { ResolvedResource } from "../package-manager.js"; import type { BashResult } from "../bash-executor.js"; import type { CompactionPreparation, CompactionResult } from "../compaction/index.js"; import type { EventBus } from "../event-bus.js"; @@ -265,6 +266,9 @@ export interface ExtensionUIContext { /** Get the currently configured custom editor factory, or undefined when using the default editor. */ getEditorComponent(): EditorFactory | undefined; + /** Get the built-in footer data provider so embedded extension UIs can reuse the core footer. */ + getFooterDataProvider(): ReadonlyFooterDataProvider; + /** Get the current theme for styling. */ readonly theme: Theme; @@ -320,7 +324,7 @@ export interface ExtensionContext { /** Model registry for API key resolution */ modelRegistry: ModelRegistry; /** Current model (may be undefined) */ - model: Model | undefined; + model: Model | undefined; /** Whether the agent is idle (not streaming) */ isIdle(): boolean; /** The current abort signal, or undefined when the agent is not streaming. */ @@ -406,7 +410,7 @@ export interface ToolRenderResultOptions { } /** Context passed to tool renderers. */ -export interface ToolRenderContext { +export interface ToolRenderContext { /** Current tool call arguments. Shared across call/result renders for the same tool call. */ args: TArgs; /** Unique id for this tool execution. Stable across call/result renders for the same tool call. */ @@ -433,10 +437,23 @@ export interface ToolRenderContext { isError: boolean; } +type ToolRenderCall = { + bivarianceHack(args: Static, theme: Theme, context: ToolRenderContext>): Component; +}["bivarianceHack"]; + +type ToolRenderResult = { + bivarianceHack( + result: AgentToolResult, + options: ToolRenderResultOptions, + theme: Theme, + context: ToolRenderContext>, + ): Component; +}["bivarianceHack"]; + /** * Tool definition for registerTool(). */ -export interface ToolDefinition { +export interface ToolDefinition { /** Tool name (used in LLM tool calls) */ name: string; /** Human-readable label for UI */ @@ -474,18 +491,13 @@ export interface ToolDefinition>; /** Custom rendering for tool call display */ - renderCall?: (args: Static, theme: Theme, context: ToolRenderContext>) => Component; + renderCall?: ToolRenderCall; /** Custom rendering for tool result display */ - renderResult?: ( - result: AgentToolResult, - options: ToolRenderResultOptions, - theme: Theme, - context: ToolRenderContext>, - ) => Component; + renderResult?: ToolRenderResult; } -type AnyToolDefinition = ToolDefinition; +type AnyToolDefinition = ToolDefinition; /** * Preserve parameter inference for standalone tool definitions. @@ -494,7 +506,7 @@ type AnyToolDefinition = ToolDefinition; * as `customTools`, where contextual typing would otherwise widen params to * `unknown`. */ -export function defineTool( +export function defineTool( tool: ToolDefinition, ): ToolDefinition & AnyToolDefinition { return tool as ToolDefinition & AnyToolDefinition; @@ -696,7 +708,7 @@ export interface ToolExecutionStartEvent { type: "tool_execution_start"; toolCallId: string; toolName: string; - args: any; + args: unknown; } /** Fired during tool execution with partial/streaming output */ @@ -704,8 +716,8 @@ export interface ToolExecutionUpdateEvent { type: "tool_execution_update"; toolCallId: string; toolName: string; - args: any; - partialResult: any; + args: unknown; + partialResult: unknown; } /** Fired when a tool finishes executing */ @@ -713,7 +725,7 @@ export interface ToolExecutionEndEvent { type: "tool_execution_end"; toolCallId: string; toolName: string; - result: any; + result: unknown; isError: boolean; } @@ -726,8 +738,8 @@ export type ModelSelectSource = "set" | "cycle" | "restore"; /** Fired when a new model is selected */ export interface ModelSelectEvent { type: "model_select"; - model: Model; - previousModel: Model | undefined; + model: Model; + previousModel: Model | undefined; source: ModelSelectSource; } @@ -1143,7 +1155,7 @@ export interface ExtensionAPI { // ========================================================================= /** Register a tool that the LLM can call. */ - registerTool( + registerTool( tool: ToolDefinition, ): void; @@ -1176,6 +1188,9 @@ export interface ExtensionAPI { /** Get the value of a registered CLI flag. */ getFlag(name: string): boolean | string | undefined; + /** Return package-provided workflow files discovered for this session. */ + getWorkflowResources(): ResolvedResource[]; + // ========================================================================= // Message Rendering // ========================================================================= @@ -1238,7 +1253,7 @@ export interface ExtensionAPI { // ========================================================================= /** Set the current model. Returns false if no API key available. */ - setModel(model: Model): Promise; + setModel(model: Model): Promise; /** Get current thinking level. */ getThinkingLevel(): ThinkingLevel; @@ -1448,7 +1463,7 @@ export type SetActiveToolsHandler = (toolNames: string[]) => void; export type RefreshToolsHandler = () => void; -export type SetModelHandler = (model: Model) => Promise; +export type SetModelHandler = (model: Model) => Promise; export type GetThinkingLevelHandler = () => ThinkingLevel; @@ -1504,7 +1519,7 @@ export interface ExtensionActions { * Required by all modes. */ export interface ExtensionContextActions { - getModel: () => Model | undefined; + getModel: () => Model | undefined; isIdle: () => boolean; getSignal: () => AbortSignal | undefined; abort: () => void; diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 7fe1dd6a1..e047cf281 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -27,7 +27,7 @@ import type { Readable } from "node:stream"; import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; -import { CONFIG_DIR_NAME, ENV_OFFLINE, getAgentDir, getAgentDirs, getEnvValue, getProjectConfigDirs } from "../config.js"; +import { APP_NAME, CONFIG_DIR_NAME, ENV_OFFLINE, getAgentDir, getAgentDirs, getEnvValue, getProjectConfigDirs } from "../config.js"; import { shouldUseWindowsShell } from "../utils/child-process.js"; import { type GitSource, parseGitUrl } from "../utils/git.js"; import { canonicalizePath, isLocalPath } from "../utils/paths.js"; @@ -62,6 +62,7 @@ export interface ResolvedPaths { skills: ResolvedResource[]; prompts: ResolvedResource[]; themes: ResolvedResource[]; + workflows: ResolvedResource[]; } export type MissingSourceAction = "install" | "skip" | "error"; @@ -149,6 +150,8 @@ interface PiManifest { skills?: string[]; prompts?: string[]; themes?: string[]; + workflows?: string[]; + workflow?: string[]; } interface ResourceAccumulator { @@ -156,6 +159,7 @@ interface ResourceAccumulator { skills: Map; prompts: Map; themes: Map; + workflows: Map; } /** @@ -181,17 +185,19 @@ interface PackageFilter { skills?: string[]; prompts?: string[]; themes?: string[]; + workflows?: string[]; } -type ResourceType = "extensions" | "skills" | "prompts" | "themes"; +type ResourceType = "extensions" | "skills" | "prompts" | "themes" | "workflows"; -const RESOURCE_TYPES: ResourceType[] = ["extensions", "skills", "prompts", "themes"]; +const RESOURCE_TYPES: ResourceType[] = ["extensions", "skills", "prompts", "themes", "workflows"]; const FILE_PATTERNS: Record = { extensions: /\.(ts|js)$/, skills: /\.md$/, prompts: /\.md$/, themes: /\.json$/, + workflows: /\.(ts|js|mjs|cjs)$/, }; const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; @@ -514,11 +520,23 @@ function collectAutoThemeEntries(dir: string): string[] { return entries; } +function getManifestFromPackageJson(pkg: Record): PiManifest | null { + const appManifest = pkg[APP_NAME]; + if (appManifest && typeof appManifest === "object" && !Array.isArray(appManifest)) { + return appManifest as PiManifest; + } + const legacyManifest = pkg.pi; + if (legacyManifest && typeof legacyManifest === "object" && !Array.isArray(legacyManifest)) { + return legacyManifest as PiManifest; + } + return null; +} + function readPiManifestFile(packageJsonPath: string): PiManifest | null { try { const content = readFileSync(packageJsonPath, "utf-8"); - const pkg = JSON.parse(content) as { pi?: PiManifest }; - return pkg.pi ?? null; + const pkg = JSON.parse(content) as Record; + return getManifestFromPackageJson(pkg); } catch { return null; } @@ -622,6 +640,19 @@ function collectResourceFiles(dir: string, resourceType: ResourceType): string[] return collectFiles(dir, FILE_PATTERNS[resourceType]); } +function conventionDirsForResource(packageRoot: string, resourceType: ResourceType): string[] { + if (resourceType === "workflows") { + return [join(packageRoot, "workflows"), join(packageRoot, "workflow")]; + } + return [join(packageRoot, resourceType)]; +} + +function manifestEntriesForResource(manifest: PiManifest | null, resourceType: ResourceType): string[] | undefined { + if (!manifest) return undefined; + if (resourceType === "workflows") return manifest.workflows ?? manifest.workflow; + return manifest[resourceType]; +} + function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean { const rel = toPosixPath(relative(baseDir, filePath)); const name = basename(filePath); @@ -1854,7 +1885,7 @@ export class DefaultPackageManager implements PackageManager { this.ensureGitIgnore(installRoot); const packageJsonPath = join(installRoot, "package.json"); if (!existsSync(packageJsonPath)) { - const pkgJson = { name: "pi-extensions", private: true }; + const pkgJson = { name: `${APP_NAME}-extensions`, private: true }; writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8"); } } @@ -1986,28 +2017,40 @@ export class DefaultPackageManager implements PackageManager { const manifest = this.readPiManifest(packageRoot); if (manifest) { for (const resourceType of RESOURCE_TYPES) { - const entries = manifest[resourceType as keyof PiManifest]; - this.addManifestEntries( - entries, - packageRoot, - resourceType, - this.getTargetMap(accumulator, resourceType), - metadata, - ); + const entries = manifestEntriesForResource(manifest, resourceType); + if (entries !== undefined) { + this.addManifestEntries( + entries, + packageRoot, + resourceType, + this.getTargetMap(accumulator, resourceType), + metadata, + ); + continue; + } + if (resourceType === "workflows") { + this.collectDefaultResources( + packageRoot, + resourceType, + this.getTargetMap(accumulator, resourceType), + metadata, + ); + } } return true; } let hasAnyDir = false; for (const resourceType of RESOURCE_TYPES) { - const dir = join(packageRoot, resourceType); - if (existsSync(dir)) { - // Collect all files from the directory (all enabled by default) - const files = collectResourceFiles(dir, resourceType); - for (const f of files) { - this.addResource(this.getTargetMap(accumulator, resourceType), f, metadata, true); + for (const dir of conventionDirsForResource(packageRoot, resourceType)) { + if (existsSync(dir)) { + // Collect all files from the directory (all enabled by default) + const files = collectResourceFiles(dir, resourceType); + for (const f of files) { + this.addResource(this.getTargetMap(accumulator, resourceType), f, metadata, true); + } + hasAnyDir = true; } - hasAnyDir = true; } } return hasAnyDir; @@ -2020,17 +2063,18 @@ export class DefaultPackageManager implements PackageManager { metadata: PathMetadata, ): void { const manifest = this.readPiManifest(packageRoot); - const entries = manifest?.[resourceType as keyof PiManifest]; - if (entries) { + const entries = manifestEntriesForResource(manifest, resourceType); + if (entries !== undefined) { this.addManifestEntries(entries, packageRoot, resourceType, target, metadata); return; } - const dir = join(packageRoot, resourceType); - if (existsSync(dir)) { - // Collect all files from the directory (all enabled by default) - const files = collectResourceFiles(dir, resourceType); - for (const f of files) { - this.addResource(target, f, metadata, true); + for (const dir of conventionDirsForResource(packageRoot, resourceType)) { + if (existsSync(dir)) { + // Collect all files from the directory (all enabled by default) + const files = collectResourceFiles(dir, resourceType); + for (const f of files) { + this.addResource(target, f, metadata, true); + } } } } @@ -2071,7 +2115,7 @@ export class DefaultPackageManager implements PackageManager { resourceType: ResourceType, ): { allFiles: string[]; enabledByManifest: Set } { const manifest = this.readPiManifest(packageRoot); - const entries = manifest?.[resourceType as keyof PiManifest]; + const entries = manifestEntriesForResource(manifest, resourceType); if (entries && entries.length > 0) { const allFiles = this.collectFilesFromManifestEntries(entries, packageRoot, resourceType); const manifestPatterns = entries.filter(isOverridePattern); @@ -2080,11 +2124,9 @@ export class DefaultPackageManager implements PackageManager { return { allFiles: Array.from(enabledByManifest), enabledByManifest }; } - const conventionDir = join(packageRoot, resourceType); - if (!existsSync(conventionDir)) { - return { allFiles: [], enabledByManifest: new Set() }; - } - const allFiles = collectResourceFiles(conventionDir, resourceType); + const allFiles = conventionDirsForResource(packageRoot, resourceType).flatMap((dir) => + existsSync(dir) ? collectResourceFiles(dir, resourceType) : [], + ); return { allFiles, enabledByManifest: new Set(allFiles) }; } @@ -2096,8 +2138,8 @@ export class DefaultPackageManager implements PackageManager { try { const content = readFileSync(packageJsonPath, "utf-8"); - const pkg = JSON.parse(content) as { pi?: PiManifest }; - return pkg.pi ?? null; + const pkg = JSON.parse(content) as Record; + return getManifestFromPackageJson(pkg); } catch { return null; } @@ -2188,12 +2230,14 @@ export class DefaultPackageManager implements PackageManager { skills: (globalSettings.skills ?? []) as string[], prompts: (globalSettings.prompts ?? []) as string[], themes: (globalSettings.themes ?? []) as string[], + workflows: (globalSettings.workflows ?? []) as string[], }; const projectOverrides = { extensions: (projectSettings.extensions ?? []) as string[], skills: (projectSettings.skills ?? []) as string[], prompts: (projectSettings.prompts ?? []) as string[], themes: (projectSettings.themes ?? []) as string[], + workflows: (projectSettings.workflows ?? []) as string[], }; const userConfigDirs = this.getBaseDirsForScope("user"); @@ -2247,6 +2291,13 @@ export class DefaultPackageManager implements PackageManager { projectOverrides.themes, configDir, ); + addResources( + "workflows", + collectResourceFiles(join(configDir, "workflows"), "workflows"), + metadata, + projectOverrides.workflows, + configDir, + ); } // Project skills from .agents/ (each with its own baseDir) @@ -2295,6 +2346,13 @@ export class DefaultPackageManager implements PackageManager { userOverrides.themes, configDir, ); + addResources( + "workflows", + collectResourceFiles(join(configDir, "workflows"), "workflows"), + metadata, + userOverrides.workflows, + configDir, + ); } // User skills from ~/.agents/ (with its own baseDir) @@ -2345,6 +2403,8 @@ export class DefaultPackageManager implements PackageManager { return accumulator.prompts; case "themes": return accumulator.themes; + case "workflows": + return accumulator.workflows; default: throw new Error(`Unknown resource type: ${resourceType}`); } @@ -2368,6 +2428,7 @@ export class DefaultPackageManager implements PackageManager { skills: new Map(), prompts: new Map(), themes: new Map(), + workflows: new Map(), }; } @@ -2396,6 +2457,7 @@ export class DefaultPackageManager implements PackageManager { skills: mapToResolved(accumulator.skills), prompts: mapToResolved(accumulator.prompts), themes: mapToResolved(accumulator.themes), + workflows: mapToResolved(accumulator.workflows), }; } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index 960992d82..bfb79bf48 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -12,7 +12,7 @@ import { canonicalizePath, isLocalPath } from "../utils/paths.js"; import { createEventBus, type EventBus } from "./event-bus.js"; import { createExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./extensions/loader.js"; import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.js"; -import { DefaultPackageManager, type PathMetadata } from "./package-manager.js"; +import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.js"; import type { PromptTemplate } from "./prompt-templates.js"; import { loadPromptTemplates } from "./prompt-templates.js"; import { SettingsManager } from "./settings-manager.js"; @@ -336,7 +336,7 @@ export class DefaultResourceLoader implements ResourceLoader { const builtinPackagePaths = this.builtinPackagePaths.length > 0 ? await this.packageManager.resolveExtensionSources(this.builtinPackagePaths, { temporary: true }) - : { extensions: [], skills: [], prompts: [], themes: [] }; + : { extensions: [], skills: [], prompts: [], themes: [], workflows: [] }; const metadataByPath = new Map(); this.extensionSkillSourceInfos = new Map(); @@ -362,6 +362,7 @@ export class DefaultResourceLoader implements ResourceLoader { const enabledSkillResources = getEnabledResources(resolvedPaths.skills); const enabledPrompts = getEnabledPaths(resolvedPaths.prompts); const enabledThemes = getEnabledPaths(resolvedPaths.themes); + const enabledWorkflowResources = getEnabledResources(resolvedPaths.workflows); const builtinEnabledExtensions = this.noExtensions ? [] @@ -369,6 +370,7 @@ export class DefaultResourceLoader implements ResourceLoader { const builtinEnabledSkillResources = this.noSkills ? [] : getEnabledResources(builtinPackagePaths.skills); const builtinEnabledPrompts = this.noPromptTemplates ? [] : getEnabledPaths(builtinPackagePaths.prompts); const builtinEnabledThemes = this.noThemes ? [] : getEnabledPaths(builtinPackagePaths.themes); + const builtinEnabledWorkflowResources = getEnabledResources(builtinPackagePaths.workflows); const mapSkillPath = (resource: { path: string; metadata: PathMetadata }): string => { if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") { @@ -411,13 +413,19 @@ export class DefaultResourceLoader implements ResourceLoader { const cliEnabledSkills = getEnabledPaths(cliExtensionPaths.skills); const cliEnabledPrompts = getEnabledPaths(cliExtensionPaths.prompts); const cliEnabledThemes = getEnabledPaths(cliExtensionPaths.themes); + const cliEnabledWorkflowResources = getEnabledResources(cliExtensionPaths.workflows); + const workflowResources = [ + ...cliEnabledWorkflowResources, + ...enabledWorkflowResources, + ...builtinEnabledWorkflowResources, + ].filter((resource) => resource.metadata.origin === "package"); const extensionPaths = this.noExtensions ? cliEnabledExtensions : this.mergePaths(cliEnabledExtensions, [...enabledExtensions, ...builtinEnabledExtensions]); - const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus); - const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); + const extensionsResult = await loadExtensions(extensionPaths, this.cwd, this.eventBus, workflowResources); + const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime, workflowResources); extensionsResult.extensions.push(...inlineExtensions.extensions); extensionsResult.errors.push(...inlineExtensions.errors); @@ -799,7 +807,7 @@ export class DefaultResourceLoader implements ResourceLoader { } } - private async loadExtensionFactories(runtime: ExtensionRuntime): Promise<{ + private async loadExtensionFactories(runtime: ExtensionRuntime, workflowResources: ResolvedResource[]): Promise<{ extensions: Extension[]; errors: Array<{ path: string; error: string }>; }> { @@ -809,7 +817,7 @@ export class DefaultResourceLoader implements ResourceLoader { for (const [index, factory] of this.extensionFactories.entries()) { const extensionPath = ``; try { - const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath); + const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath, workflowResources); extensions.push(extension); } catch (error) { const message = error instanceof Error ? error.message : "failed to load extension"; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index e35640904..d2c97f6d1 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -6,6 +6,7 @@ import { } from "@earendil-works/pi-agent-core"; import { clampThinkingLevel, + type Api, type Message, type Model, streamSimple, @@ -56,11 +57,11 @@ export interface CreateAgentSessionOptions { modelRegistry?: ModelRegistry; /** Model to use. Default: from settings, else first available */ - model?: Model; + model?: Model; /** Thinking level. Default: from settings, else 'medium' (clamped to model capabilities) */ thinkingLevel?: ThinkingLevel; /** Models available for cycling (Ctrl+P in interactive mode) */ - scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; /** * Optional default tool suppression mode when no explicit allowlist is provided. @@ -150,7 +151,7 @@ function isHostOrSubdomain(rawUrl: string, host: string): boolean { } function getAttributionHeaders( - model: Model, + model: Model, settingsManager: SettingsManager, ): Record | undefined { if (!isInstallTelemetryEnabled(settingsManager)) { diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 19371b27d..d9146093a 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -471,7 +471,7 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] { // Validate session header if (entries.length === 0) return entries; const header = entries[0]; - if (header.type !== "session" || typeof (header as any).id !== "string") { + if (header.type !== "session" || !("id" in header) || typeof header.id !== "string") { return []; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 3b65b40ff..bd2d994fa 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -79,6 +79,7 @@ export type PackageSource = skills?: string[]; prompts?: string[]; themes?: string[]; + workflows?: string[]; }; export interface Settings { @@ -105,6 +106,7 @@ export interface Settings { skills?: string[]; // Array of local skill file paths or directories prompts?: string[]; // Array of local prompt template paths or directories themes?: string[]; // Array of local theme file paths or directories + workflows?: string[]; // Array of local workflow file paths or directories enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands terminal?: TerminalSettings; images?: ImageSettings; diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index ffff92bb6..ea4b23bfe 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -18,7 +18,7 @@ export interface BuiltinSlashCommand { export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)" }, - { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, + { name: "scoped-models", description: "Enable/disable models for ctrl+p cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, { name: "share", description: "Share session as a secret GitHub gist" }, diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 6973682db..2c964f95a 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -58,7 +58,8 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { const date = `${year}-${month}-${day}`; const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : ""; - const modelName = selectedModel?.name?.trim() || selectedModel?.id || "unknown"; + const modelName = + selectedModel?.name?.trim() || selectedModel?.id || "unknown"; const contextFiles = providedContextFiles ?? []; const skills = providedSkills ?? []; @@ -164,15 +165,17 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { Follow these tool selection and usage rules in order of priority: -1. **Browser search and automation**: +1. **To-do management**: If the user has a complex task that can be broken down into actionable steps, ALWAYS use the \`todo\` tool to create a task list before proceeding. This ensures clarity and alignment with the user's goals and that you have a way to track your work and ensure you are meeting the user's expectations. + +2. **Browser search and automation**: Use web search tools, playwright-cli (refer to playwright-cli skill) for ALL browser automation tasks, including web research, form filling, and UI interaction: - ALWAYS load the playwright-cli skill before usage. - ALWAYS ASSUME playwright-cli is installed. If the \`playwright-cli\` command fails, fall back to \`npx playwright-cli\`. -2. **Testing**: ALWAYS invoke your tdd skill BEFORE creating or modifying any tests. +3. **Testing**: ALWAYS invoke your tdd skill BEFORE creating or modifying any tests. -3. **Sub-agent Orchestration**: To avoid draining your context window, prefer to use subagents for complex tasks all non-trivial operations should be delegated to subagents. +4. **Sub-agent Orchestration**: To avoid draining your context window, prefer to use subagents for complex tasks all non-trivial operations should be delegated to subagents. You should delegate running bash commands (particularly ones that are likely to produce lots of output) such as investigating with the \`aws\` CLI, using the \`gh\` CLI, digging through logs to \`bash\` subagents. diff --git a/packages/coding-agent/src/core/tools/ask-user-question/view/components/preview/preview-block-renderer.ts b/packages/coding-agent/src/core/tools/ask-user-question/view/components/preview/preview-block-renderer.ts index 8193430e5..77ab69f86 100644 --- a/packages/coding-agent/src/core/tools/ask-user-question/view/components/preview/preview-block-renderer.ts +++ b/packages/coding-agent/src/core/tools/ask-user-question/view/components/preview/preview-block-renderer.ts @@ -20,7 +20,7 @@ import type { PreviewLayoutMode } from "./preview-layout-decider.js"; * Affordance text shown below the bordered preview when focused on a preview-bearing option. * Re-exported by `preview-pane.ts` for the existing test surface. */ -export const NOTES_AFFORDANCE_TEXT = "Notes: press n to add notes"; +export const NOTES_AFFORDANCE_TEXT = "Notes: n add notes"; export interface PreviewBlockRendererConfig { question: QuestionData; diff --git a/packages/coding-agent/src/core/tools/ask-user-question/view/dialog-builder.ts b/packages/coding-agent/src/core/tools/ask-user-question/view/dialog-builder.ts index 050faac14..6a49536f7 100644 --- a/packages/coding-agent/src/core/tools/ask-user-question/view/dialog-builder.ts +++ b/packages/coding-agent/src/core/tools/ask-user-question/view/dialog-builder.ts @@ -11,12 +11,12 @@ import type { StatefulView } from "./stateful-view.js"; import type { TabComponents } from "./tab-components.js"; import { QuestionTabStrategy, SubmitTabStrategy, type TabContentStrategy } from "./tab-content-strategy.js"; -export const HINT_PART_ENTER = "Enter to select"; -export const HINT_PART_NAV = "↑/↓ to navigate"; -export const HINT_PART_TOGGLE = "Space to toggle"; -export const HINT_PART_NOTES = "n to add notes"; -export const HINT_PART_TAB = "Tab to switch questions"; -export const HINT_PART_CANCEL = "Esc to cancel"; +export const HINT_PART_ENTER = "enter select"; +export const HINT_PART_NAV = "↑/↓ navigate"; +export const HINT_PART_TOGGLE = "space toggle"; +export const HINT_PART_NOTES = "n add notes"; +export const HINT_PART_TAB = "tab switch questions"; +export const HINT_PART_CANCEL = "esc cancel"; export const HINT_SINGLE = [HINT_PART_ENTER, HINT_PART_NAV, HINT_PART_CANCEL].join(" · "); export const HINT_MULTI = [HINT_PART_ENTER, HINT_PART_NAV, HINT_PART_TAB, HINT_PART_CANCEL].join(" · "); export const HINT_MULTISELECT_SUFFIX = ` · ${HINT_PART_TOGGLE}`; diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 1d6591d45..1f432c82e 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -199,7 +199,7 @@ function rebuildBashResultRenderComponent( const state = component.state; component.clear(); - const output = getTextOutput(result as any, showImages).trim(); + const output = getTextOutput(result, showImages).trim(); if (output) { const styledOutput = output @@ -221,7 +221,7 @@ function rebuildBashResultRenderComponent( if (state.cachedSkipped && state.cachedSkipped > 0) { const hint = theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) + - ` ${keyHint("app.tools.expand", "to expand")})`; + ` ${keyHint("app.tools.expand", "Expand")})`; return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])]; } return ["", ...(state.cachedLines ?? [])]; @@ -423,7 +423,7 @@ export function createBashToolDefinition( (context.lastComponent as BashResultRenderComponent | undefined) ?? new BashResultRenderComponent(); rebuildBashResultRenderComponent( component, - result as any, + result, options, context.showImages, state.startedAt, diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index e2d552d4f..3f66bb138 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -94,7 +94,7 @@ function formatFindResult( const remaining = lines.length - maxLines; text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "Expand")})`; } } @@ -359,7 +359,7 @@ export function createFindToolDefinition( }, renderResult(result, options, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - text.setText(formatFindResult(result as any, options, theme, context.showImages)); + text.setText(formatFindResult(result, options, theme, context.showImages)); return text; }, }; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 4d441b180..9ca00fc92 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -43,6 +43,19 @@ export interface GrepToolDetails { linesTruncated?: boolean; } +type RipgrepMatchEvent = { + type: "match"; + data?: { + path?: { text?: unknown }; + line_number?: unknown; + lines?: { text?: unknown }; + }; +}; + +function isRipgrepMatchEvent(event: unknown): event is RipgrepMatchEvent { + return typeof event === "object" && event !== null && "type" in event && event.type === "match"; +} + /** * Pluggable operations for the grep tool. * Override these to delegate search to remote systems (for example SSH). @@ -102,7 +115,7 @@ function formatGrepResult( const remaining = lines.length - maxLines; text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "Expand")})`; } } @@ -270,19 +283,19 @@ export function createGrepToolDefinition( const matches: Array<{ filePath: string; lineNumber: number; lineText?: string }> = []; rl.on("line", (line) => { if (!line.trim() || matchCount >= effectiveLimit) return; - let event: any; + let event: unknown; try { - event = JSON.parse(line); + event = JSON.parse(line) as unknown; } catch { return; } - if (event.type === "match") { + if (isRipgrepMatchEvent(event)) { matchCount++; const filePath = event.data?.path?.text; const lineNumber = event.data?.line_number; const lineText = event.data?.lines?.text; - if (filePath && typeof lineNumber === "number") - matches.push({ filePath, lineNumber, lineText }); + if (typeof filePath === "string" && typeof lineNumber === "number") + matches.push({ filePath, lineNumber, lineText: typeof lineText === "string" ? lineText : undefined }); if (matchCount >= effectiveLimit) { matchLimitReached = true; stopChild(true); @@ -373,7 +386,7 @@ export function createGrepToolDefinition( }, renderResult(result, options, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - text.setText(formatGrepResult(result as any, options, theme, context.showImages)); + text.setText(formatGrepResult(result, options, theme, context.showImages)); return text; }, }; diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 6220db612..f4627e84d 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -71,6 +71,7 @@ export { createAskUserQuestionToolDefinition } from "./ask-user-question/index.j export { createTodoToolDefinition } from "./todos.js"; import type { AgentTool } from "@earendil-works/pi-agent-core"; +import type { TSchema } from "typebox"; import type { ToolDefinition } from "../extensions/types.js"; import { createAskUserQuestionToolDefinition } from "./ask-user-question/index.js"; import { type BashToolOptions, createBashTool, createBashToolDefinition } from "./bash.js"; @@ -83,8 +84,8 @@ import { createTodoToolDefinition } from "./todos.js"; import { wrapToolDefinition } from "./tool-definition-wrapper.js"; import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.js"; -export type Tool = AgentTool; -export type ToolDef = ToolDefinition; +export type Tool = AgentTool; +export type ToolDef = ToolDefinition; export type ToolName = | "read" | "bash" diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index e6ae22c75..1f53fe9c0 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -81,7 +81,7 @@ function formatLsResult( const remaining = lines.length - maxLines; text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "Expand")})`; } } @@ -145,8 +145,9 @@ export function createLsToolDefinition( let entries: string[]; try { entries = await ops.readdir(dirPath); - } catch (e: any) { - reject(new Error(`Cannot read directory: ${e.message}`)); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); + reject(new Error(`Cannot read directory: ${message}`)); return; } @@ -204,7 +205,7 @@ export function createLsToolDefinition( content: [{ type: "text", text: output }], details: Object.keys(details).length > 0 ? details : undefined, }); - } catch (e: any) { + } catch (e: unknown) { signal?.removeEventListener("abort", onAbort); reject(e); } @@ -218,7 +219,7 @@ export function createLsToolDefinition( }, renderResult(result, options, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); - text.setText(formatLsResult(result as any, options, theme, context.showImages)); + text.setText(formatLsResult(result, options, theme, context.showImages)); return text; }, }; diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index 867d04dcf..8c652b890 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -145,7 +145,7 @@ function formatCompactReadCall( args: ReadRenderArgs | undefined, theme: Theme, ): string { - const expandHint = theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`); + const expandHint = theme.fg("dim", ` (${keyText("app.tools.expand")} Expand)`); if (classification.kind === "skill") { return ( theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + @@ -187,7 +187,7 @@ function formatReadResult( const remaining = lines.length - maxLines; let text = `\n${displayLines.map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "Expand")})`; } const truncation = result.details?.truncation; @@ -332,7 +332,7 @@ export function createReadToolDefinition( if (aborted) return; signal?.removeEventListener("abort", onAbort); resolve({ content, details }); - } catch (error: any) { + } catch (error: unknown) { signal?.removeEventListener("abort", onAbort); if (!aborted) reject(error); } diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index 8c7a6fcda..355898573 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -1,6 +1,7 @@ import * as os from "node:os"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import { getCapabilities, getImageDimensions, imageFallback } from "@earendil-works/pi-tui"; +import type { ThemeColor } from "../../modes/interactive/theme/theme.js"; import { stripAnsi } from "../../utils/ansi.js"; import { sanitizeBinaryOutput } from "../../utils/shell.js"; @@ -59,6 +60,6 @@ export type ToolRenderResultLike = { details: TDetails; }; -export function invalidArgText(theme: { fg: (name: any, text: string) => string }): string { +export function invalidArgText(theme: { fg: (name: ThemeColor, text: string) => string }): string { return theme.fg("error", "[invalid arg]"); } diff --git a/packages/coding-agent/src/core/tools/todos.ts b/packages/coding-agent/src/core/tools/todos.ts index b878524b4..bb4904d1b 100644 --- a/packages/coding-agent/src/core/tools/todos.ts +++ b/packages/coding-agent/src/core/tools/todos.ts @@ -591,7 +591,7 @@ function renderTodoDetail(theme: Theme, todo: TodoRecord, expanded: boolean): st } function appendExpandHint(theme: Theme, text: string): string { - return `${text}\n${theme.fg("dim", `(${keyHint("app.tools.expand", "to expand")})`)}`; + return `${text}\n${theme.fg("dim", `(${keyHint("app.tools.expand", "Expand")})`)}`; } async function ensureTodoExists(filePath: string, id: string): Promise { diff --git a/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts b/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts index 1aca3f309..b35a4a912 100644 --- a/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts +++ b/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts @@ -1,11 +1,12 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; +import type { TSchema } from "typebox"; import type { ExtensionContext, ToolDefinition } from "../extensions/types.js"; /** Wrap a ToolDefinition into an AgentTool for the core runtime. */ -export function wrapToolDefinition( - definition: ToolDefinition, +export function wrapToolDefinition( + definition: ToolDefinition, ctxFactory?: () => ExtensionContext, -): AgentTool { +): AgentTool { return { name: definition.name, label: definition.label, @@ -20,9 +21,9 @@ export function wrapToolDefinition( /** Wrap multiple ToolDefinitions into AgentTools for the core runtime. */ export function wrapToolDefinitions( - definitions: ToolDefinition[], + definitions: ToolDefinition[], ctxFactory?: () => ExtensionContext, -): AgentTool[] { +): AgentTool[] { return definitions.map((definition) => wrapToolDefinition(definition, ctxFactory)); } @@ -32,12 +33,14 @@ export function wrapToolDefinitions( * This keeps AgentSession's internal registry definition-first even when a caller * provides plain AgentTool overrides that do not include prompt metadata or renderers. */ -export function createToolDefinitionFromAgentTool(tool: AgentTool): ToolDefinition { +export function createToolDefinitionFromAgentTool( + tool: AgentTool, +): ToolDefinition { return { name: tool.name, label: tool.label, description: tool.description, - parameters: tool.parameters as any, + parameters: tool.parameters, prepareArguments: tool.prepareArguments, executionMode: tool.executionMode, execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate), diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index e1aa40aa5..b21c78f71 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -154,7 +154,7 @@ function formatWriteCall( const remaining = lines.length - maxLines; text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")})`; + text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "Expand")})`; } } @@ -230,7 +230,7 @@ export function createWriteToolDefinition( ], details: undefined, }); - } catch (error: any) { + } catch (error: unknown) { signal?.removeEventListener("abort", onAbort); if (!aborted) reject(error); } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 25458a2f5..14313bdb3 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -303,6 +303,7 @@ export { // Main entry point export { type MainOptions, main } from "./main.js"; // Run modes for programmatic SDK usage +export { pickWhimsicalWorkingMessage } from "./modes/interactive/whimsical-messages.js"; export { InteractiveMode, type InteractiveModeOptions, @@ -343,6 +344,7 @@ export { ExtensionInputComponent, ExtensionSelectorComponent, FooterComponent, + UsageMeterComponent, keyHint, keyText, LoginDialogComponent, @@ -365,6 +367,8 @@ export { truncateToVisualLines, UserMessageComponent, UserMessageSelectorComponent, + WorkingStatusComponent, + type WorkingStatusComponentOptions, type VisualTruncateResult, } from "./modes/interactive/components/index.js"; // Theme utilities for custom tools and extensions diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 247b25710..837e008a2 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -363,7 +363,7 @@ function buildSessionOptions( options.thinkingLevel = parsed.thinking; } - // Scoped models for Ctrl+P cycling + // Scoped models for CTRL+P cycling // Keep thinking level undefined when not explicitly set in the model pattern. // Undefined means "inherit current session thinking level" during cycling. if (scopedModels.length > 0) { @@ -695,7 +695,7 @@ export async function main(args: string[], options?: MainOptions) { return `${sm.model.id}${thinkingStr}`; }) .join(", "); - console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)); + console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(ctrl+p cycle)")}`)); } const interactiveMode = new InteractiveMode(runtime, { diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index 7eb7e3061..0202b1fcd 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -101,16 +101,16 @@ export class AssistantMessageComponent extends Container { if (this.hideThinkingBlock) { // Show static thinking label when hidden this.contentContainer.addChild( - new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), 1, 0), + new Text(theme.italic(theme.fg("muted", this.hiddenThinkingLabel)), 1, 0), ); if (hasVisibleContentAfter) { this.contentContainer.addChild(new Spacer(1)); } } else { - // Thinking traces in thinkingText color, italic + // Thinking traces in muted color, italic this.contentContainer.addChild( new Markdown(content.thinking.trim(), 1, 0, this.markdownTheme, { - color: (text: string) => theme.fg("thinkingText", text), + color: (text: string) => theme.fg("muted", text), italic: true, }), ); diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index 240cd12d1..f0553b2a4 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -56,7 +56,7 @@ export class BashExecutionComponent extends Container { ui, (spinner) => theme.fg(colorKey, spinner), (text) => theme.fg("muted", text), - `Running... (${keyText("tui.select.cancel")} to cancel)`, // Plain text for loader + `Running... (${keyText("tui.select.cancel")} Cancel)`, // Plain text for loader ); this.contentContainer.addChild(this.loader); @@ -176,10 +176,10 @@ export class BashExecutionComponent extends Container { // Show how many lines are hidden (collapsed preview) if (hiddenLineCount > 0) { if (this.expanded) { - statusParts.push(`(${keyHint("app.tools.expand", "to collapse")})`); + statusParts.push(`(${keyHint("app.tools.expand", "Collapse")})`); } else { statusParts.push( - `${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("app.tools.expand", "to expand")})`, + `${theme.fg("muted", `... ${hiddenLineCount} more lines`)} (${keyHint("app.tools.expand", "Expand")})`, ); } } diff --git a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts index b140f4968..603312e08 100644 --- a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -48,7 +48,7 @@ export class BranchSummaryMessageComponent extends Box { new Text( theme.fg("customMessageText", "Branch summary (") + theme.fg("dim", keyText("app.tools.expand")) + - theme.fg("customMessageText", " to expand)"), + theme.fg("customMessageText", " Expand)"), 0, 0, ), diff --git a/packages/coding-agent/src/modes/interactive/components/chat-message-renderer.ts b/packages/coding-agent/src/modes/interactive/components/chat-message-renderer.ts index 049c20b8c..1186373cf 100644 --- a/packages/coding-agent/src/modes/interactive/components/chat-message-renderer.ts +++ b/packages/coding-agent/src/modes/interactive/components/chat-message-renderer.ts @@ -1,6 +1,7 @@ import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { Container, Text, type Component, type MarkdownTheme, type TUI } from "@earendil-works/pi-tui"; +import type { TSchema } from "typebox"; import type { MessageRenderer, ToolDefinition } from "../../../core/extensions/types.js"; import type { BashExecutionMessage, @@ -46,7 +47,7 @@ export interface ChatMessageRenderOptions { toolOutputExpanded?: boolean; showImages?: boolean; imageWidthCells?: number; - getToolDefinition?: (toolName: string) => ToolDefinition | undefined; + getToolDefinition?: (toolName: string) => ToolDefinition | undefined; getCustomMessageRenderer?: (customType: string) => MessageRenderer | undefined; } diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index e4b78f883..f06dc9fcc 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -49,7 +49,7 @@ export class CompactionSummaryMessageComponent extends Box { new Text( theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (`) + theme.fg("dim", keyText("app.tools.expand")) + - theme.fg("customMessageText", " to expand)"), + theme.fg("customMessageText", " Expand)"), 0, 0, ), diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index dc9fe614a..0f561009b 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -185,7 +185,7 @@ class ConfigSelectorHeader implements Component { render(width: number): string[] { const title = theme.bold("Resource Configuration"); const sep = theme.fg("muted", " · "); - const hint = rawKeyHint("space", "toggle") + sep + rawKeyHint("esc", "close"); + const hint = rawKeyHint("space", "Toggle") + sep + rawKeyHint("esc", "Close"); const hintWidth = visibleWidth(hint); const titleWidth = visibleWidth(title); const spacing = Math.max(1, width - titleWidth - hintWidth); diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index f70892732..8eb2b7045 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,8 +1,9 @@ -import { Editor, type EditorOptions, type EditorTheme, type TUI, visibleWidth } from "@earendil-works/pi-tui"; +import { Editor, type EditorOptions, type EditorTheme, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.js"; export interface CustomEditorOptions extends EditorOptions { promptPrefix?: string; + placeholder?: string | (() => string); } const ANSI_ESCAPE_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b[PX_][\s\S]*?\x1b\\/g; @@ -14,6 +15,7 @@ const BORDER_LINE_PATTERN = /^[─ ↑↓0-9more]+$/; export class CustomEditor extends Editor { private keybindings: KeybindingsManager; private promptPrefix: string; + private placeholder: string | (() => string) | undefined; public actionHandlers: Map void> = new Map(); // Special handlers that can be dynamically replaced @@ -27,6 +29,11 @@ export class CustomEditor extends Editor { super(tui, theme, options); this.keybindings = keybindings; this.promptPrefix = options?.promptPrefix ?? "❯ "; + this.placeholder = options?.placeholder; + } + + setPlaceholder(placeholder: string | (() => string) | undefined): void { + this.placeholder = placeholder; } render(width: number): string[] { @@ -41,6 +48,8 @@ export class CustomEditor extends Editor { let inPromptBox = false; let promptShown = false; + const placeholder = typeof this.placeholder === "function" ? this.placeholder() : this.placeholder; + return lines.map((line) => { if (this.isEditorBorderLine(line)) { borderCount += 1; @@ -53,11 +62,16 @@ export class CustomEditor extends Editor { return this.extendBorderLine(line, width); } - const prefix = inPromptBox && !promptShown ? this.promptPrefix : " ".repeat(promptWidth); + const showPrompt = inPromptBox && !promptShown; + const prefix = showPrompt ? this.promptPrefix : " ".repeat(promptWidth); + let content = line; + if (showPrompt && placeholder && this.getText() === "") { + content = truncateToWidth(placeholder, editorWidth, "..."); + } if (inPromptBox) { promptShown = true; } - return this.padLine(`${prefix}${line}`, width); + return this.padLine(`${prefix}${content}`, width); }); } diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index b100d3022..8914bf38a 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -204,7 +204,7 @@ export class FooterComponent implements Component { } const liveState = this.session.isStreaming - ? theme.fg("muted", "Esc to interrupt") + ? theme.fg("muted", "esc to interrupt") : undefined; const statusText = liveState ?? diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts index bad564a55..4b45fb0ea 100644 --- a/packages/coding-agent/src/modes/interactive/components/index.ts +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -29,7 +29,7 @@ export { DynamicBorder } from "./dynamic-border.js"; export { ExtensionEditorComponent } from "./extension-editor.js"; export { ExtensionInputComponent } from "./extension-input.js"; export { ExtensionSelectorComponent } from "./extension-selector.js"; -export { FooterComponent } from "./footer.js"; +export { FooterComponent, UsageMeterComponent } from "./footer.js"; export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.js"; export { LoginDialogComponent } from "./login-dialog.js"; export { ModelSelectorComponent } from "./model-selector.js"; @@ -45,4 +45,5 @@ export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execut export { TreeSelectorComponent } from "./tree-selector.js"; export { UserMessageComponent } from "./user-message.js"; export { UserMessageSelectorComponent } from "./user-message-selector.js"; +export { WorkingStatusComponent, type WorkingStatusComponentOptions } from "./working-status.js"; export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.js"; diff --git a/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts index bb018c185..e1fa4a490 100644 --- a/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts +++ b/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts @@ -6,12 +6,50 @@ import { getKeybindings, type Keybinding, type KeyId } from "@earendil-works/pi- import { theme } from "../theme/theme.js"; export interface KeyTextFormatOptions { + /** @deprecated Key labels are always normalized for display. */ capitalize?: boolean; } -function formatKeyPart(part: string, options: KeyTextFormatOptions): string { - const displayPart = process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part; - return options.capitalize ? displayPart.charAt(0).toUpperCase() + displayPart.slice(1) : displayPart; +const MODIFIER_LABELS: Record = { + ctrl: "ctrl", + control: "ctrl", + cmd: "cmd", + command: "cmd", + shift: "shift", + alt: "alt", + option: "alt", + meta: "meta", +}; + +const SPECIAL_KEY_LABELS: Record = { + enter: "enter", + return: "enter", + esc: "esc", + escape: "esc", + space: "space", + tab: "tab", + backspace: "backspace", + delete: "delete", + del: "delete", + up: "up", + down: "down", + left: "left", + right: "right", + home: "home", + end: "end", + pageup: "pageup", + pagedown: "pagedown", +}; + +function formatKeyPart(part: string, _options: KeyTextFormatOptions): string { + const lower = part.toLowerCase(); + const modifier = MODIFIER_LABELS[lower]; + if (modifier) return modifier; + const special = SPECIAL_KEY_LABELS[lower]; + if (special) return special; + if (/^f\d+$/i.test(part)) return lower; + if (/^[a-z]$/i.test(part)) return lower; + return part.toLowerCase(); } export function formatKeyText(key: string, options: KeyTextFormatOptions = {}): string { @@ -39,10 +77,14 @@ export function keyDisplayText(keybinding: Keybinding): string { return formatKeys(getKeybindings().getKeys(keybinding), { capitalize: true }); } +function formatHintLabel(description: string): string { + return description; +} + export function keyHint(keybinding: Keybinding, description: string): string { - return theme.fg("dim", keyText(keybinding)) + theme.fg("muted", ` ${description}`); + return theme.fg("dim", keyText(keybinding)) + theme.fg("muted", ` ${formatHintLabel(description)}`); } export function rawKeyHint(key: string, description: string): string { - return theme.fg("dim", formatKeyText(key)) + theme.fg("muted", ` ${description}`); + return theme.fg("dim", formatKeyText(key)) + theme.fg("muted", ` ${formatHintLabel(description)}`); } diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 4f759cc0d..02ce8dd1b 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -90,7 +90,7 @@ export class LoginDialogComponent extends Container implements Focusable { const linkedUrl = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`; this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0)); - const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open"; + const clickHint = process.platform === "darwin" ? "cmd+click open" : "ctrl+click open"; const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`; this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0)); @@ -117,7 +117,7 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); this.contentContainer.addChild(this.input); - this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to cancel")})`, 1, 0)); + this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "Cancel")})`, 1, 0)); this.tui.requestRender(); return new Promise((resolve, reject) => { @@ -139,7 +139,7 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(this.input); this.contentContainer.addChild( new Text( - `(${keyHint("tui.select.cancel", "to cancel,")} ${keyHint("tui.select.confirm", "to submit")})`, + `(${keyHint("tui.select.cancel", "Cancel,")} ${keyHint("tui.select.confirm", "Submit")})`, 1, 0, ), @@ -164,7 +164,7 @@ export class LoginDialogComponent extends Container implements Focusable { this.contentContainer.addChild(new Text(line, 1, 0)); } this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to close")})`, 1, 0)); + this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "Close")})`, 1, 0)); this.tui.requestRender(); } @@ -174,7 +174,7 @@ export class LoginDialogComponent extends Container implements Focusable { showWaiting(message: string): void { this.contentContainer.addChild(new Spacer(1)); this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); - this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "to cancel")})`, 1, 0)); + this.contentContainer.addChild(new Text(`(${keyHint("tui.select.cancel", "Cancel")})`, 1, 0)); this.tui.requestRender(); } diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 613ca9e1c..086aefffc 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -1,4 +1,4 @@ -import { type Model, modelsAreEqual } from "@earendil-works/pi-ai"; +import { type Api, type Model, modelsAreEqual } from "@earendil-works/pi-ai"; import { Container, type Focusable, @@ -18,11 +18,11 @@ import { keyHint } from "./keybinding-hints.js"; interface ModelItem { provider: string; id: string; - model: Model; + model: Model; } interface ScopedModelItem { - model: Model; + model: Model; thinkingLevel?: string; } @@ -49,10 +49,10 @@ export class ModelSelectorComponent extends Container implements Focusable { private activeModels: ModelItem[] = []; private filteredModels: ModelItem[] = []; private selectedIndex: number = 0; - private currentModel?: Model; + private currentModel?: Model; private settingsManager: SettingsManager; private modelRegistry: ModelRegistry; - private onSelectCallback: (model: Model) => void; + private onSelectCallback: (model: Model) => void; private onCancelCallback: () => void; private errorMessage?: string; private tui: TUI; @@ -63,11 +63,11 @@ export class ModelSelectorComponent extends Container implements Focusable { constructor( tui: TUI, - currentModel: Model | undefined, + currentModel: Model | undefined, settingsManager: SettingsManager, modelRegistry: ModelRegistry, scopedModels: ReadonlyArray, - onSelect: (model: Model) => void, + onSelect: (model: Model) => void, onCancel: () => void, initialSearchInput?: string, ) { @@ -149,7 +149,7 @@ export class ModelSelectorComponent extends Container implements Focusable { // Load available models (built-in models still work even if models.json failed) try { const availableModels = await this.modelRegistry.getAvailable(); - models = availableModels.map((model: Model) => ({ + models = availableModels.map((model: Model) => ({ provider: model.provider, id: model.id, model, @@ -326,7 +326,7 @@ export class ModelSelectorComponent extends Container implements Focusable { } } - private handleSelect(model: Model): void { + private handleSelect(model: Model): void { // Save as new default this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); this.onSelectCallback(model); diff --git a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts index c5a82d9ad..4ffda9d44 100644 --- a/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -1,4 +1,4 @@ -import type { Model } from "@earendil-works/pi-ai"; +import type { Api, Model } from "@earendil-works/pi-ai"; import { Container, type Focusable, @@ -66,12 +66,12 @@ function getSortedIds(enabledIds: EnabledIds, allIds: string[]): string[] { interface ModelItem { fullId: string; - model: Model; + model: Model; enabled: boolean; } export interface ModelsConfig { - allModels: Model[]; + allModels: Model[]; enabledModelIds: string[] | null; } @@ -88,7 +88,7 @@ export interface ModelsCallbacks { * Changes are session-only until explicitly persisted with Ctrl+S. */ export class ScopedModelsSelectorComponent extends Container implements Focusable { - private modelsById: Map> = new Map(); + private modelsById: Map> = new Map(); private allIds: string[] = []; private enabledIds: EnabledIds = null; private filteredItems: ModelItem[] = []; @@ -128,7 +128,7 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("accent", theme.bold("Model Configuration")), 0, 0)); this.addChild( - new Text(theme.fg("muted", `Session-only. ${keyText("app.models.save")} to save to settings.`), 0, 0), + new Text(theme.fg("muted", `Session-only. ${keyText("app.models.save")} Save Settings.`), 0, 0), ); this.addChild(new Spacer(1)); @@ -166,12 +166,12 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl const allEnabled = this.enabledIds === null; const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.allIds.length} enabled`; const parts = [ - `${keyText("tui.select.confirm")} toggle`, - `${keyText("app.models.enableAll")} all`, - `${keyText("app.models.clearAll")} clear`, - `${keyText("app.models.toggleProvider")} provider`, - `${keyText("app.models.reorderUp")}/${keyText("app.models.reorderDown")} reorder`, - `${keyText("app.models.save")} save`, + `${keyText("tui.select.confirm")} Toggle`, + `${keyText("app.models.enableAll")} All`, + `${keyText("app.models.clearAll")} Clear`, + `${keyText("app.models.toggleProvider")} Provider`, + `${keyText("app.models.reorderUp")}/${keyText("app.models.reorderDown")} Reorder`, + `${keyText("app.models.save")} Save`, countText, ]; return this.isDirty diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index e7c0b072d..d4fd8f28f 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -169,13 +169,13 @@ class SessionSelectorHeader implements Component { const hint1 = keyHint("tui.input.tab", "scope") + sep + theme.fg("muted", 're: regex · "phrase" exact'); const hint2Parts = [ - keyHint("app.session.toggleSort", "sort"), - keyHint("app.session.toggleNamedFilter", "named"), - keyHint("app.session.delete", "delete"), - keyHint("app.session.togglePath", `path ${pathState}`), + keyHint("app.session.toggleSort", "Sort"), + keyHint("app.session.toggleNamedFilter", "Named"), + keyHint("app.session.delete", "Delete"), + keyHint("app.session.togglePath", `Path ${pathState}`), ]; if (this.showRenameHint) { - hint2Parts.push(keyHint("app.session.rename", "rename")); + hint2Parts.push(keyHint("app.session.rename", "Rename")); } const hint2 = hint2Parts.join(sep); hintLine1 = truncateToWidth(hint1, width, "…"); @@ -411,14 +411,14 @@ class SessionList implements Component, Focusable { if (this.showCwd) { emptyMessage = ` No named sessions found. Press ${toggleKey} to show all.`; } else { - emptyMessage = ` No named sessions in current folder. Press ${toggleKey} to show all, or Tab to view all.`; + emptyMessage = ` No named sessions in current folder. ${toggleKey} show all · tab view all.`; } } else if (this.showCwd) { // "All" scope - no sessions anywhere that match filter emptyMessage = " No sessions found"; } else { // "Current folder" scope - hint to try "all" - emptyMessage = " No sessions in current folder. Press Tab to view all."; + emptyMessage = " No sessions in current folder. tab view all."; } lines.push(theme.fg("muted", truncateToWidth(emptyMessage, width, "…"))); return lines; @@ -864,7 +864,7 @@ export class SessionSelectorComponent extends Container implements Focusable { panel.addChild(new Spacer(1)); panel.addChild( new Text( - theme.fg("muted", `${keyText("tui.select.confirm")} to save · ${keyText("tui.select.cancel")} to cancel`), + theme.fg("muted", `${keyText("tui.select.confirm")} Save · ${keyText("tui.select.cancel")} Cancel`), 1, 0, ), diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 7d9d9cdf2..6e5089cc2 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -187,7 +187,7 @@ class SelectSubmenu extends Container { // Hint this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); + this.addChild(new Text(theme.fg("dim", " enter select · esc back"), 0, 0)); } handleInput(data: string): void { @@ -220,7 +220,7 @@ export class SettingsSelectorComponent extends Container { id: "steering-mode", label: "Steering mode", description: - "Enter while streaming queues steering messages. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.", + "enter while streaming queues steering messages. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.", currentValue: config.steeringMode, values: ["one-at-a-time", "all"], }, @@ -269,7 +269,7 @@ export class SettingsSelectorComponent extends Container { { id: "double-escape-action", label: "Double-escape action", - description: "Action when pressing Escape twice with empty editor", + description: "Action when pressing esc twice with empty editor", currentValue: config.doubleEscapeAction, values: ["tree", "fork", "none"], }, diff --git a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts index 8714edc2e..376787e46 100644 --- a/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts @@ -44,11 +44,11 @@ export class SkillInvocationMessageComponent extends Box { }), ); } else { - // Collapsed: single line - [skill] name (hint to expand) + // Collapsed: single line - [skill] name (expand hint) const line = theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + theme.fg("customMessageText", this.skillBlock.name) + - theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`); + theme.fg("dim", ` (${keyText("app.tools.expand")} Expand)`); this.addChild(new Text(line, 0, 0)); } } diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 867a0bb52..2b7460fcc 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -1,4 +1,6 @@ +import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; +import type { TSchema } from "typebox"; import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.js"; import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.js"; import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js"; @@ -10,42 +12,44 @@ export interface ToolExecutionOptions { imageWidthCells?: number; } +type RenderableToolResult = Omit, "details"> & { + details?: unknown; + isError: boolean; +}; +type RenderableImageContent = Extract; + export class ToolExecutionComponent extends Container { private contentBox: Box; private contentText: Text; private selfRenderContainer: Container; private callRendererComponent?: Component; private resultRendererComponent?: Component; - private rendererState: any = {}; + private rendererState: Record = {}; private imageComponents: Image[] = []; private imageSpacers: Spacer[] = []; private toolName: string; private toolCallId: string; - private args: any; + private args: unknown; private expanded = false; private showImages: boolean; private imageWidthCells: number; private isPartial = true; - private toolDefinition?: ToolDefinition; - private builtInToolDefinition?: ToolDefinition; + private toolDefinition?: ToolDefinition; + private builtInToolDefinition?: ToolDefinition; private ui: TUI; private cwd: string; private executionStarted = false; private argsComplete = false; - private result?: { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - isError: boolean; - details?: any; - }; + private result?: RenderableToolResult; private convertedImages: Map = new Map(); private hideComponent = false; constructor( toolName: string, toolCallId: string, - args: any, + args: unknown, options: ToolExecutionOptions = {}, - toolDefinition: ToolDefinition | undefined, + toolDefinition: ToolDefinition | undefined, ui: TUI, cwd: string, ) { @@ -78,7 +82,7 @@ export class ToolExecutionComponent extends Container { this.updateDisplay(); } - private getCallRenderer(): ToolDefinition["renderCall"] | undefined { + private getCallRenderer(): ToolDefinition["renderCall"] | undefined { if (!this.builtInToolDefinition) { return this.toolDefinition?.renderCall; } @@ -88,7 +92,7 @@ export class ToolExecutionComponent extends Container { return this.toolDefinition.renderCall ?? this.builtInToolDefinition.renderCall; } - private getResultRenderer(): ToolDefinition["renderResult"] | undefined { + private getResultRenderer(): ToolDefinition["renderResult"] | undefined { if (!this.builtInToolDefinition) { return this.toolDefinition?.renderResult; } @@ -144,7 +148,7 @@ export class ToolExecutionComponent extends Container { return new Text(theme.fg("toolOutput", output), 0, 0); } - updateArgs(args: any): void { + updateArgs(args: unknown): void { this.args = args; this.updateDisplay(); } @@ -161,14 +165,7 @@ export class ToolExecutionComponent extends Container { this.ui.requestRender(); } - updateResult( - result: { - content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; - details?: any; - isError: boolean; - }, - isPartial = false, - ): void { + updateResult(result: RenderableToolResult, isPartial = false): void { this.result = result; this.isPartial = isPartial; this.updateDisplay(); @@ -180,10 +177,10 @@ export class ToolExecutionComponent extends Container { if (caps.images !== "kitty") return; if (!this.result) return; - const imageBlocks = this.result.content.filter((c) => c.type === "image"); + const imageBlocks = this.result.content.filter((c): c is RenderableImageContent => c.type === "image"); for (let i = 0; i < imageBlocks.length; i++) { const img = imageBlocks[i]; - if (!img.data || !img.mimeType) continue; + if (img === undefined || !img.data || !img.mimeType) continue; if (img.mimeType === "image/png") continue; if (this.convertedImages.has(i)) continue; @@ -269,7 +266,7 @@ export class ToolExecutionComponent extends Container { } else { try { const component = resultRenderer( - { content: this.result.content as any, details: this.result.details }, + { content: this.result.content, details: this.result.details, terminate: this.result.terminate }, { expanded: this.expanded, isPartial: this.isPartial }, theme, this.getRenderContext(this.resultRendererComponent), @@ -303,11 +300,11 @@ export class ToolExecutionComponent extends Container { this.imageSpacers = []; if (this.result) { - const imageBlocks = this.result.content.filter((c) => c.type === "image"); + const imageBlocks = this.result.content.filter((c): c is RenderableImageContent => c.type === "image"); const caps = getCapabilities(); for (let i = 0; i < imageBlocks.length; i++) { const img = imageBlocks[i]; - if (caps.images && this.showImages && img.data && img.mimeType) { + if (img !== undefined && caps.images && this.showImages && img.data && img.mimeType) { const converted = this.convertedImages.get(i); const imageData = converted?.data ?? img.data; const imageMimeType = converted?.mimeType ?? img.mimeType; diff --git a/packages/coding-agent/src/modes/interactive/components/working-status.ts b/packages/coding-agent/src/modes/interactive/components/working-status.ts new file mode 100644 index 000000000..4b1efffec --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/working-status.ts @@ -0,0 +1,41 @@ +import { Text, type Component } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.js"; + +export interface WorkingStatusComponentOptions { + /** Current spinner frame. Pass an empty string to render message-only status. */ + spinner?: string; + /** Status text displayed after the spinner. */ + message?: string; + /** Optional spinner colorizer. Defaults to the active interactive accent. */ + spinnerColor?: (text: string) => string; + /** Optional message colorizer. Defaults to muted interactive text. */ + messageColor?: (text: string) => string; +} + +/** + * Inline working status used above the composer. + * + * This preserves the same geometry as pi-tui's Loader, which is the primitive + * used by the main coding-agent chat: one leading blank row plus a one-cell + * text gutter before the spinner/message row. + */ +export class WorkingStatusComponent implements Component { + constructor(private readonly options: WorkingStatusComponentOptions = {}) {} + + render(width: number): string[] { + const spinner = this.options.spinner ?? "⠋"; + const message = this.options.message ?? "Working..."; + const spinnerColor = + this.options.spinnerColor ?? + ((text: string) => theme.fg("accent", text)); + const messageColor = + this.options.messageColor ?? ((text: string) => theme.fg("muted", text)); + const indicator = spinner ? `${spinnerColor(spinner)} ` : ""; + return [ + "", + ...new Text(`${indicator}${messageColor(message)}`, 1, 0).render(width), + ]; + } + + invalidate(): void {} +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3f091d44e..29fc6d5af 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -9,6 +9,7 @@ import * as os from "node:os"; import * as path from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { + type Api, type AssistantMessage, getProviders, type ImageContent, @@ -250,7 +251,7 @@ function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean { return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat"); } -function isUnknownModel(model: Model | undefined): boolean { +function isUnknownModel(model: Model | undefined): boolean { return ( !!model && model.provider === "unknown" && @@ -940,7 +941,7 @@ export class InteractiveMode { if (extendedKeys === undefined) return undefined; if (extendedKeys !== "on" && extendedKeys !== "always") { - return "tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux."; + return "tmux extended-keys is off. Modified enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux."; } if (extendedKeysFormat === "xterm") { @@ -2226,7 +2227,7 @@ export class InteractiveMode { this.setWorkingIndicator(); if (this.loadingAnimation) { this.loadingAnimation.setMessage( - `${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`, + `${this.defaultWorkingMessage} (${keyText("app.interrupt")} Interrupt)`, ); } this.setHiddenThinkingLabel(); @@ -2425,6 +2426,7 @@ export class InteractiveMode { }, setEditorComponent: (factory) => this.setCustomEditorComponent(factory), getEditorComponent: () => this.editorComponentFactory, + getFooterDataProvider: () => this.footerDataProvider, get theme() { return theme; }, @@ -3101,7 +3103,7 @@ export class InteractiveMode { if (command) { if (this.session.isBashRunning) { this.showWarning( - "A bash command is already running. Press Esc to cancel it first.", + "A bash command is already running. esc cancel first.", ); this.editor.setText(text); return; @@ -3398,7 +3400,7 @@ export class InteractiveMode { this.session.abortCompaction(); }; this.statusContainer.clear(); - const cancelHint = `(${keyText("app.interrupt")} to cancel)`; + const cancelHint = `(${keyText("app.interrupt")} Cancel)`; const label = event.reason === "manual" ? `Compacting context... ${cancelHint}` @@ -3469,7 +3471,7 @@ export class InteractiveMode { this.statusContainer.clear(); this.retryCountdown?.dispose(); const retryMessage = (seconds: number) => - `Retrying (${event.attempt}/${event.maxAttempts}) in ${seconds}s... (${keyText("app.interrupt")} to cancel)`; + `Retrying (${event.attempt}/${event.maxAttempts}) in ${seconds}s... (${keyText("app.interrupt")} Cancel)`; this.retryLoader = new Loader( this.ui, (spinner) => theme.fg("warning", spinner), @@ -4630,12 +4632,12 @@ export class InteractiveMode { private async findExactModelMatch( searchTerm: string, - ): Promise | undefined> { + ): Promise | undefined> { const models = await this.getModelCandidates(); return findExactModelReferenceMatch(searchTerm, models); } - private async getModelCandidates(): Promise[]> { + private async getModelCandidates(): Promise[]> { if (this.session.scopedModels.length > 0) { return this.session.scopedModels.map((scoped) => scoped.model); } @@ -4656,7 +4658,7 @@ export class InteractiveMode { } private async maybeWarnAboutAnthropicSubscriptionAuth( - model: Model | undefined = this.session.model, + model: Model | undefined = this.session.model, ): Promise { if (this.settingsManager.getWarnings().anthropicExtraUsage === false) { return; @@ -4956,7 +4958,7 @@ export class InteractiveMode { this.ui, (spinner) => theme.fg("accent", spinner), (text) => theme.fg("muted", text), - `Summarizing branch... (${keyText("app.interrupt")} to cancel)`, + `Summarizing branch... (${keyText("app.interrupt")} Cancel)`, ); this.statusContainer.addChild(summaryLoader); this.ui.requestRender(); @@ -5276,7 +5278,7 @@ export class InteractiveMode { providerId: string, providerName: string, authType: "oauth" | "api_key", - previousModel: Model | undefined, + previousModel: Model | undefined, ): Promise { this.session.modelRegistry.refresh(); @@ -5285,7 +5287,7 @@ export class InteractiveMode { ? `Logged in to ${providerName}` : `Saved API key for ${providerName}`; - let selectedModel: Model | undefined; + let selectedModel: Model | undefined; let selectionError: string | undefined; if (isUnknownModel(previousModel)) { const availableModels = this.session.modelRegistry.getAvailable(); @@ -6085,7 +6087,7 @@ export class InteractiveMode { | Key | Action | |-----|--------| | \`${submit}\` | Send message | -| \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} | +| \`${newLine}\` | New line${process.platform === "win32" ? " (ctrl+enter on Windows Terminal)" : ""} | | \`${deleteWordBackward}\` | Delete word backwards | | \`${deleteWordForward}\` | Delete word forwards | | \`${deleteToLineStart}\` | Delete to start of line | diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index d11c5fc20..6cac03e56 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -72,7 +72,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise void; reject: (error: Error) => void } + { resolve: (value: RpcExtensionUIResponse) => void; reject: (error: Error) => void } >(); // Shutdown request flag @@ -276,6 +276,15 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise null, + getExtensionStatuses: () => new Map(), + getAvailableProviderCount: () => 1, + onBranchChange: () => () => {}, + }; + }, + get theme() { return theme; }, diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 43aeeeeb4..18095d669 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -6,7 +6,7 @@ */ import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { ImageContent, Model } from "@earendil-works/pi-ai"; +import type { Api, ImageContent, Model } from "@earendil-works/pi-ai"; import type { SessionStats } from "../../core/agent-session.js"; import type { BashResult } from "../../core/bash-executor.js"; import type { CompactionResult } from "../../core/compaction/index.js"; @@ -89,7 +89,7 @@ export interface RpcSlashCommand { // ============================================================================ export interface RpcSessionState { - model?: Model; + model?: Model; thinkingLevel: ThinkingLevel; isStreaming: boolean; isCompacting: boolean; @@ -125,21 +125,21 @@ export type RpcResponse = type: "response"; command: "set_model"; success: true; - data: Model; + data: Model; } | { id?: string; type: "response"; command: "cycle_model"; success: true; - data: { model: Model; thinkingLevel: ThinkingLevel; isScoped: boolean } | null; + data: { model: Model; thinkingLevel: ThinkingLevel; isScoped: boolean } | null; } | { id?: string; type: "response"; command: "get_available_models"; success: true; - data: { models: Model[] }; + data: { models: Model[] }; } // Thinking diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 05e161445..fa5aa7150 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -155,7 +155,7 @@ async function downloadFile(url: string, dest: string): Promise { } const fileStream = createWriteStream(dest); - await pipeline(Readable.fromWeb(response.body as any), fileStream); + await pipeline(Readable.fromWeb(response.body), fileStream); } function findBinaryRecursively( diff --git a/packages/coding-agent/test/assistant-message.test.ts b/packages/coding-agent/test/assistant-message.test.ts index 9af2a4a2f..9e69fec76 100644 --- a/packages/coding-agent/test/assistant-message.test.ts +++ b/packages/coding-agent/test/assistant-message.test.ts @@ -1,7 +1,7 @@ import type { AssistantMessage } from "@earendil-works/pi-ai"; import { describe, expect, test } from "vitest"; import { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { initTheme, theme } from "../src/modes/interactive/theme/theme.js"; const OSC133_ZONE_START = "\x1b]133;A\x07"; const OSC133_ZONE_END = "\x1b]133;B\x07"; @@ -54,4 +54,16 @@ describe("AssistantMessageComponent", () => { expect(rendered.includes(OSC133_ZONE_END)).toBe(false); expect(rendered.includes(OSC133_ZONE_FINAL)).toBe(false); }); + + test("renders thinking content with the muted foreground color", () => { + initTheme("catppuccin-mocha"); + + const component = new AssistantMessageComponent( + createAssistantMessage([{ type: "thinking", thinking: "checking options" }]), + ); + const rendered = component.render(60).join("\n"); + + expect(rendered).toContain(theme.getFgAnsi("muted")); + expect(rendered).not.toContain(theme.getFgAnsi("thinkingText")); + }); }); diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index 52029d2cf..4d25c5b54 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -99,7 +99,31 @@ describe("extensions discovery", () => { expect(result.extensions[0].path).toContain("index.ts"); }); - it("discovers subdirectory with package.json pi field", async () => { + it("discovers subdirectory with package.json app-name field", async () => { + const subdir = path.join(extensionsDir, "my-package"); + const srcDir = path.join(subdir, "src"); + fs.mkdirSync(subdir); + fs.mkdirSync(srcDir); + fs.writeFileSync(path.join(srcDir, "main.ts"), extensionCode); + fs.writeFileSync( + path.join(subdir, "package.json"), + JSON.stringify({ + name: "my-package", + atomic: { + extensions: ["./src/main.ts"], + }, + }), + ); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].path).toContain("src"); + expect(result.extensions[0].path).toContain("main.ts"); + }); + + it("discovers subdirectory with package.json legacy pi field", async () => { const subdir = path.join(extensionsDir, "my-package"); const srcDir = path.join(subdir, "src"); fs.mkdirSync(subdir); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 7013ba2aa..efaf81e66 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -16,7 +16,7 @@ function createBaseToolDefinition(name = "custom_tool"): ToolDefinition { name, label: name, description: "custom tool", - parameters: Type.Any(), + parameters: Type.Unknown(), execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {}, @@ -227,7 +227,7 @@ describe("ToolExecutionComponent parity", () => { test("shares renderer state across custom call and result slots", () => { type RenderState = { token?: string }; - const toolDefinition: ToolDefinition = { + const toolDefinition: ToolDefinition, unknown, RenderState> = { ...createBaseToolDefinition(), renderCall: (_args, _theme, context) => { context.state.token ??= "shared-token"; @@ -410,7 +410,7 @@ describe("ToolExecutionComponent parity", () => { const collapsed = stripAnsi(component.render(120).join("\n")); expect(collapsed).toContain(scenario.compact); - expect(collapsed.indexOf(":120-329")).toBeLessThan(collapsed.indexOf("to expand")); + expect(collapsed.indexOf(":120-329")).toBeLessThan(collapsed.indexOf("Expand")); }); } }); diff --git a/packages/intercom/README.md b/packages/intercom/README.md index 6ceefa34c..05afc4932 100644 --- a/packages/intercom/README.md +++ b/packages/intercom/README.md @@ -7,7 +7,7 @@ Direct 1:1 messaging between pi sessions on the same machine. Send context, findings, or requests from one session to another — whether you're driving the conversation or letting agents coordinate. ```text -User flow: press Alt+M or run /intercom to pick a session and send a message +User flow: ALT+M or run /intercom to pick a session and send a message ``` ## Why @@ -24,7 +24,7 @@ Pi-intercom also integrates well with [pi-subagents](https://github.com/nicobail ## In One Minute -Each pi session that has `pi-intercom` loaded and enabled connects to a tiny local broker over a local IPC transport. The broker keeps track of connected sessions and routes direct messages to the one you target by name or session ID. The extension gives you both a tool (`intercom`) and a small overlay UI (`/intercom` or `Alt+M`). Incoming messages are rendered inline inside the recipient session, can trigger a turn immediately, and are also stored in Pi session history as extension entries. +Each pi session that has `pi-intercom` loaded and enabled connects to a tiny local broker over a local IPC transport. The broker keeps track of connected sessions and routes direct messages to the one you target by name or session ID. The extension gives you both a tool (`intercom`) and a small overlay UI (`/intercom` or `ALT+M`). Incoming messages are rendered inline inside the recipient session, can trigger a turn immediately, and are also stored in Pi session history as extension entries. ## Install @@ -62,11 +62,11 @@ If a session is unnamed, pi-intercom now exposes a runtime-only fallback alias l ### From the Keyboard -Press **Alt+M** or type `/intercom` to open the session list overlay: +**ALT+M** Open or type `/intercom` to open the session list overlay: 1. **Select a session** — Use arrow keys to pick a target session 2. **Compose message** — Write your message in the compose overlay -3. **Send** — Press Enter to send, Escape to cancel +3. **Send** — Enter Send · Escape Cancel ### From the Agent @@ -352,7 +352,7 @@ Only registered in sessions where `pi-subagents` supplied the required child bri | Key | Action | |-----|--------| -| Alt+M | Open session list overlay | +| ALT+M | Open session list overlay | | ↑/↓ | Navigate session list | | Enter | Select session / Send message | | Escape | Cancel / Close overlay | diff --git a/packages/intercom/index.ts b/packages/intercom/index.ts index 6c7292a3c..8e42d5eb9 100644 --- a/packages/intercom/index.ts +++ b/packages/intercom/index.ts @@ -1056,7 +1056,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) { id: Type.String(), type: Type.String({ description: "Question type: single, multi, text, image, or info" }), question: Type.String(), - options: Type.Optional(Type.Array(Type.Any())), + options: Type.Optional(Type.Array(Type.Unknown())), context: Type.Optional(Type.String()), })), }, { description: "Structured interview request for reason='interview_request'" })), diff --git a/packages/intercom/ui/compose.ts b/packages/intercom/ui/compose.ts index 2fa4a9dc8..feb713ea9 100644 --- a/packages/intercom/ui/compose.ts +++ b/packages/intercom/ui/compose.ts @@ -4,6 +4,24 @@ import type { KeybindingsManager, Theme } from "@bastani/atomic"; import type { IntercomClient } from "../broker/client.js"; import type { SessionInfo } from "../types.js"; +function formatKeyLabel(key: string): string { + return key + .split("+") + .map((part) => { + const lower = part.toLowerCase(); + if (["ctrl", "cmd", "alt", "shift"].includes(lower)) return lower.toUpperCase(); + if (lower === "esc" || lower === "escape") return "Escape"; + if (lower === "enter") return "Enter"; + if (/^[a-z]$/.test(lower)) return lower.toUpperCase(); + return part; + }) + .join("+"); +} + +function formatKeys(keys: readonly string[]): string { + return keys.map(formatKeyLabel).join("/"); +} + export interface ComposeResult { sent: boolean; messageId?: string; @@ -105,7 +123,7 @@ export class ComposeOverlay implements Component { render(width: number): string[] { const innerWidth = Math.max(24, Math.min(width - 2, 72)); const contentWidth = Math.max(1, innerWidth - 2); - const footer = `${this.keybindings.getKeys("tui.select.confirm").join("/")}: Send • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`; + const footer = `${formatKeys(this.keybindings.getKeys("tui.select.confirm"))}: Send • ${formatKeys(this.keybindings.getKeys("tui.select.cancel"))}: Close`; const border = (text: string) => this.theme.fg("accent", text); const row = (text = "") => { const clipped = truncateToWidth(text, contentWidth, "", true); diff --git a/packages/intercom/ui/session-list.ts b/packages/intercom/ui/session-list.ts index d209be2a7..422192dfc 100644 --- a/packages/intercom/ui/session-list.ts +++ b/packages/intercom/ui/session-list.ts @@ -3,6 +3,24 @@ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import type { KeybindingsManager, Theme } from "@bastani/atomic"; import type { SessionInfo } from "../types.js"; +function formatKeyLabel(key: string): string { + return key + .split("+") + .map((part) => { + const lower = part.toLowerCase(); + if (["ctrl", "cmd", "alt", "shift"].includes(lower)) return lower.toUpperCase(); + if (lower === "esc" || lower === "escape") return "Escape"; + if (lower === "enter") return "Enter"; + if (/^[a-z]$/.test(lower)) return lower.toUpperCase(); + return part; + }) + .join("+"); +} + +function formatKeys(keys: readonly string[]): string { + return keys.map(formatKeyLabel).join("/"); +} + function middleTruncate(text: string, maxWidth: number): string { if (visibleWidth(text) <= maxWidth) { return text; @@ -103,7 +121,7 @@ export class SessionListOverlay implements Component { render(width: number): string[] { const innerWidth = Math.max(36, Math.min(width - 2, 88)); const contentWidth = Math.max(1, innerWidth - 2); - const footer = `${this.keybindings.getKeys("tui.select.confirm").join("/")}: Message • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`; + const footer = `${formatKeys(this.keybindings.getKeys("tui.select.confirm"))}: Message • ${formatKeys(this.keybindings.getKeys("tui.select.cancel"))}: Close`; const border = (text: string) => this.theme.fg("accent", text); const row = (text = "") => { const clipped = truncateToWidth(text, contentWidth, "", true); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index af56cc945..ff0c6beb1 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -279,7 +279,7 @@ Returns accumulated messages from UI sessions. Each message includes `type`, `se **Browser controls:** -- **Cmd/Ctrl+Enter** — Complete and close +- **CMD/CTRL+Enter** — Complete and close - **Escape** — Cancel and close - **Done/Cancel buttons** — Same as keyboard shortcuts @@ -332,7 +332,7 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only | Connect | `mcp({ connect: "server-name" })` | | UI messages | `mcp({ action: "ui-messages" })` | -MCP proxy and direct-tool results render compactly by default: long text shows the first three lines plus a `Ctrl+O to expand` hint, while the full result remains available when expanded and is still returned unchanged to the model. +MCP proxy and direct-tool results render compactly by default: long text shows the first three lines plus a `CTRL+O Expand` hint, while the full result remains available when expanded and is still returned unchanged to the model. Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are OR'd. @@ -353,7 +353,7 @@ Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_li If `settings.autoAuth` is `true`, `mcp({ connect: ... })`, `mcp({ tool: ... })`, and direct tool calls automatically run OAuth when needed and retry once. -In interactive sessions, you can also authenticate from `/mcp` with `ctrl+a` or Enter on a server that needs auth. In non-interactive sessions, browser-based OAuth still requires `/mcp-auth `. `/mcp-auth` without a server only opens a picker in the interactive UI. +In interactive sessions, you can also authenticate from `/mcp` with `CTRL+A` or Enter on a server that needs auth. In non-interactive sessions, browser-based OAuth still requires `/mcp-auth `. `/mcp-auth` without a server only opens a picker in the interactive UI. ## How It Works diff --git a/packages/mcp/commands.ts b/packages/mcp/commands.ts index a886f91f8..1b4841a2f 100644 --- a/packages/mcp/commands.ts +++ b/packages/mcp/commands.ts @@ -409,7 +409,7 @@ export async function openMcpAuthPanel( resolve(); }, { authOnly: true, - noticeLines: ["Select an OAuth MCP server and press Enter or ctrl+a to authenticate."], + noticeLines: ["Select OAuth MCP Server. Enter/CTRL+A Authenticate."], }); }, { overlay: true, overlayOptions: { anchor: "center", width: 82 } }, diff --git a/packages/mcp/host-html-template.ts b/packages/mcp/host-html-template.ts index edd5d3fee..420e97f27 100644 --- a/packages/mcp/host-html-template.ts +++ b/packages/mcp/host-html-template.ts @@ -96,7 +96,7 @@ export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string {
Loading UI... - +
diff --git a/packages/mcp/mcp-panel.ts b/packages/mcp/mcp-panel.ts index ae3cef196..45e255ce3 100644 --- a/packages/mcp/mcp-panel.ts +++ b/packages/mcp/mcp-panel.ts @@ -699,22 +699,22 @@ class McpPanel { lines.push(emptyRow()); const hints = this.authOnly ? [ - italic("↑↓") + " navigate", - italic("⏎") + " auth", - italic("ctrl+a") + " auth", - italic("esc") + " clear/close", - italic("ctrl+c") + " quit", + italic("↑↓") + " Navigate", + italic("⏎") + " Auth", + italic("CTRL+A") + " Auth", + italic("Escape") + " Clear/Close", + italic("CTRL+C") + " Quit", ] : [ - italic("↑↓") + " navigate", - italic("space") + " toggle", - italic("⏎") + " expand/auth", - italic("ctrl+a") + " auth", - italic("ctrl+r") + " reconnect", - italic("?") + " desc search", - italic("ctrl+s") + " save", - italic("esc") + " clear/close", - italic("ctrl+c") + " quit", + italic("↑↓") + " Navigate", + italic("Space") + " Toggle", + italic("⏎") + " Expand/Auth", + italic("CTRL+A") + " Auth", + italic("CTRL+R") + " Reconnect", + italic("?") + " Desc Search", + italic("CTRL+S") + " Save", + italic("Escape") + " Clear/Close", + italic("CTRL+C") + " Quit", ]; const gap = " "; const gapW = 2; diff --git a/packages/mcp/mcp-setup-panel.ts b/packages/mcp/mcp-setup-panel.ts index 50ea415a6..2181c97f1 100644 --- a/packages/mcp/mcp-setup-panel.ts +++ b/packages/mcp/mcp-setup-panel.ts @@ -299,7 +299,7 @@ export class McpSetupPanel { return; } - this.notice = { text: "Review the details below. Press Enter on an action with a side effect to apply it.", tone: "muted" }; + this.notice = { text: "Review Details Below. Enter Apply Action With Side Effect.", tone: "muted" }; this.tui.requestRender(); } @@ -387,13 +387,13 @@ export class McpSetupPanel { lines.push(this.padLine(line, innerW)); } lines.push(this.padLine("", innerW)); - lines.push(this.padLine(fg(this.t.muted, "Enter selects, Esc goes back, Ctrl+C closes."), innerW)); + lines.push(this.padLine(fg(this.t.muted, "Enter Select · Escape Back · CTRL+C Close"), innerW)); return lines; } private renderImports(innerW: number): string[] { const lines: string[] = []; - lines.push(this.padLine("Select compatibility imports. Space toggles, Enter saves, Esc goes back.", innerW)); + lines.push(this.padLine("Select Compatibility Imports. Space Toggle · Enter Save · Escape Back.", innerW)); lines.push(this.padLine("", innerW)); for (let index = 0; index < this.discovery.imports.length; index++) { const entry = this.discovery.imports[index]; @@ -412,7 +412,7 @@ export class McpSetupPanel { private renderPaths(innerW: number): string[] { const lines: string[] = []; - lines.push(this.padLine("Select a detected config path to open. Enter opens it, Esc goes back.", innerW)); + lines.push(this.padLine("Select Detected Config Path. Enter Open · Escape Back.", innerW)); lines.push(this.padLine("", innerW)); const paths = this.getDetectedPaths(); for (let index = 0; index < paths.length; index++) { diff --git a/packages/mcp/tool-result-renderer.ts b/packages/mcp/tool-result-renderer.ts index d300099a2..d08151fa3 100644 --- a/packages/mcp/tool-result-renderer.ts +++ b/packages/mcp/tool-result-renderer.ts @@ -58,7 +58,7 @@ export function renderMcpToolResult( .map((line) => line === "…" ? theme.fg("muted", line) : theme.fg("toolOutput", line)) .join("\n"); const hint = display.truncated && !options.expanded - ? `\n${theme.fg("muted", "(Ctrl+O to expand)")}` + ? `\n${theme.fg("muted", "(CTRL+O Expand)")}` : ""; return new Text(`${output}${hint}`, 0, 0); diff --git a/packages/subagents/README.md b/packages/subagents/README.md index dc607ab65..9e8f65643 100644 --- a/packages/subagents/README.md +++ b/packages/subagents/README.md @@ -337,7 +337,7 @@ Chains open a clarify UI by default so you can preview and edit the workflow bef Common clarify keys: - `Enter` runs in the foreground, or in the background if background is toggled on -- `Esc` cancels or backs out +- `Escape` Cancel/Back - `↑↓` moves between steps or tasks - `e` edits the task/template - `m` selects a model @@ -347,7 +347,7 @@ Common clarify keys: - `w` edits output/write behavior where supported - `r` edits reads where supported - `p` toggles progress tracking where supported -Picker screens use `↑↓`, `Enter`, `Esc`, and type-to-filter. The full-screen editor supports word wrapping, paste, `Esc` to save, and `Ctrl+C` to discard. +Picker screens use `↑↓`, `Enter`, `Escape`, and type-to-filter. The full-screen editor supports word wrapping, paste, `Escape` Save, and `CTRL+C` Discard. ## Agents and chains @@ -902,7 +902,7 @@ Async runs write: Foreground runs show compact live progress for single, chain, and parallel modes: current tool, recent output, token counts, duration, activity freshness, and current-tool duration. -Press `Ctrl+O` to expand the full streaming view with complete output per step. +Press `CTRL+O` to expand the full streaming view with complete output per step. Sequential chains show a flow line like `done scout → running planner`. Chains with parallel steps show per-step cards instead. diff --git a/packages/subagents/src/tui/render.ts b/packages/subagents/src/tui/render.ts index 25a784bab..f2e250e22 100644 --- a/packages/subagents/src/tui/render.ts +++ b/packages/subagents/src/tui/render.ts @@ -5,83 +5,106 @@ import * as path from "node:path"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import { getMarkdownTheme, type ExtensionContext } from "@bastani/atomic"; -import { Container, Markdown, Spacer, Text, visibleWidth, type Component } from "@earendil-works/pi-tui"; import { - type AgentProgress, - type AsyncJobState, - type AsyncJobStep, - type AsyncParallelGroupStatus, - type Details, - MAX_WIDGET_JOBS, - WIDGET_KEY, + Container, + Markdown, + Spacer, + Text, + visibleWidth, + type Component, +} from "@earendil-works/pi-tui"; +import { + type AgentProgress, + type AsyncJobState, + type AsyncJobStep, + type AsyncParallelGroupStatus, + type Details, + MAX_WIDGET_JOBS, + WIDGET_KEY, } from "../shared/types.ts"; -import { formatTokens, formatUsage, formatDuration, formatModelThinking, formatToolCall, shortenPath } from "../shared/formatters.ts"; -import { getDisplayItems, getLastActivity, getSingleResultOutput } from "../shared/utils.ts"; +import { + formatTokens, + formatUsage, + formatDuration, + formatModelThinking, + formatToolCall, + shortenPath, +} from "../shared/formatters.ts"; +import { + getDisplayItems, + getLastActivity, + getSingleResultOutput, +} from "../shared/utils.ts"; import { flatToLogicalStepIndex } from "../runs/background/parallel-groups.ts"; -import { aggregateStepStatus, formatActivityLabel, formatAgentRunningLabel, formatParallelOutcome } from "../shared/status-format.ts"; +import { + aggregateStepStatus, + formatActivityLabel, + formatAgentRunningLabel, + formatParallelOutcome, +} from "../shared/status-format.ts"; type Theme = ExtensionContext["ui"]["theme"]; function getTermWidth(): number { - return process.stdout.columns || 120; + return process.stdout.columns || 120; } const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); /** * Truncate a line to maxWidth, preserving ANSI styling through the ellipsis. - * + * * pi-tui's truncateToWidth adds \x1b[0m before ellipsis which resets all styling, * causing background color bleed in the TUI. This implementation tracks active * ANSI styles and re-applies them before the ellipsis. - * + * * Uses Intl.Segmenter for proper Unicode/emoji handling (not char-by-char). */ function truncLine(text: string, maxWidth: number): string { - if (visibleWidth(text) <= maxWidth) return text; - - const targetWidth = maxWidth - 1; - let result = ""; - let currentWidth = 0; - let activeStyles: string[] = []; - let i = 0; - - while (i < text.length) { - const ansiMatch = text.slice(i).match(/^\x1b\[[0-9;]*m/); - if (ansiMatch) { - const code = ansiMatch[0]; - result += code; - - if (code === "\x1b[0m" || code === "\x1b[m") { - activeStyles = []; - } else { - activeStyles.push(code); - } - i += code.length; - continue; - } - - let end = i; - while (end < text.length && !text.slice(end).match(/^\x1b\[[0-9;]*m/)) { - end++; - } - - const textPortion = text.slice(i, end); - for (const seg of segmenter.segment(textPortion)) { - const grapheme = seg.segment; - const graphemeWidth = visibleWidth(grapheme); - - if (currentWidth + graphemeWidth > targetWidth) { - return result + activeStyles.join("") + "…"; - } - - result += grapheme; - currentWidth += graphemeWidth; - } - i = end; - } - - return result + activeStyles.join("") + "…"; + if (visibleWidth(text) <= maxWidth) return text; + + const targetWidth = maxWidth - 1; + let result = ""; + let currentWidth = 0; + let activeStyles: string[] = []; + let i = 0; + + while (i < text.length) { + const ansiMatch = text.slice(i).match(/^\x1b\[[0-9;]*m/); + if (ansiMatch) { + const code = ansiMatch[0]; + result += code; + + if (code === "\x1b[0m" || code === "\x1b[m") { + activeStyles = []; + } else { + activeStyles.push(code); + } + i += code.length; + continue; + } + + let end = i; + while (end < text.length && !text.slice(end).match(/^\x1b\[[0-9;]*m/)) { + end++; + } + + const textPortion = text.slice(i, end); + for (const seg of segmenter.segment(textPortion)) { + const grapheme = seg.segment; + const graphemeWidth = visibleWidth(grapheme); + + if (currentWidth + graphemeWidth > targetWidth) { + return result + activeStyles.join("") + "…"; + } + + result += grapheme; + currentWidth += graphemeWidth; + } + i = end; + } + + return result + activeStyles.join("") + "…"; } const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -91,1167 +114,1926 @@ let widgetTimer: ReturnType | undefined; let latestWidgetCtx: ExtensionContext | undefined; let latestWidgetJobs: AsyncJobState[] = []; -const resultAnimationTimers = new Map, ResultAnimationContext["state"]>(); -const outputActivityCache = new Map(); -const STALE_EXTENSION_CONTEXT_MESSAGE = "This extension ctx is stale after session replacement or reload"; +const resultAnimationTimers = new Map< + ReturnType, + ResultAnimationContext["state"] +>(); +const outputActivityCache = new Map< + string, + { checkedAt: number; text: string } +>(); +const STALE_EXTENSION_CONTEXT_MESSAGE = + "This extension ctx is stale after session replacement or reload"; interface ResultAnimationContext { - state: { subagentResultAnimationTimer?: ReturnType }; - invalidate: () => void; + state: { subagentResultAnimationTimer?: ReturnType }; + invalidate: () => void; } function spinnerFrame(): string { - return SPINNER[Math.floor(Date.now() / WIDGET_ANIMATION_MS) % SPINNER.length]!; + return SPINNER[ + Math.floor(Date.now() / WIDGET_ANIMATION_MS) % SPINNER.length + ]!; } function isStaleExtensionContextError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - return error.message.includes(STALE_EXTENSION_CONTEXT_MESSAGE); + if (!(error instanceof Error)) return false; + return error.message.includes(STALE_EXTENSION_CONTEXT_MESSAGE); } function resultIsRunning(result: AgentToolResult
): boolean { - return result.details?.progress?.some((entry) => entry.status === "running") - || result.details?.results.some((entry) => entry.progress?.status === "running") - || false; + return ( + result.details?.progress?.some((entry) => entry.status === "running") || + result.details?.results.some( + (entry) => entry.progress?.status === "running", + ) || + false + ); } function stopResultAnimation(context: ResultAnimationContext): void { - const timer = context.state.subagentResultAnimationTimer; - if (!timer) return; - clearInterval(timer); - resultAnimationTimers.delete(timer); - context.state.subagentResultAnimationTimer = undefined; + const timer = context.state.subagentResultAnimationTimer; + if (!timer) return; + clearInterval(timer); + resultAnimationTimers.delete(timer); + context.state.subagentResultAnimationTimer = undefined; } -export function syncResultAnimation(result: AgentToolResult
, context: ResultAnimationContext): void { - if (!resultIsRunning(result)) { - stopResultAnimation(context); - return; - } - if (context.state.subagentResultAnimationTimer) return; - const timer = setInterval(() => { - try { - context.invalidate(); - } catch (error) { - if (!isStaleExtensionContextError(error)) throw error; - stopResultAnimation(context); - } - }, WIDGET_ANIMATION_MS); - timer.unref?.(); - context.state.subagentResultAnimationTimer = timer; - resultAnimationTimers.set(timer, context.state); +export function syncResultAnimation( + result: AgentToolResult
, + context: ResultAnimationContext, +): void { + if (!resultIsRunning(result)) { + stopResultAnimation(context); + return; + } + if (context.state.subagentResultAnimationTimer) return; + const timer = setInterval(() => { + try { + context.invalidate(); + } catch (error) { + if (!isStaleExtensionContextError(error)) throw error; + stopResultAnimation(context); + } + }, WIDGET_ANIMATION_MS); + timer.unref?.(); + context.state.subagentResultAnimationTimer = timer; + resultAnimationTimers.set(timer, context.state); } function extractOutputTarget(task: string): string | undefined { - const writeToMatch = task.match(/\[Write to:\s*([^\]\n]+)\]/i); - if (writeToMatch?.[1]?.trim()) return writeToMatch[1].trim(); - const findingsMatch = task.match(/Write your findings to:\s*(\S+)/i); - if (findingsMatch?.[1]?.trim()) return findingsMatch[1].trim(); - const outputMatch = task.match(/[Oo]utput(?:\s+to)?\s*:\s*(\S+)/i); - if (outputMatch?.[1]?.trim()) return outputMatch[1].trim(); - return undefined; + const writeToMatch = task.match(/\[Write to:\s*([^\]\n]+)\]/i); + if (writeToMatch?.[1]?.trim()) return writeToMatch[1].trim(); + const findingsMatch = task.match(/Write your findings to:\s*(\S+)/i); + if (findingsMatch?.[1]?.trim()) return findingsMatch[1].trim(); + const outputMatch = task.match(/[Oo]utput(?:\s+to)?\s*:\s*(\S+)/i); + if (outputMatch?.[1]?.trim()) return outputMatch[1].trim(); + return undefined; } -function hasEmptyTextOutputWithoutOutputTarget(task: string, output: string): boolean { - if (output.trim()) return false; - return !extractOutputTarget(task); +function hasEmptyTextOutputWithoutOutputTarget( + task: string, + output: string, +): boolean { + if (output.trim()) return false; + return !extractOutputTarget(task); } function getToolCallLines( - result: Pick, - expanded: boolean, + result: Pick, + expanded: boolean, ): string[] { - if (result.messages) { - return getDisplayItems(result.messages) - .filter((item): item is { type: "tool"; name: string; args: Record } => item.type === "tool") - .map((item) => formatToolCall(item.name, item.args, expanded)); - } - return result.toolCalls?.map((toolCall) => expanded ? toolCall.expandedText : toolCall.text) ?? []; + if (result.messages) { + return getDisplayItems(result.messages) + .filter( + ( + item, + ): item is { + type: "tool"; + name: string; + args: Record; + } => item.type === "tool", + ) + .map((item) => formatToolCall(item.name, item.args, expanded)); + } + return ( + result.toolCalls?.map((toolCall) => + expanded ? toolCall.expandedText : toolCall.text, + ) ?? [] + ); } - -function formatCurrentToolLine(progress: Pick, availableWidth: number, expanded: boolean): string | undefined { - if (!progress.currentTool) return undefined; - const maxToolArgsLen = Math.max(50, availableWidth - 20); - const toolArgsPreview = progress.currentToolArgs - ? (expanded || progress.currentToolArgs.length <= maxToolArgsLen - ? progress.currentToolArgs - : `${progress.currentToolArgs.slice(0, maxToolArgsLen)}...`) - : ""; - const durationSuffix = progress.currentToolStartedAt !== undefined - ? ` | ${formatDuration(Math.max(0, Date.now() - progress.currentToolStartedAt))}` - : ""; - return toolArgsPreview - ? `${progress.currentTool}: ${toolArgsPreview}${durationSuffix}` - : `${progress.currentTool}${durationSuffix}`; +function formatCurrentToolLine( + progress: Pick< + AgentProgress, + "currentTool" | "currentToolArgs" | "currentToolStartedAt" + >, + availableWidth: number, + expanded: boolean, +): string | undefined { + if (!progress.currentTool) return undefined; + const maxToolArgsLen = Math.max(50, availableWidth - 20); + const toolArgsPreview = progress.currentToolArgs + ? expanded || progress.currentToolArgs.length <= maxToolArgsLen + ? progress.currentToolArgs + : `${progress.currentToolArgs.slice(0, maxToolArgsLen)}...` + : ""; + const durationSuffix = + progress.currentToolStartedAt !== undefined + ? ` | ${formatDuration(Math.max(0, Date.now() - progress.currentToolStartedAt))}` + : ""; + return toolArgsPreview + ? `${progress.currentTool}: ${toolArgsPreview}${durationSuffix}` + : `${progress.currentTool}${durationSuffix}`; } -function buildLiveStatusLine(progress: Pick): string | undefined { - return formatActivityLabel(progress.lastActivityAt, progress.activityState); +function buildLiveStatusLine( + progress: Pick, +): string | undefined { + return formatActivityLabel(progress.lastActivityAt, progress.activityState); } function themeBold(theme: Theme, text: string): string { - return ((theme as { bold?: (value: string) => string }).bold?.(text)) ?? text; + return (theme as { bold?: (value: string) => string }).bold?.(text) ?? text; } function statJoin(theme: Theme, parts: string[]): string { - return parts.filter(Boolean).map((part) => theme.fg("dim", part)).join(` ${theme.fg("dim", "·")} `); + return parts + .filter(Boolean) + .map((part) => theme.fg("dim", part)) + .join(` ${theme.fg("dim", "·")} `); } function formatTokenStat(tokens: number): string { - return `${formatTokens(tokens)} token`; + return `${formatTokens(tokens)} token`; } function formatToolUseStat(count: number): string { - return `${count} tool use${count === 1 ? "" : "s"}`; + return `${count} tool use${count === 1 ? "" : "s"}`; } -function formatProgressStats(theme: Theme, progress: Pick | undefined, includeDuration = true): string { - if (!progress) return ""; - const parts: string[] = []; - if (progress.toolCount > 0) parts.push(formatToolUseStat(progress.toolCount)); - if (progress.tokens > 0) parts.push(formatTokenStat(progress.tokens)); - if (includeDuration && progress.durationMs > 0) parts.push(formatDuration(progress.durationMs)); - return statJoin(theme, parts); +function formatProgressStats( + theme: Theme, + progress: + | Pick + | undefined, + includeDuration = true, +): string { + if (!progress) return ""; + const parts: string[] = []; + if (progress.toolCount > 0) parts.push(formatToolUseStat(progress.toolCount)); + if (progress.tokens > 0) parts.push(formatTokenStat(progress.tokens)); + if (includeDuration && progress.durationMs > 0) + parts.push(formatDuration(progress.durationMs)); + return statJoin(theme, parts); } function firstOutputLine(text: string): string { - return text.split("\n").find((line) => line.trim())?.trim() ?? ""; + return ( + text + .split("\n") + .find((line) => line.trim()) + ?.trim() ?? "" + ); } -function resultStatusLine(result: Details["results"][number], output: string): string { - if (result.detached) return result.detachedReason ? `Detached: ${result.detachedReason}` : "Detached"; - if (result.interrupted) return "Paused"; - if (result.exitCode !== 0) return `Error: ${result.error ?? (firstOutputLine(output) || `exit ${result.exitCode}`)}`; - if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return "Done (no text output)"; - return "Done"; +function resultStatusLine( + result: Details["results"][number], + output: string, +): string { + if (result.detached) + return result.detachedReason + ? `Detached: ${result.detachedReason}` + : "Detached"; + if (result.interrupted) return "Paused"; + if (result.exitCode !== 0) + return `Error: ${result.error ?? (firstOutputLine(output) || `exit ${result.exitCode}`)}`; + if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) + return "Done (no text output)"; + return "Done"; } -function resultGlyph(result: Details["results"][number], output: string, theme: Theme, running = result.progress?.status === "running"): string { - if (running) return theme.fg("accent", spinnerFrame()); - if (result.detached) return theme.fg("warning", "■"); - if (result.interrupted) return theme.fg("warning", "■"); - if (result.exitCode !== 0) return theme.fg("error", "✗"); - if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) return theme.fg("warning", "✓"); - return theme.fg("success", "✓"); +function resultGlyph( + result: Details["results"][number], + output: string, + theme: Theme, + running = result.progress?.status === "running", +): string { + if (running) return theme.fg("accent", spinnerFrame()); + if (result.detached) return theme.fg("warning", "■"); + if (result.interrupted) return theme.fg("warning", "■"); + if (result.exitCode !== 0) return theme.fg("error", "✗"); + if (hasEmptyTextOutputWithoutOutputTarget(result.task, output)) + return theme.fg("warning", "✓"); + return theme.fg("success", "✓"); } function compactCurrentActivity(progress: AgentProgress): string { - return formatCurrentToolLine(progress, getTermWidth() - 4, false) ?? buildLiveStatusLine(progress) ?? "thinking…"; + return ( + formatCurrentToolLine(progress, getTermWidth() - 4, false) ?? + buildLiveStatusLine(progress) ?? + "thinking…" + ); } function hasAnimatedWidgetJobs(jobs: AsyncJobState[]): boolean { - return jobs.some((job) => job.status === "running"); + return jobs.some((job) => job.status === "running"); } function formatWidgetAgents(agents: string[]): string { - const distinct = [...new Set(agents)]; - if (distinct.length === 1 && agents.length > 1) return `${distinct[0]} ×${agents.length}`; - if (agents.length > 3) return `${agents.slice(0, 2).join(", ")} +${agents.length - 2} more`; - return agents.join(", "); + const distinct = [...new Set(agents)]; + if (distinct.length === 1 && agents.length > 1) + return `${distinct[0]} ×${agents.length}`; + if (agents.length > 3) + return `${agents.slice(0, 2).join(", ")} +${agents.length - 2} more`; + return agents.join(", "); } function widgetJobName(job: AsyncJobState): string { - if (job.mode === "parallel") return "parallel"; - if (job.mode === "chain") return "chain"; - if (job.mode === "single" && job.agents?.length === 1) return job.agents[0]!; - if (job.agents?.length) return formatWidgetAgents(job.agents); - return job.mode ?? "subagent"; + if (job.mode === "parallel") return "parallel"; + if (job.mode === "chain") return "chain"; + if (job.mode === "single" && job.agents?.length === 1) return job.agents[0]!; + if (job.agents?.length) return formatWidgetAgents(job.agents); + return job.mode ?? "subagent"; } function getCachedLastActivity(outputFile: string | undefined): string { - if (!outputFile) return ""; - const now = Date.now(); - const cached = outputActivityCache.get(outputFile); - if (cached && now - cached.checkedAt < 1000) return cached.text; - const text = getLastActivity(outputFile); - outputActivityCache.set(outputFile, { checkedAt: now, text }); - return text; + if (!outputFile) return ""; + const now = Date.now(); + const cached = outputActivityCache.get(outputFile); + if (cached && now - cached.checkedAt < 1000) return cached.text; + const text = getLastActivity(outputFile); + outputActivityCache.set(outputFile, { checkedAt: now, text }); + return text; } function widgetActivity(job: AsyncJobState): string { - const facts: string[] = []; - if (job.currentTool && job.currentToolStartedAt !== undefined) facts.push(`${job.currentTool} ${formatDuration(Math.max(0, Date.now() - job.currentToolStartedAt))}`); - else if (job.currentTool) facts.push(job.currentTool); - if (job.currentPath) facts.push(shortenPath(job.currentPath)); - if (job.turnCount !== undefined) facts.push(`${job.turnCount} turns`); - if (job.toolCount !== undefined) facts.push(`${job.toolCount} tools`); - const activity = formatActivityLabel(job.lastActivityAt, job.activityState) - ?? (job.status === "running" ? getCachedLastActivity(job.outputFile) : ""); - if (activity && facts.length) return `${activity} · ${facts.join(" · ")}`; - if (activity) return activity; - if (facts.length) return facts.join(" · "); - if (job.status === "running") return "thinking…"; - if (job.status === "queued") return "queued…"; - if (job.status === "paused") return "Paused"; - if (job.status === "failed") return "Failed"; - return "Done"; + const facts: string[] = []; + if (job.currentTool && job.currentToolStartedAt !== undefined) + facts.push( + `${job.currentTool} ${formatDuration(Math.max(0, Date.now() - job.currentToolStartedAt))}`, + ); + else if (job.currentTool) facts.push(job.currentTool); + if (job.currentPath) facts.push(shortenPath(job.currentPath)); + if (job.turnCount !== undefined) facts.push(`${job.turnCount} turns`); + if (job.toolCount !== undefined) facts.push(`${job.toolCount} tools`); + const activity = + formatActivityLabel(job.lastActivityAt, job.activityState) ?? + (job.status === "running" ? getCachedLastActivity(job.outputFile) : ""); + if (activity && facts.length) return `${activity} · ${facts.join(" · ")}`; + if (activity) return activity; + if (facts.length) return facts.join(" · "); + if (job.status === "running") return "thinking…"; + if (job.status === "queued") return "queued…"; + if (job.status === "paused") return "Paused"; + if (job.status === "failed") return "Failed"; + return "Done"; } function widgetStatusGlyph(job: AsyncJobState, theme: Theme): string { - if (job.status === "running") return theme.fg("accent", spinnerFrame()); - if (job.status === "queued") return theme.fg("muted", "◦"); - if (job.status === "complete") return theme.fg("success", "✓"); - if (job.status === "paused") return theme.fg("warning", "■"); - return theme.fg("error", "✗"); + if (job.status === "running") return theme.fg("accent", spinnerFrame()); + if (job.status === "queued") return theme.fg("muted", "◦"); + if (job.status === "complete") return theme.fg("success", "✓"); + if (job.status === "paused") return theme.fg("warning", "■"); + return theme.fg("error", "✗"); } function widgetStepGlyph(status: AsyncJobStep["status"], theme: Theme): string { - if (status === "running") return theme.fg("accent", spinnerFrame()); - if (status === "complete" || status === "completed") return theme.fg("success", "✓"); - if (status === "failed") return theme.fg("error", "✗"); - if (status === "paused") return theme.fg("warning", "■"); - return theme.fg("muted", "◦"); + if (status === "running") return theme.fg("accent", spinnerFrame()); + if (status === "complete" || status === "completed") + return theme.fg("success", "✓"); + if (status === "failed") return theme.fg("error", "✗"); + if (status === "paused") return theme.fg("warning", "■"); + return theme.fg("muted", "◦"); } -function widgetStepStatus(status: AsyncJobStep["status"], theme: Theme): string { - if (status === "running") return theme.fg("accent", "running"); - if (status === "complete" || status === "completed") return theme.fg("success", "complete"); - if (status === "failed") return theme.fg("error", "failed"); - if (status === "paused") return theme.fg("warning", "paused"); - return theme.fg("dim", status); +function widgetStepStatus( + status: AsyncJobStep["status"], + theme: Theme, +): string { + if (status === "running") return theme.fg("accent", "running"); + if (status === "complete" || status === "completed") + return theme.fg("success", "complete"); + if (status === "failed") return theme.fg("error", "failed"); + if (status === "paused") return theme.fg("warning", "paused"); + return theme.fg("dim", status); } -function widgetStepActivity(step: NonNullable[number]): string { - const facts: string[] = []; - if (step.currentTool && step.currentToolStartedAt !== undefined) facts.push(`${step.currentTool} ${formatDuration(Math.max(0, Date.now() - step.currentToolStartedAt))}`); - else if (step.currentTool) facts.push(step.currentTool); - if (step.currentPath) facts.push(shortenPath(step.currentPath)); - if (step.turnCount !== undefined) facts.push(`${step.turnCount} turns`); - if (step.toolCount !== undefined) facts.push(`${step.toolCount} tools`); - if (step.tokens?.total) facts.push(formatTokenStat(step.tokens.total)); - const activity = formatActivityLabel(step.lastActivityAt, step.activityState); - if (activity && facts.length) return `${activity} · ${facts.join(" · ")}`; - if (activity) return activity; - return facts.join(" · "); +function widgetStepActivity( + step: NonNullable[number], +): string { + const facts: string[] = []; + if (step.currentTool && step.currentToolStartedAt !== undefined) + facts.push( + `${step.currentTool} ${formatDuration(Math.max(0, Date.now() - step.currentToolStartedAt))}`, + ); + else if (step.currentTool) facts.push(step.currentTool); + if (step.currentPath) facts.push(shortenPath(step.currentPath)); + if (step.turnCount !== undefined) facts.push(`${step.turnCount} turns`); + if (step.toolCount !== undefined) facts.push(`${step.toolCount} tools`); + if (step.tokens?.total) facts.push(formatTokenStat(step.tokens.total)); + const activity = formatActivityLabel(step.lastActivityAt, step.activityState); + if (activity && facts.length) return `${activity} · ${facts.join(" · ")}`; + if (activity) return activity; + return facts.join(" · "); } - -function widgetChainDetails(job: AsyncJobState, theme: Theme, expanded = false, width = getTermWidth()): string[] { - if (!job.steps?.length) return []; - const total = job.chainStepCount ?? job.steps.length; - const lines: string[] = []; - for (const span of buildAsyncChainStepSpans(total, job.steps.length, job.parallelGroups)) { - const steps = job.steps.slice(span.start, span.start + span.count); - if (span.isParallel) { - const status = aggregateStepStatus(steps); - lines.push(` ${widgetStepGlyph(status, theme)} Step ${span.stepIndex + 1}/${total}: ${themeBold(theme, "parallel group")} ${theme.fg("dim", "·")} ${theme.fg("dim", formatParallelOutcome(steps, span.count))}`); - continue; - } - const step = steps[0]; - if (!step) { - lines.push(` ${theme.fg("dim", `◦ Step ${span.stepIndex + 1}/${total}: pending`)}`); - continue; - } - lines.push(...foregroundStyleWidgetStepLines(job, theme, step, "Step", span.stepIndex + 1, total, expanded, width)); - } - return lines; +function widgetChainDetails( + job: AsyncJobState, + theme: Theme, + expanded = false, + width = getTermWidth(), +): string[] { + if (!job.steps?.length) return []; + const total = job.chainStepCount ?? job.steps.length; + const lines: string[] = []; + for (const span of buildAsyncChainStepSpans( + total, + job.steps.length, + job.parallelGroups, + )) { + const steps = job.steps.slice(span.start, span.start + span.count); + if (span.isParallel) { + const status = aggregateStepStatus(steps); + lines.push( + ` ${widgetStepGlyph(status, theme)} Step ${span.stepIndex + 1}/${total}: ${themeBold(theme, "parallel group")} ${theme.fg("dim", "·")} ${theme.fg("dim", formatParallelOutcome(steps, span.count))}`, + ); + continue; + } + const step = steps[0]; + if (!step) { + lines.push( + ` ${theme.fg("dim", `◦ Step ${span.stepIndex + 1}/${total}: pending`)}`, + ); + continue; + } + lines.push( + ...foregroundStyleWidgetStepLines( + job, + theme, + step, + "Step", + span.stepIndex + 1, + total, + expanded, + width, + ), + ); + } + return lines; } -function widgetParallelAgentDetails(job: AsyncJobState, theme: Theme): string[] { - if (!job.steps?.length) return []; - if (job.mode !== "parallel" && job.mode !== "chain") return []; - if (job.mode === "chain" && !job.activeParallelGroup && job.parallelGroups?.length) return widgetChainDetails(job, theme); - const total = job.stepsTotal ?? job.steps.length; - return job.steps.map((step, index) => { - const marker = index === job.steps!.length - 1 ? "└" : "├"; - const activity = widgetStepActivity(step); - const itemTitle = job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step"; - const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking); - return ` ${theme.fg("dim", `${marker} ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index + 1}/${total}: ${step.agent} · ${widgetStepStatus(step.status, theme)}${modelDisplay}${activity ? ` · ${activity}` : ""}`)}`; - }); +function widgetParallelAgentDetails( + job: AsyncJobState, + theme: Theme, +): string[] { + if (!job.steps?.length) return []; + if (job.mode !== "parallel" && job.mode !== "chain") return []; + if ( + job.mode === "chain" && + !job.activeParallelGroup && + job.parallelGroups?.length + ) + return widgetChainDetails(job, theme); + const total = job.stepsTotal ?? job.steps.length; + return job.steps.map((step, index) => { + const marker = index === job.steps!.length - 1 ? "└" : "├"; + const activity = widgetStepActivity(step); + const itemTitle = + job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step"; + const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking); + return ` ${theme.fg("dim", `${marker} ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index + 1}/${total}: ${step.agent} · ${widgetStepStatus(step.status, theme)}${modelDisplay}${activity ? ` · ${activity}` : ""}`)}`; + }); } -function parseParallelGroupAgentCount(label: string | undefined): number | undefined { - if (!label || !label.startsWith("[") || !label.endsWith("]")) return undefined; - const inner = label.slice(1, -1).trim(); - if (!inner) return 0; - return inner.split("+").map((part) => part.trim()).filter(Boolean).length; +function parseParallelGroupAgentCount( + label: string | undefined, +): number | undefined { + if (!label || !label.startsWith("[") || !label.endsWith("]")) + return undefined; + const inner = label.slice(1, -1).trim(); + if (!inner) return 0; + return inner + .split("+") + .map((part) => part.trim()) + .filter(Boolean).length; } -function isChainParallelGroupActive(details: Pick): boolean { - if (details.mode !== "chain") return false; - if (details.currentStepIndex === undefined) return false; - const currentLabel = details.chainAgents?.[details.currentStepIndex]; - return parseParallelGroupAgentCount(currentLabel) !== undefined; +function isChainParallelGroupActive( + details: Pick, +): boolean { + if (details.mode !== "chain") return false; + if (details.currentStepIndex === undefined) return false; + const currentLabel = details.chainAgents?.[details.currentStepIndex]; + return parseParallelGroupAgentCount(currentLabel) !== undefined; } interface ChainStepSpan { - stepIndex: number; - start: number; - count: number; - isParallel: boolean; + stepIndex: number; + start: number; + count: number; + isParallel: boolean; } -function buildChainStepSpans(chainAgents: string[] | undefined): ChainStepSpan[] { - if (!chainAgents?.length) return []; - const spans: ChainStepSpan[] = []; - let start = 0; - for (let stepIndex = 0; stepIndex < chainAgents.length; stepIndex++) { - const label = chainAgents[stepIndex]!; - const parsedCount = parseParallelGroupAgentCount(label); - const count = parsedCount ?? 1; - spans.push({ stepIndex, start, count, isParallel: parsedCount !== undefined }); - start += count; - } - return spans; +function buildChainStepSpans( + chainAgents: string[] | undefined, +): ChainStepSpan[] { + if (!chainAgents?.length) return []; + const spans: ChainStepSpan[] = []; + let start = 0; + for (let stepIndex = 0; stepIndex < chainAgents.length; stepIndex++) { + const label = chainAgents[stepIndex]!; + const parsedCount = parseParallelGroupAgentCount(label); + const count = parsedCount ?? 1; + spans.push({ + stepIndex, + start, + count, + isParallel: parsedCount !== undefined, + }); + start += count; + } + return spans; } -function buildAsyncChainStepSpans(total: number, stepCount: number, parallelGroups: AsyncParallelGroupStatus[] = []): ChainStepSpan[] { - const spans: ChainStepSpan[] = []; - let flatIndex = 0; - for (let stepIndex = 0; stepIndex < total; stepIndex++) { - const group = parallelGroups.find((candidate) => candidate.stepIndex === stepIndex); - if (group) { - spans.push({ stepIndex, start: group.start, count: group.count, isParallel: true }); - flatIndex = Math.max(flatIndex, group.start + group.count); - continue; - } - spans.push({ stepIndex, start: flatIndex, count: flatIndex < stepCount ? 1 : 0, isParallel: false }); - flatIndex++; - } - return spans; +function buildAsyncChainStepSpans( + total: number, + stepCount: number, + parallelGroups: AsyncParallelGroupStatus[] = [], +): ChainStepSpan[] { + const spans: ChainStepSpan[] = []; + let flatIndex = 0; + for (let stepIndex = 0; stepIndex < total; stepIndex++) { + const group = parallelGroups.find( + (candidate) => candidate.stepIndex === stepIndex, + ); + if (group) { + spans.push({ + stepIndex, + start: group.start, + count: group.count, + isParallel: true, + }); + flatIndex = Math.max(flatIndex, group.start + group.count); + continue; + } + spans.push({ + stepIndex, + start: flatIndex, + count: flatIndex < stepCount ? 1 : 0, + isParallel: false, + }); + flatIndex++; + } + return spans; } function isDoneResult(result: Details["results"][number]): boolean { - const status = result.progress?.status; - if (status === "completed") return true; - if (status === "running" || status === "pending") return false; - if (result.interrupted || result.detached) return false; - return result.exitCode === 0; + const status = result.progress?.status; + if (status === "completed") return true; + if (status === "running" || status === "pending") return false; + if (result.interrupted || result.detached) return false; + return result.exitCode === 0; } interface MultiProgressLabel { - headerLabel: string; - itemTitle: "Step" | "Agent"; - totalCount: number; - hasParallelInChain: boolean; - activeParallelGroup: boolean; - groupStartIndex: number; - groupEndIndex: number; - showActiveGroupOnly: boolean; + headerLabel: string; + itemTitle: "Step" | "Agent"; + totalCount: number; + hasParallelInChain: boolean; + activeParallelGroup: boolean; + groupStartIndex: number; + groupEndIndex: number; + showActiveGroupOnly: boolean; } -function buildMultiProgressLabel(details: Pick, hasRunning: boolean): MultiProgressLabel { - const stepSpans = buildChainStepSpans(details.chainAgents); - const hasParallelInChain = details.mode === "chain" && stepSpans.some((span) => span.isParallel); - const activeParallelGroup = isChainParallelGroupActive(details); - const itemTitle: "Step" | "Agent" = details.mode === "parallel" || activeParallelGroup ? "Agent" : "Step"; - - if (details.mode === "parallel") { - const totalCount = details.totalSteps ?? details.results.length; - const statuses = new Array(totalCount).fill("pending") as Array<"pending" | "running" | "completed" | "failed" | "detached">; - for (const progress of details.progress ?? []) { - if (progress.index >= 0 && progress.index < totalCount) statuses[progress.index] = progress.status; - } - for (let i = 0; i < details.results.length; i++) { - const result = details.results[i]!; - const progressFromArray = details.progress?.find((progress) => progress.index === i) - || details.progress?.find((progress) => progress.agent === result.agent && progress.status === "running"); - const index = result.progress?.index ?? progressFromArray?.index ?? i; - if (index < 0 || index >= totalCount) continue; - const status = result.progress?.status - ?? (result.interrupted || result.detached - ? "detached" - : result.exitCode === 0 - ? "completed" - : "failed"); - statuses[index] = status; - } - const running = statuses.filter((status) => status === "running").length; - const done = statuses.filter((status) => status === "completed").length; - const headerLabel = hasRunning - ? `${formatAgentRunningLabel(running)} · ${done}/${totalCount} done` - : `${done}/${totalCount} done`; - return { headerLabel, itemTitle, totalCount, hasParallelInChain, activeParallelGroup, groupStartIndex: 0, groupEndIndex: totalCount, showActiveGroupOnly: false }; - } - - if (activeParallelGroup) { - const currentStepIndex = details.currentStepIndex!; - const span = stepSpans[currentStepIndex]; - const groupSize = span?.count ?? 1; - const groupStart = span?.start ?? 0; - const groupEnd = groupStart + groupSize; - let running = 0; - let done = 0; - for (let index = groupStart; index < groupEnd; index++) { - const progressEntry = details.progress?.find((progress) => progress.index === index); - const resultEntry = details.results.find((result) => result.progress?.index === index); - if (progressEntry?.status === "running") { - running++; - continue; - } - if (progressEntry?.status === "completed") { - done++; - continue; - } - if (resultEntry && isDoneResult(resultEntry)) done++; - } - const totalSteps = details.totalSteps ?? details.chainAgents?.length ?? 1; - const headerLabel = hasRunning - ? `step ${currentStepIndex + 1}/${totalSteps} · parallel group: ${formatAgentRunningLabel(running)} · ${done}/${groupSize} done` - : `step ${currentStepIndex + 1}/${totalSteps} · parallel group: ${done}/${groupSize} done`; - return { headerLabel, itemTitle, totalCount: groupSize, hasParallelInChain, activeParallelGroup, groupStartIndex: groupStart, groupEndIndex: groupEnd, showActiveGroupOnly: true }; - } - - if (details.mode === "chain" && details.chainAgents?.length) { - const totalCount = details.totalSteps ?? details.chainAgents.length; - const doneLogical = stepSpans.filter((span) => { - for (let index = span.start; index < span.start + span.count; index++) { - const progressEntry = details.progress?.find((progress) => progress.index === index); - const resultEntry = details.results.find((result) => result.progress?.index === index) ?? details.results[index]; - if (progressEntry?.status === "running" || progressEntry?.status === "pending") return false; - if (resultEntry && !isDoneResult(resultEntry)) return false; - } - return true; - }).length; - const currentStep = details.currentStepIndex !== undefined ? details.currentStepIndex + 1 : Math.min(totalCount, doneLogical + (hasRunning ? 1 : 0)); - const headerLabel = hasRunning ? `step ${currentStep}/${totalCount}` : `step ${doneLogical}/${totalCount}`; - return { headerLabel, itemTitle, totalCount, hasParallelInChain, activeParallelGroup, groupStartIndex: 0, groupEndIndex: details.results.length, showActiveGroupOnly: false }; - } - - const totalCount = details.totalSteps ?? details.results.length; - const currentStep = details.currentStepIndex !== undefined ? details.currentStepIndex + 1 : Math.min(totalCount, details.results.filter(isDoneResult).length + (hasRunning ? 1 : 0)); - const done = details.results.filter(isDoneResult).length; - const headerLabel = hasRunning ? `step ${currentStep}/${totalCount}` : `step ${done}/${totalCount}`; - return { headerLabel, itemTitle, totalCount, hasParallelInChain, activeParallelGroup, groupStartIndex: 0, groupEndIndex: details.results.length, showActiveGroupOnly: false }; +function buildMultiProgressLabel( + details: Pick< + Details, + | "mode" + | "results" + | "progress" + | "totalSteps" + | "currentStepIndex" + | "chainAgents" + >, + hasRunning: boolean, +): MultiProgressLabel { + const stepSpans = buildChainStepSpans(details.chainAgents); + const hasParallelInChain = + details.mode === "chain" && stepSpans.some((span) => span.isParallel); + const activeParallelGroup = isChainParallelGroupActive(details); + const itemTitle: "Step" | "Agent" = + details.mode === "parallel" || activeParallelGroup ? "Agent" : "Step"; + + if (details.mode === "parallel") { + const totalCount = details.totalSteps ?? details.results.length; + const statuses = new Array(totalCount).fill("pending") as Array< + "pending" | "running" | "completed" | "failed" | "detached" + >; + for (const progress of details.progress ?? []) { + if (progress.index >= 0 && progress.index < totalCount) + statuses[progress.index] = progress.status; + } + for (let i = 0; i < details.results.length; i++) { + const result = details.results[i]!; + const progressFromArray = + details.progress?.find((progress) => progress.index === i) || + details.progress?.find( + (progress) => + progress.agent === result.agent && progress.status === "running", + ); + const index = result.progress?.index ?? progressFromArray?.index ?? i; + if (index < 0 || index >= totalCount) continue; + const status = + result.progress?.status ?? + (result.interrupted || result.detached + ? "detached" + : result.exitCode === 0 + ? "completed" + : "failed"); + statuses[index] = status; + } + const running = statuses.filter((status) => status === "running").length; + const done = statuses.filter((status) => status === "completed").length; + const headerLabel = hasRunning + ? `${formatAgentRunningLabel(running)} · ${done}/${totalCount} done` + : `${done}/${totalCount} done`; + return { + headerLabel, + itemTitle, + totalCount, + hasParallelInChain, + activeParallelGroup, + groupStartIndex: 0, + groupEndIndex: totalCount, + showActiveGroupOnly: false, + }; + } + + if (activeParallelGroup) { + const currentStepIndex = details.currentStepIndex!; + const span = stepSpans[currentStepIndex]; + const groupSize = span?.count ?? 1; + const groupStart = span?.start ?? 0; + const groupEnd = groupStart + groupSize; + let running = 0; + let done = 0; + for (let index = groupStart; index < groupEnd; index++) { + const progressEntry = details.progress?.find( + (progress) => progress.index === index, + ); + const resultEntry = details.results.find( + (result) => result.progress?.index === index, + ); + if (progressEntry?.status === "running") { + running++; + continue; + } + if (progressEntry?.status === "completed") { + done++; + continue; + } + if (resultEntry && isDoneResult(resultEntry)) done++; + } + const totalSteps = details.totalSteps ?? details.chainAgents?.length ?? 1; + const headerLabel = hasRunning + ? `step ${currentStepIndex + 1}/${totalSteps} · parallel group: ${formatAgentRunningLabel(running)} · ${done}/${groupSize} done` + : `step ${currentStepIndex + 1}/${totalSteps} · parallel group: ${done}/${groupSize} done`; + return { + headerLabel, + itemTitle, + totalCount: groupSize, + hasParallelInChain, + activeParallelGroup, + groupStartIndex: groupStart, + groupEndIndex: groupEnd, + showActiveGroupOnly: true, + }; + } + + if (details.mode === "chain" && details.chainAgents?.length) { + const totalCount = details.totalSteps ?? details.chainAgents.length; + const doneLogical = stepSpans.filter((span) => { + for (let index = span.start; index < span.start + span.count; index++) { + const progressEntry = details.progress?.find( + (progress) => progress.index === index, + ); + const resultEntry = + details.results.find((result) => result.progress?.index === index) ?? + details.results[index]; + if ( + progressEntry?.status === "running" || + progressEntry?.status === "pending" + ) + return false; + if (resultEntry && !isDoneResult(resultEntry)) return false; + } + return true; + }).length; + const currentStep = + details.currentStepIndex !== undefined + ? details.currentStepIndex + 1 + : Math.min(totalCount, doneLogical + (hasRunning ? 1 : 0)); + const headerLabel = hasRunning + ? `step ${currentStep}/${totalCount}` + : `step ${doneLogical}/${totalCount}`; + return { + headerLabel, + itemTitle, + totalCount, + hasParallelInChain, + activeParallelGroup, + groupStartIndex: 0, + groupEndIndex: details.results.length, + showActiveGroupOnly: false, + }; + } + + const totalCount = details.totalSteps ?? details.results.length; + const currentStep = + details.currentStepIndex !== undefined + ? details.currentStepIndex + 1 + : Math.min( + totalCount, + details.results.filter(isDoneResult).length + (hasRunning ? 1 : 0), + ); + const done = details.results.filter(isDoneResult).length; + const headerLabel = hasRunning + ? `step ${currentStep}/${totalCount}` + : `step ${done}/${totalCount}`; + return { + headerLabel, + itemTitle, + totalCount, + hasParallelInChain, + activeParallelGroup, + groupStartIndex: 0, + groupEndIndex: details.results.length, + showActiveGroupOnly: false, + }; } -function resultRowLabel(details: Pick, label: MultiProgressLabel, resultIndex: number, stepNumber: number): string { - if (details.mode === "chain" && label.hasParallelInChain) { - const span = buildChainStepSpans(details.chainAgents).find((candidate) => resultIndex >= candidate.start && resultIndex < candidate.start + candidate.count); - if (span?.isParallel) return `Agent ${resultIndex - span.start + 1}/${span.count}`; - if (span) return `Step ${span.stepIndex + 1}`; - } - if (label.itemTitle === "Agent") { - const localStepNumber = label.activeParallelGroup - ? Math.max(1, stepNumber - label.groupStartIndex) - : stepNumber; - return `Agent ${localStepNumber}/${label.totalCount}`; - } - return `Step ${stepNumber}`; +function resultRowLabel( + details: Pick, + label: MultiProgressLabel, + resultIndex: number, + stepNumber: number, +): string { + if (details.mode === "chain" && label.hasParallelInChain) { + const span = buildChainStepSpans(details.chainAgents).find( + (candidate) => + resultIndex >= candidate.start && + resultIndex < candidate.start + candidate.count, + ); + if (span?.isParallel) + return `Agent ${resultIndex - span.start + 1}/${span.count}`; + if (span) return `Step ${span.stepIndex + 1}`; + } + if (label.itemTitle === "Agent") { + const localStepNumber = label.activeParallelGroup + ? Math.max(1, stepNumber - label.groupStartIndex) + : stepNumber; + return `Agent ${localStepNumber}/${label.totalCount}`; + } + return `Step ${stepNumber}`; } function widgetStats(job: AsyncJobState, theme: Theme): string { - const parts: string[] = []; - const stepsTotal = job.stepsTotal ?? (job.agents?.length ?? 1); - if (job.activeParallelGroup) { - const running = job.runningSteps ?? (job.status === "running" ? 1 : 0); - const done = job.completedSteps ?? (job.status === "complete" ? stepsTotal : 0); - if (job.mode === "parallel") { - if (job.status === "running" && running > 0) parts.push(formatAgentRunningLabel(running)); - if (stepsTotal > 0) parts.push(`${done}/${stepsTotal} done`); - } else { - const activeGroup = job.currentStep !== undefined - ? job.parallelGroups?.find((group) => job.currentStep! >= group.start && job.currentStep! < group.start + group.count) - : job.parallelGroups?.find((group) => group.start === 0); - const logicalStep = activeGroup?.stepIndex ?? job.currentStep ?? 0; - const total = job.chainStepCount ?? stepsTotal; - const groupParts = [`${done}/${stepsTotal} done`]; - if (job.status === "running" && running > 0) groupParts.unshift(formatAgentRunningLabel(running)); - parts.push(`step ${logicalStep + 1}/${total} · parallel group: ${groupParts.join(" · ")}`); - } - } else if (job.currentStep !== undefined) { - if (job.mode === "chain" && job.parallelGroups?.length) { - const total = job.chainStepCount ?? stepsTotal; - parts.push(`step ${flatToLogicalStepIndex(job.currentStep, total, job.parallelGroups) + 1}/${total}`); - } else { - parts.push(`step ${job.currentStep + 1}/${stepsTotal}`); - } - } else if (stepsTotal > 1) { - parts.push(`steps ${stepsTotal}`); - } - if (job.toolCount !== undefined) parts.push(formatToolUseStat(job.toolCount)); - if (job.totalTokens?.total) parts.push(formatTokenStat(job.totalTokens.total)); - const endTime = job.status === "complete" || job.status === "failed" || job.status === "paused" ? (job.updatedAt ?? Date.now()) : Date.now(); - if (job.startedAt) parts.push(formatDuration(Math.max(0, endTime - job.startedAt))); - return statJoin(theme, parts); + const parts: string[] = []; + const stepsTotal = job.stepsTotal ?? job.agents?.length ?? 1; + if (job.activeParallelGroup) { + const running = job.runningSteps ?? (job.status === "running" ? 1 : 0); + const done = + job.completedSteps ?? (job.status === "complete" ? stepsTotal : 0); + if (job.mode === "parallel") { + if (job.status === "running" && running > 0) + parts.push(formatAgentRunningLabel(running)); + if (stepsTotal > 0) parts.push(`${done}/${stepsTotal} done`); + } else { + const activeGroup = + job.currentStep !== undefined + ? job.parallelGroups?.find( + (group) => + job.currentStep! >= group.start && + job.currentStep! < group.start + group.count, + ) + : job.parallelGroups?.find((group) => group.start === 0); + const logicalStep = activeGroup?.stepIndex ?? job.currentStep ?? 0; + const total = job.chainStepCount ?? stepsTotal; + const groupParts = [`${done}/${stepsTotal} done`]; + if (job.status === "running" && running > 0) + groupParts.unshift(formatAgentRunningLabel(running)); + parts.push( + `step ${logicalStep + 1}/${total} · parallel group: ${groupParts.join(" · ")}`, + ); + } + } else if (job.currentStep !== undefined) { + if (job.mode === "chain" && job.parallelGroups?.length) { + const total = job.chainStepCount ?? stepsTotal; + parts.push( + `step ${flatToLogicalStepIndex(job.currentStep, total, job.parallelGroups) + 1}/${total}`, + ); + } else { + parts.push(`step ${job.currentStep + 1}/${stepsTotal}`); + } + } else if (stepsTotal > 1) { + parts.push(`steps ${stepsTotal}`); + } + if (job.toolCount !== undefined) parts.push(formatToolUseStat(job.toolCount)); + if (job.totalTokens?.total) + parts.push(formatTokenStat(job.totalTokens.total)); + const endTime = + job.status === "complete" || + job.status === "failed" || + job.status === "paused" + ? (job.updatedAt ?? Date.now()) + : Date.now(); + if (job.startedAt) + parts.push(formatDuration(Math.max(0, endTime - job.startedAt))); + return statJoin(theme, parts); } -function widgetStepStats(theme: Theme, step: NonNullable[number]): string { - return statJoin(theme, [ - step.turnCount !== undefined ? `${step.turnCount} turns` : "", - step.toolCount !== undefined ? formatToolUseStat(step.toolCount) : "", - step.tokens?.total ? formatTokenStat(step.tokens.total) : "", - step.durationMs !== undefined ? formatDuration(step.durationMs) : "", - ]); +function widgetStepStats( + theme: Theme, + step: NonNullable[number], +): string { + return statJoin(theme, [ + step.turnCount !== undefined ? `${step.turnCount} turns` : "", + step.toolCount !== undefined ? formatToolUseStat(step.toolCount) : "", + step.tokens?.total ? formatTokenStat(step.tokens.total) : "", + step.durationMs !== undefined ? formatDuration(step.durationMs) : "", + ]); } -function modelThinkingBadge(theme: Theme, model?: string, thinking?: string): string { - const label = formatModelThinking(model, thinking); - return label ? theme.fg("dim", ` (${label})`) : ""; +function modelThinkingBadge( + theme: Theme, + model?: string, + thinking?: string, +): string { + const label = formatModelThinking(model, thinking); + return label ? theme.fg("dim", ` (${label})`) : ""; } -function widgetStepActivityLine(step: NonNullable[number], width: number, expanded: boolean): string { - const toolLine = formatCurrentToolLine(step, width, expanded); - if (toolLine) return toolLine; - const activity = formatActivityLabel(step.lastActivityAt, step.activityState); - if (activity) return activity; - if (step.status === "running") return "thinking…"; - return ""; +function widgetStepActivityLine( + step: NonNullable[number], + width: number, + expanded: boolean, +): string { + const toolLine = formatCurrentToolLine(step, width, expanded); + if (toolLine) return toolLine; + const activity = formatActivityLabel(step.lastActivityAt, step.activityState); + if (activity) return activity; + if (step.status === "running") return "thinking…"; + return ""; } -function widgetOutputPath(job: AsyncJobState, step: NonNullable[number]): string | undefined { - if (typeof step.index !== "number") return undefined; - return path.join(job.asyncDir, `output-${step.index}.log`); +function widgetOutputPath( + job: AsyncJobState, + step: NonNullable[number], +): string | undefined { + if (typeof step.index !== "number") return undefined; + return path.join(job.asyncDir, `output-${step.index}.log`); } function foregroundStyleWidgetStepLines( - job: AsyncJobState, - theme: Theme, - step: NonNullable[number], - itemTitle: "Agent" | "Step", - index: number, - total: number, - expanded: boolean, - width: number, + job: AsyncJobState, + theme: Theme, + step: NonNullable[number], + itemTitle: "Agent" | "Step", + index: number, + total: number, + expanded: boolean, + width: number, ): string[] { - const status = widgetStepStatus(step.status, theme); - const stats = widgetStepStats(theme, step); - const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking); - const lines = [` ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`]; - const activity = widgetStepActivityLine(step, width, expanded); - if (activity) lines.push(` ${theme.fg("dim", `⎿ ${activity}`)}`); - if (step.status === "running") { - if (!expanded) lines.push(` ${theme.fg("accent", "Press Ctrl+O for live detail")}`); - const output = widgetOutputPath(job, step); - if (output) lines.push(` ${theme.fg("dim", `output: ${shortenPath(output)}`)}`); - if (expanded) { - const liveStatus = buildLiveStatusLine(step); - if (liveStatus && liveStatus !== activity) lines.push(` ${theme.fg("accent", liveStatus)}`); - for (const tool of step.recentTools?.slice(-3) ?? []) { - const maxArgsLen = Math.max(40, width - 30); - const argsPreview = tool.args.length <= maxArgsLen ? tool.args : `${tool.args.slice(0, maxArgsLen)}...`; - lines.push(` ${theme.fg("dim", `${tool.tool}${argsPreview ? `: ${argsPreview}` : ""}`)}`); - } - for (const line of step.recentOutput?.slice(-5) ?? []) { - lines.push(` ${theme.fg("dim", line)}`); - } - } - } - return lines; + const status = widgetStepStatus(step.status, theme); + const stats = widgetStepStats(theme, step); + const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking); + const lines = [ + ` ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, + ]; + const activity = widgetStepActivityLine(step, width, expanded); + if (activity) lines.push(` ${theme.fg("dim", `⎿ ${activity}`)}`); + if (step.status === "running") { + if (!expanded) + lines.push(` ${theme.fg("accent", "Press ctrl+o for live detail")}`); + const output = widgetOutputPath(job, step); + if (output) + lines.push(` ${theme.fg("dim", `output: ${shortenPath(output)}`)}`); + if (expanded) { + const liveStatus = buildLiveStatusLine(step); + if (liveStatus && liveStatus !== activity) + lines.push(` ${theme.fg("accent", liveStatus)}`); + for (const tool of step.recentTools?.slice(-3) ?? []) { + const maxArgsLen = Math.max(40, width - 30); + const argsPreview = + tool.args.length <= maxArgsLen + ? tool.args + : `${tool.args.slice(0, maxArgsLen)}...`; + lines.push( + ` ${theme.fg("dim", `${tool.tool}${argsPreview ? `: ${argsPreview}` : ""}`)}`, + ); + } + for (const line of step.recentOutput?.slice(-5) ?? []) { + lines.push(` ${theme.fg("dim", line)}`); + } + } + } + return lines; } -function foregroundStyleWidgetDetails(job: AsyncJobState, theme: Theme, expanded: boolean, width: number): string[] { - if (!job.steps?.length) return [` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`]; - if (job.mode === "chain" && !job.activeParallelGroup && job.parallelGroups?.length) return widgetChainDetails(job, theme, expanded, width); - const total = job.stepsTotal ?? job.steps.length; - const itemTitle = job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step"; - const lines: string[] = []; - for (const [index, step] of job.steps.entries()) { - lines.push(...foregroundStyleWidgetStepLines(job, theme, step, itemTitle, index + 1, total, expanded, width)); - } - return lines; +function foregroundStyleWidgetDetails( + job: AsyncJobState, + theme: Theme, + expanded: boolean, + width: number, +): string[] { + if (!job.steps?.length) + return [` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`]; + if ( + job.mode === "chain" && + !job.activeParallelGroup && + job.parallelGroups?.length + ) + return widgetChainDetails(job, theme, expanded, width); + const total = job.stepsTotal ?? job.steps.length; + const itemTitle = + job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step"; + const lines: string[] = []; + for (const [index, step] of job.steps.entries()) { + lines.push( + ...foregroundStyleWidgetStepLines( + job, + theme, + step, + itemTitle, + index + 1, + total, + expanded, + width, + ), + ); + } + return lines; } -function buildSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number, expanded: boolean): string[] { - const stats = widgetStats(job, theme); - const count = job.mode === "chain" ? job.chainStepCount : job.stepsTotal ?? job.agents?.length ?? job.steps?.length; - const mode = widgetJobName(job); - const title = `async subagent ${mode}${count && count > 1 ? ` (${count})` : ""}`; - return [ - `${theme.fg("toolTitle", themeBold(theme, title))} ${theme.fg("dim", "· background")}`, - `${widgetStatusGlyph(job, theme)} ${themeBold(theme, mode)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, - ...foregroundStyleWidgetDetails(job, theme, expanded, width), - ].map((line) => truncLine(line, width)); +function buildSingleWidgetLines( + job: AsyncJobState, + theme: Theme, + width: number, + expanded: boolean, +): string[] { + const stats = widgetStats(job, theme); + const count = + job.mode === "chain" + ? job.chainStepCount + : (job.stepsTotal ?? job.agents?.length ?? job.steps?.length); + const mode = widgetJobName(job); + const title = `async subagent ${mode}${count && count > 1 ? ` (${count})` : ""}`; + return [ + `${theme.fg("toolTitle", themeBold(theme, title))} ${theme.fg("dim", "· background")}`, + `${widgetStatusGlyph(job, theme)} ${themeBold(theme, mode)}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, + ...foregroundStyleWidgetDetails(job, theme, expanded, width), + ].map((line) => truncLine(line, width)); } -function compactSingleWidgetLines(job: AsyncJobState, theme: Theme, width: number): string[] { - const fullLines = buildSingleWidgetLines(job, theme, width, false); - if (fullLines.length <= 10 || !job.steps?.length || (job.mode !== "parallel" && !job.activeParallelGroup)) return fullLines; - - const total = job.stepsTotal ?? job.steps.length; - const itemTitle = job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step"; - const lines = fullLines.slice(0, 2); - for (const [index, step] of job.steps.entries()) { - const status = widgetStepStatus(step.status, theme); - const activity = widgetStepActivityLine(step, width, false); - const stepStats = widgetStepStats(theme, step); - const activitySuffix = activity ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", activity)}` : ""; - const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking); - lines.push(` ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${activitySuffix}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}`); - } - if (job.steps.some((step) => step.status === "running")) lines.push(theme.fg("accent", " Press Ctrl+O for live detail")); - return lines.map((line) => truncLine(line, width)); +function compactSingleWidgetLines( + job: AsyncJobState, + theme: Theme, + width: number, +): string[] { + const fullLines = buildSingleWidgetLines(job, theme, width, false); + if ( + fullLines.length <= 10 || + !job.steps?.length || + (job.mode !== "parallel" && !job.activeParallelGroup) + ) + return fullLines; + + const total = job.stepsTotal ?? job.steps.length; + const itemTitle = + job.mode === "parallel" || job.activeParallelGroup ? "Agent" : "Step"; + const lines = fullLines.slice(0, 2); + for (const [index, step] of job.steps.entries()) { + const status = widgetStepStatus(step.status, theme); + const activity = widgetStepActivityLine(step, width, false); + const stepStats = widgetStepStats(theme, step); + const activitySuffix = activity + ? ` ${theme.fg("dim", "·")} ${theme.fg("dim", activity)}` + : ""; + const modelDisplay = modelThinkingBadge(theme, step.model, step.thinking); + lines.push( + ` ${widgetStepGlyph(step.status, theme)} ${itemTitle} ${index + 1}/${total}: ${themeBold(theme, step.agent)} ${theme.fg("dim", "·")} ${status}${modelDisplay}${activitySuffix}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}`, + ); + } + if (job.steps.some((step) => step.status === "running")) + lines.push(theme.fg("accent", " Press ctrl+o for live detail")); + return lines.map((line) => truncLine(line, width)); } -function fitWidgetLineBudget(lines: string[], theme: Theme, width: number, expanded: boolean): string[] { - const rows = process.stdout.rows || 30; - const budget = expanded - ? Math.max(12, Math.min(24, Math.floor(rows * 0.55))) - : Math.max(10, Math.min(14, Math.floor(rows * 0.35))); - if (lines.length <= budget) return lines; - const visibleLines = Math.max(1, budget - 1); - const hiddenCount = lines.length - visibleLines; - const hint = expanded - ? `… ${hiddenCount} live-detail lines hidden` - : `… ${hiddenCount} lines hidden · Ctrl+O expands`; - return [...lines.slice(0, visibleLines), truncLine(theme.fg("dim", hint), width)]; +function fitWidgetLineBudget( + lines: string[], + theme: Theme, + width: number, + expanded: boolean, +): string[] { + const rows = process.stdout.rows || 30; + const budget = expanded + ? Math.max(12, Math.min(24, Math.floor(rows * 0.55))) + : Math.max(10, Math.min(14, Math.floor(rows * 0.35))); + if (lines.length <= budget) return lines; + const visibleLines = Math.max(1, budget - 1); + const hiddenCount = lines.length - visibleLines; + const hint = expanded + ? `… ${hiddenCount} live-detail lines hidden` + : `… ${hiddenCount} lines hidden · Ctrl+O expands`; + return [ + ...lines.slice(0, visibleLines), + truncLine(theme.fg("dim", hint), width), + ]; } -function buildWidgetComponent(jobs: AsyncJobState[], expanded: boolean): (_tui: unknown, theme: Theme) => Component { - return (_tui, theme) => { - const width = getTermWidth(); - const lines = expanded - ? buildWidgetLines(jobs, theme, width, true) - : jobs.length === 1 - ? compactSingleWidgetLines(jobs[0]!, theme, width) - : buildWidgetLines(jobs, theme, width, false); - const container = new Container(); - for (const line of fitWidgetLineBudget(lines, theme, width, expanded)) container.addChild(new Text(line, 1, 0)); - return container; - }; +function buildWidgetComponent( + jobs: AsyncJobState[], + expanded: boolean, +): (_tui: unknown, theme: Theme) => Component { + return (_tui, theme) => { + const width = getTermWidth(); + const lines = expanded + ? buildWidgetLines(jobs, theme, width, true) + : jobs.length === 1 + ? compactSingleWidgetLines(jobs[0]!, theme, width) + : buildWidgetLines(jobs, theme, width, false); + const container = new Container(); + for (const line of fitWidgetLineBudget(lines, theme, width, expanded)) + container.addChild(new Text(line, 1, 0)); + return container; + }; } -export function buildWidgetLines(jobs: AsyncJobState[], theme: Theme, width = getTermWidth(), expanded = false): string[] { - if (jobs.length === 0) return []; - if (jobs.length === 1) return buildSingleWidgetLines(jobs[0]!, theme, width, expanded); - const running = jobs.filter((job) => job.status === "running"); - const queued = jobs.filter((job) => job.status === "queued"); - const finished = jobs.filter((job) => job.status !== "running" && job.status !== "queued"); - - const lines: string[] = []; - const hasActive = running.length > 0 || queued.length > 0; - lines.push(truncLine(`${theme.fg(hasActive ? "accent" : "dim", hasActive ? "●" : "○")} ${theme.fg(hasActive ? "accent" : "dim", "Async agents")} ${theme.fg("dim", "· background")}`, width)); - - const items: string[][] = []; - let hiddenRunning = 0; - let hiddenFinished = 0; - let queuedSummaryShown = false; - let slots = MAX_WIDGET_JOBS; - - for (const job of running) { - if (slots <= 0) { hiddenRunning++; continue; } - const stats = widgetStats(job, theme); - items.push([ - `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, - ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`, - ...widgetParallelAgentDetails(job, theme), - ]); - slots--; - } - - if (queued.length > 0 && slots > 0) { - items.push([`${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`]); - queuedSummaryShown = true; - slots--; - } - - for (const job of finished) { - if (slots <= 0) { hiddenFinished++; continue; } - const stats = widgetStats(job, theme); - items.push([ - `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, - ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`, - ...widgetParallelAgentDetails(job, theme), - ]); - slots--; - } - - const hiddenQueued = queued.length > 0 && !queuedSummaryShown ? queued.length : 0; - const hiddenTotal = hiddenRunning + hiddenFinished + hiddenQueued; - if (hiddenTotal > 0) { - const parts: string[] = []; - if (hiddenRunning > 0) parts.push(`${hiddenRunning} running`); - if (hiddenQueued > 0) parts.push(`${hiddenQueued} queued`); - if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`); - items.push([theme.fg("dim", `+${hiddenTotal} more (${parts.join(", ")})`)]); - } - - for (let i = 0; i < items.length; i++) { - const item = items[i]!; - const last = i === items.length - 1; - const branch = last ? "└─" : "├─"; - const continuation = last ? " " : "│ "; - lines.push(truncLine(`${theme.fg("dim", branch)} ${item[0]}`, width)); - for (const detail of item.slice(1)) { - lines.push(truncLine(`${theme.fg("dim", continuation)} ${detail}`, width)); - } - } - - return lines; +export function buildWidgetLines( + jobs: AsyncJobState[], + theme: Theme, + width = getTermWidth(), + expanded = false, +): string[] { + if (jobs.length === 0) return []; + if (jobs.length === 1) + return buildSingleWidgetLines(jobs[0]!, theme, width, expanded); + const running = jobs.filter((job) => job.status === "running"); + const queued = jobs.filter((job) => job.status === "queued"); + const finished = jobs.filter( + (job) => job.status !== "running" && job.status !== "queued", + ); + + const lines: string[] = []; + const hasActive = running.length > 0 || queued.length > 0; + lines.push( + truncLine( + `${theme.fg(hasActive ? "accent" : "dim", hasActive ? "●" : "○")} ${theme.fg(hasActive ? "accent" : "dim", "Async agents")} ${theme.fg("dim", "· background")}`, + width, + ), + ); + + const items: string[][] = []; + let hiddenRunning = 0; + let hiddenFinished = 0; + let queuedSummaryShown = false; + let slots = MAX_WIDGET_JOBS; + + for (const job of running) { + if (slots <= 0) { + hiddenRunning++; + continue; + } + const stats = widgetStats(job, theme); + items.push([ + `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, + ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`, + ...widgetParallelAgentDetails(job, theme), + ]); + slots--; + } + + if (queued.length > 0 && slots > 0) { + items.push([ + `${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`, + ]); + queuedSummaryShown = true; + slots--; + } + + for (const job of finished) { + if (slots <= 0) { + hiddenFinished++; + continue; + } + const stats = widgetStats(job, theme); + items.push([ + `${widgetStatusGlyph(job, theme)} ${themeBold(theme, widgetJobName(job))}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, + ` ${theme.fg("dim", `⎿ ${widgetActivity(job)}`)}`, + ...widgetParallelAgentDetails(job, theme), + ]); + slots--; + } + + const hiddenQueued = + queued.length > 0 && !queuedSummaryShown ? queued.length : 0; + const hiddenTotal = hiddenRunning + hiddenFinished + hiddenQueued; + if (hiddenTotal > 0) { + const parts: string[] = []; + if (hiddenRunning > 0) parts.push(`${hiddenRunning} running`); + if (hiddenQueued > 0) parts.push(`${hiddenQueued} queued`); + if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`); + items.push([theme.fg("dim", `+${hiddenTotal} more (${parts.join(", ")})`)]); + } + + for (let i = 0; i < items.length; i++) { + const item = items[i]!; + const last = i === items.length - 1; + const branch = last ? "└─" : "├─"; + const continuation = last ? " " : "│ "; + lines.push(truncLine(`${theme.fg("dim", branch)} ${item[0]}`, width)); + for (const detail of item.slice(1)) { + lines.push( + truncLine(`${theme.fg("dim", continuation)} ${detail}`, width), + ); + } + } + + return lines; } function refreshAnimatedWidget(): void { - try { - if (!latestWidgetCtx?.hasUI || latestWidgetJobs.length === 0) return; - latestWidgetCtx.ui.setWidget(WIDGET_KEY, buildWidgetComponent(latestWidgetJobs, latestWidgetCtx.ui.getToolsExpanded?.() ?? false)); - latestWidgetCtx.ui.requestRender?.(); - } catch (error) { - if (!isStaleExtensionContextError(error)) throw error; - stopWidgetAnimation(); - } + try { + if (!latestWidgetCtx?.hasUI || latestWidgetJobs.length === 0) return; + latestWidgetCtx.ui.setWidget( + WIDGET_KEY, + buildWidgetComponent( + latestWidgetJobs, + latestWidgetCtx.ui.getToolsExpanded?.() ?? false, + ), + ); + latestWidgetCtx.ui.requestRender?.(); + } catch (error) { + if (!isStaleExtensionContextError(error)) throw error; + stopWidgetAnimation(); + } } function ensureWidgetAnimation(): void { - if (widgetTimer) return; - widgetTimer = setInterval(() => { - if (!hasAnimatedWidgetJobs(latestWidgetJobs)) { - stopWidgetAnimation(); - return; - } - refreshAnimatedWidget(); - }, WIDGET_ANIMATION_MS); - widgetTimer.unref?.(); + if (widgetTimer) return; + widgetTimer = setInterval(() => { + if (!hasAnimatedWidgetJobs(latestWidgetJobs)) { + stopWidgetAnimation(); + return; + } + refreshAnimatedWidget(); + }, WIDGET_ANIMATION_MS); + widgetTimer.unref?.(); } export function stopWidgetAnimation(): void { - if (widgetTimer) { - clearInterval(widgetTimer); - widgetTimer = undefined; - } - latestWidgetCtx = undefined; - latestWidgetJobs = []; - outputActivityCache.clear(); + if (widgetTimer) { + clearInterval(widgetTimer); + widgetTimer = undefined; + } + latestWidgetCtx = undefined; + latestWidgetJobs = []; + outputActivityCache.clear(); } export function stopResultAnimations(): void { - for (const [timer, state] of resultAnimationTimers) { - clearInterval(timer); - state.subagentResultAnimationTimer = undefined; - } - resultAnimationTimers.clear(); + for (const [timer, state] of resultAnimationTimers) { + clearInterval(timer); + state.subagentResultAnimationTimer = undefined; + } + resultAnimationTimers.clear(); } /** * Render the async jobs widget */ -export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void { - if (jobs.length === 0) { - stopWidgetAnimation(); - if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined); - return; - } - if (!ctx.hasUI) { - stopWidgetAnimation(); - return; - } - latestWidgetCtx = ctx; - latestWidgetJobs = [...jobs]; - - ctx.ui.setWidget(WIDGET_KEY, buildWidgetComponent(jobs, ctx.ui.getToolsExpanded?.() ?? false)); - if (hasAnimatedWidgetJobs(jobs)) ensureWidgetAnimation(); - else stopWidgetAnimation(); +export function renderWidget( + ctx: ExtensionContext, + jobs: AsyncJobState[], +): void { + if (jobs.length === 0) { + stopWidgetAnimation(); + if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined); + return; + } + if (!ctx.hasUI) { + stopWidgetAnimation(); + return; + } + latestWidgetCtx = ctx; + latestWidgetJobs = [...jobs]; + + ctx.ui.setWidget( + WIDGET_KEY, + buildWidgetComponent(jobs, ctx.ui.getToolsExpanded?.() ?? false), + ); + if (hasAnimatedWidgetJobs(jobs)) ensureWidgetAnimation(); + else stopWidgetAnimation(); } -function renderSingleCompact(d: Details, r: Details["results"][number], theme: Theme): Component { - const output = r.truncation?.text || getSingleResultOutput(r); - const progress = r.progress || r.progressSummary; - const isRunning = r.progress?.status === "running"; - const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : ""; - const stats = statJoin(theme, [ - r.usage?.turns ? `⟳${r.usage.turns}` : "", - formatProgressStats(theme, progress), - ]); - const c = new Container(); - const width = getTermWidth() - 4; - const modelDisplay = modelThinkingBadge(theme, r.model); - c.addChild(new Text(truncLine(`${resultGlyph(r, output, theme, isRunning)} ${theme.fg("toolTitle", theme.bold(r.agent))}${modelDisplay}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0)); - - if (isRunning && r.progress) { - const activity = compactCurrentActivity(r.progress); - c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0)); - const liveStatus = buildLiveStatusLine(r.progress); - if (liveStatus && liveStatus !== activity) c.addChild(new Text(truncLine(theme.fg("dim", ` ${liveStatus}`), width), 0, 0)); - c.addChild(new Text(truncLine(theme.fg("accent", " Press Ctrl+O for live detail"), width), 0, 0)); - if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0)); - return c; - } - - c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${resultStatusLine(r, output)}`), width), 0, 0)); - const preview = firstOutputLine(output); - if (preview && r.exitCode === 0 && !hasEmptyTextOutputWithoutOutputTarget(r.task, output)) { - c.addChild(new Text(truncLine(theme.fg("dim", ` ${preview}`), width), 0, 0)); - } - if (r.sessionFile) c.addChild(new Text(truncLine(theme.fg("dim", ` session: ${shortenPath(r.sessionFile)}`), width), 0, 0)); - if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0)); - if (r.truncation?.artifactPath) c.addChild(new Text(truncLine(theme.fg("dim", ` full output: ${shortenPath(r.truncation.artifactPath)}`), width), 0, 0)); - return c; +function renderSingleCompact( + d: Details, + r: Details["results"][number], + theme: Theme, +): Component { + const output = r.truncation?.text || getSingleResultOutput(r); + const progress = r.progress || r.progressSummary; + const isRunning = r.progress?.status === "running"; + const contextBadge = + d.context === "fork" ? theme.fg("warning", " [fork]") : ""; + const stats = statJoin(theme, [ + r.usage?.turns ? `⟳${r.usage.turns}` : "", + formatProgressStats(theme, progress), + ]); + const c = new Container(); + const width = getTermWidth() - 4; + const modelDisplay = modelThinkingBadge(theme, r.model); + c.addChild( + new Text( + truncLine( + `${resultGlyph(r, output, theme, isRunning)} ${theme.fg("toolTitle", theme.bold(r.agent))}${modelDisplay}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, + width, + ), + 0, + 0, + ), + ); + + if (isRunning && r.progress) { + const activity = compactCurrentActivity(r.progress); + c.addChild( + new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0), + ); + const liveStatus = buildLiveStatusLine(r.progress); + if (liveStatus && liveStatus !== activity) + c.addChild( + new Text(truncLine(theme.fg("dim", ` ${liveStatus}`), width), 0, 0), + ); + c.addChild( + new Text( + truncLine(theme.fg("accent", " Press ctrl+o for live detail"), width), + 0, + 0, + ), + ); + if (r.artifactPaths) + c.addChild( + new Text( + truncLine( + theme.fg( + "dim", + ` output: ${shortenPath(r.artifactPaths.outputPath)}`, + ), + width, + ), + 0, + 0, + ), + ); + return c; + } + + c.addChild( + new Text( + truncLine(theme.fg("dim", ` ⎿ ${resultStatusLine(r, output)}`), width), + 0, + 0, + ), + ); + const preview = firstOutputLine(output); + if ( + preview && + r.exitCode === 0 && + !hasEmptyTextOutputWithoutOutputTarget(r.task, output) + ) { + c.addChild( + new Text(truncLine(theme.fg("dim", ` ${preview}`), width), 0, 0), + ); + } + if (r.sessionFile) + c.addChild( + new Text( + truncLine( + theme.fg("dim", ` session: ${shortenPath(r.sessionFile)}`), + width, + ), + 0, + 0, + ), + ); + if (r.artifactPaths) + c.addChild( + new Text( + truncLine( + theme.fg( + "dim", + ` output: ${shortenPath(r.artifactPaths.outputPath)}`, + ), + width, + ), + 0, + 0, + ), + ); + if (r.truncation?.artifactPath) + c.addChild( + new Text( + truncLine( + theme.fg( + "dim", + ` full output: ${shortenPath(r.truncation.artifactPath)}`, + ), + width, + ), + 0, + 0, + ), + ); + return c; } function renderMultiCompact(d: Details, theme: Theme): Component { - const hasRunning = d.progress?.some((p) => p.status === "running") - || d.results.some((r) => r.progress?.status === "running"); - const failed = d.results.some((r) => r.exitCode !== 0 && r.progress?.status !== "running"); - const paused = d.results.some((r) => (r.interrupted || r.detached) && r.progress?.status !== "running"); - let totalSummary = d.progressSummary; - if (!totalSummary) { - let sawProgress = false; - const summary = { toolCount: 0, tokens: 0, durationMs: 0 }; - for (const r of d.results) { - const prog = r.progress || r.progressSummary; - if (!prog) continue; - sawProgress = true; - summary.toolCount += prog.toolCount; - summary.tokens += prog.tokens; - summary.durationMs = d.mode === "chain" ? summary.durationMs + prog.durationMs : Math.max(summary.durationMs, prog.durationMs); - } - if (sawProgress) totalSummary = summary; - } - const multiLabel = buildMultiProgressLabel(d, hasRunning); - const itemTitle = multiLabel.itemTitle; - const stats = statJoin(theme, [multiLabel.headerLabel, formatProgressStats(theme, totalSummary)]); - const glyph = hasRunning - ? theme.fg("accent", spinnerFrame()) - : failed - ? theme.fg("error", "✗") - : paused - ? theme.fg("warning", "■") - : theme.fg("success", "✓"); - const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : ""; - const c = new Container(); - const width = getTermWidth() - 4; - c.addChild(new Text(truncLine(`${glyph} ${theme.fg("toolTitle", theme.bold(d.mode))}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, width), 0, 0)); - - const useResultsDirectly = multiLabel.hasParallelInChain || !d.chainAgents?.length; - const displayStart = multiLabel.showActiveGroupOnly ? multiLabel.groupStartIndex : 0; - const displayEnd = multiLabel.showActiveGroupOnly ? multiLabel.groupEndIndex : (useResultsDirectly ? d.results.length : d.chainAgents!.length); - for (let i = displayStart; i < displayEnd; i++) { - const r = d.results[i]; - const fallbackLabel = itemTitle.toLowerCase(); - const rowNumber = multiLabel.showActiveGroupOnly ? (i - multiLabel.groupStartIndex + 1) : (i + 1); - const agentName = useResultsDirectly ? (r?.agent || `${fallbackLabel}-${rowNumber}`) : (d.chainAgents![i] || r?.agent || `${fallbackLabel}-${rowNumber}`); - if (!r) { - c.addChild(new Text(truncLine(theme.fg("dim", ` ◦ ${itemTitle} ${rowNumber}: ${agentName} · pending`), width), 0, 0)); - continue; - } - const output = getSingleResultOutput(r); - const progressFromArray = d.progress?.find((p) => p.index === i) || d.progress?.find((p) => p.agent === r.agent && p.status === "running"); - const rProg = r.progress || progressFromArray || r.progressSummary; - const rRunning = rProg && "status" in rProg && rProg.status === "running"; - const rPending = rProg && "status" in rProg && rProg.status === "pending"; - const stepNumber = r.progress?.index !== undefined ? r.progress.index + 1 : progressFromArray?.index !== undefined ? progressFromArray.index + 1 : i + 1; - const stepStats = formatProgressStats(theme, rProg); - const glyph = rPending ? theme.fg("dim", "◦") : resultGlyph(r, output, theme, rRunning); - const pendingLabel = rPending ? ` ${theme.fg("dim", "· pending")}` : ""; - const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber); - const line = `${glyph} ${stepLabel}: ${themeBold(theme, agentName)}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}${pendingLabel}`; - c.addChild(new Text(truncLine(` ${line}`, width), 0, 0)); - if (rRunning && rProg && "status" in rProg) { - const activity = compactCurrentActivity(rProg); - c.addChild(new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0)); - c.addChild(new Text(truncLine(theme.fg("accent", " Press Ctrl+O for live detail"), width), 0, 0)); - } else if (!rPending && (r.exitCode !== 0 || r.interrupted || r.detached || hasEmptyTextOutputWithoutOutputTarget(r.task, output))) { - c.addChild(new Text(truncLine(theme.fg(r.exitCode !== 0 ? "error" : "dim", ` ⎿ ${resultStatusLine(r, output)}`), width), 0, 0)); - } - const outputTarget = extractOutputTarget(r.task); - if (outputTarget) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${outputTarget}`), width), 0, 0)); - if (r.artifactPaths) c.addChild(new Text(truncLine(theme.fg("dim", ` output: ${shortenPath(r.artifactPaths.outputPath)}`), width), 0, 0)); - } - if (d.artifacts) c.addChild(new Text(truncLine(theme.fg("dim", ` artifacts: ${shortenPath(d.artifacts.dir)}`), width), 0, 0)); - return c; + const hasRunning = + d.progress?.some((p) => p.status === "running") || + d.results.some((r) => r.progress?.status === "running"); + const failed = d.results.some( + (r) => r.exitCode !== 0 && r.progress?.status !== "running", + ); + const paused = d.results.some( + (r) => (r.interrupted || r.detached) && r.progress?.status !== "running", + ); + let totalSummary = d.progressSummary; + if (!totalSummary) { + let sawProgress = false; + const summary = { toolCount: 0, tokens: 0, durationMs: 0 }; + for (const r of d.results) { + const prog = r.progress || r.progressSummary; + if (!prog) continue; + sawProgress = true; + summary.toolCount += prog.toolCount; + summary.tokens += prog.tokens; + summary.durationMs = + d.mode === "chain" + ? summary.durationMs + prog.durationMs + : Math.max(summary.durationMs, prog.durationMs); + } + if (sawProgress) totalSummary = summary; + } + const multiLabel = buildMultiProgressLabel(d, hasRunning); + const itemTitle = multiLabel.itemTitle; + const stats = statJoin(theme, [ + multiLabel.headerLabel, + formatProgressStats(theme, totalSummary), + ]); + const glyph = hasRunning + ? theme.fg("accent", spinnerFrame()) + : failed + ? theme.fg("error", "✗") + : paused + ? theme.fg("warning", "■") + : theme.fg("success", "✓"); + const contextBadge = + d.context === "fork" ? theme.fg("warning", " [fork]") : ""; + const c = new Container(); + const width = getTermWidth() - 4; + c.addChild( + new Text( + truncLine( + `${glyph} ${theme.fg("toolTitle", theme.bold(d.mode))}${contextBadge}${stats ? ` ${theme.fg("dim", "·")} ${stats}` : ""}`, + width, + ), + 0, + 0, + ), + ); + + const useResultsDirectly = + multiLabel.hasParallelInChain || !d.chainAgents?.length; + const displayStart = multiLabel.showActiveGroupOnly + ? multiLabel.groupStartIndex + : 0; + const displayEnd = multiLabel.showActiveGroupOnly + ? multiLabel.groupEndIndex + : useResultsDirectly + ? d.results.length + : d.chainAgents!.length; + for (let i = displayStart; i < displayEnd; i++) { + const r = d.results[i]; + const fallbackLabel = itemTitle.toLowerCase(); + const rowNumber = multiLabel.showActiveGroupOnly + ? i - multiLabel.groupStartIndex + 1 + : i + 1; + const agentName = useResultsDirectly + ? r?.agent || `${fallbackLabel}-${rowNumber}` + : d.chainAgents![i] || r?.agent || `${fallbackLabel}-${rowNumber}`; + if (!r) { + c.addChild( + new Text( + truncLine( + theme.fg( + "dim", + ` ◦ ${itemTitle} ${rowNumber}: ${agentName} · pending`, + ), + width, + ), + 0, + 0, + ), + ); + continue; + } + const output = getSingleResultOutput(r); + const progressFromArray = + d.progress?.find((p) => p.index === i) || + d.progress?.find((p) => p.agent === r.agent && p.status === "running"); + const rProg = r.progress || progressFromArray || r.progressSummary; + const rRunning = rProg && "status" in rProg && rProg.status === "running"; + const rPending = rProg && "status" in rProg && rProg.status === "pending"; + const stepNumber = + r.progress?.index !== undefined + ? r.progress.index + 1 + : progressFromArray?.index !== undefined + ? progressFromArray.index + 1 + : i + 1; + const stepStats = formatProgressStats(theme, rProg); + const glyph = rPending + ? theme.fg("dim", "◦") + : resultGlyph(r, output, theme, rRunning); + const pendingLabel = rPending ? ` ${theme.fg("dim", "· pending")}` : ""; + const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber); + const line = `${glyph} ${stepLabel}: ${themeBold(theme, agentName)}${stepStats ? ` ${theme.fg("dim", "·")} ${stepStats}` : ""}${pendingLabel}`; + c.addChild(new Text(truncLine(` ${line}`, width), 0, 0)); + if (rRunning && rProg && "status" in rProg) { + const activity = compactCurrentActivity(rProg); + c.addChild( + new Text(truncLine(theme.fg("dim", ` ⎿ ${activity}`), width), 0, 0), + ); + c.addChild( + new Text( + truncLine( + theme.fg("accent", " Press ctrl+o for live detail"), + width, + ), + 0, + 0, + ), + ); + } else if ( + !rPending && + (r.exitCode !== 0 || + r.interrupted || + r.detached || + hasEmptyTextOutputWithoutOutputTarget(r.task, output)) + ) { + c.addChild( + new Text( + truncLine( + theme.fg( + r.exitCode !== 0 ? "error" : "dim", + ` ⎿ ${resultStatusLine(r, output)}`, + ), + width, + ), + 0, + 0, + ), + ); + } + const outputTarget = extractOutputTarget(r.task); + if (outputTarget) + c.addChild( + new Text( + truncLine(theme.fg("dim", ` output: ${outputTarget}`), width), + 0, + 0, + ), + ); + if (r.artifactPaths) + c.addChild( + new Text( + truncLine( + theme.fg( + "dim", + ` output: ${shortenPath(r.artifactPaths.outputPath)}`, + ), + width, + ), + 0, + 0, + ), + ); + } + if (d.artifacts) + c.addChild( + new Text( + truncLine( + theme.fg("dim", ` artifacts: ${shortenPath(d.artifacts.dir)}`), + width, + ), + 0, + 0, + ), + ); + return c; } /** * Render a subagent result */ export function renderSubagentResult( - result: AgentToolResult
, - options: { expanded: boolean }, - theme: Theme, + result: AgentToolResult
, + options: { expanded: boolean }, + theme: Theme, ): Component { - const d = result.details; - if (!d || !d.results.length) { - const t = result.content[0]; - const text = t?.type === "text" ? t.text : "(no output)"; - const contextPrefix = d?.context === "fork" ? `${theme.fg("warning", "[fork]")} ` : ""; - return new Text(truncLine(`${contextPrefix}${text}`, getTermWidth() - 4), 0, 0); - } - - const expanded = options.expanded; - const mdTheme = getMarkdownTheme(); - - if (d.mode === "single" && d.results.length === 1) { - const r = d.results[0]; - if (!expanded) return renderSingleCompact(d, r, theme); - const isRunning = r.progress?.status === "running"; - const icon = isRunning - ? theme.fg("warning", "running") - : r.detached - ? theme.fg("warning", "detached") - : r.exitCode === 0 - ? theme.fg("success", "ok") - : theme.fg("error", "failed"); - const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : ""; - const output = r.truncation?.text || getSingleResultOutput(r); - - const progressInfo = isRunning && r.progress - ? ` | ${r.progress.toolCount} tools, ${formatTokens(r.progress.tokens)} tok, ${formatDuration(r.progress.durationMs)}` - : r.progressSummary - ? ` | ${r.progressSummary.toolCount} tools, ${formatTokens(r.progressSummary.tokens)} tok, ${formatDuration(r.progressSummary.durationMs)}` - : ""; - - const w = getTermWidth() - 4; - const fit = (text: string) => expanded ? text : truncLine(text, w); - const toolCallLines = getToolCallLines(r, expanded); - const c = new Container(); - c.addChild(new Text(fit(`${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${contextBadge}${progressInfo}`), 0, 0)); - c.addChild(new Spacer(1)); - const taskMaxLen = Math.max(20, w - 8); - const taskPreview = expanded || r.task.length <= taskMaxLen - ? r.task - : `${r.task.slice(0, taskMaxLen)}...`; - c.addChild( - new Text(fit(theme.fg("dim", `Task: ${taskPreview}`)), 0, 0), - ); - c.addChild(new Spacer(1)); - - if (isRunning && r.progress) { - const toolLine = formatCurrentToolLine(r.progress, w, expanded); - if (toolLine) { - c.addChild(new Text(fit(theme.fg("warning", `> ${toolLine}`)), 0, 0)); - } - const liveStatusLine = buildLiveStatusLine(r.progress); - if (liveStatusLine) { - c.addChild(new Text(fit(theme.fg("accent", liveStatusLine)), 0, 0)); - } - c.addChild(new Text(fit(theme.fg("accent", "Press Ctrl+O for live detail")), 0, 0)); - if (r.artifactPaths) { - c.addChild(new Text(fit(theme.fg("dim", `Artifacts: ${shortenPath(r.artifactPaths.outputPath)}`)), 0, 0)); - } - if (r.progress.recentTools?.length) { - for (const t of r.progress.recentTools.slice(-3)) { - const maxArgsLen = Math.max(40, w - 24); - const argsPreview = expanded || t.args.length <= maxArgsLen - ? t.args - : `${t.args.slice(0, maxArgsLen)}...`; - c.addChild(new Text(fit(theme.fg("dim", `${t.tool}: ${argsPreview}`)), 0, 0)); - } - } - for (const line of (r.progress.recentOutput ?? []).slice(-5)) { - c.addChild(new Text(fit(theme.fg("dim", ` ${line}`)), 0, 0)); - } - if (toolLine || liveStatusLine || r.progress.recentTools?.length || r.progress.recentOutput?.length || r.artifactPaths) { - c.addChild(new Spacer(1)); - } - } - - if (expanded) { - for (const line of toolCallLines) { - c.addChild(new Text(fit(theme.fg("muted", line)), 0, 0)); - } - if (toolCallLines.length) c.addChild(new Spacer(1)); - } - - if (output) c.addChild(new Markdown(output, 0, 0, mdTheme)); - c.addChild(new Spacer(1)); - if (r.skills?.length) { - c.addChild(new Text(fit(theme.fg("dim", `Skills: ${r.skills.join(", ")}`)), 0, 0)); - } - if (r.skillsWarning) { - c.addChild(new Text(fit(theme.fg("warning", `Warning: ${r.skillsWarning}`)), 0, 0)); - } - if (r.attemptedModels && r.attemptedModels.length > 1) { - c.addChild(new Text(fit(theme.fg("dim", `Fallbacks: ${r.attemptedModels.join(" → ")}`)), 0, 0)); - } - c.addChild(new Text(fit(theme.fg("dim", formatUsage(r.usage, r.model))), 0, 0)); - if (r.sessionFile) { - c.addChild(new Text(fit(theme.fg("dim", `Session: ${shortenPath(r.sessionFile)}`)), 0, 0)); - } - - if (!isRunning && r.artifactPaths) { - c.addChild(new Spacer(1)); - c.addChild(new Text(fit(theme.fg("dim", `Artifacts: ${shortenPath(r.artifactPaths.outputPath)}`)), 0, 0)); - } - return c; - } - - if (!expanded) return renderMultiCompact(d, theme); - - const hasRunning = d.progress?.some((p) => p.status === "running") - || d.results.some((r) => r.progress?.status === "running"); - const ok = d.results.filter((r) => r.progress?.status === "completed" || (r.exitCode === 0 && r.progress?.status !== "running")).length; - const hasEmptyWithoutTarget = d.results.some((r) => - r.exitCode === 0 - && r.progress?.status !== "running" - && hasEmptyTextOutputWithoutOutputTarget(r.task, getSingleResultOutput(r)), - ); - const icon = hasRunning - ? theme.fg("warning", "running") - : hasEmptyWithoutTarget - ? theme.fg("warning", "warning") - : ok === d.results.length - ? theme.fg("success", "ok") - : theme.fg("error", "failed"); - - const totalSummary = - d.progressSummary || - d.results.reduce( - (acc, r) => { - const prog = r.progress || r.progressSummary; - if (prog) { - acc.toolCount += prog.toolCount; - acc.tokens += prog.tokens; - acc.durationMs = - d.mode === "chain" - ? acc.durationMs + prog.durationMs - : Math.max(acc.durationMs, prog.durationMs); - } - return acc; - }, - { toolCount: 0, tokens: 0, durationMs: 0 }, - ); - - const summaryStr = - totalSummary.toolCount || totalSummary.tokens - ? ` | ${totalSummary.toolCount} tools, ${formatTokens(totalSummary.tokens)} tok, ${formatDuration(totalSummary.durationMs)}` - : ""; - - const modeLabel = d.mode; - const contextBadge = d.context === "fork" ? theme.fg("warning", " [fork]") : ""; - const multiLabel = buildMultiProgressLabel(d, hasRunning); - const itemTitle = multiLabel.itemTitle; - - const chainVis = d.chainAgents?.length && !multiLabel.hasParallelInChain - ? d.chainAgents - .map((agent, i) => { - const result = d.results[i]; - const isFailed = result && result.exitCode !== 0 && result.progress?.status !== "running"; - const isComplete = result && result.exitCode === 0 && result.progress?.status !== "running"; - const isEmptyWithoutTarget = Boolean(result) - && Boolean(isComplete) - && hasEmptyTextOutputWithoutOutputTarget(result.task, getSingleResultOutput(result)); - const isCurrent = i === (d.currentStepIndex ?? d.results.length); - const stepIcon = isFailed - ? theme.fg("error", "failed") - : isEmptyWithoutTarget - ? theme.fg("warning", "warning") - : isComplete - ? theme.fg("success", "done") - : isCurrent && hasRunning - ? theme.fg("warning", "running") - : theme.fg("dim", "pending"); - return `${stepIcon} ${agent}`; - }) - .join(theme.fg("dim", " → ")) - : null; - - const w = getTermWidth() - 4; - const fit = (text: string) => expanded ? text : truncLine(text, w); - const c = new Container(); - c.addChild( - new Text( - fit(`${icon} ${theme.fg("toolTitle", theme.bold(modeLabel))}${contextBadge} · ${multiLabel.headerLabel}${summaryStr}`), - 0, - 0, - ), - ); - if (chainVis) { - c.addChild(new Text(fit(` ${chainVis}`), 0, 0)); - } - - const useResultsDirectly = multiLabel.hasParallelInChain || !d.chainAgents?.length; - const displayStart = multiLabel.showActiveGroupOnly ? multiLabel.groupStartIndex : 0; - const displayEnd = multiLabel.showActiveGroupOnly ? multiLabel.groupEndIndex : (useResultsDirectly ? d.results.length : d.chainAgents!.length); - - c.addChild(new Spacer(1)); - - for (let i = displayStart; i < displayEnd; i++) { - const r = d.results[i]; - const rowNumber = multiLabel.showActiveGroupOnly ? (i - multiLabel.groupStartIndex + 1) : (i + 1); - const agentName = useResultsDirectly - ? (r?.agent || `step-${rowNumber}`) - : (d.chainAgents![i] || r?.agent || `step-${rowNumber}`); - - if (!r) { - c.addChild(new Text(fit(theme.fg("dim", ` ${itemTitle} ${rowNumber}: ${agentName}`)), 0, 0)); - c.addChild(new Text(theme.fg("dim", ` status: pending`), 0, 0)); - c.addChild(new Spacer(1)); - continue; - } - - const progressFromArray = d.progress?.find((p) => p.index === i) - || d.progress?.find((p) => p.agent === r.agent && p.status === "running"); - const rProg = r.progress || progressFromArray || r.progressSummary; - const rRunning = rProg?.status === "running"; - const stepNumber = typeof rProg?.index === "number" ? rProg.index + 1 : i + 1; - - const resultOutput = getSingleResultOutput(r); - const statusIcon = rRunning - ? theme.fg("warning", "running") - : r.exitCode !== 0 - ? theme.fg("error", "failed") - : hasEmptyTextOutputWithoutOutputTarget(r.task, resultOutput) - ? theme.fg("warning", "warning") - : theme.fg("success", "done"); - const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : ""; - const modelDisplay = modelThinkingBadge(theme, r.model); - const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber); - const stepHeader = rRunning - ? `${statusIcon} ${stepLabel}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}` - : `${statusIcon} ${stepLabel}: ${theme.bold(r.agent)}${modelDisplay}${stats}`; - const toolCallLines = getToolCallLines(r, expanded); - c.addChild(new Text(fit(stepHeader), 0, 0)); - - const taskMaxLen = Math.max(20, w - 12); - const taskPreview = expanded || r.task.length <= taskMaxLen - ? r.task - : `${r.task.slice(0, taskMaxLen)}...`; - c.addChild(new Text(fit(theme.fg("dim", ` task: ${taskPreview}`)), 0, 0)); - - const outputTarget = extractOutputTarget(r.task); - if (outputTarget) { - c.addChild(new Text(fit(theme.fg("dim", ` output: ${outputTarget}`)), 0, 0)); - } - - if (r.skills?.length) { - c.addChild(new Text(fit(theme.fg("dim", ` skills: ${r.skills.join(", ")}`)), 0, 0)); - } - if (r.skillsWarning) { - c.addChild(new Text(fit(theme.fg("warning", ` Warning: ${r.skillsWarning}`)), 0, 0)); - } - if (r.attemptedModels && r.attemptedModels.length > 1) { - c.addChild(new Text(fit(theme.fg("dim", ` fallbacks: ${r.attemptedModels.join(" → ")}`)), 0, 0)); - } - - if (rRunning && rProg) { - if (rProg.skills?.length) { - c.addChild(new Text(fit(theme.fg("accent", ` skills: ${rProg.skills.join(", ")}`)), 0, 0)); - } - const toolLine = formatCurrentToolLine(rProg, w, expanded); - if (toolLine) { - c.addChild(new Text(fit(theme.fg("warning", ` > ${toolLine}`)), 0, 0)); - } - const liveStatusLine = buildLiveStatusLine(rProg); - if (liveStatusLine) { - c.addChild(new Text(fit(theme.fg("accent", ` ${liveStatusLine}`)), 0, 0)); - } - c.addChild(new Text(fit(theme.fg("accent", " Press Ctrl+O for live detail")), 0, 0)); - if (r.artifactPaths) { - c.addChild(new Text(fit(theme.fg("dim", ` artifacts: ${shortenPath(r.artifactPaths.outputPath)}`)), 0, 0)); - } - if (rProg.recentTools?.length) { - for (const t of rProg.recentTools.slice(-3)) { - const maxArgsLen = Math.max(40, w - 30); - const argsPreview = expanded || t.args.length <= maxArgsLen - ? t.args - : `${t.args.slice(0, maxArgsLen)}...`; - c.addChild(new Text(fit(theme.fg("dim", ` ${t.tool}: ${argsPreview}`)), 0, 0)); - } - } - const recentLines = (rProg.recentOutput ?? []).slice(-5); - for (const line of recentLines) { - c.addChild(new Text(fit(theme.fg("dim", ` ${line}`)), 0, 0)); - } - } - - if (!rRunning && r.artifactPaths) { - c.addChild(new Text(fit(theme.fg("dim", ` artifacts: ${shortenPath(r.artifactPaths.outputPath)}`)), 0, 0)); - } - - if (expanded && !rRunning) { - for (const line of toolCallLines) { - c.addChild(new Text(fit(theme.fg("muted", ` ${line}`)), 0, 0)); - } - if (toolCallLines.length) c.addChild(new Spacer(1)); - } - - c.addChild(new Spacer(1)); - } - - if (d.artifacts) { - c.addChild(new Spacer(1)); - c.addChild(new Text(fit(theme.fg("dim", `Artifacts dir: ${shortenPath(d.artifacts.dir)}`)), 0, 0)); - } - return c; + const d = result.details; + if (!d || !d.results.length) { + const t = result.content[0]; + const text = t?.type === "text" ? t.text : "(no output)"; + const contextPrefix = + d?.context === "fork" ? `${theme.fg("warning", "[fork]")} ` : ""; + return new Text( + truncLine(`${contextPrefix}${text}`, getTermWidth() - 4), + 0, + 0, + ); + } + + const expanded = options.expanded; + const mdTheme = getMarkdownTheme(); + + if (d.mode === "single" && d.results.length === 1) { + const r = d.results[0]; + if (!expanded) return renderSingleCompact(d, r, theme); + const isRunning = r.progress?.status === "running"; + const icon = isRunning + ? theme.fg("warning", "running") + : r.detached + ? theme.fg("warning", "detached") + : r.exitCode === 0 + ? theme.fg("success", "ok") + : theme.fg("error", "failed"); + const contextBadge = + d.context === "fork" ? theme.fg("warning", " [fork]") : ""; + const output = r.truncation?.text || getSingleResultOutput(r); + + const progressInfo = + isRunning && r.progress + ? ` | ${r.progress.toolCount} tools, ${formatTokens(r.progress.tokens)} tok, ${formatDuration(r.progress.durationMs)}` + : r.progressSummary + ? ` | ${r.progressSummary.toolCount} tools, ${formatTokens(r.progressSummary.tokens)} tok, ${formatDuration(r.progressSummary.durationMs)}` + : ""; + + const w = getTermWidth() - 4; + const fit = (text: string) => (expanded ? text : truncLine(text, w)); + const toolCallLines = getToolCallLines(r, expanded); + const c = new Container(); + c.addChild( + new Text( + fit( + `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${contextBadge}${progressInfo}`, + ), + 0, + 0, + ), + ); + c.addChild(new Spacer(1)); + const taskMaxLen = Math.max(20, w - 8); + const taskPreview = + expanded || r.task.length <= taskMaxLen + ? r.task + : `${r.task.slice(0, taskMaxLen)}...`; + c.addChild(new Text(fit(theme.fg("dim", `Task: ${taskPreview}`)), 0, 0)); + c.addChild(new Spacer(1)); + + if (isRunning && r.progress) { + const toolLine = formatCurrentToolLine(r.progress, w, expanded); + if (toolLine) { + c.addChild(new Text(fit(theme.fg("warning", `> ${toolLine}`)), 0, 0)); + } + const liveStatusLine = buildLiveStatusLine(r.progress); + if (liveStatusLine) { + c.addChild(new Text(fit(theme.fg("accent", liveStatusLine)), 0, 0)); + } + c.addChild( + new Text(fit(theme.fg("accent", "Press ctrl+o for live detail")), 0, 0), + ); + if (r.artifactPaths) { + c.addChild( + new Text( + fit( + theme.fg( + "dim", + `Artifacts: ${shortenPath(r.artifactPaths.outputPath)}`, + ), + ), + 0, + 0, + ), + ); + } + if (r.progress.recentTools?.length) { + for (const t of r.progress.recentTools.slice(-3)) { + const maxArgsLen = Math.max(40, w - 24); + const argsPreview = + expanded || t.args.length <= maxArgsLen + ? t.args + : `${t.args.slice(0, maxArgsLen)}...`; + c.addChild( + new Text(fit(theme.fg("dim", `${t.tool}: ${argsPreview}`)), 0, 0), + ); + } + } + for (const line of (r.progress.recentOutput ?? []).slice(-5)) { + c.addChild(new Text(fit(theme.fg("dim", ` ${line}`)), 0, 0)); + } + if ( + toolLine || + liveStatusLine || + r.progress.recentTools?.length || + r.progress.recentOutput?.length || + r.artifactPaths + ) { + c.addChild(new Spacer(1)); + } + } + + if (expanded) { + for (const line of toolCallLines) { + c.addChild(new Text(fit(theme.fg("muted", line)), 0, 0)); + } + if (toolCallLines.length) c.addChild(new Spacer(1)); + } + + if (output) c.addChild(new Markdown(output, 0, 0, mdTheme)); + c.addChild(new Spacer(1)); + if (r.skills?.length) { + c.addChild( + new Text(fit(theme.fg("dim", `Skills: ${r.skills.join(", ")}`)), 0, 0), + ); + } + if (r.skillsWarning) { + c.addChild( + new Text(fit(theme.fg("warning", `Warning: ${r.skillsWarning}`)), 0, 0), + ); + } + if (r.attemptedModels && r.attemptedModels.length > 1) { + c.addChild( + new Text( + fit(theme.fg("dim", `Fallbacks: ${r.attemptedModels.join(" → ")}`)), + 0, + 0, + ), + ); + } + c.addChild( + new Text(fit(theme.fg("dim", formatUsage(r.usage, r.model))), 0, 0), + ); + if (r.sessionFile) { + c.addChild( + new Text( + fit(theme.fg("dim", `Session: ${shortenPath(r.sessionFile)}`)), + 0, + 0, + ), + ); + } + + if (!isRunning && r.artifactPaths) { + c.addChild(new Spacer(1)); + c.addChild( + new Text( + fit( + theme.fg( + "dim", + `Artifacts: ${shortenPath(r.artifactPaths.outputPath)}`, + ), + ), + 0, + 0, + ), + ); + } + return c; + } + + if (!expanded) return renderMultiCompact(d, theme); + + const hasRunning = + d.progress?.some((p) => p.status === "running") || + d.results.some((r) => r.progress?.status === "running"); + const ok = d.results.filter( + (r) => + r.progress?.status === "completed" || + (r.exitCode === 0 && r.progress?.status !== "running"), + ).length; + const hasEmptyWithoutTarget = d.results.some( + (r) => + r.exitCode === 0 && + r.progress?.status !== "running" && + hasEmptyTextOutputWithoutOutputTarget(r.task, getSingleResultOutput(r)), + ); + const icon = hasRunning + ? theme.fg("warning", "running") + : hasEmptyWithoutTarget + ? theme.fg("warning", "warning") + : ok === d.results.length + ? theme.fg("success", "ok") + : theme.fg("error", "failed"); + + const totalSummary = + d.progressSummary || + d.results.reduce( + (acc, r) => { + const prog = r.progress || r.progressSummary; + if (prog) { + acc.toolCount += prog.toolCount; + acc.tokens += prog.tokens; + acc.durationMs = + d.mode === "chain" + ? acc.durationMs + prog.durationMs + : Math.max(acc.durationMs, prog.durationMs); + } + return acc; + }, + { toolCount: 0, tokens: 0, durationMs: 0 }, + ); + + const summaryStr = + totalSummary.toolCount || totalSummary.tokens + ? ` | ${totalSummary.toolCount} tools, ${formatTokens(totalSummary.tokens)} tok, ${formatDuration(totalSummary.durationMs)}` + : ""; + + const modeLabel = d.mode; + const contextBadge = + d.context === "fork" ? theme.fg("warning", " [fork]") : ""; + const multiLabel = buildMultiProgressLabel(d, hasRunning); + const itemTitle = multiLabel.itemTitle; + + const chainVis = + d.chainAgents?.length && !multiLabel.hasParallelInChain + ? d.chainAgents + .map((agent, i) => { + const result = d.results[i]; + const isFailed = + result && + result.exitCode !== 0 && + result.progress?.status !== "running"; + const isComplete = + result && + result.exitCode === 0 && + result.progress?.status !== "running"; + const isEmptyWithoutTarget = + Boolean(result) && + Boolean(isComplete) && + hasEmptyTextOutputWithoutOutputTarget( + result.task, + getSingleResultOutput(result), + ); + const isCurrent = i === (d.currentStepIndex ?? d.results.length); + const stepIcon = isFailed + ? theme.fg("error", "failed") + : isEmptyWithoutTarget + ? theme.fg("warning", "warning") + : isComplete + ? theme.fg("success", "done") + : isCurrent && hasRunning + ? theme.fg("warning", "running") + : theme.fg("dim", "pending"); + return `${stepIcon} ${agent}`; + }) + .join(theme.fg("dim", " → ")) + : null; + + const w = getTermWidth() - 4; + const fit = (text: string) => (expanded ? text : truncLine(text, w)); + const c = new Container(); + c.addChild( + new Text( + fit( + `${icon} ${theme.fg("toolTitle", theme.bold(modeLabel))}${contextBadge} · ${multiLabel.headerLabel}${summaryStr}`, + ), + 0, + 0, + ), + ); + if (chainVis) { + c.addChild(new Text(fit(` ${chainVis}`), 0, 0)); + } + + const useResultsDirectly = + multiLabel.hasParallelInChain || !d.chainAgents?.length; + const displayStart = multiLabel.showActiveGroupOnly + ? multiLabel.groupStartIndex + : 0; + const displayEnd = multiLabel.showActiveGroupOnly + ? multiLabel.groupEndIndex + : useResultsDirectly + ? d.results.length + : d.chainAgents!.length; + + c.addChild(new Spacer(1)); + + for (let i = displayStart; i < displayEnd; i++) { + const r = d.results[i]; + const rowNumber = multiLabel.showActiveGroupOnly + ? i - multiLabel.groupStartIndex + 1 + : i + 1; + const agentName = useResultsDirectly + ? r?.agent || `step-${rowNumber}` + : d.chainAgents![i] || r?.agent || `step-${rowNumber}`; + + if (!r) { + c.addChild( + new Text( + fit(theme.fg("dim", ` ${itemTitle} ${rowNumber}: ${agentName}`)), + 0, + 0, + ), + ); + c.addChild(new Text(theme.fg("dim", ` status: pending`), 0, 0)); + c.addChild(new Spacer(1)); + continue; + } + + const progressFromArray = + d.progress?.find((p) => p.index === i) || + d.progress?.find((p) => p.agent === r.agent && p.status === "running"); + const rProg = r.progress || progressFromArray || r.progressSummary; + const rRunning = rProg?.status === "running"; + const stepNumber = + typeof rProg?.index === "number" ? rProg.index + 1 : i + 1; + + const resultOutput = getSingleResultOutput(r); + const statusIcon = rRunning + ? theme.fg("warning", "running") + : r.exitCode !== 0 + ? theme.fg("error", "failed") + : hasEmptyTextOutputWithoutOutputTarget(r.task, resultOutput) + ? theme.fg("warning", "warning") + : theme.fg("success", "done"); + const stats = rProg + ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` + : ""; + const modelDisplay = modelThinkingBadge(theme, r.model); + const stepLabel = resultRowLabel(d, multiLabel, i, stepNumber); + const stepHeader = rRunning + ? `${statusIcon} ${stepLabel}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}` + : `${statusIcon} ${stepLabel}: ${theme.bold(r.agent)}${modelDisplay}${stats}`; + const toolCallLines = getToolCallLines(r, expanded); + c.addChild(new Text(fit(stepHeader), 0, 0)); + + const taskMaxLen = Math.max(20, w - 12); + const taskPreview = + expanded || r.task.length <= taskMaxLen + ? r.task + : `${r.task.slice(0, taskMaxLen)}...`; + c.addChild( + new Text(fit(theme.fg("dim", ` task: ${taskPreview}`)), 0, 0), + ); + + const outputTarget = extractOutputTarget(r.task); + if (outputTarget) { + c.addChild( + new Text(fit(theme.fg("dim", ` output: ${outputTarget}`)), 0, 0), + ); + } + + if (r.skills?.length) { + c.addChild( + new Text( + fit(theme.fg("dim", ` skills: ${r.skills.join(", ")}`)), + 0, + 0, + ), + ); + } + if (r.skillsWarning) { + c.addChild( + new Text( + fit(theme.fg("warning", ` Warning: ${r.skillsWarning}`)), + 0, + 0, + ), + ); + } + if (r.attemptedModels && r.attemptedModels.length > 1) { + c.addChild( + new Text( + fit( + theme.fg("dim", ` fallbacks: ${r.attemptedModels.join(" → ")}`), + ), + 0, + 0, + ), + ); + } + + if (rRunning && rProg) { + if (rProg.skills?.length) { + c.addChild( + new Text( + fit(theme.fg("accent", ` skills: ${rProg.skills.join(", ")}`)), + 0, + 0, + ), + ); + } + const toolLine = formatCurrentToolLine(rProg, w, expanded); + if (toolLine) { + c.addChild( + new Text(fit(theme.fg("warning", ` > ${toolLine}`)), 0, 0), + ); + } + const liveStatusLine = buildLiveStatusLine(rProg); + if (liveStatusLine) { + c.addChild( + new Text(fit(theme.fg("accent", ` ${liveStatusLine}`)), 0, 0), + ); + } + c.addChild( + new Text( + fit(theme.fg("accent", " Press ctrl+o for live detail")), + 0, + 0, + ), + ); + if (r.artifactPaths) { + c.addChild( + new Text( + fit( + theme.fg( + "dim", + ` artifacts: ${shortenPath(r.artifactPaths.outputPath)}`, + ), + ), + 0, + 0, + ), + ); + } + if (rProg.recentTools?.length) { + for (const t of rProg.recentTools.slice(-3)) { + const maxArgsLen = Math.max(40, w - 30); + const argsPreview = + expanded || t.args.length <= maxArgsLen + ? t.args + : `${t.args.slice(0, maxArgsLen)}...`; + c.addChild( + new Text( + fit(theme.fg("dim", ` ${t.tool}: ${argsPreview}`)), + 0, + 0, + ), + ); + } + } + const recentLines = (rProg.recentOutput ?? []).slice(-5); + for (const line of recentLines) { + c.addChild(new Text(fit(theme.fg("dim", ` ${line}`)), 0, 0)); + } + } + + if (!rRunning && r.artifactPaths) { + c.addChild( + new Text( + fit( + theme.fg( + "dim", + ` artifacts: ${shortenPath(r.artifactPaths.outputPath)}`, + ), + ), + 0, + 0, + ), + ); + } + + if (expanded && !rRunning) { + for (const line of toolCallLines) { + c.addChild(new Text(fit(theme.fg("muted", ` ${line}`)), 0, 0)); + } + if (toolCallLines.length) c.addChild(new Spacer(1)); + } + + c.addChild(new Spacer(1)); + } + + if (d.artifacts) { + c.addChild(new Spacer(1)); + c.addChild( + new Text( + fit(theme.fg("dim", `Artifacts dir: ${shortenPath(d.artifacts.dir)}`)), + 0, + 0, + ), + ); + } + return c; } diff --git a/packages/web-access/README.md b/packages/web-access/README.md index 5992ee4cc..963bc5bad 100644 --- a/packages/web-access/README.md +++ b/packages/web-access/README.md @@ -235,7 +235,7 @@ Show the active Google account currently authenticated for Gemini Web. Useful wh ## Activity Monitor -Toggle with **Ctrl+Shift+W** to see live request/response activity: +Toggle with **CTRL+SHIFT+W** to see live request/response activity: ``` ─── Web Search Activity ──────────────────────────────────── diff --git a/packages/web-access/curator-page.ts b/packages/web-access/curator-page.ts index 098a39837..b084d942e 100644 --- a/packages/web-access/curator-page.ts +++ b/packages/web-access/curator-page.ts @@ -125,9 +125,9 @@ ${CSS}