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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/gentle-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions extensions/gentle-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */ }
}
};

Expand Down
96 changes: 88 additions & 8 deletions extensions/gentle-shell.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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 }));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
redrawReview();
});
pi.on("session_tree", () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<ExtensionAPI["registerShortcut"]>[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) => {
Expand Down
25 changes: 22 additions & 3 deletions lib/jobs-sidebar-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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;
});
}
Loading
Loading