From edad1e0f27c13dbb061b86ad324faae9031f4b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:26:31 +0000 Subject: [PATCH 1/4] feat(sidebar): give running jobs their own collapsible rail card The fullscreen rail now scrolls Status -> Jobs -> Agents -> TODO: a dedicated Jobs card (lib/jobs-widget.ts) lists this session's running background jobs, newest first, one row per job with its elapsed time, replacing the Jobs group the Status card used to print. It paints nothing while no job runs, so it never claims idle space. The card collapses like Agents and Todos: clicking its header toggles it (NativePointerRegion hover + click on the header row), and ctrl+shift+j (GENTLE_PI_JOBS_KEY to remap or disable) toggles from the keyboard; a collapsed card keeps the newest job and folds the rest into one overflow row. The section digest carries a one-second bucket, so elapsed time repaints without forcing sibling sections to redraw. Regular mode and narrow fullscreen keep the compact bar count; the /gentle:jobs overlay stays the full view with stop controls. --- docs/gentle-shell.md | 4 +- extensions/gentle-shell.ts | 77 ++++++++++++++++++++++++-- lib/jobs-sidebar-state.ts | 8 +++ lib/jobs-widget.ts | 88 ++++++++++++++++++++++++++++++ lib/shell-bar.ts | 6 +- lib/shell-sidebar-layout.ts | 2 +- tests/gentle-shell.test.ts | 20 ++++--- tests/jobs-widget.test.ts | 71 ++++++++++++++++++++++++ tests/shell-bar.test.ts | 8 +-- tests/shell-sidebar-layout.test.ts | 17 ++++++ 10 files changed, 277 insertions(+), 24 deletions(-) create mode 100644 lib/jobs-widget.ts create mode 100644 tests/jobs-widget.test.ts diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 30f8b5a0b..c26412204 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 while no job runs, the Status card never repeats it, and `ctrl+shift+j` (or clicking the card's header) collapses it. ### Gentle Todo diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 37fbf7c39..94fbefd82 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 { 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(); @@ -2194,6 +2197,61 @@ 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 jobsHeaderRow = 0; + const jobsRailCard: Component = { + render(width: number) { + const jobs = runningJobs ?? []; + if (jobs.length === 0) return []; + jobsHeaderRow = panelHeaderRow(theme, width, jobsCardTone(jobs)); + return renderJobsCard(jobs, theme, width, deps.now(), { collapsed: jobsCollapsed, collapseKey: jobsKey, collapseControl: true, hovered: jobsHovered }); + }, + 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; one frame a second is enough, + // and the digest's second bucket is what revalidates the cached section. + const jobsTimer = setInterval(() => { + if (runningJobs?.length) 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. @@ -2237,7 +2295,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p 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 +2416,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..0dbee84b9 100644 --- a/lib/jobs-sidebar-state.ts +++ b/lib/jobs-sidebar-state.ts @@ -2,6 +2,14 @@ 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; diff --git a/lib/jobs-widget.ts b/lib/jobs-widget.ts new file mode 100644 index 000000000..646939c90 --- /dev/null +++ b/lib/jobs-widget.ts @@ -0,0 +1,88 @@ +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; + +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 = "…"; + +// Newest first, matching the /gentle:jobs running group and the old Status +// group this card replaces. +export function widgetJobs(jobs: readonly RunningJobDisplay[]): RunningJobDisplay[] { + return [...jobs].sort((a, b) => b.startedAt - a.startedAt); +} + +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}`; +} + +function counts(jobs: readonly RunningJobDisplay[]): string { + return jobs.length === 1 ? "1 running" : `${jobs.length} running`; +} + +/** 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); + 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) => { + const elapsed = formatJobDuration(Math.max(0, now - job.startedAt)); + const label = clip(clean(job.label), Math.max(0, inner - visibleWidth(elapsed) - GLYPH_GAP.length - 1)); + const head = `${theme.fg(GLYPH_ROLE, JOBS_GLYPH)}${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-shell.test.ts b/tests/gentle-shell.test.ts index bc1c1172d..158aefee1 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -503,7 +503,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,32 +515,34 @@ 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/); + 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?.(); + 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(); } }); diff --git a/tests/jobs-widget.test.ts b/tests/jobs-widget.test.ts new file mode 100644 index 000000000..2780a65a7 --- /dev/null +++ b/tests/jobs-widget.test.ts @@ -0,0 +1,71 @@ +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); + assert.deepEqual(snapshot, [{ id: "job-1", label: "Build runtime", startedAt: 10_000 }]); + assert.equal(sorted[0]?.id, "job-1"); +}); 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 From d338746e4cccd68f067ba4a8bdf5e04569920bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:01:08 +0000 Subject: [PATCH 2/4] feat(sidebar): show job results on the jobs card with a 60s tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The jobs snapshot now carries how each job settled (status, endedAt, exitCode) as optional fields, so older publishers keep validating. The sidebar card keeps the newest three finished jobs for a minute after they settle, like the Agents card's results: ✓ for a clean exit, ✗ for a failure, – for a stop, with the elapsed frozen at the end and the subtitle counting "n running · m finished". Failed spawn jobs stay out of the tail; the display snapshot only admits exited and stopped. The card clears itself once the tail expires with nothing running behind it: the one-second frame loop paints while the last render had rows or any entry is still fresh, then goes idle. The compact bar count stays running-only. --- extensions/gentle-agents.ts | 16 ++++++++-- extensions/gentle-shell.ts | 23 +++++++++----- lib/jobs-sidebar-state.ts | 17 +++++++++-- lib/jobs-widget.ts | 39 +++++++++++++++++++----- tests/gentle-agents.test.ts | 4 +-- tests/gentle-shell.test.ts | 5 +++ tests/jobs-widget.test.ts | 61 ++++++++++++++++++++++++++++++++++++- 7 files changed, 141 insertions(+), 24 deletions(-) diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 13169c828..4da797eaa 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 (spawn + // errors) are excluded: the display snapshot only carries exited/stopped. + const finished = sessionJobs.filter((job) => (job.status === "exited" || job.status === "stopped") && 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 94fbefd82..fdddfdd34 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -126,7 +126,7 @@ import { installSidebar, invalidateSidebar, narrowStatusOwner, SIDEBAR_BREAKPOIN 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, jobsCollapseKey, type RunningJobDisplay } from "../lib/jobs-sidebar-state.ts"; -import { jobsCardTone, renderJobsCard } from "../lib/jobs-widget.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"; @@ -2066,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", () => { @@ -2202,13 +2202,16 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p // 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) return []; - jobsHeaderRow = panelHeaderRow(theme, width, jobsCardTone(jobs)); - return renderJobsCard(jobs, theme, width, deps.now(), { collapsed: jobsCollapsed, collapseKey: jobsKey, collapseControl: true, hovered: jobsHovered }); + 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; @@ -2246,10 +2249,14 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p }, }; const jobsPart = sidebarPart(tui, "jobs", { render: () => [], invalidate() {}, dispose() {} }, jobsRail); - // Elapsed time moves while a job runs; one frame a second is enough, - // and the digest's second bucket is what revalidates the cached section. + // 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(() => { - if (runningJobs?.length) tui.requestRender(); + 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, diff --git a/lib/jobs-sidebar-state.ts b/lib/jobs-sidebar-state.ts index 0dbee84b9..1d2255ef7 100644 --- a/lib/jobs-sidebar-state.ts +++ b/lib/jobs-sidebar-state.ts @@ -14,6 +14,11 @@ 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"; + endedAt?: number; + exitCode?: number | null; } export interface JobsSidebarSnapshot { @@ -22,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") 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 index 646939c90..7d6bcd6ef 100644 --- a/lib/jobs-widget.ts +++ b/lib/jobs-widget.ts @@ -14,6 +14,11 @@ import { sanitizeTerminalText } from "./terminal-theme.ts"; 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; @@ -29,10 +34,13 @@ const META_ROLE = "dim"; const GLYPH_GAP = " "; const ELLIPSIS = "…"; -// Newest first, matching the /gentle:jobs running group and the old Status -// group this card replaces. -export function widgetJobs(jobs: readonly RunningJobDisplay[]): RunningJobDisplay[] { - return [...jobs].sort((a, b) => b.startedAt - a.startedAt); +// 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 { @@ -52,8 +60,21 @@ function clip(text: string, width: number): string { 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 === "exited") return job.exitCode === 0 ? ["✓", "success"] : ["✗", "error"]; + return [JOBS_GLYPH, GLYPH_ROLE]; +} + function counts(jobs: readonly RunningJobDisplay[]): string { - return jobs.length === 1 ? "1 running" : `${jobs.length} running`; + 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. */ @@ -62,15 +83,17 @@ export function jobsCardTone(_jobs: readonly RunningJobDisplay[]): CardTone { } export function renderJobsCard(jobs: readonly RunningJobDisplay[], theme: CardTheme, width: number, now: number, options: JobsWidgetOptions): string[] { - const shown = widgetJobs(jobs); + 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) => { - const elapsed = formatJobDuration(Math.max(0, now - job.startedAt)); + // 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(GLYPH_ROLE, JOBS_GLYPH)}${GLYPH_GAP}${theme.fg(NAME_ROLE, label)}`; + 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}`; }); 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 158aefee1..da1b9a616 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -523,6 +523,11 @@ test("jobs snapshots feed their own rail card, keep one compact fallback, and re 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.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/); + 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; diff --git a/tests/jobs-widget.test.ts b/tests/jobs-widget.test.ts index 2780a65a7..c37ba9715 100644 --- a/tests/jobs-widget.test.ts +++ b/tests/jobs-widget.test.ts @@ -65,7 +65,66 @@ 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); + 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("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/); +}); From a10f5e17b5616eddb7976ae54233bdd3d60145cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:45:59 +0000 Subject: [PATCH 3/4] fix(sidebar): review fixes for the jobs card tail and compact count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on #2592: the docs now state the real empty-state rule (nothing current: no running jobs and no result inside the 60-second tail), failed spawn jobs ride the finished tail with the error glyph end to end (display status, widget glyph, publisher filter), and the compact bar and header count running jobs only, so the ⧗ N jobs count can no longer persist past the tail's TTL. --- docs/gentle-shell.md | 2 +- extensions/gentle-agents.ts | 6 +++--- extensions/gentle-shell.ts | 6 ++++-- lib/jobs-sidebar-state.ts | 4 ++-- lib/jobs-widget.ts | 1 + tests/gentle-shell.test.ts | 10 +++++++++- tests/jobs-widget.test.ts | 8 ++++++++ 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 13a09f10f..177597ad8 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -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. 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 while no job runs, the Status card never repeats it, and `ctrl+shift+j` (or clicking the card's header) collapses it. +- `/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 Todo diff --git a/extensions/gentle-agents.ts b/extensions/gentle-agents.ts index 4da797eaa..8bc3abd3e 100644 --- a/extensions/gentle-agents.ts +++ b/extensions/gentle-agents.ts @@ -1236,9 +1236,9 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv = 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 (spawn - // errors) are excluded: the display snapshot only carries exited/stopped. - const finished = sessionJobs.filter((job) => (job.status === "exited" || job.status === "stopped") && job.endedAt !== undefined && deps.now() - job.endedAt < JOBS_FINISHED_TTL_MS) + // 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) { diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index fdddfdd34..76d5b2dcb 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -2159,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, @@ -2295,7 +2297,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p 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, "…")]; + return [truncateToWidth(theme.fg("muted", shellJobsCount(runningJobs.filter((job) => job.status === undefined).length)), width, "…")]; } const rows = part.render(width); // Preserve a shrinkable exterior dock row, outside prompt/completion geometry. diff --git a/lib/jobs-sidebar-state.ts b/lib/jobs-sidebar-state.ts index 1d2255ef7..1392ef918 100644 --- a/lib/jobs-sidebar-state.ts +++ b/lib/jobs-sidebar-state.ts @@ -16,7 +16,7 @@ export interface RunningJobDisplay { 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"; + status?: "exited" | "stopped" | "failed"; endedAt?: number; exitCode?: number | null; } @@ -31,7 +31,7 @@ export function isJobsSidebarSnapshot(value: unknown): value is JobsSidebarSnaps 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") 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 index 7d6bcd6ef..38e3a628b 100644 --- a/lib/jobs-widget.ts +++ b/lib/jobs-widget.ts @@ -64,6 +64,7 @@ function clip(text: string, width: number): string { // 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]; } diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index da1b9a616..35de160fd 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -526,6 +526,13 @@ test("jobs snapshots feed their own rail card, keep one compact fallback, and re 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"); @@ -534,7 +541,8 @@ test("jobs snapshots feed their own rail card, keep one compact fallback, and re 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"); + 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" }]); diff --git a/tests/jobs-widget.test.ts b/tests/jobs-widget.test.ts index c37ba9715..3c29b8192 100644 --- a/tests/jobs-widget.test.ts +++ b/tests/jobs-widget.test.ts @@ -99,6 +99,14 @@ test("a nonzero exit paints ✗ and a stopped job paints –", () => { 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 }), []); From 5518c48bf2637c62302af94f4fdabdc576c4f364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:06:01 +0000 Subject: [PATCH 4/4] fix(sidebar): second review round for the jobs card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hidden-Status compact fallback now guards on the running-only count, so a finished tail can never paint a ⧗ 0 jobs row; the docs document the GENTLE_PI_JOBS_KEY override (empty or off disables the shortcut); and the consumer tests assert the snapshot validator accepts the finished-tail statuses (failed, stopped) while rejecting unknown ones. --- docs/gentle-shell.md | 2 +- extensions/gentle-shell.ts | 6 ++++-- tests/gentle-shell.test.ts | 10 ++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 177597ad8..40249d005 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -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. 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: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-shell.ts b/extensions/gentle-shell.ts index 76d5b2dcb..7f5d2a2da 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -2296,8 +2296,10 @@ 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.filter((job) => job.status === undefined).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. diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 35de160fd..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"; @@ -559,6 +560,12 @@ test("jobs snapshots feed their own rail card, keep one compact fallback, and re } 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 }); @@ -574,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/);