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
34 changes: 21 additions & 13 deletions extensions/background-terminals/src/ui/ps.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* /ps UI — two-stage full-screen overlay over the synchronous
* TerminalReadModel:
* - TerminalDashboard: list of all tracked terminals (select, kill, open).
* - TerminalDetailView: read-only inspector for one terminal — metadata,
* stdout/stderr toggle, scrolling, live tail. No input surface: background
* terminals have no stdin by design.
* /ps UI — two-stage inspector over the synchronous TerminalReadModel:
* - TerminalDashboard: compact picker docked above the input, listing all
* tracked terminals (select, kill, open).
* - TerminalDetailView: full-screen read-only inspector for one terminal —
* metadata, stdout/stderr toggle, scrolling, live tail. No input surface:
* background terminals have no stdin by design.
*/

import type {
Expand Down Expand Up @@ -92,7 +92,14 @@ export async function openTerminalPicker(
new TerminalDashboard(tui, theme, keybindings, view, selection, done),
{
overlay: true,
overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
// Dock the picker just above the editor (editor + strip + footer ≈ 6
// rows) like a command palette, instead of blanking the conversation.
overlayOptions: {
anchor: "bottom-center",
width: "100%",
maxHeight: "60%",
margin: { bottom: 6 },
},
},
);

Expand All @@ -111,7 +118,10 @@ export async function openTerminalPicker(
}
}

// --- Dashboard (fullscreen overlay) ----------------------------------------------
// --- Dashboard (picker docked above the input) ---------------------------------

/** A picker is a glance, not a workspace: cap the list window and scroll. */
const MAX_PICKER_ROWS = 10;

export interface DashboardSelection {
id?: string;
Expand Down Expand Up @@ -228,11 +238,9 @@ class TerminalDashboard implements Component {
const terminals = this.terminals();
reconcileDashboardSelection(this.selection, terminals);

const rows = this.tui.terminal.rows || 30;
// Render exactly terminal rows - 1 so the overlay covers the header,
// chat, editor, and extra footer lines while leaving pi's final footer
// row visible.
const bodyHeight = Math.max(6, rows - 5);
// Size the panel to its content (bounded, scrolling past the cap) so the
// docked picker never covers more conversation than the list needs.
const bodyHeight = Math.min(Math.max(terminals.length, 1), MAX_PICKER_ROWS);
const running = terminals.filter((s) => s.status === "running").length;
const keys = (binding: Parameters<KeybindingsManager["getKeys"]>[0]) =>
configuredKeys(this.keybindings, binding);
Expand Down
4 changes: 2 additions & 2 deletions extensions/shared/below-editor-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,8 @@ export class BelowEditorNavigationEditor implements EditorComponent, Focusable {
* a hint that recedes furthest.
*
* The whole tail used to be painted in the status colour, which made a routine
* "0/1 agents · 9m51s · ↓ to manage" shout as loudly as a failure. The status
* already has a coloured square on the left edge, so the tail only borrows that
* "1 running · 9m51s · ↓ to manage" shout as loudly as a failure. The status
* already has a coloured glyph on the left edge, so the tail only borrows that
* colour for the one count that carries the outcome — and only once the run has
* settled, where the colour means something.
*/
Expand Down
16 changes: 10 additions & 6 deletions extensions/subagents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ function renderSubagentResult(
}

const failed = details.status === "error";
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "");
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "");
const header =
`${icon} ` +
theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) +
Expand Down Expand Up @@ -388,9 +388,12 @@ export default function (pi: ExtensionAPI) {
manager.view.list(),
settledAcknowledgedAt,
);
// In the TUI the below-editor strip already reports the same activity and
// carries the manage affordance, so a footer status line would repeat it.
const tui = sessionContext?.mode === "tui";
ui.setStatus(
"subagents",
hasActivity(counts)
!tui && hasActivity(counts)
? formatActivityStatus(ui.theme, "subagents", counts)
: undefined,
);
Expand Down Expand Up @@ -1146,11 +1149,12 @@ export default function (pi: ExtensionAPI) {
(entry, _options, theme) => {
const data = entry.data;
const failed = data?.status === "error";
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "✓");
return new Text(
`${theme.fg(failed ? "error" : "success", "\u25cf")} ` +
`${icon} ${theme.fg("accent", data?.title ?? "?")}` +
theme.fg(
"muted",
`Agent "${data?.title ?? "?"}" ${failed ? "failed" : "finished"} \u00b7 ${data?.elapsed ?? "?"}`,
"dim",
` ${failed ? "failed" : "finished"} · ${data?.elapsed ?? "?"}`,
),
1,
0,
Expand All @@ -1163,7 +1167,7 @@ export default function (pi: ExtensionAPI) {
(entry, { expanded }, theme) => {
const data = entry.data;
const failed = data?.status === "error";
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "");
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "");
const header =
`${icon} ` +
theme.fg("accent", theme.bold(`by the way · ${data?.title ?? "?"}`)) +
Expand Down
10 changes: 5 additions & 5 deletions extensions/subagents/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,14 +140,14 @@ test("the metrics tail stays quiet while a run is healthy", () => {
}
};

// A routine run borrows no status colour in its tail: the coloured square on
// A routine run borrows no status colour in its tail: the coloured glyph on
// the left already carries the state, and hints recede furthest of all.
const running = render("running");
assert.match(running, /<muted>0\/1 agents<\/muted>/);
assert.match(running, /<muted>1 running<\/muted>/);
assert.match(running, /<dim>↓ to manage<\/dim>/);
assert.doesNotMatch(running, /<warning>0\/1 agents/);
assert.doesNotMatch(running, /<warning>1 running/);

// Once settled, the one count that carries the outcome takes the colour.
assert.match(render("error"), /<error>1\/1 agents<\/error>/);
assert.match(render("done"), /<success>1\/1 agents<\/success>/);
assert.match(render("error"), /<error>1 failed<\/error>/);
assert.match(render("done"), /<success>1 done<\/success>/);
});
38 changes: 25 additions & 13 deletions extensions/subagents/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "../shared/activity-status.ts";
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
import { formatElapsed, type SubagentSnapshot } from "./src/domain.ts";
import { formatContextUtilization } from "./src/format.ts";
import { contextPercent } from "./src/format.ts";

export interface SubagentStripEntry {
snapshot: SubagentSnapshot;
Expand Down Expand Up @@ -59,8 +59,11 @@ function statusColor(status: SubagentSnapshot["status"]) {
return "error" as const;
}

function statusSquare(snapshot: SubagentSnapshot, theme: Theme) {
return theme.fg(statusColor(snapshot.status), "■");
/** One status glyph per run state; doubles as the focus marker when selected. */
function statusGlyph(snapshot: SubagentSnapshot, theme: Theme) {
if (snapshot.status === "running") return theme.fg("warning", "●");
if (snapshot.status === "done") return theme.fg("success", "✓");
return theme.fg("error", "x");
}

/** One-line subagent manager entry with the same affordance as Workflow. */
Expand Down Expand Up @@ -95,25 +98,34 @@ export class SubagentStripWidget {
const entry = this.getEntry();
if (!entry || width <= 0) return [];
const { snapshot, counts } = entry;
const marker = this.strip.focused
const glyph = this.strip.focused
? this.theme.fg("accent", "❯")
: this.theme.fg("dim", "○");
: statusGlyph(snapshot, this.theme);
const titleText = normalizeSubagentTitle(snapshot.title, snapshot.id);
const title = this.strip.focused
? this.theme.bold(this.theme.fg("accent", titleText))
: this.theme.fg("text", titleText);
const model = snapshot.meta.modelLabel
? cleanLine(snapshot.meta.modelLabel)
: undefined;
const left = ` ${marker} ${statusSquare(snapshot, this.theme)} ${title}${model ? this.theme.fg("dim", ` · ${model}`) : ""}`;
const settled = counts.done + counts.failed;
const total = counts.running + settled;
// The footer already shows the session model; the takeover view keeps the
// per-subagent model, so the one-line strip stays title-only.
const left = ` ${glyph} ${title}`;
// Worded counts read at a glance; the selected run's own state comes
// first so the emphasis colour always lands on the matching count.
const donePart = counts.done > 0 ? `${counts.done} done` : undefined;
const failedPart =
counts.failed > 0 ? `${counts.failed} failed` : undefined;
const activity =
counts.running > 0
? [`${counts.running} running`]
: snapshot.status === "error"
? [failedPart, donePart]
: [donePart, failedPart];
const percent = contextPercent(snapshot.usage);
const right = renderNavigationMetrics(
this.theme,
[
`${settled}/${total} agents`,
...activity,
formatElapsed(snapshot),
formatContextUtilization(snapshot.usage),
percent === undefined ? undefined : `${percent}% ctx`,
],
this.strip.focused ? "enter open · ↑ back" : "↓ to manage",
snapshot.status === "running" ? undefined : statusColor(snapshot.status),
Expand Down
20 changes: 16 additions & 4 deletions extensions/subagents/src/ui/takeover.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Takeover UI for subagents (ported from v1, rendering from the synchronous
* SubagentReadModel instead of live pi sessions):
* - SubagentDashboard: full popup (overlay) listing all subagents.
* - SubagentDashboard: compact picker docked above the input, listing all subagents.
* - TakeoverView: full interactive view of one subagent with an input line
* to steer/continue it.
*/
Expand Down Expand Up @@ -132,7 +132,14 @@ export async function openSubagentPicker(
new SubagentDashboard(tui, theme, keybindings, view, selection, done),
{
overlay: true,
overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
// Dock the picker just above the editor (editor + strip + footer ≈ 6
// rows) like a command palette, instead of covering the conversation.
overlayOptions: {
anchor: "bottom-center",
width: "100%",
maxHeight: "60%",
margin: { bottom: 6 },
},
},
);

Expand All @@ -144,7 +151,10 @@ export async function openSubagentPicker(
}
}

// --- Dashboard (fullscreen overlay) ----------------------------------------------
// --- Dashboard (picker docked above the input) ---------------------------------

/** A picker is a glance, not a workspace: cap the list window and scroll. */
const MAX_PICKER_ROWS = 10;

export interface DashboardSelection {
id?: string;
Expand Down Expand Up @@ -270,8 +280,10 @@ export class SubagentDashboard implements Component {

// One timestamp per frame so every row's spinner shows the same frame.
const now = Date.now();
// Docked above the editor, the panel borrows conversation space: cap the
// list window and scroll instead of growing toward the top of the screen.
const rows = this.tui.terminal.rows || 30;
const maxBodyHeight = Math.max(1, rows - 5);
const maxBodyHeight = Math.min(Math.max(1, rows - 5), MAX_PICKER_ROWS);
const bodyHeight =
subs.length > maxBodyHeight ? maxBodyHeight : Math.max(1, subs.length);
const innerWidth = Math.max(0, width - 2);
Expand Down
2 changes: 1 addition & 1 deletion extensions/subagents/src/ui/wait-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function buildWaitResultPreview(
const results = details?.results ?? [];
const failed = results.filter((result) => result.status === "error").length;
const header =
theme.fg(failed > 0 ? "warning" : "success", "■") +
theme.fg(failed > 0 ? "warning" : "success", failed > 0 ? "!" : "✓") +
` ${theme.fg("accent", theme.bold(`${results.length} subagent${results.length === 1 ? "" : "s"} settled`))}` +
(failed > 0 ? theme.fg("error", ` · ${failed} failed`) : "");
const lines = [header];
Expand Down
15 changes: 11 additions & 4 deletions extensions/workflows/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import {
formatElapsed,
formatTokens,
statusColor,
statusSquare,
type Theme,
type WorkflowDetails,
type WorkflowStatus,
} from "./model.ts";

/** Workflow-named aliases preserve the public seam while sharing interaction. */
Expand All @@ -34,6 +34,13 @@ function cleanLine(value: string) {
return sanitizeTerminalText(value).replace(/\s+/g, " ").trim();
}

/** One status glyph per run state; doubles as the focus marker when selected. */
function statusGlyph(status: WorkflowStatus, theme: Theme) {
if (status === "completed") return theme.fg("success", "✓");
if (status === "running") return theme.fg("warning", "●");
return theme.fg("error", "x");
}

/** Live, one-line Claude-style workflow entry rendered below the editor. */
export class WorkflowStripWidget {
private readonly timer: ReturnType<typeof setInterval>;
Expand Down Expand Up @@ -70,16 +77,16 @@ export class WorkflowStripWidget {
const settled = done + failed;
const usage = aggregateUsage(details.agents);
const tokenCount = usage.input + usage.output;
const marker = this.strip.focused
const glyph = this.strip.focused
? this.theme.fg("accent", "❯")
: this.theme.fg("dim", "○");
: statusGlyph(details.status, this.theme);
const displayName = cleanLine(details.name ?? entry.runId) || entry.runId;
const name = this.strip.focused
? this.theme.bold(this.theme.fg("accent", displayName))
: this.theme.fg("text", displayName);
const rawContext = details.currentPhase ?? details.description;
const context = rawContext ? cleanLine(rawContext) : undefined;
const left = ` ${marker} ${statusSquare(details.status, this.theme)} ${name}${context ? this.theme.fg("dim", ` · ${context}`) : ""}`;
const left = ` ${glyph} ${name}${context ? this.theme.fg("dim", ` · ${context}`) : ""}`;
const right = renderNavigationMetrics(
this.theme,
[
Expand Down
Loading