From 7a273fd562ce71166f2379f0f9ea2d54308abf02 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Thu, 30 Jul 2026 01:28:59 +0200 Subject: [PATCH] Add `/` to filter claude-mux sessions --- claude-mux/README.md | 7 +- claude-mux/go.mod | 19 ++-- claude-mux/go.sum | 43 ++++--- claude-mux/internal/ui/list.go | 171 +++++++++++++++++++++++++--- claude-mux/internal/ui/list_test.go | 38 +++++++ claude-mux/main.go | 2 +- modules/cli/home.nix | 2 + overlays/default.nix | 13 ++- 8 files changed, 252 insertions(+), 43 deletions(-) diff --git a/claude-mux/README.md b/claude-mux/README.md index 5699303..645197d 100644 --- a/claude-mux/README.md +++ b/claude-mux/README.md @@ -77,6 +77,7 @@ The live states (running / waiting / open) are reported by Claude Code hooks | -------------- | ----------------------------------------------- | | `↑`/`↓`, `j`/`k` | Move the selection | | `enter` | Open the selected session (restores it if archived) | +| `/` | Search: filter the list by title as you type | | `p` | Preview: open without restoring an archived session | | `n` | Start a fresh session | | `x` | Archive the selected session | @@ -86,7 +87,11 @@ The live states (running / waiting / open) are reported by Claude Code hooks Selecting a session that is already running just jumps to its window; selecting an idle one resumes it (`claude --resume`) in a new window. Opening an archived session with `enter` also restores it out of the archived section; `p` opens it -the same way but leaves it archived, so you can peek without restoring. With `ctrl+a` (or +the same way but leaves it archived, so you can peek without restoring. Press `/` +to filter the list to sessions whose title matches what you type — the box has the +usual line-editing keys (`ctrl+w`/`ctrl+u`, word motions, `ctrl+a`/`ctrl+e`, …), +`↑`/`↓` still move the selection, and `enter` opens the highlighted match. +`esc`, `ctrl+c`, or clearing the box back to empty leaves the search. With `ctrl+a` (or `claude-mux list --all`) the picker shows sessions from every project, labelled by project; resuming one there opens (or switches to) that project's session. diff --git a/claude-mux/go.mod b/claude-mux/go.mod index 4d55cc2..503c1ab 100644 --- a/claude-mux/go.mod +++ b/claude-mux/go.mod @@ -3,26 +3,31 @@ module claude-mux go 1.26.5 require ( + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/term v0.2.1 + github.com/charmbracelet/x/term v0.2.2 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/claude-mux/go.sum b/claude-mux/go.sum index 4789639..e92ed90 100644 --- a/claude-mux/go.sum +++ b/claude-mux/go.sum @@ -1,43 +1,52 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/claude-mux/internal/ui/list.go b/claude-mux/internal/ui/list.go index 8d012eb..ce26483 100644 --- a/claude-mux/internal/ui/list.go +++ b/claude-mux/internal/ui/list.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -53,6 +54,8 @@ var ( // currentTag marks the row whose session is open in the pane the picker was // floated over ("you are here"). currentTag = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) + // searchStyle renders the "/query" search prompt in the footer. + searchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) // Per-status colours. runningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green @@ -93,8 +96,10 @@ type model struct { dir string srv *tmux.Server all bool - current string // session id open in the pane the picker was floated over - entries []manager.Entry + current string // session id open in the pane the picker was floated over + raw []manager.Entry // every loaded session, before the title filter + entries []manager.Entry // raw narrowed to the active search query + search textinput.Model // the "/" title filter; focused == actively searching cursor int offset int // index of the first visible row (scrolling) width int @@ -108,7 +113,11 @@ type model struct { // RunPicker shows the interactive picker for dir and returns the chosen action. // When all is true it starts listing sessions across every project. func RunPicker(dir string, all bool, srv *tmux.Server) (Result, error) { - m := &model{dir: dir, all: all, srv: srv} + ti := textinput.New() + ti.Prompt = "/" + ti.PromptStyle = searchStyle + ti.Placeholder = "filter by title" + m := &model{dir: dir, all: all, srv: srv, search: ti} if srv.InsideOurServer() { m.current = srv.CurrentSessionID() } @@ -155,7 +164,8 @@ func (m *model) applyLoad(msg loadedMsg) { if m.cursor >= 0 && m.cursor < len(m.entries) { selectedID = m.entries[m.cursor].ID } - m.entries = msg.entries + m.raw = msg.entries + m.applyFilter() if selectedID != "" { for i, e := range m.entries { if e.ID == selectedID { @@ -173,6 +183,27 @@ func (m *model) applyLoad(msg loadedMsg) { m.clampScroll() } +// applyFilter narrows m.raw into m.entries by the active search query, matching +// case-insensitively against session titles. An empty query shows everything. +func (m *model) applyFilter() { + q := strings.ToLower(strings.TrimSpace(m.search.Value())) + if q == "" { + m.entries = m.raw + return + } + filtered := make([]manager.Entry, 0, len(m.raw)) + for _, e := range m.raw { + if strings.Contains(strings.ToLower(e.Title), q) { + filtered = append(filtered, e) + } + } + m.entries = filtered +} + +// searchActive reports whether the search box is open (focused) or a title +// filter is otherwise in play. +func (m *model) searchActive() bool { return m.search.Focused() || m.search.Value() != "" } + // reloadInterval is how often an open picker re-reads sessions so its statuses, // titles and message counts stay live without the user pressing "r". const reloadInterval = time.Second @@ -204,7 +235,11 @@ func (m *model) dims() (width, height int) { // occupy at the current width. func (m *model) footerHeight() int { width, _ := m.dims() - return lipgloss.Height(helpStyle.Width(width).Render(m.helpText())) + 1 + h := lipgloss.Height(helpStyle.Width(width).Render(m.helpText())) + 1 + if m.searchActive() { + h++ // the "/query" search line sits above the help + } + return h } // hasArchived reports whether any entry is archived, i.e. whether the archived @@ -265,11 +300,17 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = msg.Width, msg.Height m.clampScroll() case tea.KeyMsg: + if m.search.Focused() { + return m.updateSearch(msg) + } var cmd tea.Cmd switch msg.String() { case "ctrl+c", "q", "esc": m.result = Result{Action: ActionNone} return m, tea.Quit + case "/": + // Open the search box; Focus returns the cursor-blink command. + return m, m.search.Focus() case "up", "k": if m.cursor > 0 { m.cursor-- @@ -319,13 +360,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": // Opening an archived session restores it (un-archives it); a live one // is opened as-is. - if len(m.entries) > 0 { - e := m.entries[m.cursor] - if e.Archived { - _ = state.Unarchive(e.ID) - } - m.result = Result{Action: ActionResume, Entry: e} - return m, tea.Quit + if newM, cmd, ok := m.openSelected(); ok { + return newM, cmd } case "p": // Preview: open the selected session without changing its archived @@ -338,9 +374,87 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.clampScroll() return m, cmd } + // Forward everything else (notably the cursor-blink ticks) to the search box + // while it is focused, so its cursor keeps blinking. + if m.search.Focused() { + var cmd tea.Cmd + m.search, cmd = m.search.Update(msg) + return m, cmd + } return m, nil } +// openSelected opens the highlighted session, restoring it first if archived. +// It reports ok=false (nothing to do) when the list is empty. +func (m *model) openSelected() (tea.Model, tea.Cmd, bool) { + if len(m.entries) == 0 { + return m, nil, false + } + e := m.entries[m.cursor] + if e.Archived { + _ = state.Unarchive(e.ID) + } + m.result = Result{Action: ActionResume, Entry: e} + return m, tea.Quit, true +} + +// stopSearch closes the search box and drops the filter, returning to the full +// list. Used by esc, ctrl+c, and clearing the input to empty. +func (m *model) stopSearch() { + m.search.Blur() + m.search.SetValue("") + m.applyFilter() + m.cursor = 0 + m.clampScroll() +} + +// updateSearch handles keys while the search box is open. Navigation (enter, +// up/down) and exit keys (esc, ctrl+c) are handled here; every other key is +// forwarded to the textinput, which owns all the line-editing bindings +// (ctrl+w/u/a/e, word motions, space, paste, …). Clearing the input to empty +// also exits the search, per the same "empty means done" rule as esc. +func (m *model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+c", "esc": + m.stopSearch() + return m, nil + case "enter": + // Open the highlighted match; if nothing matches there is nothing to do. + if newM, cmd, ok := m.openSelected(); ok { + return newM, cmd + } + return m, nil + case "up": + if m.cursor > 0 { + m.cursor-- + } + m.clampScroll() + return m, nil + case "down": + if m.cursor < len(m.entries)-1 { + m.cursor++ + } + m.clampScroll() + return m, nil + } + + before := m.search.Value() + var cmd tea.Cmd + m.search, cmd = m.search.Update(msg) + after := m.search.Value() + + if before != "" && after == "" { + m.stopSearch() // fully cleared the query -> leave search mode + return m, nil + } + if before != after { + m.applyFilter() + m.cursor = 0 + m.clampScroll() + } + return m, cmd +} + func (m *model) View() string { width, _ := m.dims() var b strings.Builder @@ -357,6 +471,8 @@ func (m *model) View() string { switch { case !m.loaded: msg = "Loading sessions…" + case m.searchActive() && len(m.raw) > 0: + msg = fmt.Sprintf("No titles match %q.", m.search.Value()) case m.all: msg = "No sessions found." default: @@ -421,10 +537,30 @@ func (m *model) renderHeader(width int) string { return titleStyle.Render("Claude sessions") + dimStyle.Render(" · ") + dirStyle.Render(truncate(scope, avail)) } -// renderFooter renders the wrapped help text plus a position/summary line. +// renderFooter renders the optional search line, the wrapped help text, and a +// position/summary line. func (m *model) renderFooter(width int) string { - help := helpStyle.Width(width).Render(m.helpText()) - return help + "\n" + dimStyle.Render(m.positionText()) + var b strings.Builder + if line := m.searchLine(width); line != "" { + b.WriteString(line) + b.WriteString("\n") + } + b.WriteString(helpStyle.Width(width).Render(m.helpText())) + b.WriteString("\n") + b.WriteString(dimStyle.Render(m.positionText())) + return b.String() +} + +// searchLine renders the textinput's "/query" prompt (with its live cursor). It +// returns "" when no search is in play. +func (m *model) searchLine(width int) string { + if !m.searchActive() { + return "" + } + if w := width - 1; w > 0 { + m.search.Width = w + } + return m.search.View() } // positionText summarises how much of the list is shown. @@ -515,11 +651,14 @@ func (m *model) renderRow(i int, e manager.Entry, width int) string { } func (m *model) helpText() string { + if m.search.Focused() { + return "type to filter titles · ↑/↓ move · enter open · esc/ctrl+c cancel" + } scope := "ctrl+a all" if m.all { scope = "ctrl+a this project" } - return "↑/↓ move · enter open · p preview · n new · x archive · " + scope + " · q cancel" + return "↑/↓ move · enter open · / search · p preview · n new · x archive · " + scope + " · q cancel" } // pad right-pads s to at least n runes. diff --git a/claude-mux/internal/ui/list_test.go b/claude-mux/internal/ui/list_test.go index 5cd8296..0f6158a 100644 --- a/claude-mux/internal/ui/list_test.go +++ b/claude-mux/internal/ui/list_test.go @@ -3,7 +3,10 @@ package ui import ( "testing" + "github.com/charmbracelet/bubbles/textinput" + "claude-mux/internal/manager" + "claude-mux/internal/session" ) func newTestModel(n, height int) *model { @@ -66,3 +69,38 @@ func TestClampScrollFewerEntriesThanPage(t *testing.T) { t.Fatalf("offset = %d, want 0 (no scroll needed)", m.offset) } } + +func TestApplyFilterMatchesTitlesCaseInsensitively(t *testing.T) { + m := &model{width: 80, height: 24, search: textinput.New()} + m.raw = []manager.Entry{ + {Session: session.Session{ID: "1", Title: "Fix the parser"}}, + {Session: session.Session{ID: "2", Title: "Add search to picker"}}, + {Session: session.Session{ID: "3", Title: "Refactor SEARCH index"}}, + } + + // Empty query shows everything (and shares the raw slice). + m.applyFilter() + if len(m.entries) != 3 { + t.Fatalf("empty query: got %d entries, want 3", len(m.entries)) + } + + // A query narrows to matching titles, ignoring case. + m.search.SetValue("search") + m.applyFilter() + if len(m.entries) != 2 { + t.Fatalf("query %q: got %d entries, want 2", m.search.Value(), len(m.entries)) + } + if m.entries[0].ID != "2" || m.entries[1].ID != "3" { + t.Fatalf("query %q matched wrong entries: %v", m.search.Value(), m.entries) + } + + // A non-matching query yields an empty list but keeps raw intact. + m.search.SetValue("deploy") + m.applyFilter() + if len(m.entries) != 0 { + t.Fatalf("query %q: got %d entries, want 0", m.search.Value(), len(m.entries)) + } + if len(m.raw) != 3 { + t.Fatalf("raw was mutated: got %d, want 3", len(m.raw)) + } +} diff --git a/claude-mux/main.go b/claude-mux/main.go index 8a8941e..a1d00f1 100644 --- a/claude-mux/main.go +++ b/claude-mux/main.go @@ -84,7 +84,7 @@ Inside a session: C-x u show the current Claude usage (limits) in a floating pane C-x d detach (everything keeps running) -In the picker: enter open · n new · x kill selected · ctrl+a all · q cancel +In the picker: enter open · / search titles · n new · x kill selected · ctrl+a all · q cancel In the rc popup: t toggle · s switch to it · q close Environment: diff --git a/modules/cli/home.nix b/modules/cli/home.nix index 7d575a0..a0190d5 100644 --- a/modules/cli/home.nix +++ b/modules/cli/home.nix @@ -111,6 +111,8 @@ # path is persisted at first launch, reset it to hot-reload. home.activation.claudeMuxReload = '' + export TMUX_TMPDIR="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + export PATH="${pkgs.tmux}/bin:$PATH" ${lib.getExe pkgs.claude-mux} reload || true ''; diff --git a/overlays/default.nix b/overlays/default.nix index 9b26594..5aaf499 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -35,7 +35,18 @@ in { ] (name: super.vimPlugins.${name}.overrideAttrs (old: { meta = old.meta // {license = super.lib.licenses.mit;}; - })); + })) + // { + jj-nvim = super.vimPlugins.jj-nvim.overrideAttrs (old: { + version = "feat/browse"; + src = super.fetchFromGitHub { + owner = "zoriya"; + repo = "jj.nvim"; + rev = "742cf28ce4c894782e7784e8f0a15c46121d7f50"; + hash = "sha256-HzTfZCbKYsM+FHd9tXkgWKfrj6LKwrqPUEGN3ik0nNI="; + }; + }); + }; discord = enableWayland super.discord ["discord" "Discord"]; vesktop = enableWayland super.vesktop ["vesktop"];