Skip to content

Commit e01ec09

Browse files
committed
Reserve prompt orange for slash commands and mentions
1 parent ef4a7a8 commit e01ec09

13 files changed

Lines changed: 254 additions & 122 deletions

docs/TUI.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,21 @@ The prompt box's border carries the metadata that would otherwise cost a
6868
titlebar row: the model label sits right-aligned in the top rule as
6969
`profile · model · effort` (empty segments omitted), and a
7070
compact `mcp !` sits immediately left of it when any MCP server still needs
71-
authorization (`/mcp` is the surface that names them); the brand
71+
authorization (`/mcp` is the surface that names them), painted in
72+
`UI.warning` (sand, `#d1ad7d`) — the same role `plugin !` uses. Orange is
73+
not spent on these standing marks. The brand
7274
lockup sits at the left of the bottom rule with the working directory and git
7375
branch at its right (`AppShell.promptTopRule` / `promptBottomRule`,
74-
`src/tui/shell.ts`). Both rules cost zero transcript rows because they
76+
`src/tui/shell.ts`). Context occupancy rides that bottom rule as a percent:
77+
0–60 `UI.textDim`, 61–80 `UI.warning`, 81–100 `UI.error`; an optional cost
78+
suffix stays dim. Both rules cost zero transcript rows because they
7579
ride the prompt box's own border.
7680

