From 3a87513c645dcd2a9e3323f7922ee0c1b3e1d7ed Mon Sep 17 00:00:00 2001 From: plombeer31 Date: Wed, 2 Sep 2026 18:42:01 +0300 Subject: [PATCH] tui: the composer context chip recalculates live Two gaps between the context panel and the chip under it: - Working the panel's task selector reprojected every figure inside the panel and left the composer chip on the last built prompt, so the one readout that survives closing the panel never showed the number just chosen. The chip now renders the same projection the panel does (selectComposerContextUsage): stepping the dial moves the minibar gauge, token pair and task count on the same render, and the draft keeps driving the chip until a prompt is actually built against it. - Switching the active chat model (or provider) left the chip gauging against the window the *previous* model's prompt was built with, because resolveWindow prefers the prompt-derived window over every live source. A providers_refresh that changes the active text route now drops that stale window, so the chip re-gauges from the health poller / catalogue for the newly chosen model immediately instead of one prompt build later. --- src/tui/providers/providers-reducer.test.ts | 86 ++++++++++++++++++++ src/tui/providers/providers-reducer.ts | Bin 9806 -> 11317 bytes src/tui/select-context-usage.test.ts | 66 ++++++++++++++- src/tui/select-context-usage.ts | 25 ++++++ src/tui/tui-app.tsx | 14 +++- 5 files changed, 187 insertions(+), 4 deletions(-) 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 2cd94926bb19c2126e6a407979321289c521e717..00062c7e8540f3fe7b7b916c2932fa5afb1c158a 100644 GIT binary patch delta 1530 zcmZux!EW0|5N*<%S_C{YY;}yArV#TBuQ7@Syzlr2s6PdIng`X`2jx%a2 zy{M=j(D2SLISA0AQgK4c=G5v~4Rx0;XOq!00LRu*t$YkXYqjliAbJkYG*LTCm9k~R zaYaFY|MokofN0CqpMl68lPs0dxr#i12*xF=(0I<;Dt6l zy=VNUi%*5!F^Gj322r`Q=4w}_m#@Y%3ejs@(ywG2W9Ti^TsRxzMy9z46S6|v{8*sI zG(VG9nGO$=kmwZ0r}#`4bbQcvAERA7YrIXMa)@Vnym;`ougG-1`egec%Kj)q()l?Z z9=?V~d-ic6JAGY)G0wa#2fGY!nD!gO_zg@BR-+tqSzI z(FQoB(n9hz8;7k>bm_a=^HRGe(6H6V9TyQx*#)VMTJVG}(q@UzqEFo{UM8 zpgLk-QJVy8A<`a5nk@~JPbl==7ns+qLT~Ez2Tp;@FVr)1FVfdjh_$G+; zM2EulR%t6|7-%)f#+G@HOn{To4f_{c2evtWXY|Osx|X*cSiqN1lwoV>lx8cG*&8KR zaAH|)WHgem;2OVxjgKo<263sKG3+O_P*E9IHawP|yM#P{n@AH}EUlL8yDKSH9X32R_Q(o{2tgadsCVjT~>XnlVWn-Bx=^{mi_Tnj5 zH%6tL4Y!G;gLEM4;3^u%+9LE(N-_ODWj&|)_K?{V1{i8mCoopIy%~xoYPI7 zhdAGSGp2+2{6FWCFJ}|z_;h`J`?Sll49Ue+FpRK87nbKK@&n=FAEuUsJrAnn+5emq z0Ww6~QrT|ljc_+`IR8qp`k2hpeXyxp|9S7rulv%~-c0DZ8cWss;O>L { }); }); +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