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
94 changes: 94 additions & 0 deletions src/tui-opentui/prompt-highlight.test.ts
Original file line number Diff line number Diff line change
@@ -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> | void,
): Promise<void> {
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<void> {
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)
})
})
})
85 changes: 85 additions & 0 deletions src/tui-opentui/prompt-recognition.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
])
})
})
Binary file added src/tui-opentui/prompt-recognition.ts
Binary file not shown.
62 changes: 62 additions & 0 deletions src/tui-opentui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
CliRenderEvents,
MarkdownRenderable,
ScrollBoxRenderable,
SyntaxStyle,
TextRenderable,
TextTableRenderable,
StyledText,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<AppShell, PromptRecognitionSource>()

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
Expand Down Expand Up @@ -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<AppShell, string>()

/**
* 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
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ import {
appendStreamRow,
attachClipboardImage,
setMentionSuggestionSource,
setPromptRecognitionSource,
setSentMessageHistory,
setShellRunState,
} from "../tui-opentui/shell.js";
Expand Down Expand Up @@ -2195,6 +2196,15 @@ export async function runTUI(initialConfig: Config): Promise<number> {

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))
Expand Down
Loading