diff --git a/apps/tui-go/internal/app/file_picker_test.go b/apps/tui-go/internal/app/file_picker_test.go new file mode 100644 index 00000000..fe89314a --- /dev/null +++ b/apps/tui-go/internal/app/file_picker_test.go @@ -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) + } +} diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index ec2d715e..9da2dc0c 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -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() diff --git a/apps/tui-go/internal/app/overlays.go b/apps/tui-go/internal/app/overlays.go index fe5370d4..12d55146 100644 --- a/apps/tui-go/internal/app/overlays.go +++ b/apps/tui-go/internal/app/overlays.go @@ -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() diff --git a/apps/tui-go/internal/app/render_matrix_test.go b/apps/tui-go/internal/app/render_matrix_test.go index 82c8b72b..55af662a 100644 --- a/apps/tui-go/internal/app/render_matrix_test.go +++ b/apps/tui-go/internal/app/render_matrix_test.go @@ -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 diff --git a/apps/tui-go/internal/app/server.go b/apps/tui-go/internal/app/server.go index 60039e5b..2ff8cb4a 100644 --- a/apps/tui-go/internal/app/server.go +++ b/apps/tui-go/internal/app/server.go @@ -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 diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 1c16739f..2dfd3b04 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -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 } diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index 75e3d568..ec977b75 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -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")} diff --git a/apps/tui-go/internal/protocol/protocol.go b/apps/tui-go/internal/protocol/protocol.go index b7e3e41d..dc6b363e 100644 --- a/apps/tui-go/internal/protocol/protocol.go +++ b/apps/tui-go/internal/protocol/protocol.go @@ -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})