Skip to content

Commit 4336578

Browse files
Merge pull request #398 from corbitsdev/cl-5601-highlight-recognized-skills-and-agents-in-the-chat-box
Highlight recognized skill and agent names in the prompt
2 parents c33d528 + 3aef5b0 commit 4336578

5 files changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
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.
5+
*/
6+
import { describe, expect, test } from "bun:test"
7+
import { RGBA } from "@opentui/core"
8+
import { withTestRenderer, type Harness } from "./harness"
9+
import {
10+
createAppShell,
11+
setPromptRecognitionSource,
12+
syncPromptHighlights,
13+
type AppShell,
14+
} from "./shell"
15+
import { UI } from "./theme"
16+
17+
const ACTION_FG = RGBA.fromHex(UI.action)
18+
19+
function withShell(
20+
fn: (shell: AppShell, h: Harness) => Promise<void> | void,
21+
): Promise<void> {
22+
return withTestRenderer(async (h) => {
23+
const shell = createAppShell(h.renderer, {
24+
terminal: { columns: 60, rows: 20 },
25+
wireKeys: true,
26+
run: "idle",
27+
})
28+
setPromptRecognitionSource(shell, () => ({
29+
skillNames: ["brand review"],
30+
agentNames: ["emil", "draper"],
31+
}))
32+
try {
33+
await fn(shell, h)
34+
} finally {
35+
shell.dispose()
36+
}
37+
})
38+
}
39+
40+
async function compose(shell: AppShell, h: Harness, value: string): Promise<void> {
41+
shell.prompt.value = value
42+
syncPromptHighlights(shell)
43+
await h.renderOnce()
44+
await h.renderOnce()
45+
}
46+
47+
function spansFor(h: Harness, text: string): { text: string; fg: RGBA }[] {
48+
const found: { text: string; fg: RGBA }[] = []
49+
for (const line of h.captureSpans().lines) {
50+
for (const span of line.spans) {
51+
if (span.text.includes(text)) found.push({ text: span.text, fg: span.fg })
52+
}
53+
}
54+
return found
55+
}
56+
57+
describe("prompt recognition highlighting", () => {
58+
test("a recognized agent name paints in the action color", async () => {
59+
await withShell(async (shell, h) => {
60+
await compose(shell, h, "ask emil to review")
61+
const spans = spansFor(h, "emil")
62+
expect(spans.length).toBeGreaterThan(0)
63+
expect(spans.some((s) => s.fg.equals(ACTION_FG))).toBe(true)
64+
})
65+
})
66+
67+
test("a recognized multi-word skill name paints in the action color", async () => {
68+
await withShell(async (shell, h) => {
69+
await compose(shell, h, "ask draper to run a brand review")
70+
const spans = spansFor(h, "brand review")
71+
expect(spans.length).toBeGreaterThan(0)
72+
expect(spans.some((s) => s.fg.equals(ACTION_FG))).toBe(true)
73+
})
74+
})
75+
76+
test("a lookalike that is not a recognized name stays unstyled", async () => {
77+
await withShell(async (shell, h) => {
78+
await compose(shell, h, "emily is not emil")
79+
const spans = spansFor(h, "emily")
80+
expect(spans.length).toBeGreaterThan(0)
81+
expect(spans.every((s) => !s.fg.equals(ACTION_FG))).toBe(true)
82+
})
83+
})
84+
85+
test("a mixed line highlights only the recognized tokens", async () => {
86+
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)
92+
})
93+
})
94+
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, test } from "bun:test"
2+
import {
3+
buildPromptRecognitionMatcher,
4+
resolvePromptHighlightSpans,
5+
resolvePromptRecognitionMatcher,
6+
type PromptRecognitionSource,
7+
} from "./prompt-recognition"
8+
9+
describe("buildPromptRecognitionMatcher", () => {
10+
test("returns null for an empty name set", () => {
11+
expect(buildPromptRecognitionMatcher([])).toBeNull()
12+
})
13+
14+
test("matches a known single-word name as a whole word", () => {
15+
const matcher = buildPromptRecognitionMatcher(["emil"])
16+
expect(resolvePromptHighlightSpans("ask emil to review", matcher)).toEqual([
17+
{ start: 4, end: 8 },
18+
])
19+
})
20+
21+
test("does not match a lookalike that only contains the name", () => {
22+
const matcher = buildPromptRecognitionMatcher(["emil"])
23+
expect(resolvePromptHighlightSpans("emily said hi", matcher)).toEqual([])
24+
})
25+
26+
test("matches a multi-word skill name as a whole phrase", () => {
27+
const matcher = buildPromptRecognitionMatcher(["brand review"])
28+
expect(
29+
resolvePromptHighlightSpans("ask draper to run a brand review", matcher),
30+
).toEqual([{ start: 20, end: 32 }])
31+
})
32+
33+
test("longer names win over a shorter name that is their prefix", () => {
34+
const matcher = buildPromptRecognitionMatcher(["brand", "brand review"])
35+
const spans = resolvePromptHighlightSpans("run brand review now", matcher)
36+
expect(spans).toEqual([{ start: 4, end: 16 }])
37+
})
38+
39+
test("matches every recognized token in a mixed line", () => {
40+
const matcher = buildPromptRecognitionMatcher(["emil", "draper", "brand review"])
41+
const spans = resolvePromptHighlightSpans(
42+
"ask emil and draper to run a brand review",
43+
matcher,
44+
)
45+
expect(spans).toEqual([
46+
{ start: 4, end: 8 },
47+
{ start: 13, end: 19 },
48+
{ start: 29, end: 41 },
49+
])
50+
})
51+
52+
test("matching is case-insensitive", () => {
53+
const matcher = buildPromptRecognitionMatcher(["emil"])
54+
expect(resolvePromptHighlightSpans("EMIL, please look", matcher)).toEqual([
55+
{ start: 0, end: 4 },
56+
])
57+
})
58+
})
59+
60+
describe("resolvePromptRecognitionMatcher", () => {
61+
test("caches the matcher while the name set is unchanged", () => {
62+
const source: PromptRecognitionSource = () => ({
63+
skillNames: ["brand review"],
64+
agentNames: ["emil"],
65+
})
66+
const first = resolvePromptRecognitionMatcher(source)
67+
const second = resolvePromptRecognitionMatcher(source)
68+
expect(second).toBe(first)
69+
})
70+
71+
test("rebuilds the matcher when the name set changes", () => {
72+
let names = ["emil"]
73+
const source: PromptRecognitionSource = () => ({
74+
skillNames: [],
75+
agentNames: names,
76+
})
77+
const first = resolvePromptRecognitionMatcher(source)
78+
names = ["emil", "draper"]
79+
const second = resolvePromptRecognitionMatcher(source)
80+
expect(second).not.toBe(first)
81+
expect(resolvePromptHighlightSpans("ask draper", second)).toEqual([
82+
{ start: 4, end: 10 },
83+
])
84+
})
85+
})
3.33 KB
Binary file not shown.

src/tui-opentui/shell.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
CliRenderEvents,
1414
MarkdownRenderable,
1515
ScrollBoxRenderable,
16+
SyntaxStyle,
1617
TextRenderable,
1718
TextTableRenderable,
1819
StyledText,
@@ -47,6 +48,11 @@ import {
4748
type SentHistoryBrowse,
4849
} from "../tui/sent-message-history.js"
4950
import { spliceMentionCompletion } from "./prompt-attachments.js"
51+
import {
52+
resolvePromptHighlightSpans,
53+
resolvePromptRecognitionMatcher,
54+
type PromptRecognitionSource,
55+
} from "./prompt-recognition.js"
5056
import {
5157
createPromptInput,
5258
promptCaretAtFirstRow,
@@ -354,6 +360,17 @@ export function setMentionSuggestionSource(
354360
else shellMentionSource.delete(shell)
355361
}
356362

363+
/** Names the prompt is allowed to highlight as recognized skills/agents. */
364+
const shellRecognitionSource = new WeakMap<AppShell, PromptRecognitionSource>()
365+
366+
export function setPromptRecognitionSource(
367+
shell: AppShell,
368+
source: PromptRecognitionSource | undefined,
369+
): void {
370+
if (source) shellRecognitionSource.set(shell, source)
371+
else shellRecognitionSource.delete(shell)
372+
}
373+
357374
/**
358375
* Injectable handler for the palette "observe" action. Host resolves a live
359376
* `ObserveSession` (or `null` when no subagent is running). Demo/smoke keep
@@ -1704,6 +1721,50 @@ export function syncPromptRows(shell: AppShell): void {
17041721
relayout(shell, { promptContentRows: rows })
17051722
}
17061723

1724+
let cachedPromptSyntaxStyle: SyntaxStyle | null = null
1725+
let cachedPromptRecognizedStyleId: number | null = null
1726+
1727+
/**
1728+
* The style registry backing the prompt's highlights, plus the one style id
1729+
* this feature uses. Lazy for the same reason as `transcriptSyntaxStyle`:
1730+
* construction reaches into the native render lib.
1731+
*/
1732+
function promptRecognizedStyleId(): number {
1733+
if (cachedPromptSyntaxStyle === null) {
1734+
cachedPromptSyntaxStyle = SyntaxStyle.fromStyles({
1735+
recognized: { fg: UI.action },
1736+
})
1737+
}
1738+
if (cachedPromptRecognizedStyleId === null) {
1739+
cachedPromptRecognizedStyleId = cachedPromptSyntaxStyle.resolveStyleId("recognized") ?? 0
1740+
}
1741+
return cachedPromptRecognizedStyleId
1742+
}
1743+
1744+
const promptHighlightedValue = new WeakMap<AppShell, string>()
1745+
1746+
/**
1747+
* Re-mark recognized skill/agent tokens in the prompt. Runs once per frame
1748+
* (see `onFrame` in `createShell`), and only does anything when the prompt's
1749+
* text actually changed since the last frame — typing that doesn't touch a
1750+
* token, and every non-typing frame, is a no-op string comparison.
1751+
*/
1752+
export function syncPromptHighlights(shell: AppShell): void {
1753+
const source = shellRecognitionSource.get(shell)
1754+
if (source === undefined) return
1755+
const value = shell.prompt.value
1756+
if (promptHighlightedValue.get(shell) === value) return
1757+
promptHighlightedValue.set(shell, value)
1758+
1759+
const styleId = promptRecognizedStyleId()
1760+
shell.prompt.syntaxStyle = cachedPromptSyntaxStyle
1761+
shell.prompt.clearAllHighlights()
1762+
const matcher = resolvePromptRecognitionMatcher(source)
1763+
for (const span of resolvePromptHighlightSpans(value, matcher)) {
1764+
shell.prompt.addHighlightByCharRange({ start: span.start, end: span.end, styleId })
1765+
}
1766+
}
1767+
17071768
export type RelayoutOpts = {
17081769
readonly columns?: number
17091770
readonly rows?: number
@@ -5513,6 +5574,7 @@ export function createAppShell(
55135574
const onFrame = (): void => {
55145575
if (disposed) return
55155576
syncPromptRows(shell)
5577+
syncPromptHighlights(shell)
55165578
// Applied after a natural render, not at mutation time: a row's own box
55175579
// needs a layout pass to size itself, and claiming the padding first
55185580
// starves that pass of room to lay the row out in.

src/tui/runner.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ import {
147147
appendStreamRow,
148148
attachClipboardImage,
149149
setMentionSuggestionSource,
150+
setPromptRecognitionSource,
150151
setSentMessageHistory,
151152
setShellRunState,
152153
} from "../tui-opentui/shell.js";
@@ -2243,6 +2244,15 @@ export async function runTUI(initialConfig: Config): Promise<number> {
22432244

22442245
setMentionSuggestionSource(host.shell, (prefix) => listPathSuggestions(prefix, config.cwd));
22452246

2247+
// Same names the operator can already reach by typing them: skills the
2248+
// session discovered at startup, agents from the live profile registry
2249+
// (which trust changes can update mid-session, so read through the
2250+
// closure rather than snapshotting it here).
2251+
setPromptRecognitionSource(host.shell, () => ({
2252+
skillNames: skills.map((skill) => skill.name),
2253+
agentNames: liveAgentProfiles.map((profile) => profile.id),
2254+
}));
2255+
22462256
// Recall spans the whole session, including what was sent before a resume.
22472257
void loadSentMessages(config.cwd, sessionId)
22482258
.then((sent) => setSentMessageHistory(host.shell, sent))

0 commit comments

Comments
 (0)