feat(coding-agent): redesigned agent hub layout for task clarity
- Redesigned agent hub entries as two-line cards, separating identity and status from task descriptions. - Added explicit model and thinking level badges to agent status information. - Updated agent hub rendering to support multi-line entries and adaptive vertical scrolling based on row height. - Simplified status display by using glyphs instead of redundant status labels. - Adjusted rendering logic to prioritize displaying agent task summaries on an independent indented line.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
- Added display setting to toggle between collapsing or keeping compacted history inline, now applied to live session displays
|
||||
- Added a compact session-only model picker (Alt+P) for quick model switching without changing roles
|
||||
- Redesigned Agent Hub entries as two-line cards: identity (status glyph, name, agent type, parent when nested) on the left, active model + reasoning level and age right-aligned, with the task description on its own line; dropped the redundant `sub · of Main` noise
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import { Container, Ellipsis, matchesKey, type OverlayHandle, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import { Container, Ellipsis, matchesKey, type OverlayHandle, padding, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
||||
import { ADVISOR_TRANSCRIPT_FILENAME, isAdvisorTranscriptName } from "../../advisor";
|
||||
import type { KeyId } from "../../config/keybindings";
|
||||
@@ -25,6 +25,7 @@ import { IrcBus } from "../../irc/bus";
|
||||
import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
|
||||
import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
||||
import { USER_INTERRUPT_LABEL } from "../../session/messages";
|
||||
import { parseThinkingLevel } from "../../thinking";
|
||||
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
|
||||
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
|
||||
import { theme } from "../theme/theme";
|
||||
@@ -55,20 +56,50 @@ function clampHubLine(line: string, width: number): string {
|
||||
|
||||
const STATUS_ORDER: Record<AgentStatus, number> = { running: 0, idle: 1, parked: 2, aborted: 3 };
|
||||
|
||||
/** Glyph + status word, colored per theme status conventions. */
|
||||
function statusBadge(status: AgentStatus): string {
|
||||
/** Status glyph, colored per theme status conventions. The title-line counts spell out the words. */
|
||||
function statusGlyph(status: AgentStatus): string {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return theme.fg("accent", `${theme.status.running} running`);
|
||||
return theme.fg("accent", theme.status.running);
|
||||
case "idle":
|
||||
return theme.fg("success", `${theme.status.enabled} idle`);
|
||||
return theme.fg("success", theme.status.enabled);
|
||||
case "parked":
|
||||
return theme.fg("muted", `${theme.status.shadowed} parked`);
|
||||
return theme.fg("muted", theme.status.shadowed);
|
||||
case "aborted":
|
||||
return theme.fg("error", `${theme.status.aborted} aborted`);
|
||||
return theme.fg("error", theme.status.aborted);
|
||||
}
|
||||
}
|
||||
|
||||
/** Model id + thinking level (`sonnet-4-6 ◒ high`), level colored per theme. */
|
||||
function formatModelBadge(modelId: string, level: ThinkingLevel | undefined): string {
|
||||
const model = theme.fg("muted", replaceTabs(modelId));
|
||||
if (!level || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) return model;
|
||||
const display = theme.thinking[level as keyof typeof theme.thinking] ?? level;
|
||||
return `${model} ${theme.getThinkingBorderColor(level)(display)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active model + reasoning level for a hub row: live session state when the
|
||||
* agent is attached, else the executor-reported `resolvedModel` selector
|
||||
* (`provider/id`, optionally `:<level>`). Undefined when neither is known
|
||||
* (e.g. a parked historical agent restored from disk).
|
||||
*/
|
||||
function modelBadge(ref: AgentRef, observed: ObservableSession | undefined): string | undefined {
|
||||
const model = ref.session?.model;
|
||||
if (model) {
|
||||
const level = model.thinking ? ref.session?.thinkingLevel : undefined;
|
||||
return formatModelBadge(model.id, level);
|
||||
}
|
||||
const resolved = observed?.progress?.resolvedModel;
|
||||
if (!resolved) return undefined;
|
||||
// Model ids may themselves contain colons (`qwen3:14b`), so only treat the
|
||||
// suffix as a thinking level when it parses as one.
|
||||
const colon = resolved.lastIndexOf(":");
|
||||
const level = colon >= 0 ? parseThinkingLevel(resolved.slice(colon + 1)) : undefined;
|
||||
const selector = level !== undefined ? resolved.slice(0, colon) : resolved;
|
||||
return formatModelBadge(selector.slice(selector.indexOf("/") + 1), level);
|
||||
}
|
||||
|
||||
async function registerPersistedSubagents(
|
||||
registry: AgentRegistry,
|
||||
sessionFile: string | null | undefined,
|
||||
@@ -427,17 +458,31 @@ export class AgentHubOverlayComponent extends Container {
|
||||
} else {
|
||||
const termHeight = process.stdout.rows || 40;
|
||||
// Chrome: 2 borders + title + notice? + blank + hints + border
|
||||
const maxVisible = Math.max(3, termHeight - 7 - (this.#notice ? 1 : 0));
|
||||
let start = 0;
|
||||
if (this.#rows.length > maxVisible) {
|
||||
start = Math.min(
|
||||
Math.max(0, this.#selectedRow - Math.floor(maxVisible / 2)),
|
||||
this.#rows.length - maxVisible,
|
||||
);
|
||||
const budget = Math.max(4, termHeight - 7 - (this.#notice ? 1 : 0));
|
||||
const entries = this.#rows.map((ref, i) => this.#renderEntry(ref, i === this.#selectedRow, width));
|
||||
// Entries are 1-2 lines tall; grow a window around the selection until
|
||||
// the line budget is spent, so the selected entry stays centered.
|
||||
let start = this.#selectedRow;
|
||||
let end = this.#selectedRow + 1;
|
||||
let used = entries[start]?.length ?? 0;
|
||||
for (let grew = true; grew; ) {
|
||||
grew = false;
|
||||
if (end < entries.length && used + entries[end].length <= budget) {
|
||||
used += entries[end].length;
|
||||
end++;
|
||||
grew = true;
|
||||
}
|
||||
if (start > 0 && used + entries[start - 1].length <= budget) {
|
||||
start--;
|
||||
used += entries[start].length;
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
if (start > 0) {
|
||||
lines.push(` ${theme.fg("dim", `… ${start} more`)}`);
|
||||
}
|
||||
const end = Math.min(start + maxVisible, this.#rows.length);
|
||||
for (let i = start; i < end; i++) {
|
||||
lines.push(this.#renderRow(this.#rows[i], i === this.#selectedRow, width));
|
||||
lines.push(...entries[i]);
|
||||
}
|
||||
if (end < this.#rows.length) {
|
||||
lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`);
|
||||
@@ -466,26 +511,51 @@ export class AgentHubOverlayComponent extends Container {
|
||||
return parts.join(theme.sep.dot);
|
||||
}
|
||||
|
||||
#renderRow(ref: AgentRef, selected: boolean, width: number): string {
|
||||
/**
|
||||
* One agent entry, 1-2 lines:
|
||||
* `❯ ⟳ Name type ↳ parent ⧉ 2 ········ model ◒ level · age` — identity
|
||||
* left, metadata right-aligned (inlined when the terminal is too narrow) —
|
||||
* plus an indented dim task line when the agent's work is known.
|
||||
*/
|
||||
#renderEntry(ref: AgentRef, selected: boolean, width: number): string[] {
|
||||
const max = Math.max(1, width - 2);
|
||||
const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
|
||||
const parts: string[] = [statusBadge(ref.status), theme.bold(replaceTabs(ref.id))];
|
||||
parts.push(theme.fg("dim", replaceTabs(ref.displayName)));
|
||||
parts.push(theme.fg("dim", ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind));
|
||||
if (ref.kind === "advisor") {
|
||||
parts.push(theme.fg("warning", "read-only"));
|
||||
const fields: string[] = [`${cursor} ${statusGlyph(ref.status)} ${theme.bold(replaceTabs(ref.id))}`];
|
||||
if (ref.displayName && ref.displayName !== ref.id) {
|
||||
fields.push(theme.fg("dim", replaceTabs(ref.displayName)));
|
||||
}
|
||||
const observed = this.#observableFor(ref.id);
|
||||
const task = observed?.description ?? observed?.progress?.task;
|
||||
if (task) {
|
||||
parts.push(theme.fg("muted", sanitizeLine(task, TRUNCATE_LENGTHS.TITLE)));
|
||||
if (ref.parentId && ref.parentId !== MAIN_AGENT_ID) {
|
||||
fields.push(theme.fg("dim", `↳ ${replaceTabs(ref.parentId)}`));
|
||||
}
|
||||
if (ref.kind === "advisor") {
|
||||
fields.push(theme.fg("warning", "read-only"));
|
||||
}
|
||||
const unread = this.#irc.unreadCount(ref.id);
|
||||
if (unread > 0) {
|
||||
parts.push(theme.fg("warning", `⧉ ${unread}`));
|
||||
fields.push(theme.fg("warning", `⧉ ${unread}`));
|
||||
}
|
||||
parts.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))));
|
||||
const rawLine = ` ${cursor} ${parts.join(theme.sep.dot)}`;
|
||||
return truncateToWidth(rawLine.replace(/[\r\n]+/g, " "), Math.max(1, width - 1));
|
||||
const left = ` ${fields.join(" ")}`;
|
||||
|
||||
const observed = this.#observableFor(ref.id);
|
||||
const meta: string[] = [];
|
||||
const badge = modelBadge(ref, observed);
|
||||
if (badge) meta.push(badge);
|
||||
meta.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))));
|
||||
const right = meta.join(theme.sep.dot);
|
||||
|
||||
const leftWidth = visibleWidth(left);
|
||||
const rightWidth = visibleWidth(right);
|
||||
const line =
|
||||
leftWidth + 2 + rightWidth <= max
|
||||
? left + padding(max - leftWidth - rightWidth) + right
|
||||
: truncateToWidth(`${left} ${right}`.replace(/[\r\n]+/g, " "), max);
|
||||
const entry = [line];
|
||||
|
||||
const task = observed?.description ?? observed?.progress?.task ?? ref.activity;
|
||||
if (task) {
|
||||
entry.push(` ${theme.fg("muted", sanitizeLine(task, Math.max(10, max - 5)))}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
#handleTableInput(keyData: string): void {
|
||||
|
||||
@@ -53,15 +53,14 @@ function makeHub(agents: AgentRegistry) {
|
||||
}
|
||||
|
||||
function renderedAgentIds(hub: AgentHubOverlayComponent): string[] {
|
||||
return hub
|
||||
.render(120)
|
||||
.map(line => Bun.stripANSI(line))
|
||||
.map(line => line.split(" · "))
|
||||
.filter(
|
||||
parts =>
|
||||
parts.length >= 4 && ["running", "idle", "parked", "aborted"].some(status => parts[0].endsWith(status)),
|
||||
)
|
||||
.map(parts => parts[1]!);
|
||||
// Entry first lines are ` <cursor> <status-glyph> <id> …`; task lines are
|
||||
// indented deeper and chrome lines never carry the cursor slot.
|
||||
const ids: string[] = [];
|
||||
for (const raw of hub.render(120)) {
|
||||
const match = /^ (?:❯| ) (\S+) (\S+)/u.exec(Bun.stripANSI(raw));
|
||||
if (match) ids.push(match[2]!);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe("Agent hub row ordering", () => {
|
||||
|
||||
Reference in New Issue
Block a user