feat(tui): one reference vocabulary for the composer and the transcript

A sent message accented a `/skill` named mid-prose and nothing else, so an
`@file:` ref and an `[[ Image 1 ]]` token flattened into body text. The
composer painted none of it.

splitComposerHighlights covers the whole vocabulary the desktop chips —
`/work` invoked or referenced, every `@ref` shape including quoted values,
and attachment/paste tokens — and both surfaces read it, so what you type
is what you see once it lands. Supersedes splitSlashSkillRefs.
This commit is contained in:
Brooklyn Nicholson
2026-08-18 08:37:11 -05:00
committed by brooklyn!
parent 43f395a4f8
commit e69d2fda8a
5 changed files with 181 additions and 85 deletions

View File

@@ -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)
})
})

View File

@@ -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: '' }])
})
})

View File

@@ -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 (
<Text {...(body ? { color: body } : {})}>

View File

@@ -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<Span[]>((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<boolean>(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])
}

View File

@@ -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