Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Steer a running agent from the conversation viewer.** The live conversation overlay (FleetView's `Enter`, or `/agents → Running agents`) now lets you redirect an agent without leaving the view: press `Enter` to open an inline composer, type a message, `Enter` to send — `Esc` or an empty submit just returns. The message is delivered through the same path as the `steer_subagent` tool (`AgentManager.steer()` → `session.steer`, or queued onto `pendingSteers` if the session isn't ready yet), so it appears as a user message and redirects the agent after its current tool execution; feedback is the message showing up in the live transcript you're already watching. The affordance is offered only while the agent is still running/queued (mirrors the `x`/stop affordance), and the viewer stays modal — every existing shortcut (`x`/`x` stop, arrows/`j`/`k` scroll, `q` close) is untouched, and `Enter` was previously inert here so nothing is overridden. The idle footer was reorganized to actions-left / navigation-right so the full scroll-key hint (`↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close`) stays fully visible down to 80-column terminals; the `N lines · %` readout returns on the left whenever there's spare width.
- **Forgiving model resolution for agent `model:` pins.** `resolveModel` now tolerates cosmetic id variations and falls back across providers, so a qualified or date-pinned config resolves widely instead of silently dropping to the parent model: `.` and `-` are treated as equivalent in version numbers (`claude-haiku-4.5` ≡ `claude-haiku-4-5`); a trailing `-YYYYMMDD` date stamp is optional (`anthropic/claude-haiku-4-5-20251001` matches an undated registry id); and a `provider/modelId` that isn't available under the named provider retries the bare id against every provider (the named provider is still preferred when present). An exact match still wins over a tolerant one, so dated snapshots aren't conflated — the precedence is exact → fuzzy-under-named-provider → same model under any provider → unavailable.
- **`/agents → Agent types` shows each agent's full description and what its model resolves to.** The list now renders with `SettingsList` (like the Settings menu) instead of a flat selector: the highlighted agent's full description shows on its own line below the list, so long descriptions no longer wrap and push the agent names out of alignment. The model column shows the configured model, flags it `(unavailable, fallback: inherit)` when it can't be resolved against the registry (it would silently inherit the parent model at runtime), and surfaces the resolved target `(→ provider/id)` when resolution lands on a different provider or version than configured.
- **`widgetMode` setting — control what the above-editor widget shows: `all` / `background` / `off`** ([#117](https://github.com/tintinweb/pi-subagents/pull/117) — thanks [@Alan-TheGentleman](https://github.com/Alan-TheGentleman); fixes [#118](https://github.com/tintinweb/pi-subagents/issues/118)). Foreground agents already render inline as the `Agent` tool result, so also listing them in the persistent widget double-rendered the same run (most visible in tmux/zellij). `widgetMode` (via `/agents → Settings → Widget`, or `subagents.json`) selects the widget's contents: `all` shows every agent (the previous behavior), `background` shows background/queued/scheduled/RPC runs but hides foreground, and `off` hides the widget entirely (agents still appear inline and in FleetView). Applied live — toggling refreshes immediately. Filtering keys off a new tri-state `AgentRecord.isBackground` captured at spawn (`true` = background, `false` = foreground, `undefined` = undeclared, e.g. a cross-extension RPC spawn), independent of the UI-only `invocation` snapshot — so scheduler- and RPC-spawned background agents stay visible instead of vanishing; only runs *known* to be foreground are dropped. The running-status line was also refactored from one multiline `Text` into two component rows, so rapid partial updates replace cleanly instead of leaving stale rows behind in terminal multiplexers — identical on-screen output, no more ghost lines.
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
- **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications)
- **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons. Configurable via `/agents → Settings → Widget`: `all` (every agent), `background` (default — hides foreground runs, which already render inline as the `Agent` tool result), or `off`
- **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `↓` (or `←`) at an empty prompt to jump in, `↑`/`↓` to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view`
- **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Stop a still-running agent from here by pressing `x` (then `x` again to confirm) — works for background agents too
- **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Steer a running agent inline by pressing `Enter` to open a composer, typing, then `Enter` to send (`Esc` or an empty submit returns) — the message appears as a user message and redirects the agent after its current tool. Stop a still-running agent by pressing `x` (then `x` again to confirm) — both work for background agents too
- **Custom agent types** — define agents in `.pi/agents/<name>.md` with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions
- **Mid-run steering** — inject messages into running agents to redirect their work without restarting
- **Session resume** — pick up where an agent left off, preserving full conversation context
Expand Down Expand Up @@ -123,7 +123,7 @@ While subagents are running, a Claude Code-style navigable list renders **below*
↓ 3 more
```

The list is ordered earliest-launched first, and only shows agents you can actually open (pending/queued agents with no session yet appear once they start). At an **empty prompt**, press `↓` (or `←`) to move focus from the prompt into the list — the selected row is marked `⏺`, the rest `◯`. `↑`/`↓` move the selection, `Enter` opens the selected agent's live conversation overlay (it auto-updates as the agent works), and `Esc` (or `↑` above `main`) returns to the prompt. Selecting `main` returns to the normal view. A viewer stays open when its agent finishes so you can read the final output, and finished agents linger in the list for a few seconds before dropping out. Typing anything at a non-empty prompt behaves normally — the list only captures arrow keys when the prompt is empty. Disable it entirely via `/agents → Settings → Fleet view`.
The list is ordered earliest-launched first, and only shows agents you can actually open (pending/queued agents with no session yet appear once they start). At an **empty prompt**, press `↓` (or `←`) to move focus from the prompt into the list — the selected row is marked `⏺`, the rest `◯`. `↑`/`↓` move the selection, `Enter` opens the selected agent's live conversation overlay (it auto-updates as the agent works), and `Esc` (or `↑` above `main`) returns to the prompt. Selecting `main` returns to the normal view. Inside the overlay, press `Enter` to steer the running agent — type a message and `Enter` to send it (`Esc` or an empty submit returns), and it redirects the agent the same way the `steer_subagent` tool does. A viewer stays open when its agent finishes so you can read the final output, and finished agents linger in the list for a few seconds before dropping out. Typing anything at a non-empty prompt behaves normally — the list only captures arrow keys when the prompt is empty. Disable it entirely via `/agents → Settings → Fleet view`.

Individual agent results render Claude Code-style in the conversation:

Expand Down Expand Up @@ -317,7 +317,7 @@ Create new agent ← manual wizard or AI-generated
Settings ← max concurrency, max turns, grace turns, join mode
```

- **Running agents** — select one to open its live conversation viewer. While it's still running, press `x` (then `x` again to confirm) to stop/abort it — including **background** agents, which a global Esc can't unambiguously target (Esc still stops a blocking foreground `Agent` call). A stopped agent reports its partial output flagged as incomplete, not as a completion.
- **Running agents** — select one to open its live conversation viewer. While it's still running, press `Enter` to open the steering composer, then `Enter` again to send a message that redirects the agent (same mechanism as the `steer_subagent` tool; `Esc` or an empty submit returns), or press `x` (then `x` again to confirm) to stop/abort it — including **background** agents, which a global Esc can't unambiguously target (Esc still stops a blocking foreground `Agent` call). A stopped agent reports its partial output flagged as incomplete, not as a completion.
- **Agent types** — unified list with source indicators: `•` (project), `◦` (global), `✕` (disabled). Each row shows the agent's model, and the highlighted agent's full description appears below the list. The model column flags `(unavailable, fallback: inherit)` when a configured model can't be resolved (it would silently inherit the parent model), and shows `(→ provider/id)` when it resolves to a different provider or version than configured. Select an agent to manage it:
- **Default agents** (no override): Eject (export as `.md`), Disable
- **Default agents** (ejected/overridden): Edit, Disable, Reset to default, Delete
Expand Down
21 changes: 21 additions & 0 deletions src/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,27 @@ export class AgentManager {
return record;
}

/**
* Send a steering message to an agent from the UI (mirrors the steer_subagent
* tool). A live session delivers it now — it interrupts the agent after its
* current tool execution and appears as a user message. If the session isn't
* ready yet, the message is queued on `pendingSteers` and flushed when the
* session is created. Returns false if the agent can't accept steering
* (unknown id, or no longer running/queued).
*/
steer(id: string, message: string): boolean {
const record = this.agents.get(id);
if (!record) return false;
if (record.status !== "running" && record.status !== "queued") return false;
if (record.session) {
record.session.steer(message).catch(() => {});
} else {
if (!record.pendingSteers) record.pendingSteers = [];
record.pendingSteers.push(message);
}
return true;
}

getRecord(id: string): AgentRecord | undefined {
return this.agents.get(id);
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1664,7 +1664,7 @@ Terse command-style prompts produce shallow, generic work.
if (manager.abort(record.id)) {
ctx.ui.notify(`Stopped "${record.description}".`, "info");
}
}, keybindings);
}, keybindings, (message: string) => manager.steer(record.id, message));
},
{
overlay: true,
Expand Down
95 changes: 80 additions & 15 deletions src/ui/conversation-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import type { AgentSession } from "@earendil-works/pi-coding-agent";
import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import { extractText } from "../context.js";
import type { AgentRecord } from "../types.js";
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
Expand All @@ -29,6 +29,8 @@ export class ConversationViewer implements Component {
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
private stopArmed = false;
private keys: ViewerKeys;
/** Steering composer — present while the user is typing a message to the agent. */
private composer: Input | undefined;

constructor(
private tui: TUI,
Expand All @@ -41,6 +43,8 @@ export class ConversationViewer implements Component {
private onStop?: () => void,
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
keybindings?: ViewerKeybindings,
/** Send a steering message to the agent. Omitted → no compose affordance. */
private onSteer?: (message: string) => void,
) {
this.keys = createViewerKeys(keybindings);
this.unsubscribe = session.subscribe(() => {
Expand All @@ -50,12 +54,29 @@ export class ConversationViewer implements Component {
}

handleInput(data: string): void {
// While composing a steer message, the input owns all keys (Enter sends,
// Esc cancels — both wired in openComposer()). Editing keys flow through.
if (this.composer) {
this.composer.handleInput(data);
this.tui.requestRender();
return;
}

if (matchesKey(data, "escape") || matchesKey(data, "q")) {
this.closed = true;
this.done(undefined);
return;
}

// Enter opens the steering composer (only while the agent can still be
// steered) — then type + Enter sends, Esc or an empty submit returns. When
// not steerable, fall through so the key still disarms a pending stop.
if (matchesKey(data, "enter") && this.canSteer()) {
this.stopArmed = false;
this.openComposer();
return;
}

// Stop/abort the agent (only while it can still be stopped). Two-press:
// first "x" arms, second confirms — any other key disarms.
if (matchesKey(data, "x")) {
Expand Down Expand Up @@ -162,19 +183,39 @@ export class ConversationViewer implements Component {

// Footer
lines.push(hrMid);
const scrollPct = contentLines.length <= viewportHeight
? "100%"
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
const scrollHint = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
// Stop hint goes first in the right group so it survives right-edge
// truncation on narrow terminals (the scroll hint is the expendable part).
const footerRight = this.isStoppable()
? (this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop")) +
th.fg("dim", " · ") + scrollHint
: scrollHint;
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
if (this.composer) {
// Composer row: the Input renders its own `> ` prompt and cursor.
lines.push(row(this.composer.render(innerW)[0] ?? ""));
const composeHint = th.fg("dim", "Enter send · Esc cancel");
const composeLeft = th.fg("accent", "✎ steer");
const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint));
} else {
// Actions on the left, navigation on the right. The scroll hint keeps its
// full key list so the less-obvious bindings stay discoverable; it leads
// the right group so "Esc close" is the only part that truncates first.
const sep = th.fg("dim", " · ");
const actions: string[] = [];
if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
if (this.isStoppable()) {
actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
}
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");

// Prepend the line-count/scroll-% readout only when there's spare width —
// it's the first thing dropped so it never crowds out the hints.
const scrollPct = contentLines.length <= viewportHeight
? "100%"
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
const withCount = [count, ...actions].join(sep);
const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
? withCount
: actions.join(sep);

const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
}
lines.push(hrBot);

return lines;
Expand All @@ -185,6 +226,29 @@ export class ConversationViewer implements Component {
return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
}

/** Steerable only when a steer handler exists and the agent is still active. */
private canSteer(): boolean {
return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued");
}

/** Open the inline steering composer and route subsequent input to it. */
private openComposer(): void {
const input = new Input();
input.focused = true;
input.onSubmit = (value: string) => {
const message = value.trim();
this.composer = undefined;
if (message) this.onSteer?.(message);
this.tui.requestRender();
};
input.onEscape = () => {
this.composer = undefined;
this.tui.requestRender();
};
this.composer = input;
this.tui.requestRender();
}

invalidate(): void { /* no cached state to clear */ }

dispose(): void {
Expand All @@ -205,7 +269,8 @@ export class ConversationViewer implements Component {
}

private chromeLines(): number {
return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0);
// The composer adds one row above the footer hint while it's open.
return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0);
}

private invocationLine(): string | undefined {
Expand Down
1 change: 1 addition & 0 deletions src/ui/fleet-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ export class FleetList {
if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info");
},
keybindings,
(message: string) => this.manager.steer(record.id, message),
);
},
{
Expand Down
Loading
Loading