81+
Inside the prompt, only a leading registered `/command` (the `/name` only)
82+
and `@mention` tokens anywhere paint `UI.action`. Bare skill or agent words
83+
(`implement`, `emil`, `brand review`) stay unstyled, as does a `/review`
84+
that appears mid-prose.
85+
7786
While a turn is live the lockup slot swaps the wordmark for a semantic
7887
activity word — never the raw tool, MCP server, or plugin identifier that is
7988
actually executing. `resolveTurnLabel` (`src/tui/session-chrome.ts`)
@@ -130,10 +139,13 @@ rather than repainting an unchanging frame.
130139
Color is a small, deliberate palette, not decoration
131140
(`src/tui/theme.ts`). Dimmed text is a dimmed cream, never a neutral
132141
gray, so every emphasis level keeps the same warm hue. Orange
133-
(`UI.action`) is spent once per screen: it marks the session identity and
134-
whatever is currently awaiting a human decision (an approval subject, an
135-
active choice) — nothing else competes with it. Ongoing, non-decision status
136-
uses the bronze/sand/ember chrome ramp and green (`UI.done`) for completion.
142+
(`UI.action`) is spent once per screen: it marks the session identity,
143+
a leading `/command` or `@mention` in the prompt, and whatever is currently
144+
awaiting a human decision (an approval subject, an active choice) — nothing
145+
else competes with it. Standing caution (`mcp !`, `plugin !`, the context
146+
meter's 61–80 band) uses `UI.warning`; the meter turns `UI.error` at 81–100.
147+
Ongoing, non-decision status uses the bronze/sand/ember chrome ramp and green
148+
(`UI.done`) for completion.
137149
The one deliberate exception is diff removals, where orange is content (the
138150
removed line), not a decision marker, and no decision-marker shares that row.
139151

src/provider/context-window.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,21 @@ export function contextWindowFor(model: string): number {
7777
export const COMPACTION_WINDOW_FRACTION = 0.6;
7878

7979
// Status-bar meter turns danger at this fraction of the window — past
80-
// compaction and approaching hard overflow at 1.0.
81-
export const CONTEXT_METER_DANGER_FRACTION = 0.9;
80+
// compaction and approaching hard overflow at 1.0. Inclusive integer bands
81+
// keep 80 in warning and start danger at 81.
82+
export const CONTEXT_METER_DANGER_FRACTION = 0.8;
83+
84+
export type ContextMeterBand = "quiet" | "warning" | "danger";
85+
86+
/**
87+
* Map a 0–100 context-window percent onto the meter band.
88+
* Inclusive: 0–60 quiet, 61–80 warning, 81–100 danger.
89+
*/
90+
export function contextMeterBand(percentUsed: number): ContextMeterBand {
91+
if (percentUsed <= 60) return "quiet";
92+
if (percentUsed <= 80) return "warning";
93+
return "danger";
94+
}
8295

8396
// Token threshold at which the director should compact, sized to the model's
8497
// real window. `model` may be undefined early in a session (no cycle yet); we

src/tui/prompt-border.test.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
22

33
import {
44
BORDER,
5-
CONTEXT_PRESSURE_THRESHOLD,
65
MCP_ATTENTION_LABEL,
76
PLUGIN_ATTENTION_LABEL,
87
abbreviateHome,
@@ -236,18 +235,15 @@ describe("composeCostContextMeter", () => {
236235
expect(costContextText(meter, false)).toContain("68%")
237236
})
238237

239-
test("turns pressured past the threshold, not before it", () => {
240-
const thresholdPercent = CONTEXT_PRESSURE_THRESHOLD * 100
241-
const below = composeCostContextMeter({
242-
contextPercentUsed: thresholdPercent - 1,
243-
contextIsEstimate: false,
244-
})!
245-
const atOrAbove = composeCostContextMeter({
246-
contextPercentUsed: thresholdPercent,
247-
contextIsEstimate: false,
248-
})!
249-
expect(below.pressured).toBe(false)
250-
expect(atOrAbove.pressured).toBe(true)
238+
test("bands from the percent: 60 quiet, 80 warning, 81 danger", () => {
239+
const bandAt = (percent: number) =>
240+
composeCostContextMeter({ contextPercentUsed: percent, contextIsEstimate: false })!.band
241+
expect(bandAt(0)).toBe("quiet")
242+
expect(bandAt(60)).toBe("quiet")
243+
expect(bandAt(61)).toBe("warning")
244+
expect(bandAt(80)).toBe("warning")
245+
expect(bandAt(81)).toBe("danger")
246+
expect(bandAt(100)).toBe("danger")
251247
})
252248

253249
test("flags an estimated percent with a tilde", () => {

src/tui/prompt-border.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
*/
1414

1515
import { stringWidth } from "./view/height.js"
16-
import { renderRamp } from "./ramp.js"
1716
import { formatContextPercentLabel } from "../cost/cost-summary.js"
17+
import {
18+
contextMeterBand,
19+
type ContextMeterBand,
20+
} from "../provider/context-window.js"
1821

1922
/** Rounded box drawing, all single-cell. */
2023
export const BORDER = {
@@ -44,7 +47,7 @@ export type RuleInput = {
4447
/** Left-hand run (the lockup). Dropped first when the rule cannot seat everything. */
4548
readonly brand?: string
4649
/**
47-
* Cost/context run, richest form (context ramp + percent + cost). Sits
50+
* Cost/context run, richest form (percent + cost). Sits
4851
* between the brand and the label. Dropped before the label but after the
4952
* brand: it is a live gauge, not the operator's own workspace.
5053
*/
@@ -254,15 +257,6 @@ export function ruleWidth(parts: readonly RulePart[]): number {
254257
return widthOf(parts)
255258
}
256259

257-
/**
258-
* Fraction of the context window at which the meter turns from its resting
259-
* color to `UI.action`. Proactive compaction fires at `COMPACTION_WINDOW_FRACTION`
260-
* (0.6, see `src/provider/context-window.ts`); this sits a good way below it so
261-
* the operator sees pressure building — and can act on it — before compaction
262-
* silently rewrites the conversation out from under them.
263-
*/
264-
export const CONTEXT_PRESSURE_THRESHOLD = 0.5
265-
266260
export type CostContextInput = {
267261
/** 0–100, or null when the model's context window is unknown. */
268262
readonly contextPercentUsed: number | null
@@ -274,11 +268,10 @@ export type CostContextInput = {
274268
}
275269

276270
export type CostContextMeter = {
277-
/** Density-ramp glyphs, `RAMP_WIDTH` cells, fill proportional to `percent`. */
278271
readonly percentLabel: string
279272
readonly costLabel: string | null
280-
/** True once `percent` has crossed `CONTEXT_PRESSURE_THRESHOLD`. */
281-
readonly pressured: boolean
273+
/** Inclusive band from `contextPercentUsed`: 0–60 quiet, 61–80 warning, 81–100 danger. */
274+
readonly band: ContextMeterBand
282275
}
283276

284277
/**
@@ -293,7 +286,7 @@ export function composeCostContextMeter(input: CostContextInput): CostContextMet
293286
return {
294287
percentLabel: formatContextPercentLabel(percent, input.contextIsEstimate),
295288
costLabel: cost.length > 0 ? cost : null,
296-
pressured: percent / 100 >= CONTEXT_PRESSURE_THRESHOLD,
289+
band: contextMeterBand(percent),
297290
}
298291
}
299292

src/tui/prompt-chrome.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { withTestRenderer } from "./harness"
66
import {
77
createAppShell,
88
noticeText,
9+
setPromptCostContext,
910
setPromptModelLabel,
1011
setPromptWorkspace,
1112
setMcpNeedsAuth,
@@ -15,6 +16,7 @@ import {
1516
setStatusFlash,
1617
submitPrompt,
1718
} from "./shell"
19+
import { UI } from "./theme"
1820

1921
async function withShell(
2022
fn: (shell: ReturnType<typeof createAppShell>) => void,
@@ -323,3 +325,71 @@ describe("no permanent hint strip", () => {
323325
})
324326
})
325327
})
328+
329+
type RuleChunk = { readonly text: string; readonly fg: unknown }
330+
331+
function ruleChunksOf(rule: { content: unknown }): RuleChunk[] {
332+
const content = rule.content
333+
if (typeof content !== "object" || content === null) return []
334+
const { chunks } = content as { chunks?: readonly { text?: string; fg?: unknown }[] }
335+
return (chunks ?? []).map((c) => ({ text: c.text ?? "", fg: c.fg }))
336+
}
337+
338+
function fgHex(fg: unknown): string {
339+
if (typeof fg === "string") return fg.toLowerCase()
340+
if (fg && typeof fg === "object") {
341+
const rec = fg as { hex?: string; toHex?: () => string; buffer?: ArrayLike<number> }
342+
if (typeof rec.hex === "string") return rec.hex.toLowerCase()
343+
if (typeof rec.toHex === "function") return rec.toHex().toLowerCase()
344+
if (rec.buffer !== undefined && rec.buffer.length >= 3) {
345+
const r = rec.buffer[0] ?? 0
346+
const g = rec.buffer[1] ?? 0
347+
const b = rec.buffer[2] ?? 0
348+
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`
349+
}
350+
}
351+
return ""
352+
}
353+
354+
function chunkMatching(chunks: readonly RuleChunk[], needle: string): RuleChunk | undefined {
355+
return chunks.find((c) => c.text.includes(needle))
356+
}
357+
358+
describe("chrome attention and meter colors", () => {
359+
test("mcp ! and plugin ! paint in UI.warning", async () => {
360+
await withShell((shell) => {
361+
setPromptModelLabel(shell, { profile: "xai", model: "grok 4.6" })
362+
setMcpNeedsAuth(shell, ["granola"])
363+
setPluginNeedsAttention(shell, true)
364+
const chunks = ruleChunksOf(shell.promptTopRule)
365+
const mark = chunkMatching(chunks, "mcp !")
366+
expect(mark).toBeDefined()
367+
expect(fgHex(mark?.fg)).toBe(UI.warning)
368+
})
369+
})
370+
371+
test("context percent 0–60 is textDim, 61–80 warning, 81–100 error; cost stays textDim", async () => {
372+
await withShell((shell) => {
373+
const paint = (percent: number) => {
374+
setPromptCostContext(shell, {
375+
contextPercentUsed: percent,
376+
costLabel: "$0.42",
377+
contextIsEstimate: false,
378+
})
379+
return ruleChunksOf(shell.promptBottomRule)
380+
}
381+
382+
const quiet = paint(60)
383+
expect(fgHex(chunkMatching(quiet, "60%")?.fg)).toBe(UI.textDim)
384+
expect(fgHex(chunkMatching(quiet, "$0.42")?.fg)).toBe(UI.textDim)
385+
386+
const warning = paint(80)
387+
expect(fgHex(chunkMatching(warning, "80%")?.fg)).toBe(UI.warning)
388+
expect(fgHex(chunkMatching(warning, "$0.42")?.fg)).toBe(UI.textDim)
389+
390+
const danger = paint(81)
391+
expect(fgHex(chunkMatching(danger, "81%")?.fg)).toBe(UI.error)
392+
expect(fgHex(chunkMatching(danger, "$0.42")?.fg)).toBe(UI.textDim)
393+
})
394+
})
395+
})

src/tui/prompt-highlight.test.ts

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/**
2-
* End-to-end: a recognized skill/agent name typed into the real prompt
3-
* widget paints orange; a lookalike that merely contains a recognized name
4-
* does not.
2+
* End-to-end: a leading `/command` or `@mention` typed into the real prompt
3+
* widget paints orange; bare skill/agent words and mid-prose slashes do not.
54
*/
65
import { describe, expect, test } from "bun:test"
76
import { RGBA } from "@opentui/core"
@@ -26,8 +25,7 @@ function withShell(
2625
run: "idle",
2726
})
2827
setPromptRecognitionSource(shell, () => ({
29-
skillNames: ["brand review"],
30-
agentNames: ["emil", "draper"],
28+
commandNames: ["implement", "review", "improve", "linear-create"],
3129
}))
3230
try {
3331
await fn(shell, h)
@@ -55,40 +53,50 @@ function spansFor(h: Harness, text: string): { text: string; fg: RGBA }[] {
5553
}
5654

5755
describe("prompt recognition highlighting", () => {
58-
test("a recognized agent name paints in the action color", async () => {
56+
test("a leading slash command paints in the action color", async () => {
5957
await withShell(async (shell, h) => {
60-
await compose(shell, h, "ask emil to review")
61-
const spans = spansFor(h, "emil")
58+
await compose(shell, h, "/implement")
59+
const spans = spansFor(h, "/implement")
6260
expect(spans.length).toBeGreaterThan(0)
6361
expect(spans.some((s) => s.fg.equals(ACTION_FG))).toBe(true)
6462
})
6563
})
6664

67-
test("a recognized multi-word skill name paints in the action color", async () => {
65+
test("an @mention paints in the action color", async () => {
6866
await withShell(async (shell, h) => {
69-
await compose(shell, h, "ask draper to run a brand review")
70-
const spans = spansFor(h, "brand review")
67+
await compose(shell, h, "ask @emil to review")
68+
const spans = spansFor(h, "@emil")
7169
expect(spans.length).toBeGreaterThan(0)
7270
expect(spans.some((s) => s.fg.equals(ACTION_FG))).toBe(true)
7371
})
7472
})
7573

76-
test("a lookalike that is not a recognized name stays unstyled", async () => {
74+
test("bare words stay unstyled", async () => {
7775
await withShell(async (shell, h) => {
78-
await compose(shell, h, "emily is not emil")
79-
const spans = spansFor(h, "emily")
76+
for (const word of ["emil", "implement", "brand review", "improve", "linear-create"]) {
77+
await compose(shell, h, word)
78+
const spans = spansFor(h, word)
79+
expect(spans.length).toBeGreaterThan(0)
80+
expect(spans.every((s) => !s.fg.equals(ACTION_FG))).toBe(true)
81+
}
82+
})
83+
})
84+
85+
test("a mid-prose slash command stays unstyled", async () => {
86+
await withShell(async (shell, h) => {
87+
await compose(shell, h, "please /review this")
88+
const spans = spansFor(h, "/review")
8089
expect(spans.length).toBeGreaterThan(0)
8190
expect(spans.every((s) => !s.fg.equals(ACTION_FG))).toBe(true)
8291
})
8392
})
8493

85-
test("a mixed line highlights only the recognized tokens", async () => {
94+
test("a lookalike that is not a mention stays unstyled", async () => {
8695
await withShell(async (shell, h) => {
87-
await compose(shell, h, "emily asked emil and draper for a brand review")
88-
expect(spansFor(h, "emily").every((s) => !s.fg.equals(ACTION_FG))).toBe(true)
89-
expect(spansFor(h, "emil").some((s) => s.fg.equals(ACTION_FG))).toBe(true)
90-
expect(spansFor(h, "draper").some((s) => s.fg.equals(ACTION_FG))).toBe(true)
91-
expect(spansFor(h, "brand review").some((s) => s.fg.equals(ACTION_FG))).toBe(true)
96+
await compose(shell, h, "emily is not emil")
97+
const spans = spansFor(h, "emily")
98+
expect(spans.length).toBeGreaterThan(0)
99+
expect(spans.every((s) => !s.fg.equals(ACTION_FG))).toBe(true)
92100
})
93101
})
94102
})

0 commit comments

Comments
 (0)