mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 10:02:23 +00:00
feat(coding-agent): context compaction with /compact, /autocompact, and auto-trigger
- Add /compact command for manual context compaction with optional custom instructions - Add /autocompact command to toggle automatic compaction - Auto-trigger compaction when context usage exceeds threshold (contextWindow - reserveTokens) - Add CompactionComponent for TUI display with collapsed/expanded states - Add compaction events to HTML export with collapsible summary - Refactor export-html.ts to eliminate duplication between session and streaming formats - Use setTimeout to break out of agent event handler for safe async compaction - Show compaction summary in TUI after compaction completes fixes #92
This commit is contained in:
parent
bddb99fa7c
commit
c89b1ec3c2
6 changed files with 803 additions and 1473 deletions
|
|
@ -8,7 +8,7 @@
|
||||||
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
|
||||||
import { complete } from "@mariozechner/pi-ai";
|
import { complete } from "@mariozechner/pi-ai";
|
||||||
import { type CompactionEntry, loadSessionFromEntries, type SessionEntry } from "./session-manager.js";
|
import type { CompactionEntry, SessionEntry } from "./session-manager.js";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -225,8 +225,10 @@ export async function compact(
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
customInstructions?: string,
|
customInstructions?: string,
|
||||||
): Promise<CompactionEntry> {
|
): Promise<CompactionEntry> {
|
||||||
// Reconstruct current messages from entries
|
// Don't compact if the last entry is already a compaction
|
||||||
const { messages: currentMessages } = loadSessionFromEntries(entries);
|
if (entries.length > 0 && entries[entries.length - 1].type === "compaction") {
|
||||||
|
throw new Error("Already compacted");
|
||||||
|
}
|
||||||
|
|
||||||
// Find previous compaction boundary
|
// Find previous compaction boundary
|
||||||
let prevCompactionIndex = -1;
|
let prevCompactionIndex = -1;
|
||||||
|
|
@ -246,9 +248,29 @@ export async function compact(
|
||||||
// Find cut point (entry index) within the valid range
|
// Find cut point (entry index) within the valid range
|
||||||
const firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
|
const firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
|
||||||
|
|
||||||
// Generate summary from the full current context
|
// Extract messages to summarize (before the cut point)
|
||||||
|
const messagesToSummarize: AppMessage[] = [];
|
||||||
|
for (let i = boundaryStart; i < firstKeptEntryIndex; i++) {
|
||||||
|
const entry = entries[i];
|
||||||
|
if (entry.type === "message") {
|
||||||
|
messagesToSummarize.push(entry.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also include the previous summary if there was a compaction
|
||||||
|
if (prevCompactionIndex >= 0) {
|
||||||
|
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
|
||||||
|
// Prepend the previous summary as context
|
||||||
|
messagesToSummarize.unshift({
|
||||||
|
role: "user",
|
||||||
|
content: `Previous session summary:\n${prevCompaction.summary}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate summary from messages before the cut point
|
||||||
const summary = await generateSummary(
|
const summary = await generateSummary(
|
||||||
currentMessages,
|
messagesToSummarize,
|
||||||
model,
|
model,
|
||||||
settings.reserveTokens,
|
settings.reserveTokens,
|
||||||
apiKey,
|
apiKey,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -72,17 +72,21 @@ export interface LoadedSession {
|
||||||
model: { provider: string; modelId: string } | null;
|
model: { provider: string; modelId: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUMMARY_PREFIX = `Another language model worked on this task and produced a summary. Use this to continue the work without duplicating effort:
|
export const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
|
||||||
|
|
||||||
|
<summary>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const SUMMARY_SUFFIX = `
|
||||||
|
</summary>`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a user message containing the summary with the standard prefix.
|
* Create a user message containing the summary with the standard prefix.
|
||||||
*/
|
*/
|
||||||
export function createSummaryMessage(summary: string): AppMessage {
|
export function createSummaryMessage(summary: string): AppMessage {
|
||||||
return {
|
return {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: SUMMARY_PREFIX + summary,
|
content: SUMMARY_PREFIX + summary + SUMMARY_SUFFIX,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +119,18 @@ export function parseSessionEntries(content: string): SessionEntry[] {
|
||||||
* 2. Keep all entries from firstKeptEntryIndex onwards (extracting messages)
|
* 2. Keep all entries from firstKeptEntryIndex onwards (extracting messages)
|
||||||
* 3. Prepend summary as user message
|
* 3. Prepend summary as user message
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Get the latest compaction entry from session entries, if any.
|
||||||
|
*/
|
||||||
|
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
|
||||||
|
for (let i = entries.length - 1; i >= 0; i--) {
|
||||||
|
if (entries[i].type === "compaction") {
|
||||||
|
return entries[i] as CompactionEntry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession {
|
||||||
// Find model and thinking level (always scan all entries)
|
// Find model and thinking level (always scan all entries)
|
||||||
let thinkingLevel = "off";
|
let thinkingLevel = "off";
|
||||||
|
|
|
||||||
54
packages/coding-agent/src/tui/compaction.ts
Normal file
54
packages/coding-agent/src/tui/compaction.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||||
|
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders a compaction indicator with collapsed/expanded state.
|
||||||
|
* Collapsed: shows "Context compacted from X tokens"
|
||||||
|
* Expanded: shows the full summary rendered as markdown (like a user message)
|
||||||
|
*/
|
||||||
|
export class CompactionComponent extends Container {
|
||||||
|
private expanded = false;
|
||||||
|
private tokensBefore: number;
|
||||||
|
private summary: string;
|
||||||
|
|
||||||
|
constructor(tokensBefore: number, summary: string) {
|
||||||
|
super();
|
||||||
|
this.tokensBefore = tokensBefore;
|
||||||
|
this.summary = summary;
|
||||||
|
this.updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpanded(expanded: boolean): void {
|
||||||
|
this.expanded = expanded;
|
||||||
|
this.updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDisplay(): void {
|
||||||
|
this.clear();
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
|
||||||
|
if (this.expanded) {
|
||||||
|
// Show header + summary as markdown (like user message)
|
||||||
|
const header = `**Context compacted from ${this.tokensBefore.toLocaleString()} tokens**\n\n`;
|
||||||
|
this.addChild(
|
||||||
|
new Markdown(header + this.summary, 1, 1, getMarkdownTheme(), {
|
||||||
|
bgColor: (text: string) => theme.bg("userMessageBg", text),
|
||||||
|
color: (text: string) => theme.fg("userMessageText", text),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Collapsed: just show the header line with user message styling
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
|
const shortcut = isMac ? "CMD+O" : "CTRL+O";
|
||||||
|
this.addChild(
|
||||||
|
new Text(
|
||||||
|
theme.fg("userMessageText", `--- Earlier messages compacted (${shortcut} to expand) ---`),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
(text: string) => theme.bg("userMessageBg", text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,12 +22,19 @@ import { calculateContextTokens, compact, getLastAssistantUsage, shouldCompact }
|
||||||
import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js";
|
import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js";
|
||||||
import { exportSessionToHtml } from "../export-html.js";
|
import { exportSessionToHtml } from "../export-html.js";
|
||||||
import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js";
|
import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js";
|
||||||
import { listOAuthProviders, login, logout } from "../oauth/index.js";
|
import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../oauth/index.js";
|
||||||
import { loadSessionFromEntries, type SessionManager } from "../session-manager.js";
|
import {
|
||||||
|
getLatestCompactionEntry,
|
||||||
|
loadSessionFromEntries,
|
||||||
|
type SessionManager,
|
||||||
|
SUMMARY_PREFIX,
|
||||||
|
SUMMARY_SUFFIX,
|
||||||
|
} from "../session-manager.js";
|
||||||
import type { SettingsManager } from "../settings-manager.js";
|
import type { SettingsManager } from "../settings-manager.js";
|
||||||
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
|
import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js";
|
||||||
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
|
import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js";
|
||||||
import { AssistantMessageComponent } from "./assistant-message.js";
|
import { AssistantMessageComponent } from "./assistant-message.js";
|
||||||
|
import { CompactionComponent } from "./compaction.js";
|
||||||
import { CustomEditor } from "./custom-editor.js";
|
import { CustomEditor } from "./custom-editor.js";
|
||||||
import { DynamicBorder } from "./dynamic-border.js";
|
import { DynamicBorder } from "./dynamic-border.js";
|
||||||
import { FooterComponent } from "./footer.js";
|
import { FooterComponent } from "./footer.js";
|
||||||
|
|
@ -564,58 +571,7 @@ export class TuiRenderer {
|
||||||
if (!shouldCompact(contextTokens, contextWindow, settings)) return;
|
if (!shouldCompact(contextTokens, contextWindow, settings)) return;
|
||||||
|
|
||||||
// Trigger auto-compaction
|
// Trigger auto-compaction
|
||||||
await this.handleAutoCompaction();
|
await this.executeCompaction(undefined, true);
|
||||||
}
|
|
||||||
|
|
||||||
private async handleAutoCompaction(): Promise<void> {
|
|
||||||
// Unsubscribe to stop processing events
|
|
||||||
this.unsubscribe?.();
|
|
||||||
|
|
||||||
// Abort current agent run and wait for completion
|
|
||||||
this.agent.abort();
|
|
||||||
await this.agent.waitForIdle();
|
|
||||||
|
|
||||||
// Stop loading animation
|
|
||||||
if (this.loadingAnimation) {
|
|
||||||
this.loadingAnimation.stop();
|
|
||||||
this.loadingAnimation = null;
|
|
||||||
}
|
|
||||||
this.statusContainer.clear();
|
|
||||||
|
|
||||||
// Show compacting status
|
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
|
||||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Auto-compacting context..."), 1, 1));
|
|
||||||
this.ui.requestRender();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const apiKey = await getApiKeyForModel(this.agent.state.model);
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error(`No API key for ${this.agent.state.model.provider}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = this.sessionManager.loadEntries();
|
|
||||||
const settings = this.settingsManager.getCompactionSettings();
|
|
||||||
const compactionEntry = await compact(entries, this.agent.state.model, settings, apiKey);
|
|
||||||
|
|
||||||
// Save and reload
|
|
||||||
this.sessionManager.saveCompaction(compactionEntry);
|
|
||||||
const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
|
|
||||||
this.agent.replaceMessages(loaded.messages);
|
|
||||||
|
|
||||||
// Rebuild UI
|
|
||||||
this.chatContainer.clear();
|
|
||||||
this.rebuildChatFromMessages();
|
|
||||||
|
|
||||||
this.showSuccess(
|
|
||||||
"✓ Context auto-compacted",
|
|
||||||
`Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.showError(`Auto-compaction failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resubscribe
|
|
||||||
this.subscribeToAgent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleEvent(event: AgentEvent, state: AgentState): Promise<void> {
|
private async handleEvent(event: AgentEvent, state: AgentState): Promise<void> {
|
||||||
|
|
@ -648,9 +604,12 @@ export class TuiRenderer {
|
||||||
case "message_start":
|
case "message_start":
|
||||||
if (event.message.role === "user") {
|
if (event.message.role === "user") {
|
||||||
// Check if this is a queued message
|
// Check if this is a queued message
|
||||||
const userMsg = event.message as any;
|
const userMsg = event.message;
|
||||||
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
|
const textBlocks =
|
||||||
const messageText = textBlocks.map((c: any) => c.text).join("");
|
typeof userMsg.content === "string"
|
||||||
|
? [{ type: "text", text: userMsg.content }]
|
||||||
|
: userMsg.content.filter((c) => c.type === "text");
|
||||||
|
const messageText = textBlocks.map((c) => c.text).join("");
|
||||||
|
|
||||||
const queuedIndex = this.queuedMessages.indexOf(messageText);
|
const queuedIndex = this.queuedMessages.indexOf(messageText);
|
||||||
if (queuedIndex !== -1) {
|
if (queuedIndex !== -1) {
|
||||||
|
|
@ -789,17 +748,20 @@ export class TuiRenderer {
|
||||||
|
|
||||||
private addMessageToChat(message: Message): void {
|
private addMessageToChat(message: Message): void {
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
const userMsg = message as any;
|
const userMsg = message;
|
||||||
// Extract text content from content blocks
|
// Extract text content from content blocks
|
||||||
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
|
const textBlocks =
|
||||||
const textContent = textBlocks.map((c: any) => c.text).join("");
|
typeof userMsg.content === "string"
|
||||||
|
? [{ type: "text", text: userMsg.content }]
|
||||||
|
: userMsg.content.filter((c) => c.type === "text");
|
||||||
|
const textContent = textBlocks.map((c) => c.text).join("");
|
||||||
if (textContent) {
|
if (textContent) {
|
||||||
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
|
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
|
||||||
this.chatContainer.addChild(userComponent);
|
this.chatContainer.addChild(userComponent);
|
||||||
this.isFirstUserMessage = false;
|
this.isFirstUserMessage = false;
|
||||||
}
|
}
|
||||||
} else if (message.role === "assistant") {
|
} else if (message.role === "assistant") {
|
||||||
const assistantMsg = message as AssistantMessage;
|
const assistantMsg = message;
|
||||||
|
|
||||||
// Add assistant message component
|
// Add assistant message component
|
||||||
const assistantComponent = new AssistantMessageComponent(assistantMsg);
|
const assistantComponent = new AssistantMessageComponent(assistantMsg);
|
||||||
|
|
@ -819,18 +781,32 @@ export class TuiRenderer {
|
||||||
// Update editor border color based on current thinking level
|
// Update editor border color based on current thinking level
|
||||||
this.updateEditorBorderColor();
|
this.updateEditorBorderColor();
|
||||||
|
|
||||||
|
// Get compaction info if any
|
||||||
|
const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
|
||||||
|
|
||||||
// Render messages
|
// Render messages
|
||||||
for (let i = 0; i < state.messages.length; i++) {
|
for (let i = 0; i < state.messages.length; i++) {
|
||||||
const message = state.messages[i];
|
const message = state.messages[i];
|
||||||
|
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
const userMsg = message as any;
|
const userMsg = message;
|
||||||
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
|
const textBlocks =
|
||||||
const textContent = textBlocks.map((c: any) => c.text).join("");
|
typeof userMsg.content === "string"
|
||||||
|
? [{ type: "text", text: userMsg.content }]
|
||||||
|
: userMsg.content.filter((c) => c.type === "text");
|
||||||
|
const textContent = textBlocks.map((c) => c.text).join("");
|
||||||
if (textContent) {
|
if (textContent) {
|
||||||
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
|
// Check if this is a compaction summary message
|
||||||
this.chatContainer.addChild(userComponent);
|
if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
|
||||||
this.isFirstUserMessage = false;
|
const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);
|
||||||
|
const component = new CompactionComponent(compactionEntry.tokensBefore, summary);
|
||||||
|
component.setExpanded(this.toolOutputExpanded);
|
||||||
|
this.chatContainer.addChild(component);
|
||||||
|
} else {
|
||||||
|
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
|
||||||
|
this.chatContainer.addChild(userComponent);
|
||||||
|
this.isFirstUserMessage = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (message.role === "assistant") {
|
} else if (message.role === "assistant") {
|
||||||
const assistantMsg = message as AssistantMessage;
|
const assistantMsg = message as AssistantMessage;
|
||||||
|
|
@ -892,18 +868,32 @@ export class TuiRenderer {
|
||||||
this.isFirstUserMessage = true;
|
this.isFirstUserMessage = true;
|
||||||
this.pendingTools.clear();
|
this.pendingTools.clear();
|
||||||
|
|
||||||
|
// Get compaction info if any
|
||||||
|
const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());
|
||||||
|
|
||||||
for (const message of this.agent.state.messages) {
|
for (const message of this.agent.state.messages) {
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
const userMsg = message as any;
|
const userMsg = message;
|
||||||
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
|
const textBlocks =
|
||||||
const textContent = textBlocks.map((c: any) => c.text).join("");
|
typeof userMsg.content === "string"
|
||||||
|
? [{ type: "text", text: userMsg.content }]
|
||||||
|
: userMsg.content.filter((c) => c.type === "text");
|
||||||
|
const textContent = textBlocks.map((c) => c.text).join("");
|
||||||
if (textContent) {
|
if (textContent) {
|
||||||
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
|
// Check if this is a compaction summary message
|
||||||
this.chatContainer.addChild(userComponent);
|
if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {
|
||||||
this.isFirstUserMessage = false;
|
const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);
|
||||||
|
const component = new CompactionComponent(compactionEntry.tokensBefore, summary);
|
||||||
|
component.setExpanded(this.toolOutputExpanded);
|
||||||
|
this.chatContainer.addChild(component);
|
||||||
|
} else {
|
||||||
|
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
|
||||||
|
this.chatContainer.addChild(userComponent);
|
||||||
|
this.isFirstUserMessage = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (message.role === "assistant") {
|
} else if (message.role === "assistant") {
|
||||||
const assistantMsg = message as AssistantMessage;
|
const assistantMsg = message;
|
||||||
const assistantComponent = new AssistantMessageComponent(assistantMsg);
|
const assistantComponent = new AssistantMessageComponent(assistantMsg);
|
||||||
this.chatContainer.addChild(assistantComponent);
|
this.chatContainer.addChild(assistantComponent);
|
||||||
|
|
||||||
|
|
@ -1095,10 +1085,12 @@ export class TuiRenderer {
|
||||||
private toggleToolOutputExpansion(): void {
|
private toggleToolOutputExpansion(): void {
|
||||||
this.toolOutputExpanded = !this.toolOutputExpanded;
|
this.toolOutputExpanded = !this.toolOutputExpanded;
|
||||||
|
|
||||||
// Update all tool execution components
|
// Update all tool execution and compaction components
|
||||||
for (const child of this.chatContainer.children) {
|
for (const child of this.chatContainer.children) {
|
||||||
if (child instanceof ToolExecutionComponent) {
|
if (child instanceof ToolExecutionComponent) {
|
||||||
child.setExpanded(this.toolOutputExpanded);
|
child.setExpanded(this.toolOutputExpanded);
|
||||||
|
} else if (child instanceof CompactionComponent) {
|
||||||
|
child.setExpanded(this.toolOutputExpanded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1445,7 +1437,7 @@ export class TuiRenderer {
|
||||||
// Create OAuth selector
|
// Create OAuth selector
|
||||||
this.oauthSelector = new OAuthSelectorComponent(
|
this.oauthSelector = new OAuthSelectorComponent(
|
||||||
mode,
|
mode,
|
||||||
async (providerId: any) => {
|
async (providerId: string) => {
|
||||||
// Hide selector first
|
// Hide selector first
|
||||||
this.hideOAuthSelector();
|
this.hideOAuthSelector();
|
||||||
|
|
||||||
|
|
@ -1457,7 +1449,7 @@ export class TuiRenderer {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(
|
await login(
|
||||||
providerId,
|
providerId as SupportedOAuthProvider,
|
||||||
(url: string) => {
|
(url: string) => {
|
||||||
// Show auth URL to user
|
// Show auth URL to user
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
|
|
@ -1509,7 +1501,7 @@ export class TuiRenderer {
|
||||||
} else {
|
} else {
|
||||||
// Handle logout
|
// Handle logout
|
||||||
try {
|
try {
|
||||||
await logout(providerId);
|
await logout(providerId as SupportedOAuthProvider);
|
||||||
|
|
||||||
// Invalidate OAuth cache so footer updates
|
// Invalidate OAuth cache so footer updates
|
||||||
invalidateOAuthCache();
|
invalidateOAuthCache();
|
||||||
|
|
@ -1707,7 +1699,7 @@ export class TuiRenderer {
|
||||||
|
|
||||||
private handleDebugCommand(): void {
|
private handleDebugCommand(): void {
|
||||||
// Force a render and capture all lines with their widths
|
// Force a render and capture all lines with their widths
|
||||||
const width = (this.ui as any).terminal.columns;
|
const width = this.ui.terminal.columns;
|
||||||
const allLines = this.ui.render(width);
|
const allLines = this.ui.render(width);
|
||||||
|
|
||||||
const debugLogPath = getDebugLogPath();
|
const debugLogPath = getDebugLogPath();
|
||||||
|
|
@ -1737,16 +1729,13 @@ export class TuiRenderer {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleCompactCommand(customInstructions?: string): Promise<void> {
|
private compactionAbortController: AbortController | null = null;
|
||||||
// Check if there are any messages to compact
|
|
||||||
const entries = this.sessionManager.loadEntries();
|
|
||||||
const messageCount = entries.filter((e) => e.type === "message").length;
|
|
||||||
|
|
||||||
if (messageCount < 2) {
|
|
||||||
this.showWarning("Nothing to compact (no messages yet)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared logic to execute context compaction.
|
||||||
|
* Handles aborting agent, showing loader, performing compaction, updating session/UI.
|
||||||
|
*/
|
||||||
|
private async executeCompaction(customInstructions?: string, isAuto = false): Promise<void> {
|
||||||
// Unsubscribe first to prevent processing events during compaction
|
// Unsubscribe first to prevent processing events during compaction
|
||||||
this.unsubscribe?.();
|
this.unsubscribe?.();
|
||||||
|
|
||||||
|
|
@ -1761,9 +1750,27 @@ export class TuiRenderer {
|
||||||
}
|
}
|
||||||
this.statusContainer.clear();
|
this.statusContainer.clear();
|
||||||
|
|
||||||
// Show compacting status
|
// Create abort controller for compaction
|
||||||
|
this.compactionAbortController = new AbortController();
|
||||||
|
|
||||||
|
// Set up escape handler during compaction
|
||||||
|
const originalOnEscape = this.editor.onEscape;
|
||||||
|
this.editor.onEscape = () => {
|
||||||
|
if (this.compactionAbortController) {
|
||||||
|
this.compactionAbortController.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show compacting status with loader
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Compacting context..."), 1, 1));
|
const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
|
||||||
|
const compactingLoader = new Loader(
|
||||||
|
this.ui,
|
||||||
|
(spinner) => theme.fg("accent", spinner),
|
||||||
|
(text) => theme.fg("muted", text),
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
this.statusContainer.addChild(compactingLoader);
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1773,17 +1780,23 @@ export class TuiRenderer {
|
||||||
throw new Error(`No API key for ${this.agent.state.model.provider}`);
|
throw new Error(`No API key for ${this.agent.state.model.provider}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform compaction
|
// Perform compaction with abort signal
|
||||||
|
const entries = this.sessionManager.loadEntries();
|
||||||
const settings = this.settingsManager.getCompactionSettings();
|
const settings = this.settingsManager.getCompactionSettings();
|
||||||
const compactionEntry = await compact(
|
const compactionEntry = await compact(
|
||||||
entries,
|
entries,
|
||||||
this.agent.state.model,
|
this.agent.state.model,
|
||||||
settings,
|
settings,
|
||||||
apiKey,
|
apiKey,
|
||||||
undefined,
|
this.compactionAbortController.signal,
|
||||||
customInstructions,
|
customInstructions,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if aborted after compact returned
|
||||||
|
if (this.compactionAbortController.signal.aborted) {
|
||||||
|
throw new Error("Compaction cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
// Save compaction to session
|
// Save compaction to session
|
||||||
this.sessionManager.saveCompaction(compactionEntry);
|
this.sessionManager.saveCompaction(compactionEntry);
|
||||||
|
|
||||||
|
|
@ -1795,19 +1808,49 @@ export class TuiRenderer {
|
||||||
this.chatContainer.clear();
|
this.chatContainer.clear();
|
||||||
this.rebuildChatFromMessages();
|
this.rebuildChatFromMessages();
|
||||||
|
|
||||||
// Show success
|
// Add compaction component at current position so user can see/expand the summary
|
||||||
this.showSuccess(
|
const compactionComponent = new CompactionComponent(compactionEntry.tokensBefore, compactionEntry.summary);
|
||||||
"✓ Context compacted",
|
compactionComponent.setExpanded(this.toolOutputExpanded);
|
||||||
`Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`,
|
this.chatContainer.addChild(compactionComponent);
|
||||||
);
|
|
||||||
|
// Update footer with new state (fixes context % display)
|
||||||
|
this.footer.updateState(this.agent.state);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
const successTitle = isAuto ? "✓ Context auto-compacted" : "✓ Context compacted";
|
||||||
|
this.showSuccess(successTitle, `Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showError(`Compaction failed: ${error instanceof Error ? error.message : String(error)}`);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
|
||||||
|
this.showError("Compaction cancelled");
|
||||||
|
} else {
|
||||||
|
this.showError(`Compaction failed: ${message}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Clean up
|
||||||
|
compactingLoader.stop();
|
||||||
|
this.statusContainer.clear();
|
||||||
|
this.compactionAbortController = null;
|
||||||
|
this.editor.onEscape = originalOnEscape;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resubscribe to agent
|
// Resubscribe to agent
|
||||||
this.subscribeToAgent();
|
this.subscribeToAgent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleCompactCommand(customInstructions?: string): Promise<void> {
|
||||||
|
// Check if there are any messages to compact
|
||||||
|
const entries = this.sessionManager.loadEntries();
|
||||||
|
const messageCount = entries.filter((e) => e.type === "message").length;
|
||||||
|
|
||||||
|
if (messageCount < 2) {
|
||||||
|
this.showWarning("Nothing to compact (no messages yet)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.executeCompaction(customInstructions, false);
|
||||||
|
}
|
||||||
|
|
||||||
private handleAutocompactCommand(): void {
|
private handleAutocompactCommand(): void {
|
||||||
const currentEnabled = this.settingsManager.getCompactionEnabled();
|
const currentEnabled = this.settingsManager.getCompactionEnabled();
|
||||||
const newState = !currentEnabled;
|
const newState = !currentEnabled;
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ export class Container implements Component {
|
||||||
* TUI - Main class for managing terminal UI with differential rendering
|
* TUI - Main class for managing terminal UI with differential rendering
|
||||||
*/
|
*/
|
||||||
export class TUI extends Container {
|
export class TUI extends Container {
|
||||||
private terminal: Terminal;
|
public terminal: Terminal;
|
||||||
private previousLines: string[] = [];
|
private previousLines: string[] = [];
|
||||||
private previousWidth = 0;
|
private previousWidth = 0;
|
||||||
private focusedComponent: Component | null = null;
|
private focusedComponent: Component | null = null;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue