diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index c563b63718e..5f59fc0412a 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -190,6 +190,50 @@ describe("tui session actions", () => { expect(listSessions).toHaveBeenCalledTimes(2); }); + it("skips UI work when session refresh metadata is unchanged", async () => { + const listSessions = vi.fn().mockResolvedValue({ + ts: Date.now(), + path: "/tmp/sessions.json", + count: 1, + defaults: {}, + sessions: [ + { + key: "agent:main:main", + model: "sonnet-4.6", + modelProvider: "anthropic", + totalTokens: 42, + updatedAt: 200, + }, + ], + }); + const state = createBaseState({ + sessionInfo: { + model: "sonnet-4.6", + modelProvider: "anthropic", + totalTokens: 42, + updatedAt: 100, + }, + }); + const updateFooter = vi.fn(); + const updateAutocompleteProvider = vi.fn(); + const requestRender = vi.fn(); + + const { refreshSessionInfo } = createTestSessionActions({ + client: { listSessions } as unknown as TuiBackend, + state, + updateFooter, + updateAutocompleteProvider, + tui: { requestRender } as unknown as import("@earendil-works/pi-tui").TUI, + }); + + await refreshSessionInfo(); + + expect(state.sessionInfo.updatedAt).toBe(200); + expect(updateAutocompleteProvider).not.toHaveBeenCalled(); + expect(updateFooter).not.toHaveBeenCalled(); + expect(requestRender).not.toHaveBeenCalled(); + }); + it("keeps patched model selection when a refresh returns an older snapshot", async () => { const listSessions = vi.fn().mockResolvedValue({ ts: Date.now(), diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index fceeafc6c36..4503921a45c 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -49,6 +49,46 @@ type SessionInfoEntry = SessionInfo & { providerOverride?: string; }; +function thinkingLevelsEqual( + left?: Array<{ id: string; label: string }>, + right?: Array<{ id: string; label: string }>, +): boolean { + if (left === right) { + return true; + } + if (!left || !right || left.length !== right.length) { + return false; + } + return left.every((level, index) => { + const other = right[index]; + return other?.id === level.id && other.label === level.label; + }); +} + +function goalEquals(left: SessionInfo["goal"], right: SessionInfo["goal"]): boolean { + return left === right || JSON.stringify(left ?? null) === JSON.stringify(right ?? null); +} + +function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): boolean { + return ( + left.thinkingLevel === right.thinkingLevel && + thinkingLevelsEqual(left.thinkingLevels, right.thinkingLevels) && + left.fastMode === right.fastMode && + left.verboseLevel === right.verboseLevel && + left.traceLevel === right.traceLevel && + left.reasoningLevel === right.reasoningLevel && + left.model === right.model && + left.modelProvider === right.modelProvider && + left.contextTokens === right.contextTokens && + left.inputTokens === right.inputTokens && + left.outputTokens === right.outputTokens && + left.totalTokens === right.totalTokens && + left.responseUsage === right.responseUsage && + left.displayName === right.displayName && + goalEquals(left.goal, right.goal) + ); +} + export function createSessionActions(context: SessionActionContext) { const { client, @@ -225,10 +265,17 @@ export function createSessionActions(context: SessionActionContext) { next.model = selection.model; } + const previous = state.sessionInfo; + const uiChanged = !sessionInfoUiEquals(previous, next); + if (!uiChanged && previous.updatedAt === next.updatedAt) { + return; + } state.sessionInfo = next; - updateAutocompleteProvider(); - updateFooter(); - tui.requestRender(); + if (uiChanged) { + updateAutocompleteProvider(); + updateFooter(); + tui.requestRender(); + } }; const runRefreshSessionInfo = async () => { diff --git a/src/tui/tui.ts b/src/tui/tui.ts index f1a68ca600b..74b92f92fdb 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -29,7 +29,6 @@ import { import { getSlashCommands } from "./commands.js"; import { ChatLog } from "./components/chat-log.js"; import { CustomEditor } from "./components/custom-editor.js"; -import { GatewayChatClient } from "./gateway-chat.js"; import { resolveLocalRunShutdownGraceMs } from "./local-run-shutdown.js"; import { editorTheme, theme } from "./theme/theme.js"; import type { TuiBackend } from "./tui-backend.js"; @@ -522,6 +521,8 @@ export async function runTui(opts: RunTuiOptions): Promise { let dynamicSlashCommandsKey: string | null = null; let dynamicSlashCommandsInFlightKey: string | null = null; let dynamicSlashCommandsRequestId = 0; + let dynamicSlashCommandsReady = false; + let dynamicSlashCommandsRefreshTimer: ReturnType | null = null; let lastCtrlCAt = 0; let exitRequested = false; let exitResult: TuiResult = { exitReason: "exit" }; @@ -714,6 +715,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); client = new EmbeddedTuiBackend(); } else { + const { GatewayChatClient } = await import("./gateway-chat.js"); client = await GatewayChatClient.connect({ url: opts.url, token: opts.token, @@ -769,9 +771,19 @@ export async function runTui(opts: RunTuiOptions): Promise { ); }; + const clearDynamicSlashCommandsRefreshTimer = () => { + if (!dynamicSlashCommandsRefreshTimer) { + return; + } + clearTimeout(dynamicSlashCommandsRefreshTimer); + dynamicSlashCommandsRefreshTimer = null; + }; + const refreshDynamicSlashCommands = () => { + clearDynamicSlashCommandsRefreshTimer(); const key = resolveDynamicSlashCommandsKey(); if ( + !dynamicSlashCommandsReady || !isConnected || !client.listCommands || dynamicSlashCommandsKey === key || @@ -807,9 +819,21 @@ export async function runTui(opts: RunTuiOptions): Promise { }); }; + const scheduleDynamicSlashCommandsRefresh = () => { + if ( + !dynamicSlashCommandsReady || + dynamicSlashCommandsRefreshTimer || + dynamicSlashCommandsKey === resolveDynamicSlashCommandsKey() + ) { + return; + } + dynamicSlashCommandsRefreshTimer = setTimeout(refreshDynamicSlashCommands, 0); + dynamicSlashCommandsRefreshTimer.unref?.(); + }; + const updateAutocompleteProvider = () => { applyAutocompleteProvider(); - refreshDynamicSlashCommands(); + scheduleDynamicSlashCommandsRefresh(); }; tui.addChild(root); @@ -1474,6 +1498,8 @@ export async function runTui(opts: RunTuiOptions): Promise { 4000, ); tui.requestRender(); + dynamicSlashCommandsReady = true; + scheduleDynamicSlashCommandsRefresh(); if (!autoMessageSent && autoMessage) { autoMessageSent = true; await sendMessage(autoMessage); @@ -1494,6 +1520,8 @@ export async function runTui(opts: RunTuiOptions): Promise { dynamicSlashCommands = []; dynamicSlashCommandsKey = null; dynamicSlashCommandsInFlightKey = null; + dynamicSlashCommandsReady = false; + clearDynamicSlashCommandsRefreshTimer(); dynamicSlashCommandsRequestId += 1; updateAutocompleteProvider(); pauseStreamingWatchdog();