| import { Editor } from '@earendil-works/pi-tui'; |
| import { ansi } from './tui-ansi.js'; |
| import { SKILL_INVOCATION_TOKEN_SOURCE } from './skill-token.js'; |
| |
| // A `/`-token that begins mid-message (after whitespace). Only `/skill:` has |
| // semantic value mid-message (a parseable invocation token); plain commands |
| // only execute at line start, so the provider offers `/skill:xxx` (not plain |
| // commands) for a mid-message `/`. Line-start `/` is left to pi-tui's own |
| // slash trigger; this matches only mid-message tokens (after whitespace). |
| const MID_MESSAGE_SLASH_TOKEN = /(?:\s)\/\S*$/; |
| |
| /** |
| * Editor with `/skill:<name>` invocation highlighting (issue #1148). Valid |
| * tokens render in the CLI brand accent; anything else stays plain — the |
| * absence of the affordance IS the inactive state, so there is deliberately |
| * no "failed" style. |
| * |
| * pi-tui's Editor has no span-decoration hook (its theme covers borders and |
| * the autocomplete list only), so this subclass post-processes the rendered |
| * lines: tokens are ASCII and the regex is prefix-anchored, so it can never |
| * match inside the editor's own escape sequences (border colors, the inline |
| * cursor's reverse-video marker). Two known, self-healing limits: a token |
| * split across word-wrapped lines, and a token with the cursor inside it |
| * (cursor escape codes break the plain-text match) render unhighlighted. |
| */ |
| export class MakaSkillHighlightEditor extends Editor { |
| private isInvocable: (name: string) => boolean = () => false; |
| |
| /** |
| * Swap the validator used by the render pass. Must be synchronous and |
| * cheap (called per token per render) — the runner feeds it a snapshot of |
| * the last fetched invocable-skill list. |
| */ |
| setSkillTokenValidator(validator: (name: string) => boolean): void { |
| this.isInvocable = validator; |
| this.invalidate(); |
| } |
| |
| override render(width: number): string[] { |
| const pattern = new RegExp(SKILL_INVOCATION_TOKEN_SOURCE, 'g'); |
| return super |
| .render(width) |
| .map((line) => |
| line.replace(pattern, (whole, name: string) => |
| this.isInvocable(name) ? ansi.accent(whole) : whole, |
| ), |
| ); |
| } |
| |
| override handleInput(data: string): void { |
| // Detect a printable input by whether it actually changed the editor text, |
| // instead of re-deriving pi-tui's printable decoding (Kitty CSI-u, xterm |
| // modifyOtherKeys, IME, paste). Navigation/control sequences (arrows, Enter, |
| // Escape) do not change the text, so they are ignored. Capture "before" |
| // here because super.handleInput performs the insertion. |
| const textBefore = this.getText(); |
| super.handleInput(data); |
| if (this.isShowingAutocomplete()) return; |
| if (this.getText() === textBefore) return; |
| // pi-tui auto-triggers slash completion only at line start (its |
| // isAtStartOfMessage/isInSlashCommandContext predicates require the `/` at |
| // column 0). Also trigger when a `/`-token begins mid-message, so `see /` |
| // surfaces `/skill:xxx` completions immediately. Plain commands are |
| // intentionally NOT completed mid-message (they only execute at line start); |
| // the provider offers only `/skill:`. The provider shapes the prefix so |
| // selection inserts rather than auto-submits. |
| const { line, col } = this.getCursor(); |
| // Slash completion is first-line only, matching pi-tui's isSlashMenuAllowed. |
| if (line !== 0) return; |
| const textBeforeCursor = (this.getLines()[line] ?? '').slice(0, col); |
| if (MID_MESSAGE_SLASH_TOKEN.test(textBeforeCursor)) { |
| // `tryTriggerAutocomplete` is TS-private but runtime-public (pi-tui ships |
| // plain JS with no #private fields). A contract test pins it so a future |
| // rename fails loudly instead of silently regressing mid-message trigger. |
| (this as unknown as { tryTriggerAutocomplete: () => void }).tryTriggerAutocomplete(); |
| } |
| } |
| } |