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:
parent
0a2c9b64c6
commit
8d19f195d4
28 changed files with 210 additions and 79 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
|
|
|
|||
|
|
@ -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, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const dark: ThemeJson = {
|
|||
blue: "#5f87ff",
|
||||
green: "#b5bd68",
|
||||
red: "#cc6666",
|
||||
yellow: "#ffff00",
|
||||
yellow: "#e6b800",
|
||||
gray: "#808080",
|
||||
dimGray: "#666666",
|
||||
darkGray: "#505050",
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue