diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 8115d79e6..40249d005 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -45,7 +45,7 @@ At 140 columns or wider, fullscreen defaults to a live header over a transcript- ``` - The header carries live session data: session identity on the left (brand, cwd, branch, dirty count, model · effort · profile) and the two live counters right-aligned (the context gauge and session cost). The effort reads `auto` for NaN models that manage their reasoning depth themselves (DeepSeek V4 Flash, Qwen 3.8 Flash, MiMo), with a one-time notice explaining that the selected level does not change the depth. It never shows the working/thinking state or extension statuses — those stay in the prompt indicator and the Status surface. When the terminal is too narrow for everything, segments give way in a fixed order — profile, then effort, then the whole cwd/branch/dirty group — before the counters are touched; below that, only the brand survives, and below that the header renders nothing. -- The right rail scrolls **Status → Changes → Agents → TODO**. Cards cache their rendered content until their own digest or an explicit invalidation changes. `neon` keeps the rounded border and accent title; `float` uses a tone background, a full-height `▎` accent, one-column transparent margins, painted padding above and below, and a blank painted separator between heading and body. Warning/error/success panels retain their tone colors. +- The right rail scrolls **Status → Jobs → Agents → TODO**. Cards cache their rendered content until their own digest or an explicit invalidation changes. `neon` keeps the rounded border and accent title; `float` uses a tone background, a full-height `▎` accent, one-column transparent margins, painted padding above and below, and a blank painted separator between heading and body. Warning/error/success panels retain their tone colors. - The Status card carries Project (cwd, branch, session name, active profile), Changes, and Integrations (other extensions' statuses); its live digest detects changes without requiring an event. Model, effort, context, cost, and the per-model usage table live in the header instead — the header ticks every frame, so duplicating them in a card would just make that card repaint every frame too. - Gentle Agents follows TODO: while the fullscreen rail is showing, the Agents card moves into it and lists every shown task; without the rail (narrow fullscreen or regular mode) it stays above the editor. Its card has fixed right-aligned columns for `model · effort`, tokens, cost, and elapsed, each sized to the widest value among the shown tasks — so the numbers line up vertically even when one row's values are much shorter than another's. A queued task fills only the elapsed column with the word `queued`, leaving the other columns blank rather than overwriting the row. - The sidebar reuses its last frame until something it paints changes, so silent frames stay cheap; a per-section cache means one card's changing digest (or the header's) never forces an unrelated card to redraw. @@ -308,7 +308,7 @@ until curl -sf localhost:3000/health; do sleep 1; done # server is up - While the orchestrator has background work running (a subagent task or a background job), a `bash` call that only sleeps (`sleep 10`, `sleep 2m`, or several such sleeps joined by `&&`/`;`) is refused with a message to end the turn, because each completion already arrives as its own message. Wait conditions such as `until ...; do sleep 1; done`, sleeps mixed with real commands, `bash_background`, and `monitor` are never refused, and `sleep` is unrestricted when nothing runs in the background. - Jobs live in memory, belong to the session that started them, and are stopped when the session shuts down. At most 25 run at once. - `monitor` is a background job whose every output line is an event, delivered while the command keeps running — for example `tail -f app.log | grep --line-buffered ERROR`, or a loop that prints each CI check as it finishes. Lines within 200 ms share one notice of at most 20 lines (the rest are counted, and stay in the log); notices waiting for a busy agent coalesce. `timeout_seconds` is mandatory (1–1800): the monitor is stopped when it expires and reports its event count. One that prints more than 120 lines within 60 seconds is stopped as a flood, so filter its output. When the command exits, a final notice reports the exit code and event count. It goes through the same command guards as `bash`. -- `/gentle:jobs` opens a full-terminal overlay with this session's jobs beside the selected job's command, status, output file, and live output tail; `↑`/`↓` or `j`/`k` move, `s` stops the selected running job, `q` closes (narrow terminals: `Tab` toggles details, `Escape` goes back). The footer shows `⧗ N jobs` while any run. +- `/gentle:jobs` opens a full-terminal overlay with this session's jobs beside the selected job's command, status, output file, and live output tail; `↑`/`↓` or `j`/`k` move, `s` stops the selected running job, `q` closes (narrow terminals: `Tab` toggles details, `Escape` goes back). The footer shows `⧗ N jobs` while any run. In fullscreen at 140 columns or wider, running jobs also get their own collapsible sidebar card (`⧗ Jobs`), one row per job with its elapsed time, newest first; it paints nothing only while nothing is current — no running jobs and no finished result still inside its 60-second tail, the Status card never repeats it, and `ctrl+shift+j` (or clicking the card's header) collapses it; `GENTLE_PI_JOBS_KEY` remaps the shortcut, with an empty value or `off` disabling it. ### Gentle Todo diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 13169c828..8bc3abd3e 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -19,6 +19,7 @@ import { createJobRegistry, isStandaloneSleep, type JobExecOperations, type JobR import { JOB_GLYPH, JOB_NOTICE_TYPE, jobDetails, jobNoticeText, monitorEventsText, monitorStoppedText, registerBackgroundJobTools } from "../lib/background-jobs-tools.ts"; import { createMonitorController, mergeMonitorBatches, type MonitorNotice } from "../lib/background-monitor.ts"; import { JobsView } from "../lib/jobs-view.ts"; +import { JOBS_FINISHED_TTL_MS } from "../lib/jobs-widget.ts"; import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY } from "../lib/jobs-sidebar-state.ts"; import { Text, type Component, type TUI } from "@earendil-works/pi-tui"; import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts"; @@ -1231,11 +1232,22 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = // Structured descriptions feed Status; native hosts keep the compact count. const refreshJobStatus = () => { const sessionId = activeSessionId(); - const running = jobs.list(sessionId ?? "").filter((job) => job.status === "running") + const sessionJobs = jobs.list(sessionId ?? ""); + const running = sessionJobs.filter((job) => job.status === "running") .sort((a, b) => b.startedAt - a.startedAt); + // Finished tails ride the same snapshot so the sidebar card can linger + // them briefly, like the Agents card's results; failed jobs ride too, + // painting the ✗ glyph like a nonzero exit. + const finished = sessionJobs.filter((job) => (job.status === "exited" || job.status === "stopped" || job.status === "failed") && job.endedAt !== undefined && deps.now() - job.endedAt < JOBS_FINISHED_TTL_MS) + .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0)).slice(0, 3); try { parentCtx?.ui.setStatus(JOBS_STATUS_KEY, running.length === 0 ? undefined : `${JOB_GLYPH} ${running.length} job${running.length === 1 ? "" : "s"}`); } catch { /* Status is cosmetic. */ } if (sessionId) { - try { pi.events.emit(JOBS_SIDEBAR_EVENT, { sessionId, snapshot: { jobs: running.map(({ id, label, startedAt }) => ({ id, label, startedAt })) } }); } catch { /* Display only. */ } + try { + pi.events.emit(JOBS_SIDEBAR_EVENT, { sessionId, snapshot: { jobs: [ + ...running.map(({ id, label, startedAt }) => ({ id, label, startedAt })), + ...finished.map(({ id, label, startedAt, status, endedAt, exitCode }) => ({ id, label, startedAt, status, endedAt, exitCode })), + ] } }); + } catch { /* Display only. */ } } }; diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 37fbf7c39..7f5d2a2da 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -1,6 +1,6 @@ import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent"; import { isHomeOrBroadUserDirectory } from "../lib/bounded-writer-admission.ts"; -import { Editor, Image, decodeKittyPrintable, getCapabilities, isKeyRelease, matchesKey, parseKey, truncateToWidth, visibleWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui"; +import { Editor, Image, decodeKittyPrintable, getCapabilities, isKeyRelease, matchesKey, parseKey, truncateToWidth, visibleWidth, type Component, type EditorTheme, type TUI } from "@earendil-works/pi-tui"; import { readClipboardImage, type ClipboardImage } from "../lib/clipboard-image.ts"; import { describeAttachment, ImageAttachments } from "../lib/image-attachments.ts"; import { execFile, spawnSync } from "node:child_process"; @@ -17,7 +17,7 @@ import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE, Worktree import { WorktreeChangesView } from "../lib/shell-changes-view.ts"; import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts"; import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver, type WorktreeIdentity } from "../lib/session-worktree-registry.ts"; -import { CARD_STYLE, CARD_TONE, cardStyle, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts"; +import { CARD_STYLE, CARD_TONE, cardStyle, panelHeaderRow, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts"; import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts"; import { CARD_CONTENT, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts"; import { CommandPalette, commandsKey, type CommandPaletteResult } from "../lib/command-palette.ts"; @@ -125,7 +125,9 @@ import { sidebarHeader, sidebarPart, sidebarState, VISUAL_SETTINGS_CHANGED, type import { installSidebar, invalidateSidebar, narrowStatusOwner, SIDEBAR_BREAKPOINT, STATUS_OWNER } from "../lib/shell-sidebar-layout.ts"; import { sessionChangeRoots } from "../lib/session-changes.ts"; import { REVIEW_SIDEBAR_EVENT, isReviewSidebarSnapshot, type ReviewSidebarSnapshot } from "../lib/review-sidebar-state.ts"; -import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY, isJobsSidebarSnapshot, type RunningJobDisplay } from "../lib/jobs-sidebar-state.ts"; +import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY, isJobsSidebarSnapshot, jobsCollapseKey, type RunningJobDisplay } from "../lib/jobs-sidebar-state.ts"; +import { JOBS_FINISHED_TTL_MS, jobsCardTone, renderJobsCard } from "../lib/jobs-widget.ts"; +import { NativePointerRegion } from "../lib/native-pointer-region.ts"; import { installSessionChangeCapture } from "../lib/session-change-capture.ts"; import { SelectionEngine } from "../lib/selection-engine.ts"; import { withOverlayRepaint } from "../lib/overlay-repaint.ts"; @@ -2048,6 +2050,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p let currentContext: ExtensionContext | undefined; let review: ReviewSidebarSnapshot | undefined; let runningJobs: RunningJobDisplay[] | undefined; + let jobsCollapsed = false; const redrawReview = () => { renderHost?.invalidateSidebar?.(); renderHost?.requestRender(); @@ -2063,7 +2066,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p const event = value as { sessionId?: unknown; snapshot?: unknown } | undefined; if (!currentContext || event?.sessionId !== currentContext.sessionManager.getSessionId()) return; if (!isJobsSidebarSnapshot(event.snapshot)) return; - runningJobs = event.snapshot.jobs.map(({ id, label, startedAt }) => ({ id, label, startedAt })); + runningJobs = event.snapshot.jobs.map(({ id, label, startedAt, status, endedAt, exitCode }) => ({ id, label, startedAt, status, endedAt, exitCode })); redrawReview(); }); pi.on("session_tree", () => { @@ -2156,7 +2159,9 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p pi, ctx, footerData, usage: () => usage.get(ctx.model?.provider ?? ""), profile: () => deps.activeProfile(), - jobs: () => runningJobs, + // The compact bar and header derive ⧗ N jobs from model.jobs.length, + // so this input is running-only: the snapshot carries the finished tail. + jobs: () => runningJobs?.filter((job) => job.status === undefined), review: () => review, tracker: () => tracker, visualSettings: () => visualSettings, @@ -2194,6 +2199,68 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p render: (width) => renderShellSidebarBar(footerModel(), theme, width, visualSettings), invalidate() {}, }); + // The Jobs card: its own rail section, like Agents and Todos, fed by the + // same running-job display snapshot the Status card used to print. The + // rail is natively scrollable, so it lists every running job; there is + // no above-editor copy — regular mode keeps the compact bar count. + let jobsHovered = false; + let jobsPainted = false; + let jobsHeaderRow = 0; + const jobsRailCard: Component = { + render(width: number) { + const jobs = runningJobs ?? []; + if (jobs.length === 0) { jobsPainted = false; return []; } + const lines = renderJobsCard(jobs, theme, width, deps.now(), { collapsed: jobsCollapsed, collapseKey: jobsKey, collapseControl: true, hovered: jobsHovered }); + jobsPainted = lines.length > 0; + jobsHeaderRow = jobsPainted ? panelHeaderRow(theme, width, jobsCardTone(jobs)) : 0; + return lines; + }, + invalidate() { + jobsHovered = false; + }, + }; + const jobsRegion = new NativePointerRegion(jobsRailCard, { + onHover(event) { + const next = event.y === jobsHeaderRow; + if (next === jobsHovered) return { handled: true }; + jobsHovered = next; + return { handled: true, render: true }; + }, + onLeave() { + if (!jobsHovered) return; + jobsHovered = false; + tui.requestRender(); + }, + onClick(event) { + if (event.button !== "left" || event.y !== jobsHeaderRow) return undefined; + jobsCollapsed = !jobsCollapsed; + invalidateSidebar(tui); + tui.requestRender(); + return { handled: true, render: true }; + }, + }); + const jobsRail: Component & { digest(): string; dispose(): void } = { + digest: () => JSON.stringify([jobsCollapsed, jobsHovered, runningJobs, Math.floor(deps.now() / 1000)]), + render: (width) => jobsRegion.render(width), + handleMouse: (event) => jobsRegion.handleMouse(event), + invalidate() { + jobsRegion.invalidate(); + }, + dispose() { + jobsRegion.dispose(); + }, + }; + const jobsPart = sidebarPart(tui, "jobs", { render: () => [], invalidate() {}, dispose() {} }, jobsRail); + // Elapsed time moves while a job runs, and a finished tail must clear + // itself once its TTL expires with nothing running behind it; one frame + // a second is enough, and the digest's second bucket revalidates the + // cached section. + const jobsTimer = setInterval(() => { + const now = deps.now(); + const fresh = (runningJobs ?? []).some((job) => job.status === undefined || now - (job.endedAt ?? 0) < JOBS_FINISHED_TTL_MS); + if (jobsPainted || fresh) tui.requestRender(); + }, 1_000); + jobsTimer.unref?.(); // The header row carries everything that ticks every frame (model, // effort, context, cost, usage) plus session identity and the jobs count // when Status is absent. Opaque statuses and working state stay excluded. @@ -2229,15 +2296,17 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p }), { placement: "belowEditor" }); return { ...part, render(width: number) { - if (visualSettings.statusPlacement === "hidden" && (tui as TUI & { mode?: string }).mode !== "fullscreen" && runningJobs?.length) { - return [truncateToWidth(theme.fg("muted", shellJobsCount(runningJobs.length)), width, "…")]; + // Running-only: a finished tail must never paint a ⧗ 0 jobs row. + const compactRunningJobs = runningJobs?.filter((job) => job.status === undefined); + if (visualSettings.statusPlacement === "hidden" && (tui as TUI & { mode?: string }).mode !== "fullscreen" && compactRunningJobs?.length) { + return [truncateToWidth(theme.fg("muted", shellJobsCount(compactRunningJobs.length)), width, "…")]; } const rows = part.render(width); // Preserve a shrinkable exterior dock row, outside prompt/completion geometry. return rows.length === 0 && (tui as TUI & { mode?: string }).mode === "fullscreen" && resolvePromptLayout(width, cardStyle(), theme.bg?.bind(theme)).background ? [""] : rows; }, - dispose() { ctx.ui.setWidget(HEADER_WIDGET_KEY, undefined); disposeHeader(); uninstall(); part.dispose(); if (sidebarTui === tui) { sidebarTui = undefined; groupedChangesOwner = () => false; } } }; + dispose() { clearInterval(jobsTimer); jobsPart.dispose(); ctx.ui.setWidget(HEADER_WIDGET_KEY, undefined); disposeHeader(); uninstall(); part.dispose(); if (sidebarTui === tui) { sidebarTui = undefined; groupedChangesOwner = () => false; } } }; }); void refreshUsage(ctx, true); const ownsPrompt = installPrompt( @@ -2358,6 +2427,17 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p handler: async (ctx) => showCommandPalette(pi, ctx, env), }); } + const jobsKey = jobsCollapseKey(env); + if (jobsKey) { + pi.registerShortcut(jobsKey as Parameters[0], { + description: "Collapse or expand the jobs card", + handler: async () => { + jobsCollapsed = !jobsCollapsed; + if (sidebarTui) invalidateSidebar(sidebarTui); + renderHost?.requestRender(); + }, + }); + } pi.registerCommand("gentle:customize", { description: "Configure appearance, audio notifications, Vim editing, session permissions and global Auto/YOLO defaults, and prompt history capture.", handler: async (_args, ctx) => { diff --git a/lib/jobs-sidebar-state.ts b/lib/jobs-sidebar-state.ts index 4f1ad41c1..1392ef918 100644 --- a/lib/jobs-sidebar-state.ts +++ b/lib/jobs-sidebar-state.ts @@ -2,10 +2,23 @@ export const JOBS_SIDEBAR_EVENT = "gentle-ai:jobs-sidebar"; export const JOBS_STATUS_KEY = "gentle-jobs"; +const COLLAPSE_KEY_DEFAULT = "ctrl+shift+j"; + +export function jobsCollapseKey(env: NodeJS.ProcessEnv = process.env): string | undefined { + const value = env.GENTLE_PI_JOBS_KEY?.trim(); + if (value === undefined) return COLLAPSE_KEY_DEFAULT; + return value === "" || value.toLowerCase() === "off" ? undefined : value; +} + export interface RunningJobDisplay { id: string; label: string; startedAt: number; + // Finished-tail fields; optional so older publishers that only list running + // jobs keep validating (the Agents-card pattern: results linger briefly). + status?: "exited" | "stopped" | "failed"; + endedAt?: number; + exitCode?: number | null; } export interface JobsSidebarSnapshot { @@ -14,7 +27,13 @@ export interface JobsSidebarSnapshot { export function isJobsSidebarSnapshot(value: unknown): value is JobsSidebarSnapshot { if (!value || typeof value !== "object" || !Array.isArray((value as JobsSidebarSnapshot).jobs)) return false; - return (value as JobsSidebarSnapshot).jobs.every((job) => job && typeof job === "object" && - typeof job.id === "string" && job.id.length > 0 && typeof job.label === "string" && - typeof job.startedAt === "number" && Number.isFinite(job.startedAt)); + return (value as JobsSidebarSnapshot).jobs.every((job) => { + if (!job || typeof job !== "object") return false; + if (typeof job.id !== "string" || job.id.length === 0 || typeof job.label !== "string") return false; + if (typeof job.startedAt !== "number" || !Number.isFinite(job.startedAt)) return false; + if (job.status !== undefined && job.status !== "exited" && job.status !== "stopped" && job.status !== "failed") return false; + if (job.endedAt !== undefined && (typeof job.endedAt !== "number" || !Number.isFinite(job.endedAt))) return false; + if (job.exitCode !== undefined && typeof job.exitCode !== "number" && job.exitCode !== null) return false; + return true; + }); } diff --git a/lib/jobs-widget.ts b/lib/jobs-widget.ts new file mode 100644 index 000000000..38e3a628b --- /dev/null +++ b/lib/jobs-widget.ts @@ -0,0 +1,112 @@ +import { visibleWidth } from "@earendil-works/pi-tui"; +import { JOB_GLYPH, formatJobDuration } from "./background-jobs-tools.ts"; +import type { RunningJobDisplay } from "./jobs-sidebar-state.ts"; +import { paintHoverable } from "./shell-hover.ts"; +import { CARD_TONE, panelInnerWidth, renderCard, type CardTheme, type CardTone } from "./shell-card.ts"; +import { sanitizeTerminalText } from "./terminal-theme.ts"; + +// Gentle Jobs widget: the sidebar card for this session's background jobs. +// Reads the running-job display snapshot only (id, label, startedAt), the same +// data the Status card used to print, so drawing it costs nothing per event. +// One row per job: glyph, label, elapsed right-aligned. Jobs are transient — +// the card paints nothing while no job runs, and the overlay (/gentle:jobs) +// stays the full view with stop controls and output tails. + +export const JOBS_GLYPH = JOB_GLYPH; + +// Finished jobs linger like the Agents card's results: newest 3 for a minute, +// so an exit is visible long enough to read before the card empties itself. +export const JOBS_FINISHED_TTL_MS = 60_000; +const MAX_FINISHED = 3; + +export interface JobsWidgetOptions { + collapsed: boolean; + collapseKey?: string; + // Paint the clickable "▾ Collapse" / "▸ Expand" control in the header, the + // same control the Agents and Todos cards carry. + collapseControl?: boolean; + hovered?: boolean; +} + +const GLYPH_ROLE = "accent"; +const NAME_ROLE = "text"; +const META_ROLE = "dim"; +const GLYPH_GAP = " "; +const ELLIPSIS = "…"; + +// Running jobs newest first, matching the /gentle:jobs running group and the +// old Status group this card replaces; finished tails follow, newest first. +export function widgetJobs(jobs: readonly RunningJobDisplay[], now: number): RunningJobDisplay[] { + const running = jobs.filter((job) => job.status === undefined).sort((a, b) => b.startedAt - a.startedAt); + const finished = jobs.filter((job) => job.status !== undefined && now - (job.endedAt ?? 0) < JOBS_FINISHED_TTL_MS) + .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0)).slice(0, MAX_FINISHED); + return [...running, ...finished]; +} + +function clean(text: string): string { + return sanitizeTerminalText(text).replace(/\s+/g, " "); +} + +function clip(text: string, width: number): string { + if (width <= 0) return ""; + if (visibleWidth(text) <= width) return text; + // Plain clip, mirroring the status bar: pi's truncateToWidth wraps the + // result in resets, which would end up inside a painted segment. + let clipped = ""; + for (const char of text) { + if (visibleWidth(clipped + char) > width - 1) break; + clipped += char; + } + return `${clipped}${ELLIPSIS}`; +} + +// The /gentle:jobs glyph roles: running keeps the spinner, exits mirror the +// overlay's outcome marks. +function glyph(job: RunningJobDisplay): [string, string] { + if (job.status === "stopped") return ["–", "dim"]; + if (job.status === "failed") return ["✗", "error"]; + if (job.status === "exited") return job.exitCode === 0 ? ["✓", "success"] : ["✗", "error"]; + return [JOBS_GLYPH, GLYPH_ROLE]; +} + +function counts(jobs: readonly RunningJobDisplay[]): string { + const running = jobs.filter((job) => job.status === undefined).length; + const finished = jobs.length - running; + const parts: string[] = []; + if (running > 0) parts.push(running === 1 ? "1 running" : `${running} running`); + if (finished > 0) parts.push(finished === 1 ? "1 finished" : `${finished} finished`); + return parts.join(" · "); +} + +/** The tone the card paints, so a host can locate its header row. */ +export function jobsCardTone(_jobs: readonly RunningJobDisplay[]): CardTone { + return CARD_TONE.INFO; +} + +export function renderJobsCard(jobs: readonly RunningJobDisplay[], theme: CardTheme, width: number, now: number, options: JobsWidgetOptions): string[] { + const shown = widgetJobs(jobs, now); + if (shown.length === 0) return []; + const tone = jobsCardTone(shown); + const inner = panelInnerWidth(theme, width, tone); + const listed = options.collapsed ? [shown[0]!] : shown; + const body = listed.map((job) => { + // A finished row freezes its elapsed at the end; a running one ticks. + const [mark, role] = glyph(job); + const elapsed = formatJobDuration(Math.max(0, (job.status === undefined ? now : job.endedAt ?? now) - job.startedAt)); + const label = clip(clean(job.label), Math.max(0, inner - visibleWidth(elapsed) - GLYPH_GAP.length - 1)); + const head = `${theme.fg(role, mark)}${GLYPH_GAP}${theme.fg(NAME_ROLE, label)}`; + const tail = theme.fg(META_ROLE, " ".repeat(Math.max(1, inner - visibleWidth(head) - visibleWidth(elapsed))) + elapsed); + return `${head}${tail}`; + }); + if (options.collapsed && shown.length > 1) body.push(theme.fg(META_ROLE, `${ELLIPSIS} ${shown.length - 1} more`)); + const icon = options.collapsed ? "▸" : "▾"; + const control = `${icon} ${width >= 28 ? (options.collapsed ? "Expand" : "Collapse") : ""}`.trimEnd(); + const title = options.collapseControl ? `Jobs ${paintHoverable(theme, control, options.hovered ?? false, "accent")}` : "Jobs"; + const hint = options.collapsed && options.collapseKey ? `${options.collapseKey} expand` : undefined; + return renderCard( + { title, subtitle: counts(shown), body, tone, glyph: JOBS_GLYPH }, + theme, + width, + { expanded: true, hint, panel: true }, + ); +} diff --git a/lib/shell-bar.ts b/lib/shell-bar.ts index b3ae82a87..442b83b3a 100644 --- a/lib/shell-bar.ts +++ b/lib/shell-bar.ts @@ -251,10 +251,8 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme ...(model.review.scope !== REVIEW_SCOPE_UNAVAILABLE ? [label(sanitizeStatus(model.review.scope))] : []), ], }] : []), - ...(model.jobs?.length ? [{ - title: "Jobs", - lines: [...model.jobs].sort((a, b) => b.startedAt - a.startedAt).map((job) => value(sanitizeStatus(job.label))), - }] : []), + // Running jobs live in their own sidebar card (extensions/gentle-shell.ts + // registers the "jobs" rail part); the compact bar keeps the count. { title: "Integrations", lines: model.statuses.length ? model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status))) : [label("No status reported")] }, diff --git a/lib/shell-sidebar-layout.ts b/lib/shell-sidebar-layout.ts index 1047f031e..f827b1957 100644 --- a/lib/shell-sidebar-layout.ts +++ b/lib/shell-sidebar-layout.ts @@ -255,7 +255,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () => const preparedHeaderLines = [...(headerPart?.render(Math.max(0, width - HEADER_RIGHT_INSET)) ?? [])]; const headerActive = headerPart !== undefined && preparedHeaderLines.some((line) => line.trim() !== ""); const contentWidth = scroll.getContentWidth(RAIL_WIDTH); - const sections = ["footer", "agents", "todo"].filter((key) => key !== "todo" || state.visibility?.todo !== false).map((key) => { + const sections = ["footer", "jobs", "agents", "todo"].filter((key) => key !== "todo" || state.visibility?.todo !== false).map((key) => { const component = state.parts.get(key); if (!component) { sectionCache.delete(key); diff --git a/tests/gentle-agents.test.ts b/tests/gentle-agents.test.ts index ee311bf4b..54b81ff32 100644 --- a/tests/gentle-agents.test.ts +++ b/tests/gentle-agents.test.ts @@ -6654,7 +6654,7 @@ test("/gentle:jobs opens this session's jobs overlay, stops the selected job, an gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir }); const { ctx, overlays } = fakeContext(); const statuses: Array = []; - const snapshots: Array<{ sessionId: string; snapshot: { jobs: Array<{ id: string; label: string; startedAt: number }> } }> = []; + const snapshots: Array<{ sessionId: string; snapshot: { jobs: Array<{ id: string; label: string; startedAt: number; status?: string }> } }> = []; const unsubscribe = pi.events.on("gentle-ai:jobs-sidebar", (value) => { snapshots.push(value as typeof snapshots[number]); }); Object.assign(ctx.ui, { setStatus: (key: string, value: string | undefined) => { if (key === "gentle-jobs") statuses.push(value); } }); try { @@ -6672,7 +6672,7 @@ test("/gentle:jobs opens this session's jobs overlay, stops the selected job, an view.handleInput("s"); assert.equal(jobs.runs[0]!.signal?.aborted, true, "s stops the selected job"); assert.equal(statuses.at(-1), undefined, "the footer count clears when nothing runs"); - assert.deepEqual(snapshots.at(-1)?.snapshot.jobs, [], "the Status descriptions clear when nothing runs"); + assert.deepEqual(snapshots.at(-1)?.snapshot.jobs.map(({ status }) => status), ["stopped"], "the finished tail keeps the stopped job visible once the count clears"); await jobIo(); const notices = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice"); assert.equal(notices.length, 1, "the agent was told it would be notified, so a human stop is reported once"); diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index bc1c1172d..04f66a5e7 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -24,6 +24,7 @@ import type { ShellBarTheme } from "../lib/shell-bar.ts"; import { createGentleAiExtension } from "../extensions/gentle-ai.ts"; import { decodeReviewStatusV3 } from "../lib/review-integration-v2.ts"; import { REVIEW_SIDEBAR_EVENT } from "../lib/review-sidebar-state.ts"; +import { isJobsSidebarSnapshot } from "../lib/jobs-sidebar-state.ts"; import type { NativeReviewCli } from "../lib/native-review-cli.ts"; import { resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts"; import { resolveAnimationPolicy } from "../lib/animation-policy.ts"; @@ -503,7 +504,7 @@ test("review sidebar rejects foreign-session events and unsubscribes on shutdown } }); -test("jobs snapshots populate Status, use one compact fallback, and reject stale sessions", async () => { +test("jobs snapshots feed their own rail card, keep one compact fallback, and reject stale sessions", async () => { const { pi, handlers } = fakePi(); gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }); const { ctx, ui } = fakeContext(); @@ -515,35 +516,56 @@ test("jobs snapshots populate Status, use one compact fallback, and reject stale const component = factory(tui, plainTheme, data); const state = sidebarState(tui as unknown as TUI); const rail = state.parts.get("footer")!; + const jobs = state.parts.get("jobs")!; const header = state.parts.get("header")!; const publish = (sessionId: string, jobs: unknown) => pi.events.emit("gentle-ai:jobs-sidebar", { sessionId, snapshot: { jobs } }); try { publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]); - assert.match(rail.render(46).join("\n"), /Jobs[\s\S]*CI for PR 1860/); + assert.doesNotMatch(rail.render(46).join("\n"), /Jobs|CI for PR 1860/, "the Status card never prints jobs; the jobs card owns them"); assert.match(rail.render(46).join("\n"), /MCP: 3 servers/); - assert.doesNotMatch(rail.render(46).join("\n"), /1 job/, "Jobs replaces the generic integration counter"); + assert.match(jobs.render(46).join("\n"), /Jobs[\s\S]*CI for PR 1860/); + publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }, { id: "job-0", label: "Build runtime", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }]); + assert.match(jobs.render(60).join("\n"), /1 running · 1 finished/, "finished tails ride the same snapshot"); + assert.match(jobs.render(60).join("\n"), /✓\s+Build runtime/); + // The compact count is running-only: a finished tail still paints the + // card, but the header's ⧗ N jobs derives from running entries alone. + publish("shell-session", [{ id: "job-2", label: "Fresh tail", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }]); + tui.terminal.columns = 80; + assert.match(jobs.render(46).join("\n"), /Fresh tail/, "the finished tail still paints the card"); + assert.doesNotMatch(header.render(80).join("\n"), /⧗ 1 job/, "the compact count is running-only"); + tui.terminal.columns = 160; + publish("shell-session", [{ id: "job-0", label: "Stale tail", startedAt: 1, status: "exited", endedAt: 5, exitCode: 0 }]); + assert.doesNotMatch(jobs.render(46).join("\n"), /Stale tail/, "a finished job past the TTL paints nothing"); + assert.doesNotMatch(rail.render(46).join("\n"), /1 job/, "the jobs counter never joins the integration statuses while display data exists"); state.active = true; state.ownsHost = () => true; - assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible Status card owns jobs details"); + assert.doesNotMatch(header.render(160).join("\n"), /1 job/, "the visible rail owns job details"); state.active = false; tui.terminal.columns = 80; - assert.match(header.render(80).join("\n"), /1 job/, "mobile topbar preserves the count"); - const before = rail.digest?.(); + publish("shell-session", [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }]); + assert.match(header.render(80).join("\n"), /1 job/, "mobile topbar preserves the count of a running job"); + const before = jobs.render(46).join("\n"); publish("foreign", []); publish("shell-session", [{ label: "invalid" }]); - assert.equal(rail.digest?.(), before); + assert.equal(jobs.render(46).join("\n"), before); publish("shell-session", []); - assert.doesNotMatch(rail.render(46).join("\n"), /Jobs|CI for PR/); + assert.doesNotMatch(jobs.render(46).join("\n"), /Jobs|CI for PR/); assert.doesNotMatch(header.render(80).join("\n"), /1 job/); publish("shell-session", [{ id: "job-2", label: "Build runtime", startedAt: 2 }]); await fire(handlers, "session_start", ctx); - assert.doesNotMatch(rail.render(46).join("\n"), /Build runtime/); + assert.doesNotMatch(state.parts.get("jobs")!.render(46).join("\n"), /Build runtime/); await fire(handlers, "session_shutdown", ctx); publish("shell-session", [{ id: "job-3", label: "late", startedAt: 3 }]); - assert.doesNotMatch(rail.render(46).join("\n"), /late/); + assert.doesNotMatch(state.parts.get("jobs")!.render(46).join("\n"), /late/); } finally { component.dispose(); } }); +test("the jobs snapshot validator admits the finished-tail statuses", () => { + assert.ok(isJobsSidebarSnapshot({ jobs: [{ id: "job-1", label: "Spawn failed", startedAt: 1, status: "failed", endedAt: 60_000, exitCode: null }] })); + assert.ok(isJobsSidebarSnapshot({ jobs: [{ id: "job-2", label: "Stopped", startedAt: 1, status: "stopped", endedAt: 2 }] })); + assert.equal(isJobsSidebarSnapshot({ jobs: [{ id: "job-3", label: "Bad", startedAt: 1, status: "exploded" }] }), false); +}); + test("hidden Status leaves only a jobs count in regular bottom and fullscreen header chrome", async (t) => { const home = scopedDoubleEscCancelConfigHome(t); writeVisualSettings({ ...resolveVisualSettings({ gentlePiConfigHome: home }).settings, statusPlacement: "hidden" }, { gentlePiConfigHome: home }); @@ -559,6 +581,9 @@ test("hidden Status leaves only a jobs count in regular bottom and fullscreen he pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } }); assert.equal(component.render(80).join("\n"), "⧗ 1 job"); assert.doesNotMatch(component.render(80).join("\n"), /CI for PR|gpt-5/); + pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1, status: "exited", endedAt: Date.now(), exitCode: 0 }] } }); + assert.doesNotMatch(component.render(80).join("\n"), /⧗/, "a finished-only tail never paints a ⧗ 0 jobs row"); + pi.events.emit("gentle-ai:jobs-sidebar", { sessionId: "shell-session", snapshot: { jobs: [{ id: "job-1", label: "CI for PR 1860", startedAt: 1 }] } }); tui.mode = "fullscreen"; const header = sidebarState(tui as unknown as TUI).parts.get("header")!; assert.match(header.render(160).join("\n"), /⧗ 1 job/); diff --git a/tests/jobs-widget.test.ts b/tests/jobs-widget.test.ts new file mode 100644 index 000000000..3c29b8192 --- /dev/null +++ b/tests/jobs-widget.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import test, { after, before } from "node:test"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { JOBS_GLYPH, renderJobsCard, widgetJobs } from "../lib/jobs-widget.ts"; +import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts"; +import { stripAnsi } from "../lib/terminal-theme.ts"; + +// The Jobs sidebar card replaces the Jobs group the Status card used to print. +// Rendering is pure, so it verifies without a TUI: newest job first, one row +// per running job with its elapsed time, collapsible like Agents and Todos. + +// Width math counts every painted character, so these assertions use a plain +// theme; colored roles are covered by the widget's paint calls, not here. +const plainTheme = { + fg(_color: string, text: string) { + return text; + }, + bold(text: string) { + return text; + }, +}; + +const jobs = [ + { id: "job-2", label: "Watcher CI ronda 2", startedAt: 40_000 }, + { id: "job-1", label: "Build runtime", startedAt: 10_000 }, +]; + +// The card style defaults to float; these assertions pin the outlined (neon) +// panels unless a test switches the style itself. +const initialCardStyle = cardStyle(); +before(() => setCardStyle(CARD_STYLE.NEON)); +after(() => setCardStyle(initialCardStyle)); + +test("the jobs card lists running jobs newest first with elapsed time", () => { + const lines = renderJobsCard(jobs, plainTheme, 50, 100_000, { collapsed: false, collapseControl: true }); + const text = lines.join("\n"); + assert.match(text, new RegExp(`${JOBS_GLYPH} Jobs`)); + assert.match(text, /2 running/); + const watcher = text.indexOf("Watcher CI ronda 2"); + const build = text.indexOf("Build runtime"); + assert.ok(watcher >= 0, "the newest job paints"); + assert.ok(build > watcher, "newest first, matching the /gentle:jobs running group"); + assert.match(text, /1m 0s/, "the 60s job shows its elapsed time"); + assert.match(text, /1m 30s/, "the 90s job shows its elapsed time"); + assert.match(text, /▾ Collapse/); + for (const line of lines) assert.ok(visibleWidth(stripAnsi(line)) <= 50, `row fits: ${stripAnsi(line)}`); +}); + +test("an empty job list paints nothing", () => { + assert.deepEqual(renderJobsCard([], plainTheme, 50, 100_000, { collapsed: false }), []); +}); + +test("a collapsed jobs card keeps the newest job and folds the rest", () => { + // Wide enough for the header to also fit the "ctrl+shift+j expand" hint. + const lines = renderJobsCard(jobs, plainTheme, 64, 100_000, { collapsed: true, collapseKey: "ctrl+shift+j", collapseControl: true }); + const text = lines.join("\n"); + assert.match(text, /Watcher CI ronda 2/); + assert.doesNotMatch(text, /Build runtime/); + assert.match(text, /… 1 more/); + assert.match(text, /ctrl\+shift\+j expand/); + assert.match(text, /▸ Expand/); +}); + +test("the collapse control is opt-in and the sort never mutates the snapshot", () => { + const snapshot = [{ id: "job-1", label: "Build runtime", startedAt: 10_000 }]; + const lines = renderJobsCard(snapshot, plainTheme, 50, 100_000, { collapsed: false }); + assert.doesNotMatch(lines.join("\n"), /Collapse|Expand/); + const sorted = widgetJobs(snapshot, 100_000); + assert.deepEqual(snapshot, [{ id: "job-1", label: "Build runtime", startedAt: 10_000 }]); + assert.equal(sorted[0]?.id, "job-1"); +}); + +// Finished tails mirror the Agents card's results: an exit lingers for a +// minute with the /gentle:jobs outcome glyph, its elapsed frozen at the end. + +const exitedJob = { id: "job-1", label: "Build runtime", startedAt: 10_000, status: "exited" as const, endedAt: 70_000, exitCode: 0 as number | null }; + +function finishedJobs(): Array { + return [1, 2, 3, 4].map((n) => ({ ...exitedJob, id: `job-${n}`, label: `Job ${n}`, startedAt: n * 1_000, endedAt: 100_000 + n * 1_000 })); +} + +test("a finished job lingers with a ✓ row and its elapsed frozen at the end", () => { + const lines = renderJobsCard([exitedJob], plainTheme, 50, 100_000, { collapsed: false }); + const text = lines.join("\n"); + assert.match(text, /✓\s+Build runtime/); + assert.match(text, /1m 0s/, "elapsed freezes at endedAt instead of ticking to 1m 30s"); + assert.match(text, /1 finished/); + for (const line of lines) assert.ok(visibleWidth(stripAnsi(line)) <= 50, `row fits: ${stripAnsi(line)}`); +}); + +test("a nonzero exit paints ✗ and a stopped job paints –", () => { + const lines = renderJobsCard([ + { ...exitedJob, exitCode: 1 }, + { id: "job-2", label: "Watcher CI", startedAt: 5_000, status: "stopped", endedAt: 60_000 }, + ], plainTheme, 50, 100_000, { collapsed: false }); + const text = lines.join("\n"); + assert.match(text, /✗\s+Build runtime/); + assert.match(text, /–\s+Watcher CI/); + assert.match(text, /2 finished/); +}); + +test("a failed job rides the tail with the ✗ glyph", () => { + const lines = renderJobsCard([{ ...exitedJob, status: "failed" as const }], plainTheme, 50, 100_000, { collapsed: false }); + const text = lines.join("\n"); + assert.match(text, /✗\s+Build runtime/); + assert.match(text, /1m 0s/, "elapsed freezes at endedAt like the other outcomes"); + assert.match(text, /1 finished/); +}); + +test("finished jobs past the TTL paint nothing", () => { + // now - endedAt lands exactly on the TTL: not fresh anymore. + assert.deepEqual(renderJobsCard([{ ...exitedJob, endedAt: 40_000 }], plainTheme, 50, 100_000, { collapsed: false }), []); +}); + +test("at most the three newest finished jobs survive", () => { + const jobs = finishedJobs(); + const shown = widgetJobs(jobs, 105_000); + assert.deepEqual(shown.map((job) => job.id), ["job-4", "job-3", "job-2"]); + assert.deepEqual(jobs, finishedJobs(), "the TTL filter never mutates the snapshot"); +}); + +test("running entries sort before the finished tail and never expire", () => { + const snapshot = [ + { ...exitedJob, id: "done", label: "Old build", startedAt: 1_000 }, + { id: "old", label: "Old run", startedAt: 1_000 }, + { id: "new", label: "New run", startedAt: 50_000 }, + ]; + const shown = widgetJobs(snapshot, 100_000); + assert.deepEqual(shown.map((job) => job.id), ["new", "old", "done"]); +}); + +test("the subtitle mixes running and finished counts", () => { + const lines = renderJobsCard([ + { id: "job-1", label: "New run", startedAt: 50_000 }, + { id: "job-2", label: "Watcher CI", startedAt: 5_000, status: "stopped", endedAt: 60_000 }, + ], plainTheme, 50, 100_000, { collapsed: false }); + assert.match(lines.join("\n"), /1 running · 1 finished/); +}); diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts index e86e3a517..128d85bac 100644 --- a/tests/shell-bar.test.ts +++ b/tests/shell-bar.test.ts @@ -107,7 +107,7 @@ test("visual visibility hides only selected optional status segments", () => { assert.doesNotMatch(renderShellHeaderBar(buildShellHeaderModel(data), plainTheme, 160, undefined, settings).text, /gpt-5\.5|ctx|\$9\.49|usage/); }); -test("Status groups running job descriptions beside RDD without a compact count", () => { +test("Status keeps RDD beside structured groups while running jobs moved to their own card", () => { const data = model({ jobs: [ { id: "job-2", label: "CI for PR 1860", startedAt: 2 }, { id: "job-1", label: "Build runtime", startedAt: 1 }, @@ -115,12 +115,12 @@ test("Status groups running job descriptions beside RDD without a compact count" for (const width of [30, 60]) { const lines = renderShellSidebarBar(data, plainTheme, width); const text = lines.join("\n"); - assert.match(text, /Jobs[\s\S]*CI for PR 1860[\s\S]*Build runtime/); + // Running jobs live in their own sidebar card; the Status card never + // prints them, so the two surfaces cannot duplicate one job. + assert.doesNotMatch(text, /Jobs|CI for PR 1860|Build runtime/); assert.match(text, /🌹 RDD/); - assert.doesNotMatch(text, /2 jobs/); assert.ok(lines.every((line) => visibleWidth(line) <= width)); } - assert.doesNotMatch(renderShellSidebarBar(model({ jobs: [] }), plainTheme, 60).join("\n"), /Jobs/); }); test("header shows only the running job count when requested and preserves it at mobile widths", () => { diff --git a/tests/shell-sidebar-layout.test.ts b/tests/shell-sidebar-layout.test.ts index de3a7db67..1f3aa09d2 100644 --- a/tests/shell-sidebar-layout.test.ts +++ b/tests/shell-sidebar-layout.test.ts @@ -589,6 +589,23 @@ test("a rail digest refreshes live state that no invalidation announces", (t) => assert.equal(todoRenders, 2, "explicit invalidation still reaches a rail without a digest"); }); +test("the jobs rail section paints between Status and Agents", (t) => { + const f = fixture(); + sidebarPart(f.tui, "jobs", { render: () => ["jobs"], invalidate() {} }, { digest: () => "jobs", render: () => ["Jobs card"], invalidate() {} }); + sidebarPart(f.tui, "agents", { render: () => ["agents"], invalidate() {} }, { digest: () => "agents", render: () => ["Agents card"], invalidate() {} }); + sidebarPart(f.tui, "todo", { render: () => ["todo"], invalidate() {} }, { digest: () => "todo", render: () => ["Todo card"], invalidate() {} }); + t.after(installSidebar(f.tui, theme)); + const text = renderLayoutFrame(f.root, 140, 20, () => {}).lines.join("\n"); + const footer = text.indexOf("Status"); + const jobs = text.indexOf("Jobs card"); + const agents = text.indexOf("Agents card"); + const todo = text.indexOf("Todo card"); + for (const [name, index] of [["Status", footer], ["Jobs card", jobs], ["Agents card", agents], ["Todo card", todo]] as const) { + assert.ok(index >= 0, `${name} paints in the rail`); + } + assert.ok(footer < jobs && jobs < agents && agents < todo, `sections paint in order, got: ${text}`); +}); + test("a theme switch repaints rail sections whose digest did not change", (t) => { const f = fixture(); // Pi hands extensions a live theme proxy and announces a switch only through