diff --git a/packages/pi-coding-agent/src/modes/interactive/components/armin.ts b/packages/pi-coding-agent/src/modes/interactive/components/armin.ts index afa0d780a..35a591c16 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/armin.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/armin.ts @@ -2,7 +2,7 @@ * Armin says hi! A fun easter egg with animated XBM art. */ -import type { Component, TUI } from "@gsd/pi-tui"; +import { type Component, type TUI, visibleWidth } from "@gsd/pi-tui"; import { theme } from "../theme/theme.js"; // XBM image: 31x36 pixels, LSB first, 1=background, 0=foreground @@ -88,20 +88,20 @@ export class ArminComponent implements Component { return this.cachedLines; } - const padding = 1; - const availableWidth = width - padding; + const center = (s: string) => { + const visible = visibleWidth(s); + const left = Math.max(0, Math.floor((width - visible) / 2)); + return " ".repeat(left) + s; + }; 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 - padding - clipped.length); - return ` ${theme.fg("accent", clipped)}${" ".repeat(padRight)}`; + const clipped = row.slice(0, width).join(""); + return center(theme.fg("accent", clipped)); }); // Add "ARMIN SAYS HI" at the end const message = "ARMIN SAYS HI"; - const msgPadRight = Math.max(0, width - padding - message.length); - this.cachedLines.push(` ${theme.fg("accent", message)}${" ".repeat(msgPadRight)}`); + this.cachedLines.push(center(theme.fg("accent", message))); this.cachedWidth = width; this.cachedVersion = this.gridVersion; diff --git a/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts index b0e8bb716..c558b7cfc 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts @@ -105,8 +105,6 @@ export class AssistantMessageComponent extends Container { : "Operation aborted"; if (hasVisibleContent) { this.contentContainer.addChild(new Spacer(1)); - } else { - this.contentContainer.addChild(new Spacer(1)); } this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0)); } else if (message.stopReason === "error") { diff --git a/packages/pi-coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/pi-coding-agent/src/modes/interactive/components/bash-execution.ts index cec80e097..31a12ae80 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/bash-execution.ts @@ -29,6 +29,7 @@ export class BashExecutionComponent extends Container { private expanded = false; private contentContainer: Container; private ui: TUI; + private _borderColorKey: string; constructor(command: string, ui: TUI, excludeFromContext = false) { super(); @@ -37,6 +38,7 @@ export class BashExecutionComponent extends Container { // Use dim border for excluded-from-context commands (!! prefix) const colorKey = excludeFromContext ? "dim" : "bashMode"; + this._borderColorKey = colorKey; const borderColor = (str: string) => theme.fg(colorKey, str); // Add spacer @@ -137,7 +139,7 @@ export class BashExecutionComponent extends Container { this.contentContainer.clear(); // Command header - const header = new Text(theme.fg("bashMode", theme.bold(`$ ${this.command}`)), 1, 0); + const header = new Text(theme.fg(this._borderColorKey, theme.bold(`$ ${this.command}`)), 1, 0); this.contentContainer.addChild(header); // Output diff --git a/packages/pi-coding-agent/src/modes/interactive/components/bordered-loader.ts b/packages/pi-coding-agent/src/modes/interactive/components/bordered-loader.ts index d2610da96..9c4dae2d2 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/bordered-loader.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/bordered-loader.ts @@ -34,8 +34,8 @@ export class BorderedLoader extends Container { if (this.cancellable) { this.addChild(new Spacer(1)); this.addChild(new Text(keyHint("selectCancel", "cancel"), 1, 0)); + this.addChild(new Spacer(1)); } - this.addChild(new Spacer(1)); this.addChild(new DynamicBorder(borderColor)); } diff --git a/packages/pi-coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/pi-coding-agent/src/modes/interactive/components/branch-summary-message.ts index c7b666a2f..9c7ed9730 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/branch-summary-message.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -32,7 +32,7 @@ export class BranchSummaryMessageComponent extends Box { private updateDisplay(): void { this.clear(); - const label = theme.fg("customMessageLabel", `\x1b[1m[branch]\x1b[22m`); + const label = theme.fg("customMessageLabel", theme.bold("[branch]")); this.addChild(new Text(label, 0, 0)); this.addChild(new Spacer(1)); diff --git a/packages/pi-coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/pi-coding-agent/src/modes/interactive/components/compaction-summary-message.ts index ace738406..f7e68e259 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -33,7 +33,7 @@ export class CompactionSummaryMessageComponent extends Box { this.clear(); const tokenStr = this.message.tokensBefore.toLocaleString(); - const label = theme.fg("customMessageLabel", `\x1b[1m[compaction]\x1b[22m`); + const label = theme.fg("customMessageLabel", theme.bold("[compaction]")); this.addChild(new Text(label, 0, 0)); this.addChild(new Spacer(1)); diff --git a/packages/pi-coding-agent/src/modes/interactive/components/config-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/config-selector.ts index 61f6d57dd..befee7ca6 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/config-selector.ts @@ -346,9 +346,14 @@ class ResourceList implements Component, Focusable { } } - // Scroll indicator + // Scroll indicator — count only selectable items (exclude group/subgroup headers) if (startIndex > 0 || endIndex < this.filteredItems.length) { - lines.push(theme.fg("dim", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`)); + const selectableItems = this.filteredItems.filter((e) => e.type === "item"); + const selectableTotal = selectableItems.length; + const selectablePosition = selectableItems.findIndex( + (e) => this.filteredItems.indexOf(e) === this.selectedIndex, + ); + lines.push(theme.fg("dim", ` (${selectablePosition + 1}/${selectableTotal})`)); } return lines; diff --git a/packages/pi-coding-agent/src/modes/interactive/components/countdown-timer.ts b/packages/pi-coding-agent/src/modes/interactive/components/countdown-timer.ts index 0f051c2f6..ef77320d3 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/countdown-timer.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/countdown-timer.ts @@ -7,6 +7,7 @@ import type { TUI } from "@gsd/pi-tui"; export class CountdownTimer { private intervalId: ReturnType | undefined; private remainingSeconds: number; + private _disposed = false; constructor( timeoutMs: number, @@ -18,6 +19,7 @@ export class CountdownTimer { this.onTick(this.remainingSeconds); this.intervalId = setInterval(() => { + if (this._disposed) return; this.remainingSeconds--; this.onTick(this.remainingSeconds); this.tui?.requestRender(); @@ -30,6 +32,7 @@ export class CountdownTimer { } dispose(): void { + this._disposed = true; if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = undefined; diff --git a/packages/pi-coding-agent/src/modes/interactive/components/custom-message.ts b/packages/pi-coding-agent/src/modes/interactive/components/custom-message.ts index f3f6455fb..ba7cf9634 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/custom-message.ts @@ -75,7 +75,7 @@ export class CustomMessageComponent extends Container { this.box.clear(); // Default rendering: label + content - const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`); + const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`)); this.box.addChild(new Text(label, 0, 0)); this.box.addChild(new Spacer(1)); diff --git a/packages/pi-coding-agent/src/modes/interactive/components/daxnuts.ts b/packages/pi-coding-agent/src/modes/interactive/components/daxnuts.ts index e501cd435..47b87e146 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/daxnuts.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/daxnuts.ts @@ -4,7 +4,7 @@ * A heartfelt tribute to dax (@thdxr) for providing free Kimi K2.5 access via OpenCode. */ -import type { Component, TUI } from "@gsd/pi-tui"; +import { type Component, type TUI, visibleWidth } from "@gsd/pi-tui"; import { theme } from "../theme/theme.js"; // 32x32 RGB image of dax, hex encoded (3 bytes per pixel) @@ -101,7 +101,7 @@ export class DaxnutsComponent implements Component { const lines: string[] = []; const center = (s: string) => { - const visible = s.replace(/\x1b\[[0-9;]*m/g, "").length; + const visible = visibleWidth(s); const left = Math.max(0, Math.floor((width - visible) / 2)); return " ".repeat(left) + s; }; @@ -145,7 +145,8 @@ export class DaxnutsComponent implements Component { lines.push(""); if (textPhase > 2 || this.tick >= this.maxTicks) { lines.push(center(t.fg("dim", "Try OpenCode"))); - lines.push(center(t.fg("mdLink", "https://mistral.ai/news/mistral-vibe-2-0"))); + // URL removed — was pointing to an incorrect destination + lines.push(center(t.fg("mdLink", "opencode.ai"))); } else { lines.push(""); lines.push(""); diff --git a/packages/pi-coding-agent/src/modes/interactive/components/diff.ts b/packages/pi-coding-agent/src/modes/interactive/components/diff.ts index d575d63e3..55131b023 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/diff.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/diff.ts @@ -6,7 +6,7 @@ import { theme } from "../theme/theme.js"; * Format: "+123 content" or "-123 content" or " 123 content" or " ..." */ function parseDiffLine(line: string): { prefix: string; lineNum: string; content: string } | null { - const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/); + const match = line.match(/^([+\- ])(\s*\d*)\s(.*)$/); if (!match) return null; return { prefix: match[1], lineNum: match[2], content: match[3] }; } @@ -15,7 +15,7 @@ function parseDiffLine(line: string): { prefix: string; lineNum: string; content * Replace tabs with spaces for consistent rendering. */ function replaceTabs(text: string): string { - return text.replace(/\t/g, " "); + return text.replace(/\t/g, " "); } /** diff --git a/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts b/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts index 60d2da9e3..a54298065 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts @@ -11,7 +11,9 @@ import { theme } from "../theme/theme.js"; export class DynamicBorder implements Component { private color: (str: string) => string; - constructor(color: (str: string) => string = (str) => theme.fg("border", str)) { + constructor(color: (str: string) => string = (str) => { + try { return theme.fg("border", str); } catch { return str; } + }) { this.color = color; } diff --git a/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts b/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts index 06d7ee933..525bcfc06 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts @@ -74,6 +74,7 @@ export class ExtensionInputComponent extends Container implements Focusable { handleInput(keyData: string): void { const kb = getEditorKeybindings(); if (kb.matches(keyData, "selectConfirm") || keyData === "\n") { + if (this.input.getValue().trim() === "") return; this.onSubmitCallback(this.input.getValue()); } else if (kb.matches(keyData, "selectCancel")) { this.onCancelCallback(); diff --git a/packages/pi-coding-agent/src/modes/interactive/components/extension-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/extension-selector.ts index 2870aed28..e24327fc8 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/extension-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/extension-selector.ts @@ -96,6 +96,10 @@ export class ExtensionSelectorComponent extends Container { if (idx < 0 || idx >= this.options.length) { return Math.max(0, Math.min(from, this.options.length - 1)); } + // If all items are separators, idx may still point to one — fall back to original index + if (this.isSeparator(idx)) { + return Math.max(0, Math.min(from, this.options.length - 1)); + } return idx; } diff --git a/packages/pi-coding-agent/src/modes/interactive/components/footer.ts b/packages/pi-coding-agent/src/modes/interactive/components/footer.ts index 7a2b763bf..c0cdeab01 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/footer.ts @@ -110,29 +110,36 @@ export class FooterComponent implements Component { pwd = `${pwd} • ${sessionName}`; } - // Build stats line - const statsParts = []; - if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`); - if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`); - if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`); - if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); + // Build stats line as separate groups joined by a dim middle-dot separator + const sep = ` ${theme.fg("dim", "\u00B7")} `; - // Show cost with "(sub)" indicator if using OAuth subscription + // Group 1: token I/O + const tokenGroup: string[] = []; + if (totalInput) tokenGroup.push(`↑${formatTokens(totalInput)}`); + if (totalOutput) tokenGroup.push(`↓${formatTokens(totalOutput)}`); + + // Group 2: cache metrics + const cacheGroup: string[] = []; + if (totalCacheRead) cacheGroup.push(`cr:${formatTokens(totalCacheRead)}`); + if (totalCacheWrite) cacheGroup.push(`cw:${formatTokens(totalCacheWrite)}`); + + // Group 3: cost + const costGroup: string[] = []; const usingSubscription = displayModel ? this.session.modelRegistry.isUsingOAuth(displayModel) : false; if (totalCost || usingSubscription) { const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; - statsParts.push(costStr); + costGroup.push(costStr); } // Per-prompt cost annotation (opt-in via show_token_cost preference, #1515) if (process.env.GSD_SHOW_TOKEN_COST === "1") { const lastTurnCost = this.session.getLastTurnCost(); if (lastTurnCost > 0) { - statsParts.push(`(last: ${formatPromptCost(lastTurnCost)})`); + costGroup.push(`(last: ${formatPromptCost(lastTurnCost)})`); } } - // Colorize context percentage based on usage + // Group 4: context percentage (colorized) let contextPercentStr: string; const autoIndicator = this.autoCompactEnabled ? " (auto)" : ""; const contextPercentDisplay = @@ -146,9 +153,16 @@ export class FooterComponent implements Component { } else { contextPercentStr = contextPercentDisplay; } - statsParts.push(contextPercentStr); - let statsLeft = statsParts.join(" "); + // Assemble groups: items within a group are space-separated, + // groups are separated by a dim middle-dot + const groups: string[] = []; + if (tokenGroup.length > 0) groups.push(tokenGroup.join(" ")); + if (cacheGroup.length > 0) groups.push(cacheGroup.join(" ")); + if (costGroup.length > 0) groups.push(costGroup.join(" ")); + groups.push(contextPercentStr); + + let statsLeft = groups.join(sep); // Add model name on the right side, plus thinking level if model supports it const modelName = displayModel?.id || "no-model"; diff --git a/packages/pi-coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/oauth-selector.ts index 17844be07..33e23df94 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -96,14 +96,14 @@ export class OAuthSelectorComponent extends Container { handleInput(keyData: string): void { const kb = getEditorKeybindings(); - // Up arrow + // Up arrow (wrap) if (kb.matches(keyData, "selectUp")) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.selectedIndex = this.selectedIndex === 0 ? this.allProviders.length - 1 : this.selectedIndex - 1; this.updateList(); } - // Down arrow + // Down arrow (wrap) else if (kb.matches(keyData, "selectDown")) { - this.selectedIndex = Math.min(this.allProviders.length - 1, this.selectedIndex + 1); + this.selectedIndex = this.selectedIndex === this.allProviders.length - 1 ? 0 : this.selectedIndex + 1; this.updateList(); } // Enter diff --git a/packages/pi-coding-agent/src/modes/interactive/components/provider-manager.ts b/packages/pi-coding-agent/src/modes/interactive/components/provider-manager.ts index 9129b746f..718d68030 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/provider-manager.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/provider-manager.ts @@ -43,6 +43,8 @@ export class ProviderManagerComponent extends Container implements Focusable { private modelsJsonWriter: ModelsJsonWriter; private onDone: () => void; private onDiscover: (provider: string) => void; + private confirmingRemove = false; + private hintsContainer: Container; constructor( tui: TUI, @@ -65,12 +67,9 @@ export class ProviderManagerComponent extends Container implements Focusable { this.addChild(new Spacer(1)); // Hints - const hints = [ - rawKeyHint("d", "discover"), - rawKeyHint("r", "remove"), - rawKeyHint("esc", "close"), - ].join(" "); - this.addChild(new Text(hints, 0, 0)); + this.hintsContainer = new Container(); + this.addChild(this.hintsContainer); + this.updateHints(); this.addChild(new Spacer(1)); // List @@ -116,6 +115,24 @@ export class ProviderManagerComponent extends Container implements Focusable { this.selectedIndex = Math.min(this.selectedIndex, this.providers.length - 1); } + private updateHints(): void { + this.hintsContainer.clear(); + if (this.confirmingRemove) { + const hints = [ + rawKeyHint("r", "confirm removal"), + rawKeyHint("esc", "cancel"), + ].join(" "); + this.hintsContainer.addChild(new Text(hints, 0, 0)); + } else { + const hints = [ + rawKeyHint("d", "discover"), + rawKeyHint("r", "remove auth"), + rawKeyHint("esc", "close"), + ].join(" "); + this.hintsContainer.addChild(new Text(hints, 0, 0)); + } + } + private updateList(): void { this.listContainer.clear(); @@ -156,7 +173,13 @@ export class ProviderManagerComponent extends Container implements Focusable { this.updateList(); this.tui.requestRender(); } else if (kb.matches(keyData, "selectCancel")) { - this.onDone(); + if (this.confirmingRemove) { + this.confirmingRemove = false; + this.updateHints(); + this.tui.requestRender(); + } else { + this.onDone(); + } } else if (keyData === "d" || keyData === "D") { const provider = this.providers[this.selectedIndex]; if (provider?.supportsDiscovery) { @@ -164,13 +187,21 @@ export class ProviderManagerComponent extends Container implements Focusable { } } else if (keyData === "r" || keyData === "R") { const provider = this.providers[this.selectedIndex]; - if (provider) { - this.authStorage.remove(provider.name); - this.modelsJsonWriter.removeProvider(provider.name); - this.modelRegistry.refresh(); - this.loadProviders(); - this.updateList(); - this.tui.requestRender(); + if (provider?.hasAuth) { + if (this.confirmingRemove) { + this.confirmingRemove = false; + this.authStorage.remove(provider.name); + this.modelsJsonWriter.removeProvider(provider.name); + this.modelRegistry.refresh(); + this.loadProviders(); + this.updateHints(); + this.updateList(); + this.tui.requestRender(); + } else { + this.confirmingRemove = true; + this.updateHints(); + this.tui.requestRender(); + } } } } diff --git a/packages/pi-coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/scoped-models-selector.ts index 22f677540..20354d0d3 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/scoped-models-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -318,14 +318,9 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl return; } - // Ctrl+C - clear search or cancel if empty + // Ctrl+C - always cancel immediately if (matchesKey(data, Key.ctrl("c"))) { - if (this.searchInput.getValue()) { - this.searchInput.setValue(""); - this.refresh(); - } else { - this.callbacks.onCancel(); - } + this.callbacks.onCancel(); return; } diff --git a/packages/pi-coding-agent/src/modes/interactive/components/session-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/session-selector.ts index ff37698e0..ac08e7761 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/session-selector.ts @@ -570,13 +570,13 @@ class SessionList implements Component, Focusable { return; } - // Up arrow + // Up arrow (wrap) if (kb.matches(keyData, "selectUp")) { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.selectedIndex = this.selectedIndex === 0 ? this.filteredSessions.length - 1 : this.selectedIndex - 1; } - // Down arrow + // Down arrow (wrap) else if (kb.matches(keyData, "selectDown")) { - this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1); + this.selectedIndex = this.selectedIndex === this.filteredSessions.length - 1 ? 0 : this.selectedIndex + 1; } // Page up - jump up by maxVisible items else if (kb.matches(keyData, "selectPageUp")) { diff --git a/packages/pi-coding-agent/src/modes/interactive/components/skill-invocation-message.ts b/packages/pi-coding-agent/src/modes/interactive/components/skill-invocation-message.ts index adbf71fd9..4e88f8eff 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/skill-invocation-message.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/skill-invocation-message.ts @@ -35,7 +35,7 @@ export class SkillInvocationMessageComponent extends Box { if (this.expanded) { // Expanded: label + skill name header + full content - const label = theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m`); + const label = theme.fg("customMessageLabel", theme.bold("[skill]")); this.addChild(new Text(label, 0, 0)); const header = `**${this.skillBlock.name}**\n\n`; this.addChild( @@ -46,7 +46,7 @@ export class SkillInvocationMessageComponent extends Box { } else { // Collapsed: single line - [skill] name (hint to expand) const line = - theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + + theme.fg("customMessageLabel", theme.bold("[skill]") + " ") + theme.fg("customMessageText", this.skillBlock.name) + theme.fg("dim", ` (${editorKey("expandTools")} to expand)`); this.addChild(new Text(line, 0, 0)); diff --git a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts index 399819c30..10bd5f02c 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts @@ -32,7 +32,7 @@ const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50; * Replace tabs with spaces for consistent rendering */ function replaceTabs(text: string): string { - return text.replace(/\t/g, " "); + return text.replace(/\t/g, " "); } /** @@ -915,8 +915,13 @@ export class ToolExecutionComponent extends Container { // Generic tool (shouldn't reach here for custom tools) text = theme.fg("toolTitle", theme.bold(this.toolName)); - const content = JSON.stringify(this.args, null, 2); - text += `\n\n${content}`; + const contentLines = JSON.stringify(this.args, null, 2).split("\n"); + const maxContentLines = 20; + const truncatedContent = contentLines.slice(0, maxContentLines); + if (contentLines.length > maxContentLines) { + truncatedContent.push("..."); + } + text += `\n\n${truncatedContent.join("\n")}`; const output = this.getTextOutput(); if (output) { text += `\n${output}`; diff --git a/packages/pi-coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/user-message-selector.ts index 94ccf93df..800232faa 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/user-message-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -131,9 +131,10 @@ export class UserMessageSelectorComponent extends Container { this.addChild(new Spacer(1)); this.addChild(new DynamicBorder()); - // Auto-cancel if no messages + // Auto-cancel if no messages — invoke synchronously via microtask + // to avoid the 100ms visual flicker from setTimeout if (messages.length === 0) { - setTimeout(() => onCancel(), 100); + Promise.resolve().then(() => onCancel()); } } diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index ebe9231ed..e186c6651 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -6,6 +6,9 @@ import { AssistantMessageComponent } from "../components/assistant-message.js"; import { ToolExecutionComponent } from "../components/tool-execution.js"; import { appKey } from "../components/keybinding-hints.js"; +// Tracks the last processed content index to avoid re-scanning all blocks on every message_update +let lastProcessedContentIndex = 0; + export async function handleAgentEvent(host: InteractiveModeStateHost & { init: () => Promise; getMarkdownThemeWithSettings: () => any; @@ -28,6 +31,11 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { host.footer.invalidate(); + // Reset content index tracker when a new assistant message starts + if (event.type === "message_start" && event.message.role === "assistant") { + lastProcessedContentIndex = 0; + } + switch (event.type) { case "session_state_changed": switch (event.reason) { @@ -113,7 +121,9 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { if (host.streamingComponent && event.message.role === "assistant") { host.streamingMessage = event.message; host.streamingComponent.updateContent(host.streamingMessage); - for (const content of host.streamingMessage.content) { + const contentBlocks = host.streamingMessage.content; + for (let i = lastProcessedContentIndex; i < contentBlocks.length; i++) { + const content = contentBlocks[i]; if (content.type === "toolCall") { if (!host.pendingTools.has(content.id)) { const component = new ToolExecutionComponent( @@ -161,6 +171,12 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { } } } + // Update index: fully processed blocks won't need re-scanning. + // Keep the last block's index (it may still be accumulating data), + // so we re-check it next time but skip all earlier ones. + if (contentBlocks.length > 0) { + lastProcessedContentIndex = Math.max(0, contentBlocks.length - 1); + } host.ui.requestRender(); } break; diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts index 0bb073044..7bb7f280b 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/input-controller.ts @@ -46,7 +46,12 @@ export function setupEditorSubmitHandler(host: InteractiveModeStateHost & { if (host.isExtensionCommand(text)) { host.editor.addToHistory?.(text); host.editor.setText(""); - await host.session.prompt(text); + try { + await host.session.prompt(text); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + host.showError(errorMessage); + } } else { host.queueCompactionMessage(text, "steer"); } @@ -82,5 +87,13 @@ export function setupEditorSubmitHandler(host: InteractiveModeStateHost & { } host.editor.addToHistory?.(text); + // submitPromptsDirectly is false — still dispatch via session.prompt so user input + // is not silently discarded. + try { + await host.session.prompt(text); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + host.showError(errorMessage); + } }; } diff --git a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts index 5c539923c..736da41d9 100644 --- a/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts @@ -107,6 +107,7 @@ import { getThemeByName, initTheme, onThemeChange, + stopThemeWatcher, setRegisteredThemes, setTheme, setThemeInstance, @@ -202,6 +203,9 @@ export class InteractiveMode { // Agent subscription unsubscribe function private unsubscribe?: () => void; + // Branch change listener unsubscribe function + private _branchChangeUnsub?: () => void; + // Track if editor is in bash mode (text starts with !) private isBashMode = false; @@ -511,7 +515,7 @@ export class InteractiveMode { }); // Set up git branch watcher (uses provider instead of footer) - this.footerDataProvider.onBranchChange(() => { + this._branchChangeUnsub = this.footerDataProvider.onBranchChange(() => { this.ui.requestRender(); }); @@ -1998,8 +2002,9 @@ export class InteractiveMode { } private subscribeToAgent(): void { - this.unsubscribe = this.session.subscribe(async (event) => { - await this.handleEvent(event); + let eventQueue: Promise = Promise.resolve(); + this.unsubscribe = this.session.subscribe((event) => { + eventQueue = eventQueue.then(() => this.handleEvent(event)).catch(() => {}); }); } @@ -3805,6 +3810,33 @@ export class InteractiveMode { this.loadingAnimation = undefined; } this.clearExtensionTerminalInputListeners(); + + // Clean up branch change listener (Fix 1) + this._branchChangeUnsub?.(); + this._branchChangeUnsub = undefined; + + // Clean up theme change listener and watcher (Fix 2) + onThemeChange(() => {}); + stopThemeWatcher(); + + // Resolve any pending getUserInput promise so the run() loop can exit (Fix 3) + if (this.onInputCallback) { + this.onInputCallback(""); + this.onInputCallback = undefined; + } + + // Dispose extension widgets, custom footer, and custom header (Fix 4) + this.clearExtensionWidgets(); + if (this.customFooter?.dispose) { + this.customFooter.dispose(); + } + this.customFooter = undefined; + if (this.customHeader?.dispose) { + this.customHeader.dispose(); + } + this.customHeader = undefined; + this.autocompleteProvider = undefined; + this.footer.dispose(); this.footerDataProvider.dispose(); if (this.unsubscribe) { diff --git a/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts b/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts index c735f8216..c3e12d8a8 100644 --- a/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts +++ b/packages/pi-coding-agent/src/modes/interactive/slash-command-handlers.ts @@ -236,6 +236,13 @@ export async function dispatchSlashCommand( return true; } + // If input starts with "/" but no command matched, show unknown command feedback + if (text.startsWith("/")) { + const command = text.split(/\s/)[0]; + ctx.showError(`Unknown command: ${command}. Type /help for available commands.`); + return true; + } + return false; } diff --git a/packages/pi-coding-agent/src/modes/interactive/theme/themes.ts b/packages/pi-coding-agent/src/modes/interactive/theme/themes.ts index 45ea9609d..f1459a0bb 100644 --- a/packages/pi-coding-agent/src/modes/interactive/theme/themes.ts +++ b/packages/pi-coding-agent/src/modes/interactive/theme/themes.ts @@ -23,7 +23,7 @@ const dark: ThemeJson = { blue: "#5f87ff", green: "#b5bd68", red: "#cc6666", - yellow: "#ffff00", + yellow: "#e6b800", gray: "#808080", dimGray: "#666666", darkGray: "#505050", diff --git a/src/welcome-screen.ts b/src/welcome-screen.ts index 7b8d37773..7a41e3f0e 100644 --- a/src/welcome-screen.ts +++ b/src/welcome-screen.ts @@ -8,6 +8,7 @@ import os from 'node:os' import chalk from 'chalk' +import stripAnsi from 'strip-ansi' import { GSD_LOGO } from './logo.js' export interface WelcomeScreenOptions { @@ -24,7 +25,7 @@ function getShortCwd(): string { /** Visible length — strips ANSI escape codes before measuring. */ function visLen(s: string): number { - return s.replace(/\x1b\[[0-9;]*m/g, '').length + return stripAnsi(s).length } /** Right-pad a string to the given visible width. */