mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 15:03:31 +00:00
feat(coding-agent): ResourceLoader, package management, and /reload command (#645)
- Add ResourceLoader interface and DefaultResourceLoader implementation - Add PackageManager for npm/git extension sources with install/remove/update - Add session.reload() and session.bindExtensions() APIs - Add /reload command in interactive mode - Add CLI flags: --skill, --theme, --prompt-template, --no-themes, --no-prompt-templates - Add pi install/remove/update commands for extension management - Refactor settings.json to use arrays for skills, prompts, themes - Remove legacy SkillsSettings source flags and filters - Update SDK examples and documentation for ResourceLoader pattern - Add theme registration and loadThemeFromPath for dynamic themes - Add getShellEnv to include bin dir in PATH for bash commands
This commit is contained in:
parent
866d21c252
commit
b846a4bfcf
51 changed files with 2724 additions and 1852 deletions
|
|
@ -66,7 +66,6 @@ import { type AppAction, KeybindingsManager } from "../../core/keybindings.js";
|
|||
import { createCompactionSummaryMessage } from "../../core/messages.js";
|
||||
import { resolveModelScope } from "../../core/model-resolver.js";
|
||||
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
|
||||
import { loadProjectContextFiles } from "../../core/system-prompt.js";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.js";
|
||||
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
|
||||
import { copyToClipboard } from "../../utils/clipboard.js";
|
||||
|
|
@ -156,7 +155,6 @@ export class InteractiveMode {
|
|||
private keybindings: KeybindingsManager;
|
||||
private version: string;
|
||||
private isInitialized = false;
|
||||
private hasRenderedInitialMessages = false;
|
||||
private onInputCallback?: (text: string) => void;
|
||||
private loadingAnimation: Loader | undefined = undefined;
|
||||
private readonly defaultWorkingMessage = "Working...";
|
||||
|
|
@ -321,6 +319,7 @@ export class InteractiveMode {
|
|||
{ name: "new", description: "Start a new session" },
|
||||
{ name: "compact", description: "Manually compact the session context" },
|
||||
{ name: "resume", description: "Resume a different session" },
|
||||
{ name: "reload", description: "Reload extensions, skills, prompts, and themes" },
|
||||
];
|
||||
|
||||
// Convert prompt templates to SlashCommand format for autocomplete
|
||||
|
|
@ -617,133 +616,63 @@ export class InteractiveMode {
|
|||
// Extension System
|
||||
// =========================================================================
|
||||
|
||||
private showLoadedResources(options?: { extensionPaths?: string[]; force?: boolean }): void {
|
||||
const shouldShow = options?.force || !this.settingsManager.getQuietStartup();
|
||||
if (!shouldShow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles;
|
||||
if (contextFiles.length > 0) {
|
||||
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
const skills = this.session.skills;
|
||||
if (skills.length > 0) {
|
||||
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
const skillWarnings = this.session.skillWarnings;
|
||||
if (skillWarnings.length > 0) {
|
||||
const warningList = skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
const templates = this.session.promptTemplates;
|
||||
if (templates.length > 0) {
|
||||
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
const extensionPaths = options?.extensionPaths ?? [];
|
||||
if (extensionPaths.length > 0) {
|
||||
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the extension system with TUI-based UI context.
|
||||
*/
|
||||
private async initExtensions(): Promise<void> {
|
||||
// Show discovery info unless silenced
|
||||
if (!this.settingsManager.getQuietStartup()) {
|
||||
// Show loaded project context files
|
||||
const contextFiles = loadProjectContextFiles();
|
||||
if (contextFiles.length > 0) {
|
||||
const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Show loaded skills (already discovered by SDK)
|
||||
const skills = this.session.skills;
|
||||
if (skills.length > 0) {
|
||||
const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Show skill warnings if any
|
||||
const skillWarnings = this.session.skillWarnings;
|
||||
if (skillWarnings.length > 0) {
|
||||
const warningList = skillWarnings
|
||||
.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`))
|
||||
.join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Show loaded prompt templates
|
||||
const templates = this.session.promptTemplates;
|
||||
if (templates.length > 0) {
|
||||
const templateList = templates.map((t) => theme.fg("dim", ` /${t.name} ${t.source}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded prompt templates:\n") + templateList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
}
|
||||
|
||||
const extensionRunner = this.session.extensionRunner;
|
||||
if (!extensionRunner) {
|
||||
return; // No extensions loaded
|
||||
this.showLoadedResources({ extensionPaths: [], force: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create extension UI context
|
||||
const uiContext = this.createExtensionUIContext();
|
||||
|
||||
extensionRunner.initialize(
|
||||
// ExtensionActions - for pi.* API
|
||||
{
|
||||
sendMessage: (message, options) => {
|
||||
const wasStreaming = this.session.isStreaming;
|
||||
this.session
|
||||
.sendCustomMessage(message, options)
|
||||
.then(() => {
|
||||
// Don't rebuild if initial render hasn't happened yet
|
||||
// (renderInitialMessages will handle it)
|
||||
if (!wasStreaming && message.display && this.hasRenderedInitialMessages) {
|
||||
this.rebuildChatFromMessages();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
this.showError(
|
||||
`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
sendUserMessage: (content, options) => {
|
||||
this.session.sendUserMessage(content, options).catch((err) => {
|
||||
this.showError(
|
||||
`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
appendEntry: (customType, data) => {
|
||||
this.sessionManager.appendCustomEntry(customType, data);
|
||||
},
|
||||
setSessionName: (name) => {
|
||||
this.sessionManager.appendSessionInfo(name);
|
||||
this.updateTerminalTitle();
|
||||
},
|
||||
getSessionName: () => {
|
||||
return this.sessionManager.getSessionName();
|
||||
},
|
||||
setLabel: (entryId, label) => {
|
||||
this.sessionManager.appendLabelChange(entryId, label);
|
||||
},
|
||||
getActiveTools: () => this.session.getActiveToolNames(),
|
||||
getAllTools: () => this.session.getAllTools(),
|
||||
setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames),
|
||||
setModel: async (model) => {
|
||||
const key = await this.session.modelRegistry.getApiKey(model);
|
||||
if (!key) return false;
|
||||
await this.session.setModel(model);
|
||||
return true;
|
||||
},
|
||||
getThinkingLevel: () => this.session.thinkingLevel,
|
||||
setThinkingLevel: (level) => this.session.setThinkingLevel(level),
|
||||
},
|
||||
// ExtensionContextActions - for ctx.* in event handlers
|
||||
{
|
||||
getModel: () => this.session.model,
|
||||
isIdle: () => !this.session.isStreaming,
|
||||
abort: () => this.session.abort(),
|
||||
hasPendingMessages: () => this.session.pendingMessageCount > 0,
|
||||
shutdown: () => {
|
||||
this.shutdownRequested = true;
|
||||
},
|
||||
getContextUsage: () => this.session.getContextUsage(),
|
||||
compact: (options) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await this.executeCompaction(options?.customInstructions, false);
|
||||
if (result) {
|
||||
options?.onComplete?.(result);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
options?.onError?.(err);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
// ExtensionCommandContextActions - for ctx.* in command handlers
|
||||
{
|
||||
await this.session.bindExtensions({
|
||||
uiContext,
|
||||
commandContextActions: {
|
||||
waitForIdle: () => this.session.agent.waitForIdle(),
|
||||
newSession: async (options) => {
|
||||
if (this.loadingAnimation) {
|
||||
|
|
@ -808,31 +737,16 @@ export class InteractiveMode {
|
|||
return { cancelled: false };
|
||||
},
|
||||
},
|
||||
uiContext,
|
||||
);
|
||||
|
||||
// Subscribe to extension errors
|
||||
extensionRunner.onError((error) => {
|
||||
this.showExtensionError(error.extensionPath, error.error, error.stack);
|
||||
shutdownHandler: () => {
|
||||
this.shutdownRequested = true;
|
||||
},
|
||||
onError: (error) => {
|
||||
this.showExtensionError(error.extensionPath, error.error, error.stack);
|
||||
},
|
||||
});
|
||||
|
||||
// Set up extension-registered shortcuts
|
||||
this.setupExtensionShortcuts(extensionRunner);
|
||||
|
||||
// Show loaded extensions (unless silenced)
|
||||
if (!this.settingsManager.getQuietStartup()) {
|
||||
const extensionPaths = extensionRunner.getExtensionPaths();
|
||||
if (extensionPaths.length > 0) {
|
||||
const extList = extensionPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n");
|
||||
this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded extensions:\n") + extList, 0, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
}
|
||||
|
||||
// Emit session_start event
|
||||
await extensionRunner.emit({
|
||||
type: "session_start",
|
||||
});
|
||||
this.showLoadedResources({ extensionPaths: extensionRunner.getExtensionPaths(), force: false });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -950,6 +864,38 @@ export class InteractiveMode {
|
|||
this.renderWidgets();
|
||||
}
|
||||
|
||||
private clearExtensionWidgets(): void {
|
||||
for (const widget of this.extensionWidgetsAbove.values()) {
|
||||
widget.dispose?.();
|
||||
}
|
||||
for (const widget of this.extensionWidgetsBelow.values()) {
|
||||
widget.dispose?.();
|
||||
}
|
||||
this.extensionWidgetsAbove.clear();
|
||||
this.extensionWidgetsBelow.clear();
|
||||
this.renderWidgets();
|
||||
}
|
||||
|
||||
private resetExtensionUI(): void {
|
||||
if (this.extensionSelector) {
|
||||
this.hideExtensionSelector();
|
||||
}
|
||||
if (this.extensionInput) {
|
||||
this.hideExtensionInput();
|
||||
}
|
||||
if (this.extensionEditor) {
|
||||
this.hideExtensionEditor();
|
||||
}
|
||||
this.ui.hideOverlay();
|
||||
this.setExtensionFooter(undefined);
|
||||
this.setExtensionHeader(undefined);
|
||||
this.clearExtensionWidgets();
|
||||
this.footerDataProvider.clearExtensionStatuses();
|
||||
this.footer.invalidate();
|
||||
this.setCustomEditorComponent(undefined);
|
||||
this.defaultEditor.onExtensionShortcut = undefined;
|
||||
}
|
||||
|
||||
// Maximum total widget lines to prevent viewport overflow
|
||||
private static readonly MAX_WIDGET_LINES = 10;
|
||||
|
||||
|
|
@ -1608,6 +1554,11 @@ export class InteractiveMode {
|
|||
await this.handleCompactCommand(customInstructions);
|
||||
return;
|
||||
}
|
||||
if (text === "/reload") {
|
||||
this.editor.setText("");
|
||||
await this.handleReloadCommand();
|
||||
return;
|
||||
}
|
||||
if (text === "/debug") {
|
||||
this.handleDebugCommand();
|
||||
this.editor.setText("");
|
||||
|
|
@ -2143,7 +2094,6 @@ export class InteractiveMode {
|
|||
}
|
||||
|
||||
renderInitialMessages(): void {
|
||||
this.hasRenderedInitialMessages = true;
|
||||
// Get aligned messages and entries from session context
|
||||
const context = this.sessionManager.buildSessionContext();
|
||||
this.renderSessionContext(context, {
|
||||
|
|
@ -3276,6 +3226,53 @@ export class InteractiveMode {
|
|||
// Command handlers
|
||||
// =========================================================================
|
||||
|
||||
private async handleReloadCommand(): Promise<void> {
|
||||
if (this.session.isStreaming) {
|
||||
this.showWarning("Wait for the current response to finish before reloading.");
|
||||
return;
|
||||
}
|
||||
if (this.session.isCompacting) {
|
||||
this.showWarning("Wait for compaction to finish before reloading.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetExtensionUI();
|
||||
|
||||
const loader = new BorderedLoader(this.ui, theme, "Reloading resources...", { cancellable: false });
|
||||
const previousEditor = this.editor;
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(loader);
|
||||
this.ui.setFocus(loader);
|
||||
this.ui.requestRender();
|
||||
|
||||
const restoreEditor = () => {
|
||||
loader.dispose();
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(previousEditor);
|
||||
this.ui.setFocus(previousEditor as Component);
|
||||
this.ui.requestRender();
|
||||
};
|
||||
|
||||
try {
|
||||
await this.session.reload();
|
||||
this.rebuildAutocomplete();
|
||||
const runner = this.session.extensionRunner;
|
||||
if (runner) {
|
||||
this.setupExtensionShortcuts(runner);
|
||||
}
|
||||
restoreEditor();
|
||||
this.showLoadedResources({ extensionPaths: runner?.getExtensionPaths() ?? [], force: true });
|
||||
const modelsJsonError = this.session.modelRegistry.getError();
|
||||
if (modelsJsonError) {
|
||||
this.showError(`models.json error: ${modelsJsonError}`);
|
||||
}
|
||||
this.showStatus("Reloaded resources");
|
||||
} catch (error) {
|
||||
restoreEditor();
|
||||
this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleExportCommand(text: string): Promise<void> {
|
||||
const parts = text.split(/\s+/);
|
||||
const outputPath = parts.length > 1 ? parts[1] : undefined;
|
||||
|
|
@ -3632,12 +3629,13 @@ export class InteractiveMode {
|
|||
|
||||
private handleDebugCommand(): void {
|
||||
const width = this.ui.terminal.columns;
|
||||
const height = this.ui.terminal.rows;
|
||||
const allLines = this.ui.render(width);
|
||||
|
||||
const debugLogPath = getDebugLogPath();
|
||||
const debugData = [
|
||||
`Debug output at ${new Date().toISOString()}`,
|
||||
`Terminal width: ${width}`,
|
||||
`Terminal: ${width}x${height}`,
|
||||
`Total lines: ${allLines.length}`,
|
||||
"",
|
||||
"=== All rendered lines with visible widths ===",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue