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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
validateProviderMaxInFlightRequests,
|
||||
} from "../../config/settings";
|
||||
import type {
|
||||
ComposerShape,
|
||||
ContextLineMode,
|
||||
SettingTab,
|
||||
StatusLinePreset,
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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 {
|
||||
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<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. */
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user