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 000000000..6396c3cff Binary files /dev/null and b/src/tui-opentui/prompt-recognition.ts differ diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 676a79814..53c5c2244 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -13,6 +13,7 @@ import { CliRenderEvents, MarkdownRenderable, ScrollBoxRenderable, + SyntaxStyle, TextRenderable, TextTableRenderable, StyledText, @@ -47,6 +48,11 @@ import { type SentHistoryBrowse, } from "../tui/sent-message-history.js" import { spliceMentionCompletion } from "./prompt-attachments.js" +import { + resolvePromptHighlightSpans, + resolvePromptRecognitionMatcher, + type PromptRecognitionSource, +} from "./prompt-recognition.js" import { createPromptInput, promptCaretAtFirstRow, @@ -354,6 +360,17 @@ export function setMentionSuggestionSource( else shellMentionSource.delete(shell) } +/** Names the prompt is allowed to highlight as recognized skills/agents. */ +const shellRecognitionSource = new WeakMap() + +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))