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:
committed by
brooklyn!
parent
43f395a4f8
commit
e69d2fda8a
80
ui-tui/src/__tests__/composerHighlights.test.ts
Normal file
80
ui-tui/src/__tests__/composerHighlights.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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: '' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 } : {})}>
|
||||
|
||||
95
ui-tui/src/domain/composerHighlights.ts
Normal file
95
ui-tui/src/domain/composerHighlights.ts
Normal 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])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user