diff --git a/docs/TUI.md b/docs/TUI.md index 2b8213c00..af7429032 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -68,12 +68,21 @@ The prompt box's border carries the metadata that would otherwise cost a titlebar row: the model label sits right-aligned in the top rule as `profile · model · effort` (empty segments omitted), and a compact `mcp !` sits immediately left of it when any MCP server still needs -authorization (`/mcp` is the surface that names them); the brand +authorization (`/mcp` is the surface that names them), painted in +`UI.warning` (sand, `#d1ad7d`) — the same role `plugin !` uses. Orange is +not spent on these standing marks. The brand lockup sits at the left of the bottom rule with the working directory and git branch at its right (`AppShell.promptTopRule` / `promptBottomRule`, -`src/tui/shell.ts`). Both rules cost zero transcript rows because they +`src/tui/shell.ts`). Context occupancy rides that bottom rule as a percent: +0–60 `UI.textDim`, 61–80 `UI.warning`, 81–100 `UI.error`; an optional cost +suffix stays dim. Both rules cost zero transcript rows because they ride the prompt box's own border. +Inside the prompt, only a leading registered `/command` (the `/name` only) +and `@mention` tokens anywhere paint `UI.action`. Bare skill or agent words +(`implement`, `emil`, `brand review`) stay unstyled, as does a `/review` +that appears mid-prose. + While a turn is live the lockup slot swaps the wordmark for a semantic activity word — never the raw tool, MCP server, or plugin identifier that is actually executing. `resolveTurnLabel` (`src/tui/session-chrome.ts`) @@ -130,10 +139,13 @@ rather than repainting an unchanging frame. Color is a small, deliberate palette, not decoration (`src/tui/theme.ts`). Dimmed text is a dimmed cream, never a neutral gray, so every emphasis level keeps the same warm hue. Orange -(`UI.action`) is spent once per screen: it marks the session identity and -whatever is currently awaiting a human decision (an approval subject, an -active choice) — nothing else competes with it. Ongoing, non-decision status -uses the bronze/sand/ember chrome ramp and green (`UI.done`) for completion. +(`UI.action`) is spent once per screen: it marks the session identity, +a leading `/command` or `@mention` in the prompt, and whatever is currently +awaiting a human decision (an approval subject, an active choice) — nothing +else competes with it. Standing caution (`mcp !`, `plugin !`, the context +meter's 61–80 band) uses `UI.warning`; the meter turns `UI.error` at 81–100. +Ongoing, non-decision status uses the bronze/sand/ember chrome ramp and green +(`UI.done`) for completion. The one deliberate exception is diff removals, where orange is content (the removed line), not a decision marker, and no decision-marker shares that row. diff --git a/src/provider/context-window.ts b/src/provider/context-window.ts index 03e22b0b8..3e26ab05c 100644 --- a/src/provider/context-window.ts +++ b/src/provider/context-window.ts @@ -77,8 +77,21 @@ export function contextWindowFor(model: string): number { export const COMPACTION_WINDOW_FRACTION = 0.6; // Status-bar meter turns danger at this fraction of the window — past -// compaction and approaching hard overflow at 1.0. -export const CONTEXT_METER_DANGER_FRACTION = 0.9; +// compaction and approaching hard overflow at 1.0. Inclusive integer bands +// keep 80 in warning and start danger at 81. +export const CONTEXT_METER_DANGER_FRACTION = 0.8; + +export type ContextMeterBand = "quiet" | "warning" | "danger"; + +/** + * Map a 0–100 context-window percent onto the meter band. + * Inclusive: 0–60 quiet, 61–80 warning, 81–100 danger. + */ +export function contextMeterBand(percentUsed: number): ContextMeterBand { + if (percentUsed <= 60) return "quiet"; + if (percentUsed <= 80) return "warning"; + return "danger"; +} // Token threshold at which the director should compact, sized to the model's // real window. `model` may be undefined early in a session (no cycle yet); we diff --git a/src/tui/prompt-border.test.ts b/src/tui/prompt-border.test.ts index 4771810d6..2a870bf0a 100644 --- a/src/tui/prompt-border.test.ts +++ b/src/tui/prompt-border.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test" import { BORDER, - CONTEXT_PRESSURE_THRESHOLD, MCP_ATTENTION_LABEL, PLUGIN_ATTENTION_LABEL, abbreviateHome, @@ -236,18 +235,15 @@ describe("composeCostContextMeter", () => { expect(costContextText(meter, false)).toContain("68%") }) - test("turns pressured past the threshold, not before it", () => { - const thresholdPercent = CONTEXT_PRESSURE_THRESHOLD * 100 - const below = composeCostContextMeter({ - contextPercentUsed: thresholdPercent - 1, - contextIsEstimate: false, - })! - const atOrAbove = composeCostContextMeter({ - contextPercentUsed: thresholdPercent, - contextIsEstimate: false, - })! - expect(below.pressured).toBe(false) - expect(atOrAbove.pressured).toBe(true) + test("bands from the percent: 60 quiet, 80 warning, 81 danger", () => { + const bandAt = (percent: number) => + composeCostContextMeter({ contextPercentUsed: percent, contextIsEstimate: false })!.band + expect(bandAt(0)).toBe("quiet") + expect(bandAt(60)).toBe("quiet") + expect(bandAt(61)).toBe("warning") + expect(bandAt(80)).toBe("warning") + expect(bandAt(81)).toBe("danger") + expect(bandAt(100)).toBe("danger") }) test("flags an estimated percent with a tilde", () => { diff --git a/src/tui/prompt-border.ts b/src/tui/prompt-border.ts index 91d5c436d..bdadc2815 100644 --- a/src/tui/prompt-border.ts +++ b/src/tui/prompt-border.ts @@ -13,8 +13,11 @@ */ import { stringWidth } from "./view/height.js" -import { renderRamp } from "./ramp.js" import { formatContextPercentLabel } from "../cost/cost-summary.js" +import { + contextMeterBand, + type ContextMeterBand, +} from "../provider/context-window.js" /** Rounded box drawing, all single-cell. */ export const BORDER = { @@ -44,7 +47,7 @@ export type RuleInput = { /** Left-hand run (the lockup). Dropped first when the rule cannot seat everything. */ readonly brand?: string /** - * Cost/context run, richest form (context ramp + percent + cost). Sits + * Cost/context run, richest form (percent + cost). Sits * between the brand and the label. Dropped before the label but after the * brand: it is a live gauge, not the operator's own workspace. */ @@ -254,15 +257,6 @@ export function ruleWidth(parts: readonly RulePart[]): number { return widthOf(parts) } -/** - * Fraction of the context window at which the meter turns from its resting - * color to `UI.action`. Proactive compaction fires at `COMPACTION_WINDOW_FRACTION` - * (0.6, see `src/provider/context-window.ts`); this sits a good way below it so - * the operator sees pressure building — and can act on it — before compaction - * silently rewrites the conversation out from under them. - */ -export const CONTEXT_PRESSURE_THRESHOLD = 0.5 - export type CostContextInput = { /** 0–100, or null when the model's context window is unknown. */ readonly contextPercentUsed: number | null @@ -274,11 +268,10 @@ export type CostContextInput = { } export type CostContextMeter = { - /** Density-ramp glyphs, `RAMP_WIDTH` cells, fill proportional to `percent`. */ readonly percentLabel: string readonly costLabel: string | null - /** True once `percent` has crossed `CONTEXT_PRESSURE_THRESHOLD`. */ - readonly pressured: boolean + /** Inclusive band from `contextPercentUsed`: 0–60 quiet, 61–80 warning, 81–100 danger. */ + readonly band: ContextMeterBand } /** @@ -293,7 +286,7 @@ export function composeCostContextMeter(input: CostContextInput): CostContextMet return { percentLabel: formatContextPercentLabel(percent, input.contextIsEstimate), costLabel: cost.length > 0 ? cost : null, - pressured: percent / 100 >= CONTEXT_PRESSURE_THRESHOLD, + band: contextMeterBand(percent), } } diff --git a/src/tui/prompt-chrome.test.ts b/src/tui/prompt-chrome.test.ts index 1b9e0bd0c..ee939a49d 100644 --- a/src/tui/prompt-chrome.test.ts +++ b/src/tui/prompt-chrome.test.ts @@ -6,6 +6,7 @@ import { withTestRenderer } from "./harness" import { createAppShell, noticeText, + setPromptCostContext, setPromptModelLabel, setPromptWorkspace, setMcpNeedsAuth, @@ -15,6 +16,7 @@ import { setStatusFlash, submitPrompt, } from "./shell" +import { UI } from "./theme" async function withShell( fn: (shell: ReturnType) => void, @@ -323,3 +325,71 @@ describe("no permanent hint strip", () => { }) }) }) + +type RuleChunk = { readonly text: string; readonly fg: unknown } + +function ruleChunksOf(rule: { content: unknown }): RuleChunk[] { + const content = rule.content + if (typeof content !== "object" || content === null) return [] + const { chunks } = content as { chunks?: readonly { text?: string; fg?: unknown }[] } + return (chunks ?? []).map((c) => ({ text: c.text ?? "", fg: c.fg })) +} + +function fgHex(fg: unknown): string { + if (typeof fg === "string") return fg.toLowerCase() + if (fg && typeof fg === "object") { + const rec = fg as { hex?: string; toHex?: () => string; buffer?: ArrayLike } + if (typeof rec.hex === "string") return rec.hex.toLowerCase() + if (typeof rec.toHex === "function") return rec.toHex().toLowerCase() + if (rec.buffer !== undefined && rec.buffer.length >= 3) { + const r = rec.buffer[0] ?? 0 + const g = rec.buffer[1] ?? 0 + const b = rec.buffer[2] ?? 0 + return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}` + } + } + return "" +} + +function chunkMatching(chunks: readonly RuleChunk[], needle: string): RuleChunk | undefined { + return chunks.find((c) => c.text.includes(needle)) +} + +describe("chrome attention and meter colors", () => { + test("mcp ! and plugin ! paint in UI.warning", async () => { + await withShell((shell) => { + setPromptModelLabel(shell, { profile: "xai", model: "grok 4.6" }) + setMcpNeedsAuth(shell, ["granola"]) + setPluginNeedsAttention(shell, true) + const chunks = ruleChunksOf(shell.promptTopRule) + const mark = chunkMatching(chunks, "mcp !") + expect(mark).toBeDefined() + expect(fgHex(mark?.fg)).toBe(UI.warning) + }) + }) + + test("context percent 0–60 is textDim, 61–80 warning, 81–100 error; cost stays textDim", async () => { + await withShell((shell) => { + const paint = (percent: number) => { + setPromptCostContext(shell, { + contextPercentUsed: percent, + costLabel: "$0.42", + contextIsEstimate: false, + }) + return ruleChunksOf(shell.promptBottomRule) + } + + const quiet = paint(60) + expect(fgHex(chunkMatching(quiet, "60%")?.fg)).toBe(UI.textDim) + expect(fgHex(chunkMatching(quiet, "$0.42")?.fg)).toBe(UI.textDim) + + const warning = paint(80) + expect(fgHex(chunkMatching(warning, "80%")?.fg)).toBe(UI.warning) + expect(fgHex(chunkMatching(warning, "$0.42")?.fg)).toBe(UI.textDim) + + const danger = paint(81) + expect(fgHex(chunkMatching(danger, "81%")?.fg)).toBe(UI.error) + expect(fgHex(chunkMatching(danger, "$0.42")?.fg)).toBe(UI.textDim) + }) + }) +}) diff --git a/src/tui/prompt-highlight.test.ts b/src/tui/prompt-highlight.test.ts index 535ad8a21..34155da9a 100644 --- a/src/tui/prompt-highlight.test.ts +++ b/src/tui/prompt-highlight.test.ts @@ -1,7 +1,6 @@ /** - * End-to-end: a recognized skill/agent name typed into the real prompt - * widget paints orange; a lookalike that merely contains a recognized name - * does not. + * End-to-end: a leading `/command` or `@mention` typed into the real prompt + * widget paints orange; bare skill/agent words and mid-prose slashes do not. */ import { describe, expect, test } from "bun:test" import { RGBA } from "@opentui/core" @@ -26,8 +25,7 @@ function withShell( run: "idle", }) setPromptRecognitionSource(shell, () => ({ - skillNames: ["brand review"], - agentNames: ["emil", "draper"], + commandNames: ["implement", "review", "improve", "linear-create"], })) try { await fn(shell, h) @@ -55,40 +53,50 @@ function spansFor(h: Harness, text: string): { text: string; fg: RGBA }[] { } describe("prompt recognition highlighting", () => { - test("a recognized agent name paints in the action color", async () => { + test("a leading slash command paints in the action color", async () => { await withShell(async (shell, h) => { - await compose(shell, h, "ask emil to review") - const spans = spansFor(h, "emil") + await compose(shell, h, "/implement") + const spans = spansFor(h, "/implement") expect(spans.length).toBeGreaterThan(0) expect(spans.some((s) => s.fg.equals(ACTION_FG))).toBe(true) }) }) - test("a recognized multi-word skill name paints in the action color", async () => { + test("an @mention paints in the action color", async () => { await withShell(async (shell, h) => { - await compose(shell, h, "ask draper to run a brand review") - const spans = spansFor(h, "brand review") + await compose(shell, h, "ask @emil to review") + const spans = spansFor(h, "@emil") expect(spans.length).toBeGreaterThan(0) expect(spans.some((s) => s.fg.equals(ACTION_FG))).toBe(true) }) }) - test("a lookalike that is not a recognized name stays unstyled", async () => { + test("bare words stay unstyled", async () => { await withShell(async (shell, h) => { - await compose(shell, h, "emily is not emil") - const spans = spansFor(h, "emily") + for (const word of ["emil", "implement", "brand review", "improve", "linear-create"]) { + await compose(shell, h, word) + const spans = spansFor(h, word) + expect(spans.length).toBeGreaterThan(0) + expect(spans.every((s) => !s.fg.equals(ACTION_FG))).toBe(true) + } + }) + }) + + test("a mid-prose slash command stays unstyled", async () => { + await withShell(async (shell, h) => { + await compose(shell, h, "please /review this") + const spans = spansFor(h, "/review") expect(spans.length).toBeGreaterThan(0) expect(spans.every((s) => !s.fg.equals(ACTION_FG))).toBe(true) }) }) - test("a mixed line highlights only the recognized tokens", async () => { + test("a lookalike that is not a mention stays unstyled", async () => { await withShell(async (shell, h) => { - await compose(shell, h, "emily asked emil and draper for a brand review") - expect(spansFor(h, "emily").every((s) => !s.fg.equals(ACTION_FG))).toBe(true) - expect(spansFor(h, "emil").some((s) => s.fg.equals(ACTION_FG))).toBe(true) - expect(spansFor(h, "draper").some((s) => s.fg.equals(ACTION_FG))).toBe(true) - expect(spansFor(h, "brand review").some((s) => s.fg.equals(ACTION_FG))).toBe(true) + await compose(shell, h, "emily is not emil") + const spans = spansFor(h, "emily") + expect(spans.length).toBeGreaterThan(0) + expect(spans.every((s) => !s.fg.equals(ACTION_FG))).toBe(true) }) }) }) diff --git a/src/tui/prompt-recognition.test.ts b/src/tui/prompt-recognition.test.ts index 3e2033864..f2027e23f 100644 --- a/src/tui/prompt-recognition.test.ts +++ b/src/tui/prompt-recognition.test.ts @@ -6,53 +6,80 @@ import { type PromptRecognitionSource, } from "./prompt-recognition" +const COMMANDS = ["implement", "review", "improve", "linear-create"] as const + describe("buildPromptRecognitionMatcher", () => { test("returns null for an empty name set", () => { expect(buildPromptRecognitionMatcher([])).toBeNull() }) - test("matches a known single-word name as a whole word", () => { - const matcher = buildPromptRecognitionMatcher(["emil"]) - expect(resolvePromptHighlightSpans("ask emil to review", matcher)).toEqual([ - { start: 4, end: 8 }, + test("a leading registered slash command paints /name only", () => { + const matcher = buildPromptRecognitionMatcher(["implement", "review"]) + expect(resolvePromptHighlightSpans("/implement now", matcher)).toEqual([ + { start: 0, end: 10 }, + ]) + }) + + test("does not paint arguments after the command name", () => { + const matcher = buildPromptRecognitionMatcher(["implement"]) + expect(resolvePromptHighlightSpans("/implement the thing", matcher)).toEqual([ + { start: 0, end: 10 }, + ]) + }) + + test("bare words stay unstyled even when they match a registered name", () => { + const matcher = buildPromptRecognitionMatcher([...COMMANDS]) + for (const text of ["emil", "implement", "brand review", "improve", "linear-create"]) { + expect(resolvePromptHighlightSpans(text, matcher)).toEqual([]) + } + }) + + test("a mid-prose slash command stays unstyled", () => { + const matcher = buildPromptRecognitionMatcher(["review", "implement"]) + expect(resolvePromptHighlightSpans("please /review this", matcher)).toEqual([]) + }) + + test("an @mention paints anywhere", () => { + const matcher = buildPromptRecognitionMatcher(["implement"]) + expect(resolvePromptHighlightSpans("ask @emil to review", matcher)).toEqual([ + { start: 4, end: 9 }, ]) }) - test("does not match a lookalike that only contains the name", () => { - const matcher = buildPromptRecognitionMatcher(["emil"]) - expect(resolvePromptHighlightSpans("emily said hi", matcher)).toEqual([]) + test("mentions paint even when no commands are registered", () => { + expect(resolvePromptHighlightSpans("@emil", null)).toEqual([{ start: 0, end: 5 }]) }) - test("matches a multi-word skill name as a whole phrase", () => { - const matcher = buildPromptRecognitionMatcher(["brand review"]) - expect( - resolvePromptHighlightSpans("ask draper to run a brand review", matcher), - ).toEqual([{ start: 20, end: 32 }]) + test("a quoted @mention paints the quoted token", () => { + expect(resolvePromptHighlightSpans('see @"brand review" please', null)).toEqual([ + { start: 4, end: 19 }, + ]) }) - test("longer names win over a shorter name that is their prefix", () => { - const matcher = buildPromptRecognitionMatcher(["brand", "brand review"]) - const spans = resolvePromptHighlightSpans("run brand review now", matcher) - expect(spans).toEqual([{ start: 4, end: 16 }]) + test("a leading command and a mention both paint", () => { + const matcher = buildPromptRecognitionMatcher(["implement"]) + expect(resolvePromptHighlightSpans("/implement @emil", matcher)).toEqual([ + { start: 0, end: 10 }, + { start: 11, end: 16 }, + ]) }) - test("matches every recognized token in a mixed line", () => { - const matcher = buildPromptRecognitionMatcher(["emil", "draper", "brand review"]) - const spans = resolvePromptHighlightSpans( - "ask emil and draper to run a brand review", - matcher, - ) - expect(spans).toEqual([ - { start: 4, end: 8 }, - { start: 13, end: 19 }, - { start: 29, end: 41 }, + test("longer command names win over a shorter prefix", () => { + const matcher = buildPromptRecognitionMatcher(["brand", "brand-review"]) + expect(resolvePromptHighlightSpans("/brand-review now", matcher)).toEqual([ + { start: 0, end: 13 }, ]) }) - test("matching is case-insensitive", () => { - const matcher = buildPromptRecognitionMatcher(["emil"]) - expect(resolvePromptHighlightSpans("EMIL, please look", matcher)).toEqual([ - { start: 0, end: 4 }, + test("a lookalike command prefix does not match", () => { + const matcher = buildPromptRecognitionMatcher(["implement"]) + expect(resolvePromptHighlightSpans("/implements", matcher)).toEqual([]) + }) + + test("slash matching is case-insensitive", () => { + const matcher = buildPromptRecognitionMatcher(["implement"]) + expect(resolvePromptHighlightSpans("/IMPLEMENT", matcher)).toEqual([ + { start: 0, end: 10 }, ]) }) }) @@ -60,8 +87,7 @@ describe("buildPromptRecognitionMatcher", () => { describe("resolvePromptRecognitionMatcher", () => { test("caches the matcher while the name set is unchanged", () => { const source: PromptRecognitionSource = () => ({ - skillNames: ["brand review"], - agentNames: ["emil"], + commandNames: ["implement"], }) const first = resolvePromptRecognitionMatcher(source) const second = resolvePromptRecognitionMatcher(source) @@ -69,17 +95,15 @@ describe("resolvePromptRecognitionMatcher", () => { }) test("rebuilds the matcher when the name set changes", () => { - let names = ["emil"] + let names = ["implement"] const source: PromptRecognitionSource = () => ({ - skillNames: [], - agentNames: names, + commandNames: names, }) const first = resolvePromptRecognitionMatcher(source) - names = ["emil", "draper"] + names = ["implement", "review"] const second = resolvePromptRecognitionMatcher(source) expect(second).not.toBe(first) - expect(resolvePromptHighlightSpans("ask draper", second)).toEqual([ - { start: 4, end: 10 }, - ]) + expect(resolvePromptHighlightSpans("/review", second)).toEqual([{ start: 0, end: 7 }]) + expect(resolvePromptHighlightSpans("review", second)).toEqual([]) }) }) diff --git a/src/tui/prompt-recognition.ts b/src/tui/prompt-recognition.ts index 6396c3cff..b4ba1b92b 100644 Binary files a/src/tui/prompt-recognition.ts and b/src/tui/prompt-recognition.ts differ diff --git a/src/tui/ramp-paint.test.ts b/src/tui/ramp-paint.test.ts index 505d8cf73..aa54eae8b 100644 --- a/src/tui/ramp-paint.test.ts +++ b/src/tui/ramp-paint.test.ts @@ -280,7 +280,7 @@ describe("palette", () => { test("chrome stays below the action orange so orange still reads as an event", () => { const action = saturation(UI.action) - for (const hex of [UI.inFlight, UI.inFlightBright, UI.heading]) { + for (const hex of [UI.inFlight, UI.inFlightBright, UI.heading, UI.warning]) { expect(saturation(hex)).toBeLessThan(action) } }) diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 9bffe0a9a..42d1d470b 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -2421,13 +2421,9 @@ export async function runTUI(initialConfig: Config): Promise { const fleetStallPoll = setInterval(reportFleet, FLEET_STALL_POLL_MS); if (typeof fleetStallPoll.unref === "function") fleetStallPoll.unref(); - // Same names the operator can already reach by typing them: skills the - // session discovered at startup, agents from the live profile registry - // (which trust changes can update mid-session, so read through the - // closure rather than snapshotting it here). + // Registered slash-command names only — bare skill/agent words stay unstyled. setPromptRecognitionSource(host.shell, () => ({ - skillNames: skills.map((skill) => skill.name), - agentNames: liveAgentProfiles.map((profile) => profile.id), + commandNames: listCommands().map((command) => command.name), })); // Shift+Tab: cycle reasoning effort for the live model and rebuild sources so diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 285579e68..89c6360ad 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -81,7 +81,6 @@ import { type CostContextMeter, type RulePart, } from "./prompt-border.js" -import { RAMP_WIDTH } from "./ramp.js" import { viewToTableContent, type McpStructuredView, @@ -383,7 +382,7 @@ export function setMentionSuggestionSource( else shellMentionSource.delete(shell) } -/** Names the prompt is allowed to highlight as recognized skills/agents. */ +/** Names the prompt is allowed to highlight as leading `/command` tokens. */ const shellRecognitionSource = new WeakMap() export function setPromptRecognitionSource( @@ -1574,7 +1573,7 @@ function ruleChunks(shell: AppShell, parts: readonly RulePart[]): TextChunk[] { continue } if (part.role === "attention") { - chunks.push(fgChunk(UI.action)(part.text)) + chunks.push(fgChunk(UI.warning)(part.text)) continue } chunks.push( @@ -1585,22 +1584,24 @@ function ruleChunks(shell: AppShell, parts: readonly RulePart[]): TextChunk[] { } /** - * Color a meter cell: the ramp glyphs are always orange, so pressure reads as - * *intensity* rather than a hue swap — `actionDim` while quiet, so the run - * sits as chrome and leaves `action` free for the one thing awaiting a - * decision, and only the full `action` orange once past - * `CONTEXT_PRESSURE_THRESHOLD`, when the meter itself becomes worth noticing. - * The percent and cost read as dim chrome the same as the workspace label. + * Color a meter cell: the percent takes the band color (quiet `textDim`, + * warning sand, danger red) and the optional cost suffix stays dim chrome. */ function meterChunks(shell: AppShell, cell: string): TextChunk[] { - const ramp = cell.slice(1, 1 + RAMP_WIDTH) - const rest = cell.slice(1 + RAMP_WIDTH) - const rampFg = shell.costContext?.pressured === true ? UI.action : UI.actionDim - return [ - fgChunk(UI.textFaint)(" "), - fgChunk(rampFg)(ramp), - fgChunk(UI.textDim)(rest), - ] + const meter = shell.costContext + const percentFg = + meter?.band === "danger" ? UI.error : meter?.band === "warning" ? UI.warning : UI.textDim + if (meter === null) return [fgChunk(percentFg)(cell)] + const percent = meter.percentLabel + const idx = cell.indexOf(percent) + if (idx === -1) return [fgChunk(percentFg)(cell)] + const before = cell.slice(0, idx) + const after = cell.slice(idx + percent.length) + const chunks: TextChunk[] = [] + if (before.length > 0) chunks.push(fgChunk(UI.textFaint)(before)) + chunks.push(fgChunk(percentFg)(percent)) + if (after.length > 0) chunks.push(fgChunk(UI.textDim)(after)) + return chunks } /** The status slot's state, as the lockup renderer wants it. */ @@ -1916,7 +1917,7 @@ function promptRecognizedStyleId(): number { const promptHighlightedValue = new WeakMap() /** - * Re-mark recognized skill/agent tokens in the prompt. Runs once per frame + * Re-mark leading slash commands and @mentions in the prompt. Runs once per frame * (see `onFrame` in `createShell`), and only does anything when the prompt's * text actually changed since the last frame — typing that doesn't touch a * token, and every non-typing frame, is a no-op string comparison. diff --git a/src/tui/theme.ts b/src/tui/theme.ts index 0e578ad90..7ad7200b9 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -48,6 +48,10 @@ export type Theme = { readonly heading: string /** Completed and succeeded. */ readonly done: string + /** Standing caution: attention marks, meter warning band. */ + readonly warning: string + /** Failure and the meter danger band. */ + readonly error: string } /** @@ -76,8 +80,9 @@ const CREAM_FAINT = "#787166" // separable — they differ in lightness first, hue second, and all three sit // well under the action orange's saturation. const BRONZE = "#93733f" // dimmest: motion and machine chrome -const SAND = "#d1ad7d" // brightest: keywords, links, command arguments +const SAND = "#d1ad7d" // brightest: keywords, links, args, and standing caution const EMBER = "#a97243" // burnt, between the two: document structure +const ERROR_RED = "#e0594d" // meter danger band, failures export const corbitsDark: Theme = { name: "corbits-dark", @@ -91,6 +96,8 @@ export const corbitsDark: Theme = { inFlightBright: SAND, heading: EMBER, done: BRAND.ridgeGreen, + warning: SAND, + error: ERROR_RED, } /** Every theme that ships. A picker would choose from here. */ diff --git a/tests/unit/context-window.test.ts b/tests/unit/context-window.test.ts index faf0423f7..0aa5be5b2 100644 --- a/tests/unit/context-window.test.ts +++ b/tests/unit/context-window.test.ts @@ -4,6 +4,7 @@ import { contextWindowFor, compactionThresholdFor, contextTokensFromUsage, + contextMeterBand, COMPACTION_WINDOW_FRACTION, CONTEXT_METER_DANGER_FRACTION, setModelContextWindows, @@ -66,6 +67,17 @@ describe("context meter fractions", () => { test("danger sits between compaction and hard overflow", () => { expect(CONTEXT_METER_DANGER_FRACTION).toBeGreaterThan(COMPACTION_WINDOW_FRACTION); expect(CONTEXT_METER_DANGER_FRACTION).toBeLessThan(1); - expect(CONTEXT_METER_DANGER_FRACTION).toBe(0.9); + expect(CONTEXT_METER_DANGER_FRACTION).toBe(0.8); + }); +}); + +describe("contextMeterBand", () => { + test("0–60 is quiet, 61–80 warning, 81–100 danger", () => { + expect(contextMeterBand(0)).toBe("quiet"); + expect(contextMeterBand(60)).toBe("quiet"); + expect(contextMeterBand(61)).toBe("warning"); + expect(contextMeterBand(80)).toBe("warning"); + expect(contextMeterBand(81)).toBe("danger"); + expect(contextMeterBand(100)).toBe("danger"); }); });