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
24 changes: 18 additions & 6 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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.

Expand Down
17 changes: 15 additions & 2 deletions src/provider/context-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 9 additions & 13 deletions src/tui/prompt-border.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"

import {
BORDER,
CONTEXT_PRESSURE_THRESHOLD,
MCP_ATTENTION_LABEL,
PLUGIN_ATTENTION_LABEL,
abbreviateHome,
Expand Down Expand Up @@ -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", () => {
Expand Down
23 changes: 8 additions & 15 deletions src/tui/prompt-border.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand All @@ -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
}

/**
Expand All @@ -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),
}
}

Expand Down
70 changes: 70 additions & 0 deletions src/tui/prompt-chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { withTestRenderer } from "./harness"
import {
createAppShell,
noticeText,
setPromptCostContext,
setPromptModelLabel,
setPromptWorkspace,
setMcpNeedsAuth,
Expand All @@ -15,6 +16,7 @@ import {
setStatusFlash,
submitPrompt,
} from "./shell"
import { UI } from "./theme"

async function withShell(
fn: (shell: ReturnType<typeof createAppShell>) => void,
Expand Down Expand Up @@ -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<number> }
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)
})
})
})
48 changes: 28 additions & 20 deletions src/tui/prompt-highlight.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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)
})
})
})
Loading
Loading