diff --git a/AGENTS.md b/AGENTS.md index 032c3baf2..2650e7b86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,47 @@ This repo contains multiple packages, but **`packages/coding-agent/`** is the pr ## Code Quality - No `any` types unless absolutely necessary +- **No `private`/`protected`/`public` keyword on class fields or methods** — use ES native `#` private fields for encapsulation; leave members that need external access as bare (no keyword). The only place `private`/`protected`/`public` is allowed is on **constructor parameter properties** (e.g., `constructor(private readonly session: ToolSession)`), where TypeScript requires the keyword for the implicit field declaration. + + ```typescript + // BAD: TypeScript keyword privacy + class Foo { + private bar: string; + private _baz = 0; + protected qux(): void { ... } + public greet(): void { ... } + } + + // GOOD: ES native # for private, bare for accessible + class Foo { + #bar: string; + #baz = 0; + qux(): void { ... } + greet(): void { ... } + } + + // OK: constructor parameter properties keep the keyword + class Service { + constructor(private readonly session: ToolSession) {} + } + ``` + +- **NEVER use `ReturnType<>`** — it obscures types behind indirection. Use the actual type name instead. Look up return types in source or `node_modules` type definitions and reference them directly. + + ```typescript + // BAD: Indirection through ReturnType + let timer: ReturnType | null = null; + let stmt: ReturnType; + let stat: Awaited>; + + // GOOD: Use the actual type + let timer?: NodeJS.Timeout; + let stmt: Statement; + let stat: Stats; + ``` + + If a function's return type has no exported name, define a named type alias at the call site — don't use `ReturnType<>`. + - Check node_modules for external API type definitions instead of guessing - **NEVER use inline imports** - no `await import("./foo.js")`, no `import("pkg").Type` in type positions, no dynamic imports for types. Always use standard top-level imports. - NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead @@ -122,18 +163,6 @@ import * as os from "node:os"; - **Async-only file** → `import * as fs from "node:fs/promises"` - **Needs both sync and async** → `import * as fs from "node:fs"`, use `fs.promises.xxx` for async -```typescript -// File with only async operations -import * as fs from "node:fs/promises"; -await fs.readdir(dir); -await fs.stat(path); - -// File mixing sync and async (e.g., sync in constructor, async in methods) -import * as fs from "node:fs"; -fs.existsSync(path); // sync -await fs.promises.readdir(dir); // async -``` - ### File I/O **Prefer Bun file APIs:** @@ -142,7 +171,6 @@ await fs.promises.readdir(dir); // async // Read const text = await Bun.file(path).text(); const data = await Bun.file(path).json(); -const exists = await Bun.file(path).exists(); // Write await Bun.write(path, data); @@ -184,14 +212,8 @@ if (await Bun.file(path).exists()) { return await Bun.file(path).json(); } -// BAD: Even with handle reuse, still two syscalls -const file = Bun.file(path); -if (await file.exists()) { - return await file.json(); -} - // GOOD: One syscall, atomic, type-safe error handling -import { isEnoent } from "@anthropic/pi-utils"; +import { isEnoent } from "@oh-my-pi/pi-utils"; try { return await Bun.file(path).json(); @@ -201,23 +223,6 @@ try { } ``` -**NEVER use `Bun.file().exists()` for directories** — it doesn't distinguish files from dirs: - -```typescript -// BAD: Bun.file is for files, not directories -if (await Bun.file(dirPath).exists()) { ... } - -// GOOD: Use fs.stat for directories -import * as fs from "node:fs/promises"; - -try { - const s = await fs.stat(dirPath); - if (s.isDirectory()) { ... } -} catch (err) { - if (isEnoent(err)) { /* doesn't exist */ } -} -``` - **NEVER create multiple handles to the same path**: ```typescript @@ -420,6 +425,7 @@ All text displayed in tool renderers must be sanitized before output. Raw conten ### Where to apply Sanitization applies to **every** code path that renders text to the TUI, including: + - Success output (file previews, command output, search results) - **Error messages** — these often embed file content (e.g., patch failure messages include the lines that failed to match) - Diff content (both added/removed lines) diff --git a/docs/porting-from-pi-mono.md b/docs/porting-from-pi-mono.md index 3a86fcf1b..8178391a2 100644 --- a/docs/porting-from-pi-mono.md +++ b/docs/porting-from-pi-mono.md @@ -130,6 +130,7 @@ Treat `package.json` as a contract. Merge intentionally. - Never build prompts in code; prompts are static `.md` files rendered with Handlebars. - In coding-agent, never use `console.log`/`console.warn`/`console.error`; use `logger` from `@oh-my-pi/pi-utils`. - Use `Promise.withResolvers()` instead of `new Promise((resolve, reject) => ...)`. +- **No `private`/`protected`/`public` keywords on class fields or methods.** Use ES `#` private fields for encapsulation; leave accessible members bare (no keyword). The only exception is constructor parameter properties (`constructor(private readonly x: T)`), where the keyword is required by TypeScript. When porting upstream code that uses `private foo` or `protected bar`, convert to `#foo` (private) or bare `bar` (accessible). - Prefer existing helpers and utilities over new ad-hoc code. - Preserve Bun-first infrastructure changes already made in this repo: - Runtime is Bun (no Node entry points). diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 46d9ade05..7d502d068 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -132,7 +132,7 @@ interface CursorToolResultEntry { } export class Agent { - private _state: AgentState = { + #state: AgentState = { systemPrompt: "", model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"), thinkingLevel: "off", @@ -144,53 +144,54 @@ export class Agent { error: undefined, }; - private listeners = new Set<(e: AgentEvent) => void>(); - private abortController?: AbortController; - private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; - private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - private steeringQueue: AgentMessage[] = []; - private followUpQueue: AgentMessage[] = []; - private steeringMode: "all" | "one-at-a-time"; - private followUpMode: "all" | "one-at-a-time"; - private interruptMode: "immediate" | "wait"; - public streamFn: StreamFn; - private _sessionId?: string; - private _thinkingBudgets?: ThinkingBudgets; - private _maxRetryDelayMs?: number; - public getApiKey?: (provider: string) => Promise | string | undefined; - private getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined; - private cursorExecHandlers?: CursorExecHandlers; - private cursorOnToolResult?: CursorToolResultHandler; - private runningPrompt?: Promise; - private resolveRunningPrompt?: () => void; - private kimiApiFormat?: "openai" | "anthropic"; + #listeners = new Set<(e: AgentEvent) => void>(); + #abortController?: AbortController; + #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; + #transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + #steeringQueue: AgentMessage[] = []; + #followUpQueue: AgentMessage[] = []; + #steeringMode: "all" | "one-at-a-time"; + #followUpMode: "all" | "one-at-a-time"; + #interruptMode: "immediate" | "wait"; + #sessionId?: string; + #thinkingBudgets?: ThinkingBudgets; + #maxRetryDelayMs?: number; + #getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined; + #cursorExecHandlers?: CursorExecHandlers; + #cursorOnToolResult?: CursorToolResultHandler; + #runningPrompt?: Promise; + #resolveRunningPrompt?: () => void; + #kimiApiFormat?: "openai" | "anthropic"; /** Buffered Cursor tool results with text length at time of call (for correct ordering) */ - private _cursorToolResultBuffer: CursorToolResultEntry[] = []; + #cursorToolResultBuffer: CursorToolResultEntry[] = []; + + streamFn: StreamFn; + getApiKey?: (provider: string) => Promise | string | undefined; constructor(opts: AgentOptions = {}) { - this._state = { ...this._state, ...opts.initialState }; - this.convertToLlm = opts.convertToLlm || defaultConvertToLlm; - this.transformContext = opts.transformContext; - this.steeringMode = opts.steeringMode || "one-at-a-time"; - this.followUpMode = opts.followUpMode || "one-at-a-time"; - this.interruptMode = opts.interruptMode || "immediate"; + this.#state = { ...this.#state, ...opts.initialState }; + this.#convertToLlm = opts.convertToLlm || defaultConvertToLlm; + this.#transformContext = opts.transformContext; + this.#steeringMode = opts.steeringMode || "one-at-a-time"; + this.#followUpMode = opts.followUpMode || "one-at-a-time"; + this.#interruptMode = opts.interruptMode || "immediate"; this.streamFn = opts.streamFn || streamSimple; - this._sessionId = opts.sessionId; - this._thinkingBudgets = opts.thinkingBudgets; - this._maxRetryDelayMs = opts.maxRetryDelayMs; + this.#sessionId = opts.sessionId; + this.#thinkingBudgets = opts.thinkingBudgets; + this.#maxRetryDelayMs = opts.maxRetryDelayMs; this.getApiKey = opts.getApiKey; - this.getToolContext = opts.getToolContext; - this.cursorExecHandlers = opts.cursorExecHandlers; - this.cursorOnToolResult = opts.cursorOnToolResult; - this.kimiApiFormat = opts.kimiApiFormat; + this.#getToolContext = opts.getToolContext; + this.#cursorExecHandlers = opts.cursorExecHandlers; + this.#cursorOnToolResult = opts.cursorOnToolResult; + this.#kimiApiFormat = opts.kimiApiFormat; } /** * Get the current session ID used for provider caching. */ get sessionId(): string | undefined { - return this._sessionId; + return this.#sessionId; } /** @@ -198,28 +199,28 @@ export class Agent { * Call this when switching sessions (new session, branch, resume). */ set sessionId(value: string | undefined) { - this._sessionId = value; + this.#sessionId = value; } /** * Get the current thinking budgets. */ get thinkingBudgets(): ThinkingBudgets | undefined { - return this._thinkingBudgets; + return this.#thinkingBudgets; } /** * Set custom thinking budgets for token-based providers. */ set thinkingBudgets(value: ThinkingBudgets | undefined) { - this._thinkingBudgets = value; + this.#thinkingBudgets = value; } /** * Get the current max retry delay in milliseconds. */ get maxRetryDelayMs(): number | undefined { - return this._maxRetryDelayMs; + return this.#maxRetryDelayMs; } /** @@ -227,101 +228,101 @@ export class Agent { * Set to 0 to disable the cap. */ set maxRetryDelayMs(value: number | undefined) { - this._maxRetryDelayMs = value; + this.#maxRetryDelayMs = value; } get state(): AgentState { - return this._state; + return this.#state; } subscribe(fn: (e: AgentEvent) => void): () => void { - this.listeners.add(fn); - return () => this.listeners.delete(fn); + this.#listeners.add(fn); + return () => this.#listeners.delete(fn); } emitExternalEvent(event: AgentEvent) { switch (event.type) { case "message_start": case "message_update": - this._state.streamMessage = event.message; + this.#state.streamMessage = event.message; break; case "message_end": - this._state.streamMessage = null; + this.#state.streamMessage = null; this.appendMessage(event.message); break; case "tool_execution_start": { - const pending = new Set(this._state.pendingToolCalls); + const pending = new Set(this.#state.pendingToolCalls); pending.add(event.toolCallId); - this._state.pendingToolCalls = pending; + this.#state.pendingToolCalls = pending; break; } case "tool_execution_end": { - const pending = new Set(this._state.pendingToolCalls); + const pending = new Set(this.#state.pendingToolCalls); pending.delete(event.toolCallId); - this._state.pendingToolCalls = pending; + this.#state.pendingToolCalls = pending; break; } } - this.emit(event); + this.#emit(event); } // State mutators setSystemPrompt(v: string) { - this._state.systemPrompt = v; + this.#state.systemPrompt = v; } setModel(m: Model) { - this._state.model = m; + this.#state.model = m; } setThinkingLevel(l: ThinkingLevel) { - this._state.thinkingLevel = l; + this.#state.thinkingLevel = l; } setSteeringMode(mode: "all" | "one-at-a-time") { - this.steeringMode = mode; + this.#steeringMode = mode; } getSteeringMode(): "all" | "one-at-a-time" { - return this.steeringMode; + return this.#steeringMode; } setFollowUpMode(mode: "all" | "one-at-a-time") { - this.followUpMode = mode; + this.#followUpMode = mode; } getFollowUpMode(): "all" | "one-at-a-time" { - return this.followUpMode; + return this.#followUpMode; } setInterruptMode(mode: "immediate" | "wait") { - this.interruptMode = mode; + this.#interruptMode = mode; } getInterruptMode(): "immediate" | "wait" { - return this.interruptMode; + return this.#interruptMode; } setTools(t: AgentTool[]) { - this._state.tools = t; + this.#state.tools = t; } replaceMessages(ms: AgentMessage[]) { - this._state.messages = ms.slice(); + this.#state.messages = ms.slice(); } appendMessage(m: AgentMessage) { - this._state.messages = [...this._state.messages, m]; + this.#state.messages = [...this.#state.messages, m]; } popMessage(): AgentMessage | undefined { - const messages = this._state.messages.slice(0, -1); - const removed = this._state.messages.at(-1); - this._state.messages = messages; + const messages = this.#state.messages.slice(0, -1); + const removed = this.#state.messages.at(-1); + this.#state.messages = messages; - if (removed && this._state.streamMessage === removed) { - this._state.streamMessage = null; + if (removed && this.#state.streamMessage === removed) { + this.#state.streamMessage = null; } return removed; @@ -332,7 +333,7 @@ export class Agent { * Delivered after current tool execution, skips remaining tools. */ steer(m: AgentMessage) { - this.steeringQueue.push(m); + this.#steeringQueue.push(m); } /** @@ -340,51 +341,51 @@ export class Agent { * Delivered only when agent has no more tool calls or steering messages. */ followUp(m: AgentMessage) { - this.followUpQueue.push(m); + this.#followUpQueue.push(m); } clearSteeringQueue() { - this.steeringQueue = []; + this.#steeringQueue = []; } clearFollowUpQueue() { - this.followUpQueue = []; + this.#followUpQueue = []; } clearAllQueues() { - this.steeringQueue = []; - this.followUpQueue = []; + this.#steeringQueue = []; + this.#followUpQueue = []; } hasQueuedMessages(): boolean { - return this.steeringQueue.length > 0 || this.followUpQueue.length > 0; + return this.#steeringQueue.length > 0 || this.#followUpQueue.length > 0; } - private dequeueSteeringMessages(): AgentMessage[] { - if (this.steeringMode === "one-at-a-time") { - if (this.steeringQueue.length > 0) { - const first = this.steeringQueue[0]; - this.steeringQueue = this.steeringQueue.slice(1); + #dequeueSteeringMessages(): AgentMessage[] { + if (this.#steeringMode === "one-at-a-time") { + if (this.#steeringQueue.length > 0) { + const first = this.#steeringQueue[0]; + this.#steeringQueue = this.#steeringQueue.slice(1); return [first]; } return []; } - const steering = this.steeringQueue.slice(); - this.steeringQueue = []; + const steering = this.#steeringQueue.slice(); + this.#steeringQueue = []; return steering; } - private dequeueFollowUpMessages(): AgentMessage[] { - if (this.followUpMode === "one-at-a-time") { - if (this.followUpQueue.length > 0) { - const first = this.followUpQueue[0]; - this.followUpQueue = this.followUpQueue.slice(1); + #dequeueFollowUpMessages(): AgentMessage[] { + if (this.#followUpMode === "one-at-a-time") { + if (this.#followUpQueue.length > 0) { + const first = this.#followUpQueue[0]; + this.#followUpQueue = this.#followUpQueue.slice(1); return [first]; } return []; } - const followUp = this.followUpQueue.slice(); - this.followUpQueue = []; + const followUp = this.#followUpQueue.slice(); + this.#followUpQueue = []; return followUp; } @@ -393,7 +394,7 @@ export class Agent { * Used by dequeue keybinding. */ popLastSteer(): AgentMessage | undefined { - return this.steeringQueue.pop(); + return this.#steeringQueue.pop(); } /** @@ -401,29 +402,29 @@ export class Agent { * Used by dequeue keybinding. */ popLastFollowUp(): AgentMessage | undefined { - return this.followUpQueue.pop(); + return this.#followUpQueue.pop(); } clearMessages() { - this._state.messages = []; + this.#state.messages = []; } abort() { - this.abortController?.abort(); + this.#abortController?.abort(); } waitForIdle(): Promise { - return this.runningPrompt ?? Promise.resolve(); + return this.#runningPrompt ?? Promise.resolve(); } reset() { - this._state.messages = []; - this._state.isStreaming = false; - this._state.streamMessage = null; - this._state.pendingToolCalls = new Set(); - this._state.error = undefined; - this.steeringQueue = []; - this.followUpQueue = []; + this.#state.messages = []; + this.#state.isStreaming = false; + this.#state.streamMessage = null; + this.#state.pendingToolCalls = new Set(); + this.#state.error = undefined; + this.#steeringQueue = []; + this.#followUpQueue = []; } /** Send a prompt with an AgentMessage */ @@ -434,13 +435,13 @@ export class Agent { imagesOrOptions?: ImageContent[] | AgentPromptOptions, options?: AgentPromptOptions, ) { - if (this._state.isStreaming) { + if (this.#state.isStreaming) { throw new Error( "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.", ); } - const model = this._state.model; + const model = this.#state.model; if (!model) throw new Error("No model configured"); let msgs: AgentMessage[]; @@ -473,38 +474,38 @@ export class Agent { promptOptions = imagesOrOptions as AgentPromptOptions | undefined; } - await this._runLoop(msgs, promptOptions); + await this.#runLoop(msgs, promptOptions); } /** * Continue from current context (used for retries and resuming queued messages). */ async continue() { - if (this._state.isStreaming) { + if (this.#state.isStreaming) { throw new Error("Agent is already processing. Wait for completion before continuing."); } - const messages = this._state.messages; + const messages = this.#state.messages; if (messages.length === 0) { throw new Error("No messages to continue from"); } if (messages[messages.length - 1].role === "assistant") { - const queuedSteering = this.dequeueSteeringMessages(); + const queuedSteering = this.#dequeueSteeringMessages(); if (queuedSteering.length > 0) { - await this._runLoop(queuedSteering, { skipInitialSteeringPoll: true }); + await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }); return; } - const queuedFollowUp = this.dequeueFollowUpMessages(); + const queuedFollowUp = this.#dequeueFollowUpMessages(); if (queuedFollowUp.length > 0) { - await this._runLoop(queuedFollowUp); + await this.#runLoop(queuedFollowUp); return; } throw new Error("Cannot continue from message role: assistant"); } - await this._runLoop(undefined); + await this.#runLoop(undefined); } /** @@ -512,42 +513,39 @@ export class Agent { * If messages are provided, starts a new conversation turn with those messages. * Otherwise, continues from existing context. */ - private async _runLoop( - messages?: AgentMessage[], - options?: AgentPromptOptions & { skipInitialSteeringPoll?: boolean }, - ) { - const model = this._state.model; + async #runLoop(messages?: AgentMessage[], options?: AgentPromptOptions & { skipInitialSteeringPoll?: boolean }) { + const model = this.#state.model; if (!model) throw new Error("No model configured"); let skipInitialSteeringPoll = options?.skipInitialSteeringPoll === true; - this.runningPrompt = new Promise(resolve => { - this.resolveRunningPrompt = resolve; + this.#runningPrompt = new Promise(resolve => { + this.#resolveRunningPrompt = resolve; }); - this.abortController = new AbortController(); - this._state.isStreaming = true; - this._state.streamMessage = null; - this._state.error = undefined; + this.#abortController = new AbortController(); + this.#state.isStreaming = true; + this.#state.streamMessage = null; + this.#state.error = undefined; // Clear Cursor tool result buffer at start of each run - this._cursorToolResultBuffer = []; + this.#cursorToolResultBuffer = []; - const reasoning = this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel; + const reasoning = this.#state.thinkingLevel === "off" ? undefined : this.#state.thinkingLevel; const context: AgentContext = { - systemPrompt: this._state.systemPrompt, - messages: this._state.messages.slice(), - tools: this._state.tools, + systemPrompt: this.#state.systemPrompt, + messages: this.#state.messages.slice(), + tools: this.#state.tools, }; const cursorOnToolResult = - this.cursorExecHandlers || this.cursorOnToolResult + this.#cursorExecHandlers || this.#cursorOnToolResult ? async (message: ToolResultMessage) => { let finalMessage = message; - if (this.cursorOnToolResult) { + if (this.#cursorOnToolResult) { try { - const updated = await this.cursorOnToolResult(message); + const updated = await this.#cursorOnToolResult(message); if (updated) { finalMessage = updated; } @@ -557,8 +555,8 @@ export class Agent { // Cursor executes tools server-side during streaming, so the assistant message // already incorporates results. We buffer here and emit in correct order // when the assistant message ends. - const textLength = this._getAssistantTextLength(this._state.streamMessage); - this._cursorToolResultBuffer.push({ toolResult: finalMessage, textLengthAtCall: textLength }); + const textLength = this.#getAssistantTextLength(this.#state.streamMessage); + this.#cursorToolResultBuffer.push({ toolResult: finalMessage, textLengthAtCall: textLength }); return finalMessage; } : undefined; @@ -566,88 +564,88 @@ export class Agent { const config: AgentLoopConfig = { model, reasoning, - interruptMode: this.interruptMode, - sessionId: this._sessionId, - thinkingBudgets: this._thinkingBudgets, - maxRetryDelayMs: this._maxRetryDelayMs, - kimiApiFormat: this.kimiApiFormat, + interruptMode: this.#interruptMode, + sessionId: this.#sessionId, + thinkingBudgets: this.#thinkingBudgets, + maxRetryDelayMs: this.#maxRetryDelayMs, + kimiApiFormat: this.#kimiApiFormat, toolChoice: options?.toolChoice, - convertToLlm: this.convertToLlm, - transformContext: this.transformContext, + convertToLlm: this.#convertToLlm, + transformContext: this.#transformContext, getApiKey: this.getApiKey, - getToolContext: this.getToolContext, - cursorExecHandlers: this.cursorExecHandlers, + getToolContext: this.#getToolContext, + cursorExecHandlers: this.#cursorExecHandlers, cursorOnToolResult, getSteeringMessages: async () => { if (skipInitialSteeringPoll) { skipInitialSteeringPoll = false; return []; } - return this.dequeueSteeringMessages(); + return this.#dequeueSteeringMessages(); }, - getFollowUpMessages: async () => this.dequeueFollowUpMessages(), + getFollowUpMessages: async () => this.#dequeueFollowUpMessages(), }; let partial: AgentMessage | null = null; try { const stream = messages - ? agentLoop(messages, context, config, this.abortController.signal, this.streamFn) - : agentLoopContinue(context, config, this.abortController.signal, this.streamFn); + ? agentLoop(messages, context, config, this.#abortController.signal, this.streamFn) + : agentLoopContinue(context, config, this.#abortController.signal, this.streamFn); for await (const event of stream) { // Update internal state based on events switch (event.type) { case "message_start": partial = event.message; - this._state.streamMessage = event.message; + this.#state.streamMessage = event.message; break; case "message_update": partial = event.message; - this._state.streamMessage = event.message; + this.#state.streamMessage = event.message; break; case "message_end": partial = null; // Check if this is an assistant message with buffered Cursor tool results. // If so, split the message to emit tool results at the correct position. - if (event.message.role === "assistant" && this._cursorToolResultBuffer.length > 0) { - this._emitCursorSplitAssistantMessage(event.message as AssistantMessage); + if (event.message.role === "assistant" && this.#cursorToolResultBuffer.length > 0) { + this.#emitCursorSplitAssistantMessage(event.message as AssistantMessage); continue; // Skip default emit - split method handles everything } - this._state.streamMessage = null; + this.#state.streamMessage = null; this.appendMessage(event.message); break; case "tool_execution_start": { - const s = new Set(this._state.pendingToolCalls); + const s = new Set(this.#state.pendingToolCalls); s.add(event.toolCallId); - this._state.pendingToolCalls = s; + this.#state.pendingToolCalls = s; break; } case "tool_execution_end": { - const s = new Set(this._state.pendingToolCalls); + const s = new Set(this.#state.pendingToolCalls); s.delete(event.toolCallId); - this._state.pendingToolCalls = s; + this.#state.pendingToolCalls = s; break; } case "turn_end": if (event.message.role === "assistant" && (event.message as any).errorMessage) { - this._state.error = (event.message as any).errorMessage; + this.#state.error = (event.message as any).errorMessage; } break; case "agent_end": - this._state.isStreaming = false; - this._state.streamMessage = null; + this.#state.isStreaming = false; + this.#state.streamMessage = null; break; } // Emit to listeners - this.emit(event); + this.#emit(event); } // Handle any remaining partial message @@ -661,7 +659,7 @@ export class Agent { if (!onlyEmpty) { this.appendMessage(partial); } else { - if (this.abortController?.signal.aborted) { + if (this.#abortController?.signal.aborted) { throw new Error("Request was aborted"); } } @@ -681,33 +679,33 @@ export class Agent { totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, - stopReason: this.abortController?.signal.aborted ? "aborted" : "error", + stopReason: this.#abortController?.signal.aborted ? "aborted" : "error", errorMessage: err?.message || String(err), timestamp: Date.now(), } as AgentMessage; this.appendMessage(errorMsg); - this._state.error = err?.message || String(err); - this.emit({ type: "agent_end", messages: [errorMsg] }); + this.#state.error = err?.message || String(err); + this.#emit({ type: "agent_end", messages: [errorMsg] }); } finally { - this._state.isStreaming = false; - this._state.streamMessage = null; - this._state.pendingToolCalls = new Set(); - this.abortController = undefined; - this.resolveRunningPrompt?.(); - this.runningPrompt = undefined; - this.resolveRunningPrompt = undefined; + this.#state.isStreaming = false; + this.#state.streamMessage = null; + this.#state.pendingToolCalls = new Set(); + this.#abortController = undefined; + this.#resolveRunningPrompt?.(); + this.#runningPrompt = undefined; + this.#resolveRunningPrompt = undefined; } } - private emit(e: AgentEvent) { - for (const listener of this.listeners) { + #emit(e: AgentEvent) { + for (const listener of this.#listeners) { listener(e); } } /** Calculate total text length from an assistant message's content blocks */ - private _getAssistantTextLength(message: AgentMessage | null): number { + #getAssistantTextLength(message: AgentMessage | null): number { if (!message || message.role !== "assistant" || !Array.isArray(message.content)) { return 0; } @@ -726,15 +724,15 @@ export class Agent { * * Output order: Assistant(preamble) -> ToolResults -> Assistant(continuation) */ - private _emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): void { - const buffer = this._cursorToolResultBuffer; - this._cursorToolResultBuffer = []; + #emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): void { + const buffer = this.#cursorToolResultBuffer; + this.#cursorToolResultBuffer = []; if (buffer.length === 0) { // No tool results, emit normally - this._state.streamMessage = null; + this.#state.streamMessage = null; this.appendMessage(assistantMessage); - this.emit({ type: "message_end", message: assistantMessage }); + this.#emit({ type: "message_end", message: assistantMessage }); return; } @@ -753,15 +751,15 @@ export class Agent { // If no text or split point is 0 or at/past end, don't split if (fullText.length === 0 || splitPoint <= 0 || splitPoint >= fullText.length) { // Emit assistant message first, then tool results (original behavior but with buffered results) - this._state.streamMessage = null; + this.#state.streamMessage = null; this.appendMessage(assistantMessage); - this.emit({ type: "message_end", message: assistantMessage }); + this.#emit({ type: "message_end", message: assistantMessage }); // Emit buffered tool results for (const { toolResult } of buffer) { - this.emit({ type: "message_start", message: toolResult }); + this.#emit({ type: "message_start", message: toolResult }); this.appendMessage(toolResult); - this.emit({ type: "message_end", message: toolResult }); + this.#emit({ type: "message_end", message: toolResult }); } return; } @@ -783,15 +781,15 @@ export class Agent { }; // Emit preamble - this._state.streamMessage = null; + this.#state.streamMessage = null; this.appendMessage(preambleMessage); - this.emit({ type: "message_end", message: preambleMessage }); + this.#emit({ type: "message_end", message: preambleMessage }); // Emit buffered tool results for (const { toolResult } of buffer) { - this.emit({ type: "message_start", message: toolResult }); + this.#emit({ type: "message_start", message: toolResult }); this.appendMessage(toolResult); - this.emit({ type: "message_end", message: toolResult }); + this.#emit({ type: "message_end", message: toolResult }); } // Emit continuation message (text after tools) if non-empty @@ -812,9 +810,9 @@ export class Agent { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, }; - this.emit({ type: "message_start", message: continuationMessage }); + this.#emit({ type: "message_start", message: continuationMessage }); this.appendMessage(continuationMessage); - this.emit({ type: "message_end", message: continuationMessage }); + this.#emit({ type: "message_end", message: continuationMessage }); } } } diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 0cebd6323..36ddda1be 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.ts @@ -148,7 +148,10 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream } let sawTerminalEvent = false; - for await (const event of readSseJson(response.body!, options.signal)) { + for await (const event of readSseJson( + response.body as ReadableStream, + options.signal, + )) { const parsedEvent = processProxyEvent(event, partial); if (parsedEvent) { if (parsedEvent.type === "done" || parsedEvent.type === "error") { @@ -310,11 +313,5 @@ function processProxyEvent( partial.errorMessage = proxyEvent.errorMessage; partial.usage = proxyEvent.usage; return { type: "error", reason: proxyEvent.reason, error: partial }; - - default: { - const _exhaustiveCheck: never = proxyEvent; - console.warn(`Unhandled proxy event type: ${(proxyEvent as any).type}`); - return undefined; - } } } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 22c394c24..da532eaec 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -1,5 +1,6 @@ import type { AssistantMessageEvent, + AssistantMessageEventStream, ImageContent, Message, Model, @@ -14,7 +15,7 @@ import type { Static, TSchema } from "@sinclair/typebox"; /** Stream function - can return sync or Promise for async config lookup */ export type StreamFn = ( ...args: Parameters -) => ReturnType | Promise>; +) => AssistantMessageEventStream | Promise; /** * Configuration for the agent loop. diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index c2d8ecf6d..270829967 100755 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -import { createInterface } from "readline"; +import * as readline from "node:readline"; import { CliAuthStorage } from "./storage"; import { getOAuthProviders } from "./utils/oauth"; import { loginAnthropic } from "./utils/oauth/anthropic"; @@ -13,14 +13,14 @@ import type { OAuthCredentials, OAuthProvider } from "./utils/oauth/types"; const PROVIDERS = getOAuthProviders(); -function prompt(rl: ReturnType, question: string): Promise { +function prompt(rl: readline.Interface, question: string): Promise { const { promise, resolve } = Promise.withResolvers(); rl.question(question, resolve); return promise; } async function login(provider: OAuthProvider): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const promptFn = (msg: string) => prompt(rl, `${msg} `); const storage = await CliAuthStorage.create(); @@ -201,7 +201,7 @@ Examples: return; } - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); console.log("Select a provider to logout:\n"); for (let i = 0; i < providers.length; i++) { console.log(` ${i + 1}. ${providers[i]}`); @@ -237,7 +237,7 @@ Examples: let provider = args[1] as OAuthProvider | undefined; if (!provider) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); console.log("Select a provider:\n"); for (let i = 0; i < PROVIDERS.length; i++) { console.log(` ${i + 1}. ${PROVIDERS[i].name}`); diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index f8f0057ef..4a12908bd 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -317,7 +317,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( let h2Client: http2.ClientHttp2Session | null = null; let h2Request: http2.ClientHttp2Stream | null = null; - let heartbeatTimer: ReturnType | null = null; + let heartbeatTimer: NodeJS.Timeout | null = null; try { const apiKey = options?.apiKey; diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 671431ccc..481da9688 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -4,7 +4,7 @@ * Uses the Cloud Code Assist API endpoint to access Gemini and Claude models. */ import { createHash } from "node:crypto"; -import type { Content, ThinkingConfig } from "@google/genai"; +import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "@google/genai"; import { abortableSleep, readSseJson } from "@oh-my-pi/pi-utils"; import { calculateCost } from "../models"; import type { @@ -244,10 +244,10 @@ interface CloudCodeAssistRequest { temperature?: number; thinkingConfig?: ThinkingConfig; }; - tools?: ReturnType; + tools?: { functionDeclarations: Record[] }[] | undefined; toolConfig?: { functionCallingConfig: { - mode: ReturnType; + mode: FunctionCallingConfigMode; }; }; }; diff --git a/packages/ai/src/storage.ts b/packages/ai/src/storage.ts index 72447d32d..3cdfa1de1 100644 --- a/packages/ai/src/storage.ts +++ b/packages/ai/src/storage.ts @@ -3,7 +3,7 @@ * Compatible with coding-agent's agent.db format. */ -import { Database } from "bun:sqlite"; +import { Database, type Statement } from "bun:sqlite"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -84,22 +84,22 @@ function deserializeCredential(row: AuthRow): AuthCredential | null { * Use `CliAuthStorage.create()` to instantiate (async initialization). */ export class CliAuthStorage { - private db: Database; - private insertStmt: ReturnType; - private listByProviderStmt: ReturnType; - private listAllStmt: ReturnType; - private deleteByProviderStmt: ReturnType; + #db: Database; + #insertStmt: Statement; + #listByProviderStmt: Statement; + #listAllStmt: Statement; + #deleteByProviderStmt: Statement; private constructor(db: Database) { - this.db = db; - this.initializeSchema(); + this.#db = db; + this.#initializeSchema(); - this.insertStmt = this.db.prepare( + this.#insertStmt = this.#db.prepare( "INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, ?, ?) RETURNING id", ); - this.listByProviderStmt = this.db.prepare("SELECT * FROM auth_credentials WHERE provider = ?"); - this.listAllStmt = this.db.prepare("SELECT * FROM auth_credentials"); - this.deleteByProviderStmt = this.db.prepare("DELETE FROM auth_credentials WHERE provider = ?"); + this.#listByProviderStmt = this.#db.prepare("SELECT * FROM auth_credentials WHERE provider = ?"); + this.#listAllStmt = this.#db.prepare("SELECT * FROM auth_credentials"); + this.#deleteByProviderStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE provider = ?"); } static async create(dbPath: string = getAgentDbPath()): Promise { @@ -122,8 +122,8 @@ export class CliAuthStorage { return new CliAuthStorage(db); } - private initializeSchema(): void { - this.db.exec(` + #initializeSchema(): void { + this.#db.exec(` PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; @@ -145,14 +145,14 @@ CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider); */ saveOAuth(provider: string, credentials: OAuthCredentials): void { const credential: AuthCredential = { type: "oauth", ...credentials }; - this.replaceForProvider(provider, credential); + this.#replaceForProvider(provider, credential); } /** * Get OAuth credentials for a provider. */ getOAuth(provider: string): OAuthCredentials | null { - const rows = this.listByProviderStmt.all(provider) as AuthRow[]; + const rows = this.#listByProviderStmt.all(provider) as AuthRow[]; for (const row of rows) { const credential = deserializeCredential(row); if (credential && credential.type === "oauth") { @@ -167,7 +167,7 @@ CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider); * List all providers with credentials. */ listProviders(): string[] { - const rows = this.listAllStmt.all() as AuthRow[]; + const rows = this.#listAllStmt.all() as AuthRow[]; const providers = new Set(); for (const row of rows) { providers.add(row.provider); @@ -179,24 +179,24 @@ CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider); * Delete all credentials for a provider. */ deleteProvider(provider: string): void { - this.deleteByProviderStmt.run(provider); + this.#deleteByProviderStmt.run(provider); } /** * Replace all credentials for a provider with a single credential. */ - private replaceForProvider(provider: string, credential: AuthCredential): void { + #replaceForProvider(provider: string, credential: AuthCredential): void { const serialized = serializeCredential(credential); if (!serialized) return; - const replace = this.db.transaction(() => { - this.deleteByProviderStmt.run(provider); - this.insertStmt.run(provider, serialized.credentialType, serialized.data); + const replace = this.#db.transaction(() => { + this.#deleteByProviderStmt.run(provider); + this.#insertStmt.run(provider, serialized.credentialType, serialized.data); }); replace(); } close(): void { - this.db.close(); + this.#db.close(); } } diff --git a/packages/ai/src/utils/event-stream.ts b/packages/ai/src/utils/event-stream.ts index 50cdd3f94..3ff48bf5e 100644 --- a/packages/ai/src/utils/event-stream.ts +++ b/packages/ai/src/utils/event-stream.ts @@ -2,19 +2,20 @@ import type { AssistantMessage, AssistantMessageEvent } from "../types"; // Generic event stream class for async iteration export class EventStream implements AsyncIterable { - protected queue: T[] = []; - protected waiting: ((value: IteratorResult) => void)[] = []; - protected done = false; - protected finalResultPromise: Promise; - protected resolveFinalResult!: (result: R) => void; + queue: T[] = []; + waiting: ((value: IteratorResult) => void)[] = []; + done = false; + finalResultPromise: Promise; + resolveFinalResult!: (result: R) => void; + isComplete: (event: T) => boolean; + extractResult: (event: T) => R; - constructor( - protected isComplete: (event: T) => boolean, - protected extractResult: (event: T) => R, - ) { + constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) { const { promise, resolve } = Promise.withResolvers(); this.finalResultPromise = promise; this.resolveFinalResult = resolve; + this.isComplete = isComplete; + this.extractResult = extractResult; } push(event: T): void { @@ -34,7 +35,7 @@ export class EventStream implements AsyncIterable { } } - protected deliver(event: T): void { + deliver(event: T): void { const waiter = this.waiting.shift(); if (waiter) { waiter({ value: event, done: false }); @@ -55,7 +56,7 @@ export class EventStream implements AsyncIterable { } } - protected endWaiting(): void { + endWaiting(): void { while (this.waiting.length > 0) { const waiter = this.waiting.shift()!; waiter({ value: undefined as any, done: true }); @@ -93,10 +94,10 @@ function isDeltaEvent(event: AssistantMessageEvent): event is DeltaEvent { export class AssistantMessageEventStream extends EventStream { // Throttling state - private deltaBuffer: DeltaEvent[] = []; - private flushTimer: ReturnType | null = null; - private lastFlushTime = 0; - private readonly throttleMs = 50; // 20 updates/sec + #deltaBuffer: DeltaEvent[] = []; + #flushTimer?: NodeJS.Timeout; + #lastFlushTime = 0; + readonly #throttleMs = 50; // 20 updates/sec constructor() { super( @@ -117,25 +118,25 @@ export class AssistantMessageEventStream extends EventStream= this.throttleMs) { + if (timeSinceLastFlush >= this.#throttleMs) { // Flush immediately if throttle window has passed - this.flushDeltas(); + this.#flushDeltas(); } else { // Schedule flush for when throttle window expires - const delay = this.throttleMs - timeSinceLastFlush; - this.flushTimer = setTimeout(() => { - this.flushTimer = null; - this.flushDeltas(); + const delay = this.#throttleMs - timeSinceLastFlush; + this.#flushTimer = setTimeout(() => { + this.#flushTimer = undefined; + this.#flushDeltas(); }, delay); } } - private flushDeltas(): void { - if (this.flushTimer) { - clearTimeout(this.flushTimer); - this.flushTimer = null; + #flushDeltas(): void { + if (this.#flushTimer) { + clearTimeout(this.#flushTimer); + this.#flushTimer = undefined; } - if (this.deltaBuffer.length === 0) return; + if (this.#deltaBuffer.length === 0) return; // Merge consecutive deltas for the same content block and type - const merged = this.mergeDeltas(this.deltaBuffer); - this.deltaBuffer = []; - this.lastFlushTime = Bun.nanoseconds(); + const merged = this.#mergeDeltas(this.#deltaBuffer); + this.#deltaBuffer = []; + this.#lastFlushTime = Bun.nanoseconds(); for (const event of merged) { this.deliver(event); } } - private mergeDeltas(deltas: DeltaEvent[]): AssistantMessageEvent[] { + #mergeDeltas(deltas: DeltaEvent[]): AssistantMessageEvent[] { if (deltas.length === 0) return []; if (deltas.length === 1) return [deltas[0]]; diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/utils/oauth/anthropic.ts index 291076dd5..2b045beed 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/utils/oauth/anthropic.ts @@ -14,20 +14,17 @@ const CALLBACK_PATH = "/callback"; const SCOPES = "org:create_api_key user:profile user:inference"; class AnthropicOAuthFlow extends OAuthCallbackFlow { - private verifier: string = ""; - private challenge: string = ""; + #verifier: string = ""; + #challenge: string = ""; constructor(ctrl: OAuthController) { super(ctrl, CALLBACK_PORT, CALLBACK_PATH); } - protected async generateAuthUrl( - state: string, - redirectUri: string, - ): Promise<{ url: string; instructions?: string }> { + async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> { const pkce = await generatePKCE(); - this.verifier = pkce.verifier; - this.challenge = pkce.challenge; + this.#verifier = pkce.verifier; + this.#challenge = pkce.challenge; const authParams = new URLSearchParams({ code: "true", @@ -35,7 +32,7 @@ class AnthropicOAuthFlow extends OAuthCallbackFlow { response_type: "code", redirect_uri: redirectUri, scope: SCOPES, - code_challenge: this.challenge, + code_challenge: this.#challenge, code_challenge_method: "S256", state, }); @@ -44,7 +41,7 @@ class AnthropicOAuthFlow extends OAuthCallbackFlow { return { url }; } - protected async exchangeToken(code: string, state: string, redirectUri: string): Promise { + async exchangeToken(code: string, state: string, redirectUri: string): Promise { const tokenResponse = await fetch(TOKEN_URL, { method: "POST", headers: { @@ -57,7 +54,7 @@ class AnthropicOAuthFlow extends OAuthCallbackFlow { code, state, redirect_uri: redirectUri, - code_verifier: this.verifier, + code_verifier: this.#verifier, }), }); diff --git a/packages/ai/src/utils/oauth/callback-server.ts b/packages/ai/src/utils/oauth/callback-server.ts index f32323b7c..404ec75db 100644 --- a/packages/ai/src/utils/oauth/callback-server.ts +++ b/packages/ai/src/utils/oauth/callback-server.ts @@ -23,11 +23,11 @@ export type CallbackResult = { code: string; state: string }; * Abstract base class for OAuth flows with local callback servers. */ export abstract class OAuthCallbackFlow { - protected ctrl: OAuthController; - protected preferredPort: number; - protected callbackPath: string; - private callbackResolve?: (result: CallbackResult) => void; - private callbackReject?: (error: string) => void; + ctrl: OAuthController; + preferredPort: number; + callbackPath: string; + #callbackResolve?: (result: CallbackResult) => void; + #callbackReject?: (error: string) => void; constructor(ctrl: OAuthController, preferredPort: number, callbackPath: string = CALLBACK_PATH) { this.ctrl = ctrl; @@ -41,10 +41,7 @@ export abstract class OAuthCallbackFlow { * @param redirectUri - The actual redirect URI to use (may differ from expected if port fallback occurred) * @returns Authorization URL and optional instructions */ - protected abstract generateAuthUrl( - state: string, - redirectUri: string, - ): Promise<{ url: string; instructions?: string }>; + abstract generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }>; /** * Exchange authorization code for OAuth tokens. @@ -53,12 +50,12 @@ export abstract class OAuthCallbackFlow { * @param redirectUri - The actual redirect URI used (must match authorization request) * @returns OAuth credentials */ - protected abstract exchangeToken(code: string, state: string, redirectUri: string): Promise; + abstract exchangeToken(code: string, state: string, redirectUri: string): Promise; /** * Generate CSRF state token. Override if provider needs custom state generation. */ - protected generateState(): string { + generateState(): string { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); return Array.from(bytes) @@ -73,7 +70,7 @@ export abstract class OAuthCallbackFlow { const state = this.generateState(); // Start callback server first to get actual redirect URI - const { server, redirectUri } = await this.startCallbackServer(state); + const { server, redirectUri } = await this.#startCallbackServer(state); try { // Generate auth URL with the ACTUAL redirect URI (may differ from expected if port was busy) @@ -84,7 +81,7 @@ export abstract class OAuthCallbackFlow { this.ctrl.onProgress?.("Waiting for browser authentication..."); // Wait for callback or manual input - const { code } = await this.waitForCallback(state); + const { code } = await this.#waitForCallback(state); this.ctrl.onProgress?.("Exchanging authorization code for tokens..."); @@ -97,18 +94,16 @@ export abstract class OAuthCallbackFlow { /** * Start callback server, trying preferred port first, falling back to random. */ - private async startCallbackServer( - expectedState: string, - ): Promise<{ server: Bun.Server; redirectUri: string }> { + async #startCallbackServer(expectedState: string): Promise<{ server: Bun.Server; redirectUri: string }> { // Try preferred port first try { const redirectUri = `http://${DEFAULT_HOSTNAME}:${this.preferredPort}${this.callbackPath}`; - const server = this.createServer(this.preferredPort, expectedState); + const server = this.#createServer(this.preferredPort, expectedState); return { server, redirectUri }; } catch { // Port busy or unavailable, try random port const randomPort = 0; // Let OS assign - const server = this.createServer(randomPort, expectedState); + const server = this.#createServer(randomPort, expectedState); const actualPort = server.port; const redirectUri = `http://${DEFAULT_HOSTNAME}:${actualPort}${this.callbackPath}`; this.ctrl.onProgress?.(`Preferred port ${this.preferredPort} unavailable, using port ${actualPort}`); @@ -119,19 +114,19 @@ export abstract class OAuthCallbackFlow { /** * Create HTTP server for OAuth callback. */ - private createServer(port: number, expectedState: string): Bun.Server { + #createServer(port: number, expectedState: string): Bun.Server { return Bun.serve({ hostname: DEFAULT_HOSTNAME, port, reusePort: false, - fetch: req => this.handleCallback(req, expectedState), + fetch: req => this.#handleCallback(req, expectedState), }); } /** * Handle OAuth callback HTTP request. */ - private handleCallback(req: Request, expectedState: string): Response { + #handleCallback(req: Request, expectedState: string): Response { const url = new URL(req.url); if (url.pathname !== this.callbackPath) { @@ -158,8 +153,8 @@ export abstract class OAuthCallbackFlow { } // Signal to waitForCallback - capture refs before they could be cleared - const resolve = this.callbackResolve; - const reject = this.callbackReject; + const resolve = this.#callbackResolve; + const reject = this.#callbackReject; queueMicrotask(() => { if (resultState.ok) { resolve?.({ code: resultState.code, state: resultState.state }); @@ -180,17 +175,17 @@ export abstract class OAuthCallbackFlow { /** * Wait for OAuth callback or manual input (whichever comes first). */ - private waitForCallback(expectedState: string): Promise { + #waitForCallback(expectedState: string): Promise { const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT); const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeoutSignal]) : timeoutSignal; const callbackPromise = new Promise((resolve, reject) => { - this.callbackResolve = resolve; - this.callbackReject = reject; + this.#callbackResolve = resolve; + this.#callbackReject = reject; signal.addEventListener("abort", () => { - this.callbackResolve = undefined; - this.callbackReject = undefined; + this.#callbackResolve = undefined; + this.#callbackReject = undefined; reject(new Error(`OAuth callback cancelled: ${signal.reason}`)); }); }); diff --git a/packages/ai/src/utils/oauth/google-antigravity.ts b/packages/ai/src/utils/oauth/google-antigravity.ts index cfdc4a65e..e9c3ec4d2 100644 --- a/packages/ai/src/utils/oauth/google-antigravity.ts +++ b/packages/ai/src/utils/oauth/google-antigravity.ts @@ -103,27 +103,24 @@ async function getUserEmail(accessToken: string): Promise { } class AntigravityOAuthFlow extends OAuthCallbackFlow { - private verifier: string = ""; - private challenge: string = ""; + #verifier: string = ""; + #challenge: string = ""; constructor(ctrl: OAuthController) { super(ctrl, CALLBACK_PORT, CALLBACK_PATH); } - protected async generateAuthUrl( - state: string, - redirectUri: string, - ): Promise<{ url: string; instructions?: string }> { + async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> { const pkce = await generatePKCE(); - this.verifier = pkce.verifier; - this.challenge = pkce.challenge; + this.#verifier = pkce.verifier; + this.#challenge = pkce.challenge; const authParams = new URLSearchParams({ client_id: CLIENT_ID, response_type: "code", redirect_uri: redirectUri, scope: SCOPES.join(" "), - code_challenge: this.challenge, + code_challenge: this.#challenge, code_challenge_method: "S256", state, access_type: "offline", @@ -134,7 +131,7 @@ class AntigravityOAuthFlow extends OAuthCallbackFlow { return { url, instructions: "Complete the sign-in in your browser." }; } - protected async exchangeToken(code: string, _state: string, redirectUri: string): Promise { + async exchangeToken(code: string, _state: string, redirectUri: string): Promise { this.ctrl.onProgress?.("Exchanging authorization code for tokens..."); const tokenResponse = await fetch(TOKEN_URL, { @@ -146,7 +143,7 @@ class AntigravityOAuthFlow extends OAuthCallbackFlow { code, grant_type: "authorization_code", redirect_uri: redirectUri, - code_verifier: this.verifier, + code_verifier: this.#verifier, }), }); diff --git a/packages/ai/src/utils/oauth/google-gemini-cli.ts b/packages/ai/src/utils/oauth/google-gemini-cli.ts index 2d949eb02..bdab5081f 100644 --- a/packages/ai/src/utils/oauth/google-gemini-cli.ts +++ b/packages/ai/src/utils/oauth/google-gemini-cli.ts @@ -228,27 +228,24 @@ async function getUserEmail(accessToken: string): Promise { } class GeminiCliOAuthFlow extends OAuthCallbackFlow { - private verifier: string = ""; - private challenge: string = ""; + #verifier: string = ""; + #challenge: string = ""; constructor(ctrl: OAuthController) { super(ctrl, CALLBACK_PORT, CALLBACK_PATH); } - protected async generateAuthUrl( - state: string, - redirectUri: string, - ): Promise<{ url: string; instructions?: string }> { + async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> { const pkce = await generatePKCE(); - this.verifier = pkce.verifier; - this.challenge = pkce.challenge; + this.#verifier = pkce.verifier; + this.#challenge = pkce.challenge; const authParams = new URLSearchParams({ client_id: CLIENT_ID, response_type: "code", redirect_uri: redirectUri, scope: SCOPES.join(" "), - code_challenge: this.challenge, + code_challenge: this.#challenge, code_challenge_method: "S256", state, access_type: "offline", @@ -259,7 +256,7 @@ class GeminiCliOAuthFlow extends OAuthCallbackFlow { return { url, instructions: "Complete the sign-in in your browser." }; } - protected async exchangeToken(code: string, _state: string, redirectUri: string): Promise { + async exchangeToken(code: string, _state: string, redirectUri: string): Promise { this.ctrl.onProgress?.("Exchanging authorization code for tokens..."); const tokenResponse = await fetch(TOKEN_URL, { @@ -271,7 +268,7 @@ class GeminiCliOAuthFlow extends OAuthCallbackFlow { code, grant_type: "authorization_code", redirect_uri: redirectUri, - code_verifier: this.verifier, + code_verifier: this.#verifier, }), }); diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index b80ee7f9b..2e7144be3 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -53,10 +53,7 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow { super(ctrl, CALLBACK_PORT, CALLBACK_PATH); } - protected async generateAuthUrl( - state: string, - redirectUri: string, - ): Promise<{ url: string; instructions?: string }> { + async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> { const searchParams = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, @@ -74,7 +71,7 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow { return { url, instructions: "A browser window should open. Complete login to finish." }; } - protected async exchangeToken(code: string, _state: string, redirectUri: string): Promise { + async exchangeToken(code: string, _state: string, redirectUri: string): Promise { return exchangeCodeForToken(code, this.pkce.verifier, redirectUri); } } diff --git a/packages/ai/src/utils/validation.ts b/packages/ai/src/utils/validation.ts index e2492c1ff..6e86374a2 100644 --- a/packages/ai/src/utils/validation.ts +++ b/packages/ai/src/utils/validation.ts @@ -1,11 +1,7 @@ -import AjvModule from "ajv"; -import addFormatsModule from "ajv-formats"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; import type { Tool, ToolCall } from "../types"; -// Handle both default and named exports (ESM/CJS interop) -const Ajv = (AjvModule as any).default || AjvModule; -const addFormats = (addFormatsModule as any).default || addFormatsModule; - // ============================================================================ // Type Coercion Utilities // ============================================================================ @@ -281,25 +277,13 @@ function coerceArgsFromErrors( return { value: changed ? nextArgs : args, changed }; } -// Detect if we're in a browser extension environment with strict CSP -// Chrome extensions with Manifest V3 don't allow eval/Function constructor -const isBrowserExtension = typeof globalThis !== "undefined" && (globalThis as any).chrome?.runtime?.id !== undefined; - // Create a singleton AJV instance with formats (only if not in browser extension) // AJV requires 'unsafe-eval' CSP which is not allowed in Manifest V3 -let ajv: any = null; -if (!isBrowserExtension) { - try { - ajv = new Ajv({ - allErrors: true, - strict: false, - }); - addFormats(ajv); - } catch { - // AJV initialization failed (likely CSP restriction) - console.warn("AJV validation disabled due to CSP restrictions"); - } -} +const ajv = new Ajv({ + allErrors: true, + strict: false, +}); +addFormats(ajv); /** * Finds a tool by name and validates the tool call arguments against its TypeBox schema @@ -326,13 +310,6 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): any { export function validateToolArguments(tool: Tool, toolCall: ToolCall): any { const originalArgs = toolCall.arguments; - // Skip validation in browser extension environment (CSP restrictions prevent AJV from working) - if (!ajv || isBrowserExtension) { - // Trust the LLM's output without validation - // Browser extensions can't use AJV due to Manifest V3 CSP restrictions - return originalArgs; - } - // Compile the schema const validate = ajv.compile(tool.parameters); diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 660593dcd..adff3a87e 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -20,13 +20,13 @@ interface Component { } ``` -| Member | Description | -| ---------------------------- | --------------------------------------------------------------------------------------------------------- | -| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. | -| `handleInput?(data)` | Receive keyboard input when component has focus. | -| `wantsKeyRelease?` | Opt-in to key release events (Kitty protocol). Default is `false` (release events are filtered out). | -| `getCursorPosition?(width)` | Optional cursor position within the rendered output (0-based row/col) for hardware cursor placement. | -| `invalidate()` | Clear cached render state (called when themes change or the component needs a full re-render). | +| Member | Description | +| --------------------------- | ---------------------------------------------------------------------------------------------------- | +| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. | +| `handleInput?(data)` | Receive keyboard input when component has focus. | +| `wantsKeyRelease?` | Opt-in to key release events (Kitty protocol). Default is `false` (release events are filtered out). | +| `getCursorPosition?(width)` | Optional cursor position within the rendered output (0-based row/col) for hardware cursor placement. | +| `invalidate()` | Clear cached render state (called when themes change or the component needs a full re-render). | ## Using Components @@ -333,8 +333,8 @@ class MySelector implements Component { private cachedWidth?: number; private cachedLines?: string[]; - public onSelect?: (item: string) => void; - public onCancel?: () => void; + onSelect?: (item: string) => void; + onCancel?: () => void; constructor(items: string[]) { this.items = items; diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts deleted file mode 100644 index 03b1bdcbb..000000000 --- a/packages/coding-agent/examples/hooks/snake.ts +++ /dev/null @@ -1,342 +0,0 @@ -/** - * Snake game hook - play snake with /snake command - */ -import type { HookAPI } from "@oh-my-pi/pi-coding-agent"; -import { matchesKey, padding, visibleWidth } from "@oh-my-pi/pi-tui"; - -const GAME_WIDTH = 40; -const GAME_HEIGHT = 15; -const TICK_MS = 100; - -type Direction = "up" | "down" | "left" | "right"; -type Point = { x: number; y: number }; - -interface GameState { - snake: Point[]; - food: Point; - direction: Direction; - nextDirection: Direction; - score: number; - gameOver: boolean; - highScore: number; -} - -function createInitialState(): GameState { - const startX = Math.floor(GAME_WIDTH / 2); - const startY = Math.floor(GAME_HEIGHT / 2); - return { - snake: [ - { x: startX, y: startY }, - { x: startX - 1, y: startY }, - { x: startX - 2, y: startY }, - ], - food: spawnFood([{ x: startX, y: startY }]), - direction: "right", - nextDirection: "right", - score: 0, - gameOver: false, - highScore: 0, - }; -} - -function spawnFood(snake: Point[]): Point { - let food: Point; - do { - food = { - x: Math.floor(Math.random() * GAME_WIDTH), - y: Math.floor(Math.random() * GAME_HEIGHT), - }; - } while (snake.some(s => s.x === food.x && s.y === food.y)); - return food; -} - -class SnakeComponent { - private state: GameState; - private interval: ReturnType | null = null; - private onClose: () => void; - private onSave: (state: GameState | null) => void; - private tui: { requestRender: () => void }; - private cachedLines: string[] = []; - private cachedWidth = 0; - private version = 0; - private cachedVersion = -1; - private paused: boolean; - - constructor( - tui: { requestRender: () => void }, - onClose: () => void, - onSave: (state: GameState | null) => void, - savedState?: GameState, - ) { - this.tui = tui; - if (savedState && !savedState.gameOver) { - // Resume from saved state, start paused - this.state = savedState; - this.paused = true; - } else { - // New game or saved game was over - this.state = createInitialState(); - if (savedState) { - this.state.highScore = savedState.highScore; - } - this.paused = false; - this.startGame(); - } - this.onClose = onClose; - this.onSave = onSave; - } - - private startGame(): void { - this.interval = setInterval(() => { - if (!this.state.gameOver) { - this.tick(); - this.version++; - this.tui.requestRender(); - } - }, TICK_MS); - } - - private tick(): void { - // Apply queued direction change - this.state.direction = this.state.nextDirection; - - // Calculate new head position - const head = this.state.snake[0]; - let newHead: Point; - - switch (this.state.direction) { - case "up": - newHead = { x: head.x, y: head.y - 1 }; - break; - case "down": - newHead = { x: head.x, y: head.y + 1 }; - break; - case "left": - newHead = { x: head.x - 1, y: head.y }; - break; - case "right": - newHead = { x: head.x + 1, y: head.y }; - break; - } - - // Check wall collision - if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) { - this.state.gameOver = true; - return; - } - - // Check self collision - if (this.state.snake.some(s => s.x === newHead.x && s.y === newHead.y)) { - this.state.gameOver = true; - return; - } - - // Move snake - this.state.snake.unshift(newHead); - - // Check food collision - if (newHead.x === this.state.food.x && newHead.y === this.state.food.y) { - this.state.score += 10; - if (this.state.score > this.state.highScore) { - this.state.highScore = this.state.score; - } - this.state.food = spawnFood(this.state.snake); - } else { - this.state.snake.pop(); - } - } - - handleInput(data: string): void { - // If paused (resuming), wait for any key - if (this.paused) { - if (matchesKey(data, "escape") || matchesKey(data, "esc") || data === "q" || data === "Q") { - // Quit without clearing save - this.dispose(); - this.onClose(); - return; - } - // Any other key resumes - this.paused = false; - this.startGame(); - return; - } - - // ESC to pause and save - if (matchesKey(data, "escape") || matchesKey(data, "esc")) { - this.dispose(); - this.onSave(this.state); - this.onClose(); - return; - } - - // Q to quit without saving (clears saved state) - if (data === "q" || data === "Q") { - this.dispose(); - this.onSave(null); // Clear saved state - this.onClose(); - return; - } - - // Arrow keys or WASD - if (matchesKey(data, "up") || data === "w" || data === "W") { - if (this.state.direction !== "down") this.state.nextDirection = "up"; - } else if (matchesKey(data, "down") || data === "s" || data === "S") { - if (this.state.direction !== "up") this.state.nextDirection = "down"; - } else if (matchesKey(data, "right") || data === "d" || data === "D") { - if (this.state.direction !== "left") this.state.nextDirection = "right"; - } else if (matchesKey(data, "left") || data === "a" || data === "A") { - if (this.state.direction !== "right") this.state.nextDirection = "left"; - } - - // Restart on game over - if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) { - const highScore = this.state.highScore; - this.state = createInitialState(); - this.state.highScore = highScore; - this.onSave(null); // Clear saved state on restart - this.version++; - this.tui.requestRender(); - } - } - - invalidate(): void { - this.cachedWidth = 0; - } - - render(width: number): string[] { - if (width === this.cachedWidth && this.cachedVersion === this.version) { - return this.cachedLines; - } - - const lines: string[] = []; - - // Each game cell is 2 chars wide to appear square (terminal cells are ~2:1 aspect) - const cellWidth = 2; - const effectiveWidth = Math.min(GAME_WIDTH, Math.floor((width - 4) / cellWidth)); - const effectiveHeight = GAME_HEIGHT; - - // Colors - const dim = (s: string) => `\x1b[2m${s}\x1b[22m`; - const green = (s: string) => `\x1b[32m${s}\x1b[0m`; - const red = (s: string) => `\x1b[31m${s}\x1b[0m`; - const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`; - const bold = (s: string) => `\x1b[1m${s}\x1b[22m`; - - const boxWidth = effectiveWidth * cellWidth; - - // Helper to pad content inside box - const boxLine = (content: string) => { - const contentLen = visibleWidth(content); - const pad = Math.max(0, boxWidth - contentLen); - return dim(" │") + content + padding(pad) + dim("│"); - }; - - // Top border - lines.push(this.padLine(dim(` ╭${"─".repeat(boxWidth)}╮`), width)); - - // Header with score - const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`; - const highText = `High: ${bold(yellow(String(this.state.highScore)))}`; - const title = `${bold(green("SNAKE"))} │ ${scoreText} │ ${highText}`; - lines.push(this.padLine(boxLine(title), width)); - - // Separator - lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width)); - - // Game grid - for (let y = 0; y < effectiveHeight; y++) { - let row = ""; - for (let x = 0; x < effectiveWidth; x++) { - const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y; - const isBody = this.state.snake.slice(1).some(s => s.x === x && s.y === y); - const isFood = this.state.food.x === x && this.state.food.y === y; - - if (isHead) { - row += green("██"); // Snake head (2 chars) - } else if (isBody) { - row += green("▓▓"); // Snake body (2 chars) - } else if (isFood) { - row += red("◆ "); // Food (2 chars) - } else { - row += " "; // Empty cell (2 spaces) - } - } - lines.push(this.padLine(dim(" │") + row + dim("│"), width)); - } - - // Separator - lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width)); - - // Footer - let footer: string; - if (this.paused) { - footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`; - } else if (this.state.gameOver) { - footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`; - } else { - footer = `↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`; - } - lines.push(this.padLine(boxLine(footer), width)); - - // Bottom border - lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width)); - - this.cachedLines = lines; - this.cachedWidth = width; - this.cachedVersion = this.version; - - return lines; - } - - private padLine(line: string, width: number): string { - // Calculate visible length (strip ANSI codes) - const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length; - const pad = Math.max(0, width - visibleLen); - return line + padding(pad); - } - - dispose(): void { - if (this.interval) { - clearInterval(this.interval); - this.interval = null; - } - } -} - -const SNAKE_SAVE_TYPE = "snake-save"; - -export default function (pi: HookAPI) { - pi.registerCommand("snake", { - description: "Play Snake!", - - handler: async (_args, ctx) => { - if (!ctx.hasUI) { - ctx.ui.notify("Snake requires interactive mode", "error"); - return; - } - - // Load saved state from session - const entries = ctx.sessionManager.getEntries(); - let savedState: GameState | undefined; - for (let i = entries.length - 1; i >= 0; i--) { - const entry = entries[i]; - if (entry.type === "custom" && entry.customType === SNAKE_SAVE_TYPE) { - savedState = entry.data as GameState; - break; - } - } - - await ctx.ui.custom((tui, _theme, done) => { - return new SnakeComponent( - tui, - () => done(undefined), - state => { - // Save or clear state - pi.appendEntry(SNAKE_SAVE_TYPE, state); - }, - savedState, - ); - }); - }, - }); -} diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index af6ebbf3d..60d6f5623 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -8,6 +8,20 @@ import { run } from "@oclif/core"; import { APP_NAME } from "./config"; +// oclif's warn() doesn't unwrap AggregateError — override to surface the real messages +const originalWarn = console.warn; +console.warn = (...args: unknown[]) => { + for (const arg of args) { + if (arg instanceof AggregateError) { + for (const err of arg.errors) { + originalWarn(err instanceof Error ? (err.stack ?? err.message) : String(err)); + } + return; + } + } + originalWarn(...args); +}; + process.title = APP_NAME; const argv = process.argv.slice(2); const runArgv = argv.length === 0 || argv[0]?.startsWith("-") ? ["index", ...argv] : argv; diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index 62d7e861b..8cba61759 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -1,7 +1,7 @@ /** * Process @file CLI arguments into text content and image attachments */ -import * as fs from "node:fs/promises"; +import * as fs from "node:fs"; import * as path from "node:path"; import type { ImageContent } from "@oh-my-pi/pi-ai"; import { isEnoent } from "@oh-my-pi/pi-utils"; @@ -36,15 +36,10 @@ export async function processFileArguments(fileArgs: string[], options?: Process // Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces) const absolutePath = path.resolve(resolveReadPath(fileArg, process.cwd())); - let stat: Awaited>; - try { - stat = await fs.stat(absolutePath); - } catch (err) { - if (isEnoent(err)) { - console.error(chalk.red(`Error: File not found: ${absolutePath}`)); - process.exit(1); - } - throw err; + const stat = fs.statSync(absolutePath, { throwIfNoEntry: false }); + if (!stat) { + console.error(chalk.red(`Error: File not found: ${absolutePath}`)); + process.exit(1); } const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath); @@ -58,9 +53,9 @@ export async function processFileArguments(fileArgs: string[], options?: Process } // Read file, handling not-found gracefully - let buffer: Buffer; + let buffer: Uint8Array; try { - buffer = await fs.readFile(absolutePath); + buffer = await Bun.file(absolutePath).bytes(); } catch (err) { if (isEnoent(err)) { console.error(chalk.red(`Error: File not found: ${absolutePath}`)); @@ -114,7 +109,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process } else { // Handle text file try { - const content = buffer.toString("utf-8"); + const content = new TextDecoder().decode(buffer); text += `\n${content}\n\n`; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/coding-agent/src/cli/oclif-help.ts b/packages/coding-agent/src/cli/oclif-help.ts index da7487dfa..e6445f183 100644 --- a/packages/coding-agent/src/cli/oclif-help.ts +++ b/packages/coding-agent/src/cli/oclif-help.ts @@ -5,7 +5,7 @@ import { CommandHelp, Help } from "@oclif/core"; import { getExtraHelpText } from "./args"; export default class OclifHelp extends Help { - protected async showRootHelp(): Promise { + async showRootHelp(): Promise { await super.showRootHelp(); const rootCommand = this.config.findCommand("index"); if (rootCommand) { diff --git a/packages/coding-agent/src/commit/git/index.ts b/packages/coding-agent/src/commit/git/index.ts index 0fdc6726a..8dcfabbfe 100644 --- a/packages/coding-agent/src/commit/git/index.ts +++ b/packages/coding-agent/src/commit/git/index.ts @@ -18,21 +18,21 @@ export class ControlledGit { async getDiff(staged: boolean): Promise { const args = staged ? ["diff", "--cached"] : ["diff"]; const result = await runGitCommand(this.cwd, args); - this.ensureSuccess(result, "git diff"); + this.#ensureSuccess(result, "git diff"); return result.stdout; } async getDiffForFiles(files: string[], staged = true): Promise { const args = staged ? ["diff", "--cached", "--", ...files] : ["diff", "--", ...files]; const result = await runGitCommand(this.cwd, args); - this.ensureSuccess(result, "git diff (files)"); + this.#ensureSuccess(result, "git diff (files)"); return result.stdout; } async getChangedFiles(staged: boolean): Promise { const args = staged ? ["diff", "--cached", "--name-only"] : ["diff", "--name-only"]; const result = await runGitCommand(this.cwd, args); - this.ensureSuccess(result, "git diff --name-only"); + this.#ensureSuccess(result, "git diff --name-only"); return result.stdout .split("\n") .map(line => line.trim()) @@ -42,27 +42,27 @@ export class ControlledGit { async getStat(staged: boolean): Promise { const args = staged ? ["diff", "--cached", "--stat"] : ["diff", "--stat"]; const result = await runGitCommand(this.cwd, args); - this.ensureSuccess(result, "git diff --stat"); + this.#ensureSuccess(result, "git diff --stat"); return result.stdout; } async getStatForFiles(files: string[], staged = true): Promise { const args = staged ? ["diff", "--cached", "--stat", "--", ...files] : ["diff", "--stat", "--", ...files]; const result = await runGitCommand(this.cwd, args); - this.ensureSuccess(result, "git diff --stat (files)"); + this.#ensureSuccess(result, "git diff --stat (files)"); return result.stdout; } async getNumstat(staged: boolean): Promise { const args = staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat"]; const result = await runGitCommand(this.cwd, args); - this.ensureSuccess(result, "git diff --numstat"); + this.#ensureSuccess(result, "git diff --numstat"); return parseNumstat(result.stdout); } async getRecentCommits(count: number): Promise { const result = await runGitCommand(this.cwd, ["log", `-n${count}`, "--pretty=format:%s"]); - this.ensureSuccess(result, "git log"); + this.#ensureSuccess(result, "git log"); return result.stdout .split("\n") .map(line => line.trim()) @@ -71,7 +71,7 @@ export class ControlledGit { async getStagedFiles(): Promise { const result = await runGitCommand(this.cwd, ["diff", "--cached", "--name-only"]); - this.ensureSuccess(result, "git diff --cached --name-only"); + this.#ensureSuccess(result, "git diff --cached --name-only"); return result.stdout .split("\n") .map(line => line.trim()) @@ -80,7 +80,7 @@ export class ControlledGit { async getUntrackedFiles(): Promise { const result = await runGitCommand(this.cwd, ["ls-files", "--others", "--exclude-standard"]); - this.ensureSuccess(result, "git ls-files --others --exclude-standard"); + this.#ensureSuccess(result, "git ls-files --others --exclude-standard"); return result.stdout .split("\n") .map(line => line.trim()) @@ -89,12 +89,12 @@ export class ControlledGit { async stageAll(): Promise { const result = await stageFiles(this.cwd, []); - this.ensureSuccess(result, "git add -A"); + this.#ensureSuccess(result, "git add -A"); } async stageFiles(files: string[]): Promise { const result = await stageFiles(this.cwd, files); - this.ensureSuccess(result, "git add"); + this.#ensureSuccess(result, "git add"); } async stageHunks(selections: HunkSelection[]): Promise { @@ -137,7 +137,7 @@ export class ControlledGit { try { await Bun.write(tempPath, patch); const result = await runGitCommand(this.cwd, ["apply", "--cached", "--binary", tempPath]); - this.ensureSuccess(result, "git apply --cached"); + this.#ensureSuccess(result, "git apply --cached"); } finally { await fs.rm(tempPath, { force: true }); } @@ -145,17 +145,17 @@ export class ControlledGit { async resetStaging(files: string[] = []): Promise { const result = await resetStaging(this.cwd, files); - this.ensureSuccess(result, "git reset"); + this.#ensureSuccess(result, "git reset"); } async commit(message: string): Promise { const result = await commit(this.cwd, message); - this.ensureSuccess(result, "git commit"); + this.#ensureSuccess(result, "git commit"); } async push(): Promise { const result = await push(this.cwd); - this.ensureSuccess(result, "git push"); + this.#ensureSuccess(result, "git push"); } parseDiffFiles(diff: string): FileDiff[] { @@ -171,7 +171,7 @@ export class ControlledGit { return this.parseDiffHunks(diff); } - private ensureSuccess(result: { exitCode: number; stderr: string }, label: string): void { + #ensureSuccess(result: { exitCode: number; stderr: string }, label: string): void { if (result.exitCode !== 0) { logger.error("commit git command failed", { label, stderr: result.stderr }); throw new GitError(label, result.stderr); diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 5846f8444..667e9c148 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -156,8 +156,8 @@ export class ConfigFile implements IConfigFile { #auxValidate?: (value: T) => void; constructor( - public readonly id: string, - public readonly schema: TSchema, + readonly id: string, + readonly schema: TSchema, configPath: string = path.join(getAgentDir(), `${id}.yml`), ) { this.#basePath = configPath; diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index f63489949..43f091cf0 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -166,11 +166,11 @@ export function formatKeyHints(keys: KeyId | KeyId[]): string { * Manages all keybindings (app + editor). */ export class KeybindingsManager { - private appActionToKeys: Map; + #appActionToKeys: Map; private constructor(private readonly config: KeybindingsConfig) { - this.appActionToKeys = new Map(); - this.buildMaps(); + this.#appActionToKeys = new Map(); + this.#buildMaps(); } /** @@ -178,7 +178,7 @@ export class KeybindingsManager { */ static async create(agentDir: string = getAgentDir()): Promise { const configPath = path.join(agentDir, "keybindings.json"); - const config = await KeybindingsManager.loadFromFile(configPath); + const config = await KeybindingsManager.#loadFromFile(configPath); const manager = new KeybindingsManager(config); // Set up editor keybindings globally @@ -200,7 +200,7 @@ export class KeybindingsManager { return new KeybindingsManager(config); } - private static async loadFromFile(path: string): Promise { + static async #loadFromFile(path: string): Promise { try { return await Bun.file(path).json(); } catch (error) { @@ -209,13 +209,13 @@ export class KeybindingsManager { } } - private buildMaps(): void { - this.appActionToKeys.clear(); + #buildMaps(): void { + this.#appActionToKeys.clear(); // Set defaults for app actions for (const [action, keys] of Object.entries(DEFAULT_APP_KEYBINDINGS)) { const keyArray = Array.isArray(keys) ? keys : [keys]; - this.appActionToKeys.set( + this.#appActionToKeys.set( action as AppAction, keyArray.map(key => normalizeKeyId(key as KeyId)), ); @@ -225,7 +225,7 @@ export class KeybindingsManager { for (const [action, keys] of Object.entries(this.config)) { if (keys === undefined || !isAppAction(action)) continue; const keyArray = Array.isArray(keys) ? keys : [keys]; - this.appActionToKeys.set( + this.#appActionToKeys.set( action, keyArray.map(key => normalizeKeyId(key as KeyId)), ); @@ -236,7 +236,7 @@ export class KeybindingsManager { * Check if input matches an app action. */ matches(data: string, action: AppAction): boolean { - const keys = this.appActionToKeys.get(action); + const keys = this.#appActionToKeys.get(action); if (!keys) return false; for (const key of keys) { if (matchesKey(data, key)) return true; @@ -248,7 +248,7 @@ export class KeybindingsManager { * Get keys bound to an app action. */ getKeys(action: AppAction): KeyId[] { - return this.appActionToKeys.get(action) ?? []; + return this.#appActionToKeys.get(action) ?? []; } /** diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 1d011a054..6e93e7495 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -253,10 +253,10 @@ function applyModelOverride(model: Model, override: ModelOverride): Model[] = []; - private customProviderApiKeys: Map = new Map(); - private configError: ConfigError | undefined = undefined; - private modelsConfigFile: ConfigFile; + #models: Model[] = []; + #customProviderApiKeys: Map = new Map(); + #configError: ConfigError | undefined = undefined; + #modelsConfigFile: ConfigFile; /** * @param authStorage - Auth storage for API key resolution @@ -265,83 +265,48 @@ export class ModelRegistry { readonly authStorage: AuthStorage, modelsPath?: string, ) { - this.modelsConfigFile = ModelsConfigFile.relocate(modelsPath); + this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath); // Set up fallback resolver for custom provider API keys this.authStorage.setFallbackResolver(provider => { - const keyConfig = this.customProviderApiKeys.get(provider); + const keyConfig = this.#customProviderApiKeys.get(provider); if (keyConfig) { return resolveApiKeyConfig(keyConfig); } return undefined; }); // Load models synchronously in constructor - this.loadModels(); - } - - /** - * Create an in-memory ModelRegistry instance from serialized data. - * Used by subagent workers to bypass discovery and use parent's models. - */ - static fromSerialized(data: SerializedModelRegistry, authStorage: AuthStorage): ModelRegistry { - const instance = Object.create(ModelRegistry.prototype) as ModelRegistry; - (instance as any).authStorage = authStorage; - instance.models = data.models; - instance.customProviderApiKeys = new Map(Object.entries(data.customProviderApiKeys ?? {})); - - authStorage.setFallbackResolver(provider => { - const keyConfig = instance.customProviderApiKeys.get(provider); - if (keyConfig) { - return resolveApiKeyConfig(keyConfig); - } - return undefined; - }); - - return instance; - } - - /** - * Serialize ModelRegistry for passing to subagent workers. - */ - serialize(): SerializedModelRegistry { - const customProviderApiKeys: Record = {}; - for (const [k, v] of this.customProviderApiKeys.entries()) { - customProviderApiKeys[k] = v; - } - return { - models: this.models, - customProviderApiKeys: Object.keys(customProviderApiKeys).length > 0 ? customProviderApiKeys : undefined, - }; + this.#loadModels(); } /** * Reload models from disk (built-in + custom from models.json). */ refresh(): void { - this.modelsConfigFile.invalidate(); - this.customProviderApiKeys.clear(); - this.configError = undefined; - this.loadModels(); + this.#modelsConfigFile.invalidate(); + this.#customProviderApiKeys.clear(); + this.#configError = undefined; + this.#loadModels(); } /** * Get any error from loading models.json (undefined if no error). */ getError(): ConfigError | undefined { - return this.configError; + return this.#configError; } - private loadModels() { + #loadModels() { // Load custom models from models.json first (to know which providers to override) const { models: customModels = [], overrides = new Map(), modelOverrides = new Map(), error: configError, - } = this.loadCustomModels(); - this.configError = configError; + } = this.#loadCustomModels(); + this.#configError = configError; - const builtInModels = this.loadBuiltInModels(overrides, modelOverrides); - const combined = this.mergeCustomModels(builtInModels, customModels); + const builtInModels = this.#loadBuiltInModels(overrides, modelOverrides); + const combined = this.#mergeCustomModels(builtInModels, customModels); // Update github-copilot base URL based on OAuth credentials const copilotCred = this.authStorage.getOAuthCredential("github-copilot"); @@ -350,14 +315,14 @@ export class ModelRegistry { ? (normalizeDomain(copilotCred.enterpriseUrl) ?? undefined) : undefined; const baseUrl = getGitHubCopilotBaseUrl(copilotCred.access, domain); - this.models = combined.map(m => (m.provider === "github-copilot" ? { ...m, baseUrl } : m)); + this.#models = combined.map(m => (m.provider === "github-copilot" ? { ...m, baseUrl } : m)); } else { - this.models = combined; + this.#models = combined; } } /** Load built-in models, applying provider and per-model overrides */ - private loadBuiltInModels( + #loadBuiltInModels( overrides: Map, modelOverrides: Map>, ): Model[] { @@ -385,7 +350,7 @@ export class ModelRegistry { } /** Merge custom models with built-in, replacing by provider+id match */ - private mergeCustomModels(builtInModels: Model[], customModels: Model[]): Model[] { + #mergeCustomModels(builtInModels: Model[], customModels: Model[]): Model[] { const merged = [...builtInModels]; for (const customModel of customModels) { const existingIndex = merged.findIndex(m => m.provider === customModel.provider && m.id === customModel.id); @@ -398,8 +363,8 @@ export class ModelRegistry { return merged; } - private loadCustomModels(): CustomModelsResult { - const { value, error, status } = this.modelsConfigFile.tryLoad(); + #loadCustomModels(): CustomModelsResult { + const { value, error, status } = this.#modelsConfigFile.tryLoad(); if (status === "error") { return { models: [], overrides: new Map(), modelOverrides: new Map(), error, found: true }; @@ -422,7 +387,7 @@ export class ModelRegistry { // Always store API key for fallback resolver if (providerConfig.apiKey) { - this.customProviderApiKeys.set(providerName, providerConfig.apiKey); + this.#customProviderApiKeys.set(providerName, providerConfig.apiKey); } // Parse per-model overrides @@ -435,10 +400,10 @@ export class ModelRegistry { } } - return { models: this.parseModels(value), overrides, modelOverrides: allModelOverrides, found: true }; + return { models: this.#parseModels(value), overrides, modelOverrides: allModelOverrides, found: true }; } - private parseModels(config: ModelsConfig): Model[] { + #parseModels(config: ModelsConfig): Model[] { const models: Model[] = []; for (const [providerName, providerConfig] of Object.entries(config.providers)) { @@ -447,7 +412,7 @@ export class ModelRegistry { // Store API key config for fallback resolver if (providerConfig.apiKey) { - this.customProviderApiKeys.set(providerName, providerConfig.apiKey); + this.#customProviderApiKeys.set(providerName, providerConfig.apiKey); } for (const modelDef of modelDefs) { @@ -496,7 +461,7 @@ export class ModelRegistry { * If models.json had errors, returns only built-in models. */ getAll(): Model[] { - return this.models; + return this.#models; } /** @@ -504,21 +469,21 @@ export class ModelRegistry { * This is a fast check that doesn't refresh OAuth tokens. */ getAvailable(): Model[] { - return this.models.filter(m => this.authStorage.hasAuth(m.provider)); + return this.#models.filter(m => this.authStorage.hasAuth(m.provider)); } /** * Find a model by provider and ID. */ find(provider: string, modelId: string): Model | undefined { - return this.models.find(m => m.provider === provider && m.id === modelId); + return this.#models.find(m => m.provider === provider && m.id === modelId); } /** * Get the base URL associated with a provider, if any model defines one. */ getProviderBaseUrl(provider: string): string | undefined { - return this.models.find(m => m.provider === provider && m.baseUrl)?.baseUrl; + return this.#models.find(m => m.provider === provider && m.baseUrl)?.baseUrl; } /** diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 4c1922f35..7284f5e51 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -120,39 +120,39 @@ function setByPath(obj: RawSettings, segments: string[], value: unknown): void { // ═══════════════════════════════════════════════════════════════════════════ export class Settings { - private configPath: string | null; - private cwd: string; - private agentDir: string; - private storage: AgentStorage | null = null; + #configPath: string | null; + #cwd: string; + #agentDir: string; + #storage: AgentStorage | null = null; /** Global settings from config.yml */ - private global: RawSettings = {}; + #global: RawSettings = {}; /** Project settings from .claude/settings.yml etc */ - private project: RawSettings = {}; + #project: RawSettings = {}; /** Runtime overrides (not persisted) */ - private overrides: RawSettings = {}; + #overrides: RawSettings = {}; /** Merged view (global + project + overrides) */ - private merged: RawSettings = {}; + #merged: RawSettings = {}; /** Paths modified during this session (for partial save) */ - private modified = new Set(); + #modified = new Set(); /** Pending save (debounced) */ - private saveTimer: ReturnType | null = null; - private savePromise: Promise | null = null; + #saveTimer?: NodeJS.Timeout; + #savePromise?: Promise; /** Whether to persist changes */ - private persist: boolean; + #persist: boolean; private constructor(options: SettingsOptions = {}) { - this.cwd = path.normalize(options.cwd ?? process.cwd()); - this.agentDir = path.normalize(options.agentDir ?? getAgentDir()); - this.configPath = options.inMemory ? null : path.join(this.agentDir, "config.yml"); - this.persist = !options.inMemory; + this.#cwd = path.normalize(options.cwd ?? process.cwd()); + this.#agentDir = path.normalize(options.agentDir ?? getAgentDir()); + this.#configPath = options.inMemory ? null : path.join(this.#agentDir, "config.yml"); + this.#persist = !options.inMemory; if (options.overrides) { for (const [key, value] of Object.entries(options.overrides)) { - setByPath(this.overrides, parsePath(key), value); + setByPath(this.#overrides, parsePath(key), value); } } } @@ -169,7 +169,7 @@ export class Settings { if (globalInstancePromise) return globalInstancePromise; const instance = new Settings(options); - const promise = instance.load(); + const promise = instance.#load(); globalInstancePromise = promise; return promise.then( @@ -191,7 +191,7 @@ export class Settings { */ static isolated(overrides: Partial> = {}): Settings { const instance = new Settings({ inMemory: true, overrides }); - instance.rebuildMerged(); + instance.#rebuildMerged(); return instance; } @@ -216,7 +216,7 @@ export class Settings { */ get

(path: P): SettingValue

{ const segments = parsePath(path); - const value = getByPath(this.merged, segments); + const value = getByPath(this.#merged, segments); if (value !== undefined) { return value as SettingValue

; } @@ -231,10 +231,10 @@ export class Settings { set

(path: P, value: SettingValue

): void { const prev = this.get(path); const segments = parsePath(path); - setByPath(this.global, segments, value); - this.modified.add(path); - this.rebuildMerged(); - this.queueSave(); + setByPath(this.#global, segments, value); + this.#modified.add(path); + this.#rebuildMerged(); + this.#queueSave(); // Trigger hook if exists const hook = SETTING_HOOKS[path]; @@ -248,8 +248,8 @@ export class Settings { */ override

(path: P, value: SettingValue

): void { const segments = parsePath(path); - setByPath(this.overrides, segments, value); - this.rebuildMerged(); + setByPath(this.#overrides, segments, value); + this.#rebuildMerged(); } /** @@ -257,14 +257,14 @@ export class Settings { */ clearOverride(path: SettingPath): void { const segments = parsePath(path); - let current = this.overrides; + let current = this.#overrides; for (let i = 0; i < segments.length - 1; i++) { const segment = segments[i]; if (!(segment in current)) return; current = current[segment] as RawSettings; } delete current[segments[segments.length - 1]]; - this.rebuildMerged(); + this.#rebuildMerged(); } /** @@ -272,15 +272,15 @@ export class Settings { * Call before exit to ensure all changes are persisted. */ async flush(): Promise { - if (this.saveTimer) { - clearTimeout(this.saveTimer); - this.saveTimer = null; + if (this.#saveTimer) { + clearTimeout(this.#saveTimer); + this.#saveTimer = undefined; } - if (this.savePromise) { - await this.savePromise; + if (this.#savePromise) { + await this.#savePromise; } - if (this.modified.size > 0) { - await this.saveNow(); + if (this.#modified.size > 0) { + await this.#saveNow(); } } @@ -289,19 +289,19 @@ export class Settings { // ───────────────────────────────────────────────────────────────────────── getStorage(): AgentStorage | null { - return this.storage; + return this.#storage; } getCwd(): string { - return this.cwd; + return this.#cwd; } getAgentDir(): string { - return this.agentDir; + return this.#agentDir; } getPlansDirectory(): string { - return path.join(this.agentDir, "plans"); + return path.join(this.#agentDir, "plans"); } /** @@ -312,13 +312,6 @@ export class Settings { return procmgr.getShellConfig(shell); } - /** - * Serialize current settings for passing to subagent workers. - */ - serialize(): RawSettings { - return structuredClone(this.merged); - } - /** * Get all settings in a group with full type safety. */ @@ -337,7 +330,7 @@ export class Settings { * Get edit model variants (typed accessor for complex nested config). */ getEditModelVariants(): Record { - const variants = (this.merged.edit as { modelVariants?: Record })?.modelVariants ?? {}; + const variants = (this.#merged.edit as { modelVariants?: Record })?.modelVariants ?? {}; const result: Record = {}; for (const pattern in variants) { const value = variants[pattern]; @@ -359,7 +352,7 @@ export class Settings { */ getEditVariantForModel(model: string | undefined): "patch" | "replace" | null { if (!model) return null; - const variants = (this.merged.edit as { modelVariants?: Record })?.modelVariants; + const variants = (this.#merged.edit as { modelVariants?: Record })?.modelVariants; if (!variants) return null; const modelLower = model.toLowerCase(); for (const pattern in variants) { @@ -379,7 +372,7 @@ export class Settings { * Get bash interceptor rules (typed accessor for complex array config). */ getBashInterceptorRules(): BashInterceptorRule[] { - const patterns = (this.merged.bashInterceptor as { patterns?: unknown[] })?.patterns; + const patterns = (this.#merged.bashInterceptor as { patterns?: unknown[] })?.patterns; if (!Array.isArray(patterns)) return []; return patterns.filter((p): p is BashInterceptorRule => typeof p === "object" && p !== null && "pattern" in p); @@ -432,34 +425,34 @@ export class Settings { // Loading // ───────────────────────────────────────────────────────────────────────── - private async load(): Promise { - if (this.persist) { + async #load(): Promise { + if (this.#persist) { // Open storage - this.storage = await AgentStorage.open(getAgentDbPath(this.agentDir)); + this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir)); // Migrate from legacy formats if needed - await this.migrateFromLegacy(); + await this.#migrateFromLegacy(); // Load global settings from config.yml - this.global = await this.loadYaml(this.configPath!); + this.#global = await this.#loadYaml(this.#configPath!); } // Load project settings - this.project = await this.loadProjectSettings(); + this.#project = await this.#loadProjectSettings(); // Build merged view - this.rebuildMerged(); + this.#rebuildMerged(); return this; } - private async loadYaml(filePath: string): Promise { + async #loadYaml(filePath: string): Promise { try { const content = await Bun.file(filePath).text(); const parsed = YAML.parse(content); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return {}; } - return this.migrateRawSettings(parsed as RawSettings); + return this.#migrateRawSettings(parsed as RawSettings); } catch (error) { if (isEnoent(error)) return {}; logger.warn("Settings: failed to load", { path: filePath, error: String(error) }); @@ -467,27 +460,27 @@ export class Settings { } } - private async loadProjectSettings(): Promise { + async #loadProjectSettings(): Promise { try { - const result = await loadCapability(settingsCapability.id, { cwd: this.cwd }); + const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd }); let merged: RawSettings = {}; for (const item of result.items as SettingsCapabilityItem[]) { if (item.level === "project") { - merged = this.deepMerge(merged, item.data as RawSettings); + merged = this.#deepMerge(merged, item.data as RawSettings); } } - return this.migrateRawSettings(merged); + return this.#migrateRawSettings(merged); } catch { return {}; } } - private async migrateFromLegacy(): Promise { - if (!this.configPath) return; + async #migrateFromLegacy(): Promise { + if (!this.#configPath) return; // Check if config.yml already exists try { - await Bun.file(this.configPath).text(); + await Bun.file(this.#configPath).text(); return; // Already exists, no migration needed } catch (err) { if (!isEnoent(err)) return; @@ -497,11 +490,11 @@ export class Settings { let migrated = false; // 1. Migrate from settings.json - const settingsJsonPath = path.join(this.agentDir, "settings.json"); + const settingsJsonPath = path.join(this.#agentDir, "settings.json"); try { const parsed = JSON.parse(await Bun.file(settingsJsonPath).text()); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - settings = this.deepMerge(settings, this.migrateRawSettings(parsed)); + settings = this.#deepMerge(settings, this.#migrateRawSettings(parsed)); migrated = true; try { fs.renameSync(settingsJsonPath, `${settingsJsonPath}.bak`); @@ -511,9 +504,9 @@ export class Settings { // 2. Migrate from agent.db try { - const dbSettings = this.storage?.getSettings(); + const dbSettings = this.#storage?.getSettings(); if (dbSettings) { - settings = this.deepMerge(settings, this.migrateRawSettings(dbSettings as RawSettings)); + settings = this.#deepMerge(settings, this.#migrateRawSettings(dbSettings as RawSettings)); migrated = true; } } catch {} @@ -521,14 +514,14 @@ export class Settings { // 3. Write merged settings if (migrated && Object.keys(settings).length > 0) { try { - await Bun.write(this.configPath, YAML.stringify(settings, null, 2)); - logger.debug("Settings: migrated to config.yml", { path: this.configPath }); + await Bun.write(this.#configPath, YAML.stringify(settings, null, 2)); + logger.debug("Settings: migrated to config.yml", { path: this.#configPath }); } catch {} } } /** Apply schema migrations to raw settings */ - private migrateRawSettings(raw: RawSettings): RawSettings { + #migrateRawSettings(raw: RawSettings): RawSettings { // queueMode -> steeringMode if ("queueMode" in raw && !("steeringMode" in raw)) { raw.steeringMode = raw.queueMode; @@ -550,65 +543,65 @@ export class Settings { // Saving // ───────────────────────────────────────────────────────────────────────── - private queueSave(): void { - if (!this.persist || !this.configPath) return; + #queueSave(): void { + if (!this.#persist || !this.#configPath) return; // Debounce: wait 100ms for more changes - if (this.saveTimer) { - clearTimeout(this.saveTimer); + if (this.#saveTimer) { + clearTimeout(this.#saveTimer); } - this.saveTimer = setTimeout(() => { - this.saveTimer = null; - this.saveNow().catch(err => { + this.#saveTimer = setTimeout(() => { + this.#saveTimer = undefined; + this.#saveNow().catch(err => { logger.warn("Settings: background save failed", { error: String(err) }); }); }, 100); } - private async saveNow(): Promise { - if (!this.persist || !this.configPath || this.modified.size === 0) return; + async #saveNow(): Promise { + if (!this.#persist || !this.#configPath || this.#modified.size === 0) return; - const configPath = this.configPath; - const modifiedPaths = [...this.modified]; - this.modified.clear(); + const configPath = this.#configPath; + const modifiedPaths = [...this.#modified]; + this.#modified.clear(); try { await withFileLock(configPath, async () => { // Re-read to preserve external changes - const current = await this.loadYaml(configPath); + const current = await this.#loadYaml(configPath); // Apply only our modified paths for (const modPath of modifiedPaths) { const segments = parsePath(modPath); - const value = getByPath(this.global, segments); + const value = getByPath(this.#global, segments); setByPath(current, segments, value); } // Update our global with any external changes we preserved - this.global = current; - await Bun.write(configPath, YAML.stringify(this.global, null, 2)); + this.#global = current; + await Bun.write(configPath, YAML.stringify(this.#global, null, 2)); }); } catch (error) { logger.warn("Settings: save failed", { error: String(error) }); // Re-add failed paths for retry for (const p of modifiedPaths) { - this.modified.add(p); + this.#modified.add(p); } } - this.rebuildMerged(); + this.#rebuildMerged(); } // ───────────────────────────────────────────────────────────────────────── // Utilities // ───────────────────────────────────────────────────────────────────────── - private rebuildMerged(): void { - this.merged = this.deepMerge(this.deepMerge({}, this.global), this.project); - this.merged = this.deepMerge(this.merged, this.overrides); + #rebuildMerged(): void { + this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#project); + this.#merged = this.#deepMerge(this.#merged, this.#overrides); } - private deepMerge(base: RawSettings, overrides: RawSettings): RawSettings { + #deepMerge(base: RawSettings, overrides: RawSettings): RawSettings { const result = { ...base }; for (const key of Object.keys(overrides)) { const override = overrides[key]; @@ -624,7 +617,7 @@ export class Settings { baseVal !== null && !Array.isArray(baseVal) ) { - result[key] = this.deepMerge(baseVal as RawSettings, override as RawSettings); + result[key] = this.#deepMerge(baseVal as RawSettings, override as RawSettings); } else { result[key] = override; } diff --git a/packages/coding-agent/src/cursor.ts b/packages/coding-agent/src/cursor.ts index 6336573a0..744660f00 100644 --- a/packages/coding-agent/src/cursor.ts +++ b/packages/coding-agent/src/cursor.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import * as fs from "node:fs/promises"; +import * as fs from "node:fs"; import type { AgentEvent, AgentTool, @@ -98,9 +98,9 @@ async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, let result: AgentToolResult; try { - let fileStat: Awaited> | undefined; + let fileStat: fs.Stats | undefined; try { - fileStat = await fs.stat(absolutePath); + fileStat = fs.statSync(absolutePath); } catch { throw new Error(`File not found: ${pathArg}`); } @@ -108,7 +108,7 @@ async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, throw new Error(`Path is not a file: ${pathArg}`); } - await fs.rm(absolutePath); + fs.rmSync(absolutePath); const sizeText = fileStat.size ? ` (${fileStat.size} bytes)` : ""; const message = `Deleted ${pathArg}${sizeText}`; diff --git a/packages/coding-agent/src/debug/index.ts b/packages/coding-agent/src/debug/index.ts index 1b43560ca..2bc1e8639 100644 --- a/packages/coding-agent/src/debug/index.ts +++ b/packages/coding-agent/src/debug/index.ts @@ -30,7 +30,7 @@ const DEBUG_MENU_ITEMS: SelectItem[] = [ * Debug selector component. */ export class DebugSelectorComponent extends Container { - private selectList: SelectList; + #selectList: SelectList; constructor( private ctx: InteractiveModeContext, @@ -44,55 +44,55 @@ export class DebugSelectorComponent extends Container { this.addChild(new Spacer(1)); // Select list - this.selectList = new SelectList(DEBUG_MENU_ITEMS, 7, getSelectListTheme()); + this.#selectList = new SelectList(DEBUG_MENU_ITEMS, 7, getSelectListTheme()); - this.selectList.onSelect = item => { + this.#selectList.onSelect = item => { onDone(); - void this.handleSelection(item.value); + void this.#handleSelection(item.value); }; - this.selectList.onCancel = () => { + this.#selectList.onCancel = () => { onDone(); }; - this.addChild(this.selectList); + this.addChild(this.#selectList); this.addChild(new DynamicBorder()); } handleInput(keyData: string): void { - this.selectList.handleInput(keyData); + this.#selectList.handleInput(keyData); } - private async handleSelection(value: string): Promise { + async #handleSelection(value: string): Promise { switch (value) { case "open-artifacts": - await this.handleOpenArtifacts(); + await this.#handleOpenArtifacts(); break; case "performance": - await this.handlePerformanceReport(); + await this.#handlePerformanceReport(); break; case "work": - await this.handleWorkReport(); + await this.#handleWorkReport(); break; case "dump": - await this.handleDumpReport(); + await this.#handleDumpReport(); break; case "memory": - await this.handleMemoryReport(); + await this.#handleMemoryReport(); break; case "logs": - await this.handleViewLogs(); + await this.#handleViewLogs(); break; case "system": - await this.handleViewSystemInfo(); + await this.#handleViewSystemInfo(); break; case "clear-cache": - await this.handleClearCache(); + await this.#handleClearCache(); break; } } - private async handlePerformanceReport(): Promise { + async #handlePerformanceReport(): Promise { // Start profiling let session: ProfilerSession; try { @@ -146,7 +146,7 @@ export class DebugSelectorComponent extends Container { const workProfile = getWorkProfile(30); const result = await createReportBundle({ sessionFile: this.ctx.sessionManager.getSessionFile(), - settings: this.getResolvedSettings(), + settings: this.#getResolvedSettings(), cpuProfile, workProfile, }); @@ -169,7 +169,7 @@ export class DebugSelectorComponent extends Container { this.ctx.ui.requestRender(); } - private async handleWorkReport(): Promise { + async #handleWorkReport(): Promise { try { const workProfile = getWorkProfile(30); @@ -202,7 +202,7 @@ export class DebugSelectorComponent extends Container { this.ctx.ui.requestRender(); } - private async handleDumpReport(): Promise { + async #handleDumpReport(): Promise { const loader = new Loader( this.ctx.ui, spinner => theme.fg("accent", spinner), @@ -216,7 +216,7 @@ export class DebugSelectorComponent extends Container { try { const result = await createReportBundle({ sessionFile: this.ctx.sessionManager.getSessionFile(), - settings: this.getResolvedSettings(), + settings: this.#getResolvedSettings(), }); loader.stop(); @@ -237,7 +237,7 @@ export class DebugSelectorComponent extends Container { this.ctx.ui.requestRender(); } - private async handleMemoryReport(): Promise { + async #handleMemoryReport(): Promise { const loader = new Loader( this.ctx.ui, spinner => theme.fg("accent", spinner), @@ -254,7 +254,7 @@ export class DebugSelectorComponent extends Container { const result = await createReportBundle({ sessionFile: this.ctx.sessionManager.getSessionFile(), - settings: this.getResolvedSettings(), + settings: this.#getResolvedSettings(), heapSnapshot, }); @@ -276,7 +276,7 @@ export class DebugSelectorComponent extends Container { this.ctx.ui.requestRender(); } - private async handleViewLogs(): Promise { + async #handleViewLogs(): Promise { try { const logs = await getRecentLogs(50); if (!logs) { @@ -305,7 +305,7 @@ export class DebugSelectorComponent extends Container { this.ctx.ui.requestRender(); } - private async handleViewSystemInfo(): Promise { + async #handleViewSystemInfo(): Promise { try { const info = await collectSystemInfo(); const formatted = formatSystemInfo(info); @@ -321,7 +321,7 @@ export class DebugSelectorComponent extends Container { this.ctx.ui.requestRender(); } - private async handleOpenArtifacts(): Promise { + async #handleOpenArtifacts(): Promise { const sessionFile = this.ctx.sessionManager.getSessionFile(); if (!sessionFile) { this.ctx.showWarning("No active session file."); @@ -357,7 +357,7 @@ export class DebugSelectorComponent extends Container { } } - private async handleClearCache(): Promise { + async #handleClearCache(): Promise { const sessionsDir = getSessionsDir(); // Get stats first @@ -416,7 +416,7 @@ export class DebugSelectorComponent extends Container { this.ctx.ui.requestRender(); } - private getResolvedSettings(): Record { + #getResolvedSettings(): Record { // Extract key settings for the report return { model: this.ctx.session.model?.id, diff --git a/packages/coding-agent/src/discovery/codex.ts b/packages/coding-agent/src/discovery/codex.ts index ddf71c58a..3c5a61d9d 100644 --- a/packages/coding-agent/src/discovery/codex.ts +++ b/packages/coding-agent/src/discovery/codex.ts @@ -28,7 +28,7 @@ import type { SlashCommand } from "../capability/slash-command"; import { slashCommandCapability } from "../capability/slash-command"; import type { CustomTool } from "../capability/tool"; import { toolCapability } from "../capability/tool"; -import type { LoadContext, LoadResult } from "../capability/types"; +import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; import { parseFrontmatter } from "../utils/frontmatter"; import { createSourceMeta, @@ -276,8 +276,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise - (name: string, content: string, path: string, source: ReturnType) => { + (level: "user" | "project") => (name: string, content: string, path: string, source: SourceMeta) => { const { frontmatter, body } = parseFrontmatter(content, { source: path }); const commandName = frontmatter.name || name.replace(/\.md$/, ""); return { @@ -315,12 +314,7 @@ async function loadPrompts(ctx: LoadContext): Promise> { const codexDir = getProjectCodexDir(ctx); const projectPromptsDir = path.join(codexDir, "prompts"); - const transformPrompt = ( - name: string, - content: string, - path: string, - source: ReturnType, - ) => { + const transformPrompt = (name: string, content: string, path: string, source: SourceMeta) => { const { frontmatter, body } = parseFrontmatter(content, { source: path }); const promptName = frontmatter.name || name.replace(/\.md$/, ""); return { @@ -359,8 +353,7 @@ async function loadHooks(ctx: LoadContext): Promise> { const projectHooksDir = path.join(codexDir, "hooks"); const transformHook = - (level: "user" | "project") => - (name: string, _content: string, path: string, source: ReturnType) => { + (level: "user" | "project") => (name: string, _content: string, path: string, source: SourceMeta) => { const baseName = name.replace(/\.(ts|js)$/, ""); const match = baseName.match(/^(pre|post)-(.+)$/); const hookType = (match?.[1] as "pre" | "post") || "pre"; @@ -402,8 +395,7 @@ async function loadTools(ctx: LoadContext): Promise> { const projectToolsDir = path.join(codexDir, "tools"); const transformTool = - (level: "user" | "project") => - (name: string, _content: string, path: string, source: ReturnType) => { + (level: "user" | "project") => (name: string, _content: string, path: string, source: SourceMeta) => { const toolName = name.replace(/\.(ts|js)$/, ""); return { name: toolName, diff --git a/packages/coding-agent/src/discovery/cursor.ts b/packages/coding-agent/src/discovery/cursor.ts index 47b48eabc..e2531a0e8 100644 --- a/packages/coding-agent/src/discovery/cursor.ts +++ b/packages/coding-agent/src/discovery/cursor.ts @@ -20,7 +20,7 @@ import type { Rule } from "../capability/rule"; import { ruleCapability } from "../capability/rule"; import type { Settings } from "../capability/settings"; import { settingsCapability } from "../capability/settings"; -import type { LoadContext, LoadResult } from "../capability/types"; +import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; import { parseFrontmatter } from "../utils/frontmatter"; import { createSourceMeta, @@ -136,12 +136,7 @@ async function loadRules(ctx: LoadContext): Promise> { return { items, warnings }; } -function transformMDCRule( - name: string, - content: string, - path: string, - source: ReturnType, -): Rule { +function transformMDCRule(name: string, content: string, path: string, source: SourceMeta): Rule { const { frontmatter, body } = parseFrontmatter(content, { source: path }); // Extract frontmatter fields diff --git a/packages/coding-agent/src/exa/mcp-client.ts b/packages/coding-agent/src/exa/mcp-client.ts index 7442fa1bf..89be12b4c 100644 --- a/packages/coding-agent/src/exa/mcp-client.ts +++ b/packages/coding-agent/src/exa/mcp-client.ts @@ -217,8 +217,8 @@ export async function fetchMCPToolSchema( * reducing drift when MCP servers add new parameters. */ export class MCPWrappedTool implements CustomTool { - public readonly name: string; - public readonly label: string; + readonly name: string; + readonly label: string; constructor( private readonly config: MCPToolWrapperConfig, diff --git a/packages/coding-agent/src/exa/websets.ts b/packages/coding-agent/src/exa/websets.ts index e3d7dfb52..9b21145cd 100644 --- a/packages/coding-agent/src/exa/websets.ts +++ b/packages/coding-agent/src/exa/websets.ts @@ -3,7 +3,7 @@ * * CRUD operations for websets, items, searches, enrichments, and monitoring. */ -import { Type } from "@sinclair/typebox"; +import { type TObject, type TProperties, Type } from "@sinclair/typebox"; import type { CustomTool } from "../extensibility/custom-tools/types"; import { callWebsetsTool, findApiKey } from "./mcp-client"; import type { ExaRenderDetails } from "./types"; @@ -13,7 +13,7 @@ function createWebsetTool( name: string, label: string, description: string, - parameters: ReturnType, + parameters: TObject, mcpToolName: string, ): CustomTool { return { diff --git a/packages/coding-agent/src/export/html/index.ts b/packages/coding-agent/src/export/html/index.ts index f8e28db6b..7dd3ea1b8 100644 --- a/packages/coding-agent/src/export/html/index.ts +++ b/packages/coding-agent/src/export/html/index.ts @@ -3,7 +3,7 @@ import type { AgentState } from "@oh-my-pi/pi-agent-core"; import { isEnoent } from "@oh-my-pi/pi-utils"; import { APP_NAME } from "../../config"; import { getResolvedThemeColors, getThemeExportColors } from "../../modes/theme/theme"; -import { SessionManager } from "../../session/session-manager"; +import { type SessionEntry, type SessionHeader, SessionManager } from "../../session/session-manager"; // Pre-generated template (created by scripts/generate-template.ts at publish time) import { TEMPLATE } from "./template.generated"; @@ -92,8 +92,8 @@ async function generateThemeVars(themeName?: string): Promise { } interface SessionData { - header: ReturnType; - entries: ReturnType; + header: SessionHeader | null; + entries: SessionEntry[]; leafId: string | null; systemPrompt?: string; tools?: { name: string; description: string }[]; diff --git a/packages/coding-agent/src/export/ttsr.ts b/packages/coding-agent/src/export/ttsr.ts index dde69578a..d479f95a5 100644 --- a/packages/coding-agent/src/export/ttsr.ts +++ b/packages/coding-agent/src/export/ttsr.ts @@ -28,29 +28,29 @@ const DEFAULT_SETTINGS: Required = { }; export class TtsrManager { - private readonly settings: Required; - private readonly rules = new Map(); - private readonly injectionRecords = new Map(); - private buffer = ""; - private messageCount = 0; + readonly #settings: Required; + readonly #rules = new Map(); + readonly #injectionRecords = new Map(); + #buffer = ""; + #messageCount = 0; constructor(settings?: TtsrSettings) { - this.settings = { ...DEFAULT_SETTINGS, ...settings }; + this.#settings = { ...DEFAULT_SETTINGS, ...settings }; } /** Check if a rule can be triggered based on repeat settings */ - private canTrigger(ruleName: string): boolean { - const record = this.injectionRecords.get(ruleName); + #canTrigger(ruleName: string): boolean { + const record = this.#injectionRecords.get(ruleName); if (!record) { return true; } - if (this.settings.repeatMode === "once") { + if (this.#settings.repeatMode === "once") { return false; } - const gap = this.messageCount - record.lastInjectedAt; - return gap >= this.settings.repeatGap; + const gap = this.#messageCount - record.lastInjectedAt; + return gap >= this.#settings.repeatGap; } /** Add a TTSR rule to be monitored */ @@ -59,13 +59,13 @@ export class TtsrManager { return; } - if (this.rules.has(rule.name)) { + if (this.#rules.has(rule.name)) { return; } try { const regex = new RegExp(rule.ttsrTrigger); - this.rules.set(rule.name, { rule, regex }); + this.#rules.set(rule.name, { rule, regex }); logger.debug("TTSR rule registered", { ruleName: rule.name, pattern: rule.ttsrTrigger, @@ -83,8 +83,8 @@ export class TtsrManager { check(streamBuffer: string): Rule[] { const matches: Rule[] = []; - for (const [name, entry] of this.rules) { - if (!this.canTrigger(name)) { + for (const [name, entry] of this.#rules) { + if (!this.#canTrigger(name)) { continue; } @@ -103,24 +103,24 @@ export class TtsrManager { /** Mark rules as injected (won't trigger again until conditions allow) */ markInjected(rulesToMark: Rule[]): void { for (const rule of rulesToMark) { - this.injectionRecords.set(rule.name, { lastInjectedAt: this.messageCount }); + this.#injectionRecords.set(rule.name, { lastInjectedAt: this.#messageCount }); logger.debug("TTSR rule marked as injected", { ruleName: rule.name, - messageCount: this.messageCount, - repeatMode: this.settings.repeatMode, + messageCount: this.#messageCount, + repeatMode: this.#settings.repeatMode, }); } } /** Get names of all injected rules (for persistence) */ getInjectedRuleNames(): string[] { - return Array.from(this.injectionRecords.keys()); + return Array.from(this.#injectionRecords.keys()); } /** Restore injected state from a list of rule names */ restoreInjected(ruleNames: string[]): void { for (const name of ruleNames) { - this.injectionRecords.set(name, { lastInjectedAt: 0 }); + this.#injectionRecords.set(name, { lastInjectedAt: 0 }); } if (ruleNames.length > 0) { logger.debug("TTSR injected state restored", { ruleNames }); @@ -129,36 +129,36 @@ export class TtsrManager { /** Reset stream buffer (called on new turn) */ resetBuffer(): void { - this.buffer = ""; + this.#buffer = ""; } /** Get current stream buffer */ getBuffer(): string { - return this.buffer; + return this.#buffer; } /** Append to stream buffer */ appendToBuffer(text: string): void { - this.buffer += text; + this.#buffer += text; } /** Check if any TTSRs are registered */ hasRules(): boolean { - return this.rules.size > 0; + return this.#rules.size > 0; } /** Increment message counter (call after each turn) */ incrementMessageCount(): void { - this.messageCount++; + this.#messageCount++; } /** Get current message count */ getMessageCount(): number { - return this.messageCount; + return this.#messageCount; } /** Get settings */ getSettings(): Required { - return this.settings; + return this.#settings; } } diff --git a/packages/coding-agent/src/extensibility/custom-tools/loader.ts b/packages/coding-agent/src/extensibility/custom-tools/loader.ts index f0a909181..686b4f672 100644 --- a/packages/coding-agent/src/extensibility/custom-tools/loader.ts +++ b/packages/coding-agent/src/extensibility/custom-tools/loader.ts @@ -119,11 +119,11 @@ interface ToolPathWithSource { export class CustomToolLoader { tools: LoadedCustomTool[] = []; errors: ToolLoadError[] = []; - private sharedApi: CustomToolAPI; - private seenNames: Set; + #sharedApi: CustomToolAPI; + #seenNames: Set; constructor(cwd: string, builtInToolNames: string[]) { - this.sharedApi = { + this.#sharedApi = { cwd, exec: (command: string, args: string[], options?: ExecOptions) => execCommand(command, args, options?.cwd ?? cwd, options), @@ -133,12 +133,12 @@ export class CustomToolLoader { typebox, pi: piCodingAgent, }; - this.seenNames = new Set(builtInToolNames); + this.#seenNames = new Set(builtInToolNames); } async load(pathsWithSources: ToolPathWithSource[]): Promise { for (const { path: toolPath, source } of pathsWithSources) { - const { tools: loadedTools, error } = await loadTool(toolPath, this.sharedApi.cwd, this.sharedApi, source); + const { tools: loadedTools, error } = await loadTool(toolPath, this.#sharedApi.cwd, this.#sharedApi, source); if (error) { this.errors.push(error); @@ -148,7 +148,7 @@ export class CustomToolLoader { if (loadedTools) { for (const loadedTool of loadedTools) { // Check for name conflicts - if (this.seenNames.has(loadedTool.tool.name)) { + if (this.#seenNames.has(loadedTool.tool.name)) { this.errors.push({ path: toolPath, error: `Tool name "${loadedTool.tool.name}" conflicts with existing tool`, @@ -157,7 +157,7 @@ export class CustomToolLoader { continue; } - this.seenNames.add(loadedTool.tool.name); + this.#seenNames.add(loadedTool.tool.name); this.tools.push(loadedTool); } } @@ -165,8 +165,8 @@ export class CustomToolLoader { } setUIContext(uiContext: HookUIContext, hasUI: boolean): void { - this.sharedApi.ui = uiContext; - this.sharedApi.hasUI = hasUI; + this.#sharedApi.ui = uiContext; + this.#sharedApi.hasUI = hasUI; } } diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index a9d567ad8..d6157c278 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -153,23 +153,23 @@ const noOpUIContext: ExtensionUIContext = { }; export class ExtensionRunner { - private uiContext: ExtensionUIContext; - private errorListeners: Set = new Set(); - private getModel: () => Model | undefined = () => undefined; - private isIdleFn: () => boolean = () => true; - private waitForIdleFn: () => Promise = async () => {}; - private abortFn: () => void = () => {}; - private hasPendingMessagesFn: () => boolean = () => false; - private getContextUsageFn: () => ContextUsage | undefined = () => undefined; - private compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise = async () => {}; - private getSystemPromptFn: () => string = () => ""; - private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); - private branchHandler: BranchHandler = async () => ({ cancelled: false }); - private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); - private switchSessionHandler: SwitchSessionHandler = async () => ({ cancelled: false }); - private reloadHandler: () => Promise = async () => {}; - private shutdownHandler: ShutdownHandler = () => {}; - private commandDiagnostics: Array<{ type: string; message: string; path: string }> = []; + #uiContext: ExtensionUIContext; + #errorListeners: Set = new Set(); + #getModel: () => Model | undefined = () => undefined; + #isIdleFn: () => boolean = () => true; + #waitForIdleFn: () => Promise = async () => {}; + #abortFn: () => void = () => {}; + #hasPendingMessagesFn: () => boolean = () => false; + #getContextUsageFn: () => ContextUsage | undefined = () => undefined; + #compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise = async () => {}; + #getSystemPromptFn: () => string = () => ""; + #newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); + #branchHandler: BranchHandler = async () => ({ cancelled: false }); + #navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); + #switchSessionHandler: SwitchSessionHandler = async () => ({ cancelled: false }); + #reloadHandler: () => Promise = async () => {}; + #shutdownHandler: ShutdownHandler = () => {}; + #commandDiagnostics: Array<{ type: string; message: string; path: string }> = []; constructor( private readonly extensions: Extension[], @@ -178,7 +178,7 @@ export class ExtensionRunner { private readonly sessionManager: SessionManager, private readonly modelRegistry: ModelRegistry, ) { - this.uiContext = noOpUIContext; + this.#uiContext = noOpUIContext; } initialize( @@ -200,34 +200,34 @@ export class ExtensionRunner { this.runtime.setThinkingLevel = actions.setThinkingLevel; // Context actions (required) - this.getModel = contextActions.getModel; - this.isIdleFn = contextActions.isIdle; - this.abortFn = contextActions.abort; - this.hasPendingMessagesFn = contextActions.hasPendingMessages; - this.shutdownHandler = contextActions.shutdown; - this.getSystemPromptFn = contextActions.getSystemPrompt; + this.#getModel = contextActions.getModel; + this.#isIdleFn = contextActions.isIdle; + this.#abortFn = contextActions.abort; + this.#hasPendingMessagesFn = contextActions.hasPendingMessages; + this.#shutdownHandler = contextActions.shutdown; + this.#getSystemPromptFn = contextActions.getSystemPrompt; // Command context actions (optional, only for interactive mode) if (commandContextActions) { - this.waitForIdleFn = commandContextActions.waitForIdle; - this.newSessionHandler = commandContextActions.newSession; - this.branchHandler = commandContextActions.branch; - this.navigateTreeHandler = commandContextActions.navigateTree; - this.switchSessionHandler = commandContextActions.switchSession; - this.reloadHandler = commandContextActions.reload; - this.getContextUsageFn = commandContextActions.getContextUsage; - this.compactFn = commandContextActions.compact; + this.#waitForIdleFn = commandContextActions.waitForIdle; + this.#newSessionHandler = commandContextActions.newSession; + this.#branchHandler = commandContextActions.branch; + this.#navigateTreeHandler = commandContextActions.navigateTree; + this.#switchSessionHandler = commandContextActions.switchSession; + this.#reloadHandler = commandContextActions.reload; + this.#getContextUsageFn = commandContextActions.getContextUsage; + this.#compactFn = commandContextActions.compact; } - this.uiContext = uiContext ?? noOpUIContext; + this.#uiContext = uiContext ?? noOpUIContext; } getUIContext(): ExtensionUIContext { - return this.uiContext; + return this.#uiContext; } hasUI(): boolean { - return this.uiContext !== noOpUIContext; + return this.#uiContext !== noOpUIContext; } getExtensionPaths(): string[] { @@ -259,7 +259,7 @@ export class ExtensionRunner { this.runtime.flagValues.set(name, value); } - private static readonly RESERVED_SHORTCUTS = new Set([ + static readonly #RESERVED_SHORTCUTS = new Set([ "ctrl+c", "ctrl+d", "ctrl+z", @@ -282,7 +282,7 @@ export class ExtensionRunner { for (const [key, shortcut] of ext.shortcuts) { const normalizedKey = key.toLowerCase() as KeyId; - if (ExtensionRunner.RESERVED_SHORTCUTS.has(normalizedKey)) { + if (ExtensionRunner.#RESERVED_SHORTCUTS.has(normalizedKey)) { logger.warn("Extension shortcut conflicts with built-in shortcut", { key, extensionPath: shortcut.extensionPath, @@ -305,12 +305,12 @@ export class ExtensionRunner { } onError(listener: ExtensionErrorListener): () => void { - this.errorListeners.add(listener); - return () => this.errorListeners.delete(listener); + this.#errorListeners.add(listener); + return () => this.#errorListeners.delete(listener); } emitError(error: ExtensionError): void { - for (const listener of this.errorListeners) { + for (const listener of this.#errorListeners) { listener(error); } } @@ -336,14 +336,14 @@ export class ExtensionRunner { } getRegisteredCommands(reserved?: Set): RegisteredCommand[] { - this.commandDiagnostics = []; + this.#commandDiagnostics = []; const commands: RegisteredCommand[] = []; for (const ext of this.extensions) { for (const command of ext.commands.values()) { if (reserved?.has(command.name)) { const message = `Extension command '${command.name}' from ${ext.path} conflicts with built-in commands. Skipping.`; - this.commandDiagnostics.push({ type: "warning", message, path: ext.path }); + this.#commandDiagnostics.push({ type: "warning", message, path: ext.path }); if (!this.hasUI()) { logger.warn(message); } @@ -357,7 +357,7 @@ export class ExtensionRunner { } getCommandDiagnostics(): Array<{ type: string; message: string; path: string }> { - return this.commandDiagnostics; + return this.#commandDiagnostics; } getCommand(name: string): RegisteredCommand | undefined { @@ -371,11 +371,11 @@ export class ExtensionRunner { } createContext(): ExtensionContext { - const getModel = this.getModel; + const getModel = this.#getModel; return { - ui: this.uiContext, - getContextUsage: () => this.getContextUsageFn(), - compact: instructionsOrOptions => this.compactFn(instructionsOrOptions), + ui: this.#uiContext, + getContextUsage: () => this.#getContextUsageFn(), + compact: instructionsOrOptions => this.#compactFn(instructionsOrOptions), hasUI: this.hasUI(), cwd: this.cwd, sessionManager: this.sessionManager, @@ -383,12 +383,12 @@ export class ExtensionRunner { get model() { return getModel(); }, - isIdle: () => this.isIdleFn(), - abort: () => this.abortFn(), - hasPendingMessages: () => this.hasPendingMessagesFn(), - shutdown: () => this.shutdownHandler(), - getSystemPrompt: () => this.getSystemPromptFn(), - hasQueuedMessages: () => this.hasPendingMessagesFn(), // deprecated alias + isIdle: () => this.#isIdleFn(), + abort: () => this.#abortFn(), + hasPendingMessages: () => this.#hasPendingMessagesFn(), + shutdown: () => this.#shutdownHandler(), + getSystemPrompt: () => this.#getSystemPromptFn(), + hasQueuedMessages: () => this.#hasPendingMessagesFn(), // deprecated alias }; } @@ -396,24 +396,24 @@ export class ExtensionRunner { * Request a graceful shutdown. Called by extension tools and event handlers. */ shutdown(): void { - this.shutdownHandler(); + this.#shutdownHandler(); } createCommandContext(): ExtensionCommandContext { return { ...this.createContext(), - getContextUsage: () => this.getContextUsageFn(), - waitForIdle: () => this.waitForIdleFn(), - newSession: options => this.newSessionHandler(options), - branch: entryId => this.branchHandler(entryId), - navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options), - switchSession: sessionPath => this.switchSessionHandler(sessionPath), - reload: () => this.reloadHandler(), - compact: instructionsOrOptions => this.compactFn(instructionsOrOptions), + getContextUsage: () => this.#getContextUsageFn(), + waitForIdle: () => this.#waitForIdleFn(), + newSession: options => this.#newSessionHandler(options), + branch: entryId => this.#branchHandler(entryId), + navigateTree: (targetId, options) => this.#navigateTreeHandler(targetId, options), + switchSession: sessionPath => this.#switchSessionHandler(sessionPath), + reload: () => this.#reloadHandler(), + compact: instructionsOrOptions => this.#compactFn(instructionsOrOptions), }; } - private isSessionBeforeEvent(event: RunnerEmitEvent): event is SessionBeforeEvent { + #isSessionBeforeEvent(event: RunnerEmitEvent): event is SessionBeforeEvent { return ( event.type === "session_before_switch" || event.type === "session_before_branch" || @@ -434,7 +434,7 @@ export class ExtensionRunner { try { const handlerResult = await handler(event, ctx); - if (this.isSessionBeforeEvent(event) && handlerResult) { + if (this.#isSessionBeforeEvent(event) && handlerResult) { result = handlerResult as SessionBeforeEventResult; if (result.cancel) { return result as RunnerEmitResult; diff --git a/packages/coding-agent/src/extensibility/hooks/runner.ts b/packages/coding-agent/src/extensibility/hooks/runner.ts index 1428a9c5c..74c4231fa 100644 --- a/packages/coding-agent/src/extensibility/hooks/runner.ts +++ b/packages/coding-agent/src/extensibility/hooks/runner.ts @@ -62,17 +62,17 @@ const noOpUIContext: HookUIContext = { * HookRunner executes hooks and manages event emission. */ export class HookRunner { - private uiContext: HookUIContext; - private hasUI: boolean; - private errorListeners: Set = new Set(); - private getModel: () => Model | undefined = () => undefined; - private isIdleFn: () => boolean = () => true; - private waitForIdleFn: () => Promise = async () => {}; - private abortFn: () => void = () => {}; - private hasQueuedMessagesFn: () => boolean = () => false; - private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); - private branchHandler: BranchHandler = async () => ({ cancelled: false }); - private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); + #uiContext: HookUIContext; + #hasUI: boolean; + #errorListeners: Set = new Set(); + #getModel: () => Model | undefined = () => undefined; + #isIdleFn: () => boolean = () => true; + #waitForIdleFn: () => Promise = async () => {}; + #abortFn: () => void = () => {}; + #hasQueuedMessagesFn: () => boolean = () => false; + #newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); + #branchHandler: BranchHandler = async () => ({ cancelled: false }); + #navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); constructor( private readonly hooks: LoadedHook[], @@ -80,8 +80,8 @@ export class HookRunner { private readonly sessionManager: SessionManager, private readonly modelRegistry: ModelRegistry, ) { - this.uiContext = noOpUIContext; - this.hasUI = false; + this.#uiContext = noOpUIContext; + this.#hasUI = false; } /** @@ -114,42 +114,42 @@ export class HookRunner { /** Whether UI is available */ hasUI?: boolean; }): void { - this.getModel = options.getModel; - this.isIdleFn = options.isIdle ?? (() => true); - this.waitForIdleFn = options.waitForIdle ?? (async () => {}); - this.abortFn = options.abort ?? (() => {}); - this.hasQueuedMessagesFn = options.hasQueuedMessages ?? (() => false); + this.#getModel = options.getModel; + this.#isIdleFn = options.isIdle ?? (() => true); + this.#waitForIdleFn = options.waitForIdle ?? (async () => {}); + this.#abortFn = options.abort ?? (() => {}); + this.#hasQueuedMessagesFn = options.hasQueuedMessages ?? (() => false); // Store session handlers for HookCommandContext if (options.newSessionHandler) { - this.newSessionHandler = options.newSessionHandler; + this.#newSessionHandler = options.newSessionHandler; } if (options.branchHandler) { - this.branchHandler = options.branchHandler; + this.#branchHandler = options.branchHandler; } if (options.navigateTreeHandler) { - this.navigateTreeHandler = options.navigateTreeHandler; + this.#navigateTreeHandler = options.navigateTreeHandler; } // Set per-hook handlers for pi.sendMessage() and pi.appendEntry() for (const hook of this.hooks) { hook.setSendMessageHandler(options.sendMessageHandler); hook.setAppendEntryHandler(options.appendEntryHandler); } - this.uiContext = options.uiContext ?? noOpUIContext; - this.hasUI = options.hasUI ?? false; + this.#uiContext = options.uiContext ?? noOpUIContext; + this.#hasUI = options.hasUI ?? false; } /** * Get the UI context (set by mode). */ getUIContext(): HookUIContext | null { - return this.uiContext; + return this.#uiContext; } /** * Get whether UI is available. */ getHasUI(): boolean { - return this.hasUI; + return this.#hasUI; } /** @@ -164,8 +164,8 @@ export class HookRunner { * @returns Unsubscribe function */ onError(listener: HookErrorListener): () => void { - this.errorListeners.add(listener); - return () => this.errorListeners.delete(listener); + this.#errorListeners.add(listener); + return () => this.#errorListeners.delete(listener); } /** @@ -175,7 +175,7 @@ export class HookRunner { * Emit an error to all error listeners. */ emitError(error: HookError): void { - for (const listener of this.errorListeners) { + for (const listener of this.#errorListeners) { listener(error); } } @@ -237,17 +237,17 @@ export class HookRunner { /** * Create the event context for handlers. */ - private createContext(): HookContext { + #createContext(): HookContext { return { - ui: this.uiContext, - hasUI: this.hasUI, + ui: this.#uiContext, + hasUI: this.#hasUI, cwd: this.cwd, sessionManager: this.sessionManager, modelRegistry: this.modelRegistry, - model: this.getModel(), - isIdle: () => this.isIdleFn(), - abort: () => this.abortFn(), - hasQueuedMessages: () => this.hasQueuedMessagesFn(), + model: this.#getModel(), + isIdle: () => this.#isIdleFn(), + abort: () => this.#abortFn(), + hasQueuedMessages: () => this.#hasQueuedMessagesFn(), }; } @@ -257,18 +257,18 @@ export class HookRunner { */ createCommandContext(): HookCommandContext { return { - ...this.createContext(), - waitForIdle: () => this.waitForIdleFn(), - newSession: options => this.newSessionHandler(options), - branch: entryId => this.branchHandler(entryId), - navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options), + ...this.#createContext(), + waitForIdle: () => this.#waitForIdleFn(), + newSession: options => this.#newSessionHandler(options), + branch: entryId => this.#branchHandler(entryId), + navigateTree: (targetId, options) => this.#navigateTreeHandler(targetId, options), }; } /** * Check if event type is a session "before_*" event that can be cancelled. */ - private isSessionBeforeEvent( + #isSessionBeforeEvent( type: string, ): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" { return ( @@ -288,7 +288,7 @@ export class HookRunner { ): Promise< SessionBeforeCompactResult | SessionBeforeTreeResult | SessionCompactingResult | ToolResultEventResult | undefined > { - const ctx = this.createContext(); + const ctx = this.#createContext(); let result: | SessionBeforeCompactResult | SessionBeforeTreeResult @@ -305,7 +305,7 @@ export class HookRunner { const handlerResult = await handler(event, ctx); // For session before_* events, capture the result (for cancellation) - if (this.isSessionBeforeEvent(event.type) && handlerResult) { + if (this.#isSessionBeforeEvent(event.type) && handlerResult) { result = handlerResult as SessionBeforeCompactResult | SessionBeforeTreeResult; // If cancelled, stop processing further hooks if (result.cancel) { @@ -340,7 +340,7 @@ export class HookRunner { * Errors are thrown (not swallowed) so caller can block on failure. */ async emitToolCall(event: ToolCallEvent): Promise { - const ctx = this.createContext(); + const ctx = this.#createContext(); let result: ToolCallEventResult | undefined; for (const hook of this.hooks) { @@ -372,7 +372,7 @@ export class HookRunner { * Note: Messages are already deep-copied by the caller (pi-ai preprocessor). */ async emitContext(messages: AgentMessage[]): Promise { - const ctx = this.createContext(); + const ctx = this.#createContext(); let currentMessages = messages; for (const hook of this.hooks) { @@ -409,7 +409,7 @@ export class HookRunner { prompt: string, images?: import("@oh-my-pi/pi-ai").ImageContent[], ): Promise { - const ctx = this.createContext(); + const ctx = this.#createContext(); let result: BeforeAgentStartEventResult | undefined; for (const hook of this.hooks) { diff --git a/packages/coding-agent/src/extensibility/plugins/manager.ts b/packages/coding-agent/src/extensibility/plugins/manager.ts index 0f445fcb5..0ac00052a 100644 --- a/packages/coding-agent/src/extensibility/plugins/manager.ts +++ b/packages/coding-agent/src/extensibility/plugins/manager.ts @@ -47,18 +47,18 @@ function validatePackageName(name: string): void { // ============================================================================= export class PluginManager { - private runtimeConfig: PluginRuntimeConfig | null = null; - private cwd: string; + #runtimeConfig: PluginRuntimeConfig | null = null; + #cwd: string; constructor(cwd: string = process.cwd()) { - this.cwd = cwd; + this.#cwd = cwd; } // ========================================================================== // Runtime Config Management // ========================================================================== - private async loadRuntimeConfig(): Promise { + async #loadRuntimeConfig(): Promise { const lockPath = getPluginsLockfile(); try { return await Bun.file(lockPath).json(); @@ -69,20 +69,20 @@ export class PluginManager { } } - private async ensureConfigLoaded(): Promise { - if (!this.runtimeConfig) { - this.runtimeConfig = await this.loadRuntimeConfig(); + async #ensureConfigLoaded(): Promise { + if (!this.#runtimeConfig) { + this.#runtimeConfig = await this.#loadRuntimeConfig(); } - return this.runtimeConfig; + return this.#runtimeConfig; } - private async saveRuntimeConfig(): Promise { - await this.ensureConfigLoaded(); - await Bun.write(getPluginsLockfile(), JSON.stringify(this.runtimeConfig, null, 2)); + async #saveRuntimeConfig(): Promise { + await this.#ensureConfigLoaded(); + await Bun.write(getPluginsLockfile(), JSON.stringify(this.#runtimeConfig, null, 2)); } - private async loadProjectOverrides(): Promise { - const overridesPath = getProjectPluginOverrides(this.cwd); + async #loadProjectOverrides(): Promise { + const overridesPath = getProjectPluginOverrides(this.#cwd); try { return await Bun.file(overridesPath).json(); } catch (err) { @@ -96,12 +96,12 @@ export class PluginManager { // Directory Management // ========================================================================== - private async ensurePluginsDir(): Promise { + async #ensurePluginsDir(): Promise { await fs.promises.mkdir(getPluginsDir(), { recursive: true }); await fs.promises.mkdir(getPluginsNodeModules(), { recursive: true }); } - private async ensurePackageJson(): Promise { + async #ensurePackageJson(): Promise { const pkgJsonPath = getPluginsPackageJson(); try { await Bun.file(pkgJsonPath).json(); @@ -140,7 +140,7 @@ export class PluginManager { const spec = parsePluginSpec(specString); validatePackageName(spec.packageName); - await this.ensurePackageJson(); + await this.#ensurePackageJson(); if (options.dryRun) { return { @@ -210,13 +210,13 @@ export class PluginManager { // null = use defaults // Update runtime config - const config = await this.ensureConfigLoaded(); + const config = await this.#ensureConfigLoaded(); config.plugins[pkg.name] = { version: pkg.version, enabledFeatures, enabled: true, }; - await this.saveRuntimeConfig(); + await this.#saveRuntimeConfig(); return { name: pkg.name, @@ -233,7 +233,7 @@ export class PluginManager { */ async uninstall(name: string): Promise { validatePackageName(name); - await this.ensurePackageJson(); + await this.#ensurePackageJson(); const proc = Bun.spawn(["bun", "uninstall", name], { cwd: getPluginsDir(), @@ -249,10 +249,10 @@ export class PluginManager { } // Remove from runtime config - const config = await this.ensureConfigLoaded(); + const config = await this.#ensureConfigLoaded(); delete config.plugins[name]; delete config.settings[name]; - await this.saveRuntimeConfig(); + await this.#saveRuntimeConfig(); } /** @@ -269,8 +269,8 @@ export class PluginManager { } const deps = pkg.dependencies || {}; - const projectOverrides = await this.loadProjectOverrides(); - const config = await this.ensureConfigLoaded(); + const projectOverrides = await this.#loadProjectOverrides(); + const config = await this.#ensureConfigLoaded(); const plugins: InstalledPlugin[] = []; for (const [name] of Object.entries(deps)) { @@ -312,7 +312,7 @@ export class PluginManager { * Link a local plugin for development. */ async link(localPath: string): Promise { - const absolutePath = path.resolve(this.cwd, localPath); + const absolutePath = path.resolve(this.#cwd, localPath); const pkgFilePath = path.join(absolutePath, "package.json"); let pkg: { name?: string; version: string; omp?: PluginManifest; pi?: PluginManifest }; @@ -326,7 +326,7 @@ export class PluginManager { throw new Error("package.json must have a name field"); } - await this.ensurePluginsDir(); + await this.#ensurePluginsDir(); const linkPath = path.join(getPluginsNodeModules(), pkg.name); @@ -352,13 +352,13 @@ export class PluginManager { manifest.version = pkg.version; // Add to runtime config - const config = await this.ensureConfigLoaded(); + const config = await this.#ensureConfigLoaded(); config.plugins[pkg.name] = { version: pkg.version, enabledFeatures: null, enabled: true, }; - await this.saveRuntimeConfig(); + await this.#saveRuntimeConfig(); return { name: pkg.name, @@ -378,12 +378,12 @@ export class PluginManager { * Enable or disable a plugin globally. */ async setEnabled(name: string, enabled: boolean): Promise { - const config = await this.ensureConfigLoaded(); + const config = await this.#ensureConfigLoaded(); if (!config.plugins[name]) { throw new Error(`Plugin ${name} not found in runtime config`); } config.plugins[name].enabled = enabled; - await this.saveRuntimeConfig(); + await this.#saveRuntimeConfig(); } // ========================================================================== @@ -394,7 +394,7 @@ export class PluginManager { * Get enabled features for a plugin. */ async getEnabledFeatures(name: string): Promise { - const config = await this.ensureConfigLoaded(); + const config = await this.#ensureConfigLoaded(); return config.plugins[name]?.enabledFeatures ?? null; } @@ -402,7 +402,7 @@ export class PluginManager { * Set enabled features for a plugin. */ async setEnabledFeatures(name: string, features: string[] | null): Promise { - const config = await this.ensureConfigLoaded(); + const config = await this.#ensureConfigLoaded(); if (!config.plugins[name]) { throw new Error(`Plugin ${name} not found in runtime config`); } @@ -423,7 +423,7 @@ export class PluginManager { } config.plugins[name].enabledFeatures = features; - await this.saveRuntimeConfig(); + await this.#saveRuntimeConfig(); } // ========================================================================== @@ -434,9 +434,9 @@ export class PluginManager { * Get all settings for a plugin. */ async getPluginSettings(name: string): Promise> { - const config = await this.ensureConfigLoaded(); + const config = await this.#ensureConfigLoaded(); const global = config.settings[name] || {}; - const projectOverrides = await this.loadProjectOverrides(); + const projectOverrides = await this.#loadProjectOverrides(); const project = projectOverrides.settings?.[name] || {}; // Project settings override global @@ -447,22 +447,22 @@ export class PluginManager { * Set a plugin setting value. */ async setPluginSetting(name: string, key: string, value: unknown): Promise { - const config = await this.ensureConfigLoaded(); + const config = await this.#ensureConfigLoaded(); if (!config.settings[name]) { config.settings[name] = {}; } config.settings[name][key] = value; - await this.saveRuntimeConfig(); + await this.#saveRuntimeConfig(); } /** * Delete a plugin setting. */ async deletePluginSetting(name: string, key: string): Promise { - const config = await this.ensureConfigLoaded(); + const config = await this.#ensureConfigLoaded(); if (config.settings[name]) { delete config.settings[name][key]; - await this.saveRuntimeConfig(); + await this.#saveRuntimeConfig(); } } @@ -518,7 +518,7 @@ export class PluginManager { return checks; } const deps = pkg.dependencies || {}; - const config = await this.ensureConfigLoaded(); + const config = await this.#ensureConfigLoaded(); for (const [name] of Object.entries(deps)) { const pluginPath = path.join(nodeModulesPath, name); @@ -530,7 +530,7 @@ export class PluginManager { } catch (err) { if (isEnoent(err)) { if (!fs.existsSync(pluginPath)) { - const fixed = options.fix ? await this.fixMissingPlugin() : false; + const fixed = options.fix ? await this.#fixMissingPlugin() : false; checks.push({ name: `plugin:${name}`, status: "error", @@ -588,7 +588,7 @@ export class PluginManager { if (runtimeState?.enabledFeatures && manifest?.features) { for (const feat of runtimeState.enabledFeatures) { if (!(feat in manifest.features)) { - const fixed = options.fix ? await this.removeInvalidFeature(name, feat) : false; + const fixed = options.fix ? await this.#removeInvalidFeature(name, feat) : false; checks.push({ name: `plugin:${name}:feature:${feat}`, status: "warning", @@ -603,7 +603,7 @@ export class PluginManager { // Check for orphaned runtime config entries for (const name of Object.keys(config.plugins)) { if (!(name in deps)) { - const fixed = options.fix ? await this.removeOrphanedConfig(name) : false; + const fixed = options.fix ? await this.#removeOrphanedConfig(name) : false; checks.push({ name: `orphan:${name}`, status: "warning", @@ -616,7 +616,7 @@ export class PluginManager { return checks; } - private async fixMissingPlugin(): Promise { + async #fixMissingPlugin(): Promise { try { const proc = Bun.spawn(["bun", "install"], { cwd: getPluginsDir(), @@ -631,22 +631,22 @@ export class PluginManager { } } - private async removeInvalidFeature(name: string, feat: string): Promise { - const config = await this.ensureConfigLoaded(); + async #removeInvalidFeature(name: string, feat: string): Promise { + const config = await this.#ensureConfigLoaded(); const state = config.plugins[name]; if (state?.enabledFeatures) { state.enabledFeatures = state.enabledFeatures.filter(f => f !== feat); - await this.saveRuntimeConfig(); + await this.#saveRuntimeConfig(); return true; } return false; } - private async removeOrphanedConfig(name: string): Promise { - const config = await this.ensureConfigLoaded(); + async #removeOrphanedConfig(name: string): Promise { + const config = await this.#ensureConfigLoaded(); delete config.plugins[name]; delete config.settings[name]; - await this.saveRuntimeConfig(); + await this.#saveRuntimeConfig(); return true; } } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index f9b79077d..9936a0e65 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -107,7 +107,6 @@ export { } from "./modes"; // UI components for extensions export { - ArminComponent, AssistantMessageComponent, BashExecutionComponent, BorderedLoader, diff --git a/packages/coding-agent/src/internal-urls/router.ts b/packages/coding-agent/src/internal-urls/router.ts index 3a0beb65c..9451d435d 100644 --- a/packages/coding-agent/src/internal-urls/router.ts +++ b/packages/coding-agent/src/internal-urls/router.ts @@ -10,14 +10,14 @@ import type { InternalResource, InternalUrl, ProtocolHandler } from "./types"; * registered protocol handlers. */ export class InternalUrlRouter { - private handlers = new Map(); + #handlers = new Map(); /** * Register a protocol handler. * @param handler Handler to register (uses handler.scheme as key) */ register(handler: ProtocolHandler): void { - this.handlers.set(handler.scheme, handler); + this.#handlers.set(handler.scheme, handler); } /** @@ -28,7 +28,7 @@ export class InternalUrlRouter { const match = input.match(/^([a-z][a-z0-9+.-]*):\/\//i); if (!match) return false; const scheme = match[1].toLowerCase(); - return this.handlers.has(scheme); + return this.#handlers.has(scheme); } /** @@ -54,10 +54,10 @@ export class InternalUrlRouter { (parsed as InternalUrl).rawHost = rawHost; const scheme = parsed.protocol.replace(/:$/, "").toLowerCase(); - const handler = this.handlers.get(scheme); + const handler = this.#handlers.get(scheme); if (!handler) { - const available = Array.from(this.handlers.keys()) + const available = Array.from(this.#handlers.keys()) .map(s => `${s}://`) .join(", "); throw new Error(`Unknown protocol: ${scheme}://\nSupported: ${available || "none"}`); diff --git a/packages/coding-agent/src/ipy/kernel.ts b/packages/coding-agent/src/ipy/kernel.ts index 52b307f7e..75b6e5128 100644 --- a/packages/coding-agent/src/ipy/kernel.ts +++ b/packages/coding-agent/src/ipy/kernel.ts @@ -187,6 +187,48 @@ function normalizeDisplayText(text: string): string { return text.endsWith("\n") ? text : `${text}\n`; } +/** Renders a Jupyter display_data message into text and structured outputs. */ +export function renderKernelDisplay(content: Record): { + text: string; + outputs: KernelDisplayOutput[]; +} { + const data = content.data as Record | undefined; + if (!data) return { text: "", outputs: [] }; + + const outputs: KernelDisplayOutput[] = []; + + // Handle status events (custom MIME type from prelude helpers) + if (data["application/x-omp-status"] !== undefined) { + const statusData = data["application/x-omp-status"]; + if (statusData && typeof statusData === "object" && "op" in statusData) { + outputs.push({ type: "status", event: statusData as PythonStatusEvent }); + } + // Status events don't produce text output + return { text: "", outputs }; + } + + if (typeof data["image/png"] === "string") { + outputs.push({ type: "image", data: data["image/png"] as string, mimeType: "image/png" }); + } + if (data["application/json"] !== undefined) { + outputs.push({ type: "json", data: data["application/json"] }); + } + + // Check text/markdown before text/plain since Markdown objects provide both + // (text/plain is just the repr) + if (typeof data["text/markdown"] === "string") { + return { text: normalizeDisplayText(String(data["text/markdown"])), outputs }; + } + if (typeof data["text/plain"] === "string") { + return { text: normalizeDisplayText(String(data["text/plain"])), outputs }; + } + if (data["text/html"] !== undefined) { + const markdown = htmlToBasicMarkdown(String(data["text/html"])) || ""; + return { text: markdown ? normalizeDisplayText(markdown) : "", outputs }; + } + return { text: "", outputs }; +} + export function deserializeWebSocketMessage(data: ArrayBuffer): JupyterMessage | null { const view = new DataView(data); const offsetCount = view.getUint32(0, true); @@ -302,7 +344,7 @@ export class PythonKernel { const externalConfig = getExternalGatewayConfig(); if (externalConfig) { - return PythonKernel.startWithExternalGateway(externalConfig, options.cwd, options.env); + return PythonKernel.#startWithExternalGateway(externalConfig, options.cwd, options.env); } if (options.useSharedGateway === false) { @@ -319,7 +361,7 @@ export class PythonKernel { throw new Error("Shared Python gateway unavailable"); } debugStartup("PythonKernel.start:startShared:start"); - const kernel = await PythonKernel.startWithSharedGateway(sharedResult.url, options.cwd, options.env); + const kernel = await PythonKernel.#startWithSharedGateway(sharedResult.url, options.cwd, options.env); debugStartup("PythonKernel.start:startShared:done"); time("PythonKernel.start:startWithSharedGateway"); return kernel; @@ -341,7 +383,7 @@ export class PythonKernel { throw new Error("Shared Python gateway unavailable after retry"); } - private static async startWithExternalGateway( + static async #startWithExternalGateway( config: ExternalGatewayConfig, cwd: string, env?: Record, @@ -375,8 +417,8 @@ export class PythonKernel { ); try { - await kernel.connectWebSocket(); - await kernel.initializeKernelEnvironment(cwd, env); + await kernel.#connectWebSocket(); + await kernel.#initializeKernelEnvironment(cwd, env); const preludeResult = await kernel.execute(PYTHON_PRELUDE, { silent: true, storeHistory: false }); if (preludeResult.cancelled || preludeResult.status === "error") { throw new Error("Failed to initialize Python kernel prelude"); @@ -389,7 +431,7 @@ export class PythonKernel { } } - private static async startWithSharedGateway( + static async #startWithSharedGateway( gatewayUrl: string, cwd: string, env?: Record, @@ -425,11 +467,11 @@ export class PythonKernel { try { debugStartup("sharedGateway:connectWS:start"); - await kernel.connectWebSocket(); + await kernel.#connectWebSocket(); debugStartup("sharedGateway:connectWS:done"); time("startWithSharedGateway:connectWS"); debugStartup("sharedGateway:initEnv:start"); - await kernel.initializeKernelEnvironment(cwd, env); + await kernel.#initializeKernelEnvironment(cwd, env); debugStartup("sharedGateway:initEnv:done"); time("startWithSharedGateway:initEnv"); debugStartup("sharedGateway:prelude:start"); @@ -450,7 +492,7 @@ export class PythonKernel { } } - private async connectWebSocket(): Promise { + async #connectWebSocket(): Promise { const wsBase = this.gatewayUrl.replace(/^http/, "ws"); let wsUrl = `${wsBase}/api/kernels/${this.kernelId}/channels`; if (this.#authToken) { @@ -488,7 +530,7 @@ export class PythonKernel { } this.#alive = false; this.#ws = null; - this.abortPendingExecutions(error.message); + this.#abortPendingExecutions(error.message); }; ws.onclose = () => { @@ -500,7 +542,7 @@ export class PythonKernel { reject(new Error("WebSocket closed before connection")); return; } - this.abortPendingExecutions("WebSocket closed"); + this.#abortPendingExecutions("WebSocket closed"); }; ws.onmessage = event => { @@ -537,7 +579,7 @@ export class PythonKernel { return promise; } - private async initializeKernelEnvironment(cwd: string, env?: Record): Promise { + async #initializeKernelEnvironment(cwd: string, env?: Record): Promise { const envEntries = Object.entries(env ?? {}).filter(([, value]) => value !== undefined); const envPayload = Object.fromEntries(envEntries); const initScript = [ @@ -554,7 +596,7 @@ export class PythonKernel { } } - private abortPendingExecutions(reason: string): void { + #abortPendingExecutions(reason: string): void { if (this.#pendingExecutions.size === 0) return; for (const cancel of this.#pendingExecutions.values()) { cancel(reason); @@ -695,7 +737,7 @@ export class PythonKernel { } case "execute_result": case "display_data": { - const { text, outputs } = this.renderDisplay(response.content); + const { text, outputs } = renderKernelDisplay(response.content); if (text && options?.onChunk) { await options.onChunk(text); } @@ -736,7 +778,7 @@ export class PythonKernel { "[stdin] Kernel requested input. Interactive stdin is not supported; provide input programmatically.\n", ); } - this.sendMessage({ + this.#sendMessage({ channel: "stdin", header: { msg_id: Snowflake.next(), @@ -756,7 +798,7 @@ export class PythonKernel { }); try { - this.sendMessage(msg); + this.#sendMessage(msg); } catch { cancelled = true; finalize(); @@ -812,7 +854,7 @@ export class PythonKernel { metadata: {}, content: {}, }; - this.sendMessage(msg); + this.#sendMessage(msg); } catch (err: unknown) { logger.warn("Failed to send interrupt request", { error: err instanceof Error ? err.message : String(err) }); } @@ -822,7 +864,7 @@ export class PythonKernel { if (this.#disposed) return; this.#disposed = true; this.#alive = false; - this.abortPendingExecutions("Kernel shutdown"); + this.#abortPendingExecutions("Kernel shutdown"); try { await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}`, { @@ -843,45 +885,7 @@ export class PythonKernel { } } - private renderDisplay(content: Record): { text: string; outputs: KernelDisplayOutput[] } { - const data = content.data as Record | undefined; - if (!data) return { text: "", outputs: [] }; - - const outputs: KernelDisplayOutput[] = []; - - // Handle status events (custom MIME type from prelude helpers) - if (data["application/x-omp-status"] !== undefined) { - const statusData = data["application/x-omp-status"]; - if (statusData && typeof statusData === "object" && "op" in statusData) { - outputs.push({ type: "status", event: statusData as PythonStatusEvent }); - } - // Status events don't produce text output - return { text: "", outputs }; - } - - if (typeof data["image/png"] === "string") { - outputs.push({ type: "image", data: data["image/png"] as string, mimeType: "image/png" }); - } - if (data["application/json"] !== undefined) { - outputs.push({ type: "json", data: data["application/json"] }); - } - - // Check text/markdown before text/plain since Markdown objects provide both - // (text/plain is just the repr) - if (typeof data["text/markdown"] === "string") { - return { text: normalizeDisplayText(String(data["text/markdown"])), outputs }; - } - if (typeof data["text/plain"] === "string") { - return { text: normalizeDisplayText(String(data["text/plain"])), outputs }; - } - if (data["text/html"] !== undefined) { - const markdown = htmlToBasicMarkdown(String(data["text/html"])) || ""; - return { text: markdown ? normalizeDisplayText(markdown) : "", outputs }; - } - return { text: "", outputs }; - } - - private sendMessage(msg: JupyterMessage): void { + #sendMessage(msg: JupyterMessage): void { if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) { throw new Error("WebSocket not connected"); } diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index c6e5657f2..be3958e56 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -735,7 +735,7 @@ export async function sendRequest( client.lastActivity = Date.now(); const { promise, resolve, reject } = Promise.withResolvers(); - let timeout: ReturnType | undefined; + let timeout: NodeJS.Timeout | undefined; const cleanup = () => { if (signal) { signal.removeEventListener("abort", abortHandler); diff --git a/packages/coding-agent/src/lsp/clients/biome-client.ts b/packages/coding-agent/src/lsp/clients/biome-client.ts index 64f0121e1..2ebb99de6 100644 --- a/packages/coding-agent/src/lsp/clients/biome-client.ts +++ b/packages/coding-agent/src/lsp/clients/biome-client.ts @@ -137,13 +137,13 @@ export class BiomeClient implements LinterClient { // Run biome lint with JSON reporter const result = await runBiome(["lint", "--reporter=json", filePath], this.cwd, this.config.resolvedCommand); - return this.parseJsonOutput(result.stdout, filePath); + return this.#parseJsonOutput(result.stdout, filePath); } /** * Parse Biome's JSON output into LSP Diagnostics. */ - private parseJsonOutput(jsonOutput: string, targetFile: string): Diagnostic[] { + #parseJsonOutput(jsonOutput: string, targetFile: string): Diagnostic[] { const diagnostics: Diagnostic[] = []; try { diff --git a/packages/coding-agent/src/lsp/clients/lsp-linter-client.ts b/packages/coding-agent/src/lsp/clients/lsp-linter-client.ts index 13776982e..c3586e208 100644 --- a/packages/coding-agent/src/lsp/clients/lsp-linter-client.ts +++ b/packages/coding-agent/src/lsp/clients/lsp-linter-client.ts @@ -21,7 +21,7 @@ const DEFAULT_FORMAT_OPTIONS = { * Wraps the existing LSP client infrastructure. */ export class LspLinterClient implements LinterClient { - private client: LspClient | null = null; + #client: LspClient | null = null; /** Factory method for creating LspLinterClient instances */ static create(config: ServerConfig, cwd: string): LinterClient { @@ -33,15 +33,15 @@ export class LspLinterClient implements LinterClient { private readonly cwd: string, ) {} - private async getClient(): Promise { - if (!this.client) { - this.client = await getOrCreateClient(this.config, this.cwd); + async #getClient(): Promise { + if (!this.#client) { + this.#client = await getOrCreateClient(this.config, this.cwd); } - return this.client; + return this.#client; } async format(filePath: string, content: string): Promise { - const client = await this.getClient(); + const client = await this.#getClient(); const uri = fileToUri(filePath); // Sync content to LSP @@ -67,7 +67,7 @@ export class LspLinterClient implements LinterClient { } async lint(filePath: string): Promise { - const client = await this.getClient(); + const client = await this.#getClient(); const uri = fileToUri(filePath); // Notify that file was saved to trigger diagnostics diff --git a/packages/coding-agent/src/lsp/index.ts b/packages/coding-agent/src/lsp/index.ts index 9b78b2e02..af92c557e 100644 --- a/packages/coding-agent/src/lsp/index.ts +++ b/packages/coding-agent/src/lsp/index.ts @@ -851,14 +851,14 @@ export function createLspWritethrough(cwd: string, options?: WritethroughOptions * LSP tool for language server protocol operations. */ export class LspTool implements AgentTool { - public readonly name = "lsp"; - public readonly label = "LSP"; - public readonly description: string; - public readonly parameters = lspSchema; - public readonly renderCall = renderCall; - public readonly renderResult = renderResult; - public readonly mergeCallAndResult = true; - public readonly inline = true; + readonly name = "lsp"; + readonly label = "LSP"; + readonly description: string; + readonly parameters = lspSchema; + readonly renderCall = renderCall; + readonly renderResult = renderResult; + readonly mergeCallAndResult = true; + readonly inline = true; constructor(private readonly session: ToolSession) { this.description = renderPromptTemplate(lspDescription); @@ -868,7 +868,7 @@ export class LspTool implements AgentTool(); - private tools: CustomTool[] = []; - private pendingConnections = new Map>(); - private pendingToolLoads = new Map>(); - private sources = new Map(); + #connections = new Map(); + #tools: CustomTool[] = []; + #pendingConnections = new Map>(); + #pendingToolLoads = new Map>(); + #sources = new Map(); constructor( private cwd: string, @@ -129,20 +129,20 @@ export class MCPManager { for (const [name, config] of Object.entries(configs)) { if (sources[name]) { - this.sources.set(name, sources[name]); - const existing = this.connections.get(name); + this.#sources.set(name, sources[name]); + const existing = this.#connections.get(name); if (existing) { existing._source = sources[name]; } } // Skip if already connected - if (this.connections.has(name)) { + if (this.#connections.has(name)) { connectedServers.add(name); continue; } - if (this.pendingConnections.has(name) || this.pendingToolLoads.has(name)) { + if (this.#pendingConnections.has(name) || this.#pendingToolLoads.has(name)) { continue; } @@ -159,41 +159,41 @@ export class MCPManager { if (sources[name]) { connection._source = sources[name]; } - if (this.pendingConnections.get(name) === connectionPromise) { - this.pendingConnections.delete(name); - this.connections.set(name, connection); + if (this.#pendingConnections.get(name) === connectionPromise) { + this.#pendingConnections.delete(name); + this.#connections.set(name, connection); } return connection; }, error => { - if (this.pendingConnections.get(name) === connectionPromise) { - this.pendingConnections.delete(name); + if (this.#pendingConnections.get(name) === connectionPromise) { + this.#pendingConnections.delete(name); } throw error; }, ); - this.pendingConnections.set(name, connectionPromise); + this.#pendingConnections.set(name, connectionPromise); const toolsPromise = connectionPromise.then(async connection => { const serverTools = await listTools(connection); return { connection, serverTools }; }); - this.pendingToolLoads.set(name, toolsPromise); + this.#pendingToolLoads.set(name, toolsPromise); const tracked = trackPromise(toolsPromise); connectionTasks.push({ name, config, tracked, toolsPromise }); void toolsPromise .then(({ connection, serverTools }) => { - if (this.pendingToolLoads.get(name) !== toolsPromise) return; - this.pendingToolLoads.delete(name); + if (this.#pendingToolLoads.get(name) !== toolsPromise) return; + this.#pendingToolLoads.delete(name); const customTools = MCPTool.fromTools(connection, serverTools); - this.replaceServerTools(name, customTools); + this.#replaceServerTools(name, customTools); void this.toolCache?.set(name, config, serverTools); }) .catch(error => { - if (this.pendingToolLoads.get(name) !== toolsPromise) return; - this.pendingToolLoads.delete(name); + if (this.#pendingToolLoads.get(name) !== toolsPromise) return; + this.#pendingToolLoads.delete(name); if (!allowBackgroundLogging || reportedErrors.has(name)) return; const message = error instanceof Error ? error.message : String(error); logger.error("MCP tool load failed", { path: `mcp:${name}`, error: message }); @@ -248,7 +248,7 @@ export class MCPManager { } else { const cached = cachedTools.get(name); if (cached) { - const source = this.sources.get(name); + const source = this.#sources.get(name); allTools.push(...DeferredMCPTool.fromTools(name, cached, () => this.waitForConnection(name), source)); } } @@ -256,7 +256,7 @@ export class MCPManager { } // Update cached tools - this.tools = allTools; + this.#tools = allTools; allowBackgroundLogging = true; return { @@ -267,39 +267,39 @@ export class MCPManager { }; } - private replaceServerTools(name: string, tools: CustomTool[]): void { - this.tools = this.tools.filter(t => !t.name.startsWith(`mcp_${name}_`)); - this.tools.push(...tools); + #replaceServerTools(name: string, tools: CustomTool[]): void { + this.#tools = this.#tools.filter(t => !t.name.startsWith(`mcp_${name}_`)); + this.#tools.push(...tools); } /** * Get all loaded tools. */ getTools(): CustomTool[] { - return this.tools; + return this.#tools; } /** * Get a specific connection. */ getConnection(name: string): MCPServerConnection | undefined { - return this.connections.get(name); + return this.#connections.get(name); } /** * Get the source metadata for a server. */ getSource(name: string): SourceMeta | undefined { - return this.sources.get(name) ?? this.connections.get(name)?._source; + return this.#sources.get(name) ?? this.#connections.get(name)?._source; } /** * Wait for a connection to complete (or fail). */ async waitForConnection(name: string): Promise { - const connection = this.connections.get(name); + const connection = this.#connections.get(name); if (connection) return connection; - const pending = this.pendingConnections.get(name); + const pending = this.#pendingConnections.get(name); if (pending) return pending; throw new Error(`MCP server not connected: ${name}`); } @@ -308,46 +308,46 @@ export class MCPManager { * Get all connected server names. */ getConnectedServers(): string[] { - return Array.from(this.connections.keys()); + return Array.from(this.#connections.keys()); } /** * Disconnect from a specific server. */ async disconnectServer(name: string): Promise { - this.pendingConnections.delete(name); - this.pendingToolLoads.delete(name); - this.sources.delete(name); + this.#pendingConnections.delete(name); + this.#pendingToolLoads.delete(name); + this.#sources.delete(name); - const connection = this.connections.get(name); + const connection = this.#connections.get(name); if (connection) { await disconnectServer(connection); - this.connections.delete(name); + this.#connections.delete(name); } // Remove tools from this server - this.tools = this.tools.filter(t => !t.name.startsWith(`mcp_${name}_`)); + this.#tools = this.#tools.filter(t => !t.name.startsWith(`mcp_${name}_`)); } /** * Disconnect from all servers. */ async disconnectAll(): Promise { - const promises = Array.from(this.connections.values()).map(conn => disconnectServer(conn)); + const promises = Array.from(this.#connections.values()).map(conn => disconnectServer(conn)); await Promise.allSettled(promises); - this.pendingConnections.clear(); - this.pendingToolLoads.clear(); - this.sources.clear(); - this.connections.clear(); - this.tools = []; + this.#pendingConnections.clear(); + this.#pendingToolLoads.clear(); + this.#sources.clear(); + this.#connections.clear(); + this.#tools = []; } /** * Refresh tools from a specific server. */ async refreshServerTools(name: string): Promise { - const connection = this.connections.get(name); + const connection = this.#connections.get(name); if (!connection) return; // Clear cached tools @@ -359,14 +359,14 @@ export class MCPManager { void this.toolCache?.set(name, connection.config, serverTools); // Replace tools from this server - this.replaceServerTools(name, customTools); + this.#replaceServerTools(name, customTools); } /** * Refresh tools from all servers. */ async refreshAllTools(): Promise { - const promises = Array.from(this.connections.keys()).map(name => this.refreshServerTools(name)); + const promises = Array.from(this.#connections.keys()).map(name => this.refreshServerTools(name)); await Promise.allSettled(promises); } } diff --git a/packages/coding-agent/src/mcp/tool-bridge.ts b/packages/coding-agent/src/mcp/tool-bridge.ts index fbee09a54..2746dffa7 100644 --- a/packages/coding-agent/src/mcp/tool-bridge.ts +++ b/packages/coding-agent/src/mcp/tool-bridge.ts @@ -126,14 +126,14 @@ export function parseMCPToolName(name: string): { serverName: string; toolName: * CustomTool wrapping an MCP tool with an active connection. */ export class MCPTool implements CustomTool { - public readonly name: string; - public readonly label: string; - public readonly description: string; - public readonly parameters: TSchema; + readonly name: string; + readonly label: string; + readonly description: string; + readonly parameters: TSchema; /** Original MCP tool name (before normalization) */ - public readonly mcpToolName: string; + readonly mcpToolName: string; /** Server name */ - public readonly mcpServerName: string; + readonly mcpServerName: string; /** Create MCPTool instances for all tools from an MCP server connection */ static fromTools(connection: MCPServerConnection, tools: MCPToolDefinition[]): MCPTool[] { @@ -211,14 +211,14 @@ export class MCPTool implements CustomTool { * CustomTool wrapping an MCP tool with deferred connection resolution. */ export class DeferredMCPTool implements CustomTool { - public readonly name: string; - public readonly label: string; - public readonly description: string; - public readonly parameters: TSchema; + readonly name: string; + readonly label: string; + readonly description: string; + readonly parameters: TSchema; /** Original MCP tool name (before normalization) */ - public readonly mcpToolName: string; + readonly mcpToolName: string; /** Server name */ - public readonly mcpServerName: string; + readonly mcpServerName: string; private readonly fallbackProvider: string | undefined; private readonly fallbackProviderName: string | undefined; diff --git a/packages/coding-agent/src/mcp/transports/http.ts b/packages/coding-agent/src/mcp/transports/http.ts index bafc01799..5ccb6a03d 100644 --- a/packages/coding-agent/src/mcp/transports/http.ts +++ b/packages/coding-agent/src/mcp/transports/http.ts @@ -23,9 +23,9 @@ function generateId(): string { * Uses POST for requests, supports SSE responses. */ export class HttpTransport implements MCPTransport { - private _connected = false; - private sessionId: string | null = null; - private sseConnection: AbortController | null = null; + #connected = false; + #sessionId: string | null = null; + #sseConnection: AbortController | null = null; onClose?: () => void; onError?: (error: Error) => void; @@ -34,7 +34,7 @@ export class HttpTransport implements MCPTransport { constructor(private config: MCPHttpServerConfig | MCPSseServerConfig) {} get connected(): boolean { - return this._connected; + return this.#connected; } get url(): string { @@ -46,8 +46,8 @@ export class HttpTransport implements MCPTransport { * HTTP doesn't need persistent connection, but we track state. */ async connect(): Promise { - if (this._connected) return; - this._connected = true; + if (this.#connected) return; + this.#connected = true; } /** @@ -55,40 +55,40 @@ export class HttpTransport implements MCPTransport { * Optional - only needed if server sends notifications. */ async startSSEListener(): Promise { - if (!this._connected) return; - if (this.sseConnection) return; + if (!this.#connected) return; + if (this.#sseConnection) return; - this.sseConnection = new AbortController(); + this.#sseConnection = new AbortController(); const headers: Record = { Accept: "text/event-stream", ...this.config.headers, }; - if (this.sessionId) { - headers["Mcp-Session-Id"] = this.sessionId; + if (this.#sessionId) { + headers["Mcp-Session-Id"] = this.#sessionId; } try { const response = await fetch(this.config.url, { method: "GET", headers, - signal: this.sseConnection.signal, + signal: this.#sseConnection.signal, }); if (response.status === 405) { // Server doesn't support SSE listening, that's OK - this.sseConnection = null; + this.#sseConnection = null; return; } if (!response.ok || !response.body) { - this.sseConnection = null; + this.#sseConnection = null; return; } // Read SSE stream - for await (const message of readSseJson(response.body, this.sseConnection.signal)) { - if (!this._connected) break; + for await (const message of readSseJson(response.body, this.#sseConnection.signal)) { + if (!this.#connected) break; if ("method" in message && !("id" in message)) { this.onNotification?.(message.method, message.params); } @@ -98,12 +98,12 @@ export class HttpTransport implements MCPTransport { this.onError?.(error); } } finally { - this.sseConnection = null; + this.#sseConnection = null; } } async request(method: string, params?: Record): Promise { - if (!this._connected) { + if (!this.#connected) { throw new Error("Transport not connected"); } @@ -121,8 +121,8 @@ export class HttpTransport implements MCPTransport { ...this.config.headers, }; - if (this.sessionId) { - headers["Mcp-Session-Id"] = this.sessionId; + if (this.#sessionId) { + headers["Mcp-Session-Id"] = this.#sessionId; } const response = await fetch(this.config.url, { @@ -134,7 +134,7 @@ export class HttpTransport implements MCPTransport { // Check for session ID in response const newSessionId = response.headers.get("Mcp-Session-Id"); if (newSessionId) { - this.sessionId = newSessionId; + this.#sessionId = newSessionId; } if (!response.ok) { @@ -146,7 +146,7 @@ export class HttpTransport implements MCPTransport { // Handle SSE response if (contentType.includes("text/event-stream")) { - return this.parseSSEResponse(response, id); + return this.#parseSSEResponse(response, id); } // Handle JSON response @@ -159,7 +159,7 @@ export class HttpTransport implements MCPTransport { return result.result as T; } - private async parseSSEResponse(response: Response, expectedId: string | number): Promise { + async #parseSSEResponse(response: Response, expectedId: string | number): Promise { if (!response.body) { throw new Error("No response body"); } @@ -191,7 +191,7 @@ export class HttpTransport implements MCPTransport { } async notify(method: string, params?: Record): Promise { - if (!this._connected) { + if (!this.#connected) { throw new Error("Transport not connected"); } @@ -207,8 +207,8 @@ export class HttpTransport implements MCPTransport { ...this.config.headers, }; - if (this.sessionId) { - headers["Mcp-Session-Id"] = this.sessionId; + if (this.#sessionId) { + headers["Mcp-Session-Id"] = this.#sessionId; } const response = await fetch(this.config.url, { @@ -225,21 +225,21 @@ export class HttpTransport implements MCPTransport { } async close(): Promise { - if (!this._connected) return; - this._connected = false; + if (!this.#connected) return; + this.#connected = false; // Abort SSE listener - if (this.sseConnection) { - this.sseConnection.abort(); - this.sseConnection = null; + if (this.#sseConnection) { + this.#sseConnection.abort(); + this.#sseConnection = null; } // Send session termination if we have a session - if (this.sessionId) { + if (this.#sessionId) { try { const headers: Record = { ...this.config.headers, - "Mcp-Session-Id": this.sessionId, + "Mcp-Session-Id": this.#sessionId, }; await fetch(this.config.url, { @@ -249,7 +249,7 @@ export class HttpTransport implements MCPTransport { } catch { // Ignore termination errors } - this.sessionId = null; + this.#sessionId = null; } this.onClose?.(); diff --git a/packages/coding-agent/src/mcp/transports/stdio.ts b/packages/coding-agent/src/mcp/transports/stdio.ts index 1cfe9fedb..e856590fe 100644 --- a/packages/coding-agent/src/mcp/transports/stdio.ts +++ b/packages/coding-agent/src/mcp/transports/stdio.ts @@ -19,16 +19,16 @@ function generateId(): string { * Spawns a subprocess and communicates via stdin/stdout. */ export class StdioTransport implements MCPTransport { - private process: Subprocess<"pipe", "pipe", "pipe"> | null = null; - private pendingRequests = new Map< + #process: Subprocess<"pipe", "pipe", "pipe"> | null = null; + #pendingRequests = new Map< string | number, { resolve: (value: unknown) => void; reject: (error: Error) => void; } >(); - private _connected = false; - private readLoop: Promise | null = null; + #connected = false; + #readLoop: Promise | null = null; onClose?: () => void; onError?: (error: Error) => void; @@ -37,14 +37,14 @@ export class StdioTransport implements MCPTransport { constructor(private config: MCPStdioServerConfig) {} get connected(): boolean { - return this._connected; + return this.#connected; } /** * Start the subprocess and begin reading. */ async connect(): Promise { - if (this._connected) return; + if (this.#connected) return; const args = this.config.args ?? []; const env = { @@ -52,7 +52,7 @@ export class StdioTransport implements MCPTransport { ...this.config.env, }; - this.process = spawn({ + this.#process = spawn({ cmd: [this.config.command, ...args], cwd: this.config.cwd ?? process.cwd(), env, @@ -61,43 +61,43 @@ export class StdioTransport implements MCPTransport { stderr: "pipe", }); - this._connected = true; + this.#connected = true; // Start reading stdout - this.readLoop = this.startReadLoop(); + this.#readLoop = this.#startReadLoop(); // Log stderr for debugging - this.startStderrLoop(); + this.#startStderrLoop(); } - private async startReadLoop(): Promise { - if (!this.process?.stdout) return; + async #startReadLoop(): Promise { + if (!this.#process?.stdout) return; try { - for await (const line of readJsonl(this.process.stdout)) { - if (!this._connected) break; + for await (const line of readJsonl(this.#process.stdout)) { + if (!this.#connected) break; try { - this.handleMessage(line as JsonRpcResponse); + this.#handleMessage(line as JsonRpcResponse); } catch { // Skip malformed lines } } } catch (error) { - if (this._connected) { + if (this.#connected) { this.onError?.(error instanceof Error ? error : new Error(String(error))); } } finally { - this.handleClose(); + this.#handleClose(); } } - private async startStderrLoop(): Promise { - if (!this.process?.stderr) return; + async #startStderrLoop(): Promise { + if (!this.#process?.stderr) return; - const reader = this.process.stderr.getReader(); + const reader = this.#process.stderr.getReader(); const decoder = new TextDecoder(); try { - while (this._connected) { + while (this.#connected) { const { done, value } = await reader.read(); if (done) break; // Log stderr but don't treat as error - servers use it for logging @@ -114,12 +114,12 @@ export class StdioTransport implements MCPTransport { } } - private handleMessage(message: JsonRpcResponse): void { + #handleMessage(message: JsonRpcResponse): void { // Check if it's a response (has id) if ("id" in message && message.id !== null) { - const pending = this.pendingRequests.get(message.id); + const pending = this.#pendingRequests.get(message.id); if (pending) { - this.pendingRequests.delete(message.id); + this.#pendingRequests.delete(message.id); if (message.error) { pending.reject(new Error(`MCP error ${message.error.code}: ${message.error.message}`)); } else { @@ -133,21 +133,21 @@ export class StdioTransport implements MCPTransport { } } - private handleClose(): void { - if (!this._connected) return; - this._connected = false; + #handleClose(): void { + if (!this.#connected) return; + this.#connected = false; // Reject all pending requests - for (const [, pending] of this.pendingRequests) { + for (const [, pending] of this.#pendingRequests) { pending.reject(new Error("Transport closed")); } - this.pendingRequests.clear(); + this.#pendingRequests.clear(); this.onClose?.(); } async request(method: string, params?: Record): Promise { - if (!this._connected || !this.process?.stdin) { + if (!this.#connected || !this.#process?.stdin) { throw new Error("Transport not connected"); } @@ -160,7 +160,7 @@ export class StdioTransport implements MCPTransport { }; return new Promise((resolve, reject) => { - this.pendingRequests.set(id, { + this.#pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, reject, }); @@ -168,17 +168,17 @@ export class StdioTransport implements MCPTransport { const message = `${JSON.stringify(request)}\n`; try { // Bun's FileSink has write() method directly - this.process!.stdin.write(message); - this.process!.stdin.flush(); + this.#process!.stdin.write(message); + this.#process!.stdin.flush(); } catch (error: unknown) { - this.pendingRequests.delete(id); + this.#pendingRequests.delete(id); reject(error); } }); } async notify(method: string, params?: Record): Promise { - if (!this._connected || !this.process?.stdin) { + if (!this.#connected || !this.#process?.stdin) { throw new Error("Transport not connected"); } @@ -190,30 +190,30 @@ export class StdioTransport implements MCPTransport { const message = `${JSON.stringify(notification)}\n`; // Bun's FileSink has write() method directly - this.process.stdin.write(message); - this.process.stdin.flush(); + this.#process.stdin.write(message); + this.#process.stdin.flush(); } async close(): Promise { - if (!this._connected) return; - this._connected = false; + if (!this.#connected) return; + this.#connected = false; // Reject pending requests - for (const [, pending] of this.pendingRequests) { + for (const [, pending] of this.#pendingRequests) { pending.reject(new Error("Transport closed")); } - this.pendingRequests.clear(); + this.#pendingRequests.clear(); // Kill subprocess - if (this.process) { - this.process.kill(); - this.process = null; + if (this.#process) { + this.#process.kill(); + this.#process = null; } // Wait for read loop to finish - if (this.readLoop) { - await this.readLoop.catch(() => {}); - this.readLoop = null; + if (this.#readLoop) { + await this.#readLoop.catch(() => {}); + this.#readLoop = null; } this.onClose?.(); diff --git a/packages/coding-agent/src/modes/components/armin.ts b/packages/coding-agent/src/modes/components/armin.ts deleted file mode 100644 index a1ecb3c06..000000000 --- a/packages/coding-agent/src/modes/components/armin.ts +++ /dev/null @@ -1,379 +0,0 @@ -/** - * Armin says hi! A fun easter egg with animated XBM art. - */ -import { type Component, padding, type TUI } from "@oh-my-pi/pi-tui"; -import { theme } from "../../modes/theme/theme"; - -// XBM image: 31x36 pixels, LSB first, 1=background, 0=foreground -const WIDTH = 31; -const HEIGHT = 36; -const BITS = [ - 0xff, 0xff, 0xff, 0x7f, 0xff, 0xf0, 0xff, 0x7f, 0xff, 0xed, 0xff, 0x7f, 0xff, 0xdb, 0xff, 0x7f, 0xff, 0xb7, 0xff, - 0x7f, 0xff, 0x77, 0xfe, 0x7f, 0x3f, 0xf8, 0xfe, 0x7f, 0xdf, 0xff, 0xfe, 0x7f, 0xdf, 0x3f, 0xfc, 0x7f, 0x9f, 0xc3, - 0xfb, 0x7f, 0x6f, 0xfc, 0xf4, 0x7f, 0xf7, 0x0f, 0xf7, 0x7f, 0xf7, 0xff, 0xf7, 0x7f, 0xf7, 0xff, 0xe3, 0x7f, 0xf7, - 0x07, 0xe8, 0x7f, 0xef, 0xf8, 0x67, 0x70, 0x0f, 0xff, 0xbb, 0x6f, 0xf1, 0x00, 0xd0, 0x5b, 0xfd, 0x3f, 0xec, 0x53, - 0xc1, 0xff, 0xef, 0x57, 0x9f, 0xfd, 0xee, 0x5f, 0x9f, 0xfc, 0xae, 0x5f, 0x1f, 0x78, 0xac, 0x5f, 0x3f, 0x00, 0x50, - 0x6c, 0x7f, 0x00, 0xdc, 0x77, 0xff, 0xc0, 0x3f, 0x78, 0xff, 0x01, 0xf8, 0x7f, 0xff, 0x03, 0x9c, 0x78, 0xff, 0x07, - 0x8c, 0x7c, 0xff, 0x0f, 0xce, 0x78, 0xff, 0xff, 0xcf, 0x7f, 0xff, 0xff, 0xcf, 0x78, 0xff, 0xff, 0xdf, 0x78, 0xff, - 0xff, 0xdf, 0x7d, 0xff, 0xff, 0x3f, 0x7e, 0xff, 0xff, 0xff, 0x7f, -]; - -const BYTES_PER_ROW = Math.ceil(WIDTH / 8); -const DISPLAY_HEIGHT = Math.ceil(HEIGHT / 2); // Half-block rendering - -type Effect = "typewriter" | "scanline" | "rain" | "fade" | "crt" | "glitch" | "dissolve"; - -const EFFECTS: Effect[] = ["typewriter", "scanline", "rain", "fade", "crt", "glitch", "dissolve"]; - -// Get pixel at (x, y): true = foreground, false = background -function getPixel(x: number, y: number): boolean { - if (y >= HEIGHT) return false; - const byteIndex = y * BYTES_PER_ROW + Math.floor(x / 8); - const bitIndex = x % 8; - return ((BITS[byteIndex] >> bitIndex) & 1) === 0; -} - -// Get the character for a cell (2 vertical pixels packed) -function getChar(x: number, row: number): string { - const upper = getPixel(x, row * 2); - const lower = getPixel(x, row * 2 + 1); - if (upper && lower) return "█"; - if (upper) return "▀"; - if (lower) return "▄"; - return " "; -} - -// Build the final image grid -function buildFinalGrid(): string[][] { - const grid: string[][] = []; - for (let row = 0; row < DISPLAY_HEIGHT; row++) { - const line: string[] = []; - for (let x = 0; x < WIDTH; x++) { - line.push(getChar(x, row)); - } - grid.push(line); - } - return grid; -} - -export class ArminComponent implements Component { - private interval: ReturnType | null = null; - private effect: Effect; - private finalGrid: string[][]; - private currentGrid: string[][]; - private effectState: Record = {}; - private cachedLines: string[] = []; - private cachedWidth = 0; - private gridVersion = 0; - private cachedVersion = -1; - - constructor(private readonly ui: TUI) { - this.effect = EFFECTS[Math.floor(Math.random() * EFFECTS.length)]; - this.finalGrid = buildFinalGrid(); - this.currentGrid = this.createEmptyGrid(); - - this.initEffect(); - this.startAnimation(); - } - - invalidate(): void { - this.cachedWidth = 0; - } - - render(width: number): string[] { - if (width === this.cachedWidth && this.cachedVersion === this.gridVersion) { - return this.cachedLines; - } - - const indent = 1; - const availableWidth = width - indent; - - this.cachedLines = this.currentGrid.map(row => { - // Clip row to available width before applying color - const clipped = row.slice(0, availableWidth).join(""); - const padRight = Math.max(0, width - indent - clipped.length); - return ` ${theme.fg("accent", clipped)}${padding(padRight)}`; - }); - - // Add "ARMIN SAYS HI" at the end - const message = "ARMIN SAYS HI"; - const msgPadRight = Math.max(0, width - indent - message.length); - this.cachedLines.push(` ${theme.fg("accent", message)}${padding(msgPadRight)}`); - - this.cachedWidth = width; - this.cachedVersion = this.gridVersion; - - return this.cachedLines; - } - - private createEmptyGrid(): string[][] { - return Array.from({ length: DISPLAY_HEIGHT }, () => Array(WIDTH).fill(" ")); - } - - private initEffect(): void { - switch (this.effect) { - case "typewriter": - this.effectState = { pos: 0 }; - break; - case "scanline": - this.effectState = { row: 0 }; - break; - case "rain": - // Track falling position for each column - this.effectState = { - drops: Array.from({ length: WIDTH }, () => ({ - y: -Math.floor(Math.random() * DISPLAY_HEIGHT * 2), - settled: 0, - })), - }; - break; - case "fade": { - // Shuffle all pixel positions - const positions: [number, number][] = []; - for (let row = 0; row < DISPLAY_HEIGHT; row++) { - for (let x = 0; x < WIDTH; x++) { - positions.push([row, x]); - } - } - // Fisher-Yates shuffle - for (let i = positions.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [positions[i], positions[j]] = [positions[j], positions[i]]; - } - this.effectState = { positions, idx: 0 }; - break; - } - case "crt": - this.effectState = { expansion: 0 }; - break; - case "glitch": - this.effectState = { phase: 0, glitchFrames: 8 }; - break; - case "dissolve": { - // Start with random noise - this.currentGrid = Array.from({ length: DISPLAY_HEIGHT }, () => - Array.from({ length: WIDTH }, () => { - const chars = [" ", "░", "▒", "▓", "█", "▀", "▄"]; - return chars[Math.floor(Math.random() * chars.length)]; - }), - ); - // Shuffle positions for gradual resolve - const dissolvePositions: [number, number][] = []; - for (let row = 0; row < DISPLAY_HEIGHT; row++) { - for (let x = 0; x < WIDTH; x++) { - dissolvePositions.push([row, x]); - } - } - for (let i = dissolvePositions.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [dissolvePositions[i], dissolvePositions[j]] = [dissolvePositions[j], dissolvePositions[i]]; - } - this.effectState = { positions: dissolvePositions, idx: 0 }; - break; - } - } - } - - private startAnimation(): void { - const fps = this.effect === "glitch" ? 60 : 30; - this.interval = setInterval(() => { - const done = this.tickEffect(); - this.updateDisplay(); - this.ui.requestRender(); - if (done) { - this.stopAnimation(); - } - }, 1000 / fps); - } - - private stopAnimation(): void { - if (this.interval) { - clearInterval(this.interval); - this.interval = null; - } - } - - private tickEffect(): boolean { - switch (this.effect) { - case "typewriter": - return this.tickTypewriter(); - case "scanline": - return this.tickScanline(); - case "rain": - return this.tickRain(); - case "fade": - return this.tickFade(); - case "crt": - return this.tickCrt(); - case "glitch": - return this.tickGlitch(); - case "dissolve": - return this.tickDissolve(); - default: - return true; - } - } - - private tickTypewriter(): boolean { - const state = this.effectState as { pos: number }; - const pixelsPerFrame = 3; - - for (let i = 0; i < pixelsPerFrame; i++) { - const row = Math.floor(state.pos / WIDTH); - const x = state.pos % WIDTH; - if (row >= DISPLAY_HEIGHT) return true; - this.currentGrid[row][x] = this.finalGrid[row][x]; - state.pos++; - } - return false; - } - - private tickScanline(): boolean { - const state = this.effectState as { row: number }; - if (state.row >= DISPLAY_HEIGHT) return true; - - // Copy row - for (let x = 0; x < WIDTH; x++) { - this.currentGrid[state.row][x] = this.finalGrid[state.row][x]; - } - state.row++; - return false; - } - - private tickRain(): boolean { - const state = this.effectState as { - drops: { y: number; settled: number }[]; - }; - - let allSettled = true; - this.currentGrid = this.createEmptyGrid(); - - for (let x = 0; x < WIDTH; x++) { - const drop = state.drops[x]; - - // Draw settled pixels - for (let row = DISPLAY_HEIGHT - 1; row >= DISPLAY_HEIGHT - drop.settled; row--) { - if (row >= 0) { - this.currentGrid[row][x] = this.finalGrid[row][x]; - } - } - - // Check if this column is done - if (drop.settled >= DISPLAY_HEIGHT) continue; - - allSettled = false; - - // Find the target row for this column (lowest non-space pixel) - let targetRow = -1; - for (let row = DISPLAY_HEIGHT - 1 - drop.settled; row >= 0; row--) { - if (this.finalGrid[row][x] !== " ") { - targetRow = row; - break; - } - } - - // Move drop down - drop.y++; - - // Draw falling drop - if (drop.y >= 0 && drop.y < DISPLAY_HEIGHT) { - if (targetRow >= 0 && drop.y >= targetRow) { - // Settle - drop.settled = DISPLAY_HEIGHT - targetRow; - drop.y = -Math.floor(Math.random() * 5) - 1; - } else { - // Still falling - this.currentGrid[drop.y][x] = "▓"; - } - } - } - - return allSettled; - } - - private tickFade(): boolean { - const state = this.effectState as { positions: [number, number][]; idx: number }; - const pixelsPerFrame = 15; - - for (let i = 0; i < pixelsPerFrame; i++) { - if (state.idx >= state.positions.length) return true; - const [row, x] = state.positions[state.idx]; - this.currentGrid[row][x] = this.finalGrid[row][x]; - state.idx++; - } - return false; - } - - private tickCrt(): boolean { - const state = this.effectState as { expansion: number }; - const midRow = Math.floor(DISPLAY_HEIGHT / 2); - - this.currentGrid = this.createEmptyGrid(); - - // Draw from middle expanding outward - const top = midRow - state.expansion; - const bottom = midRow + state.expansion; - - for (let row = Math.max(0, top); row <= Math.min(DISPLAY_HEIGHT - 1, bottom); row++) { - for (let x = 0; x < WIDTH; x++) { - this.currentGrid[row][x] = this.finalGrid[row][x]; - } - } - - state.expansion++; - return state.expansion > DISPLAY_HEIGHT; - } - - private tickGlitch(): boolean { - const state = this.effectState as { phase: number; glitchFrames: number }; - - if (state.phase < state.glitchFrames) { - // Glitch phase: show corrupted version - this.currentGrid = this.finalGrid.map(row => { - const offset = Math.floor(Math.random() * 7) - 3; - const glitchRow = [...row]; - - // Random horizontal offset - if (Math.random() < 0.3) { - const shifted = glitchRow.slice(offset).concat(glitchRow.slice(0, offset)); - return shifted.slice(0, WIDTH); - } - - // Random vertical swap - if (Math.random() < 0.2) { - const swapRow = Math.floor(Math.random() * DISPLAY_HEIGHT); - return [...this.finalGrid[swapRow]]; - } - - return glitchRow; - }); - state.phase++; - return false; - } - - // Final frame: show clean image - this.currentGrid = this.finalGrid.map(row => [...row]); - return true; - } - - private tickDissolve(): boolean { - const state = this.effectState as { positions: [number, number][]; idx: number }; - const pixelsPerFrame = 20; - - for (let i = 0; i < pixelsPerFrame; i++) { - if (state.idx >= state.positions.length) return true; - const [row, x] = state.positions[state.idx]; - this.currentGrid[row][x] = this.finalGrid[row][x]; - state.idx++; - } - return false; - } - - private updateDisplay(): void { - this.gridVersion++; - } - - dispose(): void { - this.stopAnimation(); - } -} diff --git a/packages/coding-agent/src/modes/components/assistant-message.ts b/packages/coding-agent/src/modes/components/assistant-message.ts index f6a58b4e8..bce1581c0 100644 --- a/packages/coding-agent/src/modes/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/components/assistant-message.ts @@ -7,9 +7,9 @@ import { getMarkdownTheme, theme } from "../../modes/theme/theme"; * Component that renders a complete assistant message */ export class AssistantMessageComponent extends Container { - private contentContainer: Container; - private lastMessage?: AssistantMessage; - private prerenderInFlight = false; + #contentContainer: Container; + #lastMessage?: AssistantMessage; + #prerenderInFlight = false; constructor( message?: AssistantMessage, @@ -18,8 +18,8 @@ export class AssistantMessageComponent extends Container { super(); // Container for text/thinking content - this.contentContainer = new Container(); - this.addChild(this.contentContainer); + this.#contentContainer = new Container(); + this.addChild(this.#contentContainer); if (message) { this.updateContent(message); @@ -28,8 +28,8 @@ export class AssistantMessageComponent extends Container { override invalidate(): void { super.invalidate(); - if (this.lastMessage) { - this.updateContent(this.lastMessage); + if (this.#lastMessage) { + this.updateContent(this.#lastMessage); } } @@ -37,14 +37,14 @@ export class AssistantMessageComponent extends Container { this.hideThinkingBlock = hide; } - private triggerMermaidPrerender(message: AssistantMessage): void { - if (!TERMINAL.imageProtocol || this.prerenderInFlight) return; + #triggerMermaidPrerender(message: AssistantMessage): void { + if (!TERMINAL.imageProtocol || this.#prerenderInFlight) return; // Check if any text content has pending mermaid blocks const hasPending = message.content.some(c => c.type === "text" && c.text.trim() && hasPendingMermaid(c.text)); if (!hasPending) return; - this.prerenderInFlight = true; + this.#prerenderInFlight = true; // Fire off background prerender (async () => { @@ -53,27 +53,27 @@ export class AssistantMessageComponent extends Container { await prerenderMermaid(content.text); } } - this.prerenderInFlight = false; + this.#prerenderInFlight = false; // Invalidate to re-render with cached images this.invalidate(); })(); } updateContent(message: AssistantMessage): void { - this.lastMessage = message; + this.#lastMessage = message; // Clear content container - this.contentContainer.clear(); + this.#contentContainer.clear(); // Trigger background mermaid pre-rendering if needed - this.triggerMermaidPrerender(message); + this.#triggerMermaidPrerender(message); const hasVisibleContent = message.content.some( c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()), ); if (hasVisibleContent) { - this.contentContainer.addChild(new Spacer(1)); + this.#contentContainer.addChild(new Spacer(1)); } // Render content in order @@ -82,7 +82,7 @@ export class AssistantMessageComponent extends Container { if (content.type === "text" && content.text.trim()) { // Assistant text messages with no background - trim the text // Set paddingY=0 to avoid extra spacing before tool executions - this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme())); + this.#contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme())); } else if (content.type === "thinking" && content.thinking.trim()) { // Add spacing only when another visible assistant content block follows. // This avoids a superfluous blank line before separately-rendered tool execution blocks. @@ -92,20 +92,20 @@ export class AssistantMessageComponent extends Container { if (this.hideThinkingBlock) { // Show static "Thinking..." label when hidden - this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0)); + this.#contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0)); if (hasVisibleContentAfter) { - this.contentContainer.addChild(new Spacer(1)); + this.#contentContainer.addChild(new Spacer(1)); } } else { // Thinking traces in thinkingText color, italic - this.contentContainer.addChild( + this.#contentContainer.addChild( new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), { color: (text: string) => theme.fg("thinkingText", text), italic: true, }), ); if (hasVisibleContentAfter) { - this.contentContainer.addChild(new Spacer(1)); + this.#contentContainer.addChild(new Spacer(1)); } } } @@ -121,15 +121,15 @@ export class AssistantMessageComponent extends Container { ? message.errorMessage : "Operation aborted"; if (hasVisibleContent) { - this.contentContainer.addChild(new Spacer(1)); + this.#contentContainer.addChild(new Spacer(1)); } else { - this.contentContainer.addChild(new Spacer(1)); + this.#contentContainer.addChild(new Spacer(1)); } - this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0)); } else if (message.stopReason === "error") { const errorMsg = message.errorMessage || "Unknown error"; - this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0)); + this.#contentContainer.addChild(new Spacer(1)); + this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0)); } } } diff --git a/packages/coding-agent/src/modes/components/bash-execution.ts b/packages/coding-agent/src/modes/components/bash-execution.ts index c73baa379..db31224fd 100644 --- a/packages/coding-agent/src/modes/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/components/bash-execution.ts @@ -12,13 +12,13 @@ import { truncateToVisualLines } from "./visual-truncate"; const PREVIEW_LINES = 20; export class BashExecutionComponent extends Container { - private outputLines: string[] = []; - private status: "running" | "complete" | "cancelled" | "error" = "running"; - private exitCode: number | undefined = undefined; - private loader: Loader; - private truncation?: TruncationMeta; - private expanded = false; - private contentContainer: Container; + #outputLines: string[] = []; + #status: "running" | "complete" | "cancelled" | "error" = "running"; + #exitCode: number | undefined = undefined; + #loader: Loader; + #truncation?: TruncationMeta; + #expanded = false; + #contentContainer: Container; constructor( private readonly command: string, @@ -38,22 +38,22 @@ export class BashExecutionComponent extends Container { this.addChild(new DynamicBorder(borderColor)); // Content container (holds dynamic content between borders) - this.contentContainer = new Container(); - this.addChild(this.contentContainer); + this.#contentContainer = new Container(); + this.addChild(this.#contentContainer); // Command header const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0); - this.contentContainer.addChild(header); + this.#contentContainer.addChild(header); // Loader - this.loader = new Loader( + this.#loader = new Loader( ui, spinner => theme.fg(colorKey, spinner), text => theme.fg("muted", text), `Running… (esc to cancel)`, getSymbolTheme().spinnerFrames, ); - this.contentContainer.addChild(this.loader); + this.#contentContainer.addChild(this.#loader); // Bottom border this.addChild(new DynamicBorder(borderColor)); @@ -63,29 +63,29 @@ export class BashExecutionComponent extends Container { * Set whether the output is expanded (shows full output) or collapsed (preview only). */ setExpanded(expanded: boolean): void { - this.expanded = expanded; - this.updateDisplay(); + this.#expanded = expanded; + this.#updateDisplay(); } override invalidate(): void { super.invalidate(); - this.updateDisplay(); + this.#updateDisplay(); } appendOutput(chunk: string): void { - const clean = this.normalizeOutput(chunk); + const clean = this.#normalizeOutput(chunk); // Append to output lines const newLines = clean.split("\n"); - if (this.outputLines.length > 0 && newLines.length > 0) { + if (this.#outputLines.length > 0 && newLines.length > 0) { // Append first chunk to last line (incomplete line continuation) - this.outputLines[this.outputLines.length - 1] += newLines[0]; - this.outputLines.push(...newLines.slice(1)); + this.#outputLines[this.#outputLines.length - 1] += newLines[0]; + this.#outputLines.push(...newLines.slice(1)); } else { - this.outputLines.push(...newLines); + this.#outputLines.push(...newLines); } - this.updateDisplay(); + this.#updateDisplay(); } setComplete( @@ -93,48 +93,48 @@ export class BashExecutionComponent extends Container { cancelled: boolean, options?: { output?: string; truncation?: TruncationMeta }, ): void { - this.exitCode = exitCode; - this.status = cancelled + this.#exitCode = exitCode; + this.#status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== undefined && exitCode !== null ? "error" : "complete"; - this.truncation = options?.truncation; + this.#truncation = options?.truncation; if (options?.output !== undefined) { - this.setOutput(options.output); + this.#setOutput(options.output); } // Stop loader - this.loader.stop(); + this.#loader.stop(); - this.updateDisplay(); + this.#updateDisplay(); } - private updateDisplay(): void { - const availableLines = this.outputLines; + #updateDisplay(): void { + const availableLines = this.#outputLines; // Apply preview truncation based on expanded state const previewLogicalLines = availableLines.slice(-PREVIEW_LINES); const hiddenLineCount = availableLines.length - previewLogicalLines.length; // Rebuild content container - this.contentContainer.clear(); + this.#contentContainer.clear(); // Command header const header = new Text(theme.fg("bashMode", theme.bold(`$ ${this.command}`)), 1, 0); - this.contentContainer.addChild(header); + this.#contentContainer.addChild(header); // Output if (availableLines.length > 0) { - if (this.expanded) { + if (this.#expanded) { // Show all lines const displayText = availableLines.map(line => theme.fg("muted", line)).join("\n"); - this.contentContainer.addChild(new Text(`\n${displayText}`, 1, 0)); + this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0)); } else { // Use shared visual truncation utility, recomputed per render width const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n"); const previewText = `\n${styledOutput}`; - this.contentContainer.addChild({ + this.#contentContainer.addChild({ render: (width: number) => { const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1); return visualLines; @@ -145,8 +145,8 @@ export class BashExecutionComponent extends Container { } // Loader or status - if (this.status === "running") { - this.contentContainer.addChild(this.loader); + if (this.#status === "running") { + this.#contentContainer.addChild(this.#loader); } else { const statusParts: string[] = []; @@ -155,51 +155,51 @@ export class BashExecutionComponent extends Container { statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`)); } - if (this.status === "cancelled") { + if (this.#status === "cancelled") { statusParts.push(theme.fg("warning", "(cancelled)")); - } else if (this.status === "error") { - statusParts.push(theme.fg("error", `(exit ${this.exitCode})`)); + } else if (this.#status === "error") { + statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`)); } - if (this.truncation) { + if (this.#truncation) { const warnings: string[] = []; - if (this.truncation.artifactId) { - warnings.push(`Full output: artifact://${this.truncation.artifactId}`); + if (this.#truncation.artifactId) { + warnings.push(`Full output: artifact://${this.#truncation.artifactId}`); } - if (this.truncation.truncatedBy === "lines") { + if (this.#truncation.truncatedBy === "lines") { warnings.push( - `Truncated: showing ${this.truncation.outputLines} of ${this.truncation.totalLines} lines`, + `Truncated: showing ${this.#truncation.outputLines} of ${this.#truncation.totalLines} lines`, ); } else { warnings.push( - `Truncated: ${this.truncation.outputLines} lines shown (${formatSize(this.truncation.outputBytes)} limit)`, + `Truncated: ${this.#truncation.outputLines} lines shown (${formatSize(this.#truncation.outputBytes)} limit)`, ); } statusParts.push(theme.fg("warning", warnings.join(". "))); } if (statusParts.length > 0) { - this.contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0)); + this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0)); } } } - private normalizeOutput(text: string): string { + #normalizeOutput(text: string): string { // Strip ANSI codes and normalize line endings // Note: binary data is already sanitized in tui-renderer.ts executeBashCommand return Bun.stripANSI(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } - private setOutput(output: string): void { - const clean = this.normalizeOutput(output); - this.outputLines = clean ? clean.split("\n") : []; + #setOutput(output: string): void { + const clean = this.#normalizeOutput(output); + this.#outputLines = clean ? clean.split("\n") : []; } /** * Get the raw output for creating BashExecutionMessage. */ getOutput(): string { - return this.outputLines.join("\n"); + return this.#outputLines.join("\n"); } /** diff --git a/packages/coding-agent/src/modes/components/bordered-loader.ts b/packages/coding-agent/src/modes/components/bordered-loader.ts index a164bee5d..a3aaa81bf 100644 --- a/packages/coding-agent/src/modes/components/bordered-loader.ts +++ b/packages/coding-agent/src/modes/components/bordered-loader.ts @@ -4,19 +4,19 @@ import { DynamicBorder } from "./dynamic-border"; /** Loader wrapped with borders for hook UI */ export class BorderedLoader extends Container { - private loader: CancellableLoader; + #loader: CancellableLoader; constructor(tui: TUI, theme: Theme, message: string) { super(); const borderColor = (s: string) => theme.fg("border", s); this.addChild(new DynamicBorder(borderColor)); - this.loader = new CancellableLoader( + this.#loader = new CancellableLoader( tui, s => theme.fg("accent", s), s => theme.fg("muted", s), message, ); - this.addChild(this.loader); + 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)); @@ -24,18 +24,18 @@ export class BorderedLoader extends Container { } get signal(): AbortSignal { - return this.loader.signal; + return this.#loader.signal; } set onAbort(fn: (() => void) | undefined) { - this.loader.onAbort = fn; + this.#loader.onAbort = fn; } handleInput(data: string): void { - this.loader.handleInput(data); + this.#loader.handleInput(data); } dispose(): void { - this.loader.dispose(); + this.#loader.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/branch-summary-message.ts b/packages/coding-agent/src/modes/components/branch-summary-message.ts index 002ddfafa..5a0bbfb16 100644 --- a/packages/coding-agent/src/modes/components/branch-summary-message.ts +++ b/packages/coding-agent/src/modes/components/branch-summary-message.ts @@ -7,31 +7,31 @@ import type { BranchSummaryMessage } from "../../session/messages"; * Uses same background color as hook messages for visual consistency. */ export class BranchSummaryMessageComponent extends Box { - private expanded = false; + #expanded = false; constructor(private readonly message: BranchSummaryMessage) { super(1, 1, t => theme.bg("customMessageBg", t)); - this.updateDisplay(); + this.#updateDisplay(); } setExpanded(expanded: boolean): void { - this.expanded = expanded; - this.updateDisplay(); + this.#expanded = expanded; + this.#updateDisplay(); } override invalidate(): void { super.invalidate(); - this.updateDisplay(); + this.#updateDisplay(); } - private updateDisplay(): void { + #updateDisplay(): void { this.clear(); const label = theme.fg("customMessageLabel", theme.bold("[branch]")); this.addChild(new Text(label, 0, 0)); this.addChild(new Spacer(1)); - if (this.expanded) { + if (this.#expanded) { const header = "**Branch Summary**\n\n"; this.addChild( new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), { diff --git a/packages/coding-agent/src/modes/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/components/compaction-summary-message.ts index 7f632e6e7..55363a7c2 100644 --- a/packages/coding-agent/src/modes/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/components/compaction-summary-message.ts @@ -7,24 +7,24 @@ import type { CompactionSummaryMessage } from "../../session/messages"; * Uses same background color as hook messages for visual consistency. */ export class CompactionSummaryMessageComponent extends Box { - private expanded = false; + #expanded = false; constructor(private readonly message: CompactionSummaryMessage) { super(1, 1, t => theme.bg("customMessageBg", t)); - this.updateDisplay(); + this.#updateDisplay(); } setExpanded(expanded: boolean): void { - this.expanded = expanded; - this.updateDisplay(); + this.#expanded = expanded; + this.#updateDisplay(); } override invalidate(): void { super.invalidate(); - this.updateDisplay(); + this.#updateDisplay(); } - private updateDisplay(): void { + #updateDisplay(): void { this.clear(); const tokenStr = this.message.tokensBefore.toLocaleString(); @@ -32,7 +32,7 @@ export class CompactionSummaryMessageComponent extends Box { this.addChild(new Text(label, 0, 0)); this.addChild(new Spacer(1)); - if (this.expanded) { + if (this.#expanded) { const header = `**Compacted from ${tokenStr} tokens**\n\n`; this.addChild( new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), { diff --git a/packages/coding-agent/src/modes/components/countdown-timer.ts b/packages/coding-agent/src/modes/components/countdown-timer.ts index 71a123232..8841d52a8 100644 --- a/packages/coding-agent/src/modes/components/countdown-timer.ts +++ b/packages/coding-agent/src/modes/components/countdown-timer.ts @@ -4,9 +4,9 @@ import type { TUI } from "@oh-my-pi/pi-tui"; export class CountdownTimer { - private intervalId: ReturnType | undefined; - private remainingSeconds: number; - private readonly initialMs: number; + #intervalId: NodeJS.Timeout | undefined; + #remainingSeconds: number; + readonly #initialMs: number; constructor( timeoutMs: number, @@ -14,16 +14,16 @@ export class CountdownTimer { private onTick: (seconds: number) => void, private onExpire: () => void, ) { - this.initialMs = timeoutMs; - this.remainingSeconds = Math.ceil(timeoutMs / 1000); - this.onTick(this.remainingSeconds); + this.#initialMs = timeoutMs; + this.#remainingSeconds = Math.ceil(timeoutMs / 1000); + this.onTick(this.#remainingSeconds); - this.intervalId = setInterval(() => { - this.remainingSeconds--; - this.onTick(this.remainingSeconds); + this.#intervalId = setInterval(() => { + this.#remainingSeconds--; + this.onTick(this.#remainingSeconds); this.tui?.requestRender(); - if (this.remainingSeconds <= 0) { + if (this.#remainingSeconds <= 0) { this.dispose(); this.onExpire(); } @@ -32,15 +32,15 @@ export class CountdownTimer { /** Reset the countdown to its initial value */ reset(): void { - this.remainingSeconds = Math.ceil(this.initialMs / 1000); - this.onTick(this.remainingSeconds); + this.#remainingSeconds = Math.ceil(this.#initialMs / 1000); + this.onTick(this.#remainingSeconds); this.tui?.requestRender(); } dispose(): void { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = undefined; + if (this.#intervalId) { + clearInterval(this.#intervalId); + this.#intervalId = undefined; } } } diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index 673225956..171d87231 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -4,48 +4,48 @@ import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi * Custom editor that handles Escape and Ctrl+C keys for coding-agent */ export class CustomEditor extends Editor { - public onEscape?: () => void; - public onCtrlC?: () => void; - public onCtrlD?: () => void; - public onShiftTab?: () => void; - public onCtrlP?: () => void; - public onShiftCtrlP?: () => void; - public onCtrlL?: () => void; - public onCtrlR?: () => void; - public onCtrlO?: () => void; - public onCtrlT?: () => void; - public onCtrlG?: () => void; - public onCtrlZ?: () => void; - public onQuestionMark?: () => void; - public onCapsLock?: () => void; - public onAltP?: () => void; + onEscape?: () => void; + onCtrlC?: () => void; + onCtrlD?: () => void; + onShiftTab?: () => void; + onCtrlP?: () => void; + onShiftCtrlP?: () => void; + onCtrlL?: () => void; + onCtrlR?: () => void; + onCtrlO?: () => void; + onCtrlT?: () => void; + onCtrlG?: () => void; + onCtrlZ?: () => void; + onQuestionMark?: () => void; + onCapsLock?: () => void; + onAltP?: () => void; /** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */ - public onCtrlV?: () => Promise; + onCtrlV?: () => Promise; /** Called when Alt+Up is pressed (dequeue keybinding). */ - public onAltUp?: () => void; + onAltUp?: () => void; /** Custom key handlers from extensions */ - private customKeyHandlers = new Map void>(); + #customKeyHandlers = new Map void>(); /** * Register a custom key handler. Extensions use this for shortcuts. */ setCustomKeyHandler(key: KeyId, handler: () => void): void { - this.customKeyHandlers.set(key, handler); + this.#customKeyHandlers.set(key, handler); } /** * Remove a custom key handler. */ removeCustomKeyHandler(key: KeyId): void { - this.customKeyHandlers.delete(key); + this.#customKeyHandlers.delete(key); } /** * Clear all custom key handlers. */ clearCustomKeyHandlers(): void { - this.customKeyHandlers.clear(); + this.#customKeyHandlers.clear(); } handleInput(data: string): void { @@ -157,7 +157,7 @@ export class CustomEditor extends Editor { } // Check custom key handlers (extensions) - for (const [keyId, handler] of this.customKeyHandlers) { + for (const [keyId, handler] of this.#customKeyHandlers) { if (matchesKey(data, keyId)) { handler(); return; diff --git a/packages/coding-agent/src/modes/components/custom-message.ts b/packages/coding-agent/src/modes/components/custom-message.ts index 7e4dff73c..99529345e 100644 --- a/packages/coding-agent/src/modes/components/custom-message.ts +++ b/packages/coding-agent/src/modes/components/custom-message.ts @@ -10,9 +10,9 @@ import type { CustomMessage } from "../../session/messages"; * Uses distinct styling to differentiate from user messages. */ export class CustomMessageComponent extends Container { - private box: Box; - private customComponent?: Component; - private _expanded = false; + #box: Box; + #customComponent?: Component; + #expanded = false; constructor( private readonly message: CustomMessage, @@ -23,37 +23,37 @@ export class CustomMessageComponent extends Container { this.addChild(new Spacer(1)); // Create box with custom background (used for default rendering) - this.box = new Box(1, 1, t => theme.bg("customMessageBg", t)); + this.#box = new Box(1, 1, t => theme.bg("customMessageBg", t)); - this.rebuild(); + this.#rebuild(); } setExpanded(expanded: boolean): void { - if (this._expanded !== expanded) { - this._expanded = expanded; - this.rebuild(); + if (this.#expanded !== expanded) { + this.#expanded = expanded; + this.#rebuild(); } } override invalidate(): void { super.invalidate(); - this.rebuild(); + this.#rebuild(); } - private rebuild(): void { + #rebuild(): void { // Remove previous content component - if (this.customComponent) { - this.removeChild(this.customComponent); - this.customComponent = undefined; + if (this.#customComponent) { + this.removeChild(this.#customComponent); + this.#customComponent = undefined; } - this.removeChild(this.box); + 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); + const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme); if (component) { - this.customComponent = component; + this.#customComponent = component; this.addChild(component); return; } @@ -63,13 +63,13 @@ export class CustomMessageComponent extends Container { } // Default rendering uses our box - this.addChild(this.box); - this.box.clear(); + this.addChild(this.#box); + this.#box.clear(); // Default rendering: label + content const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`)); - this.box.addChild(new Text(label, 0, 0)); - this.box.addChild(new Spacer(1)); + this.#box.addChild(new Text(label, 0, 0)); + this.#box.addChild(new Spacer(1)); // Extract text content let text: string; @@ -82,7 +82,7 @@ export class CustomMessageComponent extends Container { .join("\n"); } - this.box.addChild( + this.#box.addChild( new Markdown(text, 0, 0, getMarkdownTheme(), { color: (value: string) => theme.fg("customMessageText", value), }), diff --git a/packages/coding-agent/src/modes/components/dynamic-border.ts b/packages/coding-agent/src/modes/components/dynamic-border.ts index 684638581..f61fc46ee 100644 --- a/packages/coding-agent/src/modes/components/dynamic-border.ts +++ b/packages/coding-agent/src/modes/components/dynamic-border.ts @@ -9,10 +9,10 @@ import { theme } from "../../modes/theme/theme"; * function when using DynamicBorder in components exported for hook use. */ export class DynamicBorder implements Component { - private color: (str: string) => string; + #color: (str: string) => string; constructor(color: (str: string) => string = str => theme.fg("border", str)) { - this.color = color; + this.#color = color; } invalidate(): void { @@ -20,6 +20,6 @@ export class DynamicBorder implements Component { } render(width: number): string[] { - return [this.color(theme.boxSharp.horizontal.repeat(Math.max(1, width)))]; + return [this.#color(theme.boxSharp.horizontal.repeat(Math.max(1, width)))]; } } diff --git a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts index 8617249d6..4a316db6b 100644 --- a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts +++ b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts @@ -30,11 +30,11 @@ import { applyFilter, createInitialState, filterByProvider, refreshState, toggle import type { DashboardState } from "./types"; export class ExtensionDashboard extends Container { - private state!: DashboardState; - private mainList!: ExtensionList; - private inspector!: InspectorPanel; + #state!: DashboardState; + #mainList!: ExtensionList; + #inspector!: InspectorPanel; - public onClose?: () => void; + onClose?: () => void; private constructor( private readonly cwd: string, @@ -50,54 +50,54 @@ export class ExtensionDashboard extends Container { terminalHeight?: number, ): Promise { const dashboard = new ExtensionDashboard(cwd, settingsInstance, terminalHeight ?? process.stdout.rows ?? 24); - await dashboard.init(); + await dashboard.#init(); return dashboard; } - private async init(): Promise { + async #init(): Promise { const sm = this.settingsInstance ?? (await Settings.init()); const disabledIds = sm ? ((sm.get("disabledExtensions") as string[]) ?? []) : []; - this.state = await createInitialState(this.cwd, disabledIds); + this.#state = await createInitialState(this.cwd, disabledIds); // Calculate max visible items based on terminal height // Reserve ~10 lines for header, tabs, help text, borders const maxVisible = Math.max(5, Math.floor((this.terminalHeight - 10) / 2)); // Create main list - always focused - this.mainList = new ExtensionList( - this.state.searchFiltered, + this.#mainList = new ExtensionList( + this.#state.searchFiltered, { onSelectionChange: ext => { - this.state.selected = ext; - this.inspector.setExtension(ext); + this.#state.selected = ext; + this.#inspector.setExtension(ext); }, onToggle: (extensionId, enabled) => { - this.handleExtensionToggle(extensionId, enabled); + this.#handleExtensionToggle(extensionId, enabled); }, onMasterToggle: providerId => { - this.handleProviderToggle(providerId); + this.#handleProviderToggle(providerId); }, - masterSwitchProvider: this.getActiveProviderId(), + masterSwitchProvider: this.#getActiveProviderId(), }, maxVisible, ); - this.mainList.setFocused(true); + this.#mainList.setFocused(true); // Create inspector - this.inspector = new InspectorPanel(); - if (this.state.selected) { - this.inspector.setExtension(this.state.selected); + this.#inspector = new InspectorPanel(); + if (this.#state.selected) { + this.#inspector.setExtension(this.#state.selected); } - this.buildLayout(); + this.#buildLayout(); } - private getActiveProviderId(): string | null { - const tab = this.state.tabs[this.state.activeTabIndex]; + #getActiveProviderId(): string | null { + const tab = this.#state.tabs[this.#state.activeTabIndex]; return tab && tab.id !== "all" ? tab.id : null; } - private buildLayout(): void { + #buildLayout(): void { this.clear(); // Top border @@ -107,13 +107,13 @@ export class ExtensionDashboard extends Container { this.addChild(new Text(theme.bold(theme.fg("accent", " Extension Control Center")), 0, 0)); // Tab bar - this.addChild(new Text(this.renderTabBar(), 0, 0)); + this.addChild(new Text(this.#renderTabBar(), 0, 0)); this.addChild(new Spacer(1)); // 2-column body with height limit // Reserve ~8 lines for header, tabs, help text, borders const bodyMaxHeight = Math.max(5, this.terminalHeight - 8); - this.addChild(new TwoColumnBody(this.mainList, this.inspector, bodyMaxHeight)); + this.addChild(new TwoColumnBody(this.#mainList, this.#inspector, bodyMaxHeight)); this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("dim", " ↑/↓: navigate Space: toggle Tab: next provider Esc: close"), 0, 0)); @@ -122,12 +122,12 @@ export class ExtensionDashboard extends Container { this.addChild(new DynamicBorder()); } - private renderTabBar(): string { + #renderTabBar(): string { const parts: string[] = [" "]; - for (let i = 0; i < this.state.tabs.length; i++) { - const tab = this.state.tabs[i]; - const isActive = i === this.state.activeTabIndex; + for (let i = 0; i < this.#state.tabs.length; i++) { + const tab = this.#state.tabs[i]; + const isActive = i === this.#state.activeTabIndex; const isEmpty = tab.count === 0 && tab.id !== "all"; const isDisabled = !tab.enabled && tab.id !== "all"; @@ -157,12 +157,12 @@ export class ExtensionDashboard extends Container { return parts.join(""); } - private handleProviderToggle(providerId: string): void { + #handleProviderToggle(providerId: string): void { toggleProvider(providerId); - void this.refreshFromState(); + void this.#refreshFromState(); } - private handleExtensionToggle(extensionId: string, enabled: boolean): void { + #handleExtensionToggle(extensionId: string, enabled: boolean): void { const sm = this.settingsInstance ?? Settings.instance; if (!sm) return; @@ -180,67 +180,67 @@ export class ExtensionDashboard extends Container { } } - void this.refreshFromState(); + void this.#refreshFromState(); } - private async refreshFromState(): Promise { + async #refreshFromState(): Promise { // Remember current tab ID before refresh - const currentTabId = this.state.tabs[this.state.activeTabIndex]?.id; + const currentTabId = this.#state.tabs[this.#state.activeTabIndex]?.id; const sm = this.settingsInstance ?? Settings.instance; const disabledIds = sm ? ((sm.get("disabledExtensions") as string[]) ?? []) : []; - this.state = await refreshState(this.state, this.cwd, disabledIds); + this.#state = await refreshState(this.#state, this.cwd, disabledIds); // Find the same tab in the new (re-sorted) list if (currentTabId) { - const newIndex = this.state.tabs.findIndex(t => t.id === currentTabId); + const newIndex = this.#state.tabs.findIndex(t => t.id === currentTabId); if (newIndex >= 0) { - this.state.activeTabIndex = newIndex; + this.#state.activeTabIndex = newIndex; } } - this.mainList.setExtensions(this.state.searchFiltered); - this.mainList.setMasterSwitchProvider(this.getActiveProviderId()); + this.#mainList.setExtensions(this.#state.searchFiltered); + this.#mainList.setMasterSwitchProvider(this.#getActiveProviderId()); - if (this.state.selected) { - this.inspector.setExtension(this.state.selected); + if (this.#state.selected) { + this.#inspector.setExtension(this.#state.selected); } - this.buildLayout(); + this.#buildLayout(); } - private switchTab(direction: 1 | -1): void { - const numTabs = this.state.tabs.length; + #switchTab(direction: 1 | -1): void { + const numTabs = this.#state.tabs.length; if (numTabs === 0) return; // Find next selectable tab (skip empty+enabled providers) - let nextIndex = this.state.activeTabIndex; + let nextIndex = this.#state.activeTabIndex; for (let i = 0; i < numTabs; i++) { nextIndex = (nextIndex + direction + numTabs) % numTabs; - const tab = this.state.tabs[nextIndex]; + const tab = this.#state.tabs[nextIndex]; const isEmptyEnabled = tab.count === 0 && tab.enabled && tab.id !== "all"; if (!isEmptyEnabled) break; } - this.state.activeTabIndex = nextIndex; + this.#state.activeTabIndex = nextIndex; // Re-filter for new tab - const tab = this.state.tabs[this.state.activeTabIndex]; - this.state.tabFiltered = filterByProvider(this.state.extensions, tab.id); - this.state.searchFiltered = applyFilter(this.state.tabFiltered, this.state.searchQuery); - this.state.listIndex = 0; - this.state.scrollOffset = 0; - this.state.selected = this.state.searchFiltered[0] ?? null; + const tab = this.#state.tabs[this.#state.activeTabIndex]; + this.#state.tabFiltered = filterByProvider(this.#state.extensions, tab.id); + this.#state.searchFiltered = applyFilter(this.#state.tabFiltered, this.#state.searchQuery); + this.#state.listIndex = 0; + this.#state.scrollOffset = 0; + this.#state.selected = this.#state.searchFiltered[0] ?? null; // Update list - this.mainList.setExtensions(this.state.searchFiltered); - this.mainList.setMasterSwitchProvider(this.getActiveProviderId()); - this.mainList.resetSelection(); + this.#mainList.setExtensions(this.#state.searchFiltered); + this.#mainList.setMasterSwitchProvider(this.#getActiveProviderId()); + this.#mainList.resetSelection(); - if (this.state.selected) { - this.inspector.setExtension(this.state.selected); + if (this.#state.selected) { + this.#inspector.setExtension(this.#state.selected); } - this.buildLayout(); + this.#buildLayout(); } handleInput(data: string): void { @@ -252,12 +252,12 @@ export class ExtensionDashboard extends Container { // Escape - clear search first, then close if (matchesKey(data, "escape") || matchesKey(data, "esc")) { - if (this.state.searchQuery.length > 0) { - this.state.searchQuery = ""; - this.state.searchFiltered = this.state.tabFiltered; - this.mainList.setExtensions(this.state.searchFiltered); - this.mainList.clearSearch(); - this.buildLayout(); + if (this.#state.searchQuery.length > 0) { + this.#state.searchQuery = ""; + this.#state.searchFiltered = this.#state.tabFiltered; + this.#mainList.setExtensions(this.#state.searchFiltered); + this.#mainList.clearSearch(); + this.#buildLayout(); return; } this.onClose?.(); @@ -266,22 +266,22 @@ export class ExtensionDashboard extends Container { // Tab/Shift+Tab: Cycle through tabs if (matchesKey(data, "tab")) { - this.switchTab(1); + this.#switchTab(1); return; } if (matchesKey(data, "shift+tab")) { - this.switchTab(-1); + this.#switchTab(-1); return; } // All other input goes to the list - this.mainList.handleInput(data); + this.#mainList.handleInput(data); // Sync search query back to state - const query = this.mainList.getSearchQuery(); - if (query !== this.state.searchQuery) { - this.state.searchQuery = query; - this.state.searchFiltered = applyFilter(this.state.tabFiltered, query); + const query = this.#mainList.getSearchQuery(); + if (query !== this.#state.searchQuery) { + this.#state.searchQuery = query; + this.#state.searchFiltered = applyFilter(this.#state.tabFiltered, query); } } } diff --git a/packages/coding-agent/src/modes/components/extensions/extension-list.ts b/packages/coding-agent/src/modes/components/extensions/extension-list.ts index 8993fbf10..6f3b3e671 100644 --- a/packages/coding-agent/src/modes/components/extensions/extension-list.ts +++ b/packages/coding-agent/src/modes/components/extensions/extension-list.ts @@ -31,71 +31,71 @@ type ListItem = | { type: "extension"; item: Extension }; export class ExtensionList implements Component { - private listItems: ListItem[] = []; - private selectedIndex = 0; - private scrollOffset = 0; - private searchQuery = ""; - private focused = false; - private masterSwitchProvider: string | null = null; - private maxVisible: number; + #listItems: ListItem[] = []; + #selectedIndex = 0; + #scrollOffset = 0; + #searchQuery = ""; + #focused = false; + #masterSwitchProvider: string | null = null; + #maxVisible: number; constructor( private extensions: Extension[], private readonly callbacks: ExtensionListCallbacks = {}, maxVisible?: number, ) { - this.masterSwitchProvider = callbacks.masterSwitchProvider ?? null; - this.maxVisible = maxVisible ?? DEFAULT_MAX_VISIBLE; - this.rebuildList(); + this.#masterSwitchProvider = callbacks.masterSwitchProvider ?? null; + this.#maxVisible = maxVisible ?? DEFAULT_MAX_VISIBLE; + this.#rebuildList(); } setMaxVisible(maxVisible: number): void { - this.maxVisible = maxVisible; - this.clampSelection(); + this.#maxVisible = maxVisible; + this.#clampSelection(); } setExtensions(extensions: Extension[]): void { this.extensions = extensions; - this.rebuildList(); - this.clampSelection(); + this.#rebuildList(); + this.#clampSelection(); } setFocused(focused: boolean): void { - this.focused = focused; + this.#focused = focused; } setMasterSwitchProvider(providerId: string | null): void { - this.masterSwitchProvider = providerId; - this.rebuildList(); + this.#masterSwitchProvider = providerId; + this.#rebuildList(); } getSearchQuery(): string { - return this.searchQuery; + return this.#searchQuery; } resetSelection(): void { - this.selectedIndex = 0; - this.scrollOffset = 0; - this.notifySelectionChange(); + this.#selectedIndex = 0; + this.#scrollOffset = 0; + this.#notifySelectionChange(); } getSelectedExtension(): Extension | null { - const item = this.listItems[this.selectedIndex]; + const item = this.#listItems[this.#selectedIndex]; return item?.type === "extension" ? item.item : null; } /** Get the currently selected kind header (for preview purposes) */ getSelectedKind(): ExtensionKind | null { - const item = this.listItems[this.selectedIndex]; + const item = this.#listItems[this.#selectedIndex]; return item?.type === "kind-header" ? item.kind : null; } setSearchQuery(query: string): void { - this.searchQuery = query; - this.rebuildList(); - this.selectedIndex = 0; - this.scrollOffset = 0; - this.notifySelectionChange(); + this.#searchQuery = query; + this.#rebuildList(); + this.#selectedIndex = 0; + this.#scrollOffset = 0; + this.#notifySelectionChange(); } clearSearch(): void { @@ -109,47 +109,47 @@ export class ExtensionList implements Component { // Search bar const searchPrefix = theme.fg("muted", "Search: "); - const searchText = this.searchQuery || (this.focused ? "" : theme.fg("dim", "type to filter")); - const cursor = this.focused ? theme.fg("accent", "_") : ""; + const searchText = this.#searchQuery || (this.#focused ? "" : theme.fg("dim", "type to filter")); + const cursor = this.#focused ? theme.fg("accent", "_") : ""; lines.push(searchPrefix + searchText + cursor); lines.push(""); - if (this.listItems.length === 0) { + if (this.#listItems.length === 0) { lines.push(theme.fg("muted", " No extensions found for this provider.")); return lines; } // Determine if master switch is off (for dimming child items) - const masterDisabled = this.masterSwitchProvider !== null && !isProviderEnabled(this.masterSwitchProvider); + const masterDisabled = this.#masterSwitchProvider !== null && !isProviderEnabled(this.#masterSwitchProvider); // Calculate visible range - const startIdx = this.scrollOffset; - const endIdx = Math.min(startIdx + this.maxVisible, this.listItems.length); + const startIdx = this.#scrollOffset; + const endIdx = Math.min(startIdx + this.#maxVisible, this.#listItems.length); // Render visible items for (let i = startIdx; i < endIdx; i++) { - const listItem = this.listItems[i]; - const isSelected = this.focused && i === this.selectedIndex; + const listItem = this.#listItems[i]; + const isSelected = this.#focused && i === this.#selectedIndex; if (listItem.type === "master") { - lines.push(this.renderMasterSwitch(listItem, isSelected, width)); + lines.push(this.#renderMasterSwitch(listItem, isSelected, width)); } else if (listItem.type === "kind-header") { - lines.push(this.renderKindHeader(listItem, isSelected, width)); + lines.push(this.#renderKindHeader(listItem, isSelected, width)); } else { - lines.push(this.renderExtensionRow(listItem.item, isSelected, width, masterDisabled)); + lines.push(this.#renderExtensionRow(listItem.item, isSelected, width, masterDisabled)); } } // Scroll indicator - if (this.listItems.length > this.maxVisible) { - const indicator = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.listItems.length})`); + if (this.#listItems.length > this.#maxVisible) { + const indicator = theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.#listItems.length})`); lines.push(indicator); } return lines; } - private renderMasterSwitch(item: ListItem & { type: "master" }, isSelected: boolean, width: number): string { + #renderMasterSwitch(item: ListItem & { type: "master" }, isSelected: boolean, width: number): string { const checkbox = item.enabled ? theme.fg("success", theme.checkbox.checked) : theme.fg("dim", theme.checkbox.unchecked); @@ -169,7 +169,7 @@ export class ExtensionList implements Component { return truncateToWidth(line, width); } - private renderKindHeader(item: ListItem & { type: "kind-header" }, isSelected: boolean, width: number): string { + #renderKindHeader(item: ListItem & { type: "kind-header" }, isSelected: boolean, width: number): string { const countBadge = theme.fg("muted", `(${item.count})`); let line = `${item.icon} ${item.label} ${countBadge}`; @@ -183,12 +183,12 @@ export class ExtensionList implements Component { return truncateToWidth(line, width); } - private renderExtensionRow(ext: Extension, isSelected: boolean, width: number, masterDisabled: boolean): string { + #renderExtensionRow(ext: Extension, isSelected: boolean, width: number, masterDisabled: boolean): string { // When master is disabled, all items appear dimmed const effectivelyDisabled = masterDisabled || ext.state === "disabled"; // Status icon - const stateIcon = this.getStateIcon(ext.state, masterDisabled); + const stateIcon = this.#getStateIcon(ext.state, masterDisabled); // Name let name = ext.displayName; @@ -206,7 +206,7 @@ export class ExtensionList implements Component { } // Pad name - const namePadded = this.padText(name, nameWidth); + const namePadded = this.#padText(name, nameWidth); line += namePadded; // Trigger hint @@ -226,7 +226,7 @@ export class ExtensionList implements Component { return truncateToWidth(line, width); } - private getKindIcon(kind: ExtensionKind): string { + #getKindIcon(kind: ExtensionKind): string { switch (kind) { case "extension-module": return theme.icon.extensionTool; @@ -253,7 +253,7 @@ export class ExtensionList implements Component { } } - private getStateIcon(state: ExtensionState, masterDisabled: boolean): string { + #getStateIcon(state: ExtensionState, masterDisabled: boolean): string { if (masterDisabled) { return theme.fg("dim", theme.status.disabled); } @@ -267,7 +267,7 @@ export class ExtensionList implements Component { } } - private padText(text: string, targetWidth: number): string { + #padText(text: string, targetWidth: number): string { const width = visibleWidth(text); if (width >= targetWidth) { return truncateToWidth(text, targetWidth); @@ -275,34 +275,34 @@ export class ExtensionList implements Component { return text + padding(targetWidth - width); } - private rebuildList(): void { - this.listItems = []; + #rebuildList(): void { + this.#listItems = []; // Apply search filter - const filtered = this.searchQuery.length > 0 ? applyFilter(this.extensions, this.searchQuery) : this.extensions; + const filtered = this.#searchQuery.length > 0 ? applyFilter(this.extensions, this.#searchQuery) : this.extensions; // When searching, show flat list - if (this.searchQuery.length > 0) { + if (this.#searchQuery.length > 0) { for (const ext of filtered) { - this.listItems.push({ type: "extension", item: ext }); + this.#listItems.push({ type: "extension", item: ext }); } return; } // Provider-specific view: Master switch + flat list - if (this.masterSwitchProvider) { - const providerName = filtered[0]?.source.providerName ?? this.masterSwitchProvider; - const enabled = isProviderEnabled(this.masterSwitchProvider); + if (this.#masterSwitchProvider) { + const providerName = filtered[0]?.source.providerName ?? this.#masterSwitchProvider; + const enabled = isProviderEnabled(this.#masterSwitchProvider); - this.listItems.push({ + this.#listItems.push({ type: "master", - providerId: this.masterSwitchProvider, + providerId: this.#masterSwitchProvider, providerName, enabled, }); for (const ext of filtered) { - this.listItems.push({ type: "extension", item: ext }); + this.#listItems.push({ type: "extension", item: ext }); } return; } @@ -332,21 +332,21 @@ export class ExtensionList implements Component { const items = byKind.get(kind); if (!items || items.length === 0) continue; - this.listItems.push({ + this.#listItems.push({ type: "kind-header", kind, - label: this.getKindLabel(kind), - icon: this.getKindIcon(kind), + label: this.#getKindLabel(kind), + icon: this.#getKindIcon(kind), count: items.length, }); for (const ext of items) { - this.listItems.push({ type: "extension", item: ext }); + this.#listItems.push({ type: "extension", item: ext }); } } } - private getKindLabel(kind: ExtensionKind): string { + #getKindLabel(kind: ExtensionKind): string { switch (kind) { case "extension-module": return "Extension Modules"; @@ -373,21 +373,21 @@ export class ExtensionList implements Component { } } - private clampSelection(): void { - if (this.listItems.length === 0) { - this.selectedIndex = 0; - this.scrollOffset = 0; + #clampSelection(): void { + if (this.#listItems.length === 0) { + this.#selectedIndex = 0; + this.#scrollOffset = 0; return; } - this.selectedIndex = Math.min(this.selectedIndex, this.listItems.length - 1); - this.selectedIndex = Math.max(0, this.selectedIndex); + this.#selectedIndex = Math.min(this.#selectedIndex, this.#listItems.length - 1); + this.#selectedIndex = Math.max(0, this.#selectedIndex); // Adjust scroll offset - if (this.selectedIndex < this.scrollOffset) { - this.scrollOffset = this.selectedIndex; - } else if (this.selectedIndex >= this.scrollOffset + this.maxVisible) { - this.scrollOffset = this.selectedIndex - this.maxVisible + 1; + if (this.#selectedIndex < this.#scrollOffset) { + this.#scrollOffset = this.#selectedIndex; + } else if (this.#selectedIndex >= this.#scrollOffset + this.#maxVisible) { + this.#scrollOffset = this.#selectedIndex - this.#maxVisible + 1; } } @@ -396,23 +396,24 @@ export class ExtensionList implements Component { // Navigation if (matchesKey(data, "up") || data === "k") { - this.moveSelectionUp(); + this.#moveSelectionUp(); return; } if (matchesKey(data, "down") || data === "j") { - this.moveSelectionDown(); + this.#moveSelectionDown(); return; } // Space: Toggle selected item if (data === " ") { - const item = this.listItems[this.selectedIndex]; + const item = this.#listItems[this.#selectedIndex]; if (item?.type === "master") { this.callbacks.onMasterToggle?.(item.providerId); } else if (item?.type === "extension") { // Only allow toggling if master is enabled - const masterDisabled = this.masterSwitchProvider !== null && !isProviderEnabled(this.masterSwitchProvider); + const masterDisabled = + this.#masterSwitchProvider !== null && !isProviderEnabled(this.#masterSwitchProvider); if (!masterDisabled) { const newEnabled = item.item.state === "disabled"; this.callbacks.onToggle?.(item.item.id, newEnabled); @@ -423,11 +424,12 @@ export class ExtensionList implements Component { // Enter: Same as space - toggle selected item if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { - const item = this.listItems[this.selectedIndex]; + const item = this.#listItems[this.#selectedIndex]; if (item?.type === "master") { this.callbacks.onMasterToggle?.(item.providerId); } else if (item?.type === "extension") { - const masterDisabled = this.masterSwitchProvider !== null && !isProviderEnabled(this.masterSwitchProvider); + const masterDisabled = + this.#masterSwitchProvider !== null && !isProviderEnabled(this.#masterSwitchProvider); if (!masterDisabled) { const newEnabled = item.item.state === "disabled"; this.callbacks.onToggle?.(item.item.id, newEnabled); @@ -438,8 +440,8 @@ export class ExtensionList implements Component { // Backspace: Delete from search query if (matchesKey(data, "backspace")) { - if (this.searchQuery.length > 0) { - this.setSearchQuery(this.searchQuery.slice(0, -1)); + if (this.#searchQuery.length > 0) { + this.setSearchQuery(this.#searchQuery.slice(0, -1)); } return; } @@ -450,32 +452,32 @@ export class ExtensionList implements Component { if (data === "j" || data === "k") { return; } - this.setSearchQuery(this.searchQuery + data); + this.setSearchQuery(this.#searchQuery + data); return; } } - private moveSelectionUp(): void { - if (this.selectedIndex > 0) { - this.selectedIndex--; - if (this.selectedIndex < this.scrollOffset) { - this.scrollOffset = this.selectedIndex; + #moveSelectionUp(): void { + if (this.#selectedIndex > 0) { + this.#selectedIndex--; + if (this.#selectedIndex < this.#scrollOffset) { + this.#scrollOffset = this.#selectedIndex; } - this.notifySelectionChange(); + this.#notifySelectionChange(); } } - private moveSelectionDown(): void { - if (this.selectedIndex < this.listItems.length - 1) { - this.selectedIndex++; - if (this.selectedIndex >= this.scrollOffset + this.maxVisible) { - this.scrollOffset = this.selectedIndex - this.maxVisible + 1; + #moveSelectionDown(): void { + if (this.#selectedIndex < this.#listItems.length - 1) { + this.#selectedIndex++; + if (this.#selectedIndex >= this.#scrollOffset + this.#maxVisible) { + this.#scrollOffset = this.#selectedIndex - this.#maxVisible + 1; } - this.notifySelectionChange(); + this.#notifySelectionChange(); } } - private notifySelectionChange(): void { + #notifySelectionChange(): void { const ext = this.getSelectedExtension(); this.callbacks.onSelectionChange?.(ext); } diff --git a/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts b/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts index 87ffc3853..c80fd2a4f 100644 --- a/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts +++ b/packages/coding-agent/src/modes/components/extensions/inspector-panel.ts @@ -9,20 +9,20 @@ import { theme } from "../../../modes/theme/theme"; import type { Extension, ExtensionState } from "./types"; export class InspectorPanel implements Component { - private extension: Extension | null = null; + #extension: Extension | null = null; setExtension(extension: Extension | null): void { - this.extension = extension; + this.#extension = extension; } invalidate(): void {} render(width: number): string[] { - if (!this.extension) { + if (!this.#extension) { return [theme.fg("muted", "Select an extension"), theme.fg("dim", "to view details")]; } - const ext = this.extension; + const ext = this.#extension; const lines: string[] = []; // Name header @@ -30,7 +30,7 @@ export class InspectorPanel implements Component { lines.push(""); // Kind badge - lines.push(theme.fg("muted", "Type: ") + this.getKindBadge(ext.kind)); + lines.push(theme.fg("muted", "Type: ") + this.#getKindBadge(ext.kind)); lines.push(""); // Description (wrapped) @@ -46,40 +46,40 @@ export class InspectorPanel implements Component { lines.push(theme.fg("muted", "Origin:")); const levelLabel = ext.source.level === "user" ? "User" : ext.source.level === "project" ? "Project" : "Native"; lines.push(` ${theme.italic(`via ${ext.source.providerName} (${levelLabel})`)}`); - lines.push(` ${theme.fg("dim", this.shortenPath(ext.path))}`); + lines.push(` ${theme.fg("dim", this.#shortenPath(ext.path))}`); lines.push(""); // Status badge lines.push(theme.fg("muted", "Status:")); - lines.push(` ${this.getStatusBadge(ext.state, ext.disabledReason, ext.shadowedBy)}`); + lines.push(` ${this.#getStatusBadge(ext.state, ext.disabledReason, ext.shadowedBy)}`); lines.push(""); // Preview section (routed based on kind) - const previewLines = this.renderPreview(ext, width); + const previewLines = this.#renderPreview(ext, width); lines.push(...previewLines); return lines; } - private renderPreview(ext: Extension, width: number): string[] { + #renderPreview(ext: Extension, width: number): string[] { const lines: string[] = []; let content: string[] = []; switch (ext.kind) { case "context-file": - content = this.renderFilePreview(ext.raw, width); + content = this.#renderFilePreview(ext.raw, width); break; case "tool": - content = this.renderToolArgs(ext.raw, width); + content = this.#renderToolArgs(ext.raw, width); break; case "skill": - content = this.renderSkillContent(ext.raw, width); + content = this.#renderSkillContent(ext.raw, width); break; case "mcp": - content = this.renderMcpDetails(ext.raw, width); + content = this.#renderMcpDetails(ext.raw, width); break; default: - content = this.renderDefaultPreview(ext, width); + content = this.#renderDefaultPreview(ext, width); break; } @@ -90,12 +90,12 @@ export class InspectorPanel implements Component { return lines; } - private renderFilePreview(raw: unknown, width: number): string[] { + #renderFilePreview(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Preview:")); lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); - const content = this.getContextFileContent(raw); + const content = this.#getContextFileContent(raw); if (!content) { lines.push(theme.fg("dim", " (no content)")); lines.push(""); @@ -104,7 +104,7 @@ export class InspectorPanel implements Component { const fileLines = content.split("\n"); for (const line of fileLines.slice(0, 20)) { - const highlighted = this.highlightMarkdown(line); + const highlighted = this.#highlightMarkdown(line); lines.push(truncateToWidth(highlighted, width - 2)); } @@ -116,7 +116,7 @@ export class InspectorPanel implements Component { return lines; } - private getContextFileContent(raw: unknown): string | null { + #getContextFileContent(raw: unknown): string | null { if (raw && typeof raw === "object" && "content" in raw) { const content = (raw as { content?: unknown }).content; return typeof content === "string" ? content : null; @@ -124,7 +124,7 @@ export class InspectorPanel implements Component { return null; } - private highlightMarkdown(line: string): string { + #highlightMarkdown(line: string): string { // Basic markdown syntax highlighting let highlighted = line; @@ -148,7 +148,7 @@ export class InspectorPanel implements Component { return highlighted; } - private renderToolArgs(raw: unknown, width: number): string[] { + #renderToolArgs(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Arguments:")); lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); @@ -187,7 +187,7 @@ export class InspectorPanel implements Component { return lines; } - private renderSkillContent(raw: unknown, width: number): string[] { + #renderSkillContent(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Instruction:")); lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); @@ -216,7 +216,7 @@ export class InspectorPanel implements Component { return lines; } - private renderMcpDetails(raw: unknown, width: number): string[] { + #renderMcpDetails(raw: unknown, width: number): string[] { const lines: string[] = []; lines.push(theme.fg("muted", "Connection:")); lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40)))); @@ -252,7 +252,7 @@ export class InspectorPanel implements Component { return lines; } - private renderDefaultPreview(ext: Extension, width: number): string[] { + #renderDefaultPreview(ext: Extension, width: number): string[] { const lines: string[] = []; // Show trigger pattern if present @@ -266,7 +266,7 @@ export class InspectorPanel implements Component { return lines; } - private getKindBadge(kind: string): string { + #getKindBadge(kind: string): string { const kindColors: Record = { "extension-module": "accent", skill: "accent", @@ -284,7 +284,7 @@ export class InspectorPanel implements Component { return theme.fg(color as any, kind); } - private getStatusBadge(state: ExtensionState, reason?: string, shadowedBy?: string): string { + #getStatusBadge(state: ExtensionState, reason?: string, shadowedBy?: string): string { switch (state) { case "active": return theme.fg("success", `${theme.status.enabled} Active`); @@ -302,7 +302,7 @@ export class InspectorPanel implements Component { } } - private shortenPath(path: string): string { + #shortenPath(path: string): string { const home = os.homedir(); if (home && path.startsWith(home)) { return `~${path.slice(home.length)}`; diff --git a/packages/coding-agent/src/modes/components/footer.ts b/packages/coding-agent/src/modes/components/footer.ts index 19280c773..212432b6c 100644 --- a/packages/coding-agent/src/modes/components/footer.ts +++ b/packages/coding-agent/src/modes/components/footer.ts @@ -42,16 +42,16 @@ async function findGitHeadPath(): Promise<{ path: string; content: string } | nu * Footer component that shows pwd, token stats, and context usage */ export class FooterComponent implements Component { - private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name - private gitWatcher: fs.FSWatcher | null = null; - private onBranchChange: (() => void) | null = null; - private autoCompactEnabled: boolean = true; - private extensionStatuses: Map = new Map(); + #cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name + #gitWatcher: fs.FSWatcher | null = null; + #onBranchChange: (() => void) | null = null; + #autoCompactEnabled: boolean = true; + #extensionStatuses: Map = new Map(); constructor(private readonly session: AgentSession) {} setAutoCompactEnabled(enabled: boolean): void { - this.autoCompactEnabled = enabled; + this.#autoCompactEnabled = enabled; } /** @@ -63,9 +63,9 @@ export class FooterComponent implements Component { */ setExtensionStatus(key: string, text: string | undefined): void { if (text === undefined) { - this.extensionStatuses.delete(key); + this.#extensionStatuses.delete(key); } else { - this.extensionStatuses.set(key, text); + this.#extensionStatuses.set(key, text); } } @@ -74,15 +74,15 @@ export class FooterComponent implements Component { * Call the provided callback when branch changes. */ watchBranch(onBranchChange: () => void): void { - this.onBranchChange = onBranchChange; - this.setupGitWatcher(); + this.#onBranchChange = onBranchChange; + this.#setupGitWatcher(); } - private setupGitWatcher(): void { + #setupGitWatcher(): void { // Clean up existing watcher - if (this.gitWatcher) { - this.gitWatcher.close(); - this.gitWatcher = null; + if (this.#gitWatcher) { + this.#gitWatcher.close(); + this.#gitWatcher = null; } findGitHeadPath().then(result => { @@ -91,10 +91,10 @@ export class FooterComponent implements Component { } try { - this.gitWatcher = fs.watch(result.path, () => { - this.cachedBranch = undefined; // Invalidate cache - if (this.onBranchChange) { - this.onBranchChange(); + this.#gitWatcher = fs.watch(result.path, () => { + this.#cachedBranch = undefined; // Invalidate cache + if (this.#onBranchChange) { + this.#onBranchChange(); } }); } catch { @@ -107,46 +107,46 @@ export class FooterComponent implements Component { * Clean up the file watcher */ dispose(): void { - if (this.gitWatcher) { - this.gitWatcher.close(); - this.gitWatcher = null; + if (this.#gitWatcher) { + this.#gitWatcher.close(); + this.#gitWatcher = null; } } invalidate(): void { // Invalidate cached branch so it gets re-read on next render - this.cachedBranch = undefined; + this.#cachedBranch = undefined; } /** * Get current git branch by reading .git/HEAD directly. * Returns null if not in a git repo, branch name otherwise. */ - private getCurrentBranch(): string | null { + #getCurrentBranch(): string | null { // Return cached value if available - if (this.cachedBranch !== undefined) { - return this.cachedBranch; + if (this.#cachedBranch !== undefined) { + return this.#cachedBranch; } // Note: fire-and-forget async call - will return undefined on first call // This is acceptable since it's a cached value that will update on next render findGitHeadPath().then(result => { if (!result) { - this.cachedBranch = null; - if (this.onBranchChange) { - this.onBranchChange(); + this.#cachedBranch = null; + if (this.#onBranchChange) { + this.#onBranchChange(); } return; } const content = result.content.trim(); if (content.startsWith("ref: refs/heads/")) { - this.cachedBranch = content.slice(16); + this.#cachedBranch = content.slice(16); } else { - this.cachedBranch = "detached"; + this.#cachedBranch = "detached"; } - if (this.onBranchChange) { - this.onBranchChange(); + if (this.#onBranchChange) { + this.#onBranchChange(); } }); @@ -204,7 +204,7 @@ export class FooterComponent implements Component { let pwd = shortenPath(process.cwd()); // Add git branch if available - const branch = this.getCurrentBranch(); + const branch = this.#getCurrentBranch(); if (branch) { pwd = `${pwd} (${branch})`; } @@ -237,7 +237,7 @@ export class FooterComponent implements Component { // Colorize context percentage based on usage let contextPercentStr: string; - const autoIndicator = this.autoCompactEnabled ? " (auto)" : ""; + const autoIndicator = this.#autoCompactEnabled ? " (auto)" : ""; const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`; if (contextPercentValue > 90) { contextPercentStr = theme.fg("error", contextPercentDisplay); @@ -308,8 +308,8 @@ export class FooterComponent implements Component { const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder]; // Add extension statuses on a single line, sorted by key alphabetically - if (this.extensionStatuses.size > 0) { - const sortedStatuses = Array.from(this.extensionStatuses.entries()) + if (this.#extensionStatuses.size > 0) { + const sortedStatuses = Array.from(this.#extensionStatuses.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([, text]) => sanitizeStatusText(text)); const statusLine = sortedStatuses.join(" "); diff --git a/packages/coding-agent/src/modes/components/history-search.ts b/packages/coding-agent/src/modes/components/history-search.ts index cbe61c57d..34d741a2c 100644 --- a/packages/coding-agent/src/modes/components/history-search.ts +++ b/packages/coding-agent/src/modes/components/history-search.ts @@ -15,17 +15,17 @@ import type { HistoryEntry, HistoryStorage } from "../../session/history-storage import { DynamicBorder } from "./dynamic-border"; class HistoryResultsList implements Component { - private results: HistoryEntry[] = []; - private selectedIndex = 0; - private maxVisible = 10; + #results: HistoryEntry[] = []; + #selectedIndex = 0; + #maxVisible = 10; setResults(results: HistoryEntry[], selectedIndex: number): void { - this.results = results; - this.selectedIndex = selectedIndex; + this.#results = results; + this.#selectedIndex = selectedIndex; } setSelectedIndex(selectedIndex: number): void { - this.selectedIndex = selectedIndex; + this.#selectedIndex = selectedIndex; } invalidate(): void { @@ -35,20 +35,20 @@ class HistoryResultsList implements Component { render(width: number): string[] { const lines: string[] = []; - if (this.results.length === 0) { + if (this.#results.length === 0) { lines.push(theme.fg("muted", " No matching history")); return lines; } const startIndex = Math.max( 0, - Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.results.length - this.maxVisible), + Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.#results.length - this.#maxVisible), ); - const endIndex = Math.min(startIndex + this.maxVisible, this.results.length); + const endIndex = Math.min(startIndex + this.#maxVisible, this.#results.length); for (let i = startIndex; i < endIndex; i++) { - const entry = this.results[i]; - const isSelected = i === this.selectedIndex; + const entry = this.#results[i]; + const isSelected = i === this.#selectedIndex; const cursorSymbol = `${theme.nav.cursor} `; const cursorWidth = visibleWidth(cursorSymbol); @@ -60,8 +60,8 @@ class HistoryResultsList implements Component { lines.push(cursor + (isSelected ? theme.bold(truncated) : truncated)); } - if (startIndex > 0 || endIndex < this.results.length) { - const scrollText = ` (${this.selectedIndex + 1}/${this.results.length})`; + if (startIndex > 0 || endIndex < this.#results.length) { + const scrollText = ` (${this.#selectedIndex + 1}/${this.#results.length})`; lines.push(theme.fg("muted", truncateToWidth(scrollText, width, Ellipsis.Omit))); } @@ -70,88 +70,88 @@ class HistoryResultsList implements Component { } export class HistorySearchComponent extends Container { - private historyStorage: HistoryStorage; - private searchInput: Input; - private results: HistoryEntry[] = []; - private selectedIndex = 0; - private resultsList: HistoryResultsList; - private onSelect: (prompt: string) => void; - private onCancel: () => void; - private resultLimit = 100; + #historyStorage: HistoryStorage; + #searchInput: Input; + #results: HistoryEntry[] = []; + #selectedIndex = 0; + #resultsList: HistoryResultsList; + #onSelect: (prompt: string) => void; + #onCancel: () => void; + #resultLimit = 100; constructor(historyStorage: HistoryStorage, onSelect: (prompt: string) => void, onCancel: () => void) { super(); - this.historyStorage = historyStorage; - this.onSelect = onSelect; - this.onCancel = onCancel; + this.#historyStorage = historyStorage; + this.#onSelect = onSelect; + this.#onCancel = onCancel; - this.searchInput = new Input(); - this.searchInput.onSubmit = () => { - const selected = this.results[this.selectedIndex]; + this.#searchInput = new Input(); + this.#searchInput.onSubmit = () => { + const selected = this.#results[this.#selectedIndex]; if (selected) { - this.onSelect(selected.prompt); + this.#onSelect(selected.prompt); } }; - this.searchInput.onEscape = () => { - this.onCancel(); + this.#searchInput.onEscape = () => { + this.#onCancel(); }; - this.resultsList = new HistoryResultsList(); + this.#resultsList = new HistoryResultsList(); this.addChild(new Spacer(1)); this.addChild(new Text(theme.bold("Search History (Ctrl+R)"), 1, 0)); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); - this.addChild(this.searchInput); + this.addChild(this.#searchInput); this.addChild(new Spacer(1)); - this.addChild(this.resultsList); + this.addChild(this.#resultsList); this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("muted", "up/down navigate enter select esc cancel"), 1, 0)); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); - this.updateResults(); + this.#updateResults(); } handleInput(keyData: string): void { if (matchesKey(keyData, "up")) { - if (this.results.length === 0) return; - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - this.resultsList.setSelectedIndex(this.selectedIndex); + if (this.#results.length === 0) return; + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); + this.#resultsList.setSelectedIndex(this.#selectedIndex); return; } if (matchesKey(keyData, "down")) { - if (this.results.length === 0) return; - this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1); - this.resultsList.setSelectedIndex(this.selectedIndex); + if (this.#results.length === 0) return; + this.#selectedIndex = Math.min(this.#results.length - 1, this.#selectedIndex + 1); + this.#resultsList.setSelectedIndex(this.#selectedIndex); return; } if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selected = this.results[this.selectedIndex]; + const selected = this.#results[this.#selectedIndex]; if (selected) { - this.onSelect(selected.prompt); + this.#onSelect(selected.prompt); } return; } if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { - this.onCancel(); + this.#onCancel(); return; } - this.searchInput.handleInput(keyData); - this.updateResults(); + this.#searchInput.handleInput(keyData); + this.#updateResults(); } - private updateResults(): void { - const query = this.searchInput.getValue().trim(); - this.results = query - ? this.historyStorage.search(query, this.resultLimit) - : this.historyStorage.getRecent(this.resultLimit); - this.selectedIndex = 0; - this.resultsList.setResults(this.results, this.selectedIndex); + #updateResults(): void { + const query = this.#searchInput.getValue().trim(); + this.#results = query + ? this.#historyStorage.search(query, this.#resultLimit) + : this.#historyStorage.getRecent(this.#resultLimit); + this.#selectedIndex = 0; + this.#resultsList.setResults(this.#results, this.#selectedIndex); } } diff --git a/packages/coding-agent/src/modes/components/hook-editor.ts b/packages/coding-agent/src/modes/components/hook-editor.ts index c7eb71805..cd13ce39f 100644 --- a/packages/coding-agent/src/modes/components/hook-editor.ts +++ b/packages/coding-agent/src/modes/components/hook-editor.ts @@ -8,10 +8,10 @@ import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { DynamicBorder } from "./dynamic-border"; export class HookEditorComponent extends Container { - private editor: Editor; - private onSubmitCallback: (value: string) => void; - private onCancelCallback: () => void; - private tui: TUI; + #editor: Editor; + #onSubmitCallback: (value: string) => void; + #onCancelCallback: () => void; + #tui: TUI; constructor( tui: TUI, @@ -22,9 +22,9 @@ export class HookEditorComponent extends Container { ) { super(); - this.tui = tui; - this.onSubmitCallback = onSubmit; - this.onCancelCallback = onCancel; + this.#tui = tui; + this.#onSubmitCallback = onSubmit; + this.#onCancelCallback = onCancel; // Add top border this.addChild(new DynamicBorder()); @@ -35,11 +35,11 @@ export class HookEditorComponent extends Container { this.addChild(new Spacer(1)); // Create editor - this.editor = new Editor(getEditorTheme()); + this.#editor = new Editor(getEditorTheme()); if (prefill) { - this.editor.setText(prefill); + this.#editor.setText(prefill); } - this.addChild(this.editor); + this.addChild(this.#editor); this.addChild(new Spacer(1)); @@ -56,40 +56,40 @@ export class HookEditorComponent extends Container { handleInput(keyData: string): void { // Ctrl+Enter to submit if (keyData === "\x1b[13;5u" || keyData === "\x1b[27;5;13~") { - this.onSubmitCallback(this.editor.getText()); + this.#onSubmitCallback(this.#editor.getText()); return; } // Escape to cancel if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { - this.onCancelCallback(); + this.#onCancelCallback(); return; } // Ctrl+G for external editor if (matchesKey(keyData, "ctrl+g")) { - void this.openExternalEditor(); + void this.#openExternalEditor(); return; } // Forward to editor - this.editor.handleInput(keyData); + this.#editor.handleInput(keyData); } - private async openExternalEditor(): Promise { + async #openExternalEditor(): Promise { const editorCmd = getEditorCommand(); if (!editorCmd) return; - const currentText = this.editor.getText(); + const currentText = this.#editor.getText(); try { - this.tui.stop(); + this.#tui.stop(); const result = await openInEditor(editorCmd, currentText); if (result !== null) { - this.editor.setText(result); + this.#editor.setText(result); } } finally { - this.tui.start(); - this.tui.requestRender(true); + this.#tui.start(); + this.#tui.requestRender(true); } } } diff --git a/packages/coding-agent/src/modes/components/hook-input.ts b/packages/coding-agent/src/modes/components/hook-input.ts index b3cf862c4..d8d7e5c01 100644 --- a/packages/coding-agent/src/modes/components/hook-input.ts +++ b/packages/coding-agent/src/modes/components/hook-input.ts @@ -12,12 +12,12 @@ export interface HookInputOptions { } export class HookInputComponent extends Container { - private input: Input; - private onSubmitCallback: (value: string) => void; - private onCancelCallback: () => void; - private titleText: Text; - private baseTitle: string; - private countdown: CountdownTimer | undefined; + #input: Input; + #onSubmitCallback: (value: string) => void; + #onCancelCallback: () => void; + #titleText: Text; + #baseTitle: string; + #countdown: CountdownTimer | undefined; constructor( title: string, @@ -28,28 +28,28 @@ export class HookInputComponent extends Container { ) { super(); - this.onSubmitCallback = onSubmit; - this.onCancelCallback = onCancel; - this.baseTitle = title; + this.#onSubmitCallback = onSubmit; + this.#onCancelCallback = onCancel; + this.#baseTitle = title; this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); - this.titleText = new Text(theme.fg("accent", title), 1, 0); - this.addChild(this.titleText); + this.#titleText = new Text(theme.fg("accent", title), 1, 0); + this.addChild(this.#titleText); this.addChild(new Spacer(1)); if (opts?.timeout && opts.timeout > 0 && opts.tui) { - this.countdown = new CountdownTimer( + this.#countdown = new CountdownTimer( opts.timeout, opts.tui, - s => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)), - () => this.onCancelCallback(), + s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)), + () => this.#onCancelCallback(), ); } - this.input = new Input(); - this.addChild(this.input); + this.#input = new Input(); + this.addChild(this.#input); this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0)); this.addChild(new Spacer(1)); @@ -58,15 +58,15 @@ export class HookInputComponent extends Container { handleInput(keyData: string): void { if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - this.onSubmitCallback(this.input.getValue()); + this.#onSubmitCallback(this.#input.getValue()); } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { - this.onCancelCallback(); + this.#onCancelCallback(); } else { - this.input.handleInput(keyData); + this.#input.handleInput(keyData); } } dispose(): void { - this.countdown?.dispose(); + this.#countdown?.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/hook-message.ts b/packages/coding-agent/src/modes/components/hook-message.ts index adbc80294..14f4ca9a1 100644 --- a/packages/coding-agent/src/modes/components/hook-message.ts +++ b/packages/coding-agent/src/modes/components/hook-message.ts @@ -10,9 +10,9 @@ import type { HookMessage } from "../../session/messages"; * Uses distinct styling to differentiate from user messages. */ export class HookMessageComponent extends Container { - private box: Box; - private customComponent?: Component; - private _expanded = false; + #box: Box; + #customComponent?: Component; + #expanded = false; constructor( private readonly message: HookMessage, @@ -23,38 +23,38 @@ export class HookMessageComponent extends Container { 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.#box = new Box(1, 1, t => theme.bg("customMessageBg", t)); - this.rebuild(); + this.#rebuild(); } setExpanded(expanded: boolean): void { - if (this._expanded !== expanded) { - this._expanded = expanded; - this.rebuild(); + if (this.#expanded !== expanded) { + this.#expanded = expanded; + this.#rebuild(); } } override invalidate(): void { super.invalidate(); - this.rebuild(); + this.#rebuild(); } - private rebuild(): void { + #rebuild(): void { // Remove previous content component - if (this.customComponent) { - this.removeChild(this.customComponent); - this.customComponent = undefined; + if (this.#customComponent) { + this.removeChild(this.#customComponent); + this.#customComponent = undefined; } - this.removeChild(this.box); + 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); + const component = this.customRenderer(this.message, { expanded: this.#expanded }, theme); if (component) { // Custom renderer provides its own styled component - this.customComponent = component; + this.#customComponent = component; this.addChild(component); return; } @@ -64,13 +64,13 @@ export class HookMessageComponent extends Container { } // Default rendering uses our box - this.addChild(this.box); - this.box.clear(); + this.addChild(this.#box); + this.#box.clear(); // Default rendering: label + content const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`)); - this.box.addChild(new Text(label, 0, 0)); - this.box.addChild(new Spacer(1)); + this.#box.addChild(new Text(label, 0, 0)); + this.#box.addChild(new Spacer(1)); // Extract text content let text: string; @@ -84,14 +84,14 @@ export class HookMessageComponent extends Container { } // Limit lines when collapsed - if (!this._expanded) { + if (!this.#expanded) { const lines = text.split("\n"); if (lines.length > 5) { text = `${lines.slice(0, 5).join("\n")}\n…`; } } - this.box.addChild( + this.#box.addChild( new Markdown(text, 0, 0, getMarkdownTheme(), { color: (text: string) => theme.fg("customMessageText", text), }), diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index 7c42fd71b..f85095897 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -16,10 +16,10 @@ export interface HookSelectorOptions { } class OutlinedList extends Container { - private lines: string[] = []; + #lines: string[] = []; setLines(lines: string[]): void { - this.lines = lines; + this.#lines = lines; this.invalidate(); } @@ -27,7 +27,7 @@ class OutlinedList extends Container { const borderColor = (text: string) => theme.fg("border", text); const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width))); const innerWidth = Math.max(1, width - 2); - const content = this.lines.map(line => { + const content = this.#lines.map(line => { const pad = Math.max(0, innerWidth - visibleWidth(line)); return `${borderColor(theme.boxSharp.vertical)}${line}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`; }); @@ -36,16 +36,16 @@ class OutlinedList extends Container { } export class HookSelectorComponent extends Container { - private options: string[]; - private selectedIndex: number; - private maxVisible: number; - private listContainer: Container | undefined; - private outlinedList: OutlinedList | undefined; - private onSelectCallback: (option: string) => void; - private onCancelCallback: () => void; - private titleText: Text; - private baseTitle: string; - private countdown: CountdownTimer | undefined; + #options: string[]; + #selectedIndex: number; + #maxVisible: number; + #listContainer: Container | undefined; + #outlinedList: OutlinedList | undefined; + #onSelectCallback: (option: string) => void; + #onCancelCallback: () => void; + #titleText: Text; + #baseTitle: string; + #countdown: CountdownTimer | undefined; constructor( title: string, @@ -56,100 +56,100 @@ export class HookSelectorComponent extends Container { ) { super(); - this.options = options; - this.selectedIndex = Math.min(opts?.initialIndex ?? 0, options.length - 1); - this.maxVisible = Math.max(3, opts?.maxVisible ?? 12); - this.onSelectCallback = onSelect; - this.onCancelCallback = onCancel; - this.baseTitle = title; + this.#options = options; + this.#selectedIndex = Math.min(opts?.initialIndex ?? 0, options.length - 1); + this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12); + this.#onSelectCallback = onSelect; + this.#onCancelCallback = onCancel; + this.#baseTitle = title; this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); - this.titleText = new Text(theme.fg("accent", title), 1, 0); - this.addChild(this.titleText); + this.#titleText = new Text(theme.fg("accent", title), 1, 0); + this.addChild(this.#titleText); this.addChild(new Spacer(1)); if (opts?.timeout && opts.timeout > 0 && opts.tui) { - this.countdown = new CountdownTimer( + this.#countdown = new CountdownTimer( opts.timeout, opts.tui, - s => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)), + s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)), () => { // Auto-select current option on timeout (typically the first/recommended option) - const selected = this.options[this.selectedIndex]; + const selected = this.#options[this.#selectedIndex]; if (selected) { - this.onSelectCallback(selected); + this.#onSelectCallback(selected); } else { - this.onCancelCallback(); + this.#onCancelCallback(); } }, ); } if (opts?.outline) { - this.outlinedList = new OutlinedList(); - this.addChild(this.outlinedList); + this.#outlinedList = new OutlinedList(); + this.addChild(this.#outlinedList); } else { - this.listContainer = new Container(); - this.addChild(this.listContainer); + this.#listContainer = new Container(); + this.addChild(this.#listContainer); } this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("dim", "up/down navigate enter select esc cancel"), 1, 0)); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); - this.updateList(); + this.#updateList(); } - private updateList(): void { + #updateList(): void { const lines: string[] = []; const startIndex = Math.max( 0, - Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.options.length - this.maxVisible), + Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.#options.length - this.#maxVisible), ); - const endIndex = Math.min(startIndex + this.maxVisible, this.options.length); + const endIndex = Math.min(startIndex + this.#maxVisible, this.#options.length); for (let i = startIndex; i < endIndex; i++) { - const isSelected = i === this.selectedIndex; + const isSelected = i === this.#selectedIndex; const text = isSelected - ? theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", this.options[i]) - : ` ${theme.fg("text", this.options[i])}`; + ? theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", this.#options[i]) + : ` ${theme.fg("text", this.#options[i])}`; lines.push(text); } - if (startIndex > 0 || endIndex < this.options.length) { - lines.push(theme.fg("dim", ` (${this.selectedIndex + 1}/${this.options.length})`)); + if (startIndex > 0 || endIndex < this.#options.length) { + lines.push(theme.fg("dim", ` (${this.#selectedIndex + 1}/${this.#options.length})`)); } - if (this.outlinedList) { - this.outlinedList.setLines(lines); + if (this.#outlinedList) { + this.#outlinedList.setLines(lines); return; } - this.listContainer?.clear(); + this.#listContainer?.clear(); for (const line of lines) { - this.listContainer?.addChild(new Text(line, 1, 0)); + this.#listContainer?.addChild(new Text(line, 1, 0)); } } handleInput(keyData: string): void { // Reset countdown on any interaction - this.countdown?.reset(); + this.#countdown?.reset(); if (matchesKey(keyData, "up") || keyData === "k") { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - this.updateList(); + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); + this.#updateList(); } else if (matchesKey(keyData, "down") || keyData === "j") { - this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1); - this.updateList(); + this.#selectedIndex = Math.min(this.#options.length - 1, this.#selectedIndex + 1); + this.#updateList(); } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selected = this.options[this.selectedIndex]; - if (selected) this.onSelectCallback(selected); + const selected = this.#options[this.#selectedIndex]; + if (selected) this.#onSelectCallback(selected); } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) { - this.onCancelCallback(); + this.#onCancelCallback(); } } dispose(): void { - this.countdown?.dispose(); + this.#countdown?.dispose(); } } diff --git a/packages/coding-agent/src/modes/components/index.ts b/packages/coding-agent/src/modes/components/index.ts index 3db32ff7a..cd2b08aee 100644 --- a/packages/coding-agent/src/modes/components/index.ts +++ b/packages/coding-agent/src/modes/components/index.ts @@ -1,5 +1,4 @@ // UI Components barrel export -export { ArminComponent } from "./armin"; export { AssistantMessageComponent } from "./assistant-message"; export { BashExecutionComponent } from "./bash-execution"; export { BorderedLoader } from "./bordered-loader"; diff --git a/packages/coding-agent/src/modes/components/login-dialog.ts b/packages/coding-agent/src/modes/components/login-dialog.ts index af27120f3..f79dd9b05 100644 --- a/packages/coding-agent/src/modes/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/components/login-dialog.ts @@ -8,12 +8,12 @@ import { DynamicBorder } from "./dynamic-border"; * Login dialog component - replaces editor during OAuth login flow */ export class LoginDialogComponent extends Container { - private contentContainer: Container; - private input: Input; - private tui: TUI; - private abortController = new AbortController(); - private inputResolver?: (value: string) => void; - private inputRejecter?: (error: Error) => void; + #contentContainer: Container; + #input: Input; + #tui: TUI; + #abortController = new AbortController(); + #inputResolver?: (value: string) => void; + #inputRejecter?: (error: Error) => void; constructor( tui: TUI, @@ -21,7 +21,7 @@ export class LoginDialogComponent extends Container { private onComplete: (success: boolean, message?: string) => void, ) { super(); - this.tui = tui; + this.#tui = tui; const providerInfo = getOAuthProviders().find(p => p.id === providerId); const providerName = providerInfo?.name || providerId; @@ -33,20 +33,20 @@ export class LoginDialogComponent extends Container { this.addChild(new Text(theme.fg("warning", `Login to ${providerName}`), 1, 0)); // Dynamic content area - this.contentContainer = new Container(); - this.addChild(this.contentContainer); + this.#contentContainer = new Container(); + this.addChild(this.#contentContainer); // Input (always present, used when needed) - this.input = new Input(); - this.input.onSubmit = () => { - if (this.inputResolver) { - this.inputResolver(this.input.getValue()); - this.inputResolver = undefined; - this.inputRejecter = undefined; + this.#input = new Input(); + this.#input.onSubmit = () => { + if (this.#inputResolver) { + this.#inputResolver(this.#input.getValue()); + this.#inputResolver = undefined; + this.#inputRejecter = undefined; } }; - this.input.onEscape = () => { - this.cancel(); + this.#input.onEscape = () => { + this.#cancel(); }; // Bottom border @@ -54,15 +54,15 @@ export class LoginDialogComponent extends Container { } get signal(): AbortSignal { - return this.abortController.signal; + return this.#abortController.signal; } - private cancel(): void { - this.abortController.abort(); - if (this.inputRejecter) { - this.inputRejecter(new Error("Login cancelled")); - this.inputResolver = undefined; - this.inputRejecter = undefined; + #cancel(): void { + this.#abortController.abort(); + if (this.#inputRejecter) { + this.#inputRejecter(new Error("Login cancelled")); + this.#inputResolver = undefined; + this.#inputRejecter = undefined; } this.onComplete(false, "Login cancelled"); } @@ -71,17 +71,17 @@ export class LoginDialogComponent extends Container { * Called by onAuth callback - show URL and optional instructions */ showAuth(url: string, instructions?: string): void { - this.contentContainer.clear(); - this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(theme.fg("accent", url), 1, 0)); + this.#contentContainer.clear(); + this.#contentContainer.addChild(new Spacer(1)); + this.#contentContainer.addChild(new Text(theme.fg("accent", url), 1, 0)); const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open"; const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`; - this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0)); if (instructions) { - this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); + this.#contentContainer.addChild(new Spacer(1)); + this.#contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); } // Try to open browser using $ @@ -100,24 +100,24 @@ export class LoginDialogComponent extends Container { } })(); - this.tui.requestRender(); + this.#tui.requestRender(); } /** * Show input for manual code/URL entry (for callback server providers) */ showManualInput(prompt: string): Promise { - this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); - if (!this.contentContainer.children.includes(this.input)) { - this.contentContainer.addChild(this.input); + this.#contentContainer.addChild(new Spacer(1)); + this.#contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); + if (!this.#contentContainer.children.includes(this.#input)) { + this.#contentContainer.addChild(this.#input); } - this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); - this.tui.requestRender(); + this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); + this.#tui.requestRender(); const { promise, resolve, reject } = Promise.withResolvers(); - this.inputResolver = resolve; - this.inputRejecter = reject; + this.#inputResolver = resolve; + this.#inputRejecter = reject; return promise; } @@ -126,22 +126,22 @@ export class LoginDialogComponent extends Container { * Note: Does NOT clear content, appends to existing (preserves URL from showAuth) */ showPrompt(message: string, placeholder?: string): Promise { - this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(theme.fg("text", message), 1, 0)); + this.#contentContainer.addChild(new Spacer(1)); + this.#contentContainer.addChild(new Text(theme.fg("text", message), 1, 0)); if (placeholder) { - this.contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0)); } - if (!this.contentContainer.children.includes(this.input)) { - this.contentContainer.addChild(this.input); + if (!this.#contentContainer.children.includes(this.#input)) { + this.#contentContainer.addChild(this.#input); } - this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel, Enter to submit)"), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel, Enter to submit)"), 1, 0)); - this.input.setValue(""); - this.tui.requestRender(); + this.#input.setValue(""); + this.#tui.requestRender(); const { promise, resolve, reject } = Promise.withResolvers(); - this.inputResolver = resolve; - this.inputRejecter = reject; + this.#inputResolver = resolve; + this.#inputRejecter = reject; return promise; } @@ -149,29 +149,29 @@ export class LoginDialogComponent extends Container { * Show waiting message (for polling flows like GitHub Copilot) */ showWaiting(message: string): void { - this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); - this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); - this.tui.requestRender(); + this.#contentContainer.addChild(new Spacer(1)); + this.#contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); + this.#contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); + this.#tui.requestRender(); } /** * Called by onProgress callback */ showProgress(message: string): void { - this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); - this.tui.requestRender(); + this.#contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); + this.#tui.requestRender(); } handleInput(data: string): void { const kb = getEditorKeybindings(); if (kb.matches(data, "selectCancel")) { - this.cancel(); + this.#cancel(); return; } // Pass to input - this.input.handleInput(data); + this.#input.handleInput(data); } } diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index a81cfce9b..cb43544a8 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -61,31 +61,31 @@ function getTabBarTheme(): TabBarTheme { * - Escape: Close menu or selector */ export class ModelSelectorComponent extends Container { - private searchInput: Input; - private headerContainer: Container; - private tabBar: TabBar | null = null; - private listContainer: Container; - private menuContainer: Container; - private allModels: ModelItem[] = []; - private filteredModels: ModelItem[] = []; - private selectedIndex: number = 0; - private roles: { [key in ModelRole]?: Model } = {}; - private settings: Settings; - private modelRegistry: ModelRegistry; - private onSelectCallback: (model: Model, role: ModelRole | null) => void; - private onCancelCallback: () => void; - private errorMessage?: unknown; - private tui: TUI; - private scopedModels: ReadonlyArray; - private temporaryOnly: boolean; + #searchInput: Input; + #headerContainer: Container; + #tabBar: TabBar | null = null; + #listContainer: Container; + #menuContainer: Container; + #allModels: ModelItem[] = []; + #filteredModels: ModelItem[] = []; + #selectedIndex: number = 0; + #roles: { [key in ModelRole]?: Model } = {}; + #settings: Settings; + #modelRegistry: ModelRegistry; + #onSelectCallback: (model: Model, role: ModelRole | null) => void; + #onCancelCallback: () => void; + #errorMessage?: unknown; + #tui: TUI; + #scopedModels: ReadonlyArray; + #temporaryOnly: boolean; // Tab state - private providers: string[] = [ALL_TAB]; - private activeTabIndex: number = 0; + #providers: string[] = [ALL_TAB]; + #activeTabIndex: number = 0; // Context menu state - private isMenuOpen: boolean = false; - private menuSelectedIndex: number = 0; + #isMenuOpen: boolean = false; + #menuSelectedIndex: number = 0; constructor( tui: TUI, @@ -99,17 +99,17 @@ export class ModelSelectorComponent extends Container { ) { super(); - this.tui = tui; - this.settings = settings; - this.modelRegistry = modelRegistry; - this.scopedModels = scopedModels; - this.onSelectCallback = onSelect; - this.onCancelCallback = onCancel; - this.temporaryOnly = options?.temporaryOnly ?? false; + this.#tui = tui; + this.#settings = settings; + this.#modelRegistry = modelRegistry; + this.#scopedModels = scopedModels; + this.#onSelectCallback = onSelect; + this.#onCancelCallback = onCancel; + this.#temporaryOnly = options?.temporaryOnly ?? false; const initialSearchInput = options?.initialSearchInput; // Load current role assignments from settings - this._loadRoleModels(); + this.#loadRoleModels(); // Add top border this.addChild(new DynamicBorder()); @@ -124,33 +124,33 @@ export class ModelSelectorComponent extends Container { this.addChild(new Spacer(1)); // Create header container for tab bar - this.headerContainer = new Container(); - this.addChild(this.headerContainer); + this.#headerContainer = new Container(); + this.addChild(this.#headerContainer); this.addChild(new Spacer(1)); // Create search input - this.searchInput = new Input(); + this.#searchInput = new Input(); if (initialSearchInput) { - this.searchInput.setValue(initialSearchInput); + this.#searchInput.setValue(initialSearchInput); } - this.searchInput.onSubmit = () => { + this.#searchInput.onSubmit = () => { // Enter on search input opens menu if we have a selection - if (this.filteredModels[this.selectedIndex]) { - this.openMenu(); + if (this.#filteredModels[this.#selectedIndex]) { + this.#openMenu(); } }; - this.addChild(this.searchInput); + this.addChild(this.#searchInput); this.addChild(new Spacer(1)); // Create list container - this.listContainer = new Container(); - this.addChild(this.listContainer); + this.#listContainer = new Container(); + this.addChild(this.#listContainer); // Create menu container (hidden by default) - this.menuContainer = new Container(); - this.addChild(this.menuContainer); + this.#menuContainer = new Container(); + this.addChild(this.#menuContainer); this.addChild(new Spacer(1)); @@ -158,44 +158,44 @@ export class ModelSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Load models and do initial render - this.loadModels().then(() => { - this.buildProviderTabs(); - this.updateTabBar(); + this.#loadModels().then(() => { + this.#buildProviderTabs(); + this.#updateTabBar(); if (initialSearchInput) { - this.filterModels(initialSearchInput); + this.#filterModels(initialSearchInput); } else { - this.updateList(); + this.#updateList(); } // Request re-render after models are loaded - this.tui.requestRender(); + this.#tui.requestRender(); }); } - private _loadRoleModels(): void { - const allModels = this.modelRegistry.getAll(); + #loadRoleModels(): void { + const allModels = this.#modelRegistry.getAll(); for (const role of MODEL_ROLE_IDS) { - const modelId = this.settings.getModelRole(role); + const modelId = this.#settings.getModelRole(role); if (!modelId) continue; const parsed = parseModelString(modelId); if (parsed) { const model = allModels.find(m => m.provider === parsed.provider && m.id === parsed.id); if (model) { - this.roles[role] = model; + this.#roles[role] = model; } } } } - private sortModels(models: ModelItem[]): void { + #sortModels(models: ModelItem[]): void { // Sort: tagged models (default/smol/slow/plan) first, then MRU, then alphabetical - const mruOrder = this.settings.getStorage()?.getModelUsageOrder() ?? []; + const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? []; const mruIndex = new Map(mruOrder.map((key, i) => [key, i])); const modelRank = (model: ModelItem) => { let i = 0; while (i < MODEL_ROLE_IDS.length) { const role = MODEL_ROLE_IDS[i]; - if (this.roles[role] && modelsAreEqual(this.roles[role], model.model)) { + if (this.#roles[role] && modelsAreEqual(this.#roles[role], model.model)) { break; } i++; @@ -223,140 +223,140 @@ export class ModelSelectorComponent extends Container { }); } - private async loadModels(): Promise { + async #loadModels(): Promise { let models: ModelItem[]; // Use scoped models if provided via --models flag - if (this.scopedModels.length > 0) { - models = this.scopedModels.map(scoped => ({ + if (this.#scopedModels.length > 0) { + models = this.#scopedModels.map(scoped => ({ provider: scoped.model.provider, id: scoped.model.id, model: scoped.model, })); } else { // Refresh to pick up any changes to models.json - await this.modelRegistry.refresh(); + await this.#modelRegistry.refresh(); // Check for models.json errors - const loadError = this.modelRegistry.getError(); + const loadError = this.#modelRegistry.getError(); if (loadError) { - this.errorMessage = loadError; + this.#errorMessage = loadError; } // Load available models (built-in models still work even if models.json failed) try { - const availableModels = this.modelRegistry.getAvailable(); + const availableModels = this.#modelRegistry.getAvailable(); models = availableModels.map((model: Model) => ({ provider: model.provider, id: model.id, model, })); } catch (error) { - this.allModels = []; - this.filteredModels = []; - this.errorMessage = error instanceof Error ? error.message : String(error); + this.#allModels = []; + this.#filteredModels = []; + this.#errorMessage = error instanceof Error ? error.message : String(error); return; } } - this.sortModels(models); + this.#sortModels(models); - this.allModels = models; - this.filteredModels = models; - this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, models.length - 1)); + this.#allModels = models; + this.#filteredModels = models; + this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, models.length - 1)); } - private buildProviderTabs(): void { + #buildProviderTabs(): void { // Extract unique providers from models const providerSet = new Set(); - for (const item of this.allModels) { + for (const item of this.#allModels) { providerSet.add(item.provider.toUpperCase()); } // Sort providers alphabetically const sortedProviders = Array.from(providerSet).sort(); - this.providers = [ALL_TAB, ...sortedProviders]; + this.#providers = [ALL_TAB, ...sortedProviders]; } - private updateTabBar(): void { - this.headerContainer.clear(); + #updateTabBar(): void { + this.#headerContainer.clear(); - const tabs: Tab[] = this.providers.map(provider => ({ id: provider, label: provider })); - const tabBar = new TabBar("Models", tabs, getTabBarTheme(), this.activeTabIndex); + const tabs: Tab[] = this.#providers.map(provider => ({ id: provider, label: provider })); + const tabBar = new TabBar("Models", tabs, getTabBarTheme(), this.#activeTabIndex); tabBar.onTabChange = (_tab, index) => { - this.activeTabIndex = index; - this.selectedIndex = 0; - this.applyTabFilter(); + this.#activeTabIndex = index; + this.#selectedIndex = 0; + this.#applyTabFilter(); }; - this.tabBar = tabBar; - this.headerContainer.addChild(tabBar); + this.#tabBar = tabBar; + this.#headerContainer.addChild(tabBar); } - private getActiveProvider(): string { - return this.providers[this.activeTabIndex] ?? ALL_TAB; + #getActiveProvider(): string { + return this.#providers[this.#activeTabIndex] ?? ALL_TAB; } - private filterModels(query: string): void { - const activeProvider = this.getActiveProvider(); + #filterModels(query: string): void { + const activeProvider = this.#getActiveProvider(); // Start with all models or filter by provider - let baseModels = this.allModels; + let baseModels = this.#allModels; if (activeProvider !== ALL_TAB) { - baseModels = this.allModels.filter(m => m.provider.toUpperCase() === activeProvider); + baseModels = this.#allModels.filter(m => m.provider.toUpperCase() === activeProvider); } // Apply fuzzy filter if query is present if (query.trim()) { // If user is searching, auto-switch to ALL tab to show global results if (activeProvider !== ALL_TAB) { - this.activeTabIndex = 0; - if (this.tabBar && this.tabBar.getActiveIndex() !== 0) { - this.tabBar.setActiveIndex(0); + this.#activeTabIndex = 0; + if (this.#tabBar && this.#tabBar.getActiveIndex() !== 0) { + this.#tabBar.setActiveIndex(0); return; } - this.updateTabBar(); - baseModels = this.allModels; + this.#updateTabBar(); + baseModels = this.#allModels; } const fuzzyMatches = fuzzyFilter(baseModels, query, ({ id, provider }) => `${id} ${provider}`); - this.sortModels(fuzzyMatches); - this.filteredModels = fuzzyMatches; + this.#sortModels(fuzzyMatches); + this.#filteredModels = fuzzyMatches; } else { - this.filteredModels = baseModels; + this.#filteredModels = baseModels; } - this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1)); - this.updateList(); + this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredModels.length - 1)); + this.#updateList(); } - private applyTabFilter(): void { - const query = this.searchInput.getValue(); - this.filterModels(query); + #applyTabFilter(): void { + const query = this.#searchInput.getValue(); + this.#filterModels(query); } - private updateList(): void { - this.listContainer.clear(); + #updateList(): void { + this.#listContainer.clear(); const maxVisible = 10; const startIndex = Math.max( 0, - Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible), + Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), this.#filteredModels.length - maxVisible), ); - const endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length); + const endIndex = Math.min(startIndex + maxVisible, this.#filteredModels.length); - const activeProvider = this.getActiveProvider(); + const activeProvider = this.#getActiveProvider(); const showProvider = activeProvider === ALL_TAB; // Show visible slice of filtered models for (let i = startIndex; i < endIndex; i++) { - const item = this.filteredModels[i]; + const item = this.#filteredModels[i]; if (!item) continue; - const isSelected = i === this.selectedIndex; + const isSelected = i === this.#selectedIndex; // Build role badges (inverted: color as background, black text) const badges: string[] = []; for (const role of MODEL_ROLE_IDS) { const { tag, color } = MODEL_ROLES[role]; - if (tag && modelsAreEqual(this.roles[role], item.model)) { + if (tag && modelsAreEqual(this.#roles[role], item.model)) { badges.push(makeInvertedBadge(tag, color ?? "success")); } } @@ -381,53 +381,53 @@ export class ModelSelectorComponent extends Container { } } - this.listContainer.addChild(new Text(line, 0, 0)); + this.#listContainer.addChild(new Text(line, 0, 0)); } // Add scroll indicator if needed - if (startIndex > 0 || endIndex < this.filteredModels.length) { - const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`); - this.listContainer.addChild(new Text(scrollInfo, 0, 0)); + if (startIndex > 0 || endIndex < this.#filteredModels.length) { + const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.#filteredModels.length})`); + this.#listContainer.addChild(new Text(scrollInfo, 0, 0)); } // Show error message or "no results" if empty - if (this.errorMessage) { - const errorLines = String(this.errorMessage).split("\n"); + if (this.#errorMessage) { + const errorLines = String(this.#errorMessage).split("\n"); for (const line of errorLines) { - this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0)); + this.#listContainer.addChild(new Text(theme.fg("error", line), 0, 0)); } - } else if (this.filteredModels.length === 0) { - this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0)); + } else if (this.#filteredModels.length === 0) { + this.#listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0)); } else { - const selected = this.filteredModels[this.selectedIndex]; - this.listContainer.addChild(new Spacer(1)); - this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0)); + const selected = this.#filteredModels[this.#selectedIndex]; + this.#listContainer.addChild(new Spacer(1)); + this.#listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0)); } } - private openMenu(): void { - if (this.filteredModels.length === 0) return; + #openMenu(): void { + if (this.#filteredModels.length === 0) return; - this.isMenuOpen = true; - this.menuSelectedIndex = 0; - this.updateMenu(); + this.#isMenuOpen = true; + this.#menuSelectedIndex = 0; + this.#updateMenu(); } - private closeMenu(): void { - this.isMenuOpen = false; - this.menuContainer.clear(); + #closeMenu(): void { + this.#isMenuOpen = false; + this.#menuContainer.clear(); } - private updateMenu(): void { - this.menuContainer.clear(); + #updateMenu(): void { + this.#menuContainer.clear(); - const selectedModel = this.filteredModels[this.selectedIndex]; + const selectedModel = this.#filteredModels[this.#selectedIndex]; if (!selectedModel) return; const headerText = ` Action for: ${selectedModel.id}`; const hintText = " Enter: confirm Esc: cancel"; const actionLines = MENU_ACTIONS.map((action, index) => { - const prefix = index === this.menuSelectedIndex ? ` ${theme.nav.cursor} ` : " "; + const prefix = index === this.#menuSelectedIndex ? ` ${theme.nav.cursor} ` : " "; return `${prefix}${action.label}`; }); const menuWidth = Math.max( @@ -437,15 +437,15 @@ export class ModelSelectorComponent extends Container { ); // Menu header - this.menuContainer.addChild(new Spacer(1)); - this.menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); - this.menuContainer.addChild(new Text(theme.fg("text", ` Action for: ${theme.bold(selectedModel.id)}`), 0, 0)); - this.menuContainer.addChild(new Spacer(1)); + this.#menuContainer.addChild(new Spacer(1)); + this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); + this.#menuContainer.addChild(new Text(theme.fg("text", ` Action for: ${theme.bold(selectedModel.id)}`), 0, 0)); + this.#menuContainer.addChild(new Spacer(1)); // Menu options for (let i = 0; i < MENU_ACTIONS.length; i++) { const action = MENU_ACTIONS[i]!; - const isSelected = i === this.menuSelectedIndex; + const isSelected = i === this.#menuSelectedIndex; let line: string; if (isSelected) { @@ -453,50 +453,50 @@ export class ModelSelectorComponent extends Container { } else { line = theme.fg("muted", ` ${action.label}`); } - this.menuContainer.addChild(new Text(line, 0, 0)); + this.#menuContainer.addChild(new Text(line, 0, 0)); } - this.menuContainer.addChild(new Spacer(1)); - this.menuContainer.addChild(new Text(theme.fg("dim", hintText), 0, 0)); - this.menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); + this.#menuContainer.addChild(new Spacer(1)); + this.#menuContainer.addChild(new Text(theme.fg("dim", hintText), 0, 0)); + this.#menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0)); } handleInput(keyData: string): void { - if (this.isMenuOpen) { - this.handleMenuInput(keyData); + if (this.#isMenuOpen) { + this.#handleMenuInput(keyData); return; } // Tab bar navigation - if (this.tabBar?.handleInput(keyData)) { + if (this.#tabBar?.handleInput(keyData)) { return; } // Up arrow - navigate list (wrap to bottom when at top) if (matchesKey(keyData, "up")) { - if (this.filteredModels.length === 0) return; - this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1; - this.updateList(); + if (this.#filteredModels.length === 0) return; + this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredModels.length - 1 : this.#selectedIndex - 1; + this.#updateList(); return; } // Down arrow - navigate list (wrap to top when at bottom) if (matchesKey(keyData, "down")) { - if (this.filteredModels.length === 0) return; - this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1; - this.updateList(); + if (this.#filteredModels.length === 0) return; + this.#selectedIndex = this.#selectedIndex === this.#filteredModels.length - 1 ? 0 : this.#selectedIndex + 1; + this.#updateList(); return; } // Enter - open context menu or select directly in temporary mode if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selectedModel = this.filteredModels[this.selectedIndex]; + const selectedModel = this.#filteredModels[this.#selectedIndex]; if (selectedModel) { - if (this.temporaryOnly) { + if (this.#temporaryOnly) { // In temporary mode, skip menu and select directly - this.handleSelect(selectedModel.model, null); + this.#handleSelect(selectedModel.model, null); } else { - this.openMenu(); + this.#openMenu(); } } return; @@ -504,69 +504,69 @@ export class ModelSelectorComponent extends Container { // Escape or Ctrl+C - close selector if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) { - this.onCancelCallback(); + this.#onCancelCallback(); return; } // Pass everything else to search input - this.searchInput.handleInput(keyData); - this.filterModels(this.searchInput.getValue()); + this.#searchInput.handleInput(keyData); + this.#filterModels(this.#searchInput.getValue()); } - private handleMenuInput(keyData: string): void { + #handleMenuInput(keyData: string): void { // Up arrow - navigate menu if (matchesKey(keyData, "up")) { - this.menuSelectedIndex = (this.menuSelectedIndex - 1 + MENU_ACTIONS.length) % MENU_ACTIONS.length; - this.updateMenu(); + this.#menuSelectedIndex = (this.#menuSelectedIndex - 1 + MENU_ACTIONS.length) % MENU_ACTIONS.length; + this.#updateMenu(); return; } // Down arrow - navigate menu if (matchesKey(keyData, "down")) { - this.menuSelectedIndex = (this.menuSelectedIndex + 1) % MENU_ACTIONS.length; - this.updateMenu(); + this.#menuSelectedIndex = (this.#menuSelectedIndex + 1) % MENU_ACTIONS.length; + this.#updateMenu(); return; } // Enter - confirm selection if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selectedModel = this.filteredModels[this.selectedIndex]; - const action = MENU_ACTIONS[this.menuSelectedIndex]; + const selectedModel = this.#filteredModels[this.#selectedIndex]; + const action = MENU_ACTIONS[this.#menuSelectedIndex]; if (selectedModel && action) { - this.handleSelect(selectedModel.model, action.role); - this.closeMenu(); + this.#handleSelect(selectedModel.model, action.role); + this.#closeMenu(); } return; } // Escape or Ctrl+C - close menu only if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) { - this.closeMenu(); + this.#closeMenu(); return; } } - private handleSelect(model: Model, role: ModelRole | null): void { + #handleSelect(model: Model, role: ModelRole | null): void { // For temporary role, don't save to settings - just notify caller if (role === null) { - this.onSelectCallback(model, null); + this.#onSelectCallback(model, null); return; } // Save to settings - this.settings.setModelRole(role, `${model.provider}/${model.id}`); + this.#settings.setModelRole(role, `${model.provider}/${model.id}`); // Update local state for UI - this.roles[role] = model; + this.#roles[role] = model; // Notify caller (for updating agent state if needed) - this.onSelectCallback(model, role); + this.#onSelectCallback(model, role); // Update list to show new badges - this.updateList(); + this.#updateList(); } getSearchInput(): Input { - return this.searchInput; + return this.#searchInput; } } diff --git a/packages/coding-agent/src/modes/components/oauth-selector.ts b/packages/coding-agent/src/modes/components/oauth-selector.ts index afa6063a9..864c4e958 100644 --- a/packages/coding-agent/src/modes/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/components/oauth-selector.ts @@ -8,14 +8,14 @@ import { DynamicBorder } from "./dynamic-border"; * Component that renders an OAuth provider selector */ export class OAuthSelectorComponent extends Container { - private listContainer: Container; - private allProviders: OAuthProviderInfo[] = []; - private selectedIndex: number = 0; - private mode: "login" | "logout"; - private authStorage: AuthStorage; - private onSelectCallback: (providerId: string) => void; - private onCancelCallback: () => void; - private statusMessage: string | undefined; + #listContainer: Container; + #allProviders: OAuthProviderInfo[] = []; + #selectedIndex: number = 0; + #mode: "login" | "logout"; + #authStorage: AuthStorage; + #onSelectCallback: (providerId: string) => void; + #onCancelCallback: () => void; + #statusMessage: string | undefined; constructor( mode: "login" | "logout", @@ -25,13 +25,13 @@ export class OAuthSelectorComponent extends Container { ) { super(); - this.mode = mode; - this.authStorage = authStorage; - this.onSelectCallback = onSelect; - this.onCancelCallback = onCancel; + this.#mode = mode; + this.#authStorage = authStorage; + this.#onSelectCallback = onSelect; + this.#onCancelCallback = onCancel; // Load all OAuth providers - this.loadProviders(); + this.#loadProviders(); // Add top border this.addChild(new DynamicBorder()); @@ -43,8 +43,8 @@ export class OAuthSelectorComponent extends Container { this.addChild(new Spacer(1)); // Create list container - this.listContainer = new Container(); - this.addChild(this.listContainer); + this.#listContainer = new Container(); + this.addChild(this.#listContainer); this.addChild(new Spacer(1)); @@ -52,25 +52,25 @@ export class OAuthSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Initial render - this.updateList(); + this.#updateList(); } - private loadProviders(): void { - this.allProviders = getOAuthProviders(); + #loadProviders(): void { + this.#allProviders = getOAuthProviders(); } - private updateList(): void { - this.listContainer.clear(); + #updateList(): void { + this.#listContainer.clear(); - for (let i = 0; i < this.allProviders.length; i++) { - const provider = this.allProviders[i]; + for (let i = 0; i < this.#allProviders.length; i++) { + const provider = this.#allProviders[i]; if (!provider) continue; - const isSelected = i === this.selectedIndex; + const isSelected = i === this.#selectedIndex; const isAvailable = provider.available; // Check if user is logged in for this provider - const isLoggedIn = this.authStorage.hasOAuth(provider.id); + const isLoggedIn = this.#authStorage.hasOAuth(provider.id); const statusIndicator = isLoggedIn ? theme.fg("success", ` ${theme.status.success} logged in`) : ""; let line = ""; @@ -83,53 +83,53 @@ export class OAuthSelectorComponent extends Container { line = text + statusIndicator; } - this.listContainer.addChild(new TruncatedText(line, 0, 0)); + this.#listContainer.addChild(new TruncatedText(line, 0, 0)); } // Show "no providers" if empty - if (this.allProviders.length === 0) { + if (this.#allProviders.length === 0) { const message = - this.mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first."; - this.listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0)); + this.#mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first."; + this.#listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0)); } - if (this.statusMessage) { - this.listContainer.addChild(new Spacer(1)); - this.listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.statusMessage}`), 0, 0)); + if (this.#statusMessage) { + this.#listContainer.addChild(new Spacer(1)); + this.#listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.#statusMessage}`), 0, 0)); } } handleInput(keyData: string): void { // Up arrow if (matchesKey(keyData, "up")) { - if (this.allProviders.length > 0) { - this.selectedIndex = this.selectedIndex === 0 ? this.allProviders.length - 1 : this.selectedIndex - 1; + if (this.#allProviders.length > 0) { + this.#selectedIndex = this.#selectedIndex === 0 ? this.#allProviders.length - 1 : this.#selectedIndex - 1; } - this.statusMessage = undefined; - this.updateList(); + this.#statusMessage = undefined; + this.#updateList(); } // Down arrow else if (matchesKey(keyData, "down")) { - if (this.allProviders.length > 0) { - this.selectedIndex = this.selectedIndex === this.allProviders.length - 1 ? 0 : this.selectedIndex + 1; + if (this.#allProviders.length > 0) { + this.#selectedIndex = this.#selectedIndex === this.#allProviders.length - 1 ? 0 : this.#selectedIndex + 1; } - this.statusMessage = undefined; - this.updateList(); + this.#statusMessage = undefined; + this.#updateList(); } // Enter else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selectedProvider = this.allProviders[this.selectedIndex]; + const selectedProvider = this.#allProviders[this.#selectedIndex]; if (selectedProvider?.available) { - this.statusMessage = undefined; - this.onSelectCallback(selectedProvider.id); + this.#statusMessage = undefined; + this.#onSelectCallback(selectedProvider.id); } else if (selectedProvider) { - this.statusMessage = "Provider unavailable in this environment."; - this.updateList(); + this.#statusMessage = "Provider unavailable in this environment."; + this.#updateList(); } } // Escape or Ctrl+C else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) { - this.onCancelCallback(); + this.#onCancelCallback(); } } } diff --git a/packages/coding-agent/src/modes/components/plugin-settings.ts b/packages/coding-agent/src/modes/components/plugin-settings.ts index 15c990c49..8c96d05da 100644 --- a/packages/coding-agent/src/modes/components/plugin-settings.ts +++ b/packages/coding-agent/src/modes/components/plugin-settings.ts @@ -36,7 +36,7 @@ export interface PluginListCallbacks { * Selecting a plugin opens its detail view. */ export class PluginListComponent extends Container { - private readonly selectList: SelectList; + readonly #selectList: SelectList; constructor( private readonly plugins: InstalledPlugin[], @@ -57,8 +57,8 @@ export class PluginListComponent extends Container { this.addChild(new DynamicBorder()); // Create empty list that just handles escape - this.selectList = new SelectList([], 1, getSelectListTheme()); - this.selectList.onCancel = callbacks.onCancel; + this.#selectList = new SelectList([], 1, getSelectListTheme()); + this.#selectList.onCancel = callbacks.onCancel; return; } @@ -81,25 +81,25 @@ export class PluginListComponent extends Container { }; }); - this.selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme()); + this.#selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme()); - this.selectList.onSelect = item => { + this.#selectList.onSelect = item => { const plugin = this.plugins.find(p => p.name === item.value); if (plugin) { callbacks.onPluginSelect(plugin); } }; - this.selectList.onCancel = callbacks.onCancel; + this.#selectList.onCancel = callbacks.onCancel; - this.addChild(this.selectList); + this.addChild(this.#selectList); this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("dim", " Enter to configure · Esc to go back"), 0, 0)); this.addChild(new DynamicBorder()); } handleInput(data: string): void { - this.selectList.handleInput(data); + this.#selectList.handleInput(data); } } @@ -121,7 +121,7 @@ export interface PluginDetailCallbacks { * - Config settings */ export class PluginDetailComponent extends Container { - private settingsList!: SettingsList; + #settingsList!: SettingsList; constructor( private plugin: InstalledPlugin, @@ -130,10 +130,10 @@ export class PluginDetailComponent extends Container { ) { super(); - void this.rebuild(); + void this.#rebuild(); } - private async rebuild(): Promise { + async #rebuild(): Promise { this.clear(); const plugin = this.plugin; @@ -239,7 +239,7 @@ export class PluginDetailComponent extends Container { } } - this.settingsList = new SettingsList( + this.#settingsList = new SettingsList( items, Math.min(items.length, 10), getSettingsListTheme(), @@ -269,15 +269,15 @@ export class PluginDetailComponent extends Container { this.callbacks.onBack, ); - this.addChild(this.settingsList); + this.addChild(this.#settingsList); this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("dim", " Enter to edit · Esc to go back"), 0, 0)); this.addChild(new DynamicBorder()); } handleInput(data: string): void { - if (!this.settingsList) return; - this.settingsList.handleInput(data); + if (!this.#settingsList) return; + this.#settingsList.handleInput(data); } } @@ -289,7 +289,7 @@ export class PluginDetailComponent extends Container { * Submenu for enum config values. */ class ConfigEnumSubmenu extends Container { - private selectList: SelectList; + #selectList: SelectList; constructor( key: string, @@ -309,23 +309,23 @@ class ConfigEnumSubmenu extends Container { this.addChild(new Spacer(1)); const items: SelectItem[] = values.map(v => ({ value: v, label: v })); - this.selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme()); + this.#selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme()); const currentIndex = values.indexOf(currentValue); if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); + this.#selectList.setSelectedIndex(currentIndex); } - this.selectList.onSelect = item => onSelect(item.value); - this.selectList.onCancel = onCancel; + this.#selectList.onSelect = item => onSelect(item.value); + this.#selectList.onCancel = onCancel; - this.addChild(this.selectList); + this.addChild(this.#selectList); this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to cancel"), 0, 0)); } handleInput(data: string): void { - this.selectList.handleInput(data); + this.#selectList.handleInput(data); } } @@ -333,7 +333,7 @@ class ConfigEnumSubmenu extends Container { * Submenu for string/number config values with text input. */ class ConfigInputSubmenu extends Container { - private input: Input; + #input: Input; constructor( key: string, @@ -364,12 +364,12 @@ class ConfigInputSubmenu extends Container { this.addChild(new Spacer(1)); // Input field - this.input = new Input(); + this.#input = new Input(); if (!schema.secret && currentValue) { - this.input.setValue(currentValue); + this.#input.setValue(currentValue); } - this.input.onSubmit = value => { + this.#input.onSubmit = value => { if (value.trim()) { this.onSubmit(value); } else { @@ -377,7 +377,7 @@ class ConfigInputSubmenu extends Container { } }; - this.addChild(this.input); + this.addChild(this.#input); this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("dim", " Enter to save · Esc to cancel"), 0, 0)); } @@ -387,7 +387,7 @@ class ConfigInputSubmenu extends Container { this.onCancel(); return; } - this.input.handleInput(data); + this.#input.handleInput(data); } } @@ -410,68 +410,68 @@ interface InputHandler { * Manages navigation between plugin list and plugin detail views. */ export class PluginSettingsComponent extends Container { - private manager: PluginManager; - private viewComponent: (Container & InputHandler) | null = null; + #manager: PluginManager; + #viewComponent: (Container & InputHandler) | null = null; // biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management - private currentView: "list" | "detail" = "list"; + #currentView: "list" | "detail" = "list"; // biome-ignore lint/correctness/noUnusedPrivateClassMembers: state tracking for view management - private currentPlugin: InstalledPlugin | null = null; + #currentPlugin: InstalledPlugin | null = null; constructor( cwd: string, private readonly callbacks: PluginSettingsCallbacks, ) { super(); - this.manager = new PluginManager(cwd); - this.showPluginList(); + this.#manager = new PluginManager(cwd); + this.#showPluginList(); } - private async showPluginList(): Promise { - this.currentView = "list"; - this.currentPlugin = null; + async #showPluginList(): Promise { + this.#currentView = "list"; + this.#currentPlugin = null; this.clear(); - const plugins = await this.manager.list(); + const plugins = await this.#manager.list(); - this.viewComponent = new PluginListComponent(plugins, { - onPluginSelect: plugin => this.showPluginDetail(plugin), + this.#viewComponent = new PluginListComponent(plugins, { + onPluginSelect: plugin => this.#showPluginDetail(plugin), onCancel: () => this.callbacks.onClose(), }); - this.addChild(this.viewComponent); + this.addChild(this.#viewComponent); } - private showPluginDetail(plugin: InstalledPlugin): void { - this.currentView = "detail"; - this.currentPlugin = plugin; + #showPluginDetail(plugin: InstalledPlugin): void { + this.#currentView = "detail"; + this.#currentPlugin = plugin; this.clear(); - this.viewComponent = new PluginDetailComponent(plugin, this.manager, { + this.#viewComponent = new PluginDetailComponent(plugin, this.#manager, { onEnabledChange: async enabled => { - await this.manager.setEnabled(plugin.name, enabled); + await this.#manager.setEnabled(plugin.name, enabled); this.callbacks.onPluginChanged(); }, onFeatureChange: async (feature, enabled) => { - const current = new Set((await this.manager.getEnabledFeatures(plugin.name)) ?? []); + const current = new Set((await this.#manager.getEnabledFeatures(plugin.name)) ?? []); if (enabled) { current.add(feature); } else { current.delete(feature); } - await this.manager.setEnabledFeatures(plugin.name, [...current]); + await this.#manager.setEnabledFeatures(plugin.name, [...current]); this.callbacks.onPluginChanged(); }, onConfigChange: async (key, value) => { - await this.manager.setPluginSetting(plugin.name, key, value); + await this.#manager.setPluginSetting(plugin.name, key, value); this.callbacks.onPluginChanged(); }, - onBack: () => this.showPluginList(), + onBack: () => this.#showPluginList(), }); - this.addChild(this.viewComponent); + this.addChild(this.#viewComponent); } handleInput(data: string): void { - this.viewComponent?.handleInput(data); + this.#viewComponent?.handleInput(data); } } diff --git a/packages/coding-agent/src/modes/components/python-execution.ts b/packages/coding-agent/src/modes/components/python-execution.ts index 6b6106090..f2d3a90d3 100644 --- a/packages/coding-agent/src/modes/components/python-execution.ts +++ b/packages/coding-agent/src/modes/components/python-execution.ts @@ -12,15 +12,15 @@ import { truncateToVisualLines } from "./visual-truncate"; const PREVIEW_LINES = 20; export class PythonExecutionComponent extends Container { - private outputLines: string[] = []; - private status: "running" | "complete" | "cancelled" | "error" = "running"; - private exitCode: number | undefined = undefined; - private loader: Loader; - private truncation?: TruncationMeta; - private expanded = false; - private contentContainer: Container; + #outputLines: string[] = []; + #status: "running" | "complete" | "cancelled" | "error" = "running"; + #exitCode: number | undefined = undefined; + #loader: Loader; + #truncation?: TruncationMeta; + #expanded = false; + #contentContainer: Container; - private formatHeader(colorKey: "dim" | "pythonMode"): Text { + #formatHeader(colorKey: "dim" | "pythonMode"): Text { const prompt = theme.fg(colorKey, theme.bold(">>>")); const continuation = theme.fg(colorKey, " "); const codeLines = highlightCode(this.code, "python"); @@ -43,44 +43,44 @@ export class PythonExecutionComponent extends Container { this.addChild(new Spacer(1)); this.addChild(new DynamicBorder(borderColor)); - this.contentContainer = new Container(); - this.addChild(this.contentContainer); - this.contentContainer.addChild(this.formatHeader(colorKey)); + this.#contentContainer = new Container(); + this.addChild(this.#contentContainer); + this.#contentContainer.addChild(this.#formatHeader(colorKey)); - this.loader = new Loader( + this.#loader = new Loader( ui, spinner => theme.fg(colorKey, spinner), text => theme.fg("muted", text), `Running… (esc to cancel)`, getSymbolTheme().spinnerFrames, ); - this.contentContainer.addChild(this.loader); + this.#contentContainer.addChild(this.#loader); this.addChild(new DynamicBorder(borderColor)); } setExpanded(expanded: boolean): void { - this.expanded = expanded; - this.updateDisplay(); + this.#expanded = expanded; + this.#updateDisplay(); } override invalidate(): void { super.invalidate(); - this.updateDisplay(); + this.#updateDisplay(); } appendOutput(chunk: string): void { - const clean = this.normalizeOutput(chunk); + const clean = this.#normalizeOutput(chunk); const newLines = clean.split("\n"); - if (this.outputLines.length > 0 && newLines.length > 0) { - this.outputLines[this.outputLines.length - 1] += newLines[0]; - this.outputLines.push(...newLines.slice(1)); + if (this.#outputLines.length > 0 && newLines.length > 0) { + this.#outputLines[this.#outputLines.length - 1] += newLines[0]; + this.#outputLines.push(...newLines.slice(1)); } else { - this.outputLines.push(...newLines); + this.#outputLines.push(...newLines); } - this.updateDisplay(); + this.#updateDisplay(); } setComplete( @@ -88,39 +88,39 @@ export class PythonExecutionComponent extends Container { cancelled: boolean, options?: { output?: string; truncation?: TruncationMeta }, ): void { - this.exitCode = exitCode; - this.status = cancelled + this.#exitCode = exitCode; + this.#status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== undefined && exitCode !== null ? "error" : "complete"; - this.truncation = options?.truncation; + this.#truncation = options?.truncation; if (options?.output !== undefined) { - this.setOutput(options.output); + this.#setOutput(options.output); } - this.loader.stop(); - this.updateDisplay(); + this.#loader.stop(); + this.#updateDisplay(); } - private updateDisplay(): void { - const availableLines = this.outputLines; + #updateDisplay(): void { + const availableLines = this.#outputLines; const previewLogicalLines = availableLines.slice(-PREVIEW_LINES); const hiddenLineCount = availableLines.length - previewLogicalLines.length; - this.contentContainer.clear(); + this.#contentContainer.clear(); const colorKey = this.excludeFromContext ? "dim" : "pythonMode"; - this.contentContainer.addChild(this.formatHeader(colorKey)); + this.#contentContainer.addChild(this.#formatHeader(colorKey)); if (availableLines.length > 0) { - if (this.expanded) { + if (this.#expanded) { const displayText = availableLines.map(line => theme.fg("muted", line)).join("\n"); - this.contentContainer.addChild(new Text(`\n${displayText}`, 1, 0)); + this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0)); } else { const styledOutput = previewLogicalLines.map(line => theme.fg("muted", line)).join("\n"); const previewText = `\n${styledOutput}`; - this.contentContainer.addChild({ + this.#contentContainer.addChild({ render: (width: number) => { const { visualLines } = truncateToVisualLines(previewText, PREVIEW_LINES, width, 1); return visualLines; @@ -130,8 +130,8 @@ export class PythonExecutionComponent extends Container { } } - if (this.status === "running") { - this.contentContainer.addChild(this.loader); + if (this.#status === "running") { + this.#contentContainer.addChild(this.#loader); } else { const statusParts: string[] = []; @@ -139,46 +139,46 @@ export class PythonExecutionComponent extends Container { statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`)); } - if (this.status === "cancelled") { + if (this.#status === "cancelled") { statusParts.push(theme.fg("warning", "(cancelled)")); - } else if (this.status === "error") { - statusParts.push(theme.fg("error", `(exit ${this.exitCode})`)); + } else if (this.#status === "error") { + statusParts.push(theme.fg("error", `(exit ${this.#exitCode})`)); } - if (this.truncation) { + if (this.#truncation) { const warnings: string[] = []; - if (this.truncation.artifactId) { - warnings.push(`Full output: artifact://${this.truncation.artifactId}`); + if (this.#truncation.artifactId) { + warnings.push(`Full output: artifact://${this.#truncation.artifactId}`); } - if (this.truncation.truncatedBy === "lines") { + if (this.#truncation.truncatedBy === "lines") { warnings.push( - `Truncated: showing ${this.truncation.outputLines} of ${this.truncation.totalLines} lines`, + `Truncated: showing ${this.#truncation.outputLines} of ${this.#truncation.totalLines} lines`, ); } else { warnings.push( - `Truncated: ${this.truncation.outputLines} lines shown (${formatSize(this.truncation.outputBytes)} limit)`, + `Truncated: ${this.#truncation.outputLines} lines shown (${formatSize(this.#truncation.outputBytes)} limit)`, ); } statusParts.push(theme.fg("warning", warnings.join(". "))); } if (statusParts.length > 0) { - this.contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0)); + this.#contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0)); } } } - private normalizeOutput(text: string): string { + #normalizeOutput(text: string): string { return Bun.stripANSI(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } - private setOutput(output: string): void { - const clean = this.normalizeOutput(output); - this.outputLines = clean ? clean.split("\n") : []; + #setOutput(output: string): void { + const clean = this.#normalizeOutput(output); + this.#outputLines = clean ? clean.split("\n") : []; } getOutput(): string { - return this.outputLines.join("\n"); + return this.#outputLines.join("\n"); } getCode(): string { diff --git a/packages/coding-agent/src/modes/components/queue-mode-selector.ts b/packages/coding-agent/src/modes/components/queue-mode-selector.ts index ae631113e..c9a575427 100644 --- a/packages/coding-agent/src/modes/components/queue-mode-selector.ts +++ b/packages/coding-agent/src/modes/components/queue-mode-selector.ts @@ -6,7 +6,7 @@ import { DynamicBorder } from "./dynamic-border"; * Component that renders a queue mode selector with borders */ export class QueueModeSelectorComponent extends Container { - private selectList: SelectList; + #selectList: SelectList; constructor( currentMode: "all" | "one-at-a-time", @@ -28,29 +28,29 @@ export class QueueModeSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Create selector - this.selectList = new SelectList(queueModes, 2, getSelectListTheme()); + this.#selectList = new SelectList(queueModes, 2, getSelectListTheme()); // Preselect current mode const currentIndex = queueModes.findIndex(item => item.value === currentMode); if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); + this.#selectList.setSelectedIndex(currentIndex); } - this.selectList.onSelect = item => { + this.#selectList.onSelect = item => { onSelect(item.value as "all" | "one-at-a-time"); }; - this.selectList.onCancel = () => { + this.#selectList.onCancel = () => { onCancel(); }; - this.addChild(this.selectList); + this.addChild(this.#selectList); // Add bottom border this.addChild(new DynamicBorder()); } getSelectList(): SelectList { - return this.selectList; + return this.#selectList; } } diff --git a/packages/coding-agent/src/modes/components/read-tool-group.ts b/packages/coding-agent/src/modes/components/read-tool-group.ts index 793f36e7f..8ac608def 100644 --- a/packages/coding-agent/src/modes/components/read-tool-group.ts +++ b/packages/coding-agent/src/modes/components/read-tool-group.ts @@ -20,20 +20,20 @@ type ReadEntry = { }; export class ReadToolGroupComponent extends Container implements ToolExecutionHandle { - private entries = new Map(); - private text: Text; + #entries = new Map(); + #text: Text; constructor() { super(); - this.text = new Text("", 0, 0); - this.addChild(this.text); - this.updateDisplay(); + this.#text = new Text("", 0, 0); + this.addChild(this.#text); + this.#updateDisplay(); } updateArgs(args: ReadRenderArgs, toolCallId?: string): void { if (!toolCallId) return; const rawPath = args.file_path || args.path || ""; - const entry: ReadEntry = this.entries.get(toolCallId) ?? { + const entry: ReadEntry = this.#entries.get(toolCallId) ?? { toolCallId, path: rawPath, offset: args.offset, @@ -43,8 +43,8 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa entry.path = rawPath; entry.offset = args.offset; entry.limit = args.limit; - this.entries.set(toolCallId, entry); - this.updateDisplay(); + this.#entries.set(toolCallId, entry); + this.#updateDisplay(); } updateResult( @@ -53,38 +53,38 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa toolCallId?: string, ): void { if (!toolCallId) return; - const entry = this.entries.get(toolCallId); + const entry = this.#entries.get(toolCallId); if (!entry) return; if (isPartial) return; entry.status = result.isError ? "error" : "success"; - this.updateDisplay(); + this.#updateDisplay(); } setArgsComplete(_toolCallId?: string): void { - this.updateDisplay(); + this.#updateDisplay(); } setExpanded(_expanded: boolean): void { - this.updateDisplay(); + this.#updateDisplay(); } getComponent(): Component { return this; } - private updateDisplay(): void { - const entries = [...this.entries.values()]; + #updateDisplay(): void { + const entries = [...this.#entries.values()]; if (entries.length === 0) { - this.text.setText(` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))}`); + this.#text.setText(` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))}`); return; } if (entries.length === 1) { const entry = entries[0]; - const statusSymbol = this.formatStatus(entry.status); - const pathDisplay = this.formatPath(entry); - this.text.setText( + const statusSymbol = this.#formatStatus(entry.status); + const pathDisplay = this.#formatPath(entry); + this.#text.setText( ` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay} ${statusSymbol}`.trimEnd(), ); return; @@ -95,15 +95,15 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa const total = entries.length; for (const [index, entry] of entries.entries()) { const connector = index === total - 1 ? theme.tree.last : theme.tree.branch; - const statusSymbol = this.formatStatus(entry.status); - const pathDisplay = this.formatPath(entry); + const statusSymbol = this.#formatStatus(entry.status); + const pathDisplay = this.#formatPath(entry); lines.push(` ${theme.fg("dim", connector)} ${statusSymbol} ${pathDisplay}`.trimEnd()); } - this.text.setText(lines.join("\n")); + this.#text.setText(lines.join("\n")); } - private formatPath(entry: ReadEntry): string { + #formatPath(entry: ReadEntry): string { const filePath = shortenPath(entry.path); let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…"); if (entry.offset !== undefined || entry.limit !== undefined) { @@ -114,7 +114,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa return pathDisplay; } - private formatStatus(status: ReadEntry["status"]): string { + #formatStatus(status: ReadEntry["status"]): string { if (status === "success") { return theme.fg("success", theme.status.success); } diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index 2d4bc3a51..e92e7a92b 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -18,25 +18,25 @@ import { DynamicBorder } from "./dynamic-border"; * Custom session list component with multi-line items and search */ class SessionList implements Component { - private filteredSessions: SessionInfo[] = []; - private selectedIndex: number = 0; - private readonly searchInput: Input; - public onSelect?: (sessionPath: string) => void; - public onCancel?: () => void; - public onExit: () => void = () => {}; - private maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank) + #filteredSessions: SessionInfo[] = []; + #selectedIndex: number = 0; + readonly #searchInput: Input; + onSelect?: (sessionPath: string) => void; + onCancel?: () => void; + onExit: () => void = () => {}; + #maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank) constructor( private readonly allSessions: SessionInfo[], private readonly showCwd = false, ) { - this.filteredSessions = allSessions; - this.searchInput = new Input(); + this.#filteredSessions = allSessions; + this.#searchInput = new Input(); // Handle Enter in search input - select current item - this.searchInput.onSubmit = () => { - if (this.filteredSessions[this.selectedIndex]) { - const selected = this.filteredSessions[this.selectedIndex]; + this.#searchInput.onSubmit = () => { + if (this.#filteredSessions[this.#selectedIndex]) { + const selected = this.#filteredSessions[this.#selectedIndex]; if (this.onSelect) { this.onSelect(selected.path); } @@ -44,8 +44,8 @@ class SessionList implements Component { }; } - private filterSessions(query: string): void { - this.filteredSessions = fuzzyFilter(this.allSessions, query, session => { + #filterSessions(query: string): void { + this.#filteredSessions = fuzzyFilter(this.allSessions, query, session => { const parts = [ session.id, session.title ?? "", @@ -56,7 +56,7 @@ class SessionList implements Component { ]; return parts.filter(Boolean).join(" "); }); - this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); + this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1)); } invalidate(): void { @@ -67,10 +67,10 @@ class SessionList implements Component { const lines: string[] = []; // Render search input - lines.push(...this.searchInput.render(width)); + lines.push(...this.#searchInput.render(width)); lines.push(""); // Blank line after search - if (this.filteredSessions.length === 0) { + if (this.#filteredSessions.length === 0) { if (this.showCwd) { // "All" scope - no sessions anywhere that match filter lines.push(truncateToWidth(theme.fg("muted", " No sessions found"), width)); @@ -103,14 +103,17 @@ class SessionList implements Component { // Calculate visible range with scrolling const startIndex = Math.max( 0, - Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredSessions.length - this.maxVisible), + Math.min( + this.#selectedIndex - Math.floor(this.#maxVisible / 2), + this.#filteredSessions.length - this.#maxVisible, + ), ); - const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length); + const endIndex = Math.min(startIndex + this.#maxVisible, this.#filteredSessions.length); // Render visible sessions (2-3 lines per session + blank line) for (let i = startIndex; i < endIndex; i++) { - const session = this.filteredSessions[i]; - const isSelected = i === this.selectedIndex; + const session = this.#filteredSessions[i]; + const isSelected = i === this.#selectedIndex; // Normalize first message to single line const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim(); @@ -148,8 +151,8 @@ class SessionList implements Component { } // Add scroll indicator if needed - if (startIndex > 0 || endIndex < this.filteredSessions.length) { - const scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`; + if (startIndex > 0 || endIndex < this.#filteredSessions.length) { + const scrollText = ` (${this.#selectedIndex + 1}/${this.#filteredSessions.length})`; const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width)); lines.push(scrollInfo); } @@ -160,23 +163,23 @@ class SessionList implements Component { handleInput(keyData: string): void { // Up arrow if (matchesKey(keyData, "up")) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.#selectedIndex = Math.max(0, this.#selectedIndex - 1); } // Down arrow else if (matchesKey(keyData, "down")) { - this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1); + this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + 1); } // Page up - jump up by maxVisible items else if (matchesKey(keyData, "pageUp")) { - this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible); + this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#maxVisible); } // Page down - jump down by maxVisible items else if (matchesKey(keyData, "pageDown")) { - this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + this.maxVisible); + this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + this.#maxVisible); } // Enter else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selected = this.filteredSessions[this.selectedIndex]; + const selected = this.#filteredSessions[this.#selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.path); } @@ -193,8 +196,8 @@ class SessionList implements Component { } // Pass everything else to search input else { - this.searchInput.handleInput(keyData); - this.filterSessions(this.searchInput.getValue()); + this.#searchInput.handleInput(keyData); + this.#filterSessions(this.#searchInput.getValue()); } } } @@ -203,7 +206,7 @@ class SessionList implements Component { * Component that renders a session selector */ export class SessionSelectorComponent extends Container { - private sessionList: SessionList; + #sessionList: SessionList; constructor( sessions: SessionInfo[], @@ -221,12 +224,12 @@ export class SessionSelectorComponent extends Container { this.addChild(new Spacer(1)); // Create session list - this.sessionList = new SessionList(sessions); - this.sessionList.onSelect = onSelect; - this.sessionList.onCancel = onCancel; - this.sessionList.onExit = onExit; + this.#sessionList = new SessionList(sessions); + this.#sessionList.onSelect = onSelect; + this.#sessionList.onCancel = onCancel; + this.#sessionList.onExit = onExit; - this.addChild(this.sessionList); + this.addChild(this.#sessionList); // Add bottom border this.addChild(new Spacer(1)); @@ -234,6 +237,6 @@ export class SessionSelectorComponent extends Container { } getSessionList(): SessionList { - return this.sessionList; + return this.#sessionList; } } diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index 8e7a0d387..69442ec20 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -39,8 +39,8 @@ function getTabBarTheme(): TabBarTheme { * A submenu component for selecting from a list of options. */ class SelectSubmenu extends Container { - private selectList: SelectList; - private previewText: Text | null = null; + #selectList: SelectList; + #previewText: Text | null = null; constructor( title: string, @@ -67,51 +67,51 @@ class SelectSubmenu extends Container { if (getPreview) { this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("muted", "Preview:"), 0, 0)); - this.previewText = new Text(getPreview(), 0, 0); - this.addChild(this.previewText); + this.#previewText = new Text(getPreview(), 0, 0); + this.addChild(this.#previewText); } // Spacer this.addChild(new Spacer(1)); // Select list - this.selectList = new SelectList(options, Math.min(options.length, 10), getSelectListTheme()); + this.#selectList = new SelectList(options, Math.min(options.length, 10), getSelectListTheme()); // Pre-select current value const currentIndex = options.findIndex(o => o.value === currentValue); if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); + this.#selectList.setSelectedIndex(currentIndex); } - this.selectList.onSelect = item => { + this.#selectList.onSelect = item => { onSelect(item.value); }; - this.selectList.onCancel = onCancel; + this.#selectList.onCancel = onCancel; if (onSelectionChange) { - this.selectList.onSelectionChange = item => { + this.#selectList.onSelectionChange = item => { onSelectionChange(item.value); // Update preview after the preview callback has applied changes - this.updatePreview(); + this.#updatePreview(); }; } - this.addChild(this.selectList); + this.addChild(this.#selectList); // Hint this.addChild(new Spacer(1)); this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); } - private updatePreview(): void { - if (this.previewText && this.getPreview) { - this.previewText.setText(this.getPreview()); + #updatePreview(): void { + if (this.#previewText && this.getPreview) { + this.#previewText.setText(this.getPreview()); } } handleInput(data: string): void { - this.selectList.handleInput(data); + this.#selectList.handleInput(data); } } @@ -169,13 +169,13 @@ export interface SettingsCallbacks { * Uses declarative settings definitions from settings-defs.ts. */ export class SettingsSelectorComponent extends Container { - private tabBar: TabBar; - private currentList: SettingsList | null = null; - private currentSubmenu: Container | null = null; - private pluginComponent: PluginSettingsComponent | null = null; - private statusPreviewContainer: Container | null = null; - private statusPreviewText: Text | null = null; - private currentTabId: SettingTab | "plugins" = "display"; + #tabBar: TabBar; + #currentList: SettingsList | null = null; + #currentSubmenu: Container | null = null; + #pluginComponent: PluginSettingsComponent | null = null; + #statusPreviewContainer: Container | null = null; + #statusPreviewText: Text | null = null; + #currentTabId: SettingTab | "plugins" = "display"; constructor( private readonly context: SettingsRuntimeContext, @@ -187,38 +187,38 @@ export class SettingsSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Tab bar - this.tabBar = new TabBar("Settings", getSettingsTabs(), getTabBarTheme()); - this.tabBar.onTabChange = () => { - this.switchToTab(this.tabBar.getActiveTab().id as SettingTab | "plugins"); + this.#tabBar = new TabBar("Settings", getSettingsTabs(), getTabBarTheme()); + this.#tabBar.onTabChange = () => { + this.#switchToTab(this.#tabBar.getActiveTab().id as SettingTab | "plugins"); }; - this.addChild(this.tabBar); + this.addChild(this.#tabBar); // Spacer after tab bar this.addChild(new Spacer(1)); // Initialize with first tab - this.switchToTab("display"); + this.#switchToTab("display"); // Add bottom border this.addChild(new DynamicBorder()); } - private switchToTab(tabId: SettingTab | "plugins"): void { - this.currentTabId = tabId; + #switchToTab(tabId: SettingTab | "plugins"): void { + this.#currentTabId = tabId; // Remove current content - if (this.currentList) { - this.removeChild(this.currentList); - this.currentList = null; + if (this.#currentList) { + this.removeChild(this.#currentList); + this.#currentList = null; } - if (this.pluginComponent) { - this.removeChild(this.pluginComponent); - this.pluginComponent = null; + if (this.#pluginComponent) { + this.removeChild(this.#pluginComponent); + this.#pluginComponent = null; } - if (this.statusPreviewContainer) { - this.removeChild(this.statusPreviewContainer); - this.statusPreviewContainer = null; - this.statusPreviewText = null; + if (this.#statusPreviewContainer) { + this.removeChild(this.#statusPreviewContainer); + this.#statusPreviewContainer = null; + this.#statusPreviewText = null; } // Remove bottom border temporarily @@ -226,9 +226,9 @@ export class SettingsSelectorComponent extends Container { this.removeChild(bottomBorder); if (tabId === "plugins") { - this.showPluginsTab(); + this.#showPluginsTab(); } else { - this.showSettingsTab(tabId); + this.#showSettingsTab(tabId); } // Re-add bottom border @@ -238,13 +238,13 @@ export class SettingsSelectorComponent extends Container { /** * Convert a setting definition to a SettingItem for the UI. */ - private defToItem(def: SettingDef): SettingItem | null { + #defToItem(def: SettingDef): SettingItem | null { // Check condition if (def.type === "boolean" && def.condition && !def.condition()) { return null; } - const currentValue = this.getCurrentValue(def); + const currentValue = this.#getCurrentValue(def); switch (def.type) { case "boolean": @@ -271,7 +271,7 @@ export class SettingsSelectorComponent extends Container { label: def.label, description: def.description, currentValue: String(currentValue ?? ""), - submenu: (cv, done) => this.createSubmenu(def, cv, done), + submenu: (cv, done) => this.#createSubmenu(def, cv, done), }; } } @@ -279,14 +279,14 @@ export class SettingsSelectorComponent extends Container { /** * Get the current value for a setting. */ - private getCurrentValue(def: SettingDef): unknown { + #getCurrentValue(def: SettingDef): unknown { return settings.get(def.path); } /** * Create a submenu for a submenu-type setting. */ - private createSubmenu( + #createSubmenu( def: SettingDef & { type: "submenu" }, currentValue: string, done: (value?: string) => void, @@ -321,7 +321,7 @@ export class SettingsSelectorComponent extends Container { rightSegments: presetDef.rightSegments, separator: presetDef.separator, }); - this.updateStatusPreview(); + this.#updateStatusPreview(); }; onPreviewCancel = () => { const currentPreset = settings.get("statusLine.preset"); @@ -332,17 +332,17 @@ export class SettingsSelectorComponent extends Container { rightSegments: presetDef.rightSegments, separator: presetDef.separator, }); - this.updateStatusPreview(); + this.#updateStatusPreview(); }; } else if (def.path === "statusLine.separator") { onPreview = value => { this.callbacks.onStatusLinePreview?.({ separator: value as StatusLineSeparatorStyle }); - this.updateStatusPreview(); + this.#updateStatusPreview(); }; onPreviewCancel = () => { const separator = settings.get("statusLine.separator"); this.callbacks.onStatusLinePreview?.({ separator }); - this.updateStatusPreview(); + this.#updateStatusPreview(); }; } @@ -356,7 +356,7 @@ export class SettingsSelectorComponent extends Container { currentValue, value => { // Persist - this.setSettingValue(def.path, value); + this.#setSettingValue(def.path, value); // Notify this.callbacks.onChange(def.path, value); done(value); @@ -373,7 +373,7 @@ export class SettingsSelectorComponent extends Container { /** * Set a setting value, handling type conversion. */ - private setSettingValue(path: SettingPath, value: string): void { + #setSettingValue(path: SettingPath, value: string): void { // Handle number conversions const currentValue = settings.get(path); if (typeof currentValue === "number") { @@ -388,12 +388,12 @@ export class SettingsSelectorComponent extends Container { /** * Show a settings tab using definitions. */ - private showSettingsTab(tabId: SettingTab): void { + #showSettingsTab(tabId: SettingTab): void { const defs = getSettingsForTab(tabId); const items: SettingItem[] = []; for (const def of defs) { - const item = this.defToItem(def); + const item = this.#defToItem(def); if (item) { items.push(item); } @@ -401,16 +401,16 @@ export class SettingsSelectorComponent extends Container { // Add status line preview for status tab if (tabId === "status") { - this.statusPreviewContainer = new Container(); - this.statusPreviewContainer.addChild(new Spacer(1)); - this.statusPreviewContainer.addChild(new Text(theme.fg("muted", "Preview:"), 0, 0)); - this.statusPreviewText = new Text(this.getStatusPreviewString(), 0, 0); - this.statusPreviewContainer.addChild(this.statusPreviewText); - this.statusPreviewContainer.addChild(new Spacer(1)); - this.addChild(this.statusPreviewContainer); + this.#statusPreviewContainer = new Container(); + this.#statusPreviewContainer.addChild(new Spacer(1)); + this.#statusPreviewContainer.addChild(new Text(theme.fg("muted", "Preview:"), 0, 0)); + this.#statusPreviewText = new Text(this.#getStatusPreviewString(), 0, 0); + this.#statusPreviewContainer.addChild(this.#statusPreviewText); + this.#statusPreviewContainer.addChild(new Spacer(1)); + this.addChild(this.#statusPreviewContainer); } - this.currentList = new SettingsList( + this.#currentList = new SettingsList( items, 10, getSettingsListTheme(), @@ -426,7 +426,7 @@ export class SettingsSelectorComponent extends Container { this.callbacks.onChange(path, boolValue); if (tabId === "status") { - this.triggerStatusLinePreview(); + this.#triggerStatusLinePreview(); } } else if (def.type === "enum") { settings.set(path, newValue as never); @@ -437,13 +437,13 @@ export class SettingsSelectorComponent extends Container { () => this.callbacks.onCancel(), ); - this.addChild(this.currentList); + this.addChild(this.#currentList); } /** * Get the status line preview string. */ - private getStatusPreviewString(): string { + #getStatusPreviewString(): string { if (this.callbacks.getStatusLinePreview) { return this.callbacks.getStatusLinePreview(); } @@ -453,7 +453,7 @@ export class SettingsSelectorComponent extends Container { /** * Trigger status line preview with current settings. */ - private triggerStatusLinePreview(): void { + #triggerStatusLinePreview(): void { const statusLineSettings: StatusLinePreviewSettings = { preset: settings.get("statusLine.preset"), leftSegments: settings.get("statusLine.leftSegments"), @@ -461,29 +461,29 @@ export class SettingsSelectorComponent extends Container { separator: settings.get("statusLine.separator"), }; this.callbacks.onStatusLinePreview?.(statusLineSettings); - this.updateStatusPreview(); + this.#updateStatusPreview(); } /** * Update the inline status preview text. */ - private updateStatusPreview(): void { - if (this.statusPreviewText && this.currentTabId === "status") { - this.statusPreviewText.setText(this.getStatusPreviewString()); + #updateStatusPreview(): void { + if (this.#statusPreviewText && this.#currentTabId === "status") { + this.#statusPreviewText.setText(this.#getStatusPreviewString()); } } - private showPluginsTab(): void { - this.pluginComponent = new PluginSettingsComponent(this.context.cwd, { + #showPluginsTab(): void { + this.#pluginComponent = new PluginSettingsComponent(this.context.cwd, { onClose: () => this.callbacks.onCancel(), onPluginChanged: () => this.callbacks.onPluginsChanged?.(), }); - this.addChild(this.pluginComponent); + this.addChild(this.#pluginComponent); } getFocusComponent(): SettingsList | PluginSettingsComponent { // Return the current focusable component - one of these will always be set - return (this.currentList || this.pluginComponent)!; + return (this.#currentList || this.#pluginComponent)!; } handleInput(data: string): void { @@ -494,21 +494,21 @@ export class SettingsSelectorComponent extends Container { matchesKey(data, "left") || matchesKey(data, "right") ) { - this.tabBar.handleInput(data); + this.#tabBar.handleInput(data); return; } // Escape at top level cancels - if ((matchesKey(data, "escape") || matchesKey(data, "esc")) && !this.currentSubmenu) { + if ((matchesKey(data, "escape") || matchesKey(data, "esc")) && !this.#currentSubmenu) { this.callbacks.onCancel(); return; } // Pass to current content - if (this.currentList) { - this.currentList.handleInput(data); - } else if (this.pluginComponent) { - this.pluginComponent.handleInput(data); + if (this.#currentList) { + this.#currentList.handleInput(data); + } else if (this.#pluginComponent) { + this.#pluginComponent.handleInput(data); } } } diff --git a/packages/coding-agent/src/modes/components/show-images-selector.ts b/packages/coding-agent/src/modes/components/show-images-selector.ts index 740519f98..6b8469c8d 100644 --- a/packages/coding-agent/src/modes/components/show-images-selector.ts +++ b/packages/coding-agent/src/modes/components/show-images-selector.ts @@ -6,7 +6,7 @@ import { DynamicBorder } from "./dynamic-border"; * Component that renders a show images selector with borders */ export class ShowImagesSelectorComponent extends Container { - private selectList: SelectList; + #selectList: SelectList; constructor(currentValue: boolean, onSelect: (show: boolean) => void, onCancel: () => void) { super(); @@ -20,26 +20,26 @@ export class ShowImagesSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Create selector - this.selectList = new SelectList(items, 5, getSelectListTheme()); + this.#selectList = new SelectList(items, 5, getSelectListTheme()); // Preselect current value - this.selectList.setSelectedIndex(currentValue ? 0 : 1); + this.#selectList.setSelectedIndex(currentValue ? 0 : 1); - this.selectList.onSelect = item => { + this.#selectList.onSelect = item => { onSelect(item.value === "yes"); }; - this.selectList.onCancel = () => { + this.#selectList.onCancel = () => { onCancel(); }; - this.addChild(this.selectList); + this.addChild(this.#selectList); // Add bottom border this.addChild(new DynamicBorder()); } getSelectList(): SelectList { - return this.selectList; + return this.#selectList; } } diff --git a/packages/coding-agent/src/modes/components/skill-message.ts b/packages/coding-agent/src/modes/components/skill-message.ts index 300cf8e25..3187b30da 100644 --- a/packages/coding-agent/src/modes/components/skill-message.ts +++ b/packages/coding-agent/src/modes/components/skill-message.ts @@ -5,43 +5,43 @@ import { getMarkdownTheme, theme } from "../../modes/theme/theme"; import type { CustomMessage, SkillPromptDetails } from "../../session/messages"; export class SkillMessageComponent extends Container { - private box: Box; - private contentComponent?: Component; - private _expanded = false; + #box: Box; + #contentComponent?: Component; + #expanded = false; constructor(private readonly message: CustomMessage) { super(); this.addChild(new Spacer(1)); - this.box = new Box(1, 1, t => theme.bg("customMessageBg", t)); - this.rebuild(); + 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(); + if (this.#expanded !== expanded) { + this.#expanded = expanded; + this.#rebuild(); } } override invalidate(): void { super.invalidate(); - this.rebuild(); + this.#rebuild(); } - private rebuild(): void { - if (this.contentComponent) { - this.removeChild(this.contentComponent); - this.contentComponent = undefined; + #rebuild(): void { + if (this.#contentComponent) { + this.removeChild(this.#contentComponent); + this.#contentComponent = undefined; } - this.removeChild(this.box); - this.addChild(this.box); - this.box.clear(); + this.removeChild(this.#box); + this.addChild(this.#box); + this.#box.clear(); const label = theme.fg("customMessageLabel", theme.bold("[skill]")); - this.box.addChild(new Text(label, 0, 0)); - this.box.addChild(new Spacer(1)); + this.#box.addChild(new Text(label, 0, 0)); + this.#box.addChild(new Spacer(1)); const details = this.message.details; const args = details?.args?.trim(); @@ -52,33 +52,33 @@ export class SkillMessageComponent extends Container { typeof details?.lineCount === "number" ? `Prompt: ${details.lineCount} lines` : undefined, ].filter((line): line is string => Boolean(line)); - this.box.addChild( + this.#box.addChild( new Markdown(infoLines.join("\n"), 0, 0, getMarkdownTheme(), { color: (value: string) => theme.fg("customMessageText", value), }), ); - if (!this._expanded) { + if (!this.#expanded) { return; } - const text = this.extractText(); + const text = this.#extractText(); if (!text) { return; } - this.box.addChild(new Spacer(1)); + this.#box.addChild(new Spacer(1)); const promptHeader = theme.fg("customMessageLabel", theme.bold("Prompt")); - this.box.addChild(new Text(promptHeader, 0, 0)); - this.box.addChild(new Spacer(1)); + this.#box.addChild(new Text(promptHeader, 0, 0)); + this.#box.addChild(new Spacer(1)); - this.contentComponent = new Markdown(text, 0, 0, getMarkdownTheme(), { + this.#contentComponent = new Markdown(text, 0, 0, getMarkdownTheme(), { color: (value: string) => theme.fg("customMessageText", value), }); - this.box.addChild(this.contentComponent); + this.#box.addChild(this.#contentComponent); } - private extractText(): string { + #extractText(): string { if (typeof this.message.content === "string") { return this.message.content; } diff --git a/packages/coding-agent/src/modes/components/status-line-segment-editor.ts b/packages/coding-agent/src/modes/components/status-line-segment-editor.ts index 4520dd95d..6771fffb9 100644 --- a/packages/coding-agent/src/modes/components/status-line-segment-editor.ts +++ b/packages/coding-agent/src/modes/components/status-line-segment-editor.ts @@ -50,9 +50,9 @@ export interface SegmentEditorCallbacks { } export class StatusLineSegmentEditorComponent extends Container { - private segments: SegmentState[]; - private selectedIndex: number = 0; - private focusColumn: "left" | "right" | "disabled" = "left"; + #segments: SegmentState[]; + #selectedIndex: number = 0; + #focusColumn: "left" | "right" | "disabled" = "left"; constructor( currentLeft: StatusLineSegmentId[], @@ -62,125 +62,125 @@ export class StatusLineSegmentEditorComponent extends Container { super(); // Initialize segment states - this.segments = []; + this.#segments = []; const usedIds = new Set(); // Add left segments in order for (let i = 0; i < currentLeft.length; i++) { const id = currentLeft[i]; - this.segments.push({ id, column: "left", order: i }); + this.#segments.push({ id, column: "left", order: i }); usedIds.add(id); } // Add right segments in order for (let i = 0; i < currentRight.length; i++) { const id = currentRight[i]; - this.segments.push({ id, column: "right", order: i }); + this.#segments.push({ id, column: "right", order: i }); usedIds.add(id); } // Add remaining segments as disabled for (const id of ALL_SEGMENT_IDS) { if (!usedIds.has(id)) { - this.segments.push({ id, column: "disabled", order: 999 }); + this.#segments.push({ id, column: "disabled", order: 999 }); } } // Trigger initial preview - this.triggerPreview(); + this.#triggerPreview(); } - private getSegmentsForColumn(column: Column): SegmentState[] { - return this.segments.filter(s => s.column === column).sort((a, b) => a.order - b.order); + #getSegmentsForColumn(column: Column): SegmentState[] { + return this.#segments.filter(s => s.column === column).sort((a, b) => a.order - b.order); } - private getCurrentColumnSegments(): SegmentState[] { - return this.getSegmentsForColumn(this.focusColumn); + #getCurrentColumnSegments(): SegmentState[] { + return this.#getSegmentsForColumn(this.#focusColumn); } - private triggerPreview(): void { - const left = this.getSegmentsForColumn("left").map(s => s.id); - const right = this.getSegmentsForColumn("right").map(s => s.id); + #triggerPreview(): void { + const left = this.#getSegmentsForColumn("left").map(s => s.id); + const right = this.#getSegmentsForColumn("right").map(s => s.id); this.callbacks.onPreview?.(left, right); } handleInput(data: string): void { - const columnSegments = this.getCurrentColumnSegments(); + const columnSegments = this.#getCurrentColumnSegments(); if (matchesKey(data, "up") || data === "k") { // Move selection up within column, or jump to previous column - if (this.selectedIndex > 0) { - this.selectedIndex--; + if (this.#selectedIndex > 0) { + this.#selectedIndex--; } else { // Jump to previous column - if (this.focusColumn === "disabled") { - const rightSegs = this.getSegmentsForColumn("right"); + if (this.#focusColumn === "disabled") { + const rightSegs = this.#getSegmentsForColumn("right"); if (rightSegs.length > 0) { - this.focusColumn = "right"; - this.selectedIndex = rightSegs.length - 1; + this.#focusColumn = "right"; + this.#selectedIndex = rightSegs.length - 1; } else { - const leftSegs = this.getSegmentsForColumn("left"); + const leftSegs = this.#getSegmentsForColumn("left"); if (leftSegs.length > 0) { - this.focusColumn = "left"; - this.selectedIndex = leftSegs.length - 1; + this.#focusColumn = "left"; + this.#selectedIndex = leftSegs.length - 1; } } - } else if (this.focusColumn === "right") { - const leftSegs = this.getSegmentsForColumn("left"); + } else if (this.#focusColumn === "right") { + const leftSegs = this.#getSegmentsForColumn("left"); if (leftSegs.length > 0) { - this.focusColumn = "left"; - this.selectedIndex = leftSegs.length - 1; + this.#focusColumn = "left"; + this.#selectedIndex = leftSegs.length - 1; } } } } else if (matchesKey(data, "down") || data === "j") { // Move selection down within column, or jump to next column - if (this.selectedIndex < columnSegments.length - 1) { - this.selectedIndex++; + if (this.#selectedIndex < columnSegments.length - 1) { + this.#selectedIndex++; } else { // Jump to next column - if (this.focusColumn === "left") { - const rightSegs = this.getSegmentsForColumn("right"); + if (this.#focusColumn === "left") { + const rightSegs = this.#getSegmentsForColumn("right"); if (rightSegs.length > 0) { - this.focusColumn = "right"; - this.selectedIndex = 0; + this.#focusColumn = "right"; + this.#selectedIndex = 0; } else { - const disabledSegs = this.getSegmentsForColumn("disabled"); + const disabledSegs = this.#getSegmentsForColumn("disabled"); if (disabledSegs.length > 0) { - this.focusColumn = "disabled"; - this.selectedIndex = 0; + this.#focusColumn = "disabled"; + this.#selectedIndex = 0; } } - } else if (this.focusColumn === "right") { - const disabledSegs = this.getSegmentsForColumn("disabled"); + } else if (this.#focusColumn === "right") { + const disabledSegs = this.#getSegmentsForColumn("disabled"); if (disabledSegs.length > 0) { - this.focusColumn = "disabled"; - this.selectedIndex = 0; + this.#focusColumn = "disabled"; + this.#selectedIndex = 0; } } } } else if (matchesKey(data, "tab")) { // Cycle segment: left → right → disabled → left - const seg = columnSegments[this.selectedIndex]; + const seg = columnSegments[this.#selectedIndex]; if (seg) { const oldColumn = seg.column; if (seg.column === "left") { seg.column = "right"; - seg.order = this.getSegmentsForColumn("right").length; + seg.order = this.#getSegmentsForColumn("right").length; } else if (seg.column === "right") { seg.column = "disabled"; seg.order = 999; } else { seg.column = "left"; - seg.order = this.getSegmentsForColumn("left").length; + seg.order = this.#getSegmentsForColumn("left").length; } // Recompact orders in old column - this.recompactColumn(oldColumn); - this.triggerPreview(); + this.#recompactColumn(oldColumn); + this.#triggerPreview(); } } else if (matchesKey(data, "shift+tab")) { // Reverse cycle: left ← right ← disabled ← left - const seg = columnSegments[this.selectedIndex]; + const seg = columnSegments[this.#selectedIndex]; if (seg) { const oldColumn = seg.column; if (seg.column === "left") { @@ -188,63 +188,63 @@ export class StatusLineSegmentEditorComponent extends Container { seg.order = 999; } else if (seg.column === "right") { seg.column = "left"; - seg.order = this.getSegmentsForColumn("left").length; + seg.order = this.#getSegmentsForColumn("left").length; } else { seg.column = "right"; - seg.order = this.getSegmentsForColumn("right").length; + seg.order = this.#getSegmentsForColumn("right").length; } - this.recompactColumn(oldColumn); - this.triggerPreview(); + this.#recompactColumn(oldColumn); + this.#triggerPreview(); } } else if (data === " ") { // Quick toggle: disabled ↔ left - const seg = columnSegments[this.selectedIndex]; + const seg = columnSegments[this.#selectedIndex]; if (seg) { const oldColumn = seg.column; if (seg.column === "disabled") { seg.column = "left"; - seg.order = this.getSegmentsForColumn("left").length; + seg.order = this.#getSegmentsForColumn("left").length; } else { seg.column = "disabled"; seg.order = 999; } - this.recompactColumn(oldColumn); - this.triggerPreview(); + this.#recompactColumn(oldColumn); + this.#triggerPreview(); } } else if (data === "K") { // Move segment up in order (Shift+K) - const seg = columnSegments[this.selectedIndex]; - if (seg && seg.column !== "disabled" && this.selectedIndex > 0) { - const prevSeg = columnSegments[this.selectedIndex - 1]; + const seg = columnSegments[this.#selectedIndex]; + if (seg && seg.column !== "disabled" && this.#selectedIndex > 0) { + const prevSeg = columnSegments[this.#selectedIndex - 1]; const tempOrder = seg.order; seg.order = prevSeg.order; prevSeg.order = tempOrder; - this.selectedIndex--; - this.triggerPreview(); + this.#selectedIndex--; + this.#triggerPreview(); } } else if (data === "J") { // Move segment down in order (Shift+J) - const seg = columnSegments[this.selectedIndex]; - if (seg && seg.column !== "disabled" && this.selectedIndex < columnSegments.length - 1) { - const nextSeg = columnSegments[this.selectedIndex + 1]; + const seg = columnSegments[this.#selectedIndex]; + if (seg && seg.column !== "disabled" && this.#selectedIndex < columnSegments.length - 1) { + const nextSeg = columnSegments[this.#selectedIndex + 1]; const tempOrder = seg.order; seg.order = nextSeg.order; nextSeg.order = tempOrder; - this.selectedIndex++; - this.triggerPreview(); + this.#selectedIndex++; + this.#triggerPreview(); } } else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { - const left = this.getSegmentsForColumn("left").map(s => s.id); - const right = this.getSegmentsForColumn("right").map(s => s.id); + const left = this.#getSegmentsForColumn("left").map(s => s.id); + const right = this.#getSegmentsForColumn("right").map(s => s.id); this.callbacks.onSave(left, right); } else if (matchesKey(data, "escape") || matchesKey(data, "esc")) { this.callbacks.onCancel(); } } - private recompactColumn(column: Column): void { + #recompactColumn(column: Column): void { if (column === "disabled") return; - const segs = this.getSegmentsForColumn(column); + const segs = this.#getSegmentsForColumn(column); for (let i = 0; i < segs.length; i++) { segs[i].order = i; } @@ -274,9 +274,9 @@ export class StatusLineSegmentEditorComponent extends Container { lines.push(""); // Get segments for each column - const leftSegs = this.getSegmentsForColumn("left"); - const rightSegs = this.getSegmentsForColumn("right"); - const disabledSegs = this.getSegmentsForColumn("disabled"); + const leftSegs = this.#getSegmentsForColumn("left"); + const rightSegs = this.#getSegmentsForColumn("right"); + const disabledSegs = this.#getSegmentsForColumn("disabled"); // Calculate column widths const colWidth = Math.max(18, Math.floor((width - 6) / 3)); @@ -284,15 +284,15 @@ export class StatusLineSegmentEditorComponent extends Container { // Column headers const activeMarker = theme.nav.back; const leftHeader = - this.focusColumn === "left" + this.#focusColumn === "left" ? theme.bold(theme.fg("accent", `${activeMarker} LEFT`)) : theme.fg("muted", " LEFT"); const rightHeader = - this.focusColumn === "right" + this.#focusColumn === "right" ? theme.bold(theme.fg("accent", `${activeMarker} RIGHT`)) : theme.fg("muted", " RIGHT"); const disabledHeader = - this.focusColumn === "disabled" + this.#focusColumn === "disabled" ? theme.bold(theme.fg("accent", `${activeMarker} AVAILABLE`)) : theme.fg("muted", " AVAILABLE"); @@ -306,13 +306,13 @@ export class StatusLineSegmentEditorComponent extends Container { let line = ""; // Left column - line += this.renderSegmentCell(leftSegs[row], "left", row, colWidth); + line += this.#renderSegmentCell(leftSegs[row], "left", row, colWidth); // Right column - line += this.renderSegmentCell(rightSegs[row], "right", row, colWidth); + line += this.#renderSegmentCell(rightSegs[row], "right", row, colWidth); // Disabled column - line += this.renderSegmentCell(disabledSegs[row], "disabled", row, colWidth); + line += this.#renderSegmentCell(disabledSegs[row], "disabled", row, colWidth); lines.push(line); } @@ -330,12 +330,12 @@ export class StatusLineSegmentEditorComponent extends Container { return lines; } - private renderSegmentCell(seg: SegmentState | undefined, column: Column, row: number, colWidth: number): string { + #renderSegmentCell(seg: SegmentState | undefined, column: Column, row: number, colWidth: number): string { if (!seg) { return "".padEnd(colWidth + 2); } - const isSelected = this.focusColumn === column && this.selectedIndex === row; + const isSelected = this.#focusColumn === column && this.#selectedIndex === row; const info = SEGMENT_INFO[seg.id]; const label = info?.label ?? seg.id; diff --git a/packages/coding-agent/src/modes/components/status-line.ts b/packages/coding-agent/src/modes/components/status-line.ts index fe76c512d..5432841c8 100644 --- a/packages/coding-agent/src/modes/components/status-line.ts +++ b/packages/coding-agent/src/modes/components/status-line.ts @@ -60,22 +60,22 @@ function findGitHeadPath(): string | null { // ═══════════════════════════════════════════════════════════════════════════ export class StatusLineComponent implements Component { - private settings: StatusLineSettings = {}; - private cachedBranch: string | null | undefined = undefined; - private gitWatcher: fs.FSWatcher | null = null; - private onBranchChange: (() => void) | null = null; - private autoCompactEnabled: boolean = true; - private hookStatuses: Map = new Map(); - private subagentCount: number = 0; - private sessionStartTime: number = Date.now(); - private planModeStatus: { enabled: boolean; paused: boolean } | null = null; + #settings: StatusLineSettings = {}; + #cachedBranch: string | null | undefined = undefined; + #gitWatcher: fs.FSWatcher | null = null; + #onBranchChange: (() => void) | null = null; + #autoCompactEnabled: boolean = true; + #hookStatuses: Map = new Map(); + #subagentCount: number = 0; + #sessionStartTime: number = Date.now(); + #planModeStatus: { enabled: boolean; paused: boolean } | null = null; // Git status caching (1s TTL) - private cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null; - private gitStatusLastFetch = 0; + #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null; + #gitStatusLastFetch = 0; constructor(private readonly session: AgentSession) { - this.settings = { + this.#settings = { preset: settings.get("statusLine.preset"), leftSegments: settings.get("statusLine.leftSegments"), rightSegments: settings.get("statusLine.rightSegments"), @@ -86,52 +86,52 @@ export class StatusLineComponent implements Component { } updateSettings(settings: StatusLineSettings): void { - this.settings = settings; + this.#settings = settings; } setAutoCompactEnabled(enabled: boolean): void { - this.autoCompactEnabled = enabled; + this.#autoCompactEnabled = enabled; } setSubagentCount(count: number): void { - this.subagentCount = count; + this.#subagentCount = count; } setSessionStartTime(time: number): void { - this.sessionStartTime = time; + this.#sessionStartTime = time; } setPlanModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void { - this.planModeStatus = status ?? null; + this.#planModeStatus = status ?? null; } setHookStatus(key: string, text: string | undefined): void { if (text === undefined) { - this.hookStatuses.delete(key); + this.#hookStatuses.delete(key); } else { - this.hookStatuses.set(key, text); + this.#hookStatuses.set(key, text); } } watchBranch(onBranchChange: () => void): void { - this.onBranchChange = onBranchChange; - this.setupGitWatcher(); + this.#onBranchChange = onBranchChange; + this.#setupGitWatcher(); } - private setupGitWatcher(): void { - if (this.gitWatcher) { - this.gitWatcher.close(); - this.gitWatcher = null; + #setupGitWatcher(): void { + if (this.#gitWatcher) { + this.#gitWatcher.close(); + this.#gitWatcher = null; } const gitHeadPath = findGitHeadPath(); if (!gitHeadPath) return; try { - this.gitWatcher = fs.watch(gitHeadPath, () => { - this.cachedBranch = undefined; - if (this.onBranchChange) { - this.onBranchChange(); + this.#gitWatcher = fs.watch(gitHeadPath, () => { + this.#cachedBranch = undefined; + if (this.#onBranchChange) { + this.#onBranchChange(); } }); } catch { @@ -140,45 +140,45 @@ export class StatusLineComponent implements Component { } dispose(): void { - if (this.gitWatcher) { - this.gitWatcher.close(); - this.gitWatcher = null; + if (this.#gitWatcher) { + this.#gitWatcher.close(); + this.#gitWatcher = null; } } invalidate(): void { - this.cachedBranch = undefined; + this.#cachedBranch = undefined; } - private getCurrentBranch(): string | null { - if (this.cachedBranch !== undefined) { - return this.cachedBranch; + #getCurrentBranch(): string | null { + if (this.#cachedBranch !== undefined) { + return this.#cachedBranch; } const gitHeadPath = findGitHeadPath(); if (!gitHeadPath) { - this.cachedBranch = null; + this.#cachedBranch = null; return null; } try { const content = fs.readFileSync(gitHeadPath, "utf8").trim(); if (content.startsWith("ref: refs/heads/")) { - this.cachedBranch = content.slice(16); + this.#cachedBranch = content.slice(16); } else { - this.cachedBranch = "detached"; + this.#cachedBranch = "detached"; } } catch { - this.cachedBranch = null; + this.#cachedBranch = null; } - return this.cachedBranch ?? null; + return this.#cachedBranch ?? null; } - private getGitStatus(): { staged: number; unstaged: number; untracked: number } | null { + #getGitStatus(): { staged: number; unstaged: number; untracked: number } | null { const now = Date.now(); - if (now - this.gitStatusLastFetch < 1000) { - return this.cachedGitStatus; + if (now - this.#gitStatusLastFetch < 1000) { + return this.#cachedGitStatus; } // Fire async fetch, return cached value @@ -187,8 +187,8 @@ export class StatusLineComponent implements Component { const result = await $`git status --porcelain`.quiet().nothrow(); if (result.exitCode !== 0) { - this.cachedGitStatus = null; - this.gitStatusLastFetch = now; + this.#cachedGitStatus = null; + this.#gitStatusLastFetch = now; return; } @@ -217,18 +217,18 @@ export class StatusLineComponent implements Component { } } - this.cachedGitStatus = { staged, unstaged, untracked }; - this.gitStatusLastFetch = now; + this.#cachedGitStatus = { staged, unstaged, untracked }; + this.#gitStatusLastFetch = now; } catch { - this.cachedGitStatus = null; - this.gitStatusLastFetch = now; + this.#cachedGitStatus = null; + this.#gitStatusLastFetch = now; } })(); - return this.cachedGitStatus; + return this.#cachedGitStatus; } - private buildSegmentContext(width: number): SegmentContext { + #buildSegmentContext(width: number): SegmentContext { const state = this.session.state; // Get usage statistics @@ -258,26 +258,26 @@ export class StatusLineComponent implements Component { return { session: this.session, width, - options: this.resolveSettings().segmentOptions ?? {}, - planMode: this.planModeStatus, + options: this.#resolveSettings().segmentOptions ?? {}, + planMode: this.#planModeStatus, usageStats, contextPercent, contextWindow, - autoCompactEnabled: this.autoCompactEnabled, - subagentCount: this.subagentCount, - sessionStartTime: this.sessionStartTime, + autoCompactEnabled: this.#autoCompactEnabled, + subagentCount: this.#subagentCount, + sessionStartTime: this.#sessionStartTime, git: { - branch: this.getCurrentBranch(), - status: this.getGitStatus(), + branch: this.#getCurrentBranch(), + status: this.#getGitStatus(), }, }; } - private resolveSettings(): Required< + #resolveSettings(): Required< Pick > & StatusLineSettings { - const preset = this.settings.preset ?? "default"; + const preset = this.#settings.preset ?? "default"; const presetDef = getPreset(preset); const useCustomSegments = preset === "custom"; const mergedSegmentOptions: StatusLineSettings["segmentOptions"] = {}; @@ -286,7 +286,7 @@ export class StatusLineComponent implements Component { mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = { ...(options as Record) }; } - for (const [segment, options] of Object.entries(this.settings.segmentOptions ?? {})) { + for (const [segment, options] of Object.entries(this.#settings.segmentOptions ?? {})) { const current = mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] ?? {}; mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = { ...(current as Record), @@ -295,24 +295,24 @@ export class StatusLineComponent implements Component { } const leftSegments = useCustomSegments - ? (this.settings.leftSegments ?? presetDef.leftSegments) + ? (this.#settings.leftSegments ?? presetDef.leftSegments) : presetDef.leftSegments; const rightSegments = useCustomSegments - ? (this.settings.rightSegments ?? presetDef.rightSegments) + ? (this.#settings.rightSegments ?? presetDef.rightSegments) : presetDef.rightSegments; return { - ...this.settings, + ...this.#settings, leftSegments, rightSegments, - separator: this.settings.separator ?? presetDef.separator, + separator: this.#settings.separator ?? presetDef.separator, segmentOptions: mergedSegmentOptions, }; } - private buildStatusLine(width: number): string { - const ctx = this.buildSegmentContext(width); - const effectiveSettings = this.resolveSettings(); + #buildStatusLine(width: number): string { + const ctx = this.#buildSegmentContext(width); + const effectiveSettings = this.#resolveSettings(); const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme); const bgAnsi = theme.getBgAnsi("statusLineBg"); @@ -403,7 +403,7 @@ export class StatusLineComponent implements Component { } getTopBorder(width: number): { content: string; width: number } { - const content = this.buildStatusLine(width); + const content = this.#buildStatusLine(width); return { content, width: visibleWidth(content), @@ -412,12 +412,12 @@ export class StatusLineComponent implements Component { render(width: number): string[] { // Only render hook statuses - main status is in editor's top border - const showHooks = this.settings.showHookStatus ?? true; - if (!showHooks || this.hookStatuses.size === 0) { + const showHooks = this.#settings.showHookStatus ?? true; + if (!showHooks || this.#hookStatuses.size === 0) { return []; } - const sortedStatuses = Array.from(this.hookStatuses.entries()) + const sortedStatuses = Array.from(this.#hookStatuses.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([, text]) => sanitizeStatusText(text)); const hookLine = sortedStatuses.join(" "); diff --git a/packages/coding-agent/src/modes/components/theme-selector.ts b/packages/coding-agent/src/modes/components/theme-selector.ts index 91bf99610..d0a09990b 100644 --- a/packages/coding-agent/src/modes/components/theme-selector.ts +++ b/packages/coding-agent/src/modes/components/theme-selector.ts @@ -7,8 +7,8 @@ import { DynamicBorder } from "./dynamic-border"; * Themes must be pre-loaded and passed to the constructor. */ export class ThemeSelectorComponent extends Container { - private selectList: SelectList; - private onPreview: (themeName: string) => void; + #selectList: SelectList; + #onPreview: (themeName: string) => void; constructor( currentTheme: string, @@ -18,7 +18,7 @@ export class ThemeSelectorComponent extends Container { onPreview: (themeName: string) => void, ) { super(); - this.onPreview = onPreview; + this.#onPreview = onPreview; // Create select items from provided themes const themeItems: SelectItem[] = themes.map(name => ({ @@ -31,33 +31,33 @@ export class ThemeSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Create selector - this.selectList = new SelectList(themeItems, 10, getSelectListTheme()); + this.#selectList = new SelectList(themeItems, 10, getSelectListTheme()); // Preselect current theme const currentIndex = themes.indexOf(currentTheme); if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); + this.#selectList.setSelectedIndex(currentIndex); } - this.selectList.onSelect = item => { + this.#selectList.onSelect = item => { onSelect(item.value); }; - this.selectList.onCancel = () => { + this.#selectList.onCancel = () => { onCancel(); }; - this.selectList.onSelectionChange = item => { - this.onPreview(item.value); + this.#selectList.onSelectionChange = item => { + this.#onPreview(item.value); }; - this.addChild(this.selectList); + this.addChild(this.#selectList); // Add bottom border this.addChild(new DynamicBorder()); } getSelectList(): SelectList { - return this.selectList; + return this.#selectList; } } diff --git a/packages/coding-agent/src/modes/components/thinking-selector.ts b/packages/coding-agent/src/modes/components/thinking-selector.ts index 2d484eb8c..bcf86bc48 100644 --- a/packages/coding-agent/src/modes/components/thinking-selector.ts +++ b/packages/coding-agent/src/modes/components/thinking-selector.ts @@ -16,7 +16,7 @@ const LEVEL_DESCRIPTIONS: Record = { * Component that renders a thinking level selector with borders */ export class ThinkingSelectorComponent extends Container { - private selectList: SelectList; + #selectList: SelectList; constructor( currentLevel: ThinkingLevel, @@ -36,29 +36,29 @@ export class ThinkingSelectorComponent extends Container { this.addChild(new DynamicBorder()); // Create selector - this.selectList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme()); + this.#selectList = new SelectList(thinkingLevels, thinkingLevels.length, getSelectListTheme()); // Preselect current level const currentIndex = thinkingLevels.findIndex(item => item.value === currentLevel); if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); + this.#selectList.setSelectedIndex(currentIndex); } - this.selectList.onSelect = item => { + this.#selectList.onSelect = item => { onSelect(item.value as ThinkingLevel); }; - this.selectList.onCancel = () => { + this.#selectList.onCancel = () => { onCancel(); }; - this.addChild(this.selectList); + this.addChild(this.#selectList); // Add bottom border this.addChild(new DynamicBorder()); } getSelectList(): SelectList { - return this.selectList; + return this.#selectList; } } diff --git a/packages/coding-agent/src/modes/components/todo-display.ts b/packages/coding-agent/src/modes/components/todo-display.ts index 281b40563..797e113e2 100644 --- a/packages/coding-agent/src/modes/components/todo-display.ts +++ b/packages/coding-agent/src/modes/components/todo-display.ts @@ -27,17 +27,17 @@ async function loadTodoFile(filePath: string): Promise { } export class TodoDisplayComponent { - public todos: TodoItem[] = []; - private expanded = false; - private visible = false; - private cached: RenderCache | undefined; + todos: TodoItem[] = []; + #expanded = false; + #visible = false; + #cached: RenderCache | undefined; constructor(private readonly sessionFile: string | null) {} async loadTodos(): Promise { if (!this.sessionFile) { this.todos = []; - this.visible = false; + this.#visible = false; return; } @@ -45,36 +45,36 @@ export class TodoDisplayComponent { const todoPath = path.join(artifactsDir, TODO_FILE_NAME); const data = await loadTodoFile(todoPath); this.todos = data?.todos ?? []; - this.visible = this.todos.length > 0; - this.cached = undefined; + this.#visible = this.todos.length > 0; + this.#cached = undefined; } setTodos(todos: TodoItem[]): void { this.todos = todos; - this.visible = this.todos.length > 0; - this.cached = undefined; + this.#visible = this.todos.length > 0; + this.#cached = undefined; } setExpanded(expanded: boolean): void { - this.expanded = expanded; - this.cached = undefined; + this.#expanded = expanded; + this.#cached = undefined; } isVisible(): boolean { - return this.visible; + return this.#visible; } render(width: number): string[] { - if (!this.visible || this.todos.length === 0) { + if (!this.#visible || this.todos.length === 0) { return []; } - const key = new Hasher().bool(this.expanded).u32(width).digest(); - if (this.cached?.key === key) return this.cached.lines; + const key = new Hasher().bool(this.#expanded).u32(width).digest(); + if (this.#cached?.key === key) return this.#cached.lines; const lines: string[] = []; - const maxItems = this.expanded ? this.todos.length : Math.min(5, this.todos.length); - const hasMore = !this.expanded && this.todos.length > 5; + const maxItems = this.#expanded ? this.todos.length : Math.min(5, this.todos.length); + const hasMore = !this.#expanded && this.todos.length > 5; for (let i = 0; i < maxItems; i++) { const todo = this.todos[i]; @@ -102,12 +102,12 @@ export class TodoDisplayComponent { } const result = lines.map(l => truncateToWidth(l, width, Ellipsis.Omit)); - this.cached = { key, lines: result }; + this.#cached = { key, lines: result }; return result; } getRenderedComponent(): Text | null { - if (!this.visible) return null; + if (!this.#visible) return null; const lines = this.render(80); return new Text(lines.join("\n"), 0, 0); } diff --git a/packages/coding-agent/src/modes/components/todo-reminder.ts b/packages/coding-agent/src/modes/components/todo-reminder.ts index 4203efd12..b8880b184 100644 --- a/packages/coding-agent/src/modes/components/todo-reminder.ts +++ b/packages/coding-agent/src/modes/components/todo-reminder.ts @@ -7,7 +7,7 @@ import type { TodoItem } from "../../tools/todo-write"; * Shows when the agent stops with incomplete todos. */ export class TodoReminderComponent extends Container { - private box: Box; + #box: Box; constructor( private readonly todos: TodoItem[], @@ -18,23 +18,23 @@ export class TodoReminderComponent extends Container { this.addChild(new Spacer(1)); - this.box = new Box(1, 1, t => theme.inverse(theme.fg("warning", t))); - this.addChild(this.box); + this.#box = new Box(1, 1, t => theme.inverse(theme.fg("warning", t))); + this.addChild(this.#box); - this.rebuild(); + this.#rebuild(); } - private rebuild(): void { - this.box.clear(); + #rebuild(): void { + this.#box.clear(); const count = this.todos.length; const label = count === 1 ? "todo" : "todos"; const header = `${theme.icon.warning} ${count} incomplete ${label} - reminder ${this.attempt}/${this.maxAttempts}`; - this.box.addChild(new Text(header, 0, 0)); - this.box.addChild(new Spacer(1)); + this.#box.addChild(new Text(header, 0, 0)); + this.#box.addChild(new Spacer(1)); const todoList = this.todos.map(t => ` ${theme.checkbox.unchecked} ${t.content}`).join("\n"); - this.box.addChild(new Text(theme.italic(todoList), 0, 0)); + this.#box.addChild(new Text(theme.italic(todoList), 0, 0)); } } diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 477bdc40d..0b9058906 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -59,37 +59,37 @@ export interface ToolExecutionHandle { * Component that renders a tool call with its result (updateable) */ export class ToolExecutionComponent extends Container { - private contentBox: Box; // Used for custom tools and bash visual truncation - private contentText: Text; // For built-in tools (with its own padding/bg) - private imageComponents: Image[] = []; - private imageSpacers: Spacer[] = []; - private toolName: string; - private toolLabel: string; - private args: any; - private expanded = false; - private showImages: boolean; - private editFuzzyThreshold: number | undefined; - private editAllowFuzzy: boolean | undefined; - private isPartial = true; - private tool?: AgentTool; - private ui: TUI; - private cwd: string; - private result?: { + #contentBox: Box; // Used for custom tools and bash visual truncation + #contentText: Text; // For built-in tools (with its own padding/bg) + #imageComponents: Image[] = []; + #imageSpacers: Spacer[] = []; + #toolName: string; + #toolLabel: string; + #args: any; + #expanded = false; + #showImages: boolean; + #editFuzzyThreshold: number | undefined; + #editAllowFuzzy: boolean | undefined; + #isPartial = true; + #tool?: AgentTool; + #ui: TUI; + #cwd: string; + #result?: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean; details?: any; }; // Cached edit diff preview (computed when args arrive, before tool executes) - private editDiffPreview?: EditDiffResult | EditDiffError; - private editDiffArgsKey?: string; // Track which args the preview is for + #editDiffPreview?: EditDiffResult | EditDiffError; + #editDiffArgsKey?: string; // Track which args the preview is for // Cached converted images for Kitty protocol (which requires PNG), keyed by index - private convertedImages: Map = new Map(); + #convertedImages: Map = new Map(); // Spinner animation for partial task results - private spinnerFrame = 0; - private spinnerInterval: ReturnType | null = null; + #spinnerFrame = 0; + #spinnerInterval?: NodeJS.Timeout; // Track if args are still being streamed (for edit/write spinner) - private argsComplete = false; - private renderState: { + #argsComplete = false; + #renderState: { spinnerFrame: number; expanded: boolean; isPartial: boolean; @@ -109,38 +109,38 @@ export class ToolExecutionComponent extends Container { cwd: string = process.cwd(), ) { super(); - this.toolName = toolName; - this.toolLabel = tool?.label ?? toolName; - this.args = args; - this.showImages = options.showImages ?? true; - this.editFuzzyThreshold = options.editFuzzyThreshold; - this.editAllowFuzzy = options.editAllowFuzzy; - this.tool = tool; - this.ui = ui; - this.cwd = cwd; + this.#toolName = toolName; + this.#toolLabel = tool?.label ?? toolName; + this.#args = args; + this.#showImages = options.showImages ?? true; + this.#editFuzzyThreshold = options.editFuzzyThreshold; + this.#editAllowFuzzy = options.editAllowFuzzy; + this.#tool = tool; + this.#ui = ui; + this.#cwd = cwd; this.addChild(new Spacer(1)); // Always create both - contentBox for custom tools/bash/tools with renderers, contentText for other built-ins - this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text)); - this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text)); + this.#contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text)); + this.#contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text)); // Use Box for custom tools or built-in tools that have renderers const hasRenderer = toolName in toolRenderers; const hasCustomRenderer = !!(tool?.renderCall || tool?.renderResult); if (hasCustomRenderer || hasRenderer) { - this.addChild(this.contentBox); + this.addChild(this.#contentBox); } else { - this.addChild(this.contentText); + this.addChild(this.#contentText); } - this.updateDisplay(); + this.#updateDisplay(); } updateArgs(args: any, _toolCallId?: string): void { - this.args = args; - this.updateSpinnerAnimation(); - this.updateDisplay(); + this.#args = args; + this.#updateSpinnerAnimation(); + this.#updateDisplay(); } /** @@ -148,46 +148,46 @@ export class ToolExecutionComponent extends Container { * This triggers diff computation for edit tool. */ setArgsComplete(_toolCallId?: string): void { - this.argsComplete = true; - this.updateSpinnerAnimation(); - this.maybeComputeEditDiff(); + this.#argsComplete = true; + this.#updateSpinnerAnimation(); + this.#maybeComputeEditDiff(); } /** * Compute edit diff preview when we have complete args. * This runs async and updates display when done. */ - private maybeComputeEditDiff(): void { - if (this.toolName !== "edit") return; + #maybeComputeEditDiff(): void { + if (this.#toolName !== "edit") return; - const path = this.args?.path; - const op = this.args?.op; + const path = this.#args?.path; + const op = this.#args?.op; if (op) { - const diff = this.args?.diff; - const rename = this.args?.rename; + const diff = this.#args?.diff; + const rename = this.#args?.rename; if (!path) return; const argsKey = JSON.stringify({ path, op, rename, diff }); - if (this.editDiffArgsKey === argsKey) return; - this.editDiffArgsKey = argsKey; + if (this.#editDiffArgsKey === argsKey) return; + this.#editDiffArgsKey = argsKey; - computePatchDiff({ path, op, rename, diff }, this.cwd, { - fuzzyThreshold: this.editFuzzyThreshold, - allowFuzzy: this.editAllowFuzzy, + computePatchDiff({ path, op, rename, diff }, this.#cwd, { + fuzzyThreshold: this.#editFuzzyThreshold, + allowFuzzy: this.#editAllowFuzzy, }).then(result => { - if (this.editDiffArgsKey === argsKey) { - this.editDiffPreview = result; - this.updateDisplay(); - this.ui.requestRender(); + if (this.#editDiffArgsKey === argsKey) { + this.#editDiffPreview = result; + this.#updateDisplay(); + this.#ui.requestRender(); } }); return; } - const oldText = this.args?.old_text; - const newText = this.args?.new_text; - const all = this.args?.all; + const oldText = this.#args?.old_text; + const newText = this.#args?.new_text; + const all = this.#args?.all; // Need all three params to compute diff if (!path || oldText === undefined || newText === undefined) return; @@ -196,17 +196,17 @@ export class ToolExecutionComponent extends Container { const argsKey = JSON.stringify({ path, oldText, newText, all }); // Skip if we already computed for these exact args - if (this.editDiffArgsKey === argsKey) return; + if (this.#editDiffArgsKey === argsKey) return; - this.editDiffArgsKey = argsKey; + this.#editDiffArgsKey = argsKey; // Compute diff async - computeEditDiff(path, oldText, newText, this.cwd, true, all, this.editFuzzyThreshold).then(result => { + computeEditDiff(path, oldText, newText, this.#cwd, true, all, this.#editFuzzyThreshold).then(result => { // Only update if args haven't changed since we started - if (this.editDiffArgsKey === argsKey) { - this.editDiffPreview = result; - this.updateDisplay(); - this.ui.requestRender(); + if (this.#editDiffArgsKey === argsKey) { + this.#editDiffPreview = result; + this.#updateDisplay(); + this.#ui.requestRender(); } }); } @@ -220,26 +220,26 @@ export class ToolExecutionComponent extends Container { isPartial = false, _toolCallId?: string, ): void { - this.result = result; - this.isPartial = isPartial; + this.#result = result; + this.#isPartial = isPartial; // When tool is complete, ensure args are marked complete so spinner stops if (!isPartial) { - this.argsComplete = true; + this.#argsComplete = true; } - this.updateSpinnerAnimation(); - this.updateDisplay(); + this.#updateSpinnerAnimation(); + this.#updateDisplay(); // Convert non-PNG images to PNG for Kitty protocol (async) - this.maybeConvertImagesForKitty(); + this.#maybeConvertImagesForKitty(); } /** * Get all image blocks from result content and details.images. * Some tools (like generate_image) store images in details to avoid bloating model context. */ - private getAllImageBlocks(): Array<{ data?: string; mimeType?: string }> { - if (!this.result) return []; - const contentImages = this.result.content?.filter((c: any) => c.type === "image") || []; - const detailImages = this.result.details?.images || []; + #getAllImageBlocks(): Array<{ data?: string; mimeType?: string }> { + if (!this.#result) return []; + const contentImages = this.#result.content?.filter((c: any) => c.type === "image") || []; + const detailImages = this.#result.details?.images || []; return [...contentImages, ...detailImages]; } @@ -247,28 +247,28 @@ export class ToolExecutionComponent extends Container { * Convert non-PNG images to PNG for Kitty graphics protocol. * Kitty requires PNG format (f=100), so JPEG/GIF/WebP won't display. */ - private maybeConvertImagesForKitty(): void { + #maybeConvertImagesForKitty(): void { // Only needed for Kitty protocol if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return; - if (!this.result) return; + if (!this.#result) return; - const imageBlocks = this.getAllImageBlocks(); + const imageBlocks = this.#getAllImageBlocks(); for (let i = 0; i < imageBlocks.length; i++) { const img = imageBlocks[i]; if (!img.data || !img.mimeType) continue; // Skip if already PNG or already converted if (img.mimeType === "image/png") continue; - if (this.convertedImages.has(i)) continue; + if (this.#convertedImages.has(i)) continue; // Convert async - catch errors from processing const index = i; convertToPng(img.data, img.mimeType) .then(converted => { if (converted) { - this.convertedImages.set(index, converted); - this.updateDisplay(); - this.ui.requestRender(); + this.#convertedImages.set(index, converted); + this.#updateDisplay(); + this.#ui.requestRender(); } }) .catch(() => { @@ -280,23 +280,23 @@ export class ToolExecutionComponent extends Container { /** * Start or stop spinner animation based on whether this is a partial task result. */ - private updateSpinnerAnimation(): void { + #updateSpinnerAnimation(): void { // Spinner for: task tool with partial result, or edit/write while args streaming - const isStreamingArgs = !this.argsComplete && (this.toolName === "edit" || this.toolName === "write"); - const isPartialTask = this.isPartial && this.toolName === "task"; + const isStreamingArgs = !this.#argsComplete && (this.#toolName === "edit" || this.#toolName === "write"); + const isPartialTask = this.#isPartial && this.#toolName === "task"; const needsSpinner = isStreamingArgs || isPartialTask; - if (needsSpinner && !this.spinnerInterval) { - this.spinnerInterval = setInterval(() => { + if (needsSpinner && !this.#spinnerInterval) { + this.#spinnerInterval = setInterval(() => { const frameCount = theme.spinnerFrames.length; if (frameCount === 0) return; - this.spinnerFrame = (this.spinnerFrame + 1) % frameCount; - this.renderState.spinnerFrame = this.spinnerFrame; - this.ui.requestRender(); + this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount; + this.#renderState.spinnerFrame = this.#spinnerFrame; + this.#ui.requestRender(); // NO updateDisplay() — existing component closures read from renderState }, 80); - } else if (!needsSpinner && this.spinnerInterval) { - clearInterval(this.spinnerInterval); - this.spinnerInterval = null; + } else if (!needsSpinner && this.#spinnerInterval) { + clearInterval(this.#spinnerInterval); + this.#spinnerInterval = undefined; } } @@ -304,74 +304,74 @@ export class ToolExecutionComponent extends Container { * Stop spinner animation and cleanup resources. */ stopAnimation(): void { - if (this.spinnerInterval) { - clearInterval(this.spinnerInterval); - this.spinnerInterval = null; + if (this.#spinnerInterval) { + clearInterval(this.#spinnerInterval); + this.#spinnerInterval = undefined; } } setExpanded(expanded: boolean): void { - this.expanded = expanded; - this.updateDisplay(); + this.#expanded = expanded; + this.#updateDisplay(); } setShowImages(show: boolean): void { - this.showImages = show; - this.updateDisplay(); + this.#showImages = show; + this.#updateDisplay(); } override invalidate(): void { super.invalidate(); - this.updateDisplay(); + this.#updateDisplay(); } - private updateDisplay(): void { + #updateDisplay(): void { // Set background based on state - const bgFn = this.isPartial + const bgFn = this.#isPartial ? (text: string) => theme.bg("toolPendingBg", text) - : this.result?.isError + : this.#result?.isError ? (text: string) => theme.bg("toolErrorBg", text) : (text: string) => theme.bg("toolSuccessBg", text); // Sync shared mutable render state for component closures - this.renderState.expanded = this.expanded; - this.renderState.isPartial = this.isPartial; - this.renderState.spinnerFrame = this.spinnerFrame; + this.#renderState.expanded = this.#expanded; + this.#renderState.isPartial = this.#isPartial; + this.#renderState.spinnerFrame = this.#spinnerFrame; // Check for custom tool rendering - if (this.tool && (this.tool.renderCall || this.tool.renderResult)) { - const tool = this.tool; + if (this.#tool && (this.#tool.renderCall || this.#tool.renderResult)) { + const tool = this.#tool; const mergeCallAndResult = Boolean((tool as { mergeCallAndResult?: boolean }).mergeCallAndResult); // Custom tools use Box for flexible component rendering const inline = Boolean((tool as { inline?: boolean }).inline); - this.contentBox.setBgFn(inline ? undefined : bgFn); - this.contentBox.clear(); + this.#contentBox.setBgFn(inline ? undefined : bgFn); + this.#contentBox.clear(); // Render call component - const shouldRenderCall = !this.result || !mergeCallAndResult; + const shouldRenderCall = !this.#result || !mergeCallAndResult; if (shouldRenderCall && tool.renderCall) { try { - const callComponent = tool.renderCall(this.args, theme); + const callComponent = tool.renderCall(this.#args, theme); if (callComponent) { // Ensure component has invalidate() method for Component interface const component = callComponent as any; if (!component.invalidate) { component.invalidate = () => {}; } - this.contentBox.addChild(component); + this.#contentBox.addChild(component); } } catch (err) { - logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) }); + logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) }); // Fall back to default on error - this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0)); + this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0)); } } else { // No custom renderCall, show tool name - this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0)); + this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0)); } // Render result component if we have a result - if (this.result && tool.renderResult) { + if (this.#result && tool.renderResult) { try { const renderResult = tool.renderResult as ( result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean }, @@ -380,10 +380,14 @@ export class ToolExecutionComponent extends Container { args?: unknown, ) => Component; const resultComponent = renderResult( - { content: this.result.content as any, details: this.result.details, isError: this.result.isError }, - this.renderState, + { + content: this.#result.content as any, + details: this.#result.details, + isError: this.#result.isError, + }, + this.#renderState, theme, - this.args, + this.#args, ); if (resultComponent) { // Ensure component has invalidate() method for Component interface @@ -391,62 +395,66 @@ export class ToolExecutionComponent extends Container { if (!component.invalidate) { component.invalidate = () => {}; } - this.contentBox.addChild(component); + this.#contentBox.addChild(component); } } catch (err) { - logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) }); + logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) }); // Fall back to showing raw output on error - const output = this.getTextOutput(); + const output = this.#getTextOutput(); if (output) { - this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0)); + this.#contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0)); } } - } else if (this.result) { + } else if (this.#result) { // Has result but no custom renderResult - const output = this.getTextOutput(); + const output = this.#getTextOutput(); if (output) { - this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0)); + this.#contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0)); } } - } else if (this.toolName in toolRenderers) { + } else if (this.#toolName in toolRenderers) { // Built-in tools with renderers - const renderer = toolRenderers[this.toolName]; + const renderer = toolRenderers[this.#toolName]; // Inline renderers skip background styling - this.contentBox.setBgFn(renderer.inline ? undefined : bgFn); - this.contentBox.clear(); + this.#contentBox.setBgFn(renderer.inline ? undefined : bgFn); + this.#contentBox.clear(); - const shouldRenderCall = !this.result || !renderer.mergeCallAndResult; + const shouldRenderCall = !this.#result || !renderer.mergeCallAndResult; if (shouldRenderCall) { // Render call component try { - const callComponent = renderer.renderCall(this.args, theme, this.renderState); + const callComponent = renderer.renderCall(this.#args, theme, this.#renderState); if (callComponent) { // Ensure component has invalidate() method for Component interface const component = callComponent as any; if (!component.invalidate) { component.invalidate = () => {}; } - this.contentBox.addChild(component); + this.#contentBox.addChild(component); } } catch (err) { - logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) }); + logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) }); // Fall back to default on error - this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolLabel)), 0, 0)); + this.#contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0)); } } // Render result component if we have a result - if (this.result) { + if (this.#result) { try { // Build render context for tools that need extra state - const renderContext = this.buildRenderContext(); - this.renderState.renderContext = renderContext; + const renderContext = this.#buildRenderContext(); + this.#renderState.renderContext = renderContext; const resultComponent = renderer.renderResult( - { content: this.result.content as any, details: this.result.details, isError: this.result.isError }, - this.renderState, + { + content: this.#result.content as any, + details: this.#result.details, + isError: this.#result.isError, + }, + this.#renderState, theme, - this.args, // Pass args for tools that need them + this.#args, // Pass args for tools that need them ); if (resultComponent) { // Ensure component has invalidate() method for Component interface @@ -454,41 +462,41 @@ export class ToolExecutionComponent extends Container { if (!component.invalidate) { component.invalidate = () => {}; } - this.contentBox.addChild(component); + this.#contentBox.addChild(component); } } catch (err) { - logger.warn("Tool renderer failed", { tool: this.toolName, error: String(err) }); + logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) }); // Fall back to showing raw output on error - const output = this.getTextOutput(); + const output = this.#getTextOutput(); if (output) { - this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0)); + this.#contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0)); } } } } else { // Other built-in tools: use Text directly with caching - this.contentText.setCustomBgFn(bgFn); - this.contentText.setText(this.formatToolExecution()); + this.#contentText.setCustomBgFn(bgFn); + this.#contentText.setText(this.#formatToolExecution()); } // Handle images (same for both custom and built-in) - for (const img of this.imageComponents) { + for (const img of this.#imageComponents) { this.removeChild(img); } - this.imageComponents = []; - for (const spacer of this.imageSpacers) { + this.#imageComponents = []; + for (const spacer of this.#imageSpacers) { this.removeChild(spacer); } - this.imageSpacers = []; + this.#imageSpacers = []; - if (this.result) { - const imageBlocks = this.getAllImageBlocks(); + if (this.#result) { + const imageBlocks = this.#getAllImageBlocks(); for (let i = 0; i < imageBlocks.length; i++) { const img = imageBlocks[i]; - if (TERMINAL.imageProtocol && this.showImages && img.data && img.mimeType) { + if (TERMINAL.imageProtocol && this.#showImages && img.data && img.mimeType) { // Use converted PNG for Kitty protocol if available - const converted = this.convertedImages.get(i); + const converted = this.#convertedImages.get(i); const imageData = converted?.data ?? img.data; const imageMimeType = converted?.mimeType ?? img.mimeType; @@ -499,14 +507,14 @@ export class ToolExecutionComponent extends Container { const spacer = new Spacer(1); this.addChild(spacer); - this.imageSpacers.push(spacer); + this.#imageSpacers.push(spacer); const imageComponent = new Image( imageData, imageMimeType, { fallbackColor: (s: string) => theme.fg("toolOutput", s) }, { maxWidthCells: 60 }, ); - this.imageComponents.push(imageComponent); + this.#imageComponents.push(imageComponent); this.addChild(imageComponent); } } @@ -516,40 +524,40 @@ export class ToolExecutionComponent extends Container { /** * Build render context for tools that need extra state (bash, python, edit) */ - private buildRenderContext(): Record { + #buildRenderContext(): Record { const context: Record = {}; const normalizeTimeoutSeconds = (value: unknown, maxSeconds: number): number | undefined => { if (typeof value !== "number" || !Number.isFinite(value)) return undefined; return Math.max(1, Math.min(maxSeconds, value)); }; - if (this.toolName === "bash" && this.result) { + if (this.#toolName === "bash" && this.#result) { // Pass raw output and expanded state - renderer handles width-aware truncation - const output = this.getTextOutput().trimEnd(); + const output = this.#getTextOutput().trimEnd(); context.output = output; - context.expanded = this.expanded; + context.expanded = this.#expanded; context.previewLines = BASH_DEFAULT_PREVIEW_LINES; - context.timeout = normalizeTimeoutSeconds(this.args?.timeout, 3600); - } else if (this.toolName === "python" && this.result) { - const output = this.getTextOutput().trimEnd(); + context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 3600); + } else if (this.#toolName === "python" && this.#result) { + const output = this.#getTextOutput().trimEnd(); context.output = output; - context.expanded = this.expanded; + context.expanded = this.#expanded; context.previewLines = PYTHON_DEFAULT_PREVIEW_LINES; - context.timeout = normalizeTimeoutSeconds(this.args?.timeout, 600); - } else if (this.toolName === "edit") { + context.timeout = normalizeTimeoutSeconds(this.#args?.timeout, 600); + } else if (this.#toolName === "edit") { // Edit needs diff preview and renderDiff function - context.editDiffPreview = this.editDiffPreview; + context.editDiffPreview = this.#editDiffPreview; context.renderDiff = renderDiff; } return context; } - private getTextOutput(): string { - if (!this.result) return ""; + #getTextOutput(): string { + if (!this.#result) return ""; - const textBlocks = this.result.content?.filter((c: any) => c.type === "text") || []; - const imageBlocks = this.getAllImageBlocks(); + const textBlocks = this.#result.content?.filter((c: any) => c.type === "text") || []; + const imageBlocks = this.#getAllImageBlocks(); let output = textBlocks .map((c: any) => { @@ -557,7 +565,7 @@ export class ToolExecutionComponent extends Container { }) .join("\n"); - if (imageBlocks.length > 0 && (!TERMINAL.imageProtocol || !this.showImages)) { + if (imageBlocks.length > 0 && (!TERMINAL.imageProtocol || !this.#showImages)) { const imageIndicators = imageBlocks .map((img: any) => { const dims = img.data ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined; @@ -573,24 +581,24 @@ export class ToolExecutionComponent extends Container { /** * Format a generic tool execution (fallback for tools without custom renderers) */ - private formatToolExecution(): string { + #formatToolExecution(): string { const lines: string[] = []; - const icon = this.isPartial ? "pending" : this.result?.isError ? "error" : "success"; - lines.push(renderStatusLine({ icon, title: this.toolLabel }, theme)); + const icon = this.#isPartial ? "pending" : this.#result?.isError ? "error" : "success"; + lines.push(renderStatusLine({ icon, title: this.#toolLabel }, theme)); - const argsObject = this.args && typeof this.args === "object" ? (this.args as Record) : null; - if (!this.expanded && argsObject && Object.keys(argsObject).length > 0) { + const argsObject = this.#args && typeof this.#args === "object" ? (this.#args as Record) : null; + if (!this.#expanded && argsObject && Object.keys(argsObject).length > 0) { const preview = formatArgsInline(argsObject, 70); if (preview) { lines.push(` ${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", preview)}`); } } - if (this.expanded && this.args !== undefined) { + if (this.#expanded && this.#args !== undefined) { lines.push(""); lines.push(theme.fg("dim", "Args")); const tree = renderJsonTreeLines( - this.args, + this.#args, theme, JSON_TREE_MAX_DEPTH_EXPANDED, JSON_TREE_MAX_LINES_EXPANDED, @@ -603,11 +611,11 @@ export class ToolExecutionComponent extends Container { lines.push(""); } - if (!this.result) { + if (!this.#result) { return lines.join("\n"); } - const textContent = this.getTextOutput().trimEnd(); + const textContent = this.#getTextOutput().trimEnd(); if (!textContent) { lines.push(theme.fg("dim", "(no output)")); return lines.join("\n"); @@ -616,15 +624,15 @@ export class ToolExecutionComponent extends Container { if (textContent.startsWith("{") || textContent.startsWith("[")) { try { const parsed = JSON.parse(textContent); - const maxDepth = this.expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED; - const maxLines = this.expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED; - const maxScalarLen = this.expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED; + const maxDepth = this.#expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED; + const maxLines = this.#expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED; + const maxScalarLen = this.#expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED; const tree = renderJsonTreeLines(parsed, theme, maxDepth, maxLines, maxScalarLen); if (tree.lines.length > 0) { lines.push(...tree.lines); - if (!this.expanded) { - lines.push(formatExpandHint(theme, this.expanded, true)); + if (!this.#expanded) { + lines.push(formatExpandHint(theme, this.#expanded, true)); } else if (tree.truncated) { lines.push(theme.fg("dim", "…")); } @@ -636,7 +644,7 @@ export class ToolExecutionComponent extends Container { } const outputLines = textContent.split("\n"); - const maxOutputLines = this.expanded ? 12 : 4; + const maxOutputLines = this.#expanded ? 12 : 4; const displayLines = outputLines.slice(0, maxOutputLines); for (const line of displayLines) { @@ -645,9 +653,9 @@ export class ToolExecutionComponent extends Container { if (outputLines.length > maxOutputLines) { const remaining = outputLines.length - maxOutputLines; - lines.push(`${theme.fg("dim", `… ${remaining} more lines`)} ${formatExpandHint(theme, this.expanded, true)}`); - } else if (!this.expanded) { - lines.push(formatExpandHint(theme, this.expanded, true)); + lines.push(`${theme.fg("dim", `… ${remaining} more lines`)} ${formatExpandHint(theme, this.#expanded, true)}`); + } else if (!this.#expanded) { + lines.push(formatExpandHint(theme, this.#expanded, true)); } return lines.join("\n"); diff --git a/packages/coding-agent/src/modes/components/tree-selector.ts b/packages/coding-agent/src/modes/components/tree-selector.ts index 0480eaf1a..b50805f50 100644 --- a/packages/coding-agent/src/modes/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/components/tree-selector.ts @@ -47,19 +47,19 @@ interface ToolCallInfo { } class TreeList implements Component { - private flatNodes: FlatNode[] = []; - private filteredNodes: FlatNode[] = []; - private selectedIndex = 0; - private filterMode: FilterMode = "default"; - private searchQuery = ""; - private toolCallMap: Map = new Map(); - private multipleRoots = false; - private activePathIds: Set = new Set(); - private lastSelectedId: string | null = null; + #flatNodes: FlatNode[] = []; + #filteredNodes: FlatNode[] = []; + #selectedIndex = 0; + #filterMode: FilterMode = "default"; + #searchQuery = ""; + #toolCallMap: Map = new Map(); + #multipleRoots = false; + #activePathIds: Set = new Set(); + #lastSelectedId: string | null = null; - public onSelect?: (entryId: string) => void; - public onCancel?: () => void; - public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void; + onSelect?: (entryId: string) => void; + onCancel?: () => void; + onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void; constructor( tree: SessionTreeNode[], @@ -67,32 +67,32 @@ class TreeList implements Component { private readonly maxVisibleLines: number, initialSelectedId?: string, ) { - this.multipleRoots = tree.length > 1; - this.flatNodes = this.flattenTree(tree); - this.buildActivePath(); - this.applyFilter(); + this.#multipleRoots = tree.length > 1; + this.#flatNodes = this.#flattenTree(tree); + this.#buildActivePath(); + this.#applyFilter(); // Start with initialSelectedId if provided, otherwise current leaf const targetId = initialSelectedId ?? currentLeafId; - this.selectedIndex = this.findNearestVisibleIndex(targetId); - this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? null; + this.#selectedIndex = this.#findNearestVisibleIndex(targetId); + this.#lastSelectedId = this.#filteredNodes[this.#selectedIndex]?.node.entry.id ?? null; } /** Build the set of entry IDs on the path from root to current leaf */ - private buildActivePath(): void { - this.activePathIds.clear(); + #buildActivePath(): void { + this.#activePathIds.clear(); if (!this.currentLeafId) return; // Build a map of id -> entry for parent lookup const entryMap = new Map(); - for (const flatNode of this.flatNodes) { + 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); + this.#activePathIds.add(currentId); const node = entryMap.get(currentId); if (!node) break; currentId = node.node.entry.parentId ?? null; @@ -103,17 +103,17 @@ class TreeList implements Component { * Find the index of the nearest visible entry, walking up the parent chain if needed. * Returns the index in filteredNodes, or the last index as fallback. */ - private findNearestVisibleIndex(entryId: string | null): number { - if (this.filteredNodes.length === 0) return 0; + #findNearestVisibleIndex(entryId: string | null): number { + if (this.#filteredNodes.length === 0) return 0; // Build a map for parent lookup const entryMap = new Map(); - for (const flatNode of this.flatNodes) { + for (const flatNode of this.#flatNodes) { entryMap.set(flatNode.node.entry.id, flatNode); } // Build a map of visible entry IDs to their indices in filteredNodes - const visibleIdToIndex = new Map(this.filteredNodes.map((node, i) => [node.node.entry.id, i])); + const visibleIdToIndex = new Map(this.#filteredNodes.map((node, i) => [node.node.entry.id, i])); // Walk from entryId up to root, looking for a visible entry let currentId = entryId; @@ -126,12 +126,12 @@ class TreeList implements Component { } // Fallback: last visible entry - return this.filteredNodes.length - 1; + return this.#filteredNodes.length - 1; } - private flattenTree(roots: SessionTreeNode[]): FlatNode[] { + #flattenTree(roots: SessionTreeNode[]): FlatNode[] { const result: FlatNode[] = []; - this.toolCallMap.clear(); + this.#toolCallMap.clear(); // Indentation rules: // - At indent 0: stay at 0 unless parent has >1 children (then +1) @@ -191,7 +191,7 @@ class TreeList implements Component { 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 }; - this.toolCallMap.set(tc.id, { name: tc.name, arguments: tc.arguments }); + this.#toolCallMap.set(tc.id, { name: tc.name, arguments: tc.arguments }); } } } @@ -235,7 +235,7 @@ class TreeList implements Component { 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 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 }] @@ -259,16 +259,16 @@ class TreeList implements Component { return result; } - private applyFilter(): void { + #applyFilter(): void { // Update lastSelectedId only when we have a valid selection (non-empty list) // This preserves the selection when switching through empty filter results - if (this.filteredNodes.length > 0) { - this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId; + if (this.#filteredNodes.length > 0) { + this.#lastSelectedId = this.#filteredNodes[this.#selectedIndex]?.node.entry.id ?? this.#lastSelectedId; } - const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean); + const searchTokens = this.#searchQuery.toLowerCase().split(/\s+/).filter(Boolean); - this.filteredNodes = this.flatNodes.filter(flatNode => { + this.#filteredNodes = this.#flatNodes.filter(flatNode => { const entry = flatNode.node.entry; const isCurrentLeaf = entry.id === this.currentLeafId; @@ -276,7 +276,7 @@ class TreeList implements Component { // 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 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) { @@ -293,7 +293,7 @@ class TreeList implements Component { entry.type === "model_change" || entry.type === "thinking_level_change"; - switch (this.filterMode) { + switch (this.#filterMode) { case "user-only": // Just user messages passesFilter = entry.type === "message" && entry.message.role === "user"; @@ -320,7 +320,7 @@ class TreeList implements Component { // Apply search filter if (searchTokens.length > 0) { - const nodeText = this.getSearchableText(flatNode.node).toLowerCase(); + const nodeText = this.#getSearchableText(flatNode.node).toLowerCase(); return searchTokens.every(token => nodeText.includes(token)); } @@ -328,21 +328,21 @@ class TreeList implements Component { }); // Try to preserve cursor on the same node, or find nearest visible ancestor - if (this.lastSelectedId) { - this.selectedIndex = this.findNearestVisibleIndex(this.lastSelectedId); - } else if (this.selectedIndex >= this.filteredNodes.length) { + if (this.#lastSelectedId) { + this.#selectedIndex = this.#findNearestVisibleIndex(this.#lastSelectedId); + } else if (this.#selectedIndex >= this.#filteredNodes.length) { // Clamp index if out of bounds - this.selectedIndex = Math.max(0, this.filteredNodes.length - 1); + this.#selectedIndex = Math.max(0, this.#filteredNodes.length - 1); } // Update lastSelectedId to the actual selection (may have changed due to parent walk) - if (this.filteredNodes.length > 0) { - this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId; + if (this.#filteredNodes.length > 0) { + this.#lastSelectedId = this.#filteredNodes[this.#selectedIndex]?.node.entry.id ?? this.#lastSelectedId; } } /** Get searchable text content from a node */ - private getSearchableText(node: SessionTreeNode): string { + #getSearchableText(node: SessionTreeNode): string { const entry = node.entry; const parts: string[] = []; @@ -355,7 +355,7 @@ class TreeList implements Component { const msg = entry.message; parts.push(msg.role); if ("content" in msg && msg.content) { - parts.push(this.extractContent(msg.content)); + parts.push(this.#extractContent(msg.content)); } if (msg.role === "bashExecution") { const bashMsg = msg as { command?: string }; @@ -368,7 +368,7 @@ class TreeList implements Component { if (typeof entry.content === "string") { parts.push(entry.content); } else { - parts.push(this.extractContent(entry.content)); + parts.push(this.#extractContent(entry.content)); } break; } @@ -398,15 +398,15 @@ class TreeList implements Component { invalidate(): void {} getSearchQuery(): string { - return this.searchQuery; + return this.#searchQuery; } getSelectedNode(): SessionTreeNode | undefined { - return this.filteredNodes[this.selectedIndex]?.node; + return this.#filteredNodes[this.#selectedIndex]?.node; } updateNodeLabel(entryId: string, label: string | undefined): void { - for (const flatNode of this.flatNodes) { + for (const flatNode of this.#flatNodes) { if (flatNode.node.entry.id === entryId) { flatNode.node.label = label; break; @@ -414,8 +414,8 @@ class TreeList implements Component { } } - private getFilterLabel(): string { - switch (this.filterMode) { + #getFilterLabel(): string { + switch (this.#filterMode) { case "no-tools": return " [no-tools]"; case "user-only": @@ -432,31 +432,31 @@ class TreeList implements Component { render(width: number): string[] { const lines: string[] = []; - if (this.filteredNodes.length === 0) { + 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)); + 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, + this.#selectedIndex - Math.floor(this.maxVisibleLines / 2), + this.#filteredNodes.length - this.maxVisibleLines, ), ); - const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length); + const endIndex = Math.min(startIndex + this.maxVisibleLines, this.#filteredNodes.length); for (let i = startIndex; i < endIndex; i++) { - const flatNode = this.filteredNodes[i]; + const flatNode = this.#filteredNodes[i]; const entry = flatNode.node.entry; - const isSelected = i === this.selectedIndex; + 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; + 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) @@ -496,11 +496,11 @@ class TreeList implements Component { const prefix = prefixChars.join(""); // Active path marker - shown right before the entry text - const isOnActivePath = this.activePathIds.has(entry.id); + const isOnActivePath = this.#activePathIds.has(entry.id); const pathMarker = isOnActivePath ? theme.fg("accent", `${theme.md.bullet} `) : ""; const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; - const content = this.getEntryDisplayText(flatNode.node, isSelected); + const content = this.#getEntryDisplayText(flatNode.node, isSelected); let line = cursor + theme.fg("dim", prefix) + pathMarker + label + content; if (isSelected) { @@ -511,7 +511,7 @@ class TreeList implements Component { lines.push( truncateToWidth( - theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getFilterLabel()}`), + theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.#filteredNodes.length})${this.#getFilterLabel()}`), width, ), ); @@ -519,7 +519,7 @@ class TreeList implements Component { return lines; } - private getEntryDisplayText(node: SessionTreeNode, isSelected: boolean): string { + #getEntryDisplayText(node: SessionTreeNode, isSelected: boolean): string { const entry = node.entry; let result: string; @@ -531,11 +531,11 @@ class TreeList implements Component { const role = msg.role; if (role === "user") { const msgWithContent = msg as { content?: unknown }; - const content = normalize(this.extractContent(msgWithContent.content)); + 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)); + const textContent = normalize(this.#extractContent(msgWithContent.content)); if (textContent) { result = theme.fg("success", "assistant: ") + textContent; } else if (msgWithContent.stopReason === "aborted") { @@ -548,9 +548,9 @@ class TreeList implements Component { } } else if (role === "toolResult") { const toolMsg = msg as { toolCallId?: string; toolName?: string }; - const toolCall = toolMsg.toolCallId ? this.toolCallMap.get(toolMsg.toolCallId) : undefined; + const toolCall = toolMsg.toolCallId ? this.#toolCallMap.get(toolMsg.toolCallId) : undefined; if (toolCall) { - result = theme.fg("muted", this.formatToolCall(toolCall.name, toolCall.arguments)); + result = theme.fg("muted", this.#formatToolCall(toolCall.name, toolCall.arguments)); } else { result = theme.fg("muted", `[${toolMsg.toolName ?? "tool"}]`); } @@ -600,7 +600,7 @@ class TreeList implements Component { return isSelected ? theme.bold(result) : result; } - private extractContent(content: unknown): string { + #extractContent(content: unknown): string { const maxLen = 200; if (typeof content === "string") return content.slice(0, maxLen); if (Array.isArray(content)) { @@ -616,7 +616,7 @@ class TreeList implements Component { return ""; } - private hasTextContent(content: unknown): boolean { + #hasTextContent(content: unknown): boolean { if (typeof content === "string") return content.trim().length > 0; if (Array.isArray(content)) { for (const c of content) { @@ -629,7 +629,7 @@ class TreeList implements Component { return false; } - private formatToolCall(name: string, args: Record): string { + #formatToolCall(name: string, args: Record): string { switch (name) { case "read": { const path = shortenPath(String(args.path || args.file_path || "")); @@ -683,24 +683,24 @@ class TreeList implements Component { handleInput(keyData: string): void { if (matchesKey(keyData, "up")) { - this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; + this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredNodes.length - 1 : this.#selectedIndex - 1; } else if (matchesKey(keyData, "down")) { - this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1; + this.#selectedIndex = this.#selectedIndex === this.#filteredNodes.length - 1 ? 0 : this.#selectedIndex + 1; } else if (matchesKey(keyData, "left")) { // Page up - this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines); + this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisibleLines); } else if (matchesKey(keyData, "right")) { // Page down - this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines); + this.#selectedIndex = Math.min(this.#filteredNodes.length - 1, this.#selectedIndex + this.maxVisibleLines); } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selected = this.filteredNodes[this.selectedIndex]; + const selected = this.#filteredNodes[this.#selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.node.entry.id); } } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { - if (this.searchQuery) { - this.searchQuery = ""; - this.applyFilter(); + if (this.#searchQuery) { + this.#searchQuery = ""; + this.#applyFilter(); } else { this.onCancel?.(); } @@ -709,37 +709,37 @@ class TreeList implements Component { } else if (matchesKey(keyData, "shift+ctrl+o") || matchesKey(keyData, "ctrl+shift+o")) { // 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(); + const currentIndex = modes.indexOf(this.#filterMode); + this.#filterMode = modes[(currentIndex - 1 + modes.length) % modes.length]; + this.#applyFilter(); } else if (matchesKey(keyData, "ctrl+o")) { // 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(); + const currentIndex = modes.indexOf(this.#filterMode); + this.#filterMode = modes[(currentIndex + 1) % modes.length]; + this.#applyFilter(); } else if (matchesKey(keyData, "alt+d")) { - this.filterMode = "default"; - this.applyFilter(); + this.#filterMode = "default"; + this.#applyFilter(); } else if (matchesKey(keyData, "alt+t")) { - this.filterMode = "no-tools"; - this.applyFilter(); + this.#filterMode = "no-tools"; + this.#applyFilter(); } else if (matchesKey(keyData, "alt+u")) { - this.filterMode = "user-only"; - this.applyFilter(); + this.#filterMode = "user-only"; + this.#applyFilter(); } else if (matchesKey(keyData, "alt+l")) { - this.filterMode = "labeled-only"; - this.applyFilter(); + this.#filterMode = "labeled-only"; + this.#applyFilter(); } else if (matchesKey(keyData, "alt+a")) { - this.filterMode = "all"; - this.applyFilter(); + this.#filterMode = "all"; + this.#applyFilter(); } else if (matchesKey(keyData, "backspace")) { - if (this.searchQuery.length > 0) { - this.searchQuery = this.searchQuery.slice(0, -1); - this.applyFilter(); + if (this.#searchQuery.length > 0) { + this.#searchQuery = this.#searchQuery.slice(0, -1); + this.#applyFilter(); } - } else if (matchesKey(keyData, "shift+l") && !this.searchQuery) { - const selected = this.filteredNodes[this.selectedIndex]; + } else if (matchesKey(keyData, "shift+l") && !this.#searchQuery) { + const selected = this.#filteredNodes[this.#selectedIndex]; if (selected && this.onLabelEdit) { this.onLabelEdit(selected.node.entry.id, selected.node.label); } @@ -749,8 +749,8 @@ class TreeList implements Component { return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); }); if (!hasControlChars && keyData.length > 0) { - this.searchQuery += keyData; - this.applyFilter(); + this.#searchQuery += keyData; + this.#applyFilter(); } } } @@ -775,17 +775,17 @@ class SearchLine implements Component { /** Label input component shown when editing a label */ class LabelInput implements Component { - private input: Input; - public onSubmit?: (entryId: string, label: string | undefined) => void; - public onCancel?: () => void; + #input: Input; + onSubmit?: (entryId: string, label: string | undefined) => void; + onCancel?: () => void; constructor( private readonly entryId: string, currentLabel: string | undefined, ) { - this.input = new Input(); + this.#input = new Input(); if (currentLabel) { - this.input.setValue(currentLabel); + this.#input.setValue(currentLabel); } } @@ -796,19 +796,19 @@ class LabelInput implements Component { 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(...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 (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const value = this.input.getValue().trim(); + const value = this.#input.getValue().trim(); this.onSubmit?.(this.entryId, value || undefined); } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { this.onCancel?.(); } else { - this.input.handleInput(keyData); + this.#input.handleInput(keyData); } } } @@ -817,10 +817,10 @@ class LabelInput implements Component { * 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; + #treeList: TreeList; + #labelInput: LabelInput | null = null; + #labelInputContainer: Container; + #treeContainer: Container; constructor( tree: SessionTreeNode[], @@ -833,15 +833,15 @@ export class TreeSelectorComponent extends Container { super(); 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.#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.#treeContainer = new Container(); + this.#treeContainer.addChild(this.#treeList); - this.labelInputContainer = new Container(); + this.#labelInputContainer = new Container(); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); @@ -856,11 +856,11 @@ export class TreeSelectorComponent extends Container { 0, ), ); - this.addChild(new SearchLine(this.treeList)); + 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(this.#treeContainer); + this.addChild(this.#labelInputContainer); this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); @@ -869,36 +869,36 @@ export class TreeSelectorComponent extends Container { } } - private showLabelInput(entryId: string, currentLabel: string | undefined): void { - this.labelInput = new LabelInput(entryId, currentLabel); - this.labelInput.onSubmit = (id, label) => { - this.treeList.updateNodeLabel(id, label); + #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.#hideLabelInput(); }; - this.labelInput.onCancel = () => this.hideLabelInput(); + this.#labelInput.onCancel = () => this.#hideLabelInput(); - this.treeContainer.clear(); - this.labelInputContainer.clear(); - this.labelInputContainer.addChild(this.labelInput); + 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); + #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); + if (this.#labelInput) { + this.#labelInput.handleInput(keyData); } else { - this.treeList.handleInput(keyData); + this.#treeList.handleInput(keyData); } } getTreeList(): TreeList { - return this.treeList; + return this.#treeList; } } diff --git a/packages/coding-agent/src/modes/components/ttsr-notification.ts b/packages/coding-agent/src/modes/components/ttsr-notification.ts index 8571001b2..6734bac37 100644 --- a/packages/coding-agent/src/modes/components/ttsr-notification.ts +++ b/packages/coding-agent/src/modes/components/ttsr-notification.ts @@ -7,8 +7,8 @@ import { theme } from "../../modes/theme/theme"; * Shows when a rule violation is detected and the stream is being rewound. */ export class TtsrNotificationComponent extends Container { - private box: Box; - private _expanded = false; + #box: Box; + #expanded = false; constructor(private readonly rules: Rule[]) { super(); @@ -16,25 +16,25 @@ export class TtsrNotificationComponent extends Container { this.addChild(new Spacer(1)); // Use inverse warning color for yellow background effect - this.box = new Box(1, 1, t => theme.inverse(theme.fg("warning", t))); - this.addChild(this.box); + this.#box = new Box(1, 1, t => theme.inverse(theme.fg("warning", t))); + this.addChild(this.#box); - this.rebuild(); + this.#rebuild(); } setExpanded(expanded: boolean): void { - if (this._expanded !== expanded) { - this._expanded = expanded; - this.rebuild(); + if (this.#expanded !== expanded) { + this.#expanded = expanded; + this.#rebuild(); } } isExpanded(): boolean { - return this._expanded; + return this.#expanded; } - private rebuild(): void { - this.box.clear(); + #rebuild(): void { + this.#box.clear(); // Build header: warning symbol + rule name + rewind icon const ruleNames = this.rules.map(r => theme.bold(r.name)).join(", "); @@ -44,16 +44,16 @@ export class TtsrNotificationComponent extends Container { // Create header with rewind icon on the right const rewindIcon = theme.icon.rewind; - this.box.addChild(new Text(`${header} ${rewindIcon}`, 0, 0)); + this.#box.addChild(new Text(`${header} ${rewindIcon}`, 0, 0)); // Show description(s) - italic and truncated for (const rule of this.rules) { const desc = rule.description || rule.content; if (desc) { - this.box.addChild(new Spacer(1)); + this.#box.addChild(new Spacer(1)); let displayText = desc.trim(); - if (!this._expanded) { + if (!this.#expanded) { // Truncate to first 2 lines const lines = displayText.split("\n"); if (lines.length > 2) { @@ -62,18 +62,18 @@ export class TtsrNotificationComponent extends Container { } // Use italic for subtle distinction (fg colors conflict with inverse) - this.box.addChild(new Text(theme.italic(displayText), 0, 0)); + this.#box.addChild(new Text(theme.italic(displayText), 0, 0)); } } // Show expand hint if collapsed and there's more content - if (!this._expanded) { + if (!this.#expanded) { const hasMoreContent = this.rules.some(r => { const desc = r.description || r.content; return desc && desc.split("\n").length > 2; }); if (hasMoreContent) { - this.box.addChild(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0)); + this.#box.addChild(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0)); } } } diff --git a/packages/coding-agent/src/modes/components/user-message-selector.ts b/packages/coding-agent/src/modes/components/user-message-selector.ts index ad4d0368e..7b6dffff9 100644 --- a/packages/coding-agent/src/modes/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/components/user-message-selector.ts @@ -12,15 +12,15 @@ interface UserMessageItem { * Custom user message list component with selection */ class UserMessageList implements Component { - private selectedIndex: number = 0; - public onSelect?: (entryId: string) => void; - public onCancel?: () => void; - private maxVisible: number = 10; // Max messages visible + #selectedIndex: number = 0; + onSelect?: (entryId: string) => void; + onCancel?: () => void; + #maxVisible: number = 10; // Max messages visible constructor(private readonly messages: UserMessageItem[]) { // Store messages in chronological order (oldest to newest) // Start with the last (most recent) message selected - this.selectedIndex = Math.max(0, this.messages.length - 1); + this.#selectedIndex = Math.max(0, this.messages.length - 1); } invalidate(): void { @@ -38,14 +38,14 @@ class UserMessageList implements Component { // Calculate visible range with scrolling const startIndex = Math.max( 0, - Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.messages.length - this.maxVisible), + Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.messages.length - this.#maxVisible), ); - const endIndex = Math.min(startIndex + this.maxVisible, this.messages.length); + const endIndex = Math.min(startIndex + this.#maxVisible, this.messages.length); // Render visible messages (2 lines per message + blank line) for (let i = startIndex; i < endIndex; i++) { const message = this.messages[i]; - const isSelected = i === this.selectedIndex; + const isSelected = i === this.#selectedIndex; // Normalize message to single line const normalizedMessage = message.text.replace(/\n/g, " ").trim(); @@ -68,7 +68,7 @@ class UserMessageList implements Component { // Add scroll indicator if needed if (startIndex > 0 || endIndex < this.messages.length) { - const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.messages.length})`); + const scrollInfo = theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.messages.length})`); lines.push(scrollInfo); } @@ -78,15 +78,15 @@ class UserMessageList implements Component { handleInput(keyData: string): void { // Up arrow - go to previous (older) message, wrap to bottom when at top if (matchesKey(keyData, "up")) { - this.selectedIndex = this.selectedIndex === 0 ? this.messages.length - 1 : this.selectedIndex - 1; + this.#selectedIndex = this.#selectedIndex === 0 ? this.messages.length - 1 : this.#selectedIndex - 1; } // Down arrow - go to next (newer) message, wrap to top when at bottom else if (matchesKey(keyData, "down")) { - this.selectedIndex = this.selectedIndex === this.messages.length - 1 ? 0 : this.selectedIndex + 1; + this.#selectedIndex = this.#selectedIndex === this.messages.length - 1 ? 0 : this.#selectedIndex + 1; } // Enter - select message and branch else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selected = this.messages[this.selectedIndex]; + const selected = this.messages[this.#selectedIndex]; if (selected && this.onSelect) { this.onSelect(selected.id); } @@ -110,7 +110,7 @@ class UserMessageList implements Component { * Component that renders a user message selector for branching */ export class UserMessageSelectorComponent extends Container { - private messageList: UserMessageList; + #messageList: UserMessageList; constructor(messages: UserMessageItem[], onSelect: (entryId: string) => void, onCancel: () => void) { super(); @@ -124,11 +124,11 @@ export class UserMessageSelectorComponent extends Container { this.addChild(new Spacer(1)); // Create message list - this.messageList = new UserMessageList(messages); - this.messageList.onSelect = onSelect; - this.messageList.onCancel = onCancel; + this.#messageList = new UserMessageList(messages); + this.#messageList.onSelect = onSelect; + this.#messageList.onCancel = onCancel; - this.addChild(this.messageList); + this.addChild(this.#messageList); // Add bottom border this.addChild(new Spacer(1)); @@ -141,6 +141,6 @@ export class UserMessageSelectorComponent extends Container { } getMessageList(): UserMessageList { - return this.messageList; + return this.#messageList; } } diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index 0855149ac..44d9a8f34 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -53,17 +53,17 @@ export class WelcomeComponent implements Component { const piLogo = ["▀████████████▀", " ╘███ ███ ", " ███ ███ ", " ███ ███ ", " ▄███▄ ▄███▄ "]; // Apply gradient to logo - const logoColored = piLogo.map(line => this.gradientLine(line)); + const logoColored = piLogo.map(line => this.#gradientLine(line)); // Left column - centered content const leftLines = [ "", - this.centerText(theme.bold("Welcome back!"), leftCol), + this.#centerText(theme.bold("Welcome back!"), leftCol), "", - ...logoColored.map(l => this.centerText(l, leftCol)), + ...logoColored.map(l => this.#centerText(l, leftCol)), "", - this.centerText(theme.fg("muted", this.modelName), leftCol), - this.centerText(theme.fg("borderMuted", this.providerName), leftCol), + this.#centerText(theme.fg("muted", this.modelName), leftCol), + this.#centerText(theme.fg("borderMuted", this.providerName), leftCol), ]; // Right column separator @@ -138,8 +138,8 @@ export class WelcomeComponent implements Component { // Content rows const maxRows = Math.max(leftLines.length, rightLines.length); for (let i = 0; i < maxRows; i++) { - const left = this.fitToWidth(leftLines[i] ?? "", leftCol); - const right = this.fitToWidth(rightLines[i] ?? "", rightCol); + const left = this.#fitToWidth(leftLines[i] ?? "", leftCol); + const right = this.#fitToWidth(rightLines[i] ?? "", rightCol); lines.push(v + left + v + right + v); } @@ -150,7 +150,7 @@ export class WelcomeComponent implements Component { } /** Center text within a given width */ - private centerText(text: string, width: number): string { + #centerText(text: string, width: number): string { const visLen = visibleWidth(text); if (visLen >= width) { return truncateToWidth(text, width); @@ -161,7 +161,7 @@ export class WelcomeComponent implements Component { } /** Apply magenta→cyan gradient to a string */ - private gradientLine(line: string): string { + #gradientLine(line: string): string { const colors = [ "\x1b[38;5;199m", // bright magenta "\x1b[38;5;171m", // magenta-purple @@ -191,7 +191,7 @@ export class WelcomeComponent implements Component { } /** Fit string to exact width with ANSI-aware truncation/padding */ - private fitToWidth(str: string, width: number): string { + #fitToWidth(str: string, width: number): string { const visLen = visibleWidth(str); if (visLen > width) { const ellipsis = "…"; diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 82db23108..0961f7514 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -9,7 +9,6 @@ import { $ } from "bun"; import { loadCustomShare } from "../../export/custom-share"; import type { CompactOptions } from "../../extensibility/extensions/types"; import { getGatewayStatus } from "../../ipy/gateway-coordinator"; -import { ArminComponent } from "../../modes/components/armin"; import { BashExecutionComponent } from "../../modes/components/bash-execution"; import { BorderedLoader } from "../../modes/components/bordered-loader"; import { DynamicBorder } from "../../modes/components/dynamic-border"; @@ -448,12 +447,6 @@ export class CommandController { this.ctx.ui.requestRender(); } - handleArminSaysHi(): void { - this.ctx.chatContainer.addChild(new Spacer(1)); - this.ctx.chatContainer.addChild(new ArminComponent(this.ctx.ui)); - this.ctx.ui.requestRender(); - } - async handleBashCommand(command: string, excludeFromContext = false): Promise { const isDeferred = this.ctx.session.isStreaming; this.ctx.bashComponent = new BashExecutionComponent(command, this.ctx.ui, excludeFromContext); diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 6a80fb05a..a1c2b5311 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -11,25 +11,25 @@ import type { AgentSessionEvent } from "../../session/agent-session"; import type { ExitPlanModeDetails } from "../../tools"; export class EventController { - private lastReadGroup: ReadToolGroupComponent | undefined = undefined; - private lastThinkingCount = 0; - private renderedCustomMessages = new Set(); + #lastReadGroup: ReadToolGroupComponent | undefined = undefined; + #lastThinkingCount = 0; + #renderedCustomMessages = new Set(); constructor(private ctx: InteractiveModeContext) {} - private resetReadGroup(): void { - this.lastReadGroup = undefined; + #resetReadGroup(): void { + this.#lastReadGroup = undefined; } - private getReadGroup(): ReadToolGroupComponent { - if (!this.lastReadGroup) { + #getReadGroup(): ReadToolGroupComponent { + if (!this.#lastReadGroup) { this.ctx.chatContainer.addChild(new Text("", 0, 0)); const group = new ReadToolGroupComponent(); group.setExpanded(this.ctx.toolOutputExpanded); this.ctx.chatContainer.addChild(group); - this.lastReadGroup = group; + this.#lastReadGroup = group; } - return this.lastReadGroup; + return this.#lastReadGroup; } subscribeToAgent(): void { @@ -76,15 +76,15 @@ export class EventController { case "message_start": if (event.message.role === "hookMessage" || event.message.role === "custom") { const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`; - if (this.renderedCustomMessages.has(signature)) { + if (this.#renderedCustomMessages.has(signature)) { break; } - this.renderedCustomMessages.add(signature); - this.resetReadGroup(); + this.#renderedCustomMessages.add(signature); + this.#resetReadGroup(); this.ctx.addMessageToChat(event.message); this.ctx.ui.requestRender(); } else if (event.message.role === "user") { - this.resetReadGroup(); + this.#resetReadGroup(); this.ctx.addMessageToChat(event.message); if (!event.message.synthetic) { this.ctx.editor.setText(""); @@ -92,12 +92,12 @@ export class EventController { } this.ctx.ui.requestRender(); } else if (event.message.role === "fileMention") { - this.resetReadGroup(); + this.#resetReadGroup(); this.ctx.addMessageToChat(event.message); this.ctx.ui.requestRender(); } else if (event.message.role === "assistant") { - this.lastThinkingCount = 0; - this.resetReadGroup(); + this.#lastThinkingCount = 0; + this.#resetReadGroup(); this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock); this.ctx.streamingMessage = event.message; this.ctx.chatContainer.addChild(this.ctx.streamingComponent); @@ -114,9 +114,9 @@ export class EventController { const thinkingCount = this.ctx.streamingMessage.content.filter( content => content.type === "thinking" && content.thinking.trim(), ).length; - if (thinkingCount > this.lastThinkingCount) { - this.resetReadGroup(); - this.lastThinkingCount = thinkingCount; + if (thinkingCount > this.#lastThinkingCount) { + this.#resetReadGroup(); + this.#lastThinkingCount = thinkingCount; } for (const content of this.ctx.streamingMessage.content) { @@ -124,13 +124,13 @@ export class EventController { if (!this.ctx.pendingTools.has(content.id)) { if (content.name === "read") { - const group = this.getReadGroup(); + const group = this.#getReadGroup(); group.updateArgs(content.arguments, content.id); this.ctx.pendingTools.set(content.id, group); continue; } - this.resetReadGroup(); + this.#resetReadGroup(); this.ctx.chatContainer.addChild(new Text("", 0, 0)); const tool = this.ctx.session.getToolByName(content.name); const component = new ToolExecutionComponent( @@ -198,14 +198,14 @@ export class EventController { case "tool_execution_start": { if (!this.ctx.pendingTools.has(event.toolCallId)) { if (event.toolName === "read") { - const group = this.getReadGroup(); + const group = this.#getReadGroup(); group.updateArgs(event.args, event.toolCallId); this.ctx.pendingTools.set(event.toolCallId, group); this.ctx.ui.requestRender(); break; } - this.resetReadGroup(); + this.#resetReadGroup(); const tool = this.ctx.session.getToolByName(event.toolName); const component = new ToolExecutionComponent( event.toolName, diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index cb19d1142..3e9fa5c06 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -18,10 +18,10 @@ import type { InteractiveModeContext } from "../../modes/types"; import { setTerminalTitle } from "../../utils/title-generator"; export class ExtensionUiController { - private hookSelectorOverlay: OverlayHandle | undefined; - private hookInputOverlay: OverlayHandle | undefined; + #hookSelectorOverlay: OverlayHandle | undefined; + #hookInputOverlay: OverlayHandle | undefined; - private readonly dialogOverlayOptions = { + readonly #dialogOverlayOptions = { anchor: "bottom-center", width: "80%", minWidth: 40, @@ -555,8 +555,8 @@ export class ExtensionUiController { dialogOptions?: ExtensionUIDialogOptions, ): Promise { const { promise, resolve } = Promise.withResolvers(); - this.hookSelectorOverlay?.hide(); - this.hookSelectorOverlay = undefined; + this.#hookSelectorOverlay?.hide(); + this.#hookSelectorOverlay = undefined; const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12)); this.ctx.hookSelector = new HookSelectorComponent( title, @@ -577,7 +577,7 @@ export class ExtensionUiController { maxVisible, }, ); - this.hookSelectorOverlay = this.ctx.ui.showOverlay(this.ctx.hookSelector, this.dialogOverlayOptions); + this.#hookSelectorOverlay = this.ctx.ui.showOverlay(this.ctx.hookSelector, this.#dialogOverlayOptions); return promise; } @@ -586,8 +586,8 @@ export class ExtensionUiController { */ hideHookSelector(): void { this.ctx.hookSelector?.dispose(); - this.hookSelectorOverlay?.hide(); - this.hookSelectorOverlay = undefined; + this.#hookSelectorOverlay?.hide(); + this.#hookSelectorOverlay = undefined; this.ctx.hookSelector = undefined; this.ctx.ui.setFocus(this.ctx.editor); this.ctx.ui.requestRender(); @@ -606,8 +606,8 @@ export class ExtensionUiController { */ showHookInput(title: string, placeholder?: string): Promise { const { promise, resolve } = Promise.withResolvers(); - this.hookInputOverlay?.hide(); - this.hookInputOverlay = undefined; + this.#hookInputOverlay?.hide(); + this.#hookInputOverlay = undefined; this.ctx.hookInput = new HookInputComponent( title, placeholder, @@ -620,7 +620,7 @@ export class ExtensionUiController { resolve(undefined); }, ); - this.hookInputOverlay = this.ctx.ui.showOverlay(this.ctx.hookInput, this.dialogOverlayOptions); + this.#hookInputOverlay = this.ctx.ui.showOverlay(this.ctx.hookInput, this.#dialogOverlayOptions); return promise; } @@ -629,8 +629,8 @@ export class ExtensionUiController { */ hideHookInput(): void { this.ctx.hookInput?.dispose(); - this.hookInputOverlay?.hide(); - this.hookInputOverlay = undefined; + this.#hookInputOverlay?.hide(); + this.#hookInputOverlay = undefined; this.ctx.hookInput = undefined; this.ctx.ui.setFocus(this.ctx.editor); this.ctx.ui.requestRender(); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 2fd5fc3ba..4e6c1b35f 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -322,11 +322,6 @@ export class InputController { this.ctx.editor.setText(""); return; } - if (text === "/arminsayshi") { - this.ctx.handleArminSaysHi(); - this.ctx.editor.setText(""); - return; - } if (text === "/resume") { this.ctx.showSessionSelector(); this.ctx.editor.setText(""); @@ -722,15 +717,15 @@ export class InputController { this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`); } - private getEditorTerminalPath(): string | null { + #getEditorTerminalPath(): string | null { if (process.platform === "win32") { return null; } return "/dev/tty"; } - private async openEditorTerminalHandle(): Promise { - const terminalPath = this.getEditorTerminalPath(); + async #openEditorTerminalHandle(): Promise { + const terminalPath = this.#getEditorTerminalPath(); if (!terminalPath) { return null; } @@ -752,7 +747,7 @@ export class InputController { let ttyHandle: fs.FileHandle | null = null; try { - ttyHandle = await this.openEditorTerminalHandle(); + ttyHandle = await this.#openEditorTerminalHandle(); this.ctx.ui.stop(); const stdio: [number | "inherit", number | "inherit", number | "inherit"] = ttyHandle diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index fde5c8df5..656a5fcf8 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -3,7 +3,7 @@ * Handles TUI rendering and user interaction, delegating business logic to AgentSession. */ import * as path from "node:path"; -import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { Agent, AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai"; import type { Component, Loader, SlashCommand } from "@oh-my-pi/pi-tui"; import { @@ -74,83 +74,83 @@ export interface InteractiveModeOptions { } export class InteractiveMode implements InteractiveModeContext { - public session: AgentSession; - public sessionManager: SessionManager; - public settings: Settings; - public keybindings: KeybindingsManager; - public agent: AgentSession["agent"]; - public historyStorage?: HistoryStorage; + session: AgentSession; + sessionManager: SessionManager; + settings: Settings; + keybindings: KeybindingsManager; + agent: Agent; + historyStorage?: HistoryStorage; - public ui: TUI; - public chatContainer: Container; - public pendingMessagesContainer: Container; - public statusContainer: Container; - public todoContainer: Container; - public editor: CustomEditor; - public editorContainer: Container; - public statusLine: StatusLineComponent; + ui: TUI; + chatContainer: Container; + pendingMessagesContainer: Container; + statusContainer: Container; + todoContainer: Container; + editor: CustomEditor; + editorContainer: Container; + statusLine: StatusLineComponent; - public isInitialized = false; - public isBackgrounded = false; - public isBashMode = false; - public toolOutputExpanded = false; - public todoExpanded = false; - public planModeEnabled = false; - public planModePaused = false; - public planModePlanFilePath: string | undefined = undefined; - public todoItems: TodoItem[] = []; - public hideThinkingBlock = false; - public pendingImages: ImageContent[] = []; - public compactionQueuedMessages: CompactionQueuedMessage[] = []; - public pendingTools = new Map(); - public pendingBashComponents: BashExecutionComponent[] = []; - public bashComponent: BashExecutionComponent | undefined = undefined; - public pendingPythonComponents: PythonExecutionComponent[] = []; - public pythonComponent: PythonExecutionComponent | undefined = undefined; - public isPythonMode = false; - public streamingComponent: AssistantMessageComponent | undefined = undefined; - public streamingMessage: AssistantMessage | undefined = undefined; - public loadingAnimation: Loader | undefined = undefined; - public autoCompactionLoader: Loader | undefined = undefined; - public retryLoader: Loader | undefined = undefined; - private pendingWorkingMessage: string | undefined; - private readonly defaultWorkingMessage = `Working… (esc to interrupt)`; - public autoCompactionEscapeHandler?: () => void; - public retryEscapeHandler?: () => void; - public unsubscribe?: () => void; - public onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void; - public lastSigintTime = 0; - public lastEscapeTime = 0; - public shutdownRequested = false; - private isShuttingDown = false; - public hookSelector: HookSelectorComponent | undefined = undefined; - public hookInput: HookInputComponent | undefined = undefined; - public hookEditor: HookEditorComponent | undefined = undefined; - public lastStatusSpacer: Spacer | undefined = undefined; - public lastStatusText: Text | undefined = undefined; - public fileSlashCommands: Set = new Set(); - public skillCommands: Map = new Map(); + isInitialized = false; + isBackgrounded = false; + isBashMode = false; + toolOutputExpanded = false; + todoExpanded = false; + planModeEnabled = false; + planModePaused = false; + planModePlanFilePath: string | undefined = undefined; + todoItems: TodoItem[] = []; + hideThinkingBlock = false; + pendingImages: ImageContent[] = []; + compactionQueuedMessages: CompactionQueuedMessage[] = []; + pendingTools = new Map(); + pendingBashComponents: BashExecutionComponent[] = []; + bashComponent: BashExecutionComponent | undefined = undefined; + pendingPythonComponents: PythonExecutionComponent[] = []; + pythonComponent: PythonExecutionComponent | undefined = undefined; + isPythonMode = false; + streamingComponent: AssistantMessageComponent | undefined = undefined; + streamingMessage: AssistantMessage | undefined = undefined; + loadingAnimation: Loader | undefined = undefined; + autoCompactionLoader: Loader | undefined = undefined; + retryLoader: Loader | undefined = undefined; + #pendingWorkingMessage: string | undefined; + readonly #defaultWorkingMessage = `Working… (esc to interrupt)`; + autoCompactionEscapeHandler?: () => void; + retryEscapeHandler?: () => void; + unsubscribe?: () => void; + onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void; + lastSigintTime = 0; + lastEscapeTime = 0; + shutdownRequested = false; + #isShuttingDown = false; + hookSelector: HookSelectorComponent | undefined = undefined; + hookInput: HookInputComponent | undefined = undefined; + hookEditor: HookEditorComponent | undefined = undefined; + lastStatusSpacer: Spacer | undefined = undefined; + lastStatusText: Text | undefined = undefined; + fileSlashCommands: Set = new Set(); + skillCommands: Map = new Map(); - private pendingSlashCommands: SlashCommand[] = []; - private cleanupUnsubscribe?: () => void; - private readonly version: string; - private readonly changelogMarkdown: string | undefined; - private planModePreviousTools: string[] | undefined; - private planModePreviousModel: Model | undefined; - private pendingModelSwitch: Model | undefined; - private planModeHasEntered = false; - public readonly lspServers: + #pendingSlashCommands: SlashCommand[] = []; + #cleanupUnsubscribe?: () => void; + readonly #version: string; + readonly #changelogMarkdown: string | undefined; + #planModePreviousTools: string[] | undefined; + #planModePreviousModel: Model | undefined; + #pendingModelSwitch: Model | undefined; + #planModeHasEntered = false; + readonly lspServers: | Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }> | undefined = undefined; - public mcpManager?: import("../mcp").MCPManager; - private readonly toolUiContextSetter: (uiContext: ExtensionUIContext, hasUI: boolean) => void; + mcpManager?: import("../mcp").MCPManager; + readonly #toolUiContextSetter: (uiContext: ExtensionUIContext, hasUI: boolean) => void; - private readonly commandController: CommandController; - private readonly eventController: EventController; - private readonly extensionUiController: ExtensionUiController; - private readonly inputController: InputController; - private readonly selectorController: SelectorController; - private readonly uiHelpers: UiHelpers; + readonly #commandController: CommandController; + readonly #eventController: EventController; + readonly #extensionUiController: ExtensionUiController; + readonly #inputController: InputController; + readonly #selectorController: SelectorController; + readonly #uiHelpers: UiHelpers; constructor( session: AgentSession, @@ -167,9 +167,9 @@ export class InteractiveMode implements InteractiveModeContext { this.settings = session.settings; this.keybindings = KeybindingsManager.inMemory(); this.agent = session.agent; - this.version = version; - this.changelogMarkdown = changelogMarkdown; - this.toolUiContextSetter = setToolUIContext; + this.#version = version; + this.#changelogMarkdown = changelogMarkdown; + this.#toolUiContextSetter = setToolUIContext; this.lspServers = lspServers; this.mcpManager = mcpManager; @@ -227,14 +227,14 @@ export class InteractiveMode implements InteractiveModeContext { } // Store pending commands for init() where file commands are loaded async - this.pendingSlashCommands = [...BUILTIN_SLASH_COMMANDS, ...hookCommands, ...customCommands, ...skillCommandList]; + this.#pendingSlashCommands = [...BUILTIN_SLASH_COMMANDS, ...hookCommands, ...customCommands, ...skillCommandList]; - this.uiHelpers = new UiHelpers(this); - this.extensionUiController = new ExtensionUiController(this); - this.eventController = new EventController(this); - this.commandController = new CommandController(this); - this.selectorController = new SelectorController(this); - this.inputController = new InputController(this); + this.#uiHelpers = new UiHelpers(this); + this.#extensionUiController = new ExtensionUiController(this); + this.#eventController = new EventController(this); + this.#commandController = new CommandController(this); + this.#selectorController = new SelectorController(this); + this.#inputController = new InputController(this); } async init(): Promise { @@ -245,7 +245,7 @@ export class InteractiveMode implements InteractiveModeContext { debugStartup("InteractiveMode.init:keybindings"); // Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP) - this.cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush()); + this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush()); debugStartup("InteractiveMode.init:cleanupRegistered"); // Load and convert file commands to SlashCommand format (async) @@ -259,7 +259,7 @@ export class InteractiveMode implements InteractiveModeContext { // Setup autocomplete with all commands const autocompleteProvider = new CombinedAutocompleteProvider( - [...this.pendingSlashCommands, ...fileSlashCommands], + [...this.#pendingSlashCommands, ...fileSlashCommands], process.cwd(), ); this.editor.setAutocompleteProvider(autocompleteProvider); @@ -288,7 +288,7 @@ export class InteractiveMode implements InteractiveModeContext { if (!startupQuiet) { // Add welcome header debugStartup("InteractiveMode.init:welcomeComponent:start"); - const welcome = new WelcomeComponent(this.version, modelName, providerName, recentSessions, lspServerInfo); + const welcome = new WelcomeComponent(this.#version, modelName, providerName, recentSessions, lspServerInfo); debugStartup("InteractiveMode.init:welcomeComponent:created"); // Setup UI layout @@ -297,17 +297,17 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.addChild(new Spacer(1)); // Add changelog if provided - if (this.changelogMarkdown) { + if (this.#changelogMarkdown) { this.ui.addChild(new DynamicBorder()); if (settings.get("collapseChangelog")) { - const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/); - const latestVersion = versionMatch ? versionMatch[1] : this.version; + const versionMatch = this.#changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/); + const latestVersion = versionMatch ? versionMatch[1] : this.#version; const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`; this.ui.addChild(new Text(condensedText, 1, 0)); } else { this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0)); this.ui.addChild(new Spacer(1)); - this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme())); + this.ui.addChild(new Markdown(this.#changelogMarkdown.trim(), 1, 0, getMarkdownTheme())); this.ui.addChild(new Spacer(1)); } this.ui.addChild(new DynamicBorder()); @@ -329,11 +329,11 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border) this.ui.setFocus(this.editor); - this.inputController.setupKeyHandlers(); - this.inputController.setupEditorSubmitHandler(); + this.#inputController.setupKeyHandlers(); + this.#inputController.setupEditorSubmitHandler(); // Load initial todos - await this.loadTodoList(); + await this.#loadTodoList(); // Start the UI this.ui.start(); @@ -347,10 +347,10 @@ export class InteractiveMode implements InteractiveModeContext { await this.initHooksAndCustomTools(); // Restore mode from session (e.g. plan mode on resume) - await this.restoreModeFromSession(); + await this.#restoreModeFromSession(); // Subscribe to agent events - this.subscribeToAgent(); + this.#subscribeToAgent(); // Set up theme file watcher onThemeChange(() => { @@ -403,7 +403,7 @@ export class InteractiveMode implements InteractiveModeContext { this.renderSessionContext(context); } - private formatTodoLine(todo: TodoItem, prefix: string): string { + #formatTodoLine(todo: TodoItem, prefix: string): string { const checkbox = theme.checkbox; const label = todo.content; switch (todo.status) { @@ -416,7 +416,7 @@ export class InteractiveMode implements InteractiveModeContext { } } - private getCollapsedTodos(todos: TodoItem[]): TodoItem[] { + #getCollapsedTodos(todos: TodoItem[]): TodoItem[] { let startIndex = 0; for (let i = todos.length - 1; i >= 0; i -= 1) { if (todos[i].status === "completed") { @@ -427,20 +427,20 @@ export class InteractiveMode implements InteractiveModeContext { return todos.slice(startIndex, startIndex + 5); } - private renderTodoList(): void { + #renderTodoList(): void { this.todoContainer.clear(); if (this.todoItems.length === 0) { return; } - const visibleTodos = this.todoExpanded ? this.todoItems : this.getCollapsedTodos(this.todoItems); + const visibleTodos = this.todoExpanded ? this.todoItems : this.#getCollapsedTodos(this.todoItems); const indent = " "; const hook = theme.tree.hook; const lines = [indent + theme.bold(theme.fg("accent", "Todos"))]; visibleTodos.forEach((todo, index) => { const prefix = `${indent}${index === 0 ? hook : " "} `; - lines.push(this.formatTodoLine(todo, prefix)); + lines.push(this.#formatTodoLine(todo, prefix)); }); if (!this.todoExpanded && visibleTodos.length < this.todoItems.length) { @@ -451,11 +451,11 @@ export class InteractiveMode implements InteractiveModeContext { this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0)); } - private async loadTodoList(): Promise { + async #loadTodoList(): Promise { const sessionFile = this.sessionManager.getSessionFile() ?? null; if (!sessionFile) { this.todoItems = []; - this.renderTodoList(); + this.#renderTodoList(); return; } const artifactsDir = sessionFile.slice(0, -6); @@ -470,20 +470,20 @@ export class InteractiveMode implements InteractiveModeContext { } catch (error) { if (isEnoent(error)) { this.todoItems = []; - this.renderTodoList(); + this.#renderTodoList(); return; } logger.warn("Failed to load todos", { path: todoPath, error: String(error) }); } - this.renderTodoList(); + this.#renderTodoList(); } - private getPlanFilePath(): string { + #getPlanFilePath(): string { const sessionId = this.sessionManager.getSessionId(); return `plan://${sessionId}/plan.md`; } - private resolvePlanFilePath(planFilePath: string): string { + #resolvePlanFilePath(planFilePath: string): string { if (planFilePath.startsWith("plan://")) { return resolvePlanUrlToPath(planFilePath, { getPlansDirectory: () => this.settings.getPlansDirectory(), @@ -493,7 +493,7 @@ export class InteractiveMode implements InteractiveModeContext { return planFilePath; } - private updatePlanModeStatus(): void { + #updatePlanModeStatus(): void { const status = this.planModeEnabled || this.planModePaused ? { @@ -506,16 +506,16 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.requestRender(); } - private async applyPlanModeModel(): Promise { + async #applyPlanModeModel(): Promise { const planModel = this.session.resolveRoleModel("plan"); if (!planModel) return; const currentModel = this.session.model; if (currentModel && currentModel.provider === planModel.provider && currentModel.id === planModel.id) { return; } - this.planModePreviousModel = currentModel; + this.#planModePreviousModel = currentModel; if (this.session.isStreaming) { - this.pendingModelSwitch = planModel; + this.#pendingModelSwitch = planModel; return; } try { @@ -529,9 +529,9 @@ export class InteractiveMode implements InteractiveModeContext { /** Apply any deferred model switch after the current stream ends. */ async flushPendingModelSwitch(): Promise { - const model = this.pendingModelSwitch; + const model = this.#pendingModelSwitch; if (!model) return; - this.pendingModelSwitch = undefined; + this.#pendingModelSwitch = undefined; try { await this.session.setModelTemporary(model); } catch (error) { @@ -542,35 +542,32 @@ export class InteractiveMode implements InteractiveModeContext { } /** Restore mode state from session entries on resume (e.g. plan mode). */ - private async restoreModeFromSession(): Promise { + async #restoreModeFromSession(): Promise { const sessionContext = this.sessionManager.buildSessionContext(); if (sessionContext.mode === "plan") { const planFilePath = sessionContext.modeData?.planFilePath as string | undefined; - await this.enterPlanMode({ planFilePath }); + await this.#enterPlanMode({ planFilePath }); } else if (sessionContext.mode === "plan_paused") { this.planModePaused = true; - this.planModeHasEntered = true; - this.updatePlanModeStatus(); + this.#planModeHasEntered = true; + this.#updatePlanModeStatus(); } } - private async enterPlanMode(options?: { - planFilePath?: string; - workflow?: "parallel" | "iterative"; - }): Promise { + async #enterPlanMode(options?: { planFilePath?: string; workflow?: "parallel" | "iterative" }): Promise { if (this.planModeEnabled) { return; } this.planModePaused = false; - const planFilePath = options?.planFilePath ?? this.getPlanFilePath(); + const planFilePath = options?.planFilePath ?? this.#getPlanFilePath(); const previousTools = this.session.getActiveToolNames(); const hasExitTool = this.session.getToolByName("exit_plan_mode") !== undefined; const planTools = hasExitTool ? [...previousTools, "exit_plan_mode"] : previousTools; const uniquePlanTools = [...new Set(planTools)]; - this.planModePreviousTools = previousTools; + this.#planModePreviousTools = previousTools; this.planModePlanFilePath = planFilePath; this.planModeEnabled = true; @@ -579,32 +576,32 @@ export class InteractiveMode implements InteractiveModeContext { enabled: true, planFilePath, workflow: options?.workflow ?? "parallel", - reentry: this.planModeHasEntered, + reentry: this.#planModeHasEntered, }); if (this.session.isStreaming) { await this.session.sendPlanModeContext({ deliverAs: "steer" }); } - this.planModeHasEntered = true; - await this.applyPlanModeModel(); - this.updatePlanModeStatus(); + this.#planModeHasEntered = true; + await this.#applyPlanModeModel(); + this.#updatePlanModeStatus(); this.sessionManager.appendModeChange("plan", { planFilePath }); this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`); } - private async exitPlanMode(options?: { silent?: boolean; paused?: boolean }): Promise { + async #exitPlanMode(options?: { silent?: boolean; paused?: boolean }): Promise { if (!this.planModeEnabled) { return; } - const previousTools = this.planModePreviousTools; + const previousTools = this.#planModePreviousTools; if (previousTools && previousTools.length > 0) { await this.session.setActiveToolsByName(previousTools); } - if (this.planModePreviousModel) { + if (this.#planModePreviousModel) { if (this.session.isStreaming) { - this.pendingModelSwitch = this.planModePreviousModel; + this.#pendingModelSwitch = this.#planModePreviousModel; } else { - await this.session.setModelTemporary(this.planModePreviousModel); + await this.session.setModelTemporary(this.#planModePreviousModel); } } @@ -612,9 +609,9 @@ export class InteractiveMode implements InteractiveModeContext { this.planModeEnabled = false; this.planModePaused = options?.paused ?? false; this.planModePlanFilePath = undefined; - this.planModePreviousTools = undefined; - this.planModePreviousModel = undefined; - this.updatePlanModeStatus(); + this.#planModePreviousTools = undefined; + this.#planModePreviousModel = undefined; + this.#updatePlanModeStatus(); const paused = options?.paused ?? false; this.sessionManager.appendModeChange(paused ? "plan_paused" : "none"); if (!options?.silent) { @@ -622,8 +619,8 @@ export class InteractiveMode implements InteractiveModeContext { } } - private async readPlanFile(planFilePath: string): Promise { - const resolvedPath = this.resolvePlanFilePath(planFilePath); + async #readPlanFile(planFilePath: string): Promise { + const resolvedPath = this.#resolvePlanFilePath(planFilePath); try { return await Bun.file(resolvedPath).text(); } catch (error) { @@ -634,7 +631,7 @@ export class InteractiveMode implements InteractiveModeContext { } } - private renderPlanPreview(planContent: string): void { + #renderPlanPreview(planContent: string): void { this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DynamicBorder()); this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Plan Review")), 1, 1)); @@ -644,9 +641,9 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.requestRender(); } - private async approvePlan(planContent: string): Promise { - const previousTools = this.planModePreviousTools ?? this.session.getActiveToolNames(); - await this.exitPlanMode({ silent: true, paused: false }); + async #approvePlan(planContent: string): Promise { + const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames(); + await this.#exitPlanMode({ silent: true, paused: false }); await this.handleClearCommand(); if (previousTools.length > 0) { await this.session.setActiveToolsByName(previousTools); @@ -663,10 +660,10 @@ export class InteractiveMode implements InteractiveModeContext { "This exits plan mode without approving a plan.", ); if (!confirmed) return; - await this.exitPlanMode({ paused: true }); + await this.#exitPlanMode({ paused: true }); return; } - await this.enterPlanMode(); + await this.#enterPlanMode(); } async handleExitPlanModeTool(details: ExitPlanModeDetails): Promise { @@ -675,15 +672,15 @@ export class InteractiveMode implements InteractiveModeContext { return; } - const planFilePath = details.planFilePath || this.planModePlanFilePath || this.getPlanFilePath(); + const planFilePath = details.planFilePath || this.planModePlanFilePath || this.#getPlanFilePath(); this.planModePlanFilePath = planFilePath; - const planContent = await this.readPlanFile(planFilePath); + const planContent = await this.#readPlanFile(planFilePath); if (!planContent) { this.showError(`Plan file not found at ${planFilePath}`); return; } - this.renderPlanPreview(planContent); + this.#renderPlanPreview(planContent); const choice = await this.showHookSelector("Plan mode - next step", [ "Approve and execute", "Refine plan", @@ -691,7 +688,7 @@ export class InteractiveMode implements InteractiveModeContext { ]); if (choice === "Approve and execute") { - await this.approvePlan(planContent); + await this.#approvePlan(planContent); return; } if (choice === "Refine plan") { @@ -711,8 +708,8 @@ export class InteractiveMode implements InteractiveModeContext { if (this.unsubscribe) { this.unsubscribe(); } - if (this.cleanupUnsubscribe) { - this.cleanupUnsubscribe(); + if (this.#cleanupUnsubscribe) { + this.#cleanupUnsubscribe(); } if (this.isInitialized) { this.ui.stop(); @@ -721,8 +718,8 @@ export class InteractiveMode implements InteractiveModeContext { } async shutdown(): Promise { - if (this.isShuttingDown) return; - this.isShuttingDown = true; + if (this.#isShuttingDown) return; + this.#isShuttingDown = true; // Flush pending session writes before shutdown await this.sessionManager.flush(); @@ -761,40 +758,40 @@ export class InteractiveMode implements InteractiveModeContext { // Extension UI integration setToolUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void { - this.toolUiContextSetter(uiContext, hasUI); + this.#toolUiContextSetter(uiContext, hasUI); } initializeHookRunner(uiContext: ExtensionUIContext, hasUI: boolean): void { - this.extensionUiController.initializeHookRunner(uiContext, hasUI); + this.#extensionUiController.initializeHookRunner(uiContext, hasUI); } createBackgroundUiContext(): ExtensionUIContext { - return this.extensionUiController.createBackgroundUiContext(); + return this.#extensionUiController.createBackgroundUiContext(); } // Event handling async handleBackgroundEvent(event: AgentSessionEvent): Promise { - await this.eventController.handleBackgroundEvent(event); + await this.#eventController.handleBackgroundEvent(event); } // UI helpers showStatus(message: string, options?: { dim?: boolean }): void { - this.uiHelpers.showStatus(message, options); + this.#uiHelpers.showStatus(message, options); } showError(message: string): void { - this.uiHelpers.showError(message); + this.#uiHelpers.showError(message); } showWarning(message: string): void { - this.uiHelpers.showWarning(message); + this.#uiHelpers.showWarning(message); } setWorkingMessage(message?: string): void { if (message === undefined) { - this.pendingWorkingMessage = undefined; + this.#pendingWorkingMessage = undefined; if (this.loadingAnimation) { - this.loadingAnimation.setMessage(this.defaultWorkingMessage); + this.loadingAnimation.setMessage(this.#defaultWorkingMessage); } return; } @@ -804,276 +801,272 @@ export class InteractiveMode implements InteractiveModeContext { return; } - this.pendingWorkingMessage = message; + this.#pendingWorkingMessage = message; } applyPendingWorkingMessage(): void { - if (this.pendingWorkingMessage === undefined) { + if (this.#pendingWorkingMessage === undefined) { return; } - const message = this.pendingWorkingMessage; - this.pendingWorkingMessage = undefined; + const message = this.#pendingWorkingMessage; + this.#pendingWorkingMessage = undefined; this.setWorkingMessage(message); } showNewVersionNotification(newVersion: string): void { - this.uiHelpers.showNewVersionNotification(newVersion); + this.#uiHelpers.showNewVersionNotification(newVersion); } clearEditor(): void { - this.uiHelpers.clearEditor(); + this.#uiHelpers.clearEditor(); } updatePendingMessagesDisplay(): void { - this.uiHelpers.updatePendingMessagesDisplay(); + this.#uiHelpers.updatePendingMessagesDisplay(); } queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { - this.uiHelpers.queueCompactionMessage(text, mode); + this.#uiHelpers.queueCompactionMessage(text, mode); } flushCompactionQueue(options?: { willRetry?: boolean }): Promise { - return this.uiHelpers.flushCompactionQueue(options); + return this.#uiHelpers.flushCompactionQueue(options); } flushPendingBashComponents(): void { - this.uiHelpers.flushPendingBashComponents(); + this.#uiHelpers.flushPendingBashComponents(); } isKnownSlashCommand(text: string): boolean { - return this.uiHelpers.isKnownSlashCommand(text); + return this.#uiHelpers.isKnownSlashCommand(text); } addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { - this.uiHelpers.addMessageToChat(message, options); + this.#uiHelpers.addMessageToChat(message, options); } renderSessionContext( sessionContext: SessionContext, options?: { updateFooter?: boolean; populateHistory?: boolean }, ): void { - this.uiHelpers.renderSessionContext(sessionContext, options); + this.#uiHelpers.renderSessionContext(sessionContext, options); } renderInitialMessages(): void { - this.uiHelpers.renderInitialMessages(); + this.#uiHelpers.renderInitialMessages(); } getUserMessageText(message: Message): string { - return this.uiHelpers.getUserMessageText(message); + return this.#uiHelpers.getUserMessageText(message); } findLastAssistantMessage(): AssistantMessage | undefined { - return this.uiHelpers.findLastAssistantMessage(); + return this.#uiHelpers.findLastAssistantMessage(); } extractAssistantText(message: AssistantMessage): string { - return this.uiHelpers.extractAssistantText(message); + return this.#uiHelpers.extractAssistantText(message); } // Command handling handleExportCommand(text: string): Promise { - return this.commandController.handleExportCommand(text); + return this.#commandController.handleExportCommand(text); } handleDumpCommand(): Promise { - return this.commandController.handleDumpCommand(); + return this.#commandController.handleDumpCommand(); } handleShareCommand(): Promise { - return this.commandController.handleShareCommand(); + return this.#commandController.handleShareCommand(); } handleCopyCommand(): Promise { - return this.commandController.handleCopyCommand(); + return this.#commandController.handleCopyCommand(); } handleSessionCommand(): Promise { - return this.commandController.handleSessionCommand(); + return this.#commandController.handleSessionCommand(); } handleUsageCommand(reports?: UsageReport[] | null): Promise { - return this.commandController.handleUsageCommand(reports); + return this.#commandController.handleUsageCommand(reports); } async handleChangelogCommand(): Promise { - await this.commandController.handleChangelogCommand(); + await this.#commandController.handleChangelogCommand(); } handleHotkeysCommand(): void { - this.commandController.handleHotkeysCommand(); + this.#commandController.handleHotkeysCommand(); } handleClearCommand(): Promise { - return this.commandController.handleClearCommand(); + return this.#commandController.handleClearCommand(); } handleForkCommand(): Promise { - return this.commandController.handleForkCommand(); + return this.#commandController.handleForkCommand(); } showDebugSelector(): void { - this.selectorController.showDebugSelector(); - } - - handleArminSaysHi(): void { - this.commandController.handleArminSaysHi(); + this.#selectorController.showDebugSelector(); } handleBashCommand(command: string, excludeFromContext?: boolean): Promise { - return this.commandController.handleBashCommand(command, excludeFromContext); + return this.#commandController.handleBashCommand(command, excludeFromContext); } handlePythonCommand(code: string, excludeFromContext?: boolean): Promise { - return this.commandController.handlePythonCommand(code, excludeFromContext); + return this.#commandController.handlePythonCommand(code, excludeFromContext); } handleCompactCommand(customInstructions?: string): Promise { - return this.commandController.handleCompactCommand(customInstructions); + return this.#commandController.handleCompactCommand(customInstructions); } handleHandoffCommand(customInstructions?: string): Promise { - return this.commandController.handleHandoffCommand(customInstructions); + return this.#commandController.handleHandoffCommand(customInstructions); } executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise { - return this.commandController.executeCompaction(customInstructionsOrOptions, isAuto); + return this.#commandController.executeCompaction(customInstructionsOrOptions, isAuto); } openInBrowser(urlOrPath: string): void { - this.commandController.openInBrowser(urlOrPath); + this.#commandController.openInBrowser(urlOrPath); } // Selector handling showSettingsSelector(): void { - this.selectorController.showSettingsSelector(); + this.#selectorController.showSettingsSelector(); } showHistorySearch(): void { - this.selectorController.showHistorySearch(); + this.#selectorController.showHistorySearch(); } showExtensionsDashboard(): void { - void this.selectorController.showExtensionsDashboard(); + void this.#selectorController.showExtensionsDashboard(); } showModelSelector(options?: { temporaryOnly?: boolean }): void { - this.selectorController.showModelSelector(options); + this.#selectorController.showModelSelector(options); } showUserMessageSelector(): void { - this.selectorController.showUserMessageSelector(); + this.#selectorController.showUserMessageSelector(); } showTreeSelector(): void { - this.selectorController.showTreeSelector(); + this.#selectorController.showTreeSelector(); } showSessionSelector(): void { - this.selectorController.showSessionSelector(); + this.#selectorController.showSessionSelector(); } handleResumeSession(sessionPath: string): Promise { - return this.selectorController.handleResumeSession(sessionPath); + return this.#selectorController.handleResumeSession(sessionPath); } showOAuthSelector(mode: "login" | "logout"): Promise { - return this.selectorController.showOAuthSelector(mode); + return this.#selectorController.showOAuthSelector(mode); } showHookConfirm(title: string, message: string): Promise { - return this.extensionUiController.showHookConfirm(title, message); + return this.#extensionUiController.showHookConfirm(title, message); } // Input handling handleCtrlC(): void { - this.inputController.handleCtrlC(); + this.#inputController.handleCtrlC(); } handleCtrlD(): void { - this.inputController.handleCtrlD(); + this.#inputController.handleCtrlD(); } handleCtrlZ(): void { - this.inputController.handleCtrlZ(); + this.#inputController.handleCtrlZ(); } handleDequeue(): void { - this.inputController.handleDequeue(); + this.#inputController.handleDequeue(); } handleBackgroundCommand(): void { - this.inputController.handleBackgroundCommand(); + this.#inputController.handleBackgroundCommand(); } handleImagePaste(): Promise { - return this.inputController.handleImagePaste(); + return this.#inputController.handleImagePaste(); } cycleThinkingLevel(): void { - this.inputController.cycleThinkingLevel(); + this.#inputController.cycleThinkingLevel(); } cycleRoleModel(options?: { temporary?: boolean }): Promise { - return this.inputController.cycleRoleModel(options); + return this.#inputController.cycleRoleModel(options); } toggleToolOutputExpansion(): void { - this.inputController.toggleToolOutputExpansion(); + this.#inputController.toggleToolOutputExpansion(); } setToolsExpanded(expanded: boolean): void { - this.inputController.setToolsExpanded(expanded); + this.#inputController.setToolsExpanded(expanded); } toggleThinkingBlockVisibility(): void { - this.inputController.toggleThinkingBlockVisibility(); + this.#inputController.toggleThinkingBlockVisibility(); } toggleTodoExpansion(): void { this.todoExpanded = !this.todoExpanded; - this.renderTodoList(); + this.#renderTodoList(); this.ui.requestRender(); } setTodos(todos: TodoItem[]): void { this.todoItems = todos; - this.renderTodoList(); + this.#renderTodoList(); this.ui.requestRender(); } async reloadTodos(): Promise { - await this.loadTodoList(); + await this.#loadTodoList(); this.ui.requestRender(); } openExternalEditor(): void { - this.inputController.openExternalEditor(); + this.#inputController.openExternalEditor(); } registerExtensionShortcuts(): void { - this.inputController.registerExtensionShortcuts(); + this.#inputController.registerExtensionShortcuts(); } // Hook UI methods initHooksAndCustomTools(): Promise { - return this.extensionUiController.initHooksAndCustomTools(); + return this.#extensionUiController.initHooksAndCustomTools(); } emitCustomToolSessionEvent( reason: "start" | "switch" | "branch" | "tree" | "shutdown", previousSessionFile?: string, ): Promise { - return this.extensionUiController.emitCustomToolSessionEvent(reason, previousSessionFile); + return this.#extensionUiController.emitCustomToolSessionEvent(reason, previousSessionFile); } setHookWidget(key: string, content: unknown): void { - this.extensionUiController.setHookWidget(key, content); + this.#extensionUiController.setHookWidget(key, content); } setHookStatus(key: string, text: string | undefined): void { - this.extensionUiController.setHookStatus(key, text); + this.#extensionUiController.setHookStatus(key, text); } showHookSelector( @@ -1081,31 +1074,31 @@ export class InteractiveMode implements InteractiveModeContext { options: string[], dialogOptions?: ExtensionUIDialogOptions, ): Promise { - return this.extensionUiController.showHookSelector(title, options, dialogOptions); + return this.#extensionUiController.showHookSelector(title, options, dialogOptions); } hideHookSelector(): void { - this.extensionUiController.hideHookSelector(); + this.#extensionUiController.hideHookSelector(); } showHookInput(title: string, placeholder?: string): Promise { - return this.extensionUiController.showHookInput(title, placeholder); + return this.#extensionUiController.showHookInput(title, placeholder); } hideHookInput(): void { - this.extensionUiController.hideHookInput(); + this.#extensionUiController.hideHookInput(); } showHookEditor(title: string, prefill?: string): Promise { - return this.extensionUiController.showHookEditor(title, prefill); + return this.#extensionUiController.showHookEditor(title, prefill); } hideHookEditor(): void { - this.extensionUiController.hideHookEditor(); + this.#extensionUiController.hideHookEditor(); } showHookNotify(message: string, type?: "info" | "warning" | "error"): void { - this.extensionUiController.showHookNotify(message, type); + this.#extensionUiController.showHookNotify(message, type); } showHookCustom( @@ -1116,18 +1109,18 @@ export class InteractiveMode implements InteractiveModeContext { done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise, ): Promise { - return this.extensionUiController.showHookCustom(factory); + return this.#extensionUiController.showHookCustom(factory); } showExtensionError(extensionPath: string, error: string): void { - this.extensionUiController.showExtensionError(extensionPath, error); + this.#extensionUiController.showExtensionError(extensionPath, error); } showToolError(toolName: string, error: string): void { - this.extensionUiController.showToolError(toolName, error); + this.#extensionUiController.showToolError(toolName, error); } - private subscribeToAgent(): void { - this.eventController.subscribeToAgent(); + #subscribeToAgent(): void { + this.#eventController.subscribeToAgent(); } } diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 22e380489..41aba31ba 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -82,12 +82,12 @@ function isAgentEvent(value: unknown): value is AgentEvent { // ============================================================================ export class RpcClient { - private process: ptree.ChildProcess | null = null; - private eventListeners: RpcEventListener[] = []; - private pendingRequests: Map void; reject: (error: Error) => void }> = + #process: ptree.ChildProcess | null = null; + #eventListeners: RpcEventListener[] = []; + #pendingRequests: Map void; reject: (error: Error) => void }> = new Map(); - private requestId = 0; - private abortController = new AbortController(); + #requestId = 0; + #abortController = new AbortController(); constructor(private options: RpcClientOptions = {}) {} @@ -95,7 +95,7 @@ export class RpcClient { * Start the RPC agent process. */ async start(): Promise { - if (this.process) { + if (this.#process) { throw new Error("Client already started"); } @@ -112,17 +112,17 @@ export class RpcClient { args.push(...this.options.args); } - this.process = ptree.spawn(["bun", cliPath, ...args], { + this.#process = ptree.spawn(["bun", cliPath, ...args], { cwd: this.options.cwd, env: { ...Bun.env, ...this.options.env }, stdin: "pipe", }); // Process lines in background - const lines = readJsonl(this.process.stdout, this.abortController.signal); + const lines = readJsonl(this.#process.stdout, this.#abortController.signal); void (async () => { for await (const line of lines) { - this.handleLine(line); + this.#handleLine(line); } })().catch(() => {}); @@ -130,10 +130,10 @@ export class RpcClient { await Bun.sleep(100); try { - const exitCode = await Promise.race([this.process.exited, Bun.sleep(500).then(() => null)]); + const exitCode = await Promise.race([this.#process.exited, Bun.sleep(500).then(() => null)]); if (exitCode !== null) { throw new Error( - `Agent process exited immediately with code ${exitCode}. Stderr: ${this.process.peekStderr()}`, + `Agent process exited immediately with code ${exitCode}. Stderr: ${this.#process.peekStderr()}`, ); } } catch { @@ -145,23 +145,23 @@ export class RpcClient { * Stop the RPC agent process. */ stop() { - if (!this.process) return; + if (!this.#process) return; - this.process.kill(); - this.abortController.abort(); - this.process = null; - this.pendingRequests.clear(); + this.#process.kill(); + this.#abortController.abort(); + this.#process = null; + this.#pendingRequests.clear(); } /** * Subscribe to agent events. */ onEvent(listener: RpcEventListener): () => void { - this.eventListeners.push(listener); + this.#eventListeners.push(listener); return () => { - const index = this.eventListeners.indexOf(listener); + const index = this.#eventListeners.indexOf(listener); if (index !== -1) { - this.eventListeners.splice(index, 1); + this.#eventListeners.splice(index, 1); } }; } @@ -170,7 +170,7 @@ export class RpcClient { * Get collected stderr output (useful for debugging). */ getStderr(): string { - return this.process?.peekStderr() ?? ""; + return this.#process?.peekStderr() ?? ""; } // ========================================================================= @@ -183,28 +183,28 @@ export class RpcClient { * Use waitForIdle() to wait for completion. */ async prompt(message: string, images?: ImageContent[]): Promise { - await this.send({ type: "prompt", message, images }); + await this.#send({ type: "prompt", message, images }); } /** * Queue a steering message to interrupt the agent mid-run. */ async steer(message: string, images?: ImageContent[]): Promise { - await this.send({ type: "steer", message, images }); + await this.#send({ type: "steer", message, images }); } /** * Queue a follow-up message to be processed after the agent finishes. */ async followUp(message: string, images?: ImageContent[]): Promise { - await this.send({ type: "follow_up", message, images }); + await this.#send({ type: "follow_up", message, images }); } /** * Abort current operation. */ async abort(): Promise { - await this.send({ type: "abort" }); + await this.#send({ type: "abort" }); } /** @@ -213,24 +213,24 @@ export class RpcClient { * @returns Object with `cancelled: true` if an extension cancelled the new session */ async newSession(parentSession?: string): Promise<{ cancelled: boolean }> { - const response = await this.send({ type: "new_session", parentSession }); - return this.getData(response); + const response = await this.#send({ type: "new_session", parentSession }); + return this.#getData(response); } /** * Get current session state. */ async getState(): Promise { - const response = await this.send({ type: "get_state" }); - return this.getData(response); + const response = await this.#send({ type: "get_state" }); + return this.#getData(response); } /** * Set model by provider and ID. */ async setModel(provider: string, modelId: string): Promise<{ provider: string; id: string }> { - const response = await this.send({ type: "set_model", provider, modelId }); - return this.getData(response); + const response = await this.#send({ type: "set_model", provider, modelId }); + return this.#getData(response); } /** @@ -241,105 +241,105 @@ export class RpcClient { thinkingLevel: ThinkingLevel; isScoped: boolean; } | null> { - const response = await this.send({ type: "cycle_model" }); - return this.getData(response); + const response = await this.#send({ type: "cycle_model" }); + return this.#getData(response); } /** * Get list of available models. */ async getAvailableModels(): Promise { - const response = await this.send({ type: "get_available_models" }); - return this.getData<{ models: ModelInfo[] }>(response).models; + const response = await this.#send({ type: "get_available_models" }); + return this.#getData<{ models: ModelInfo[] }>(response).models; } /** * Set thinking level. */ async setThinkingLevel(level: ThinkingLevel): Promise { - await this.send({ type: "set_thinking_level", level }); + await this.#send({ type: "set_thinking_level", level }); } /** * Cycle thinking level. */ async cycleThinkingLevel(): Promise<{ level: ThinkingLevel } | null> { - const response = await this.send({ type: "cycle_thinking_level" }); - return this.getData(response); + const response = await this.#send({ type: "cycle_thinking_level" }); + return this.#getData(response); } /** * Set steering mode. */ async setSteeringMode(mode: "all" | "one-at-a-time"): Promise { - await this.send({ type: "set_steering_mode", mode }); + await this.#send({ type: "set_steering_mode", mode }); } /** * Set follow-up mode. */ async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise { - await this.send({ type: "set_follow_up_mode", mode }); + await this.#send({ type: "set_follow_up_mode", mode }); } /** * Compact session context. */ async compact(customInstructions?: string): Promise { - const response = await this.send({ type: "compact", customInstructions }); - return this.getData(response); + const response = await this.#send({ type: "compact", customInstructions }); + return this.#getData(response); } /** * Set auto-compaction enabled/disabled. */ async setAutoCompaction(enabled: boolean): Promise { - await this.send({ type: "set_auto_compaction", enabled }); + await this.#send({ type: "set_auto_compaction", enabled }); } /** * Set auto-retry enabled/disabled. */ async setAutoRetry(enabled: boolean): Promise { - await this.send({ type: "set_auto_retry", enabled }); + await this.#send({ type: "set_auto_retry", enabled }); } /** * Abort in-progress retry. */ async abortRetry(): Promise { - await this.send({ type: "abort_retry" }); + await this.#send({ type: "abort_retry" }); } /** * Execute a bash command. */ async bash(command: string): Promise { - const response = await this.send({ type: "bash", command }); - return this.getData(response); + const response = await this.#send({ type: "bash", command }); + return this.#getData(response); } /** * Abort running bash command. */ async abortBash(): Promise { - await this.send({ type: "abort_bash" }); + await this.#send({ type: "abort_bash" }); } /** * Get session statistics. */ async getSessionStats(): Promise { - const response = await this.send({ type: "get_session_stats" }); - return this.getData(response); + const response = await this.#send({ type: "get_session_stats" }); + return this.#getData(response); } /** * Export session to HTML. */ async exportHtml(outputPath?: string): Promise<{ path: string }> { - const response = await this.send({ type: "export_html", outputPath }); - return this.getData(response); + const response = await this.#send({ type: "export_html", outputPath }); + return this.#getData(response); } /** @@ -347,8 +347,8 @@ export class RpcClient { * @returns Object with `cancelled: true` if an extension cancelled the switch */ async switchSession(sessionPath: string): Promise<{ cancelled: boolean }> { - const response = await this.send({ type: "switch_session", sessionPath }); - return this.getData(response); + const response = await this.#send({ type: "switch_session", sessionPath }); + return this.#getData(response); } /** @@ -356,32 +356,32 @@ export class RpcClient { * @returns Object with `text` (the message text) and `cancelled` (if extension cancelled) */ async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> { - const response = await this.send({ type: "branch", entryId }); - return this.getData(response); + const response = await this.#send({ type: "branch", entryId }); + return this.#getData(response); } /** * Get messages available for branching. */ async getBranchMessages(): Promise> { - const response = await this.send({ type: "get_branch_messages" }); - return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages; + const response = await this.#send({ type: "get_branch_messages" }); + return this.#getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages; } /** * Get text of last assistant message. */ async getLastAssistantText(): Promise { - const response = await this.send({ type: "get_last_assistant_text" }); - return this.getData<{ text: string | null }>(response).text; + const response = await this.#send({ type: "get_last_assistant_text" }); + return this.#getData<{ text: string | null }>(response).text; } /** * Get all messages in the session. */ async getMessages(): Promise { - const response = await this.send({ type: "get_messages" }); - return this.getData<{ messages: AgentMessage[] }>(response).messages; + const response = await this.#send({ type: "get_messages" }); + return this.#getData<{ messages: AgentMessage[] }>(response).messages; } // ========================================================================= @@ -408,7 +408,7 @@ export class RpcClient { if (settled) return; settled = true; unsubscribe(); - reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.process?.peekStderr() ?? ""}`)); + reject(new Error(`Timeout waiting for agent to become idle. Stderr: ${this.#process?.peekStderr() ?? ""}`)); })(); return promise; } @@ -434,7 +434,7 @@ export class RpcClient { if (settled) return; settled = true; unsubscribe(); - reject(new Error(`Timeout collecting events. Stderr: ${this.process?.peekStderr() ?? ""}`)); + reject(new Error(`Timeout collecting events. Stderr: ${this.#process?.peekStderr() ?? ""}`)); })(); return promise; } @@ -452,13 +452,13 @@ export class RpcClient { // Internal // ========================================================================= - private handleLine(data: unknown): void { + #handleLine(data: unknown): void { // Check if it's a response to a pending request if (isRpcResponse(data)) { const id = data.id; - if (id && this.pendingRequests.has(id)) { - const pending = this.pendingRequests.get(id)!; - this.pendingRequests.delete(id); + if (id && this.#pendingRequests.has(id)) { + const pending = this.#pendingRequests.get(id)!; + this.#pendingRequests.delete(id); pending.resolve(data); return; } @@ -467,31 +467,31 @@ export class RpcClient { if (!isAgentEvent(data)) return; // Otherwise it's an event - for (const listener of this.eventListeners) { + for (const listener of this.#eventListeners) { listener(data); } } - private send(command: RpcCommandBody): Promise { - if (!this.process?.stdin) { + #send(command: RpcCommandBody): Promise { + if (!this.#process?.stdin) { throw new Error("Client not started"); } - const id = `req_${++this.requestId}`; + const id = `req_${++this.#requestId}`; const fullCommand = { ...command, id } as RpcCommand; const { promise, resolve, reject } = Promise.withResolvers(); let settled = false; void (async () => { await Bun.sleep(30000); if (settled) return; - this.pendingRequests.delete(id); + this.#pendingRequests.delete(id); settled = true; reject( - new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.process?.peekStderr() ?? ""}`), + new Error(`Timeout waiting for response to ${command.type}. Stderr: ${this.#process?.peekStderr() ?? ""}`), ); })(); - this.pendingRequests.set(id, { + this.#pendingRequests.set(id, { resolve: response => { if (settled) return; settled = true; @@ -505,13 +505,13 @@ export class RpcClient { }); // Write to stdin after registering the handler - const stdin = this.process!.stdin as import("bun").FileSink; + const stdin = this.#process!.stdin as import("bun").FileSink; stdin.write(`${JSON.stringify(fullCommand)}\n`); // flush() returns number | Promise - handle both cases const flushResult = stdin.flush(); if (flushResult instanceof Promise) { flushResult.catch((err: Error) => { - this.pendingRequests.delete(id); + this.#pendingRequests.delete(id); if (settled) return; settled = true; reject(err); @@ -520,7 +520,7 @@ export class RpcClient { return promise; } - private getData(response: RpcResponse): T { + #getData(response: RpcResponse): T { if (!response.success) { const errorResponse = response as Extract; throw new Error(errorResponse.error); diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 2696efda2..441d24005 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -76,7 +76,7 @@ export async function runRpcMode(session: AgentSession): Promise { ) {} /** Helper for dialog methods with signal/timeout support */ - private createDialogPromise( + #createDialogPromise( opts: ExtensionUIDialogOptions | undefined, defaultValue: T, request: Record, @@ -86,7 +86,7 @@ export async function runRpcMode(session: AgentSession): Promise { const id = Snowflake.next() as string; const { promise, resolve, reject } = Promise.withResolvers(); - let timeoutId: ReturnType | undefined; + let timeoutId: NodeJS.Timeout | undefined; const cleanup = () => { if (timeoutId) clearTimeout(timeoutId); @@ -119,7 +119,7 @@ export async function runRpcMode(session: AgentSession): Promise { } select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise { - return this.createDialogPromise( + return this.#createDialogPromise( dialogOptions, undefined, { method: "select", title, options, timeout: dialogOptions?.timeout }, @@ -133,7 +133,7 @@ export async function runRpcMode(session: AgentSession): Promise { } confirm(title: string, message: string, dialogOptions?: ExtensionUIDialogOptions): Promise { - return this.createDialogPromise( + return this.#createDialogPromise( dialogOptions, false, { method: "confirm", title, message, timeout: dialogOptions?.timeout }, @@ -151,7 +151,7 @@ export async function runRpcMode(session: AgentSession): Promise { placeholder?: string, dialogOptions?: ExtensionUIDialogOptions, ): Promise { - return this.createDialogPromise( + return this.#createDialogPromise( dialogOptions, undefined, { method: "input", title, placeholder, timeout: dialogOptions?.timeout }, diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 0aead31d9..d9433f992 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -1126,9 +1126,9 @@ const langMap: Record = { }; export class Theme { - private fgColors: Record; - private bgColors: Record; - private symbols: SymbolMap; + #fgColors: Record; + #bgColors: Record; + #symbols: SymbolMap; constructor( fgColors: Record, @@ -1137,34 +1137,34 @@ export class Theme { private readonly symbolPreset: SymbolPreset, symbolOverrides: Partial>, ) { - this.fgColors = {} as Record; + this.#fgColors = {} as Record; for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) { - this.fgColors[key] = fgAnsi(value, mode); + this.#fgColors[key] = fgAnsi(value, mode); } - this.bgColors = {} as Record; + this.#bgColors = {} as Record; for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) { - this.bgColors[key] = bgAnsi(value, mode); + this.#bgColors[key] = bgAnsi(value, mode); } // Build symbol map from preset + overrides const baseSymbols = SYMBOL_PRESETS[symbolPreset]; - this.symbols = { ...baseSymbols }; + this.#symbols = { ...baseSymbols }; for (const [key, value] of Object.entries(symbolOverrides)) { - if (key in this.symbols) { - this.symbols[key as SymbolKey] = value; + if (key in this.#symbols) { + this.#symbols[key as SymbolKey] = value; } else { - logger.debug("Invalid symbol key in override", { key, availableKeys: Object.keys(this.symbols) }); + logger.debug("Invalid symbol key in override", { key, availableKeys: Object.keys(this.#symbols) }); } } } fg(color: ThemeColor, text: string): string { - const ansi = this.fgColors[color]; + const ansi = this.#fgColors[color]; if (!ansi) throw new Error(`Unknown theme color: ${color}`); return `${ansi}${text}\x1b[39m`; // Reset only foreground color } bg(color: ThemeBg, text: string): string { - const ansi = this.bgColors[color]; + const ansi = this.#bgColors[color]; if (!ansi) throw new Error(`Unknown theme background color: ${color}`); return `${ansi}${text}\x1b[49m`; // Reset only background color } @@ -1190,13 +1190,13 @@ export class Theme { } getFgAnsi(color: ThemeColor): string { - const ansi = this.fgColors[color]; + const ansi = this.#fgColors[color]; if (!ansi) throw new Error(`Unknown theme color: ${color}`); return ansi; } getBgAnsi(color: ThemeBg): string { - const ansi = this.bgColors[color]; + const ansi = this.#bgColors[color]; if (!ansi) throw new Error(`Unknown theme background color: ${color}`); return ansi; } @@ -1241,14 +1241,14 @@ export class Theme { * Get a symbol by key. */ symbol(key: SymbolKey): string { - return this.symbols[key]; + return this.#symbols[key]; } /** * Get a symbol styled with a color. */ styledSymbol(key: SymbolKey, color: ThemeColor): string { - return this.fg(color, this.symbols[key]); + return this.fg(color, this.#symbols[key]); } /** @@ -1264,150 +1264,150 @@ export class Theme { get status() { return { - success: this.symbols["status.success"], - error: this.symbols["status.error"], - warning: this.symbols["status.warning"], - info: this.symbols["status.info"], - pending: this.symbols["status.pending"], - disabled: this.symbols["status.disabled"], - enabled: this.symbols["status.enabled"], - running: this.symbols["status.running"], - shadowed: this.symbols["status.shadowed"], - aborted: this.symbols["status.aborted"], + success: this.#symbols["status.success"], + error: this.#symbols["status.error"], + warning: this.#symbols["status.warning"], + info: this.#symbols["status.info"], + pending: this.#symbols["status.pending"], + disabled: this.#symbols["status.disabled"], + enabled: this.#symbols["status.enabled"], + running: this.#symbols["status.running"], + shadowed: this.#symbols["status.shadowed"], + aborted: this.#symbols["status.aborted"], }; } get nav() { return { - cursor: this.symbols["nav.cursor"], - selected: this.symbols["nav.selected"], - expand: this.symbols["nav.expand"], - collapse: this.symbols["nav.collapse"], - back: this.symbols["nav.back"], + cursor: this.#symbols["nav.cursor"], + selected: this.#symbols["nav.selected"], + expand: this.#symbols["nav.expand"], + collapse: this.#symbols["nav.collapse"], + back: this.#symbols["nav.back"], }; } get tree() { return { - branch: this.symbols["tree.branch"], - last: this.symbols["tree.last"], - vertical: this.symbols["tree.vertical"], - horizontal: this.symbols["tree.horizontal"], - hook: this.symbols["tree.hook"], + branch: this.#symbols["tree.branch"], + last: this.#symbols["tree.last"], + vertical: this.#symbols["tree.vertical"], + horizontal: this.#symbols["tree.horizontal"], + hook: this.#symbols["tree.hook"], }; } get boxRound() { return { - topLeft: this.symbols["boxRound.topLeft"], - topRight: this.symbols["boxRound.topRight"], - bottomLeft: this.symbols["boxRound.bottomLeft"], - bottomRight: this.symbols["boxRound.bottomRight"], - horizontal: this.symbols["boxRound.horizontal"], - vertical: this.symbols["boxRound.vertical"], + topLeft: this.#symbols["boxRound.topLeft"], + topRight: this.#symbols["boxRound.topRight"], + bottomLeft: this.#symbols["boxRound.bottomLeft"], + bottomRight: this.#symbols["boxRound.bottomRight"], + horizontal: this.#symbols["boxRound.horizontal"], + vertical: this.#symbols["boxRound.vertical"], }; } get boxSharp() { return { - topLeft: this.symbols["boxSharp.topLeft"], - topRight: this.symbols["boxSharp.topRight"], - bottomLeft: this.symbols["boxSharp.bottomLeft"], - bottomRight: this.symbols["boxSharp.bottomRight"], - horizontal: this.symbols["boxSharp.horizontal"], - vertical: this.symbols["boxSharp.vertical"], - cross: this.symbols["boxSharp.cross"], - teeDown: this.symbols["boxSharp.teeDown"], - teeUp: this.symbols["boxSharp.teeUp"], - teeRight: this.symbols["boxSharp.teeRight"], - teeLeft: this.symbols["boxSharp.teeLeft"], + topLeft: this.#symbols["boxSharp.topLeft"], + topRight: this.#symbols["boxSharp.topRight"], + bottomLeft: this.#symbols["boxSharp.bottomLeft"], + bottomRight: this.#symbols["boxSharp.bottomRight"], + horizontal: this.#symbols["boxSharp.horizontal"], + vertical: this.#symbols["boxSharp.vertical"], + cross: this.#symbols["boxSharp.cross"], + teeDown: this.#symbols["boxSharp.teeDown"], + teeUp: this.#symbols["boxSharp.teeUp"], + teeRight: this.#symbols["boxSharp.teeRight"], + teeLeft: this.#symbols["boxSharp.teeLeft"], }; } get sep() { return { - powerline: this.symbols["sep.powerline"], - powerlineThin: this.symbols["sep.powerlineThin"], - powerlineLeft: this.symbols["sep.powerlineLeft"], - powerlineRight: this.symbols["sep.powerlineRight"], - powerlineThinLeft: this.symbols["sep.powerlineThinLeft"], - powerlineThinRight: this.symbols["sep.powerlineThinRight"], - block: this.symbols["sep.block"], - space: this.symbols["sep.space"], - asciiLeft: this.symbols["sep.asciiLeft"], - asciiRight: this.symbols["sep.asciiRight"], - dot: this.symbols["sep.dot"], - slash: this.symbols["sep.slash"], - pipe: this.symbols["sep.pipe"], + powerline: this.#symbols["sep.powerline"], + powerlineThin: this.#symbols["sep.powerlineThin"], + powerlineLeft: this.#symbols["sep.powerlineLeft"], + powerlineRight: this.#symbols["sep.powerlineRight"], + powerlineThinLeft: this.#symbols["sep.powerlineThinLeft"], + powerlineThinRight: this.#symbols["sep.powerlineThinRight"], + block: this.#symbols["sep.block"], + space: this.#symbols["sep.space"], + asciiLeft: this.#symbols["sep.asciiLeft"], + asciiRight: this.#symbols["sep.asciiRight"], + dot: this.#symbols["sep.dot"], + slash: this.#symbols["sep.slash"], + pipe: this.#symbols["sep.pipe"], }; } get icon() { return { - model: this.symbols["icon.model"], - plan: this.symbols["icon.plan"], - folder: this.symbols["icon.folder"], - file: this.symbols["icon.file"], - git: this.symbols["icon.git"], - branch: this.symbols["icon.branch"], - tokens: this.symbols["icon.tokens"], - context: this.symbols["icon.context"], - cost: this.symbols["icon.cost"], - time: this.symbols["icon.time"], - pi: this.symbols["icon.pi"], - agents: this.symbols["icon.agents"], - cache: this.symbols["icon.cache"], - input: this.symbols["icon.input"], - output: this.symbols["icon.output"], - host: this.symbols["icon.host"], - session: this.symbols["icon.session"], - package: this.symbols["icon.package"], - warning: this.symbols["icon.warning"], - rewind: this.symbols["icon.rewind"], - auto: this.symbols["icon.auto"], - extensionSkill: this.symbols["icon.extensionSkill"], - extensionTool: this.symbols["icon.extensionTool"], - extensionSlashCommand: this.symbols["icon.extensionSlashCommand"], - extensionMcp: this.symbols["icon.extensionMcp"], - extensionRule: this.symbols["icon.extensionRule"], - extensionHook: this.symbols["icon.extensionHook"], - extensionPrompt: this.symbols["icon.extensionPrompt"], - extensionContextFile: this.symbols["icon.extensionContextFile"], - extensionInstruction: this.symbols["icon.extensionInstruction"], + model: this.#symbols["icon.model"], + plan: this.#symbols["icon.plan"], + folder: this.#symbols["icon.folder"], + file: this.#symbols["icon.file"], + git: this.#symbols["icon.git"], + branch: this.#symbols["icon.branch"], + tokens: this.#symbols["icon.tokens"], + context: this.#symbols["icon.context"], + cost: this.#symbols["icon.cost"], + time: this.#symbols["icon.time"], + pi: this.#symbols["icon.pi"], + agents: this.#symbols["icon.agents"], + cache: this.#symbols["icon.cache"], + input: this.#symbols["icon.input"], + output: this.#symbols["icon.output"], + host: this.#symbols["icon.host"], + session: this.#symbols["icon.session"], + package: this.#symbols["icon.package"], + warning: this.#symbols["icon.warning"], + rewind: this.#symbols["icon.rewind"], + auto: this.#symbols["icon.auto"], + extensionSkill: this.#symbols["icon.extensionSkill"], + extensionTool: this.#symbols["icon.extensionTool"], + extensionSlashCommand: this.#symbols["icon.extensionSlashCommand"], + extensionMcp: this.#symbols["icon.extensionMcp"], + extensionRule: this.#symbols["icon.extensionRule"], + extensionHook: this.#symbols["icon.extensionHook"], + extensionPrompt: this.#symbols["icon.extensionPrompt"], + extensionContextFile: this.#symbols["icon.extensionContextFile"], + extensionInstruction: this.#symbols["icon.extensionInstruction"], }; } get thinking() { return { - minimal: this.symbols["thinking.minimal"], - low: this.symbols["thinking.low"], - medium: this.symbols["thinking.medium"], - high: this.symbols["thinking.high"], - xhigh: this.symbols["thinking.xhigh"], + minimal: this.#symbols["thinking.minimal"], + low: this.#symbols["thinking.low"], + medium: this.#symbols["thinking.medium"], + high: this.#symbols["thinking.high"], + xhigh: this.#symbols["thinking.xhigh"], }; } get checkbox() { return { - checked: this.symbols["checkbox.checked"], - unchecked: this.symbols["checkbox.unchecked"], + checked: this.#symbols["checkbox.checked"], + unchecked: this.#symbols["checkbox.unchecked"], }; } get format() { return { - bullet: this.symbols["format.bullet"], - dash: this.symbols["format.dash"], - bracketLeft: this.symbols["format.bracketLeft"], - bracketRight: this.symbols["format.bracketRight"], + bullet: this.#symbols["format.bullet"], + dash: this.#symbols["format.dash"], + bracketLeft: this.#symbols["format.bracketLeft"], + bracketRight: this.#symbols["format.bracketRight"], }; } get md() { return { - quoteBorder: this.symbols["md.quoteBorder"], - hrChar: this.symbols["md.hrChar"], - bullet: this.symbols["md.bullet"], + quoteBorder: this.#symbols["md.quoteBorder"], + hrChar: this.#symbols["md.hrChar"], + bullet: this.#symbols["md.bullet"], }; } @@ -1430,10 +1430,10 @@ export class Theme { * Maps common language names to their corresponding symbol keys. */ getLangIcon(lang: string | undefined): string { - if (!lang) return this.symbols["lang.default"]; + if (!lang) return this.#symbols["lang.default"]; const normalized = lang.toLowerCase(); const key = langMap[normalized]; - return key ? this.symbols[key] : this.symbols["lang.default"]; + return key ? this.#symbols[key] : this.#symbols["lang.default"]; } } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 0bcd08c54..e230d1225 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -145,7 +145,6 @@ export interface InteractiveModeContext { handleDumpCommand(): Promise; handleClearCommand(): Promise; handleForkCommand(): Promise; - handleArminSaysHi(): void; handleBashCommand(command: string, excludeFromContext?: boolean): Promise; handlePythonCommand(code: string, excludeFromContext?: boolean): Promise; handleCompactCommand(customInstructions?: string): Promise; diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index d989597c2..6f4871abb 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -109,8 +109,8 @@ export type PatchParams = { path: string; op?: string; rename?: string; diff?: s // ═══════════════════════════════════════════════════════════════════════════ class LspFileSystem implements FileSystem { - private lastDiagnostics: FileDiagnosticsResult | undefined; - private fileCache: Record = {}; + #lastDiagnostics: FileDiagnosticsResult | undefined; + #fileCache: Record = {}; constructor( private readonly writethrough: ( @@ -125,11 +125,11 @@ class LspFileSystem implements FileSystem { ) {} #getFile(path: string): Bun.BunFile { - if (this.fileCache[path]) { - return this.fileCache[path]; + if (this.#fileCache[path]) { + return this.#fileCache[path]; } const file = Bun.file(path); - this.fileCache[path] = file; + this.#fileCache[path] = file; return file; } @@ -150,7 +150,7 @@ class LspFileSystem implements FileSystem { const file = this.#getFile(path); const result = await this.writethrough(path, content, this.signal, file, this.batchRequest); if (result) { - this.lastDiagnostics = result; + this.#lastDiagnostics = result; } } @@ -163,7 +163,7 @@ class LspFileSystem implements FileSystem { } getDiagnostics(): FileDiagnosticsResult | undefined { - return this.lastDiagnostics; + return this.#lastDiagnostics; } } @@ -200,15 +200,15 @@ type TInput = typeof replaceEditSchema | typeof patchEditSchema; * Creates replace-mode or patch-mode behavior based on session settings. */ export class EditTool implements AgentTool { - public readonly name = "edit"; - public readonly label = "Edit"; - public readonly nonAbortable = true; - public readonly concurrency = "exclusive"; + readonly name = "edit"; + readonly label = "Edit"; + readonly nonAbortable = true; + readonly concurrency = "exclusive"; - private readonly allowFuzzy: boolean; - private readonly fuzzyThreshold: number; - private readonly writethrough: WritethroughCallback; - private readonly envEditVariant: string; + readonly #allowFuzzy: boolean; + readonly #fuzzyThreshold: number; + readonly #writethrough: WritethroughCallback; + readonly #envEditVariant: string; constructor(private readonly session: ToolSession) { const { @@ -216,7 +216,7 @@ export class EditTool implements AgentTool { PI_EDIT_FUZZY_THRESHOLD: editFuzzyThreshold = "auto", PI_EDIT_VARIANT: envEditVariant = "auto", } = Bun.env; - this.envEditVariant = envEditVariant; + this.#envEditVariant = envEditVariant; if (envEditVariant !== "replace" && envEditVariant !== "patch" && envEditVariant !== "auto") { throw new Error(`Invalid PI_EDIT_VARIANT: ${envEditVariant}`); @@ -225,25 +225,25 @@ export class EditTool implements AgentTool { switch (editFuzzy) { case "true": case "1": - this.allowFuzzy = true; + this.#allowFuzzy = true; break; case "false": case "0": - this.allowFuzzy = false; + this.#allowFuzzy = false; break; case "auto": - this.allowFuzzy = session.settings.get("edit.fuzzyMatch"); + this.#allowFuzzy = session.settings.get("edit.fuzzyMatch"); break; default: throw new Error(`Invalid PI_EDIT_FUZZY: ${editFuzzy}`); } switch (editFuzzyThreshold) { case "auto": - this.fuzzyThreshold = session.settings.get("edit.fuzzyThreshold"); + this.#fuzzyThreshold = session.settings.get("edit.fuzzyThreshold"); break; default: - this.fuzzyThreshold = parseFloat(editFuzzyThreshold); - if (Number.isNaN(this.fuzzyThreshold) || this.fuzzyThreshold < 0 || this.fuzzyThreshold > 1) { + this.#fuzzyThreshold = parseFloat(editFuzzyThreshold); + if (Number.isNaN(this.#fuzzyThreshold) || this.#fuzzyThreshold < 0 || this.#fuzzyThreshold > 1) { throw new Error(`Invalid PI_EDIT_FUZZY_THRESHOLD: ${editFuzzyThreshold}`); } break; @@ -252,7 +252,7 @@ export class EditTool implements AgentTool { const enableLsp = session.enableLsp ?? true; const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnEdit"); const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite"); - this.writethrough = enableLsp + this.#writethrough = enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop; } @@ -261,34 +261,34 @@ export class EditTool implements AgentTool { * Determine patch mode dynamically based on current model. * This is re-evaluated on each access so tool definitions stay current when model changes. */ - private get patchMode(): boolean { - if (this.envEditVariant === "replace") return false; - if (this.envEditVariant === "patch") return true; + get mode(): "replace" | "patch" { + if (this.#envEditVariant === "replace") return "replace"; + if (this.#envEditVariant === "patch") return "patch"; // Auto mode: check model-specific settings const activeModel = this.session.getActiveModelString?.(); const modelVariant = this.session.settings.getEditVariantForModel(activeModel); - if (modelVariant === "replace") return false; - if (modelVariant === "patch") return true; + if (modelVariant === "replace") return "replace"; + if (modelVariant === "patch") return "patch"; - return this.session.settings.get("edit.patchMode"); + return this.session.settings.get("edit.patchMode") ? "patch" : "replace"; } /** * Dynamic description based on current patch mode (which depends on current model). */ - public get description(): string { - return this.patchMode ? renderPromptTemplate(patchDescription) : renderPromptTemplate(replaceDescription); + get description(): string { + return this.mode === "patch" ? renderPromptTemplate(patchDescription) : renderPromptTemplate(replaceDescription); } /** * Dynamic parameters schema based on current patch mode (which depends on current model). */ - public get parameters(): TInput { - return this.patchMode ? patchEditSchema : replaceEditSchema; + get parameters(): TInput { + return this.mode === "patch" ? patchEditSchema : replaceEditSchema; } - public async execute( + async execute( _toolCallId: string, params: ReplaceParams | PatchParams, signal?: AbortSignal, @@ -300,7 +300,7 @@ export class EditTool implements AgentTool { // ───────────────────────────────────────────────────────────────── // Patch mode execution // ───────────────────────────────────────────────────────────────── - if (this.patchMode) { + if (this.mode === "patch") { const { path, op: rawOp, rename, diff } = params as PatchParams; // Normalize unrecognized operations to "update" @@ -318,12 +318,12 @@ export class EditTool implements AgentTool { } const input: PatchInput = { path: resolvedPath, op, rename: resolvedRename, diff }; - const fs = new LspFileSystem(this.writethrough, signal, batchRequest); + const fs = new LspFileSystem(this.#writethrough, signal, batchRequest); const result = await applyPatch(input, { cwd: this.session.cwd, fs, - fuzzyThreshold: this.fuzzyThreshold, - allowFuzzy: this.allowFuzzy, + fuzzyThreshold: this.#fuzzyThreshold, + allowFuzzy: this.#allowFuzzy, }); const effRename = result.change.newPath ? rename : undefined; @@ -411,16 +411,16 @@ export class EditTool implements AgentTool { const normalizedNewText = normalizeToLF(new_text); const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, { - fuzzy: this.allowFuzzy, + fuzzy: this.#allowFuzzy, all: all ?? false, - threshold: this.fuzzyThreshold, + threshold: this.#fuzzyThreshold, }); if (result.count === 0) { // Get error details const matchOutcome = findMatch(normalizedContent, normalizedOldText, { - allowFuzzy: this.allowFuzzy, - threshold: this.fuzzyThreshold, + allowFuzzy: this.#allowFuzzy, + threshold: this.#fuzzyThreshold, }); if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { @@ -433,8 +433,8 @@ export class EditTool implements AgentTool { } throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, { - allowFuzzy: this.allowFuzzy, - threshold: this.fuzzyThreshold, + allowFuzzy: this.#allowFuzzy, + threshold: this.#fuzzyThreshold, fuzzyMatches: matchOutcome.fuzzyMatches, }); } @@ -446,7 +446,7 @@ export class EditTool implements AgentTool { } const finalContent = bom + restoreLineEndings(result.content, originalEnding); - const diagnostics = await this.writethrough(absolutePath, finalContent, signal, file, batchRequest); + const diagnostics = await this.#writethrough(absolutePath, finalContent, signal, file, batchRequest); const diffResult = generateDiffString(normalizedContent, result.content); const resultText = diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md index 5076cb551..a7d408cac 100644 --- a/packages/coding-agent/src/prompts/tools/task.md +++ b/packages/coding-agent/src/prompts/tools/task.md @@ -26,8 +26,26 @@ Agent type for all tasks in this batch. Shared background prepended verbatim to every task `assignment`. Use only for session-specific information subagents lack. -Do NOT include project rules, coding conventions, or style guidelines — subagents already have AGENTS.md and context files in their system prompt. Repeating them wastes tokens. +Do NOT include project rules, coding conventions, or style guidelines — subagents already have AGENTS.md and context files in their system prompt. Repeating them wastes tokens and inflates context. Restating any rule from AGENTS.md in `context` is a bug — treat it like a lint error. +**Before writing each line of context, ask:** "Would this sentence be true for ANY task in this repo, or only for THIS specific batch?" If it applies to any task → it's a project rule → the subagent already has it → delete the line. + +WRONG — restating project rules the subagent already has: +``` +## Constraints +- Use X import style, not Y (per AGENTS.md) +- Use Z for private fields per AGENTS.md +- Run the formatter after changes +- Follow the logging convention +``` +Every line above restates a project convention. The subagent reads AGENTS.md. Delete them all. + +RIGHT — only session-specific decisions the subagent cannot infer from project files: +``` +## Constraints +- We decided to use approach A over B (session decision) +- The migration target type is `Foo` from `bar` package (looked up this session) +``` Use template; omit non-applicable sections: @@ -126,10 +144,12 @@ Use structure every assignment: - "Follow conventions." - "No WASM." **Redundant context** — wastes tokens repeating what subagents already have: -- Restating AGENTS.md rules (coding style, import conventions, logger usage) +- Restating AGENTS.md rules (coding style, import conventions, formatting commands, logger usage, etc.) - Repeating project constraints from context files - Listing tool/framework preferences already documented in the repo +If a constraint appears in AGENTS.md, it MUST NOT appear in `context`. The subagent has the full system prompt. + If tempted to write above, expand using templates. **Output format in prose instead of `schema`** — agent returns null: Structured agents (`explore`, `reviewer`) have built-in output schemas. Describing a different output format in `context`/`assignment` without overriding via `schema` creates a mismatch — the agent can't reconcile your prose instructions with its schema and submits null data. Always use `schema` for output structure, or pick an agent whose built-in schema matches your needs. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 2f6f32555..385004a48 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -259,116 +259,116 @@ export class AgentSession { readonly sessionManager: SessionManager; readonly settings: Settings; - private _scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; - private _promptTemplates: PromptTemplate[]; - private _slashCommands: FileSlashCommand[]; + #scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; + #promptTemplates: PromptTemplate[]; + #slashCommands: FileSlashCommand[]; // Event subscription state - private _unsubscribeAgent?: () => void; - private _eventListeners: AgentSessionEventListener[] = []; + #unsubscribeAgent?: () => void; + #eventListeners: AgentSessionEventListener[] = []; /** Tracks pending steering messages for UI display. Removed when delivered. */ - private _steeringMessages: string[] = []; + #steeringMessages: string[] = []; /** Tracks pending follow-up messages for UI display. Removed when delivered. */ - private _followUpMessages: string[] = []; + #followUpMessages: string[] = []; /** Messages queued to be included with the next user prompt as context ("asides"). */ - private _pendingNextTurnMessages: CustomMessage[] = []; - private _planModeState: PlanModeState | undefined; - private _planReferenceSent = false; + #pendingNextTurnMessages: CustomMessage[] = []; + #planModeState: PlanModeState | undefined; + #planReferenceSent = false; // Compaction state - private _compactionAbortController: AbortController | undefined = undefined; - private _autoCompactionAbortController: AbortController | undefined = undefined; + #compactionAbortController: AbortController | undefined = undefined; + #autoCompactionAbortController: AbortController | undefined = undefined; // Branch summarization state - private _branchSummaryAbortController: AbortController | undefined = undefined; + #branchSummaryAbortController: AbortController | undefined = undefined; // Handoff state - private _handoffAbortController: AbortController | undefined = undefined; + #handoffAbortController: AbortController | undefined = undefined; // Retry state - private _retryAbortController: AbortController | undefined = undefined; - private _retryAttempt = 0; - private _retryPromise: Promise | undefined = undefined; - private _retryResolve: (() => void) | undefined = undefined; + #retryAbortController: AbortController | undefined = undefined; + #retryAttempt = 0; + #retryPromise: Promise | undefined = undefined; + #retryResolve: (() => void) | undefined = undefined; // Todo completion reminder state - private _todoReminderCount = 0; + #todoReminderCount = 0; // Bash execution state - private _bashAbortController: AbortController | undefined = undefined; - private _pendingBashMessages: BashExecutionMessage[] = []; + #bashAbortController: AbortController | undefined = undefined; + #pendingBashMessages: BashExecutionMessage[] = []; // Python execution state - private _pythonAbortController: AbortController | undefined = undefined; - private _pendingPythonMessages: PythonExecutionMessage[] = []; + #pythonAbortController: AbortController | undefined = undefined; + #pendingPythonMessages: PythonExecutionMessage[] = []; // Extension system - private _extensionRunner: ExtensionRunner | undefined = undefined; - private _turnIndex = 0; + #extensionRunner: ExtensionRunner | undefined = undefined; + #turnIndex = 0; - private _skills: Skill[]; - private _skillWarnings: SkillWarning[]; + #skills: Skill[]; + #skillWarnings: SkillWarning[]; // Custom commands (TypeScript slash commands) - private _customCommands: LoadedCustomCommand[] = []; + #customCommands: LoadedCustomCommand[] = []; - private _skillsSettings: Required | undefined; + #skillsSettings: Required | undefined; // Model registry for API key resolution - private _modelRegistry: ModelRegistry; + #modelRegistry: ModelRegistry; // Tool registry and prompt builder for extensions - private _toolRegistry: Map; - private _rebuildSystemPrompt: ((toolNames: string[], tools: Map) => Promise) | undefined; - private _baseSystemPrompt: string; + #toolRegistry: Map; + #rebuildSystemPrompt: ((toolNames: string[], tools: Map) => Promise) | undefined; + #baseSystemPrompt: string; // TTSR manager for time-traveling stream rules - private _ttsrManager: TtsrManager | undefined = undefined; - private _pendingTtsrInjections: Rule[] = []; - private _ttsrAbortPending = false; + #ttsrManager: TtsrManager | undefined = undefined; + #pendingTtsrInjections: Rule[] = []; + #ttsrAbortPending = false; - private _streamingEditAbortTriggered = false; - private _streamingEditCheckedLineCounts = new Map(); - private _streamingEditFileCache = new Map(); - private _promptInFlight = false; + #streamingEditAbortTriggered = false; + #streamingEditCheckedLineCounts = new Map(); + #streamingEditFileCache = new Map(); + #promptInFlight = false; constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; this.settings = config.settings; - this._scopedModels = config.scopedModels ?? []; - this._promptTemplates = config.promptTemplates ?? []; - this._slashCommands = config.slashCommands ?? []; - this._extensionRunner = config.extensionRunner; - this._skills = config.skills ?? []; - this._skillWarnings = config.skillWarnings ?? []; - this._customCommands = config.customCommands ?? []; - this._skillsSettings = config.skillsSettings; - this._modelRegistry = config.modelRegistry; - this._toolRegistry = config.toolRegistry ?? new Map(); - this._rebuildSystemPrompt = config.rebuildSystemPrompt; - this._baseSystemPrompt = this.agent.state.systemPrompt; - this._ttsrManager = config.ttsrManager; + this.#scopedModels = config.scopedModels ?? []; + this.#promptTemplates = config.promptTemplates ?? []; + this.#slashCommands = config.slashCommands ?? []; + this.#extensionRunner = config.extensionRunner; + this.#skills = config.skills ?? []; + this.#skillWarnings = config.skillWarnings ?? []; + this.#customCommands = config.customCommands ?? []; + this.#skillsSettings = config.skillsSettings; + this.#modelRegistry = config.modelRegistry; + this.#toolRegistry = config.toolRegistry ?? new Map(); + this.#rebuildSystemPrompt = config.rebuildSystemPrompt; + this.#baseSystemPrompt = this.agent.state.systemPrompt; + this.#ttsrManager = config.ttsrManager; // Always subscribe to agent events for internal handling // (session persistence, hooks, auto-compaction, retry logic) - this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); + this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent); } /** Model registry for API key resolution and model discovery */ get modelRegistry(): ModelRegistry { - return this._modelRegistry; + return this.#modelRegistry; } /** TTSR manager for time-traveling stream rules */ get ttsrManager(): TtsrManager | undefined { - return this._ttsrManager; + return this.#ttsrManager; } /** Whether a TTSR abort is pending (stream was aborted to inject rules) */ get isTtsrAbortPending(): boolean { - return this._ttsrAbortPending; + return this.#ttsrAbortPending; } // ========================================================================= @@ -376,85 +376,85 @@ export class AgentSession { // ========================================================================= /** Emit an event to all listeners */ - private _emit(event: AgentSessionEvent): void { + #emit(event: AgentSessionEvent): void { // Copy array before iteration to avoid mutation during iteration - const listeners = [...this._eventListeners]; + const listeners = [...this.#eventListeners]; for (const l of listeners) { l(event); } } // Track last assistant message for auto-compaction check - private _lastAssistantMessage: AssistantMessage | undefined = undefined; + #lastAssistantMessage: AssistantMessage | undefined = undefined; /** Internal handler for agent events - shared by subscribe and reconnect */ - private _handleAgentEvent = async (event: AgentEvent): Promise => { + #handleAgentEvent = async (event: AgentEvent): Promise => { // When a user message starts, check if it's from either queue and remove it BEFORE emitting // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { - const messageText = this._getUserMessageText(event.message); + const messageText = this.#getUserMessageText(event.message); if (messageText) { // Check steering queue first - const steeringIndex = this._steeringMessages.indexOf(messageText); + const steeringIndex = this.#steeringMessages.indexOf(messageText); if (steeringIndex !== -1) { - this._steeringMessages.splice(steeringIndex, 1); + this.#steeringMessages.splice(steeringIndex, 1); } else { // Check follow-up queue - const followUpIndex = this._followUpMessages.indexOf(messageText); + const followUpIndex = this.#followUpMessages.indexOf(messageText); if (followUpIndex !== -1) { - this._followUpMessages.splice(followUpIndex, 1); + this.#followUpMessages.splice(followUpIndex, 1); } } } } // Emit to extensions first - await this._emitExtensionEvent(event); + await this.#emitExtensionEvent(event); // Notify all listeners - this._emit(event); + this.#emit(event); if (event.type === "turn_start") { - this._resetStreamingEditState(); + this.#resetStreamingEditState(); // TTSR: Reset buffer on turn start - this._ttsrManager?.resetBuffer(); + this.#ttsrManager?.resetBuffer(); } // TTSR: Increment message count on turn end (for repeat-after-gap tracking) - if (event.type === "turn_end" && this._ttsrManager) { - this._ttsrManager.incrementMessageCount(); + if (event.type === "turn_end" && this.#ttsrManager) { + this.#ttsrManager.incrementMessageCount(); } // TTSR: Check for pattern matches on text deltas and tool call argument deltas - if (event.type === "message_update" && this._ttsrManager?.hasRules()) { + if (event.type === "message_update" && this.#ttsrManager?.hasRules()) { const assistantEvent = event.assistantMessageEvent; // Monitor both assistant prose (text_delta) and tool call arguments (toolcall_delta) if (assistantEvent.type === "text_delta" || assistantEvent.type === "toolcall_delta") { - this._ttsrManager.appendToBuffer(assistantEvent.delta); - const matches = this._ttsrManager.check(this._ttsrManager.getBuffer()); + this.#ttsrManager.appendToBuffer(assistantEvent.delta); + const matches = this.#ttsrManager.check(this.#ttsrManager.getBuffer()); if (matches.length > 0) { // Mark rules as injected so they don't trigger again - this._ttsrManager.markInjected(matches); + this.#ttsrManager.markInjected(matches); // Store for injection on retry - this._pendingTtsrInjections.push(...matches); + this.#pendingTtsrInjections.push(...matches); // Emit TTSR event before aborting (so UI can handle it) - this._ttsrAbortPending = true; - this._emit({ type: "ttsr_triggered", rules: matches }); + this.#ttsrAbortPending = true; + this.#emit({ type: "ttsr_triggered", rules: matches }); // Abort the stream this.agent.abort(); // Schedule retry after a short delay setTimeout(async () => { - this._ttsrAbortPending = false; + this.#ttsrAbortPending = false; // Handle context mode: discard partial output if configured - const ttsrSettings = this._ttsrManager?.getSettings(); + const ttsrSettings = this.#ttsrManager?.getSettings(); if (ttsrSettings?.contextMode === "discard") { // Remove the partial/aborted message from agent state this.agent.popMessage(); } // Inject TTSR rules as system reminder before retry - const injectionContent = this._getTtsrInjectionContent(); + const injectionContent = this.#getTtsrInjectionContent(); if (injectionContent) { this.agent.appendMessage({ role: "user", @@ -470,14 +470,14 @@ export class AgentSession { } if (event.type === "message_update" && event.assistantMessageEvent.type === "toolcall_start") { - this._preCacheStreamingEditFile(event); + this.#preCacheStreamingEditFile(event); } if ( event.type === "message_update" && (event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta") ) { - this._maybeAbortStreamingEdit(event); + this.#maybeAbortStreamingEdit(event); } // Handle session persistence @@ -504,19 +504,19 @@ export class AgentSession { // Track assistant message for auto-compaction (checked on agent_end) if (event.message.role === "assistant") { - this._lastAssistantMessage = event.message; + this.#lastAssistantMessage = event.message; // Reset retry counter immediately on successful assistant response // This prevents accumulation across multiple LLM calls within a turn const assistantMsg = event.message as AssistantMessage; - if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) { - this._emit({ + if (assistantMsg.stopReason !== "error" && this.#retryAttempt > 0) { + this.#emit({ type: "auto_retry_end", success: true, - attempt: this._retryAttempt, + attempt: this.#retryAttempt, }); - this._retryAttempt = 0; - this._resolveRetry(); + this.#retryAttempt = 0; + this.#resolveRetry(); } } @@ -528,56 +528,56 @@ export class AgentSession { $normative?: Record; }; if ($normative && toolCallId && this.settings.get("normativeRewrite")) { - await this._rewriteToolCallArgs(toolCallId, $normative); + await this.#rewriteToolCallArgs(toolCallId, $normative); } // Invalidate streaming edit cache when edit tool completes to prevent stale data if (toolName === "edit" && details?.path) { - this._invalidateFileCacheForPath(details.path); + this.#invalidateFileCacheForPath(details.path); } } } // Check auto-retry and auto-compaction after agent completes - if (event.type === "agent_end" && this._lastAssistantMessage) { - const msg = this._lastAssistantMessage; - this._lastAssistantMessage = undefined; + if (event.type === "agent_end" && this.#lastAssistantMessage) { + const msg = this.#lastAssistantMessage; + this.#lastAssistantMessage = undefined; // Check for retryable errors first (overloaded, rate limit, server errors) - if (this._isRetryableError(msg)) { - const didRetry = await this._handleRetryableError(msg); + if (this.#isRetryableError(msg)) { + const didRetry = await this.#handleRetryableError(msg); if (didRetry) return; // Retry was initiated, don't proceed to compaction } - await this._checkCompaction(msg); + await this.#checkCompaction(msg); // Check for incomplete todos (unless there was an error or abort) if (msg.stopReason !== "error" && msg.stopReason !== "aborted") { - await this._checkTodoCompletion(); + await this.#checkTodoCompletion(); } } }; /** Resolve the pending retry promise */ - private _resolveRetry(): void { - if (this._retryResolve) { - this._retryResolve(); - this._retryResolve = undefined; - this._retryPromise = undefined; + #resolveRetry(): void { + if (this.#retryResolve) { + this.#retryResolve(); + this.#retryResolve = undefined; + this.#retryPromise = undefined; } } /** Get TTSR injection content and clear pending injections */ - private _getTtsrInjectionContent(): string | undefined { - if (this._pendingTtsrInjections.length === 0) return undefined; - const content = this._pendingTtsrInjections + #getTtsrInjectionContent(): string | undefined { + if (this.#pendingTtsrInjections.length === 0) return undefined; + const content = this.#pendingTtsrInjections .map(r => renderPromptTemplate(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content })) .join("\n\n"); - this._pendingTtsrInjections = []; + this.#pendingTtsrInjections = []; return content; } /** Extract text content from a message */ - private _getUserMessageText(message: Message): string { + #getUserMessageText(message: Message): string { if (message.role !== "user") return ""; const content = message.content; if (typeof content === "string") return content; @@ -589,7 +589,7 @@ export class AgentSession { } /** Find the last assistant message in agent state (including aborted ones) */ - private _findLastAssistantMessage(): AssistantMessage | undefined { + #findLastAssistantMessage(): AssistantMessage | undefined { const messages = this.agent.state.messages; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; @@ -600,13 +600,13 @@ export class AgentSession { return undefined; } - private _resetStreamingEditState(): void { - this._streamingEditAbortTriggered = false; - this._streamingEditCheckedLineCounts.clear(); - this._streamingEditFileCache.clear(); + #resetStreamingEditState(): void { + this.#streamingEditAbortTriggered = false; + this.#streamingEditCheckedLineCounts.clear(); + this.#streamingEditFileCache.clear(); } - private async _preCacheStreamingEditFile(event: AgentEvent): Promise { + async #preCacheStreamingEditFile(event: AgentEvent): Promise { if (!this.settings.get("edit.streamingAbort")) return; if (event.type !== "message_update") return; const assistantEvent = event.assistantMessageEvent; @@ -627,30 +627,30 @@ export class AgentSession { if (!path) return; const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd()); - this._ensureFileCache(resolvedPath); + this.#ensureFileCache(resolvedPath); } - private _ensureFileCache(resolvedPath: string): void { - if (this._streamingEditFileCache.has(resolvedPath)) return; + #ensureFileCache(resolvedPath: string): void { + if (this.#streamingEditFileCache.has(resolvedPath)) return; try { const rawText = fs.readFileSync(resolvedPath, "utf-8"); const { text } = stripBom(rawText); - this._streamingEditFileCache.set(resolvedPath, normalizeToLF(text)); + this.#streamingEditFileCache.set(resolvedPath, normalizeToLF(text)); } catch { // Don't cache on read errors (including ENOENT) - let the edit tool handle them } } /** Invalidate cache for a file after an edit completes to prevent stale data */ - private _invalidateFileCacheForPath(path: string): void { + #invalidateFileCacheForPath(path: string): void { const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd()); - this._streamingEditFileCache.delete(resolvedPath); + this.#streamingEditFileCache.delete(resolvedPath); } - private _maybeAbortStreamingEdit(event: AgentEvent): void { + #maybeAbortStreamingEdit(event: AgentEvent): void { if (!this.settings.get("edit.streamingAbort")) return; - if (this._streamingEditAbortTriggered) return; + if (this.#streamingEditAbortTriggered) return; if (event.type !== "message_update") return; const assistantEvent = event.assistantMessageEvent; if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return; @@ -685,9 +685,9 @@ export class AgentSession { if (!hasChangeLine) return; const lineCount = lines.length; - const lastChecked = this._streamingEditCheckedLineCounts.get(toolCall.id); + const lastChecked = this.#streamingEditCheckedLineCounts.get(toolCall.id); if (lastChecked !== undefined && lineCount <= lastChecked) return; - this._streamingEditCheckedLineCounts.set(toolCall.id, lineCount); + this.#streamingEditCheckedLineCounts.set(toolCall.id, lineCount); const rename = typeof args.rename === "string" ? args.rename : undefined; @@ -696,15 +696,15 @@ export class AgentSession { .map(line => line.slice(1)); if (removedLines.length > 0) { const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd()); - let cachedContent = this._streamingEditFileCache.get(resolvedPath); + let cachedContent = this.#streamingEditFileCache.get(resolvedPath); if (cachedContent === undefined) { - this._ensureFileCache(resolvedPath); - cachedContent = this._streamingEditFileCache.get(resolvedPath); + this.#ensureFileCache(resolvedPath); + cachedContent = this.#streamingEditFileCache.get(resolvedPath); } if (cachedContent !== undefined) { const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line))); if (missing) { - this._streamingEditAbortTriggered = true; + this.#streamingEditAbortTriggered = true; logger.warn("Streaming edit aborted due to patch preview failure", { toolCallId: toolCall.id, path, @@ -715,27 +715,27 @@ export class AgentSession { return; } if (assistantEvent.type === "toolcall_delta") return; - void this._checkRemovedLinesAsync(toolCall.id, path, resolvedPath, removedLines); + void this.#checkRemovedLinesAsync(toolCall.id, path, resolvedPath, removedLines); return; } if (assistantEvent.type === "toolcall_delta") return; - void this._checkPreviewPatchAsync(toolCall.id, path, rename, normalizedDiff); + void this.#checkPreviewPatchAsync(toolCall.id, path, rename, normalizedDiff); } - private async _checkRemovedLinesAsync( + async #checkRemovedLinesAsync( toolCallId: string, path: string, resolvedPath: string, removedLines: string[], ): Promise { - if (this._streamingEditAbortTriggered) return; + if (this.#streamingEditAbortTriggered) return; try { const { text } = stripBom(await Bun.file(resolvedPath).text()); const normalizedContent = normalizeToLF(text); const missing = removedLines.find(line => !normalizedContent.includes(normalizeToLF(line))); if (missing) { - this._streamingEditAbortTriggered = true; + this.#streamingEditAbortTriggered = true; logger.warn("Streaming edit aborted due to patch preview failure", { toolCallId, path, @@ -752,13 +752,13 @@ export class AgentSession { } } - private async _checkPreviewPatchAsync( + async #checkPreviewPatchAsync( toolCallId: string, path: string, rename: string | undefined, normalizedDiff: string, ): Promise { - if (this._streamingEditAbortTriggered) return; + if (this.#streamingEditAbortTriggered) return; try { await previewPatch( { path, op: "update", rename, diff: normalizedDiff }, @@ -770,7 +770,7 @@ export class AgentSession { ); } catch (error) { if (error instanceof ParseError) return; - this._streamingEditAbortTriggered = true; + this.#streamingEditAbortTriggered = true; logger.warn("Streaming edit aborted due to patch preview failure", { toolCallId, path, @@ -781,7 +781,7 @@ export class AgentSession { } /** Rewrite tool call arguments in agent state and persisted session history. */ - private async _rewriteToolCallArgs(toolCallId: string, args: Record): Promise { + async #rewriteToolCallArgs(toolCallId: string, args: Record): Promise { let updated = false; const messages = this.agent.state.messages; for (let i = messages.length - 1; i >= 0; i--) { @@ -808,30 +808,30 @@ export class AgentSession { } /** Emit extension events based on agent events */ - private async _emitExtensionEvent(event: AgentEvent): Promise { - if (!this._extensionRunner) return; + async #emitExtensionEvent(event: AgentEvent): Promise { + if (!this.#extensionRunner) return; if (event.type === "agent_start") { - this._turnIndex = 0; - await this._extensionRunner.emit({ type: "agent_start" }); + this.#turnIndex = 0; + await this.#extensionRunner.emit({ type: "agent_start" }); } else if (event.type === "agent_end") { - await this._extensionRunner.emit({ type: "agent_end", messages: event.messages }); + await this.#extensionRunner.emit({ type: "agent_end", messages: event.messages }); } else if (event.type === "turn_start") { const hookEvent: TurnStartEvent = { type: "turn_start", - turnIndex: this._turnIndex, + turnIndex: this.#turnIndex, timestamp: Date.now(), }; - await this._extensionRunner.emit(hookEvent); + await this.#extensionRunner.emit(hookEvent); } else if (event.type === "turn_end") { const hookEvent: TurnEndEvent = { type: "turn_end", - turnIndex: this._turnIndex, + turnIndex: this.#turnIndex, message: event.message, toolResults: event.toolResults, }; - await this._extensionRunner.emit(hookEvent); - this._turnIndex++; + await this.#extensionRunner.emit(hookEvent); + this.#turnIndex++; } } @@ -841,13 +841,13 @@ export class AgentSession { * Multiple listeners can be added. Returns unsubscribe function for this listener. */ subscribe(listener: AgentSessionEventListener): () => void { - this._eventListeners.push(listener); + this.#eventListeners.push(listener); // Return unsubscribe function for this specific listener return () => { - const index = this._eventListeners.indexOf(listener); + const index = this.#eventListeners.indexOf(listener); if (index !== -1) { - this._eventListeners.splice(index, 1); + this.#eventListeners.splice(index, 1); } }; } @@ -857,10 +857,10 @@ export class AgentSession { * User listeners are preserved and will receive events again after resubscribe(). * Used internally during operations that need to pause event processing. */ - private _disconnectFromAgent(): void { - if (this._unsubscribeAgent) { - this._unsubscribeAgent(); - this._unsubscribeAgent = undefined; + #disconnectFromAgent(): void { + if (this.#unsubscribeAgent) { + this.#unsubscribeAgent(); + this.#unsubscribeAgent = undefined; } } @@ -868,9 +868,9 @@ export class AgentSession { * Reconnect to agent events after _disconnectFromAgent(). * Preserves all existing listeners. */ - private _reconnectToAgent(): void { - if (this._unsubscribeAgent) return; // Already connected - this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); + #reconnectToAgent(): void { + if (this.#unsubscribeAgent) return; // Already connected + this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent); } /** @@ -880,8 +880,8 @@ export class AgentSession { async dispose(): Promise { await this.sessionManager.flush(); await cleanupSshResources(); - this._disconnectFromAgent(); - this._eventListeners = []; + this.#disconnectFromAgent(); + this.#eventListeners = []; } // ========================================================================= @@ -905,7 +905,7 @@ export class AgentSession { /** Whether agent is currently streaming a response */ get isStreaming(): boolean { - return this.agent.state.isStreaming || this._promptInFlight; + return this.agent.state.isStreaming || this.#promptInFlight; } /** Current effective system prompt (includes any per-turn extension modifications) */ @@ -915,7 +915,7 @@ export class AgentSession { /** Current retry attempt (0 if not retrying) */ get retryAttempt(): number { - return this._retryAttempt; + return this.#retryAttempt; } /** @@ -930,14 +930,14 @@ export class AgentSession { * Get a tool by name from the registry. */ getToolByName(name: string): AgentTool | undefined { - return this._toolRegistry.get(name); + return this.#toolRegistry.get(name); } /** * Get all configured tool names (built-in via --tools or default, plus custom tools). */ getAllToolNames(): string[] { - return Array.from(this._toolRegistry.keys()); + return Array.from(this.#toolRegistry.keys()); } /** @@ -950,7 +950,7 @@ export class AgentSession { const tools: AgentTool[] = []; const validToolNames: string[] = []; for (const name of toolNames) { - const tool = this._toolRegistry.get(name); + const tool = this.#toolRegistry.get(name); if (tool) { tools.push(tool); validToolNames.push(name); @@ -959,15 +959,15 @@ export class AgentSession { this.agent.setTools(tools); // Rebuild base system prompt with new tool set - if (this._rebuildSystemPrompt) { - this._baseSystemPrompt = await this._rebuildSystemPrompt(validToolNames, this._toolRegistry); - this.agent.setSystemPrompt(this._baseSystemPrompt); + if (this.#rebuildSystemPrompt) { + this.#baseSystemPrompt = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry); + this.agent.setSystemPrompt(this.#baseSystemPrompt); } } /** Whether auto-compaction is currently running */ get isCompacting(): boolean { - return this._autoCompactionAbortController !== undefined || this._compactionAbortController !== undefined; + return this.#autoCompactionAbortController !== undefined || this.#compactionAbortController !== undefined; } /** All messages including custom types like BashExecutionMessage */ @@ -1007,30 +1007,30 @@ export class AgentSession { /** Scoped models for cycling (from --models flag) */ get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel: ThinkingLevel }> { - return this._scopedModels; + return this.#scopedModels; } /** Prompt templates */ getPlanModeState(): PlanModeState | undefined { - return this._planModeState; + return this.#planModeState; } setPlanModeState(state: PlanModeState | undefined): void { - this._planModeState = state; + this.#planModeState = state; if (state?.enabled) { - this._planReferenceSent = false; + this.#planReferenceSent = false; } } markPlanReferenceSent(): void { - this._planReferenceSent = true; + this.#planReferenceSent = true; } /** * Inject the plan mode context message into the conversation history. */ async sendPlanModeContext(options?: { deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise { - const message = await this._buildPlanModeMessage(); + const message = await this.#buildPlanModeMessage(); if (!message) return; await this.sendCustomMessage( { @@ -1044,16 +1044,16 @@ export class AgentSession { } resolveRoleModel(role: ModelRole): Model | undefined { - return this._resolveRoleModel(role, this._modelRegistry.getAvailable(), this.model); + return this.#resolveRoleModel(role, this.#modelRegistry.getAvailable(), this.model); } get promptTemplates(): ReadonlyArray { - return this._promptTemplates; + return this.#promptTemplates; } /** Custom commands (TypeScript slash commands) */ get customCommands(): ReadonlyArray { - return this._customCommands; + return this.#customCommands; } // ========================================================================= @@ -1065,9 +1065,9 @@ export class AgentSession { * Returns null if plan mode is not enabled. * @returns The plan mode message, or null if plan mode is not enabled. */ - private async _buildPlanReferenceMessage(): Promise { - if (this._planModeState?.enabled) return null; - if (this._planReferenceSent) return null; + async #buildPlanReferenceMessage(): Promise { + if (this.#planModeState?.enabled) return null; + if (this.#planReferenceSent) return null; const planFilePath = `plan://${this.sessionManager.getSessionId()}/plan.md`; const resolvedPlanPath = resolvePlanUrlToPath(planFilePath, { @@ -1076,7 +1076,7 @@ export class AgentSession { }); let planContent: string; try { - planContent = await fs.promises.readFile(resolvedPlanPath, "utf-8"); + planContent = await Bun.file(resolvedPlanPath).text(); } catch (error) { if (isEnoent(error)) { return null; @@ -1089,7 +1089,7 @@ export class AgentSession { planContent, }); - this._planReferenceSent = true; + this.#planReferenceSent = true; return { role: "custom", @@ -1100,8 +1100,8 @@ export class AgentSession { }; } - private async _buildPlanModeMessage(): Promise { - const state = this._planModeState; + async #buildPlanModeMessage(): Promise { + const state = this.#planModeState; if (!state?.enabled) return null; const sessionPlanUrl = `plan://${this.sessionManager.getSessionId()}/plan.md`; const resolvedPlanPath = state.planFilePath.startsWith("plan://") @@ -1119,16 +1119,7 @@ export class AgentSession { ? state.planFilePath : sessionPlanUrl; - let planExists = false; - try { - const stat = await fs.promises.stat(resolvedPlanPath); - planExists = stat.isFile(); - } catch (error) { - if (!isEnoent(error)) { - throw error; - } - } - + const planExists = fs.existsSync(resolvedPlanPath); const content = renderPromptTemplate(planModeActivePrompt, { planFilePath: displayPlanPath, planExists, @@ -1163,13 +1154,13 @@ export class AgentSession { // Handle extension commands first (execute immediately, even during streaming) if (expandPromptTemplates && text.startsWith("/")) { - const handled = await this._tryExecuteExtensionCommand(text); + const handled = await this.#tryExecuteExtensionCommand(text); if (handled) { return; } // Try custom commands (TypeScript slash commands) - const customResult = await this._tryExecuteCustomCommand(text); + const customResult = await this.#tryExecuteCustomCommand(text); if (customResult !== null) { if (customResult === "") { return; @@ -1180,12 +1171,12 @@ export class AgentSession { // Try file-based slash commands (markdown files from commands/ directories) // Only if text still starts with "/" (wasn't transformed by custom command) if (text.startsWith("/")) { - text = expandSlashCommand(text, this._slashCommands); + text = expandSlashCommand(text, this.#slashCommands); } } // Expand file-based prompt templates if requested - const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this._promptTemplates]) : text; + const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text; // If streaming, queue via steer() or followUp() based on option if (this.isStreaming) { @@ -1195,9 +1186,9 @@ export class AgentSession { ); } if (options.streamingBehavior === "followUp") { - await this._queueFollowUp(expandedText, options?.images); + await this.#queueFollowUp(expandedText, options?.images); } else { - await this._queueSteer(expandedText, options?.images); + await this.#queueSteer(expandedText, options?.images); } return; } @@ -1207,7 +1198,7 @@ export class AgentSession { userContent.push(...options.images); } - await this._promptWithMessage( + await this.#promptWithMessage( { role: "user", content: userContent, @@ -1250,22 +1241,22 @@ export class AgentSession { timestamp: Date.now(), }; - await this._promptWithMessage(customMessage, textContent, options); + await this.#promptWithMessage(customMessage, textContent, options); } - private async _promptWithMessage( + async #promptWithMessage( message: AgentMessage, expandedText: string, options?: Pick, ): Promise { - this._promptInFlight = true; + this.#promptInFlight = true; try { // Flush any pending bash messages before the new prompt - this._flushPendingBashMessages(); - this._flushPendingPythonMessages(); + this.#flushPendingBashMessages(); + this.#flushPendingPythonMessages(); // Reset todo reminder count on new user prompt - this._todoReminderCount = 0; + this.#todoReminderCount = 0; // Validate model if (!this.model) { @@ -1277,7 +1268,7 @@ export class AgentSession { } // Validate API key - const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId); + const apiKey = await this.#modelRegistry.getApiKey(this.model, this.sessionId); if (!apiKey) { throw new Error( `No API key found for ${this.model.provider}.\n\n` + @@ -1286,18 +1277,18 @@ export class AgentSession { } // Check if we need to compact before sending (catches aborted responses) - const lastAssistant = this._findLastAssistantMessage(); + const lastAssistant = this.#findLastAssistantMessage(); if (lastAssistant) { - await this._checkCompaction(lastAssistant, false); + await this.#checkCompaction(lastAssistant, false); } // Build messages array (custom messages if any, then user message) const messages: AgentMessage[] = []; - const planReferenceMessage = await this._buildPlanReferenceMessage?.(); + const planReferenceMessage = await this.#buildPlanReferenceMessage?.(); if (planReferenceMessage) { messages.push(planReferenceMessage); } - const planModeMessage = await this._buildPlanModeMessage(); + const planModeMessage = await this.#buildPlanModeMessage(); if (planModeMessage) { messages.push(planModeMessage); } @@ -1305,10 +1296,10 @@ export class AgentSession { messages.push(message); // Inject any pending "nextTurn" messages as context alongside the user message - for (const msg of this._pendingNextTurnMessages) { + for (const msg of this.#pendingNextTurnMessages) { messages.push(msg); } - this._pendingNextTurnMessages = []; + this.#pendingNextTurnMessages = []; // Auto-read @filepath mentions const fileMentions = extractFileMentions(expandedText); @@ -1320,11 +1311,11 @@ export class AgentSession { } // Emit before_agent_start extension event - if (this._extensionRunner) { - const result = await this._extensionRunner.emitBeforeAgentStart( + if (this.#extensionRunner) { + const result = await this.#extensionRunner.emitBeforeAgentStart( expandedText, options?.images, - this._baseSystemPrompt, + this.#baseSystemPrompt, ); if (result?.messages) { for (const msg of result.messages) { @@ -1342,41 +1333,41 @@ export class AgentSession { if (result?.systemPrompt !== undefined) { this.agent.setSystemPrompt(result.systemPrompt); } else { - this.agent.setSystemPrompt(this._baseSystemPrompt); + this.agent.setSystemPrompt(this.#baseSystemPrompt); } } const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined; await this.agent.prompt(messages, agentPromptOptions); - await this.waitForRetry(); + await this.#waitForRetry(); } finally { - this._promptInFlight = false; + this.#promptInFlight = false; } } /** * Try to execute an extension command. Returns true if command was found and executed. */ - private async _tryExecuteExtensionCommand(text: string): Promise { - if (!this._extensionRunner) return false; + async #tryExecuteExtensionCommand(text: string): Promise { + if (!this.#extensionRunner) return false; // Parse command name and args const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); - const command = this._extensionRunner.getCommand(commandName); + const command = this.#extensionRunner.getCommand(commandName); if (!command) return false; // Get command context from extension runner (includes session control methods) - const ctx = this._extensionRunner.createCommandContext(); + const ctx = this.#extensionRunner.createCommandContext(); try { await command.handler(args, ctx); return true; } catch (err) { // Emit error via extension runner - this._extensionRunner.emitError({ + this.#extensionRunner.emitError({ extensionPath: `command:${commandName}`, event: "command", error: err instanceof Error ? err.message : String(err), @@ -1385,9 +1376,9 @@ export class AgentSession { } } - private _createCommandContext(): ExtensionCommandContext { - if (this._extensionRunner) { - return this._extensionRunner.createCommandContext(); + #createCommandContext(): ExtensionCommandContext { + if (this.#extensionRunner) { + return this.#extensionRunner.createCommandContext(); } return { @@ -1395,7 +1386,7 @@ export class AgentSession { hasUI: false, cwd: this.sessionManager.getCwd(), sessionManager: this.sessionManager, - modelRegistry: this._modelRegistry, + modelRegistry: this.#modelRegistry, model: this.model ?? undefined, isIdle: () => !this.isStreaming, abort: () => { @@ -1448,8 +1439,8 @@ export class AgentSession { * Try to execute a custom command. Returns the prompt string if found, null otherwise. * If the command returns void, returns empty string to indicate it was handled. */ - private async _tryExecuteCustomCommand(text: string): Promise { - if (this._customCommands.length === 0) return null; + async #tryExecuteCustomCommand(text: string): Promise { + if (this.#customCommands.length === 0) return null; // Parse command name and args const spaceIndex = text.indexOf(" "); @@ -1457,11 +1448,11 @@ export class AgentSession { const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); // Find matching command - const loaded = this._customCommands.find(c => c.command.name === commandName); + const loaded = this.#customCommands.find(c => c.command.name === commandName); if (!loaded) return null; // Get command context from extension runner (includes session control methods) - const baseCtx = this._createCommandContext(); + const baseCtx = this.#createCommandContext(); const ctx = { ...baseCtx, hasQueuedMessages: baseCtx.hasPendingMessages, @@ -1475,8 +1466,8 @@ export class AgentSession { return result ?? ""; } catch (err) { // Emit error via extension runner - if (this._extensionRunner) { - this._extensionRunner.emitError({ + if (this.#extensionRunner) { + this.#extensionRunner.emitError({ extensionPath: `custom-command:${commandName}`, event: "command", error: err instanceof Error ? err.message : String(err), @@ -1494,11 +1485,11 @@ export class AgentSession { */ async steer(text: string, images?: ImageContent[]): Promise { if (text.startsWith("/")) { - this._throwIfExtensionCommand(text); + this.#throwIfExtensionCommand(text); } - const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); - await this._queueSteer(expandedText, images); + const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]); + await this.#queueSteer(expandedText, images); } /** @@ -1506,19 +1497,19 @@ export class AgentSession { */ async followUp(text: string, images?: ImageContent[]): Promise { if (text.startsWith("/")) { - this._throwIfExtensionCommand(text); + this.#throwIfExtensionCommand(text); } - const expandedText = expandPromptTemplate(text, [...this._promptTemplates]); - await this._queueFollowUp(expandedText, images); + const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]); + await this.#queueFollowUp(expandedText, images); } /** * Internal: Queue a steering message (already expanded, no extension command check). */ - private async _queueSteer(text: string, images?: ImageContent[]): Promise { + async #queueSteer(text: string, images?: ImageContent[]): Promise { const displayText = text || (images && images.length > 0 ? "[Image]" : ""); - this._steeringMessages.push(displayText); + this.#steeringMessages.push(displayText); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images && images.length > 0) { content.push(...images); @@ -1533,9 +1524,9 @@ export class AgentSession { /** * Internal: Queue a follow-up message (already expanded, no extension command check). */ - private async _queueFollowUp(text: string, images?: ImageContent[]): Promise { + async #queueFollowUp(text: string, images?: ImageContent[]): Promise { const displayText = text || (images && images.length > 0 ? "[Image]" : ""); - this._followUpMessages.push(displayText); + this.#followUpMessages.push(displayText); const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; if (images && images.length > 0) { content.push(...images); @@ -1550,12 +1541,12 @@ export class AgentSession { /** * Throw an error if the text is an extension command. */ - private _throwIfExtensionCommand(text: string): void { - if (!this._extensionRunner) return; + #throwIfExtensionCommand(text: string): void { + if (!this.#extensionRunner) return; const spaceIndex = text.indexOf(" "); const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); - const command = this._extensionRunner.getCommand(commandName); + const command = this.#extensionRunner.getCommand(commandName); if (command) { throw new Error( @@ -1586,7 +1577,7 @@ export class AgentSession { }; if (this.isStreaming) { if (options?.deliverAs === "nextTurn") { - this._pendingNextTurnMessages.push(appMessage); + this.#pendingNextTurnMessages.push(appMessage); return; } @@ -1656,22 +1647,22 @@ export class AgentSession { * Useful for restoring to editor when user aborts. */ clearQueue(): { steering: string[]; followUp: string[] } { - const steering = [...this._steeringMessages]; - const followUp = [...this._followUpMessages]; - this._steeringMessages = []; - this._followUpMessages = []; + const steering = [...this.#steeringMessages]; + const followUp = [...this.#followUpMessages]; + this.#steeringMessages = []; + this.#followUpMessages = []; this.agent.clearAllQueues(); return { steering, followUp }; } /** Number of pending messages (includes both steering and follow-up) */ get queuedMessageCount(): number { - return this._steeringMessages.length + this._followUpMessages.length; + return this.#steeringMessages.length + this.#followUpMessages.length; } /** Get pending messages (read-only) */ getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } { - return { steering: this._steeringMessages, followUp: this._followUpMessages }; + return { steering: this.#steeringMessages, followUp: this.#followUpMessages }; } /** @@ -1680,14 +1671,14 @@ export class AgentSession { */ popLastQueuedMessage(): string | undefined { // Pop from steering first (LIFO) - if (this._steeringMessages.length > 0) { - const message = this._steeringMessages.pop(); + if (this.#steeringMessages.length > 0) { + const message = this.#steeringMessages.pop(); this.agent.popLastSteer(); return message; } // Then from follow-up - if (this._followUpMessages.length > 0) { - const message = this._followUpMessages.pop(); + if (this.#followUpMessages.length > 0) { + const message = this.#followUpMessages.pop(); this.agent.popLastFollowUp(); return message; } @@ -1695,17 +1686,17 @@ export class AgentSession { } get skillsSettings(): Required | undefined { - return this._skillsSettings; + return this.#skillsSettings; } /** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */ get skills(): readonly Skill[] { - return this._skills; + return this.#skills; } /** Skill loading warnings captured by SDK */ get skillWarnings(): readonly SkillWarning[] { - return this._skillWarnings; + return this.#skillWarnings; } /** @@ -1728,8 +1719,8 @@ export class AgentSession { const previousSessionFile = this.sessionFile; // Emit session_before_switch event with reason "new" (can be cancelled) - if (this._extensionRunner?.hasHandlers("session_before_switch")) { - const result = (await this._extensionRunner.emit({ + if (this.#extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this.#extensionRunner.emit({ type: "session_before_switch", reason: "new", })) as SessionBeforeSwitchResult | undefined; @@ -1739,25 +1730,25 @@ export class AgentSession { } } - this._disconnectFromAgent(); + this.#disconnectFromAgent(); await this.abort(); this.agent.reset(); await this.sessionManager.flush(); await this.sessionManager.newSession(options); this.agent.sessionId = this.sessionManager.getSessionId(); - this._steeringMessages = []; - this._followUpMessages = []; - this._pendingNextTurnMessages = []; + this.#steeringMessages = []; + this.#followUpMessages = []; + this.#pendingNextTurnMessages = []; this.sessionManager.appendThinkingLevelChange(this.thinkingLevel); - this._todoReminderCount = 0; - this._planReferenceSent = false; - this._reconnectToAgent(); + this.#todoReminderCount = 0; + this.#planReferenceSent = false; + this.#reconnectToAgent(); // Emit session_switch event with reason "new" to hooks - if (this._extensionRunner) { - await this._extensionRunner.emit({ + if (this.#extensionRunner) { + await this.#extensionRunner.emit({ type: "session_switch", reason: "new", previousSessionFile, @@ -1784,8 +1775,8 @@ export class AgentSession { const previousSessionFile = this.sessionFile; // Emit session_before_switch event with reason "fork" (can be cancelled) - if (this._extensionRunner?.hasHandlers("session_before_switch")) { - const result = (await this._extensionRunner.emit({ + if (this.#extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this.#extensionRunner.emit({ type: "session_before_switch", reason: "fork", })) as SessionBeforeSwitchResult | undefined; @@ -1827,8 +1818,8 @@ export class AgentSession { this.agent.sessionId = this.sessionManager.getSessionId(); // Emit session_switch event with reason "fork" to hooks - if (this._extensionRunner) { - await this._extensionRunner.emit({ + if (this.#extensionRunner) { + await this.#extensionRunner.emit({ type: "session_switch", reason: "fork", previousSessionFile, @@ -1848,7 +1839,7 @@ export class AgentSession { * @throws Error if no API key available for the model */ async setModel(model: Model, role: ModelRole = "default"): Promise { - const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId); + const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${model.provider}/${model.id}`); } @@ -1868,7 +1859,7 @@ export class AgentSession { * @throws Error if no API key available for the model */ async setModelTemporary(model: Model): Promise { - const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId); + const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${model.provider}/${model.id}`); } @@ -1888,10 +1879,10 @@ export class AgentSession { * @returns The new model info, or undefined if only one model available */ async cycleModel(direction: "forward" | "backward" = "forward"): Promise { - if (this._scopedModels.length > 0) { - return this._cycleScopedModel(direction); + if (this.#scopedModels.length > 0) { + return this.#cycleScopedModel(direction); } - return this._cycleAvailableModel(direction); + return this.#cycleAvailableModel(direction); } /** @@ -1904,7 +1895,7 @@ export class AgentSession { roleOrder: readonly ModelRole[], options?: { temporary?: boolean }, ): Promise { - const availableModels = this._modelRegistry.getAvailable(); + const availableModels = this.#modelRegistry.getAvailable(); if (availableModels.length === 0) return undefined; const currentModel = this.model; @@ -1952,17 +1943,17 @@ export class AgentSession { return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role }; } - private async _getScopedModelsWithApiKey(): Promise> { + async #getScopedModelsWithApiKey(): Promise> { const apiKeysByProvider = new Map(); const result: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = []; - for (const scoped of this._scopedModels) { + for (const scoped of this.#scopedModels) { const provider = scoped.model.provider; let apiKey: string | undefined; if (apiKeysByProvider.has(provider)) { apiKey = apiKeysByProvider.get(provider); } else { - apiKey = await this._modelRegistry.getApiKeyForProvider(provider, this.sessionId); + apiKey = await this.#modelRegistry.getApiKeyForProvider(provider, this.sessionId); apiKeysByProvider.set(provider, apiKey); } @@ -1974,8 +1965,8 @@ export class AgentSession { return result; } - private async _cycleScopedModel(direction: "forward" | "backward"): Promise { - const scopedModels = await this._getScopedModelsWithApiKey(); + async #cycleScopedModel(direction: "forward" | "backward"): Promise { + const scopedModels = await this.#getScopedModelsWithApiKey(); if (scopedModels.length <= 1) return undefined; const currentModel = this.model; @@ -1998,8 +1989,8 @@ export class AgentSession { return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; } - private async _cycleAvailableModel(direction: "forward" | "backward"): Promise { - const availableModels = this._modelRegistry.getAvailable(); + async #cycleAvailableModel(direction: "forward" | "backward"): Promise { + const availableModels = this.#modelRegistry.getAvailable(); if (availableModels.length <= 1) return undefined; const currentModel = this.model; @@ -2010,7 +2001,7 @@ export class AgentSession { const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; const nextModel = availableModels[nextIndex]; - const apiKey = await this._modelRegistry.getApiKey(nextModel, this.sessionId); + const apiKey = await this.#modelRegistry.getApiKey(nextModel, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`); } @@ -2030,7 +2021,7 @@ export class AgentSession { * Get all available models with valid API keys. */ getAvailableModels(): Model[] { - return this._modelRegistry.getAvailable(); + return this.#modelRegistry.getAvailable(); } // ========================================================================= @@ -2044,7 +2035,7 @@ export class AgentSession { */ setThinkingLevel(level: ThinkingLevel, persist: boolean = false): void { const availableLevels = this.getAvailableThinkingLevels(); - const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); + const effectiveLevel = availableLevels.includes(level) ? level : this.#clampThinkingLevel(level, availableLevels); // Only persist if actually changing const isChanging = effectiveLevel !== this.agent.state.thinkingLevel; @@ -2098,7 +2089,7 @@ export class AgentSession { return !!this.model?.reasoning; } - private _clampThinkingLevel(level: ThinkingLevel, availableLevels: ThinkingLevel[]): ThinkingLevel { + #clampThinkingLevel(level: ThinkingLevel, availableLevels: ThinkingLevel[]): ThinkingLevel { const ordered = THINKING_LEVELS_WITH_XHIGH; const available = new Set(availableLevels); const requestedIndex = ordered.indexOf(level); @@ -2151,7 +2142,7 @@ export class AgentSession { // Compaction // ========================================================================= - private async _pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> { + async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> { const branchEntries = this.sessionManager.getBranch(); const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG); if (result.prunedCount === 0) { @@ -2171,9 +2162,9 @@ export class AgentSession { * @param options Optional callbacks for completion/error handling */ async compact(customInstructions?: string, options?: CompactOptions): Promise { - this._disconnectFromAgent(); + this.#disconnectFromAgent(); await this.abort(); - this._compactionAbortController = new AbortController(); + this.#compactionAbortController = new AbortController(); try { if (!this.model) { @@ -2182,7 +2173,7 @@ export class AgentSession { const compactionSettings = this.settings.getGroup("compaction"); const compactionModel = this.model; - const apiKey = await this._modelRegistry.getApiKey(compactionModel, this.sessionId); + const apiKey = await this.#modelRegistry.getApiKey(compactionModel, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${compactionModel.provider}`); } @@ -2205,13 +2196,13 @@ export class AgentSession { let hookPrompt: string | undefined; let preserveData: Record | undefined; - if (this._extensionRunner?.hasHandlers("session_before_compact")) { - const result = (await this._extensionRunner.emit({ + if (this.#extensionRunner?.hasHandlers("session_before_compact")) { + const result = (await this.#extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, customInstructions, - signal: this._compactionAbortController.signal, + signal: this.#compactionAbortController.signal, })) as SessionBeforeCompactResult | undefined; if (result?.cancel) { @@ -2224,9 +2215,9 @@ export class AgentSession { } } - if (!hookCompaction && this._extensionRunner?.hasHandlers("session.compacting")) { + if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); - const result = (await this._extensionRunner.emit({ + const result = (await this.#extensionRunner.emit({ type: "session.compacting", sessionId: this.sessionId, messages: compactMessages, @@ -2258,7 +2249,7 @@ export class AgentSession { compactionModel, apiKey, customInstructions, - this._compactionAbortController.signal, + this.#compactionAbortController.signal, { promptOverride: hookPrompt, extraContext: hookContext }, ); summary = result.summary; @@ -2268,7 +2259,7 @@ export class AgentSession { details = result.details; } - if (this._compactionAbortController.signal.aborted) { + if (this.#compactionAbortController.signal.aborted) { throw new Error("Compaction cancelled"); } @@ -2290,8 +2281,8 @@ export class AgentSession { | CompactionEntry | undefined; - if (this._extensionRunner && savedCompactionEntry) { - await this._extensionRunner.emit({ + if (this.#extensionRunner && savedCompactionEntry) { + await this.#extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, fromExtension, @@ -2313,8 +2304,8 @@ export class AgentSession { options?.onError?.(err); throw error; } finally { - this._compactionAbortController = undefined; - this._reconnectToAgent(); + this.#compactionAbortController = undefined; + this.#reconnectToAgent(); } } @@ -2322,29 +2313,29 @@ export class AgentSession { * Cancel in-progress compaction (manual or auto). */ abortCompaction(): void { - this._compactionAbortController?.abort(); - this._autoCompactionAbortController?.abort(); + this.#compactionAbortController?.abort(); + this.#autoCompactionAbortController?.abort(); } /** * Cancel in-progress branch summarization. */ abortBranchSummary(): void { - this._branchSummaryAbortController?.abort(); + this.#branchSummaryAbortController?.abort(); } /** * Cancel in-progress handoff generation. */ abortHandoff(): void { - this._handoffAbortController?.abort(); + this.#handoffAbortController?.abort(); } /** * Check if handoff generation is in progress. */ get isGeneratingHandoff(): boolean { - return this._handoffAbortController !== undefined; + return this.#handoffAbortController !== undefined; } /** @@ -2364,7 +2355,7 @@ export class AgentSession { throw new Error("Nothing to hand off (no messages yet)"); } - this._handoffAbortController = new AbortController(); + this.#handoffAbortController = new AbortController(); // Build the handoff prompt let handoffPrompt = `Write a comprehensive handoff document that will allow another instance of yourself to seamlessly continue this work. The document should capture everything needed to resume without access to this conversation. @@ -2407,7 +2398,7 @@ Be thorough - include exact file paths, function names, error messages, and tech let handoffText: string | undefined; const completionPromise = new Promise((resolve, reject) => { const unsubscribe = this.subscribe(event => { - if (this._handoffAbortController?.signal.aborted) { + if (this.#handoffAbortController?.signal.aborted) { unsubscribe(); reject(new Error("Handoff cancelled")); return; @@ -2440,7 +2431,7 @@ Be thorough - include exact file paths, function names, error messages, and tech await this.prompt(handoffPrompt, { expandPromptTemplates: false }); await completionPromise; - if (!handoffText || this._handoffAbortController.signal.aborted) { + if (!handoffText || this.#handoffAbortController.signal.aborted) { return undefined; } @@ -2449,10 +2440,10 @@ Be thorough - include exact file paths, function names, error messages, and tech await this.sessionManager.newSession(); this.agent.reset(); this.agent.sessionId = this.sessionManager.getSessionId(); - this._steeringMessages = []; - this._followUpMessages = []; - this._pendingNextTurnMessages = []; - this._todoReminderCount = 0; + this.#steeringMessages = []; + this.#followUpMessages = []; + this.#pendingNextTurnMessages = []; + this.#todoReminderCount = 0; // Inject the handoff document as a custom message const handoffContent = `\n${handoffText}\n\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`; @@ -2464,7 +2455,7 @@ Be thorough - include exact file paths, function names, error messages, and tech return { document: handoffText }; } finally { - this._handoffAbortController = undefined; + this.#handoffAbortController = undefined; } } @@ -2479,11 +2470,11 @@ Be thorough - include exact file paths, function names, error messages, and tech * @param assistantMessage The assistant message to check * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true */ - private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { + async #checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { const compactionSettings = this.settings.getGroup("compaction"); if (!compactionSettings.enabled) return; - const pruneResult = await this._pruneToolOutputs(); + const pruneResult = await this.#pruneToolOutputs(); // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return; @@ -2514,7 +2505,7 @@ Be thorough - include exact file paths, function names, error messages, and tech if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.replaceMessages(messages.slice(0, -1)); } - await this._runAutoCompaction("overflow", true); + await this.#runAutoCompaction("overflow", true); return; } @@ -2527,24 +2518,24 @@ Be thorough - include exact file paths, function names, error messages, and tech contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved); } if (shouldCompact(contextTokens, contextWindow, compactionSettings)) { - await this._runAutoCompaction("threshold", false); + await this.#runAutoCompaction("threshold", false); } } /** * Check if agent stopped with incomplete todos and prompt to continue. */ - private async _checkTodoCompletion(): Promise { + async #checkTodoCompletion(): Promise { const remindersEnabled = this.settings.get("todo.reminders"); const todosEnabled = this.settings.get("todo.enabled"); if (!remindersEnabled || !todosEnabled) { - this._todoReminderCount = 0; + this.#todoReminderCount = 0; return; } const remindersMax = this.settings.get("todo.reminders.max"); - if (this._todoReminderCount >= remindersMax) { - logger.debug("Todo completion: max reminders reached", { count: this._todoReminderCount }); + if (this.#todoReminderCount >= remindersMax) { + logger.debug("Todo completion: max reminders reached", { count: this.#todoReminderCount }); return; } @@ -2560,7 +2551,7 @@ Be thorough - include exact file paths, function names, error messages, and tech todos = data?.todos ?? []; } catch (err) { if (isEnoent(err)) { - this._todoReminderCount = 0; + this.#todoReminderCount = 0; } return; } @@ -2568,30 +2559,30 @@ Be thorough - include exact file paths, function names, error messages, and tech // Check for incomplete todos const incomplete = todos.filter(t => t.status !== "completed"); if (incomplete.length === 0) { - this._todoReminderCount = 0; + this.#todoReminderCount = 0; return; } // Build reminder message - this._todoReminderCount++; + this.#todoReminderCount++; const todoList = incomplete.map(t => `- ${t.content}`).join("\n"); const reminder = `\n` + `You stopped with ${incomplete.length} incomplete todo item(s):\n${todoList}\n\n` + `Please continue working on these tasks or mark them complete if finished.\n` + - `(Reminder ${this._todoReminderCount}/${remindersMax})\n` + + `(Reminder ${this.#todoReminderCount}/${remindersMax})\n` + ``; logger.debug("Todo completion: sending reminder", { incomplete: incomplete.length, - attempt: this._todoReminderCount, + attempt: this.#todoReminderCount, }); // Emit event for UI to render notification - this._emit({ + this.#emit({ type: "todo_reminder", todos: incomplete, - attempt: this._todoReminderCount, + attempt: this.#todoReminderCount, maxAttempts: remindersMax, }); @@ -2604,15 +2595,11 @@ Be thorough - include exact file paths, function names, error messages, and tech this.agent.continue().catch(() => {}); } - private _getModelKey(model: Model): string { + #getModelKey(model: Model): string { return `${model.provider}/${model.id}`; } - private _resolveRoleModel( - role: ModelRole, - availableModels: Model[], - currentModel: Model | undefined, - ): Model | undefined { + #resolveRoleModel(role: ModelRole, availableModels: Model[], currentModel: Model | undefined): Model | undefined { const roleModelStr = role === "default" ? (this.settings.getModelRole("default") ?? @@ -2629,13 +2616,13 @@ Be thorough - include exact file paths, function names, error messages, and tech return availableModels.find(m => m.id.toLowerCase() === roleLower); } - private _getCompactionModelCandidates(availableModels: Model[]): Model[] { + #getCompactionModelCandidates(availableModels: Model[]): Model[] { const candidates: Model[] = []; const seen = new Set(); const addCandidate = (model: Model | undefined): void => { if (!model) return; - const key = this._getModelKey(model); + const key = this.#getModelKey(model); if (seen.has(key)) return; seen.add(key); candidates.push(model); @@ -2643,12 +2630,12 @@ Be thorough - include exact file paths, function names, error messages, and tech const currentModel = this.model; for (const role of MODEL_ROLE_IDS) { - addCandidate(this._resolveRoleModel(role, availableModels, currentModel)); + addCandidate(this.#resolveRoleModel(role, availableModels, currentModel)); } const sortedByContext = [...availableModels].sort((a, b) => b.contextWindow - a.contextWindow); for (const model of sortedByContext) { - if (!seen.has(this._getModelKey(model))) { + if (!seen.has(this.#getModelKey(model))) { addCandidate(model); break; } @@ -2660,25 +2647,25 @@ Be thorough - include exact file paths, function names, error messages, and tech /** * Internal: Run auto-compaction with events. */ - private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { + async #runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const compactionSettings = this.settings.getGroup("compaction"); - this._emit({ type: "auto_compaction_start", reason }); + this.#emit({ type: "auto_compaction_start", reason }); // Properly abort and null existing controller before replacing - if (this._autoCompactionAbortController) { - this._autoCompactionAbortController.abort(); + if (this.#autoCompactionAbortController) { + this.#autoCompactionAbortController.abort(); } - this._autoCompactionAbortController = new AbortController(); + this.#autoCompactionAbortController = new AbortController(); try { if (!this.model) { - this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); + this.#emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; } - const availableModels = this._modelRegistry.getAvailable(); + const availableModels = this.#modelRegistry.getAvailable(); if (availableModels.length === 0) { - this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); + this.#emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; } @@ -2686,7 +2673,7 @@ Be thorough - include exact file paths, function names, error messages, and tech const preparation = prepareCompaction(pathEntries, compactionSettings); if (!preparation) { - this._emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); + this.#emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false }); return; } @@ -2696,17 +2683,17 @@ Be thorough - include exact file paths, function names, error messages, and tech let hookPrompt: string | undefined; let preserveData: Record | undefined; - if (this._extensionRunner?.hasHandlers("session_before_compact")) { - const hookResult = (await this._extensionRunner.emit({ + if (this.#extensionRunner?.hasHandlers("session_before_compact")) { + const hookResult = (await this.#extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, customInstructions: undefined, - signal: this._autoCompactionAbortController.signal, + signal: this.#autoCompactionAbortController.signal, })) as SessionBeforeCompactResult | undefined; if (hookResult?.cancel) { - this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false }); + this.#emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false }); return; } @@ -2716,9 +2703,9 @@ Be thorough - include exact file paths, function names, error messages, and tech } } - if (!hookCompaction && this._extensionRunner?.hasHandlers("session.compacting")) { + if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) { const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages); - const result = (await this._extensionRunner.emit({ + const result = (await this.#extensionRunner.emit({ type: "session.compacting", sessionId: this.sessionId, messages: compactMessages, @@ -2744,13 +2731,13 @@ Be thorough - include exact file paths, function names, error messages, and tech details = hookCompaction.details; preserveData ??= hookCompaction.preserveData; } else { - const candidates = this._getCompactionModelCandidates(availableModels); + const candidates = this.#getCompactionModelCandidates(availableModels); const retrySettings = this.settings.getGroup("retry"); let compactResult: CompactionResult | undefined; let lastError: unknown; for (const candidate of candidates) { - const apiKey = await this._modelRegistry.getApiKey(candidate, this.sessionId); + const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId); if (!apiKey) continue; let attempt = 0; @@ -2761,21 +2748,21 @@ Be thorough - include exact file paths, function names, error messages, and tech candidate, apiKey, undefined, - this._autoCompactionAbortController.signal, + this.#autoCompactionAbortController.signal, { promptOverride: hookPrompt, extraContext: hookContext }, ); break; } catch (error) { - if (this._autoCompactionAbortController.signal.aborted) { + if (this.#autoCompactionAbortController.signal.aborted) { throw error; } const message = error instanceof Error ? error.message : String(error); - const retryAfterMs = this._parseRetryAfterMsFromError(message); + const retryAfterMs = this.#parseRetryAfterMsFromError(message); const shouldRetry = retrySettings.enabled && attempt < retrySettings.maxRetries && - (retryAfterMs !== undefined || this._isRetryableErrorMessage(message)); + (retryAfterMs !== undefined || this.#isRetryableErrorMessage(message)); if (!shouldRetry) { lastError = error; break; @@ -2810,7 +2797,7 @@ Be thorough - include exact file paths, function names, error messages, and tech error: message, model: `${candidate.provider}/${candidate.id}`, }); - await abortableSleep(delayMs, this._autoCompactionAbortController.signal); + await abortableSleep(delayMs, this.#autoCompactionAbortController.signal); } } @@ -2833,8 +2820,8 @@ Be thorough - include exact file paths, function names, error messages, and tech details = compactResult.details; } - if (this._autoCompactionAbortController.signal.aborted) { - this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false }); + if (this.#autoCompactionAbortController.signal.aborted) { + this.#emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false }); return; } @@ -2856,8 +2843,8 @@ Be thorough - include exact file paths, function names, error messages, and tech | CompactionEntry | undefined; - if (this._extensionRunner && savedCompactionEntry) { - await this._extensionRunner.emit({ + if (this.#extensionRunner && savedCompactionEntry) { + await this.#extensionRunner.emit({ type: "session_compact", compactionEntry: savedCompactionEntry, fromExtension, @@ -2872,7 +2859,7 @@ Be thorough - include exact file paths, function names, error messages, and tech details, preserveData, }; - this._emit({ type: "auto_compaction_end", result, aborted: false, willRetry }); + this.#emit({ type: "auto_compaction_end", result, aborted: false, willRetry }); if (!willRetry && compactionSettings.autoContinue !== false) { await this.prompt("Continue if you have next steps.", { @@ -2899,12 +2886,12 @@ Be thorough - include exact file paths, function names, error messages, and tech }, 100); } } catch (error) { - if (this._autoCompactionAbortController?.signal.aborted) { - this._emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false }); + if (this.#autoCompactionAbortController?.signal.aborted) { + this.#emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false }); return; } const errorMessage = error instanceof Error ? error.message : "compaction failed"; - this._emit({ + this.#emit({ type: "auto_compaction_end", result: undefined, aborted: false, @@ -2915,7 +2902,7 @@ Be thorough - include exact file paths, function names, error messages, and tech : `Auto-compaction failed: ${errorMessage}`, }); } finally { - this._autoCompactionAbortController = undefined; + this.#autoCompactionAbortController = undefined; } } @@ -2939,7 +2926,7 @@ Be thorough - include exact file paths, function names, error messages, and tech * Check if an error is retryable (overloaded, rate limit, server errors). * Context overflow errors are NOT retryable (handled by compaction instead). */ - private _isRetryableError(message: AssistantMessage): boolean { + #isRetryableError(message: AssistantMessage): boolean { if (message.stopReason !== "error" || !message.errorMessage) return false; // Context overflow is handled by compaction, not retry @@ -2947,21 +2934,21 @@ Be thorough - include exact file paths, function names, error messages, and tech if (isContextOverflow(message, contextWindow)) return false; const err = message.errorMessage; - return this._isRetryableErrorMessage(err); + return this.#isRetryableErrorMessage(err); } - private _isRetryableErrorMessage(errorMessage: string): boolean { + #isRetryableErrorMessage(errorMessage: string): boolean { // Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed, retry delay exceeded return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|fetch failed|retry delay/i.test( errorMessage, ); } - private _isUsageLimitErrorMessage(errorMessage: string): boolean { + #isUsageLimitErrorMessage(errorMessage: string): boolean { return /usage.?limit|usage_limit_reached|limit_reached/i.test(errorMessage); } - private _parseRetryAfterMsFromError(errorMessage: string): number | undefined { + #parseRetryAfterMsFromError(errorMessage: string): number | undefined { const now = Date.now(); const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage); if (retryAfterMsMatch) { @@ -3010,39 +2997,39 @@ Be thorough - include exact file paths, function names, error messages, and tech * Handle retryable errors with exponential backoff. * @returns true if retry was initiated, false if max retries exceeded or disabled */ - private async _handleRetryableError(message: AssistantMessage): Promise { + async #handleRetryableError(message: AssistantMessage): Promise { const retrySettings = this.settings.getGroup("retry"); if (!retrySettings.enabled) return false; - this._retryAttempt++; + this.#retryAttempt++; // Create retry promise on first attempt so waitForRetry() can await it // Ensure only one promise exists (avoid orphaned promises from concurrent calls) - if (!this._retryPromise) { + if (!this.#retryPromise) { const { promise, resolve } = Promise.withResolvers(); - this._retryPromise = promise; - this._retryResolve = resolve; + this.#retryPromise = promise; + this.#retryResolve = resolve; } - if (this._retryAttempt > retrySettings.maxRetries) { + if (this.#retryAttempt > retrySettings.maxRetries) { // Max retries exceeded, emit final failure and reset - this._emit({ + this.#emit({ type: "auto_retry_end", success: false, - attempt: this._retryAttempt - 1, + attempt: this.#retryAttempt - 1, finalError: message.errorMessage, }); - this._retryAttempt = 0; - this._resolveRetry(); // Resolve so waitForRetry() completes + this.#retryAttempt = 0; + this.#resolveRetry(); // Resolve so waitForRetry() completes return false; } const errorMessage = message.errorMessage || "Unknown error"; - let delayMs = retrySettings.baseDelayMs * 2 ** (this._retryAttempt - 1); + let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1); - if (this.model && this._isUsageLimitErrorMessage(errorMessage)) { - const retryAfterMs = this._parseRetryAfterMsFromError(errorMessage); - const switched = await this._modelRegistry.authStorage.markUsageLimitReached( + if (this.model && this.#isUsageLimitErrorMessage(errorMessage)) { + const retryAfterMs = this.#parseRetryAfterMsFromError(errorMessage); + const switched = await this.#modelRegistry.authStorage.markUsageLimitReached( this.model.provider, this.sessionId, { @@ -3055,9 +3042,9 @@ Be thorough - include exact file paths, function names, error messages, and tech } } - this._emit({ + this.#emit({ type: "auto_retry_start", - attempt: this._retryAttempt, + attempt: this.#retryAttempt, maxAttempts: retrySettings.maxRetries, delayMs, errorMessage, @@ -3071,27 +3058,27 @@ Be thorough - include exact file paths, function names, error messages, and tech // Wait with exponential backoff (abortable) // Properly abort and null existing controller before replacing - if (this._retryAbortController) { - this._retryAbortController.abort(); + if (this.#retryAbortController) { + this.#retryAbortController.abort(); } - this._retryAbortController = new AbortController(); + this.#retryAbortController = new AbortController(); try { - await abortableSleep(delayMs, this._retryAbortController.signal); + await abortableSleep(delayMs, this.#retryAbortController.signal); } catch { // Aborted during sleep - emit end event so UI can clean up - const attempt = this._retryAttempt; - this._retryAttempt = 0; - this._retryAbortController = undefined; - this._emit({ + const attempt = this.#retryAttempt; + this.#retryAttempt = 0; + this.#retryAbortController = undefined; + this.#emit({ type: "auto_retry_end", success: false, attempt, finalError: "Retry cancelled", }); - this._resolveRetry(); + this.#resolveRetry(); return false; } - this._retryAbortController = undefined; + this.#retryAbortController = undefined; // Retry via continue() - use setTimeout to break out of event handler chain setTimeout(() => { @@ -3107,24 +3094,24 @@ Be thorough - include exact file paths, function names, error messages, and tech * Cancel in-progress retry. */ abortRetry(): void { - this._retryAbortController?.abort(); + this.#retryAbortController?.abort(); // Note: _retryAttempt is reset in the catch block of _autoRetry - this._resolveRetry(); + this.#resolveRetry(); } /** * Wait for any in-progress retry to complete. * Returns immediately if no retry is in progress. */ - private async waitForRetry(): Promise { - if (this._retryPromise) { - await this._retryPromise; + async #waitForRetry(): Promise { + if (this.#retryPromise) { + await this.#retryPromise; } } /** Whether auto-retry is currently in progress */ get isRetrying(): boolean { - return this._retryPromise !== undefined; + return this.#retryPromise !== undefined; } /** Whether auto-retry is enabled */ @@ -3155,19 +3142,19 @@ Be thorough - include exact file paths, function names, error messages, and tech onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }, ): Promise { - this._bashAbortController = new AbortController(); + this.#bashAbortController = new AbortController(); try { const result = await executeBashCommand(command, { onChunk, - signal: this._bashAbortController.signal, + signal: this.#bashAbortController.signal, sessionKey: this.sessionId, }); this.recordBashResult(command, result, options); return result; } finally { - this._bashAbortController = undefined; + this.#bashAbortController = undefined; } } @@ -3192,7 +3179,7 @@ Be thorough - include exact file paths, function names, error messages, and tech // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering if (this.isStreaming) { // Queue for later - will be flushed on agent_end - this._pendingBashMessages.push(bashMessage); + this.#pendingBashMessages.push(bashMessage); } else { // Add to agent state immediately this.agent.appendMessage(bashMessage); @@ -3206,27 +3193,27 @@ Be thorough - include exact file paths, function names, error messages, and tech * Cancel running bash command. */ abortBash(): void { - this._bashAbortController?.abort(); + this.#bashAbortController?.abort(); } /** Whether a bash command is currently running */ get isBashRunning(): boolean { - return this._bashAbortController !== undefined; + return this.#bashAbortController !== undefined; } /** Whether there are pending bash messages waiting to be flushed */ get hasPendingBashMessages(): boolean { - return this._pendingBashMessages.length > 0; + return this.#pendingBashMessages.length > 0; } /** * Flush pending bash messages to agent state and session. * Called after agent turn completes to maintain proper message ordering. */ - private _flushPendingBashMessages(): void { - if (this._pendingBashMessages.length === 0) return; + #flushPendingBashMessages(): void { + if (this.#pendingBashMessages.length === 0) return; - for (const bashMessage of this._pendingBashMessages) { + for (const bashMessage of this.#pendingBashMessages) { // Add to agent state this.agent.appendMessage(bashMessage); @@ -3234,7 +3221,7 @@ Be thorough - include exact file paths, function names, error messages, and tech this.sessionManager.appendMessage(bashMessage); } - this._pendingBashMessages = []; + this.#pendingBashMessages = []; } // ========================================================================= @@ -3253,7 +3240,7 @@ Be thorough - include exact file paths, function names, error messages, and tech onChunk?: (chunk: string) => void, options?: { excludeFromContext?: boolean }, ): Promise { - this._pythonAbortController = new AbortController(); + this.#pythonAbortController = new AbortController(); try { // Use the same session ID as the Python tool for kernel sharing @@ -3267,13 +3254,13 @@ Be thorough - include exact file paths, function names, error messages, and tech kernelMode: this.settings.get("python.kernelMode"), useSharedGateway: this.settings.get("python.sharedGateway"), onChunk, - signal: this._pythonAbortController.signal, + signal: this.#pythonAbortController.signal, }); this.recordPythonResult(code, result, options); return result; } finally { - this._pythonAbortController = undefined; + this.#pythonAbortController = undefined; } } @@ -3296,7 +3283,7 @@ Be thorough - include exact file paths, function names, error messages, and tech // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering if (this.isStreaming) { - this._pendingPythonMessages.push(pythonMessage); + this.#pendingPythonMessages.push(pythonMessage); } else { this.agent.appendMessage(pythonMessage); this.sessionManager.appendMessage(pythonMessage); @@ -3307,31 +3294,31 @@ Be thorough - include exact file paths, function names, error messages, and tech * Cancel running Python execution. */ abortPython(): void { - this._pythonAbortController?.abort(); + this.#pythonAbortController?.abort(); } /** Whether a Python execution is currently running */ get isPythonRunning(): boolean { - return this._pythonAbortController !== undefined; + return this.#pythonAbortController !== undefined; } /** Whether there are pending Python messages waiting to be flushed */ get hasPendingPythonMessages(): boolean { - return this._pendingPythonMessages.length > 0; + return this.#pendingPythonMessages.length > 0; } /** * Flush pending Python messages to agent state and session. */ - private _flushPendingPythonMessages(): void { - if (this._pendingPythonMessages.length === 0) return; + #flushPendingPythonMessages(): void { + if (this.#pendingPythonMessages.length === 0) return; - for (const pythonMessage of this._pendingPythonMessages) { + for (const pythonMessage of this.#pendingPythonMessages) { this.agent.appendMessage(pythonMessage); this.sessionManager.appendMessage(pythonMessage); } - this._pendingPythonMessages = []; + this.#pendingPythonMessages = []; } // ========================================================================= @@ -3360,8 +3347,8 @@ Be thorough - include exact file paths, function names, error messages, and tech const previousSessionFile = this.sessionManager.getSessionFile(); // Emit session_before_switch event (can be cancelled) - if (this._extensionRunner?.hasHandlers("session_before_switch")) { - const result = (await this._extensionRunner.emit({ + if (this.#extensionRunner?.hasHandlers("session_before_switch")) { + const result = (await this.#extensionRunner.emit({ type: "session_before_switch", reason: "resume", targetSessionFile: sessionPath, @@ -3372,11 +3359,11 @@ Be thorough - include exact file paths, function names, error messages, and tech } } - this._disconnectFromAgent(); + this.#disconnectFromAgent(); await this.abort(); - this._steeringMessages = []; - this._followUpMessages = []; - this._pendingNextTurnMessages = []; + this.#steeringMessages = []; + this.#followUpMessages = []; + this.#pendingNextTurnMessages = []; // Flush pending writes before switching await this.sessionManager.flush(); @@ -3389,8 +3376,8 @@ Be thorough - include exact file paths, function names, error messages, and tech const sessionContext = this.sessionManager.buildSessionContext(); // Emit session_switch event to hooks - if (this._extensionRunner) { - await this._extensionRunner.emit({ + if (this.#extensionRunner) { + await this.#extensionRunner.emit({ type: "session_switch", reason: "resume", previousSessionFile, @@ -3406,7 +3393,7 @@ Be thorough - include exact file paths, function names, error messages, and tech if (slashIdx > 0) { const provider = defaultModelStr.slice(0, slashIdx); const modelId = defaultModelStr.slice(slashIdx + 1); - const availableModels = this._modelRegistry.getAvailable(); + const availableModels = this.#modelRegistry.getAvailable(); const match = availableModels.find(m => m.provider === provider && m.id === modelId); if (match) { this.agent.setModel(match); @@ -3424,12 +3411,12 @@ Be thorough - include exact file paths, function names, error messages, and tech const availableLevels = this.getAvailableThinkingLevels(); const effectiveLevel = availableLevels.includes(defaultThinkingLevel) ? defaultThinkingLevel - : this._clampThinkingLevel(defaultThinkingLevel, availableLevels); + : this.#clampThinkingLevel(defaultThinkingLevel, availableLevels); this.agent.setThinkingLevel(effectiveLevel); this.sessionManager.appendThinkingLevelChange(effectiveLevel); } - this._reconnectToAgent(); + this.#reconnectToAgent(); return true; } @@ -3450,13 +3437,13 @@ Be thorough - include exact file paths, function names, error messages, and tech throw new Error("Invalid entry ID for branching"); } - const selectedText = this._extractUserMessageText(selectedEntry.message.content); + const selectedText = this.#extractUserMessageText(selectedEntry.message.content); let skipConversationRestore = false; // Emit session_before_branch event (can be cancelled) - if (this._extensionRunner?.hasHandlers("session_before_branch")) { - const result = (await this._extensionRunner.emit({ + if (this.#extensionRunner?.hasHandlers("session_before_branch")) { + const result = (await this.#extensionRunner.emit({ type: "session_before_branch", entryId, })) as SessionBeforeBranchResult | undefined; @@ -3468,7 +3455,7 @@ Be thorough - include exact file paths, function names, error messages, and tech } // Clear pending messages (bound to old session state) - this._pendingNextTurnMessages = []; + this.#pendingNextTurnMessages = []; // Flush pending writes before branching await this.sessionManager.flush(); @@ -3484,8 +3471,8 @@ Be thorough - include exact file paths, function names, error messages, and tech const sessionContext = this.sessionManager.buildSessionContext(); // Emit session_branch event to hooks (after branch completes) - if (this._extensionRunner) { - await this._extensionRunner.emit({ + if (this.#extensionRunner) { + await this.#extensionRunner.emit({ type: "session_branch", previousSessionFile, }); @@ -3549,16 +3536,16 @@ Be thorough - include exact file paths, function names, error messages, and tech }; // Set up abort controller for summarization - this._branchSummaryAbortController = new AbortController(); + this.#branchSummaryAbortController = new AbortController(); let hookSummary: { summary: string; details?: unknown } | undefined; let fromExtension = false; // Emit session_before_tree event - if (this._extensionRunner?.hasHandlers("session_before_tree")) { - const result = (await this._extensionRunner.emit({ + if (this.#extensionRunner?.hasHandlers("session_before_tree")) { + const result = (await this.#extensionRunner.emit({ type: "session_before_tree", preparation, - signal: this._branchSummaryAbortController.signal, + signal: this.#branchSummaryAbortController.signal, })) as SessionBeforeTreeResult | undefined; if (result?.cancel) { @@ -3576,7 +3563,7 @@ Be thorough - include exact file paths, function names, error messages, and tech let summaryDetails: unknown; if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) { const model = this.model!; - const apiKey = await this._modelRegistry.getApiKey(model, this.sessionId); + const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId); if (!apiKey) { throw new Error(`No API key for ${model.provider}`); } @@ -3584,11 +3571,11 @@ Be thorough - include exact file paths, function names, error messages, and tech const result = await generateBranchSummary(entriesToSummarize, { model, apiKey, - signal: this._branchSummaryAbortController.signal, + signal: this.#branchSummaryAbortController.signal, customInstructions: options.customInstructions, reserveTokens: branchSummarySettings.reserveTokens, }); - this._branchSummaryAbortController = undefined; + this.#branchSummaryAbortController = undefined; if (result.aborted) { return { cancelled: true, aborted: true }; } @@ -3612,7 +3599,7 @@ Be thorough - include exact file paths, function names, error messages, and tech if (targetEntry.type === "message" && targetEntry.message.role === "user") { // User message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; - editorText = this._extractUserMessageText(targetEntry.message.content); + editorText = this.#extractUserMessageText(targetEntry.message.content); } else if (targetEntry.type === "custom_message") { // Custom message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; @@ -3648,8 +3635,8 @@ Be thorough - include exact file paths, function names, error messages, and tech this.agent.replaceMessages(sessionContext.messages); // Emit session_tree event - if (this._extensionRunner) { - await this._extensionRunner.emit({ + if (this.#extensionRunner) { + await this.#extensionRunner.emit({ type: "session_tree", newLeafId: this.sessionManager.getLeafId(), oldLeafId, @@ -3658,7 +3645,7 @@ Be thorough - include exact file paths, function names, error messages, and tech }); } - this._branchSummaryAbortController = undefined; + this.#branchSummaryAbortController = undefined; return { editorText, cancelled: false, summaryEntry }; } @@ -3673,7 +3660,7 @@ Be thorough - include exact file paths, function names, error messages, and tech if (entry.type !== "message") continue; if (entry.message.role !== "user") continue; - const text = this._extractUserMessageText(entry.message.content); + const text = this.#extractUserMessageText(entry.message.content); if (text) { result.push({ entryId: entry.id, text }); } @@ -3682,7 +3669,7 @@ Be thorough - include exact file paths, function names, error messages, and tech return result; } - private _extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { + #extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { if (typeof content === "string") return content; if (Array.isArray(content)) { return content @@ -3771,7 +3758,7 @@ Be thorough - include exact file paths, function names, error messages, and tech const contextWindow = model.contextWindow ?? 0; if (contextWindow <= 0) return undefined; - const estimate = this._estimateContextTokens(); + const estimate = this.#estimateContextTokens(); const percent = (estimate.tokens / contextWindow) * 100; return { @@ -3785,17 +3772,17 @@ Be thorough - include exact file paths, function names, error messages, and tech } async fetchUsageReports(): Promise { - const authStorage = this._modelRegistry.authStorage; + const authStorage = this.#modelRegistry.authStorage; if (!authStorage.fetchUsageReports) return null; return authStorage.fetchUsageReports({ - baseUrlResolver: provider => this._modelRegistry.getProviderBaseUrl?.(provider), + baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider), }); } /** * Estimate context tokens from messages, using the last assistant usage when available. */ - private _estimateContextTokens(): { + #estimateContextTokens(): { tokens: number; usageTokens: number; trailingTokens: number; @@ -4136,14 +4123,14 @@ Be thorough - include exact file paths, function names, error messages, and tech * Check if extensions have handlers for a specific event type. */ hasExtensionHandlers(eventType: string): boolean { - return this._extensionRunner?.hasHandlers(eventType) ?? false; + return this.#extensionRunner?.hasHandlers(eventType) ?? false; } /** * Get the extension runner (for setting UI context and error handlers). */ get extensionRunner(): ExtensionRunner | undefined { - return this._extensionRunner; + return this.#extensionRunner; } /** @@ -4151,8 +4138,8 @@ Be thorough - include exact file paths, function names, error messages, and tech */ async emitCustomToolSessionEvent(reason: "start" | "switch" | "branch" | "tree" | "shutdown"): Promise { if (reason !== "shutdown") return; - if (this._extensionRunner?.hasHandlers("session_shutdown")) { - await this._extensionRunner.emit({ type: "session_shutdown" }); + if (this.#extensionRunner?.hasHandlers("session_shutdown")) { + await this.#extensionRunner.emit({ type: "session_shutdown" }); } await cleanupSshResources(); } diff --git a/packages/coding-agent/src/session/agent-storage.ts b/packages/coding-agent/src/session/agent-storage.ts index e630349aa..b473c9273 100644 --- a/packages/coding-agent/src/session/agent-storage.ts +++ b/packages/coding-agent/src/session/agent-storage.ts @@ -1,4 +1,4 @@ -import { Database } from "bun:sqlite"; +import { Database, type Statement } from "bun:sqlite"; import * as fs from "node:fs"; import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; @@ -6,9 +6,6 @@ import { getAgentDbPath } from "../config"; import type { RawSettings as Settings } from "../config/settings"; import type { AuthCredential } from "./auth-storage"; -/** Prepared SQLite statement type from bun:sqlite */ -type Statement = ReturnType; - /** Row shape for settings table queries */ type SettingsRow = { key: string; @@ -118,28 +115,28 @@ function deserializeCredential(row: AuthRow): AuthCredential | null { * Uses singleton pattern per database path; access via AgentStorage.open(). */ export class AgentStorage { - private db: Database; - private static instances = new Map(); + #db: Database; + static #instances = new Map(); - private listSettingsStmt: Statement; - private getCacheStmt: Statement; - private upsertCacheStmt: Statement; - private deleteExpiredCacheStmt: Statement; - private listAuthStmt: Statement; - private listAuthByProviderStmt: Statement; - private insertAuthStmt: Statement; - private updateAuthStmt: Statement; - private deleteAuthStmt: Statement; - private deleteAuthByProviderStmt: Statement; - private countAuthStmt: Statement; - private upsertModelUsageStmt: Statement; - private listModelUsageStmt: Statement; - private modelUsageCache: string[] | null = null; + #listSettingsStmt: Statement; + #getCacheStmt: Statement; + #upsertCacheStmt: Statement; + #deleteExpiredCacheStmt: Statement; + #listAuthStmt: Statement; + #listAuthByProviderStmt: Statement; + #insertAuthStmt: Statement; + #updateAuthStmt: Statement; + #deleteAuthStmt: Statement; + #deleteAuthByProviderStmt: Statement; + #countAuthStmt: Statement; + #upsertModelUsageStmt: Statement; + #listModelUsageStmt: Statement; + #modelUsageCache: string[] | null = null; private constructor(dbPath: string) { - this.ensureDir(dbPath); + this.#ensureDir(dbPath); try { - this.db = new Database(dbPath); + this.#db = new Database(dbPath); } catch (err) { const dir = path.dirname(dbPath); const dirExists = fs.existsSync(dir); @@ -151,37 +148,37 @@ export class AgentStorage { ); } - this.initializeSchema(); - this.hardenPermissions(dbPath); + this.#initializeSchema(); + this.#hardenPermissions(dbPath); - this.listSettingsStmt = this.db.prepare("SELECT key, value FROM settings"); + this.#listSettingsStmt = this.#db.prepare("SELECT key, value FROM settings"); - this.getCacheStmt = this.db.prepare("SELECT value FROM cache WHERE key = ? AND expires_at > unixepoch()"); - this.upsertCacheStmt = this.db.prepare( + this.#getCacheStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ? AND expires_at > unixepoch()"); + this.#upsertCacheStmt = this.#db.prepare( "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at", ); - this.deleteExpiredCacheStmt = this.db.prepare("DELETE FROM cache WHERE expires_at <= unixepoch()"); + this.#deleteExpiredCacheStmt = this.#db.prepare("DELETE FROM cache WHERE expires_at <= unixepoch()"); - this.listAuthStmt = this.db.prepare( + this.#listAuthStmt = this.#db.prepare( "SELECT id, provider, credential_type, data FROM auth_credentials ORDER BY id ASC", ); - this.listAuthByProviderStmt = this.db.prepare( + this.#listAuthByProviderStmt = this.#db.prepare( "SELECT id, provider, credential_type, data FROM auth_credentials WHERE provider = ? ORDER BY id ASC", ); - this.insertAuthStmt = this.db.prepare( + this.#insertAuthStmt = this.#db.prepare( "INSERT INTO auth_credentials (provider, credential_type, data) VALUES (?, ?, ?) RETURNING id", ); - this.updateAuthStmt = this.db.prepare( + this.#updateAuthStmt = this.#db.prepare( "UPDATE auth_credentials SET credential_type = ?, data = ?, updated_at = unixepoch() WHERE id = ?", ); - this.deleteAuthStmt = this.db.prepare("DELETE FROM auth_credentials WHERE id = ?"); - this.deleteAuthByProviderStmt = this.db.prepare("DELETE FROM auth_credentials WHERE provider = ?"); - this.countAuthStmt = this.db.prepare("SELECT COUNT(*) as count FROM auth_credentials"); + this.#deleteAuthStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?"); + this.#deleteAuthByProviderStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE provider = ?"); + this.#countAuthStmt = this.#db.prepare("SELECT COUNT(*) as count FROM auth_credentials"); - this.upsertModelUsageStmt = this.db.prepare( + this.#upsertModelUsageStmt = this.#db.prepare( "INSERT INTO model_usage (model_key, last_used_at) VALUES (?, unixepoch()) ON CONFLICT(model_key) DO UPDATE SET last_used_at = unixepoch()", ); - this.listModelUsageStmt = this.db.prepare( + this.#listModelUsageStmt = this.#db.prepare( "SELECT model_key, last_used_at FROM model_usage ORDER BY last_used_at DESC", ); } @@ -190,8 +187,8 @@ export class AgentStorage { * Creates tables if missing and migrates legacy single-blob settings to key-value format. * Handles v1 to v2 schema migration for settings table. */ - private initializeSchema(): void { - this.db.exec(` + #initializeSchema(): void { + this.#db.exec(` PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; @@ -221,13 +218,13 @@ CREATE TABLE IF NOT EXISTS model_usage ( CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY); `); - const settingsInfo = this.db.prepare("PRAGMA table_info(settings)").all() as Array<{ name?: string }>; + const settingsInfo = this.#db.prepare("PRAGMA table_info(settings)").all() as Array<{ name?: string }>; const hasSettingsTable = settingsInfo.length > 0; const hasKey = settingsInfo.some(column => column.name === "key"); const hasValue = settingsInfo.some(column => column.name === "value"); if (!hasSettingsTable) { - this.db.exec(` + this.#db.exec(` CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -237,7 +234,7 @@ CREATE TABLE settings ( } else if (!hasKey || !hasValue) { // Migrate v1 schema: single JSON blob in `data` column → per-key rows let legacySettings: Record | null = null; - const row = this.db.prepare("SELECT data FROM settings WHERE id = 1").get() as { data?: string } | undefined; + const row = this.#db.prepare("SELECT data FROM settings WHERE id = 1").get() as { data?: string } | undefined; if (row?.data) { try { const parsed = JSON.parse(row.data); @@ -251,9 +248,9 @@ CREATE TABLE settings ( } } - const migrate = this.db.transaction((settings: Record | null) => { - this.db.exec("DROP TABLE settings"); - this.db.exec(` + const migrate = this.#db.transaction((settings: Record | null) => { + this.#db.exec("DROP TABLE settings"); + this.#db.exec(` CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -261,7 +258,7 @@ CREATE TABLE settings ( ); `); if (settings) { - const insert = this.db.prepare( + const insert = this.#db.prepare( "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, unixepoch())", ); for (const [key, value] of Object.entries(settings)) { @@ -276,7 +273,7 @@ CREATE TABLE settings ( migrate(legacySettings); } - const versionRow = this.db.prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get() as + const versionRow = this.#db.prepare("SELECT version FROM schema_version ORDER BY version DESC LIMIT 1").get() as | { version?: number } | undefined; if (versionRow?.version !== undefined && versionRow.version !== SCHEMA_VERSION) { @@ -285,7 +282,7 @@ CREATE TABLE settings ( expected: SCHEMA_VERSION, }); } - this.db.prepare("INSERT OR REPLACE INTO schema_version(version) VALUES (?)").run(SCHEMA_VERSION); + this.#db.prepare("INSERT OR REPLACE INTO schema_version(version) VALUES (?)").run(SCHEMA_VERSION); } /** @@ -295,7 +292,7 @@ CREATE TABLE settings ( * @returns AgentStorage instance for the given path */ static async open(dbPath: string = getAgentDbPath()): Promise { - const existing = AgentStorage.instances.get(dbPath); + const existing = AgentStorage.#instances.get(dbPath); if (existing) return existing; const maxRetries = 3; @@ -305,7 +302,7 @@ CREATE TABLE settings ( for (let attempt = 0; attempt < maxRetries; attempt++) { try { const storage = new AgentStorage(dbPath); - AgentStorage.instances.set(dbPath, storage); + AgentStorage.#instances.set(dbPath, storage); return storage; } catch (err) { const isSqliteBusy = err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_BUSY"; @@ -329,7 +326,7 @@ CREATE TABLE settings ( * @deprecated Use config.yml instead. This is only for migration. */ getSettings(): Settings | null { - const rows = (this.listSettingsStmt.all() as SettingsRow[]) ?? []; + const rows = (this.#listSettingsStmt.all() as SettingsRow[]) ?? []; if (rows.length === 0) return null; const settings: Record = {}; for (const row of rows) { @@ -360,7 +357,7 @@ CREATE TABLE settings ( */ getCache(key: string): string | null { try { - const row = this.getCacheStmt.get(key) as { value?: string } | undefined; + const row = this.#getCacheStmt.get(key) as { value?: string } | undefined; return row?.value ?? null; } catch { return null; @@ -372,7 +369,7 @@ CREATE TABLE settings ( */ setCache(key: string, value: string, expiresAtSec: number): void { try { - this.upsertCacheStmt.run(key, value, expiresAtSec); + this.#upsertCacheStmt.run(key, value, expiresAtSec); } catch (error) { logger.warn("AgentStorage failed to set cache", { key, error: String(error) }); } @@ -383,7 +380,7 @@ CREATE TABLE settings ( */ cleanExpiredCache(): void { try { - this.deleteExpiredCacheStmt.run(); + this.#deleteExpiredCacheStmt.run(); } catch { // Ignore cleanup errors } @@ -395,8 +392,8 @@ CREATE TABLE settings ( */ recordModelUsage(modelKey: string): void { try { - this.upsertModelUsageStmt.run(modelKey); - this.modelUsageCache = null; + this.#upsertModelUsageStmt.run(modelKey); + this.#modelUsageCache = null; } catch (error) { logger.warn("AgentStorage failed to record model usage", { modelKey, error: String(error) }); } @@ -408,13 +405,13 @@ CREATE TABLE settings ( * @returns Array of model keys ("provider/modelId") in MRU order */ getModelUsageOrder(): string[] { - if (this.modelUsageCache) { - return this.modelUsageCache; + if (this.#modelUsageCache) { + return this.#modelUsageCache; } try { - const rows = this.listModelUsageStmt.all() as ModelUsageRow[]; - this.modelUsageCache = rows.map(row => row.model_key); - return this.modelUsageCache; + const rows = this.#listModelUsageStmt.all() as ModelUsageRow[]; + this.#modelUsageCache = rows.map(row => row.model_key); + return this.#modelUsageCache; } catch (error) { logger.warn("AgentStorage failed to get model usage order", { error: String(error) }); return []; @@ -426,7 +423,7 @@ CREATE TABLE settings ( * @returns True if at least one credential is stored */ hasAuthCredentials(): boolean { - const row = this.countAuthStmt.get() as { count?: number } | undefined; + const row = this.#countAuthStmt.get() as { count?: number } | undefined; return (row?.count ?? 0) > 0; } @@ -438,8 +435,8 @@ CREATE TABLE settings ( listAuthCredentials(provider?: string): StoredAuthCredential[] { const rows = (provider - ? (this.listAuthByProviderStmt.all(provider) as AuthRow[]) - : (this.listAuthStmt.all() as AuthRow[])) ?? []; + ? (this.#listAuthByProviderStmt.all(provider) as AuthRow[]) + : (this.#listAuthStmt.all() as AuthRow[])) ?? []; const results: StoredAuthCredential[] = []; for (const row of rows) { @@ -458,11 +455,11 @@ CREATE TABLE settings ( * @returns Array of newly stored credentials with their database IDs */ replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] { - const replace = this.db.transaction((providerName: string, items: AuthCredential[]) => { - this.deleteAuthByProviderStmt.run(providerName); + const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => { + this.#deleteAuthByProviderStmt.run(providerName); const inserted: StoredAuthCredential[] = []; for (const credential of items) { - const record = this.insertAuthCredential(providerName, credential); + const record = this.#insertAuthCredential(providerName, credential); if (record) inserted.push(record); } return inserted; @@ -483,7 +480,7 @@ CREATE TABLE settings ( return; } try { - this.updateAuthStmt.run(serialized.credentialType, serialized.data, id); + this.#updateAuthStmt.run(serialized.credentialType, serialized.data, id); } catch (error) { logger.warn("AgentStorage updateAuthCredential failed", { id, error: String(error) }); } @@ -495,7 +492,7 @@ CREATE TABLE settings ( */ deleteAuthCredential(id: number): void { try { - this.deleteAuthStmt.run(id); + this.#deleteAuthStmt.run(id); } catch (error) { logger.warn("AgentStorage deleteAuthCredential failed", { id, error: String(error) }); } @@ -507,7 +504,7 @@ CREATE TABLE settings ( */ deleteAuthCredentialsForProvider(provider: string): void { try { - this.deleteAuthByProviderStmt.run(provider); + this.#deleteAuthByProviderStmt.run(provider); } catch (error) { logger.warn("AgentStorage deleteAuthCredentialsForProvider failed", { provider, @@ -522,14 +519,14 @@ CREATE TABLE settings ( * @param credential - Credential to insert * @returns Stored credential with database ID, or null on failure */ - private insertAuthCredential(provider: string, credential: AuthCredential): StoredAuthCredential | null { + #insertAuthCredential(provider: string, credential: AuthCredential): StoredAuthCredential | null { const serialized = serializeCredential(credential); if (!serialized) { logger.warn("AgentStorage insertAuthCredential invalid type", { provider, type: credential.type }); return null; } try { - const row = this.insertAuthStmt.get(provider, serialized.credentialType, serialized.data) as + const row = this.#insertAuthStmt.get(provider, serialized.credentialType, serialized.data) as | { id?: number } | undefined; if (!row?.id) { @@ -547,7 +544,7 @@ CREATE TABLE settings ( * Ensures the parent directory for the database file exists. * @param dbPath - Path to the database file */ - private ensureDir(dbPath: string): void { + #ensureDir(dbPath: string): void { const dir = path.dirname(dbPath); try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); @@ -564,7 +561,7 @@ CREATE TABLE settings ( } } - private hardenPermissions(dbPath: string): void { + #hardenPermissions(dbPath: string): void { const dir = path.dirname(dbPath); try { fs.chmodSync(dir, 0o700); diff --git a/packages/coding-agent/src/session/auth-storage.ts b/packages/coding-agent/src/session/auth-storage.ts index 0d3eace28..6f4f9fb67 100644 --- a/packages/coding-agent/src/session/auth-storage.ts +++ b/packages/coding-agent/src/session/auth-storage.ts @@ -33,7 +33,6 @@ import { zaiUsageProvider, } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; -import { getAgentDbPath } from "../config"; import { resolveConfigValue } from "../config/resolve-config-value"; import { AgentStorage } from "./agent-storage"; @@ -141,33 +140,33 @@ class AuthStorageUsageCache implements UsageCache { * Reads from SQLite (agent.db). */ export class AuthStorage { - private static readonly defaultBackoffMs = 60_000; // Default backoff when no reset time available + static readonly #defaultBackoffMs = 60_000; // Default backoff when no reset time available /** Provider -> credentials cache, populated from agent.db on reload(). */ - private data: Map = new Map(); - private runtimeOverrides: Map = new Map(); + #data: Map = new Map(); + #runtimeOverrides: Map = new Map(); /** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */ - private providerRoundRobinIndex: Map = new Map(); + #providerRoundRobinIndex: Map = new Map(); /** Tracks the last used credential per provider for a session (used for rate-limit switching). */ - private sessionLastCredential: Map> = new Map(); + #sessionLastCredential: Map> = new Map(); /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */ - private credentialBackoff: Map> = new Map(); - private usageProviderResolver?: (provider: Provider) => UsageProvider | undefined; - private usageCache?: UsageCache; - private usageFetch: typeof fetch; - private usageNow: () => number; - private usageLogger?: UsageLogger; - private fallbackResolver?: (provider: string) => string | undefined; + #credentialBackoff: Map> = new Map(); + #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined; + #usageCache?: UsageCache; + #usageFetch: typeof fetch; + #usageNow: () => number; + #usageLogger?: UsageLogger; + #fallbackResolver?: (provider: string) => string | undefined; private constructor( private storage: AgentStorage, options: AuthStorageOptions = {}, ) { - this.usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider; - this.usageCache = options.usageCache ?? new AuthStorageUsageCache(this.storage); - this.usageFetch = options.usageFetch ?? fetch; - this.usageNow = options.usageNow ?? Date.now; - this.usageLogger = + this.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider; + this.#usageCache = options.usageCache ?? new AuthStorageUsageCache(this.storage); + this.#usageFetch = options.usageFetch ?? fetch; + this.#usageNow = options.usageNow ?? Date.now; + this.#usageLogger = options.usageLogger ?? ({ debug: (message, meta) => logger.debug(message, meta), @@ -184,89 +183,19 @@ export class AuthStorage { return new AuthStorage(storage, options); } - /** - * Create an in-memory AuthStorage instance from serialized data. - * Used by subagent workers to bypass discovery and use parent's credentials. - */ - static async fromSerialized(data: SerializedAuthStorage, options: AuthStorageOptions = {}): Promise { - const dbPath = data.dbPath ?? getAgentDbPath(); - const storage = await AgentStorage.open(dbPath); - - const instance = Object.create(AuthStorage.prototype) as AuthStorage; - instance.storage = storage; - instance.data = new Map(); - instance.runtimeOverrides = new Map(); - instance.providerRoundRobinIndex = new Map(); - instance.sessionLastCredential = new Map(); - instance.credentialBackoff = new Map(); - instance.usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider; - instance.usageCache = options.usageCache ?? new AuthStorageUsageCache(instance.storage); - instance.usageFetch = options.usageFetch ?? fetch; - instance.usageNow = options.usageNow ?? Date.now; - instance.usageLogger = - options.usageLogger ?? - ({ - debug: (message, meta) => logger.debug(message, meta), - warn: (message, meta) => logger.warn(message, meta), - } satisfies UsageLogger); - - for (const [provider, creds] of Object.entries(data.credentials)) { - instance.data.set( - provider, - creds.map(c => ({ - id: c.id, - credential: - c.type === "api_key" - ? ({ type: "api_key", key: c.data.key as string } satisfies ApiKeyCredential) - : ({ type: "oauth", ...c.data } as OAuthCredential), - })), - ); - } - if (data.runtimeOverrides) { - for (const [k, v] of Object.entries(data.runtimeOverrides)) { - instance.runtimeOverrides.set(k, v); - } - } - - return instance; - } - - /** - * Serialize AuthStorage for passing to subagent workers. - * Excludes runtime state (round-robin, backoff, usage cache). - */ - serialize(): SerializedAuthStorage { - const credentials: SerializedAuthStorage["credentials"] = {}; - for (const [provider, creds] of this.data.entries()) { - credentials[provider] = creds.map(c => ({ - id: c.id, - type: c.credential.type, - data: c.credential.type === "api_key" ? { key: c.credential.key } : { ...c.credential }, - })); - } - const runtimeOverrides: Record = {}; - for (const [k, v] of this.runtimeOverrides.entries()) { - runtimeOverrides[k] = v; - } - return { - credentials, - runtimeOverrides: Object.keys(runtimeOverrides).length > 0 ? runtimeOverrides : undefined, - }; - } - /** * Set a runtime API key override (not persisted to disk). * Used for CLI --api-key flag. */ setRuntimeApiKey(provider: string, apiKey: string): void { - this.runtimeOverrides.set(provider, apiKey); + this.#runtimeOverrides.set(provider, apiKey); } /** * Remove a runtime API key override. */ removeRuntimeApiKey(provider: string): void { - this.runtimeOverrides.delete(provider); + this.#runtimeOverrides.delete(provider); } /** @@ -274,7 +203,7 @@ export class AuthStorage { * Used for custom provider keys from models.json. */ setFallbackResolver(resolver: (provider: string) => string | undefined): void { - this.fallbackResolver = resolver; + this.#fallbackResolver = resolver; } /** @@ -292,12 +221,12 @@ export class AuthStorage { const dedupedGrouped = new Map(); for (const [provider, entries] of grouped.entries()) { - const deduped = this.pruneDuplicateStoredCredentials(provider, entries); + const deduped = this.#pruneDuplicateStoredCredentials(provider, entries); if (deduped.length > 0) { dedupedGrouped.set(provider, deduped); } } - this.data = dedupedGrouped; + this.#data = dedupedGrouped; } /** @@ -305,8 +234,8 @@ export class AuthStorage { * @param provider - Provider name (e.g., "anthropic", "openai") * @returns Array of stored credentials, empty if none exist */ - private getStoredCredentials(provider: string): StoredCredential[] { - return this.data.get(provider) ?? []; + #getStoredCredentials(provider: string): StoredCredential[] { + return this.#data.get(provider) ?? []; } /** @@ -315,34 +244,34 @@ export class AuthStorage { * @param provider - Provider name (e.g., "anthropic", "openai") * @param credentials - Array of stored credentials to cache */ - private setStoredCredentials(provider: string, credentials: StoredCredential[]): void { + #setStoredCredentials(provider: string, credentials: StoredCredential[]): void { if (credentials.length === 0) { - this.data.delete(provider); + this.#data.delete(provider); } else { - this.data.set(provider, credentials); + this.#data.set(provider, credentials); } } - private getOAuthIdentifiers(credential: OAuthCredential): string[] { + #getOAuthIdentifiers(credential: OAuthCredential): string[] { const identifiers: string[] = []; const accountId = credential.accountId?.trim(); if (accountId) identifiers.push(`account:${accountId}`); const email = credential.email?.trim().toLowerCase(); if (email) identifiers.push(`email:${email}`); if (identifiers.length > 0) return identifiers; - const tokenIdentifiers = this.getOAuthIdentifiersFromToken(credential.access) ?? []; + const tokenIdentifiers = this.#getOAuthIdentifiersFromToken(credential.access) ?? []; for (const identifier of tokenIdentifiers) { identifiers.push(identifier); } if (identifiers.length > 0) return identifiers; - const refreshIdentifiers = this.getOAuthIdentifiersFromToken(credential.refresh) ?? []; + const refreshIdentifiers = this.#getOAuthIdentifiersFromToken(credential.refresh) ?? []; for (const identifier of refreshIdentifiers) { identifiers.push(identifier); } return identifiers; } - private getOAuthIdentifiersFromToken(token: string | undefined): string[] | undefined { + #getOAuthIdentifiersFromToken(token: string | undefined): string[] | undefined { if (!token) return undefined; const parts = token.split("."); if (parts.length !== 3) return undefined; @@ -374,7 +303,7 @@ export class AuthStorage { } } - private dedupeOAuthCredentials(credentials: AuthCredential[]): AuthCredential[] { + #dedupeOAuthCredentials(credentials: AuthCredential[]): AuthCredential[] { const seen = new Set(); const deduped: AuthCredential[] = []; for (let index = credentials.length - 1; index >= 0; index -= 1) { @@ -383,7 +312,7 @@ export class AuthStorage { deduped.push(credential); continue; } - const identifiers = this.getOAuthIdentifiers(credential); + const identifiers = this.#getOAuthIdentifiers(credential); if (identifiers.length === 0) { deduped.push(credential); continue; @@ -399,7 +328,7 @@ export class AuthStorage { return deduped.reverse(); } - private pruneDuplicateStoredCredentials(provider: string, entries: StoredCredential[]): StoredCredential[] { + #pruneDuplicateStoredCredentials(provider: string, entries: StoredCredential[]): StoredCredential[] { const seen = new Set(); const kept: StoredCredential[] = []; const removed: StoredCredential[] = []; @@ -410,7 +339,7 @@ export class AuthStorage { kept.push(entry); continue; } - const identifiers = this.getOAuthIdentifiers(credential); + const identifiers = this.#getOAuthIdentifiers(credential); if (identifiers.length === 0) { kept.push(entry); continue; @@ -428,18 +357,18 @@ export class AuthStorage { for (const entry of removed) { this.storage.deleteAuthCredential(entry.id); } - this.resetProviderAssignments(provider); + this.#resetProviderAssignments(provider); } return kept.reverse(); } /** Returns all credentials for a provider as an array */ - private getCredentialsForProvider(provider: string): AuthCredential[] { - return this.getStoredCredentials(provider).map(entry => entry.credential); + #getCredentialsForProvider(provider: string): AuthCredential[] { + return this.#getStoredCredentials(provider).map(entry => entry.credential); } /** Composite key for round-robin tracking: "anthropic:oauth" or "openai:api_key" */ - private getProviderTypeKey(provider: string, type: AuthCredential["type"]): string { + #getProviderTypeKey(provider: string, type: AuthCredential["type"]): string { return `${provider}:${type}`; } @@ -447,11 +376,11 @@ export class AuthStorage { * Returns next index in round-robin sequence for load distribution. * Increments stored counter and wraps at total. */ - private getNextRoundRobinIndex(providerKey: string, total: number): number { + #getNextRoundRobinIndex(providerKey: string, total: number): number { if (total <= 1) return 0; - const current = this.providerRoundRobinIndex.get(providerKey) ?? -1; + const current = this.#providerRoundRobinIndex.get(providerKey) ?? -1; const next = (current + 1) % total; - this.providerRoundRobinIndex.set(providerKey, next); + this.#providerRoundRobinIndex.set(providerKey, next); return next; } @@ -459,7 +388,7 @@ export class AuthStorage { * FNV-1a hash for deterministic session-to-credential mapping. * Ensures the same session always starts with the same credential. */ - private getHashedIndex(sessionId: string, total: number): number { + #getHashedIndex(sessionId: string, total: number): number { if (total <= 1) return 0; let hash = 2166136261; // FNV offset basis for (let i = 0; i < sessionId.length; i++) { @@ -475,9 +404,11 @@ export class AuthStorage { * Without sessionId: starts from round-robin index (load balancing). * Order wraps around so all credentials are tried if earlier ones are blocked. */ - private getCredentialOrder(providerKey: string, sessionId: string | undefined, total: number): number[] { + #getCredentialOrder(providerKey: string, sessionId: string | undefined, total: number): number[] { if (total <= 1) return [0]; - const start = sessionId ? this.getHashedIndex(sessionId, total) : this.getNextRoundRobinIndex(providerKey, total); + const start = sessionId + ? this.#getHashedIndex(sessionId, total) + : this.#getNextRoundRobinIndex(providerKey, total); const order: number[] = []; for (let i = 0; i < total; i++) { order.push((start + i) % total); @@ -486,15 +417,15 @@ export class AuthStorage { } /** Checks if a credential is temporarily blocked due to usage limits. */ - private isCredentialBlocked(providerKey: string, credentialIndex: number): boolean { - const backoffMap = this.credentialBackoff.get(providerKey); + #isCredentialBlocked(providerKey: string, credentialIndex: number): boolean { + const backoffMap = this.#credentialBackoff.get(providerKey); if (!backoffMap) return false; const blockedUntil = backoffMap.get(credentialIndex); if (!blockedUntil) return false; if (blockedUntil <= Date.now()) { backoffMap.delete(credentialIndex); if (backoffMap.size === 0) { - this.credentialBackoff.delete(providerKey); + this.#credentialBackoff.delete(providerKey); } return false; } @@ -502,33 +433,33 @@ export class AuthStorage { } /** Marks a credential as blocked until the specified time. */ - private markCredentialBlocked(providerKey: string, credentialIndex: number, blockedUntilMs: number): void { - const backoffMap = this.credentialBackoff.get(providerKey) ?? new Map(); + #markCredentialBlocked(providerKey: string, credentialIndex: number, blockedUntilMs: number): void { + const backoffMap = this.#credentialBackoff.get(providerKey) ?? new Map(); const existing = backoffMap.get(credentialIndex) ?? 0; backoffMap.set(credentialIndex, Math.max(existing, blockedUntilMs)); - this.credentialBackoff.set(providerKey, backoffMap); + this.#credentialBackoff.set(providerKey, backoffMap); } /** Records which credential was used for a session (for rate-limit switching). */ - private recordSessionCredential( + #recordSessionCredential( provider: string, sessionId: string | undefined, type: AuthCredential["type"], index: number, ): void { if (!sessionId) return; - const sessionMap = this.sessionLastCredential.get(provider) ?? new Map(); + const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map(); sessionMap.set(sessionId, { type, index }); - this.sessionLastCredential.set(provider, sessionMap); + this.#sessionLastCredential.set(provider, sessionMap); } /** Retrieves the last credential used by a session. */ - private getSessionCredential( + #getSessionCredential( provider: string, sessionId: string | undefined, ): { type: AuthCredential["type"]; index: number } | undefined { if (!sessionId) return undefined; - return this.sessionLastCredential.get(provider)?.get(sessionId); + return this.#sessionLastCredential.get(provider)?.get(sessionId); } /** @@ -536,12 +467,12 @@ export class AuthStorage { * Returns both the credential and its index in the original array (for updates/removal). * Uses deterministic hashing for session stickiness and skips blocked credentials when possible. */ - private selectCredentialByType( + #selectCredentialByType( provider: string, type: T, sessionId?: string, ): { credential: Extract; index: number } | undefined { - const credentials = this.getCredentialsForProvider(provider) + const credentials = this.#getCredentialsForProvider(provider) .map((credential, index) => ({ credential, index })) .filter( (entry): entry is { credential: Extract; index: number } => @@ -551,13 +482,13 @@ export class AuthStorage { if (credentials.length === 0) return undefined; if (credentials.length === 1) return credentials[0]; - const providerKey = this.getProviderTypeKey(provider, type); - const order = this.getCredentialOrder(providerKey, sessionId, credentials.length); + const providerKey = this.#getProviderTypeKey(provider, type); + const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length); const fallback = credentials[order[0]]; for (const idx of order) { const candidate = credentials[idx]; - if (!this.isCredentialBlocked(providerKey, candidate.index)) { + if (!this.#isCredentialBlocked(providerKey, candidate.index)) { return candidate; } } @@ -569,49 +500,49 @@ export class AuthStorage { * Clears round-robin and session assignment state for a provider. * Called when credentials are added/removed to prevent stale index references. */ - private resetProviderAssignments(provider: string): void { - for (const key of this.providerRoundRobinIndex.keys()) { + #resetProviderAssignments(provider: string): void { + for (const key of this.#providerRoundRobinIndex.keys()) { if (key.startsWith(`${provider}:`)) { - this.providerRoundRobinIndex.delete(key); + this.#providerRoundRobinIndex.delete(key); } } - this.sessionLastCredential.delete(provider); - for (const key of this.credentialBackoff.keys()) { + this.#sessionLastCredential.delete(provider); + for (const key of this.#credentialBackoff.keys()) { if (key.startsWith(`${provider}:`)) { - this.credentialBackoff.delete(key); + this.#credentialBackoff.delete(key); } } } /** Updates credential at index in-place (used for OAuth token refresh) */ - private replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void { - const entries = this.getStoredCredentials(provider); + #replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void { + const entries = this.#getStoredCredentials(provider); if (index < 0 || index >= entries.length) return; const target = entries[index]; this.storage.updateAuthCredential(target.id, credential); const updated = [...entries]; updated[index] = { id: target.id, credential }; - this.setStoredCredentials(provider, updated); + this.#setStoredCredentials(provider, updated); } /** * Removes credential at index (used when OAuth refresh fails). * Cleans up provider entry if last credential removed. */ - private removeCredentialAt(provider: string, index: number): void { - const entries = this.getStoredCredentials(provider); + #removeCredentialAt(provider: string, index: number): void { + const entries = this.#getStoredCredentials(provider); if (index < 0 || index >= entries.length) return; this.storage.deleteAuthCredential(entries[index].id); const updated = entries.filter((_value, idx) => idx !== index); - this.setStoredCredentials(provider, updated); - this.resetProviderAssignments(provider); + this.#setStoredCredentials(provider, updated); + this.#resetProviderAssignments(provider); } /** * Get credential for a provider (first entry if multiple). */ get(provider: string): AuthCredential | undefined { - return this.getCredentialsForProvider(provider)[0]; + return this.#getCredentialsForProvider(provider)[0]; } /** @@ -619,13 +550,13 @@ export class AuthStorage { */ async set(provider: string, credential: AuthCredentialEntry): Promise { const normalized = Array.isArray(credential) ? credential : [credential]; - const deduped = this.dedupeOAuthCredentials(normalized); + const deduped = this.#dedupeOAuthCredentials(normalized); const stored = this.storage.replaceAuthCredentialsForProvider(provider, deduped); - this.setStoredCredentials( + this.#setStoredCredentials( provider, stored.map(record => ({ id: record.id, credential: record.credential })), ); - this.resetProviderAssignments(provider); + this.#resetProviderAssignments(provider); } /** @@ -633,22 +564,22 @@ export class AuthStorage { */ async remove(provider: string): Promise { this.storage.deleteAuthCredentialsForProvider(provider); - this.data.delete(provider); - this.resetProviderAssignments(provider); + this.#data.delete(provider); + this.#resetProviderAssignments(provider); } /** * List all providers with credentials. */ list(): string[] { - return [...this.data.keys()]; + return [...this.#data.keys()]; } /** * Check if credentials exist for a provider in agent.db. */ has(provider: string): boolean { - return this.getCredentialsForProvider(provider).length > 0; + return this.#getCredentialsForProvider(provider).length > 0; } /** @@ -656,10 +587,10 @@ export class AuthStorage { * Unlike getApiKey(), this doesn't refresh OAuth tokens. */ hasAuth(provider: string): boolean { - if (this.runtimeOverrides.has(provider)) return true; - if (this.getCredentialsForProvider(provider).length > 0) return true; + if (this.#runtimeOverrides.has(provider)) return true; + if (this.#getCredentialsForProvider(provider).length > 0) return true; if (getEnvApiKey(provider)) return true; - if (this.fallbackResolver?.(provider)) return true; + if (this.#fallbackResolver?.(provider)) return true; return false; } @@ -667,14 +598,14 @@ export class AuthStorage { * Check if OAuth credentials are configured for a provider. */ hasOAuth(provider: string): boolean { - return this.getCredentialsForProvider(provider).some(credential => credential.type === "oauth"); + return this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth"); } /** * Get OAuth credentials for a provider. */ getOAuthCredential(provider: string): OAuthCredential | undefined { - return this.getCredentialsForProvider(provider).find( + return this.#getCredentialsForProvider(provider).find( (credential): credential is OAuthCredential => credential.type === "oauth", ); } @@ -684,7 +615,7 @@ export class AuthStorage { */ getAll(): AuthStorageData { const result: AuthStorageData = {}; - for (const [provider, entries] of this.data.entries()) { + for (const [provider, entries] of this.#data.entries()) { const credentials = entries.map(entry => entry.credential); if (credentials.length === 1) { result[provider] = credentials[0]; @@ -753,7 +684,7 @@ export class AuthStorage { } const newCredential: OAuthCredential = { type: "oauth", ...credentials }; - const existing = this.getCredentialsForProvider(provider); + const existing = this.#getCredentialsForProvider(provider); if (existing.length === 0) { await this.set(provider, newCredential); return; @@ -774,7 +705,7 @@ export class AuthStorage { // Queries provider usage endpoints to detect rate limits before they occur. // ───────────────────────────────────────────────────────────────────────────── - private buildUsageCredential(credential: OAuthCredential): UsageCredential { + #buildUsageCredential(credential: OAuthCredential): UsageCredential { return { type: "oauth", accessToken: credential.access, @@ -787,14 +718,14 @@ export class AuthStorage { }; } - private getUsageReportMetadataValue(report: UsageReport, key: string): string | undefined { + #getUsageReportMetadataValue(report: UsageReport, key: string): string | undefined { const metadata = report.metadata; if (!metadata || typeof metadata !== "object") return undefined; const value = metadata[key]; return typeof value === "string" ? value.trim() : undefined; } - private getUsageReportScopeAccountId(report: UsageReport): string | undefined { + #getUsageReportScopeAccountId(report: UsageReport): string | undefined { const ids = new Set(); for (const limit of report.limits) { const accountId = limit.scope.accountId?.trim(); @@ -804,24 +735,24 @@ export class AuthStorage { return undefined; } - private getUsageReportIdentifiers(report: UsageReport): string[] { + #getUsageReportIdentifiers(report: UsageReport): string[] { const identifiers: string[] = []; - const email = this.getUsageReportMetadataValue(report, "email"); + const email = this.#getUsageReportMetadataValue(report, "email"); if (email) identifiers.push(`email:${email.toLowerCase()}`); - const accountId = this.getUsageReportMetadataValue(report, "accountId"); + const accountId = this.#getUsageReportMetadataValue(report, "accountId"); if (accountId) identifiers.push(`account:${accountId}`); - const account = this.getUsageReportMetadataValue(report, "account"); + const account = this.#getUsageReportMetadataValue(report, "account"); if (account) identifiers.push(`account:${account}`); - const user = this.getUsageReportMetadataValue(report, "user"); + const user = this.#getUsageReportMetadataValue(report, "user"); if (user) identifiers.push(`account:${user}`); - const username = this.getUsageReportMetadataValue(report, "username"); + const username = this.#getUsageReportMetadataValue(report, "username"); if (username) identifiers.push(`account:${username}`); - const scopeAccountId = this.getUsageReportScopeAccountId(report); + const scopeAccountId = this.#getUsageReportScopeAccountId(report); if (scopeAccountId) identifiers.push(`account:${scopeAccountId}`); return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`); } - private mergeUsageReportGroup(reports: UsageReport[]): UsageReport { + #mergeUsageReportGroup(reports: UsageReport[]): UsageReport { if (reports.length === 1) return reports[0]; const sorted = [...reports].sort((a, b) => { const limitDiff = b.limits.length - a.limits.length; @@ -859,12 +790,12 @@ export class AuthStorage { }; } - private dedupeUsageReports(reports: UsageReport[]): UsageReport[] { + #dedupeUsageReports(reports: UsageReport[]): UsageReport[] { const groups: UsageReport[][] = []; const idToGroup = new Map(); for (const report of reports) { - const identifiers = this.getUsageReportIdentifiers(report); + const identifiers = this.#getUsageReportIdentifiers(report); let groupIndex: number | undefined; for (const identifier of identifiers) { const existing = idToGroup.get(identifier); @@ -883,9 +814,9 @@ export class AuthStorage { } } - const deduped = groups.map(group => this.mergeUsageReportGroup(group)); + const deduped = groups.map(group => this.#mergeUsageReportGroup(group)); if (deduped.length !== reports.length) { - this.usageLogger?.debug("Usage reports deduped", { + this.#usageLogger?.debug("Usage reports deduped", { before: reports.length, after: deduped.length, }); @@ -893,7 +824,7 @@ export class AuthStorage { return deduped; } - private isUsageLimitExhausted(limit: UsageLimit): boolean { + #isUsageLimitExhausted(limit: UsageLimit): boolean { if (limit.status === "exhausted") return true; const amount = limit.amount; if (amount.usedFraction !== undefined && amount.usedFraction >= 1) return true; @@ -905,15 +836,15 @@ export class AuthStorage { } /** Returns true if usage indicates rate limit has been reached. */ - private isUsageLimitReached(report: UsageReport): boolean { - return report.limits.some(limit => this.isUsageLimitExhausted(limit)); + #isUsageLimitReached(report: UsageReport): boolean { + return report.limits.some(limit => this.#isUsageLimitExhausted(limit)); } /** Extracts the earliest reset timestamp from exhausted windows (in ms). */ - private getUsageResetAtMs(report: UsageReport, nowMs: number): number | undefined { + #getUsageResetAtMs(report: UsageReport, nowMs: number): number | undefined { const candidates: number[] = []; for (const limit of report.limits) { - if (!this.isUsageLimitExhausted(limit)) continue; + if (!this.#isUsageLimitExhausted(limit)) continue; const window = limit.window; if (window?.resetsAt && window.resetsAt > nowMs) { candidates.push(window.resetsAt); @@ -927,13 +858,13 @@ export class AuthStorage { return Math.min(...candidates); } - private async getUsageReport( + async #getUsageReport( provider: Provider, credential: OAuthCredential, options?: { baseUrl?: string }, ): Promise { - const resolver = this.usageProviderResolver; - const cache = this.usageCache; + const resolver = this.#usageProviderResolver; + const cache = this.#usageCache; if (!resolver || !cache) return null; const providerImpl = resolver(provider); @@ -941,7 +872,7 @@ export class AuthStorage { const params = { provider, - credential: this.buildUsageCredential(credential), + credential: this.#buildUsageCredential(credential), baseUrl: options?.baseUrl, }; @@ -950,9 +881,9 @@ export class AuthStorage { try { return await providerImpl.fetchUsage(params, { cache, - fetch: this.usageFetch, - now: this.usageNow, - logger: this.usageLogger, + fetch: this.#usageFetch, + now: this.#usageNow, + logger: this.#usageLogger, }); } catch (error) { logger.debug("AuthStorage usage fetch failed", { @@ -966,30 +897,33 @@ export class AuthStorage { async fetchUsageReports(options?: { baseUrlResolver?: (provider: Provider) => string | undefined; }): Promise { - const resolver = this.usageProviderResolver; - const cache = this.usageCache; + const resolver = this.#usageProviderResolver; + const cache = this.#usageCache; if (!resolver || !cache) return null; const tasks: Array> = []; - const providers = new Set([...this.data.keys(), ...DEFAULT_USAGE_PROVIDERS.map(provider => provider.id)]); - this.usageLogger?.debug("Usage fetch requested", { + const providers = new Set([ + ...this.#data.keys(), + ...DEFAULT_USAGE_PROVIDERS.map(provider => provider.id), + ]); + this.#usageLogger?.debug("Usage fetch requested", { providers: Array.from(providers).sort(), }); for (const provider of providers) { const providerImpl = resolver(provider as Provider); if (!providerImpl) continue; const baseUrl = options?.baseUrlResolver?.(provider as Provider); - let entries = this.getStoredCredentials(provider); + let entries = this.#getStoredCredentials(provider); if (entries.length > 0) { - const dedupedEntries = this.pruneDuplicateStoredCredentials(provider, entries); + const dedupedEntries = this.#pruneDuplicateStoredCredentials(provider, entries); if (dedupedEntries.length !== entries.length) { - this.setStoredCredentials(provider, dedupedEntries); + this.#setStoredCredentials(provider, dedupedEntries); } entries = dedupedEntries; } if (entries.length === 0) { - const runtimeKey = this.runtimeOverrides.get(provider); + const runtimeKey = this.#runtimeOverrides.get(provider); const envKey = getEnvApiKey(provider); const apiKey = runtimeKey ?? envKey; if (!apiKey) { @@ -1003,7 +937,7 @@ export class AuthStorage { if (providerImpl.supports && !providerImpl.supports(params)) { continue; } - this.usageLogger?.debug("Usage fetch queued", { + this.#usageLogger?.debug("Usage fetch queued", { provider, credentialType: "api_key", baseUrl, @@ -1012,9 +946,9 @@ export class AuthStorage { providerImpl .fetchUsage(params, { cache, - fetch: this.usageFetch, - now: this.usageNow, - logger: this.usageLogger, + fetch: this.#usageFetch, + now: this.#usageNow, + logger: this.#usageLogger, }) .catch(error => { logger.debug("AuthStorage usage fetch failed", { @@ -1032,7 +966,7 @@ export class AuthStorage { const usageCredential: UsageCredential = credential.type === "api_key" ? { type: "api_key", apiKey: credential.key } - : this.buildUsageCredential(credential); + : this.#buildUsageCredential(credential); const params = { provider: provider as Provider, credential: usageCredential, @@ -1043,7 +977,7 @@ export class AuthStorage { continue; } - this.usageLogger?.debug("Usage fetch queued", { + this.#usageLogger?.debug("Usage fetch queued", { provider, credentialType: usageCredential.type, baseUrl, @@ -1055,9 +989,9 @@ export class AuthStorage { providerImpl .fetchUsage(params, { cache, - fetch: this.usageFetch, - now: this.usageNow, - logger: this.usageLogger, + fetch: this.#usageFetch, + now: this.#usageNow, + logger: this.#usageLogger, }) .catch(error => { logger.debug("AuthStorage usage fetch failed", { @@ -1073,16 +1007,16 @@ export class AuthStorage { if (tasks.length === 0) return []; const results = await Promise.all(tasks); const reports = results.filter((report): report is UsageReport => report !== null); - const deduped = this.dedupeUsageReports(reports); - this.usageLogger?.debug("Usage fetch resolved", { + const deduped = this.#dedupeUsageReports(reports); + this.#usageLogger?.debug("Usage fetch resolved", { reports: deduped.map(report => { const accountLabel = - this.getUsageReportMetadataValue(report, "email") ?? - this.getUsageReportMetadataValue(report, "accountId") ?? - this.getUsageReportMetadataValue(report, "account") ?? - this.getUsageReportMetadataValue(report, "user") ?? - this.getUsageReportMetadataValue(report, "username") ?? - this.getUsageReportScopeAccountId(report); + this.#getUsageReportMetadataValue(report, "email") ?? + this.#getUsageReportMetadataValue(report, "accountId") ?? + this.#getUsageReportMetadataValue(report, "account") ?? + this.#getUsageReportMetadataValue(report, "user") ?? + this.#getUsageReportMetadataValue(report, "username") ?? + this.#getUsageReportScopeAccountId(report); return { provider: report.provider, limits: report.limits.length, @@ -1103,19 +1037,19 @@ export class AuthStorage { sessionId: string | undefined, options?: { retryAfterMs?: number; baseUrl?: string }, ): Promise { - const sessionCredential = this.getSessionCredential(provider, sessionId); + const sessionCredential = this.#getSessionCredential(provider, sessionId); if (!sessionCredential) return false; - const providerKey = this.getProviderTypeKey(provider, sessionCredential.type); - const now = this.usageNow(); - let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.defaultBackoffMs); + const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type); + const now = this.#usageNow(); + let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs); if (provider === "openai-codex" && sessionCredential.type === "oauth") { - const credential = this.getCredentialsForProvider(provider)[sessionCredential.index]; + const credential = this.#getCredentialsForProvider(provider)[sessionCredential.index]; if (credential?.type === "oauth") { - const report = await this.getUsageReport(provider, credential, options); - if (report && this.isUsageLimitReached(report)) { - const resetAtMs = this.getUsageResetAtMs(report, this.usageNow()); + const report = await this.#getUsageReport(provider, credential, options); + if (report && this.#isUsageLimitReached(report)) { + const resetAtMs = this.#getUsageResetAtMs(report, this.#usageNow()); if (resetAtMs && resetAtMs > blockedUntil) { blockedUntil = resetAtMs; } @@ -1123,16 +1057,16 @@ export class AuthStorage { } } - this.markCredentialBlocked(providerKey, sessionCredential.index, blockedUntil); + this.#markCredentialBlocked(providerKey, sessionCredential.index, blockedUntil); - const remainingCredentials = this.getCredentialsForProvider(provider) + const remainingCredentials = this.#getCredentialsForProvider(provider) .map((credential, index) => ({ credential, index })) .filter( (entry): entry is { credential: AuthCredential; index: number } => entry.credential.type === sessionCredential.type && entry.index !== sessionCredential.index, ); - return remainingCredentials.some(candidate => !this.isCredentialBlocked(providerKey, candidate.index)); + return remainingCredentials.some(candidate => !this.#isCredentialBlocked(providerKey, candidate.index)); } /** @@ -1140,25 +1074,25 @@ export class AuthStorage { * Skips blocked credentials and checks usage limits for providers with usage data. * Falls back to earliest-unblocking credential if all are blocked. */ - private async resolveOAuthApiKey( + async #resolveOAuthApiKey( provider: string, sessionId?: string, options?: { baseUrl?: string }, ): Promise { - const credentials = this.getCredentialsForProvider(provider) + const credentials = this.#getCredentialsForProvider(provider) .map((credential, index) => ({ credential, index })) .filter((entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth"); if (credentials.length === 0) return undefined; - const providerKey = this.getProviderTypeKey(provider, "oauth"); - const order = this.getCredentialOrder(providerKey, sessionId, credentials.length); + const providerKey = this.#getProviderTypeKey(provider, "oauth"); + const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length); const fallback = credentials[order[0]]; const checkUsage = provider === "openai-codex" && credentials.length > 1; for (const idx of order) { const selection = credentials[idx]; - const apiKey = await this.tryOAuthCredential( + const apiKey = await this.#tryOAuthCredential( provider, selection, providerKey, @@ -1170,15 +1104,15 @@ export class AuthStorage { if (apiKey) return apiKey; } - if (fallback && this.isCredentialBlocked(providerKey, fallback.index)) { - return this.tryOAuthCredential(provider, fallback, providerKey, sessionId, options, checkUsage, true); + if (fallback && this.#isCredentialBlocked(providerKey, fallback.index)) { + return this.#tryOAuthCredential(provider, fallback, providerKey, sessionId, options, checkUsage, true); } return undefined; } /** Attempts to use a single OAuth credential, checking usage and refreshing token. */ - private async tryOAuthCredential( + async #tryOAuthCredential( provider: string, selection: { credential: OAuthCredential; index: number }, providerKey: string, @@ -1187,7 +1121,7 @@ export class AuthStorage { checkUsage: boolean, allowBlocked: boolean, ): Promise { - if (!allowBlocked && this.isCredentialBlocked(providerKey, selection.index)) { + if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) { return undefined; } @@ -1195,14 +1129,14 @@ export class AuthStorage { let usageChecked = false; if (checkUsage) { - usage = await this.getUsageReport(provider, selection.credential, options); + usage = await this.#getUsageReport(provider, selection.credential, options); usageChecked = true; - if (usage && this.isUsageLimitReached(usage)) { - const resetAtMs = this.getUsageResetAtMs(usage, this.usageNow()); - this.markCredentialBlocked( + if (usage && this.#isUsageLimitReached(usage)) { + const resetAtMs = this.#getUsageResetAtMs(usage, this.#usageNow()); + this.#markCredentialBlocked( providerKey, selection.index, - resetAtMs ?? this.usageNow() + AuthStorage.defaultBackoffMs, + resetAtMs ?? this.#usageNow() + AuthStorage.#defaultBackoffMs, ); return undefined; } @@ -1226,25 +1160,25 @@ export class AuthStorage { projectId: result.newCredentials.projectId ?? selection.credential.projectId, enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl, }; - this.replaceCredentialAt(provider, selection.index, updated); + this.#replaceCredentialAt(provider, selection.index, updated); if (checkUsage) { const sameAccount = selection.credential.accountId === updated.accountId; if (!usageChecked || !sameAccount) { - usage = await this.getUsageReport(provider, updated, options); + usage = await this.#getUsageReport(provider, updated, options); } - if (usage && this.isUsageLimitReached(usage)) { - const resetAtMs = this.getUsageResetAtMs(usage, this.usageNow()); - this.markCredentialBlocked( + if (usage && this.#isUsageLimitReached(usage)) { + const resetAtMs = this.#getUsageResetAtMs(usage, this.#usageNow()); + this.#markCredentialBlocked( providerKey, selection.index, - resetAtMs ?? this.usageNow() + AuthStorage.defaultBackoffMs, + resetAtMs ?? this.#usageNow() + AuthStorage.#defaultBackoffMs, ); return undefined; } } - this.recordSessionCredential(provider, sessionId, "oauth", selection.index); + this.#recordSessionCredential(provider, sessionId, "oauth", selection.index); return result.apiKey; } catch (error) { const errorMsg = String(error); @@ -1263,13 +1197,13 @@ export class AuthStorage { if (isDefinitiveFailure) { // Permanently remove invalid credentials - this.removeCredentialAt(provider, selection.index); - if (this.getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) { + this.#removeCredentialAt(provider, selection.index); + if (this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) { return this.getApiKey(provider, sessionId, options); } } else { // Block temporarily for transient failures (5 minutes) - this.markCredentialBlocked(providerKey, selection.index, this.usageNow() + 5 * 60 * 1000); + this.#markCredentialBlocked(providerKey, selection.index, this.#usageNow() + 5 * 60 * 1000); } } @@ -1287,18 +1221,18 @@ export class AuthStorage { */ async getApiKey(provider: string, sessionId?: string, options?: { baseUrl?: string }): Promise { // Runtime override takes highest priority - const runtimeKey = this.runtimeOverrides.get(provider); + const runtimeKey = this.#runtimeOverrides.get(provider); if (runtimeKey) { return runtimeKey; } - const apiKeySelection = this.selectCredentialByType(provider, "api_key", sessionId); + const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId); if (apiKeySelection) { - this.recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index); + this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index); return resolveConfigValue(apiKeySelection.credential.key); } - const oauthKey = await this.resolveOAuthApiKey(provider, sessionId, options); + const oauthKey = await this.#resolveOAuthApiKey(provider, sessionId, options); if (oauthKey) { return oauthKey; } @@ -1308,6 +1242,6 @@ export class AuthStorage { if (envKey) return envKey; // Fall back to custom resolver (e.g., models.json custom providers) - return this.fallbackResolver?.(provider) ?? undefined; + return this.#fallbackResolver?.(provider) ?? undefined; } } diff --git a/packages/coding-agent/src/session/history-storage.ts b/packages/coding-agent/src/session/history-storage.ts index f0f98985a..161d4fd87 100644 --- a/packages/coding-agent/src/session/history-storage.ts +++ b/packages/coding-agent/src/session/history-storage.ts @@ -1,4 +1,4 @@ -import { Database } from "bun:sqlite"; +import { Database, type Statement } from "bun:sqlite"; import * as fs from "node:fs"; import * as path from "node:path"; import { logger } from "@oh-my-pi/pi-utils"; @@ -11,8 +11,6 @@ export interface HistoryEntry { cwd?: string; } -type Statement = ReturnType; - type HistoryRow = { id: number; prompt: string; @@ -21,26 +19,26 @@ type HistoryRow = { }; export class HistoryStorage { - private db: Database; - private static instance?: HistoryStorage; + #db: Database; + static #instance?: HistoryStorage; // Prepared statements - private insertStmt: Statement; - private recentStmt: Statement; - private searchStmt: Statement; - private lastPromptStmt: Statement; + #insertStmt: Statement; + #recentStmt: Statement; + #searchStmt: Statement; + #lastPromptStmt: Statement; // In-memory cache of last prompt to avoid sync DB reads on add - private lastPromptCache: string | null = null; + #lastPromptCache: string | null = null; private constructor(dbPath: string) { - this.ensureDir(dbPath); + this.#ensureDir(dbPath); - this.db = new Database(dbPath); + this.#db = new Database(dbPath); - const hasFts = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_fts'").get(); + const hasFts = this.#db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_fts'").get(); - this.db.exec(` + this.#db.exec(` PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; @@ -62,42 +60,42 @@ END; if (!hasFts) { try { - this.db.run("INSERT INTO history_fts(history_fts) VALUES('rebuild')"); + this.#db.run("INSERT INTO history_fts(history_fts) VALUES('rebuild')"); } catch (error) { logger.warn("HistoryStorage FTS rebuild failed", { error: String(error) }); } } - this.insertStmt = this.db.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)"); - this.recentStmt = this.db.prepare( + this.#insertStmt = this.#db.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)"); + this.#recentStmt = this.#db.prepare( "SELECT id, prompt, created_at, cwd FROM history ORDER BY created_at DESC, id DESC LIMIT ?", ); - this.searchStmt = this.db.prepare( + this.#searchStmt = this.#db.prepare( "SELECT h.id, h.prompt, h.created_at, h.cwd FROM history_fts f JOIN history h ON h.id = f.rowid WHERE history_fts MATCH ? ORDER BY h.created_at DESC, h.id DESC LIMIT ?", ); - this.lastPromptStmt = this.db.prepare("SELECT prompt FROM history ORDER BY id DESC LIMIT 1"); + this.#lastPromptStmt = this.#db.prepare("SELECT prompt FROM history ORDER BY id DESC LIMIT 1"); - const last = this.lastPromptStmt.get() as { prompt?: string } | undefined; - this.lastPromptCache = last?.prompt ?? null; + const last = this.#lastPromptStmt.get() as { prompt?: string } | undefined; + this.#lastPromptCache = last?.prompt ?? null; } static open(dbPath: string = path.join(getAgentDir(), "history.db")): HistoryStorage { - if (!HistoryStorage.instance) { - HistoryStorage.instance = new HistoryStorage(dbPath); + if (!HistoryStorage.#instance) { + HistoryStorage.#instance = new HistoryStorage(dbPath); } - return HistoryStorage.instance; + return HistoryStorage.#instance; } add(prompt: string, cwd?: string): void { const trimmed = prompt.trim(); if (!trimmed) return; - if (this.lastPromptCache === trimmed) return; + if (this.#lastPromptCache === trimmed) return; - this.lastPromptCache = trimmed; + this.#lastPromptCache = trimmed; setImmediate(() => { try { - this.insertStmt.run(trimmed, cwd ?? null); + this.#insertStmt.run(trimmed, cwd ?? null); } catch (error) { logger.error("HistoryStorage add failed", { error: String(error) }); } @@ -105,12 +103,12 @@ END; } getRecent(limit: number): HistoryEntry[] { - const safeLimit = this.normalizeLimit(limit); + const safeLimit = this.#normalizeLimit(limit); if (safeLimit === 0) return []; try { - const rows = this.recentStmt.all(safeLimit) as HistoryRow[]; - return rows.map(row => this.toEntry(row)); + const rows = this.#recentStmt.all(safeLimit) as HistoryRow[]; + return rows.map(row => this.#toEntry(row)); } catch (error) { logger.error("HistoryStorage getRecent failed", { error: String(error) }); return []; @@ -118,33 +116,33 @@ END; } search(query: string, limit: number): HistoryEntry[] { - const safeLimit = this.normalizeLimit(limit); + const safeLimit = this.#normalizeLimit(limit); if (safeLimit === 0) return []; - const ftsQuery = this.buildFtsQuery(query); + const ftsQuery = this.#buildFtsQuery(query); if (!ftsQuery) return []; try { - const rows = this.searchStmt.all(ftsQuery, safeLimit) as HistoryRow[]; - return rows.map(row => this.toEntry(row)); + const rows = this.#searchStmt.all(ftsQuery, safeLimit) as HistoryRow[]; + return rows.map(row => this.#toEntry(row)); } catch (error) { logger.error("HistoryStorage search failed", { error: String(error) }); return []; } } - private ensureDir(dbPath: string): void { + #ensureDir(dbPath: string): void { const dir = path.dirname(dbPath); fs.mkdirSync(dir, { recursive: true }); } - private normalizeLimit(limit: number): number { + #normalizeLimit(limit: number): number { if (!Number.isFinite(limit)) return 0; const clamped = Math.max(0, Math.floor(limit)); return Math.min(clamped, 1000); } - private buildFtsQuery(query: string): string | null { + #buildFtsQuery(query: string): string | null { const tokens = query .trim() .split(/\s+/) @@ -161,7 +159,7 @@ END; .join(" "); } - private toEntry(row: HistoryRow): HistoryEntry { + #toEntry(row: HistoryRow): HistoryEntry { return { id: row.id, prompt: row.prompt, diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 6b485e638..6a3759683 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -846,89 +846,89 @@ async function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore } class NdjsonFileWriter { - private writer: SessionStorageWriter; - private closed = false; - private closing = false; - private error: Error | undefined; - private pendingWrites: Promise = Promise.resolve(); - private onError: ((err: Error) => void) | undefined; + #writer: SessionStorageWriter; + #closed = false; + #closing = false; + #error: Error | undefined; + #pendingWrites: Promise = Promise.resolve(); + #onError: ((err: Error) => void) | undefined; constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) { - this.onError = options?.onError; - this.writer = storage.openWriter(path, { + this.#onError = options?.onError; + this.#writer = storage.openWriter(path, { flags: options?.flags ?? "a", - onError: (err: Error) => this.recordError(err), + onError: (err: Error) => this.#recordError(err), }); } - private recordError(err: unknown): Error { + #recordError(err: unknown): Error { const writeErr = toError(err); - if (!this.error) this.error = writeErr; - this.onError?.(writeErr); + if (!this.#error) this.#error = writeErr; + this.#onError?.(writeErr); return writeErr; } - private enqueue(task: () => Promise): Promise { + #enqueue(task: () => Promise): Promise { const run = async () => { - if (this.error) throw this.error; + if (this.#error) throw this.#error; await task(); }; - const next = this.pendingWrites.then(run); + const next = this.#pendingWrites.then(run); void next.catch((err: unknown) => { - if (!this.error) this.error = toError(err); + if (!this.#error) this.#error = toError(err); }); - this.pendingWrites = next; + this.#pendingWrites = next; return next; } - private async writeLine(line: string): Promise { - if (this.error) throw this.error; + async #writeLine(line: string): Promise { + if (this.#error) throw this.#error; try { - await this.writer.writeLine(line); + await this.#writer.writeLine(line); } catch (err) { - throw this.recordError(err); + throw this.#recordError(err); } } /** Queue a write. Returns a promise so callers can await if needed. */ write(entry: FileEntry): Promise { - if (this.closed || this.closing) throw new Error("Writer closed"); - if (this.error) throw this.error; + if (this.#closed || this.#closing) throw new Error("Writer closed"); + if (this.#error) throw this.#error; const line = `${JSON.stringify(entry)}\n`; - return this.enqueue(() => this.writeLine(line)); + return this.#enqueue(() => this.#writeLine(line)); } /** Flush all buffered data to disk. Waits for all queued writes. */ async flush(): Promise { - if (this.closed) return; - if (this.error) throw this.error; + if (this.#closed) return; + if (this.#error) throw this.#error; - await this.enqueue(async () => {}); + await this.#enqueue(async () => {}); - if (this.error) throw this.error; + if (this.#error) throw this.#error; try { - await this.writer.flush(); + await this.#writer.flush(); } catch (err) { - throw this.recordError(err); + throw this.#recordError(err); } } /** Sync data to persistent storage. */ async fsync(): Promise { - if (this.closed) return; - if (this.error) throw this.error; + if (this.#closed) return; + if (this.#error) throw this.#error; try { - await this.writer.fsync(); + await this.#writer.fsync(); } catch (err) { - throw this.recordError(err); + throw this.#recordError(err); } } /** Close the writer, flushing all data. */ async close(): Promise { - if (this.closed || this.closing) return; - this.closing = true; + if (this.#closed || this.#closing) return; + this.#closing = true; let closeError: Error | undefined; try { @@ -938,27 +938,27 @@ class NdjsonFileWriter { } try { - await this.pendingWrites; + await this.#pendingWrites; } catch (err) { if (!closeError) closeError = toError(err); } try { - await this.writer.close(); + await this.#writer.close(); } catch (err) { - const endErr = this.recordError(err); + const endErr = this.#recordError(err); if (!closeError) closeError = endErr; } - this.closed = true; + this.#closed = true; - if (!closeError && this.error) closeError = this.error; + if (!closeError && this.#error) closeError = this.#error; if (closeError) throw closeError; } /** Check if there's a stored error. */ getError(): Error | undefined { - return this.error; + return this.#error; } } @@ -1076,21 +1076,21 @@ async function collectSessionsFromFiles(files: string[], storage: SessionStorage } export class SessionManager { - private sessionId: string = ""; - private sessionName: string | undefined; - private sessionFile: string | undefined; - private flushed: boolean = false; - private fileEntries: FileEntry[] = []; - private byId: Map = new Map(); - private labelsById: Map = new Map(); - private leafId: string | null = null; - private usageStatistics: UsageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; - private persistWriter: NdjsonFileWriter | undefined; - private persistWriterPath: string | undefined; - private persistChain: Promise = Promise.resolve(); - private persistError: Error | undefined; - private persistErrorReported = false; - private readonly blobStore: BlobStore; + #sessionId: string = ""; + #sessionName: string | undefined; + #sessionFile: string | undefined; + #flushed: boolean = false; + #fileEntries: FileEntry[] = []; + #byId: Map = new Map(); + #labelsById: Map = new Map(); + #leafId: string | null = null; + #usageStatistics: UsageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; + #persistWriter: NdjsonFileWriter | undefined; + #persistWriterPath: string | undefined; + #persistChain: Promise = Promise.resolve(); + #persistError: Error | undefined; + #persistErrorReported = false; + readonly #blobStore: BlobStore; private constructor( private readonly cwd: string, @@ -1098,7 +1098,7 @@ export class SessionManager { private readonly persist: boolean, private readonly storage: SessionStorage, ) { - this.blobStore = new BlobStore(getBlobsDir()); + this.#blobStore = new BlobStore(getBlobsDir()); if (persist && sessionDir) { this.storage.ensureDirSync(sessionDir); } @@ -1107,54 +1107,54 @@ export class SessionManager { /** Puts a binary blob into the blob store and returns the blob reference */ async putBlob(data: Buffer): Promise { - return this.blobStore.put(data); + return this.#blobStore.put(data); } /** Initialize with a specific session file (used by factory methods) */ - private async _initSessionFile(sessionFile: string): Promise { + async #initSessionFile(sessionFile: string): Promise { await this.setSessionFile(sessionFile); } /** Initialize with a new session (used by factory methods) */ - private _initNewSession(): void { - this._newSessionSync(); + #initNewSession(): void { + this.#newSessionSync(); } /** Switch to a different session file (used for resume and branching) */ async setSessionFile(sessionFile: string): Promise { - await this._closePersistWriter(); - this.persistError = undefined; - this.persistErrorReported = false; - this.sessionFile = path.resolve(sessionFile); - writeTerminalBreadcrumb(this.cwd, this.sessionFile); - this.fileEntries = await loadEntriesFromFile(this.sessionFile, this.storage); - if (this.fileEntries.length > 0) { - const header = this.fileEntries.find(e => e.type === "session") as SessionHeader | undefined; - this.sessionId = header?.id ?? Snowflake.next(); - this.sessionName = header?.title; + await this.#closePersistWriter(); + this.#persistError = undefined; + this.#persistErrorReported = false; + this.#sessionFile = path.resolve(sessionFile); + writeTerminalBreadcrumb(this.cwd, this.#sessionFile); + this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage); + if (this.#fileEntries.length > 0) { + const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined; + this.#sessionId = header?.id ?? Snowflake.next(); + this.#sessionName = header?.title; - if (migrateToCurrentVersion(this.fileEntries)) { - await this._rewriteFile(); + if (migrateToCurrentVersion(this.#fileEntries)) { + await this.#rewriteFile(); } - await resolveBlobRefsInEntries(this.fileEntries, this.blobStore); + await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore); - this._buildIndex(); - this.flushed = true; + this.#buildIndex(); + this.#flushed = true; } else { - const explicitPath = this.sessionFile; - this._newSessionSync(); - this.sessionFile = explicitPath; // preserve explicit path from --session flag - await this._rewriteFile(); - this.flushed = true; + const explicitPath = this.#sessionFile; + this.#newSessionSync(); + this.#sessionFile = explicitPath; // preserve explicit path from --session flag + await this.#rewriteFile(); + this.#flushed = true; return; } } /** Start a new session. Closes any existing writer first. */ async newSession(options?: NewSessionOptions): Promise { - await this._closePersistWriter(); - return this._newSessionSync(options); + await this.#closePersistWriter(); + return this.#newSessionSync(options); } /** @@ -1163,125 +1163,125 @@ export class SessionManager { * @returns { oldSessionFile, newSessionFile } or undefined if not persisting */ async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> { - if (!this.persist || !this.sessionFile) { + if (!this.persist || !this.#sessionFile) { return undefined; } - const oldSessionFile = this.sessionFile; - const oldSessionId = this.sessionId; + const oldSessionFile = this.#sessionFile; + const oldSessionId = this.#sessionId; // Close the current writer - await this._closePersistWriter(); - this.persistChain = Promise.resolve(); - this.persistError = undefined; - this.persistErrorReported = false; + await this.#closePersistWriter(); + this.#persistChain = Promise.resolve(); + this.#persistError = undefined; + this.#persistErrorReported = false; // Create new session ID and header - this.sessionId = Snowflake.next(); + this.#sessionId = Snowflake.next(); const timestamp = new Date().toISOString(); const fileTimestamp = timestamp.replace(/[:.]/g, "-"); - this.sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); + this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`); // Update the header with new ID but keep all entries - const oldHeader = this.fileEntries.find(e => e.type === "session") as SessionHeader | undefined; + const oldHeader = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined; const newHeader: SessionHeader = { type: "session", version: CURRENT_SESSION_VERSION, - id: this.sessionId, - title: oldHeader?.title ?? this.sessionName, + id: this.#sessionId, + title: oldHeader?.title ?? this.#sessionName, timestamp, cwd: this.cwd, parentSession: oldSessionId, }; - this.sessionName = newHeader.title; + this.#sessionName = newHeader.title; // Replace the header in fileEntries - const entries = this.fileEntries.filter(e => e.type !== "session") as SessionEntry[]; - this.fileEntries = [newHeader, ...entries]; + const entries = this.#fileEntries.filter(e => e.type !== "session") as SessionEntry[]; + this.#fileEntries = [newHeader, ...entries]; // Write the new session file - this.flushed = false; - await this._rewriteFile(); + this.#flushed = false; + await this.#rewriteFile(); - return { oldSessionFile, newSessionFile: this.sessionFile }; + return { oldSessionFile, newSessionFile: this.#sessionFile }; } /** Sync version for initial creation (no existing writer to close) */ - private _newSessionSync(options?: NewSessionOptions): string | undefined { - this.persistChain = Promise.resolve(); - this.persistError = undefined; - this.persistErrorReported = false; - this.sessionId = Snowflake.next(); - this.sessionName = undefined; + #newSessionSync(options?: NewSessionOptions): string | undefined { + this.#persistChain = Promise.resolve(); + this.#persistError = undefined; + this.#persistErrorReported = false; + this.#sessionId = Snowflake.next(); + this.#sessionName = undefined; const timestamp = new Date().toISOString(); const header: SessionHeader = { type: "session", version: CURRENT_SESSION_VERSION, - id: this.sessionId, + id: this.#sessionId, timestamp, cwd: this.cwd, parentSession: options?.parentSession, }; - this.fileEntries = [header]; - this.byId.clear(); - this.labelsById.clear(); - this.leafId = null; - this.flushed = false; - this.usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; + this.#fileEntries = [header]; + this.#byId.clear(); + this.#labelsById.clear(); + this.#leafId = null; + this.#flushed = false; + this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; if (this.persist) { const fileTimestamp = timestamp.replace(/[:.]/g, "-"); - this.sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); - writeTerminalBreadcrumb(this.cwd, this.sessionFile); + this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`); + writeTerminalBreadcrumb(this.cwd, this.#sessionFile); } - return this.sessionFile; + return this.#sessionFile; } - private _buildIndex(): void { - this.byId.clear(); - this.labelsById.clear(); - this.leafId = null; - this.usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; - for (const entry of this.fileEntries) { + #buildIndex(): void { + this.#byId.clear(); + this.#labelsById.clear(); + this.#leafId = null; + this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }; + for (const entry of this.#fileEntries) { if (entry.type === "session") continue; - this.byId.set(entry.id, entry); - this.leafId = entry.id; + this.#byId.set(entry.id, entry); + this.#leafId = entry.id; if (entry.type === "label") { if (entry.label) { - this.labelsById.set(entry.targetId, entry.label); + this.#labelsById.set(entry.targetId, entry.label); } else { - this.labelsById.delete(entry.targetId); + this.#labelsById.delete(entry.targetId); } } if (entry.type === "message" && entry.message.role === "assistant") { const usage = entry.message.usage; - this.usageStatistics.input += usage.input; - this.usageStatistics.output += usage.output; - this.usageStatistics.cacheRead += usage.cacheRead; - this.usageStatistics.cacheWrite += usage.cacheWrite; - this.usageStatistics.cost += usage.cost.total; + this.#usageStatistics.input += usage.input; + this.#usageStatistics.output += usage.output; + this.#usageStatistics.cacheRead += usage.cacheRead; + this.#usageStatistics.cacheWrite += usage.cacheWrite; + this.#usageStatistics.cost += usage.cost.total; } if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") { const usage = getTaskToolUsage(entry.message.details); if (usage) { - this.usageStatistics.input += usage.input; - this.usageStatistics.output += usage.output; - this.usageStatistics.cacheRead += usage.cacheRead; - this.usageStatistics.cacheWrite += usage.cacheWrite; - this.usageStatistics.cost += usage.cost.total; + this.#usageStatistics.input += usage.input; + this.#usageStatistics.output += usage.output; + this.#usageStatistics.cacheRead += usage.cacheRead; + this.#usageStatistics.cacheWrite += usage.cacheWrite; + this.#usageStatistics.cost += usage.cost.total; } } } } - private _recordPersistError(err: unknown): Error { + #recordPersistError(err: unknown): Error { const normalized = toError(err); - if (!this.persistError) this.persistError = normalized; - if (!this.persistErrorReported) { - this.persistErrorReported = true; + if (!this.#persistError) this.#persistError = normalized; + if (!this.#persistErrorReported) { + this.#persistErrorReported = true; logger.error("Session persistence error.", { - sessionFile: this.sessionFile, + sessionFile: this.#sessionFile, error: normalized.message, stack: normalized.stack, }); @@ -1289,52 +1289,52 @@ export class SessionManager { return normalized; } - private _queuePersistTask(task: () => Promise, options?: { ignoreError?: boolean }): Promise { - const next = this.persistChain.then(async () => { - if (this.persistError && !options?.ignoreError) throw this.persistError; + #queuePersistTask(task: () => Promise, options?: { ignoreError?: boolean }): Promise { + const next = this.#persistChain.then(async () => { + if (this.#persistError && !options?.ignoreError) throw this.#persistError; await task(); }); - this.persistChain = next.catch(err => { - this._recordPersistError(err); + this.#persistChain = next.catch(err => { + this.#recordPersistError(err); }); return next; } - private _ensurePersistWriter(): NdjsonFileWriter | undefined { - if (!this.persist || !this.sessionFile) return undefined; - if (this.persistError) throw this.persistError; - if (this.persistWriter && this.persistWriterPath === this.sessionFile) return this.persistWriter; + #ensurePersistWriter(): NdjsonFileWriter | undefined { + if (!this.persist || !this.#sessionFile) return undefined; + if (this.#persistError) throw this.#persistError; + if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) return this.#persistWriter; // Note: caller must await _closePersistWriter() before calling this if switching files - this.persistWriter = new NdjsonFileWriter(this.storage, this.sessionFile, { + this.#persistWriter = new NdjsonFileWriter(this.storage, this.#sessionFile, { onError: err => { - this._recordPersistError(err); + this.#recordPersistError(err); }, }); - this.persistWriterPath = this.sessionFile; - return this.persistWriter; + this.#persistWriterPath = this.#sessionFile; + return this.#persistWriter; } - private async _closePersistWriterInternal(): Promise { - if (this.persistWriter) { - await this.persistWriter.close(); - this.persistWriter = undefined; + async #closePersistWriterInternal(): Promise { + if (this.#persistWriter) { + await this.#persistWriter.close(); + this.#persistWriter = undefined; } - this.persistWriterPath = undefined; + this.#persistWriterPath = undefined; } - private async _closePersistWriter(): Promise { - await this._queuePersistTask( + async #closePersistWriter(): Promise { + await this.#queuePersistTask( async () => { - await this._closePersistWriterInternal(); + await this.#closePersistWriterInternal(); }, { ignoreError: true }, ); } - private async _writeEntriesAtomically(entries: FileEntry[]): Promise { - if (!this.sessionFile) return; - const dir = path.resolve(this.sessionFile, ".."); - const tempPath = path.join(dir, `.${path.basename(this.sessionFile)}.${Snowflake.next()}.tmp`); + async #writeEntriesAtomically(entries: FileEntry[]): Promise { + if (!this.#sessionFile) return; + const dir = path.resolve(this.#sessionFile, ".."); + const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`); const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" }); try { for (const entry of entries) { @@ -1343,8 +1343,7 @@ export class SessionManager { await writer.flush(); await writer.fsync(); await writer.close(); - await this.storage.rename(tempPath, this.sessionFile); - this.storage.fsyncDirSync(dir); + await this.storage.rename(tempPath, this.#sessionFile); } catch (err) { try { await writer.close(); @@ -1360,15 +1359,15 @@ export class SessionManager { } } - private async _rewriteFile(): Promise { - if (!this.persist || !this.sessionFile) return; - await this._queuePersistTask(async () => { - await this._closePersistWriterInternal(); + async #rewriteFile(): Promise { + if (!this.persist || !this.#sessionFile) return; + await this.#queuePersistTask(async () => { + await this.#closePersistWriterInternal(); const entries = await Promise.all( - this.fileEntries.map(entry => prepareEntryForPersistence(entry, this.blobStore)), + this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)), ); - await this._writeEntriesAtomically(entries); - this.flushed = true; + await this.#writeEntriesAtomically(entries); + this.#flushed = true; }); } @@ -1378,14 +1377,14 @@ export class SessionManager { /** Flush pending writes to disk. Call before switching sessions or on shutdown. */ async flush(): Promise { - if (!this.persistWriter) return; - await this._queuePersistTask(async () => { - if (this.persistWriter) { - await this.persistWriter.flush(); - await this.persistWriter.fsync(); + if (!this.#persistWriter) return; + await this.#queuePersistTask(async () => { + if (this.#persistWriter) { + await this.#persistWriter.flush(); + await this.#persistWriter.fsync(); } }); - if (this.persistError) throw this.persistError; + if (this.#persistError) throw this.#persistError; } getCwd(): string { @@ -1394,7 +1393,7 @@ export class SessionManager { /** Get usage statistics across all assistant messages in the session. */ getUsageStatistics(): UsageStatistics { - return this.usageStatistics; + return this.#usageStatistics; } getSessionDir(): string { @@ -1402,86 +1401,88 @@ export class SessionManager { } getSessionId(): string { - return this.sessionId; + return this.#sessionId; } getSessionFile(): string | undefined { - return this.sessionFile; + return this.#sessionFile; } getSessionName(): string | undefined { - return this.sessionName; + return this.#sessionName; } async setSessionName(name: string): Promise { - this.sessionName = name; + this.#sessionName = name; // Update the in-memory header (so first flush includes title) - const header = this.fileEntries.find(e => e.type === "session") as SessionHeader | undefined; + const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined; if (header) { header.title = name; } // Update the session file header with the title (if already flushed) - const sessionFile = this.sessionFile; + const sessionFile = this.#sessionFile; if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) { - await this._rewriteFile(); + await this.#rewriteFile(); } } _persist(entry: SessionEntry): void { - if (!this.persist || !this.sessionFile) return; - if (this.persistError) throw this.persistError; + if (!this.persist || !this.#sessionFile) return; + if (this.#persistError) throw this.#persistError; - const hasAssistant = this.fileEntries.some(e => e.type === "message" && e.message.role === "assistant"); + const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant"); if (!hasAssistant) { // Mark as not flushed so when assistant arrives, all entries get written - this.flushed = false; + this.#flushed = false; return; } - if (!this.flushed) { - this.flushed = true; - void this._queuePersistTask(async () => { - const writer = this._ensurePersistWriter(); + if (!this.#flushed) { + this.#flushed = true; + void this.#queuePersistTask(async () => { + const writer = this.#ensurePersistWriter(); if (!writer) return; - const entries = await Promise.all(this.fileEntries.map(e => prepareEntryForPersistence(e, this.blobStore))); + const entries = await Promise.all( + this.#fileEntries.map(e => prepareEntryForPersistence(e, this.#blobStore)), + ); for (const persistedEntry of entries) { await writer.write(persistedEntry); } }); } else { - void this._queuePersistTask(async () => { - const writer = this._ensurePersistWriter(); + void this.#queuePersistTask(async () => { + const writer = this.#ensurePersistWriter(); if (!writer) return; - const persistedEntry = await prepareEntryForPersistence(entry, this.blobStore); + const persistedEntry = await prepareEntryForPersistence(entry, this.#blobStore); await writer.write(persistedEntry); }); } } - private _appendEntry(entry: SessionEntry): void { - this.fileEntries.push(entry); - this.byId.set(entry.id, entry); - this.leafId = entry.id; + #appendEntry(entry: SessionEntry): void { + this.#fileEntries.push(entry); + this.#byId.set(entry.id, entry); + this.#leafId = entry.id; this._persist(entry); if (entry.type === "message" && entry.message.role === "assistant") { const usage = entry.message.usage; - this.usageStatistics.input += usage.input; - this.usageStatistics.output += usage.output; - this.usageStatistics.cacheRead += usage.cacheRead; - this.usageStatistics.cacheWrite += usage.cacheWrite; - this.usageStatistics.cost += usage.cost.total; + this.#usageStatistics.input += usage.input; + this.#usageStatistics.output += usage.output; + this.#usageStatistics.cacheRead += usage.cacheRead; + this.#usageStatistics.cacheWrite += usage.cacheWrite; + this.#usageStatistics.cost += usage.cost.total; } if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") { const usage = getTaskToolUsage(entry.message.details); if (usage) { - this.usageStatistics.input += usage.input; - this.usageStatistics.output += usage.output; - this.usageStatistics.cacheRead += usage.cacheRead; - this.usageStatistics.cacheWrite += usage.cacheWrite; - this.usageStatistics.cost += usage.cost.total; + this.#usageStatistics.input += usage.input; + this.#usageStatistics.output += usage.output; + this.#usageStatistics.cacheRead += usage.cacheRead; + this.#usageStatistics.cacheWrite += usage.cacheWrite; + this.#usageStatistics.cost += usage.cost.total; } } } @@ -1503,12 +1504,12 @@ export class SessionManager { ): string { const entry: SessionMessageEntry = { type: "message", - id: generateId(this.byId), - parentId: this.leafId, + id: generateId(this.#byId), + parentId: this.#leafId, timestamp: new Date().toISOString(), message, }; - this._appendEntry(entry); + this.#appendEntry(entry); return entry.id; } @@ -1516,12 +1517,12 @@ export class SessionManager { appendThinkingLevelChange(thinkingLevel: string): string { const entry: ThinkingLevelChangeEntry = { type: "thinking_level_change", - id: generateId(this.byId), - parentId: this.leafId, + id: generateId(this.#byId), + parentId: this.#leafId, timestamp: new Date().toISOString(), thinkingLevel, }; - this._appendEntry(entry); + this.#appendEntry(entry); return entry.id; } @@ -1529,13 +1530,13 @@ export class SessionManager { appendModeChange(mode: string, data?: Record): string { const entry: ModeChangeEntry = { type: "mode_change", - id: generateId(this.byId), - parentId: this.leafId, + id: generateId(this.#byId), + parentId: this.#leafId, timestamp: new Date().toISOString(), mode, data, }; - this._appendEntry(entry); + this.#appendEntry(entry); return entry.id; } @@ -1547,13 +1548,13 @@ export class SessionManager { appendModelChange(model: string, role?: string): string { const entry: ModelChangeEntry = { type: "model_change", - id: generateId(this.byId), - parentId: this.leafId, + id: generateId(this.#byId), + parentId: this.#leafId, timestamp: new Date().toISOString(), model, role, }; - this._appendEntry(entry); + this.#appendEntry(entry); return entry.id; } @@ -1561,12 +1562,12 @@ export class SessionManager { appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string { const entry: SessionInitEntry = { type: "session_init", - id: generateId(this.byId), - parentId: this.leafId, + id: generateId(this.#byId), + parentId: this.#leafId, timestamp: new Date().toISOString(), ...init, }; - this._appendEntry(entry); + this.#appendEntry(entry); return entry.id; } @@ -1582,8 +1583,8 @@ export class SessionManager { ): string { const entry: CompactionEntry = { type: "compaction", - id: generateId(this.byId), - parentId: this.leafId, + id: generateId(this.#byId), + parentId: this.#leafId, timestamp: new Date().toISOString(), summary, shortSummary, @@ -1593,7 +1594,7 @@ export class SessionManager { fromExtension, preserveData, }; - this._appendEntry(entry); + this.#appendEntry(entry); return entry.id; } @@ -1603,11 +1604,11 @@ export class SessionManager { type: "custom", customType, data, - id: generateId(this.byId), - parentId: this.leafId, + id: generateId(this.#byId), + parentId: this.#leafId, timestamp: new Date().toISOString(), }; - this._appendEntry(entry); + this.#appendEntry(entry); return entry.id; } @@ -1616,8 +1617,8 @@ export class SessionManager { * Use sparingly (e.g., pruning old tool outputs). */ async rewriteEntries(): Promise { - if (!this.persist || !this.sessionFile) return; - await this._rewriteFile(); + if (!this.persist || !this.#sessionFile) return; + await this.#rewriteFile(); } /** @@ -1626,8 +1627,8 @@ export class SessionManager { */ async rewriteAssistantToolCallArgs(toolCallId: string, args: Record): Promise { let updated = false; - for (let i = this.fileEntries.length - 1; i >= 0; i--) { - const entry = this.fileEntries[i]; + for (let i = this.#fileEntries.length - 1; i >= 0; i--) { + const entry = this.#fileEntries[i]; if (entry.type !== "message" || entry.message.role !== "assistant") continue; const message = entry.message as { content?: unknown }; if (!Array.isArray(message.content)) continue; @@ -1644,8 +1645,8 @@ export class SessionManager { if (updated) break; } - if (updated && this.persist && this.sessionFile) { - await this._rewriteFile(); + if (updated && this.persist && this.#sessionFile) { + await this.#rewriteFile(); } return updated; } @@ -1670,11 +1671,11 @@ export class SessionManager { content, display, details, - id: generateId(this.byId), - parentId: this.leafId, + id: generateId(this.#byId), + parentId: this.#leafId, timestamp: new Date().toISOString(), }; - this._appendEntry(entry); + this.#appendEntry(entry); return entry.id; } @@ -1690,12 +1691,12 @@ export class SessionManager { appendTtsrInjection(ruleNames: string[]): string { const entry: TtsrInjectionEntry = { type: "ttsr_injection", - id: generateId(this.byId), - parentId: this.leafId, + id: generateId(this.#byId), + parentId: this.#leafId, timestamp: new Date().toISOString(), injectedRules: ruleNames, }; - this._appendEntry(entry); + this.#appendEntry(entry); return entry.id; } @@ -1721,11 +1722,11 @@ export class SessionManager { // ========================================================================= getLeafId(): string | null { - return this.leafId; + return this.#leafId; } getLeafEntry(): SessionEntry | undefined { - return this.leafId ? this.byId.get(this.leafId) : undefined; + return this.#leafId ? this.#byId.get(this.#leafId) : undefined; } /** @@ -1738,13 +1739,13 @@ export class SessionManager { if (current.type === "model_change") { return current.role ?? "default"; } - current = current.parentId ? this.byId.get(current.parentId) : undefined; + current = current.parentId ? this.#byId.get(current.parentId) : undefined; } return undefined; } getEntry(id: string): SessionEntry | undefined { - return this.byId.get(id); + return this.#byId.get(id); } /** @@ -1752,7 +1753,7 @@ export class SessionManager { */ getChildren(parentId: string): SessionEntry[] { const children: SessionEntry[] = []; - for (const entry of this.byId.values()) { + for (const entry of this.#byId.values()) { if (entry.parentId === parentId) { children.push(entry); } @@ -1764,7 +1765,7 @@ export class SessionManager { * Get the label for an entry, if any. */ getLabel(id: string): string | undefined { - return this.labelsById.get(id); + return this.#labelsById.get(id); } /** @@ -1773,22 +1774,22 @@ export class SessionManager { * Pass undefined or empty string to clear the label. */ appendLabelChange(targetId: string, label: string | undefined): string { - if (!this.byId.has(targetId)) { + if (!this.#byId.has(targetId)) { throw new Error(`Entry ${targetId} not found`); } const entry: LabelEntry = { type: "label", - id: generateId(this.byId), - parentId: this.leafId, + id: generateId(this.#byId), + parentId: this.#leafId, timestamp: new Date().toISOString(), targetId, label, }; - this._appendEntry(entry); + this.#appendEntry(entry); if (label) { - this.labelsById.set(targetId, label); + this.#labelsById.set(targetId, label); } else { - this.labelsById.delete(targetId); + this.#labelsById.delete(targetId); } return entry.id; } @@ -1800,11 +1801,11 @@ export class SessionManager { */ getBranch(fromId?: string): SessionEntry[] { const path: SessionEntry[] = []; - const startId = fromId ?? this.leafId; - let current = startId ? this.byId.get(startId) : undefined; + const startId = fromId ?? this.#leafId; + let current = startId ? this.#byId.get(startId) : undefined; while (current) { path.unshift(current); - current = current.parentId ? this.byId.get(current.parentId) : undefined; + current = current.parentId ? this.#byId.get(current.parentId) : undefined; } return path; } @@ -1814,14 +1815,14 @@ export class SessionManager { * Uses tree traversal from current leaf. */ buildSessionContext(): SessionContext { - return buildSessionContext(this.getEntries(), this.leafId, this.byId); + return buildSessionContext(this.getEntries(), this.#leafId, this.#byId); } /** * Get session header. */ getHeader(): SessionHeader | null { - const h = this.fileEntries.find(e => e.type === "session"); + const h = this.#fileEntries.find(e => e.type === "session"); return h ? (h as SessionHeader) : null; } @@ -1831,7 +1832,7 @@ export class SessionManager { * change the leaf pointer. Entries cannot be modified or deleted. */ getEntries(): SessionEntry[] { - return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session"); + return this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session"); } /** @@ -1846,7 +1847,7 @@ export class SessionManager { // Create nodes with resolved labels for (const entry of entries) { - const label = this.labelsById.get(entry.id); + const label = this.#labelsById.get(entry.id); nodeMap.set(entry.id, { entry, children: [], label }); } @@ -1889,10 +1890,10 @@ export class SessionManager { * are not modified or deleted. */ branch(branchFromId: string): void { - if (!this.byId.has(branchFromId)) { + if (!this.#byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } - this.leafId = branchFromId; + this.#leafId = branchFromId; } /** @@ -1901,7 +1902,7 @@ export class SessionManager { * Use this when navigating to re-edit the first user message. */ resetLeaf(): void { - this.leafId = null; + this.#leafId = null; } /** @@ -1910,13 +1911,13 @@ export class SessionManager { * context from the abandoned conversation path. */ branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string { - if (branchFromId !== null && !this.byId.has(branchFromId)) { + if (branchFromId !== null && !this.#byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } - this.leafId = branchFromId; + this.#leafId = branchFromId; const entry: BranchSummaryEntry = { type: "branch_summary", - id: generateId(this.byId), + id: generateId(this.#byId), parentId: branchFromId, timestamp: new Date().toISOString(), fromId: branchFromId ?? "root", @@ -1924,7 +1925,7 @@ export class SessionManager { details, fromExtension, }; - this._appendEntry(entry); + this.#appendEntry(entry); return entry.id; } @@ -1934,7 +1935,7 @@ export class SessionManager { * Returns the new session file path, or undefined if not persisting. */ createBranchedSession(leafId: string): string | undefined { - const previousSessionFile = this.sessionFile; + const previousSessionFile = this.#sessionFile; const branchPath = this.getBranch(leafId); if (branchPath.length === 0) { throw new Error(`Entry ${leafId} not found`); @@ -1960,7 +1961,7 @@ export class SessionManager { // Collect labels for entries in the path const pathEntryIds = new Set(pathWithoutLabels.map(e => e.id)); const labelsToWrite: Array<{ targetId: string; label: string }> = []; - for (const [targetId, label] of this.labelsById) { + for (const [targetId, label] of this.#labelsById) { if (pathEntryIds.has(targetId)) { labelsToWrite.push({ targetId, label }); } @@ -1991,11 +1992,11 @@ export class SessionManager { parentId = labelEntry.id; } this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`); - this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; - this.sessionId = newSessionId; - this.sessionFile = newSessionFile; - this.flushed = true; - this._buildIndex(); + this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; + this.#sessionId = newSessionId; + this.#sessionFile = newSessionFile; + this.#flushed = true; + this.#buildIndex(); return newSessionFile; } @@ -2014,9 +2015,9 @@ export class SessionManager { labelEntries.push(labelEntry); parentId = labelEntry.id; } - this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; - this.sessionId = newSessionId; - this._buildIndex(); + this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; + this.#sessionId = newSessionId; + this.#buildIndex(); return undefined; } @@ -2028,7 +2029,7 @@ export class SessionManager { static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager { const dir = sessionDir ?? getDefaultSessionDir(cwd, storage); const manager = new SessionManager(cwd, dir, true, storage); - manager._initNewSession(); + manager.#initNewSession(); return manager; } @@ -2046,16 +2047,16 @@ export class SessionManager { const manager = new SessionManager(cwd, dir, true, storage); const forkEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[]; migrateToCurrentVersion(forkEntries); - await resolveBlobRefsInEntries(forkEntries, manager.blobStore); + await resolveBlobRefsInEntries(forkEntries, manager.#blobStore); const sourceHeader = forkEntries.find(e => e.type === "session") as SessionHeader | undefined; const historyEntries = forkEntries.filter(entry => entry.type !== "session") as SessionEntry[]; - manager._newSessionSync({ parentSession: sourceHeader?.id }); - const newHeader = manager.fileEntries[0] as SessionHeader; + manager.#newSessionSync({ parentSession: sourceHeader?.id }); + const newHeader = manager.#fileEntries[0] as SessionHeader; newHeader.title = sourceHeader?.title; - manager.fileEntries = [newHeader, ...historyEntries]; - manager.sessionName = newHeader.title; - manager._buildIndex(); - await manager._rewriteFile(); + manager.#fileEntries = [newHeader, ...historyEntries]; + manager.#sessionName = newHeader.title; + manager.#buildIndex(); + await manager.#rewriteFile(); return manager; } @@ -2076,7 +2077,7 @@ export class SessionManager { // If no sessionDir provided, derive from file's parent directory const dir = sessionDir ?? path.resolve(filePath, ".."); const manager = new SessionManager(cwd, dir, true, storage); - await manager._initSessionFile(filePath); + await manager.#initSessionFile(filePath); return manager; } @@ -2096,9 +2097,9 @@ export class SessionManager { const mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage)); const manager = new SessionManager(cwd, dir, true, storage); if (mostRecent) { - await manager._initSessionFile(mostRecent); + await manager.#initSessionFile(mostRecent); } else { - manager._initNewSession(); + manager.#initNewSession(); } return manager; } @@ -2106,7 +2107,7 @@ export class SessionManager { /** Create an in-memory session (no file persistence) */ static inMemory(cwd: string = process.cwd(), storage: SessionStorage = new MemorySessionStorage()): SessionManager { const manager = new SessionManager(cwd, "", false, storage); - manager._initNewSession(); + manager.#initNewSession(); return manager; } diff --git a/packages/coding-agent/src/session/session-storage.ts b/packages/coding-agent/src/session/session-storage.ts index ce16334b3..459eb99a4 100644 --- a/packages/coding-agent/src/session/session-storage.ts +++ b/packages/coding-agent/src/session/session-storage.ts @@ -29,7 +29,6 @@ export interface SessionStorage { writeText(path: string, content: string): Promise; rename(path: string, nextPath: string): Promise; unlink(path: string): Promise; - fsyncDirSync(dir: string): void; openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter; } @@ -47,13 +46,13 @@ const writerRegistry = new FinalizationRegistry(fd => { }); class FileSessionStorageWriter implements SessionStorageWriter { - private fd: number; - private closed = false; - private error: Error | undefined; - private onError: ((err: Error) => void) | undefined; + #fd: number; + #closed = false; + #error: Error | undefined; + #onError: ((err: Error) => void) | undefined; constructor(fpath: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) { - this.onError = options?.onError; + this.#onError = options?.onError; const flags = options?.flags ?? "a"; // Ensure parent directory exists const dir = path.dirname(fpath); @@ -61,65 +60,65 @@ class FileSessionStorageWriter implements SessionStorageWriter { fs.mkdirSync(dir, { recursive: true }); } // Open file once, keep fd for lifetime - this.fd = fs.openSync(fpath, flags === "w" ? "w" : "a"); + this.#fd = fs.openSync(fpath, flags === "w" ? "w" : "a"); // Register for cleanup if abandoned without close() - writerRegistry.register(this, this.fd, this); + writerRegistry.register(this, this.#fd, this); } - private recordError(err: unknown): Error { + #recordError(err: unknown): Error { const error = toError(err); - if (!this.error) this.error = error; - this.onError?.(error); + if (!this.#error) this.#error = error; + this.#onError?.(error); return error; } async writeLine(line: string): Promise { - if (this.closed) throw new Error("Writer closed"); - if (this.error) throw this.error; + if (this.#closed) throw new Error("Writer closed"); + if (this.#error) throw this.#error; try { const buf = Buffer.from(line, "utf-8"); let offset = 0; while (offset < buf.length) { - const written = fs.writeSync(this.fd, buf, offset, buf.length - offset); + const written = fs.writeSync(this.#fd, buf, offset, buf.length - offset); if (written === 0) { throw new Error("Short write"); } offset += written; } } catch (err) { - throw this.recordError(err); + throw this.#recordError(err); } } async flush(): Promise { - if (this.error) throw this.error; + if (this.#error) throw this.#error; // OS buffers are flushed on fsync, nothing to do here } async fsync(): Promise { - if (this.closed) throw new Error("Writer closed"); - if (this.error) throw this.error; + if (this.#closed) throw new Error("Writer closed"); + if (this.#error) throw this.#error; try { - fs.fsyncSync(this.fd); + fs.fsyncSync(this.#fd); } catch (err) { - throw this.recordError(err); + throw this.#recordError(err); } } async close(): Promise { - if (this.closed) return; - this.closed = true; + if (this.#closed) return; + this.#closed = true; // Unregister from finalization - we're closing properly writerRegistry.unregister(this); try { - fs.closeSync(this.fd); + fs.closeSync(this.#fd); } catch { // Ignore close errors } } getError(): Error | undefined { - return this.error; + return this.#error; } } @@ -163,18 +162,11 @@ export class FileSessionStorage implements SessionStorage { } readText(path: string): Promise { - return fs.promises.readFile(path, "utf-8"); + return Bun.file(path).text(); } async readTextPrefix(path: string, maxBytes: number): Promise { - const handle = await fs.promises.open(path, "r"); - try { - const buffer = Buffer.allocUnsafe(maxBytes); - const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0); - return buffer.subarray(0, bytesRead).toString("utf-8"); - } finally { - await handle.close(); - } + return Bun.file(path).slice(0, maxBytes).text(); } async writeText(path: string, content: string): Promise { @@ -193,19 +185,6 @@ export class FileSessionStorage implements SessionStorage { return fs.promises.unlink(path); } - fsyncDirSync(dir: string): void { - try { - const fd = fs.openSync(dir, "r"); - try { - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } - } catch { - // Best-effort: some platforms/filesystems don't support fsync on directories. - } - } - openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter { return new FileSessionStorageWriter(path, options); } @@ -220,88 +199,88 @@ function matchesPattern(name: string, pattern: string): boolean { } class MemorySessionStorageWriter implements SessionStorageWriter { - private storage: MemorySessionStorage; - private path: string; - private closed = false; - private error: Error | undefined; - private onError: ((err: Error) => void) | undefined; - private ready: Promise; + #storage: MemorySessionStorage; + #path: string; + #closed = false; + #error: Error | undefined; + #onError: ((err: Error) => void) | undefined; + #ready: Promise; constructor( storage: MemorySessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }, ) { - this.storage = storage; - this.path = path; - this.onError = options?.onError; - this.ready = this.initialize(options?.flags ?? "a"); + this.#storage = storage; + this.#path = path; + this.#onError = options?.onError; + this.#ready = this.#initialize(options?.flags ?? "a"); } - private async initialize(flags: "a" | "w"): Promise { + async #initialize(flags: "a" | "w"): Promise { if (flags === "w") { - await this.storage.writeText(this.path, ""); + await this.#storage.writeText(this.#path, ""); } } - private recordError(err: unknown): Error { + #recordError(err: unknown): Error { const error = toError(err); - if (!this.error) this.error = error; - this.onError?.(error); + if (!this.#error) this.#error = error; + this.#onError?.(error); return error; } async writeLine(line: string): Promise { - if (this.closed) throw new Error("Writer closed"); - await this.ready; - if (this.error) throw this.error; + if (this.#closed) throw new Error("Writer closed"); + await this.#ready; + if (this.#error) throw this.#error; try { - const existing = this.storage.existsSync(this.path) ? await this.storage.readText(this.path) : ""; - await this.storage.writeText(this.path, `${existing}${line}`); + const existing = this.#storage.existsSync(this.#path) ? await this.#storage.readText(this.#path) : ""; + await this.#storage.writeText(this.#path, `${existing}${line}`); } catch (err) { - throw this.recordError(err); + throw this.#recordError(err); } } async flush(): Promise { - await this.ready; - if (this.error) throw this.error; + await this.#ready; + if (this.#error) throw this.#error; } async fsync(): Promise { // No-op for in-memory storage - await this.ready; - if (this.error) throw this.error; + await this.#ready; + if (this.#error) throw this.#error; } async close(): Promise { - if (this.closed) return; - await this.ready; - this.closed = true; + if (this.#closed) return; + await this.#ready; + this.#closed = true; } getError(): Error | undefined { - return this.error; + return this.#error; } } export class MemorySessionStorage implements SessionStorage { - private files = new Map(); + #files = new Map(); ensureDirSync(_dir: string): void { // No-op for in-memory storage. } existsSync(path: string): boolean { - return this.files.has(path); + return this.#files.has(path); } writeTextSync(path: string, content: string): void { - this.files.set(path, { content, mtimeMs: Date.now() }); + this.#files.set(path, { content, mtimeMs: Date.now() }); } statSync(path: string): SessionStorageStat { - const entry = this.files.get(path); + const entry = this.#files.get(path); if (!entry) throw new Error(`File not found: ${path}`); return { size: entry.content.length, @@ -313,7 +292,7 @@ export class MemorySessionStorage implements SessionStorage { listFilesSync(dir: string, pattern: string): string[] { const prefix = dir.endsWith("/") ? dir : `${dir}/`; const files: string[] = []; - for (const path of this.files.keys()) { + for (const path of this.#files.keys()) { if (!path.startsWith(prefix)) continue; const name = path.slice(prefix.length); if (name.includes("/") || name.includes("\\")) continue; @@ -328,13 +307,13 @@ export class MemorySessionStorage implements SessionStorage { } readText(path: string): Promise { - const entry = this.files.get(path); + const entry = this.#files.get(path); if (!entry) return Promise.reject(new Error(`File not found: ${path}`)); return Promise.resolve(entry.content); } readTextPrefix(path: string, maxBytes: number): Promise { - const entry = this.files.get(path); + const entry = this.#files.get(path); if (!entry) return Promise.reject(new Error(`File not found: ${path}`)); return Promise.resolve(entry.content.slice(0, maxBytes)); } @@ -345,22 +324,18 @@ export class MemorySessionStorage implements SessionStorage { } rename(path: string, nextPath: string): Promise { - const entry = this.files.get(path); + const entry = this.#files.get(path); if (!entry) return Promise.reject(new Error(`File not found: ${path}`)); - this.files.set(nextPath, entry); - this.files.delete(path); + this.#files.set(nextPath, entry); + this.#files.delete(path); return Promise.resolve(); } unlink(path: string): Promise { - this.files.delete(path); + this.#files.delete(path); return Promise.resolve(); } - fsyncDirSync(_dir: string): void { - // No-op for in-memory storage. - } - openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter { return new MemorySessionStorageWriter(this, path, options); } diff --git a/packages/coding-agent/src/ssh/connection-manager.ts b/packages/coding-agent/src/ssh/connection-manager.ts index cb99c1a5e..8ea41e5ec 100644 --- a/packages/coding-agent/src/ssh/connection-manager.ts +++ b/packages/coding-agent/src/ssh/connection-manager.ts @@ -1,4 +1,4 @@ -import * as fs from "node:fs/promises"; +import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { isEnoent, logger } from "@oh-my-pi/pi-utils"; @@ -34,10 +34,10 @@ const activeHosts = new Map(); const pendingConnections = new Map>(); const hostInfoCache = new Map(); -async function ensureControlDir(): Promise { - await fs.mkdir(CONTROL_DIR, { recursive: true, mode: 0o700 }); +function ensureControlDir() { + fs.mkdirSync(CONTROL_DIR, { recursive: true, mode: 0o700 }); try { - await fs.chmod(CONTROL_DIR, 0o700); + fs.chmodSync(CONTROL_DIR, 0o700); } catch (err) { logger.debug("SSH control dir chmod failed", { path: CONTROL_DIR, error: String(err) }); } @@ -54,9 +54,9 @@ function getHostInfoPath(name: string): string { async function validateKeyPermissions(keyPath?: string): Promise { if (!keyPath) return; - let stats: Awaited>; + let stats: fs.Stats; try { - stats = await fs.stat(keyPath); + stats = await fs.promises.stat(keyPath); } catch (err) { if (isEnoent(err)) { throw new Error(`SSH key not found: ${keyPath}`); @@ -203,7 +203,7 @@ function shouldRefreshHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): bo async function loadHostInfoFromDisk(host: SSHConnectionTarget): Promise { const path = getHostInfoPath(host.name); try { - const raw = await fs.readFile(path, "utf-8"); + const raw = await fs.promises.readFile(path, "utf-8"); const parsed = parseHostInfo(JSON.parse(raw)); if (!parsed) return undefined; const resolved = applyCompatOverride(host, parsed); @@ -219,7 +219,7 @@ async function loadHostInfoFromDisk(host: SSHConnectionTarget): Promise { const path = getHostInfoPath(hostName); try { - const raw = await fs.readFile(path, "utf-8"); + const raw = await fs.promises.readFile(path, "utf-8"); const parsed = parseHostInfo(JSON.parse(raw)); if (!parsed) return undefined; return parsed; @@ -381,7 +381,7 @@ export async function ensureConnection(host: SSHConnectionTarget): Promise const promise = (async () => { ensureSshBinary(); - await ensureControlDir(); + ensureControlDir(); await validateKeyPermissions(host.keyPath); const target = buildSshTarget(host); diff --git a/packages/coding-agent/src/task/agents.ts b/packages/coding-agent/src/task/agents.ts index cb6f1fe2b..2dc3e7e5f 100644 --- a/packages/coding-agent/src/task/agents.ts +++ b/packages/coding-agent/src/task/agents.ts @@ -72,7 +72,7 @@ const EMBEDDED_AGENTS: { name: string; content: string }[] = EMBEDDED_AGENT_DEFS export class AgentParsingError extends Error { constructor( error: Error, - public readonly source?: unknown, + readonly source?: unknown, ) { super(`Failed to parse agent: ${error.message}`, { cause: error }); this.name = "AgentParsingError"; diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index f5854abdb..cfb66dc21 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -479,7 +479,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise | null = null; + let pendingTerminationTimeoutId: NodeJS.Timeout | null = null; const listenerController = new AbortController(); const listenerSignal = listenerController.signal; const abortController = new AbortController(); @@ -543,7 +543,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise | null = null; + let progressTimeoutId: NodeJS.Timeout | null = null; const emitProgressNow = () => { progress.durationMs = Date.now() - startTime; diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index bf2fb2e5b..8e7ae0713 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -51,6 +51,7 @@ import { cleanupWorktree, ensureWorktree, getRepoRoot, + type WorktreeBaseline, } from "./worktree"; /** Format byte count for display */ @@ -131,13 +132,13 @@ async function buildDescription(cwd: string, maxConcurrency: number, isolationEn * Use `TaskTool.create(session)` to instantiate. */ export class TaskTool implements AgentTool { - public readonly name = "task"; - public readonly label = "Task"; - public readonly parameters: TaskSchema; - public readonly renderCall = renderCall; - public readonly renderResult = renderResult; + readonly name = "task"; + readonly label = "Task"; + readonly parameters: TaskSchema; + readonly renderCall = renderCall; + readonly renderResult = renderResult; - private readonly blockedAgent: string | undefined; + readonly #blockedAgent: string | undefined; private constructor( private readonly session: ToolSession, @@ -145,20 +146,20 @@ export class TaskTool implements AgentTool { isolationEnabled: boolean, ) { this.parameters = isolationEnabled ? taskSchema : taskSchemaNoIsolation; - this.blockedAgent = $env.PI_BLOCKED_AGENT; + this.#blockedAgent = $env.PI_BLOCKED_AGENT; } /** * Create a TaskTool instance with async agent discovery. */ - public static async create(session: ToolSession): Promise { + static async create(session: ToolSession): Promise { const maxConcurrency = session.settings.get("task.maxConcurrency"); const isolationEnabled = session.settings.get("task.isolation.enabled"); const description = await buildDescription(session.cwd, maxConcurrency, isolationEnabled); return new TaskTool(session, description, isolationEnabled); } - public async execute( + async execute( _toolCallId: string, params: TaskParams, signal?: AbortSignal, @@ -293,7 +294,7 @@ export class TaskTool implements AgentTool { } let repoRoot: string | null = null; - let baseline = null as Awaited> | null; + let baseline: WorktreeBaseline | null = null; if (isIsolated) { try { repoRoot = await getRepoRoot(this.session.cwd); @@ -341,12 +342,12 @@ export class TaskTool implements AgentTool { try { // Check self-recursion prevention - if (this.blockedAgent && agentName === this.blockedAgent) { + if (this.#blockedAgent && agentName === this.#blockedAgent) { return { content: [ { type: "text", - text: `Cannot spawn ${this.blockedAgent} agent from within itself (recursion prevention). Use a different agent type.`, + text: `Cannot spawn ${this.#blockedAgent} agent from within itself (recursion prevention). Use a different agent type.`, }, ], details: { diff --git a/packages/coding-agent/src/task/subprocess-tool-registry.ts b/packages/coding-agent/src/task/subprocess-tool-registry.ts index 5ca232a96..7dd45d321 100644 --- a/packages/coding-agent/src/task/subprocess-tool-registry.ts +++ b/packages/coding-agent/src/task/subprocess-tool-registry.ts @@ -50,34 +50,34 @@ export interface SubprocessToolHandler { /** Registry for subprocess tool handlers */ class SubprocessToolRegistryImpl { - private handlers = new Map(); + #handlers = new Map(); /** * Register a handler for a tool's subprocess events. */ register(toolName: string, handler: SubprocessToolHandler): void { - this.handlers.set(toolName, handler as SubprocessToolHandler); + this.#handlers.set(toolName, handler as SubprocessToolHandler); } /** * Get the handler for a tool, if registered. */ getHandler(toolName: string): SubprocessToolHandler | undefined { - return this.handlers.get(toolName); + return this.#handlers.get(toolName); } /** * Check if a tool has a registered handler. */ hasHandler(toolName: string): boolean { - return this.handlers.has(toolName); + return this.#handlers.has(toolName); } /** * Get all registered tool names. */ getRegisteredTools(): string[] { - return Array.from(this.handlers.keys()); + return Array.from(this.#handlers.keys()); } } diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 8302140d3..d7a07c8c2 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -250,10 +250,10 @@ interface AskParams { * on implementation choices as the agent works. */ export class AskTool implements AgentTool { - public readonly name = "ask"; - public readonly label = "Ask"; - public readonly description: string; - public readonly parameters = askSchema; + readonly name = "ask"; + readonly label = "Ask"; + readonly description: string; + readonly parameters = askSchema; constructor(private readonly session: ToolSession) { this.description = renderPromptTemplate(askDescription); @@ -264,13 +264,13 @@ export class AskTool implements AgentTool { } /** Send terminal notification when ask tool is waiting for input */ - private sendAskNotification(): void { + #sendAskNotification(): void { const method = this.session.settings.get("ask.notify"); if (method === "off") return; TERMINAL.sendNotification("Waiting for input"); } - public async execute( + async execute( _toolCallId: string, params: AskParams, _signal?: AbortSignal, @@ -295,7 +295,7 @@ export class AskTool implements AgentTool { const timeout = planModeEnabled ? null : settingsTimeout; // Send notification if waiting and not suppressed - this.sendAskNotification(); + this.#sendAskNotification(); if (params.questions.length === 0) { return { diff --git a/packages/coding-agent/src/tools/bash.ts b/packages/coding-agent/src/tools/bash.ts index 64935b477..e6356850c 100644 --- a/packages/coding-agent/src/tools/bash.ts +++ b/packages/coding-agent/src/tools/bash.ts @@ -1,3 +1,4 @@ +import type * as fs from "node:fs"; import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; @@ -46,17 +47,17 @@ export interface BashToolOptions {} * Executes bash commands with optional timeout and working directory. */ export class BashTool implements AgentTool { - public readonly name = "bash"; - public readonly label = "Bash"; - public readonly description: string; - public readonly parameters = bashSchema; - public readonly concurrency = "exclusive"; + readonly name = "bash"; + readonly label = "Bash"; + readonly description: string; + readonly parameters = bashSchema; + readonly concurrency = "exclusive"; constructor(private readonly session: ToolSession) { this.description = renderPromptTemplate(bashDescription); } - public async execute( + async execute( _toolCallId: string, { command: rawCommand, @@ -87,7 +88,7 @@ export class BashTool implements AgentTool { } const commandCwd = cwd ? resolveToCwd(cwd, this.session.cwd) : this.session.cwd; - let cwdStat: Awaited>; + let cwdStat: fs.Stats; try { cwdStat = await Bun.file(commandCwd).stat(); } catch { diff --git a/packages/coding-agent/src/tools/browser.ts b/packages/coding-agent/src/tools/browser.ts index 547efd73a..3e01e63b4 100644 --- a/packages/coding-agent/src/tools/browser.ts +++ b/packages/coding-agent/src/tools/browser.ts @@ -428,44 +428,44 @@ function formatEvaluateResult(value: unknown): string { * Puppeteer tool for headless browser automation. */ export class BrowserTool implements AgentTool { - public readonly name = "puppeteer"; - public readonly label = "Puppeteer"; - public readonly description: string; - public readonly parameters = browserSchema; - private browser: Browser | null = null; - private page: Page | null = null; - private currentHeadless: boolean | null = null; - private browserSession: CDPSession | null = null; - private userAgentOverride: UserAgentOverride | null = null; - private elementIdCounter = 0; - private readonly elementCache = new Map(); - private readonly patchedClients = new WeakSet(); + readonly name = "puppeteer"; + readonly label = "Puppeteer"; + readonly description: string; + readonly parameters = browserSchema; + #browser: Browser | null = null; + #page: Page | null = null; + #currentHeadless: boolean | null = null; + #browserSession: CDPSession | null = null; + #userAgentOverride: UserAgentOverride | null = null; + #elementIdCounter = 0; + readonly #elementCache = new Map(); + readonly #patchedClients = new WeakSet(); constructor(private readonly session: ToolSession) { this.description = renderPromptTemplate(browserDescription, {}); } - private async closeBrowser(): Promise { - await this.clearElementCache(); - if (this.page && !this.page.isClosed()) { - await this.page.close(); + async #closeBrowser(): Promise { + await this.#clearElementCache(); + if (this.#page && !this.#page.isClosed()) { + await this.#page.close(); } - this.page = null; - if (this.browser?.connected) { - await this.browser.close(); + this.#page = null; + if (this.#browser?.connected) { + await this.#browser.close(); } - this.browser = null; - this.browserSession = null; - this.userAgentOverride = null; + this.#browser = null; + this.#browserSession = null; + this.#userAgentOverride = null; } - private async resetBrowser(params?: BrowserParams): Promise { - await this.closeBrowser(); - this.currentHeadless = this.session.settings.get("browser.headless"); + async #resetBrowser(params?: BrowserParams): Promise { + await this.#closeBrowser(); + this.#currentHeadless = this.session.settings.get("browser.headless"); const initialViewport = params?.viewport ?? DEFAULT_VIEWPORT; - this.browser = await puppeteer.launch({ - headless: this.currentHeadless, - defaultViewport: this.currentHeadless ? initialViewport : null, + this.#browser = await puppeteer.launch({ + headless: this.#currentHeadless, + defaultViewport: this.#currentHeadless ? initialViewport : null, args: [ "--no-sandbox", "--disable-setuid-sandbox", @@ -474,46 +474,46 @@ export class BrowserTool implements AgentTool { + async #ensurePage(params?: BrowserParams): Promise { const desiredHeadless = this.session.settings.get("browser.headless"); - if (this.currentHeadless !== null && this.currentHeadless !== desiredHeadless) { - return this.resetBrowser(params); + if (this.#currentHeadless !== null && this.#currentHeadless !== desiredHeadless) { + return this.#resetBrowser(params); } - if (this.page && !this.page.isClosed()) { - return this.page; + if (this.#page && !this.#page.isClosed()) { + return this.#page; } - if (!this.browser || !this.browser.isConnected()) { - return this.resetBrowser(params); + if (!this.#browser || !this.#browser.isConnected()) { + return this.#resetBrowser(params); } - this.page = await this.browser.newPage(); - await this.applyStealthPatches(this.page); - if (this.currentHeadless || params?.viewport) { - await this.applyViewport(this.page, params?.viewport); + this.#page = await this.#browser.newPage(); + await this.#applyStealthPatches(this.#page); + if (this.#currentHeadless || params?.viewport) { + await this.#applyViewport(this.#page, params?.viewport); } - return this.page; + return this.#page; } - private async applyViewport(page: Page, viewport?: BrowserParams["viewport"]): Promise { + async #applyViewport(page: Page, viewport?: BrowserParams["viewport"]): Promise { const target = viewport ?? DEFAULT_VIEWPORT; await page.setViewport(target); } - private async clearElementCache(): Promise { - if (this.elementCache.size === 0) { - this.elementIdCounter = 0; + async #clearElementCache(): Promise { + if (this.#elementCache.size === 0) { + this.#elementIdCounter = 0; return; } - const handles = Array.from(this.elementCache.values()); - this.elementCache.clear(); - this.elementIdCounter = 0; + const handles = Array.from(this.#elementCache.values()); + this.#elementCache.clear(); + this.#elementIdCounter = 0; await Promise.all( handles.map(async handle => { try { @@ -525,25 +525,25 @@ export class BrowserTool implements AgentTool { - const handle = this.elementCache.get(id); + async #resolveCachedHandle(id: number): Promise { + const handle = this.#elementCache.get(id); if (!handle) { throw new ToolError(`Unknown element_id ${id}. Run observe to refresh the element list.`); } try { const isConnected = (await handle.evaluate(el => el.isConnected)) as boolean; if (!isConnected) { - await this.clearElementCache(); + await this.#clearElementCache(); throw new ToolError(`Element_id ${id} is stale. Run observe again.`); } } catch { - await this.clearElementCache(); + await this.#clearElementCache(); throw new ToolError(`Element_id ${id} is stale. Run observe again.`); } return handle; } - private isInteractiveNode(node: SerializedAXNode): boolean { + #isInteractiveNode(node: SerializedAXNode): boolean { if (INTERACTIVE_AX_ROLES.has(node.role)) return true; return ( node.checked !== undefined || @@ -554,12 +554,12 @@ export class BrowserTool implements AgentTool { - if (options.includeAll || this.isInteractiveNode(node)) { + if (options.includeAll || this.#isInteractiveNode(node)) { const handle = await node.elementHandle(); if (handle) { let inViewport = true; @@ -571,7 +571,7 @@ export class BrowserTool implements AgentTool { - await this.resetBrowser(); + async restartForModeChange(): Promise { + await this.#resetBrowser(); } - private async applyStealthPatches(page: Page): Promise { - this.patchSourceUrl(page); - await this.applyUserAgentOverride(page); - await this.injectStealthScripts(page); + async #applyStealthPatches(page: Page): Promise { + this.#patchSourceUrl(page); + await this.#applyUserAgentOverride(page); + await this.#injectStealthScripts(page); } - private async applyUserAgentOverride(page: Page): Promise { + async #applyUserAgentOverride(page: Page): Promise { const client = resolvePageClient(page); if (!client) return; - const override = await this.resolveUserAgentOverride(page); - await this.sendUserAgentOverride(client, override); - await this.configureUserAgentTargets(override); + const override = await this.#resolveUserAgentOverride(page); + await this.#sendUserAgentOverride(client, override); + await this.#configureUserAgentTargets(override); } - private async resolveUserAgentOverride(page: Page): Promise { - if (this.userAgentOverride) return this.userAgentOverride; + async #resolveUserAgentOverride(page: Page): Promise { + if (this.#userAgentOverride) return this.#userAgentOverride; const rawUserAgent = await page.browser().userAgent(); let userAgent = rawUserAgent.replace("HeadlessChrome/", "Chrome/"); if (userAgent.includes("Linux") && !userAgent.includes("Android")) { @@ -699,7 +699,7 @@ export class BrowserTool implements AgentTool { - if (!this.browser) return; - if (!this.browserSession) { - this.browserSession = await this.browser.target().createCDPSession(); - await this.browserSession.send("Target.setAutoAttach", { + async #configureUserAgentTargets(override: UserAgentOverride): Promise { + if (!this.#browser) return; + if (!this.#browserSession) { + this.#browserSession = await this.#browser.target().createCDPSession(); + await this.#browserSession.send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: false, flatten: true, }); - this.browserSession.on("Target.attachedToTarget", async (event: { sessionId: string }) => { - const connection = this.browserSession?.connection(); + this.#browserSession.on("Target.attachedToTarget", async (event: { sessionId: string }) => { + const connection = this.#browserSession?.connection(); const session = connection?.session(event.sessionId); - if (!session || !this.userAgentOverride) return; - await this.sendUserAgentOverride(this.wrapSession(session), this.userAgentOverride); + if (!session || !this.#userAgentOverride) return; + await this.#sendUserAgentOverride(this.#wrapSession(session), this.#userAgentOverride); }); } - const targets = this.browser.targets(); + const targets = this.#browser.targets(); await Promise.all( targets.map(async target => { const session = await target.createCDPSession(); - await this.sendUserAgentOverride(this.wrapSession(session), override); + await this.#sendUserAgentOverride(this.#wrapSession(session), override); }), ); } - private wrapSession(session: CDPSession): PuppeteerCdpClient { + #wrapSession(session: CDPSession): PuppeteerCdpClient { return { send: async (method, params) => session.send(method as never, params as never), }; } - private async sendUserAgentOverride(client: PuppeteerCdpClient, override: UserAgentOverride): Promise { + async #sendUserAgentOverride(client: PuppeteerCdpClient, override: UserAgentOverride): Promise { try { await client.send("Network.enable"); } catch {} @@ -768,12 +768,12 @@ export class BrowserTool implements AgentTool) => { const next = async (payload?: Record) => { @@ -813,7 +813,7 @@ export class BrowserTool implements AgentTool { + async #injectStealthScripts(page: Page): Promise { const scripts = [ stealthTamperingScript, stealthActivityScript, @@ -880,7 +880,7 @@ export class BrowserTool implements AgentTool this.resetBrowser(params)); + const page = await untilAborted(signal, () => this.#resetBrowser(params)); const viewport = page.viewport(); details.viewport = viewport ?? DEFAULT_VIEWPORT; return toolResult(details).text("Opened headless browser session").done(); } case "close": { - await untilAborted(signal, () => this.closeBrowser()); + await untilAborted(signal, () => this.#closeBrowser()); return toolResult(details).text("Closed headless browser session").done(); } case "goto": { const url = ensureParam(params.url, "url", params.action); details.url = url; - const page = await this.ensurePage(params); + const page = await this.#ensurePage(params); const waitUntil = params.wait_until ?? "networkidle2"; - await this.clearElementCache(); + await this.#clearElementCache(); await untilAborted(signal, () => page.goto(url, { waitUntil, timeout: timeoutMs })); const finalUrl = page.url(); const title = (await untilAborted(signal, () => page.title())) as string; @@ -920,10 +920,10 @@ export class BrowserTool implements AgentTool page.accessibility.snapshot({ interestingOnly: !(params.include_all ?? false) }), )) as SerializedAXNode | null; @@ -931,7 +931,7 @@ export class BrowserTool implements AgentTool handle.click()); } catch { - await this.clearElementCache(); + await this.#clearElementCache(); throw new ToolError(`Element_id ${elementId} is stale. Run observe again.`); } return toolResult(details).text(`Clicked element ${elementId}`).done(); @@ -1000,7 +1000,7 @@ export class BrowserTool implements AgentTool locator.waitHandle())) as ElementHandle; @@ -1012,13 +1012,13 @@ export class BrowserTool implements AgentTool handle.focus()); await untilAborted(signal, () => page.keyboard.type(text, { delay: 0 })); } catch { - await this.clearElementCache(); + await this.#clearElementCache(); throw new ToolError(`Element_id ${elementId} is stale. Run observe again.`); } return toolResult(details).text(`Typed into element ${elementId}`).done(); @@ -1027,7 +1027,7 @@ export class BrowserTool implements AgentTool locator.fill(value)); @@ -1037,7 +1037,7 @@ export class BrowserTool implements AgentTool handle.evaluate((el, inputValue) => { @@ -1051,14 +1051,14 @@ export class BrowserTool implements AgentTool page.focus(resolvedSelector)); @@ -1069,14 +1069,14 @@ export class BrowserTool implements AgentTool page.mouse.wheel({ deltaX, deltaY })); return toolResult(details).text(`Scrolled by ${deltaX}, ${deltaY}`).done(); } case "drag": { const fromSelector = ensureParam(params.from_selector, "from_selector", params.action); const toSelector = ensureParam(params.to_selector, "to_selector", params.action); - const page = await this.ensurePage(params); + const page = await this.#ensurePage(params); const resolvedFromSelector = normalizeSelector(fromSelector); const resolvedToSelector = normalizeSelector(toSelector); const fromHandle = (await untilAborted(signal, () => @@ -1116,7 +1116,7 @@ export class BrowserTool implements AgentTool locator.wait()); @@ -1124,7 +1124,7 @@ export class BrowserTool implements AgentTool page.evaluate((source: string) => { const evaluator = new Function(`return (${source});`); @@ -1136,7 +1136,7 @@ export class BrowserTool implements AgentTool { @@ -1162,7 +1162,7 @@ export class BrowserTool implements AgentTool { @@ -1188,7 +1188,7 @@ export class BrowserTool implements AgentTool { @@ -1226,7 +1226,7 @@ export class BrowserTool implements AgentTool page.content())) as string; const url = page.url(); @@ -1263,7 +1263,7 @@ export class BrowserTool implements AgentTool { - public readonly name = "calc"; - public readonly label = "Calc"; - public readonly description: string; - public readonly parameters = calculatorSchema; + readonly name = "calc"; + readonly label = "Calc"; + readonly description: string; + readonly parameters = calculatorSchema; constructor(_session: ToolSession) { this.description = renderPromptTemplate(calculatorDescription); } - public async execute( + async execute( _toolCallId: string, { calculations }: CalculatorParams, signal?: AbortSignal, diff --git a/packages/coding-agent/src/tools/context.ts b/packages/coding-agent/src/tools/context.ts index 347e86174..9e9d61924 100644 --- a/packages/coding-agent/src/tools/context.ts +++ b/packages/coding-agent/src/tools/context.ts @@ -12,28 +12,28 @@ declare module "@oh-my-pi/pi-agent-core" { } export class ToolContextStore { - private uiContext: ExtensionUIContext | undefined; - private hasUI = false; - private toolNames: string[] = []; + #uiContext: ExtensionUIContext | undefined; + #hasUI = false; + #toolNames: string[] = []; constructor(private readonly getBaseContext: () => CustomToolContext) {} getContext(toolCall?: ToolCallContext): AgentToolContext { return { ...this.getBaseContext(), - ui: this.uiContext, - hasUI: this.hasUI, - toolNames: this.toolNames, + ui: this.#uiContext, + hasUI: this.#hasUI, + toolNames: this.#toolNames, toolCall, }; } setUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void { - this.uiContext = uiContext; - this.hasUI = hasUI; + this.#uiContext = uiContext; + this.#hasUI = hasUI; } setToolNames(names: string[]): void { - this.toolNames = names; + this.#toolNames = names; } } diff --git a/packages/coding-agent/src/tools/exit-plan-mode.ts b/packages/coding-agent/src/tools/exit-plan-mode.ts index fcee4843e..e5be1fdb6 100644 --- a/packages/coding-agent/src/tools/exit-plan-mode.ts +++ b/packages/coding-agent/src/tools/exit-plan-mode.ts @@ -16,16 +16,16 @@ export interface ExitPlanModeDetails { } export class ExitPlanModeTool implements AgentTool { - public readonly name = "exit_plan_mode"; - public readonly label = "ExitPlanMode"; - public readonly description: string; - public readonly parameters = exitPlanModeSchema; + readonly name = "exit_plan_mode"; + readonly label = "ExitPlanMode"; + readonly description: string; + readonly parameters = exitPlanModeSchema; constructor(private readonly session: ToolSession) { this.description = renderPromptTemplate(exitPlanModeDescription); } - public async execute( + async execute( _toolCallId: string, _params: Record, _signal?: AbortSignal, diff --git a/packages/coding-agent/src/tools/fetch.ts b/packages/coding-agent/src/tools/fetch.ts index a9b484706..992661f32 100644 --- a/packages/coding-agent/src/tools/fetch.ts +++ b/packages/coding-agent/src/tools/fetch.ts @@ -852,16 +852,16 @@ export interface FetchToolDetails { } export class FetchTool implements AgentTool { - public readonly name = "fetch"; - public readonly label = "Fetch"; - public readonly description: string; - public readonly parameters = fetchSchema; + readonly name = "fetch"; + readonly label = "Fetch"; + readonly description: string; + readonly parameters = fetchSchema; constructor(private readonly session: ToolSession) { this.description = renderPromptTemplate(fetchDescription); } - public async execute( + async execute( _toolCallId: string, params: Static, signal?: AbortSignal, diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index bad1ae74a..874158ee6 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -1,4 +1,4 @@ -import * as fs from "node:fs/promises"; +import * as fs from "node:fs"; import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { FileType, type GlobMatch, glob } from "@oh-my-pi/pi-natives"; @@ -123,22 +123,22 @@ export interface FindToolOptions { } export class FindTool implements AgentTool { - public readonly name = "find"; - public readonly label = "Find"; - public readonly description: string; - public readonly parameters = findSchema; + readonly name = "find"; + readonly label = "Find"; + readonly description: string; + readonly parameters = findSchema; - private readonly customOps?: FindOperations; + readonly #customOps?: FindOperations; constructor( private readonly session: ToolSession, options?: FindToolOptions, ) { - this.customOps = options?.operations; + this.#customOps = options?.operations; this.description = renderPromptTemplate(findDescription); } - public async execute( + async execute( _toolCallId: string, params: Static, signal?: AbortSignal, @@ -177,13 +177,13 @@ export class FindTool implements AgentTool { const includeHidden = hidden ?? true; // If custom operations provided with glob, use that instead of fd - if (this.customOps?.glob) { - if (!(await this.customOps.exists(searchPath))) { + if (this.#customOps?.glob) { + if (!(await this.#customOps.exists(searchPath))) { throw new ToolError(`Path not found: ${searchPath}`); } - if (!hasGlob && this.customOps.stat) { - const stat = await this.customOps.stat(searchPath); + if (!hasGlob && this.#customOps.stat) { + const stat = await this.#customOps.stat(searchPath); if (stat.isFile()) { const files = [scopePath]; const details: FindToolDetails = { @@ -196,7 +196,7 @@ export class FindTool implements AgentTool { } } - const results = await this.customOps.glob(globPattern, searchPath, { + const results = await this.#customOps.glob(globPattern, searchPath, { ignore: ["**/node_modules/**", "**/.git/**"], limit: effectiveLimit, }); @@ -239,9 +239,9 @@ export class FindTool implements AgentTool { return resultBuilder.done(); } - let searchStat: Awaited>; + let searchStat: fs.Stats; try { - searchStat = await fs.stat(searchPath); + searchStat = await fs.promises.stat(searchPath); } catch (err) { if (isEnoent(err)) { throw new ToolError(`Path not found: ${searchPath}`); @@ -263,7 +263,7 @@ export class FindTool implements AgentTool { throw new ToolError(`Path is not a directory: ${searchPath}`); } - let matches: Awaited>["matches"]; + let matches: GlobMatch[]; const onUpdateMatches: string[] = []; const updateIntervalMs = 200; let lastUpdate = 0; diff --git a/packages/coding-agent/src/tools/grep.ts b/packages/coding-agent/src/tools/grep.ts index ee31cd406..46dd60a2a 100644 --- a/packages/coding-agent/src/tools/grep.ts +++ b/packages/coding-agent/src/tools/grep.ts @@ -1,7 +1,7 @@ -import * as nodePath from "node:path"; +import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; -import { grep } from "@oh-my-pi/pi-natives"; +import { type GrepResult, grep } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; import { untilAborted } from "@oh-my-pi/pi-utils"; @@ -63,10 +63,10 @@ export interface GrepToolOptions { type GrepParams = Static; export class GrepTool implements AgentTool { - public readonly name = "grep"; - public readonly label = "Grep"; - public readonly description: string; - public readonly parameters = grepSchema; + readonly name = "grep"; + readonly label = "Grep"; + readonly description: string; + readonly parameters = grepSchema; constructor( private readonly session: ToolSession, @@ -75,7 +75,7 @@ export class GrepTool implements AgentTool { this.description = renderPromptTemplate(grepDescription); } - public async execute( + async execute( _toolCallId: string, params: GrepParams, signal?: AbortSignal, @@ -111,7 +111,7 @@ export class GrepTool implements AgentTool { const searchPath = resolveToCwd(searchDir || ".", this.session.cwd); const scopePath = (() => { - const relative = nodePath.relative(this.session.cwd, searchPath).replace(/\\/g, "/"); + const relative = path.relative(this.session.cwd, searchPath).replace(/\\/g, "/"); return relative.length === 0 ? "." : relative; })(); @@ -127,7 +127,7 @@ export class GrepTool implements AgentTool { const effectiveLimit = normalizedLimit ?? DEFAULT_MATCH_LIMIT; // Run grep - let result: Awaited>; + let result: GrepResult; try { result = await grep({ pattern: normalizedPattern, @@ -157,7 +157,7 @@ export class GrepTool implements AgentTool { if (isDirectory) { return cleanPath.replace(/\\/g, "/"); } - return nodePath.basename(cleanPath); + return path.basename(cleanPath); }; // Build output diff --git a/packages/coding-agent/src/tools/notebook.ts b/packages/coding-agent/src/tools/notebook.ts index 036435968..aa667618e 100644 --- a/packages/coding-agent/src/tools/notebook.ts +++ b/packages/coding-agent/src/tools/notebook.ts @@ -60,16 +60,16 @@ function splitIntoLines(content: string): string[] { type NotebookParams = Static; export class NotebookTool implements AgentTool { - public readonly name = "notebook"; - public readonly label = "Notebook"; - public readonly description = + readonly name = "notebook"; + readonly label = "Notebook"; + readonly description = "Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) with new source. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path. The cell_number is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number."; - public readonly parameters = notebookSchema; - public readonly concurrency = "exclusive"; + readonly parameters = notebookSchema; + readonly concurrency = "exclusive"; constructor(private readonly session: ToolSession) {} - public async execute( + async execute( _toolCallId: string, params: NotebookParams, signal?: AbortSignal, diff --git a/packages/coding-agent/src/tools/output-meta.ts b/packages/coding-agent/src/tools/output-meta.ts index 6254b4a5f..6a7d919f3 100644 --- a/packages/coding-agent/src/tools/output-meta.ts +++ b/packages/coding-agent/src/tools/output-meta.ts @@ -428,8 +428,16 @@ export function wrapToolWithMetaNotice>(tool: return result; }; - return Object.create(tool, { - execute: { value: wrappedExecute, enumerable: true }, + // Use Proxy so property access (description, parameters, mode) stays on the original + // tool. Object.create(tool) would make getters run with this=wrapper, breaking + // private fields on tools like EditTool. + return new Proxy(tool, { + get(target, prop) { + if (prop === "execute") return wrappedExecute; + const value = (target as Record)[prop]; + if (typeof value === "function") return value.bind(target); + return value; + }, }) as T; } diff --git a/packages/coding-agent/src/tools/python.ts b/packages/coding-agent/src/tools/python.ts index 98807ed3d..1c6944832 100644 --- a/packages/coding-agent/src/tools/python.ts +++ b/packages/coding-agent/src/tools/python.ts @@ -1,3 +1,4 @@ +import type * as fs from "node:fs"; import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import type { ImageContent } from "@oh-my-pi/pi-ai"; @@ -142,31 +143,31 @@ export interface PythonToolOptions { } export class PythonTool implements AgentTool { - public readonly name = "python"; - public readonly label = "Python"; - public readonly description: string; - public readonly parameters = pythonSchema; - public readonly concurrency = "exclusive"; + readonly name = "python"; + readonly label = "Python"; + readonly description: string; + readonly parameters = pythonSchema; + readonly concurrency = "exclusive"; - private readonly proxyExecutor?: PythonProxyExecutor; + readonly #proxyExecutor?: PythonProxyExecutor; constructor( private readonly session: ToolSession | null, options?: PythonToolOptions, ) { - this.proxyExecutor = options?.proxyExecutor; + this.#proxyExecutor = options?.proxyExecutor; this.description = getPythonToolDescription(); } - public async execute( + async execute( _toolCallId: string, params: Static, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, _ctx?: AgentToolContext, ): Promise> { - if (this.proxyExecutor) { - return this.proxyExecutor(params, signal); + if (this.#proxyExecutor) { + return this.#proxyExecutor(params, signal); } if (!this.session) { @@ -195,7 +196,7 @@ export class PythonTool implements AgentTool { } const commandCwd = cwd ? resolveToCwd(cwd, this.session.cwd) : this.session.cwd; - let cwdStat: Awaited>; + let cwdStat: fs.Stats; try { cwdStat = await Bun.file(commandCwd).stat(); } catch { diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index fd9134301..9e07c472e 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -535,24 +535,24 @@ type ReadParams = { path: string; offset?: number; limit?: number; lines?: boole * Directories return a formatted listing with modification times. */ export class ReadTool implements AgentTool { - public readonly name = "read"; - public readonly label = "Read"; - public readonly description: string; - public readonly parameters = readSchema; - public readonly nonAbortable = true; + readonly name = "read"; + readonly label = "Read"; + readonly description: string; + readonly parameters = readSchema; + readonly nonAbortable = true; - private readonly autoResizeImages: boolean; - private readonly defaultLineNumbers: boolean; + readonly #autoResizeImages: boolean; + readonly #defaultLineNumbers: boolean; constructor(private readonly session: ToolSession) { - this.autoResizeImages = session.settings.get("images.autoResize"); - this.defaultLineNumbers = session.settings.get("readLineNumbers"); + this.#autoResizeImages = session.settings.get("images.autoResize"); + this.#defaultLineNumbers = session.settings.get("readLineNumbers"); this.description = renderPromptTemplate(readDescription, { DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES), }); } - public async execute( + async execute( _toolCallId: string, params: ReadParams, signal?: AbortSignal, @@ -564,7 +564,7 @@ export class ReadTool implements AgentTool { // Handle internal URLs (agent://, skill://) const internalRouter = this.session.internalRouter; if (internalRouter?.canHandle(readPath)) { - return this.handleInternalUrl(readPath, offset, limit, lines); + return this.#handleInternalUrl(readPath, offset, limit, lines); } const absolutePath = resolveReadPath(readPath, this.session.cwd); @@ -604,7 +604,7 @@ export class ReadTool implements AgentTool { } if (isDirectory) { - return this.readDirectory(absolutePath, limit, signal); + return this.#readDirectory(absolutePath, limit, signal); } const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath); @@ -636,7 +636,7 @@ export class ReadTool implements AgentTool { } else { const base64 = new Uint8Array(buffer).toBase64(); - if (this.autoResizeImages) { + if (this.#autoResizeImages) { // Resize image if needed - catch errors from Photon try { const resized = await resizeImage({ type: "image", data: base64, mimeType }); @@ -754,7 +754,7 @@ export class ReadTool implements AgentTool { }; // Add line numbers if requested (uses setting default if not specified) - const shouldAddLineNumbers = lines ?? this.defaultLineNumbers; + const shouldAddLineNumbers = lines ?? this.#defaultLineNumbers; const prependLineNumbers = (text: string, startNum: number): string => { const textLines = text.split("\n"); const lastLineNum = startNum + textLines.length - 1; @@ -831,7 +831,7 @@ export class ReadTool implements AgentTool { * Handle internal URLs (agent://, skill://). * Supports pagination via offset/limit but rejects them when query extraction is used. */ - private async handleInternalUrl( + async #handleInternalUrl( url: string, offset?: number, limit?: number, @@ -904,7 +904,7 @@ export class ReadTool implements AgentTool { const truncation = truncateHead(selectedContent); // Add line numbers if requested - const shouldAddLineNumbers = lines ?? this.defaultLineNumbers; + const shouldAddLineNumbers = lines ?? this.#defaultLineNumbers; const prependLineNumbers = (text: string, startNum: number): string => { const textLines = text.split("\n"); const lastLineNum = startNum + textLines.length - 1; @@ -976,7 +976,7 @@ export class ReadTool implements AgentTool { } /** Read directory contents as a formatted listing */ - private async readDirectory( + async #readDirectory( absolutePath: string, limit: number | undefined, signal?: AbortSignal, diff --git a/packages/coding-agent/src/tools/ssh.ts b/packages/coding-agent/src/tools/ssh.ts index 650ce9985..be5cca3b3 100644 --- a/packages/coding-agent/src/tools/ssh.ts +++ b/packages/coding-agent/src/tools/ssh.ts @@ -125,30 +125,30 @@ interface SshToolParams { } export class SshTool implements AgentTool { - public readonly name = "ssh"; - public readonly label = "SSH"; - public readonly parameters = sshSchema; - public readonly concurrency = "exclusive"; + readonly name = "ssh"; + readonly label = "SSH"; + readonly parameters = sshSchema; + readonly concurrency = "exclusive"; - private readonly allowedHosts: Set; + readonly #allowedHosts: Set; constructor( private readonly session: ToolSession, private readonly hostNames: string[], private readonly hostsByName: Map, - public readonly description: string, + readonly description: string, ) { - this.allowedHosts = new Set(this.hostNames); + this.#allowedHosts = new Set(this.hostNames); } - public async execute( + async execute( _toolCallId: string, { host, command, cwd, timeout: rawTimeout = 60 }: SshToolParams, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, _ctx?: AgentToolContext, ): Promise> { - if (!this.allowedHosts.has(host)) { + if (!this.#allowedHosts.has(host)) { throw new ToolError(`Unknown SSH host: ${host}. Available hosts: ${this.hostNames.join(", ")}`); } diff --git a/packages/coding-agent/src/tools/submit-result.ts b/packages/coding-agent/src/tools/submit-result.ts index 00b641c38..d4e3e9421 100644 --- a/packages/coding-agent/src/tools/submit-result.ts +++ b/packages/coding-agent/src/tools/submit-result.ts @@ -53,15 +53,15 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): string { } export class SubmitResultTool implements AgentTool { - public readonly name = "submit_result"; - public readonly label = "Submit Result"; - public readonly description = + readonly name = "submit_result"; + readonly label = "Submit Result"; + readonly description = "Finish the task with structured JSON output. Call exactly once at the end of the task.\n\n" + "If you cannot complete the task, call with status='aborted' and an error message."; - public readonly parameters: TObject; + readonly parameters: TObject; - private readonly validate?: ValidateFunction; - private readonly schemaError?: string; + readonly #validate?: ValidateFunction; + readonly #schemaError?: string; constructor(session: ToolSession) { const schemaResult = normalizeSchema(session.outputSchema); @@ -72,13 +72,13 @@ export class SubmitResultTool implements AgentTool if (normalizedSchema !== undefined && !schemaError) { try { - this.validate = ajv.compile(normalizedSchema as any); + this.#validate = ajv.compile(normalizedSchema as any); } catch (err) { schemaError = err instanceof Error ? err.message : String(err); } } - this.schemaError = schemaError; + this.#schemaError = schemaError; const schemaHint = formatSchema(normalizedSchema ?? session.outputSchema); @@ -102,7 +102,7 @@ export class SubmitResultTool implements AgentTool }); } - public async execute( + async execute( _toolCallId: string, params: Static, _signal?: AbortSignal, @@ -116,11 +116,11 @@ export class SubmitResultTool implements AgentTool if (params.data === undefined || params.data === null) { throw new Error("data is required when status is 'success' (got null/undefined)"); } - if (this.schemaError) { - throw new Error(`Invalid output schema: ${this.schemaError}`); + if (this.#schemaError) { + throw new Error(`Invalid output schema: ${this.#schemaError}`); } - if (this.validate && !this.validate(params.data)) { - throw new Error(`Output does not match schema: ${formatAjvErrors(this.validate.errors)}`); + if (this.#validate && !this.#validate(params.data)) { + throw new Error(`Output does not match schema: ${formatAjvErrors(this.#validate.errors)}`); } } diff --git a/packages/coding-agent/src/tools/todo-write.ts b/packages/coding-agent/src/tools/todo-write.ts index b9df375b9..c241bd117 100644 --- a/packages/coding-agent/src/tools/todo-write.ts +++ b/packages/coding-agent/src/tools/todo-write.ts @@ -150,17 +150,17 @@ function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string // ============================================================================= export class TodoWriteTool implements AgentTool { - public readonly name = "todo_write"; - public readonly label = "Todo Write"; - public readonly description: string; - public readonly parameters = todoWriteSchema; - public readonly concurrency = "exclusive"; + readonly name = "todo_write"; + readonly label = "Todo Write"; + readonly description: string; + readonly parameters = todoWriteSchema; + readonly concurrency = "exclusive"; constructor(private readonly session: ToolSession) { this.description = renderPromptTemplate(todoWriteDescription); } - public async execute( + async execute( _toolCallId: string, params: TodoWriteParams, _signal?: AbortSignal, diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 4259a2cfb..32dbe4b1f 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -70,26 +70,26 @@ type WriteParams = { path: string; content: string }; * Creates or overwrites files with optional LSP formatting and diagnostics. */ export class WriteTool implements AgentTool { - public readonly name = "write"; - public readonly label = "Write"; - public readonly description: string; - public readonly parameters = writeSchema; - public readonly nonAbortable = true; - public readonly concurrency = "exclusive"; + readonly name = "write"; + readonly label = "Write"; + readonly description: string; + readonly parameters = writeSchema; + readonly nonAbortable = true; + readonly concurrency = "exclusive"; - private readonly writethrough: WritethroughCallback; + readonly #writethrough: WritethroughCallback; constructor(private readonly session: ToolSession) { const enableLsp = session.enableLsp ?? true; const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite"); const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnWrite"); - this.writethrough = enableLsp + this.#writethrough = enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop; this.description = renderPromptTemplate(writeDescription); } - public async execute( + async execute( _toolCallId: string, { path, content }: WriteParams, signal?: AbortSignal, @@ -101,7 +101,7 @@ export class WriteTool implements AgentTool void>>(); + readonly #listeners = new Map void>>(); - public emit(channel: string, data: unknown): void { - const handlers = this.listeners.get(channel); + emit(channel: string, data: unknown): void { + const handlers = this.#listeners.get(channel); if (handlers) { for (const handler of handlers) { handler(data); @@ -10,9 +10,9 @@ export class EventBus { } } - public on(channel: string, handler: (data: unknown) => void): () => void { - if (!this.listeners.has(channel)) { - this.listeners.set(channel, new Set()); + on(channel: string, handler: (data: unknown) => void): () => void { + if (!this.#listeners.has(channel)) { + this.#listeners.set(channel, new Set()); } const safeHandler = async (data: unknown) => { try { @@ -21,11 +21,11 @@ export class EventBus { console.error(`Event handler error (${channel}):`, err); } }; - this.listeners.get(channel)!.add(safeHandler); - return () => this.listeners.get(channel)?.delete(safeHandler); + this.#listeners.get(channel)!.add(safeHandler); + return () => this.#listeners.get(channel)?.delete(safeHandler); } - public clear(): void { - this.listeners.clear(); + clear(): void { + this.#listeners.clear(); } } diff --git a/packages/coding-agent/src/utils/frontmatter.ts b/packages/coding-agent/src/utils/frontmatter.ts index 8533dc320..22179e34c 100644 --- a/packages/coding-agent/src/utils/frontmatter.ts +++ b/packages/coding-agent/src/utils/frontmatter.ts @@ -16,7 +16,7 @@ function truncate(content: string, maxLength: number): string { export class FrontmatterError extends Error { constructor( error: Error, - public readonly source?: unknown, + readonly source?: unknown, ) { super(`Failed to parse YAML frontmatter (${source}): ${error.message}`, { cause: error }); this.name = "FrontmatterError"; diff --git a/packages/coding-agent/src/utils/ignore-files.ts b/packages/coding-agent/src/utils/ignore-files.ts index 0a4913449..e08f61227 100644 --- a/packages/coding-agent/src/utils/ignore-files.ts +++ b/packages/coding-agent/src/utils/ignore-files.ts @@ -6,7 +6,7 @@ import ignore from "ignore"; export const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"] as const; -export type IgnoreMatcher = ReturnType; +export type IgnoreMatcher = ignore.Ignore; /** * Convert a path to POSIX format (forward slashes). diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index a0155fe4f..bd076177b 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -230,16 +230,16 @@ export async function runSearchQuery( * Session is accepted for interface consistency but not used. */ export class SearchTool implements AgentTool { - public readonly name = "web_search"; - public readonly label = "Web Search"; - public readonly description: string; - public readonly parameters = webSearchSchema; + readonly name = "web_search"; + readonly label = "Web Search"; + readonly description: string; + readonly parameters = webSearchSchema; constructor(_session: ToolSession) { this.description = renderPromptTemplate(webSearchDescription); } - public async execute( + async execute( _toolCallId: string, params: SearchParams, _signal?: AbortSignal, diff --git a/packages/coding-agent/src/web/search/providers/anthropic.ts b/packages/coding-agent/src/web/search/providers/anthropic.ts index 10af6b99b..4e455822e 100644 --- a/packages/coding-agent/src/web/search/providers/anthropic.ts +++ b/packages/coding-agent/src/web/search/providers/anthropic.ts @@ -4,7 +4,12 @@ * Uses Claude's built-in web_search_20250305 tool to search the web. * Returns synthesized answers with citations and source metadata. */ -import { applyClaudeToolPrefix, buildAnthropicSystemBlocks, stripClaudeToolPrefix } from "@oh-my-pi/pi-ai"; +import { + type AnthropicSystemBlock, + applyClaudeToolPrefix, + buildAnthropicSystemBlocks, + stripClaudeToolPrefix, +} from "@oh-my-pi/pi-ai"; import { $env } from "@oh-my-pi/pi-utils"; import { buildAnthropicHeaders, buildAnthropicUrl, findAnthropicAuth } from "../../../web/search/auth"; import type { @@ -63,7 +68,7 @@ function buildSystemBlocks( auth: AnthropicAuthConfig, model: string, systemPrompt?: string, -): ReturnType { +): AnthropicSystemBlock[] | undefined { const includeClaudeCode = !model.startsWith("claude-3-5-haiku"); const extraInstructions = auth.isOAuth ? ["You are a helpful AI assistant with web search capabilities."] : []; diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index a310eefd6..de17dbbd1 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -115,13 +115,40 @@ describe("AgentSession auto-compaction queue resume", () => { const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue(); - const runAutoCompaction = ( - session as unknown as { - _runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise; - } - )._runAutoCompaction.bind(session); + // Wait for auto_compaction_end event to know when the async handler is done + const { promise: compactionDone, resolve: onCompactionDone } = Promise.withResolvers(); + session.subscribe(event => { + if (event.type === "auto_compaction_end") onCompactionDone(); + }); - await runAutoCompaction("threshold", false); + // Build a fake AssistantMessage with high token usage to trigger threshold + // compaction (contextWindow=200000, threshold ~80%). + const assistantMsg = { + role: "assistant" as const, + content: [], + api: "anthropic-messages" as const, + provider: "anthropic" as const, + model: "claude-sonnet-4-5", + stopReason: "stop" as const, + usage: { + input: 190000, + output: 1000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 191000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + + // Drive auto-compaction through the event flow: + // message_end → stores #lastAssistantMessage + // agent_end → #checkCompaction → shouldCompact → #runAutoCompaction + session.agent.emitExternalEvent({ type: "message_end", message: assistantMsg }); + session.agent.emitExternalEvent({ type: "agent_end", messages: [assistantMsg] }); + + // Wait for the async compaction handler to finish, then advance past setTimeout(100) + await compactionDone; vi.advanceTimersByTime(200); expect(continueSpy).toHaveBeenCalledTimes(1); diff --git a/packages/coding-agent/test/core/python-kernel-display.test.ts b/packages/coding-agent/test/core/python-kernel-display.test.ts index 86ff190fc..0863ee180 100644 --- a/packages/coding-agent/test/core/python-kernel-display.test.ts +++ b/packages/coding-agent/test/core/python-kernel-display.test.ts @@ -1,20 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { type KernelDisplayOutput, PythonKernel } from "@oh-my-pi/pi-coding-agent/ipy/kernel"; - -const renderDisplay = ( - PythonKernel as unknown as { - prototype: { - renderDisplay: (content: Record) => { - text: string; - outputs: KernelDisplayOutput[]; - }; - }; - } -).prototype.renderDisplay; +import { renderKernelDisplay } from "@oh-my-pi/pi-coding-agent/ipy/kernel"; describe("PythonKernel display rendering", () => { it("normalizes text/plain output and returns no display outputs", () => { - const { text, outputs } = renderDisplay.call({} as PythonKernel, { + const { text, outputs } = renderKernelDisplay({ data: { "text/plain": "hello" }, }); @@ -23,7 +12,7 @@ describe("PythonKernel display rendering", () => { }); it("collects image and json display outputs without text", () => { - const { text, outputs } = renderDisplay.call({} as PythonKernel, { + const { text, outputs } = renderKernelDisplay({ data: { "image/png": "abc", "application/json": { foo: "bar" } }, }); @@ -35,7 +24,7 @@ describe("PythonKernel display rendering", () => { }); it("converts text/html to markdown", () => { - const { text, outputs } = renderDisplay.call({} as PythonKernel, { + const { text, outputs } = renderKernelDisplay({ data: { "text/html": "

Hello

" }, }); @@ -44,7 +33,7 @@ describe("PythonKernel display rendering", () => { }); it("combines text/plain with json output", () => { - const { text, outputs } = renderDisplay.call({} as PythonKernel, { + const { text, outputs } = renderKernelDisplay({ data: { "text/plain": "value", "application/json": { ok: true } }, }); diff --git a/packages/coding-agent/test/core/settings-manager-python.test.ts b/packages/coding-agent/test/core/settings-manager-python.test.ts index a5376faed..d0f13d87a 100644 --- a/packages/coding-agent/test/core/settings-manager-python.test.ts +++ b/packages/coding-agent/test/core/settings-manager-python.test.ts @@ -8,17 +8,4 @@ describe("Settings python settings", () => { expect(settings.get("python.toolMode")).toBe("both"); expect(settings.get("python.kernelMode")).toBe("session"); }); - - it("persists python tool and kernel modes", () => { - const settings = Settings.isolated({}); - - settings.set("python.toolMode", "bash-only"); - settings.set("python.kernelMode", "per-call"); - - expect(settings.get("python.toolMode")).toBe("bash-only"); - expect(settings.get("python.kernelMode")).toBe("per-call"); - const serialized = settings.serialize(); - expect((serialized.python as any)?.toolMode).toBe("bash-only"); - expect((serialized.python as any)?.kernelMode).toBe("per-call"); - }); }); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 910c7bdc3..5ade12cfc 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1,5 +1,4 @@ import { beforeAll, describe, expect, test, vi } from "bun:test"; -import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import { Container } from "@oh-my-pi/pi-tui"; @@ -17,46 +16,46 @@ describe("InteractiveMode.showStatus", () => { }); test("coalesces immediately-sequential status messages", () => { - const fakeThis: any = { + const ctx: any = { chatContainer: new Container(), ui: { requestRender: vi.fn() }, isBackgrounded: false, lastStatusSpacer: undefined, lastStatusText: undefined, }; - fakeThis.uiHelpers = new UiHelpers(fakeThis); + const helpers = new UiHelpers(ctx); - (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE"); - expect(fakeThis.chatContainer.children).toHaveLength(2); - expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_ONE"); + helpers.showStatus("STATUS_ONE"); + expect(ctx.chatContainer.children).toHaveLength(2); + expect(renderLastLine(ctx.chatContainer)).toContain("STATUS_ONE"); - (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO"); + helpers.showStatus("STATUS_TWO"); // second status updates the previous line instead of appending - expect(fakeThis.chatContainer.children).toHaveLength(2); - expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO"); - expect(renderLastLine(fakeThis.chatContainer)).not.toContain("STATUS_ONE"); + expect(ctx.chatContainer.children).toHaveLength(2); + expect(renderLastLine(ctx.chatContainer)).toContain("STATUS_TWO"); + expect(renderLastLine(ctx.chatContainer)).not.toContain("STATUS_ONE"); }); test("appends a new status line if something else was added in between", () => { - const fakeThis: any = { + const ctx: any = { chatContainer: new Container(), ui: { requestRender: vi.fn() }, isBackgrounded: false, lastStatusSpacer: undefined, lastStatusText: undefined, }; - fakeThis.uiHelpers = new UiHelpers(fakeThis); + const helpers = new UiHelpers(ctx); - (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE"); - expect(fakeThis.chatContainer.children).toHaveLength(2); + helpers.showStatus("STATUS_ONE"); + expect(ctx.chatContainer.children).toHaveLength(2); // Something else gets added to the chat in between status updates - fakeThis.chatContainer.addChild({ render: () => ["OTHER"], invalidate: () => {} }); - expect(fakeThis.chatContainer.children).toHaveLength(3); + ctx.chatContainer.addChild({ render: () => ["OTHER"], invalidate: () => {} }); + expect(ctx.chatContainer.children).toHaveLength(3); - (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO"); + helpers.showStatus("STATUS_TWO"); // adds spacer + text - expect(fakeThis.chatContainer.children).toHaveLength(5); - expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO"); + expect(ctx.chatContainer.children).toHaveLength(5); + expect(renderLastLine(ctx.chatContainer)).toContain("STATUS_TWO"); }); }); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index accd439f5..c4e10b034 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -25,6 +25,21 @@ describe("ModelRegistry", () => { } }); + type ProviderConfig = { + baseUrl: string; + apiKey: string; + api: string; + models: Array<{ + id: string; + name: string; + reasoning: boolean; + input: string[]; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + contextWindow: number; + maxTokens: number; + }>; + }; + /** Create minimal provider config */ function providerConfig( baseUrl: string, @@ -47,7 +62,7 @@ describe("ModelRegistry", () => { }; } - function writeModelsJson(providers: Record>) { + function writeModelsJson(providers: Record) { fs.writeFileSync(modelsJsonPath, JSON.stringify({ providers })); } diff --git a/packages/natives/src/native.ts b/packages/natives/src/native.ts index 76d965835..4441c4ccf 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/src/native.ts @@ -80,15 +80,8 @@ function maybeExtractEmbeddedAddon(errors: string[]): string | null { return null; } - try { - fs.statSync(versionedAddonPath); + if (fs.existsSync(versionedAddonPath)) { return versionedAddonPath; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "ENOENT") { - const message = err instanceof Error ? err.message : String(err); - errors.push(`embedded addon stat: ${message}`); - return null; - } } try { diff --git a/packages/react-edit-benchmark/index.ts b/packages/react-edit-benchmark/index.ts index a6414b1e3..bae8815e4 100644 --- a/packages/react-edit-benchmark/index.ts +++ b/packages/react-edit-benchmark/index.ts @@ -335,60 +335,60 @@ async function main(): Promise { } class LiveProgress { - private readonly totalRuns: number; - private readonly runsPerTask: number; - private readonly isTty: boolean; - private started = 0; - private completed = 0; - private success = 0; - private totalInput = 0; - private totalOutput = 0; - private totalDuration = 0; - private totalReads = 0; - private totalEdits = 0; - private totalWrites = 0; - private totalEditSuccesses = 0; - private totalToolInputChars = 0; - private indentScores: number[] = []; - private lastLineLength = 0; + readonly #totalRuns: number; + readonly #runsPerTask: number; + readonly #isTty: boolean; + #started = 0; + #completed = 0; + #success = 0; + #totalInput = 0; + #totalOutput = 0; + #totalDuration = 0; + #totalReads = 0; + #totalEdits = 0; + #totalWrites = 0; + #totalEditSuccesses = 0; + #totalToolInputChars = 0; + #indentScores: number[] = []; + #lastLineLength = 0; constructor(totalRuns: number, runsPerTask: number) { - this.totalRuns = totalRuns; - this.runsPerTask = runsPerTask; - this.isTty = Boolean(process.stdout.isTTY); + this.#totalRuns = totalRuns; + this.#runsPerTask = runsPerTask; + this.#isTty = Boolean(process.stdout.isTTY); } handleEvent(event: ProgressEvent): void { if (event.status === "started") { - this.started += 1; - if (!this.isTty) { - console.log(` [${event.taskId}] Run ${event.runIndex + 1}/${this.runsPerTask} started...`); + this.#started += 1; + if (!this.#isTty) { + console.log(` [${event.taskId}] Run ${event.runIndex + 1}/${this.#runsPerTask} started...`); } - this.renderLine(); + this.#renderLine(); return; } - this.completed += 1; + this.#completed += 1; if (event.result) { if (event.result.success) { - this.success += 1; + this.#success += 1; } - this.totalInput += event.result.tokens.input; - this.totalOutput += event.result.tokens.output; - this.totalDuration += event.result.duration; - this.totalReads += event.result.toolCalls.read; - this.totalEdits += event.result.toolCalls.edit; - this.totalWrites += event.result.toolCalls.write; - this.totalEditSuccesses += event.result.toolCalls.editSuccesses; - this.totalToolInputChars += event.result.toolCalls.totalInputChars; + this.#totalInput += event.result.tokens.input; + this.#totalOutput += event.result.tokens.output; + this.#totalDuration += event.result.duration; + this.#totalReads += event.result.toolCalls.read; + this.#totalEdits += event.result.toolCalls.edit; + this.#totalWrites += event.result.toolCalls.write; + this.#totalEditSuccesses += event.result.toolCalls.editSuccesses; + this.#totalToolInputChars += event.result.toolCalls.totalInputChars; if (typeof event.result.indentScore === "number") { - this.indentScores.push(event.result.indentScore); + this.#indentScores.push(event.result.indentScore); } } if (event.result && !event.result.success && event.result.error) { - this.flushLine(); - console.log(` [${event.taskId}] Run ${event.runIndex + 1}/${this.runsPerTask} failed: ${event.result.error}`); + this.#flushLine(); + console.log(` [${event.taskId}] Run ${event.runIndex + 1}/${this.#runsPerTask} failed: ${event.result.error}`); if (event.result.diff) { const diffLines = event.result.diff.split("\n").slice(0, 30); if (diffLines.length > 0) { @@ -403,76 +403,76 @@ class LiveProgress { } } - if (!this.isTty) { + if (!this.#isTty) { const status = event.result?.success ? "completed" : "failed"; - console.log(` [${event.taskId}] Run ${event.runIndex + 1}/${this.runsPerTask} ${status}`); + console.log(` [${event.taskId}] Run ${event.runIndex + 1}/${this.#runsPerTask} ${status}`); } - this.renderLine(); + this.#renderLine(); } finish(): void { - this.flushLine(); - this.printSummary(); + this.#flushLine(); + this.#printSummary(); } - private printSummary(): void { - const n = this.completed; + #printSummary(): void { + const n = this.#completed; if (n === 0) return; - const successRate = (this.success / n) * 100; - const editSuccessRate = this.totalEdits > 0 ? (this.totalEditSuccesses / this.totalEdits) * 100 : 100; + const successRate = (this.#success / n) * 100; + const editSuccessRate = this.#totalEdits > 0 ? (this.#totalEditSuccesses / this.#totalEdits) * 100 : 100; const avgIndent = - this.indentScores.length > 0 ? this.indentScores.reduce((a, b) => a + b, 0) / this.indentScores.length : 0; + this.#indentScores.length > 0 ? this.#indentScores.reduce((a, b) => a + b, 0) / this.#indentScores.length : 0; console.log(""); console.log("Runtime Stats:"); - console.log(` Task success: ${successRate.toFixed(1)}% (${this.success}/${n})`); - console.log(` Edit success: ${editSuccessRate.toFixed(1)}% (${this.totalEditSuccesses}/${this.totalEdits})`); + console.log(` Task success: ${successRate.toFixed(1)}% (${this.#success}/${n})`); + console.log(` Edit success: ${editSuccessRate.toFixed(1)}% (${this.#totalEditSuccesses}/${this.#totalEdits})`); console.log(` Avg indent score: ${avgIndent.toFixed(2)}`); - console.log(` Tool calls: read=${this.totalReads} edit=${this.totalEdits} write=${this.totalWrites}`); - console.log(` Tool input chars: ${this.totalToolInputChars.toLocaleString()}`); + console.log(` Tool calls: read=${this.#totalReads} edit=${this.#totalEdits} write=${this.#totalWrites}`); + console.log(` Tool input chars: ${this.#totalToolInputChars.toLocaleString()}`); console.log( - ` Avg tokens/task: ${Math.round(this.totalInput / n)} in / ${Math.round(this.totalOutput / n)} out`, + ` Avg tokens/task: ${Math.round(this.#totalInput / n)} in / ${Math.round(this.#totalOutput / n)} out`, ); - console.log(` Avg time/task: ${Math.round(this.totalDuration / n)}ms`); + console.log(` Avg time/task: ${Math.round(this.#totalDuration / n)}ms`); } - private renderLine(): void { - if (!this.isTty) { + #renderLine(): void { + if (!this.#isTty) { return; } - const successRate = this.completed > 0 ? (this.success / this.completed) * 100 : 0; - const editRate = this.totalEdits > 0 ? (this.totalEditSuccesses / this.totalEdits) * 100 : 100; - const avgInput = this.completed > 0 ? Math.round(this.totalInput / this.completed) : 0; - const avgOutput = this.completed > 0 ? Math.round(this.totalOutput / this.completed) : 0; - const avgDuration = this.completed > 0 ? Math.round(this.totalDuration / this.completed) : 0; - const inFlight = this.started - this.completed; - const bar = this.renderBar(this.completed, this.totalRuns, 20); - const line = ` ${bar} ${this.completed}/${this.totalRuns} task=${successRate.toFixed(0)}% edit=${editRate.toFixed(0)}% tok=${avgInput}/${avgOutput} ${avgDuration}ms r/e/w=${this.totalReads}/${this.totalEdits}/${this.totalWrites} fly=${inFlight}`; - this.writeLine(line); + const successRate = this.#completed > 0 ? (this.#success / this.#completed) * 100 : 0; + const editRate = this.#totalEdits > 0 ? (this.#totalEditSuccesses / this.#totalEdits) * 100 : 100; + const avgInput = this.#completed > 0 ? Math.round(this.#totalInput / this.#completed) : 0; + const avgOutput = this.#completed > 0 ? Math.round(this.#totalOutput / this.#completed) : 0; + const avgDuration = this.#completed > 0 ? Math.round(this.#totalDuration / this.#completed) : 0; + const inFlight = this.#started - this.#completed; + const bar = this.#renderBar(this.#completed, this.#totalRuns, 20); + const line = ` ${bar} ${this.#completed}/${this.#totalRuns} task=${successRate.toFixed(0)}% edit=${editRate.toFixed(0)}% tok=${avgInput}/${avgOutput} ${avgDuration}ms r/e/w=${this.#totalReads}/${this.#totalEdits}/${this.#totalWrites} fly=${inFlight}`; + this.#writeLine(line); } - private renderBar(done: number, total: number, width: number): string { + #renderBar(done: number, total: number, width: number): string { const ratio = total === 0 ? 0 : done / total; const filled = Math.round(ratio * width); const empty = Math.max(0, width - filled); return `[${"#".repeat(filled)}${"-".repeat(empty)}]`; } - private writeLine(line: string): void { - const pad = this.lastLineLength > line.length ? padding(this.lastLineLength - line.length) : ""; + #writeLine(line: string): void { + const pad = this.#lastLineLength > line.length ? padding(this.#lastLineLength - line.length) : ""; process.stdout.write(`\r${line}${pad}`); - this.lastLineLength = line.length; + this.#lastLineLength = line.length; } - private flushLine(): void { - if (!this.isTty) { + #flushLine(): void { + if (!this.#isTty) { return; } - if (this.lastLineLength > 0) { - process.stdout.write("\r" + padding(this.lastLineLength) + "\r"); - this.lastLineLength = 0; + if (this.#lastLineLength > 0) { + process.stdout.write("\r" + padding(this.#lastLineLength) + "\r"); + this.#lastLineLength = 0; } } } diff --git a/packages/react-edit-benchmark/mutations.ts b/packages/react-edit-benchmark/mutations.ts index cb0d94556..c23c0c6e7 100644 --- a/packages/react-edit-benchmark/mutations.ts +++ b/packages/react-edit-benchmark/mutations.ts @@ -135,16 +135,16 @@ class SwapComparisonMutation extends BaseMutation { fixHint = "Swap the comparison operator to the correct variant."; description = "A comparison operator is subtly wrong."; - private pattern = /(?<=[\s(])(?<=|>=|<|>)(?=\s*[\d\w(])/; - private swap: Record = { "<=": "<", "<": "<=", ">=": ">", ">": ">=" }; + #pattern = /(?<=[\s(])(?<=|>=|<|>)(?=\s*[\d\w(])/; + #swap: Record = { "<=": "<", "<": "<=", ">=": ">", ">": ">=" }; canApply(content: string): boolean { - return this.pattern.test(content); + return this.#pattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); - const candidate = pickCandidate(lines, this.pattern, (m) => this.swap[m.groups?.op ?? m[0]] ?? null, rng); + const candidate = pickCandidate(lines, this.#pattern, (m) => this.#swap[m.groups?.op ?? m[0]] ?? null, rng); if (!candidate) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; const info = applyCandidate(lines, candidate); return [lines.join("\n"), info]; @@ -157,16 +157,16 @@ class SwapEqualityMutation extends BaseMutation { fixHint = "Fix the equality comparison operator."; description = "An equality operator is inverted."; - private pattern = /(?])(?===|!==|==|!=)(?!=)/; - private swap: Record = { "===": "!==", "!==": "===", "==": "!=", "!=": "==" }; + #pattern = /(?])(?===|!==|==|!=)(?!=)/; + #swap: Record = { "===": "!==", "!==": "===", "==": "!=", "!=": "==" }; canApply(content: string): boolean { - return this.pattern.test(content); + return this.#pattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); - const candidate = pickCandidate(lines, this.pattern, (m) => this.swap[m.groups?.op ?? m[0]] ?? null, rng); + const candidate = pickCandidate(lines, this.#pattern, (m) => this.#swap[m.groups?.op ?? m[0]] ?? null, rng); if (!candidate) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; const info = applyCandidate(lines, candidate); return [lines.join("\n"), info]; @@ -179,16 +179,16 @@ class SwapLogicalMutation extends BaseMutation { fixHint = "Use the intended boolean operator."; description = "A boolean operator is incorrect."; - private pattern = /(?&&|\|\|)/; - private swap: Record = { "&&": "||", "||": "&&" }; + #pattern = /(?&&|\|\|)/; + #swap: Record = { "&&": "||", "||": "&&" }; canApply(content: string): boolean { - return this.pattern.test(content); + return this.#pattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); - const candidate = pickCandidate(lines, this.pattern, (m) => this.swap[m.groups?.op ?? m[0]] ?? null, rng); + const candidate = pickCandidate(lines, this.#pattern, (m) => this.#swap[m.groups?.op ?? m[0]] ?? null, rng); if (!candidate) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; const info = applyCandidate(lines, candidate); return [lines.join("\n"), info]; @@ -201,15 +201,15 @@ class RemoveNegationMutation extends BaseMutation { fixHint = "Remove the stray logical negation."; description = "A negation operator is accidentally applied."; - private pattern = /!(?!=)/; + #pattern = /!(?!=)/; canApply(content: string): boolean { - return this.pattern.test(content); + return this.#pattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); - const candidate = pickCandidate(lines, this.pattern, () => "", rng); + const candidate = pickCandidate(lines, this.#pattern, () => "", rng); if (!candidate) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; const info = applyCandidate(lines, candidate); return [lines.join("\n"), info]; @@ -222,16 +222,16 @@ class SwapIncDecMutation extends BaseMutation { fixHint = "Replace the increment/decrement operator with the intended one."; description = "An increment/decrement operator points the wrong direction."; - private pattern = /(?\+\+|--)/; - private swap: Record = { "++": "--", "--": "++" }; + #pattern = /(?\+\+|--)/; + #swap: Record = { "++": "--", "--": "++" }; canApply(content: string): boolean { - return this.pattern.test(content); + return this.#pattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); - const candidate = pickCandidate(lines, this.pattern, (m) => this.swap[m.groups?.op ?? m[0]] ?? null, rng); + const candidate = pickCandidate(lines, this.#pattern, (m) => this.#swap[m.groups?.op ?? m[0]] ?? null, rng); if (!candidate) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; const info = applyCandidate(lines, candidate); return [lines.join("\n"), info]; @@ -244,16 +244,16 @@ class SwapArithmeticMutation extends BaseMutation { fixHint = "Correct the arithmetic operator."; description = "An arithmetic operator was swapped."; - private pattern = /(?<=\s)(?[+\-*/])(?=\s)/; - private swap: Record = { "+": "-", "-": "+", "*": "/", "/": "*" }; + #pattern = /(?<=\s)(?[+\-*/])(?=\s)/; + #swap: Record = { "+": "-", "-": "+", "*": "/", "/": "*" }; canApply(content: string): boolean { - return this.pattern.test(content); + return this.#pattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); - const candidate = pickCandidate(lines, this.pattern, (m) => this.swap[m.groups?.op ?? m[0]] ?? null, rng); + const candidate = pickCandidate(lines, this.#pattern, (m) => this.#swap[m.groups?.op ?? m[0]] ?? null, rng); if (!candidate) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; const info = applyCandidate(lines, candidate); return [lines.join("\n"), info]; @@ -266,16 +266,16 @@ class BooleanLiteralFlipMutation extends BaseMutation { fixHint = "Flip the boolean literal to the intended value."; description = "A boolean literal is inverted."; - private pattern = /\b(true|false)\b/; - private swap: Record = { true: "false", false: "true" }; + #pattern = /\b(true|false)\b/; + #swap: Record = { true: "false", false: "true" }; canApply(content: string): boolean { - return this.pattern.test(content); + return this.#pattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); - const candidate = pickCandidate(lines, this.pattern, (m) => this.swap[m[1]] ?? null, rng); + const candidate = pickCandidate(lines, this.#pattern, (m) => this.#swap[m[1]] ?? null, rng); if (!candidate) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; const info = applyCandidate(lines, candidate); return [lines.join("\n"), info]; @@ -289,15 +289,15 @@ class OptionalChainRemovalMutation extends BaseMutation { "Restore the optional chaining operator (`?.`) at the ONE location where it was removed. Do not add optional chaining elsewhere."; description = "Optional chaining was removed from a property access."; - private pattern = /\?\.(?=[\w\[(])/; + #pattern = /\?\.(?=[\w\[(])/; canApply(content: string): boolean { - return this.pattern.test(content); + return this.#pattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); - const candidate = pickCandidate(lines, this.pattern, () => ".", rng); + const candidate = pickCandidate(lines, this.#pattern, () => ".", rng); if (!candidate) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; const info = applyCandidate(lines, candidate); return [lines.join("\n"), info]; @@ -310,17 +310,17 @@ class CallArgumentSwapMutation extends BaseMutation { fixHint = "Swap the two arguments to their original order."; description = "Two arguments in a call are swapped."; - private pattern = /(?[\w.$]+)\(\s*(?[^(),]+?)\s*,\s*(?[^(),]+?)\s*\)/; + #pattern = /(?[\w.$]+)\(\s*(?[^(),]+?)\s*,\s*(?[^(),]+?)\s*\)/; canApply(content: string): boolean { - return this.pattern.test(content); + return this.#pattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); const candidate = pickCandidate( lines, - this.pattern, + this.#pattern, (m) => { const callee = m.groups?.callee ?? ""; const a = m.groups?.a ?? ""; @@ -341,16 +341,16 @@ class NullishCoalescingSwapMutation extends BaseMutation { fixHint = "Use the intended nullish/logical operator."; description = "A nullish coalescing operator was swapped."; - private pattern = /(?\?\?|\|\|)/; - private swap: Record = { "??": "||", "||": "??" }; + #pattern = /(?\?\?|\|\|)/; + #swap: Record = { "??": "||", "||": "??" }; canApply(content: string): boolean { - return this.pattern.test(content); + return this.#pattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); - const candidate = pickCandidate(lines, this.pattern, (m) => this.swap[m.groups?.op ?? m[0]] ?? null, rng); + const candidate = pickCandidate(lines, this.#pattern, (m) => this.#swap[m.groups?.op ?? m[0]] ?? null, rng); if (!candidate) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; const info = applyCandidate(lines, candidate); return [lines.join("\n"), info]; @@ -363,17 +363,17 @@ class RegexQuantifierSwapMutation extends BaseMutation { fixHint = "Fix the ONE regex quantifier that was swapped (between `+` and `*`). Do not modify other quantifiers."; description = "A regex quantifier was swapped, changing whitespace matching."; - private literalPattern = /\/(?(?:\\\/|[^/\n])*)\/(?[gimsuy]*)/g; - private quantPattern = /(\\[A-Za-z]|\\.|\[[^\]]+\])(?[+*])/g; + #literalPattern = /\/(?(?:\\\/|[^/\n])*)\/(?[gimsuy]*)/g; + #quantPattern = /(\\[A-Za-z]|\\.|\[[^\]]+\])(?[+*])/g; canApply(content: string): boolean { const lines = content.split("\n"); - return this.iterCandidates(lines).length > 0; + return this.#iterCandidates(lines).length > 0; } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); - const candidates = this.iterCandidates(lines); + const candidates = this.#iterCandidates(lines); if (candidates.length === 0) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; const lineCounts = new Map(); @@ -388,11 +388,11 @@ class RegexQuantifierSwapMutation extends BaseMutation { return [lines.join("\n"), info]; } - private iterCandidates(lines: string[]): Candidate[] { + #iterCandidates(lines: string[]): Candidate[] { const candidates: Candidate[] = []; for (let lineNumber = 1; lineNumber <= lines.length; lineNumber++) { const line = lines[lineNumber - 1]; - const litRegex = new RegExp(this.literalPattern.source, this.literalPattern.flags); + const litRegex = new RegExp(this.#literalPattern.source, this.#literalPattern.flags); let litMatch: RegExpExecArray | null; while ((litMatch = litRegex.exec(line)) !== null) { if (isCommented(line, litMatch.index)) continue; @@ -402,7 +402,7 @@ class RegexQuantifierSwapMutation extends BaseMutation { } const bodyStart = litMatch.index + 1; // after opening / const body = litMatch.groups?.body ?? ""; - const quantRegex = new RegExp(this.quantPattern.source, this.quantPattern.flags); + const quantRegex = new RegExp(this.#quantPattern.source, this.#quantPattern.flags); let tokenMatch: RegExpExecArray | null; while ((tokenMatch = quantRegex.exec(body)) !== null) { const quantifier = tokenMatch.groups?.quant ?? tokenMatch[2]; @@ -428,10 +428,10 @@ class UnicodeHyphenMutation extends BaseMutation { fixHint = "Replace the unicode dash with a plain ASCII hyphen."; description = "A string literal contains a lookalike unicode dash."; - private stringPattern = /(?['"])(?(?:\\.|[^\\\n])*?)\k/g; + #stringPattern = /(?['"])(?(?:\\.|[^\\\n])*?)\k/g; canApply(content: string): boolean { - return content.includes("-") && this.stringPattern.test(content); + return content.includes("-") && this.#stringPattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { @@ -439,7 +439,7 @@ class UnicodeHyphenMutation extends BaseMutation { const candidates: Candidate[] = []; for (let lineNumber = 1; lineNumber <= lines.length; lineNumber++) { const line = lines[lineNumber - 1]; - const regex = new RegExp(this.stringPattern.source, this.stringPattern.flags); + const regex = new RegExp(this.#stringPattern.source, this.#stringPattern.flags); let match: RegExpExecArray | null; while ((match = regex.exec(line)) !== null) { if (isCommented(line, match.index)) continue; @@ -469,8 +469,8 @@ class IdentifierMultiEditMutation extends BaseMutation { fixHint = "Restore the identifier to its original spelling in all affected locations."; description = "An identifier is misspelled in multiple separate locations."; - private pattern = /\b[A-Za-z_$][\w$]*\b/g; - private keywords = new Set([ + #pattern = /\b[A-Za-z_$][\w$]*\b/g; + #keywords = new Set([ "await", "break", "case", @@ -520,7 +520,7 @@ class IdentifierMultiEditMutation extends BaseMutation { ]); canApply(content: string): boolean { - return this.pattern.test(content); + return this.#pattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { @@ -530,12 +530,12 @@ class IdentifierMultiEditMutation extends BaseMutation { for (let lineNumber = 1; lineNumber <= lines.length; lineNumber++) { const line = lines[lineNumber - 1]; const masked = stripStrings(line); - const regex = new RegExp(this.pattern.source, this.pattern.flags); + const regex = new RegExp(this.#pattern.source, this.#pattern.flags); let match: RegExpExecArray | null; while ((match = regex.exec(masked)) !== null) { if (isCommented(line, match.index)) continue; const identifier = match[0]; - if (this.keywords.has(identifier)) continue; + if (this.#keywords.has(identifier)) continue; const mutated = mutateIdentifier(identifier); if (mutated === null) continue; const spans = occurrences.get(identifier) ?? []; @@ -587,17 +587,17 @@ class DuplicateLineLiteralFlipMutation extends BaseMutation { fixHint = "Fix the literal or operator on the duplicated line."; description = "A duplicated line contains a subtle literal/operator change."; - private boolPattern = /\b(true|false)\b/; - private eqPattern = /(?])(?:===|!==|==|!=)(?!=)/; - private compPattern = /(?<=[\s(])(?<=|>=|<|>)(?=\s*[\d\w(])/; - private boolSwap: Record = { true: "false", false: "true" }; - private eqSwap: Record = { "===": "!==", "!==": "===", "==": "!=", "!=": "==" }; - private compSwap: Record = { "<=": "<", "<": "<=", ">=": ">", ">": ">=" }; + #boolPattern = /\b(true|false)\b/; + #eqPattern = /(?])(?:===|!==|==|!=)(?!=)/; + #compPattern = /(?<=[\s(])(?<=|>=|<|>)(?=\s*[\d\w(])/; + #boolSwap: Record = { true: "false", false: "true" }; + #eqSwap: Record = { "===": "!==", "!==": "===", "==": "!=", "!=": "==" }; + #compSwap: Record = { "<=": "<", "<": "<=", ">=": ">", ">": ">=" }; canApply(content: string): boolean { const lines = content.split("\n"); if (lines.length === new Set(lines).size) return false; - return this.boolPattern.test(content) || this.eqPattern.test(content) || this.compPattern.test(content); + return this.#boolPattern.test(content) || this.#eqPattern.test(content) || this.#compPattern.test(content); } mutate(content: string, rng: () => number): [string, MutationInfo] { @@ -614,9 +614,9 @@ class DuplicateLineLiteralFlipMutation extends BaseMutation { for (const [line, indices] of lineMap) { if (indices.length < 2) continue; for (const [pattern, swapMap] of [ - [this.boolPattern, this.boolSwap], - [this.eqPattern, this.eqSwap], - [this.compPattern, this.compSwap], + [this.#boolPattern, this.#boolSwap], + [this.#eqPattern, this.#eqSwap], + [this.#compPattern, this.#compSwap], ] as const) { const regex = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g"); let match: RegExpExecArray | null; @@ -651,25 +651,25 @@ class SwapAdjacentLinesMutation extends BaseMutation { fixHint = "Swap the two adjacent lines back to their original order."; description = "Two adjacent statements are in the wrong order."; - private statementPattern = + #statementPattern = /^\s*(?:(?:const|let|var)\s+\w+\s*=|return\s+|\w+\s*(?:\.\w+)*\s*\(|\w+\s*(?:\.\w+)*\s*=)/; canApply(content: string): boolean { const lines = content.split("\n"); for (let i = 0; i < lines.length - 1; i++) { - if (this.isSwappablePair(lines, i)) return true; + if (this.#isSwappablePair(lines, i)) return true; } return false; } - private isSwappablePair(lines: string[], i: number): boolean { + #isSwappablePair(lines: string[], i: number): boolean { const lineA = lines[i]; const lineB = lines[i + 1]; if (!lineA.trim() || !lineB.trim()) return false; const indentA = lineA.length - lineA.trimStart().length; const indentB = lineB.length - lineB.trimStart().length; if (indentA !== indentB) return false; - if (!this.statementPattern.test(lineA) || !this.statementPattern.test(lineB)) return false; + if (!this.#statementPattern.test(lineA) || !this.#statementPattern.test(lineB)) return false; if (lineA.trim() === lineB.trim()) return false; if (lineA.includes("//") || lineB.includes("//")) return false; return true; @@ -679,7 +679,7 @@ class SwapAdjacentLinesMutation extends BaseMutation { const lines = content.split("\n"); const candidates: number[] = []; for (let i = 0; i < lines.length - 1; i++) { - if (this.isSwappablePair(lines, i)) candidates.push(i); + if (this.#isSwappablePair(lines, i)) candidates.push(i); } if (candidates.length === 0) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; @@ -704,11 +704,11 @@ class SwapIfElseBranchesMutation extends BaseMutation { "Swap the if and else branch bodies back to their original positions. The condition should be negated to match."; description = "The if and else branches are swapped (condition should be negated)."; - private ifPattern = /^\s*if\s*\([^)]+\)\s*\{/; + #ifPattern = /^\s*if\s*\([^)]+\)\s*\{/; canApply(content: string): boolean { if (!content.includes("} else {")) return false; - return content.split("\n").some((line) => this.ifPattern.test(line)); + return content.split("\n").some((line) => this.#ifPattern.test(line)); } mutate(content: string, rng: () => number): [string, MutationInfo] { @@ -718,7 +718,7 @@ class SwapIfElseBranchesMutation extends BaseMutation { let i = 0; while (i < lines.length) { const line = lines[i]; - if (this.ifPattern.test(line)) { + if (this.#ifPattern.test(line)) { const ifStart = i; let braceDepth = (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length; let j = i + 1; @@ -786,21 +786,21 @@ class RemoveEarlyReturnMutation extends BaseMutation { "Restore the missing guard clause (if statement with early return). Add back the exact 3-line pattern: if condition, return statement, closing brace."; description = "A guard clause (early return) was removed."; - private guardPattern = /^(?\s*)if\s*\([^)]+\)\s*\{\s*$/; - private returnPattern = /^\s*return\b/; + #guardPattern = /^(?\s*)if\s*\([^)]+\)\s*\{\s*$/; + #returnPattern = /^\s*return\b/; canApply(content: string): boolean { const lines = content.split("\n"); for (let i = 0; i < lines.length - 2; i++) { - if (this.isGuardClause(lines, i)) return true; + if (this.#isGuardClause(lines, i)) return true; } return false; } - private isGuardClause(lines: string[], i: number): boolean { - if (!this.guardPattern.test(lines[i])) return false; + #isGuardClause(lines: string[], i: number): boolean { + if (!this.#guardPattern.test(lines[i])) return false; if (i + 2 >= lines.length) return false; - if (!this.returnPattern.test(lines[i + 1])) return false; + if (!this.#returnPattern.test(lines[i + 1])) return false; if (lines[i + 2].trim() !== "}") return false; return true; } @@ -809,7 +809,7 @@ class RemoveEarlyReturnMutation extends BaseMutation { const lines = content.split("\n"); const candidates: number[] = []; for (let i = 0; i < lines.length - 2; i++) { - if (this.isGuardClause(lines, i)) candidates.push(i); + if (this.#isGuardClause(lines, i)) candidates.push(i); } if (candidates.length === 0) return [content, { lineNumber: 0, originalSnippet: "", mutatedSnippet: "" }]; @@ -835,10 +835,10 @@ class SwapNamedImportsMutation extends BaseMutation { "Swap ONLY the two imported names that are in the wrong order. Do not reorder other imports or modify other import statements."; description = "Two named imports are swapped in a destructuring import."; - private importPattern = /import\s*\{(?[^}]+)\}\s*from\s*['"]/; + #importPattern = /import\s*\{(?[^}]+)\}\s*from\s*['"]/; canApply(content: string): boolean { - const regex = new RegExp(this.importPattern.source, "g"); + const regex = new RegExp(this.#importPattern.source, "g"); let match: RegExpExecArray | null; while ((match = regex.exec(content)) !== null) { const imports = match.groups?.imports ?? ""; @@ -858,7 +858,7 @@ class SwapNamedImportsMutation extends BaseMutation { for (let lineNumber = 1; lineNumber <= lines.length; lineNumber++) { const line = lines[lineNumber - 1]; - const regex = new RegExp(this.importPattern.source, "g"); + const regex = new RegExp(this.#importPattern.source, "g"); let match: RegExpExecArray | null; while ((match = regex.exec(line)) !== null) { const importsStr = match.groups?.imports ?? ""; @@ -902,17 +902,17 @@ class DeleteStatementMutation extends BaseMutation { fixHint = "Restore the deleted statement."; description = "A critical statement was deleted from the code."; - private statementPattern = /^\s*(?:(?:const|let|var)\s+\w+\s*=.+;|\w+\s*\+=.+;|\w+\s*-=.+;|\w+\s*=\s*\w+.+;)\s*$/; + #statementPattern = /^\s*(?:(?:const|let|var)\s+\w+\s*=.+;|\w+\s*\+=.+;|\w+\s*-=.+;|\w+\s*=\s*\w+.+;)\s*$/; canApply(content: string): boolean { - return content.split("\n").some((line) => this.statementPattern.test(line)); + return content.split("\n").some((line) => this.#statementPattern.test(line)); } mutate(content: string, rng: () => number): [string, MutationInfo] { const lines = content.split("\n"); const candidates: number[] = []; for (let i = 0; i < lines.length; i++) { - if (this.statementPattern.test(lines[i])) { + if (this.#statementPattern.test(lines[i])) { if (!lines[i].includes("//") && !lines[i].includes("/*")) { candidates.push(i); } @@ -941,7 +941,7 @@ class OffByOneMutation extends BaseMutation { fixHint = "Fix the off-by-one error in the numeric literal or comparison."; description = "A numeric boundary has an off-by-one error."; - private patterns: Array<[RegExp, (m: RegExpExecArray) => string]> = [ + #patterns: Array<[RegExp, (m: RegExpExecArray) => string]> = [ [/(?<=[\s(,=<>])0(?=[\s),;])/, () => "1"], [/(?<=[\s(,=<>])1(?=[\s),;])/, () => "0"], [/\.length\s*-\s*1(?=[\s),;\]])/, () => ".length - 2"], @@ -951,7 +951,7 @@ class OffByOneMutation extends BaseMutation { ]; canApply(content: string): boolean { - return this.patterns.some(([pattern]) => pattern.test(content)); + return this.#patterns.some(([pattern]) => pattern.test(content)); } mutate(content: string, rng: () => number): [string, MutationInfo] { @@ -971,7 +971,7 @@ class OffByOneMutation extends BaseMutation { continue; } - for (const [pattern, replacementFn] of this.patterns) { + for (const [pattern, replacementFn] of this.#patterns) { const regex = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g"); let match: RegExpExecArray | null; while ((match = regex.exec(line)) !== null) { diff --git a/packages/stats/src/aggregator.ts b/packages/stats/src/aggregator.ts index 346fbce34..3cbb29227 100644 --- a/packages/stats/src/aggregator.ts +++ b/packages/stats/src/aggregator.ts @@ -1,4 +1,4 @@ -import * as fs from "node:fs/promises"; +import * as fs from "node:fs"; import { getRecentErrors as dbGetRecentErrors, getRecentRequests as dbGetRecentRequests, @@ -24,9 +24,9 @@ import type { DashboardStats, MessageStats, RequestDetails } from "./types"; */ async function syncSessionFile(sessionFile: string): Promise { // Get file stats - let fileStats: Awaited>; + let fileStats: fs.Stats; try { - fileStats = await fs.stat(sessionFile); + fileStats = await fs.promises.stat(sessionFile); } catch { return 0; } diff --git a/packages/tui/README.md b/packages/tui/README.md index 14993e874..ec438b65e 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -574,8 +574,8 @@ class MyInteractiveComponent implements Component { private selectedIndex = 0; private items = ["Option 1", "Option 2", "Option 3"]; - public onSelect?: (index: number) => void; - public onCancel?: () => void; + onSelect?: (index: number) => void; + onCancel?: () => void; handleInput(data: string): void { if (isArrowUp(data)) { diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 25a9ad98b..fb95c165f 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -169,14 +169,14 @@ export interface AutocompleteProvider { // Combined provider that handles both slash commands and file paths export class CombinedAutocompleteProvider implements AutocompleteProvider { - private commands: (SlashCommand | AutocompleteItem)[]; - private basePath: string; - private dirCache: Map = new Map(); - private readonly DIR_CACHE_TTL = 2000; // 2 seconds + #commands: (SlashCommand | AutocompleteItem)[]; + #basePath: string; + #dirCache: Map = new Map(); + readonly #DIR_CACHE_TTL = 2000; // 2 seconds constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string = process.cwd()) { - this.commands = commands; - this.basePath = basePath; + this.#commands = commands; + this.#basePath = basePath; } async getSuggestions( @@ -188,15 +188,15 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { const textBeforeCursor = currentLine.slice(0, cursorCol); // Check for @ file reference (fuzzy search) - must be after a delimiter or at start - const atPrefix = this.extractAtPrefix(textBeforeCursor); + const atPrefix = this.#extractAtPrefix(textBeforeCursor); if (atPrefix) { const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix); const suggestions = rawPrefix.length > 0 - ? await this.getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix }) - : await this.getFileSuggestions("@"); + ? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix }) + : await this.#getFileSuggestions("@"); if (suggestions.length === 0 && rawPrefix.length > 0) { - const fallback = await this.getFileSuggestions(atPrefix); + const fallback = await this.#getFileSuggestions(atPrefix); if (fallback.length === 0) return null; return { items: fallback, prefix: atPrefix }; } @@ -218,7 +218,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { const lowerPrefix = prefix.toLowerCase(); // Filter commands using fuzzy matching (subsequence match) - const matches = this.commands + const matches = this.#commands .filter(cmd => { const name = "name" in cmd ? cmd.name : cmd.value; if (!name) return false; @@ -255,7 +255,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/" const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space - const command = this.commands.find(cmd => { + const command = this.#commands.find(cmd => { const name = "name" in cmd ? cmd.name : cmd.value; return name === commandName; }); @@ -276,10 +276,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } // Check for file paths - triggered by Tab or if we detect a path pattern - const pathMatch = this.extractPathPrefix(textBeforeCursor, false); + const pathMatch = this.#extractPathPrefix(textBeforeCursor, false); if (pathMatch !== null) { - const suggestions = await this.getFileSuggestions(pathMatch); + const suggestions = await this.#getFileSuggestions(pathMatch); if (suggestions.length === 0) return null; // Check if we have an exact match that is a directory @@ -372,7 +372,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } // Extract @ prefix for fuzzy file suggestions - private extractAtPrefix(text: string): string | null { + #extractAtPrefix(text: string): string | null { const quotedPrefix = extractQuotedPrefix(text); if (quotedPrefix?.startsWith('@"')) { return quotedPrefix; @@ -389,7 +389,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } // Extract a path-like prefix from the text before cursor - private extractPathPrefix(text: string, forceExtract: boolean = false): string | null { + #extractPathPrefix(text: string, forceExtract: boolean = false): string | null { const quotedPrefix = extractQuotedPrefix(text); if (quotedPrefix) { return quotedPrefix; @@ -419,7 +419,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } // Expand home directory (~/) to actual home path - private expandHomePath(filePath: string): string { + #expandHomePath(filePath: string): string { if (filePath.startsWith("~/")) { const expandedPath = path.join(os.homedir(), filePath.slice(2)); // Preserve trailing slash if original path had one @@ -430,40 +430,40 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return filePath; } - private async getCachedDirEntries(searchDir: string): Promise { + async #getCachedDirEntries(searchDir: string): Promise { const now = Date.now(); - const cached = this.dirCache.get(searchDir); + const cached = this.#dirCache.get(searchDir); - if (cached && now - cached.timestamp < this.DIR_CACHE_TTL) { + if (cached && now - cached.timestamp < this.#DIR_CACHE_TTL) { return cached.entries; } const entries = await fs.promises.readdir(searchDir, { withFileTypes: true }); - this.dirCache.set(searchDir, { entries, timestamp: now }); + this.#dirCache.set(searchDir, { entries, timestamp: now }); - if (this.dirCache.size > 100) { - const sortedKeys = [...this.dirCache.entries()] + if (this.#dirCache.size > 100) { + const sortedKeys = [...this.#dirCache.entries()] .sort((a, b) => a[1].timestamp - b[1].timestamp) .slice(0, 50) .map(([key]) => key); for (const key of sortedKeys) { - this.dirCache.delete(key); + this.#dirCache.delete(key); } } return entries; } - public invalidateDirCache(dir?: string): void { + invalidateDirCache(dir?: string): void { if (dir) { - this.dirCache.delete(dir); + this.#dirCache.delete(dir); } else { - this.dirCache.clear(); + this.#dirCache.clear(); } } // Get file/directory suggestions for a given path prefix - private async getFileSuggestions(prefix: string): Promise { + async #getFileSuggestions(prefix: string): Promise { try { let searchDir: string; let searchPrefix: string; @@ -472,7 +472,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // Handle home directory expansion if (expandedPrefix.startsWith("~")) { - expandedPrefix = this.expandHomePath(expandedPrefix); + expandedPrefix = this.#expandHomePath(expandedPrefix); } const isRootPrefix = @@ -489,7 +489,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { searchDir = expandedPrefix; } else { - searchDir = path.join(this.basePath, expandedPrefix); + searchDir = path.join(this.#basePath, expandedPrefix); } searchPrefix = ""; } else if (rawPrefix.endsWith("/")) { @@ -497,7 +497,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { searchDir = expandedPrefix; } else { - searchDir = path.join(this.basePath, expandedPrefix); + searchDir = path.join(this.#basePath, expandedPrefix); } searchPrefix = ""; } else { @@ -507,12 +507,12 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { searchDir = dir; } else { - searchDir = path.join(this.basePath, dir); + searchDir = path.join(this.#basePath, dir); } searchPrefix = file; } - const entries = await this.getCachedDirEntries(searchDir); + const entries = await this.#getCachedDirEntries(searchDir); const suggestions: AutocompleteItem[] = []; for (const entry of entries) { @@ -600,7 +600,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { // Score an entry against the query (higher = better match) // isDirectory adds bonus to prioritize folders - private scoreEntry(filePath: string, query: string, isDirectory: boolean): number { + #scoreEntry(filePath: string, query: string, isDirectory: boolean): number { const fileName = path.basename(filePath); const lowerFileName = fileName.toLowerCase(); const lowerQuery = query.toLowerCase(); @@ -622,14 +622,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return score; } - private async getFuzzyFileSuggestions( - query: string, - options: { isQuotedPrefix: boolean }, - ): Promise { + async #getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): Promise { try { const result = await fuzzyFind({ query, - path: this.basePath, + path: this.#basePath, maxResults: 100, hidden: true, gitignore: true, @@ -647,7 +644,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { .map(entry => ({ path: entry.path, isDirectory: entry.isDirectory, - score: query ? this.scoreEntry(entry.path, query, entry.isDirectory) : 1, + score: query ? this.#scoreEntry(entry.path, query, entry.isDirectory) : 1, })) .filter(entry => entry.score > 0); @@ -692,9 +689,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } // Force extract path prefix - this will always return something - const pathMatch = this.extractPathPrefix(textBeforeCursor, true); + const pathMatch = this.#extractPathPrefix(textBeforeCursor, true); if (pathMatch !== null) { - const suggestions = await this.getFileSuggestions(pathMatch); + const suggestions = await this.#getFileSuggestions(pathMatch); if (suggestions.length === 0) return null; return { diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index 1f24ad76c..4d929e955 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -11,66 +11,66 @@ type Cache = { */ export class Box implements Component { children: Component[] = []; - private paddingX: number; - private paddingY: number; - private bgFn?: (text: string) => string; + #paddingX: number; + #paddingY: number; + #bgFn?: (text: string) => string; // Cache for rendered output - private cached?: Cache; + #cached?: Cache; constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) { - this.paddingX = paddingX; - this.paddingY = paddingY; - this.bgFn = bgFn; + this.#paddingX = paddingX; + this.#paddingY = paddingY; + this.#bgFn = bgFn; } addChild(component: Component): void { this.children.push(component); - this.invalidateCache(); + this.#invalidateCache(); } removeChild(component: Component): void { const index = this.children.indexOf(component); if (index !== -1) { this.children.splice(index, 1); - this.invalidateCache(); + this.#invalidateCache(); } } clear(): void { this.children = []; - this.invalidateCache(); + this.#invalidateCache(); } setBgFn(bgFn?: (text: string) => string): void { - this.bgFn = bgFn; + this.#bgFn = bgFn; // Don't invalidate here - we'll detect bgFn changes by sampling output } - private invalidateCache(): void { - this.cached = undefined; + #invalidateCache(): void { + this.#cached = undefined; } - private static _tmp = new Uint32Array(2); - private computeCacheKey(width: number, childLines: string[], bgSample: string | undefined): bigint { - Box._tmp[0] = width; - Box._tmp[1] = childLines.length; - let h = Bun.hash.xxHash64(Box._tmp); + static #tmp = new Uint32Array(2); + #computeCacheKey(width: number, childLines: string[], bgSample: string | undefined): bigint { + Box.#tmp[0] = width; + Box.#tmp[1] = childLines.length; + let h = Bun.hash.xxHash64(Box.#tmp); for (const line of childLines) { - Box._tmp[0] = line.length; - h = Bun.hash.xxHash64(Box._tmp, h); + Box.#tmp[0] = line.length; + h = Bun.hash.xxHash64(Box.#tmp, h); h = Bun.hash.xxHash64(line, h); } h = Bun.hash.xxHash64(bgSample ?? "", h); return h; } - private matchCache(cacheKey: bigint): boolean { - return this.cached?.key === cacheKey; + #matchCache(cacheKey: bigint): boolean { + return this.#cached?.key === cacheKey; } invalidate(): void { - this.invalidateCache(); + this.#invalidateCache(); for (const child of this.children) { child.invalidate?.(); } @@ -81,8 +81,8 @@ export class Box implements Component { return []; } - const contentWidth = Math.max(1, width - this.paddingX * 2); - const leftPad = padding(this.paddingX); + const contentWidth = Math.max(1, width - this.#paddingX * 2); + const leftPad = padding(this.#paddingX); // Render all children const childLines: string[] = []; @@ -98,46 +98,46 @@ export class Box implements Component { } // Check if bgFn output changed by sampling - const bgSample = this.bgFn ? this.bgFn("test") : undefined; + const bgSample = this.#bgFn ? this.#bgFn("test") : undefined; - const cacheKey = this.computeCacheKey(width, childLines, bgSample); + const cacheKey = this.#computeCacheKey(width, childLines, bgSample); // Check cache validity - if (this.matchCache(cacheKey)) { - return this.cached!.result; + if (this.#matchCache(cacheKey)) { + return this.#cached!.result; } // Apply background and padding const result: string[] = []; // Top padding - for (let i = 0; i < this.paddingY; i++) { - result.push(this.applyBg("", width)); + for (let i = 0; i < this.#paddingY; i++) { + result.push(this.#applyBg("", width)); } // Content for (const line of childLines) { - result.push(this.applyBg(line, width)); + result.push(this.#applyBg(line, width)); } // Bottom padding - for (let i = 0; i < this.paddingY; i++) { - result.push(this.applyBg("", width)); + for (let i = 0; i < this.#paddingY; i++) { + result.push(this.#applyBg("", width)); } // Update cache - this.cached = { key: cacheKey, result }; + this.#cached = { key: cacheKey, result }; return result; } - private applyBg(line: string, width: number): string { + #applyBg(line: string, width: number): string { const visLen = visibleWidth(line); const padNeeded = Math.max(0, width - visLen); const padded = line + padding(padNeeded); - if (this.bgFn) { - return applyBackgroundToLine(padded, width, this.bgFn); + if (this.#bgFn) { + return applyBackgroundToLine(padded, width, this.#bgFn); } return padded; } diff --git a/packages/tui/src/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts index ec16447ae..46317ed4c 100644 --- a/packages/tui/src/components/cancellable-loader.ts +++ b/packages/tui/src/components/cancellable-loader.ts @@ -11,24 +11,24 @@ import { Loader } from "./loader"; * doWork(loader.signal).then(done); */ export class CancellableLoader extends Loader { - private abortController = new AbortController(); + #abortController = new AbortController(); /** Called when user presses Escape */ onAbort?: () => void; /** AbortSignal that is aborted when user presses Escape */ get signal(): AbortSignal { - return this.abortController.signal; + return this.#abortController.signal; } /** Whether the loader was aborted */ get aborted(): boolean { - return this.abortController.signal.aborted; + return this.#abortController.signal.aborted; } handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "esc")) { - this.abortController.abort(); + this.#abortController.abort(); this.onAbort?.(); } } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 6729df6c1..0d166f52a 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,10 +1,9 @@ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete"; -import { getEditorKeybindings } from "../keybindings"; +import { type EditorKeybindingsManager, getEditorKeybindings } from "../keybindings"; import { matchesKey } from "../keys"; import { KillRing } from "../kill-ring"; import type { SymbolTheme } from "../symbols"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui"; -import { UndoStack } from "../undo-stack"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, padding, truncateToWidth, visibleWidth } from "../utils"; import { SelectList, type SelectListTheme } from "./select-list"; @@ -283,7 +282,7 @@ interface HistoryStorage { } export class Editor implements Component, Focusable { - private state: EditorState = { + #state: EditorState = { lines: [""], cursorLine: 0, cursorCol: 0, @@ -292,73 +291,73 @@ export class Editor implements Component, Focusable { /** Focusable interface - set by TUI when focus changes */ focused: boolean = false; - private theme: EditorTheme; - private useTerminalCursor = false; + #theme: EditorTheme; + #useTerminalCursor = false; // Store last layout width for cursor navigation - private lastLayoutWidth: number = 80; - private paddingXOverride: number | undefined; - private maxHeight?: number; - private scrollOffset: number = 0; + #lastLayoutWidth: number = 80; + #paddingXOverride: number | undefined; + #maxHeight?: number; + #scrollOffset: number = 0; // Emacs-style kill ring - private killRing = new KillRing(); - private lastAction: "kill" | "yank" | null = null; + #killRing = new KillRing(); + #lastAction: "kill" | "yank" | null = null; // Character jump mode - private jumpMode: "forward" | "backward" | null = null; + #jumpMode: "forward" | "backward" | null = null; // Preferred visual column for vertical cursor movement (sticky column) - private preferredVisualCol: number | null = null; + #preferredVisualCol: number | null = null; // Border color (can be changed dynamically) - public borderColor: (str: string) => string; + borderColor: (str: string) => string; // Autocomplete support - private autocompleteProvider?: AutocompleteProvider; - private autocompleteList?: SelectList; - private autocompleteState: "regular" | "force" | null = null; - private autocompletePrefix: string = ""; - private autocompleteRequestId: number = 0; - private autocompleteMaxVisible: number = 5; - public onAutocompleteUpdate?: () => void; + #autocompleteProvider?: AutocompleteProvider; + #autocompleteList?: SelectList; + #autocompleteState: "regular" | "force" | null = null; + #autocompletePrefix: string = ""; + #autocompleteRequestId: number = 0; + #autocompleteMaxVisible: number = 5; + onAutocompleteUpdate?: () => void; // Paste tracking for large pastes - private pastes: Map = new Map(); - private pasteCounter: number = 0; + #pastes: Map = new Map(); + #pasteCounter: number = 0; // Bracketed paste mode buffering - private pasteBuffer: string = ""; - private isInPaste: boolean = false; + #pasteBuffer: string = ""; + #isInPaste: boolean = false; // Prompt history for up/down navigation - private history: string[] = []; - private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. - private historyStorage?: HistoryStorage; + #history: string[] = []; + #historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. + #historyStorage?: HistoryStorage; // Undo stack for editor state changes - private undoStack = new UndoStack(); - private suspendUndo = false; + #undoStack: EditorState[] = []; + #suspendUndo = false; // Debounce timer for autocomplete updates - private autocompleteTimeout: ReturnType | null = null; + #autocompleteTimeout?: NodeJS.Timeout; - public onSubmit?: (text: string) => void; - public onAltEnter?: (text: string) => void; - public onChange?: (text: string) => void; - public onAutocompleteCancel?: () => void; - public disableSubmit: boolean = false; + onSubmit?: (text: string) => void; + onAltEnter?: (text: string) => void; + onChange?: (text: string) => void; + onAutocompleteCancel?: () => void; + disableSubmit: boolean = false; // Custom top border (for status line integration) - private topBorderContent?: EditorTopBorder; + #topBorderContent?: EditorTopBorder; constructor(theme: EditorTheme) { - this.theme = theme; + this.#theme = theme; this.borderColor = theme.borderColor; } setAutocompleteProvider(provider: AutocompleteProvider): void { - this.autocompleteProvider = provider; + this.#autocompleteProvider = provider; } /** @@ -366,41 +365,41 @@ export class Editor implements Component, Focusable { * Pass undefined to use the default plain border. */ setTopBorder(content: EditorTopBorder | undefined): void { - this.topBorderContent = content; + this.#topBorderContent = content; } /** * Use the real terminal cursor instead of rendering a cursor glyph. */ setUseTerminalCursor(useTerminalCursor: boolean): void { - this.useTerminalCursor = useTerminalCursor; + this.#useTerminalCursor = useTerminalCursor; } setMaxHeight(maxHeight: number | undefined): void { - this.maxHeight = maxHeight; - this.scrollOffset = 0; + this.#maxHeight = maxHeight; + this.#scrollOffset = 0; } setPaddingX(paddingX: number): void { - this.paddingXOverride = Math.max(0, paddingX); + this.#paddingXOverride = Math.max(0, paddingX); } getAutocompleteMaxVisible(): number { - return this.autocompleteMaxVisible; + return this.#autocompleteMaxVisible; } setAutocompleteMaxVisible(maxVisible: number): void { const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; - if (this.autocompleteMaxVisible !== newMaxVisible) { - this.autocompleteMaxVisible = newMaxVisible; + if (this.#autocompleteMaxVisible !== newMaxVisible) { + this.#autocompleteMaxVisible = newMaxVisible; } } setHistoryStorage(storage: HistoryStorage): void { - this.historyStorage = storage; + this.#historyStorage = storage; const recent = storage.getRecent(100); - this.history = recent.map(entry => entry.prompt); - this.historyIndex = -1; + this.#history = recent.map(entry => entry.prompt); + this.#historyIndex = -1; } /** @@ -411,56 +410,56 @@ export class Editor implements Component, Focusable { const trimmed = text.trim(); if (!trimmed) return; // Don't add consecutive duplicates - if (this.history.length > 0 && this.history[0] === trimmed) return; - this.history.unshift(trimmed); + if (this.#history.length > 0 && this.#history[0] === trimmed) return; + this.#history.unshift(trimmed); // Limit history size - if (this.history.length > 100) { - this.history.pop(); + if (this.#history.length > 100) { + this.#history.pop(); } - this.historyStorage?.add(trimmed, process.cwd()); + this.#historyStorage?.add(trimmed, process.cwd()); } - private isEditorEmpty(): boolean { - return this.state.lines.length === 1 && this.state.lines[0] === ""; + #isEditorEmpty(): boolean { + return this.#state.lines.length === 1 && this.#state.lines[0] === ""; } - private isOnFirstVisualLine(): boolean { - const visualLines = this.buildVisualLineMap(this.lastLayoutWidth); - const currentVisualLine = this.findCurrentVisualLine(visualLines); + #isOnFirstVisualLine(): boolean { + const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth); + const currentVisualLine = this.#findCurrentVisualLine(visualLines); return currentVisualLine === 0; } - private isOnLastVisualLine(): boolean { - const visualLines = this.buildVisualLineMap(this.lastLayoutWidth); - const currentVisualLine = this.findCurrentVisualLine(visualLines); + #isOnLastVisualLine(): boolean { + const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth); + const currentVisualLine = this.#findCurrentVisualLine(visualLines); return currentVisualLine === visualLines.length - 1; } - private navigateHistory(direction: 1 | -1): void { - this.resetKillSequence(); - if (this.history.length === 0) return; + #navigateHistory(direction: 1 | -1): void { + this.#resetKillSequence(); + if (this.#history.length === 0) return; - const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases - if (newIndex < -1 || newIndex >= this.history.length) return; + const newIndex = this.#historyIndex - direction; // Up(-1) increases index, Down(1) decreases + if (newIndex < -1 || newIndex >= this.#history.length) return; - this.historyIndex = newIndex; + this.#historyIndex = newIndex; - if (this.historyIndex === -1) { + if (this.#historyIndex === -1) { // Returned to "current" state - clear editor - this.setTextInternal(""); + this.#setTextInternal(""); } else { - this.setTextInternal(this.history[this.historyIndex] || ""); + this.#setTextInternal(this.#history[this.#historyIndex] || ""); } } /** Internal setText that doesn't reset history state - used by navigateHistory */ - private setTextInternal(text: string): void { - this.clearUndoStack(); + #setTextInternal(text: string): void { + this.#undoStack.length = 0; const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); - this.state.lines = lines.length === 0 ? [""] : lines; - this.state.cursorLine = this.state.lines.length - 1; - this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0); + this.#state.lines = lines.length === 0 ? [""] : lines; + this.#state.cursorLine = this.#state.lines.length - 1; + this.#setCursorCol(this.#state.lines[this.#state.cursorLine]?.length || 0); if (this.onChange) { this.onChange(this.getText()); @@ -471,51 +470,51 @@ export class Editor implements Component, Focusable { // No cached state to invalidate currently } - private getEditorPaddingX(): number { - const padding = this.paddingXOverride ?? this.theme.editorPaddingX ?? 2; + #getEditorPaddingX(): number { + const padding = this.#paddingXOverride ?? this.#theme.editorPaddingX ?? 2; return Math.max(0, padding); } - private getContentWidth(width: number, paddingX: number): number { + #getContentWidth(width: number, paddingX: number): number { return Math.max(0, width - 2 * (paddingX + 1)); } - private getLayoutWidth(width: number, paddingX: number): number { - const contentWidth = this.getContentWidth(width, paddingX); + #getLayoutWidth(width: number, paddingX: number): number { + const contentWidth = this.#getContentWidth(width, paddingX); return Math.max(1, contentWidth - (paddingX === 0 ? 1 : 0)); } - private getVisibleContentHeight(contentLines: number): number { - if (this.maxHeight === undefined) return contentLines; - return Math.max(1, this.maxHeight - 2); + #getVisibleContentHeight(contentLines: number): number { + if (this.#maxHeight === undefined) return contentLines; + return Math.max(1, this.#maxHeight - 2); } - private updateScrollOffset(layoutWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void { + #updateScrollOffset(layoutWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void { if (layoutLines.length <= visibleHeight) { - this.scrollOffset = 0; + this.#scrollOffset = 0; return; } - const visualLines = this.buildVisualLineMap(layoutWidth); - const cursorLine = this.findCurrentVisualLine(visualLines); - if (cursorLine < this.scrollOffset) { - this.scrollOffset = cursorLine; - } else if (cursorLine >= this.scrollOffset + visibleHeight) { - this.scrollOffset = cursorLine - visibleHeight + 1; + const visualLines = this.#buildVisualLineMap(layoutWidth); + const cursorLine = this.#findCurrentVisualLine(visualLines); + if (cursorLine < this.#scrollOffset) { + this.#scrollOffset = cursorLine; + } else if (cursorLine >= this.#scrollOffset + visibleHeight) { + this.#scrollOffset = cursorLine - visibleHeight + 1; } const maxOffset = Math.max(0, layoutLines.length - visibleHeight); - this.scrollOffset = Math.min(this.scrollOffset, maxOffset); + this.#scrollOffset = Math.min(this.#scrollOffset, maxOffset); } render(width: number): string[] { - const paddingX = this.getEditorPaddingX(); - const contentAreaWidth = this.getContentWidth(width, paddingX); - const layoutWidth = this.getLayoutWidth(width, paddingX); - this.lastLayoutWidth = layoutWidth; + const paddingX = this.#getEditorPaddingX(); + const contentAreaWidth = this.#getContentWidth(width, paddingX); + const layoutWidth = this.#getLayoutWidth(width, paddingX); + this.#lastLayoutWidth = layoutWidth; // Box-drawing characters for rounded corners - const box = this.theme.symbols.boxRound; + const box = this.#theme.symbols.boxRound; const borderWidth = paddingX + 1; const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`); const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`); @@ -523,17 +522,17 @@ export class Editor implements Component, Focusable { const horizontal = this.borderColor(box.horizontal); // Layout the text - const layoutLines = this.layoutText(layoutWidth); - const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length); - this.updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight); - const visibleLayoutLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + visibleContentHeight); + const layoutLines = this.#layoutText(layoutWidth); + const visibleContentHeight = this.#getVisibleContentHeight(layoutLines.length); + this.#updateScrollOffset(layoutWidth, layoutLines, visibleContentHeight); + const visibleLayoutLines = layoutLines.slice(this.#scrollOffset, this.#scrollOffset + visibleContentHeight); const result: string[] = []; // Render top border: ╭─ [status content] ────────────────╮ const topFillWidth = width - borderWidth * 2; - if (this.topBorderContent) { - const { content, width: statusWidth } = this.topBorderContent; + if (this.#topBorderContent) { + const { content, width: statusWidth } = this.#topBorderContent; if (statusWidth <= topFillWidth) { // Status fits - add fill after it const fillWidth = topFillWidth - statusWidth; @@ -551,7 +550,7 @@ export class Editor implements Component, Focusable { // Render each layout line // Emit hardware cursor marker only when focused and not showing autocomplete - const emitCursorMarker = this.focused && !this.autocompleteState; + const emitCursorMarker = this.focused && !this.#autocompleteState; const lineContentWidth = contentAreaWidth; for (const layoutLine of visibleLayoutLines) { @@ -563,13 +562,13 @@ export class Editor implements Component, Focusable { const hasCursor = layoutLine.hasCursor && layoutLine.cursorPos !== undefined; const marker = emitCursorMarker ? CURSOR_MARKER : ""; - if (hasCursor && this.useTerminalCursor) { + if (hasCursor && this.#useTerminalCursor) { if (marker) { const before = displayText.slice(0, layoutLine.cursorPos); const after = displayText.slice(layoutLine.cursorPos); displayText = before + marker + after; } - } else if (hasCursor && !this.useTerminalCursor) { + } else if (hasCursor && !this.#useTerminalCursor) { const before = displayText.slice(0, layoutLine.cursorPos); const after = displayText.slice(layoutLine.cursorPos); @@ -584,7 +583,7 @@ export class Editor implements Component, Focusable { // displayWidth stays the same - we're replacing, not adding } else { // Cursor is at the end - add thin cursor glyph - const cursorChar = this.theme.symbols.inputCursor; + const cursorChar = this.#theme.symbols.inputCursor; const cursor = `\x1b[5m${cursorChar}\x1b[0m`; displayText = before + marker + cursor; displayWidth += visibleWidth(cursorChar); @@ -613,8 +612,8 @@ export class Editor implements Component, Focusable { } // Add autocomplete list if active - if (this.autocompleteState && this.autocompleteList) { - const autocompleteResult = this.autocompleteList.render(width); + if (this.#autocompleteState && this.#autocompleteList) { + const autocompleteResult = this.#autocompleteList.render(width); result.push(...autocompleteResult); } @@ -625,23 +624,23 @@ export class Editor implements Component, Focusable { const kb = getEditorKeybindings(); // Handle character jump mode (awaiting next character to jump to) - if (this.jumpMode !== null) { + if (this.#jumpMode !== null) { // Cancel if the hotkey is pressed again if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) { - this.jumpMode = null; + this.#jumpMode = null; return; } if (data.charCodeAt(0) >= 32) { // Printable character - perform the jump - const direction = this.jumpMode; - this.jumpMode = null; - this.jumpToChar(data, direction); + const direction = this.#jumpMode; + this.#jumpMode = null; + this.#jumpToChar(data, direction); return; } // Control character - cancel and fall through to normal handling - this.jumpMode = null; + this.#jumpMode = null; } // Handle bracketed paste mode @@ -650,32 +649,32 @@ export class Editor implements Component, Focusable { // Check if we're starting a bracketed paste if (data.includes("\x1b[200~")) { - this.isInPaste = true; - this.pasteBuffer = ""; + this.#isInPaste = true; + this.#pasteBuffer = ""; // Remove the start marker and keep the rest data = data.replace("\x1b[200~", ""); } // If we're in a paste, buffer the data - if (this.isInPaste) { + if (this.#isInPaste) { // Append data to buffer first (end marker could be split across chunks) - this.pasteBuffer += data; + this.#pasteBuffer += data; // Check if the accumulated buffer contains the end marker - const endIndex = this.pasteBuffer.indexOf("\x1b[201~"); + const endIndex = this.#pasteBuffer.indexOf("\x1b[201~"); if (endIndex !== -1) { // Extract content before the end marker - const pasteContent = this.pasteBuffer.substring(0, endIndex); + const pasteContent = this.#pasteBuffer.substring(0, endIndex); // Process the complete paste - this.handlePaste(pasteContent); + this.#handlePaste(pasteContent); // Reset paste state - this.isInPaste = false; + this.#isInPaste = false; // Process any remaining data after the end marker - const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~ - this.pasteBuffer = ""; + const remaining = this.#pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~ + this.#pasteBuffer = ""; if (remaining.length > 0) { this.handleInput(remaining); @@ -696,15 +695,15 @@ export class Editor implements Component, Focusable { // Ctrl+- / Ctrl+_ - Undo last edit if (matchesKey(data, "ctrl+-") || matchesKey(data, "ctrl+_")) { - this.applyUndo(); + this.#applyUndo(); return; } // Handle autocomplete special keys first (but don't block other input) - if (this.autocompleteState && this.autocompleteList) { + if (this.#autocompleteState && this.#autocompleteList) { // Escape - cancel autocomplete if (matchesKey(data, "escape") || matchesKey(data, "esc")) { - this.cancelAutocomplete(true); + this.#cancelAutocomplete(true); return; } // Let the autocomplete list handle navigation and selection @@ -718,28 +717,28 @@ export class Editor implements Component, Focusable { ) { // Only pass arrow keys to the list, not Enter/Tab (we handle those directly) if (matchesKey(data, "up") || matchesKey(data, "down")) { - this.autocompleteList.handleInput(data); + this.#autocompleteList.handleInput(data); this.onAutocompleteUpdate?.(); return; } // If Tab was pressed, always apply the selection if (matchesKey(data, "tab")) { - const selected = this.autocompleteList.getSelectedItem(); - if (selected && this.autocompleteProvider) { - const result = this.autocompleteProvider.applyCompletion( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, + const selected = this.#autocompleteList.getSelectedItem(); + if (selected && this.#autocompleteProvider) { + const result = this.#autocompleteProvider.applyCompletion( + this.#state.lines, + this.#state.cursorLine, + this.#state.cursorCol, selected, - this.autocompletePrefix, + this.#autocompletePrefix, ); - this.state.lines = result.lines; - this.state.cursorLine = result.cursorLine; - this.setCursorCol(result.cursorCol); + this.#state.lines = result.lines; + this.#state.cursorLine = result.cursorLine; + this.#setCursorCol(result.cursorCol); - this.cancelAutocomplete(); + this.#cancelAutocomplete(); if (this.onChange) { this.onChange(this.getText()); @@ -751,50 +750,50 @@ export class Editor implements Component, Focusable { // If Enter was pressed on a slash command, apply completion and submit if ( (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") && - this.autocompletePrefix.startsWith("/") + this.#autocompletePrefix.startsWith("/") ) { // Check for stale autocomplete state due to debounce - const currentLine = this.state.lines[this.state.cursorLine] ?? ""; - const currentTextBeforeCursor = currentLine.slice(0, this.state.cursorCol); - if (currentTextBeforeCursor !== this.autocompletePrefix) { + const currentLine = this.#state.lines[this.#state.cursorLine] ?? ""; + const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol); + if (currentTextBeforeCursor !== this.#autocompletePrefix) { // Autocomplete is stale - cancel and fall through to normal submission - this.cancelAutocomplete(); + this.#cancelAutocomplete(); } else { - const selected = this.autocompleteList.getSelectedItem(); - if (selected && this.autocompleteProvider) { - const result = this.autocompleteProvider.applyCompletion( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, + const selected = this.#autocompleteList.getSelectedItem(); + if (selected && this.#autocompleteProvider) { + const result = this.#autocompleteProvider.applyCompletion( + this.#state.lines, + this.#state.cursorLine, + this.#state.cursorCol, selected, - this.autocompletePrefix, + this.#autocompletePrefix, ); - this.state.lines = result.lines; - this.state.cursorLine = result.cursorLine; - this.setCursorCol(result.cursorCol); + this.#state.lines = result.lines; + this.#state.cursorLine = result.cursorLine; + this.#setCursorCol(result.cursorCol); } - this.cancelAutocomplete(); + this.#cancelAutocomplete(); } // Don't return - fall through to submission logic } // If Enter was pressed on a file path, apply completion else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { - const selected = this.autocompleteList.getSelectedItem(); - if (selected && this.autocompleteProvider) { - const result = this.autocompleteProvider.applyCompletion( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, + const selected = this.#autocompleteList.getSelectedItem(); + if (selected && this.#autocompleteProvider) { + const result = this.#autocompleteProvider.applyCompletion( + this.#state.lines, + this.#state.cursorLine, + this.#state.cursorCol, selected, - this.autocompletePrefix, + this.#autocompletePrefix, ); - this.state.lines = result.lines; - this.state.cursorLine = result.cursorLine; - this.setCursorCol(result.cursorCol); + this.#state.lines = result.lines; + this.#state.cursorLine = result.cursorLine; + this.#setCursorCol(result.cursorCol); - this.cancelAutocomplete(); + this.#cancelAutocomplete(); if (this.onChange) { this.onChange(this.getText()); @@ -808,54 +807,54 @@ export class Editor implements Component, Focusable { } // Tab key - context-aware completion (but not when already autocompleting) - if (matchesKey(data, "tab") && !this.autocompleteState) { - this.handleTabCompletion(); + if (matchesKey(data, "tab") && !this.#autocompleteState) { + this.#handleTabCompletion(); return; } // Continue with rest of input handling // Ctrl+K - Delete to end of line if (matchesKey(data, "ctrl+k")) { - this.deleteToEndOfLine(); + this.#deleteToEndOfLine(); } // Ctrl+U - Delete to start of line else if (matchesKey(data, "ctrl+u")) { - this.deleteToStartOfLine(); + this.#deleteToStartOfLine(); } // Ctrl+W - Delete word backwards else if (matchesKey(data, "ctrl+w")) { - this.deleteWordBackwards(); + this.#deleteWordBackwards(); } // Option/Alt+Backspace - Delete word backwards else if (matchesKey(data, "alt+backspace")) { - this.deleteWordBackwards(); + this.#deleteWordBackwards(); } // Option/Alt+D - Delete word forwards else if (matchesKey(data, "alt+d") || matchesKey(data, "alt+delete")) { - this.deleteWordForwards(); + this.#deleteWordForwards(); } // Ctrl+Y - Yank from kill ring else if (matchesKey(data, "ctrl+y")) { - this.yankFromKillRing(); + this.#yankFromKillRing(); } // Alt+Y - Yank-pop (cycle kill ring) else if (matchesKey(data, "alt+y")) { - this.yankPop(); + this.#yankPop(); } // Ctrl+A - Move to start of line else if (matchesKey(data, "ctrl+a")) { - this.moveToLineStart(); + this.#moveToLineStart(); } // Ctrl+E - Move to end of line else if (matchesKey(data, "ctrl+e")) { - this.moveToLineEnd(); + this.#moveToLineEnd(); } // Alt+Enter - special handler if callback exists, otherwise new line else if (matchesKey(data, "alt+enter")) { if (this.onAltEnter) { this.onAltEnter(this.getText()); } else { - this.addNewLine(); + this.#addNewLine(); } } // New line @@ -869,12 +868,12 @@ export class Editor implements Component, Focusable { (data.length > 1 && data.includes("\x1b") && data.includes("\r")) || (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping ) { - if (this.shouldSubmitOnBackslashEnter(data, kb)) { - this.handleBackspace(); - this.submitValue(); + if (this.#shouldSubmitOnBackslashEnter(data, kb)) { + this.#handleBackspace(); + this.#submitValue(); return; } - this.addNewLine(); + this.#addNewLine(); } // Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits) else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { @@ -883,71 +882,71 @@ export class Editor implements Component, Focusable { return; } - this.submitValue(); + this.#submitValue(); } // Backspace (including Shift+Backspace) else if (matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) { - this.handleBackspace(); + this.#handleBackspace(); } // Line navigation shortcuts (Home/End keys) else if (matchesKey(data, "home")) { - this.moveToLineStart(); + this.#moveToLineStart(); } else if (matchesKey(data, "end")) { - this.moveToLineEnd(); + this.#moveToLineEnd(); } // Forward delete (Fn+Backspace or Delete key, including Shift+Delete) else if (matchesKey(data, "delete") || matchesKey(data, "shift+delete")) { - this.handleForwardDelete(); + this.#handleForwardDelete(); } // Word navigation (Option/Alt + Arrow or Ctrl + Arrow) else if (matchesKey(data, "alt+left") || matchesKey(data, "ctrl+left")) { // Word left - this.resetKillSequence(); - this.moveWordBackwards(); + this.#resetKillSequence(); + this.#moveWordBackwards(); } else if (matchesKey(data, "alt+right") || matchesKey(data, "ctrl+right")) { // Word right - this.resetKillSequence(); - this.moveWordForwards(); + this.#resetKillSequence(); + this.#moveWordForwards(); } // Arrow keys else if (matchesKey(data, "up")) { // Up - history navigation or cursor movement - if (this.isEditorEmpty()) { - this.navigateHistory(-1); // Start browsing history - } else if (this.historyIndex > -1 && this.isOnFirstVisualLine()) { - this.navigateHistory(-1); // Navigate to older history entry - } else if (this.isOnFirstVisualLine()) { + if (this.#isEditorEmpty()) { + this.#navigateHistory(-1); // Start browsing history + } else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) { + this.#navigateHistory(-1); // Navigate to older history entry + } else if (this.#isOnFirstVisualLine()) { // Already at top - jump to start of line - this.moveToLineStart(); + this.#moveToLineStart(); } else { - this.moveCursor(-1, 0); // Cursor movement (within text or history entry) + this.#moveCursor(-1, 0); // Cursor movement (within text or history entry) } } else if (matchesKey(data, "down")) { // Down - history navigation or cursor movement - if (this.historyIndex > -1 && this.isOnLastVisualLine()) { - this.navigateHistory(1); // Navigate to newer history entry or clear - } else if (this.isOnLastVisualLine()) { + if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) { + this.#navigateHistory(1); // Navigate to newer history entry or clear + } else if (this.#isOnLastVisualLine()) { // Already at bottom - jump to end of line - this.moveToLineEnd(); + this.#moveToLineEnd(); } else { - this.moveCursor(1, 0); // Cursor movement (within text or history entry) + this.#moveCursor(1, 0); // Cursor movement (within text or history entry) } } else if (matchesKey(data, "right")) { // Right - this.moveCursor(0, 1); + this.#moveCursor(0, 1); } else if (matchesKey(data, "left")) { // Left - this.moveCursor(0, -1); + this.#moveCursor(0, -1); } // Shift+Space - insert regular space (Kitty protocol sends escape sequence) else if (matchesKey(data, "shift+space")) { - this.insertCharacter(" "); + this.#insertCharacter(" "); } // Character jump mode triggers else if (kb.matches(data, "jumpForward")) { - this.jumpMode = "forward"; + this.#jumpMode = "forward"; } else if (kb.matches(data, "jumpBackward")) { - this.jumpMode = "backward"; + this.#jumpMode = "backward"; } // Kitty CSI-u printable characters (shifted symbols like @, ?, {, }) else { @@ -958,15 +957,15 @@ export class Editor implements Component, Focusable { } // Regular characters (printable characters and unicode, but not control characters) if (data.charCodeAt(0) >= 32) { - this.insertCharacter(data); + this.#insertCharacter(data); } } } - private layoutText(contentWidth: number): LayoutLine[] { + #layoutText(contentWidth: number): LayoutLine[] { const layoutLines: LayoutLine[] = []; - if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) { + if (this.#state.lines.length === 0 || (this.#state.lines.length === 1 && this.#state.lines[0] === "")) { // Empty editor layoutLines.push({ text: "", @@ -977,9 +976,9 @@ export class Editor implements Component, Focusable { } // Process each logical line - for (let i = 0; i < this.state.lines.length; i++) { - const line = this.state.lines[i] || ""; - const isCurrentLine = i === this.state.cursorLine; + for (let i = 0; i < this.#state.lines.length; i++) { + const line = this.#state.lines[i] || ""; + const isCurrentLine = i === this.#state.cursorLine; const lineVisibleWidth = visibleWidth(line); if (lineVisibleWidth <= contentWidth) { @@ -988,7 +987,7 @@ export class Editor implements Component, Focusable { layoutLines.push({ text: line, hasCursor: true, - cursorPos: this.state.cursorCol, + cursorPos: this.#state.cursorCol, }); } else { layoutLines.push({ @@ -1004,7 +1003,7 @@ export class Editor implements Component, Focusable { const chunk = chunks[chunkIndex]; if (!chunk) continue; - const cursorPos = this.state.cursorCol; + const cursorPos = this.#state.cursorCol; const isLastChunk = chunkIndex === chunks.length - 1; // Determine if cursor is in this chunk @@ -1052,7 +1051,7 @@ export class Editor implements Component, Focusable { } getText(): string { - return this.state.lines.join("\n"); + return this.#state.lines.join("\n"); } /** @@ -1060,8 +1059,8 @@ export class Editor implements Component, Focusable { * Use this when you need the full content (e.g., for external editor). */ getExpandedText(): string { - let result = this.state.lines.join("\n"); - for (const [pasteId, pasteContent] of this.pastes) { + let result = this.#state.lines.join("\n"); + for (const [pasteId, pasteContent] of this.#pastes) { const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g"); result = result.replace(markerRegex, pasteContent); } @@ -1069,31 +1068,31 @@ export class Editor implements Component, Focusable { } getLines(): string[] { - return [...this.state.lines]; + return [...this.#state.lines]; } getCursor(): { line: number; col: number } { - return { line: this.state.cursorLine, col: this.state.cursorCol }; + return { line: this.#state.cursorLine, col: this.#state.cursorCol }; } setText(text: string): void { - this.historyIndex = -1; // Exit history browsing mode - this.resetKillSequence(); - this.setTextInternal(text); + this.#historyIndex = -1; // Exit history browsing mode + this.#resetKillSequence(); + this.#setTextInternal(text); } /** Insert text at the current cursor position */ insertText(text: string): void { - this.historyIndex = -1; - this.resetKillSequence(); - this.recordUndoState(); + this.#historyIndex = -1; + this.#resetKillSequence(); + this.#recordUndoState(); - const line = this.state.lines[this.state.cursorLine] || ""; - const before = line.slice(0, this.state.cursorCol); - const after = line.slice(this.state.cursorCol); + const line = this.#state.lines[this.#state.cursorLine] || ""; + const before = line.slice(0, this.#state.cursorCol); + const after = line.slice(this.#state.cursorCol); - this.state.lines[this.state.cursorLine] = before + text + after; - this.setCursorCol(this.state.cursorCol + text.length); + this.#state.lines[this.#state.cursorLine] = before + text + after; + this.#setCursorCol(this.#state.cursorCol + text.length); if (this.onChange) { this.onChange(this.getText()); @@ -1101,63 +1100,63 @@ export class Editor implements Component, Focusable { } // All the editor methods from before... - private insertCharacter(char: string): void { - this.historyIndex = -1; // Exit history browsing mode - this.resetKillSequence(); - this.recordUndoState(); + #insertCharacter(char: string): void { + this.#historyIndex = -1; // Exit history browsing mode + this.#resetKillSequence(); + this.#recordUndoState(); - const line = this.state.lines[this.state.cursorLine] || ""; + const line = this.#state.lines[this.#state.cursorLine] || ""; - const before = line.slice(0, this.state.cursorCol); - const after = line.slice(this.state.cursorCol); + const before = line.slice(0, this.#state.cursorCol); + const after = line.slice(this.#state.cursorCol); - this.state.lines[this.state.cursorLine] = before + char + after; - this.setCursorCol(this.state.cursorCol + char.length); + this.#state.lines[this.#state.cursorLine] = before + char + after; + this.#setCursorCol(this.#state.cursorCol + char.length); if (this.onChange) { this.onChange(this.getText()); } // Check if we should trigger or update autocomplete - if (!this.autocompleteState) { + if (!this.#autocompleteState) { // Auto-trigger for "/" at the start of a line (slash commands) - if (char === "/" && this.isAtStartOfMessage()) { - this.tryTriggerAutocomplete(); + if (char === "/" && this.#isAtStartOfMessage()) { + this.#tryTriggerAutocomplete(); } // Auto-trigger for "@" file reference (fuzzy search) else if (char === "@") { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol); // Only trigger if @ is after whitespace or at start of line const charBeforeAt = textBeforeCursor[textBeforeCursor.length - 2]; if (textBeforeCursor.length === 1 || charBeforeAt === " " || charBeforeAt === "\t") { - this.tryTriggerAutocomplete(); + this.#tryTriggerAutocomplete(); } } // Also auto-trigger when typing letters/path chars in a slash command context else if (/[a-zA-Z0-9.\-_/]/.test(char)) { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol); // Check if we're in a slash command (with or without space for arguments) if (textBeforeCursor.trimStart().startsWith("/")) { - this.tryTriggerAutocomplete(); + this.#tryTriggerAutocomplete(); } // Check if we're in an @ file reference context else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { - this.tryTriggerAutocomplete(); + this.#tryTriggerAutocomplete(); } } } else { - this.debouncedUpdateAutocomplete(); + this.#debouncedUpdateAutocomplete(); } } - private handlePaste(pastedText: string): void { - this.historyIndex = -1; // Exit history browsing mode - this.resetKillSequence(); - this.recordUndoState(); + #handlePaste(pastedText: string): void { + this.#historyIndex = -1; // Exit history browsing mode + this.#resetKillSequence(); + this.#recordUndoState(); - this.withUndoSuspended(() => { + this.#withUndoSuspended(() => { // Clean the pasted text const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); @@ -1173,8 +1172,8 @@ export class Editor implements Component, Focusable { // If pasting a file path (starts with /, ~, or .) and the character before // the cursor is a word character, prepend a space for better readability if (/^[/~.]/.test(filteredText)) { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : ""; + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const charBeforeCursor = this.#state.cursorCol > 0 ? currentLine[this.#state.cursorCol - 1] : ""; if (charBeforeCursor && /\w/.test(charBeforeCursor)) { filteredText = ` ${filteredText}`; } @@ -1187,16 +1186,16 @@ export class Editor implements Component, Focusable { const totalChars = filteredText.length; if (pastedLines.length > 10 || totalChars > 1000) { // Store the paste and insert a marker - this.pasteCounter++; - const pasteId = this.pasteCounter; - this.pastes.set(pasteId, filteredText); + this.#pasteCounter++; + const pasteId = this.#pasteCounter; + this.#pastes.set(pasteId, filteredText); // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]" const marker = pastedLines.length > 10 ? `[paste #${pasteId} +${pastedLines.length} lines]` : `[paste #${pasteId} ${totalChars} chars]`; - this.insertTextAtCursor(marker); + this.#insertTextAtCursor(marker); return; } @@ -1204,100 +1203,100 @@ export class Editor implements Component, Focusable { if (pastedLines.length === 1) { // Single line - insert character by character to trigger autocomplete for (const char of filteredText) { - this.insertCharacter(char); + this.#insertCharacter(char); } return; } // Multi-line paste - use insertTextAtCursor for proper handling - this.insertTextAtCursor(filteredText); + this.#insertTextAtCursor(filteredText); }); } - private addNewLine(): void { - this.historyIndex = -1; // Exit history browsing mode - this.resetKillSequence(); - this.recordUndoState(); + #addNewLine(): void { + this.#historyIndex = -1; // Exit history browsing mode + this.#resetKillSequence(); + this.#recordUndoState(); - const currentLine = this.state.lines[this.state.cursorLine] || ""; + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; - const before = currentLine.slice(0, this.state.cursorCol); - const after = currentLine.slice(this.state.cursorCol); + const before = currentLine.slice(0, this.#state.cursorCol); + const after = currentLine.slice(this.#state.cursorCol); // Split current line - this.state.lines[this.state.cursorLine] = before; - this.state.lines.splice(this.state.cursorLine + 1, 0, after); + this.#state.lines[this.#state.cursorLine] = before; + this.#state.lines.splice(this.#state.cursorLine + 1, 0, after); // Move cursor to start of new line - this.state.cursorLine++; - this.setCursorCol(0); + this.#state.cursorLine++; + this.#setCursorCol(0); if (this.onChange) { this.onChange(this.getText()); } } - private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType): boolean { + #shouldSubmitOnBackslashEnter(data: string, kb: EditorKeybindingsManager): boolean { if (this.disableSubmit) return false; if (!matchesKey(data, "enter")) return false; const submitKeys = kb.getKeys("submit"); const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return"); if (!hasShiftEnter) return false; - const currentLine = this.state.lines[this.state.cursorLine] || ""; - return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\"; + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + return this.#state.cursorCol > 0 && currentLine[this.#state.cursorCol - 1] === "\\"; } - private submitValue(): void { - this.resetKillSequence(); + #submitValue(): void { + this.#resetKillSequence(); - let result = this.state.lines.join("\n").trim(); - for (const [pasteId, pasteContent] of this.pastes) { + let result = this.#state.lines.join("\n").trim(); + for (const [pasteId, pasteContent] of this.#pastes) { const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g"); result = result.replace(markerRegex, pasteContent); } - this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; - this.pastes.clear(); - this.pasteCounter = 0; - this.historyIndex = -1; - this.scrollOffset = 0; - this.undoStack.clear(); + this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 }; + this.#pastes.clear(); + this.#pasteCounter = 0; + this.#historyIndex = -1; + this.#scrollOffset = 0; + this.#undoStack.length = 0; if (this.onChange) this.onChange(""); if (this.onSubmit) this.onSubmit(result); } - private handleBackspace(): void { - this.historyIndex = -1; // Exit history browsing mode - this.resetKillSequence(); - this.recordUndoState(); + #handleBackspace(): void { + this.#historyIndex = -1; // Exit history browsing mode + this.#resetKillSequence(); + this.#recordUndoState(); - if (this.state.cursorCol > 0) { + if (this.#state.cursorCol > 0) { // Delete grapheme before cursor (handles emojis, combining characters, etc.) - const line = this.state.lines[this.state.cursorLine] || ""; - const beforeCursor = line.slice(0, this.state.cursorCol); + const line = this.#state.lines[this.#state.cursorLine] || ""; + const beforeCursor = line.slice(0, this.#state.cursorCol); // Find the last grapheme in the text before cursor const graphemes = [...segmenter.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; - const before = line.slice(0, this.state.cursorCol - graphemeLength); - const after = line.slice(this.state.cursorCol); + const before = line.slice(0, this.#state.cursorCol - graphemeLength); + const after = line.slice(this.#state.cursorCol); - this.state.lines[this.state.cursorLine] = before + after; - this.setCursorCol(this.state.cursorCol - graphemeLength); - } else if (this.state.cursorLine > 0) { + this.#state.lines[this.#state.cursorLine] = before + after; + this.#setCursorCol(this.#state.cursorCol - graphemeLength); + } else if (this.#state.cursorLine > 0) { // Merge with previous line - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const previousLine = this.#state.lines[this.#state.cursorLine - 1] || ""; - this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; - this.state.lines.splice(this.state.cursorLine, 1); + this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine; + this.#state.lines.splice(this.#state.cursorLine, 1); - this.state.cursorLine--; - this.setCursorCol(previousLine.length); + this.#state.cursorLine--; + this.#setCursorCol(previousLine.length); } if (this.onChange) { @@ -1305,19 +1304,19 @@ export class Editor implements Component, Focusable { } // Update or re-trigger autocomplete after backspace - if (this.autocompleteState) { - this.debouncedUpdateAutocomplete(); + if (this.#autocompleteState) { + this.#debouncedUpdateAutocomplete(); } else { // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol); // Slash command context if (textBeforeCursor.trimStart().startsWith("/")) { - this.tryTriggerAutocomplete(); + this.#tryTriggerAutocomplete(); } // @ file reference context else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { - this.tryTriggerAutocomplete(); + this.#tryTriggerAutocomplete(); } } } @@ -1326,16 +1325,16 @@ export class Editor implements Component, Focusable { * Set cursor column and clear preferredVisualCol. * Use this for all non-vertical cursor movements to reset sticky column behavior. */ - private setCursorCol(col: number): void { - this.state.cursorCol = col; - this.preferredVisualCol = null; + #setCursorCol(col: number): void { + this.#state.cursorCol = col; + this.#preferredVisualCol = null; } /** * Move cursor to a target visual line, applying sticky column logic. * Shared by moveCursor() and pageScroll(). */ - private moveToVisualLine( + #moveToVisualLine( visualLines: Array<{ logicalLine: number; startCol: number; length: number }>, currentVisualLine: number, targetVisualLine: number, @@ -1344,7 +1343,7 @@ export class Editor implements Component, Focusable { const targetVL = visualLines[targetVisualLine]; if (currentVL && targetVL) { - const currentVisualCol = this.state.cursorCol - currentVL.startCol; + const currentVisualCol = this.#state.cursorCol - currentVL.startCol; // For non-last segments, clamp to length-1 to stay within the segment const isLastSourceSegment = @@ -1357,17 +1356,17 @@ export class Editor implements Component, Focusable { visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine; const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1); - const moveToVisualCol = this.computeVerticalMoveColumn( + const moveToVisualCol = this.#computeVerticalMoveColumn( currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol, ); // Set cursor position - this.state.cursorLine = targetVL.logicalLine; + this.#state.cursorLine = targetVL.logicalLine; const targetCol = targetVL.startCol + moveToVisualCol; - const logicalLine = this.state.lines[targetVL.logicalLine] || ""; - this.state.cursorCol = Math.min(targetCol, logicalLine.length); + const logicalLine = this.#state.lines[targetVL.logicalLine] || ""; + this.#state.cursorCol = Math.min(targetCol, logicalLine.length); } } @@ -1375,122 +1374,118 @@ export class Editor implements Component, Focusable { * Compute the target visual column for vertical cursor movement. * Implements the sticky column decision table. */ - private computeVerticalMoveColumn( + #computeVerticalMoveColumn( currentVisualCol: number, sourceMaxVisualCol: number, targetMaxVisualCol: number, ): number { - const hasPreferred = this.preferredVisualCol !== null; + const hasPreferred = this.#preferredVisualCol !== null; const cursorInMiddle = currentVisualCol < sourceMaxVisualCol; const targetTooShort = targetMaxVisualCol < currentVisualCol; if (!hasPreferred || cursorInMiddle) { if (targetTooShort) { - this.preferredVisualCol = currentVisualCol; + this.#preferredVisualCol = currentVisualCol; return targetMaxVisualCol; } - this.preferredVisualCol = null; + this.#preferredVisualCol = null; return currentVisualCol; } - const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol!; + const targetCantFitPreferred = targetMaxVisualCol < this.#preferredVisualCol!; if (targetTooShort || targetCantFitPreferred) { return targetMaxVisualCol; } - const result = this.preferredVisualCol!; - this.preferredVisualCol = null; + const result = this.#preferredVisualCol!; + this.#preferredVisualCol = null; return result; } - private moveToLineStart(): void { - this.resetKillSequence(); - this.setCursorCol(0); + #moveToLineStart(): void { + this.#resetKillSequence(); + this.#setCursorCol(0); } - private moveToLineEnd(): void { - this.resetKillSequence(); - const currentLine = this.state.lines[this.state.cursorLine] || ""; - this.setCursorCol(currentLine.length); + #moveToLineEnd(): void { + this.#resetKillSequence(); + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + this.#setCursorCol(currentLine.length); } - private resetKillSequence(): void { - this.lastAction = null; + #resetKillSequence(): void { + this.#lastAction = null; } - private clearUndoStack(): void { - this.undoStack.clear(); - } - - private withUndoSuspended(fn: () => T): T { - const wasSuspended = this.suspendUndo; - this.suspendUndo = true; + #withUndoSuspended(fn: () => T): T { + const wasSuspended = this.#suspendUndo; + this.#suspendUndo = true; try { return fn(); } finally { - this.suspendUndo = wasSuspended; + this.#suspendUndo = wasSuspended; } } - private recordUndoState(): void { - if (this.suspendUndo) return; - this.undoStack.push(this.state); + #recordUndoState(): void { + if (this.#suspendUndo) return; + this.#undoStack.push(structuredClone(this.#state)); } - private applyUndo(): void { - const snapshot = this.undoStack.pop(); + #applyUndo(): void { + const snapshot = this.#undoStack.pop(); if (!snapshot) return; - this.historyIndex = -1; - this.resetKillSequence(); - this.preferredVisualCol = null; - Object.assign(this.state, snapshot); + this.#historyIndex = -1; + this.#resetKillSequence(); + this.#preferredVisualCol = null; + Object.assign(this.#state, snapshot); if (this.onChange) { this.onChange(this.getText()); } - if (this.autocompleteState) { - this.debouncedUpdateAutocomplete(); + if (this.#autocompleteState) { + this.#debouncedUpdateAutocomplete(); } else { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol); if (textBeforeCursor.trimStart().startsWith("/")) { - this.tryTriggerAutocomplete(); + this.#tryTriggerAutocomplete(); } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { - this.tryTriggerAutocomplete(); + this.#tryTriggerAutocomplete(); } } } - private recordKill(text: string, direction: "forward" | "backward", accumulate = this.lastAction === "kill"): void { + #recordKill(text: string, direction: "forward" | "backward", accumulate = this.#lastAction === "kill"): void { if (!text) return; - this.killRing.push(text, { prepend: direction === "backward", accumulate }); - this.lastAction = "kill"; + this.#killRing.push(text, { prepend: direction === "backward", accumulate }); + this.#lastAction = "kill"; } - private insertTextAtCursor(text: string): void { - this.historyIndex = -1; - this.resetKillSequence(); - this.recordUndoState(); + #insertTextAtCursor(text: string): void { + this.#historyIndex = -1; + this.#resetKillSequence(); + this.#recordUndoState(); const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const lines = normalized.split("\n"); if (lines.length === 1) { - const line = this.state.lines[this.state.cursorLine] || ""; - const before = line.slice(0, this.state.cursorCol); - const after = line.slice(this.state.cursorCol); - this.state.lines[this.state.cursorLine] = before + normalized + after; - this.setCursorCol(this.state.cursorCol + normalized.length); + const line = this.#state.lines[this.#state.cursorLine] || ""; + const before = line.slice(0, this.#state.cursorCol); + const after = line.slice(this.#state.cursorCol); + this.#state.lines[this.#state.cursorLine] = before + normalized + after; + this.#setCursorCol(this.#state.cursorCol + normalized.length); } else { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const beforeCursor = currentLine.slice(0, this.state.cursorCol); - const afterCursor = currentLine.slice(this.state.cursorCol); + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.#state.cursorCol); + const afterCursor = currentLine.slice(this.#state.cursorCol); const newLines: string[] = []; - for (let i = 0; i < this.state.cursorLine; i++) { - newLines.push(this.state.lines[i] || ""); + for (let i = 0; i < this.#state.cursorLine; i++) { + newLines.push(this.#state.lines[i] || ""); } newLines.push(beforeCursor + (lines[0] || "")); @@ -1499,13 +1494,13 @@ export class Editor implements Component, Focusable { } newLines.push((lines[lines.length - 1] || "") + afterCursor); - for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) { - newLines.push(this.state.lines[i] || ""); + for (let i = this.#state.cursorLine + 1; i < this.#state.lines.length; i++) { + newLines.push(this.#state.lines[i] || ""); } - this.state.lines = newLines; - this.state.cursorLine += lines.length - 1; - this.setCursorCol((lines[lines.length - 1] || "").length); + this.#state.lines = newLines; + this.#state.cursorLine += lines.length - 1; + this.#setCursorCol((lines[lines.length - 1] || "").length); } if (this.onChange) { @@ -1513,30 +1508,30 @@ export class Editor implements Component, Focusable { } } - private yankFromKillRing(): void { - const text = this.killRing.peek(); + #yankFromKillRing(): void { + const text = this.#killRing.peek(); if (!text) return; - this.insertTextAtCursor(text); - this.lastAction = "yank"; + this.#insertTextAtCursor(text); + this.#lastAction = "yank"; } - private yankPop(): void { - if (this.lastAction !== "yank") return; - if (this.killRing.length <= 1) return; + #yankPop(): void { + if (this.#lastAction !== "yank") return; + if (this.#killRing.length <= 1) return; - this.historyIndex = -1; - this.recordUndoState(); + this.#historyIndex = -1; + this.#recordUndoState(); - this.withUndoSuspended(() => { - if (!this.deleteYankedText()) return; - this.killRing.rotate(); - const text = this.killRing.peek(); + this.#withUndoSuspended(() => { + if (!this.#deleteYankedText()) return; + this.#killRing.rotate(); + const text = this.#killRing.peek(); if (text) { - this.insertTextAtCursor(text); + this.#insertTextAtCursor(text); } }); - this.lastAction = "yank"; + this.#lastAction = "yank"; } /** @@ -1545,32 +1540,32 @@ export class Editor implements Component, Focusable { * This is a best-effort operation and assumes the cursor is still positioned * at the end of the yanked text. */ - private deleteYankedText(): boolean { - const yankedText = this.killRing.peek(); + #deleteYankedText(): boolean { + const yankedText = this.#killRing.peek(); if (!yankedText) return false; const yankLines = yankedText.split("\n"); - const endLine = this.state.cursorLine; - const endCol = this.state.cursorCol; + const endLine = this.#state.cursorLine; + const endCol = this.#state.cursorCol; const startLine = endLine - (yankLines.length - 1); if (startLine < 0) return false; if (yankLines.length === 1) { - const line = this.state.lines[endLine] ?? ""; + const line = this.#state.lines[endLine] ?? ""; const startCol = endCol - yankedText.length; if (startCol < 0) return false; if (line.slice(startCol, endCol) !== yankedText) return false; - this.state.lines[endLine] = line.slice(0, startCol) + line.slice(endCol); - this.state.cursorLine = endLine; - this.setCursorCol(startCol); + this.#state.lines[endLine] = line.slice(0, startCol) + line.slice(endCol); + this.#state.cursorLine = endLine; + this.#setCursorCol(startCol); return true; } const firstInserted = yankLines[0] ?? ""; const lastInserted = yankLines[yankLines.length - 1] ?? ""; - const firstLineText = this.state.lines[startLine] ?? ""; - const lastLineText = this.state.lines[endLine] ?? ""; + const firstLineText = this.#state.lines[startLine] ?? ""; + const lastLineText = this.#state.lines[endLine] ?? ""; if (!firstLineText.endsWith(firstInserted)) return false; if (endCol !== lastInserted.length) return false; @@ -1582,94 +1577,94 @@ export class Editor implements Component, Focusable { const suffix = lastLineText.slice(endCol); const newLine = firstLineText.slice(0, startCol) + suffix; - this.state.lines.splice(startLine, yankLines.length, newLine); - this.state.cursorLine = startLine; - this.setCursorCol(startCol); + this.#state.lines.splice(startLine, yankLines.length, newLine); + this.#state.cursorLine = startLine; + this.#setCursorCol(startCol); return true; } - private deleteToStartOfLine(): void { - this.historyIndex = -1; // Exit history browsing mode - this.recordUndoState(); + #deleteToStartOfLine(): void { + this.#historyIndex = -1; // Exit history browsing mode + this.#recordUndoState(); - const currentLine = this.state.lines[this.state.cursorLine] || ""; + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; let deletedText = ""; - if (this.state.cursorCol > 0) { + if (this.#state.cursorCol > 0) { // Delete from start of line up to cursor - deletedText = currentLine.slice(0, this.state.cursorCol); - this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol); - this.setCursorCol(0); - } else if (this.state.cursorLine > 0) { + deletedText = currentLine.slice(0, this.#state.cursorCol); + this.#state.lines[this.#state.cursorLine] = currentLine.slice(this.#state.cursorCol); + this.#setCursorCol(0); + } else if (this.#state.cursorLine > 0) { // At start of line - merge with previous line deletedText = "\n"; - const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; - this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; - this.state.lines.splice(this.state.cursorLine, 1); - this.state.cursorLine--; - this.setCursorCol(previousLine.length); + const previousLine = this.#state.lines[this.#state.cursorLine - 1] || ""; + this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine; + this.#state.lines.splice(this.#state.cursorLine, 1); + this.#state.cursorLine--; + this.#setCursorCol(previousLine.length); } - this.recordKill(deletedText, "backward"); + this.#recordKill(deletedText, "backward"); if (this.onChange) { this.onChange(this.getText()); } } - private deleteToEndOfLine(): void { - this.historyIndex = -1; // Exit history browsing mode - this.recordUndoState(); + #deleteToEndOfLine(): void { + this.#historyIndex = -1; // Exit history browsing mode + this.#recordUndoState(); - const currentLine = this.state.lines[this.state.cursorLine] || ""; + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; let deletedText = ""; - if (this.state.cursorCol < currentLine.length) { + if (this.#state.cursorCol < currentLine.length) { // Delete from cursor to end of line - deletedText = currentLine.slice(this.state.cursorCol); - this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol); - } else if (this.state.cursorLine < this.state.lines.length - 1) { + deletedText = currentLine.slice(this.#state.cursorCol); + this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, this.#state.cursorCol); + } else if (this.#state.cursorLine < this.#state.lines.length - 1) { // At end of line - merge with next line - const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; + const nextLine = this.#state.lines[this.#state.cursorLine + 1] || ""; deletedText = "\n"; - this.state.lines[this.state.cursorLine] = currentLine + nextLine; - this.state.lines.splice(this.state.cursorLine + 1, 1); + this.#state.lines[this.#state.cursorLine] = currentLine + nextLine; + this.#state.lines.splice(this.#state.cursorLine + 1, 1); } - this.recordKill(deletedText, "forward"); + this.#recordKill(deletedText, "forward"); if (this.onChange) { this.onChange(this.getText()); } } - private deleteWordBackwards(): void { - this.historyIndex = -1; // Exit history browsing mode - this.recordUndoState(); + #deleteWordBackwards(): void { + this.#historyIndex = -1; // Exit history browsing mode + this.#recordUndoState(); - const currentLine = this.state.lines[this.state.cursorLine] || ""; + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; // If at start of line, behave like backspace at column 0 (merge with previous line) - if (this.state.cursorCol === 0) { - if (this.state.cursorLine > 0) { - this.recordKill("\n", "backward"); - const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; - this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; - this.state.lines.splice(this.state.cursorLine, 1); - this.state.cursorLine--; - this.setCursorCol(previousLine.length); + if (this.#state.cursorCol === 0) { + if (this.#state.cursorLine > 0) { + this.#recordKill("\n", "backward"); + const previousLine = this.#state.lines[this.#state.cursorLine - 1] || ""; + this.#state.lines[this.#state.cursorLine - 1] = previousLine + currentLine; + this.#state.lines.splice(this.#state.cursorLine, 1); + this.#state.cursorLine--; + this.#setCursorCol(previousLine.length); } } else { - const oldCursorCol = this.state.cursorCol; - this.moveWordBackwards(); - const deleteFrom = this.state.cursorCol; - this.setCursorCol(oldCursorCol); + const oldCursorCol = this.#state.cursorCol; + this.#moveWordBackwards(); + const deleteFrom = this.#state.cursorCol; + this.#setCursorCol(oldCursorCol); const deletedText = currentLine.slice(deleteFrom, oldCursorCol); - this.state.lines[this.state.cursorLine] = - currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol); - this.setCursorCol(deleteFrom); - this.recordKill(deletedText, "backward"); + this.#state.lines[this.#state.cursorLine] = + currentLine.slice(0, deleteFrom) + currentLine.slice(this.#state.cursorCol); + this.#setCursorCol(deleteFrom); + this.#recordKill(deletedText, "backward"); } if (this.onChange) { @@ -1677,28 +1672,28 @@ export class Editor implements Component, Focusable { } } - private deleteWordForwards(): void { - this.historyIndex = -1; // Exit history browsing mode - this.recordUndoState(); + #deleteWordForwards(): void { + this.#historyIndex = -1; // Exit history browsing mode + this.#recordUndoState(); - const currentLine = this.state.lines[this.state.cursorLine] || ""; + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; - if (this.state.cursorCol >= currentLine.length) { - if (this.state.cursorLine < this.state.lines.length - 1) { - this.recordKill("\n", "forward"); - const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; - this.state.lines[this.state.cursorLine] = currentLine + nextLine; - this.state.lines.splice(this.state.cursorLine + 1, 1); + if (this.#state.cursorCol >= currentLine.length) { + if (this.#state.cursorLine < this.#state.lines.length - 1) { + this.#recordKill("\n", "forward"); + const nextLine = this.#state.lines[this.#state.cursorLine + 1] || ""; + this.#state.lines[this.#state.cursorLine] = currentLine + nextLine; + this.#state.lines.splice(this.#state.cursorLine + 1, 1); } } else { - const oldCursorCol = this.state.cursorCol; - this.moveWordForwards(); - const deleteTo = this.state.cursorCol; - this.setCursorCol(oldCursorCol); + const oldCursorCol = this.#state.cursorCol; + this.#moveWordForwards(); + const deleteTo = this.#state.cursorCol; + this.#setCursorCol(oldCursorCol); const deletedText = currentLine.slice(oldCursorCol, deleteTo); - this.state.lines[this.state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo); - this.recordKill(deletedText, "forward"); + this.#state.lines[this.#state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo); + this.#recordKill(deletedText, "forward"); } if (this.onChange) { @@ -1706,30 +1701,30 @@ export class Editor implements Component, Focusable { } } - private handleForwardDelete(): void { - this.historyIndex = -1; // Exit history browsing mode - this.resetKillSequence(); - this.recordUndoState(); + #handleForwardDelete(): void { + this.#historyIndex = -1; // Exit history browsing mode + this.#resetKillSequence(); + this.#recordUndoState(); - const currentLine = this.state.lines[this.state.cursorLine] || ""; + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; - if (this.state.cursorCol < currentLine.length) { + if (this.#state.cursorCol < currentLine.length) { // Delete grapheme at cursor position (handles emojis, combining characters, etc.) - const afterCursor = currentLine.slice(this.state.cursorCol); + const afterCursor = currentLine.slice(this.#state.cursorCol); // Find the first grapheme at cursor const graphemes = [...segmenter.segment(afterCursor)]; const firstGrapheme = graphemes[0]; const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; - const before = currentLine.slice(0, this.state.cursorCol); - const after = currentLine.slice(this.state.cursorCol + graphemeLength); - this.state.lines[this.state.cursorLine] = before + after; - } else if (this.state.cursorLine < this.state.lines.length - 1) { + const before = currentLine.slice(0, this.#state.cursorCol); + const after = currentLine.slice(this.#state.cursorCol + graphemeLength); + this.#state.lines[this.#state.cursorLine] = before + after; + } else if (this.#state.cursorLine < this.#state.lines.length - 1) { // At end of line - merge with next line - const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; - this.state.lines[this.state.cursorLine] = currentLine + nextLine; - this.state.lines.splice(this.state.cursorLine + 1, 1); + const nextLine = this.#state.lines[this.#state.cursorLine + 1] || ""; + this.#state.lines[this.#state.cursorLine] = currentLine + nextLine; + this.#state.lines.splice(this.#state.cursorLine + 1, 1); } if (this.onChange) { @@ -1737,18 +1732,18 @@ export class Editor implements Component, Focusable { } // Update or re-trigger autocomplete after forward delete - if (this.autocompleteState) { - this.debouncedUpdateAutocomplete(); + if (this.#autocompleteState) { + this.#debouncedUpdateAutocomplete(); } else { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol); // Slash command context if (textBeforeCursor.trimStart().startsWith("/")) { - this.tryTriggerAutocomplete(); + this.#tryTriggerAutocomplete(); } // @ file reference context else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) { - this.tryTriggerAutocomplete(); + this.#tryTriggerAutocomplete(); } } } @@ -1756,15 +1751,15 @@ export class Editor implements Component, Focusable { /** * Build a mapping from visual lines to logical positions. * Returns an array where each element represents a visual line with: - * - logicalLine: index into this.state.lines + * - logicalLine: index into this.#state.lines * - startCol: starting column in the logical line * - length: length of this visual line segment */ - private buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> { + #buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> { const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = []; - for (let i = 0; i < this.state.lines.length; i++) { - const line = this.state.lines[i] || ""; + for (let i = 0; i < this.#state.lines.length; i++) { + const line = this.#state.lines[i] || ""; const lineVisWidth = visibleWidth(line); if (line.length === 0) { // Empty line still takes one visual line @@ -1790,14 +1785,12 @@ export class Editor implements Component, Focusable { /** * Find the visual line index for the current cursor position. */ - private findCurrentVisualLine( - visualLines: Array<{ logicalLine: number; startCol: number; length: number }>, - ): number { + #findCurrentVisualLine(visualLines: Array<{ logicalLine: number; startCol: number; length: number }>): number { for (let i = 0; i < visualLines.length; i++) { const vl = visualLines[i]; if (!vl) continue; - if (vl.logicalLine === this.state.cursorLine) { - const colInSegment = this.state.cursorCol - vl.startCol; + if (vl.logicalLine === this.#state.cursorLine) { + const colInSegment = this.#state.cursorCol - vl.startCol; // Cursor is in this segment if it's within range // For the last segment of a logical line, cursor can be at length (end position) const isLastSegmentOfLine = @@ -1811,73 +1804,73 @@ export class Editor implements Component, Focusable { return visualLines.length - 1; } - private moveCursor(deltaLine: number, deltaCol: number): void { - this.resetKillSequence(); - const visualLines = this.buildVisualLineMap(this.lastLayoutWidth); - const currentVisualLine = this.findCurrentVisualLine(visualLines); + #moveCursor(deltaLine: number, deltaCol: number): void { + this.#resetKillSequence(); + const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth); + const currentVisualLine = this.#findCurrentVisualLine(visualLines); if (deltaLine !== 0) { const targetVisualLine = currentVisualLine + deltaLine; if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) { - this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine); + this.#moveToVisualLine(visualLines, currentVisualLine, targetVisualLine); } } if (deltaCol !== 0) { - const currentLine = this.state.lines[this.state.cursorLine] || ""; + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; if (deltaCol > 0) { // Moving right - move by one grapheme (handles emojis, combining characters, etc.) - if (this.state.cursorCol < currentLine.length) { - const afterCursor = currentLine.slice(this.state.cursorCol); + if (this.#state.cursorCol < currentLine.length) { + const afterCursor = currentLine.slice(this.#state.cursorCol); const graphemes = [...segmenter.segment(afterCursor)]; const firstGrapheme = graphemes[0]; - this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1)); - } else if (this.state.cursorLine < this.state.lines.length - 1) { + this.#setCursorCol(this.#state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1)); + } else if (this.#state.cursorLine < this.#state.lines.length - 1) { // Wrap to start of next logical line - this.state.cursorLine++; - this.setCursorCol(0); + this.#state.cursorLine++; + this.#setCursorCol(0); } else { // At end of last line - can't move, but set preferredVisualCol for up/down navigation const currentVL = visualLines[currentVisualLine]; if (currentVL) { - this.preferredVisualCol = this.state.cursorCol - currentVL.startCol; + this.#preferredVisualCol = this.#state.cursorCol - currentVL.startCol; } } } else { // Moving left - move by one grapheme (handles emojis, combining characters, etc.) - if (this.state.cursorCol > 0) { - const beforeCursor = currentLine.slice(0, this.state.cursorCol); + if (this.#state.cursorCol > 0) { + const beforeCursor = currentLine.slice(0, this.#state.cursorCol); const graphemes = [...segmenter.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; - this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1)); - } else if (this.state.cursorLine > 0) { + this.#setCursorCol(this.#state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1)); + } else if (this.#state.cursorLine > 0) { // Wrap to end of previous logical line - this.state.cursorLine--; - const prevLine = this.state.lines[this.state.cursorLine] || ""; - this.setCursorCol(prevLine.length); + this.#state.cursorLine--; + const prevLine = this.#state.lines[this.#state.cursorLine] || ""; + this.#setCursorCol(prevLine.length); } } } } - private moveWordBackwards(): void { - const currentLine = this.state.lines[this.state.cursorLine] || ""; + #moveWordBackwards(): void { + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; // If at start of line, move to end of previous line - if (this.state.cursorCol === 0) { - if (this.state.cursorLine > 0) { - this.state.cursorLine--; - const prevLine = this.state.lines[this.state.cursorLine] || ""; - this.setCursorCol(prevLine.length); + if (this.#state.cursorCol === 0) { + if (this.#state.cursorLine > 0) { + this.#state.cursorLine--; + const prevLine = this.#state.lines[this.#state.cursorLine] || ""; + this.#setCursorCol(prevLine.length); } return; } - const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol); const graphemes = [...segmenter.segment(textBeforeCursor)]; - let newCol = this.state.cursorCol; + let newCol = this.#state.cursorCol; // Skip trailing whitespace while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) { @@ -1903,60 +1896,60 @@ export class Editor implements Component, Focusable { } } - this.setCursorCol(newCol); + this.#setCursorCol(newCol); } /** * Jump to the first occurrence of a character in the specified direction. * Multi-line search. Case-sensitive. Skips the current cursor position. */ - private jumpToChar(char: string, direction: "forward" | "backward"): void { - this.resetKillSequence(); + #jumpToChar(char: string, direction: "forward" | "backward"): void { + this.#resetKillSequence(); const isForward = direction === "forward"; - const lines = this.state.lines; + const lines = this.#state.lines; const end = isForward ? lines.length : -1; const step = isForward ? 1 : -1; - for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) { + for (let lineIdx = this.#state.cursorLine; lineIdx !== end; lineIdx += step) { const line = lines[lineIdx] || ""; - const isCurrentLine = lineIdx === this.state.cursorLine; + const isCurrentLine = lineIdx === this.#state.cursorLine; // Current line: start after/before cursor; other lines: search full line const searchFrom = isCurrentLine ? isForward - ? this.state.cursorCol + 1 - : this.state.cursorCol - 1 + ? this.#state.cursorCol + 1 + : this.#state.cursorCol - 1 : undefined; const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom); if (idx !== -1) { - this.state.cursorLine = lineIdx; - this.setCursorCol(idx); + this.#state.cursorLine = lineIdx; + this.#setCursorCol(idx); return; } } // No match found - cursor stays in place } - private moveWordForwards(): void { - const currentLine = this.state.lines[this.state.cursorLine] || ""; + #moveWordForwards(): void { + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; // If at end of line, move to start of next line - if (this.state.cursorCol >= currentLine.length) { - if (this.state.cursorLine < this.state.lines.length - 1) { - this.state.cursorLine++; - this.setCursorCol(0); + if (this.#state.cursorCol >= currentLine.length) { + if (this.#state.cursorLine < this.#state.lines.length - 1) { + this.#state.cursorLine++; + this.#setCursorCol(0); } return; } - const textAfterCursor = currentLine.slice(this.state.cursorCol); + const textAfterCursor = currentLine.slice(this.#state.cursorCol); const segments = segmenter.segment(textAfterCursor); const iterator = segments[Symbol.iterator](); let next = iterator.next(); - let newCol = this.state.cursorCol; + let newCol = this.#state.cursorCol; // Skip leading whitespace while (!next.done && isWhitespaceChar(next.value.segment)) { @@ -1981,68 +1974,72 @@ export class Editor implements Component, Focusable { } } - this.setCursorCol(newCol); + this.#setCursorCol(newCol); } // Helper method to check if cursor is at start of message (for slash command detection) - private isAtStartOfMessage(): boolean { - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const beforeCursor = currentLine.slice(0, this.state.cursorCol); + #isAtStartOfMessage(): boolean { + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.#state.cursorCol); // At start if line is empty, only contains whitespace, or is just "/" return beforeCursor.trim() === "" || beforeCursor.trim() === "/"; } // Autocomplete methods - private async tryTriggerAutocomplete(explicitTab: boolean = false): Promise { - if (!this.autocompleteProvider) return; + async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise { + if (!this.#autocompleteProvider) return; // Check if we should trigger file completion on Tab if (explicitTab) { - const provider = this.autocompleteProvider as CombinedAutocompleteProvider; + const provider = this.#autocompleteProvider as CombinedAutocompleteProvider; const shouldTrigger = !provider.shouldTriggerFileCompletion || - provider.shouldTriggerFileCompletion(this.state.lines, this.state.cursorLine, this.state.cursorCol); + provider.shouldTriggerFileCompletion(this.#state.lines, this.#state.cursorLine, this.#state.cursorCol); if (!shouldTrigger) { return; } } - const requestId = ++this.autocompleteRequestId; + const requestId = ++this.#autocompleteRequestId; - const suggestions = await this.autocompleteProvider.getSuggestions( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, + const suggestions = await this.#autocompleteProvider.getSuggestions( + this.#state.lines, + this.#state.cursorLine, + this.#state.cursorCol, ); - if (requestId !== this.autocompleteRequestId) return; + if (requestId !== this.#autocompleteRequestId) return; if (suggestions && suggestions.items.length > 0) { - this.autocompletePrefix = suggestions.prefix; - this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList); - this.autocompleteState = "regular"; + this.#autocompletePrefix = suggestions.prefix; + this.#autocompleteList = new SelectList( + suggestions.items, + this.#autocompleteMaxVisible, + this.#theme.selectList, + ); + this.#autocompleteState = "regular"; this.onAutocompleteUpdate?.(); } else { - this.cancelAutocomplete(); + this.#cancelAutocomplete(); this.onAutocompleteUpdate?.(); } } - private handleTabCompletion(): void { - if (!this.autocompleteProvider) return; + #handleTabCompletion(): void { + if (!this.#autocompleteProvider) return; - const currentLine = this.state.lines[this.state.cursorLine] || ""; - const beforeCursor = currentLine.slice(0, this.state.cursorCol); + const currentLine = this.#state.lines[this.#state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.#state.cursorCol); // Check if we're in a slash command context if (beforeCursor.trimStart().startsWith("/") && !beforeCursor.trimStart().includes(" ")) { - this.handleSlashCommandCompletion(); + this.#handleSlashCommandCompletion(); } else { - this.forceFileAutocomplete(true); + this.#forceFileAutocomplete(true); } } - private handleSlashCommandCompletion(): void { - this.tryTriggerAutocomplete(true); + #handleSlashCommandCompletion(): void { + this.#tryTriggerAutocomplete(true); } /* @@ -2050,41 +2047,41 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ 17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19 536643416/job/55932288317 havea look at .gi */ - private async forceFileAutocomplete(explicitTab: boolean = false): Promise { - if (!this.autocompleteProvider) return; + async #forceFileAutocomplete(explicitTab: boolean = false): Promise { + if (!this.#autocompleteProvider) return; // Check if provider supports force file suggestions via runtime check - const provider = this.autocompleteProvider as { + const provider = this.#autocompleteProvider as { getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"]; }; if (typeof provider.getForceFileSuggestions !== "function") { - await this.tryTriggerAutocomplete(true); + await this.#tryTriggerAutocomplete(true); return; } - const requestId = ++this.autocompleteRequestId; + const requestId = ++this.#autocompleteRequestId; const suggestions = await provider.getForceFileSuggestions( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, + this.#state.lines, + this.#state.cursorLine, + this.#state.cursorCol, ); - if (requestId !== this.autocompleteRequestId) return; + if (requestId !== this.#autocompleteRequestId) return; if (suggestions && suggestions.items.length > 0) { // If there's exactly one suggestion and this was an explicit Tab press, apply it immediately if (explicitTab && suggestions.items.length === 1) { const item = suggestions.items[0]!; - const result = this.autocompleteProvider.applyCompletion( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, + const result = this.#autocompleteProvider.applyCompletion( + this.#state.lines, + this.#state.cursorLine, + this.#state.cursorCol, item, suggestions.prefix, ); - this.state.lines = result.lines; - this.state.cursorLine = result.cursorLine; - this.setCursorCol(result.cursorCol); + this.#state.lines = result.lines; + this.#state.cursorLine = result.cursorLine; + this.#setCursorCol(result.cursorCol); if (this.onChange) { this.onChange(this.getText()); @@ -2092,75 +2089,83 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/ return; } - this.autocompletePrefix = suggestions.prefix; - this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList); - this.autocompleteState = "force"; + this.#autocompletePrefix = suggestions.prefix; + this.#autocompleteList = new SelectList( + suggestions.items, + this.#autocompleteMaxVisible, + this.#theme.selectList, + ); + this.#autocompleteState = "force"; this.onAutocompleteUpdate?.(); } else { - this.cancelAutocomplete(); + this.#cancelAutocomplete(); this.onAutocompleteUpdate?.(); } } - private cancelAutocomplete(notifyCancel: boolean = false): void { - const wasAutocompleting = this.autocompleteState !== null; - this.clearAutocompleteTimeout(); - this.autocompleteRequestId += 1; - this.autocompleteState = null; - this.autocompleteList = undefined; - this.autocompletePrefix = ""; + #cancelAutocomplete(notifyCancel: boolean = false): void { + const wasAutocompleting = this.#autocompleteState !== null; + this.#clearAutocompleteTimeout(); + this.#autocompleteRequestId += 1; + this.#autocompleteState = null; + this.#autocompleteList = undefined; + this.#autocompletePrefix = ""; if (notifyCancel && wasAutocompleting) { this.onAutocompleteCancel?.(); } } - public isShowingAutocomplete(): boolean { - return this.autocompleteState !== null; + isShowingAutocomplete(): boolean { + return this.#autocompleteState !== null; } - private async updateAutocomplete(): Promise { - if (!this.autocompleteState || !this.autocompleteProvider) return; + async #updateAutocomplete(): Promise { + if (!this.#autocompleteState || !this.#autocompleteProvider) return; // In force mode, use forceFileAutocomplete to get suggestions - if (this.autocompleteState === "force") { - this.forceFileAutocomplete(); + if (this.#autocompleteState === "force") { + this.#forceFileAutocomplete(); return; } - const requestId = ++this.autocompleteRequestId; + const requestId = ++this.#autocompleteRequestId; - const suggestions = await this.autocompleteProvider.getSuggestions( - this.state.lines, - this.state.cursorLine, - this.state.cursorCol, + const suggestions = await this.#autocompleteProvider.getSuggestions( + this.#state.lines, + this.#state.cursorLine, + this.#state.cursorCol, ); - if (requestId !== this.autocompleteRequestId) return; + if (requestId !== this.#autocompleteRequestId) return; if (suggestions && suggestions.items.length > 0) { - this.autocompletePrefix = suggestions.prefix; + this.#autocompletePrefix = suggestions.prefix; // Always create new SelectList to ensure update - this.autocompleteList = new SelectList(suggestions.items, this.autocompleteMaxVisible, this.theme.selectList); + this.#autocompleteList = new SelectList( + suggestions.items, + this.#autocompleteMaxVisible, + this.#theme.selectList, + ); this.onAutocompleteUpdate?.(); } else { - this.cancelAutocomplete(); + this.#cancelAutocomplete(); this.onAutocompleteUpdate?.(); } } - private debouncedUpdateAutocomplete(): void { - if (this.autocompleteTimeout) { - clearTimeout(this.autocompleteTimeout); + #debouncedUpdateAutocomplete(): void { + if (this.#autocompleteTimeout) { + clearTimeout(this.#autocompleteTimeout); } - this.autocompleteTimeout = setTimeout(() => { - this.updateAutocomplete(); - this.autocompleteTimeout = null; + this.#autocompleteTimeout = setTimeout(() => { + this.#updateAutocomplete(); + this.#autocompleteTimeout = undefined; }, 100); } - private clearAutocompleteTimeout(): void { - if (this.autocompleteTimeout) { - clearTimeout(this.autocompleteTimeout); - this.autocompleteTimeout = null; + #clearAutocompleteTimeout(): void { + if (this.#autocompleteTimeout) { + clearTimeout(this.#autocompleteTimeout); + this.#autocompleteTimeout = undefined; } } } diff --git a/packages/tui/src/components/image.ts b/packages/tui/src/components/image.ts index 17127230c..f212fdc47 100644 --- a/packages/tui/src/components/image.ts +++ b/packages/tui/src/components/image.ts @@ -18,14 +18,14 @@ export interface ImageOptions { } export class Image implements Component { - private base64Data: string; - private mimeType: string; - private dimensions: ImageDimensions; - private theme: ImageTheme; - private options: ImageOptions; + #base64Data: string; + #mimeType: string; + #dimensions: ImageDimensions; + #theme: ImageTheme; + #options: ImageOptions; - private cachedLines?: string[]; - private cachedWidth?: number; + #cachedLines?: string[]; + #cachedWidth?: number; constructor( base64Data: string, @@ -34,29 +34,29 @@ export class Image implements Component { options: ImageOptions = {}, dimensions?: ImageDimensions, ) { - this.base64Data = base64Data; - this.mimeType = mimeType; - this.theme = theme; - this.options = options; - this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 }; + this.#base64Data = base64Data; + this.#mimeType = mimeType; + this.#theme = theme; + this.#options = options; + this.#dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 }; } invalidate(): void { - this.cachedLines = undefined; - this.cachedWidth = undefined; + this.#cachedLines = undefined; + this.#cachedWidth = undefined; } render(width: number): string[] { - if (this.cachedLines && this.cachedWidth === width) { - return this.cachedLines; + if (this.#cachedLines && this.#cachedWidth === width) { + return this.#cachedLines; } - const maxWidth = Math.min(width - 2, this.options.maxWidthCells ?? 60); + const maxWidth = Math.min(width - 2, this.#options.maxWidthCells ?? 60); let lines: string[]; if (TERMINAL.imageProtocol) { - const result = renderImage(this.base64Data, this.dimensions, { maxWidthCells: maxWidth }); + const result = renderImage(this.#base64Data, this.#dimensions, { maxWidthCells: maxWidth }); if (result) { // Return `rows` lines so TUI accounts for image height @@ -70,16 +70,16 @@ export class Image implements Component { const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : ""; lines.push(moveUp + result.sequence); } else { - const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); - lines = [this.theme.fallbackColor(fallback)]; + const fallback = imageFallback(this.#mimeType, this.#dimensions, this.#options.filename); + lines = [this.#theme.fallbackColor(fallback)]; } } else { - const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); - lines = [this.theme.fallbackColor(fallback)]; + const fallback = imageFallback(this.#mimeType, this.#dimensions, this.#options.filename); + lines = [this.#theme.fallbackColor(fallback)]; } - this.cachedLines = lines; - this.cachedWidth = width; + this.#cachedLines = lines; + this.#cachedWidth = width; return lines; } diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index fae4c4cb9..acce3ceab 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,7 +1,6 @@ import { getEditorKeybindings } from "../keybindings"; import { KillRing } from "../kill-ring"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui"; -import { UndoStack } from "../undo-stack"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, padding, visibleWidth } from "../utils"; const segmenter = getSegmenter(); @@ -15,32 +14,32 @@ interface InputState { * Input component - single-line text input with horizontal scrolling */ export class Input implements Component, Focusable { - private value: string = ""; - private cursor: number = 0; // Cursor position in the value - public onSubmit?: (value: string) => void; - public onEscape?: () => void; + #value: string = ""; + #cursor: number = 0; // Cursor position in the value + onSubmit?: (value: string) => void; + onEscape?: () => void; /** Focusable interface - set by TUI when focus changes */ focused: boolean = false; // Bracketed paste mode buffering - private pasteBuffer: string = ""; - private isInPaste: boolean = false; + #pasteBuffer: string = ""; + #isInPaste: boolean = false; // Kill ring for Emacs-style kill/yank operations - private killRing = new KillRing(); - private lastAction: "kill" | "yank" | "type-word" | null = null; + #killRing = new KillRing(); + #lastAction: "kill" | "yank" | "type-word" | null = null; // Undo support - private undoStack = new UndoStack(); + #undoStack: InputState[] = []; getValue(): string { - return this.value; + return this.#value; } setValue(value: string): void { - this.value = value; - this.cursor = Math.min(this.cursor, value.length); + this.#value = value; + this.#cursor = Math.min(this.#cursor, value.length); } handleInput(data: string): void { @@ -50,30 +49,30 @@ export class Input implements Component, Focusable { // Check if we're starting a bracketed paste if (data.includes("\x1b[200~")) { - this.isInPaste = true; - this.pasteBuffer = ""; + this.#isInPaste = true; + this.#pasteBuffer = ""; data = data.replace("\x1b[200~", ""); } // If we're in a paste, buffer the data - if (this.isInPaste) { + if (this.#isInPaste) { // Check if this chunk contains the end marker - this.pasteBuffer += data; + this.#pasteBuffer += data; - const endIndex = this.pasteBuffer.indexOf("\x1b[201~"); + const endIndex = this.#pasteBuffer.indexOf("\x1b[201~"); if (endIndex !== -1) { // Extract the pasted content - const pasteContent = this.pasteBuffer.substring(0, endIndex); + const pasteContent = this.#pasteBuffer.substring(0, endIndex); // Process the complete paste - this.handlePaste(pasteContent); + this.#handlePaste(pasteContent); // Reset paste state - this.isInPaste = false; + this.#isInPaste = false; // Handle any remaining input after the paste marker - const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~ - this.pasteBuffer = ""; + const remaining = this.#pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~ + this.#pasteBuffer = ""; if (remaining) { this.handleInput(remaining); } @@ -91,99 +90,99 @@ export class Input implements Component, Focusable { // Undo if (kb.matches(data, "undo")) { - this.undo(); + this.#undo(); return; } // Submit if (kb.matches(data, "submit") || data === "\n") { - if (this.onSubmit) this.onSubmit(this.value); + if (this.onSubmit) this.onSubmit(this.#value); return; } // Deletion if (kb.matches(data, "deleteCharBackward")) { - this.handleBackspace(); + this.#handleBackspace(); return; } if (kb.matches(data, "deleteCharForward")) { - this.handleForwardDelete(); + this.#handleForwardDelete(); return; } if (kb.matches(data, "deleteWordBackward")) { - this.deleteWordBackwards(); + this.#deleteWordBackwards(); return; } if (kb.matches(data, "deleteWordForward")) { - this.deleteWordForward(); + this.#deleteWordForward(); return; } if (kb.matches(data, "deleteToLineStart")) { - this.deleteToLineStart(); + this.#deleteToLineStart(); return; } if (kb.matches(data, "deleteToLineEnd")) { - this.deleteToLineEnd(); + this.#deleteToLineEnd(); return; } // Kill ring actions if (kb.matches(data, "yank")) { - this.yank(); + this.#yank(); return; } if (kb.matches(data, "yankPop")) { - this.yankPop(); + this.#yankPop(); return; } // Cursor movement if (kb.matches(data, "cursorLeft")) { - this.lastAction = null; - if (this.cursor > 0) { - const beforeCursor = this.value.slice(0, this.cursor); + this.#lastAction = null; + if (this.#cursor > 0) { + const beforeCursor = this.#value.slice(0, this.#cursor); const graphemes = [...segmenter.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; - this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1; + this.#cursor -= lastGrapheme ? lastGrapheme.segment.length : 1; } return; } if (kb.matches(data, "cursorRight")) { - this.lastAction = null; - if (this.cursor < this.value.length) { - const afterCursor = this.value.slice(this.cursor); + this.#lastAction = null; + if (this.#cursor < this.#value.length) { + const afterCursor = this.#value.slice(this.#cursor); const graphemes = [...segmenter.segment(afterCursor)]; const firstGrapheme = graphemes[0]; - this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1; + this.#cursor += firstGrapheme ? firstGrapheme.segment.length : 1; } return; } if (kb.matches(data, "cursorLineStart")) { - this.lastAction = null; - this.cursor = 0; + this.#lastAction = null; + this.#cursor = 0; return; } if (kb.matches(data, "cursorLineEnd")) { - this.lastAction = null; - this.cursor = this.value.length; + this.#lastAction = null; + this.#cursor = this.#value.length; return; } if (kb.matches(data, "cursorWordLeft")) { - this.moveWordBackwards(); + this.#moveWordBackwards(); return; } if (kb.matches(data, "cursorWordRight")) { - this.moveWordForwards(); + this.#moveWordForwards(); return; } @@ -194,181 +193,181 @@ export class Input implements Component, Focusable { return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); }); if (!hasControlChars) { - this.insertCharacter(data); + this.#insertCharacter(data); } } - private insertCharacter(text: string): void { + #insertCharacter(text: string): void { const isWordChunk = [...text].every(ch => !isWhitespaceChar(ch)); // Undo coalescing: consecutive word typing coalesces into one undo unit. - if (!isWordChunk || this.lastAction !== "type-word") { - this.pushUndo(); + if (!isWordChunk || this.#lastAction !== "type-word") { + this.#pushUndo(); } - this.lastAction = "type-word"; + this.#lastAction = "type-word"; - this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); - this.cursor += text.length; + this.#value = this.#value.slice(0, this.#cursor) + text + this.#value.slice(this.#cursor); + this.#cursor += text.length; } - private handleBackspace(): void { - this.lastAction = null; - if (this.cursor <= 0) { + #handleBackspace(): void { + this.#lastAction = null; + if (this.#cursor <= 0) { return; } - this.pushUndo(); + this.#pushUndo(); - const beforeCursor = this.value.slice(0, this.cursor); + const beforeCursor = this.#value.slice(0, this.#cursor); const graphemes = [...segmenter.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; - this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor); - this.cursor -= graphemeLength; + this.#value = this.#value.slice(0, this.#cursor - graphemeLength) + this.#value.slice(this.#cursor); + this.#cursor -= graphemeLength; } - private handleForwardDelete(): void { - this.lastAction = null; - if (this.cursor >= this.value.length) { + #handleForwardDelete(): void { + this.#lastAction = null; + if (this.#cursor >= this.#value.length) { return; } - this.pushUndo(); + this.#pushUndo(); - const afterCursor = this.value.slice(this.cursor); + const afterCursor = this.#value.slice(this.#cursor); const graphemes = [...segmenter.segment(afterCursor)]; const firstGrapheme = graphemes[0]; const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; - this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength); + this.#value = this.#value.slice(0, this.#cursor) + this.#value.slice(this.#cursor + graphemeLength); } - private deleteToLineStart(): void { - if (this.cursor === 0) { + #deleteToLineStart(): void { + if (this.#cursor === 0) { return; } - this.pushUndo(); - const deletedText = this.value.slice(0, this.cursor); - this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" }); - this.lastAction = "kill"; + this.#pushUndo(); + const deletedText = this.#value.slice(0, this.#cursor); + this.#killRing.push(deletedText, { prepend: true, accumulate: this.#lastAction === "kill" }); + this.#lastAction = "kill"; - this.value = this.value.slice(this.cursor); - this.cursor = 0; + this.#value = this.#value.slice(this.#cursor); + this.#cursor = 0; } - private deleteToLineEnd(): void { - if (this.cursor >= this.value.length) { + #deleteToLineEnd(): void { + if (this.#cursor >= this.#value.length) { return; } - this.pushUndo(); - const deletedText = this.value.slice(this.cursor); - this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" }); - this.lastAction = "kill"; + this.#pushUndo(); + const deletedText = this.#value.slice(this.#cursor); + this.#killRing.push(deletedText, { prepend: false, accumulate: this.#lastAction === "kill" }); + this.#lastAction = "kill"; - this.value = this.value.slice(0, this.cursor); + this.#value = this.#value.slice(0, this.#cursor); } - private deleteWordBackwards(): void { - if (this.cursor === 0) { + #deleteWordBackwards(): void { + if (this.#cursor === 0) { return; } // Save state before cursor movement (moveWordBackwards resets lastAction). - const wasKill = this.lastAction === "kill"; - this.pushUndo(); + const wasKill = this.#lastAction === "kill"; + this.#pushUndo(); - const oldCursor = this.cursor; - this.moveWordBackwards(); - const deleteFrom = this.cursor; - this.cursor = oldCursor; + const oldCursor = this.#cursor; + this.#moveWordBackwards(); + const deleteFrom = this.#cursor; + this.#cursor = oldCursor; - const deletedText = this.value.slice(deleteFrom, this.cursor); - this.killRing.push(deletedText, { prepend: true, accumulate: wasKill }); - this.lastAction = "kill"; + const deletedText = this.#value.slice(deleteFrom, this.#cursor); + this.#killRing.push(deletedText, { prepend: true, accumulate: wasKill }); + this.#lastAction = "kill"; - this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor); - this.cursor = deleteFrom; + this.#value = this.#value.slice(0, deleteFrom) + this.#value.slice(this.#cursor); + this.#cursor = deleteFrom; } - private deleteWordForward(): void { - if (this.cursor >= this.value.length) { + #deleteWordForward(): void { + if (this.#cursor >= this.#value.length) { return; } // Save state before cursor movement (moveWordForwards resets lastAction). - const wasKill = this.lastAction === "kill"; - this.pushUndo(); + const wasKill = this.#lastAction === "kill"; + this.#pushUndo(); - const oldCursor = this.cursor; - this.moveWordForwards(); - const deleteTo = this.cursor; - this.cursor = oldCursor; + const oldCursor = this.#cursor; + this.#moveWordForwards(); + const deleteTo = this.#cursor; + this.#cursor = oldCursor; - const deletedText = this.value.slice(this.cursor, deleteTo); - this.killRing.push(deletedText, { prepend: false, accumulate: wasKill }); - this.lastAction = "kill"; + const deletedText = this.#value.slice(this.#cursor, deleteTo); + this.#killRing.push(deletedText, { prepend: false, accumulate: wasKill }); + this.#lastAction = "kill"; - this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo); + this.#value = this.#value.slice(0, this.#cursor) + this.#value.slice(deleteTo); } - private yank(): void { - const text = this.killRing.peek(); + #yank(): void { + const text = this.#killRing.peek(); if (!text) { return; } - this.pushUndo(); - this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); - this.cursor += text.length; - this.lastAction = "yank"; + this.#pushUndo(); + this.#value = this.#value.slice(0, this.#cursor) + text + this.#value.slice(this.#cursor); + this.#cursor += text.length; + this.#lastAction = "yank"; } - private yankPop(): void { - if (this.lastAction !== "yank" || this.killRing.length <= 1) { + #yankPop(): void { + if (this.#lastAction !== "yank" || this.#killRing.length <= 1) { return; } - this.pushUndo(); + this.#pushUndo(); - const prevText = this.killRing.peek() ?? ""; - this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor); - this.cursor -= prevText.length; + const prevText = this.#killRing.peek() ?? ""; + this.#value = this.#value.slice(0, this.#cursor - prevText.length) + this.#value.slice(this.#cursor); + this.#cursor -= prevText.length; - this.killRing.rotate(); - const text = this.killRing.peek() ?? ""; - this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); - this.cursor += text.length; - this.lastAction = "yank"; + this.#killRing.rotate(); + const text = this.#killRing.peek() ?? ""; + this.#value = this.#value.slice(0, this.#cursor) + text + this.#value.slice(this.#cursor); + this.#cursor += text.length; + this.#lastAction = "yank"; } - private pushUndo(): void { - this.undoStack.push({ value: this.value, cursor: this.cursor }); + #pushUndo(): void { + this.#undoStack.push({ value: this.#value, cursor: this.#cursor }); } - private undo(): void { - const snapshot = this.undoStack.pop(); + #undo(): void { + const snapshot = this.#undoStack.pop(); if (!snapshot) { return; } - this.value = snapshot.value; - this.cursor = snapshot.cursor; - this.lastAction = null; + this.#value = snapshot.value; + this.#cursor = snapshot.cursor; + this.#lastAction = null; } - private moveWordBackwards(): void { - if (this.cursor === 0) { + #moveWordBackwards(): void { + if (this.#cursor === 0) { return; } - this.lastAction = null; + this.#lastAction = null; - const textBeforeCursor = this.value.slice(0, this.cursor); + const textBeforeCursor = this.#value.slice(0, this.#cursor); const graphemes = [...segmenter.segment(textBeforeCursor)]; // Skip trailing whitespace while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) { - this.cursor -= graphemes.pop()?.segment.length || 0; + this.#cursor -= graphemes.pop()?.segment.length || 0; } if (graphemes.length > 0) { @@ -376,7 +375,7 @@ export class Input implements Component, Focusable { if (isPunctuationChar(lastGrapheme)) { // Skip punctuation run while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) { - this.cursor -= graphemes.pop()?.segment.length || 0; + this.#cursor -= graphemes.pop()?.segment.length || 0; } } else { // Skip word run @@ -385,26 +384,26 @@ export class Input implements Component, Focusable { !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") && !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") ) { - this.cursor -= graphemes.pop()?.segment.length || 0; + this.#cursor -= graphemes.pop()?.segment.length || 0; } } } } - private moveWordForwards(): void { - if (this.cursor >= this.value.length) { + #moveWordForwards(): void { + if (this.#cursor >= this.#value.length) { return; } - this.lastAction = null; - const textAfterCursor = this.value.slice(this.cursor); + this.#lastAction = null; + const textAfterCursor = this.#value.slice(this.#cursor); const segments = segmenter.segment(textAfterCursor); const iterator = segments[Symbol.iterator](); let next = iterator.next(); // Skip leading whitespace while (!next.done && isWhitespaceChar(next.value.segment)) { - this.cursor += next.value.segment.length; + this.#cursor += next.value.segment.length; next = iterator.next(); } @@ -413,29 +412,29 @@ export class Input implements Component, Focusable { if (isPunctuationChar(firstGrapheme)) { // Skip punctuation run while (!next.done && isPunctuationChar(next.value.segment)) { - this.cursor += next.value.segment.length; + this.#cursor += next.value.segment.length; next = iterator.next(); } } else { // Skip word run while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) { - this.cursor += next.value.segment.length; + this.#cursor += next.value.segment.length; next = iterator.next(); } } } } - private handlePaste(pastedText: string): void { - this.lastAction = null; - this.pushUndo(); + #handlePaste(pastedText: string): void { + this.#lastAction = null; + this.#pushUndo(); // Clean the pasted text - remove newlines and carriage returns const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, ""); // Insert at cursor position - this.value = this.value.slice(0, this.cursor) + cleanText + this.value.slice(this.cursor); - this.cursor += cleanText.length; + this.#value = this.#value.slice(0, this.#cursor) + cleanText + this.#value.slice(this.#cursor); + this.#cursor += cleanText.length; } invalidate(): void { @@ -452,20 +451,20 @@ export class Input implements Component, Focusable { } let visibleText = ""; - let cursorDisplay = this.cursor; + let cursorDisplay = this.#cursor; - if (this.value.length < availableWidth) { + if (this.#value.length < availableWidth) { // Everything fits (leave room for cursor at end) - visibleText = this.value; + visibleText = this.#value; } else { // Need horizontal scrolling // Reserve one character for cursor if it's at the end - const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth; + const scrollWidth = this.#cursor === this.#value.length ? availableWidth - 1 : availableWidth; const halfWidth = Math.floor(scrollWidth / 2); const findValidStart = (start: number) => { - while (start < this.value.length) { - const charCode = this.value.charCodeAt(start); + while (start < this.#value.length) { + const charCode = this.#value.charCodeAt(start); // this is low surrogate, not a valid start if (charCode >= 0xdc00 && charCode < 0xe000) { start++; @@ -478,7 +477,7 @@ export class Input implements Component, Focusable { const findValidEnd = (end: number) => { while (end > 0) { - const charCode = this.value.charCodeAt(end - 1); + const charCode = this.#value.charCodeAt(end - 1); // this is high surrogate, might be split. if (charCode >= 0xd800 && charCode < 0xdc00) { end--; @@ -489,20 +488,20 @@ export class Input implements Component, Focusable { return end; }; - if (this.cursor < halfWidth) { + if (this.#cursor < halfWidth) { // Cursor near start - visibleText = this.value.slice(0, findValidEnd(scrollWidth)); - cursorDisplay = this.cursor; - } else if (this.cursor > this.value.length - halfWidth) { + visibleText = this.#value.slice(0, findValidEnd(scrollWidth)); + cursorDisplay = this.#cursor; + } else if (this.#cursor > this.#value.length - halfWidth) { // Cursor near end - const start = findValidStart(this.value.length - scrollWidth); - visibleText = this.value.slice(start); - cursorDisplay = this.cursor - start; + const start = findValidStart(this.#value.length - scrollWidth); + visibleText = this.#value.slice(start); + cursorDisplay = this.#cursor - start; } else { // Cursor in middle - const start = findValidStart(this.cursor - halfWidth); - visibleText = this.value.slice(start, findValidEnd(start + scrollWidth)); - cursorDisplay = this.cursor - start; + const start = findValidStart(this.#cursor - halfWidth); + visibleText = this.#value.slice(start, findValidEnd(start + scrollWidth)); + cursorDisplay = this.#cursor - start; } } diff --git a/packages/tui/src/components/loader.ts b/packages/tui/src/components/loader.ts index 42a20f009..5f7ab64e8 100644 --- a/packages/tui/src/components/loader.ts +++ b/packages/tui/src/components/loader.ts @@ -5,10 +5,10 @@ import { Text } from "./text"; * Loader component that updates every 80ms with spinning animation */ export class Loader extends Text { - private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - private currentFrame = 0; - private intervalId: NodeJS.Timeout | null = null; - private ui: TUI | null = null; + #frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + #currentFrame = 0; + #intervalId?: NodeJS.Timeout; + #ui: TUI | null = null; constructor( ui: TUI, @@ -18,9 +18,9 @@ export class Loader extends Text { spinnerFrames?: string[], ) { super("", 1, 0); - this.ui = ui; + this.#ui = ui; if (spinnerFrames && spinnerFrames.length > 0) { - this.frames = spinnerFrames; + this.#frames = spinnerFrames; } this.start(); } @@ -30,30 +30,30 @@ export class Loader extends Text { } start() { - this.updateDisplay(); - this.intervalId = setInterval(() => { - this.currentFrame = (this.currentFrame + 1) % this.frames.length; - this.updateDisplay(); + this.#updateDisplay(); + this.#intervalId = setInterval(() => { + this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length; + this.#updateDisplay(); }, 80); } stop() { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; + if (this.#intervalId) { + clearInterval(this.#intervalId); + this.#intervalId = undefined; } } setMessage(message: string) { this.message = message; - this.updateDisplay(); + this.#updateDisplay(); } - private updateDisplay() { - const frame = this.frames[this.currentFrame]; + #updateDisplay() { + const frame = this.#frames[this.#currentFrame]; this.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`); - if (this.ui) { - this.ui.requestRender(); + if (this.#ui) { + this.#ui.requestRender(); } } } diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index a70ee3d7b..ed198fd98 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -59,19 +59,19 @@ interface InlineStyleContext { } export class Markdown implements Component { - private text: string; - private paddingX: number; // Left/right padding - private paddingY: number; // Top/bottom padding - private defaultTextStyle?: DefaultTextStyle; - private theme: MarkdownTheme; - private defaultStylePrefix?: string; + #text: string; + #paddingX: number; // Left/right padding + #paddingY: number; // Top/bottom padding + #defaultTextStyle?: DefaultTextStyle; + #theme: MarkdownTheme; + #defaultStylePrefix?: string; /** Number of spaces used to indent code block content. */ - private codeBlockIndent: number; + #codeBlockIndent: number; // Cache for rendered output - private cachedText?: string; - private cachedWidth?: number; - private cachedLines?: string[]; + #cachedText?: string; + #cachedWidth?: number; + #cachedLines?: string[]; constructor( text: string, @@ -81,46 +81,46 @@ export class Markdown implements Component { defaultTextStyle?: DefaultTextStyle, codeBlockIndent: number = 2, ) { - this.text = text; - this.paddingX = paddingX; - this.paddingY = paddingY; - this.theme = theme; - this.defaultTextStyle = defaultTextStyle; - this.codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent)); + this.#text = text; + this.#paddingX = paddingX; + this.#paddingY = paddingY; + this.#theme = theme; + this.#defaultTextStyle = defaultTextStyle; + this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent)); } setText(text: string): void { - this.text = text; + this.#text = text; this.invalidate(); } invalidate(): void { - this.cachedText = undefined; - this.cachedWidth = undefined; - this.cachedLines = undefined; + this.#cachedText = undefined; + this.#cachedWidth = undefined; + this.#cachedLines = undefined; } render(width: number): string[] { // Check cache - if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { - return this.cachedLines; + if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) { + return this.#cachedLines; } // Calculate available width for content (subtract horizontal padding) - const contentWidth = Math.max(1, width - this.paddingX * 2); + const contentWidth = Math.max(1, width - this.#paddingX * 2); // Don't render anything if there's no actual text - if (!this.text || this.text.trim() === "") { + if (!this.#text || this.#text.trim() === "") { const result: string[] = []; // Update cache - this.cachedText = this.text; - this.cachedWidth = width; - this.cachedLines = result; + this.#cachedText = this.#text; + this.#cachedWidth = width; + this.#cachedLines = result; return result; } // Replace tabs with 3 spaces for consistent rendering - const normalizedText = replaceTabs(this.text); + const normalizedText = replaceTabs(this.#text); // Parse markdown to HTML-like tokens const tokens = marked.lexer(normalizedText); @@ -131,7 +131,7 @@ export class Markdown implements Component { for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; const nextToken = tokens[i + 1]; - const tokenLines = this.renderToken(token, contentWidth, nextToken?.type); + const tokenLines = this.#renderToken(token, contentWidth, nextToken?.type); renderedLines.push(...tokenLines); } @@ -147,9 +147,9 @@ export class Markdown implements Component { } // Add margins and background to each wrapped line - const leftMargin = padding(this.paddingX); - const rightMargin = padding(this.paddingX); - const bgFn = this.defaultTextStyle?.bgColor; + const leftMargin = padding(this.#paddingX); + const rightMargin = padding(this.#paddingX); + const bgFn = this.#defaultTextStyle?.bgColor; const contentLines: string[] = []; for (const line of wrappedLines) { @@ -174,7 +174,7 @@ export class Markdown implements Component { // Add top/bottom padding (empty lines) const emptyLine = padding(width); const emptyLines: string[] = []; - for (let i = 0; i < this.paddingY; i++) { + for (let i = 0; i < this.#paddingY; i++) { const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine; emptyLines.push(line); } @@ -183,9 +183,9 @@ export class Markdown implements Component { const result = [...emptyLines, ...contentLines, ...emptyLines]; // Update cache - this.cachedText = this.text; - this.cachedWidth = width; - this.cachedLines = result; + this.#cachedText = this.#text; + this.#cachedWidth = width; + this.#cachedLines = result; return result.length > 0 ? result : [""]; } @@ -196,98 +196,98 @@ export class Markdown implements Component { * NOTE: Background color is NOT applied here - it's applied at the padding stage * to ensure it extends to the full line width. */ - private applyDefaultStyle(text: string): string { - if (!this.defaultTextStyle) { + #applyDefaultStyle(text: string): string { + if (!this.#defaultTextStyle) { return text; } let styled = text; // Apply foreground color (NOT background - that's applied at padding stage) - if (this.defaultTextStyle.color) { - styled = this.defaultTextStyle.color(styled); + if (this.#defaultTextStyle.color) { + styled = this.#defaultTextStyle.color(styled); } - // Apply text decorations using this.theme - if (this.defaultTextStyle.bold) { - styled = this.theme.bold(styled); + // Apply text decorations using this.#theme + if (this.#defaultTextStyle.bold) { + styled = this.#theme.bold(styled); } - if (this.defaultTextStyle.italic) { - styled = this.theme.italic(styled); + if (this.#defaultTextStyle.italic) { + styled = this.#theme.italic(styled); } - if (this.defaultTextStyle.strikethrough) { - styled = this.theme.strikethrough(styled); + if (this.#defaultTextStyle.strikethrough) { + styled = this.#theme.strikethrough(styled); } - if (this.defaultTextStyle.underline) { - styled = this.theme.underline(styled); + if (this.#defaultTextStyle.underline) { + styled = this.#theme.underline(styled); } return styled; } - private getDefaultStylePrefix(): string { - if (!this.defaultTextStyle) { + #getDefaultStylePrefix(): string { + if (!this.#defaultTextStyle) { return ""; } - if (this.defaultStylePrefix !== undefined) { - return this.defaultStylePrefix; + if (this.#defaultStylePrefix !== undefined) { + return this.#defaultStylePrefix; } const sentinel = "\u0000"; let styled = sentinel; - if (this.defaultTextStyle.color) { - styled = this.defaultTextStyle.color(styled); + if (this.#defaultTextStyle.color) { + styled = this.#defaultTextStyle.color(styled); } - if (this.defaultTextStyle.bold) { - styled = this.theme.bold(styled); + if (this.#defaultTextStyle.bold) { + styled = this.#theme.bold(styled); } - if (this.defaultTextStyle.italic) { - styled = this.theme.italic(styled); + if (this.#defaultTextStyle.italic) { + styled = this.#theme.italic(styled); } - if (this.defaultTextStyle.strikethrough) { - styled = this.theme.strikethrough(styled); + if (this.#defaultTextStyle.strikethrough) { + styled = this.#theme.strikethrough(styled); } - if (this.defaultTextStyle.underline) { - styled = this.theme.underline(styled); + if (this.#defaultTextStyle.underline) { + styled = this.#theme.underline(styled); } const sentinelIndex = styled.indexOf(sentinel); - this.defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ""; - return this.defaultStylePrefix; + this.#defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ""; + return this.#defaultStylePrefix; } - private getStylePrefix(styleFn: (text: string) => string): string { + #getStylePrefix(styleFn: (text: string) => string): string { const sentinel = "\u0000"; const styled = styleFn(sentinel); const sentinelIndex = styled.indexOf(sentinel); return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ""; } - private getDefaultInlineStyleContext(): InlineStyleContext { + #getDefaultInlineStyleContext(): InlineStyleContext { return { - applyText: (text: string) => this.applyDefaultStyle(text), - stylePrefix: this.getDefaultStylePrefix(), + applyText: (text: string) => this.#applyDefaultStyle(text), + stylePrefix: this.#getDefaultStylePrefix(), }; } - private renderToken(token: Token, width: number, nextTokenType?: string): string[] { + #renderToken(token: Token, width: number, nextTokenType?: string): string[] { const lines: string[] = []; switch (token.type) { case "heading": { const headingLevel = token.depth; const headingPrefix = `${"#".repeat(headingLevel)} `; - const headingText = this.renderInlineTokens(token.tokens || []); + const headingText = this.#renderInlineTokens(token.tokens || []); let styledHeading: string; if (headingLevel === 1) { - styledHeading = this.theme.heading(this.theme.bold(this.theme.underline(headingText))); + styledHeading = this.#theme.heading(this.#theme.bold(this.#theme.underline(headingText))); } else if (headingLevel === 2) { - styledHeading = this.theme.heading(this.theme.bold(headingText)); + styledHeading = this.#theme.heading(this.#theme.bold(headingText)); } else { - styledHeading = this.theme.heading(this.theme.bold(headingPrefix + headingText)); + styledHeading = this.#theme.heading(this.#theme.bold(headingPrefix + headingText)); } lines.push(styledHeading); if (nextTokenType !== "space") { @@ -297,7 +297,7 @@ export class Markdown implements Component { } case "paragraph": { - const paragraphText = this.renderInlineTokens(token.tokens || []); + const paragraphText = this.#renderInlineTokens(token.tokens || []); lines.push(paragraphText); // Don't add spacing if next token is space or list if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") { @@ -308,12 +308,12 @@ export class Markdown implements Component { case "code": { // Handle mermaid diagrams with image rendering when available - if (token.lang === "mermaid" && this.theme.getMermaidImage) { + if (token.lang === "mermaid" && this.#theme.getMermaidImage) { const hash = Bun.hash(token.text.trim()).toString(16); - const image = this.theme.getMermaidImage(hash); + const image = this.#theme.getMermaidImage(hash); if (image && TERMINAL.imageProtocol) { - const imageLines = this.renderMermaidImage(image, width); + const imageLines = this.#renderMermaidImage(image, width); if (imageLines) { lines.push(...imageLines); if (nextTokenType !== "space") { @@ -324,10 +324,10 @@ export class Markdown implements Component { } } - const codeIndent = padding(this.codeBlockIndent); - lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); - if (this.theme.highlightCode) { - const highlightedLines = this.theme.highlightCode(token.text, token.lang); + const codeIndent = padding(this.#codeBlockIndent); + lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); + if (this.#theme.highlightCode) { + const highlightedLines = this.#theme.highlightCode(token.text, token.lang); for (const hlLine of highlightedLines) { lines.push(`${codeIndent}${hlLine}`); } @@ -335,10 +335,10 @@ export class Markdown implements Component { // Split code by newlines and style each line const codeLines = token.text.split("\n"); for (const codeLine of codeLines) { - lines.push(`${codeIndent}${this.theme.codeBlock(codeLine)}`); + lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`); } } - lines.push(this.theme.codeBlockBorder("```")); + lines.push(this.#theme.codeBlockBorder("```")); if (nextTokenType !== "space") { lines.push(""); // Add spacing after code blocks (unless space token follows) } @@ -346,7 +346,7 @@ export class Markdown implements Component { } case "list": { - const listLines = this.renderList(token as any, 0); + const listLines = this.#renderList(token as any, 0); lines.push(...listLines); // Don't add spacing after lists if a space token follows // (the space token will handle it) @@ -354,18 +354,18 @@ export class Markdown implements Component { } case "table": { - const tableLines = this.renderTable(token as any, width); + const tableLines = this.#renderTable(token as any, width); lines.push(...tableLines); break; } case "blockquote": { - const quoteStyle = (text: string) => this.theme.quote(this.theme.italic(text)); + const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text)); const quoteStyleContext: InlineStyleContext = { applyText: quoteStyle, - stylePrefix: this.getStylePrefix(quoteStyle), + stylePrefix: this.#getStylePrefix(quoteStyle), }; - const quoteText = this.renderInlineTokens(token.tokens || [], quoteStyleContext); + const quoteText = this.#renderInlineTokens(token.tokens || [], quoteStyleContext); const quoteLines = quoteText.split("\n"); // Calculate available width for quote content (subtract border + space = 2 chars) @@ -375,7 +375,7 @@ export class Markdown implements Component { // Wrap the styled line, then add border to each wrapped line const wrappedLines = wrapTextWithAnsi(quoteLine, quoteContentWidth); for (const wrappedLine of wrappedLines) { - lines.push(this.theme.quoteBorder(`${this.theme.symbols.quoteBorder} `) + wrappedLine); + lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine); } } if (nextTokenType !== "space") { @@ -385,7 +385,7 @@ export class Markdown implements Component { } case "hr": - lines.push(this.theme.hr(this.theme.symbols.hrChar.repeat(Math.min(width, 80)))); + lines.push(this.#theme.hr(this.#theme.symbols.hrChar.repeat(Math.min(width, 80)))); if (nextTokenType !== "space") { lines.push(""); // Add spacing after horizontal rules (unless space token follows) } @@ -394,7 +394,7 @@ export class Markdown implements Component { case "html": // Render HTML as plain text (escaped for terminal) if ("raw" in token && typeof token.raw === "string") { - lines.push(this.applyDefaultStyle(token.raw.trim())); + lines.push(this.#applyDefaultStyle(token.raw.trim())); } break; @@ -413,9 +413,9 @@ export class Markdown implements Component { return lines; } - private renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string { + #renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string { let result = ""; - const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext(); + const resolvedStyleContext = styleContext ?? this.#getDefaultInlineStyleContext(); const { applyText, stylePrefix } = resolvedStyleContext; const applyTextWithNewlines = (text: string): string => { const segments: string[] = text.split("\n"); @@ -427,7 +427,7 @@ export class Markdown implements Component { case "text": // Text tokens in list items can have nested tokens for inline formatting if (token.tokens && token.tokens.length > 0) { - result += this.renderInlineTokens(token.tokens, resolvedStyleContext); + result += this.#renderInlineTokens(token.tokens, resolvedStyleContext); } else { result += applyTextWithNewlines(token.text); } @@ -435,38 +435,38 @@ export class Markdown implements Component { case "paragraph": // Paragraph tokens contain nested inline tokens - result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); break; case "strong": { - const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); - result += this.theme.bold(boldContent) + stylePrefix; + const boldContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.#theme.bold(boldContent) + stylePrefix; break; } case "em": { - const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); - result += this.theme.italic(italicContent) + stylePrefix; + const italicContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.#theme.italic(italicContent) + stylePrefix; break; } case "codespan": - result += this.theme.code(token.text) + stylePrefix; + result += this.#theme.code(token.text) + stylePrefix; break; case "link": { - const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); // If link text matches href, only show the link once // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes // For mailto: links, strip the prefix before comparing (autolinked emails have // text="foo@bar.com" but href="mailto:foo@bar.com") const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href; if (token.text === token.href || token.text === hrefForComparison) { - result += this.theme.link(this.theme.underline(linkText)) + stylePrefix; + result += this.#theme.link(this.#theme.underline(linkText)) + stylePrefix; } else { result += - this.theme.link(this.theme.underline(linkText)) + - this.theme.linkUrl(` (${token.href})`) + + this.#theme.link(this.#theme.underline(linkText)) + + this.#theme.linkUrl(` (${token.href})`) + stylePrefix; } break; @@ -477,8 +477,8 @@ export class Markdown implements Component { break; case "del": { - const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); - result += this.theme.strikethrough(delContent) + stylePrefix; + const delContent = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.#theme.strikethrough(delContent) + stylePrefix; break; } @@ -503,7 +503,7 @@ export class Markdown implements Component { /** * Render a list with proper nesting support */ - private renderList(token: Token & { items: any[]; ordered: boolean; start?: number }, depth: number): string[] { + #renderList(token: Token & { items: any[]; ordered: boolean; start?: number }, depth: number): string[] { const lines: string[] = []; const indent = " ".repeat(depth); // Use the list's start property (defaults to 1 for ordered lists) @@ -514,7 +514,7 @@ export class Markdown implements Component { const bullet = token.ordered ? `${startNumber + i}. ` : "- "; // Process item tokens to handle nested lists - const itemLines = this.renderListItem(item.tokens || [], depth); + const itemLines = this.#renderListItem(item.tokens || [], depth); if (itemLines.length > 0) { // First line - check if it's a nested list @@ -527,7 +527,7 @@ export class Markdown implements Component { lines.push(firstLine); } else { // Regular text content - add indent and bullet - lines.push(indent + this.theme.listBullet(bullet) + firstLine); + lines.push(indent + this.#theme.listBullet(bullet) + firstLine); } // Rest of the lines @@ -544,7 +544,7 @@ export class Markdown implements Component { } } } else { - lines.push(indent + this.theme.listBullet(bullet)); + lines.push(indent + this.#theme.listBullet(bullet)); } } @@ -555,43 +555,43 @@ export class Markdown implements Component { * Render list item tokens, handling nested lists * Returns lines WITHOUT the parent indent (renderList will add it) */ - private renderListItem(tokens: Token[], parentDepth: number): string[] { + #renderListItem(tokens: Token[], parentDepth: number): string[] { const lines: string[] = []; for (const token of tokens) { if (token.type === "list") { // Nested list - render with one additional indent level // These lines will have their own indent, so we just add them as-is - const nestedLines = this.renderList(token as any, parentDepth + 1); + const nestedLines = this.#renderList(token as any, parentDepth + 1); lines.push(...nestedLines); } else if (token.type === "text") { // Text content (may have inline tokens) const text = - token.tokens && token.tokens.length > 0 ? this.renderInlineTokens(token.tokens) : token.text || ""; + token.tokens && token.tokens.length > 0 ? this.#renderInlineTokens(token.tokens) : token.text || ""; lines.push(text); } else if (token.type === "paragraph") { // Paragraph in list item - const text = this.renderInlineTokens(token.tokens || []); + const text = this.#renderInlineTokens(token.tokens || []); lines.push(text); } else if (token.type === "code") { // Code block in list item - const codeIndent = padding(this.codeBlockIndent); - lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); - if (this.theme.highlightCode) { - const highlightedLines = this.theme.highlightCode(token.text, token.lang); + const codeIndent = padding(this.#codeBlockIndent); + lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); + if (this.#theme.highlightCode) { + const highlightedLines = this.#theme.highlightCode(token.text, token.lang); for (const hlLine of highlightedLines) { lines.push(`${codeIndent}${hlLine}`); } } else { const codeLines = token.text.split("\n"); for (const codeLine of codeLines) { - lines.push(`${codeIndent}${this.theme.codeBlock(codeLine)}`); + lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`); } } - lines.push(this.theme.codeBlockBorder("```")); + lines.push(this.#theme.codeBlockBorder("```")); } else { // Other token types - try to render as inline - const text = this.renderInlineTokens([token]); + const text = this.#renderInlineTokens([token]); if (text) { lines.push(text); } @@ -604,7 +604,7 @@ export class Markdown implements Component { /** * Get the visible width of the longest word in a string. */ - private getLongestWordWidth(text: string, maxWidth?: number): number { + #getLongestWordWidth(text: string, maxWidth?: number): number { const words = text.split(/\s+/).filter(word => word.length > 0); let longest = 0; for (const word of words) { @@ -622,7 +622,7 @@ export class Markdown implements Component { * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled * consistently with the rest of the renderer. */ - private wrapCellText(text: string, maxWidth: number): string[] { + #wrapCellText(text: string, maxWidth: number): string[] { return wrapTextWithAnsi(text, Math.max(1, maxWidth)); } @@ -630,10 +630,7 @@ export class Markdown implements Component { * Render a table with width-aware cell wrapping. * Cells that don't fit are wrapped to multiple lines. */ - private renderTable( - token: Token & { header: any[]; rows: any[][]; raw?: string }, - availableWidth: number, - ): string[] { + #renderTable(token: Token & { header: any[]; rows: any[][]; raw?: string }, availableWidth: number): string[] { const lines: string[] = []; const numCols = token.header.length; @@ -658,17 +655,17 @@ export class Markdown implements Component { const naturalWidths: number[] = []; const minWordWidths: number[] = []; for (let i = 0; i < numCols; i++) { - const headerText = this.renderInlineTokens(token.header[i].tokens || []); + const headerText = this.#renderInlineTokens(token.header[i].tokens || []); naturalWidths[i] = visibleWidth(headerText); - minWordWidths[i] = Math.max(1, this.getLongestWordWidth(headerText, maxUnbrokenWordWidth)); + minWordWidths[i] = Math.max(1, this.#getLongestWordWidth(headerText, maxUnbrokenWordWidth)); } for (const row of token.rows) { for (let i = 0; i < row.length; i++) { - const cellText = this.renderInlineTokens(row[i].tokens || []); + const cellText = this.#renderInlineTokens(row[i].tokens || []); naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText)); minWordWidths[i] = Math.max( minWordWidths[i] || 1, - this.getLongestWordWidth(cellText, maxUnbrokenWordWidth), + this.#getLongestWordWidth(cellText, maxUnbrokenWordWidth), ); } } @@ -743,7 +740,7 @@ export class Markdown implements Component { } } - const t = this.theme.symbols.table; + const t = this.#theme.symbols.table; const h = t.horizontal; const v = t.vertical; @@ -753,8 +750,8 @@ export class Markdown implements Component { // Render header with wrapping const headerCellLines: string[][] = token.header.map((cell, i) => { - const text = this.renderInlineTokens(cell.tokens || []); - return this.wrapCellText(text, columnWidths[i]); + const text = this.#renderInlineTokens(cell.tokens || []); + return this.#wrapCellText(text, columnWidths[i]); }); const headerLineCount = Math.max(...headerCellLines.map(c => c.length)); @@ -762,7 +759,7 @@ export class Markdown implements Component { const rowParts = headerCellLines.map((cellLines, colIdx) => { const text = cellLines[lineIdx] || ""; const padded = text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text))); - return this.theme.bold(padded); + return this.#theme.bold(padded); }); lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`); } @@ -776,8 +773,8 @@ export class Markdown implements Component { for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) { const row = token.rows[rowIndex]; const rowCellLines: string[][] = row.map((cell, i) => { - const text = this.renderInlineTokens(cell.tokens || []); - return this.wrapCellText(text, columnWidths[i]); + const text = this.#renderInlineTokens(cell.tokens || []); + return this.#wrapCellText(text, columnWidths[i]); }); const rowLineCount = Math.max(...rowCellLines.map(c => c.length)); @@ -806,7 +803,7 @@ export class Markdown implements Component { * Render a mermaid image using terminal graphics protocol. * Returns array of lines (image placeholder rows) or null if rendering fails. */ - private renderMermaidImage(image: MermaidImage, availableWidth: number): string[] | null { + #renderMermaidImage(image: MermaidImage, availableWidth: number): string[] | null { if (!TERMINAL.imageProtocol) return null; const cellDims = getCellDimensions(); diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 342881d22..2eec17fc5 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -19,29 +19,29 @@ export interface SelectListTheme { } export class SelectList implements Component { - private filteredItems: ReadonlyArray; - private selectedIndex: number = 0; + #filteredItems: ReadonlyArray; + #selectedIndex: number = 0; - public onSelect?: (item: SelectItem) => void; - public onCancel?: () => void; - public onSelectionChange?: (item: SelectItem) => void; + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; + onSelectionChange?: (item: SelectItem) => void; constructor( private readonly items: ReadonlyArray, private readonly maxVisible: number, private readonly theme: SelectListTheme, ) { - this.filteredItems = items; + this.#filteredItems = items; } setFilter(filter: string): void { - this.filteredItems = this.items.filter(item => item.value.toLowerCase().startsWith(filter.toLowerCase())); + this.#filteredItems = this.items.filter(item => item.value.toLowerCase().startsWith(filter.toLowerCase())); // Reset selection when filter changes - this.selectedIndex = 0; + this.#selectedIndex = 0; } setSelectedIndex(index: number): void { - this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); + this.#selectedIndex = Math.max(0, Math.min(index, this.#filteredItems.length - 1)); } invalidate(): void { @@ -52,7 +52,7 @@ export class SelectList implements Component { const lines: string[] = []; // If no items match filter, show message - if (this.filteredItems.length === 0) { + if (this.#filteredItems.length === 0) { lines.push(this.theme.noMatch(" No matching commands")); return lines; } @@ -60,16 +60,16 @@ export class SelectList implements Component { // Calculate visible range with scrolling const startIndex = Math.max( 0, - Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible), + Math.min(this.#selectedIndex - Math.floor(this.maxVisible / 2), this.#filteredItems.length - this.maxVisible), ); - const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); + const endIndex = Math.min(startIndex + this.maxVisible, this.#filteredItems.length); // Render visible items for (let i = startIndex; i < endIndex; i++) { - const item = this.filteredItems[i]; + const item = this.#filteredItems[i]; if (!item) continue; - const isSelected = i === this.selectedIndex; + const isSelected = i === this.#selectedIndex; let line = ""; if (isSelected) { @@ -136,8 +136,8 @@ export class SelectList implements Component { } // Add scroll indicators if needed - if (startIndex > 0 || endIndex < this.filteredItems.length) { - const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`; + if (startIndex > 0 || endIndex < this.#filteredItems.length) { + const scrollText = ` (${this.#selectedIndex + 1}/${this.#filteredItems.length})`; // Truncate if too long for terminal lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, Ellipsis.Omit))); } @@ -148,17 +148,17 @@ export class SelectList implements Component { handleInput(keyData: string): void { // Up arrow - wrap to bottom when at top if (matchesKey(keyData, "up")) { - this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1; - this.notifySelectionChange(); + this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1; + this.#notifySelectionChange(); } // Down arrow - wrap to top when at bottom else if (matchesKey(keyData, "down")) { - this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1; - this.notifySelectionChange(); + this.#selectedIndex = this.#selectedIndex === this.#filteredItems.length - 1 ? 0 : this.#selectedIndex + 1; + this.#notifySelectionChange(); } // Enter else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") { - const selectedItem = this.filteredItems[this.selectedIndex]; + const selectedItem = this.#filteredItems[this.#selectedIndex]; if (selectedItem && this.onSelect) { this.onSelect(selectedItem); } @@ -171,15 +171,15 @@ export class SelectList implements Component { } } - private notifySelectionChange(): void { - const selectedItem = this.filteredItems[this.selectedIndex]; + #notifySelectionChange(): void { + const selectedItem = this.#filteredItems[this.#selectedIndex]; if (selectedItem && this.onSelectionChange) { this.onSelectionChange(selectedItem); } } getSelectedItem(): SelectItem | null { - const item = this.filteredItems[this.selectedIndex]; + const item = this.#filteredItems[this.#selectedIndex]; return item || null; } } diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 5e3620f68..dcf333d80 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -26,16 +26,16 @@ export interface SettingsListTheme { } export class SettingsList implements Component { - private items: SettingItem[]; - private theme: SettingsListTheme; - private selectedIndex = 0; - private maxVisible: number; - private onChange: (id: string, newValue: string) => void; - private onCancel: () => void; + #items: SettingItem[]; + #theme: SettingsListTheme; + #selectedIndex = 0; + #maxVisible: number; + #onChange: (id: string, newValue: string) => void; + #onCancel: () => void; // Submenu state - private submenuComponent: Component | null = null; - private submenuItemIndex: number | null = null; + #submenuComponent: Component | null = null; + #submenuItemIndex: number | null = null; constructor( items: SettingItem[], @@ -44,71 +44,71 @@ export class SettingsList implements Component { onChange: (id: string, newValue: string) => void, onCancel: () => void, ) { - this.items = items; - this.maxVisible = maxVisible; - this.theme = theme; - this.onChange = onChange; - this.onCancel = onCancel; + this.#items = items; + this.#maxVisible = maxVisible; + this.#theme = theme; + this.#onChange = onChange; + this.#onCancel = onCancel; } /** Update an item's currentValue */ updateValue(id: string, newValue: string): void { - const item = this.items.find(i => i.id === id); + const item = this.#items.find(i => i.id === id); if (item) { item.currentValue = newValue; } } invalidate(): void { - this.submenuComponent?.invalidate?.(); + this.#submenuComponent?.invalidate?.(); } render(width: number): string[] { // If submenu is active, render it instead - if (this.submenuComponent) { - return this.submenuComponent.render(width); + if (this.#submenuComponent) { + return this.#submenuComponent.render(width); } - return this.renderMainList(width); + return this.#renderMainList(width); } - private renderMainList(width: number): string[] { + #renderMainList(width: number): string[] { const lines: string[] = []; - if (this.items.length === 0) { - lines.push(this.theme.hint(" No settings available")); + if (this.#items.length === 0) { + lines.push(this.#theme.hint(" No settings available")); return lines; } // Calculate visible range with scrolling const startIndex = Math.max( 0, - Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.items.length - this.maxVisible), + Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.#items.length - this.#maxVisible), ); - const endIndex = Math.min(startIndex + this.maxVisible, this.items.length); + const endIndex = Math.min(startIndex + this.#maxVisible, this.#items.length); // Calculate max label width for alignment - const maxLabelWidth = Math.min(30, Math.max(...this.items.map(item => visibleWidth(item.label)))); + const maxLabelWidth = Math.min(30, Math.max(...this.#items.map(item => visibleWidth(item.label)))); // Render visible items for (let i = startIndex; i < endIndex; i++) { - const item = this.items[i]; + const item = this.#items[i]; if (!item) continue; - const isSelected = i === this.selectedIndex; - const prefix = isSelected ? this.theme.cursor : " "; + const isSelected = i === this.#selectedIndex; + const prefix = isSelected ? this.#theme.cursor : " "; const prefixWidth = visibleWidth(prefix); // Pad label to align values const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label))); - const labelText = this.theme.label(labelPadded, isSelected); + const labelText = this.#theme.label(labelPadded, isSelected); // Calculate space for value const separator = " "; const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator); const valueMaxWidth = width - usedWidth - 2; - const valueText = this.theme.value( + const valueText = this.#theme.value( truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit), isSelected, ); @@ -117,24 +117,24 @@ export class SettingsList implements Component { } // Add scroll indicator if needed - if (startIndex > 0 || endIndex < this.items.length) { - const scrollText = ` (${this.selectedIndex + 1}/${this.items.length})`; - lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, Ellipsis.Omit))); + if (startIndex > 0 || endIndex < this.#items.length) { + const scrollText = ` (${this.#selectedIndex + 1}/${this.#items.length})`; + lines.push(this.#theme.hint(truncateToWidth(scrollText, width - 2, Ellipsis.Omit))); } // Add description for selected item - const selectedItem = this.items[this.selectedIndex]; + const selectedItem = this.#items[this.#selectedIndex]; if (selectedItem?.description) { lines.push(""); const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4); for (const line of wrappedDesc) { - lines.push(this.theme.description(` ${line}`)); + lines.push(this.#theme.description(` ${line}`)); } } // Add hint lines.push(""); - lines.push(truncateToWidth(this.theme.hint(" Enter/Space to change · Esc to cancel"), width)); + lines.push(truncateToWidth(this.#theme.hint(" Enter/Space to change · Esc to cancel"), width)); return lines; } @@ -142,36 +142,36 @@ export class SettingsList implements Component { handleInput(data: string): void { // If submenu is active, delegate all input to it // The submenu's onCancel (triggered by escape) will call done() which closes it - if (this.submenuComponent) { - this.submenuComponent.handleInput?.(data); + if (this.#submenuComponent) { + this.#submenuComponent.handleInput?.(data); return; } // Main list input handling if (matchesKey(data, "up")) { - this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1; + this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1; } else if (matchesKey(data, "down")) { - this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1; + this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1; } else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n" || data === " ") { - this.activateItem(); + this.#activateItem(); } else if (matchesKey(data, "escape") || matchesKey(data, "esc") || matchesKey(data, "ctrl+c")) { - this.onCancel(); + this.#onCancel(); } } - private activateItem(): void { - const item = this.items[this.selectedIndex]; + #activateItem(): void { + const item = this.#items[this.#selectedIndex]; if (!item) return; if (item.submenu) { // Open submenu, passing current value so it can pre-select correctly - this.submenuItemIndex = this.selectedIndex; - this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => { + this.#submenuItemIndex = this.#selectedIndex; + this.#submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => { if (selectedValue !== undefined) { item.currentValue = selectedValue; - this.onChange(item.id, selectedValue); + this.#onChange(item.id, selectedValue); } - this.closeSubmenu(); + this.#closeSubmenu(); }); } else if (item.values && item.values.length > 0) { // Cycle through values @@ -179,16 +179,16 @@ export class SettingsList implements Component { const nextIndex = (currentIndex + 1) % item.values.length; const newValue = item.values[nextIndex]; item.currentValue = newValue; - this.onChange(item.id, newValue); + this.#onChange(item.id, newValue); } } - private closeSubmenu(): void { - this.submenuComponent = null; + #closeSubmenu(): void { + this.#submenuComponent = null; // Restore selection to the item that opened the submenu - if (this.submenuItemIndex !== null) { - this.selectedIndex = this.submenuItemIndex; - this.submenuItemIndex = null; + if (this.#submenuItemIndex !== null) { + this.#selectedIndex = this.#submenuItemIndex; + this.#submenuItemIndex = null; } } } diff --git a/packages/tui/src/components/spacer.ts b/packages/tui/src/components/spacer.ts index ce21920ae..79cc01f2a 100644 --- a/packages/tui/src/components/spacer.ts +++ b/packages/tui/src/components/spacer.ts @@ -4,14 +4,14 @@ import type { Component } from "../tui"; * Spacer component that renders empty lines */ export class Spacer implements Component { - private lines: number; + #lines: number; constructor(lines: number = 1) { - this.lines = lines; + this.#lines = lines; } setLines(lines: number): void { - this.lines = lines; + this.#lines = lines; } invalidate(): void { @@ -20,7 +20,7 @@ export class Spacer implements Component { render(_width: number): string[] { const result: string[] = []; - for (let i = 0; i < this.lines; i++) { + for (let i = 0; i < this.#lines; i++) { result.push(""); } return result; diff --git a/packages/tui/src/components/tab-bar.ts b/packages/tui/src/components/tab-bar.ts index 055a4a815..b2685c683 100644 --- a/packages/tui/src/components/tab-bar.ts +++ b/packages/tui/src/components/tab-bar.ts @@ -46,48 +46,48 @@ export interface TabBarTheme { * ``` */ export class TabBar implements Component { - private tabs: Tab[]; - private activeIndex: number = 0; - private theme: TabBarTheme; - private label: string; + #tabs: Tab[]; + #activeIndex: number = 0; + #theme: TabBarTheme; + #label: string; /** Callback fired when the active tab changes */ - public onTabChange?: (tab: Tab, index: number) => void; + onTabChange?: (tab: Tab, index: number) => void; constructor(label: string, tabs: Tab[], theme: TabBarTheme, initialIndex: number = 0) { - this.label = label; - this.tabs = tabs; - this.theme = theme; - this.activeIndex = initialIndex; + this.#label = label; + this.#tabs = tabs; + this.#theme = theme; + this.#activeIndex = initialIndex; } /** Get the currently active tab */ getActiveTab(): Tab { - return this.tabs[this.activeIndex]; + return this.#tabs[this.#activeIndex]; } /** Get the index of the currently active tab */ getActiveIndex(): number { - return this.activeIndex; + return this.#activeIndex; } /** Set the active tab by index (clamped to valid range) */ setActiveIndex(index: number): void { - const newIndex = Math.max(0, Math.min(index, this.tabs.length - 1)); - if (newIndex !== this.activeIndex) { - this.activeIndex = newIndex; - this.onTabChange?.(this.tabs[this.activeIndex], this.activeIndex); + const newIndex = Math.max(0, Math.min(index, this.#tabs.length - 1)); + if (newIndex !== this.#activeIndex) { + this.#activeIndex = newIndex; + this.onTabChange?.(this.#tabs[this.#activeIndex], this.#activeIndex); } } /** Move to the next tab (wraps to first tab after last) */ nextTab(): void { - this.setActiveIndex((this.activeIndex + 1) % this.tabs.length); + this.setActiveIndex((this.#activeIndex + 1) % this.#tabs.length); } /** Move to the previous tab (wraps to last tab before first) */ prevTab(): void { - this.setActiveIndex((this.activeIndex - 1 + this.tabs.length) % this.tabs.length); + this.setActiveIndex((this.#activeIndex - 1 + this.#tabs.length) % this.#tabs.length); } invalidate(): void { @@ -115,25 +115,25 @@ export class TabBar implements Component { const parts: string[] = []; // Label prefix - parts.push(this.theme.label(`${this.label}:`)); + parts.push(this.#theme.label(`${this.#label}:`)); parts.push(" "); // Tab buttons - for (let i = 0; i < this.tabs.length; i++) { - const tab = this.tabs[i]; - if (i === this.activeIndex) { - parts.push(this.theme.activeTab(` ${tab.label} `)); + for (let i = 0; i < this.#tabs.length; i++) { + const tab = this.#tabs[i]; + if (i === this.#activeIndex) { + parts.push(this.#theme.activeTab(` ${tab.label} `)); } else { - parts.push(this.theme.inactiveTab(` ${tab.label} `)); + parts.push(this.#theme.inactiveTab(` ${tab.label} `)); } - if (i < this.tabs.length - 1) { + if (i < this.#tabs.length - 1) { parts.push(" "); } } // Navigation hint parts.push(" "); - parts.push(this.theme.hint("(tab to cycle)")); + parts.push(this.#theme.hint("(tab to cycle)")); const line = parts.join(""); const maxWidth = Math.max(1, width); diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index 020334e2c..b41145a53 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -5,74 +5,74 @@ import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWith * Text component - displays multi-line text with word wrapping */ export class Text implements Component { - private text: string; - private paddingX: number; // Left/right padding - private paddingY: number; // Top/bottom padding - private customBgFn?: (text: string) => string; + #text: string; + #paddingX: number; // Left/right padding + #paddingY: number; // Top/bottom padding + #customBgFn?: (text: string) => string; // Cache for rendered output - private cachedText?: string; - private cachedWidth?: number; - private cachedLines?: string[]; + #cachedText?: string; + #cachedWidth?: number; + #cachedLines?: string[]; constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) { - this.text = text; - this.paddingX = paddingX; - this.paddingY = paddingY; - this.customBgFn = customBgFn; + this.#text = text; + this.#paddingX = paddingX; + this.#paddingY = paddingY; + this.#customBgFn = customBgFn; } getText(): string { - return this.text; + return this.#text; } setText(text: string): void { - this.text = text; - this.cachedText = undefined; - this.cachedWidth = undefined; - this.cachedLines = undefined; + this.#text = text; + this.#cachedText = undefined; + this.#cachedWidth = undefined; + this.#cachedLines = undefined; } setCustomBgFn(customBgFn?: (text: string) => string): void { - this.customBgFn = customBgFn; - this.cachedText = undefined; - this.cachedWidth = undefined; - this.cachedLines = undefined; + this.#customBgFn = customBgFn; + this.#cachedText = undefined; + this.#cachedWidth = undefined; + this.#cachedLines = undefined; } invalidate(): void { - this.cachedText = undefined; - this.cachedWidth = undefined; - this.cachedLines = undefined; + this.#cachedText = undefined; + this.#cachedWidth = undefined; + this.#cachedLines = undefined; } render(width: number): string[] { // Check cache - if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { - return this.cachedLines; + if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) { + return this.#cachedLines; } // Don't render anything if there's no actual text - if (!this.text || this.text.trim() === "") { + if (!this.#text || this.#text.trim() === "") { const result: string[] = []; - this.cachedText = this.text; - this.cachedWidth = width; - this.cachedLines = result; + this.#cachedText = this.#text; + this.#cachedWidth = width; + this.#cachedLines = result; return result; } // Replace tabs with 3 spaces - const normalizedText = replaceTabs(this.text); + const normalizedText = replaceTabs(this.#text); // Calculate content width (subtract left/right margins) - const contentWidth = Math.max(1, width - this.paddingX * 2); + const contentWidth = Math.max(1, width - this.#paddingX * 2); // Wrap text (this preserves ANSI codes but does NOT pad) const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth); // Add margins and background to each line - const leftMargin = padding(this.paddingX); - const rightMargin = padding(this.paddingX); + const leftMargin = padding(this.#paddingX); + const rightMargin = padding(this.#paddingX); const contentLines: string[] = []; for (const line of wrappedLines) { @@ -80,8 +80,8 @@ export class Text implements Component { const lineWithMargins = leftMargin + line + rightMargin; // Apply background if specified (this also pads to full width) - if (this.customBgFn) { - contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn)); + if (this.#customBgFn) { + contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.#customBgFn)); } else { // No background - just pad to width with spaces const visibleLen = visibleWidth(lineWithMargins); @@ -93,17 +93,17 @@ export class Text implements Component { // Add top/bottom padding (empty lines) const emptyLine = padding(width); const emptyLines: string[] = []; - for (let i = 0; i < this.paddingY; i++) { - const line = this.customBgFn ? applyBackgroundToLine(emptyLine, width, this.customBgFn) : emptyLine; + for (let i = 0; i < this.#paddingY; i++) { + const line = this.#customBgFn ? applyBackgroundToLine(emptyLine, width, this.#customBgFn) : emptyLine; emptyLines.push(line); } const result = [...emptyLines, ...contentLines, ...emptyLines]; // Update cache - this.cachedText = this.text; - this.cachedWidth = width; - this.cachedLines = result; + this.#cachedText = this.#text; + this.#cachedWidth = width; + this.#cachedLines = result; return result.length > 0 ? result : [""]; } diff --git a/packages/tui/src/components/truncated-text.ts b/packages/tui/src/components/truncated-text.ts index f2801b495..143fb0aec 100644 --- a/packages/tui/src/components/truncated-text.ts +++ b/packages/tui/src/components/truncated-text.ts @@ -5,14 +5,14 @@ import { padding, truncateToWidth } from "../utils"; * Text component that truncates to fit viewport width */ export class TruncatedText implements Component { - private text: string; - private paddingX: number; - private paddingY: number; + #text: string; + #paddingX: number; + #paddingY: number; constructor(text: string, paddingX: number = 0, paddingY: number = 0) { - this.text = text; - this.paddingX = paddingX; - this.paddingY = paddingY; + this.#text = text; + this.#paddingX = paddingX; + this.#paddingY = paddingY; } invalidate(): void { @@ -26,33 +26,33 @@ export class TruncatedText implements Component { const emptyLine = padding(width); // Add vertical padding above - for (let i = 0; i < this.paddingY; i++) { + for (let i = 0; i < this.#paddingY; i++) { result.push(emptyLine); } // Calculate available width after horizontal padding - const availableWidth = Math.max(1, width - this.paddingX * 2); + const availableWidth = Math.max(1, width - this.#paddingX * 2); // Take only the first line (stop at newline) - let singleLineText = this.text; - const newlineIndex = this.text.indexOf("\n"); + let singleLineText = this.#text; + const newlineIndex = this.#text.indexOf("\n"); if (newlineIndex !== -1) { - singleLineText = this.text.substring(0, newlineIndex); + singleLineText = this.#text.substring(0, newlineIndex); } // Truncate text if needed (accounting for ANSI codes) const displayText = truncateToWidth(singleLineText, availableWidth); // Add horizontal padding - const leftPadding = padding(this.paddingX); - const rightPadding = padding(this.paddingX); + const leftPadding = padding(this.#paddingX); + const rightPadding = padding(this.#paddingX); const lineWithPadding = leftPadding + displayText + rightPadding; // Don't pad to full width - avoids trailing spaces when copying result.push(lineWithPadding); // Add vertical padding below - for (let i = 0; i < this.paddingY; i++) { + for (let i = 0; i < this.#paddingY; i++) { result.push(emptyLine); } diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index f0e8641fe..9cc60ef33 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -120,20 +120,20 @@ const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId; * Manages keybindings for the editor. */ export class EditorKeybindingsManager { - private actionToKeys: Map; + #actionToKeys: Map; constructor(config: EditorKeybindingsConfig = {}) { - this.actionToKeys = new Map(); - this.buildMaps(config); + this.#actionToKeys = new Map(); + this.#buildMaps(config); } - private buildMaps(config: EditorKeybindingsConfig): void { - this.actionToKeys.clear(); + #buildMaps(config: EditorKeybindingsConfig): void { + this.#actionToKeys.clear(); // Start with defaults for (const [action, keys] of Object.entries(DEFAULT_EDITOR_KEYBINDINGS)) { const keyArray = Array.isArray(keys) ? keys : [keys]; - this.actionToKeys.set( + this.#actionToKeys.set( action as EditorAction, keyArray.map(key => normalizeKeyId(key as KeyId)), ); @@ -143,7 +143,7 @@ export class EditorKeybindingsManager { for (const [action, keys] of Object.entries(config)) { if (keys === undefined) continue; const keyArray = Array.isArray(keys) ? keys : [keys]; - this.actionToKeys.set( + this.#actionToKeys.set( action as EditorAction, keyArray.map(key => normalizeKeyId(key as KeyId)), ); @@ -154,7 +154,7 @@ export class EditorKeybindingsManager { * Check if input matches a specific action. */ matches(data: string, action: EditorAction): boolean { - const keys = this.actionToKeys.get(action); + const keys = this.#actionToKeys.get(action); if (!keys) return false; for (const key of keys) { if (matchesKey(data, key)) return true; @@ -171,14 +171,14 @@ export class EditorKeybindingsManager { * Get keys bound to an action. */ getKeys(action: EditorAction): KeyId[] { - return this.actionToKeys.get(action) ?? []; + return this.#actionToKeys.get(action) ?? []; } /** * Update configuration. */ setConfig(config: EditorKeybindingsConfig): void { - this.buildMaps(config); + this.#buildMaps(config); } } diff --git a/packages/tui/src/kill-ring.ts b/packages/tui/src/kill-ring.ts index 2292f91aa..602b9930e 100644 --- a/packages/tui/src/kill-ring.ts +++ b/packages/tui/src/kill-ring.ts @@ -6,7 +6,7 @@ * (cycle through older entries). */ export class KillRing { - private ring: string[] = []; + #ring: string[] = []; /** * Add text to the kill ring. @@ -19,28 +19,28 @@ export class KillRing { push(text: string, opts: { prepend: boolean; accumulate?: boolean }): void { if (!text) return; - if (opts.accumulate && this.ring.length > 0) { - const last = this.ring.pop()!; - this.ring.push(opts.prepend ? text + last : last + text); + if (opts.accumulate && this.#ring.length > 0) { + const last = this.#ring.pop()!; + this.#ring.push(opts.prepend ? text + last : last + text); } else { - this.ring.push(text); + this.#ring.push(text); } } /** Get most recent entry without modifying the ring. */ peek(): string | undefined { - return this.ring.length > 0 ? this.ring[this.ring.length - 1] : undefined; + return this.#ring.length > 0 ? this.#ring[this.#ring.length - 1] : undefined; } /** Move last entry to front (for yank-pop cycling). */ rotate(): void { - if (this.ring.length > 1) { - const last = this.ring.pop()!; - this.ring.unshift(last); + if (this.#ring.length > 1) { + const last = this.#ring.pop()!; + this.#ring.unshift(last); } } get length(): number { - return this.ring.length; + return this.#ring.length; } } diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index f8dcc2c7b..097af9eda 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -240,22 +240,22 @@ export type StdinBufferEventMap = { * Handles partial escape sequences that arrive across multiple chunks. */ export class StdinBuffer extends EventEmitter { - private buffer: string = ""; - private timeout: ReturnType | null = null; - private readonly timeoutMs: number; - private pasteMode: boolean = false; - private pasteBuffer: string = ""; + #buffer: string = ""; + #timeout?: NodeJS.Timeout; + readonly #timeoutMs: number; + #pasteMode: boolean = false; + #pasteBuffer: string = ""; constructor(options: StdinBufferOptions = {}) { super(); - this.timeoutMs = options.timeout ?? 10; + this.#timeoutMs = options.timeout ?? 10; } - public process(data: string | Buffer): void { + process(data: string | Buffer): void { // Clear any pending timeout - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = null; + if (this.#timeout) { + clearTimeout(this.#timeout); + this.#timeout = undefined; } // Handle high-byte conversion (for compatibility with parseKeypress) @@ -272,24 +272,24 @@ export class StdinBuffer extends EventEmitter { str = data; } - if (str.length === 0 && this.buffer.length === 0) { + if (str.length === 0 && this.#buffer.length === 0) { this.emit("data", ""); return; } - this.buffer += str; + this.#buffer += str; - if (this.pasteMode) { - this.pasteBuffer += this.buffer; - this.buffer = ""; + if (this.#pasteMode) { + this.#pasteBuffer += this.#buffer; + this.#buffer = ""; - const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END); + const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END); if (endIndex !== -1) { - const pastedContent = this.pasteBuffer.slice(0, endIndex); - const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); + const pastedContent = this.#pasteBuffer.slice(0, endIndex); + const remaining = this.#pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); - this.pasteMode = false; - this.pasteBuffer = ""; + this.#pasteMode = false; + this.#pasteBuffer = ""; this.emit("paste", pastedContent); @@ -300,28 +300,28 @@ export class StdinBuffer extends EventEmitter { return; } - const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START); + const startIndex = this.#buffer.indexOf(BRACKETED_PASTE_START); if (startIndex !== -1) { if (startIndex > 0) { - const beforePaste = this.buffer.slice(0, startIndex); + const beforePaste = this.#buffer.slice(0, startIndex); const result = extractCompleteSequences(beforePaste); for (const sequence of result.sequences) { this.emit("data", sequence); } } - this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length); - this.pasteMode = true; - this.pasteBuffer = this.buffer; - this.buffer = ""; + this.#buffer = this.#buffer.slice(startIndex + BRACKETED_PASTE_START.length); + this.#pasteMode = true; + this.#pasteBuffer = this.#buffer; + this.#buffer = ""; - const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END); + const endIndex = this.#pasteBuffer.indexOf(BRACKETED_PASTE_END); if (endIndex !== -1) { - const pastedContent = this.pasteBuffer.slice(0, endIndex); - const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); + const pastedContent = this.#pasteBuffer.slice(0, endIndex); + const remaining = this.#pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); - this.pasteMode = false; - this.pasteBuffer = ""; + this.#pasteMode = false; + this.#pasteBuffer = ""; this.emit("paste", pastedContent); @@ -332,51 +332,51 @@ export class StdinBuffer extends EventEmitter { return; } - const result = extractCompleteSequences(this.buffer); - this.buffer = result.remainder; + const result = extractCompleteSequences(this.#buffer); + this.#buffer = result.remainder; for (const sequence of result.sequences) { this.emit("data", sequence); } - if (this.buffer.length > 0) { - this.timeout = setTimeout(() => { + if (this.#buffer.length > 0) { + this.#timeout = setTimeout(() => { const flushed = this.flush(); for (const sequence of flushed) { this.emit("data", sequence); } - }, this.timeoutMs); + }, this.#timeoutMs); } } flush(): string[] { - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = null; + if (this.#timeout) { + clearTimeout(this.#timeout); + this.#timeout = undefined; } - if (this.buffer.length === 0) { + if (this.#buffer.length === 0) { return []; } - const sequences = [this.buffer]; - this.buffer = ""; + const sequences = [this.#buffer]; + this.#buffer = ""; return sequences; } clear(): void { - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = null; + if (this.#timeout) { + clearTimeout(this.#timeout); + this.#timeout = undefined; } - this.buffer = ""; - this.pasteMode = false; - this.pasteBuffer = ""; + this.#buffer = ""; + this.#pasteMode = false; + this.#pasteBuffer = ""; } getBuffer(): string { - return this.buffer; + return this.#buffer; } destroy(): void { diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index d8ba40abd..3917b0999 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -83,29 +83,29 @@ export interface Terminal { * Real terminal using process.stdin/stdout */ export class ProcessTerminal implements Terminal { - private wasRaw = false; - private inputHandler?: (data: string) => void; - private resizeHandler?: () => void; - private _kittyProtocolActive = false; - private stdinBuffer?: StdinBuffer; - private stdinDataHandler?: (data: string) => void; - private dead = false; - private writeLogPath = $env.PI_TUI_WRITE_LOG || ""; + #wasRaw = false; + #inputHandler?: (data: string) => void; + #resizeHandler?: () => void; + #kittyProtocolActive = false; + #stdinBuffer?: StdinBuffer; + #stdinDataHandler?: (data: string) => void; + #dead = false; + #writeLogPath = $env.PI_TUI_WRITE_LOG || ""; get kittyProtocolActive(): boolean { - return this._kittyProtocolActive; + return this.#kittyProtocolActive; } start(onInput: (data: string) => void, onResize: () => void): void { - this.inputHandler = onInput; - this.resizeHandler = onResize; + this.#inputHandler = onInput; + this.#resizeHandler = onResize; // Register for emergency cleanup activeTerminal = this; terminalEverStarted = true; // Save previous state and enable raw mode - this.wasRaw = process.stdin.isRaw || false; + this.#wasRaw = process.stdin.isRaw || false; if (process.stdin.setRawMode) { process.stdin.setRawMode(true); } @@ -113,10 +113,10 @@ export class ProcessTerminal implements Terminal { process.stdin.resume(); // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~ - this.safeWrite("\x1b[?2004h"); + this.#safeWrite("\x1b[?2004h"); // Set up resize handler immediately - process.stdout.on("resize", this.resizeHandler); + process.stdout.on("resize", this.#resizeHandler); // Refresh terminal dimensions - they may be stale after suspend/resume // (SIGWINCH is lost while process is stopped). Unix only. @@ -127,7 +127,7 @@ export class ProcessTerminal implements Terminal { // Query and enable Kitty keyboard protocol // The query handler intercepts input temporarily, then installs the user's handler // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ - this.queryAndEnableKittyProtocol(); + this.#queryAndEnableKittyProtocol(); } /** @@ -138,45 +138,45 @@ export class ProcessTerminal implements Terminal { * This is done here (after stdinBuffer parsing) rather than on raw stdin * to handle the case where the response arrives split across multiple events. */ - private setupStdinBuffer(): void { - this.stdinBuffer = new StdinBuffer({ timeout: 10 }); + #setupStdinBuffer(): void { + this.#stdinBuffer = new StdinBuffer({ timeout: 10 }); // Kitty protocol response pattern: \x1b[?u const kittyResponsePattern = /^\x1b\[\?(\d+)u$/; // Forward individual sequences to the input handler - this.stdinBuffer.on("data", (sequence: string) => { + this.#stdinBuffer.on("data", (sequence: string) => { // Check for Kitty protocol response (only if not already enabled) - if (!this._kittyProtocolActive) { + if (!this.#kittyProtocolActive) { const match = sequence.match(kittyResponsePattern); if (match) { - this._kittyProtocolActive = true; + this.#kittyProtocolActive = true; setKittyProtocolActive(true); // Enable Kitty keyboard protocol (push flags) // Flag 1 = disambiguate escape codes // Flag 2 = report event types (press/repeat/release) // Flag 4 = report alternate keys - this.safeWrite("\x1b[>7u"); + this.#safeWrite("\x1b[>7u"); return; // Don't forward protocol response to TUI } } - if (this.inputHandler) { - this.inputHandler(sequence); + if (this.#inputHandler) { + this.#inputHandler(sequence); } }); // Re-wrap paste content with bracketed paste markers for existing editor handling - this.stdinBuffer.on("paste", (content: string) => { - if (this.inputHandler) { - this.inputHandler(`\x1b[200~${content}\x1b[201~`); + this.#stdinBuffer.on("paste", (content: string) => { + if (this.#inputHandler) { + this.#inputHandler(`\x1b[200~${content}\x1b[201~`); } }); // Handler that pipes stdin data through the buffer - this.stdinDataHandler = (data: string) => { - this.stdinBuffer!.process(data); + this.#stdinDataHandler = (data: string) => { + this.#stdinBuffer!.process(data); }; } @@ -189,23 +189,23 @@ export class ProcessTerminal implements Terminal { * The response is detected in setupStdinBuffer's data handler, which properly * handles the case where the response arrives split across multiple stdin events. */ - private queryAndEnableKittyProtocol(): void { - this.setupStdinBuffer(); - process.stdin.on("data", this.stdinDataHandler!); - this.safeWrite("\x1b[?u"); + #queryAndEnableKittyProtocol(): void { + this.#setupStdinBuffer(); + process.stdin.on("data", this.#stdinDataHandler!); + this.#safeWrite("\x1b[?u"); } async drainInput(maxMs = 1000, idleMs = 50): Promise { - if (this._kittyProtocolActive) { + if (this.#kittyProtocolActive) { // Disable Kitty keyboard protocol first so any late key releases // do not generate new Kitty escape sequences. - this.safeWrite("\x1b[ { @@ -225,7 +225,7 @@ export class ProcessTerminal implements Terminal { } } finally { process.stdin.removeListener("data", onData); - this.inputHandler = previousHandler; + this.#inputHandler = previousHandler; } } @@ -236,30 +236,30 @@ export class ProcessTerminal implements Terminal { } // Disable bracketed paste mode - this.safeWrite("\x1b[?2004l"); + this.#safeWrite("\x1b[?2004l"); // Disable Kitty keyboard protocol if not already done by drainInput() - if (this._kittyProtocolActive) { - this.safeWrite("\x1b[ 0) { // Move down - this.safeWrite(`\x1b[${lines}B`); + this.#safeWrite(`\x1b[${lines}B`); } else if (lines < 0) { // Move up - this.safeWrite(`\x1b[${-lines}A`); + this.#safeWrite(`\x1b[${-lines}A`); } // lines === 0: no movement } hideCursor(): void { - this.safeWrite("\x1b[?25l"); + this.#safeWrite("\x1b[?25l"); } showCursor(): void { - this.safeWrite("\x1b[?25h"); + this.#safeWrite("\x1b[?25h"); } clearLine(): void { - this.safeWrite("\x1b[K"); + this.#safeWrite("\x1b[K"); } clearFromCursor(): void { - this.safeWrite("\x1b[J"); + this.#safeWrite("\x1b[J"); } clearScreen(): void { - this.safeWrite("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1) + this.#safeWrite("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1) } setTitle(title: string): void { // OSC 0;title BEL - set terminal window title - this.safeWrite(`\x1b]0;${title}\x07`); + this.#safeWrite(`\x1b]0;${title}\x07`); } } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 3de0a17ee..e7cd3024f 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -9,6 +9,8 @@ import type { Terminal } from "./terminal"; import { setCellDimensions, TERMINAL } from "./terminal-capabilities"; import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils"; +const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; + /** * Component interface - all components must implement this */ @@ -195,27 +197,27 @@ export class Container implements Component { * TUI - Main class for managing terminal UI with differential rendering */ export class TUI extends Container { - public terminal: Terminal; - private previousLines: string[] = []; - private previousWidth = 0; - private focusedComponent: Component | null = null; + terminal: Terminal; + #previousLines: string[] = []; + #previousWidth = 0; + #focusedComponent: Component | null = null; /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ - public onDebug?: () => void; - private renderRequested = false; - private cursorRow = 0; // Logical cursor row (end of rendered content) - private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning) - private inputBuffer = ""; // Buffer for parsing terminal responses - private cellSizeQueryPending = false; - private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1"; - private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off) - private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered) - private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves - private fullRedrawCount = 0; - private stopped = false; + onDebug?: () => void; + #renderRequested = false; + #cursorRow = 0; // Logical cursor row (end of rendered content) + #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning) + #inputBuffer = ""; // Buffer for parsing terminal responses + #cellSizeQueryPending = false; + #showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1"; + #clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off) + #maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered) + #previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves + #fullRedrawCount = 0; + #stopped = false; // Overlay stack for modal components rendered on top of base content - private overlayStack: { + overlayStack: { component: Component; options?: OverlayOptions; preFocus: Component | null; @@ -226,21 +228,21 @@ export class TUI extends Container { super(); this.terminal = terminal; if (showHardwareCursor !== undefined) { - this.showHardwareCursor = showHardwareCursor; + this.#showHardwareCursor = showHardwareCursor; } } get fullRedraws(): number { - return this.fullRedrawCount; + return this.#fullRedrawCount; } getShowHardwareCursor(): boolean { - return this.showHardwareCursor; + return this.#showHardwareCursor; } setShowHardwareCursor(enabled: boolean): void { - if (this.showHardwareCursor === enabled) return; - this.showHardwareCursor = enabled; + if (this.#showHardwareCursor === enabled) return; + this.#showHardwareCursor = enabled; if (!enabled) { this.terminal.hideCursor(); } @@ -248,7 +250,7 @@ export class TUI extends Container { } getClearOnShrink(): boolean { - return this.clearOnShrink; + return this.#clearOnShrink; } /** @@ -257,16 +259,16 @@ export class TUI extends Container { * When false, empty rows remain (reduces redraws on slower terminals). */ setClearOnShrink(enabled: boolean): void { - this.clearOnShrink = enabled; + this.#clearOnShrink = enabled; } setFocus(component: Component | null): void { // Clear focused flag on old component - if (isFocusable(this.focusedComponent)) { - this.focusedComponent.focused = false; + if (isFocusable(this.#focusedComponent)) { + this.#focusedComponent.focused = false; } - this.focusedComponent = component; + this.#focusedComponent = component; // Set focused flag on new component if (isFocusable(component)) { @@ -279,10 +281,10 @@ export class TUI extends Container { * Returns a handle to control the overlay's visibility. */ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { - const entry = { component, options, preFocus: this.focusedComponent, hidden: false }; + const entry = { component, options, preFocus: this.#focusedComponent, hidden: false }; this.overlayStack.push(entry); // Only focus if overlay is actually visible - if (this.isOverlayVisible(entry)) { + if (this.#isOverlayVisible(entry)) { this.setFocus(component); } this.terminal.hideCursor(); @@ -295,8 +297,8 @@ export class TUI extends Container { if (index !== -1) { this.overlayStack.splice(index, 1); // Restore focus if this overlay had focus - if (this.focusedComponent === component) { - const topVisible = this.getTopmostVisibleOverlay(); + if (this.#focusedComponent === component) { + const topVisible = this.#getTopmostVisibleOverlay(); this.setFocus(topVisible?.component ?? entry.preFocus); } if (this.overlayStack.length === 0) this.terminal.hideCursor(); @@ -309,13 +311,13 @@ export class TUI extends Container { // Update focus when hiding/showing if (hidden) { // If this overlay had focus, move focus to next visible or preFocus - if (this.focusedComponent === component) { - const topVisible = this.getTopmostVisibleOverlay(); + if (this.#focusedComponent === component) { + const topVisible = this.#getTopmostVisibleOverlay(); this.setFocus(topVisible?.component ?? entry.preFocus); } } else { // Restore focus to this overlay when showing (if it's actually visible) - if (this.isOverlayVisible(entry)) { + if (this.#isOverlayVisible(entry)) { this.setFocus(component); } } @@ -330,7 +332,7 @@ export class TUI extends Container { const overlay = this.overlayStack.pop(); if (!overlay) return; // Find topmost visible overlay, or fall back to preFocus - const topVisible = this.getTopmostVisibleOverlay(); + const topVisible = this.#getTopmostVisibleOverlay(); this.setFocus(topVisible?.component ?? overlay.preFocus); if (this.overlayStack.length === 0) this.terminal.hideCursor(); this.requestRender(); @@ -338,11 +340,11 @@ export class TUI extends Container { /** Check if there are any visible overlays */ hasOverlay(): boolean { - return this.overlayStack.some(o => this.isOverlayVisible(o)); + return this.overlayStack.some(o => this.#isOverlayVisible(o)); } /** Check if an overlay entry is currently visible */ - private isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean { + #isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean { if (entry.hidden) return false; if (entry.options?.visible) { return entry.options.visible(this.terminal.columns, this.terminal.rows); @@ -351,9 +353,9 @@ export class TUI extends Container { } /** Find the topmost visible overlay, if any */ - private getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined { + #getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined { for (let i = this.overlayStack.length - 1; i >= 0; i--) { - if (this.isOverlayVisible(this.overlayStack[i])) { + if (this.#isOverlayVisible(this.overlayStack[i])) { return this.overlayStack[i]; } } @@ -366,33 +368,33 @@ export class TUI extends Container { } start(): void { - this.stopped = false; + this.#stopped = false; this.terminal.start( - data => this.handleInput(data), + data => this.#handleInput(data), () => this.requestRender(), ); this.terminal.hideCursor(); - this.queryCellSize(); + this.#queryCellSize(); this.requestRender(); } - private queryCellSize(): void { + #queryCellSize(): void { // Only query if terminal supports images (cell size is only used for image rendering) if (!TERMINAL.imageProtocol) { return; } // Query terminal for cell size in pixels: CSI 16 t // Response format: CSI 6 ; height ; width t - this.cellSizeQueryPending = true; + this.#cellSizeQueryPending = true; this.terminal.write("\x1b[16t"); } stop(): void { - this.stopped = true; + this.#stopped = true; // Move cursor to the end of the content to prevent overwriting/artifacts on exit - if (this.previousLines.length > 0) { - const targetRow = this.previousLines.length; // Line after the last content - const lineDiff = targetRow - this.hardwareCursorRow; + if (this.#previousLines.length > 0) { + const targetRow = this.#previousLines.length; // Line after the last content + const lineDiff = targetRow - this.#hardwareCursorRow; if (lineDiff > 0) { this.terminal.write(`\x1b[${lineDiff}B`); } else if (lineDiff < 0) { @@ -407,26 +409,26 @@ export class TUI extends Container { requestRender(force = false): void { if (force) { - this.previousLines = []; - this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear - this.cursorRow = 0; - this.hardwareCursorRow = 0; - this.maxLinesRendered = 0; - this.previousViewportTop = 0; + this.#previousLines = []; + this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear + this.#cursorRow = 0; + this.#hardwareCursorRow = 0; + this.#maxLinesRendered = 0; + this.#previousViewportTop = 0; } - if (this.renderRequested) return; - this.renderRequested = true; + if (this.#renderRequested) return; + this.#renderRequested = true; process.nextTick(() => { - this.renderRequested = false; - this.doRender(); + this.#renderRequested = false; + this.#doRender(); }); } - private handleInput(data: string): void { + #handleInput(data: string): void { // If we're waiting for cell size response, buffer input and parse - if (this.cellSizeQueryPending) { - this.inputBuffer += data; - const filtered = this.parseCellSizeResponse(); + if (this.#cellSizeQueryPending) { + this.#inputBuffer += data; + const filtered = this.#parseCellSizeResponse(); if (filtered.length === 0) return; data = filtered; } @@ -439,10 +441,10 @@ export class TUI extends Container { // If focused component is an overlay, verify it's still visible // (visibility can change due to terminal resize or visible() callback) - const focusedOverlay = this.overlayStack.find(o => o.component === this.focusedComponent); - if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) { + const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent); + if (focusedOverlay && !this.#isOverlayVisible(focusedOverlay)) { // Focused overlay is no longer visible, redirect to topmost visible overlay - const topVisible = this.getTopmostVisibleOverlay(); + const topVisible = this.#getTopmostVisibleOverlay(); if (topVisible) { this.setFocus(topVisible.component); } else { @@ -453,21 +455,21 @@ export class TUI extends Container { // Pass input to focused component (including Ctrl+C) // The focused component can decide how to handle Ctrl+C - if (this.focusedComponent?.handleInput) { + if (this.#focusedComponent?.handleInput) { // Filter out key release events unless component opts in - if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) { + if (isKeyRelease(data) && !this.#focusedComponent.wantsKeyRelease) { return; } - this.focusedComponent.handleInput(data); + this.#focusedComponent.handleInput(data); this.requestRender(); } } - private parseCellSizeResponse(): string { + #parseCellSizeResponse(): string { // Response format: ESC [ 6 ; height ; width t // Match the response pattern const responsePattern = /\x1b\[6;(\d+);(\d+)t/; - const match = this.inputBuffer.match(responsePattern); + const match = this.#inputBuffer.match(responsePattern); if (match) { const heightPx = parseInt(match[1], 10); @@ -481,17 +483,17 @@ export class TUI extends Container { } // Remove the response from buffer - this.inputBuffer = this.inputBuffer.replace(responsePattern, ""); - this.cellSizeQueryPending = false; + this.#inputBuffer = this.#inputBuffer.replace(responsePattern, ""); + this.#cellSizeQueryPending = false; } // Check if we have a partial cell size response starting (wait for more data) // Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet) const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/; - if (partialCellSizePattern.test(this.inputBuffer)) { + if (partialCellSizePattern.test(this.#inputBuffer)) { // Check if it's actually a complete different escape sequence (ends with a letter) // Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc. - const lastChar = this.inputBuffer[this.inputBuffer.length - 1]; + const lastChar = this.#inputBuffer[this.#inputBuffer.length - 1]; if (!/[a-zA-Z~]/.test(lastChar)) { // Doesn't end with a terminator, might be incomplete - wait for more return ""; @@ -499,9 +501,9 @@ export class TUI extends Container { } // No cell size response found, return buffered data as user input - const result = this.inputBuffer; - this.inputBuffer = ""; - this.cellSizeQueryPending = false; // Give up waiting + const result = this.#inputBuffer; + this.#inputBuffer = ""; + this.#cellSizeQueryPending = false; // Give up waiting return result; } @@ -509,7 +511,7 @@ export class TUI extends Container { * Resolve overlay layout from options. * Returns { width, row, col, maxHeight } for rendering. */ - private resolveOverlayLayout( + #resolveOverlayLayout( options: OverlayOptions | undefined, overlayHeight: number, termWidth: number, @@ -564,7 +566,7 @@ export class TUI extends Container { row = marginTop + Math.floor(maxRow * percent); } else { // Invalid format, fall back to center - row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop); + row = this.#resolveAnchorRow("center", effectiveHeight, availHeight, marginTop); } } else { // Absolute row position @@ -573,7 +575,7 @@ export class TUI extends Container { } else { // Anchor-based (default: center) const anchor = opt.anchor ?? "center"; - row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop); + row = this.#resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop); } if (opt.col !== undefined) { @@ -586,7 +588,7 @@ export class TUI extends Container { col = marginLeft + Math.floor(maxCol * percent); } else { // Invalid format, fall back to center - col = this.resolveAnchorCol("center", width, availWidth, marginLeft); + col = this.#resolveAnchorCol("center", width, availWidth, marginLeft); } } else { // Absolute column position @@ -595,7 +597,7 @@ export class TUI extends Container { } else { // Anchor-based (default: center) const anchor = opt.anchor ?? "center"; - col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft); + col = this.#resolveAnchorCol(anchor, width, availWidth, marginLeft); } // Apply offsets @@ -609,7 +611,7 @@ export class TUI extends Container { return { width, row, col, maxHeight }; } - private resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number { + #resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number { switch (anchor) { case "top-left": case "top-center": @@ -626,7 +628,7 @@ export class TUI extends Container { } } - private resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number { + #resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number { switch (anchor) { case "top-left": case "left-center": @@ -644,7 +646,7 @@ export class TUI extends Container { } /** Composite all overlays into content lines (in stack order, later = on top). */ - private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { + #compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { if (this.overlayStack.length === 0) return lines; const result = [...lines]; @@ -654,13 +656,13 @@ export class TUI extends Container { for (const entry of this.overlayStack) { // Skip invisible overlays (hidden or visible() returns false) - if (!this.isOverlayVisible(entry)) continue; + if (!this.#isOverlayVisible(entry)) continue; const { component, options } = entry; // Get layout with height=0 first to determine width and maxHeight // (width and maxHeight don't depend on overlay height) - const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight); + const { width, maxHeight } = this.#resolveOverlayLayout(options, 0, termWidth, termHeight); // Render component at calculated width let overlayLines = component.render(width); @@ -671,7 +673,7 @@ export class TUI extends Container { } // Get final row/col with actual overlay height - const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); + const { row, col } = this.#resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); rendered.push({ overlayLines, row, col, w: width }); minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length); @@ -703,7 +705,7 @@ export class TUI extends Container { // (components should already respect width, but this ensures it) const truncatedOverlayLine = visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i]; - result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth); + result[idx] = this.#compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth); modifiedLines.add(idx); } } @@ -723,10 +725,8 @@ export class TUI extends Container { return result; } - private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; - - private applyLineResets(lines: string[]): string[] { - const reset = TUI.SEGMENT_RESET; + #applyLineResets(lines: string[]): string[] { + const reset = SEGMENT_RESET; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!TERMINAL.isImageLine(line)) { @@ -737,7 +737,7 @@ export class TUI extends Container { } /** Splice overlay content into a base line at a specific column. Single-pass optimized. */ - private compositeLineAt( + #compositeLineAt( baseLine: string, overlayLine: string, startCol: number, @@ -762,7 +762,7 @@ export class TUI extends Container { const afterPad = Math.max(0, afterTarget - base.afterWidth); // Compose result - const r = TUI.SEGMENT_RESET; + const r = SEGMENT_RESET; const result = base.before + " ".repeat(beforePad) + @@ -795,7 +795,7 @@ export class TUI extends Container { * @param height - Terminal height (visible viewport size) * @returns Cursor position { row, col } or null if no marker found */ - private extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null { + #extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null { // Only scan the bottom `height` lines (visible viewport) const viewportTop = Math.max(0, lines.length - height); for (let row = lines.length - 1; row >= viewportTop; row--) { @@ -815,13 +815,13 @@ export class TUI extends Container { return null; } - private doRender(): void { - if (this.stopped) return; + #doRender(): void { + if (this.#stopped) return; const width = this.terminal.columns; const height = this.terminal.rows; - let viewportTop = Math.max(0, this.maxLinesRendered - height); - let prevViewportTop = this.previousViewportTop; - let hardwareCursorRow = this.hardwareCursorRow; + let viewportTop = Math.max(0, this.#maxLinesRendered - height); + let prevViewportTop = this.#previousViewportTop; + let hardwareCursorRow = this.#hardwareCursorRow; const computeLineDiff = (targetRow: number): number => { const currentScreenRow = hardwareCursorRow - prevViewportTop; const targetScreenRow = targetRow - viewportTop; @@ -833,20 +833,20 @@ export class TUI extends Container { // Composite overlays into the rendered lines (before differential compare) if (this.overlayStack.length > 0) { - newLines = this.compositeOverlays(newLines, width, height); + newLines = this.#compositeOverlays(newLines, width, height); } // Extract cursor position before applying line resets (marker must be found first) - const cursorPos = this.extractCursorPosition(newLines, height); + const cursorPos = this.#extractCursorPosition(newLines, height); - newLines = this.applyLineResets(newLines); + newLines = this.#applyLineResets(newLines); // Width changed - need full re-render (line wrapping changes) - const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; + const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width; // Helper to clear scrollback and viewport and render all new lines const fullRender = (clear: boolean): void => { - this.fullRedrawCount += 1; + this.#fullRedrawCount += 1; let buffer = "\x1b[?2026h"; // Begin synchronized output if (clear) buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home for (let i = 0; i < newLines.length; i++) { @@ -855,30 +855,30 @@ export class TUI extends Container { } buffer += "\x1b[?2026l"; // End synchronized output this.terminal.write(buffer); - this.cursorRow = Math.max(0, newLines.length - 1); - this.hardwareCursorRow = this.cursorRow; + this.#cursorRow = Math.max(0, newLines.length - 1); + this.#hardwareCursorRow = this.#cursorRow; // Reset max lines when clearing, otherwise track growth if (clear) { - this.maxLinesRendered = newLines.length; + this.#maxLinesRendered = newLines.length; } else { - this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length); } - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousLines = newLines; - this.previousWidth = width; + this.#previousViewportTop = Math.max(0, this.#maxLinesRendered - height); + this.#positionHardwareCursor(cursorPos, newLines.length); + this.#previousLines = newLines; + this.#previousWidth = width; }; const debugRedraw = process.env.PI_DEBUG_REDRAW === "1"; const logRedraw = (reason: string): void => { if (!debugRedraw) return; const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log"); - const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`; + const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`; fs.appendFileSync(logPath, msg); }; // First render - just output everything without clearing (assumes clean screen) - if (this.previousLines.length === 0 && !widthChanged) { + if (this.#previousLines.length === 0 && !widthChanged) { logRedraw("first render"); fullRender(false); return; @@ -886,7 +886,7 @@ export class TUI extends Container { // Width changed - full re-render (line wrapping changes) if (widthChanged) { - logRedraw(`width changed (${this.previousWidth} -> ${width})`); + logRedraw(`width changed (${this.#previousWidth} -> ${width})`); fullRender(true); return; } @@ -894,8 +894,8 @@ export class TUI extends Container { // Content shrunk below the working area and no overlays - re-render to clear empty rows. // When an overlay is active, avoid clearing to reduce flicker and avoid resetting scrollback. // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var - if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) { - logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`); + if (this.#clearOnShrink && newLines.length < this.#maxLinesRendered && this.overlayStack.length === 0) { + logRedraw(`clearOnShrink (maxLinesRendered=${this.#maxLinesRendered})`); fullRender(true); return; } @@ -903,9 +903,9 @@ export class TUI extends Container { // Find first and last changed lines let firstChanged = -1; let lastChanged = -1; - const maxLines = Math.max(newLines.length, this.previousLines.length); + const maxLines = Math.max(newLines.length, this.#previousLines.length); for (let i = 0; i < maxLines; i++) { - const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; + const oldLine = i < this.#previousLines.length ? this.#previousLines[i] : ""; const newLine = i < newLines.length ? newLines[i] : ""; if (oldLine !== newLine) { @@ -915,25 +915,25 @@ export class TUI extends Container { lastChanged = i; } } - const appendedLines = newLines.length > this.previousLines.length; + const appendedLines = newLines.length > this.#previousLines.length; if (appendedLines) { if (firstChanged === -1) { - firstChanged = this.previousLines.length; + firstChanged = this.#previousLines.length; } lastChanged = newLines.length - 1; } - const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0; + const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0; // No changes - but still need to update hardware cursor position if it moved if (firstChanged === -1) { - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + this.#positionHardwareCursor(cursorPos, newLines.length); + this.#previousViewportTop = Math.max(0, this.#maxLinesRendered - height); return; } // All changes are in deleted lines (nothing to render, just clear) if (firstChanged >= newLines.length) { - if (this.previousLines.length > newLines.length) { + if (this.#previousLines.length > newLines.length) { let buffer = "\x1b[?2026h"; // Move to end of new content (clamp to 0 for empty content) const targetRow = Math.max(0, newLines.length - 1); @@ -942,7 +942,7 @@ export class TUI extends Container { else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; buffer += "\r"; // Clear extra lines without scrolling - const extraLines = this.previousLines.length - newLines.length; + const extraLines = this.#previousLines.length - newLines.length; if (extraLines > height) { logRedraw(`extraLines > height (${extraLines} > ${height})`); fullRender(true); @@ -960,19 +960,19 @@ export class TUI extends Container { } buffer += "\x1b[?2026l"; this.terminal.write(buffer); - this.cursorRow = targetRow; - this.hardwareCursorRow = targetRow; + this.#cursorRow = targetRow; + this.#hardwareCursorRow = targetRow; } - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousLines = newLines; - this.previousWidth = width; - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + this.#positionHardwareCursor(cursorPos, newLines.length); + this.#previousLines = newLines; + this.#previousWidth = width; + this.#previousViewportTop = Math.max(0, this.#maxLinesRendered - height); return; } // Check if firstChanged is above what was previously visible // Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks - const previousContentViewportTop = Math.max(0, this.previousLines.length - height); + const previousContentViewportTop = Math.max(0, this.#previousLines.length - height); if (firstChanged < previousContentViewportTop) { // First change is above previous viewport - need full re-render logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`); @@ -1051,15 +1051,15 @@ export class TUI extends Container { let finalCursorRow = renderEnd; // If we had more lines before, clear them and move cursor back - if (this.previousLines.length > newLines.length) { + if (this.#previousLines.length > newLines.length) { // Move to end of new content first if we stopped before it if (renderEnd < newLines.length - 1) { const moveDown = newLines.length - 1 - renderEnd; buffer += `\x1b[${moveDown}B`; finalCursorRow = newLines.length - 1; } - const extraLines = this.previousLines.length - newLines.length; - for (let i = newLines.length; i < this.previousLines.length; i++) { + const extraLines = this.#previousLines.length - newLines.length; + for (let i = newLines.length; i < this.#previousLines.length; i++) { buffer += "\r\n\x1b[2K"; } // Move cursor back to end of new content @@ -1075,7 +1075,7 @@ export class TUI extends Container { const debugData = [ `firstChanged: ${firstChanged}`, `viewportTop: ${viewportTop}`, - `cursorRow: ${this.cursorRow}`, + `cursorRow: ${this.#cursorRow}`, `height: ${height}`, `lineDiff: ${lineDiff}`, `hardwareCursorRow: ${hardwareCursorRow}`, @@ -1083,13 +1083,13 @@ export class TUI extends Container { `finalCursorRow: ${finalCursorRow}`, `cursorPos: ${JSON.stringify(cursorPos)}`, `newLines.length: ${newLines.length}`, - `previousLines.length: ${this.previousLines.length}`, + `previousLines.length: ${this.#previousLines.length}`, "", "=== newLines ===", JSON.stringify(newLines, null, 2), "", "=== previousLines ===", - JSON.stringify(this.previousLines, null, 2), + JSON.stringify(this.#previousLines, null, 2), "", "=== buffer ===", JSON.stringify(buffer), @@ -1103,17 +1103,17 @@ export class TUI extends Container { // Track cursor position for next render // cursorRow tracks end of content (for viewport calculation) // hardwareCursorRow tracks actual terminal cursor position (for movement) - this.cursorRow = Math.max(0, newLines.length - 1); - this.hardwareCursorRow = finalCursorRow; + this.#cursorRow = Math.max(0, newLines.length - 1); + this.#hardwareCursorRow = finalCursorRow; // Track terminal's working area (grows but doesn't shrink unless cleared) - this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); - this.previousViewportTop = Math.max(0, this.maxLinesRendered - height); + this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length); + this.#previousViewportTop = Math.max(0, this.#maxLinesRendered - height); // Position hardware cursor for IME - this.positionHardwareCursor(cursorPos, newLines.length); + this.#positionHardwareCursor(cursorPos, newLines.length); - this.previousLines = newLines; - this.previousWidth = width; + this.#previousLines = newLines; + this.#previousWidth = width; } /** @@ -1121,7 +1121,7 @@ export class TUI extends Container { * @param cursorPos The cursor position extracted from rendered output, or null * @param totalLines Total number of rendered lines */ - private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void { + #positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void { if (!cursorPos || totalLines <= 0) { this.terminal.hideCursor(); return; @@ -1132,7 +1132,7 @@ export class TUI extends Container { const targetCol = Math.max(0, cursorPos.col); // Move cursor from current position to target - const rowDelta = targetRow - this.hardwareCursorRow; + const rowDelta = targetRow - this.#hardwareCursorRow; let buffer = ""; if (rowDelta > 0) { buffer += `\x1b[${rowDelta}B`; // Move down @@ -1146,8 +1146,8 @@ export class TUI extends Container { this.terminal.write(buffer); } - this.hardwareCursorRow = targetRow; - if (this.showHardwareCursor) { + this.#hardwareCursorRow = targetRow; + if (this.#showHardwareCursor) { this.terminal.showCursor(); } else { this.terminal.hideCursor(); diff --git a/packages/tui/src/undo-stack.ts b/packages/tui/src/undo-stack.ts deleted file mode 100644 index 5b9a7e9ce..000000000 --- a/packages/tui/src/undo-stack.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Generic undo stack with clone-on-push semantics. - * - * Stores deep clones of state snapshots. Popped snapshots are returned - * directly (no re-cloning) since they are already detached. - */ -export class UndoStack { - private stack: S[] = []; - - /** Push a deep clone of the given state onto the stack. */ - push(state: S): void { - this.stack.push(structuredClone(state)); - } - - /** Pop and return the most recent snapshot, or undefined if empty. */ - pop(): S | undefined { - return this.stack.pop(); - } - - /** Remove all snapshots. */ - clear(): void { - this.stack.length = 0; - } - - get length(): number { - return this.stack.length; - } -} diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index 1478259b5..54ab21c68 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -542,7 +542,7 @@ describe("Markdown component", () => { it("should not leak styles into following lines when rendered in TUI", async () => { class MarkdownWithInput implements Component { - public markdownLineCount = 0; + markdownLineCount = 0; constructor(private readonly markdown: Markdown) {} diff --git a/packages/utils/src/ptree.ts b/packages/utils/src/ptree.ts index da959ecbe..9e8ab07b4 100644 --- a/packages/utils/src/ptree.ts +++ b/packages/utils/src/ptree.ts @@ -104,7 +104,7 @@ export class ChildProcess { #stderrStream?: ReadableStream; constructor( - public readonly proc: PipedSubprocess, + readonly proc: PipedSubprocess, readonly exposeStderr: boolean, ) { // Eagerly drain stderr into a truncated tail string + raw chunks. diff --git a/scripts/repro-stuck.ts b/scripts/repro-stuck.ts index a2ec3c7aa..20cc41685 100644 --- a/scripts/repro-stuck.ts +++ b/scripts/repro-stuck.ts @@ -12,6 +12,7 @@ * --rounds=N Max rounds to try (default: 1000) */ +import { Subprocess } from "bun"; import * as path from "node:path"; const CLI_PATH = path.resolve(import.meta.dir, "../packages/coding-agent/src/cli.ts"); @@ -36,7 +37,7 @@ function parseArgs(): Args { } interface Instance { - proc: ReturnType; + proc: Subprocess; port: number; stdout: string; stderr: string;