mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 02:01:29 +00:00
Merge branch 'main' into pb/tui-status-coalesce
This commit is contained in:
commit
ac6f5006a9
216 changed files with 14479 additions and 8725 deletions
|
|
@ -53,16 +53,15 @@ export class AssistantMessageComponent extends Container {
|
|||
|
||||
if (this.hideThinkingBlock) {
|
||||
// Show static "Thinking..." label when hidden
|
||||
this.contentContainer.addChild(new Text(theme.fg("muted", "Thinking..."), 1, 0));
|
||||
this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0));
|
||||
if (hasTextAfter) {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
}
|
||||
} else {
|
||||
// Thinking traces in muted color, italic
|
||||
// Use Markdown component with default text style for consistent styling
|
||||
// Thinking traces in thinkingText color, italic
|
||||
this.contentContainer.addChild(
|
||||
new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
|
||||
color: (text: string) => theme.fg("muted", text),
|
||||
color: (text: string) => theme.fg("thinkingText", text),
|
||||
italic: true,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export class BashExecutionComponent extends Container {
|
|||
private command: string;
|
||||
private outputLines: string[] = [];
|
||||
private status: "running" | "complete" | "cancelled" | "error" = "running";
|
||||
private exitCode: number | null = null;
|
||||
private exitCode: number | undefined = undefined;
|
||||
private loader: Loader;
|
||||
private truncationResult?: TruncationResult;
|
||||
private fullOutputPath?: string;
|
||||
|
|
@ -90,13 +90,17 @@ export class BashExecutionComponent extends Container {
|
|||
}
|
||||
|
||||
setComplete(
|
||||
exitCode: number | null,
|
||||
exitCode: number | undefined,
|
||||
cancelled: boolean,
|
||||
truncationResult?: TruncationResult,
|
||||
fullOutputPath?: string,
|
||||
): void {
|
||||
this.exitCode = exitCode;
|
||||
this.status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== null ? "error" : "complete";
|
||||
this.status = cancelled
|
||||
? "cancelled"
|
||||
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
|
||||
? "error"
|
||||
: "complete";
|
||||
this.truncationResult = truncationResult;
|
||||
this.fullOutputPath = fullOutputPath;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
|
||||
import type { Theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
/** Loader wrapped with borders for hook UI */
|
||||
export class BorderedLoader extends Container {
|
||||
private loader: CancellableLoader;
|
||||
|
||||
constructor(tui: TUI, theme: Theme, message: string) {
|
||||
super();
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
this.loader = new CancellableLoader(
|
||||
tui,
|
||||
(s) => theme.fg("accent", s),
|
||||
(s) => theme.fg("muted", s),
|
||||
message,
|
||||
);
|
||||
this.addChild(this.loader);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
get signal(): AbortSignal {
|
||||
return this.loader.signal;
|
||||
}
|
||||
|
||||
set onAbort(fn: (() => void) | undefined) {
|
||||
this.loader.onAbort = fn;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.loader.handleInput(data);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.loader.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { BranchSummaryMessage } from "../../../core/messages.js";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a branch summary message with collapsed/expanded state.
|
||||
* Uses same background color as hook messages for visual consistency.
|
||||
*/
|
||||
export class BranchSummaryMessageComponent extends Box {
|
||||
private expanded = false;
|
||||
private message: BranchSummaryMessage;
|
||||
|
||||
constructor(message: BranchSummaryMessage) {
|
||||
super(1, 1, (t) => theme.bg("customMessageBg", t));
|
||||
this.message = message;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
this.clear();
|
||||
|
||||
const label = theme.fg("customMessageLabel", `\x1b[1m[branch]\x1b[22m`);
|
||||
this.addChild(new Text(label, 0, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
if (this.expanded) {
|
||||
const header = "**Branch Summary**\n\n";
|
||||
this.addChild(
|
||||
new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
|
||||
color: (text: string) => theme.fg("customMessageText", text),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.addChild(new Text(theme.fg("customMessageText", "Branch summary (ctrl+o to expand)"), 0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { CompactionSummaryMessage } from "../../../core/messages.js";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a compaction message with collapsed/expanded state.
|
||||
* Uses same background color as hook messages for visual consistency.
|
||||
*/
|
||||
export class CompactionSummaryMessageComponent extends Box {
|
||||
private expanded = false;
|
||||
private message: CompactionSummaryMessage;
|
||||
|
||||
constructor(message: CompactionSummaryMessage) {
|
||||
super(1, 1, (t) => theme.bg("customMessageBg", t));
|
||||
this.message = message;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
this.clear();
|
||||
|
||||
const tokenStr = this.message.tokensBefore.toLocaleString();
|
||||
const label = theme.fg("customMessageLabel", `\x1b[1m[compaction]\x1b[22m`);
|
||||
this.addChild(new Text(label, 0, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
if (this.expanded) {
|
||||
const header = `**Compacted from ${tokenStr} tokens**\n\n`;
|
||||
this.addChild(
|
||||
new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
|
||||
color: (text: string) => theme.fg("customMessageText", text),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.addChild(
|
||||
new Text(theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (ctrl+o to expand)`), 0, 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a compaction indicator with collapsed/expanded state.
|
||||
* Collapsed: shows "Context compacted from X tokens"
|
||||
* Expanded: shows the full summary rendered as markdown (like a user message)
|
||||
*/
|
||||
export class CompactionComponent extends Container {
|
||||
private expanded = false;
|
||||
private tokensBefore: number;
|
||||
private summary: string;
|
||||
|
||||
constructor(tokensBefore: number, summary: string) {
|
||||
super();
|
||||
this.tokensBefore = tokensBefore;
|
||||
this.summary = summary;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
this.clear();
|
||||
|
||||
if (this.expanded) {
|
||||
// Show header + summary as markdown (like user message)
|
||||
this.addChild(new Spacer(1));
|
||||
const header = `**Context compacted from ${this.tokensBefore.toLocaleString()} tokens**\n\n`;
|
||||
this.addChild(
|
||||
new Markdown(header + this.summary, 1, 1, getMarkdownTheme(), {
|
||||
bgColor: (text: string) => theme.bg("userMessageBg", text),
|
||||
color: (text: string) => theme.fg("userMessageText", text),
|
||||
}),
|
||||
);
|
||||
this.addChild(new Spacer(1));
|
||||
} else {
|
||||
// Collapsed: simple text in warning color with token count
|
||||
const tokenStr = this.tokensBefore.toLocaleString();
|
||||
this.addChild(
|
||||
new Text(
|
||||
theme.fg("warning", `Earlier messages compacted from ${tokenStr} tokens (ctrl+o to expand)`),
|
||||
1,
|
||||
1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import type { TextContent } from "@mariozechner/pi-ai";
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { HookMessageRenderer } from "../../../core/hooks/types.js";
|
||||
import type { HookMessage } from "../../../core/messages.js";
|
||||
import { getMarkdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
/**
|
||||
* Component that renders a custom message entry from hooks.
|
||||
* Uses distinct styling to differentiate from user messages.
|
||||
*/
|
||||
export class HookMessageComponent extends Container {
|
||||
private message: HookMessage<unknown>;
|
||||
private customRenderer?: HookMessageRenderer;
|
||||
private box: Box;
|
||||
private customComponent?: Component;
|
||||
private _expanded = false;
|
||||
|
||||
constructor(message: HookMessage<unknown>, customRenderer?: HookMessageRenderer) {
|
||||
super();
|
||||
this.message = message;
|
||||
this.customRenderer = customRenderer;
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
// Create box with purple background (used for default rendering)
|
||||
this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
||||
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
if (this._expanded !== expanded) {
|
||||
this._expanded = expanded;
|
||||
this.rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
// Remove previous content component
|
||||
if (this.customComponent) {
|
||||
this.removeChild(this.customComponent);
|
||||
this.customComponent = undefined;
|
||||
}
|
||||
this.removeChild(this.box);
|
||||
|
||||
// Try custom renderer first - it handles its own styling
|
||||
if (this.customRenderer) {
|
||||
try {
|
||||
const component = this.customRenderer(this.message, { expanded: this._expanded }, theme);
|
||||
if (component) {
|
||||
// Custom renderer provides its own styled component
|
||||
this.customComponent = component;
|
||||
this.addChild(component);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to default rendering
|
||||
}
|
||||
}
|
||||
|
||||
// Default rendering uses our box
|
||||
this.addChild(this.box);
|
||||
this.box.clear();
|
||||
|
||||
// Default rendering: label + content
|
||||
const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`);
|
||||
this.box.addChild(new Text(label, 0, 0));
|
||||
this.box.addChild(new Spacer(1));
|
||||
|
||||
// Extract text content
|
||||
let text: string;
|
||||
if (typeof this.message.content === "string") {
|
||||
text = this.message.content;
|
||||
} else {
|
||||
text = this.message.content
|
||||
.filter((c): c is TextContent => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Limit lines when collapsed
|
||||
if (!this._expanded) {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length > 5) {
|
||||
text = `${lines.slice(0, 5).join("\n")}\n...`;
|
||||
}
|
||||
}
|
||||
|
||||
this.box.addChild(
|
||||
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
||||
color: (text: string) => theme.fg("customMessageText", text),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,18 +36,18 @@ export class ModelSelectorComponent extends Container {
|
|||
private allModels: ModelItem[] = [];
|
||||
private filteredModels: ModelItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private currentModel: Model<any> | null;
|
||||
private currentModel?: Model<any>;
|
||||
private settingsManager: SettingsManager;
|
||||
private modelRegistry: ModelRegistry;
|
||||
private onSelectCallback: (model: Model<any>) => void;
|
||||
private onCancelCallback: () => void;
|
||||
private errorMessage: string | null = null;
|
||||
private errorMessage?: string;
|
||||
private tui: TUI;
|
||||
private scopedModels: ReadonlyArray<ScopedModelItem>;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
currentModel: Model<any> | null,
|
||||
currentModel: Model<any> | undefined,
|
||||
settingsManager: SettingsManager,
|
||||
modelRegistry: ModelRegistry,
|
||||
scopedModels: ReadonlyArray<ScopedModelItem>,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
type TUI,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import type { CustomAgentTool } from "../../../core/custom-tools/types.js";
|
||||
import type { CustomTool } from "../../../core/custom-tools/types.js";
|
||||
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js";
|
||||
import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js";
|
||||
import { renderDiff } from "./diff.js";
|
||||
|
|
@ -55,7 +55,7 @@ export class ToolExecutionComponent extends Container {
|
|||
private expanded = false;
|
||||
private showImages: boolean;
|
||||
private isPartial = true;
|
||||
private customTool?: CustomAgentTool;
|
||||
private customTool?: CustomTool;
|
||||
private ui: TUI;
|
||||
private result?: {
|
||||
content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
|
||||
|
|
@ -67,7 +67,7 @@ export class ToolExecutionComponent extends Container {
|
|||
toolName: string,
|
||||
args: any,
|
||||
options: ToolExecutionOptions = {},
|
||||
customTool: CustomAgentTool | undefined,
|
||||
customTool: CustomTool | undefined,
|
||||
ui: TUI,
|
||||
) {
|
||||
super();
|
||||
|
|
@ -415,10 +415,14 @@ export class ToolExecutionComponent extends Container {
|
|||
} else if (this.toolName === "edit") {
|
||||
const rawPath = this.args?.file_path || this.args?.path || "";
|
||||
const path = shortenPath(rawPath);
|
||||
text =
|
||||
theme.fg("toolTitle", theme.bold("edit")) +
|
||||
" " +
|
||||
(path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."));
|
||||
|
||||
// Build path display, appending :line if we have a successful result with line info
|
||||
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
||||
if (this.result && !this.result.isError && this.result.details?.firstChangedLine) {
|
||||
pathDisplay += theme.fg("warning", `:${this.result.details.firstChangedLine}`);
|
||||
}
|
||||
|
||||
text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`;
|
||||
|
||||
if (this.result) {
|
||||
if (this.result.isError) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,866 @@
|
|||
import {
|
||||
type Component,
|
||||
Container,
|
||||
Input,
|
||||
isArrowDown,
|
||||
isArrowLeft,
|
||||
isArrowRight,
|
||||
isArrowUp,
|
||||
isBackspace,
|
||||
isCtrlC,
|
||||
isCtrlO,
|
||||
isEnter,
|
||||
isEscape,
|
||||
isShiftCtrlO,
|
||||
Spacer,
|
||||
Text,
|
||||
TruncatedText,
|
||||
truncateToWidth,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import type { SessionTreeNode } from "../../../core/session-manager.js";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
|
||||
interface GutterInfo {
|
||||
position: number; // displayIndent level where the connector was shown
|
||||
show: boolean; // true = show │, false = show spaces
|
||||
}
|
||||
|
||||
/** Flattened tree node for navigation */
|
||||
interface FlatNode {
|
||||
node: SessionTreeNode;
|
||||
/** Indentation level (each level = 3 chars) */
|
||||
indent: number;
|
||||
/** Whether to show connector (├─ or └─) - true if parent has multiple children */
|
||||
showConnector: boolean;
|
||||
/** If showConnector, true = last sibling (└─), false = not last (├─) */
|
||||
isLast: boolean;
|
||||
/** Gutter info for each ancestor branch point */
|
||||
gutters: GutterInfo[];
|
||||
/** True if this node is a root under a virtual branching root (multiple roots) */
|
||||
isVirtualRootChild: boolean;
|
||||
}
|
||||
|
||||
/** Filter mode for tree display */
|
||||
type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
|
||||
|
||||
/**
|
||||
* Tree list component with selection and ASCII art visualization
|
||||
*/
|
||||
/** Tool call info for lookup */
|
||||
interface ToolCallInfo {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class TreeList implements Component {
|
||||
private flatNodes: FlatNode[] = [];
|
||||
private filteredNodes: FlatNode[] = [];
|
||||
private selectedIndex = 0;
|
||||
private currentLeafId: string | null;
|
||||
private maxVisibleLines: number;
|
||||
private filterMode: FilterMode = "default";
|
||||
private searchQuery = "";
|
||||
private toolCallMap: Map<string, ToolCallInfo> = new Map();
|
||||
private multipleRoots = false;
|
||||
private activePathIds: Set<string> = new Set();
|
||||
|
||||
public onSelect?: (entryId: string) => void;
|
||||
public onCancel?: () => void;
|
||||
public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;
|
||||
|
||||
constructor(tree: SessionTreeNode[], currentLeafId: string | null, maxVisibleLines: number) {
|
||||
this.currentLeafId = currentLeafId;
|
||||
this.maxVisibleLines = maxVisibleLines;
|
||||
this.multipleRoots = tree.length > 1;
|
||||
this.flatNodes = this.flattenTree(tree);
|
||||
this.buildActivePath();
|
||||
this.applyFilter();
|
||||
|
||||
// Start with current leaf selected
|
||||
const leafIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === currentLeafId);
|
||||
if (leafIndex !== -1) {
|
||||
this.selectedIndex = leafIndex;
|
||||
} else {
|
||||
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the set of entry IDs on the path from root to current leaf */
|
||||
private buildActivePath(): void {
|
||||
this.activePathIds.clear();
|
||||
if (!this.currentLeafId) return;
|
||||
|
||||
// Build a map of id -> entry for parent lookup
|
||||
const entryMap = new Map<string, FlatNode>();
|
||||
for (const flatNode of this.flatNodes) {
|
||||
entryMap.set(flatNode.node.entry.id, flatNode);
|
||||
}
|
||||
|
||||
// Walk from leaf to root
|
||||
let currentId: string | null = this.currentLeafId;
|
||||
while (currentId) {
|
||||
this.activePathIds.add(currentId);
|
||||
const node = entryMap.get(currentId);
|
||||
if (!node) break;
|
||||
currentId = node.node.entry.parentId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
private flattenTree(roots: SessionTreeNode[]): FlatNode[] {
|
||||
const result: FlatNode[] = [];
|
||||
this.toolCallMap.clear();
|
||||
|
||||
// Indentation rules:
|
||||
// - At indent 0: stay at 0 unless parent has >1 children (then +1)
|
||||
// - At indent 1: children always go to indent 2 (visual grouping of subtree)
|
||||
// - At indent 2+: stay flat for single-child chains, +1 only if parent branches
|
||||
|
||||
// Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]
|
||||
type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, GutterInfo[], boolean];
|
||||
const stack: StackItem[] = [];
|
||||
|
||||
// Determine which subtrees contain the active leaf (to sort current branch first)
|
||||
// Use iterative post-order traversal to avoid stack overflow
|
||||
const containsActive = new Map<SessionTreeNode, boolean>();
|
||||
const leafId = this.currentLeafId;
|
||||
{
|
||||
// Build list in pre-order, then process in reverse for post-order effect
|
||||
const allNodes: SessionTreeNode[] = [];
|
||||
const preOrderStack: SessionTreeNode[] = [...roots];
|
||||
while (preOrderStack.length > 0) {
|
||||
const node = preOrderStack.pop()!;
|
||||
allNodes.push(node);
|
||||
// Push children in reverse so they're processed left-to-right
|
||||
for (let i = node.children.length - 1; i >= 0; i--) {
|
||||
preOrderStack.push(node.children[i]);
|
||||
}
|
||||
}
|
||||
// Process in reverse (post-order): children before parents
|
||||
for (let i = allNodes.length - 1; i >= 0; i--) {
|
||||
const node = allNodes[i];
|
||||
let has = leafId !== null && node.entry.id === leafId;
|
||||
for (const child of node.children) {
|
||||
if (containsActive.get(child)) {
|
||||
has = true;
|
||||
}
|
||||
}
|
||||
containsActive.set(node, has);
|
||||
}
|
||||
}
|
||||
|
||||
// Add roots in reverse order, prioritizing the one containing the active leaf
|
||||
// If multiple roots, treat them as children of a virtual root that branches
|
||||
const multipleRoots = roots.length > 1;
|
||||
const orderedRoots = [...roots].sort((a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a)));
|
||||
for (let i = orderedRoots.length - 1; i >= 0; i--) {
|
||||
const isLast = i === orderedRoots.length - 1;
|
||||
stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]);
|
||||
}
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!;
|
||||
|
||||
// Extract tool calls from assistant messages for later lookup
|
||||
const entry = node.entry;
|
||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
const content = (entry.message as { content?: unknown }).content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (typeof block === "object" && block !== null && "type" in block && block.type === "toolCall") {
|
||||
const tc = block as { id: string; name: string; arguments: Record<string, unknown> };
|
||||
this.toolCallMap.set(tc.id, { name: tc.name, arguments: tc.arguments });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild });
|
||||
|
||||
const children = node.children;
|
||||
const multipleChildren = children.length > 1;
|
||||
|
||||
// Order children so the branch containing the active leaf comes first
|
||||
const orderedChildren = (() => {
|
||||
const prioritized: SessionTreeNode[] = [];
|
||||
const rest: SessionTreeNode[] = [];
|
||||
for (const child of children) {
|
||||
if (containsActive.get(child)) {
|
||||
prioritized.push(child);
|
||||
} else {
|
||||
rest.push(child);
|
||||
}
|
||||
}
|
||||
return [...prioritized, ...rest];
|
||||
})();
|
||||
|
||||
// Calculate child indent
|
||||
let childIndent: number;
|
||||
if (multipleChildren) {
|
||||
// Parent branches: children get +1
|
||||
childIndent = indent + 1;
|
||||
} else if (justBranched && indent > 0) {
|
||||
// First generation after a branch: +1 for visual grouping
|
||||
childIndent = indent + 1;
|
||||
} else {
|
||||
// Single-child chain: stay flat
|
||||
childIndent = indent;
|
||||
}
|
||||
|
||||
// Build gutters for children
|
||||
// If this node showed a connector, add a gutter entry for descendants
|
||||
// Only add gutter if connector is actually displayed (not suppressed for virtual root children)
|
||||
const connectorDisplayed = showConnector && !isVirtualRootChild;
|
||||
// When connector is displayed, add a gutter entry at the connector's position
|
||||
// Connector is at position (displayIndent - 1), so gutter should be there too
|
||||
const currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent;
|
||||
const connectorPosition = Math.max(0, currentDisplayIndent - 1);
|
||||
const childGutters: GutterInfo[] = connectorDisplayed
|
||||
? [...gutters, { position: connectorPosition, show: !isLast }]
|
||||
: gutters;
|
||||
|
||||
// Add children in reverse order
|
||||
for (let i = orderedChildren.length - 1; i >= 0; i--) {
|
||||
const childIsLast = i === orderedChildren.length - 1;
|
||||
stack.push([
|
||||
orderedChildren[i],
|
||||
childIndent,
|
||||
multipleChildren,
|
||||
multipleChildren,
|
||||
childIsLast,
|
||||
childGutters,
|
||||
false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private applyFilter(): void {
|
||||
// Remember currently selected node to preserve cursor position
|
||||
const previouslySelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
|
||||
|
||||
const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
this.filteredNodes = this.flatNodes.filter((flatNode) => {
|
||||
const entry = flatNode.node.entry;
|
||||
const isCurrentLeaf = entry.id === this.currentLeafId;
|
||||
|
||||
// Skip assistant messages with only tool calls (no text) unless error/aborted
|
||||
// Always show current leaf so active position is visible
|
||||
if (entry.type === "message" && entry.message.role === "assistant" && !isCurrentLeaf) {
|
||||
const msg = entry.message as { stopReason?: string; content?: unknown };
|
||||
const hasText = this.hasTextContent(msg.content);
|
||||
const isErrorOrAborted = msg.stopReason && msg.stopReason !== "stop" && msg.stopReason !== "toolUse";
|
||||
// Only hide if no text AND not an error/aborted message
|
||||
if (!hasText && !isErrorOrAborted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filter mode
|
||||
let passesFilter = true;
|
||||
// Entry types hidden in default view (settings/bookkeeping)
|
||||
const isSettingsEntry =
|
||||
entry.type === "label" ||
|
||||
entry.type === "custom" ||
|
||||
entry.type === "model_change" ||
|
||||
entry.type === "thinking_level_change";
|
||||
|
||||
switch (this.filterMode) {
|
||||
case "user-only":
|
||||
// Just user messages
|
||||
passesFilter = entry.type === "message" && entry.message.role === "user";
|
||||
break;
|
||||
case "no-tools":
|
||||
// Default minus tool results
|
||||
passesFilter = !isSettingsEntry && !(entry.type === "message" && entry.message.role === "toolResult");
|
||||
break;
|
||||
case "labeled-only":
|
||||
// Just labeled entries
|
||||
passesFilter = flatNode.node.label !== undefined;
|
||||
break;
|
||||
case "all":
|
||||
// Show everything
|
||||
passesFilter = true;
|
||||
break;
|
||||
default:
|
||||
// Default mode: hide settings/bookkeeping entries
|
||||
passesFilter = !isSettingsEntry;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!passesFilter) return false;
|
||||
|
||||
// Apply search filter
|
||||
if (searchTokens.length > 0) {
|
||||
const nodeText = this.getSearchableText(flatNode.node).toLowerCase();
|
||||
return searchTokens.every((token) => nodeText.includes(token));
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Try to preserve cursor on the same node after filtering
|
||||
if (previouslySelectedId) {
|
||||
const newIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === previouslySelectedId);
|
||||
if (newIndex !== -1) {
|
||||
this.selectedIndex = newIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back: clamp index if out of bounds
|
||||
if (this.selectedIndex >= this.filteredNodes.length) {
|
||||
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get searchable text content from a node */
|
||||
private getSearchableText(node: SessionTreeNode): string {
|
||||
const entry = node.entry;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (node.label) {
|
||||
parts.push(node.label);
|
||||
}
|
||||
|
||||
switch (entry.type) {
|
||||
case "message": {
|
||||
const msg = entry.message;
|
||||
parts.push(msg.role);
|
||||
if ("content" in msg && msg.content) {
|
||||
parts.push(this.extractContent(msg.content));
|
||||
}
|
||||
if (msg.role === "bashExecution") {
|
||||
const bashMsg = msg as { command?: string };
|
||||
if (bashMsg.command) parts.push(bashMsg.command);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "custom_message": {
|
||||
parts.push(entry.customType);
|
||||
if (typeof entry.content === "string") {
|
||||
parts.push(entry.content);
|
||||
} else {
|
||||
parts.push(this.extractContent(entry.content));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "compaction":
|
||||
parts.push("compaction");
|
||||
break;
|
||||
case "branch_summary":
|
||||
parts.push("branch summary", entry.summary);
|
||||
break;
|
||||
case "model_change":
|
||||
parts.push("model", entry.modelId);
|
||||
break;
|
||||
case "thinking_level_change":
|
||||
parts.push("thinking", entry.thinkingLevel);
|
||||
break;
|
||||
case "custom":
|
||||
parts.push("custom", entry.customType);
|
||||
break;
|
||||
case "label":
|
||||
parts.push("label", entry.label ?? "");
|
||||
break;
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
getSearchQuery(): string {
|
||||
return this.searchQuery;
|
||||
}
|
||||
|
||||
getSelectedNode(): SessionTreeNode | undefined {
|
||||
return this.filteredNodes[this.selectedIndex]?.node;
|
||||
}
|
||||
|
||||
updateNodeLabel(entryId: string, label: string | undefined): void {
|
||||
for (const flatNode of this.flatNodes) {
|
||||
if (flatNode.node.entry.id === entryId) {
|
||||
flatNode.node.label = label;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getFilterLabel(): string {
|
||||
switch (this.filterMode) {
|
||||
case "no-tools":
|
||||
return " [no-tools]";
|
||||
case "user-only":
|
||||
return " [user]";
|
||||
case "labeled-only":
|
||||
return " [labeled]";
|
||||
case "all":
|
||||
return " [all]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (this.filteredNodes.length === 0) {
|
||||
lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width));
|
||||
lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.getFilterLabel()}`), width));
|
||||
return lines;
|
||||
}
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.selectedIndex - Math.floor(this.maxVisibleLines / 2),
|
||||
this.filteredNodes.length - this.maxVisibleLines,
|
||||
),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);
|
||||
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const flatNode = this.filteredNodes[i];
|
||||
const entry = flatNode.node.entry;
|
||||
const isSelected = i === this.selectedIndex;
|
||||
|
||||
// Build line: cursor + prefix + path marker + label + content
|
||||
const cursor = isSelected ? theme.fg("accent", "› ") : " ";
|
||||
|
||||
// If multiple roots, shift display (roots at 0, not 1)
|
||||
const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent;
|
||||
|
||||
// Build prefix with gutters at their correct positions
|
||||
// Each gutter has a position (displayIndent where its connector was shown)
|
||||
const connector =
|
||||
flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─ " : "├─ ") : "";
|
||||
const connectorPosition = connector ? displayIndent - 1 : -1;
|
||||
|
||||
// Build prefix char by char, placing gutters and connector at their positions
|
||||
const totalChars = displayIndent * 3;
|
||||
const prefixChars: string[] = [];
|
||||
for (let i = 0; i < totalChars; i++) {
|
||||
const level = Math.floor(i / 3);
|
||||
const posInLevel = i % 3;
|
||||
|
||||
// Check if there's a gutter at this level
|
||||
const gutter = flatNode.gutters.find((g) => g.position === level);
|
||||
if (gutter) {
|
||||
if (posInLevel === 0) {
|
||||
prefixChars.push(gutter.show ? "│" : " ");
|
||||
} else {
|
||||
prefixChars.push(" ");
|
||||
}
|
||||
} else if (connector && level === connectorPosition) {
|
||||
// Connector at this level
|
||||
if (posInLevel === 0) {
|
||||
prefixChars.push(flatNode.isLast ? "└" : "├");
|
||||
} else if (posInLevel === 1) {
|
||||
prefixChars.push("─");
|
||||
} else {
|
||||
prefixChars.push(" ");
|
||||
}
|
||||
} else {
|
||||
prefixChars.push(" ");
|
||||
}
|
||||
}
|
||||
const prefix = prefixChars.join("");
|
||||
|
||||
// Active path marker - shown right before the entry text
|
||||
const isOnActivePath = this.activePathIds.has(entry.id);
|
||||
const pathMarker = isOnActivePath ? theme.fg("accent", "• ") : "";
|
||||
|
||||
const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : "";
|
||||
const content = this.getEntryDisplayText(flatNode.node, isSelected);
|
||||
|
||||
let line = cursor + theme.fg("dim", prefix) + pathMarker + label + content;
|
||||
if (isSelected) {
|
||||
line = theme.bg("selectedBg", line);
|
||||
}
|
||||
lines.push(truncateToWidth(line, width));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
truncateToWidth(
|
||||
theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getFilterLabel()}`),
|
||||
width,
|
||||
),
|
||||
);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private getEntryDisplayText(node: SessionTreeNode, isSelected: boolean): string {
|
||||
const entry = node.entry;
|
||||
let result: string;
|
||||
|
||||
const normalize = (s: string) => s.replace(/[\n\t]/g, " ").trim();
|
||||
|
||||
switch (entry.type) {
|
||||
case "message": {
|
||||
const msg = entry.message;
|
||||
const role = msg.role;
|
||||
if (role === "user") {
|
||||
const msgWithContent = msg as { content?: unknown };
|
||||
const content = normalize(this.extractContent(msgWithContent.content));
|
||||
result = theme.fg("accent", "user: ") + content;
|
||||
} else if (role === "assistant") {
|
||||
const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string };
|
||||
const textContent = normalize(this.extractContent(msgWithContent.content));
|
||||
if (textContent) {
|
||||
result = theme.fg("success", "assistant: ") + textContent;
|
||||
} else if (msgWithContent.stopReason === "aborted") {
|
||||
result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)");
|
||||
} else if (msgWithContent.errorMessage) {
|
||||
const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80);
|
||||
result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg);
|
||||
} else {
|
||||
result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)");
|
||||
}
|
||||
} else if (role === "toolResult") {
|
||||
const toolMsg = msg as { toolCallId?: string; toolName?: string };
|
||||
const toolCall = toolMsg.toolCallId ? this.toolCallMap.get(toolMsg.toolCallId) : undefined;
|
||||
if (toolCall) {
|
||||
result = theme.fg("muted", this.formatToolCall(toolCall.name, toolCall.arguments));
|
||||
} else {
|
||||
result = theme.fg("muted", `[${toolMsg.toolName ?? "tool"}]`);
|
||||
}
|
||||
} else if (role === "bashExecution") {
|
||||
const bashMsg = msg as { command?: string };
|
||||
result = theme.fg("dim", `[bash]: ${normalize(bashMsg.command ?? "")}`);
|
||||
} else {
|
||||
result = theme.fg("dim", `[${role}]`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "custom_message": {
|
||||
const content =
|
||||
typeof entry.content === "string"
|
||||
? entry.content
|
||||
: entry.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
result = theme.fg("customMessageLabel", `[${entry.customType}]: `) + normalize(content);
|
||||
break;
|
||||
}
|
||||
case "compaction": {
|
||||
const tokens = Math.round(entry.tokensBefore / 1000);
|
||||
result = theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`);
|
||||
break;
|
||||
}
|
||||
case "branch_summary":
|
||||
result = theme.fg("warning", `[branch summary]: `) + normalize(entry.summary);
|
||||
break;
|
||||
case "model_change":
|
||||
result = theme.fg("dim", `[model: ${entry.modelId}]`);
|
||||
break;
|
||||
case "thinking_level_change":
|
||||
result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`);
|
||||
break;
|
||||
case "custom":
|
||||
result = theme.fg("dim", `[custom: ${entry.customType}]`);
|
||||
break;
|
||||
case "label":
|
||||
result = theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`);
|
||||
break;
|
||||
default:
|
||||
result = "";
|
||||
}
|
||||
|
||||
return isSelected ? theme.bold(result) : result;
|
||||
}
|
||||
|
||||
private extractContent(content: unknown): string {
|
||||
const maxLen = 200;
|
||||
if (typeof content === "string") return content.slice(0, maxLen);
|
||||
if (Array.isArray(content)) {
|
||||
let result = "";
|
||||
for (const c of content) {
|
||||
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
|
||||
result += (c as { text: string }).text;
|
||||
if (result.length >= maxLen) return result.slice(0, maxLen);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private hasTextContent(content: unknown): boolean {
|
||||
if (typeof content === "string") return content.trim().length > 0;
|
||||
if (Array.isArray(content)) {
|
||||
for (const c of content) {
|
||||
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
|
||||
const text = (c as { text?: string }).text;
|
||||
if (text && text.trim().length > 0) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private formatToolCall(name: string, args: Record<string, unknown>): string {
|
||||
const shortenPath = (p: string): string => {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || "";
|
||||
if (home && p.startsWith(home)) return `~${p.slice(home.length)}`;
|
||||
return p;
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case "read": {
|
||||
const path = shortenPath(String(args.path || args.file_path || ""));
|
||||
const offset = args.offset as number | undefined;
|
||||
const limit = args.limit as number | undefined;
|
||||
let display = path;
|
||||
if (offset !== undefined || limit !== undefined) {
|
||||
const start = offset ?? 1;
|
||||
const end = limit !== undefined ? start + limit - 1 : "";
|
||||
display += `:${start}${end ? `-${end}` : ""}`;
|
||||
}
|
||||
return `[read: ${display}]`;
|
||||
}
|
||||
case "write": {
|
||||
const path = shortenPath(String(args.path || args.file_path || ""));
|
||||
return `[write: ${path}]`;
|
||||
}
|
||||
case "edit": {
|
||||
const path = shortenPath(String(args.path || args.file_path || ""));
|
||||
return `[edit: ${path}]`;
|
||||
}
|
||||
case "bash": {
|
||||
const rawCmd = String(args.command || "");
|
||||
const cmd = rawCmd
|
||||
.replace(/[\n\t]/g, " ")
|
||||
.trim()
|
||||
.slice(0, 50);
|
||||
return `[bash: ${cmd}${rawCmd.length > 50 ? "..." : ""}]`;
|
||||
}
|
||||
case "grep": {
|
||||
const pattern = String(args.pattern || "");
|
||||
const path = shortenPath(String(args.path || "."));
|
||||
return `[grep: /${pattern}/ in ${path}]`;
|
||||
}
|
||||
case "find": {
|
||||
const pattern = String(args.pattern || "");
|
||||
const path = shortenPath(String(args.path || "."));
|
||||
return `[find: ${pattern} in ${path}]`;
|
||||
}
|
||||
case "ls": {
|
||||
const path = shortenPath(String(args.path || "."));
|
||||
return `[ls: ${path}]`;
|
||||
}
|
||||
default: {
|
||||
// Custom tool - show name and truncated JSON args
|
||||
const argsStr = JSON.stringify(args).slice(0, 40);
|
||||
return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "..." : ""}]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
if (isArrowUp(keyData)) {
|
||||
this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1;
|
||||
} else if (isArrowDown(keyData)) {
|
||||
this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
} else if (isArrowLeft(keyData)) {
|
||||
// Page up
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines);
|
||||
} else if (isArrowRight(keyData)) {
|
||||
// Page down
|
||||
this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines);
|
||||
} else if (isEnter(keyData)) {
|
||||
const selected = this.filteredNodes[this.selectedIndex];
|
||||
if (selected && this.onSelect) {
|
||||
this.onSelect(selected.node.entry.id);
|
||||
}
|
||||
} else if (isEscape(keyData)) {
|
||||
if (this.searchQuery) {
|
||||
this.searchQuery = "";
|
||||
this.applyFilter();
|
||||
} else {
|
||||
this.onCancel?.();
|
||||
}
|
||||
} else if (isCtrlC(keyData)) {
|
||||
this.onCancel?.();
|
||||
} else if (isShiftCtrlO(keyData)) {
|
||||
// Cycle filter backwards
|
||||
const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
|
||||
const currentIndex = modes.indexOf(this.filterMode);
|
||||
this.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length];
|
||||
this.applyFilter();
|
||||
} else if (isCtrlO(keyData)) {
|
||||
// Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default
|
||||
const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"];
|
||||
const currentIndex = modes.indexOf(this.filterMode);
|
||||
this.filterMode = modes[(currentIndex + 1) % modes.length];
|
||||
this.applyFilter();
|
||||
} else if (isBackspace(keyData)) {
|
||||
if (this.searchQuery.length > 0) {
|
||||
this.searchQuery = this.searchQuery.slice(0, -1);
|
||||
this.applyFilter();
|
||||
}
|
||||
} else if (keyData === "l" && !this.searchQuery) {
|
||||
const selected = this.filteredNodes[this.selectedIndex];
|
||||
if (selected && this.onLabelEdit) {
|
||||
this.onLabelEdit(selected.node.entry.id, selected.node.label);
|
||||
}
|
||||
} else {
|
||||
const hasControlChars = [...keyData].some((ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
|
||||
});
|
||||
if (!hasControlChars && keyData.length > 0) {
|
||||
this.searchQuery += keyData;
|
||||
this.applyFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Component that displays the current search query */
|
||||
class SearchLine implements Component {
|
||||
constructor(private treeList: TreeList) {}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const query = this.treeList.getSearchQuery();
|
||||
if (query) {
|
||||
return [truncateToWidth(` ${theme.fg("muted", "Search:")} ${theme.fg("accent", query)}`, width)];
|
||||
}
|
||||
return [truncateToWidth(` ${theme.fg("muted", "Search:")}`, width)];
|
||||
}
|
||||
|
||||
handleInput(_keyData: string): void {}
|
||||
}
|
||||
|
||||
/** Label input component shown when editing a label */
|
||||
class LabelInput implements Component {
|
||||
private input: Input;
|
||||
private entryId: string;
|
||||
public onSubmit?: (entryId: string, label: string | undefined) => void;
|
||||
public onCancel?: () => void;
|
||||
|
||||
constructor(entryId: string, currentLabel: string | undefined) {
|
||||
this.entryId = entryId;
|
||||
this.input = new Input();
|
||||
if (currentLabel) {
|
||||
this.input.setValue(currentLabel);
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
const indent = " ";
|
||||
const availableWidth = width - indent.length;
|
||||
lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width));
|
||||
lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width)));
|
||||
lines.push(truncateToWidth(`${indent}${theme.fg("dim", "enter: save esc: cancel")}`, width));
|
||||
return lines;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
if (isEnter(keyData)) {
|
||||
const value = this.input.getValue().trim();
|
||||
this.onSubmit?.(this.entryId, value || undefined);
|
||||
} else if (isEscape(keyData)) {
|
||||
this.onCancel?.();
|
||||
} else {
|
||||
this.input.handleInput(keyData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that renders a session tree selector for navigation
|
||||
*/
|
||||
export class TreeSelectorComponent extends Container {
|
||||
private treeList: TreeList;
|
||||
private labelInput: LabelInput | null = null;
|
||||
private labelInputContainer: Container;
|
||||
private treeContainer: Container;
|
||||
private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void;
|
||||
|
||||
constructor(
|
||||
tree: SessionTreeNode[],
|
||||
currentLeafId: string | null,
|
||||
terminalHeight: number,
|
||||
onSelect: (entryId: string) => void,
|
||||
onCancel: () => void,
|
||||
onLabelChange?: (entryId: string, label: string | undefined) => void,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.onLabelChangeCallback = onLabelChange;
|
||||
const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2));
|
||||
|
||||
this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines);
|
||||
this.treeList.onSelect = onSelect;
|
||||
this.treeList.onCancel = onCancel;
|
||||
this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);
|
||||
|
||||
this.treeContainer = new Container();
|
||||
this.treeContainer.addChild(this.treeList);
|
||||
|
||||
this.labelInputContainer = new Container();
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Text(theme.bold(" Session Tree"), 1, 0));
|
||||
this.addChild(
|
||||
new TruncatedText(theme.fg("muted", " ↑/↓: move. ←/→: page. l: label. ^O/⇧^O: filter. Type to search"), 0, 0),
|
||||
);
|
||||
this.addChild(new SearchLine(this.treeList));
|
||||
this.addChild(new DynamicBorder());
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.treeContainer);
|
||||
this.addChild(this.labelInputContainer);
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
if (tree.length === 0) {
|
||||
setTimeout(() => onCancel(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
private showLabelInput(entryId: string, currentLabel: string | undefined): void {
|
||||
this.labelInput = new LabelInput(entryId, currentLabel);
|
||||
this.labelInput.onSubmit = (id, label) => {
|
||||
this.treeList.updateNodeLabel(id, label);
|
||||
this.onLabelChangeCallback?.(id, label);
|
||||
this.hideLabelInput();
|
||||
};
|
||||
this.labelInput.onCancel = () => this.hideLabelInput();
|
||||
|
||||
this.treeContainer.clear();
|
||||
this.labelInputContainer.clear();
|
||||
this.labelInputContainer.addChild(this.labelInput);
|
||||
}
|
||||
|
||||
private hideLabelInput(): void {
|
||||
this.labelInput = null;
|
||||
this.labelInputContainer.clear();
|
||||
this.treeContainer.clear();
|
||||
this.treeContainer.addChild(this.treeList);
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
if (this.labelInput) {
|
||||
this.labelInput.handleInput(keyData);
|
||||
} else {
|
||||
this.treeList.handleInput(keyData);
|
||||
}
|
||||
}
|
||||
|
||||
getTreeList(): TreeList {
|
||||
return this.treeList;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ import { theme } from "../theme/theme.js";
|
|||
import { DynamicBorder } from "./dynamic-border.js";
|
||||
|
||||
interface UserMessageItem {
|
||||
index: number; // Index in the full messages array
|
||||
id: string; // Entry ID in the session
|
||||
text: string; // The message text
|
||||
timestamp?: string; // Optional timestamp if available
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ interface UserMessageItem {
|
|||
class UserMessageList implements Component {
|
||||
private messages: UserMessageItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
public onSelect?: (messageIndex: number) => void;
|
||||
public onSelect?: (entryId: string) => void;
|
||||
public onCancel?: () => void;
|
||||
private maxVisible: number = 10; // Max messages visible
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ class UserMessageList implements Component {
|
|||
else if (isEnter(keyData)) {
|
||||
const selected = this.messages[this.selectedIndex];
|
||||
if (selected && this.onSelect) {
|
||||
this.onSelect(selected.index);
|
||||
this.onSelect(selected.id);
|
||||
}
|
||||
}
|
||||
// Escape - cancel
|
||||
|
|
@ -125,7 +125,7 @@ class UserMessageList implements Component {
|
|||
export class UserMessageSelectorComponent extends Container {
|
||||
private messageList: UserMessageList;
|
||||
|
||||
constructor(messages: UserMessageItem[], onSelect: (messageIndex: number) => void, onCancel: () => void) {
|
||||
constructor(messages: UserMessageItem[], onSelect: (entryId: string) => void, onCancel: () => void) {
|
||||
super();
|
||||
|
||||
// Add header
|
||||
|
|
|
|||
|
|
@ -5,13 +5,9 @@ import { getMarkdownTheme, theme } from "../theme/theme.js";
|
|||
* Component that renders a user message
|
||||
*/
|
||||
export class UserMessageComponent extends Container {
|
||||
constructor(text: string, isFirst: boolean) {
|
||||
constructor(text: string) {
|
||||
super();
|
||||
|
||||
// Add spacer before user message (except first one)
|
||||
if (!isFirst) {
|
||||
this.addChild(new Spacer(1));
|
||||
}
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(
|
||||
new Markdown(text, 1, 1, getMarkdownTheme(), {
|
||||
bgColor: (text: string) => theme.bg("userMessageBg", text),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,10 +11,12 @@
|
|||
"dimGray": "#666666",
|
||||
"darkGray": "#505050",
|
||||
"accent": "#8abeb7",
|
||||
"selectedBg": "#3a3a4a",
|
||||
"userMsgBg": "#343541",
|
||||
"toolPendingBg": "#282832",
|
||||
"toolSuccessBg": "#283228",
|
||||
"toolErrorBg": "#3c2828"
|
||||
"toolErrorBg": "#3c2828",
|
||||
"customMsgBg": "#2d2838"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "accent",
|
||||
|
|
@ -27,9 +29,14 @@
|
|||
"muted": "gray",
|
||||
"dim": "dimGray",
|
||||
"text": "",
|
||||
"thinkingText": "gray",
|
||||
|
||||
"selectedBg": "selectedBg",
|
||||
"userMessageBg": "userMsgBg",
|
||||
"userMessageText": "",
|
||||
"customMessageBg": "customMsgBg",
|
||||
"customMessageText": "",
|
||||
"customMessageLabel": "#9575cd",
|
||||
"toolPendingBg": "toolPendingBg",
|
||||
"toolSuccessBg": "toolSuccessBg",
|
||||
"toolErrorBg": "toolErrorBg",
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@
|
|||
"mediumGray": "#6c6c6c",
|
||||
"dimGray": "#8a8a8a",
|
||||
"lightGray": "#b0b0b0",
|
||||
"selectedBg": "#d0d0e0",
|
||||
"userMsgBg": "#e8e8e8",
|
||||
"toolPendingBg": "#e8e8f0",
|
||||
"toolSuccessBg": "#e8f0e8",
|
||||
"toolErrorBg": "#f0e8e8"
|
||||
"toolErrorBg": "#f0e8e8",
|
||||
"customMsgBg": "#ede7f6"
|
||||
},
|
||||
"colors": {
|
||||
"accent": "teal",
|
||||
|
|
@ -26,9 +28,14 @@
|
|||
"muted": "mediumGray",
|
||||
"dim": "dimGray",
|
||||
"text": "",
|
||||
"thinkingText": "mediumGray",
|
||||
|
||||
"selectedBg": "selectedBg",
|
||||
"userMessageBg": "userMsgBg",
|
||||
"userMessageText": "",
|
||||
"customMessageBg": "customMsgBg",
|
||||
"customMessageText": "",
|
||||
"customMessageLabel": "#7e57c2",
|
||||
"toolPendingBg": "toolPendingBg",
|
||||
"toolSuccessBg": "toolSuccessBg",
|
||||
"toolErrorBg": "toolErrorBg",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@
|
|||
"text",
|
||||
"userMessageBg",
|
||||
"userMessageText",
|
||||
"customMessageBg",
|
||||
"customMessageText",
|
||||
"customMessageLabel",
|
||||
"toolPendingBg",
|
||||
"toolSuccessBg",
|
||||
"toolErrorBg",
|
||||
|
|
@ -122,6 +125,18 @@
|
|||
"$ref": "#/$defs/colorValue",
|
||||
"description": "User message text color"
|
||||
},
|
||||
"customMessageBg": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Custom message background (hook-injected messages)"
|
||||
},
|
||||
"customMessageText": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Custom message text color"
|
||||
},
|
||||
"customMessageLabel": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Custom message type label color"
|
||||
},
|
||||
"toolPendingBg": {
|
||||
"$ref": "#/$defs/colorValue",
|
||||
"description": "Tool execution box (pending state)"
|
||||
|
|
|
|||
|
|
@ -34,9 +34,14 @@ const ThemeJsonSchema = Type.Object({
|
|||
muted: ColorValueSchema,
|
||||
dim: ColorValueSchema,
|
||||
text: ColorValueSchema,
|
||||
// Backgrounds & Content Text (7 colors)
|
||||
thinkingText: ColorValueSchema,
|
||||
// Backgrounds & Content Text (11 colors)
|
||||
selectedBg: ColorValueSchema,
|
||||
userMessageBg: ColorValueSchema,
|
||||
userMessageText: ColorValueSchema,
|
||||
customMessageBg: ColorValueSchema,
|
||||
customMessageText: ColorValueSchema,
|
||||
customMessageLabel: ColorValueSchema,
|
||||
toolPendingBg: ColorValueSchema,
|
||||
toolSuccessBg: ColorValueSchema,
|
||||
toolErrorBg: ColorValueSchema,
|
||||
|
|
@ -94,7 +99,10 @@ export type ThemeColor =
|
|||
| "muted"
|
||||
| "dim"
|
||||
| "text"
|
||||
| "thinkingText"
|
||||
| "userMessageText"
|
||||
| "customMessageText"
|
||||
| "customMessageLabel"
|
||||
| "toolTitle"
|
||||
| "toolOutput"
|
||||
| "mdHeading"
|
||||
|
|
@ -127,7 +135,13 @@ export type ThemeColor =
|
|||
| "thinkingXhigh"
|
||||
| "bashMode";
|
||||
|
||||
export type ThemeBg = "userMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
|
||||
export type ThemeBg =
|
||||
| "selectedBg"
|
||||
| "userMessageBg"
|
||||
| "customMessageBg"
|
||||
| "toolPendingBg"
|
||||
| "toolSuccessBg"
|
||||
| "toolErrorBg";
|
||||
|
||||
type ColorMode = "truecolor" | "256color";
|
||||
|
||||
|
|
@ -482,7 +496,14 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
|
|||
const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
|
||||
const fgColors: Record<ThemeColor, string | number> = {} as Record<ThemeColor, string | number>;
|
||||
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
|
||||
const bgColorKeys: Set<string> = new Set(["userMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg"]);
|
||||
const bgColorKeys: Set<string> = new Set([
|
||||
"selectedBg",
|
||||
"userMessageBg",
|
||||
"customMessageBg",
|
||||
"toolPendingBg",
|
||||
"toolSuccessBg",
|
||||
"toolErrorBg",
|
||||
]);
|
||||
for (const [key, value] of Object.entries(resolvedColors)) {
|
||||
if (bgColorKeys.has(key)) {
|
||||
bgColors[key as ThemeBg] = value;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
* - `pi --mode json "prompt"` - JSON event stream
|
||||
*/
|
||||
|
||||
import type { Attachment } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { AgentSession } from "../core/agent-session.js";
|
||||
|
||||
/**
|
||||
|
|
@ -18,38 +17,36 @@ import type { AgentSession } from "../core/agent-session.js";
|
|||
* @param mode Output mode: "text" for final response only, "json" for all events
|
||||
* @param messages Array of prompts to send
|
||||
* @param initialMessage Optional first message (may contain @file content)
|
||||
* @param initialAttachments Optional attachments for the initial message
|
||||
* @param initialImages Optional images for the initial message
|
||||
*/
|
||||
export async function runPrintMode(
|
||||
session: AgentSession,
|
||||
mode: "text" | "json",
|
||||
messages: string[],
|
||||
initialMessage?: string,
|
||||
initialAttachments?: Attachment[],
|
||||
initialImages?: ImageContent[],
|
||||
): Promise<void> {
|
||||
// Load entries once for session start events
|
||||
const entries = session.sessionManager.getEntries();
|
||||
|
||||
// Hook runner already has no-op UI context by default (set in main.ts)
|
||||
// Set up hooks for print mode (no UI)
|
||||
const hookRunner = session.hookRunner;
|
||||
if (hookRunner) {
|
||||
// Use actual session file if configured (via --session), otherwise null
|
||||
hookRunner.setSessionFile(session.sessionFile);
|
||||
hookRunner.initialize({
|
||||
getModel: () => session.model,
|
||||
sendMessageHandler: (message, triggerTurn) => {
|
||||
session.sendHookMessage(message, triggerTurn).catch((e) => {
|
||||
console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
});
|
||||
},
|
||||
appendEntryHandler: (customType, data) => {
|
||||
session.sessionManager.appendCustomEntry(customType, data);
|
||||
},
|
||||
});
|
||||
hookRunner.onError((err) => {
|
||||
console.error(`Hook error (${err.hookPath}): ${err.error}`);
|
||||
});
|
||||
// No-op send handler for print mode (single-shot, no async messages)
|
||||
hookRunner.setSendHandler(() => {
|
||||
console.error("Warning: pi.send() is not supported in print mode");
|
||||
});
|
||||
// Emit session event
|
||||
// Emit session_start event
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
entries,
|
||||
sessionFile: session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
reason: "start",
|
||||
type: "session_start",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -57,12 +54,17 @@ export async function runPrintMode(
|
|||
for (const { tool } of session.customTools) {
|
||||
if (tool.onSession) {
|
||||
try {
|
||||
await tool.onSession({
|
||||
entries,
|
||||
sessionFile: session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
reason: "start",
|
||||
});
|
||||
await tool.onSession(
|
||||
{
|
||||
reason: "start",
|
||||
previousSessionFile: undefined,
|
||||
},
|
||||
{
|
||||
sessionManager: session.sessionManager,
|
||||
modelRegistry: session.modelRegistry,
|
||||
model: session.model,
|
||||
},
|
||||
);
|
||||
} catch (_err) {
|
||||
// Silently ignore tool errors
|
||||
}
|
||||
|
|
@ -79,7 +81,7 @@ export async function runPrintMode(
|
|||
|
||||
// Send initial message with attachments
|
||||
if (initialMessage) {
|
||||
await session.prompt(initialMessage, { attachments: initialAttachments });
|
||||
await session.prompt(initialMessage, { images: initialImages });
|
||||
}
|
||||
|
||||
// Send remaining messages
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@
|
|||
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import * as readline from "node:readline";
|
||||
import type { AgentEvent, AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { CompactionResult, SessionStats } from "../../core/agent-session.js";
|
||||
import type { AgentEvent, AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { SessionStats } from "../../core/agent-session.js";
|
||||
import type { BashResult } from "../../core/bash-executor.js";
|
||||
import type { CompactionResult } from "../../core/compaction/index.js";
|
||||
import type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc-types.js";
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -166,8 +168,8 @@ export class RpcClient {
|
|||
* Returns immediately after sending; use onEvent() to receive streaming events.
|
||||
* Use waitForIdle() to wait for completion.
|
||||
*/
|
||||
async prompt(message: string, attachments?: Attachment[]): Promise<void> {
|
||||
await this.send({ type: "prompt", message, attachments });
|
||||
async prompt(message: string, images?: ImageContent[]): Promise<void> {
|
||||
await this.send({ type: "prompt", message, images });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -324,17 +326,17 @@ export class RpcClient {
|
|||
* Branch from a specific message.
|
||||
* @returns Object with `text` (the message text) and `cancelled` (if hook cancelled)
|
||||
*/
|
||||
async branch(entryIndex: number): Promise<{ text: string; cancelled: boolean }> {
|
||||
const response = await this.send({ type: "branch", entryIndex });
|
||||
async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> {
|
||||
const response = await this.send({ type: "branch", entryId });
|
||||
return this.getData(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get messages available for branching.
|
||||
*/
|
||||
async getBranchMessages(): Promise<Array<{ entryIndex: number; text: string }>> {
|
||||
async getBranchMessages(): Promise<Array<{ entryId: string; text: string }>> {
|
||||
const response = await this.send({ type: "get_branch_messages" });
|
||||
return this.getData<{ messages: Array<{ entryIndex: number; text: string }> }>(response).messages;
|
||||
return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -348,9 +350,9 @@ export class RpcClient {
|
|||
/**
|
||||
* Get all messages in the session.
|
||||
*/
|
||||
async getMessages(): Promise<AppMessage[]> {
|
||||
async getMessages(): Promise<AgentMessage[]> {
|
||||
const response = await this.send({ type: "get_messages" });
|
||||
return this.getData<{ messages: AppMessage[] }>(response).messages;
|
||||
return this.getData<{ messages: AgentMessage[] }>(response).messages;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -403,9 +405,9 @@ export class RpcClient {
|
|||
/**
|
||||
* Send prompt and wait for completion, returning all events.
|
||||
*/
|
||||
async promptAndWait(message: string, attachments?: Attachment[], timeout = 60000): Promise<AgentEvent[]> {
|
||||
async promptAndWait(message: string, images?: ImageContent[], timeout = 60000): Promise<AgentEvent[]> {
|
||||
const eventsPromise = this.collectEvents(timeout);
|
||||
await this.prompt(message, attachments);
|
||||
await this.prompt(message, images);
|
||||
return eventsPromise;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,17 +51,17 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
* Create a hook UI context that uses the RPC protocol.
|
||||
*/
|
||||
const createHookUIContext = (): HookUIContext => ({
|
||||
async select(title: string, options: string[]): Promise<string | null> {
|
||||
async select(title: string, options: string[]): Promise<string | undefined> {
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingHookRequests.set(id, {
|
||||
resolve: (response: RpcHookUIResponse) => {
|
||||
if ("cancelled" in response && response.cancelled) {
|
||||
resolve(null);
|
||||
resolve(undefined);
|
||||
} else if ("value" in response) {
|
||||
resolve(response.value);
|
||||
} else {
|
||||
resolve(null);
|
||||
resolve(undefined);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
|
|
@ -89,17 +89,17 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
});
|
||||
},
|
||||
|
||||
async input(title: string, placeholder?: string): Promise<string | null> {
|
||||
async input(title: string, placeholder?: string): Promise<string | undefined> {
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingHookRequests.set(id, {
|
||||
resolve: (response: RpcHookUIResponse) => {
|
||||
if ("cancelled" in response && response.cancelled) {
|
||||
resolve(null);
|
||||
resolve(undefined);
|
||||
} else if ("value" in response) {
|
||||
resolve(response.value);
|
||||
} else {
|
||||
resolve(null);
|
||||
resolve(undefined);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
|
|
@ -118,37 +118,51 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
notifyType: type,
|
||||
} as RpcHookUIRequest);
|
||||
},
|
||||
});
|
||||
|
||||
// Load entries once for session start events
|
||||
const entries = session.sessionManager.getEntries();
|
||||
async custom() {
|
||||
// Custom UI not supported in RPC mode
|
||||
return undefined as never;
|
||||
},
|
||||
|
||||
setEditorText(text: string): void {
|
||||
// Fire and forget - host can implement editor control
|
||||
output({
|
||||
type: "hook_ui_request",
|
||||
id: crypto.randomUUID(),
|
||||
method: "set_editor_text",
|
||||
text,
|
||||
} as RpcHookUIRequest);
|
||||
},
|
||||
|
||||
getEditorText(): string {
|
||||
// Synchronous method can't wait for RPC response
|
||||
// Host should track editor state locally if needed
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
// Set up hooks with RPC-based UI context
|
||||
const hookRunner = session.hookRunner;
|
||||
if (hookRunner) {
|
||||
hookRunner.setUIContext(createHookUIContext(), false);
|
||||
hookRunner.setSessionFile(session.sessionFile);
|
||||
hookRunner.initialize({
|
||||
getModel: () => session.agent.state.model,
|
||||
sendMessageHandler: (message, triggerTurn) => {
|
||||
session.sendHookMessage(message, triggerTurn).catch((e) => {
|
||||
output(error(undefined, "hook_send", e.message));
|
||||
});
|
||||
},
|
||||
appendEntryHandler: (customType, data) => {
|
||||
session.sessionManager.appendCustomEntry(customType, data);
|
||||
},
|
||||
uiContext: createHookUIContext(),
|
||||
hasUI: false,
|
||||
});
|
||||
hookRunner.onError((err) => {
|
||||
output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error });
|
||||
});
|
||||
// Set up send handler for pi.send()
|
||||
hookRunner.setSendHandler((text, attachments) => {
|
||||
// In RPC mode, just queue or prompt based on streaming state
|
||||
if (session.isStreaming) {
|
||||
session.queueMessage(text);
|
||||
} else {
|
||||
session.prompt(text, { attachments }).catch((e) => {
|
||||
output(error(undefined, "hook_send", e.message));
|
||||
});
|
||||
}
|
||||
});
|
||||
// Emit session event
|
||||
// Emit session_start event
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
entries,
|
||||
sessionFile: session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
reason: "start",
|
||||
type: "session_start",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -157,12 +171,17 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
for (const { tool } of session.customTools) {
|
||||
if (tool.onSession) {
|
||||
try {
|
||||
await tool.onSession({
|
||||
entries,
|
||||
sessionFile: session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
reason: "start",
|
||||
});
|
||||
await tool.onSession(
|
||||
{
|
||||
previousSessionFile: undefined,
|
||||
reason: "start",
|
||||
},
|
||||
{
|
||||
sessionManager: session.sessionManager,
|
||||
modelRegistry: session.modelRegistry,
|
||||
model: session.model,
|
||||
},
|
||||
);
|
||||
} catch (_err) {
|
||||
// Silently ignore tool errors
|
||||
}
|
||||
|
|
@ -185,10 +204,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
|
||||
case "prompt": {
|
||||
// Don't await - events will stream
|
||||
// Hook commands and file slash commands are handled in session.prompt()
|
||||
session
|
||||
.prompt(command.message, {
|
||||
attachments: command.attachments,
|
||||
expandSlashCommands: false,
|
||||
images: command.images,
|
||||
})
|
||||
.catch((e) => output(error(id, "prompt", e.message)));
|
||||
return success(id, "prompt");
|
||||
|
|
@ -344,7 +363,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
}
|
||||
|
||||
case "branch": {
|
||||
const result = await session.branch(command.entryIndex);
|
||||
const result = await session.branch(command.entryId);
|
||||
return success(id, "branch", { text: result.selectedText, cancelled: result.cancelled });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@
|
|||
* Responses and events are emitted as JSON lines on stdout.
|
||||
*/
|
||||
|
||||
import type { AppMessage, Attachment, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import type { CompactionResult, SessionStats } from "../../core/agent-session.js";
|
||||
import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent, Model } from "@mariozechner/pi-ai";
|
||||
import type { SessionStats } from "../../core/agent-session.js";
|
||||
import type { BashResult } from "../../core/bash-executor.js";
|
||||
import type { CompactionResult } from "../../core/compaction/index.js";
|
||||
|
||||
// ============================================================================
|
||||
// RPC Commands (stdin)
|
||||
|
|
@ -16,7 +17,7 @@ import type { BashResult } from "../../core/bash-executor.js";
|
|||
|
||||
export type RpcCommand =
|
||||
// Prompting
|
||||
| { id?: string; type: "prompt"; message: string; attachments?: Attachment[] }
|
||||
| { id?: string; type: "prompt"; message: string; images?: ImageContent[] }
|
||||
| { id?: string; type: "queue_message"; message: string }
|
||||
| { id?: string; type: "abort" }
|
||||
| { id?: string; type: "reset" }
|
||||
|
|
@ -52,7 +53,7 @@ export type RpcCommand =
|
|||
| { id?: string; type: "get_session_stats" }
|
||||
| { id?: string; type: "export_html"; outputPath?: string }
|
||||
| { id?: string; type: "switch_session"; sessionPath: string }
|
||||
| { id?: string; type: "branch"; entryIndex: number }
|
||||
| { id?: string; type: "branch"; entryId: string }
|
||||
| { id?: string; type: "get_branch_messages" }
|
||||
| { id?: string; type: "get_last_assistant_text" }
|
||||
|
||||
|
|
@ -64,12 +65,12 @@ export type RpcCommand =
|
|||
// ============================================================================
|
||||
|
||||
export interface RpcSessionState {
|
||||
model: Model<any> | null;
|
||||
model?: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
isStreaming: boolean;
|
||||
isCompacting: boolean;
|
||||
queueMode: "all" | "one-at-a-time";
|
||||
sessionFile: string | null;
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
autoCompactionEnabled: boolean;
|
||||
messageCount: number;
|
||||
|
|
@ -149,7 +150,7 @@ export type RpcResponse =
|
|||
type: "response";
|
||||
command: "get_branch_messages";
|
||||
success: true;
|
||||
data: { messages: Array<{ entryIndex: number; text: string }> };
|
||||
data: { messages: Array<{ entryId: string; text: string }> };
|
||||
}
|
||||
| {
|
||||
id?: string;
|
||||
|
|
@ -160,7 +161,7 @@ export type RpcResponse =
|
|||
}
|
||||
|
||||
// Messages
|
||||
| { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AppMessage[] } }
|
||||
| { id?: string; type: "response"; command: "get_messages"; success: true; data: { messages: AgentMessage[] } }
|
||||
|
||||
// Error response (any command can fail)
|
||||
| { id?: string; type: "response"; command: string; success: false; error: string };
|
||||
|
|
@ -180,7 +181,8 @@ export type RpcHookUIRequest =
|
|||
method: "notify";
|
||||
message: string;
|
||||
notifyType?: "info" | "warning" | "error";
|
||||
};
|
||||
}
|
||||
| { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string };
|
||||
|
||||
// ============================================================================
|
||||
// Hook UI Commands (stdin)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue