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 OverlayStats
OverlayIdeas OverlayIdeas
OverlaySessions OverlaySessions
OverlayFiles
) )
// RouterEntry is one line in a session's conversation transcript. // RouterEntry is one line in a session's conversation transcript.
@@ -306,6 +307,13 @@ type Model struct {
paletteFilter string paletteFilter string
paletteIndex int 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 // animation
frame int // tick counter; drives spinner + caret blink frame int // tick counter; drives spinner + caret blink
ticking bool // true while a tick loop is scheduled. The loop is gated on animating() 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()) return m.center(m.ideasModal())
case OverlaySessions: case OverlaySessions:
return m.center(m.sessionsModal()) return m.center(m.sessionsModal())
case OverlayFiles:
return m.center(m.filesModal())
} }
return base return base
} }
@@ -520,6 +522,63 @@ func (m Model) paletteModal() string {
return m.center(modal) 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 { func (m Model) toolPaletteModal() string {
t := m.theme t := m.theme
w := m.modalWidth() w := m.modalWidth()
@@ -597,6 +597,7 @@ var nonRenderingEventTypes = map[string]string{
protocol.TypeSnapshotComplete: "control frame: ends the snapshot-buffering phase; no render", 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.TypeRouterResponse: "recognized no-op: superseded by chat.turn on the global stream",
protocol.TypeProtocolError: "transport diagnostic: explicit no-op, not surfaced", 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 // 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) { if m.ideasIndex >= len(m.ideas) {
m.ideasIndex = 0 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: case protocol.TypeConfigSnapshot:
m.configFields = msg.ConfigFields m.configFields = msg.ConfigFields
m.configRestart = msg.ConfigRestartRequired m.configRestart = msg.ConfigRestartRequired
@@ -371,7 +378,7 @@ func sessionIDOf(msg protocol.ServerMessage) string {
protocol.TypeWorkflowList, protocol.TypeRouterResponse, protocol.TypeWorkflowList, protocol.TypeRouterResponse,
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus, protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats, protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
protocol.TypeIdeaList: protocol.TypeIdeaList, protocol.TypeFileList:
return "" return ""
default: default:
return msg.SessionID 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. // copiedFlashFrames is how many ticks the "✓ copied" footer note lingers after a yank.
const copiedFlashFrames = 12 const copiedFlashFrames = 12
// fileListMax caps how many @-picker rows are filtered/rendered at once.
const fileListMax = 200
func readServer(c *ws.Client) tea.Cmd { func readServer(c *ws.Client) tea.Cmd {
return func() tea.Msg { return serverMsg{<-c.Incoming()} } return func() tea.Msg { return serverMsg{<-c.Incoming()} }
} }
@@ -51,7 +54,7 @@ func (m Model) animating() bool {
if m.reconnecting { if m.reconnecting {
return true 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 return true
} }
if m.copiedFlash != 0 && m.frame-m.copiedFlash < copiedFlashFrames { 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() m.openPalette()
return m, nil 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.appendRunes(string(k.Runes))
m.syncFilter() m.syncFilter()
} }
@@ -462,6 +471,65 @@ func (m *Model) openPalette() {
m.paletteIndex = 0 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 // 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 // freeform request. Submitting sends StartSession(id, intent); an empty line starts with no
// intent (fixed-task workflows). Esc cancels. // 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"): case runeIs(k, "I"):
m.overlay = OverlayNone 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 return m, nil
} }
+3
View File
@@ -227,6 +227,9 @@ func (m Model) renderFooter() string {
if m.inputMode == ModeRouter && m.inputBuffer == "" { if m.inputMode == ModeRouter && m.inputBuffer == "" {
hints = append(hints, hint("/", "cmds")) hints = append(hints, hint("/", "cmds"))
} }
if m.inputMode == ModeRouter {
hints = append(hints, hint("@", "files"))
}
case m.displayState() == StateApproval: case m.displayState() == StateApproval:
// Approval actions live in the band itself; the footer offers navigation only. // Approval actions live in the band itself; the footer offers navigation only.
hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")} hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")}
+10
View File
@@ -55,6 +55,7 @@ const (
TypeConfigSnapshot = "config.snapshot" TypeConfigSnapshot = "config.snapshot"
TypeSessionStats = "session.stats" TypeSessionStats = "session.stats"
TypeIdeaList = "idea.list" TypeIdeaList = "idea.list"
TypeFileList = "file.list"
) )
// ServerMessage is a flat decode of every server->client variant. Field names // ServerMessage is a flat decode of every server->client variant. Field names
@@ -150,6 +151,9 @@ type ServerMessage struct {
// idea.list — the cross-session idea board // idea.list — the cross-session idea board
Ideas []IdeaDto `json:"ideas"` Ideas []IdeaDto `json:"ideas"`
// file.list — the session workspace's file paths (for the @ file-ref picker)
Paths []string `json:"paths"`
} }
// IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto. // IdeaDto is one idea on the cross-session board. Mirrors the Kotlin IdeaDto.
@@ -386,6 +390,12 @@ func ListArtifacts(sessionID string) []byte {
return encode("ListArtifacts", map[string]any{"sessionId": sessionID}) return encode("ListArtifacts", map[string]any{"sessionId": sessionID})
} }
// ListFiles asks the server for the session workspace's file paths (replied to with file.list),
// used to populate the @ file-reference picker.
func ListFiles(sessionID string) []byte {
return encode("ListFiles", map[string]any{"sessionId": sessionID})
}
// GetSessionStats asks the server for a session's derived metrics (replied to with session.stats). // GetSessionStats asks the server for a session's derived metrics (replied to with session.stats).
func GetSessionStats(sessionID string) []byte { func GetSessionStats(sessionID string) []byte {
return encode("GetSessionStats", map[string]any{"sessionId": sessionID}) return encode("GetSessionStats", map[string]any{"sessionId": sessionID})