From a02da243e759257e585fb63cf70688bffe834a41 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 20 Aug 2026 06:36:43 +0200 Subject: [PATCH] feat(agent): implemented preview titles and session compaction logic - Added preview title support and fallback handling across shape previews and status line segments. - Refined session list sizing and line height budgets in the session selector component. - Extracted session compaction method helpers and improved speculative compaction logic. - Prevented armed LLM speculation results from overriding local snapshot compaction execution. --- packages/coding-agent/CHANGELOG.md | 2 +- .../components/composer-shape-preview.ts | 22 +++-- .../src/modes/components/session-selector.ts | 97 ++++++++++++------- .../src/modes/components/settings-selector.ts | 1 - .../modes/components/status-line/component.ts | 55 ++++++++--- .../modes/components/status-line/segments.ts | 2 +- .../src/modes/components/status-line/types.ts | 2 + .../controllers/extension-ui-controller.ts | 2 +- .../src/modes/setup-wizard/scenes/composer.ts | 2 +- .../src/modes/utils/context-usage.ts | 21 ++-- .../src/session/compaction-methods.ts | 62 ++++++++++++ .../src/session/session-maintenance.ts | 60 ++---------- 12 files changed, 209 insertions(+), 119 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 63cb9d52a..49209fb0c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,7 +21,7 @@ - Revamped the todo HUD — overall progress renders along the tree-spine connector with smooth completion transitions. - `/handoff` (and automatic handoff compaction) now compacts in place, replacing the session context instead of forking a new session. -- Replaced `compaction.strategy`/`compaction.remoteEnabled` with the ordered `compaction.methodOrder` fallback list. +- Compaction method priorities — `compaction.methodOrder` takes an ordered preference list (e.g. `[remote, snap]` uses remote compaction where the provider supports it, such as OpenAI, and snap everywhere else), replacing `compaction.strategy`/`compaction.remoteEnabled`. - Unified inline overlays and selectors (model picker, settings, `/cleanse`) into one titled rounded-box panel style. - Risk badges and warnings on `/settings` rows, starting with External Thinking. diff --git a/packages/coding-agent/src/modes/components/composer-shape-preview.ts b/packages/coding-agent/src/modes/components/composer-shape-preview.ts index 117024b87..87c876abf 100644 --- a/packages/coding-agent/src/modes/components/composer-shape-preview.ts +++ b/packages/coding-agent/src/modes/components/composer-shape-preview.ts @@ -3,8 +3,9 @@ * scene. Chrome is rendered through the same {@link ComposerStyle} objects the * real editor uses, and status rows come from the live * {@link ComposerPreviewStatusSource} (the session's StatusLineComponent) — - * nothing about the preview is a re-implementation, so it cannot drift from - * the real composer. Only the prompt text is a stand-in. + * nothing about the preview is a re-implementation. Prompt text is a preview + * stand-in, and the `session_name` segment falls back to a stand-in title + * (passed via `previewTitle`) when the session is unnamed. */ import { type Component, @@ -25,11 +26,11 @@ import { theme } from "../theme/theme"; */ export interface ComposerPreviewStatusSource { /** Powerline bar with the context gauge (box top border content). */ - getTopBorder(width: number): { content: string; width: number }; + getTopBorder(width: number, previewTitle?: string): { content: string; width: number }; /** Plain right-group chip (claude top rule content). */ - getStandaloneTopBorder(width: number): { content: string; width: number }; + getStandaloneTopBorder(width: number, previewTitle?: string): { content: string; width: number }; /** Plain standalone bottom bar carrying the given segment groups. */ - renderBottomBar(width: number, groups: "left" | "full"): string; + renderBottomBar(width: number, groups: "left" | "full", previewTitle?: string): string; } export interface ComposerShapePreviewOptions { @@ -37,13 +38,15 @@ export interface ComposerShapePreviewOptions { /** Live status renderer; omitted (tests), the chrome renders without status rows. */ status?: ComposerPreviewStatusSource; } +/** Stand-in session title shown while the previewed session is unnamed. */ +const PREVIEW_TITLE = "omp"; export function renderComposerShapePreview( shape: ComposerShape, width: number, status?: ComposerPreviewStatusSource, ): readonly string[] { - const previewWidth = Math.max(24, Math.min(width, 76)); + const previewWidth = Math.max(24, Math.min(width, 96)); const style = getComposerStyle(shape); const paddingX = style.defaultPaddingX(undefined); const chromeWidth = style.sideChromeWidth(paddingX); @@ -51,10 +54,9 @@ export function renderComposerShapePreview( let topBorder: EditorTopBorder | undefined; if (status) { if (style.statusAttachment === "top-border") { - const availableWidth = Math.max(1, previewWidth - chromeWidth * 2); - topBorder = status.getTopBorder(availableWidth); + topBorder = status.getTopBorder(Math.max(1, previewWidth - chromeWidth * 2), PREVIEW_TITLE); } else if (style.statusAttachment === "top-rule-chip") { - topBorder = status.getStandaloneTopBorder(previewWidth); + topBorder = status.getStandaloneTopBorder(previewWidth, PREVIEW_TITLE); } } @@ -94,7 +96,7 @@ export function renderComposerShapePreview( if (bottom !== undefined) lines.push(bottom); if (style.bottomBar !== "none" && status) { - const bar = status.renderBottomBar(previewWidth, style.bottomBar); + const bar = status.renderBottomBar(previewWidth, style.bottomBar, PREVIEW_TITLE); if (bar) { if (style.bottomBarGap) lines.push(""); lines.push(bar); diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index 15f4d81de..dc7ab27e9 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -329,23 +329,26 @@ class SessionList implements Component { } /** - * Number of sessions to show at once, sized so the whole picker fits the - * current viewport instead of pushing its header/search off the top. + * Session-row line budget for one render, sized so the whole picker fits + * the current viewport instead of pushing its header/search off the top. * - * Budget = rows − chrome − reserve, divided by the worst-case per-session - * height. Chrome (11) is the rounded panel and surrounding spacers/footer - * (6) plus the list's search line, blank, scroll indicator, blank, and hint - * (5). A titled session is the tallest item at 4 lines (title + preview + - * metadata + blank); budgeting for that guarantees no overflow even when - * every visible entry has a title. The reserve covers below-editor hook - * widgets / cursor. + * Chrome (7) is the panel's top border, one spacer, the list's search line + * and its blank, and the pinned footer minus its leading blank (hint, + * blank, bottom border) — the last visible session's separator blank is + * never rendered, so the footer's own blank stands in for it. The reserve + * covers below-editor hook widgets / cursor. The floor of 8 always admits + * two titled sessions (the tallest item at 4 lines: title + preview + + * metadata + separator). */ - #visibleCount(): number { - const CHROME = 11; - const PER_SESSION = 4; + #lineBudget(): number { + const CHROME = 7; const RESERVE = 1; - const budget = this.#getTerminalRows() - CHROME - RESERVE; - return Math.max(2, Math.floor(budget / PER_SESSION)); + return Math.max(8, this.#getTerminalRows() - CHROME - RESERVE); + } + + /** PageUp/PageDown jump, approximated from the worst-case session height. */ + #pageSize(): number { + return Math.max(2, Math.floor(this.#lineBudget() / 4)); } /** Replace the visible dataset, e.g. when toggling folder/all-projects scope. */ @@ -556,21 +559,36 @@ class SessionList implements Component { return date.toLocaleDateString(); }; - // Calculate visible range with scrolling. The window is sized to the - // current viewport so the picker never overflows past the top. - const maxVisible = this.#visibleCount(); - const startIndex = Math.max( - 0, - Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), this.#filteredSessions.length - maxVisible), - ); - const endIndex = Math.min(startIndex + maxVisible, this.#filteredSessions.length); + // Pack the window around the selection by actual line height (3 lines + // per session, 4 when a title adds a preview line) until the viewport + // budget is spent, so short sessions never strand blank rows a + // worst-case count-based window would leave (then padded by + // fill-height). + const filtered = this.#filteredSessions; + const itemHeight = (session: SessionInfo): number => (session.title ? 4 : 3); + const budget = this.#lineBudget(); + let startIndex = this.#selectedIndex; + let endIndex = this.#selectedIndex + 1; + let used = itemHeight(filtered[this.#selectedIndex]!); + // Alternate growth below/above the selection to keep it roughly centered. + for (let preferDown = true; ; preferDown = !preferDown) { + const canDown = endIndex < filtered.length && used + itemHeight(filtered[endIndex]!) <= budget; + const canUp = startIndex > 0 && used + itemHeight(filtered[startIndex - 1]!) <= budget; + if (!canDown && !canUp) break; + if (canDown && (preferDown || !canUp)) { + used += itemHeight(filtered[endIndex]!); + endIndex++; + } else { + startIndex--; + used += itemHeight(filtered[startIndex]!); + } + } - // Render visible sessions (3 lines, or 4 when a title adds a preview line). // Each session block is built into sessionLines, then wrapped by ScrollView // so the right-edge scrollbar is proportional at the physical-line level. const sessionLines: string[] = []; const sessionRowIndex: number[] = []; - const overflow = this.#filteredSessions.length > maxVisible; + const overflow = startIndex > 0 || endIndex < filtered.length; const rowWidth = Math.max(0, width - (overflow ? 1 : 0)); for (let i = startIndex; i < endIndex; i++) { const blockStart = sessionLines.length; @@ -622,20 +640,31 @@ class SessionList implements Component { const metadataLine = truncateToWidth(metadata, rowWidth); sessionLines.push(metadataLine); - sessionLines.push(""); // Blank line between sessions + // Blank separator between sessions; the last block ends flush against + // the footer, whose leading blank provides the same gap. + if (i < endIndex - 1) sessionLines.push(""); for (let k = blockStart; k < sessionLines.length; k++) sessionRowIndex[k] = i; } - // Wrap the rendered window in a ScrollView for a proportional right-edge bar. - const visibleCount = endIndex - startIndex; - const linesPerItem = visibleCount > 0 ? sessionLines.length / visibleCount : 1; + // Wrap the rendered window in a ScrollView for a proportional right-edge + // bar, with exact physical-line totals from the per-session heights. + let totalRows = 0; + let offsetRows = 0; + for (let i = 0; i < filtered.length; i++) { + if (i === startIndex) offsetRows = totalRows; + totalRows += itemHeight(filtered[i]!); + } + // The last session's separator blank is never rendered (see the block + // loop above), so exclude it or a fully visible list would still show a + // scrollbar. + totalRows -= 1; const sv = new ScrollView(sessionLines, { height: sessionLines.length, scrollbar: "auto", - totalRows: Math.round(this.#filteredSessions.length * linesPerItem), + totalRows, theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) }, }); - sv.setScrollOffset(Math.round(startIndex * linesPerItem)); + sv.setScrollOffset(offsetRows); const sessionRegionStart = lines.length; const svLines = sv.render(width); for (let k = 0; k < svLines.length; k++) this.#hitRows[sessionRegionStart + k] = sessionRowIndex[k]; @@ -677,13 +706,13 @@ class SessionList implements Component { // Page up - jump up by maxVisible items if (matchesKey(keyData, "pageUp")) { this.#selectionMoved = true; - this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#visibleCount()); + this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#pageSize()); return; } // Page down - jump down by maxVisible items if (matchesKey(keyData, "pageDown")) { this.#selectionMoved = true; - this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + this.#visibleCount()); + this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + this.#pageSize()); return; } // Enter @@ -799,11 +828,9 @@ export class SessionSelectorComponent extends OverlayPanel { this.#title = options.title ?? "Resume Session"; this.#scopeLabel = options.scopeLabel; this.title = this.#headerLabel(); - // Keep the existing breathing room; OverlayPanel supplies the two outer + // One spacer of breathing room; OverlayPanel supplies the two outer // border rows and the horizontal inset. this.addChild(new Spacer(1)); - this.addChild(new Spacer(1)); - this.addChild(new Spacer(1)); this.addChild(this.#messageContainer); // Create session list in folder scope; the empty-state hint invites the // user to Tab into all-projects rather than silently surfacing other diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index fd865b7b8..207dcb88f 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -35,7 +35,6 @@ import { validateProviderMaxInFlightRequests, } from "../../config/settings"; import type { - ComposerShape, ContextLineMode, SettingTab, StatusLinePreset, diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index 18c36911d..4ecce56c3 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -9,7 +9,7 @@ import { truncateToWidth, visibleWidth, } from "@oh-my-pi/pi-tui"; -import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils"; +import { adjustHsv, formatNumber, getProjectDir } from "@oh-my-pi/pi-utils"; import { settings } from "../../../config/settings"; import type { AgentSession } from "../../../session/agent-session"; import type { OAuthAccountIdentity } from "../../../session/auth-storage"; @@ -1601,6 +1601,7 @@ export class StatusLineComponent implements Component { includePath: boolean, includeGit: boolean, includePr: boolean, + previewTitle?: string, ): SegmentContext { const state = this.session.state; @@ -1668,6 +1669,7 @@ export class StatusLineComponent implements Component { session: this.session, focusedAgentId: this.#focusedAgentId, sessionAccent: this.#resolveSettings().sessionAccent !== false, + previewTitle, activeRepo: activeRepoCache.activeRepo, width, options: segmentOptions ?? {}, @@ -1756,8 +1758,15 @@ export class StatusLineComponent implements Component { * - `plain-left`: left segments only (claude composer; the right group * lives in the editor's top rule). * - `plain-right`: right segments only (claude composer's top rule). + * + * `previewTitle` is a stand-in session title for composer previews; the + * `session_name` segment renders it when the session is unnamed. */ - #buildStatusLine(width: number, layout: "box" | "plain-full" | "plain-left" | "plain-right" = "box"): string { + #buildStatusLine( + width: number, + layout: "box" | "plain-full" | "plain-left" | "plain-right" = "box", + previewTitle?: string, + ): string { const effectiveSettings = this.#resolveSettings(); const plain = layout !== "box"; const includePath = @@ -1774,6 +1783,7 @@ export class StatusLineComponent implements Component { includePath, includeGit, includePr, + previewTitle, ); const separatorDef = plain ? { left: "·", right: "·" } @@ -1866,6 +1876,22 @@ export class StatusLineComponent implements Component { const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0); if (topFillWidth > 0) { + // Truncate the session-name segment before dropping right segments — + // the title is the only elastic one on the right, and dropping it + // wholesale left narrow bars (and the ≤76-col composer previews) + // without any title. + const nameSegIdx = rightSegIds.indexOf("session_name"); + if (nameSegIdx >= 0 && totalWidth() > topFillWidth) { + // Badge/job parts were unshifted ahead of the tracked segment ids. + const nameIdx = nameSegIdx + (right.length - rightSegIds.length); + const currentNameVW = visibleWidth(right[nameIdx]); + const minNameVW = 8; + const shrinkBy = Math.min(Math.max(0, currentNameVW - minNameVW), totalWidth() - topFillWidth); + if (shrinkBy > 0) { + right[nameIdx] = truncateToWidth(right[nameIdx], currentNameVW - shrinkBy); + rightWidth = groupWidth(right, rightCapWidth, rightSepWidth); + } + } while (totalWidth() > topFillWidth && right.length > 0) { right.pop(); rightWidth = groupWidth(right, rightCapWidth, rightSepWidth); @@ -2021,7 +2047,9 @@ export class StatusLineComponent implements Component { const cellFor = (percent: number) => Math.min(scaleWidth - 1, Math.max(0, Math.round((percent / 100) * scaleWidth))); thresholdIdx = cellFor(boundaries.thresholdPercent); - speculationIdx = cellFor(boundaries.speculationPercent); + // null = no background speculation will run (async disabled or the + // first available method is local/instant) — no tick to show. + if (boundaries.speculationPercent !== null) speculationIdx = cellFor(boundaries.speculationPercent); if (speculationIdx === thresholdIdx) speculationIdx = -1; // threshold wins the cell } } @@ -2051,7 +2079,9 @@ export class StatusLineComponent implements Component { const speculationGlyph = theme.symbol("context.speculation"); const thresholdGlyph = theme.symbol("context.compaction"); const speculationColor = theme.getFgAnsi("muted"); - const thresholdColor = theme.getFgAnsi("warning"); + const rawAccentHex = accentHex ?? theme.getColorHex("borderAccent"); + const dimmedAccentHex = adjustHsv(rawAccentHex, { s: 0.7, v: 0.75 }); + const thresholdColor = getSessionAccentAnsi(dimmedAccentHex) ?? usedColor; let out = "\x1b[49m"; let activeColor = ""; @@ -2085,15 +2115,18 @@ export class StatusLineComponent implements Component { // Collab-guest replicas and test mocks have no session-scoped settings; // the global store carries the same compaction knobs. const source = typeof this.session.settings?.getGroup === "function" ? this.session.settings : settings; + // The active model gates which compaction method a real pass would run + // (and therefore whether a speculation tick is meaningful). + const model = this.session.state?.model ?? this.session.model; try { - return computeCompactionBoundaries(source, contextWindow); + return computeCompactionBoundaries(source, contextWindow, model); } catch { return null; } } - getTopBorder(width: number): { content: string; width: number; revision: number } { - let content = this.#buildStatusLine(width); + getTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } { + let content = this.#buildStatusLine(width, "box", previewTitle); if (this.#focusedAgentId && content) { // Dim the whole bar while focus-proxied. Group/cap terminators emit full // `\x1b[0m` resets that would cancel faint mid-bar, so re-open it after each. @@ -2125,8 +2158,8 @@ export class StatusLineComponent implements Component { } /** Plain right-group content for the claude composer's top rule. */ - getStandaloneTopBorder(width: number): { content: string; width: number; revision: number } { - let content = this.#buildStatusLine(width, "plain-right"); + getStandaloneTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } { + let content = this.#buildStatusLine(width, "plain-right", previewTitle); if (this.#focusedAgentId && content) { content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`; } @@ -2143,8 +2176,8 @@ export class StatusLineComponent implements Component { * loop and by composer previews (which inject a candidate layout instead of * the active one). */ - renderBottomBar(width: number, groups: "left" | "full"): string { - let content = this.#buildStatusLine(width, groups === "left" ? "plain-left" : "plain-full"); + renderBottomBar(width: number, groups: "left" | "full", previewTitle?: string): string { + let content = this.#buildStatusLine(width, groups === "left" ? "plain-left" : "plain-full", previewTitle); if (this.#focusedAgentId && content) { content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`; } diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 32b91419a..77619d584 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -631,7 +631,7 @@ const sessionNameSegment: StatusLineSegment = { id: "session_name", render(ctx) { const sessionManager = ctx.session.sessionManager; - const name = sessionManager?.getSessionName(); + const name = sessionManager?.getSessionName() || ctx.previewTitle; if (!name) return { content: "", visible: false }; const accentEnabled = ctx.sessionAccent !== false; diff --git a/packages/coding-agent/src/modes/components/status-line/types.ts b/packages/coding-agent/src/modes/components/status-line/types.ts index d24894adc..e42c81c91 100644 --- a/packages/coding-agent/src/modes/components/status-line/types.ts +++ b/packages/coding-agent/src/modes/components/status-line/types.ts @@ -63,6 +63,8 @@ export interface SegmentContext { focusedAgentId?: string | undefined; /** Effective `statusLine.sessionAccent`; `false` disables hash-derived accent colors, while `true` or omission enables them. */ sessionAccent?: boolean; + /** Stand-in session title for previews; `session_name` renders it when the session is unnamed. */ + previewTitle?: string; activeRepo: ActiveRepoContext | null; width: number; options: StatusLineSegmentOptions; diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 9b3be9da7..fa23f2222 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -22,8 +22,8 @@ import type { TerminalInputHandler, } from "../../extensibility/extensions"; import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler"; -import { installExtensionComposerShape } from "../../modes/components/composer-shape-registry"; import { AskDialogComponent, boundPromptTitle } from "../../modes/components/ask-dialog"; +import { installExtensionComposerShape } from "../../modes/components/composer-shape-registry"; import { HookEditorComponent } from "../../modes/components/hook-editor"; import { HookInputComponent } from "../../modes/components/hook-input"; import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/components/hook-selector"; diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts index 89fe3a725..f34f3fbbd 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts @@ -1,7 +1,7 @@ import { routeSelectListMouse, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; import type { ComposerShape } from "../../../config/settings-schema"; -import { getComposerShapeOptions } from "../../components/composer-shape-registry"; import { renderComposerShapePreview } from "../../components/composer-shape-preview"; +import { getComposerShapeOptions } from "../../components/composer-shape-registry"; import { getSelectListTheme, theme } from "../../theme/theme"; import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types"; diff --git a/packages/coding-agent/src/modes/utils/context-usage.ts b/packages/coding-agent/src/modes/utils/context-usage.ts index d46b9a894..cd4db2624 100644 --- a/packages/coding-agent/src/modes/utils/context-usage.ts +++ b/packages/coding-agent/src/modes/utils/context-usage.ts @@ -6,6 +6,7 @@ import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; import { formatNumber } from "@oh-my-pi/pi-utils"; import type { Skill } from "../../extensibility/skills"; import type { AgentSession } from "../../session/agent-session"; +import { resolveSpeculationMethod } from "../../session/compaction-methods"; import { estimateInlineSavings, type SnapcompactSavingsEstimate } from "../../session/snapcompact-inline"; import { resolveSpeculationLeadTokens } from "../../session/speculation-lead"; import type { Tool } from "../../tools"; @@ -46,28 +47,36 @@ export interface ContextBreakdown { export interface CompactionBoundaries { /** Where auto-compaction fires. */ thresholdPercent: number; - /** Where the background speculative summarizer starts (threshold − lead). */ - speculationPercent: number; + /** + * Where the background speculative summarizer starts (threshold − lead), or + * `null` when no speculation will run (async compaction disabled, or the + * first available method is local — snapcompact/shake — and thus instant). + */ + speculationPercent: number | null; } /** * Boundary positions for the status line's annotated context gauge. `null` * when compaction is disabled/off or the window is unknown — the gauge then - * renders without markers. + * renders without markers. `model` resolves which configured method a real + * pass would run; without it, model-gated methods count as unavailable. */ export function computeCompactionBoundaries( settings: AgentSession["settings"], contextWindow: number, + model?: Model | null, ): CompactionBoundaries | null { if (!(contextWindow > 0)) return null; - const compactionSettings = settings.getGroup("compaction") as CompactionSettings; - if (!compactionSettings.enabled || compactionSettings.strategy === "off") return null; + const configured = settings.getGroup("compaction"); + const compactionSettings = configured as CompactionSettings; + if (!configured.enabled || compactionSettings.strategy === "off") return null; const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings); if (!(thresholdTokens > 0) || thresholdTokens > contextWindow) return null; + const speculates = configured.asyncEnabled !== false && resolveSpeculationMethod(model, configured) !== undefined; const leadTokens = resolveSpeculationLeadTokens(thresholdTokens); return { thresholdPercent: (thresholdTokens / contextWindow) * 100, - speculationPercent: (Math.max(0, thresholdTokens - leadTokens) / contextWindow) * 100, + speculationPercent: speculates ? (Math.max(0, thresholdTokens - leadTokens) / contextWindow) * 100 : null, }; } diff --git a/packages/coding-agent/src/session/compaction-methods.ts b/packages/coding-agent/src/session/compaction-methods.ts index d220c9b4c..466fada04 100644 --- a/packages/coding-agent/src/session/compaction-methods.ts +++ b/packages/coding-agent/src/session/compaction-methods.ts @@ -1,5 +1,12 @@ /** Ordered automatic context-maintenance methods and their settings metadata. */ +import { + type CompactionSettings as EngineCompactionSettings, + shouldUseProviderNativeCompaction, +} from "@oh-my-pi/pi-agent-core/compaction"; +import type { Model } from "@oh-my-pi/pi-ai"; +import type { CompactionSettings } from "../config/settings-schema"; + /** Choices presented by the ordered compaction-method setting. */ export const COMPACTION_METHOD_CHOICES = [ { @@ -67,3 +74,58 @@ export function resolveCompactionMethodOrder(value: unknown): CompactionMethod[] } return methods; } + +const STRATEGY_BY_COMPACTION_METHOD: Record = { + remote: "context-full", + snapcompact: "snapcompact", + handoff: "handoff", + soft: "context-full", + shake: "shake", +}; + +/** + * Convert the selected preference into the engine's compact operation flags. + * The engine intentionally remains usable by SDK consumers that do not expose + * the coding agent's preference list. + */ +export function resolveMethodSettings( + settings: CompactionSettings, + method: CompactionMethod, +): EngineCompactionSettings { + return { + ...settings, + strategy: STRATEGY_BY_COMPACTION_METHOD[method], + remoteEnabled: method === "remote", + }; +} + +/** Whether server compaction has either a configured endpoint or an active native route. */ +export function canUseRemoteCompaction(model: Model | null | undefined, settings: EngineCompactionSettings): boolean { + return ( + (typeof settings.remoteEndpoint === "string" && settings.remoteEndpoint.length > 0) || + (model !== null && model !== undefined && shouldUseProviderNativeCompaction(model, settings)) + ); +} + +/** + * First configured method a threshold pass would run, or undefined when it is + * local (snapcompact/shake) — local methods are effectively instant, so there + * is nothing to speculate. Shared by the maintenance loop's speculation gate + * and the status line's annotated context gauge (speculation marker). + */ +export function resolveSpeculationMethod( + model: Model | null | undefined, + settings: CompactionSettings, +): "remote" | "handoff" | "soft" | undefined { + for (const candidate of resolveCompactionMethodOrder(settings.methodOrder)) { + const available = + candidate === "remote" + ? canUseRemoteCompaction(model, resolveMethodSettings(settings, candidate)) + : candidate === "snapcompact" + ? model?.input?.includes("image") === true + : true; + if (!available) continue; + return candidate === "remote" || candidate === "handoff" || candidate === "soft" ? candidate : undefined; + } + return undefined; +} diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index 6dee380ff..6cb1e1c24 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -70,8 +70,11 @@ import type { ContextUsageBreakdown, HandoffResult, SessionHandoffOptions } from import { findCompactMode } from "./compact-modes"; import { type CompactionMethod, + canUseRemoteCompaction, DEFAULT_COMPACTION_METHOD_ORDER, resolveCompactionMethodOrder, + resolveMethodSettings, + resolveSpeculationMethod, } from "./compaction-methods"; import { convertToLlm, stripImagesFromMessage } from "./messages"; import { isTerminalTextAssistantAnswer } from "./queued-messages"; @@ -113,38 +116,6 @@ const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = { automaticContinuationBlocked: true, }; -const STRATEGY_BY_COMPACTION_METHOD: Record = { - remote: "context-full", - snapcompact: "snapcompact", - handoff: "handoff", - soft: "context-full", - shake: "shake", -}; - -/** - * Convert the selected preference into the engine's compact operation flags. - * The engine intentionally remains usable by SDK consumers that do not expose - * the coding agent's preference list. - */ -function resolveMethodSettings( - settings: ConfiguredCompactionSettings, - method: CompactionMethod, -): EngineCompactionSettings { - return { - ...settings, - strategy: STRATEGY_BY_COMPACTION_METHOD[method], - remoteEnabled: method === "remote", - }; -} - -/** Whether server compaction has either a configured endpoint or an active native route. */ -function canUseRemoteCompaction(model: Model | null | undefined, settings: EngineCompactionSettings): boolean { - return ( - (typeof settings.remoteEndpoint === "string" && settings.remoteEndpoint.length > 0) || - (model !== null && model !== undefined && shouldUseProviderNativeCompaction(model, settings)) - ); -} - /** Whether a configured preference list contains at least one automatic method. */ function hasConfiguredCompactionMethod(settings: ConfiguredCompactionSettings): boolean { return resolveCompactionMethodOrder(settings.methodOrder).length > 0; @@ -1169,7 +1140,7 @@ export class SessionMaintenance { } const model = this.#model; if (!model) return; - const method = this.#resolveSpeculationMethod(model, settings); + const method = resolveSpeculationMethod(model, settings); if (!method) return; const controller = new AbortController(); const run: SpeculationRun = { controller, promise: Promise.resolve(), contextTokensAtStart: contextTokens }; @@ -1183,24 +1154,6 @@ export class SessionMaintenance { }); } - /** First configured method a threshold pass would run, or undefined when it is local (nothing to speculate). */ - #resolveSpeculationMethod( - model: Model, - settings: ConfiguredCompactionSettings, - ): "remote" | "handoff" | "soft" | undefined { - for (const candidate of resolveCompactionMethodOrder(settings.methodOrder)) { - const available = - candidate === "remote" - ? canUseRemoteCompaction(model, resolveMethodSettings(settings, candidate)) - : candidate === "snapcompact" - ? model.input.includes("image") - : true; - if (!available) continue; - return candidate === "remote" || candidate === "handoff" || candidate === "soft" ? candidate : undefined; - } - return undefined; - } - /** Produce and arm one speculative compaction result off a branch snapshot. */ async #runSpeculation( run: SpeculationRun, @@ -2644,7 +2597,10 @@ export class SessionMaintenance { // in the background. Claiming consumes the slot either way: an in-flight // run is aborted (this real pass supersedes it) and an armed result is // returned only when still valid for the current branch/model/settings. - const armedSpec = this.#claimArmedSpeculation(); + // Snapcompact is local and instant, so an armed LLM summary (possible + // only when settings/model changed since arming) never overrides it. + const claimedSpec = this.#claimArmedSpeculation(); + const armedSpec = method === "snapcompact" ? undefined : claimedSpec; const effectiveSettings = resolveMethodSettings(compactionSettings, method); const fallbackFromShake = options.fallbackFromShake === true;