refactor: extract slash command handlers from interactive-mode (#1485)
Move slash command dispatch logic and 12 individual command handlers (/export, /share, /copy, /name, /session, /changelog, /hotkeys, /compact, /thinking, /edit-mode, /arminsayshi, plus showThinkingSelector) into a new slash-command-handlers.ts module. InteractiveMode now delegates to dispatchSlashCommand() via a SlashCommandContext interface, keeping the integration surface minimal. Handlers that are also invoked from keybindings/events remain on InteractiveMode and are accessed through the context. Reduces interactive-mode.ts from 4,783 to 4,272 lines (-511).
This commit is contained in:
parent
4e29ca4544
commit
334a7cf076
2 changed files with 703 additions and 561 deletions
|
|
@ -7,11 +7,10 @@ import * as crypto from "node:crypto";
|
|||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { AgentMessage, ThinkingLevel } from "@gsd/pi-agent-core";
|
||||
import type { AgentMessage } from "@gsd/pi-agent-core";
|
||||
import type { AssistantMessage, ImageContent, Message, Model, OAuthProviderId } from "@gsd/pi-ai";
|
||||
import type {
|
||||
AutocompleteItem,
|
||||
EditorAction,
|
||||
EditorComponent,
|
||||
EditorTheme,
|
||||
KeyId,
|
||||
|
|
@ -40,7 +39,6 @@ import {
|
|||
APP_NAME,
|
||||
getAuthPath,
|
||||
getDebugLogPath,
|
||||
getShareViewerUrl,
|
||||
getUpdateInstruction,
|
||||
VERSION,
|
||||
} from "../../config.js";
|
||||
|
|
@ -62,10 +60,8 @@ import { type SessionContext, SessionManager } from "../../core/session-manager.
|
|||
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.js";
|
||||
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
|
||||
import { copyToClipboard } from "../../utils/clipboard.js";
|
||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
|
||||
import { ensureTool } from "../../utils/tools-manager.js";
|
||||
import { ArminComponent } from "./components/armin.js";
|
||||
import { AssistantMessageComponent } from "./components/assistant-message.js";
|
||||
import { BashExecutionComponent } from "./components/bash-execution.js";
|
||||
import { BorderedLoader } from "./components/bordered-loader.js";
|
||||
|
|
@ -86,12 +82,13 @@ import { OAuthSelectorComponent } from "./components/oauth-selector.js";
|
|||
import { ProviderManagerComponent } from "./components/provider-manager.js";
|
||||
import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js";
|
||||
import { SessionSelectorComponent } from "./components/session-selector.js";
|
||||
import { SelectSubmenu, SettingsSelectorComponent, THINKING_DESCRIPTIONS } from "./components/settings-selector.js";
|
||||
import { SettingsSelectorComponent } from "./components/settings-selector.js";
|
||||
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.js";
|
||||
import { ToolExecutionComponent } from "./components/tool-execution.js";
|
||||
import { TreeSelectorComponent } from "./components/tree-selector.js";
|
||||
import { UserMessageComponent } from "./components/user-message.js";
|
||||
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
|
||||
import { type SlashCommandContext, dispatchSlashCommand, getAppKeyDisplay } from "./slash-command-handlers.js";
|
||||
import {
|
||||
getAvailableThemes,
|
||||
getAvailableThemesWithPaths,
|
||||
|
|
@ -1980,135 +1977,57 @@ export class InteractiveMode {
|
|||
}
|
||||
}
|
||||
|
||||
private getSlashCommandContext(): SlashCommandContext {
|
||||
return {
|
||||
session: this.session,
|
||||
ui: this.ui,
|
||||
keybindings: this.keybindings,
|
||||
chatContainer: this.chatContainer,
|
||||
statusContainer: this.statusContainer,
|
||||
editorContainer: this.editorContainer,
|
||||
headerContainer: this.headerContainer,
|
||||
pendingMessagesContainer: this.pendingMessagesContainer,
|
||||
editor: this.editor,
|
||||
defaultEditor: this.defaultEditor,
|
||||
sessionManager: this.sessionManager,
|
||||
settingsManager: this.settingsManager,
|
||||
invalidateFooter: () => this.footer.invalidate(),
|
||||
showStatus: (msg) => this.showStatus(msg),
|
||||
showError: (msg) => this.showError(msg),
|
||||
showWarning: (msg) => this.showWarning(msg),
|
||||
showSelector: (create) => this.showSelector(create),
|
||||
updateEditorBorderColor: () => this.updateEditorBorderColor(),
|
||||
getMarkdownThemeWithSettings: () => this.getMarkdownThemeWithSettings(),
|
||||
requestRender: () => this.ui.requestRender(),
|
||||
updateTerminalTitle: () => this.updateTerminalTitle(),
|
||||
showSettingsSelector: () => this.showSettingsSelector(),
|
||||
showModelsSelector: () => this.showModelsSelector(),
|
||||
handleModelCommand: (searchTerm) => this.handleModelCommand(searchTerm),
|
||||
showUserMessageSelector: () => this.showUserMessageSelector(),
|
||||
showTreeSelector: () => this.showTreeSelector(),
|
||||
showProviderManager: () => this.showProviderManager(),
|
||||
showOAuthSelector: (mode) => this.showOAuthSelector(mode),
|
||||
showSessionSelector: () => this.showSessionSelector(),
|
||||
handleClearCommand: () => this.handleClearCommand(),
|
||||
handleReloadCommand: () => this.handleReloadCommand(),
|
||||
handleDebugCommand: () => this.handleDebugCommand(),
|
||||
shutdown: () => this.shutdown(),
|
||||
executeCompaction: (instructions, isAuto) => this.executeCompaction(instructions, isAuto),
|
||||
};
|
||||
}
|
||||
|
||||
private setupEditorSubmitHandler(): void {
|
||||
this.defaultEditor.onSubmit = async (text: string) => {
|
||||
text = text.trim();
|
||||
if (!text) return;
|
||||
|
||||
// Handle commands
|
||||
if (text === "/settings") {
|
||||
this.showSettingsSelector();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/scoped-models") {
|
||||
this.editor.setText("");
|
||||
await this.showModelsSelector();
|
||||
return;
|
||||
}
|
||||
if (text === "/model" || text.startsWith("/model ")) {
|
||||
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
|
||||
this.editor.setText("");
|
||||
await this.handleModelCommand(searchTerm);
|
||||
return;
|
||||
}
|
||||
if (text.startsWith("/export")) {
|
||||
await this.handleExportCommand(text);
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/share") {
|
||||
await this.handleShareCommand();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/copy") {
|
||||
this.handleCopyCommand();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/name" || text.startsWith("/name ")) {
|
||||
this.handleNameCommand(text);
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/session") {
|
||||
this.handleSessionCommand();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/changelog") {
|
||||
this.handleChangelogCommand();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/hotkeys") {
|
||||
this.handleHotkeysCommand();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/fork") {
|
||||
this.showUserMessageSelector();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/tree") {
|
||||
this.showTreeSelector();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/provider") {
|
||||
this.showProviderManager();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/login") {
|
||||
this.showOAuthSelector("login");
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/logout") {
|
||||
this.showOAuthSelector("logout");
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/new") {
|
||||
this.editor.setText("");
|
||||
await this.handleClearCommand();
|
||||
return;
|
||||
}
|
||||
if (text === "/compact" || text.startsWith("/compact ")) {
|
||||
const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
|
||||
this.editor.setText("");
|
||||
await this.handleCompactCommand(customInstructions);
|
||||
return;
|
||||
}
|
||||
if (text === "/reload") {
|
||||
this.editor.setText("");
|
||||
await this.handleReloadCommand();
|
||||
return;
|
||||
}
|
||||
if (text === "/thinking" || text.startsWith("/thinking ")) {
|
||||
const arg = text.startsWith("/thinking ") ? text.slice(10).trim() : undefined;
|
||||
this.editor.setText("");
|
||||
this.handleThinkingCommand(arg);
|
||||
return;
|
||||
}
|
||||
if (text === "/edit-mode" || text.startsWith("/edit-mode ")) {
|
||||
const arg = text.startsWith("/edit-mode ") ? text.slice(11).trim() : undefined;
|
||||
this.editor.setText("");
|
||||
this.handleEditModeCommand(arg);
|
||||
return;
|
||||
}
|
||||
if (text === "/debug") {
|
||||
this.handleDebugCommand();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/arminsayshi") {
|
||||
this.handleArminSaysHi();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/resume") {
|
||||
this.showSessionSelector();
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
if (text === "/quit") {
|
||||
this.editor.setText("");
|
||||
await this.shutdown();
|
||||
return;
|
||||
// Handle slash commands
|
||||
if (text.startsWith("/")) {
|
||||
const handled = await dispatchSlashCommand(text, this.getSlashCommandContext());
|
||||
if (handled) {
|
||||
this.editor.setText("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle bash command (! for normal, !! for excluded from context)
|
||||
|
|
@ -2897,78 +2816,6 @@ export class InteractiveMode {
|
|||
}
|
||||
}
|
||||
|
||||
private handleThinkingCommand(arg?: string): void {
|
||||
if (!this.session.supportsThinking()) {
|
||||
this.showStatus("Current model does not support thinking");
|
||||
return;
|
||||
}
|
||||
|
||||
const availableLevels = this.session.getAvailableThinkingLevels();
|
||||
|
||||
if (arg) {
|
||||
const level = arg.toLowerCase();
|
||||
if (!availableLevels.includes(level as ThinkingLevel)) {
|
||||
this.showStatus(`Invalid thinking level "${arg}". Available: ${availableLevels.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
this.session.setThinkingLevel(level as ThinkingLevel);
|
||||
this.footer.invalidate();
|
||||
this.updateEditorBorderColor();
|
||||
this.showStatus(`Thinking level: ${level}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showThinkingSelector();
|
||||
}
|
||||
|
||||
private handleEditModeCommand(arg?: string): void {
|
||||
const modes = ["standard", "hashline"] as const;
|
||||
|
||||
if (arg) {
|
||||
const mode = arg.toLowerCase();
|
||||
if (!modes.includes(mode as typeof modes[number])) {
|
||||
this.showStatus(`Invalid edit mode "${arg}". Available: standard, hashline`);
|
||||
return;
|
||||
}
|
||||
this.session.setEditMode(mode as "standard" | "hashline");
|
||||
this.showStatus(`Edit mode: ${mode}${mode === "hashline" ? " (LINE#ID anchored edits)" : " (text-match edits)"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle
|
||||
const current = this.session.editMode;
|
||||
const next = current === "standard" ? "hashline" : "standard";
|
||||
this.session.setEditMode(next);
|
||||
this.showStatus(`Edit mode: ${next}${next === "hashline" ? " (LINE#ID anchored edits)" : " (text-match edits)"}`);
|
||||
}
|
||||
|
||||
private showThinkingSelector(): void {
|
||||
const availableLevels = this.session.getAvailableThinkingLevels();
|
||||
this.showSelector((done) => {
|
||||
const selector = new SelectSubmenu(
|
||||
"Thinking Level",
|
||||
"Select reasoning depth for thinking-capable models",
|
||||
availableLevels.map((level) => ({
|
||||
value: level,
|
||||
label: level,
|
||||
description: THINKING_DESCRIPTIONS[level],
|
||||
})),
|
||||
this.session.thinkingLevel,
|
||||
(value) => {
|
||||
this.session.setThinkingLevel(value as ThinkingLevel);
|
||||
this.footer.invalidate();
|
||||
this.updateEditorBorderColor();
|
||||
done();
|
||||
this.showStatus(`Thinking level: ${value}`);
|
||||
},
|
||||
() => {
|
||||
done();
|
||||
},
|
||||
);
|
||||
return { component: selector, focus: selector };
|
||||
});
|
||||
}
|
||||
|
||||
private async cycleModel(direction: "forward" | "backward"): Promise<void> {
|
||||
try {
|
||||
const result = await this.session.cycleModel(direction);
|
||||
|
|
@ -3159,7 +3006,7 @@ export class InteractiveMode {
|
|||
const text = theme.fg("dim", `Follow-up: ${message}`);
|
||||
this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0));
|
||||
}
|
||||
const dequeueHint = this.getAppKeyDisplay("dequeue");
|
||||
const dequeueHint = getAppKeyDisplay(this.keybindings, "dequeue");
|
||||
const hintText = theme.fg("dim", `↳ ${dequeueHint} to edit all queued messages`);
|
||||
this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
|
||||
}
|
||||
|
|
@ -4191,346 +4038,6 @@ export class InteractiveMode {
|
|||
}
|
||||
}
|
||||
|
||||
private async handleExportCommand(text: string): Promise<void> {
|
||||
const parts = text.split(/\s+/);
|
||||
const outputPath = parts.length > 1 ? parts[1] : undefined;
|
||||
|
||||
try {
|
||||
const filePath = await this.session.exportToHtml(outputPath);
|
||||
this.showStatus(`Session exported to: ${filePath}`);
|
||||
} catch (error: unknown) {
|
||||
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleShareCommand(): Promise<void> {
|
||||
// Check if gh is available and logged in
|
||||
try {
|
||||
const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" });
|
||||
if (authResult.status !== 0) {
|
||||
this.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
|
||||
return;
|
||||
}
|
||||
|
||||
// Export to a temp file
|
||||
const tmpFile = path.join(os.tmpdir(), "session.html");
|
||||
try {
|
||||
await this.session.exportToHtml(tmpFile);
|
||||
} catch (error: unknown) {
|
||||
this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show cancellable loader, replacing the editor
|
||||
const loader = new BorderedLoader(this.ui, theme, "Creating gist...");
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(loader);
|
||||
this.ui.setFocus(loader);
|
||||
this.ui.requestRender();
|
||||
|
||||
const restoreEditor = () => {
|
||||
loader.dispose();
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.ui.setFocus(this.editor);
|
||||
try {
|
||||
fs.unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
};
|
||||
|
||||
// Create a secret gist asynchronously
|
||||
let proc: ReturnType<typeof spawn> | null = null;
|
||||
|
||||
loader.onAbort = () => {
|
||||
proc?.kill();
|
||||
restoreEditor();
|
||||
this.showStatus("Share cancelled");
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
|
||||
proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on("close", (code) => resolve({ stdout, stderr, code }));
|
||||
});
|
||||
|
||||
if (loader.signal.aborted) return;
|
||||
|
||||
restoreEditor();
|
||||
|
||||
if (result.code !== 0) {
|
||||
const errorMsg = result.stderr?.trim() || "Unknown error";
|
||||
this.showError(`Failed to create gist: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract gist ID from the URL returned by gh
|
||||
// gh returns something like: https://gist.github.com/username/GIST_ID
|
||||
const gistUrl = result.stdout?.trim();
|
||||
const gistId = gistUrl?.split("/").pop();
|
||||
if (!gistId) {
|
||||
this.showError("Failed to parse gist ID from gh output");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the preview URL
|
||||
const previewUrl = getShareViewerUrl(gistId);
|
||||
this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
|
||||
} catch (error: unknown) {
|
||||
if (!loader.signal.aborted) {
|
||||
restoreEditor();
|
||||
this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleCopyCommand(): void {
|
||||
const text = this.session.getLastAssistantText();
|
||||
if (!text) {
|
||||
this.showError("No agent messages to copy yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
copyToClipboard(text);
|
||||
this.showStatus("Copied last agent message to clipboard");
|
||||
} catch (error) {
|
||||
this.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
private handleNameCommand(text: string): void {
|
||||
const name = text.replace(/^\/name\s*/, "").trim();
|
||||
if (!name) {
|
||||
const currentName = this.sessionManager.getSessionName();
|
||||
if (currentName) {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0));
|
||||
} else {
|
||||
this.showWarning("Usage: /name <name>");
|
||||
}
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionManager.appendSessionInfo(name);
|
||||
this.updateTerminalTitle();
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0));
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private handleSessionCommand(): void {
|
||||
const stats = this.session.getSessionStats();
|
||||
const sessionName = this.sessionManager.getSessionName();
|
||||
|
||||
let info = `${theme.bold("Session Info")}\n\n`;
|
||||
if (sessionName) {
|
||||
info += `${theme.fg("dim", "Name:")} ${sessionName}\n`;
|
||||
}
|
||||
info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`;
|
||||
info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
|
||||
info += `${theme.bold("Messages")}\n`;
|
||||
info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`;
|
||||
info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`;
|
||||
info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`;
|
||||
info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`;
|
||||
info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`;
|
||||
info += `${theme.bold("Tokens")}\n`;
|
||||
info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`;
|
||||
info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`;
|
||||
if (stats.tokens.cacheRead > 0) {
|
||||
info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`;
|
||||
}
|
||||
if (stats.tokens.cacheWrite > 0) {
|
||||
info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`;
|
||||
}
|
||||
info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;
|
||||
|
||||
if (stats.cost > 0) {
|
||||
info += `\n${theme.bold("Cost")}\n`;
|
||||
info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`;
|
||||
}
|
||||
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Text(info, 1, 0));
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private handleChangelogCommand(): void {
|
||||
const changelogPath = getChangelogPath();
|
||||
const allEntries = parseChangelog(changelogPath);
|
||||
|
||||
const changelogMarkdown =
|
||||
allEntries.length > 0
|
||||
? allEntries
|
||||
.reverse()
|
||||
.map((e) => e.content)
|
||||
.join("\n\n")
|
||||
: "No changelog entries found.";
|
||||
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new DynamicBorder());
|
||||
this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, this.getMarkdownThemeWithSettings()));
|
||||
this.chatContainer.addChild(new DynamicBorder());
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capitalize keybinding for display (e.g., "ctrl+c" -> "Ctrl+C").
|
||||
*/
|
||||
private capitalizeKey(key: string): string {
|
||||
return key
|
||||
.split("/")
|
||||
.map((k) =>
|
||||
k
|
||||
.split("+")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("+"),
|
||||
)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capitalized display string for an app keybinding action.
|
||||
*/
|
||||
private getAppKeyDisplay(action: AppAction): string {
|
||||
return this.capitalizeKey(appKey(this.keybindings, action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capitalized display string for an editor keybinding action.
|
||||
*/
|
||||
private getEditorKeyDisplay(action: EditorAction): string {
|
||||
return this.capitalizeKey(editorKey(action));
|
||||
}
|
||||
|
||||
private handleHotkeysCommand(): void {
|
||||
// Navigation keybindings
|
||||
const cursorWordLeft = this.getEditorKeyDisplay("cursorWordLeft");
|
||||
const cursorWordRight = this.getEditorKeyDisplay("cursorWordRight");
|
||||
const cursorLineStart = this.getEditorKeyDisplay("cursorLineStart");
|
||||
const cursorLineEnd = this.getEditorKeyDisplay("cursorLineEnd");
|
||||
const jumpForward = this.getEditorKeyDisplay("jumpForward");
|
||||
const jumpBackward = this.getEditorKeyDisplay("jumpBackward");
|
||||
const pageUp = this.getEditorKeyDisplay("pageUp");
|
||||
const pageDown = this.getEditorKeyDisplay("pageDown");
|
||||
|
||||
// Editing keybindings
|
||||
const submit = this.getEditorKeyDisplay("submit");
|
||||
const newLine = this.getEditorKeyDisplay("newLine");
|
||||
const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward");
|
||||
const deleteWordForward = this.getEditorKeyDisplay("deleteWordForward");
|
||||
const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart");
|
||||
const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd");
|
||||
const yank = this.getEditorKeyDisplay("yank");
|
||||
const yankPop = this.getEditorKeyDisplay("yankPop");
|
||||
const undo = this.getEditorKeyDisplay("undo");
|
||||
const tab = this.getEditorKeyDisplay("tab");
|
||||
|
||||
// App keybindings
|
||||
const interrupt = this.getAppKeyDisplay("interrupt");
|
||||
const clear = this.getAppKeyDisplay("clear");
|
||||
const exit = this.getAppKeyDisplay("exit");
|
||||
const suspend = this.getAppKeyDisplay("suspend");
|
||||
const cycleThinkingLevel = this.getAppKeyDisplay("cycleThinkingLevel");
|
||||
const cycleModelForward = this.getAppKeyDisplay("cycleModelForward");
|
||||
const selectModel = this.getAppKeyDisplay("selectModel");
|
||||
const expandTools = this.getAppKeyDisplay("expandTools");
|
||||
const toggleThinking = this.getAppKeyDisplay("toggleThinking");
|
||||
const externalEditor = this.getAppKeyDisplay("externalEditor");
|
||||
const followUp = this.getAppKeyDisplay("followUp");
|
||||
const dequeue = this.getAppKeyDisplay("dequeue");
|
||||
|
||||
let hotkeys = `
|
||||
**Navigation**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| \`Arrow keys\` | Move cursor / browse history (Up when empty) |
|
||||
| \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
|
||||
| \`${cursorLineStart}\` | Start of line |
|
||||
| \`${cursorLineEnd}\` | End of line |
|
||||
| \`${jumpForward}\` | Jump forward to character |
|
||||
| \`${jumpBackward}\` | Jump backward to character |
|
||||
| \`${pageUp}\` / \`${pageDown}\` | Scroll by page |
|
||||
|
||||
**Editing**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| \`${submit}\` | Send message |
|
||||
| \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} |
|
||||
| \`${deleteWordBackward}\` | Delete word backwards |
|
||||
| \`${deleteWordForward}\` | Delete word forwards |
|
||||
| \`${deleteToLineStart}\` | Delete to start of line |
|
||||
| \`${deleteToLineEnd}\` | Delete to end of line |
|
||||
| \`${yank}\` | Paste the most-recently-deleted text |
|
||||
| \`${yankPop}\` | Cycle through the deleted text after pasting |
|
||||
| \`${undo}\` | Undo |
|
||||
|
||||
**Other**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| \`${tab}\` | Path completion / accept autocomplete |
|
||||
| \`${interrupt}\` | Cancel autocomplete / abort streaming |
|
||||
| \`${clear}\` | Clear editor (first) / exit (second) |
|
||||
| \`${exit}\` | Exit (when editor is empty) |
|
||||
| \`${suspend}\` | Suspend to background |
|
||||
| \`${cycleThinkingLevel}\` | Cycle thinking level |
|
||||
| \`${cycleModelForward}\` | Cycle models |
|
||||
| \`${selectModel}\` | Open model selector |
|
||||
| \`${expandTools}\` | Toggle tool output expansion |
|
||||
| \`${toggleThinking}\` | Toggle thinking block visibility |
|
||||
| \`${externalEditor}\` | Edit message in external editor |
|
||||
| \`${followUp}\` | Queue follow-up message |
|
||||
| \`${dequeue}\` | Restore queued messages |
|
||||
| \`Ctrl+V\` | Paste image from clipboard |
|
||||
| \`/\` | Slash commands |
|
||||
| \`!\` | Run bash command |
|
||||
| \`!!\` | Run bash command (excluded from context) |
|
||||
`;
|
||||
|
||||
// Add extension-registered shortcuts
|
||||
const extensionRunner = this.session.extensionRunner;
|
||||
if (extensionRunner) {
|
||||
const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig());
|
||||
if (shortcuts.size > 0) {
|
||||
hotkeys += `
|
||||
**Extensions**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
`;
|
||||
for (const [key, shortcut] of shortcuts) {
|
||||
const description = shortcut.description ?? shortcut.extensionPath;
|
||||
const keyDisplay = formatKeyForDisplay(key).replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
hotkeys += `| \`${keyDisplay}\` | ${description} |\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new DynamicBorder());
|
||||
this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings()));
|
||||
this.chatContainer.addChild(new DynamicBorder());
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private async handleClearCommand(): Promise<void> {
|
||||
// Stop loading animation
|
||||
if (this.loadingAnimation) {
|
||||
|
|
@ -4589,12 +4096,6 @@ export class InteractiveMode {
|
|||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private handleArminSaysHi(): void {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new ArminComponent(this.ui));
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private handleDaxnuts(): void {
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
this.chatContainer.addChild(new DaxnutsComponent(this.ui));
|
||||
|
|
@ -4696,18 +4197,6 @@ export class InteractiveMode {
|
|||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
private async handleCompactCommand(customInstructions?: string): Promise<void> {
|
||||
const entries = this.sessionManager.getEntries();
|
||||
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 async executeCompaction(customInstructions?: string, isAuto = false): Promise<CompactionResult | undefined> {
|
||||
// Stop loading animation
|
||||
if (this.loadingAnimation) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,653 @@
|
|||
/**
|
||||
* Slash command dispatch and handler implementations extracted from InteractiveMode.
|
||||
*
|
||||
* The `dispatchSlashCommand` function contains the dispatch logic (routing text
|
||||
* to handlers), and individual handler functions implement each command.
|
||||
*
|
||||
* Handlers that are also invoked from keybindings or other subsystems remain on
|
||||
* InteractiveMode and are called through the `SlashCommandContext` interface.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { ThinkingLevel } from "@gsd/pi-agent-core";
|
||||
import type {
|
||||
EditorAction,
|
||||
EditorComponent,
|
||||
MarkdownTheme,
|
||||
} from "@gsd/pi-tui";
|
||||
import {
|
||||
type Component,
|
||||
Container,
|
||||
Markdown,
|
||||
Spacer,
|
||||
Text,
|
||||
} from "@gsd/pi-tui";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
getShareViewerUrl,
|
||||
} from "../../config.js";
|
||||
import type { AgentSession } from "../../core/agent-session.js";
|
||||
import type { AppAction, KeybindingsManager } from "../../core/keybindings.js";
|
||||
import type { SessionManager } from "../../core/session-manager.js";
|
||||
import type { SettingsManager } from "../../core/settings-manager.js";
|
||||
import { copyToClipboard } from "../../utils/clipboard.js";
|
||||
import { getChangelogPath, parseChangelog } from "../../utils/changelog.js";
|
||||
import { ArminComponent } from "./components/armin.js";
|
||||
import { BorderedLoader } from "./components/bordered-loader.js";
|
||||
import { DynamicBorder } from "./components/dynamic-border.js";
|
||||
import { appKey, editorKey, formatKeyForDisplay } from "./components/keybinding-hints.js";
|
||||
import { SelectSubmenu, THINKING_DESCRIPTIONS } from "./components/settings-selector.js";
|
||||
import { theme } from "./theme/theme.js";
|
||||
|
||||
import type { TUI } from "@gsd/pi-tui";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context interface — the subset of InteractiveMode needed by slash commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Provides slash command handlers with access to the parts of InteractiveMode
|
||||
* they need without coupling them to the entire class.
|
||||
*/
|
||||
export interface SlashCommandContext {
|
||||
// Core objects
|
||||
readonly session: AgentSession;
|
||||
readonly ui: TUI;
|
||||
readonly keybindings: KeybindingsManager;
|
||||
|
||||
// Containers
|
||||
readonly chatContainer: Container;
|
||||
readonly statusContainer: Container;
|
||||
readonly editorContainer: Container;
|
||||
readonly headerContainer: Container;
|
||||
readonly pendingMessagesContainer: Container;
|
||||
|
||||
// Editor
|
||||
readonly editor: EditorComponent;
|
||||
readonly defaultEditor: EditorComponent & {
|
||||
onEscape?: () => void;
|
||||
};
|
||||
|
||||
// Accessors
|
||||
readonly sessionManager: SessionManager;
|
||||
readonly settingsManager: SettingsManager;
|
||||
|
||||
// Footer
|
||||
invalidateFooter(): void;
|
||||
|
||||
// UI helpers
|
||||
showStatus(message: string): void;
|
||||
showError(message: string): void;
|
||||
showWarning(message: string): void;
|
||||
showSelector(create: (done: () => void) => { component: Component; focus: Component }): void;
|
||||
updateEditorBorderColor(): void;
|
||||
getMarkdownThemeWithSettings(): MarkdownTheme;
|
||||
requestRender(): void;
|
||||
|
||||
updateTerminalTitle(): void;
|
||||
|
||||
// Methods that stay on InteractiveMode (called from both dispatch and keybindings/events)
|
||||
showSettingsSelector(): void;
|
||||
showModelsSelector(): Promise<void>;
|
||||
handleModelCommand(searchTerm?: string): Promise<void>;
|
||||
showUserMessageSelector(): void;
|
||||
showTreeSelector(): void;
|
||||
showProviderManager(): void;
|
||||
showOAuthSelector(mode: "login" | "logout"): Promise<void>;
|
||||
showSessionSelector(): void;
|
||||
handleClearCommand(): Promise<void>;
|
||||
handleReloadCommand(): Promise<void>;
|
||||
handleDebugCommand(): void;
|
||||
shutdown(): Promise<void>;
|
||||
|
||||
// For compaction
|
||||
executeCompaction(customInstructions?: string, isAuto?: boolean): Promise<unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Routes a slash command string to the appropriate handler.
|
||||
*
|
||||
* @returns `true` if the text was handled as a slash command (caller should
|
||||
* not process it further), `false` otherwise.
|
||||
*/
|
||||
export async function dispatchSlashCommand(
|
||||
text: string,
|
||||
ctx: SlashCommandContext,
|
||||
): Promise<boolean> {
|
||||
if (text === "/settings") {
|
||||
ctx.showSettingsSelector();
|
||||
return true;
|
||||
}
|
||||
if (text === "/scoped-models") {
|
||||
await ctx.showModelsSelector();
|
||||
return true;
|
||||
}
|
||||
if (text === "/model" || text.startsWith("/model ")) {
|
||||
const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined;
|
||||
await ctx.handleModelCommand(searchTerm);
|
||||
return true;
|
||||
}
|
||||
if (text.startsWith("/export")) {
|
||||
await handleExportCommand(text, ctx);
|
||||
return true;
|
||||
}
|
||||
if (text === "/share") {
|
||||
await handleShareCommand(ctx);
|
||||
return true;
|
||||
}
|
||||
if (text === "/copy") {
|
||||
handleCopyCommand(ctx);
|
||||
return true;
|
||||
}
|
||||
if (text === "/name" || text.startsWith("/name ")) {
|
||||
handleNameCommand(text, ctx);
|
||||
return true;
|
||||
}
|
||||
if (text === "/session") {
|
||||
handleSessionCommand(ctx);
|
||||
return true;
|
||||
}
|
||||
if (text === "/changelog") {
|
||||
handleChangelogCommand(ctx);
|
||||
return true;
|
||||
}
|
||||
if (text === "/hotkeys") {
|
||||
handleHotkeysCommand(ctx);
|
||||
return true;
|
||||
}
|
||||
if (text === "/fork") {
|
||||
ctx.showUserMessageSelector();
|
||||
return true;
|
||||
}
|
||||
if (text === "/tree") {
|
||||
ctx.showTreeSelector();
|
||||
return true;
|
||||
}
|
||||
if (text === "/provider") {
|
||||
ctx.showProviderManager();
|
||||
return true;
|
||||
}
|
||||
if (text === "/login") {
|
||||
await ctx.showOAuthSelector("login");
|
||||
return true;
|
||||
}
|
||||
if (text === "/logout") {
|
||||
await ctx.showOAuthSelector("logout");
|
||||
return true;
|
||||
}
|
||||
if (text === "/new") {
|
||||
await ctx.handleClearCommand();
|
||||
return true;
|
||||
}
|
||||
if (text === "/compact" || text.startsWith("/compact ")) {
|
||||
const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined;
|
||||
await handleCompactCommand(customInstructions, ctx);
|
||||
return true;
|
||||
}
|
||||
if (text === "/reload") {
|
||||
await ctx.handleReloadCommand();
|
||||
return true;
|
||||
}
|
||||
if (text === "/thinking" || text.startsWith("/thinking ")) {
|
||||
const arg = text.startsWith("/thinking ") ? text.slice(10).trim() : undefined;
|
||||
handleThinkingCommand(arg, ctx);
|
||||
return true;
|
||||
}
|
||||
if (text === "/edit-mode" || text.startsWith("/edit-mode ")) {
|
||||
const arg = text.startsWith("/edit-mode ") ? text.slice(11).trim() : undefined;
|
||||
handleEditModeCommand(arg, ctx);
|
||||
return true;
|
||||
}
|
||||
if (text === "/debug") {
|
||||
ctx.handleDebugCommand();
|
||||
return true;
|
||||
}
|
||||
if (text === "/arminsayshi") {
|
||||
handleArminSaysHi(ctx);
|
||||
return true;
|
||||
}
|
||||
if (text === "/resume") {
|
||||
ctx.showSessionSelector();
|
||||
return true;
|
||||
}
|
||||
if (text === "/quit") {
|
||||
await ctx.shutdown();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual command handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleExportCommand(text: string, ctx: SlashCommandContext): Promise<void> {
|
||||
const parts = text.split(/\s+/);
|
||||
const outputPath = parts.length > 1 ? parts[1] : undefined;
|
||||
|
||||
try {
|
||||
const filePath = await ctx.session.exportToHtml(outputPath);
|
||||
ctx.showStatus(`Session exported to: ${filePath}`);
|
||||
} catch (error: unknown) {
|
||||
ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShareCommand(ctx: SlashCommandContext): Promise<void> {
|
||||
// Check if gh is available and logged in
|
||||
try {
|
||||
const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" });
|
||||
if (authResult.status !== 0) {
|
||||
ctx.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
ctx.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
|
||||
return;
|
||||
}
|
||||
|
||||
// Export to a temp file
|
||||
const tmpFile = path.join(os.tmpdir(), "session.html");
|
||||
try {
|
||||
await ctx.session.exportToHtml(tmpFile);
|
||||
} catch (error: unknown) {
|
||||
ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show cancellable loader, replacing the editor
|
||||
const loader = new BorderedLoader(ctx.ui, theme, "Creating gist...");
|
||||
ctx.editorContainer.clear();
|
||||
ctx.editorContainer.addChild(loader);
|
||||
ctx.ui.setFocus(loader);
|
||||
ctx.requestRender();
|
||||
|
||||
const restoreEditor = () => {
|
||||
loader.dispose();
|
||||
ctx.editorContainer.clear();
|
||||
ctx.editorContainer.addChild(ctx.editor);
|
||||
ctx.ui.setFocus(ctx.editor);
|
||||
try {
|
||||
fs.unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
};
|
||||
|
||||
// Create a secret gist asynchronously
|
||||
let proc: ReturnType<typeof spawn> | null = null;
|
||||
|
||||
loader.onAbort = () => {
|
||||
proc?.kill();
|
||||
restoreEditor();
|
||||
ctx.showStatus("Share cancelled");
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => {
|
||||
proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on("close", (code) => resolve({ stdout, stderr, code }));
|
||||
});
|
||||
|
||||
if (loader.signal.aborted) return;
|
||||
|
||||
restoreEditor();
|
||||
|
||||
if (result.code !== 0) {
|
||||
const errorMsg = result.stderr?.trim() || "Unknown error";
|
||||
ctx.showError(`Failed to create gist: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract gist ID from the URL returned by gh
|
||||
// gh returns something like: https://gist.github.com/username/GIST_ID
|
||||
const gistUrl = result.stdout?.trim();
|
||||
const gistId = gistUrl?.split("/").pop();
|
||||
if (!gistId) {
|
||||
ctx.showError("Failed to parse gist ID from gh output");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the preview URL
|
||||
const previewUrl = getShareViewerUrl(gistId);
|
||||
ctx.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
|
||||
} catch (error: unknown) {
|
||||
if (!loader.signal.aborted) {
|
||||
restoreEditor();
|
||||
ctx.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyCommand(ctx: SlashCommandContext): void {
|
||||
const text = ctx.session.getLastAssistantText();
|
||||
if (!text) {
|
||||
ctx.showError("No agent messages to copy yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
copyToClipboard(text);
|
||||
ctx.showStatus("Copied last agent message to clipboard");
|
||||
} catch (error) {
|
||||
ctx.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function handleNameCommand(text: string, ctx: SlashCommandContext): void {
|
||||
const name = text.replace(/^\/name\s*/, "").trim();
|
||||
if (!name) {
|
||||
const currentName = ctx.sessionManager.getSessionName();
|
||||
if (currentName) {
|
||||
ctx.chatContainer.addChild(new Spacer(1));
|
||||
ctx.chatContainer.addChild(new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0));
|
||||
} else {
|
||||
ctx.showWarning("Usage: /name <name>");
|
||||
}
|
||||
ctx.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.sessionManager.appendSessionInfo(name);
|
||||
ctx.updateTerminalTitle();
|
||||
ctx.chatContainer.addChild(new Spacer(1));
|
||||
ctx.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0));
|
||||
ctx.requestRender();
|
||||
}
|
||||
|
||||
function handleSessionCommand(ctx: SlashCommandContext): void {
|
||||
const stats = ctx.session.getSessionStats();
|
||||
const sessionName = ctx.sessionManager.getSessionName();
|
||||
|
||||
let info = `${theme.bold("Session Info")}\n\n`;
|
||||
if (sessionName) {
|
||||
info += `${theme.fg("dim", "Name:")} ${sessionName}\n`;
|
||||
}
|
||||
info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`;
|
||||
info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
|
||||
info += `${theme.bold("Messages")}\n`;
|
||||
info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`;
|
||||
info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`;
|
||||
info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`;
|
||||
info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`;
|
||||
info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`;
|
||||
info += `${theme.bold("Tokens")}\n`;
|
||||
info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`;
|
||||
info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`;
|
||||
if (stats.tokens.cacheRead > 0) {
|
||||
info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`;
|
||||
}
|
||||
if (stats.tokens.cacheWrite > 0) {
|
||||
info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`;
|
||||
}
|
||||
info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;
|
||||
|
||||
if (stats.cost > 0) {
|
||||
info += `\n${theme.bold("Cost")}\n`;
|
||||
info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`;
|
||||
}
|
||||
|
||||
ctx.chatContainer.addChild(new Spacer(1));
|
||||
ctx.chatContainer.addChild(new Text(info, 1, 0));
|
||||
ctx.requestRender();
|
||||
}
|
||||
|
||||
function handleChangelogCommand(ctx: SlashCommandContext): void {
|
||||
const changelogPath = getChangelogPath();
|
||||
const allEntries = parseChangelog(changelogPath);
|
||||
|
||||
const changelogMarkdown =
|
||||
allEntries.length > 0
|
||||
? allEntries
|
||||
.reverse()
|
||||
.map((e) => e.content)
|
||||
.join("\n\n")
|
||||
: "No changelog entries found.";
|
||||
|
||||
ctx.chatContainer.addChild(new Spacer(1));
|
||||
ctx.chatContainer.addChild(new DynamicBorder());
|
||||
ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
|
||||
ctx.chatContainer.addChild(new Spacer(1));
|
||||
ctx.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, ctx.getMarkdownThemeWithSettings()));
|
||||
ctx.chatContainer.addChild(new DynamicBorder());
|
||||
ctx.requestRender();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /hotkeys helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function capitalizeKey(key: string): string {
|
||||
return key
|
||||
.split("/")
|
||||
.map((k) =>
|
||||
k
|
||||
.split("+")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join("+"),
|
||||
)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export function getAppKeyDisplay(keybindings: KeybindingsManager, action: AppAction): string {
|
||||
return capitalizeKey(appKey(keybindings, action));
|
||||
}
|
||||
|
||||
function getEditorKeyDisplay(action: EditorAction): string {
|
||||
return capitalizeKey(editorKey(action));
|
||||
}
|
||||
|
||||
function handleHotkeysCommand(ctx: SlashCommandContext): void {
|
||||
// Navigation keybindings
|
||||
const cursorWordLeft = getEditorKeyDisplay("cursorWordLeft");
|
||||
const cursorWordRight = getEditorKeyDisplay("cursorWordRight");
|
||||
const cursorLineStart = getEditorKeyDisplay("cursorLineStart");
|
||||
const cursorLineEnd = getEditorKeyDisplay("cursorLineEnd");
|
||||
const jumpForward = getEditorKeyDisplay("jumpForward");
|
||||
const jumpBackward = getEditorKeyDisplay("jumpBackward");
|
||||
const pageUp = getEditorKeyDisplay("pageUp");
|
||||
const pageDown = getEditorKeyDisplay("pageDown");
|
||||
|
||||
// Editing keybindings
|
||||
const submit = getEditorKeyDisplay("submit");
|
||||
const newLine = getEditorKeyDisplay("newLine");
|
||||
const deleteWordBackward = getEditorKeyDisplay("deleteWordBackward");
|
||||
const deleteWordForward = getEditorKeyDisplay("deleteWordForward");
|
||||
const deleteToLineStart = getEditorKeyDisplay("deleteToLineStart");
|
||||
const deleteToLineEnd = getEditorKeyDisplay("deleteToLineEnd");
|
||||
const yank = getEditorKeyDisplay("yank");
|
||||
const yankPop = getEditorKeyDisplay("yankPop");
|
||||
const undo = getEditorKeyDisplay("undo");
|
||||
const tab = getEditorKeyDisplay("tab");
|
||||
|
||||
// App keybindings
|
||||
const interrupt = getAppKeyDisplay(ctx.keybindings, "interrupt");
|
||||
const clear = getAppKeyDisplay(ctx.keybindings, "clear");
|
||||
const exit = getAppKeyDisplay(ctx.keybindings, "exit");
|
||||
const suspend = getAppKeyDisplay(ctx.keybindings, "suspend");
|
||||
const cycleThinkingLevel = getAppKeyDisplay(ctx.keybindings, "cycleThinkingLevel");
|
||||
const cycleModelForward = getAppKeyDisplay(ctx.keybindings, "cycleModelForward");
|
||||
const selectModel = getAppKeyDisplay(ctx.keybindings, "selectModel");
|
||||
const expandTools = getAppKeyDisplay(ctx.keybindings, "expandTools");
|
||||
const toggleThinking = getAppKeyDisplay(ctx.keybindings, "toggleThinking");
|
||||
const externalEditor = getAppKeyDisplay(ctx.keybindings, "externalEditor");
|
||||
const followUp = getAppKeyDisplay(ctx.keybindings, "followUp");
|
||||
const dequeue = getAppKeyDisplay(ctx.keybindings, "dequeue");
|
||||
|
||||
let hotkeys = `
|
||||
**Navigation**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| \`Arrow keys\` | Move cursor / browse history (Up when empty) |
|
||||
| \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
|
||||
| \`${cursorLineStart}\` | Start of line |
|
||||
| \`${cursorLineEnd}\` | End of line |
|
||||
| \`${jumpForward}\` | Jump forward to character |
|
||||
| \`${jumpBackward}\` | Jump backward to character |
|
||||
| \`${pageUp}\` / \`${pageDown}\` | Scroll by page |
|
||||
|
||||
**Editing**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| \`${submit}\` | Send message |
|
||||
| \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} |
|
||||
| \`${deleteWordBackward}\` | Delete word backwards |
|
||||
| \`${deleteWordForward}\` | Delete word forwards |
|
||||
| \`${deleteToLineStart}\` | Delete to start of line |
|
||||
| \`${deleteToLineEnd}\` | Delete to end of line |
|
||||
| \`${yank}\` | Paste the most-recently-deleted text |
|
||||
| \`${yankPop}\` | Cycle through the deleted text after pasting |
|
||||
| \`${undo}\` | Undo |
|
||||
|
||||
**Other**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| \`${tab}\` | Path completion / accept autocomplete |
|
||||
| \`${interrupt}\` | Cancel autocomplete / abort streaming |
|
||||
| \`${clear}\` | Clear editor (first) / exit (second) |
|
||||
| \`${exit}\` | Exit (when editor is empty) |
|
||||
| \`${suspend}\` | Suspend to background |
|
||||
| \`${cycleThinkingLevel}\` | Cycle thinking level |
|
||||
| \`${cycleModelForward}\` | Cycle models |
|
||||
| \`${selectModel}\` | Open model selector |
|
||||
| \`${expandTools}\` | Toggle tool output expansion |
|
||||
| \`${toggleThinking}\` | Toggle thinking block visibility |
|
||||
| \`${externalEditor}\` | Edit message in external editor |
|
||||
| \`${followUp}\` | Queue follow-up message |
|
||||
| \`${dequeue}\` | Restore queued messages |
|
||||
| \`Ctrl+V\` | Paste image from clipboard |
|
||||
| \`/\` | Slash commands |
|
||||
| \`!\` | Run bash command |
|
||||
| \`!!\` | Run bash command (excluded from context) |
|
||||
`;
|
||||
|
||||
// Add extension-registered shortcuts
|
||||
const extensionRunner = ctx.session.extensionRunner;
|
||||
if (extensionRunner) {
|
||||
const shortcuts = extensionRunner.getShortcuts(ctx.keybindings.getEffectiveConfig());
|
||||
if (shortcuts.size > 0) {
|
||||
hotkeys += `
|
||||
**Extensions**
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
`;
|
||||
for (const [key, shortcut] of shortcuts) {
|
||||
const description = shortcut.description ?? shortcut.extensionPath;
|
||||
const keyDisplay = formatKeyForDisplay(key).replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
hotkeys += `| \`${keyDisplay}\` | ${description} |\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.chatContainer.addChild(new Spacer(1));
|
||||
ctx.chatContainer.addChild(new DynamicBorder());
|
||||
ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));
|
||||
ctx.chatContainer.addChild(new Spacer(1));
|
||||
ctx.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, ctx.getMarkdownThemeWithSettings()));
|
||||
ctx.chatContainer.addChild(new DynamicBorder());
|
||||
ctx.requestRender();
|
||||
}
|
||||
|
||||
async function handleCompactCommand(customInstructions: string | undefined, ctx: SlashCommandContext): Promise<void> {
|
||||
const entries = ctx.sessionManager.getEntries();
|
||||
const messageCount = entries.filter((e) => e.type === "message").length;
|
||||
|
||||
if (messageCount < 2) {
|
||||
ctx.showWarning("Nothing to compact (no messages yet)");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.executeCompaction(customInstructions, false);
|
||||
}
|
||||
|
||||
function handleThinkingCommand(arg: string | undefined, ctx: SlashCommandContext): void {
|
||||
if (!ctx.session.supportsThinking()) {
|
||||
ctx.showStatus("Current model does not support thinking");
|
||||
return;
|
||||
}
|
||||
|
||||
const availableLevels = ctx.session.getAvailableThinkingLevels();
|
||||
|
||||
if (arg) {
|
||||
const level = arg.toLowerCase();
|
||||
if (!availableLevels.includes(level as ThinkingLevel)) {
|
||||
ctx.showStatus(`Invalid thinking level "${arg}". Available: ${availableLevels.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
ctx.session.setThinkingLevel(level as ThinkingLevel);
|
||||
ctx.invalidateFooter();
|
||||
ctx.updateEditorBorderColor();
|
||||
ctx.showStatus(`Thinking level: ${level}`);
|
||||
return;
|
||||
}
|
||||
|
||||
showThinkingSelector(ctx, availableLevels);
|
||||
}
|
||||
|
||||
function showThinkingSelector(ctx: SlashCommandContext, availableLevels: readonly ThinkingLevel[]): void {
|
||||
ctx.showSelector((done) => {
|
||||
const selector = new SelectSubmenu(
|
||||
"Thinking Level",
|
||||
"Select reasoning depth for thinking-capable models",
|
||||
availableLevels.map((level) => ({
|
||||
value: level,
|
||||
label: level,
|
||||
description: THINKING_DESCRIPTIONS[level],
|
||||
})),
|
||||
ctx.session.thinkingLevel,
|
||||
(value) => {
|
||||
ctx.session.setThinkingLevel(value as ThinkingLevel);
|
||||
ctx.invalidateFooter();
|
||||
ctx.updateEditorBorderColor();
|
||||
done();
|
||||
ctx.showStatus(`Thinking level: ${value}`);
|
||||
},
|
||||
() => {
|
||||
done();
|
||||
},
|
||||
);
|
||||
return { component: selector, focus: selector };
|
||||
});
|
||||
}
|
||||
|
||||
function handleEditModeCommand(arg: string | undefined, ctx: SlashCommandContext): void {
|
||||
const modes = ["standard", "hashline"] as const;
|
||||
|
||||
if (arg) {
|
||||
const mode = arg.toLowerCase();
|
||||
if (!modes.includes(mode as typeof modes[number])) {
|
||||
ctx.showStatus(`Invalid edit mode "${arg}". Available: standard, hashline`);
|
||||
return;
|
||||
}
|
||||
ctx.session.setEditMode(mode as "standard" | "hashline");
|
||||
ctx.showStatus(`Edit mode: ${mode}${mode === "hashline" ? " (LINE#ID anchored edits)" : " (text-match edits)"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle
|
||||
const current = ctx.session.editMode;
|
||||
const next = current === "standard" ? "hashline" : "standard";
|
||||
ctx.session.setEditMode(next);
|
||||
ctx.showStatus(`Edit mode: ${next}${next === "hashline" ? " (LINE#ID anchored edits)" : " (text-match edits)"}`);
|
||||
}
|
||||
|
||||
function handleArminSaysHi(ctx: SlashCommandContext): void {
|
||||
ctx.chatContainer.addChild(new Spacer(1));
|
||||
ctx.chatContainer.addChild(new ArminComponent(ctx.ui));
|
||||
ctx.requestRender();
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue