mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 10:02:23 +00:00
Context compaction: commands, auto-trigger, RPC support, /branch rework (fixes #92)
- Add compaction settings to Settings interface
- /compact [instructions]: manual compaction with optional focus
- /autocompact: toggle auto-compaction on/off
- Auto-compaction triggers after assistant message_end when threshold exceeded
- Footer shows (auto) when auto-compact is enabled
- RPC mode: {type: 'compact'} command emits CompactionEntry
- /branch now reads from session file to show ALL historical user messages
- createBranchedSessionFromEntries preserves compaction events
This commit is contained in:
parent
6c2360af28
commit
79731249eb
5 changed files with 375 additions and 30 deletions
|
|
@ -6,6 +6,7 @@ import { existsSync, readFileSync, statSync } from "fs";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { extname, join, resolve } from "path";
|
import { extname, join, resolve } from "path";
|
||||||
import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js";
|
import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js";
|
||||||
|
import { compact } from "./compaction.js";
|
||||||
import {
|
import {
|
||||||
APP_NAME,
|
APP_NAME,
|
||||||
CONFIG_DIR_NAME,
|
CONFIG_DIR_NAME,
|
||||||
|
|
@ -17,7 +18,7 @@ import {
|
||||||
} from "./config.js";
|
} from "./config.js";
|
||||||
import { exportFromFile } from "./export-html.js";
|
import { exportFromFile } from "./export-html.js";
|
||||||
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
||||||
import { SessionManager } from "./session-manager.js";
|
import { loadSessionFromEntries, SessionManager } from "./session-manager.js";
|
||||||
import { SettingsManager } from "./settings-manager.js";
|
import { SettingsManager } from "./settings-manager.js";
|
||||||
import { expandSlashCommand, loadSlashCommands } from "./slash-commands.js";
|
import { expandSlashCommand, loadSlashCommands } from "./slash-commands.js";
|
||||||
import { initTheme } from "./theme/theme.js";
|
import { initTheme } from "./theme/theme.js";
|
||||||
|
|
@ -814,7 +815,11 @@ async function runSingleShotMode(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRpcMode(agent: Agent, sessionManager: SessionManager): Promise<void> {
|
async function runRpcMode(
|
||||||
|
agent: Agent,
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
settingsManager: SettingsManager,
|
||||||
|
): Promise<void> {
|
||||||
// Subscribe to all events and output as JSON (same pattern as tui-renderer)
|
// Subscribe to all events and output as JSON (same pattern as tui-renderer)
|
||||||
agent.subscribe(async (event) => {
|
agent.subscribe(async (event) => {
|
||||||
console.log(JSON.stringify(event));
|
console.log(JSON.stringify(event));
|
||||||
|
|
@ -851,6 +856,35 @@ async function runRpcMode(agent: Agent, sessionManager: SessionManager): Promise
|
||||||
await agent.prompt(input.message, input.attachments);
|
await agent.prompt(input.message, input.attachments);
|
||||||
} else if (input.type === "abort") {
|
} else if (input.type === "abort") {
|
||||||
agent.abort();
|
agent.abort();
|
||||||
|
} else if (input.type === "compact") {
|
||||||
|
// Handle compaction request
|
||||||
|
try {
|
||||||
|
const apiKey = await getApiKeyForModel(agent.state.model);
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(`No API key for ${agent.state.model.provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = sessionManager.loadEntries();
|
||||||
|
const settings = settingsManager.getCompactionSettings();
|
||||||
|
const compactionEntry = await compact(
|
||||||
|
entries,
|
||||||
|
agent.state.model,
|
||||||
|
settings,
|
||||||
|
apiKey,
|
||||||
|
undefined,
|
||||||
|
input.customInstructions,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save and reload
|
||||||
|
sessionManager.saveCompaction(compactionEntry);
|
||||||
|
const loaded = loadSessionFromEntries(sessionManager.loadEntries());
|
||||||
|
agent.replaceMessages(loaded.messages);
|
||||||
|
|
||||||
|
// Emit compaction event (compactionEntry already has type: "compaction")
|
||||||
|
console.log(JSON.stringify(compactionEntry));
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(JSON.stringify({ type: "error", error: `Compaction failed: ${error.message}` }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Output error as JSON
|
// Output error as JSON
|
||||||
|
|
@ -1219,7 +1253,7 @@ export async function main(args: string[]) {
|
||||||
// Route to appropriate mode
|
// Route to appropriate mode
|
||||||
if (mode === "rpc") {
|
if (mode === "rpc") {
|
||||||
// RPC mode - headless operation
|
// RPC mode - headless operation
|
||||||
await runRpcMode(agent, sessionManager);
|
await runRpcMode(agent, sessionManager, settingsManager);
|
||||||
} else if (isInteractive) {
|
} else if (isInteractive) {
|
||||||
// Check for new version (don't block startup if it takes too long)
|
// Check for new version (don't block startup if it takes too long)
|
||||||
let newVersion: string | null = null;
|
let newVersion: string | null = null;
|
||||||
|
|
|
||||||
|
|
@ -561,4 +561,36 @@ export class SessionManager {
|
||||||
|
|
||||||
return newSessionFile;
|
return newSessionFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a branched session from session entries up to (but not including) a specific entry index.
|
||||||
|
* This preserves compaction events and all entry types.
|
||||||
|
* Returns the new session file path.
|
||||||
|
*/
|
||||||
|
createBranchedSessionFromEntries(entries: SessionEntry[], branchBeforeIndex: number): string {
|
||||||
|
const newSessionId = uuidv4();
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const newSessionFile = join(this.sessionDir, `${timestamp}_${newSessionId}.jsonl`);
|
||||||
|
|
||||||
|
// Copy all entries up to (but not including) the branch point
|
||||||
|
for (let i = 0; i < branchBeforeIndex; i++) {
|
||||||
|
const entry = entries[i];
|
||||||
|
|
||||||
|
if (entry.type === "session") {
|
||||||
|
// Rewrite session header with new ID and branchedFrom
|
||||||
|
const newHeader: SessionHeader = {
|
||||||
|
...entry,
|
||||||
|
id: newSessionId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
branchedFrom: this.sessionFile,
|
||||||
|
};
|
||||||
|
appendFileSync(newSessionFile, JSON.stringify(newHeader) + "\n");
|
||||||
|
} else {
|
||||||
|
// Copy other entries as-is
|
||||||
|
appendFileSync(newSessionFile, JSON.stringify(entry) + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newSessionFile;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { getAgentDir } from "./config.js";
|
import { getAgentDir } from "./config.js";
|
||||||
|
|
||||||
|
export interface CompactionSettings {
|
||||||
|
enabled?: boolean; // default: true
|
||||||
|
reserveTokens?: number; // default: 16384
|
||||||
|
keepRecentTokens?: number; // default: 20000
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
lastChangelogVersion?: string;
|
lastChangelogVersion?: string;
|
||||||
defaultProvider?: string;
|
defaultProvider?: string;
|
||||||
|
|
@ -9,6 +15,7 @@ export interface Settings {
|
||||||
defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high";
|
defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high";
|
||||||
queueMode?: "all" | "one-at-a-time";
|
queueMode?: "all" | "one-at-a-time";
|
||||||
theme?: string;
|
theme?: string;
|
||||||
|
compaction?: CompactionSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SettingsManager {
|
export class SettingsManager {
|
||||||
|
|
@ -108,4 +115,32 @@ export class SettingsManager {
|
||||||
this.settings.defaultThinkingLevel = level;
|
this.settings.defaultThinkingLevel = level;
|
||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getCompactionEnabled(): boolean {
|
||||||
|
return this.settings.compaction?.enabled ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCompactionEnabled(enabled: boolean): void {
|
||||||
|
if (!this.settings.compaction) {
|
||||||
|
this.settings.compaction = {};
|
||||||
|
}
|
||||||
|
this.settings.compaction.enabled = enabled;
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCompactionReserveTokens(): number {
|
||||||
|
return this.settings.compaction?.reserveTokens ?? 16384;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCompactionKeepRecentTokens(): number {
|
||||||
|
return this.settings.compaction?.keepRecentTokens ?? 20000;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCompactionSettings(): { enabled: boolean; reserveTokens: number; keepRecentTokens: number } {
|
||||||
|
return {
|
||||||
|
enabled: this.getCompactionEnabled(),
|
||||||
|
reserveTokens: this.getCompactionReserveTokens(),
|
||||||
|
keepRecentTokens: this.getCompactionKeepRecentTokens(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,16 @@ export class FooterComponent implements Component {
|
||||||
private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
|
private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
|
||||||
private gitWatcher: FSWatcher | null = null;
|
private gitWatcher: FSWatcher | null = null;
|
||||||
private onBranchChange: (() => void) | null = null;
|
private onBranchChange: (() => void) | null = null;
|
||||||
|
private autoCompactEnabled: boolean = true;
|
||||||
|
|
||||||
constructor(state: AgentState) {
|
constructor(state: AgentState) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAutoCompactEnabled(enabled: boolean): void {
|
||||||
|
this.autoCompactEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up a file watcher on .git/HEAD to detect branch changes.
|
* Set up a file watcher on .git/HEAD to detect branch changes.
|
||||||
* Call the provided callback when branch changes.
|
* Call the provided callback when branch changes.
|
||||||
|
|
@ -180,12 +185,13 @@ export class FooterComponent implements Component {
|
||||||
|
|
||||||
// Colorize context percentage based on usage
|
// Colorize context percentage based on usage
|
||||||
let contextPercentStr: string;
|
let contextPercentStr: string;
|
||||||
|
const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
|
||||||
if (contextPercentValue > 90) {
|
if (contextPercentValue > 90) {
|
||||||
contextPercentStr = theme.fg("error", `${contextPercent}%`);
|
contextPercentStr = theme.fg("error", `${contextPercent}%${autoIndicator}`);
|
||||||
} else if (contextPercentValue > 70) {
|
} else if (contextPercentValue > 70) {
|
||||||
contextPercentStr = theme.fg("warning", `${contextPercent}%`);
|
contextPercentStr = theme.fg("warning", `${contextPercent}%${autoIndicator}`);
|
||||||
} else {
|
} else {
|
||||||
contextPercentStr = `${contextPercent}%`;
|
contextPercentStr = `${contextPercent}%${autoIndicator}`;
|
||||||
}
|
}
|
||||||
statsParts.push(contextPercentStr);
|
statsParts.push(contextPercentStr);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,12 @@ import {
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { getChangelogPath, parseChangelog } from "../changelog.js";
|
import { getChangelogPath, parseChangelog } from "../changelog.js";
|
||||||
|
import { calculateContextTokens, compact, getLastAssistantUsage, shouldCompact } from "../compaction.js";
|
||||||
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 } from "../oauth/index.js";
|
||||||
import type { SessionManager } from "../session-manager.js";
|
import { loadSessionFromEntries, type SessionManager } 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";
|
||||||
|
|
@ -129,6 +130,7 @@ export class TuiRenderer {
|
||||||
this.editorContainer = new Container(); // Container to hold editor or selector
|
this.editorContainer = new Container(); // Container to hold editor or selector
|
||||||
this.editorContainer.addChild(this.editor); // Start with editor
|
this.editorContainer.addChild(this.editor); // Start with editor
|
||||||
this.footer = new FooterComponent(agent.state);
|
this.footer = new FooterComponent(agent.state);
|
||||||
|
this.footer.setAutoCompactEnabled(this.settingsManager.getCompactionEnabled());
|
||||||
|
|
||||||
// Define slash commands
|
// Define slash commands
|
||||||
const thinkingCommand: SlashCommand = {
|
const thinkingCommand: SlashCommand = {
|
||||||
|
|
@ -418,6 +420,21 @@ export class TuiRenderer {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for /compact command
|
||||||
|
if (text === "/compact" || text.startsWith("/compact ")) {
|
||||||
|
const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
|
||||||
|
this.handleCompactCommand(customInstructions);
|
||||||
|
this.editor.setText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for /autocompact command
|
||||||
|
if (text === "/autocompact") {
|
||||||
|
this.handleAutocompactCommand();
|
||||||
|
this.editor.setText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for /debug command
|
// Check for /debug command
|
||||||
if (text === "/debug") {
|
if (text === "/debug") {
|
||||||
this.handleDebugCommand();
|
this.handleDebugCommand();
|
||||||
|
|
@ -511,10 +528,84 @@ export class TuiRenderer {
|
||||||
if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {
|
if (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {
|
||||||
this.sessionManager.startSession(this.agent.state);
|
this.sessionManager.startSession(this.agent.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for auto-compaction after assistant messages
|
||||||
|
if (event.message.role === "assistant") {
|
||||||
|
await this.checkAutoCompaction();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async checkAutoCompaction(): Promise<void> {
|
||||||
|
const settings = this.settingsManager.getCompactionSettings();
|
||||||
|
if (!settings.enabled) return;
|
||||||
|
|
||||||
|
// Get last assistant usage
|
||||||
|
const entries = this.sessionManager.loadEntries();
|
||||||
|
const lastUsage = getLastAssistantUsage(entries);
|
||||||
|
if (!lastUsage) return;
|
||||||
|
|
||||||
|
const contextTokens = calculateContextTokens(lastUsage);
|
||||||
|
const contextWindow = this.agent.state.model.contextWindow;
|
||||||
|
|
||||||
|
if (!shouldCompact(contextTokens, contextWindow, settings)) return;
|
||||||
|
|
||||||
|
// Trigger auto-compaction
|
||||||
|
await this.handleAutoCompaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
if (!this.isInitialized) {
|
if (!this.isInitialized) {
|
||||||
await this.init();
|
await this.init();
|
||||||
|
|
@ -784,6 +875,50 @@ export class TuiRenderer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private rebuildChatFromMessages(): void {
|
||||||
|
// Reset state and re-render messages from agent state
|
||||||
|
this.isFirstUserMessage = true;
|
||||||
|
this.pendingTools.clear();
|
||||||
|
|
||||||
|
for (const message of this.agent.state.messages) {
|
||||||
|
if (message.role === "user") {
|
||||||
|
const userMsg = message as any;
|
||||||
|
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
|
||||||
|
const textContent = textBlocks.map((c: any) => c.text).join("");
|
||||||
|
if (textContent) {
|
||||||
|
const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);
|
||||||
|
this.chatContainer.addChild(userComponent);
|
||||||
|
this.isFirstUserMessage = false;
|
||||||
|
}
|
||||||
|
} else if (message.role === "assistant") {
|
||||||
|
const assistantMsg = message as AssistantMessage;
|
||||||
|
const assistantComponent = new AssistantMessageComponent(assistantMsg);
|
||||||
|
this.chatContainer.addChild(assistantComponent);
|
||||||
|
|
||||||
|
for (const content of assistantMsg.content) {
|
||||||
|
if (content.type === "toolCall") {
|
||||||
|
const component = new ToolExecutionComponent(content.name, content.arguments);
|
||||||
|
this.chatContainer.addChild(component);
|
||||||
|
this.pendingTools.set(content.id, component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (message.role === "toolResult") {
|
||||||
|
const component = this.pendingTools.get(message.toolCallId);
|
||||||
|
if (component) {
|
||||||
|
component.updateResult({
|
||||||
|
content: message.content,
|
||||||
|
details: message.details,
|
||||||
|
isError: message.isError,
|
||||||
|
});
|
||||||
|
this.pendingTools.delete(message.toolCallId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingTools.clear();
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
private handleCtrlC(): void {
|
private handleCtrlC(): void {
|
||||||
// Handle Ctrl+C double-press logic
|
// Handle Ctrl+C double-press logic
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -977,6 +1112,15 @@ export class TuiRenderer {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private showSuccess(message: string, detail?: string): void {
|
||||||
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
|
const text = detail
|
||||||
|
? `${theme.fg("success", message)}\n${theme.fg("muted", detail)}`
|
||||||
|
: theme.fg("success", message);
|
||||||
|
this.chatContainer.addChild(new Text(text, 1, 1));
|
||||||
|
this.ui.requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
private showThinkingSelector(): void {
|
private showThinkingSelector(): void {
|
||||||
// Create thinking selector with current level
|
// Create thinking selector with current level
|
||||||
this.thinkingSelector = new ThinkingSelectorComponent(
|
this.thinkingSelector = new ThinkingSelectorComponent(
|
||||||
|
|
@ -1176,18 +1320,30 @@ export class TuiRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
private showUserMessageSelector(): void {
|
private showUserMessageSelector(): void {
|
||||||
// Extract all user messages from the current state
|
// Read from session file directly to see ALL historical user messages
|
||||||
|
// (including those before compaction events)
|
||||||
|
const entries = this.sessionManager.loadEntries();
|
||||||
const userMessages: Array<{ index: number; text: string }> = [];
|
const userMessages: Array<{ index: number; text: string }> = [];
|
||||||
|
|
||||||
for (let i = 0; i < this.agent.state.messages.length; i++) {
|
const getUserMessageText = (content: string | Array<{ type: string; text?: string }>): string => {
|
||||||
const message = this.agent.state.messages[i];
|
if (typeof content === "string") return content;
|
||||||
if (message.role === "user") {
|
if (Array.isArray(content)) {
|
||||||
const userMsg = message as any;
|
return content
|
||||||
const textBlocks = userMsg.content.filter((c: any) => c.type === "text");
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||||
const textContent = textBlocks.map((c: any) => c.text).join("");
|
.map((c) => c.text)
|
||||||
if (textContent) {
|
.join("");
|
||||||
userMessages.push({ index: i, text: textContent });
|
}
|
||||||
}
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
const entry = entries[i];
|
||||||
|
if (entry.type !== "message") continue;
|
||||||
|
if (entry.message.role !== "user") continue;
|
||||||
|
|
||||||
|
const textContent = getUserMessageText(entry.message.content);
|
||||||
|
if (textContent) {
|
||||||
|
userMessages.push({ index: i, text: textContent });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1202,22 +1358,23 @@ export class TuiRenderer {
|
||||||
// Create user message selector
|
// Create user message selector
|
||||||
this.userMessageSelector = new UserMessageSelectorComponent(
|
this.userMessageSelector = new UserMessageSelectorComponent(
|
||||||
userMessages,
|
userMessages,
|
||||||
(messageIndex) => {
|
(entryIndex) => {
|
||||||
// Get the selected user message text to put in the editor
|
// Get the selected user message text to put in the editor
|
||||||
const selectedMessage = this.agent.state.messages[messageIndex];
|
const selectedEntry = entries[entryIndex];
|
||||||
const selectedUserMsg = selectedMessage as any;
|
if (selectedEntry.type !== "message") return;
|
||||||
const textBlocks = selectedUserMsg.content.filter((c: any) => c.type === "text");
|
if (selectedEntry.message.role !== "user") return;
|
||||||
const selectedText = textBlocks.map((c: any) => c.text).join("");
|
|
||||||
|
|
||||||
// Create a branched session with messages UP TO (but not including) the selected message
|
const selectedText = getUserMessageText(selectedEntry.message.content);
|
||||||
const newSessionFile = this.sessionManager.createBranchedSession(this.agent.state, messageIndex - 1);
|
|
||||||
|
// Create a branched session by copying entries up to (but not including) the selected entry
|
||||||
|
const newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);
|
||||||
|
|
||||||
// Set the new session file as active
|
// Set the new session file as active
|
||||||
this.sessionManager.setSessionFile(newSessionFile);
|
this.sessionManager.setSessionFile(newSessionFile);
|
||||||
|
|
||||||
// Truncate messages in agent state to before the selected message
|
// Reload the session
|
||||||
const truncatedMessages = this.agent.state.messages.slice(0, messageIndex);
|
const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
|
||||||
this.agent.replaceMessages(truncatedMessages);
|
this.agent.replaceMessages(loaded.messages);
|
||||||
|
|
||||||
// Clear and re-render the chat
|
// Clear and re-render the chat
|
||||||
this.chatContainer.clear();
|
this.chatContainer.clear();
|
||||||
|
|
@ -1226,9 +1383,7 @@ export class TuiRenderer {
|
||||||
|
|
||||||
// Show confirmation message
|
// Show confirmation message
|
||||||
this.chatContainer.addChild(new Spacer(1));
|
this.chatContainer.addChild(new Spacer(1));
|
||||||
this.chatContainer.addChild(
|
this.chatContainer.addChild(new Text(theme.fg("dim", "Branched to new session"), 1, 0));
|
||||||
new Text(theme.fg("dim", `Branched to new session from message ${messageIndex}`), 1, 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Put the selected message in the editor
|
// Put the selected message in the editor
|
||||||
this.editor.setText(selectedText);
|
this.editor.setText(selectedText);
|
||||||
|
|
@ -1570,6 +1725,89 @@ export class TuiRenderer {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe first to prevent processing events during compaction
|
||||||
|
this.unsubscribe?.();
|
||||||
|
|
||||||
|
// Abort 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", "Compacting context..."), 1, 1));
|
||||||
|
this.ui.requestRender();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get API key for current model
|
||||||
|
const apiKey = await getApiKeyForModel(this.agent.state.model);
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(`No API key for ${this.agent.state.model.provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform compaction
|
||||||
|
const settings = this.settingsManager.getCompactionSettings();
|
||||||
|
const compactionEntry = await compact(
|
||||||
|
entries,
|
||||||
|
this.agent.state.model,
|
||||||
|
settings,
|
||||||
|
apiKey,
|
||||||
|
undefined,
|
||||||
|
customInstructions,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save compaction to session
|
||||||
|
this.sessionManager.saveCompaction(compactionEntry);
|
||||||
|
|
||||||
|
// Reload session
|
||||||
|
const loaded = loadSessionFromEntries(this.sessionManager.loadEntries());
|
||||||
|
this.agent.replaceMessages(loaded.messages);
|
||||||
|
|
||||||
|
// Rebuild UI
|
||||||
|
this.chatContainer.clear();
|
||||||
|
this.rebuildChatFromMessages();
|
||||||
|
|
||||||
|
// Show success
|
||||||
|
this.showSuccess(
|
||||||
|
"✓ Context compacted",
|
||||||
|
`Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.showError(`Compaction failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resubscribe to agent
|
||||||
|
this.subscribeToAgent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAutocompactCommand(): void {
|
||||||
|
const currentEnabled = this.settingsManager.getCompactionEnabled();
|
||||||
|
const newState = !currentEnabled;
|
||||||
|
this.settingsManager.setCompactionEnabled(newState);
|
||||||
|
this.footer.setAutoCompactEnabled(newState);
|
||||||
|
|
||||||
|
this.showSuccess(
|
||||||
|
`✓ Auto-compact ${newState ? "enabled" : "disabled"}`,
|
||||||
|
newState ? "Context will be compacted automatically when nearing limits" : "Use /compact to manually compact",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private updatePendingMessagesDisplay(): void {
|
private updatePendingMessagesDisplay(): void {
|
||||||
this.pendingMessagesContainer.clear();
|
this.pendingMessagesContainer.clear();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue