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.
This commit is contained in:
can1357
2026-08-20 06:36:43 +02:00
parent 43a557081f
commit a02da243e7
12 changed files with 209 additions and 119 deletions
+1 -1
View File
@@ -21,7 +21,7 @@
- Revamped the todo HUD — overall progress renders along the tree-spine connector with smooth completion transitions. - 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. - `/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. - 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. - Risk badges and warnings on `/settings` rows, starting with External Thinking.
@@ -3,8 +3,9 @@
* scene. Chrome is rendered through the same {@link ComposerStyle} objects the * scene. Chrome is rendered through the same {@link ComposerStyle} objects the
* real editor uses, and status rows come from the live * real editor uses, and status rows come from the live
* {@link ComposerPreviewStatusSource} (the session's StatusLineComponent) — * {@link ComposerPreviewStatusSource} (the session's StatusLineComponent) —
* nothing about the preview is a re-implementation, so it cannot drift from * nothing about the preview is a re-implementation. Prompt text is a preview
* the real composer. Only the prompt text is a stand-in. * stand-in, and the `session_name` segment falls back to a stand-in title
* (passed via `previewTitle`) when the session is unnamed.
*/ */
import { import {
type Component, type Component,
@@ -25,11 +26,11 @@ import { theme } from "../theme/theme";
*/ */
export interface ComposerPreviewStatusSource { export interface ComposerPreviewStatusSource {
/** Powerline bar with the context gauge (box top border content). */ /** 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). */ /** 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. */ /** 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 { export interface ComposerShapePreviewOptions {
@@ -37,13 +38,15 @@ export interface ComposerShapePreviewOptions {
/** Live status renderer; omitted (tests), the chrome renders without status rows. */ /** Live status renderer; omitted (tests), the chrome renders without status rows. */
status?: ComposerPreviewStatusSource; status?: ComposerPreviewStatusSource;
} }
/** Stand-in session title shown while the previewed session is unnamed. */
const PREVIEW_TITLE = "omp";
export function renderComposerShapePreview( export function renderComposerShapePreview(
shape: ComposerShape, shape: ComposerShape,
width: number, width: number,
status?: ComposerPreviewStatusSource, status?: ComposerPreviewStatusSource,
): readonly string[] { ): 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 style = getComposerStyle(shape);
const paddingX = style.defaultPaddingX(undefined); const paddingX = style.defaultPaddingX(undefined);
const chromeWidth = style.sideChromeWidth(paddingX); const chromeWidth = style.sideChromeWidth(paddingX);
@@ -51,10 +54,9 @@ export function renderComposerShapePreview(
let topBorder: EditorTopBorder | undefined; let topBorder: EditorTopBorder | undefined;
if (status) { if (status) {
if (style.statusAttachment === "top-border") { if (style.statusAttachment === "top-border") {
const availableWidth = Math.max(1, previewWidth - chromeWidth * 2); topBorder = status.getTopBorder(Math.max(1, previewWidth - chromeWidth * 2), PREVIEW_TITLE);
topBorder = status.getTopBorder(availableWidth);
} else if (style.statusAttachment === "top-rule-chip") { } 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 (bottom !== undefined) lines.push(bottom);
if (style.bottomBar !== "none" && status) { if (style.bottomBar !== "none" && status) {
const bar = status.renderBottomBar(previewWidth, style.bottomBar); const bar = status.renderBottomBar(previewWidth, style.bottomBar, PREVIEW_TITLE);
if (bar) { if (bar) {
if (style.bottomBarGap) lines.push(""); if (style.bottomBarGap) lines.push("");
lines.push(bar); lines.push(bar);
@@ -329,23 +329,26 @@ class SessionList implements Component {
} }
/** /**
* Number of sessions to show at once, sized so the whole picker fits the * Session-row line budget for one render, sized so the whole picker fits
* current viewport instead of pushing its header/search off the top. * the current viewport instead of pushing its header/search off the top.
* *
* Budget = rows − chrome − reserve, divided by the worst-case per-session * Chrome (7) is the panel's top border, one spacer, the list's search line
* height. Chrome (11) is the rounded panel and surrounding spacers/footer * and its blank, and the pinned footer minus its leading blank (hint,
* (6) plus the list's search line, blank, scroll indicator, blank, and hint * blank, bottom border) — the last visible session's separator blank is
* (5). A titled session is the tallest item at 4 lines (title + preview + * never rendered, so the footer's own blank stands in for it. The reserve
* metadata + blank); budgeting for that guarantees no overflow even when * covers below-editor hook widgets / cursor. The floor of 8 always admits
* every visible entry has a title. The reserve covers below-editor hook * two titled sessions (the tallest item at 4 lines: title + preview +
* widgets / cursor. * metadata + separator).
*/ */
#visibleCount(): number { #lineBudget(): number {
const CHROME = 11; const CHROME = 7;
const PER_SESSION = 4;
const RESERVE = 1; const RESERVE = 1;
const budget = this.#getTerminalRows() - CHROME - RESERVE; return Math.max(8, this.#getTerminalRows() - CHROME - RESERVE);
return Math.max(2, Math.floor(budget / PER_SESSION)); }
/** 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. */ /** Replace the visible dataset, e.g. when toggling folder/all-projects scope. */
@@ -556,21 +559,36 @@ class SessionList implements Component {
return date.toLocaleDateString(); return date.toLocaleDateString();
}; };
// Calculate visible range with scrolling. The window is sized to the // Pack the window around the selection by actual line height (3 lines
// current viewport so the picker never overflows past the top. // per session, 4 when a title adds a preview line) until the viewport
const maxVisible = this.#visibleCount(); // budget is spent, so short sessions never strand blank rows a
const startIndex = Math.max( // worst-case count-based window would leave (then padded by
0, // fill-height).
Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), this.#filteredSessions.length - maxVisible), const filtered = this.#filteredSessions;
); const itemHeight = (session: SessionInfo): number => (session.title ? 4 : 3);
const endIndex = Math.min(startIndex + maxVisible, this.#filteredSessions.length); 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 // Each session block is built into sessionLines, then wrapped by ScrollView
// so the right-edge scrollbar is proportional at the physical-line level. // so the right-edge scrollbar is proportional at the physical-line level.
const sessionLines: string[] = []; const sessionLines: string[] = [];
const sessionRowIndex: number[] = []; 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)); const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
for (let i = startIndex; i < endIndex; i++) { for (let i = startIndex; i < endIndex; i++) {
const blockStart = sessionLines.length; const blockStart = sessionLines.length;
@@ -622,20 +640,31 @@ class SessionList implements Component {
const metadataLine = truncateToWidth(metadata, rowWidth); const metadataLine = truncateToWidth(metadata, rowWidth);
sessionLines.push(metadataLine); 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; for (let k = blockStart; k < sessionLines.length; k++) sessionRowIndex[k] = i;
} }
// Wrap the rendered window in a ScrollView for a proportional right-edge bar. // Wrap the rendered window in a ScrollView for a proportional right-edge
const visibleCount = endIndex - startIndex; // bar, with exact physical-line totals from the per-session heights.
const linesPerItem = visibleCount > 0 ? sessionLines.length / visibleCount : 1; 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, { const sv = new ScrollView(sessionLines, {
height: sessionLines.length, height: sessionLines.length,
scrollbar: "auto", scrollbar: "auto",
totalRows: Math.round(this.#filteredSessions.length * linesPerItem), totalRows,
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) }, 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 sessionRegionStart = lines.length;
const svLines = sv.render(width); const svLines = sv.render(width);
for (let k = 0; k < svLines.length; k++) this.#hitRows[sessionRegionStart + k] = sessionRowIndex[k]; 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 // Page up - jump up by maxVisible items
if (matchesKey(keyData, "pageUp")) { if (matchesKey(keyData, "pageUp")) {
this.#selectionMoved = true; this.#selectionMoved = true;
this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#visibleCount()); this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#pageSize());
return; return;
} }
// Page down - jump down by maxVisible items // Page down - jump down by maxVisible items
if (matchesKey(keyData, "pageDown")) { if (matchesKey(keyData, "pageDown")) {
this.#selectionMoved = true; 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; return;
} }
// Enter // Enter
@@ -799,11 +828,9 @@ export class SessionSelectorComponent extends OverlayPanel {
this.#title = options.title ?? "Resume Session"; this.#title = options.title ?? "Resume Session";
this.#scopeLabel = options.scopeLabel; this.#scopeLabel = options.scopeLabel;
this.title = this.#headerLabel(); 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. // border rows and the horizontal inset.
this.addChild(new Spacer(1)); this.addChild(new Spacer(1));
this.addChild(new Spacer(1));
this.addChild(new Spacer(1));
this.addChild(this.#messageContainer); this.addChild(this.#messageContainer);
// Create session list in folder scope; the empty-state hint invites the // Create session list in folder scope; the empty-state hint invites the
// user to Tab into all-projects rather than silently surfacing other // user to Tab into all-projects rather than silently surfacing other
@@ -35,7 +35,6 @@ import {
validateProviderMaxInFlightRequests, validateProviderMaxInFlightRequests,
} from "../../config/settings"; } from "../../config/settings";
import type { import type {
ComposerShape,
ContextLineMode, ContextLineMode,
SettingTab, SettingTab,
StatusLinePreset, StatusLinePreset,
@@ -9,7 +9,7 @@ import {
truncateToWidth, truncateToWidth,
visibleWidth, visibleWidth,
} from "@oh-my-pi/pi-tui"; } 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 { settings } from "../../../config/settings";
import type { AgentSession } from "../../../session/agent-session"; import type { AgentSession } from "../../../session/agent-session";
import type { OAuthAccountIdentity } from "../../../session/auth-storage"; import type { OAuthAccountIdentity } from "../../../session/auth-storage";
@@ -1601,6 +1601,7 @@ export class StatusLineComponent implements Component {
includePath: boolean, includePath: boolean,
includeGit: boolean, includeGit: boolean,
includePr: boolean, includePr: boolean,
previewTitle?: string,
): SegmentContext { ): SegmentContext {
const state = this.session.state; const state = this.session.state;
@@ -1668,6 +1669,7 @@ export class StatusLineComponent implements Component {
session: this.session, session: this.session,
focusedAgentId: this.#focusedAgentId, focusedAgentId: this.#focusedAgentId,
sessionAccent: this.#resolveSettings().sessionAccent !== false, sessionAccent: this.#resolveSettings().sessionAccent !== false,
previewTitle,
activeRepo: activeRepoCache.activeRepo, activeRepo: activeRepoCache.activeRepo,
width, width,
options: segmentOptions ?? {}, options: segmentOptions ?? {},
@@ -1756,8 +1758,15 @@ export class StatusLineComponent implements Component {
* - `plain-left`: left segments only (claude composer; the right group * - `plain-left`: left segments only (claude composer; the right group
* lives in the editor's top rule). * lives in the editor's top rule).
* - `plain-right`: right segments only (claude composer'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 effectiveSettings = this.#resolveSettings();
const plain = layout !== "box"; const plain = layout !== "box";
const includePath = const includePath =
@@ -1774,6 +1783,7 @@ export class StatusLineComponent implements Component {
includePath, includePath,
includeGit, includeGit,
includePr, includePr,
previewTitle,
); );
const separatorDef = plain const separatorDef = plain
? { left: "·", right: "·" } ? { left: "·", right: "·" }
@@ -1866,6 +1876,22 @@ export class StatusLineComponent implements Component {
const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0); const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0);
if (topFillWidth > 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) { while (totalWidth() > topFillWidth && right.length > 0) {
right.pop(); right.pop();
rightWidth = groupWidth(right, rightCapWidth, rightSepWidth); rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
@@ -2021,7 +2047,9 @@ export class StatusLineComponent implements Component {
const cellFor = (percent: number) => const cellFor = (percent: number) =>
Math.min(scaleWidth - 1, Math.max(0, Math.round((percent / 100) * scaleWidth))); Math.min(scaleWidth - 1, Math.max(0, Math.round((percent / 100) * scaleWidth)));
thresholdIdx = cellFor(boundaries.thresholdPercent); 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 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 speculationGlyph = theme.symbol("context.speculation");
const thresholdGlyph = theme.symbol("context.compaction"); const thresholdGlyph = theme.symbol("context.compaction");
const speculationColor = theme.getFgAnsi("muted"); 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 out = "\x1b[49m";
let activeColor = ""; let activeColor = "";
@@ -2085,15 +2115,18 @@ export class StatusLineComponent implements Component {
// Collab-guest replicas and test mocks have no session-scoped settings; // Collab-guest replicas and test mocks have no session-scoped settings;
// the global store carries the same compaction knobs. // the global store carries the same compaction knobs.
const source = typeof this.session.settings?.getGroup === "function" ? this.session.settings : settings; 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 { try {
return computeCompactionBoundaries(source, contextWindow); return computeCompactionBoundaries(source, contextWindow, model);
} catch { } catch {
return null; return null;
} }
} }
getTopBorder(width: number): { content: string; width: number; revision: number } { getTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } {
let content = this.#buildStatusLine(width); let content = this.#buildStatusLine(width, "box", previewTitle);
if (this.#focusedAgentId && content) { if (this.#focusedAgentId && content) {
// Dim the whole bar while focus-proxied. Group/cap terminators emit full // 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. // `\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. */ /** Plain right-group content for the claude composer's top rule. */
getStandaloneTopBorder(width: number): { content: string; width: number; revision: number } { getStandaloneTopBorder(width: number, previewTitle?: string): { content: string; width: number; revision: number } {
let content = this.#buildStatusLine(width, "plain-right"); let content = this.#buildStatusLine(width, "plain-right", previewTitle);
if (this.#focusedAgentId && content) { if (this.#focusedAgentId && content) {
content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`; 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 * loop and by composer previews (which inject a candidate layout instead of
* the active one). * the active one).
*/ */
renderBottomBar(width: number, groups: "left" | "full"): string { renderBottomBar(width: number, groups: "left" | "full", previewTitle?: string): string {
let content = this.#buildStatusLine(width, groups === "left" ? "plain-left" : "plain-full"); let content = this.#buildStatusLine(width, groups === "left" ? "plain-left" : "plain-full", previewTitle);
if (this.#focusedAgentId && content) { if (this.#focusedAgentId && content) {
content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`; content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`;
} }
@@ -631,7 +631,7 @@ const sessionNameSegment: StatusLineSegment = {
id: "session_name", id: "session_name",
render(ctx) { render(ctx) {
const sessionManager = ctx.session.sessionManager; const sessionManager = ctx.session.sessionManager;
const name = sessionManager?.getSessionName(); const name = sessionManager?.getSessionName() || ctx.previewTitle;
if (!name) return { content: "", visible: false }; if (!name) return { content: "", visible: false };
const accentEnabled = ctx.sessionAccent !== false; const accentEnabled = ctx.sessionAccent !== false;
@@ -63,6 +63,8 @@ export interface SegmentContext {
focusedAgentId?: string | undefined; focusedAgentId?: string | undefined;
/** Effective `statusLine.sessionAccent`; `false` disables hash-derived accent colors, while `true` or omission enables them. */ /** Effective `statusLine.sessionAccent`; `false` disables hash-derived accent colors, while `true` or omission enables them. */
sessionAccent?: boolean; sessionAccent?: boolean;
/** Stand-in session title for previews; `session_name` renders it when the session is unnamed. */
previewTitle?: string;
activeRepo: ActiveRepoContext | null; activeRepo: ActiveRepoContext | null;
width: number; width: number;
options: StatusLineSegmentOptions; options: StatusLineSegmentOptions;
@@ -22,8 +22,8 @@ import type {
TerminalInputHandler, TerminalInputHandler,
} from "../../extensibility/extensions"; } from "../../extensibility/extensions";
import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler"; 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 { AskDialogComponent, boundPromptTitle } from "../../modes/components/ask-dialog";
import { installExtensionComposerShape } from "../../modes/components/composer-shape-registry";
import { HookEditorComponent } from "../../modes/components/hook-editor"; import { HookEditorComponent } from "../../modes/components/hook-editor";
import { HookInputComponent } from "../../modes/components/hook-input"; import { HookInputComponent } from "../../modes/components/hook-input";
import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/components/hook-selector"; import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/components/hook-selector";
@@ -1,7 +1,7 @@
import { routeSelectListMouse, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; import { routeSelectListMouse, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
import type { ComposerShape } from "../../../config/settings-schema"; import type { ComposerShape } from "../../../config/settings-schema";
import { getComposerShapeOptions } from "../../components/composer-shape-registry";
import { renderComposerShapePreview } from "../../components/composer-shape-preview"; import { renderComposerShapePreview } from "../../components/composer-shape-preview";
import { getComposerShapeOptions } from "../../components/composer-shape-registry";
import { getSelectListTheme, theme } from "../../theme/theme"; import { getSelectListTheme, theme } from "../../theme/theme";
import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types"; import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types";
@@ -6,6 +6,7 @@ import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
import { formatNumber } from "@oh-my-pi/pi-utils"; import { formatNumber } from "@oh-my-pi/pi-utils";
import type { Skill } from "../../extensibility/skills"; import type { Skill } from "../../extensibility/skills";
import type { AgentSession } from "../../session/agent-session"; import type { AgentSession } from "../../session/agent-session";
import { resolveSpeculationMethod } from "../../session/compaction-methods";
import { estimateInlineSavings, type SnapcompactSavingsEstimate } from "../../session/snapcompact-inline"; import { estimateInlineSavings, type SnapcompactSavingsEstimate } from "../../session/snapcompact-inline";
import { resolveSpeculationLeadTokens } from "../../session/speculation-lead"; import { resolveSpeculationLeadTokens } from "../../session/speculation-lead";
import type { Tool } from "../../tools"; import type { Tool } from "../../tools";
@@ -46,28 +47,36 @@ export interface ContextBreakdown {
export interface CompactionBoundaries { export interface CompactionBoundaries {
/** Where auto-compaction fires. */ /** Where auto-compaction fires. */
thresholdPercent: number; 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` * Boundary positions for the status line's annotated context gauge. `null`
* when compaction is disabled/off or the window is unknown — the gauge then * 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( export function computeCompactionBoundaries(
settings: AgentSession["settings"], settings: AgentSession["settings"],
contextWindow: number, contextWindow: number,
model?: Model | null,
): CompactionBoundaries | null { ): CompactionBoundaries | null {
if (!(contextWindow > 0)) return null; if (!(contextWindow > 0)) return null;
const compactionSettings = settings.getGroup("compaction") as CompactionSettings; const configured = settings.getGroup("compaction");
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return null; const compactionSettings = configured as CompactionSettings;
if (!configured.enabled || compactionSettings.strategy === "off") return null;
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings); const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
if (!(thresholdTokens > 0) || thresholdTokens > contextWindow) return null; if (!(thresholdTokens > 0) || thresholdTokens > contextWindow) return null;
const speculates = configured.asyncEnabled !== false && resolveSpeculationMethod(model, configured) !== undefined;
const leadTokens = resolveSpeculationLeadTokens(thresholdTokens); const leadTokens = resolveSpeculationLeadTokens(thresholdTokens);
return { return {
thresholdPercent: (thresholdTokens / contextWindow) * 100, thresholdPercent: (thresholdTokens / contextWindow) * 100,
speculationPercent: (Math.max(0, thresholdTokens - leadTokens) / contextWindow) * 100, speculationPercent: speculates ? (Math.max(0, thresholdTokens - leadTokens) / contextWindow) * 100 : null,
}; };
} }
@@ -1,5 +1,12 @@
/** Ordered automatic context-maintenance methods and their settings metadata. */ /** 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. */ /** Choices presented by the ordered compaction-method setting. */
export const COMPACTION_METHOD_CHOICES = [ export const COMPACTION_METHOD_CHOICES = [
{ {
@@ -67,3 +74,58 @@ export function resolveCompactionMethodOrder(value: unknown): CompactionMethod[]
} }
return methods; return methods;
} }
const STRATEGY_BY_COMPACTION_METHOD: Record<CompactionMethod, "context-full" | "handoff" | "shake" | "snapcompact"> = {
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;
}
@@ -70,8 +70,11 @@ import type { ContextUsageBreakdown, HandoffResult, SessionHandoffOptions } from
import { findCompactMode } from "./compact-modes"; import { findCompactMode } from "./compact-modes";
import { import {
type CompactionMethod, type CompactionMethod,
canUseRemoteCompaction,
DEFAULT_COMPACTION_METHOD_ORDER, DEFAULT_COMPACTION_METHOD_ORDER,
resolveCompactionMethodOrder, resolveCompactionMethodOrder,
resolveMethodSettings,
resolveSpeculationMethod,
} from "./compaction-methods"; } from "./compaction-methods";
import { convertToLlm, stripImagesFromMessage } from "./messages"; import { convertToLlm, stripImagesFromMessage } from "./messages";
import { isTerminalTextAssistantAnswer } from "./queued-messages"; import { isTerminalTextAssistantAnswer } from "./queued-messages";
@@ -113,38 +116,6 @@ const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = {
automaticContinuationBlocked: true, automaticContinuationBlocked: true,
}; };
const STRATEGY_BY_COMPACTION_METHOD: Record<CompactionMethod, "context-full" | "handoff" | "shake" | "snapcompact"> = {
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. */ /** Whether a configured preference list contains at least one automatic method. */
function hasConfiguredCompactionMethod(settings: ConfiguredCompactionSettings): boolean { function hasConfiguredCompactionMethod(settings: ConfiguredCompactionSettings): boolean {
return resolveCompactionMethodOrder(settings.methodOrder).length > 0; return resolveCompactionMethodOrder(settings.methodOrder).length > 0;
@@ -1169,7 +1140,7 @@ export class SessionMaintenance {
} }
const model = this.#model; const model = this.#model;
if (!model) return; if (!model) return;
const method = this.#resolveSpeculationMethod(model, settings); const method = resolveSpeculationMethod(model, settings);
if (!method) return; if (!method) return;
const controller = new AbortController(); const controller = new AbortController();
const run: SpeculationRun = { controller, promise: Promise.resolve(), contextTokensAtStart: contextTokens }; 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. */ /** Produce and arm one speculative compaction result off a branch snapshot. */
async #runSpeculation( async #runSpeculation(
run: SpeculationRun, run: SpeculationRun,
@@ -2644,7 +2597,10 @@ export class SessionMaintenance {
// in the background. Claiming consumes the slot either way: an in-flight // 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 // run is aborted (this real pass supersedes it) and an armed result is
// returned only when still valid for the current branch/model/settings. // 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 effectiveSettings = resolveMethodSettings(compactionSettings, method);
const fallbackFromShake = options.fallbackFromShake === true; const fallbackFromShake = options.fallbackFromShake === true;