Merge branch 'main' into fix-ask-option-descriptions

This commit is contained in:
Can Bölük
2026-06-01 18:14:42 +03:00
committed by GitHub
95 changed files with 3038 additions and 412 deletions
@@ -141,6 +141,14 @@ export interface ToolExecutionHandle {
setExpanded(expanded: boolean): void;
}
/** Drive pending-tool redraws at ~60fps so the animated border sweep is smooth.
* The TUI already throttles at its 16ms `MIN_RENDER_INTERVAL_MS`, so this is the
* natural upper bound and static frames diff to a no-op redraw at ~zero cost. */
const SPINNER_RENDER_INTERVAL_MS = 16;
/** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
* 60fps render cadence (mirrors `Loader`). */
const SPINNER_GLYPH_ADVANCE_MS = 80;
/**
* Component that renders a tool call with its result (updateable)
*/
@@ -177,6 +185,7 @@ export class ToolExecutionComponent extends Container {
// Spinner animation for partial task results
#spinnerFrame?: number;
#spinnerInterval?: NodeJS.Timeout;
#lastSpinnerAdvanceAt = 0;
// Todo write completion strikethrough reveal animation
#todoStrikeInterval?: NodeJS.Timeout;
// Track if args are still being streamed (for edit/write spinner)
@@ -404,13 +413,20 @@ export class ToolExecutionComponent extends Container {
this.#isPartial && shimmerEnabled() && (this.#toolName === "bash" || this.#toolName === "eval");
const needsSpinner = isStreamingArgs || isPartialTask || isPendingExecBlock;
if (needsSpinner && !this.#spinnerInterval) {
this.#lastSpinnerAdvanceAt = performance.now();
this.#spinnerInterval = setInterval(() => {
const now = performance.now();
const frameCount = theme.spinnerFrames.length;
if (frameCount === 0) return;
this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
this.#renderState.spinnerFrame = this.#spinnerFrame;
// Redraw at ~60fps for a smooth border sweep, but only step the spinner
// glyph at its classic ~12.5fps cadence. The TUI throttles renders at
// 16ms and the differ drops no-op redraws, so the extra ticks are free.
if (frameCount > 0 && now - this.#lastSpinnerAdvanceAt >= SPINNER_GLYPH_ADVANCE_MS) {
this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
this.#renderState.spinnerFrame = this.#spinnerFrame;
this.#lastSpinnerAdvanceAt = now;
}
this.#ui.requestRender();
}, 80);
}, SPINNER_RENDER_INTERVAL_MS);
} else if (!needsSpinner && this.#spinnerInterval) {
clearInterval(this.#spinnerInterval);
this.#spinnerInterval = undefined;
@@ -25,12 +25,14 @@ type AgentSessionEventKind = AgentSessionEvent["type"];
const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
// Events that change which foreground tools are executing, or that reset a turn.
// The eager native-scrollback rebuild mode is recomputed only on these — other
// events (assistant text streaming, IRC, notices) leave it untouched so plain
// streaming keeps the no-yank deferral.
const TOOL_RENDER_MODE_EVENTS: Record<string, true> = {
// Events that change foreground streaming state, or that reset a turn. The TUI
// eager native-scrollback rebuild mode is recomputed only on these so unrelated
// IRC/notices/status refreshes do not toggle scrollback replay policy.
const STREAM_RENDER_MODE_EVENTS: Record<string, true> = {
agent_start: true,
agent_end: true,
message_start: true,
message_end: true,
tool_execution_start: true,
tool_execution_update: true,
tool_execution_end: true,
@@ -46,6 +48,7 @@ export class EventController {
#renderedCustomMessages = new Set<string>();
#lastIntent: string | undefined = undefined;
#backgroundToolCallIds = new Set<string>();
#assistantMessageStreaming = false;
#readToolCallArgs = new Map<string, Record<string, unknown>>();
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
@@ -169,24 +172,27 @@ export class EventController {
const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
await run(event);
// While a foreground tool is executing, its streaming result re-renders and can
// re-lay-out rows that already scrolled into native scrollback. Let the TUI
// rebuild history on those offscreen edits (a snap to the tail is acceptable
// mid-tool) instead of deferring, which would leave stale/duplicated rows.
// Background-running tools are excluded so their late async updates — and the
// assistant text that streams alongside them — keep the no-yank deferral;
// agent_start resets the mode at every turn boundary.
if (TOOL_RENDER_MODE_EVENTS[event.type]) {
// While assistant text or a foreground tool is streaming, rows above the
// viewport can re-layout after they have already entered native scrollback
// (Markdown fences, wrapping, previews). Let the TUI rebuild history on
// those offscreen edits instead of deferring, which otherwise leaves stale
// tail rows duplicated above the live viewport.
// Background-running tools are excluded so late async updates outside the
// active foreground stream keep the no-yank deferral; agent_start resets
// the mode at every turn boundary.
if (STREAM_RENDER_MODE_EVENTS[event.type]) {
this.#refreshToolRenderMode();
}
}
#refreshToolRenderMode(): void {
let foregroundToolActive = false;
for (const toolCallId of this.ctx.pendingTools.keys()) {
if (!this.#backgroundToolCallIds.has(toolCallId)) {
foregroundToolActive = true;
break;
let foregroundToolActive = this.#assistantMessageStreaming;
if (!foregroundToolActive) {
for (const toolCallId of this.ctx.pendingTools.keys()) {
if (!this.#backgroundToolCallIds.has(toolCallId)) {
foregroundToolActive = true;
break;
}
}
}
this.ctx.ui.setEagerNativeScrollbackRebuild(foregroundToolActive);
@@ -196,6 +202,7 @@ export class EventController {
this.#lastIntent = undefined;
this.#readToolCallArgs.clear();
this.#readToolCallAssistantComponents.clear();
this.#assistantMessageStreaming = false;
this.#lastAssistantComponent = undefined;
if (this.ctx.retryEscapeHandler) {
this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
@@ -268,6 +275,7 @@ export class EventController {
this.ctx.ui.requestRender();
} else if (event.message.role === "assistant") {
this.#lastThinkingCount = 0;
this.#assistantMessageStreaming = true;
this.#resetReadGroup();
this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock, () =>
this.ctx.ui.requestRender(),
@@ -414,6 +422,9 @@ export class EventController {
async #handleMessageEnd(event: Extract<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
if (event.message.role === "user") return;
if (event.message.role === "assistant") {
this.#assistantMessageStreaming = false;
}
if (this.ctx.streamingComponent && event.message.role === "assistant") {
this.ctx.streamingMessage = event.message;
let errorMessage: string | undefined;
@@ -596,8 +607,8 @@ export class EventController {
}
}
}
async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
this.#assistantMessageStreaming = false;
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
@@ -1425,7 +1425,7 @@ export class MCPCommandController {
this.#showMessage(["", theme.fg("muted", `Reconnecting to "${name}"...`), ""].join("\n"));
try {
const connection = await this.ctx.mcpManager.reconnectServer(name);
const connection = await this.ctx.mcpManager.reconnectServer(name, { manual: true });
if (connection) {
// refreshMCPTools re-registers tools and preserves the user's prior
// MCP tool selection. No need to call activateDiscoveredMCPTools —