diff --git a/docs/gentle-shell.md b/docs/gentle-shell.md index 9341617ac..6d5e984f9 100644 --- a/docs/gentle-shell.md +++ b/docs/gentle-shell.md @@ -29,6 +29,8 @@ At 140 columns or wider, fullscreen splits into a live header row over a transcr ✿ Gentle Shell ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium · team ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ``` +The fullscreen sidebar Status card shows a single `Profile` field for the effective repository profile. It uses the same pin precedence as profile routing: a valid clone-local pin wins and appends `(local)`, a valid repository declaration wins and appends `(repo)`, and the globally active profile has no suffix. Invalid, stale, or unreadable pin files are skipped in precedence order: a valid repository declaration may still win after an unusable local pin, and the global profile is used only when neither pin resolves. The shell resolves this state initially, on known in-process invalidations, and through debounced parent-directory watchers, so atomic profile and pin replacements appear without per-frame filesystem or Git resolution. A missing, unreadable, or invalid global profile store leaves the line hidden. The compact bottom bar remains unchanged and does not add a profile segment. + - The header is one row, always visible, and carries only what changes every frame: 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). It never shows the working/thinking state or extension statuses — those stay in the prompt title and the compact bar. 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 → TODO**, each an event-driven card that only repaints when its own state changes: a model switch or a cost tick refreshes the header, not the rail. Every card (sidebar or not) paints the same rose frame — the rounded border in the theme's plain border role, the title in the accent role — the look every `CARD_TONE.INFO` card in Gentle Shell uses (warning/error/success cards keep their own tone colors). - The Status card carries only what an explicit event refreshes: Project (cwd, branch, session name, active profile), Changes, and Integrations (other extensions' statuses). 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. diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 486791648..0f56cad56 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -950,6 +950,8 @@ For a given working directory the winner is the local pin, then the repository d In a pinned repository the pinned profile governs subagent launches: the agents it names take its model and effort, and the agents it omits return to inherit (their own definition, then the default model). The globally active profile and writes made through `/gentle:models` do not reach those launches, which `/gentle:models` reports when it runs inside a pinned repository. `enter` follows the same boundary: inside a pinned repository it re-pins that repository instead of writing the global routing, so the panel's main key can never move another repository's routing. The panel states which layer won, names the file that holds it, and marks the profile with `(pinned)`. +The fullscreen Gentle Shell Status card shows the same effective profile in one `Profile` field: it adds `(local)` when a valid clone-local pin wins, `(repo)` when a valid repository declaration wins, and no suffix for the global active profile. Invalid, stale, or unreadable pins are skipped in precedence order; a valid repository declaration can win after an unusable local pin, and the global profile is used only when neither resolves. An unavailable or invalid global profile store leaves the field hidden. The shell snapshots this state initially and refreshes it on known in-process invalidations or debounced parent-directory watcher events, including atomic replacements; repeated Status/header rendering performs no profile filesystem or Git resolution. The compact bottom bar remains unchanged and does not show this field. + To share a pin, commit the repository declaration. When `.pi/` is ignored, Git cannot re-include a nested file until its parent directories are visible. The panel therefore prints these ordered root `.gitignore` rules, which keep unrelated `.pi` content ignored while making only the declaration committable: ```gitignore diff --git a/extensions/gentle-shell.ts b/extensions/gentle-shell.ts index 15822398f..acf29c7ef 100644 --- a/extensions/gentle-shell.ts +++ b/extensions/gentle-shell.ts @@ -1,11 +1,12 @@ import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent"; import type { EditorTheme, TUI } from "@earendil-works/pi-tui"; import { execFile, spawnSync } from "node:child_process"; -import { statSync } from "node:fs"; +import { statSync, type FSWatcher, watch } from "node:fs"; import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts"; +import { localProfilePinPath, repoProfileDeclarationPath, resolveProfilePin } from "../lib/agent-profile-pin.ts"; import * as os from "node:os"; -import { join } from "node:path"; -import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellHeaderRule, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts"; +import { dirname, join, relative, sep } from "node:path"; +import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellHeaderRule, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme, type ShellProfileState } from "../lib/shell-bar.ts"; import { CHANGE_STATUS, RootBranchLabels, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts"; import { WorktreeChangesView } from "../lib/shell-changes-view.ts"; import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts"; @@ -54,7 +55,7 @@ interface ShellBarComponent { } interface BuildOptions { - profile?: string; + profile?: ShellProfileState; home?: string; dirty?: number; usage?: ProviderUsage; @@ -62,37 +63,305 @@ interface BuildOptions { export type DevBinaryNotice = { state: "active"; path: string; sha256: string } | { state: "invalid"; reason: string }; +export interface ProfileRefreshClock { + setTimeout(callback: () => void, delay: number): ReturnType; + clearTimeout(timer: ReturnType): void; +} + export interface ShellDeps { - activeProfile(): string | undefined; + activeProfile(cwd?: string): ShellProfileState | undefined; fetch: typeof fetch; now(): number; devBinary(): DevBinaryNotice | undefined; resolveWorktree: WorktreeResolver; gitRunner(cwd: string): GitRunner; + watchProfile?: typeof watch; + profileRefreshClock?: ProfileRefreshClock; } -// The rail digest runs every frame. Cache parsing by file identity and metadata, -// not just mtime: profile writes replace the store atomically. Keep the cache -// local to this shell instance and recheck on the next frame after panel edits. -export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): () => string | undefined { - const path = profilesFilePath(env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai")); +// Profile resolution is intentionally kept out of the rail digest and render path. +// This reader caches one resolved value and is called by the shell snapshot only at +// startup or after an explicit invalidation/watch event. File identity includes the +// inode because profile and pin writes replace files atomically. +export function createActiveProfileReader( + env: NodeJS.ProcessEnv = process.env, + resolveWorktree: WorktreeResolver = resolveSessionWorktree, +): (cwd?: string) => ShellProfileState | undefined { + const configHome = env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"); + const profilesPath = profilesFilePath(configHome); let fingerprint: string | undefined; - let name: string | undefined; - return () => { + let profile: ShellProfileState | undefined; + let identityCwd: string | undefined; + let identity: { root: string; commonDir: string } | undefined; + const fileFingerprint = (path: string): string => { try { const stat = statSync(path, { bigint: true }); - const next = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; - if (next !== fingerprint) { - const result = readProfilesFileResult(path); - name = result.status === "valid" ? result.file.active : undefined; - fingerprint = next; - } - return name; + return `present:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`; } catch { + return "missing"; + } + }; + return (cwd = process.cwd()) => { + if (cwd !== identityCwd) { + identityCwd = cwd; + try { + identity = resolveWorktree(cwd, cwd); + } catch { + identity = undefined; + } fingerprint = undefined; - name = undefined; - return undefined; } + const pinPaths = identity + ? [localProfilePinPath(identity.commonDir), repoProfileDeclarationPath(identity.root)] + : []; + const identityFingerprint = identity ? `${identity.root}:${identity.commonDir}` : "no-worktree"; + const next = `${identityFingerprint}|${[profilesPath, ...pinPaths].map(fileFingerprint).join("|")}`; + if (next !== fingerprint) { + const resolution = resolveProfilePin({ cwd, configHome, resolveWorktree }); + if (resolution) { + profile = { name: resolution.profile, source: resolution.source }; + } else { + const result = readProfilesFileResult(profilesPath); + profile = result.status === "valid" && result.file.active + ? { name: result.file.active, source: "global" } + : undefined; + } + fingerprint = next; + } + return profile; + }; +} + +const PROFILE_REFRESH_DEBOUNCE_MS = 100; +const ATOMIC_REFRESH_RETRY_DELAYS_MS = [250, 500, 1000] as const; +const ATOMIC_SIBLING_SUFFIX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/i; + +function isAtomicProfileSibling(filename: string, names: Set | undefined): boolean { + if (!names) return false; + for (const name of names) { + if (name !== "profiles.json" && name !== "profile-pin.json" && name !== "profile.json") continue; + if (filename.startsWith(`.${name}.`) && ATOMIC_SIBLING_SUFFIX.test(filename.slice(name.length + 2))) return true; + } + return false; +} + +const defaultProfileRefreshClock: ProfileRefreshClock = { + setTimeout: (callback, delay) => setTimeout(callback, delay), + clearTimeout: (timer) => clearTimeout(timer), +}; + +interface EffectiveProfileSnapshot { + get(): ShellProfileState | undefined; + refresh(): boolean; + dispose(): void; +} + +interface EffectiveProfileSnapshotOptions { + read: (cwd?: string) => ShellProfileState | undefined; + cwd(): string; + env: NodeJS.ProcessEnv; + resolveWorktree: WorktreeResolver; + onChange(): void; + watch?: typeof watch; + profileRefreshClock?: ProfileRefreshClock; +} + +function existingProfileWatchDirectory(path: string, floor: string): string | undefined { + let candidate = dirname(path); + while (true) { + try { + if (statSync(candidate).isDirectory()) return candidate; + } catch { + // Atomic profile writes can create a missing parent after startup. Watch the + // nearest existing ancestor, but never broaden a profile watch to the + // filesystem root or another unrelated repository. + } + if (candidate === floor) return undefined; + const parent = dirname(candidate); + if (parent === candidate) return undefined; + candidate = parent; + } +} + +function effectiveProfileWatchTargets( + env: NodeJS.ProcessEnv, + identity: ReturnType | undefined, +): Map> { + const configHome = env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"); + const paths: Array<{ path: string; floor: string }> = [ + { path: profilesFilePath(configHome), floor: dirname(configHome) }, + ]; + if (identity) { + paths.push( + { path: localProfilePinPath(identity.commonDir), floor: identity.commonDir }, + { path: repoProfileDeclarationPath(identity.root), floor: identity.root }, + ); + } + const targets = new Map>(); + for (const { path, floor } of paths) { + const directory = existingProfileWatchDirectory(path, floor); + if (!directory) continue; + const firstPathComponent = relative(directory, path).split(sep)[0]; + if (!firstPathComponent || firstPathComponent === "..") continue; + const names = targets.get(directory) ?? new Set(); + names.add(firstPathComponent); + targets.set(directory, names); + } + return targets; +} + +function copyProfileState(profile: ShellProfileState | undefined): ShellProfileState | undefined { + return profile === undefined ? undefined : { name: profile.name, source: profile.source }; +} + +function sameProfileState(left: ShellProfileState | undefined, right: ShellProfileState | undefined): boolean { + return left?.name === right?.name && left?.source === right?.source; +} + +export function createEffectiveProfileSnapshot(options: EffectiveProfileSnapshotOptions): EffectiveProfileSnapshot { + let disposed = false; + let observedCwd = options.cwd(); + let profile = copyProfileState(options.read(observedCwd)); + let identityCwd: string | undefined; + let worktreeIdentity: ReturnType | undefined; + let identityResolved = false; + let refreshTimer: ReturnType | undefined; + let atomicRetryTimer: ReturnType | undefined; + let atomicRetryIndex = 0; + let atomicRetryFilename: string | undefined; + let atomicRefreshPending = false; + const clock = options.profileRefreshClock ?? defaultProfileRefreshClock; + let watchers: FSWatcher[] = []; + let watchedDirectories: string[] = []; + const failedWatchDirectories = new Set(); + // Worktree identity is stable for this snapshot's cwd; a cwd transition is the + // explicit invalidation event. Profile events reuse the cached result instead of Git. + const resolveIdentity = (cwd: string): ReturnType | undefined => { + if (!identityResolved || cwd !== identityCwd) { + identityCwd = cwd; + identityResolved = true; + try { + worktreeIdentity = options.resolveWorktree(cwd, cwd); + } catch { + worktreeIdentity = undefined; + } + } + return worktreeIdentity; + }; + + const closeWatchers = () => { + for (const watcher of watchers) { + try { + watcher.close(); + } catch { + // Best-effort cleanup; the shell component owns every watcher it creates. + } + } + watchers = []; + watchedDirectories = []; + }; + const installWatchers = (cwd: string) => { + const targets = effectiveProfileWatchTargets(options.env, resolveIdentity(cwd)); + const availableDirectories = [...targets.keys()]; + for (const directory of failedWatchDirectories) if (!availableDirectories.includes(directory)) failedWatchDirectories.delete(directory); + const directories = availableDirectories.filter((directory) => !failedWatchDirectories.has(directory)); + if (directories.length === watchedDirectories.length && directories.every((directory, index) => directory === watchedDirectories[index]) && watchers.length === directories.length) return; + closeWatchers(); + watchedDirectories = directories; + for (const directory of directories) { + try { + const watcher = (options.watch ?? watch)(directory, (_eventType, filename) => { + const names = targets.get(directory); + const named = filename?.toString(); + if (named === undefined || names?.has(named)) scheduleRefresh(); + else if (isAtomicProfileSibling(named, names)) { + // A temp event may precede rename with no subsequent target event. + // Coalesce bursts and sample a finite window after the first read. + if (named !== atomicRetryFilename) { + // Each writer uses a fresh UUID; duplicate events for one temp file + // coalesce, while a new write receives its own bounded retry window. + atomicRetryFilename = named; + atomicRetryIndex = 0; + if (atomicRetryTimer) clock.clearTimeout(atomicRetryTimer); + atomicRetryTimer = undefined; + } + scheduleRefresh(true); + } + }); + watcher.on("error", () => { + if (disposed) return; + failedWatchDirectories.add(directory); + const index = watchers.indexOf(watcher); + if (index >= 0) watchers.splice(index, 1); + const directoryIndex = watchedDirectories.indexOf(directory); + if (directoryIndex >= 0) watchedDirectories.splice(directoryIndex, 1); + try { watcher.close(); } catch { /* Best-effort cleanup after an asynchronous watch failure. */ } + scheduleRefresh(); + }); + watcher.unref(); + watchers.push(watcher); + } catch { + // Missing or unsupported watch roots are covered by the initial and + // in-process refresh seams; other roots remain best-effort. + } + } + }; + const refresh = (): boolean => { + if (disposed) return false; + const cwd = options.cwd(); + if (cwd !== observedCwd) observedCwd = cwd; + const next = copyProfileState(options.read(cwd)); + // A parent watcher may have observed a directory being created. Recompute + // roots after reading so later atomic writes are watched at their nearest + // parent instead of remaining stranded on the old ancestor. + installWatchers(cwd); + if (sameProfileState(profile, next)) return false; + if (atomicRetryTimer) clock.clearTimeout(atomicRetryTimer); + atomicRetryTimer = undefined; + atomicRetryIndex = 0; + atomicRefreshPending = false; + profile = next; + options.onChange(); + return true; + }; + const scheduleAtomicRetry = () => { + if (disposed || !atomicRefreshPending || atomicRetryTimer) return; + if (atomicRetryIndex >= ATOMIC_REFRESH_RETRY_DELAYS_MS.length) { + atomicRefreshPending = false; + return; + } + const delay = ATOMIC_REFRESH_RETRY_DELAYS_MS[atomicRetryIndex++]!; + atomicRetryTimer = clock.setTimeout(() => { + atomicRetryTimer = undefined; + if (!refresh()) scheduleAtomicRetry(); + }, delay); + atomicRetryTimer.unref(); + }; + const scheduleRefresh = (atomic = false) => { + if (disposed) return; + if (atomic) atomicRefreshPending = true; + if (refreshTimer) clock.clearTimeout(refreshTimer); + refreshTimer = clock.setTimeout(() => { + refreshTimer = undefined; + const changed = refresh(); + if (!changed && atomicRefreshPending) scheduleAtomicRetry(); + }, PROFILE_REFRESH_DEBOUNCE_MS); + refreshTimer.unref(); + }; + installWatchers(observedCwd); + + return { + get: () => profile, + refresh, + dispose() { + if (disposed) return; + disposed = true; + if (refreshTimer) clock.clearTimeout(refreshTimer); + if (atomicRetryTimer) clock.clearTimeout(atomicRetryTimer); + refreshTimer = undefined; + atomicRetryTimer = undefined; + closeWatchers(); + }, }; } @@ -169,14 +438,20 @@ export function createShellBarComponent( footerData: ShellFooterData, dirty: () => number | undefined = () => undefined, usage: () => ProviderUsage | undefined = () => undefined, + profileRefresh: () => boolean | void = () => false, + profile: () => ShellProfileState | undefined = () => undefined, ): ShellBarComponent { const unsubscribe = footerData.onBranchChange(() => { + // A profile refresh that changed the effective state already invalidates and + // paints the shell through its change seam. Avoid asking the TUI for the same + // render a second time; branch-only changes still use the ordinary path. + if (profileRefresh() === true) return; host.invalidateSidebar?.(); host.requestRender(); }); return { render(width: number) { - return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width); + return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage(), profile: profile() }), theme, width); }, invalidate() {}, dispose() { @@ -773,7 +1048,11 @@ async function fetchFromSource(source: UsageSource, apiKey: string | undefined, export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial = {}): void { installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree); if (!shellEnabled(env)) return; - const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides }; + const deps: ShellDeps = { + ...defaultShellDeps, + activeProfile: createActiveProfileReader(env, overrides.resolveWorktree ?? defaultShellDeps.resolveWorktree), + ...overrides, + }; const usage = new UsageStore(); // Providers gentle-shell has never heard of get a usage source too, when // the extension that owns them registers one on pi.events; see the @@ -882,6 +1161,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p let changes: SessionChanges | undefined; let registry: SessionWorktreeRegistry | undefined; let currentContext: ExtensionContext | undefined; + let profileSnapshot: EffectiveProfileSnapshot | undefined; let shown = ""; const applyChanges = (ctx: ExtensionContext, model: ChangesModel) => { const fingerprint = changesFingerprint(model); @@ -926,13 +1206,38 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p const tracker = changes; ctx.ui.setFooter((tui, theme, footerData) => { renderHost = { requestRender: () => tui.requestRender(), invalidateSidebar: () => invalidateSidebar(tui) }; - const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? "")); + profileSnapshot?.dispose(); + const snapshot = createEffectiveProfileSnapshot({ + read: deps.activeProfile, + cwd: () => ctx.sessionManager.getCwd(), + env, + resolveWorktree: deps.resolveWorktree, + watch: deps.watchProfile, + profileRefreshClock: deps.profileRefreshClock, + onChange: () => { + invalidateSidebar(tui); + tui.requestRender(); + }, + }); + profileSnapshot = snapshot; + const bottom = createShellBarComponent( + pi, + ctx, + renderHost, + theme, + footerData, + () => tracker.model.files.length, + () => usage.get(ctx.model?.provider ?? ""), + () => snapshot.refresh(), + () => snapshot.get(), + ); // The Status card paints live session state that no event re-registers a // part for: model, effort, context, cost, session name and extension - // statuses. The digest is what keeps the fullscreen memo honest, and it - // rebuilds the model exactly as the narrow bottom bar does every frame. + // statuses. Profile resolution happens only in the snapshot refresh seam; + // digest and render consume that in-memory value exactly like the other + // structured model fields. const footerModel = (): ShellBarModel => ({ - ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile() }), + ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: snapshot.get() }), changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted, notice: tracker.model.notice }, }); const part = sidebarPart(tui, "footer", bottom, { @@ -958,7 +1263,16 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p }, }); const uninstall = installSidebar(tui, theme); - return { ...part, dispose() { disposeHeader(); uninstall(); part.dispose(); } }; + return { + ...part, + dispose() { + snapshot.dispose(); + if (profileSnapshot === snapshot) profileSnapshot = undefined; + disposeHeader(); + uninstall(); + part.dispose(); + }, + }; }); void refreshUsage(ctx, true); const ownsPrompt = installPrompt( @@ -994,6 +1308,8 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p } registry?.close(); registry = undefined; + profileSnapshot?.dispose(); + profileSnapshot = undefined; changes = undefined; currentContext = undefined; unsubscribeWorktrees(); diff --git a/lib/shell-bar.ts b/lib/shell-bar.ts index ce20fd29b..f2568df28 100644 --- a/lib/shell-bar.ts +++ b/lib/shell-bar.ts @@ -10,8 +10,15 @@ export { gaugeTone, renderGauge, type GaugeTone }; // three-line footer. Everything here is pure so the bar can be rendered and // verified without a live TUI. +export type ShellProfileSource = "global" | "local" | "repo"; + +export interface ShellProfileState { + name: string; + source: ShellProfileSource; +} + export interface ShellBarModel { - profile?: string; + profile?: ShellProfileState; changes?: { files: number; added: number; deleted: number; notice?: string }; cwd: string; branch: string | null; @@ -38,7 +45,7 @@ export interface ShellHeaderModel { dirty: number | undefined; modelId: string; effort: string | undefined; - profile?: string; + profile?: ShellProfileState; contextPercent: number | null; costTotal: number; subscription: boolean; @@ -116,6 +123,11 @@ function sanitizeStatus(text: string): string { return sanitizeTerminalText(text.replace(/[\r\n\t]/g, " ")).replace(/ +/g, " ").trim(); } +function formatProfile(profile: ShellProfileState): string { + const suffix = profile.source === "global" ? "" : ` (${profile.source})`; + return sanitizeStatus(`${profile.name}${suffix}`); +} + // Shared by the compact bar, the sidebar Status card, and the fullscreen // header row, so the three surfaces never drift on how they paint the same // facts. @@ -197,7 +209,7 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme value(model.cwd), ...(branch ? [branch] : []), ...(model.sessionName ? [`${label("Session")} ${value(model.sessionName)}`] : []), - ...(model.profile ? [`${label("Profile")} ${value(sanitizeStatus(model.profile))}`] : []), + ...(model.profile ? [`${label("Profile")} ${value(formatProfile(model.profile))}`] : []), ], }, { @@ -235,7 +247,7 @@ function headerLeftStages(model: ShellHeaderModel, theme: ShellBarTheme): string const location = locationSegment(model, theme); const withEffort = executionSegment(model.modelId, model.effort, theme); const modelOnly = executionSegment(model.modelId, undefined, theme); - const withProfile = model.profile ? `${withEffort} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.MODEL, sanitizeStatus(model.profile))}` : withEffort; + const withProfile = model.profile ? `${withEffort} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.MODEL, formatProfile(model.profile))}` : withEffort; return [ [brand, location, withProfile], [brand, location, withEffort], diff --git a/odd/tasks/effective-profile-status.md b/odd/tasks/effective-profile-status.md new file mode 100644 index 000000000..8b625dc11 --- /dev/null +++ b/odd/tasks/effective-profile-status.md @@ -0,0 +1,134 @@ +# Effective repository profile in Status + +## Objective + +Fix GitHub issue #1176 so the fullscreen Status sidebar shows the profile that governs subagent launches in the current repository. Preserve the winning source returned by `resolveProfilePin()` and display `(local)` for a clone-local pin or `(repo)` for a repository declaration; otherwise show the globally active profile without a suffix. + +## Context + +- Upstream issue: https://github.com/Gentleman-Programming/gentle-shell/issues/1176 +- Starting point: `origin/main` at `1170dc84c2198b53807431f6e03d02bf8dcc3444` +- Re-investigation baseline: `main` at `43269de359c5052d2cadb72ab4cf2d57ca0211b0` +- Branch: `fix/effective-profile-status` +- Pre-existing untracked `mise.toml` is outside this feature and must remain untouched. +- Current main routes the profile through both the fullscreen Status card and live header; profile + resolution must stay outside `digest()` and `render()`. + +## Scope + +- Resolve the effective profile through the existing repository-pin authority. +- Keep profile state structured through the shell model and render the valid winning pin source as `(local)` or `(repo)`. +- Refresh the Status digest when the global profile or relevant pin state changes. +- Preserve the compact bottom bar behavior: it does not show a profile. +- Add focused regression coverage and update user-facing documentation if the documented semantics require clarification. +- Keep the correction reviewable without shrinking necessary tests or documentation to meet a line target. +- Review follow-up plan: `work-items/active/fix/1176-effective-profile-status/review-follow-up-implementation-plan.md`. +- Baseline for this follow-up: PR head `cfbdf1be`; local feature branch `8e3578c9` contains the later main merge, with the same watcher/test behavior and a changed documentation sentence. +- TDD mode: enabled for these tasks by the user-accepted follow-up plan (RED/GREEN/TRIANGULATE/REFACTOR); exact focused runner: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts`. +- RDD switch: off (clone-local; observed before implementation). Native review is not enabled for this candidate. +- Review workload: forecast approximately 250–400 authored diff lines for T8–T10; the existing PR already exceeds 400 lines and requests `size:exception`. Delivery strategy: `exception-ok` for PR #1252, explicitly selected by the user despite its existing size-exception request. Keep task-scoped work-unit commits on this feature branch; no push/PR creation is authorized here. + +## Tasks + +- [x] T1 — Implement pin-aware Status profile resolution, focused tests, and documentation; run focused and repository checks; commit as one reviewable work unit. +- [x] T2 — Rebase the design onto current main: refresh one cached effective-profile snapshot through + invalidation/watch events, use it in Status and the live header, and prove repeated renders perform + no profile filesystem or Git resolution. +- [x] T3 — Merge current `main` at `43269de3`, reconcile the feature with the live header and current + shell behavior, and verify the merged candidate. +- [x] T4 — Replace the lossy `pinned` boolean with the effective source (`global`, `local`, or `repo`), render the winning pin scope, cover same-profile source transitions, update documentation, and verify within the user-specified line budgets. Route: delegated writer because the change spans multiple non-trivial files. +- [x] T5 — Integrate current `main` at `cf1fdb65`, resolve the `extensions/gentle-shell.ts` import conflict while preserving both effective-profile state and the fullscreen header rule, run focused and repository verification, and commit the integration. +- [x] T6 — Correct review finding `R4-watch-runtime-error`: handle asynchronous `FSWatcher` errors without terminating the Pi host, add focused regression coverage, validate within the native correction budget, and commit the fix. +- [x] T7 — Integrate updated `main` at `b6188bef`, resolve the test import conflict while preserving both subscription-usage and effective-profile coverage, verify the merged candidate, push the feature branch, then integrate it into `downstream/main`. +- [x] T8 — Filter irrelevant profile watcher events and reuse the resolved worktree identity; preserve null-filename, ancestor creation, atomic replacement, and non-Git global behavior. Route: delegated writer (production and regression tests); checks: observed RED/GREEN focused tests, resolver/read counters, typecheck, diff check, work-unit commit. +- [x] T9 — Remove real OS watcher/timer dependence from both fullscreen Status refresh regressions while preserving integration assertions and rebind coverage. Route: delegated writer (integration tests and narrow test seam); checks: observed RED/GREEN, focused repeat, suite, work-unit commit. +- [x] T10 — Clarify global-store and invalid/stale-pin fallback in the fullscreen documentation, verify consistency with reference docs, and commit the documentation work unit. Route: inline direct unless additional non-trivial files become necessary; checks: readback, markdown/diff check, work-unit commit. + +## Acceptance criteria + +- No valid pin: `Profile `. +- Valid clone-local pin: `Profile (local)`. +- Valid repository declaration: `Profile (repo)`. +- Invalid, stale, missing, or unreadable pin layers are skipped; a valid lower-priority repository declaration wins before falling back to the global active profile without a suffix. +- Irrelevant watched-directory changes do not schedule profile refresh or synchronous Git resolution; relevant and unknown-filename changes still refresh the snapshot. +- The two fullscreen refresh regressions use controlled watcher events and debounce timing, not OS delivery or short wall-clock deadlines. +- A same-name transition between local and repository sources refreshes the displayed scope. +- Local pin precedence over repository declaration remains owned by `resolveProfilePin()`. +- Creating, changing, or removing a pin updates the fullscreen Status digest and live header without + restarting Pi. +- Repeated Status/header `digest()` and `render()` calls perform no profile filesystem or Git + resolution. +- External atomic replacements are observed through debounced parent-directory watchers, which are + disposed with the shell component. +- The compact bottom bar remains unchanged. +- Focused tests, typecheck/runtime checks, complete test suite, and `git diff --check` pass or any skipped/failed check is reported. + +## Follow-up progress + +- T8 complete in local commit `729bfa62`; T9 complete in local commit `e4bcd254`; T10 complete in the local documentation work unit. The four profile-watcher refresh tests now use injected events and a controlled clock instead of wall-clock debounce waits. +- Current branch is ahead of `origin/fix/effective-profile-status` because of an earlier local main merge; do not silently reset, rebase, push, or use the remote PR head as the checked-out source. +- Next: report the macOS-specific verification limit without claiming it passed; no push was performed. + +## Follow-up evidence + +- T8 RED: injected watcher tests failed before implementation: unrelated `index` event caused an extra read (`2 !== 1`); outside-Git global refresh repeated worktree lookup (`2 !== 1`). +- T8 GREEN: two targeted tests passed after filtering by next path component and caching the watcher-side worktree identity per `cwd`. +- T8 writer and independent verifier: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts` — 148 passed, 0 failed; `node scripts/check-types.mjs` — passed with 195 baseline diagnostics, no regression; `git diff --check` — passed. +- T8 independent finding: the two new tests use 130 ms real waits against a 100 ms debounce; T9 must replace those waits too. Relevant-event Git from the separate profile reader is not measured by watcher-side counters and was intentionally not changed. +- T8 parent spot check: `git diff --check` passed; no untracked source changes. RDD-off native assessment reported high risk; independent verifier completed with no confirmed production defect. +- T8 commit: `729bfa62` (`fix(shell): avoid unrelated profile watcher refreshes`), local only; user approved work-unit commits but not push. +- T9 RED: before seam wiring six targeted watcher tests failed because injected watchers were not installed and fake-clock advances did not refresh the snapshots. +- T9 GREEN: six targeted tests passed; original source transitions, late store creation, atomic replacement, null filenames, watcher errors, and disposal now use injected events and a per-snapshot clock. Production defaults retain Node watchers and timers. +- T9 writer and independent verifier: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts` — 148 passed, 0 failed; `node scripts/check-types.mjs` — passed with 195 baseline diagnostics, no regressions; `git diff --check` passed. A new test-helper type error was corrected before the final run. +- T9 follow-up verifier: later relevant event at t=60 resets the debounce from t=100 to t=160; focused suite 148 passed, diff check passed. Throwing watcher `close()` remains a low-priority pre-existing coverage gap outside the three review observations. +- T9 parent spot check: `git diff --check` passed. RDD-off assessment reported high risk; separate verification found no production defect. +- T9 commit: `e4bcd254` (`test(shell): control profile watcher events and debounce`), local only. +- T10 docs: `docs/gentle-shell.md` and `docs/readme-reference.md` now state that an unusable local pin can reveal a valid repository declaration, and an unavailable/invalid global store hides the field. The local ignored work-item index labels its earlier draft as historical and updates its next step. +- T10 independent full verification: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts tests/shell-sidebar-layout.test.ts` — 178 passed, 0 failed; `node scripts/check-types.mjs` — passed with 195 recorded diagnostics, no regressions, 4 improved pairs; `node --experimental-strip-types --test tests/*.test.ts` — 3355 passed, 0 failed, 38 skipped; `node scripts/check-provider-contract.mjs` — passed contract 1.2.0; `node --experimental-strip-types tests/runtime-harness.mjs` — exit 0; `git diff --check` — passed. Native assessment for docs was unassessable, so the separate verifier was required and completed. macOS-specific execution remains unavailable in this Linux checkout. + +## Evidence + +- T1 commit: `ef07e0ef` (`fix(shell): show effective repository profile`) +- TDD RED: the new pin test expected `other (pinned)` but observed the global `team` profile before implementation. +- Focused tests: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts` — 60 passed, 0 failed; `node --experimental-strip-types --test tests/profile-pin.test.ts` — 20 passed, 0 failed. +- Full tests: `node --experimental-strip-types --test tests/*.test.ts` — 2643 passed, 0 failed, 38 skipped. +- Type check: `node scripts/check-types.mjs` — passed with 197 recorded baseline diagnostics, no regressions, and 2 diagnostic pairs improved. +- Runtime module check: skipped because `scripts/check-runtime-modules.mjs` does not exist on this branch. +- Diff check: `git diff --check` — passed. +- Verification incident: `pnpm run typecheck` was discarded as a hermetic receipt because pnpm 12 dependency verification triggered install/postinstall side effects in ignored dependency areas. Read-only Git inspection confirmed no new tracked changes; final verification used direct Node commands only. +- Native review: unavailable before lineage creation. Two committed-range START attempts against `1170dc84c2198b53807431f6e03d02bf8dcc3444` were rejected with `candidate-target-projection-drift`; both reported `lineage_created: false` and performed no mutation. +- T2 focused tests: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts tests/shell-sidebar-layout.test.ts` — 82 passed, 0 failed. +- T2 full tests: `node --experimental-strip-types --test tests/*.test.ts` — 2639 passed, 0 failed, 47 skipped. +- T2 type check: `node scripts/check-types.mjs` — passed with 197 baseline diagnostics and no regressions. +- T2 diff check: `git diff --check` — passed. +- T2 independent verification: passed with no findings; confirmed cached Status/header parity, compact-bar stability, watcher debounce/disposal, atomic replacement handling, and no profile I/O from repeated digest/render calls. +- T2 commit: `692d140b` (`fix(shell): refresh effective profile outside render path`). +- T3 focused tests after merge: 160 passed, 0 failed. +- T3 split full suite: 2894 total — 2847 passed, 0 failed, 47 skipped. The relay-routing file + used `GENTLE_PI_GENTLE_AI_DEV_BINARY=/usr/bin/true` because the package-local v3.4.0 runtime is + absent; the remaining suite ran with the normal environment. +- T3 type check: passed with 196 baseline diagnostics and no regressions. +- T3 diff check: `git diff --check` — passed. +- T3 merge commit: `e32c62ce` (`chore(branch): merge current main into effective profile fix`). +- T4 TDD RED: focused tests observed 129 passed and 5 failed after expectations changed to exact global/local/repo sources, including missing local suffixes and old reader state. +- T4 TDD GREEN: focused tests passed with 134 passed and 0 failed after replacing `pinned` with `source` and comparing source during snapshot refresh. +- T4 focused verification: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts tests/shell-sidebar-layout.test.ts` — 160 passed, 0 failed. +- T4 type check: `node scripts/check-types.mjs` — passed with 196 recorded diagnostics, no regressions, and 3 file/code pairs improved. +- T4 full suite: the required command reported 2846 passed, 1 failed, and 47 skipped only in `tests/review-host-relay-routing.test.ts`; the mandated override passed 31 tests, and the remaining suite passed 2816 tests with 47 skipped. +- T4 diff check: `git diff --check` — passed after removing one test trailing-whitespace line. +- T4 line budgets: 19 changed production/documentation lines and 112 changed test lines (additions plus deletions; task-artifact bookkeeping excluded). +- T5 conflict resolution: preserved `dirname`, `ShellProfileState`, and effective-profile snapshot behavior from the feature branch while retaining `renderShellHeaderRule` and the decorative-row mouse guard from `main`. +- T5 focused tests: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts tests/shell-sidebar-layout.test.ts` — 166 passed, 0 failed. +- T5 type check: `node scripts/check-types.mjs` — passed with 196 recorded diagnostics, no regressions, and 3 file/code pairs improved. +- T5 full suite: the required command reported 2923 passed, 1 failed, and 47 skipped only in `tests/review-host-relay-routing.test.ts`; the established native-runtime override passed that file's 31 tests, and the remaining suite passed 2893 tests with 47 skipped. +- T5 diff check: `git diff --check --cached` — passed. +- T5 merge commit: `c1bd0148` (`chore(branch): merge current main into effective profile fix`). +- T6 correction: asynchronous watcher errors close and retire the failed watcher, suppress retry loops, and remain safe after disposal; focused regression coverage exercises the error lifecycle. +- T6 correction size: 44 diff lines across production and test code, within the authorized 80-line plan. +- T6 focused tests: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts tests/shell-sidebar-layout.test.ts` — 167 passed, 0 failed. +- T6 type check: `node scripts/check-types.mjs` — passed with 196 recorded diagnostics, no regressions, and 3 file/code pairs improved. +- T6 diff check: `git diff --check` — passed. +- T7 conflict resolution: retained `createEffectiveProfileSnapshot` and `createShellBarComponent` from the feature branch while preserving `USAGE_SOURCE_EVENT` and `USAGE_SOURCE_SCHEMA` from updated `main`. +- T7 focused verification: `node --experimental-strip-types --test tests/gentle-shell.test.ts tests/shell-bar.test.ts tests/shell-sidebar-layout.test.ts tests/shell-usage.test.ts` — 211 passed, 0 failed, 0 skipped. +- T7 type check: `node scripts/check-types.mjs` — passed with 196 recorded diagnostics and no regressions. +- T7 merge checks: no unmerged paths or conflict markers; `git diff --check --cached` passed. diff --git a/odd/tasks/profile-sidebar-atomic-watch-regression.md b/odd/tasks/profile-sidebar-atomic-watch-regression.md new file mode 100644 index 000000000..999087264 --- /dev/null +++ b/odd/tasks/profile-sidebar-atomic-watch-regression.md @@ -0,0 +1,41 @@ +# Profile sidebar atomic-watch regression + +## Objective + +Restore live fullscreen profile updates when the platform reports only the temporary filename from an atomic profile or pin replacement and omits the final target filename. + +## Evidence and boundaries + +- Live Pi trace on 2026-09-23: pin replacement from `MediumWork (local)` to `HeavyWork (local)` emitted `rename` for `.profile-pin.json..tmp`, rejected by `extensions/gentle-shell.ts`; no target-basename event or snapshot refresh followed. A fresh disk read returned `HeavyWork (local)`, while the sidebar remained `MediumWork (local)` until `/reload`. +- An earlier event naming `profile-pin.json` caused the expected snapshot change and fullscreen render. The existing deterministic tests and temporary real-watcher fixture observed target basenames, so they missed this event-only case. +- Previous feature history: `odd/tasks/effective-profile-status.md`, especially review follow-up T8–T10. +- Current branch: `fix/effective-profile-status` at `fb9ad2a2`; starting working tree clean. No push, PR, real profile-store mutation by the agent, or live-process instrumentation is in scope. + +## Scope and approach + +- Preserve filtering of unrelated watched-directory events; recognize only the atomic sibling temporary filenames produced for watched `profiles.json`, `profile-pin.json`, or `profile.json` targets where applicable. +- Schedule a bounded refresh after the atomic replacement is complete; cover the case where the only event arrives before the rename and no final-target event follows. Avoid repeated Git resolution or unbounded polling. +- Keep existing null-filename, direct-target, late-directory, source-transition, and watcher-disposal behavior. +- Use one work unit for implementation and its regression tests. Forecast: roughly 100–220 authored changed lines, under the ~400-line review planning heuristic; delivery strategy `ask-on-risk`. The task has two non-trivial edit surfaces, so delegate one writer. +- TDD: enabled by the user-approved plan for this regression (write a deterministic failing reproduction first, then implement); focused runner `node --experimental-strip-types --test tests/gentle-shell.test.ts`. Full runner: `node --experimental-strip-types --test tests/*.test.ts`. Typecheck: `node scripts/check-types.mjs`. RDD switch: off, confirmed by `gentle-ai review mode status` on this branch. +- Delivery: the user explicitly authorized one local work-unit commit for this fix, tests, and task record; do not push or open a PR. + +## Tasks + +- [x] T1 — Add deterministic temp-only and pre-rename-event regressions, implement a target-scoped bounded refresh, and pass focused tests, typecheck, and diff check. Route: delegated writer for `extensions/gentle-shell.ts` and `tests/gentle-shell.test.ts`; final RED/GREEN also covers a second distinct-UUID write near retry exhaustion. Included with this task record in the local work-unit commit; SHA recorded in the Engram mirror. +- [x] T2 — Run the complete repository test suite and validate the updated fullscreen sidebar in a fresh `/reload` with a user-initiated profile change. Route: independent delegated command verification and parent-coordinated live check; user confirmed the sidebar now follows profile changes without another reload. + +## Acceptance criteria + +- An atomic pin or global-profile write updates the effective profile and fullscreen sidebar even if `fs.watch` reports only the matching temporary basename. +- An event before the rename still produces an update after the completed replacement, within a bounded retry policy. +- Unrelated temporary files and names do not refresh the profile; direct target and null events continue to work. +- No profile resolution is added to sidebar digest/render; timers and watchers are disposed safely; no unbounded retry or unrelated filesystem activity. +- Focused tests, typecheck, full suite, and `git diff --check` pass; live Pi verification is reported distinctly from simulated tests. + +## Progress + +- T1 complete: deterministic regressions cover temp-only pin/global writes, delayed replacement, irrelevant temp names, null/direct target interleavings before debounce and during active retry, bounded retry reads, exhaustion, change cancellation, disposal, and a second distinct-UUID write near exhaustion. Each distinct temp filename receives a finite retry window; same-name bursts coalesce with one cancellable timer. A rename completing after its bounded window still needs another event or explicit refresh. +- T1 RED/GREEN sequence: delayed rename 114 passed/1 failed → 115 passed; stale retry after direct event 115 passed/1 failed → 116 passed; pre-debounce direct/null 116 passed/2 failed → 118 passed; duplicate active retry 118 passed/2 failed → 120 passed; second distinct-UUID write 120 passed/1 failed → final 121 passed, 0 failed. `node scripts/check-types.mjs` reported 195 recorded diagnostics with no regressions; `git diff --check` passed. The user subsequently approved a local work-unit commit without push. +- T2 final independent verification: `node --experimental-strip-types --test tests/*.test.ts` — 3362 passed, 0 failed, 38 skipped; `node scripts/check-types.mjs` — 195 recorded diagnostics, no regressions (4 improved pairs); `git diff --check` passed. The independent verifier confirmed all earlier findings fixed and found no other material defect in scoped code. Parent spot check passed. Source/test authored diff: 299 lines (additions plus deletions), below the ~400-line delivery review heuristic. +- T2 live Pi check: after `/reload`, the user changed the profile and confirmed the fullscreen sidebar now updates correctly without another reload. This is user-observed live behavior, distinct from the deterministic automated tests. macOS-specific execution was not available; the 38 suite skips remain. The user approved a local commit of this work unit; its exact SHA is recorded in the Engram recovery mirror. No push is authorized. diff --git a/tests/gentle-shell.test.ts b/tests/gentle-shell.test.ts index 5f5a0f894..7569dd2fa 100644 --- a/tests/gentle-shell.test.ts +++ b/tests/gentle-shell.test.ts @@ -2,15 +2,17 @@ import assert from "node:assert/strict"; import { execFileSync, execFile } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import test from "node:test"; import { initTheme, type ExtensionAPI, type ExtensionContext, type SlashCommandInfo, type SourceInfo } from "@earendil-works/pi-coding-agent"; import type { TUI, TuiMouseEvent } from "@earendil-works/pi-tui"; -import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, type GentlePromptEditor } from "../extensions/gentle-shell.ts"; +import installGentleShell, { buildShellBarModel, createActiveProfileReader, createEffectiveProfileSnapshot, createShellBarComponent, changesShortcut, devBinaryCard, extractQueuedText, fetchCodexUsage, fetchNanUsage, loadFileDiff, shellGitRunner, openInExternalEditor, usageShortcut, type GentlePromptEditor, type ProfileRefreshClock, type ShellDeps } from "../extensions/gentle-shell.ts"; import { USAGE_SOURCE_EVENT, USAGE_SOURCE_SCHEMA } from "../lib/shell-usage.ts"; import { CHANGE_STATUS } from "../lib/shell-changes.ts"; import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts"; -import type { ShellBarTheme } from "../lib/shell-bar.ts"; +import type { ShellBarTheme, ShellProfileState } from "../lib/shell-bar.ts"; +import { localProfilePinPath, repoProfileDeclarationPath, serializeProfilePin } from "../lib/agent-profile-pin.ts"; +import { profilesFilePath } from "../lib/agent-profiles.ts"; import { stripAnsi } from "../lib/terminal-theme.ts"; // The Gentle Shell extension wires the pure bar renderer into pi's footer @@ -130,6 +132,64 @@ async function fire(handlers: Map void; + closed: boolean; + closeCalls: number; +} + +function createProfileWatchHarness() { + let now = 0; + let nextTimerId = 0; + const timers = new Map, { due: number; callback: () => void }>(); + const watchers: ProfileWatchRecord[] = []; + const profileRefreshClock: ProfileRefreshClock = { + setTimeout(callback, delay) { + const timer = { id: ++nextTimerId, unref() {} } as unknown as ReturnType; + timers.set(timer, { due: now + delay, callback }); + return timer; + }, + clearTimeout(timer) { + timers.delete(timer); + }, + }; + const watchProfile: NonNullable = ((directory: string, callback: ProfileWatchRecord["callback"]) => { + const record: ProfileWatchRecord = { directory, callback, closed: false, closeCalls: 0 }; + watchers.push(record); + return { + on(event: string, listener: (error: Error) => void) { + if (event === "error") record.onError = listener; + return this; + }, + unref() {}, + close() { + record.closed = true; + record.closeCalls++; + }, + }; + }) as unknown as NonNullable; + const activeWatcher = (directory: string) => { + const record = watchers.find((candidate) => candidate.directory === directory && !candidate.closed); + assert.ok(record, `expected an active watcher for ${directory}`); + return record; + }; + const emit = (directory: string, filename: string | Buffer | null) => activeWatcher(directory).callback("change", filename); + const advance = (milliseconds: number) => { + now += milliseconds; + while (true) { + const next = [...timers.entries()] + .filter(([, timer]) => timer.due <= now) + .sort((left, right) => left[1].due - right[1].due)[0]; + if (!next) return; + timers.delete(next[0]); + next[1].callback(); + } + }; + return { watchers, profileRefreshClock, watchProfile, activeWatcher, emit, advance, pendingTimers: () => timers.size }; +} + function fakeContext(options: { hasUI?: boolean; entries?: unknown[]; oauth?: boolean; pending?: boolean; idle?: boolean; editorFactory?: unknown; token?: string; select?: (title: string, options: string[]) => Promise } = {}): { ctx: ExtensionContext; ui: FakeUi; overlayReady: Promise } { const ui: FakeUi = { footerFactory: undefined, editorFactory: options.editorFactory, widgets: new Map(), widgetSets: 0, workingVisible: undefined, notices: [], overlay: undefined, overlayView: undefined, closeOverlay: undefined }; let resolveOverlay: () => void; @@ -212,8 +272,9 @@ test("buildShellBarModel reads session, model, and footer data", () => { getAvailableProviderCount: () => 1, onBranchChange: () => () => {}, }; - const built = buildShellBarModel(pi, ctx, footerData, { home: "/home/alan" }); + const built = buildShellBarModel(pi, ctx, footerData, { home: "/home/alan", profile: { name: "team", source: "local" } }); assert.equal(built.cwd, "/repo"); + assert.deepEqual(built.profile, { name: "team", source: "local" }); assert.equal(built.branch, "main"); assert.equal(built.sessionName, "Release notes"); assert.equal(built.modelId, "gpt-5.5"); @@ -241,6 +302,66 @@ test("buildShellBarModel shortens the home directory and hides effort for non-re assert.equal(built.branch, null); }); +test("the live shell header consumes the structured profile snapshot without painting it in the compact bar", () => { + const { pi } = fakePi(); + const { ctx } = fakeContext(); + let reads = 0; + const component = createShellBarComponent( + pi, + ctx, + { requestRender() {}, invalidateSidebar() {} }, + plainTheme, + { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }, + () => undefined, + () => undefined, + () => false, + () => { + reads++; + return { name: "team", source: "local" }; + }, + ); + assert.equal(reads, 0, "the in-memory profile is read by the header, not mounted from disk"); + const [line] = component.render(160); + assert.equal(reads, 1); + assert.doesNotMatch(line, /team|\((local|repo)\)/, "the compact bar's visual contract remains unchanged"); + component.dispose(); +}); + +test("branch invalidation does not duplicate a render already requested by profile refresh", () => { + const { pi } = fakePi(); + const { ctx } = fakeContext(); + let branchChanged: (() => void) | undefined; + let requests = 0; + let invalidations = 0; + const component = createShellBarComponent( + pi, + ctx, + { requestRender() { requests++; }, invalidateSidebar() { invalidations++; } }, + plainTheme, + { + getGitBranch: () => "main", + getExtensionStatuses: () => new Map(), + getAvailableProviderCount: () => 1, + onBranchChange: (callback) => { + branchChanged = callback; + return () => { branchChanged = undefined; }; + }, + }, + () => undefined, + () => undefined, + () => { + invalidations++; + requests++; + return true; + }, + () => ({ name: "other", source: "local" }), + ); + branchChanged!(); + assert.equal(invalidations, 1); + assert.equal(requests, 1, "the profile refresh already requested the render"); + component.dispose(); +}); + test("gentleShell installs the footer on session_start when a UI exists", () => { const { pi, handlers } = fakePi(); gentleShell(pi, {}); @@ -261,13 +382,22 @@ test("gentleShell installs the footer on session_start when a UI exists", () => test("the fullscreen Status rail carries a live digest so a profile switch refreshes it", async () => { const { pi, handlers } = fakePi(); - let profile: string | undefined = "team"; + let profile: ShellProfileState | undefined = { name: "team", source: "global" }; gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { activeProfile: () => profile }); const { ctx, ui } = fakeContext(); await fire(handlers, "session_start", ctx); const statuses = new Map(); - const liveFooterData = { getGitBranch: () => "main", getExtensionStatuses: () => statuses, getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }; + let branchChanged: (() => void) | undefined; + const liveFooterData = { + getGitBranch: () => "main", + getExtensionStatuses: () => statuses, + getAvailableProviderCount: () => 1, + onBranchChange: (callback: () => void) => { + branchChanged = callback; + return () => { branchChanged = undefined; }; + }, + }; const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void }; const component = factory(tui, plainTheme, liveFooterData); @@ -279,10 +409,12 @@ test("the fullscreen Status rail carries a live digest so a profile switch refre assert.match(rail.render(46).join("\n"), /Profile.*team/); const beforeProfile = live(); - profile = "other"; + profile = { name: "other", source: "global" }; + branchChanged!(); assert.notEqual(live(), beforeProfile); assert.match(rail.render(46).join("\n"), /Profile.*other/); profile = undefined; + branchChanged!(); assert.doesNotMatch(rail.render(46).join("\n"), /Profile/); const beforeStatus = live(); @@ -295,6 +427,72 @@ test("the fullscreen Status rail carries a live digest so a profile switch refre } }); +test("fullscreen Status digest follows effective pin source changes without restarting the shell", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-status-pin-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const worktreeRoot = join(root, "repo"); + const commonDir = join(root, "clone"); + const resolveWorktree = () => ({ root: worktreeRoot, commonDir }); + mkdirSync(join(worktreeRoot, ".pi", "gentle-ai"), { recursive: true }); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + writeFileSync(join(root, "profiles.json"), JSON.stringify({ + kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} }, + })); + const repoPin = repoProfileDeclarationPath(worktreeRoot); + const localPin = localProfilePinPath(commonDir); + const { pi, handlers } = fakePi(); + const profileWatch = createProfileWatchHarness(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { + resolveWorktree, + watchProfile: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, + }); + const { ctx, ui } = fakeContext(); + await fire(handlers, "session_start", ctx); + const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void }; + const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; + const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }); + try { + const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail; + const refreshAfterEvent = (directory: string, filename: string, expectedDigest: string, rendered: RegExp) => { + const before = rail.digest!(); + profileWatch.emit(directory, filename); + profileWatch.advance(99); + assert.equal(rail.digest!(), before, "the Status snapshot stays unchanged before the debounce expires"); + profileWatch.advance(1); + assert.notEqual(rail.digest!(), expectedDigest, "the watcher event refreshes the Status digest after debounce"); + assert.match(rail.render(46).join("\n"), rendered); + }; + assert.match(rail.render(46).join("\n"), /Profile.*team/); + const beforePin = rail.digest!(); + writeFileSync(repoPin, serializeProfilePin("other")); + refreshAfterEvent(dirname(repoPin), basename(repoPin), beforePin, /Profile.*other \(repo\)/); + assert.notEqual(rail.digest!(), beforePin, "creating a pin must invalidate the Status digest"); + const replacement = join(root, "profile-replacement.json"); + writeFileSync(replacement, serializeProfilePin("team")); + renameSync(replacement, repoPin); + const beforeReplacement = rail.digest!(); + refreshAfterEvent(dirname(repoPin), basename(repoPin), beforeReplacement, /Profile.*team \(repo\)/); + const beforeLocal = rail.digest!(); + writeFileSync(localPin, serializeProfilePin("team")); + refreshAfterEvent(dirname(localPin), basename(localPin), beforeLocal, /Profile.*team \(local\)/); + assert.notEqual(rail.digest!(), beforeLocal, "same-profile local precedence must invalidate the Status digest"); + const beforeRepo = rail.digest!(); + rmSync(localPin); + refreshAfterEvent(dirname(localPin), basename(localPin), beforeRepo, /Profile.*team \(repo\)/); + assert.notEqual(rail.digest!(), beforeRepo, "returning to the repository source must invalidate the Status digest"); + const beforeRemoval = rail.digest!(); + rmSync(repoPin); + refreshAfterEvent(dirname(repoPin), basename(repoPin), beforeRemoval, /Profile.*team/); + assert.notEqual(rail.digest!(), beforeRemoval, "removing a pin must invalidate the Status digest"); + assert.match(rail.render(46).join("\n"), /Profile.*team/); + assert.doesNotMatch(rail.render(46).join("\n"), /\((local|repo)\)/); + assert.ok(profileWatch.watchers.length > 0, "the fullscreen Status factory installs the injected watchers"); + } finally { + component.dispose(); + } +}); + test("the fullscreen header rail carries a live digest so model, context, and cost changes refresh it", async () => { const { pi, handlers } = fakePi(); gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }); @@ -335,6 +533,54 @@ test("the fullscreen header rail carries a live digest so model, context, and co } }); +test("fullscreen Status rebinds parent watchers when a profile store appears after startup", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-watch-root-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "created", "gentle-ai"); + const worktreeRoot = join(root, "repo"); + const commonDir = join(root, "clone"); + mkdirSync(worktreeRoot, { recursive: true }); + mkdirSync(commonDir, { recursive: true }); + mkdirSync(join(root, "created"), { recursive: true }); + const profileFile = (active: string) => JSON.stringify({ + kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: { team: {}, other: {} }, + }); + const { pi, handlers } = fakePi(); + const profileWatch = createProfileWatchHarness(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: configHome, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { + resolveWorktree: () => ({ root: worktreeRoot, commonDir }), + watchProfile: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, + }); + const { ctx, ui } = fakeContext(); + await fire(handlers, "session_start", ctx); + const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void }; + const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; + const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }); + try { + const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail; + assert.doesNotMatch(rail.render(46).join("\n"), /Profile/); + const oldAncestor = profileWatch.activeWatcher(join(root, "created")); + mkdirSync(configHome, { recursive: true }); + writeFileSync(profilesFilePath(configHome), profileFile("team")); + profileWatch.emit(join(root, "created"), basename(configHome)); + profileWatch.advance(99); + assert.doesNotMatch(rail.render(46).join("\n"), /Profile/, "the late store is not visible before the debounce expires"); + profileWatch.advance(1); + assert.match(rail.render(46).join("\n"), /Profile.*team/); + assert.equal(oldAncestor.closed, true, "the parent watcher is retired after the store directory appears"); + profileWatch.activeWatcher(configHome); + const replacement = join(root, "profile-replacement.json"); + writeFileSync(replacement, profileFile("other")); + renameSync(replacement, profilesFilePath(configHome)); + profileWatch.emit(configHome, basename(profilesFilePath(configHome))); + profileWatch.advance(100); + assert.match(rail.render(46).join("\n"), /Profile.*other/); + } finally { + component.dispose(); + } +}); + test("clicking the header's usage segment opens the usage panel; other header clicks are ignored", async () => { const { pi, handlers } = fakePi(); gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }); @@ -363,27 +609,515 @@ test("clicking the header's usage segment opens the usage panel; other header cl } }); +test("fullscreen Status keeps the effective profile in a snapshot between known invalidations", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-snapshot-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const worktreeRoot = join(root, "repo"); + const commonDir = join(root, "clone"); + mkdirSync(join(worktreeRoot, ".pi", "gentle-ai"), { recursive: true }); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + let profile: ShellProfileState | undefined = { name: "team", source: "global" }; + let reads = 0; + let branchChanged: (() => void) | undefined; + const { pi, handlers } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { + resolveWorktree: () => ({ root: worktreeRoot, commonDir }), + activeProfile: () => { + reads++; + return profile; + }, + }); + const { ctx, ui } = fakeContext(); + await fire(handlers, "session_start", ctx); + const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void }; + const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; + const component = factory(tui, plainTheme, { + getGitBranch: () => "main", + getExtensionStatuses: () => new Map(), + getAvailableProviderCount: () => 1, + onBranchChange: (callback: () => void) => { + branchChanged = callback; + return () => { branchChanged = undefined; }; + }, + }); + try { + const rail = sidebarState(tui as unknown as TUI).parts.get("footer") as SidebarRail; + assert.equal(reads, 1, "the initial footer mount resolves the effective profile once"); + const before = rail.digest!(); + for (let i = 0; i < 3; i++) { + rail.digest!(); + rail.render(46); + } + assert.equal(reads, 1, "repeated Status digest/render calls use the in-memory snapshot"); + profile = { name: "other", source: "local" }; + branchChanged!(); + assert.equal(reads, 2, "a known in-process invalidation refreshes the snapshot"); + assert.notEqual(rail.digest!(), before); + assert.match(rail.render(46).join("\n"), /Profile.*other \(local\)/); + } finally { + component.dispose(); + } +}); + +test("fullscreen Status disposes profile watchers and pending refreshes with the footer", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-watch-lifecycle-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const worktreeRoot = join(root, "repo"); + const commonDir = join(root, "clone"); + mkdirSync(join(worktreeRoot, ".pi", "gentle-ai"), { recursive: true }); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + writeFileSync(profilesFilePath(root), JSON.stringify({ + kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {}, other: {} }, + })); + let reads = 0; + const repoPin = repoProfileDeclarationPath(worktreeRoot); + const profileWatch = createProfileWatchHarness(); + const { pi, handlers } = fakePi(); + gentleShell(pi, { GENTLE_PI_CONFIG_HOME: root, GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { + resolveWorktree: () => ({ root: worktreeRoot, commonDir }), + activeProfile: () => { + reads++; + return { name: "team", source: "global" }; + }, + watchProfile: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, + }); + const { ctx, ui } = fakeContext(); + await fire(handlers, "session_start", ctx); + const factory = ui.footerFactory as (tui: unknown, theme: ShellBarTheme, footerData: unknown) => { render(width: number): string[]; dispose(): void }; + const tui = { terminal: { rows: 40, columns: 160 }, requestRender() {} }; + const component = factory(tui, plainTheme, { getGitBranch: () => "main", getExtensionStatuses: () => new Map(), getAvailableProviderCount: () => 1, onBranchChange: () => () => {} }); + assert.equal(reads, 1); + profileWatch.emit(dirname(repoPin), basename(repoPin)); + assert.equal(profileWatch.pendingTimers(), 1, "a relevant event has a pending debounced refresh"); + component.dispose(); + component.dispose(); + assert.ok(profileWatch.watchers.every((watcher) => watcher.closed && watcher.closeCalls === 1), "footer disposal closes each watcher exactly once"); + writeFileSync(repoPin, serializeProfilePin("other")); + profileWatch.advance(100); + assert.equal(reads, 1, "disposed Status components must close watchers and pending refresh timers"); +}); + +test("profile watchers filter filenames, rebind ancestors, and cache worktree identity by cwd", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-watch-filter-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + const commonDir = join(root, "clone"); + const localWatchDirectory = join(commonDir, "gentle-ai"); + mkdirSync(localWatchDirectory, { recursive: true }); + const nextRoot = join(root, "next-repo"); + const nextCommonDir = join(root, "next-clone"); + mkdirSync(nextRoot, { recursive: true }); + mkdirSync(join(nextCommonDir, "gentle-ai"), { recursive: true }); + let cwd = root; + const resolutions: string[] = []; + const reads: string[] = []; + const profileWatch = createProfileWatchHarness(); + let profile: ShellProfileState | undefined = { name: "team", source: "global" }; + let changes = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => cwd, + env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: (path: string) => { + resolutions.push(path); + return path === root + ? { root, commonDir } + : { root: nextRoot, commonDir: nextCommonDir }; + }, + read: (path) => { reads.push(path!); return profile; }, + onChange: () => { changes++; }, + watch: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, + }); + const emit = profileWatch.emit; + try { + assert.deepEqual(reads, [root]); + assert.deepEqual(resolutions, [root], "the worktree identity is resolved once for the initial cwd"); + for (const filename of ["index", "HEAD", ".git", "profile.json"]) emit(root, filename); + emit(localWatchDirectory, "index"); + profileWatch.advance(100); + assert.equal(reads.length, 1, "named unrelated files, including a matching basename in another parent, do not refresh"); + assert.deepEqual(resolutions, [root], "unrelated watcher events do not resolve Git"); + + mkdirSync(configHome, { recursive: true }); + emit(root, "config"); + profileWatch.advance(99); + assert.equal(reads.length, 1, "a relevant event remains debounced until the full interval"); + profileWatch.advance(1); + assert.equal(reads.length, 2, "a shared watch directory retains the global store's next path component"); + profileWatch.activeWatcher(configHome); + + mkdirSync(join(root, ".pi", "gentle-ai"), { recursive: true }); + profile = { name: "other", source: "repo" }; + emit(root, Buffer.from(".pi")); + profileWatch.advance(100); + assert.equal(reads.length, 3, "creating a watched ancestor refreshes the profile"); + assert.deepEqual(snapshot.get(), { name: "other", source: "repo" }, "the refreshed snapshot preserves the effective pin source"); + assert.equal(changes, 1); + profileWatch.activeWatcher(join(root, ".pi", "gentle-ai")); + assert.deepEqual(resolutions, [root], "refreshing the same cwd reuses its worktree identity"); + + emit(configHome, "profile.json"); + profileWatch.advance(100); + assert.equal(reads.length, 3, "the same filename in a different parent is unrelated"); + const repoProfileDirectory = join(root, ".pi", "gentle-ai"); + emit(repoProfileDirectory, "profile.json"); + emit(repoProfileDirectory, "profile.json"); + profileWatch.advance(99); + assert.equal(reads.length, 3, "repeated relevant events remain grouped before the debounce expires"); + profileWatch.advance(1); + assert.equal(reads.length, 4, "a watched target filename refreshes the profile once"); + assert.equal(changes, 1, "an unchanged resolved state does not notify the shell"); + + emit(repoProfileDirectory, "profile.json"); + profileWatch.advance(60); + emit(repoProfileDirectory, "profile.json"); + profileWatch.advance(40); + assert.equal(reads.length, 4, "a later relevant event resets the deadline, so there is no refresh at t=100"); + profileWatch.advance(60); + assert.equal(reads.length, 5, "the coalesced refresh runs at t=160 from the first event"); + assert.equal(changes, 1, "the reset-deadline refresh preserves unchanged-state notification behavior"); + + emit(localWatchDirectory, null); + profileWatch.advance(99); + assert.equal(reads.length, 5, "unknown-filename refreshes remain debounced"); + profileWatch.advance(1); + assert.equal(reads.length, 6, "an unknown filename preserves conservative refresh behavior"); + + cwd = nextRoot; + snapshot.refresh(); + assert.deepEqual(reads, [root, root, root, root, root, root, nextRoot]); + assert.deepEqual(resolutions, [root, nextRoot], "a cwd transition resolves and caches the new worktree identity"); + assert.ok(profileWatch.watchers.some((record) => record.directory === nextRoot && !record.closed), "watchers rebind to the new worktree"); + assert.ok(profileWatch.watchers.some((record) => record.directory === join(root, ".pi", "gentle-ai") && record.closed), "watchers from the old worktree are closed"); + } finally { + snapshot.dispose(); + } +}); + +test("profile watchers keep the global store observable outside Git", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-global-watch-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + mkdirSync(configHome, { recursive: true }); + let reads = 0; + let resolutions = 0; + const profileWatch = createProfileWatchHarness(); + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, + env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => { resolutions++; throw new Error("not a Git worktree"); }, + read: () => { reads++; return { name: "team", source: "global" }; }, + onChange() {}, + watch: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, + }); + try { + assert.equal(reads, 1); + assert.equal(resolutions, 1); + assert.equal(profileWatch.watchers[0]!.directory, configHome); + profileWatch.emit(configHome, basename(profilesFilePath(configHome))); + profileWatch.advance(99); + assert.equal(reads, 1, "global profile changes remain debounced"); + profileWatch.advance(1); + assert.equal(reads, 2, "global profile changes remain observable outside Git"); + assert.equal(resolutions, 1, "the failed worktree lookup is cached for this cwd"); + } finally { + snapshot.dispose(); + } +}); + +test("atomic sibling events refresh pin and global profiles even when rename completes late", (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-atomic-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + const commonDir = join(root, "clone"); + mkdirSync(configHome); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + const harness = createProfileWatchHarness(); + let disk: ShellProfileState = { name: "old", source: "local" }; + let reads = 0; + let resolutions = 0; + let changes = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => { resolutions++; return { root, commonDir }; }, + read: () => { reads++; return disk; }, + onChange: () => { changes++; }, + watch: harness.watchProfile, profileRefreshClock: harness.profileRefreshClock, + }); + const localDir = join(commonDir, "gentle-ai"); + const uuid = "12345678-1234-1234-1234-123456789abc"; + try { + for (const name of [".unrelated." + uuid + ".tmp", ".profile-pin.json.bad.tmp", ".profiles.json." + uuid + ".tmp"]) harness.emit(localDir, name); + assert.equal(harness.pendingTimers(), 0, "unrelated temp names do not schedule reads"); + harness.emit(localDir, `.profile-pin.json.${uuid}.tmp`); + harness.advance(100); + assert.equal(snapshot.get()?.name, "old", "event can precede replacement"); + disk = { name: "new pin", source: "local" }; + harness.advance(1000); + assert.deepEqual(snapshot.get(), disk, "bounded follow-up observes a late rename"); + assert.equal(harness.pendingTimers(), 0); + harness.emit(configHome, `.profiles.json.${uuid}.tmp`); + disk = { name: "new global", source: "global" }; + harness.advance(1000); + assert.deepEqual(snapshot.get(), disk); + assert.equal(changes, 2); + assert.equal(resolutions, 1); + assert.equal(harness.pendingTimers(), 0); + assert.ok(reads <= 7, "atomic retries remain bounded"); + harness.emit(localDir, `.profile-pin.json.${uuid}.tmp`); + assert.ok(harness.pendingTimers() > 0); + snapshot.dispose(); + assert.equal(harness.pendingTimers(), 0, "disposal cancels delayed work"); + } finally { snapshot.dispose(); } +}); + +test("a direct or null event that observes the replacement cancels stale atomic retries", (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-atomic-target-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + mkdirSync(configHome); + const harness = createProfileWatchHarness(); + let disk = "old"; + let reads = 0; + let resolutions = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => { resolutions++; throw new Error("not Git"); }, + read: () => { reads++; return { name: disk, source: "global" }; }, + onChange() {}, watch: harness.watchProfile, profileRefreshClock: harness.profileRefreshClock, + }); + try { + for (const event of ["profiles.json", null]) { + harness.emit(configHome, ".profiles.json.12345678-1234-1234-1234-123456789abc.tmp"); + harness.advance(100); + assert.equal(harness.pendingTimers(), 1, "unchanged first read schedules one retry"); + disk = event === null ? "after null" : "after target"; + harness.emit(configHome, event); + harness.advance(100); + assert.equal(snapshot.get()?.name, disk); + assert.equal(harness.pendingTimers(), 0, "observed change cancels stale retry"); + const settledReads = reads; + harness.advance(2000); + assert.equal(reads, settledReads, "no stale retry reads after the direct refresh"); + } + assert.equal(resolutions, 1); + } finally { snapshot.dispose(); } +}); + +for (const interveningEvent of ["profiles.json", null] as const) { + test(`atomic retry survives a pre-debounce ${interveningEvent === null ? "null" : "direct-target"} event`, (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-atomic-coalesced-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + mkdirSync(configHome); + const harness = createProfileWatchHarness(); + let disk = "old"; + let reads = 0; + let resolutions = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => { resolutions++; throw new Error("not Git"); }, + read: () => { reads++; return { name: disk, source: "global" }; }, + onChange() {}, watch: harness.watchProfile, profileRefreshClock: harness.profileRefreshClock, + }); + try { + harness.emit(configHome, ".profiles.json.12345678-1234-1234-1234-123456789abc.tmp"); + harness.advance(50); + harness.emit(configHome, interveningEvent); + harness.advance(100); + assert.equal(snapshot.get()?.name, "old", "the first read precedes the delayed rename"); + assert.equal(harness.pendingTimers(), 1, "coalescing preserves the atomic retry"); + disk = "new after rename"; + harness.advance(250); + assert.equal(snapshot.get()?.name, disk, "the retry observes the replacement without another event"); + assert.equal(harness.pendingTimers(), 0); + const settledReads = reads; + harness.advance(2000); + assert.equal(reads, settledReads, "the completed change leaves no retry sequence"); + assert.equal(resolutions, 1, "profile refreshes reuse cached worktree identity"); + } finally { snapshot.dispose(); } + }); +} + +for (const interveningEvent of ["profiles.json", null] as const) { + test(`atomic retry timer stays singular through ${interveningEvent === null ? "null" : "direct-target"} refreshes`, (t) => { + const uuid = "12345678-1234-1234-1234-123456789abc"; + const createScenario = () => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-retry-timer-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + mkdirSync(configHome); + const harness = createProfileWatchHarness(); + let disk = "old"; + let reads = 0; + let resolutions = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => { resolutions++; throw new Error("not Git"); }, + read: () => { reads++; return { name: disk, source: "global" }; }, + onChange() {}, watch: harness.watchProfile, profileRefreshClock: harness.profileRefreshClock, + }); + return { + harness, snapshot, + setDisk: (name: string) => { disk = name; }, + reads: () => reads, + resolutions: () => resolutions, + }; + }; + const startRetryWithUnchangedEvent = (scenario: ReturnType) => { + scenario.harness.emit(scenario.harness.watchers[0]!.directory, `.profiles.json.${uuid}.tmp`); + scenario.harness.advance(100); + scenario.harness.emit(scenario.harness.watchers[0]!.directory, interveningEvent); + scenario.harness.advance(100); + }; + + const changed = createScenario(); + startRetryWithUnchangedEvent(changed); + assert.equal(changed.harness.pendingTimers(), 1, "an unchanged event must not overwrite the active retry handle"); + changed.setDisk("changed"); + assert.equal(changed.snapshot.refresh(), true, "an explicit refresh observes the later change"); + assert.equal(changed.harness.pendingTimers(), 0, "observing a change cancels the sole retry timer"); + const changedReads = changed.reads(); + changed.harness.advance(2000); + assert.equal(changed.reads(), changedReads, "no stale timer reads after the change"); + assert.equal(changed.resolutions(), 1); + changed.snapshot.dispose(); + + const disposed = createScenario(); + startRetryWithUnchangedEvent(disposed); + assert.equal(disposed.harness.pendingTimers(), 1); + disposed.snapshot.dispose(); + assert.equal(disposed.harness.pendingTimers(), 0, "disposal cancels every outstanding retry"); + const disposedReads = disposed.reads(); + disposed.harness.advance(2000); + assert.equal(disposed.reads(), disposedReads, "disposed snapshots never read from orphaned timers"); + + const exhausted = createScenario(); + exhausted.harness.emit(exhausted.harness.watchers[0]!.directory, `.profiles.json.${uuid}.tmp`); + exhausted.harness.advance(100); + exhausted.harness.emit(exhausted.harness.watchers[0]!.directory, interveningEvent); + exhausted.harness.advance(100); + assert.equal(exhausted.harness.pendingTimers(), 1); + for (const delay of [250, 500]) { + exhausted.harness.advance(delay); + assert.equal(exhausted.harness.pendingTimers(), 1, "each retry schedules only its single successor"); + } + exhausted.harness.advance(1000); + assert.equal(exhausted.harness.pendingTimers(), 0, "retry exhaustion leaves no timer handle or callback"); + assert.ok(exhausted.reads() <= 6, "coalesced retries remain bounded"); + exhausted.setDisk("after exhaustion"); + exhausted.harness.emit(exhausted.harness.watchers[0]!.directory, "profiles.json"); + exhausted.harness.advance(100); + assert.equal(exhausted.snapshot.get()?.name, "after exhaustion"); + const exhaustedReads = exhausted.reads(); + exhausted.harness.advance(2000); + assert.equal(exhausted.reads(), exhaustedReads, "exhaustion does not leave a duplicate retry"); + exhausted.snapshot.dispose(); + }); +} + +test("a new atomic temp filename gets a fresh bounded retry window near exhaustion", (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-new-atomic-write-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const configHome = join(root, "config"); + mkdirSync(configHome); + const harness = createProfileWatchHarness(); + let disk = "old"; + let reads = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, env: { GENTLE_PI_CONFIG_HOME: configHome }, + resolveWorktree: () => undefined, + read: () => { reads++; return { name: disk, source: "global" }; }, + onChange() {}, watch: harness.watchProfile, profileRefreshClock: harness.profileRefreshClock, + }); + const firstTemp = ".profiles.json.11111111-1111-1111-1111-111111111111.tmp"; + const secondTemp = ".profiles.json.22222222-2222-2222-2222-222222222222.tmp"; + try { + harness.emit(configHome, firstTemp); + harness.emit(configHome, firstTemp); + assert.equal(harness.pendingTimers(), 1, "same-write events coalesce into one debounce"); + harness.advance(100); + harness.advance(250); + harness.advance(500); + harness.advance(950); + assert.equal(harness.pendingTimers(), 1, "the first write has one final retry pending at t=1800"); + + harness.emit(configHome, secondTemp); + assert.equal(harness.pendingTimers(), 1, "the new write replaces the old retry with one debounce"); + harness.advance(100); + assert.equal(harness.pendingTimers(), 1, "the second write starts a fresh retry sequence"); + disk = "second write completed"; + harness.advance(250); + assert.equal(snapshot.get()?.name, disk, "the delayed second rename is observed without a final-name event"); + assert.equal(harness.pendingTimers(), 0, "observed change cancels the fresh sequence"); + const settledReads = reads; + harness.advance(2000); + assert.equal(reads, settledReads, "no retries remain after the profile changes"); + + harness.emit(configHome, ".profiles.json.33333333-3333-3333-3333-333333333333.tmp"); + harness.advance(100); + harness.emit(configHome, null); + harness.advance(100); + assert.equal(harness.pendingTimers(), 1, "a null event does not duplicate the active retry"); + snapshot.dispose(); + assert.equal(harness.pendingTimers(), 0, "disposal cancels the active retry after a null event"); + const disposedReads = reads; + harness.advance(2000); + assert.equal(reads, disposedReads, "no callback reads after disposal"); + assert.ok(reads <= 8, "the separate writes retain finite retry budgets"); + } finally { snapshot.dispose(); } +}); + +test("profile watcher errors close the failed watcher without retrying it", async (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-watch-error-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const profileWatch = createProfileWatchHarness(); + let reads = 0; + const snapshot = createEffectiveProfileSnapshot({ + cwd: () => root, + env: { GENTLE_PI_CONFIG_HOME: root }, + resolveWorktree: () => ({ root, commonDir: root }), + onChange() {}, + read: () => { reads++; return undefined; }, + watch: profileWatch.watchProfile, + profileRefreshClock: profileWatch.profileRefreshClock, + }); + const failedWatcher = profileWatch.watchers[0]!; + assert.doesNotThrow(() => failedWatcher.onError!(new Error("watched directory vanished"))); + assert.equal(failedWatcher.closeCalls, 1, "the failed watcher is closed immediately"); + assert.equal(profileWatch.pendingTimers(), 1, "watch errors schedule one debounced refresh"); + profileWatch.advance(100); + assert.equal(reads, 2, "the scheduled error refresh still runs"); + assert.equal(profileWatch.watchers.length, 1, "a persistent watcher failure does not create a retry loop"); + snapshot.dispose(); + assert.equal(failedWatcher.closeCalls, 1, "disposal does not close an already failed watcher again"); + assert.doesNotThrow(() => failedWatcher.onError!(new Error("late error after disposal"))); + assert.equal(profileWatch.pendingTimers(), 0, "late errors after disposal schedule no work"); +}); + test("profile reader follows store changes and rejects missing or invalid active markers", (t) => { const root = mkdtempSync(join(tmpdir(), "shell-profile-")); t.after(() => rmSync(root, { recursive: true, force: true })); const path = join(root, "profiles.json"); - const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root }); + const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root }, () => undefined); const save = (active: string | undefined) => writeFileSync(path, JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: { team: {}, other: {} }, })); assert.equal(read(), undefined); save("team"); - assert.equal(read(), "team"); - assert.equal(read(), "team"); + assert.deepEqual(read(), { name: "team", source: "global" }); + assert.deepEqual(read(), { name: "team", source: "global" }); save("other"); - assert.equal(read(), "other"); + assert.deepEqual(read(), { name: "other", source: "global" }); const replacement = join(root, "replacement.json"); writeFileSync(replacement, JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {} } })); renameSync(replacement, path); - assert.equal(read(), "team", "atomic replacement refreshes the cached profile"); + assert.deepEqual(read(), { name: "team", source: "global" }, "atomic replacement refreshes the cached profile"); const isolated = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: join(root, "other-home") }); assert.equal(isolated(), undefined); - assert.equal(read(), "team", "another shell's config home does not alter this cache"); + assert.deepEqual(read(), { name: "team", source: "global" }, "another shell's config home does not alter this cache"); save("missing"); assert.equal(read(), undefined); save(undefined); @@ -391,11 +1125,49 @@ test("profile reader follows store changes and rejects missing or invalid active writeFileSync(path, "{broken"); assert.equal(read(), undefined); save("team"); - assert.equal(read(), "team"); + assert.deepEqual(read(), { name: "team", source: "global" }); rmSync(path); assert.equal(read(), undefined); }); +test("profile reader shows the effective pin source and refreshes when pins change", (t) => { + const root = mkdtempSync(join(tmpdir(), "shell-profile-pin-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const worktreeRoot = join(root, "repo"); + const commonDir = join(root, "clone"); + const resolveWorktree = () => ({ root: worktreeRoot, commonDir }); + const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root }, resolveWorktree); + const profilesPath = join(root, "profiles.json"); + const save = (active: string) => writeFileSync(profilesPath, JSON.stringify({ + kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: { team: {}, other: {} }, + })); + const repoPin = repoProfileDeclarationPath(worktreeRoot); + const localPin = localProfilePinPath(commonDir); + mkdirSync(join(worktreeRoot, ".pi", "gentle-ai"), { recursive: true }); + mkdirSync(join(commonDir, "gentle-ai"), { recursive: true }); + + save("team"); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "global" }); + writeFileSync(repoPin, serializeProfilePin("other")); + assert.deepEqual(read(worktreeRoot), { name: "other", source: "repo" }); + writeFileSync(repoPin, serializeProfilePin("team")); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "repo" }, "changed pins invalidate the reader cache"); + writeFileSync(localPin, serializeProfilePin("team")); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "local" }, "same-profile local precedence changes the effective source"); + rmSync(localPin); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "repo" }, "removing the local pin restores the repository source"); + rmSync(repoPin); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "global" }, "removed pins fall back to the global active profile"); + writeFileSync(repoPin, serializeProfilePin("missing")); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "global" }, "stale pins fall back globally"); + writeFileSync(repoPin, "{broken"); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "global" }, "invalid pins fall back globally"); + writeFileSync(localPin, serializeProfilePin("other")); + assert.deepEqual(read(worktreeRoot), { name: "other", source: "local" }, "a valid local pin wins over the global profile"); + rmSync(localPin); + assert.deepEqual(read(worktreeRoot), { name: "team", source: "global" }); +}); + test("gentleShell stays out of the way without a UI or when disabled", () => { const disabled = fakePi(); gentleShell(disabled.pi, { GENTLE_PI_SHELL: "0" }); diff --git a/tests/shell-bar.test.ts b/tests/shell-bar.test.ts index d7c4f21f7..13f22aaf7 100644 --- a/tests/shell-bar.test.ts +++ b/tests/shell-bar.test.ts @@ -240,7 +240,7 @@ test("sidebar unifies project, captured changes and integrations in one frame", test("sidebar profile wraps long names without changing the compact bar", () => { const profile = "team-" + "x".repeat(59); const base = model(); - const active = model({ profile }); + const active = model({ profile: { name: profile, source: "global" } }); for (const width of [24, 46]) { const lines = renderShellSidebarBar(active, plainTheme, width); assert.ok(lines.every((line) => visibleWidth(line) <= width)); @@ -250,6 +250,15 @@ test("sidebar profile wraps long names without changing the compact bar", () => assert.deepEqual(renderShellBar(active, plainTheme, 120), renderShellBar(base, plainTheme, 120)); }); +test("sidebar formats and sanitizes the effective pin source suffix", () => { + const local = model({ profile: { name: "other\x1b[31m", source: "local" } }); + const sidebar = renderShellSidebarBar(local, plainTheme, 46).join("\n"); + assert.match(sidebar, /Profile.*other \(local\)/); + assert.doesNotMatch(sidebar, /\x1b\[/); + const [compact] = renderShellBar(local, plainTheme, 120); + assert.doesNotMatch(compact, /other|local/); +}); + test("sidebar Status card drops Model, Effort, Context, Cost and Usage, keeping Project, Changes and Integrations", () => { const usage = { provider: "openai-codex", @@ -257,7 +266,7 @@ test("sidebar Status card drops Model, Effort, Context, Cost and Usage, keeping fetchedAt: 0, limits: [{ name: "codex", limitReached: false, windows: [{ label: "5h", usedPercent: 62, windowSeconds: 18_000, resetAt: null }] }], }; - const data = model({ profile: "team", sessionName: "session", usage, changes: { files: 1, added: 2, deleted: 1 }, statuses: ["MCP connected"] }); + const data = model({ profile: { name: "team", source: "global" }, sessionName: "session", usage, changes: { files: 1, added: 2, deleted: 1 }, statuses: ["MCP connected"] }); const text = renderShellSidebarBar(data, plainTheme, 60).join("\n"); assert.doesNotMatch(text, /Usage/); assert.doesNotMatch(text, /Model/); @@ -278,14 +287,14 @@ test("sidebar Status card drops Model, Effort, Context, Cost and Usage, keeping // statuses — those stay in the prompt and the Status card. test("buildShellHeaderModel keeps only the header's fields from the bar model", () => { - const header = buildShellHeaderModel(model({ profile: "team", statuses: ["MCP: 3 servers"] })); + const header = buildShellHeaderModel(model({ profile: { name: "team", source: "global" }, statuses: ["MCP: 3 servers"] })); assert.deepEqual(header, { cwd: "~/work/gentle-pi", branch: "main", dirty: undefined, modelId: "gpt-5.5", effort: "medium", - profile: "team", + profile: { name: "team", source: "global" }, contextPercent: 45, costTotal: 9.49, subscription: true, @@ -295,7 +304,7 @@ test("buildShellHeaderModel keeps only the header's fields from the bar model", }); test("renderShellHeaderBar draws the brand, identity, and right-aligned counters (plus the standing usage segment) in one line", () => { - const header = buildShellHeaderModel(model({ profile: "team" })); + const header = buildShellHeaderModel(model({ profile: { name: "team", source: "global" } })); const { text: line } = renderShellHeaderBar(header, plainTheme, 120); const left = "✿ Gentle Shell ⟡ ~/work/gentle-pi main ⟡ gpt-5.5 · medium · team"; const right = "ctx ▰▰▰▰▱▱▱▱ 45% ⟡ $9.49 sub ⟡ usage"; @@ -318,7 +327,7 @@ test("renderShellHeaderBar colors the brand bold and by role", () => { }); test("renderShellHeaderBar drops the profile, then the effort, then the whole location before the right group", () => { - const withProfile = buildShellHeaderModel(model({ profile: "team" })); + const withProfile = buildShellHeaderModel(model({ profile: { name: "team", source: "global" } })); const { text: wide } = renderShellHeaderBar(withProfile, plainTheme, 120); assert.match(wide, /gpt-5\.5 · medium · team/); assert.match(wide, /~\/work\/gentle-pi main/);