diff --git a/ui-tui/src/__tests__/composerHighlights.test.ts b/ui-tui/src/__tests__/composerHighlights.test.ts new file mode 100644 index 0000000000..0fcc9598ab --- /dev/null +++ b/ui-tui/src/__tests__/composerHighlights.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' + +import { highlightsStable, splitComposerHighlights } from '../domain/composerHighlights.js' + +const painted = (text: string) => + splitComposerHighlights(text) + .filter(segment => segment.ref) + .map(segment => segment.text) + +describe('splitComposerHighlights', () => { + it('marks a command invocation and a skill named mid-prose', () => { + expect(painted('/work fix the leak')).toEqual(['/work']) + expect(painted('clean this up with /clean')).toEqual(['/clean']) + expect(painted('run /clean then /work')).toEqual(['/clean', '/work']) + }) + + it('marks @ references, including quoted values with spaces', () => { + expect(painted('see @file:src/a.ts please')).toEqual(['@file:src/a.ts']) + expect(painted('see @file:`my notes.md` please')).toEqual(['@file:`my notes.md`']) + expect(painted('diff @diff and @staged')).toEqual(['@diff', '@staged']) + }) + + it('marks attachment and paste tokens', () => { + expect(painted('what is in [[ Image 1 ]] here')).toEqual(['[[ Image 1 ]]']) + expect(painted('paste [[ log.. [3 lines] ]] ok')).toEqual(['[[ log.. [3 lines] ]]']) + }) + + it('marks every kind in one message', () => { + expect(painted('/work with @file:a.ts and [[ Image 2 ]]')).toEqual(['/work', '@file:a.ts', '[[ Image 2 ]]']) + }) + + it('leaves paths, bare slashes, and email addresses alone', () => { + for (const text of [ + 'look at /usr/local/bin', + 'check src/foo/bar', + 'a 3 /4 b', + 'either / or', + 'email me@example.com' + ]) { + expect(splitComposerHighlights(text)).toEqual([{ ref: false, text }]) + } + }) + + it('marks a half-typed token so the accent tracks the caret', () => { + // The composer paints while you type — waiting for the token to close + // would flash the accent on only after the last character. A bare `/` + // counts at the caret: that's the command menu opening. + expect(painted('/wor')).toEqual(['/wor']) + expect(painted('ref @fi')).toEqual(['@fi']) + expect(painted('/')).toEqual(['/']) + }) + + it('round-trips the input exactly', () => { + for (const text of ['/work a', 'x @file:b [[ Image 1 ]]', 'plain text', '', 'look at /usr/local/bin']) { + expect( + splitComposerHighlights(text) + .map(segment => segment.text) + .join('') + ).toBe(text) + } + }) + + it('always returns at least one segment', () => { + expect(splitComposerHighlights('')).toEqual([{ ref: false, text: '' }]) + }) +}) + +describe('highlightsStable', () => { + // Fast-echo writes ONLY the new cells, so it may run only when every + // character already on screen keeps the colour it had. + it('allows the bypass while a token just grows', () => { + expect(highlightsStable('/wor', '/work')).toBe(true) + expect(highlightsStable('hello', 'hello ')).toBe(true) + }) + + it('blocks the bypass when a keystroke re-colours existing cells', () => { + expect(highlightsStable('[[ a ]', '[[ a ]]')).toBe(false) + expect(highlightsStable('/usr', '/usr/')).toBe(false) + }) +}) diff --git a/ui-tui/src/__tests__/inlineSlashSkill.test.ts b/ui-tui/src/__tests__/inlineSlashSkill.test.ts index 61578fce68..3fd35cf1f9 100644 --- a/ui-tui/src/__tests__/inlineSlashSkill.test.ts +++ b/ui-tui/src/__tests__/inlineSlashSkill.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { inlineSlashTrigger, splitSlashSkillRefs } from '../domain/slash.js' +import { inlineSlashTrigger } from '../domain/slash.js' import { completionRequestForInput } from '../hooks/useCompletion.js' describe('inlineSlashTrigger', () => { @@ -91,44 +91,3 @@ describe('completionRequestForInput — inline skill references', () => { expect(completionRequestForInput('open /usr/lo')).toMatchObject({ method: 'complete.path' }) }) }) - -describe('splitSlashSkillRefs', () => { - it('marks a skill referenced mid-prose', () => { - expect(splitSlashSkillRefs('clean this up with /clean')).toEqual([ - { ref: false, text: 'clean this up with ' }, - { ref: true, text: '/clean' } - ]) - }) - - it('keeps the prose on both sides of the reference', () => { - expect(splitSlashSkillRefs('run /clean then ship')).toEqual([ - { ref: false, text: 'run ' }, - { ref: true, text: '/clean' }, - { ref: false, text: ' then ship' } - ]) - }) - - it('does not mark paths', () => { - for (const text of ['look at /usr/local/bin', 'check src/foo/bar', 'a 3 /4 b']) { - expect(splitSlashSkillRefs(text)).toEqual([{ ref: false, text }]) - } - }) - - it('does not mark a leading slash — that is a command, not a reference', () => { - expect(splitSlashSkillRefs('/clean')).toEqual([{ ref: false, text: '/clean' }]) - }) - - it('round-trips the input exactly', () => { - for (const text of ['run /clean then /work ok', 'plain text', '', 'look at /usr/local/bin']) { - expect( - splitSlashSkillRefs(text) - .map(s => s.text) - .join('') - ).toBe(text) - } - }) - - it('always returns at least one segment', () => { - expect(splitSlashSkillRefs('')).toEqual([{ ref: false, text: '' }]) - }) -}) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 1d3d7e6761..7417450e35 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -4,10 +4,10 @@ import { memo, useState } from 'react' import { TERMUX_TUI_MODE } from '../config/env.js' import { LONG_MSG } from '../config/limits.js' import { hasLeadGap } from '../domain/blockLayout.js' +import { splitComposerHighlights } from '../domain/composerHighlights.js' import { sectionMode } from '../domain/details.js' import { userDisplay } from '../domain/messages.js' import { ROLE } from '../domain/roles.js' -import { splitSlashSkillRefs } from '../domain/slash.js' import { transcriptBodyWidth, transcriptGutterWidth } from '../lib/inputMetrics.js' import { boundedLiveRenderText, @@ -230,11 +230,11 @@ export const MessageLine = memo(function MessageLine({ ) } - // A skill the user referenced mid-prose (`clean this up with /clean`) - // keeps the accent it wore as a completion in the composer, instead of - // flattening back into the body text. + // A skill, `@ref`, or attachment token the user put in the message keeps + // the accent it wore in the composer, instead of flattening back into the + // body text. if (msg.role === 'user') { - const segments = splitSlashSkillRefs(msg.text) + const segments = splitComposerHighlights(msg.text) return ( diff --git a/ui-tui/src/domain/composerHighlights.ts b/ui-tui/src/domain/composerHighlights.ts new file mode 100644 index 0000000000..f5ee602ad3 --- /dev/null +++ b/ui-tui/src/domain/composerHighlights.ts @@ -0,0 +1,95 @@ +import { PASTE_SNIPPET_RE } from '../protocol/paste.js' + +/** + * Reference spans in composer text: a `/skill` invoked or named in prose, an + * `@file:` / `@url:` / `@session:` ref, and a `[[ Image 1 ]]` / paste token. + * The same vocabulary the desktop chips, so the two surfaces agree on what a + * reference is. + * + * Concatenating every `text` reproduces the input exactly — styling only, the + * text is never rewritten. Regexes are built per call: a shared `/g` instance + * carries `lastIndex` between callers and silently skips the first match in + * the next string it is handed. + */ +export type ComposerHighlight = { ref: boolean; text: string } + +// Leading OR mid-prose. `(?![\w-]*\/)` keeps `/usr/local` from lighting up as +// `/usr`, and requiring a letter keeps `a 3 /4 b` plain. A BARE `/` counts only +// at the very end — that is the user opening the command menu, not prose. +const slashRe = () => /(?<=^|\s)(?:\/[a-zA-Z][\w-]*(?![\w-]*\/)|\/$)/g + +// Every `@ref` shape the composer accepts: a typed kind (`@file:src/a.ts`), a +// quoted value with spaces, a bare `@diff` / `@staged`, and the half-typed +// `@fi` the user is still working on. Quoted alternatives come before bare +// `\S+` or a quoted value would end at its first space. +const atRe = () => /(?<=^|\s)@(?:[\w-]+:(?:`[^`\n]*`?|"[^"\n]*"?|'[^'\n]*'?|\S*)|\S*)/g + +const tokenRe = () => new RegExp(PASTE_SNIPPET_RE.source, 'g') + +type Span = { end: number; start: number } + +const matchSpans = (text: string, re: RegExp): Span[] => + [...text.matchAll(re)].filter(m => m[0]).map(m => ({ end: (m.index ?? 0) + m[0].length, start: m.index ?? 0 })) + +export const splitComposerHighlights = (text: string): ComposerHighlight[] => { + // Tokens, then @refs, then slashes: on an overlap the earlier kind wins, so + // a slash inside a quoted ref value stays part of that ref. + const spans = [...matchSpans(text, tokenRe()), ...matchSpans(text, atRe()), ...matchSpans(text, slashRe())] + .sort((a, b) => a.start - b.start) + .reduce((kept, span) => { + if (!kept.some(prev => span.start < prev.end && span.end > prev.start)) { + kept.push(span) + } + + return kept + }, []) + + const out: ComposerHighlight[] = [] + let last = 0 + + for (const span of spans) { + if (span.start > last) { + out.push({ ref: false, text: text.slice(last, span.start) }) + } + + out.push({ ref: true, text: text.slice(span.start, span.end) }) + last = span.end + } + + if (last < text.length || !out.length) { + out.push({ ref: false, text: text.slice(last) }) + } + + return out +} + +/** Per-character "is this cell accented", indexed to match the input string. */ +export const highlightMask = (text: string): boolean[] => { + const mask = new Array(text.length).fill(false) + let offset = 0 + + for (const segment of splitComposerHighlights(text)) { + if (segment.ref) { + mask.fill(true, offset, offset + segment.text.length) + } + + offset += segment.text.length + } + + return mask +} + +/** + * Whether every character that stays on screen keeps the color it had. + * + * The fast-echo bypass writes ONLY the new cells, so it may run only when a + * keystroke leaves the existing ones alone. Typing `]` to close a + * `[[ token ]]`, or a second `/` demoting `/usr` to a path, recolors text + * already painted — those have to go through a full Ink repaint instead. + */ +export const highlightsStable = (prev: string, next: string): boolean => { + const before = highlightMask(prev) + const after = highlightMask(next) + + return before.slice(0, Math.min(before.length, after.length)).every((on, i) => on === after[i]) +} diff --git a/ui-tui/src/domain/slash.ts b/ui-tui/src/domain/slash.ts index b40578c960..1f1b12847d 100644 --- a/ui-tui/src/domain/slash.ts +++ b/ui-tui/src/domain/slash.ts @@ -53,44 +53,6 @@ export const parseSlashCommand = (cmd: string) => { return { arg: rest.join(' '), cmd, name: name.toLowerCase() } } -// A skill referenced mid-prose in a message that's already been sent -// ("clean this up with /clean"). The composer offers it as a completion, so -// the transcript marks it as one rather than flattening it into the body text. -// -// Unlike the caret-anchored trigger above this scans finished text, so it has -// to reject a token that continues into a path: `/usr/local/bin` would -// otherwise mark `/usr`. `(?![\w-]*\/)` requires the token to end at something -// other than another slash. A leading `/` is excluded too — that's a command -// invocation, which never reaches the transcript as a user message. -const SLASH_SKILL_REF_RE = /(?<=\s)\/[a-zA-Z][\w-]*(?![\w-]*\/)/g - -/** - * Split `text` into alternating plain and `/skill` reference runs. Always - * returns at least one segment, and concatenating every `text` reproduces the - * input exactly — the transcript styles the reference without rewriting it. - */ -export const splitSlashSkillRefs = (text: string): { ref: boolean; text: string }[] => { - const out: { ref: boolean; text: string }[] = [] - let last = 0 - - for (const match of text.matchAll(SLASH_SKILL_REF_RE)) { - const start = match.index ?? 0 - - if (start > last) { - out.push({ ref: false, text: text.slice(last, start) }) - } - - out.push({ ref: true, text: match[0] }) - last = start + match[0].length - } - - if (last < text.length || !out.length) { - out.push({ ref: false, text: text.slice(last) }) - } - - return out -} - /** * Apply a completion row to the current input, mirroring the editor's * replace semantics: replace from `compReplace` with the row text, dropping