feat(tui-go): @ file-reference picker

Typing `@` at a word boundary in the chat input (mid-word `@` stays literal, e.g. emails)
opens a workspace file picker: it requests file.list for the session (cached per session),
filters as you type, and enter inserts `@path ` at the cursor — back to typing. Renders as
a transparent overlay, windowed around the selection. file.list is classified non-rendering
in the render-matrix guard. Tests cover token-boundary detection, ref insertion, and filtering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 10:24:57 +00:00
parent 65e5d88ed7
commit 04d7e26482
8 changed files with 245 additions and 2 deletions
+94 -1
View File
@@ -21,6 +21,9 @@ type tickMsg struct{}
// copiedFlashFrames is how many ticks the "✓ copied" footer note lingers after a yank.
const copiedFlashFrames = 12
// fileListMax caps how many @-picker rows are filtered/rendered at once.
const fileListMax = 200
func readServer(c *ws.Client) tea.Cmd {
return func() tea.Msg { return serverMsg{<-c.Incoming()} }
}
@@ -51,7 +54,7 @@ func (m Model) animating() bool {
if m.reconnecting {
return true
}
if m.overlay == OverlayPalette { // the palette shows a blinking filter caret
if m.overlay == OverlayPalette || m.overlay == OverlayFiles { // blinking filter caret
return true
}
if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames {
@@ -448,6 +451,12 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.openPalette()
return m, nil
}
// `@` at a token boundary opens the workspace file picker; the selection inserts
// `@path`. Mid-word `@` (e.g. an email) is literal.
if m.inputMode == ModeRouter && string(k.Runes) == "@" && m.atTokenBoundary() {
m.openFiles()
return m, nil
}
m.appendRunes(string(k.Runes))
m.syncFilter()
}
@@ -462,6 +471,65 @@ func (m *Model) openPalette() {
m.paletteIndex = 0
}
// atTokenBoundary reports whether the input cursor sits at the start of a word (start of
// line or just after a space) — where `@` should begin a file reference rather than be literal.
func (m Model) atTokenBoundary() bool {
if m.inputCursor <= 0 {
return true
}
if m.inputCursor <= len(m.inputBuffer) {
return m.inputBuffer[m.inputCursor-1] == ' '
}
return false
}
// openFiles opens the `@` file picker for the selected session and requests its workspace
// paths (cached per session). Editing mode is left in Insert so closing returns to typing.
func (m *Model) openFiles() {
m.overlay = OverlayFiles
m.fileFilter = ""
m.fileIndex = 0
if m.filesFor != m.selectedID {
m.files = nil
m.filesLoading = true
}
m.client.Send(protocol.ListFiles(m.selectedID))
}
// insertFileRef inserts `@path ` at the input cursor (the `@` was consumed to open the picker).
func (m *Model) insertFileRef(path string) {
ref := "@" + path + " "
c := m.inputCursor
if c > len(m.inputBuffer) {
c = len(m.inputBuffer)
}
m.inputBuffer = m.inputBuffer[:c] + ref + m.inputBuffer[c:]
m.inputCursor = c + len(ref)
m.fileFilter = ""
}
// filteredFiles narrows the workspace paths by the typed filter (case-insensitive substring),
// capped for render.
func (m Model) filteredFiles() []string {
if m.fileFilter == "" {
if len(m.files) > fileListMax {
return m.files[:fileListMax]
}
return m.files
}
f := strings.ToLower(m.fileFilter)
out := make([]string, 0, fileListMax)
for _, p := range m.files {
if strings.Contains(strings.ToLower(p), f) {
out = append(out, p)
if len(out) >= fileListMax {
break
}
}
}
return out
}
// beginIntent stashes the chosen workflow and switches to a text-entry prompt for the
// freeform request. Submitting sends StartSession(id, intent); an empty line starts with no
// intent (fixed-task workflows). Esc cancels.
@@ -638,6 +706,31 @@ func (m Model) handleOverlayKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case runeIs(k, "I"):
m.overlay = OverlayNone
}
case OverlayFiles:
files := m.filteredFiles()
switch {
case k.Type == tea.KeyUp:
if m.fileIndex > 0 {
m.fileIndex--
}
case k.Type == tea.KeyDown:
if m.fileIndex < len(files)-1 {
m.fileIndex++
}
case k.Type == tea.KeyEnter:
if m.fileIndex >= 0 && m.fileIndex < len(files) {
m.insertFileRef(files[m.fileIndex])
}
m.overlay = OverlayNone // back to typing (still in insert mode)
case k.Type == tea.KeyBackspace:
if n := len(m.fileFilter); n > 0 {
m.fileFilter = m.fileFilter[:n-1]
m.fileIndex = 0
}
case k.Type == tea.KeyRunes || k.Type == tea.KeySpace:
m.fileFilter += string(k.Runes)
m.fileIndex = 0
}
}
return m, nil
}