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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user