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
@@ -0,0 +1,62 @@
package app
import "testing"
func TestAtTokenBoundary(t *testing.T) {
cases := []struct {
buf string
cur int
want bool
}{
{"", 0, true}, // start of empty line
{"see ", 4, true}, // just after a space
{"email", 5, false}, // mid-word (e.g. user@host)
{"a b", 2, true}, // after the space
{"a b", 3, false}, // after 'b'
}
for _, c := range cases {
m := NewModel(nil)
m.inputBuffer = c.buf
m.inputCursor = c.cur
if got := m.atTokenBoundary(); got != c.want {
t.Errorf("atTokenBoundary(%q,@%d) = %v, want %v", c.buf, c.cur, got, c.want)
}
}
}
// `@` consumes itself to open the picker; selecting a file inserts `@path ` at the cursor.
func TestInsertFileRef(t *testing.T) {
m := NewModel(nil)
m.inputBuffer = "look at "
m.inputCursor = len(m.inputBuffer)
m.insertFileRef("src/main.go")
if want := "look at @src/main.go "; m.inputBuffer != want {
t.Fatalf("buffer = %q, want %q", m.inputBuffer, want)
}
if m.inputCursor != len(m.inputBuffer) {
t.Fatalf("cursor = %d, want %d", m.inputCursor, len(m.inputBuffer))
}
}
func TestFilteredFiles(t *testing.T) {
m := NewModel(nil)
m.files = []string{"cmd/main.go", "internal/app/view.go", "README.md", "internal/app/model.go"}
m.fileFilter = ""
if len(m.filteredFiles()) != 4 {
t.Fatalf("empty filter should pass all 4, got %d", len(m.filteredFiles()))
}
m.fileFilter = "app/"
got := m.filteredFiles()
if len(got) != 2 {
t.Fatalf("filter %q matched %d, want 2 (%v)", m.fileFilter, len(got), got)
}
m.fileFilter = "README"
if got := m.filteredFiles(); len(got) != 1 || got[0] != "README.md" {
t.Fatalf("filter README = %v, want [README.md]", got)
}
}
+8
View File
@@ -57,6 +57,7 @@ const (
OverlayStats
OverlayIdeas
OverlaySessions
OverlayFiles
)
// RouterEntry is one line in a session's conversation transcript.
@@ -306,6 +307,13 @@ type Model struct {
paletteFilter string
paletteIndex int
// @ file picker (OverlayFiles) — workspace paths from the file.list reply
files []string // all workspace-relative paths for filesFor
filesFor string // sessionId the file list belongs to
filesLoading bool
fileFilter string // the query typed after @ (narrows the list)
fileIndex int
// animation
frame int // tick counter; drives spinner + caret blink
ticking bool // true while a tick loop is scheduled. The loop is gated on animating()
+59
View File
@@ -124,6 +124,8 @@ func (m Model) renderOverlay(base string) string {
return m.center(m.ideasModal())
case OverlaySessions:
return m.center(m.sessionsModal())
case OverlayFiles:
return m.center(m.filesModal())
}
return base
}
@@ -520,6 +522,63 @@ func (m Model) paletteModal() string {
return m.center(modal)
}
// filesModal is the `@` file-reference picker: a filter line over the session workspace's
// paths, windowed around the selection; enter inserts `@path` into the chat input.
func (m Model) filesModal() string {
t := m.theme
w := m.modalWidth()
files := m.filteredFiles()
var b strings.Builder
b.WriteString(m.titleLine("@ file reference") + "\n\n")
caret := mbg(t, " ", t.P.BgPanel)
if m.caretVisible() {
caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
}
prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("@ ")
if m.fileFilter == "" {
b.WriteString(prompt + caret + mbg(t, "type to filter files…", t.P.Faint) + "\n\n")
} else {
b.WriteString(prompt + mbg(t, m.fileFilter, t.P.FgStrong) + caret + "\n\n")
}
switch {
case m.filesLoading:
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
case len(m.files) == 0:
b.WriteString(mbg(t, " no workspace files (is a workspace bound?)", t.P.Faint) + "\n")
case len(files) == 0:
b.WriteString(mbg(t, " no matching files", t.P.Faint) + "\n")
default:
const maxRows = 12
start := 0
if m.fileIndex >= maxRows {
start = m.fileIndex - maxRows + 1
}
end := start + maxRows
if end > len(files) {
end = len(files)
}
for i := start; i < end; i++ {
marker := mbg(t, " ", t.P.BgPanel)
fg := t.P.Fg
if i == m.fileIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▌ ")
fg = t.P.FgStrong
}
b.WriteString(marker +
lipgloss.NewStyle().Foreground(fg).Background(t.P.BgPanel).Render(clip(files[i], w-6)) + "\n")
}
if len(files) > maxRows {
b.WriteString(mbg(t, " "+itoa(len(files))+" matches", t.P.Faint) + "\n")
}
}
b.WriteString("\n" + modalHints(t, [][2]string{{"↑↓", "select"}, {"enter", "insert"}, {"esc", "close"}}))
modal := t.Overlay.Width(w).Render(b.String())
return m.center(modal)
}
func (m Model) toolPaletteModal() string {
t := m.theme
w := m.modalWidth()
@@ -597,6 +597,7 @@ var nonRenderingEventTypes = map[string]string{
protocol.TypeSnapshotComplete: "control frame: ends the snapshot-buffering phase; no render",
protocol.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream",
protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced",
protocol.TypeFileList: "query reply: populates the @ file picker overlay, not the transcript",
}
// TestRenderMatrixCoversEveryEventType is the load-bearing matrix guarantee: it scans
+8 -1
View File
@@ -326,6 +326,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if m.ideasIndex >= len(m.ideas) {
m.ideasIndex = 0
}
case protocol.TypeFileList:
m.files = msg.Paths
m.filesFor = msg.SessionID
m.filesLoading = false
if m.fileIndex >= len(m.filteredFiles()) {
m.fileIndex = 0
}
case protocol.TypeConfigSnapshot:
m.configFields = msg.ConfigFields
m.configRestart = msg.ConfigRestartRequired
@@ -371,7 +378,7 @@ func sessionIDOf(msg protocol.ServerMessage) string {
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
protocol.TypeIdeaList:
protocol.TypeIdeaList, protocol.TypeFileList:
return ""
default:
return msg.SessionID
+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
}
+3
View File
@@ -227,6 +227,9 @@ func (m Model) renderFooter() string {
if m.inputMode == ModeRouter && m.inputBuffer == "" {
hints = append(hints, hint("/", "cmds"))
}
if m.inputMode == ModeRouter {
hints = append(hints, hint("@", "files"))
}
case m.displayState() == StateApproval:
// Approval actions live in the band itself; the footer offers navigation only.
hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")}