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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -55,6 +55,7 @@ const (
|
||||
TypeConfigSnapshot = "config.snapshot"
|
||||
TypeSessionStats = "session.stats"
|
||||
TypeIdeaList = "idea.list"
|
||||
TypeFileList = "file.list"
|
||||
)
|
||||
|
||||
// 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
|
||||
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.
|
||||
@@ -386,6 +390,12 @@ func ListArtifacts(sessionID string) []byte {
|
||||
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).
|
||||
func GetSessionStats(sessionID string) []byte {
|
||||
return encode("GetSessionStats", map[string]any{"sessionId": sessionID})
|
||||
|
||||
Reference in New Issue
Block a user