Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/tui/providers/providers-reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
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);
});
});
Binary file modified src/tui/providers/providers-reducer.ts
Binary file not shown.
66 changes: 65 additions & 1 deletion src/tui/select-context-usage.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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");
Expand Down
25 changes: 25 additions & 0 deletions src/tui/select-context-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
14 changes: 11 additions & 3 deletions src/tui/tui-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1637,8 +1640,13 @@ export function TuiApp({
dispatch({ type: "context_pairs_selected", pairs: next });
}, []);

const promptContextSlot = contextUsage ? (
<ContextChip usage={contextUsage} layer={MOUSE_LAYER_PANEL} />
// 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 ? (
<ContextChip usage={composerContextUsage} layer={MOUSE_LAYER_PANEL} />
) : null;
// Always drawn, including in `default`. A control that appears only
// once you are in an unusual mode is a control nobody discovers, and
Expand Down
Loading