diff --git a/src/tui/providers/providers-reducer.test.ts b/src/tui/providers/providers-reducer.test.ts index 1f68c2dc..92dcd584 100644 --- a/src/tui/providers/providers-reducer.test.ts +++ b/src/tui/providers/providers-reducer.test.ts @@ -35,3 +35,89 @@ describe("reduceProvidersPanel", () => { expect(down.providersPanel.cursor).toBe(1); }); }); + +describe("a refresh that switches the active text route", () => { + const row = (overrides: Record) => ({ + id: "a", + kind: "openrouter", + isActiveText: true, + isActiveEmbedding: false, + hasApiKey: true, + chatModel: "openai/gpt-4o-mini", + embeddingModel: null, + ...overrides, + }); + + /** A state that has measured a prompt against a 32k window. */ + function measuredState() { + const base = createInitialTuiState({ + session: { id: "s1", workingDir: "/tmp" }, + }); + const withRows = reduceProvidersPanel(base, { + type: "providers_refresh", + rows: [row({})], + })!; + return { + ...withRows, + contextUsage: { + ...withRows.contextUsage, + tokens: 14_000, + contextWindow: 32_768, + }, + }; + } + + /** + * The window the last prompt was built against belongs to the model + * that built it. `resolveWindow` prefers it over every live source, so + * left standing it has the composer chip gauging the freshly chosen + * model against the old model's window until the next prompt build. + */ + it("drops the prompt-derived window when the chat model changes", () => { + const next = reduceProvidersPanel(measuredState(), { + type: "providers_refresh", + rows: [row({ chatModel: "anthropic/claude-sonnet-5" })], + })!; + expect(next.contextUsage.contextWindow).toBeNull(); + // Only the window is stale — the measured prompt size still stands. + expect(next.contextUsage.tokens).toBe(14_000); + }); + + it("drops it when a different provider takes over chat", () => { + const next = reduceProvidersPanel(measuredState(), { + type: "providers_refresh", + rows: [ + row({ isActiveText: false }), + row({ id: "b", chatModel: "openai/gpt-4o-mini" }), + ], + })!; + expect(next.contextUsage.contextWindow).toBeNull(); + }); + + it("keeps it across an ordinary refresh of the same route", () => { + const next = reduceProvidersPanel(measuredState(), { + type: "providers_refresh", + rows: [row({ hasApiKey: false })], + })!; + expect(next.contextUsage.contextWindow).toBe(32_768); + }); + + it("does not treat the first population of the rows as a switch", () => { + const base = { + ...createInitialTuiState({ session: { id: "s1", workingDir: "/tmp" } }), + }; + const seeded = { + ...base, + contextUsage: { + ...base.contextUsage, + tokens: 14_000, + contextWindow: 32_768, + }, + }; + const next = reduceProvidersPanel(seeded, { + type: "providers_refresh", + rows: [row({})], + })!; + expect(next.contextUsage.contextWindow).toBe(32_768); + }); +}); diff --git a/src/tui/providers/providers-reducer.ts b/src/tui/providers/providers-reducer.ts index 2cd94926..00062c7e 100644 Binary files a/src/tui/providers/providers-reducer.ts and b/src/tui/providers/providers-reducer.ts differ diff --git a/src/tui/select-context-usage.test.ts b/src/tui/select-context-usage.test.ts index 8b50671e..d2026e69 100644 --- a/src/tui/select-context-usage.test.ts +++ b/src/tui/select-context-usage.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ProviderRow } from "./providers/providers-panel-state.js"; -import { selectContextUsage } from "./select-context-usage.js"; +import { + selectComposerContextUsage, + selectContextUsage, +} from "./select-context-usage.js"; import { fakeSession } from "./test-fixtures.js"; import { createInitialTuiState, @@ -91,6 +94,67 @@ describe("selectContextUsage", () => { }); }); +describe("the composer chip follows the task-count draft", () => { + /** A session of three tasks, measured under a cap of 20. */ + function measuredUsage(): ContextUsageState { + return usage({ + tokens: 8_200, + contextWindow: 128_000, + conversationTokens: 900, + conversationPairs: 3, + conversationPairsCap: 20, + droppedPairs: 0, + pairCosts: [300, 280, 320], + sections: [ + { label: "prompt scaffold", tokens: 6_100 }, + { label: "conversation", tokens: 900 }, + ], + }); + } + + it("shows the measurement while no draft is in force", () => { + const state = stateWith(measuredUsage()); + expect(selectComposerContextUsage(state)).toEqual( + selectContextUsage(state), + ); + }); + + /** + * The point of the selector: stepping the dial in the panel moves the + * chip on the same render, not one prompt build later. + */ + it("reprojects at the draft the moment one exists", () => { + const state = stateWith(measuredUsage(), { contextPanelPairsDraft: 22 }); + const view = selectComposerContextUsage(state); + // Everything outside the transcript plus every task that exists — + // dialing past the session's real size adds nothing. + expect(view?.tokens).toBe(6_100 + 900); + expect(view?.pairs).toBe(3); + expect(view?.pairsCap).toBe(22); + expect(view?.droppedPairs).toBe(0); + }); + + it("prices a draft below the measured count", () => { + const state = stateWith(measuredUsage(), { contextPanelPairsDraft: 2 }); + const view = selectComposerContextUsage(state); + // The two newest tasks survive; the oldest is priced out. + expect(view?.conversationTokens).toBe(280 + 320); + expect(view?.pairs).toBe(2); + expect(view?.droppedPairs).toBe(1); + }); + + /** + * `prompt_built` retires the draft when reality catches up with it; + * until that dispatch lands a draft equal to the cap must already + * read as the measurement, or the chip would swap a real tokenizer + * count for an estimate on a no-op. + */ + it("keeps the measurement when the draft equals the cap", () => { + const state = stateWith(measuredUsage(), { contextPanelPairsDraft: 20 }); + expect(selectComposerContextUsage(state)?.tokens).toBe(8_200); + }); +}); + describe("which limit holds the transcript down", () => { it("names config when the configured cap is what binds", () => { expect(selectContextUsage(stateWith(usage()))?.capSource).toBe("config"); diff --git a/src/tui/select-context-usage.ts b/src/tui/select-context-usage.ts index ef5a8e5d..206ecbf9 100644 --- a/src/tui/select-context-usage.ts +++ b/src/tui/select-context-usage.ts @@ -250,3 +250,28 @@ export function selectContextUsage(state: TuiState): ContextUsageView | null { sections, }; } + +/** + * What the composer's chip renders: the measured view, reprojected at + * the operator's draft task count whenever one is in force. + * + * The detail panel has always projected the draft; the chip kept + * showing the last built prompt, so working the selector moved the + * panel's numbers while the bar under it sat still — and the one + * readout that survives closing the panel never said what was just + * chosen. Sharing the panel's own condition (`draft === pairsCap` + * means reality already caught up — see `prompt_built`, which retires + * the draft on exactly that match) keeps the two surfaces telling one + * story, and the draft outliving the panel is deliberate: the chip + * carries the chosen figure until a prompt is actually built against + * it. + */ +export function selectComposerContextUsage( + state: TuiState, +): ContextUsageView | null { + const measured = selectContextUsage(state); + if (measured === null) return null; + const draft = state.contextPanelPairsDraft; + if (draft === null || draft === measured.pairsCap) return measured; + return usageAtPairs(measured, draft); +} diff --git a/src/tui/tui-app.tsx b/src/tui/tui-app.tsx index b38cbbb4..60429b94 100644 --- a/src/tui/tui-app.tsx +++ b/src/tui/tui-app.tsx @@ -15,7 +15,10 @@ import { CodingModePopup } from "./components/coding-mode-popup.js"; import { OnboardingScreen } from "./components/onboarding-screen.js"; import { TerminalTooSmall } from "./components/terminal-too-small.js"; import { ContextPanel } from "./components/context-panel.js"; -import { selectContextUsage } from "./select-context-usage.js"; +import { + selectComposerContextUsage, + selectContextUsage, +} from "./select-context-usage.js"; import { Box, Text, useApp, useInput, type DOMElement, type Key } from "ink"; import type { HuggingFaceRepoChoices } from "../local-llm/index.js"; import { @@ -1637,8 +1640,13 @@ export function TuiApp({ dispatch({ type: "context_pairs_selected", pairs: next }); }, []); - const promptContextSlot = contextUsage ? ( - + // The chip follows the operator's draft task count the instant the + // selector moves; the panel keeps the measured view and projects the + // draft itself, so the two stay in step. See + // `selectComposerContextUsage`. + const composerContextUsage = selectComposerContextUsage(state); + const promptContextSlot = composerContextUsage ? ( + ) : null; // Always drawn, including in `default`. A control that appears only // once you are in an unusual mode is a control nobody discovers, and