fix(tui): comprehensive TUI review — layout, flow, rendering, and state fixes

Addresses 30+ issues found in a full review of the interactive TUI spanning
layout/visual, user flow, message rendering, and state management dimensions.

Critical (state/memory):
- Fix onBranchChange unsubscribe function being discarded; store and call in stop()
- Add onThemeChange cleanup in stop() to prevent stale callback retention
- Resolve getUserInput() Promise on shutdown so run() while-loop exits cleanly
- Serialize concurrent message_update event handlers via Promise chain to prevent
  duplicate ToolExecutionComponent creation under rapid streaming
- Add cleanup of customFooter, customHeader, autocompleteProvider, and extension
  widgets in stop() to prevent timer/watcher leaks

Major (UX/flow):
- Add two-step confirmation for provider auth removal (r key) — matches session
  delete pattern; first press shows confirm hint, second press executes
- Normalize list navigation wrapping: oauth-selector and session-selector now
  wrap at boundaries, consistent with all other selectors
- Ctrl+C in scoped-models-selector now always cancels modal immediately instead
  of clearing search first
- Config-selector position indicator now counts only selectable items, excluding
  non-selectable group headers from both numerator and denominator
- user-message-selector auto-dismiss replaced setTimeout(100) with
  Promise.resolve().then() to eliminate 100ms flicker
- Add "Unknown command: /foo. Type /help for available commands." feedback for
  unrecognized slash commands instead of silently submitting as chat
- Fix dead-end input path: submitPromptsDirectly=false now dispatches prompt
- Wrap session.prompt in isCompacting path with try/catch (was missing, other
  path had it)
- Add Esc-to-close hint to provider-manager footer (was undocumented)

Rendering bugs:
- Remove identical dead-code else branch in assistant-message spacing logic
- Add 20-line truncation to generic/unknown tool JSON rendering (was unbounded)
- bash-execution updateDisplay() now uses stored _borderColorKey so
  excludeFromContext dim styling is preserved on re-render
- Fix countdown-timer dispose race: _disposed flag prevents extra tick after
  clearInterval
- extension-selector nextSelectable() guard prevents cursor landing on separator
- extension-input now rejects empty/whitespace-only submissions
- Normalize bordered-loader spacing: non-cancellable variant no longer adds
  orphaned spacer before bottom border

Visual/theme:
- daxnuts.ts center() replaced naive ANSI regex with visibleWidth() from
  @gsd/pi-tui for correct true-color sequence handling
- Remove incorrect mistral.ai URL from daxnuts component
- armin.ts now centers art using same visibleWidth approach as daxnuts
- Dark theme warning color: #ffff00 → #e6b800 (muted amber, less harsh)
- dynamic-border default color function wrapped in try/catch to guard against
  undefined theme in jiti-loaded extension contexts
- Footer stats grouped with · separator; cache labels changed from R/W to cr:/cw:
- Replace raw \x1b[1m ANSI codes in custom-message, branch-summary-message,
  compaction-summary-message, skill-invocation-message with theme.bold()
- welcome-screen visLen now uses strip-ansi instead of hand-rolled regex

Performance:
- diff.ts parseDiffLine regex: [+-\s] → [+\- ] (space only, not all whitespace)
- tab replacement width: 3 spaces → 4 spaces (standard) in both diff.ts and
  tool-execution.ts
- chat-controller message_update: skip already-processed content blocks using
  lastProcessedContentIndex to reduce O(n) scan per event
This commit is contained in:
Jeremy McSpadden 2026-03-23 19:56:27 -05:00 committed by Jeremy
parent 0a2c9b64c6
commit 8d19f195d4
28 changed files with 210 additions and 79 deletions

View file

@ -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;

View file

@ -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") {

View file

@ -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

View file

@ -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));
}

View file

@ -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));

View file

@ -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));

View file

@ -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;

View file

@ -7,6 +7,7 @@ import type { TUI } from "@gsd/pi-tui";
export class CountdownTimer {
private intervalId: ReturnType<typeof setInterval> | 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;

View file

@ -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));

View file

@ -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("");

View file

@ -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, " ");
}
/**

View file

@ -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;
}

View file

@ -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();

View file

@ -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;
}

View file

@ -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";

View file

@ -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

View file

@ -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();
}
}
}
}

View file

@ -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;
}

View file

@ -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")) {

View file

@ -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));

View file

@ -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}`;

View file

@ -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());
}
}

View file

@ -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<void>;
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;

View file

@ -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);
}
};
}

View file

@ -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<void> = 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) {

View file

@ -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;
}

View file

@ -23,7 +23,7 @@ const dark: ThemeJson = {
blue: "#5f87ff",
green: "#b5bd68",
red: "#cc6666",
yellow: "#ffff00",
yellow: "#e6b800",
gray: "#808080",
dimGray: "#666666",
darkGray: "#505050",

View file

@ -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. */