From 3aef5b031149c2b52d19401ebcbdc59f28db3114 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 11:43:13 -0700 Subject: [PATCH] Highlight recognized skill and agent names in the prompt Typed text like "ask emil and draper to run a brand review" gave no signal that emil, draper, or brand review had been recognized as addressable skills/agents until the message was sent. The prompt now re-scans its text once per frame and highlights any span matching a known skill or agent name. --- src/tui-opentui/prompt-highlight.test.ts | 94 +++++++++++++++++++++ src/tui-opentui/prompt-recognition.test.ts | 85 +++++++++++++++++++ src/tui-opentui/prompt-recognition.ts | Bin 0 -> 3408 bytes src/tui-opentui/shell.ts | 62 ++++++++++++++ src/tui/runner.ts | 10 +++ 5 files changed, 251 insertions(+) create mode 100644 src/tui-opentui/prompt-highlight.test.ts create mode 100644 src/tui-opentui/prompt-recognition.test.ts create mode 100644 src/tui-opentui/prompt-recognition.ts diff --git a/src/tui-opentui/prompt-highlight.test.ts b/src/tui-opentui/prompt-highlight.test.ts new file mode 100644 index 000000000..535ad8a21 --- /dev/null +++ b/src/tui-opentui/prompt-highlight.test.ts @@ -0,0 +1,94 @@ +/** + * 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. + */ +import { describe, expect, test } from "bun:test" +import { RGBA } from "@opentui/core" +import { withTestRenderer, type Harness } from "./harness" +import { + createAppShell, + setPromptRecognitionSource, + syncPromptHighlights, + type AppShell, +} from "./shell" +import { UI } from "./theme" + +const ACTION_FG = RGBA.fromHex(UI.action) + +function withShell( + fn: (shell: AppShell, h: Harness) => Promise | void, +): Promise { + return withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 60, rows: 20 }, + wireKeys: true, + run: "idle", + }) + setPromptRecognitionSource(shell, () => ({ + skillNames: ["brand review"], + agentNames: ["emil", "draper"], + })) + try { + await fn(shell, h) + } finally { + shell.dispose() + } + }) +} + +async function compose(shell: AppShell, h: Harness, value: string): Promise { + shell.prompt.value = value + syncPromptHighlights(shell) + await h.renderOnce() + await h.renderOnce() +} + +function spansFor(h: Harness, text: string): { text: string; fg: RGBA }[] { + const found: { text: string; fg: RGBA }[] = [] + for (const line of h.captureSpans().lines) { + for (const span of line.spans) { + if (span.text.includes(text)) found.push({ text: span.text, fg: span.fg }) + } + } + return found +} + +describe("prompt recognition highlighting", () => { + test("a recognized agent name paints in the action color", async () => { + await withShell(async (shell, h) => { + 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 recognized multi-word skill name 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") + 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 () => { + await withShell(async (shell, h) => { + 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) + }) + }) + + test("a mixed line highlights only the recognized tokens", 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) + }) + }) +}) diff --git a/src/tui-opentui/prompt-recognition.test.ts b/src/tui-opentui/prompt-recognition.test.ts new file mode 100644 index 000000000..3e2033864 --- /dev/null +++ b/src/tui-opentui/prompt-recognition.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test" +import { + buildPromptRecognitionMatcher, + resolvePromptHighlightSpans, + resolvePromptRecognitionMatcher, + type PromptRecognitionSource, +} from "./prompt-recognition" + +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("does not match a lookalike that only contains the name", () => { + const matcher = buildPromptRecognitionMatcher(["emil"]) + expect(resolvePromptHighlightSpans("emily said hi", matcher)).toEqual([]) + }) + + 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("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("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("matching is case-insensitive", () => { + const matcher = buildPromptRecognitionMatcher(["emil"]) + expect(resolvePromptHighlightSpans("EMIL, please look", matcher)).toEqual([ + { start: 0, end: 4 }, + ]) + }) +}) + +describe("resolvePromptRecognitionMatcher", () => { + test("caches the matcher while the name set is unchanged", () => { + const source: PromptRecognitionSource = () => ({ + skillNames: ["brand review"], + agentNames: ["emil"], + }) + const first = resolvePromptRecognitionMatcher(source) + const second = resolvePromptRecognitionMatcher(source) + expect(second).toBe(first) + }) + + test("rebuilds the matcher when the name set changes", () => { + let names = ["emil"] + const source: PromptRecognitionSource = () => ({ + skillNames: [], + agentNames: names, + }) + const first = resolvePromptRecognitionMatcher(source) + names = ["emil", "draper"] + const second = resolvePromptRecognitionMatcher(source) + expect(second).not.toBe(first) + expect(resolvePromptHighlightSpans("ask draper", second)).toEqual([ + { start: 4, end: 10 }, + ]) + }) +}) diff --git a/src/tui-opentui/prompt-recognition.ts b/src/tui-opentui/prompt-recognition.ts new file mode 100644 index 0000000000000000000000000000000000000000..6396c3cff7b19cec8c68fd63210bf1a0b90d29b6 GIT binary patch literal 3408 zcma)8U2oeq6y0-v#hp=fQtBw(Yc{v&iUKRJZbMt34@rj5GA(hDNsXjpXGwtli2cI- zlAU`g$#&9od$1vq)cri?Tpk}D9?&8EXsWuwFB)5s*QKkh`BN__T$-jKWf!C>Z6jH= z(t%W-?Xk( zq|$XZieUx!s_7lWcJj*y4fbtVYp)k<&)HJb{8~Jfs%f;REND#^LC0Uk^ZS$sd-~uy zU+RloQmK&-a$C5y(?7ZjtrIJw8n^@3Wc+^+;3>hy{N!zBYP@o}ij?C){{`@tp z1>G#C6dWnK)Yn*amwGgDw9wvM!K)N>vTfpoRvLxlJ{%t$93LLiC(eR9*oQNB8>w#7 zxHSrcn7N?vvNdwAAqCsgo7Q<4Lig!G#eWv0%;@HTBtI#lz)5yab{3-t>Q2vh4@%b` zJ$QHUW#6gWG@~r1*^5VFOQm?Vn`1;h?Kgp17mVN&t=N`{HyXhAh`UEus_FD2WfcV z^BS?1Iy*is4xhdJ?a9qumfxP9ou8eZA6HYFoSi*+I>`^<2Y_Ih{bDsrQRz*>ctUg_ z#^*I%TIPz-g9!tOIq+ib{DLy)r?hm)O^pmp{1fds~%CV`X@mK-W_rIpK0Sg_IMp1p;IkF0rNlK$t{zrYI?|FW3^N zn6{VDJ_;q2xvWVm{6(|UYzH`MSRV`=;cQZs0G;0{7arRV5~pny>m(UH%||?n&&P7l zr176&H?aC%AWXNwKGcOtQ94etvj!6ZJbC54x-K|@44VXLThj*}lg(nK+DsmW)a<7K z3sza47fW<;X71&gR+6lP5R5rMnyp7occm z5EzL*KH!xjFd)#W?Ye*d7QNk*r% z>-$BPkCO2bq4Wk>0f6QpV-{#0=AEEn04kFKlXP9{B&)nhAQodY4L1N=K?@cIz3Of8 zTtWeu;`o&RF`#7hGe?)6O=P24w!}|-Wi{U9- zuxv?kn-G}9vmK)!*M%M8lz8VL#!>uvy8MS&WT|E+52sWr{}*^8#YGd71vF+L)QAjh zo)$$R2aE0Sozl3t9kk~?PQMbICb*Ogw*{>1;ijm-$>emPOMmcd@T9}#YH2J!d-@a& zV*|xbKsYhJjg0D#^PPT&8rh4Q)Ccqq`m24gsdINv~_= zRSEh7BRs+&o^-*tg9}DWOeO>~LdM=8=!Zy|vjSwBay%po1mpZwXB`gHoWx`|Dfiu3p^~Q@vh4>aY*Z?}uvEE>M z%H9y~n~g3-?fYqfGwa&mPLZVmw~#9|j%J*Go)hDXgU%%#NFc{lv|Xq(^e$bj#7|+^ y`xLhU+^Kd}=vjI@eSSV9Ed5QQF9&zFdfAs{?RT() + +export function setPromptRecognitionSource( + shell: AppShell, + source: PromptRecognitionSource | undefined, +): void { + if (source) shellRecognitionSource.set(shell, source) + else shellRecognitionSource.delete(shell) +} + /** * Injectable handler for the palette "observe" action. Host resolves a live * `ObserveSession` (or `null` when no subagent is running). Demo/smoke keep @@ -1704,6 +1721,50 @@ export function syncPromptRows(shell: AppShell): void { relayout(shell, { promptContentRows: rows }) } +let cachedPromptSyntaxStyle: SyntaxStyle | null = null +let cachedPromptRecognizedStyleId: number | null = null + +/** + * The style registry backing the prompt's highlights, plus the one style id + * this feature uses. Lazy for the same reason as `transcriptSyntaxStyle`: + * construction reaches into the native render lib. + */ +function promptRecognizedStyleId(): number { + if (cachedPromptSyntaxStyle === null) { + cachedPromptSyntaxStyle = SyntaxStyle.fromStyles({ + recognized: { fg: UI.action }, + }) + } + if (cachedPromptRecognizedStyleId === null) { + cachedPromptRecognizedStyleId = cachedPromptSyntaxStyle.resolveStyleId("recognized") ?? 0 + } + return cachedPromptRecognizedStyleId +} + +const promptHighlightedValue = new WeakMap() + +/** + * Re-mark recognized skill/agent tokens 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. + */ +export function syncPromptHighlights(shell: AppShell): void { + const source = shellRecognitionSource.get(shell) + if (source === undefined) return + const value = shell.prompt.value + if (promptHighlightedValue.get(shell) === value) return + promptHighlightedValue.set(shell, value) + + const styleId = promptRecognizedStyleId() + shell.prompt.syntaxStyle = cachedPromptSyntaxStyle + shell.prompt.clearAllHighlights() + const matcher = resolvePromptRecognitionMatcher(source) + for (const span of resolvePromptHighlightSpans(value, matcher)) { + shell.prompt.addHighlightByCharRange({ start: span.start, end: span.end, styleId }) + } +} + export type RelayoutOpts = { readonly columns?: number readonly rows?: number @@ -5471,6 +5532,7 @@ export function createAppShell( const onFrame = (): void => { if (disposed) return syncPromptRows(shell) + syncPromptHighlights(shell) // Applied after a natural render, not at mutation time: a row's own box // needs a layout pass to size itself, and claiming the padding first // starves that pass of room to lay the row out in. diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 49a5d0ed8..f09f8b3b9 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -139,6 +139,7 @@ import { appendStreamRow, attachClipboardImage, setMentionSuggestionSource, + setPromptRecognitionSource, setSentMessageHistory, setShellRunState, } from "../tui-opentui/shell.js"; @@ -2195,6 +2196,15 @@ export async function runTUI(initialConfig: Config): Promise { setMentionSuggestionSource(host.shell, (prefix) => listPathSuggestions(prefix, config.cwd)); + // 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). + setPromptRecognitionSource(host.shell, () => ({ + skillNames: skills.map((skill) => skill.name), + agentNames: liveAgentProfiles.map((profile) => profile.id), + })); + // Recall spans the whole session, including what was sent before a resume. void loadSentMessages(config.cwd, sessionId) .then((sent) => setSentMessageHistory(host.shell, sent))