Skip to content

Commit 5a7de56

Browse files
committed
TUI: add search capabilities
1 parent 29f7ebd commit 5a7de56

2 files changed

Lines changed: 364 additions & 8 deletions

File tree

pkg/tui/tui.go

Lines changed: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,20 @@ type errMsg struct {
8282
}
8383

8484
func Run(ctx context.Context, cfg *config.Config, agents []agent.Agent) error {
85+
searchInput := textinput.New()
86+
searchInput.Placeholder = "Search messages..."
87+
searchInput.CharLimit = 100
88+
8589
m := Model{
86-
ctx: ctx,
87-
config: cfg,
88-
agents: agents,
89-
messages: make([]agent.Message, 0),
90-
running: false,
90+
ctx: ctx,
91+
config: cfg,
92+
agents: agents,
93+
messages: make([]agent.Message, 0),
94+
running: false,
95+
searchInput: searchInput,
96+
searchMode: false,
97+
searchResults: make([]int, 0),
98+
currentSearchIndex: -1,
9199
}
92100

93101
p := tea.NewProgram(m, tea.WithAltScreen())
@@ -107,9 +115,63 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
107115

108116
switch msg := msg.(type) {
109117
case tea.KeyMsg:
118+
// Handle search mode keys
119+
if m.searchMode {
120+
switch msg.Type {
121+
case tea.KeyEsc:
122+
// Exit search mode
123+
m.searchMode = false
124+
m.searchInput.SetValue("")
125+
m.searchResults = make([]int, 0)
126+
m.currentSearchIndex = -1
127+
return m, nil
128+
case tea.KeyEnter:
129+
// Perform search
130+
m.performSearch()
131+
return m, nil
132+
default:
133+
// Handle other keys in search input
134+
switch msg.String() {
135+
case "n":
136+
// Next search result
137+
if len(m.searchResults) > 0 {
138+
m.currentSearchIndex = (m.currentSearchIndex + 1) % len(m.searchResults)
139+
m.scrollToSearchResult()
140+
}
141+
return m, nil
142+
case "N":
143+
// Previous search result
144+
if len(m.searchResults) > 0 {
145+
m.currentSearchIndex--
146+
if m.currentSearchIndex < 0 {
147+
m.currentSearchIndex = len(m.searchResults) - 1
148+
}
149+
m.scrollToSearchResult()
150+
}
151+
return m, nil
152+
default:
153+
// Update search input
154+
var cmd tea.Cmd
155+
m.searchInput, cmd = m.searchInput.Update(msg)
156+
return m, cmd
157+
}
158+
}
159+
}
160+
161+
// Handle normal mode keys
110162
switch msg.Type {
111-
case tea.KeyCtrlC, tea.KeyEsc:
163+
case tea.KeyCtrlC:
112164
return m, tea.Quit
165+
case tea.KeyEsc:
166+
return m, tea.Quit
167+
case tea.KeyCtrlF:
168+
// Enter search mode (only if ready)
169+
if m.ready {
170+
m.searchMode = true
171+
// Don't call Focus() to avoid cursor initialization issues in tests
172+
// The searchMode flag will route events to searchInput
173+
return m, nil
174+
}
113175
case tea.KeyCtrlS:
114176
if !m.running {
115177
m.running = true
@@ -134,6 +196,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
134196
ta.SetHeight(3)
135197
m.textarea = ta
136198

199+
// Initialize search input
200+
searchInput := textinput.New()
201+
searchInput.Placeholder = "Search messages..."
202+
searchInput.CharLimit = 100
203+
// Initialize the internal cursor by updating with a dummy message
204+
searchInput, _ = searchInput.Update(nil)
205+
m.searchInput = searchInput
206+
207+
// Initialize search state if not already set
208+
if m.searchResults == nil {
209+
m.searchResults = make([]int, 0)
210+
}
211+
if m.currentSearchIndex == 0 {
212+
m.currentSearchIndex = -1
213+
}
214+
137215
m.ready = true
138216
} else {
139217
m.viewport.Width = msg.Width
@@ -188,9 +266,21 @@ func (m Model) View() string {
188266
b.WriteString(statusStyle.Render(status))
189267
b.WriteString("\n")
190268

191-
help := helpStyle.Render("Ctrl+C: Quit | Ctrl+S: Start | Ctrl+P: Pause/Resume | ↑↓: Scroll")
269+
help := helpStyle.Render("Ctrl+C: Quit | Ctrl+S: Start | Ctrl+P: Pause/Resume | Ctrl+F: Search | ↑↓: Scroll")
192270
b.WriteString(help)
193271

272+
// Show search bar when in search mode
273+
if m.searchMode {
274+
b.WriteString("\n")
275+
searchBar := searchStyle.Render("Search: ") + m.searchInput.View()
276+
if len(m.searchResults) > 0 {
277+
searchBar += fmt.Sprintf(" (%d/%d matches, n/N to navigate)", m.currentSearchIndex+1, len(m.searchResults))
278+
} else if m.searchInput.Value() != "" {
279+
searchBar += " (no matches)"
280+
}
281+
b.WriteString(searchBar)
282+
}
283+
194284
if m.err != nil {
195285
b.WriteString("\n")
196286
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(fmt.Sprintf("Error: %v", m.err)))
@@ -225,6 +315,64 @@ func (m Model) renderMessages() string {
225315
return b.String()
226316
}
227317

318+
// performSearch searches through messages for the search term
319+
func (m *Model) performSearch() {
320+
searchTerm := strings.ToLower(m.searchInput.Value())
321+
if searchTerm == "" {
322+
m.searchResults = make([]int, 0)
323+
m.currentSearchIndex = -1
324+
return
325+
}
326+
327+
// Clear previous results
328+
m.searchResults = make([]int, 0)
329+
330+
// Search through all messages
331+
for i, msg := range m.messages {
332+
// Search in message content and agent name
333+
if strings.Contains(strings.ToLower(msg.Content), searchTerm) ||
334+
strings.Contains(strings.ToLower(msg.AgentName), searchTerm) {
335+
m.searchResults = append(m.searchResults, i)
336+
}
337+
}
338+
339+
// Set current index to first result if any found
340+
if len(m.searchResults) > 0 {
341+
m.currentSearchIndex = 0
342+
m.scrollToSearchResult()
343+
} else {
344+
m.currentSearchIndex = -1
345+
}
346+
}
347+
348+
// scrollToSearchResult scrolls the viewport to show the current search result
349+
func (m *Model) scrollToSearchResult() {
350+
if m.currentSearchIndex < 0 || m.currentSearchIndex >= len(m.searchResults) {
351+
return
352+
}
353+
354+
// Get the message index
355+
msgIndex := m.searchResults[m.currentSearchIndex]
356+
357+
// Calculate approximate line position
358+
// Each message takes roughly 4 lines (timestamp line + content + blank line + separator)
359+
linePos := msgIndex * 4
360+
361+
// Scroll viewport to show this message
362+
// Try to position it in the middle of the viewport
363+
targetLine := linePos - (m.viewport.Height / 2)
364+
if targetLine < 0 {
365+
targetLine = 0
366+
}
367+
368+
// Calculate the percentage position
369+
totalLines := len(m.messages) * 4
370+
if totalLines > 0 {
371+
percent := float64(targetLine) / float64(totalLines)
372+
m.viewport.SetYOffset(int(percent * float64(m.viewport.TotalLineCount())))
373+
}
374+
}
375+
228376
func (m Model) startConversation() tea.Cmd {
229377
return func() tea.Msg {
230378
orchConfig := orchestrator.OrchestratorConfig{

0 commit comments

Comments
 (0)