gora-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gora/go_tui/main.go ADDED
@@ -0,0 +1,2634 @@
1
+ package main
2
+
3
+ import (
4
+ "database/sql"
5
+ "errors"
6
+ "flag"
7
+ "fmt"
8
+ "os"
9
+ "path/filepath"
10
+ "regexp"
11
+ "runtime"
12
+ "sort"
13
+ "strings"
14
+ "time"
15
+
16
+ "github.com/charmbracelet/bubbles/help"
17
+ "github.com/charmbracelet/bubbles/key"
18
+ "github.com/charmbracelet/bubbles/list"
19
+ "github.com/charmbracelet/bubbles/table"
20
+ "github.com/charmbracelet/bubbles/textinput"
21
+ "github.com/charmbracelet/bubbles/viewport"
22
+ tea "github.com/charmbracelet/bubbletea"
23
+ "github.com/charmbracelet/lipgloss"
24
+ "github.com/muesli/termenv"
25
+ _ "modernc.org/sqlite"
26
+ )
27
+
28
+ var (
29
+ bg = lipgloss.Color("#1a1b26")
30
+ surface = lipgloss.Color("#1f2335")
31
+ fg = lipgloss.Color("#c0caf5")
32
+ muted = lipgloss.Color("#565f89")
33
+ border = lipgloss.Color("#3b4261")
34
+ cyan = lipgloss.Color("#7dcfff")
35
+ purple = lipgloss.Color("#bb9af7")
36
+ green = lipgloss.Color("#9ece6a")
37
+ yellow = lipgloss.Color("#e0af68")
38
+ red = lipgloss.Color("#f7768e")
39
+
40
+ baseStyle = lipgloss.NewStyle().Foreground(fg).Background(bg)
41
+ mutedStyle = lipgloss.NewStyle().Foreground(muted).Background(bg)
42
+ sectionStyle = lipgloss.NewStyle().Foreground(cyan).Background(bg).Bold(true)
43
+ selectedStyle = lipgloss.NewStyle().Foreground(fg).Background(bg).Bold(true)
44
+ searchStyle = lipgloss.NewStyle().Foreground(fg).Background(surface).Border(lipgloss.NormalBorder()).BorderForeground(border).Padding(0, 1)
45
+ searchText = lipgloss.NewStyle().Foreground(fg).Background(surface)
46
+ searchHint = lipgloss.NewStyle().Foreground(muted).Background(surface)
47
+ searchCursor = lipgloss.NewStyle().Foreground(bg).Background(yellow)
48
+
49
+ imageTagPattern = regexp.MustCompile(`(?s)</?image\b[^>]*(>|$)`)
50
+ )
51
+
52
+ type item struct {
53
+ SessionKey string
54
+ Provider string
55
+ Title string
56
+ Timestamp string
57
+ CreatedAt string
58
+ UpdatedAt string
59
+ Cwd string
60
+ Models string
61
+ Preview string
62
+ MessageCount int
63
+ Ordinal int
64
+ IsMatch bool
65
+ }
66
+
67
+ type focusArea int
68
+
69
+ const (
70
+ focusSearch focusArea = iota
71
+ focusFilters
72
+ focusResults
73
+ )
74
+
75
+ type filterSection int
76
+
77
+ const (
78
+ sectionHarness filterSection = iota
79
+ sectionRepos
80
+ sectionModels
81
+ sectionRoles
82
+ filterSectionCount
83
+ )
84
+
85
+ type filterOption struct {
86
+ Value string
87
+ Label string
88
+ Count int
89
+ }
90
+
91
+ type filterListItem struct {
92
+ option filterOption
93
+ selected bool
94
+ section filterSection
95
+ description string
96
+ }
97
+
98
+ func (i filterListItem) FilterValue() string {
99
+ return i.option.Label + " " + i.option.Value
100
+ }
101
+
102
+ func (i filterListItem) Title() string {
103
+ check := "[ ]"
104
+ if i.selected {
105
+ check = "[x]"
106
+ }
107
+ label := i.option.Label
108
+ if i.section == sectionRepos {
109
+ label = shortCwd(i.option.Value)
110
+ }
111
+ return check + " " + label
112
+ }
113
+
114
+ func (i filterListItem) Description() string {
115
+ if i.description != "" {
116
+ return i.description
117
+ }
118
+ if i.option.Count > 0 {
119
+ return fmt.Sprintf("%d chats", i.option.Count)
120
+ }
121
+ return i.option.Value
122
+ }
123
+
124
+ type transcriptMessage struct {
125
+ Role string
126
+ Timestamp string
127
+ Model string
128
+ Text string
129
+ }
130
+
131
+ type actionKind int
132
+
133
+ const (
134
+ actionNone actionKind = iota
135
+ actionOpen
136
+ actionBack
137
+ actionExportMarkdown
138
+ actionExportText
139
+ actionFilters
140
+ )
141
+
142
+ type actionButton struct {
143
+ Label string
144
+ Action actionKind
145
+ X int
146
+ Y int
147
+ Width int
148
+ }
149
+
150
+ type keySet struct {
151
+ short []key.Binding
152
+ full [][]key.Binding
153
+ }
154
+
155
+ func (k keySet) ShortHelp() []key.Binding {
156
+ return k.short
157
+ }
158
+
159
+ func (k keySet) FullHelp() [][]key.Binding {
160
+ return k.full
161
+ }
162
+
163
+ type keyMap struct {
164
+ Open key.Binding
165
+ Back key.Binding
166
+ ExportMarkdown key.Binding
167
+ ExportText key.Binding
168
+ Filter key.Binding
169
+ FilterHarness key.Binding
170
+ FilterRepos key.Binding
171
+ FilterModels key.Binding
172
+ FilterRoles key.Binding
173
+ AdvanceFilter key.Binding
174
+ Search key.Binding
175
+ Focus key.Binding
176
+ Move key.Binding
177
+ Select key.Binding
178
+ Clear key.Binding
179
+ ClearSection key.Binding
180
+ Apply key.Binding
181
+ Quit key.Binding
182
+ }
183
+
184
+ func newKeyMap() keyMap {
185
+ return keyMap{
186
+ Open: key.NewBinding(
187
+ key.WithKeys("enter", "right"),
188
+ key.WithHelp("enter", "open"),
189
+ ),
190
+ Back: key.NewBinding(
191
+ key.WithKeys("esc", "left"),
192
+ key.WithHelp("esc/left", "back"),
193
+ ),
194
+ ExportMarkdown: key.NewBinding(
195
+ key.WithKeys("m"),
196
+ key.WithHelp("m", "export md"),
197
+ ),
198
+ ExportText: key.NewBinding(
199
+ key.WithKeys("t"),
200
+ key.WithHelp("t", "export txt"),
201
+ ),
202
+ Filter: key.NewBinding(
203
+ key.WithKeys("ctrl+f", "enter"),
204
+ key.WithHelp("ctrl+f", "filters"),
205
+ ),
206
+ FilterHarness: key.NewBinding(
207
+ key.WithKeys("1", "h"),
208
+ key.WithHelp("1/h", "harness"),
209
+ ),
210
+ FilterRepos: key.NewBinding(
211
+ key.WithKeys("2", "r"),
212
+ key.WithHelp("2/r", "repos"),
213
+ ),
214
+ FilterModels: key.NewBinding(
215
+ key.WithKeys("3", "m"),
216
+ key.WithHelp("3/m", "models"),
217
+ ),
218
+ FilterRoles: key.NewBinding(
219
+ key.WithKeys("4", "o"),
220
+ key.WithHelp("4/o", "roles"),
221
+ ),
222
+ AdvanceFilter: key.NewBinding(
223
+ key.WithKeys("enter"),
224
+ key.WithHelp("enter", "select + next"),
225
+ ),
226
+ Search: key.NewBinding(
227
+ key.WithKeys("/"),
228
+ key.WithHelp("/", "search"),
229
+ ),
230
+ Focus: key.NewBinding(
231
+ key.WithKeys("tab", "shift+tab"),
232
+ key.WithHelp("tab", "focus"),
233
+ ),
234
+ Move: key.NewBinding(
235
+ key.WithKeys("up", "down", "k", "j", "pgup", "pgdown"),
236
+ key.WithHelp("↑/↓", "move"),
237
+ ),
238
+ Select: key.NewBinding(
239
+ key.WithKeys(" ", "enter"),
240
+ key.WithHelp("space", "select"),
241
+ ),
242
+ Clear: key.NewBinding(
243
+ key.WithKeys("ctrl+u"),
244
+ key.WithHelp("ctrl+u", "clear"),
245
+ ),
246
+ ClearSection: key.NewBinding(
247
+ key.WithKeys("c"),
248
+ key.WithHelp("c", "clear section"),
249
+ ),
250
+ Apply: key.NewBinding(
251
+ key.WithKeys("esc", "left"),
252
+ key.WithHelp("esc", "apply"),
253
+ ),
254
+ Quit: key.NewBinding(
255
+ key.WithKeys("ctrl+c", "q"),
256
+ key.WithHelp("ctrl+c", "quit"),
257
+ ),
258
+ }
259
+ }
260
+
261
+ func newHelpModel() help.Model {
262
+ model := help.New()
263
+ model.ShortSeparator = " "
264
+ model.FullSeparator = " "
265
+ model.Styles.ShortKey = lipgloss.NewStyle().Foreground(cyan).Background(bg).Bold(true)
266
+ model.Styles.ShortDesc = lipgloss.NewStyle().Foreground(muted).Background(bg)
267
+ model.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(border).Background(bg)
268
+ model.Styles.FullKey = model.Styles.ShortKey
269
+ model.Styles.FullDesc = model.Styles.ShortDesc
270
+ model.Styles.FullSeparator = model.Styles.ShortSeparator
271
+ model.Styles.Ellipsis = lipgloss.NewStyle().Foreground(muted).Background(bg)
272
+ return model
273
+ }
274
+
275
+ func newResultsTable() table.Model {
276
+ model := table.New(table.WithFocused(false), table.WithHeight(12), table.WithWidth(80))
277
+ model.SetStyles(resultsTableStyles())
278
+ return model
279
+ }
280
+
281
+ func resultsTableStyles() table.Styles {
282
+ styles := table.DefaultStyles()
283
+ styles.Header = lipgloss.NewStyle().
284
+ Foreground(cyan).
285
+ Background(bg).
286
+ Bold(true).
287
+ Padding(0, 1).
288
+ BorderStyle(lipgloss.NormalBorder()).
289
+ BorderForeground(border).
290
+ BorderBottom(true)
291
+ styles.Cell = lipgloss.NewStyle().
292
+ Foreground(fg).
293
+ Background(bg).
294
+ Padding(0, 1)
295
+ styles.Selected = lipgloss.NewStyle().
296
+ Foreground(fg).
297
+ Background(surface).
298
+ Bold(true)
299
+ return styles
300
+ }
301
+
302
+ func newFilterList(width int, height int) list.Model {
303
+ delegate := list.NewDefaultDelegate()
304
+ delegate.SetHeight(2)
305
+ delegate.SetSpacing(0)
306
+ delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(fg).Background(bg).Padding(0, 0, 0, 2)
307
+ delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(muted).Background(bg).Padding(0, 0, 0, 2)
308
+ delegate.Styles.SelectedTitle = lipgloss.NewStyle().
309
+ Foreground(bg).
310
+ Background(cyan).
311
+ Bold(true).
312
+ Padding(0, 1)
313
+ delegate.Styles.SelectedDesc = lipgloss.NewStyle().
314
+ Foreground(fg).
315
+ Background(surface).
316
+ Padding(0, 1)
317
+ delegate.Styles.DimmedTitle = mutedStyle.Padding(0, 0, 0, 2)
318
+ delegate.Styles.DimmedDesc = mutedStyle.Padding(0, 0, 0, 2)
319
+ delegate.Styles.FilterMatch = lipgloss.NewStyle().Foreground(yellow).Bold(true)
320
+
321
+ model := list.New(nil, delegate, width, height)
322
+ model.SetShowHelp(false)
323
+ model.SetShowFilter(false)
324
+ model.SetFilteringEnabled(false)
325
+ model.SetShowStatusBar(true)
326
+ model.SetShowPagination(true)
327
+ model.SetStatusBarItemName("option", "options")
328
+ model.DisableQuitKeybindings()
329
+ model.Styles.Title = lipgloss.NewStyle().Foreground(cyan).Background(bg).Bold(true)
330
+ model.Styles.StatusBar = lipgloss.NewStyle().Foreground(muted).Background(bg)
331
+ model.Styles.PaginationStyle = lipgloss.NewStyle().Foreground(muted).Background(bg)
332
+ model.Styles.HelpStyle = mutedStyle
333
+ return model
334
+ }
335
+
336
+ type model struct {
337
+ db *sql.DB
338
+ dbPath string
339
+ search textinput.Model
340
+ transcriptView viewport.Model
341
+ helpBubble help.Model
342
+ resultsTable table.Model
343
+ filterList list.Model
344
+ keys keyMap
345
+ providers map[string]bool
346
+ repoFilters map[string]bool
347
+ modelFilters map[string]bool
348
+ providerOptions []filterOption
349
+ repoOptions []filterOption
350
+ modelOptions []filterOption
351
+ allRoles bool
352
+ detail bool
353
+ detailItem item
354
+ filterOpen bool
355
+ focus focusArea
356
+ filterSection filterSection
357
+ previewKey string
358
+ previewWidth int
359
+ previewAllRoles bool
360
+ previewLines []string
361
+ previewErr error
362
+ selected int
363
+ width int
364
+ height int
365
+ searchTop int
366
+ searchBottom int
367
+ filterTop int
368
+ filterBottom int
369
+ rowsTop int
370
+ browseButtons []actionButton
371
+ detailButtons []actionButton
372
+ results []item
373
+ notice string
374
+ err error
375
+ }
376
+
377
+ func main() {
378
+ configureTerminalColors()
379
+
380
+ dbPath := flag.String("db", "", "SQLite index path")
381
+ flag.Parse()
382
+
383
+ path := *dbPath
384
+ if path == "" {
385
+ path = defaultDBPath()
386
+ }
387
+
388
+ db, err := sql.Open("sqlite", path)
389
+ if err != nil {
390
+ fmt.Fprintf(os.Stderr, "open database: %v\n", err)
391
+ os.Exit(1)
392
+ }
393
+ defer db.Close()
394
+ db.SetMaxOpenConns(1)
395
+
396
+ search := textinput.New()
397
+ search.Placeholder = "type to search local Codex, Claude Code, and Pi chats"
398
+ search.Prompt = ""
399
+ search.Focus()
400
+ search.CharLimit = 512
401
+ search.PlaceholderStyle = mutedStyle
402
+ search.TextStyle = baseStyle
403
+ search.Cursor.Style = lipgloss.NewStyle().Foreground(yellow).Background(bg)
404
+
405
+ m := &model{
406
+ db: db,
407
+ dbPath: path,
408
+ search: search,
409
+ transcriptView: viewport.New(80, 20),
410
+ helpBubble: newHelpModel(),
411
+ resultsTable: newResultsTable(),
412
+ filterList: newFilterList(80, 20),
413
+ keys: newKeyMap(),
414
+ providers: map[string]bool{},
415
+ repoFilters: map[string]bool{},
416
+ modelFilters: map[string]bool{},
417
+ allRoles: true,
418
+ }
419
+ m.providerOptions, _ = loadProviderOptions(db)
420
+ m.repoOptions, _ = loadRepoOptions(db)
421
+ m.modelOptions, _ = loadModelOptions(db)
422
+ m.reload()
423
+
424
+ program := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
425
+ if _, err := program.Run(); err != nil {
426
+ fmt.Fprintf(os.Stderr, "run tui: %v\n", err)
427
+ os.Exit(1)
428
+ }
429
+ }
430
+
431
+ func configureTerminalColors() {
432
+ if os.Getenv("GORA_NO_COLOR") == "1" {
433
+ return
434
+ }
435
+ lipgloss.SetColorProfile(termenv.TrueColor)
436
+ lipgloss.SetHasDarkBackground(true)
437
+ }
438
+
439
+ func (m *model) Init() tea.Cmd {
440
+ return textinput.Blink
441
+ }
442
+
443
+ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
444
+ switch msg := msg.(type) {
445
+ case tea.WindowSizeMsg:
446
+ m.width = msg.Width
447
+ m.height = msg.Height
448
+ m.helpBubble.Width = msg.Width
449
+ m.search.Width = max(10, msg.Width-8)
450
+ m.transcriptView.Width = max(20, msg.Width-2)
451
+ m.transcriptView.Height = m.detailHeight()
452
+ m.configureResultsTable(max(72, msg.Width), max(4, msg.Height/2))
453
+ m.configureFilterList(max(72, msg.Width), max(8, msg.Height-8))
454
+ case tea.KeyMsg:
455
+ return m, m.handleKey(msg)
456
+ case tea.MouseMsg:
457
+ return m, m.handleMouse(msg)
458
+ }
459
+ return m, nil
460
+ }
461
+
462
+ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
463
+ key := msg.String()
464
+ if m.filterOpen {
465
+ return m.handleFilterKey(msg)
466
+ }
467
+ if m.detail {
468
+ switch key {
469
+ case "esc", "left":
470
+ m.detail = false
471
+ case "ctrl+c", "q":
472
+ return tea.Quit
473
+ case "m":
474
+ m.exportSelected("md")
475
+ case "t":
476
+ m.exportSelected("txt")
477
+ default:
478
+ var cmd tea.Cmd
479
+ m.transcriptView, cmd = m.transcriptView.Update(msg)
480
+ return cmd
481
+ }
482
+ return nil
483
+ }
484
+
485
+ switch key {
486
+ case "ctrl+c":
487
+ return tea.Quit
488
+ case "esc":
489
+ if m.search.Value() != "" {
490
+ m.search.SetValue("")
491
+ m.resetPosition()
492
+ m.reload()
493
+ return nil
494
+ }
495
+ if m.hasActiveFilters() {
496
+ m.clearFilters()
497
+ m.notice = "filters cleared"
498
+ m.resetPosition()
499
+ m.reload()
500
+ return nil
501
+ }
502
+ return tea.Quit
503
+ case "tab":
504
+ m.nextFocus()
505
+ case "shift+tab":
506
+ m.previousFocus()
507
+ case "ctrl+f":
508
+ m.setFocus(focusFilters)
509
+ m.openFilters()
510
+ case "/":
511
+ m.setFocus(focusSearch)
512
+ case "up", "k":
513
+ m.move(-1)
514
+ m.setFocus(focusResults)
515
+ case "down", "j":
516
+ m.move(1)
517
+ m.setFocus(focusResults)
518
+ case "pgup":
519
+ m.move(-10)
520
+ m.setFocus(focusResults)
521
+ case "pgdown":
522
+ m.move(10)
523
+ m.setFocus(focusResults)
524
+ case "enter", "right":
525
+ if m.focus == focusFilters {
526
+ m.openFilters()
527
+ return nil
528
+ }
529
+ if len(m.results) > 0 {
530
+ m.setFocus(focusResults)
531
+ m.openDetail()
532
+ }
533
+ case "ctrl+r":
534
+ m.allRoles = !m.allRoles
535
+ m.resetPosition()
536
+ m.reload()
537
+ case "ctrl+u":
538
+ m.search.SetValue("")
539
+ m.resetPosition()
540
+ m.reload()
541
+ default:
542
+ if m.focus != focusSearch {
543
+ if key == "m" {
544
+ m.exportSelected("md")
545
+ }
546
+ if key == "t" {
547
+ m.exportSelected("txt")
548
+ }
549
+ return nil
550
+ }
551
+ before := m.search.Value()
552
+ var cmd tea.Cmd
553
+ m.search, cmd = m.search.Update(msg)
554
+ if m.search.Value() != before {
555
+ m.resetPosition()
556
+ m.reload()
557
+ }
558
+ return cmd
559
+ }
560
+ return nil
561
+ }
562
+
563
+ func (m *model) handleMouse(msg tea.MouseMsg) tea.Cmd {
564
+ if m.detail {
565
+ if msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress {
566
+ if action := buttonActionAt(m.detailButtons, msg.X, msg.Y); action != actionNone {
567
+ m.performAction(action)
568
+ return nil
569
+ }
570
+ }
571
+ var cmd tea.Cmd
572
+ m.transcriptView, cmd = m.transcriptView.Update(msg)
573
+ return cmd
574
+ }
575
+ if msg.Button == tea.MouseButtonWheelUp {
576
+ m.move(-3)
577
+ m.setFocus(focusResults)
578
+ return nil
579
+ }
580
+ if msg.Button == tea.MouseButtonWheelDown {
581
+ m.move(3)
582
+ m.setFocus(focusResults)
583
+ return nil
584
+ }
585
+ if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress {
586
+ return nil
587
+ }
588
+ if m.filterOpen {
589
+ m.handleFilterClick(msg)
590
+ return nil
591
+ }
592
+ if action := buttonActionAt(m.browseButtons, msg.X, msg.Y); action != actionNone {
593
+ m.performAction(action)
594
+ return nil
595
+ }
596
+ switch {
597
+ case msg.Y >= m.searchTop && msg.Y <= m.searchBottom:
598
+ m.setFocus(focusSearch)
599
+ case msg.Y >= m.filterTop && msg.Y <= m.filterBottom:
600
+ m.setFocus(focusFilters)
601
+ m.openFilters()
602
+ case msg.Y >= m.rowsTop:
603
+ m.setFocus(focusResults)
604
+ }
605
+ return nil
606
+ }
607
+
608
+ func (m *model) performAction(action actionKind) {
609
+ switch action {
610
+ case actionOpen:
611
+ if len(m.results) > 0 {
612
+ m.setFocus(focusResults)
613
+ m.openDetail()
614
+ }
615
+ case actionBack:
616
+ m.detail = false
617
+ case actionExportMarkdown:
618
+ m.exportSelected("md")
619
+ case actionExportText:
620
+ m.exportSelected("txt")
621
+ case actionFilters:
622
+ m.setFocus(focusFilters)
623
+ m.openFilters()
624
+ }
625
+ }
626
+
627
+ func buttonActionAt(buttons []actionButton, x int, y int) actionKind {
628
+ for _, button := range buttons {
629
+ if y == button.Y && x >= button.X && x < button.X+button.Width {
630
+ return button.Action
631
+ }
632
+ }
633
+ return actionNone
634
+ }
635
+
636
+ func (m *model) setFocus(focus focusArea) {
637
+ m.focus = focus
638
+ if focus == focusSearch {
639
+ m.search.Focus()
640
+ m.resultsTable.Blur()
641
+ return
642
+ }
643
+ m.search.Blur()
644
+ if focus == focusResults {
645
+ m.resultsTable.Focus()
646
+ return
647
+ }
648
+ m.resultsTable.Blur()
649
+ }
650
+
651
+ func (m *model) nextFocus() {
652
+ switch m.focus {
653
+ case focusSearch:
654
+ m.setFocus(focusFilters)
655
+ case focusFilters:
656
+ m.setFocus(focusResults)
657
+ default:
658
+ m.setFocus(focusSearch)
659
+ }
660
+ }
661
+
662
+ func (m *model) previousFocus() {
663
+ switch m.focus {
664
+ case focusSearch:
665
+ m.setFocus(focusResults)
666
+ case focusFilters:
667
+ m.setFocus(focusSearch)
668
+ default:
669
+ m.setFocus(focusFilters)
670
+ }
671
+ }
672
+
673
+ func (m *model) View() string {
674
+ if m.width == 0 || m.height == 0 {
675
+ return ""
676
+ }
677
+ if m.filterOpen {
678
+ return m.filterView()
679
+ }
680
+ if m.detail {
681
+ return m.detailView()
682
+ }
683
+ return m.browseView()
684
+ }
685
+
686
+ func (m *model) browseView() string {
687
+ width := max(72, m.width)
688
+ searchBlock := m.renderSearch(width)
689
+ filterBlock := m.renderFilters(width)
690
+ statusBlock := m.renderStatus(width)
691
+ m.searchTop = 0
692
+ m.searchBottom = lipgloss.Height(searchBlock) - 1
693
+ m.filterTop = m.searchBottom + 1
694
+ m.filterBottom = m.filterTop + lipgloss.Height(filterBlock) - 1
695
+ m.rowsTop = m.filterBottom + lipgloss.Height(statusBlock) + 2
696
+
697
+ lines := []string{searchBlock, filterBlock, statusBlock}
698
+ footer := m.renderFooter(width)
699
+ bodyHeight := max(6, m.height-lipgloss.Height(strings.Join(lines, "\n"))-lipgloss.Height(footer)-2)
700
+ lines = append(lines, m.renderBrowseBody(width, bodyHeight))
701
+ actionY := lipgloss.Height(strings.Join(lines, "\n"))
702
+ lines = append(lines, m.renderBrowseActions(width, actionY))
703
+ lines = append(lines, footer)
704
+ return baseStyle.Width(width).Height(m.height).Render(strings.Join(lines, "\n"))
705
+ }
706
+
707
+ func (m *model) renderBrowseBody(width int, height int) string {
708
+ if width >= 104 && len(m.results) > 0 {
709
+ gap := 2
710
+ rightWidth := clamp(width/3, 38, 52)
711
+ leftWidth := max(48, width-rightWidth-gap)
712
+ leftTitle := m.renderResultsTitle(leftWidth)
713
+ left := strings.Join([]string{
714
+ leftTitle,
715
+ m.renderResultsTable(leftWidth, max(4, height-lipgloss.Height(leftTitle))),
716
+ }, "\n")
717
+ right := m.renderSelectedPreview(rightWidth, height)
718
+ return lipgloss.JoinHorizontal(lipgloss.Top, left, baseStyle.Render(strings.Repeat(" ", gap)), right)
719
+ }
720
+
721
+ title := m.renderResultsTitle(width)
722
+ tableHeight := max(4, height-lipgloss.Height(title)-5)
723
+ preview := m.renderSelectedPreview(width, max(4, height-lipgloss.Height(title)-tableHeight))
724
+ return strings.Join([]string{title, m.renderResultsTable(width, tableHeight), preview}, "\n")
725
+ }
726
+
727
+ func (m *model) renderSearch(width int) string {
728
+ contentWidth := max(20, width-8)
729
+ m.search.Width = contentWidth
730
+ style := searchStyle
731
+ if m.focus == focusSearch {
732
+ style = style.BorderForeground(cyan)
733
+ }
734
+ return style.Width(contentWidth).Render(m.renderSearchInput(contentWidth))
735
+ }
736
+
737
+ func (m *model) renderSearchInput(width int) string {
738
+ value := m.search.Value()
739
+ if value == "" {
740
+ return searchHint.Render(padRight(m.search.Placeholder, width))
741
+ }
742
+ if m.focus != focusSearch {
743
+ return searchText.Render(padRight(fit(value, width), width))
744
+ }
745
+
746
+ runes := []rune(value)
747
+ position := clamp(m.search.Position(), 0, len(runes))
748
+ start := 0
749
+ if position >= width {
750
+ start = position - width + 1
751
+ }
752
+ end := min(len(runes), start+width)
753
+ visible := runes[start:end]
754
+ cursor := position - start
755
+
756
+ if len(visible) >= width && cursor >= width {
757
+ visible = visible[:width-1]
758
+ cursor = width - 1
759
+ }
760
+
761
+ before := string(visible[:min(cursor, len(visible))])
762
+ cursorChar := " "
763
+ after := ""
764
+ if cursor < len(visible) {
765
+ cursorChar = string(visible[cursor])
766
+ after = string(visible[cursor+1:])
767
+ }
768
+
769
+ rendered := searchText.Render(before) + searchCursor.Render(cursorChar) + searchText.Render(after)
770
+ return rendered + searchText.Render(strings.Repeat(" ", max(0, width-lipgloss.Width(rendered))))
771
+ }
772
+
773
+ func (m *model) renderFilters(width int) string {
774
+ provider := m.compactProviderSummary()
775
+ modelName := m.compactModelSummary()
776
+ repo := m.compactRepoSummary()
777
+ roles := "user+assistant"
778
+ if m.allRoles {
779
+ roles = "all roles"
780
+ }
781
+
782
+ title := sectionStyle.Render("Filters")
783
+ if m.focus == focusFilters {
784
+ title = selectedStyle.Foreground(cyan).Render("Filters")
785
+ }
786
+ action := renderFilterAction(m.keys.Filter, m.focus == focusFilters)
787
+ help := mutedStyle.Render("Tab focus Enter edit 1/2/3/4 inside editor")
788
+ top := lipgloss.JoinHorizontal(lipgloss.Center, title, baseStyle.Render(" "), action, baseStyle.Render(" "), help)
789
+ if lipgloss.Width(top) > width {
790
+ top = lipgloss.JoinHorizontal(lipgloss.Center, title, baseStyle.Render(" "), action)
791
+ }
792
+
793
+ chips := []string{
794
+ filterControl("Harness", provider, len(m.providers) > 0, purple),
795
+ filterControl("Repo", repo, len(m.repoFilters) > 0, green),
796
+ filterControl("Model", modelName, len(m.modelFilters) > 0, yellow),
797
+ filterControl("Roles", roles, !m.allRoles, cyan),
798
+ }
799
+ return top + "\n" + joinFilterControls(chips, width)
800
+ }
801
+
802
+ func (m *model) renderStatus(width int) string {
803
+ if m.search.Value() != "" {
804
+ status := fmt.Sprintf("%d matches for %q", len(m.results), m.search.Value())
805
+ return m.renderStatusLine(status, width)
806
+ }
807
+ return m.renderStatusLine(fmt.Sprintf("%d recent chats", len(m.results)), width)
808
+ }
809
+
810
+ func (m *model) renderStatusLine(status string, width int) string {
811
+ if m.notice != "" {
812
+ status += " " + m.notice
813
+ }
814
+ if m.err != nil {
815
+ status += " " + m.err.Error()
816
+ }
817
+ return mutedStyle.Render(fit(status, width))
818
+ }
819
+
820
+ func (m *model) renderResultsTitle(width int) string {
821
+ count := fmt.Sprintf("%d chats", len(m.results))
822
+ title := sectionStyle.Render("found chats")
823
+ spacer := mutedStyle.Render(strings.Repeat(" ", max(1, width-lipgloss.Width(title)-lipgloss.Width(count))))
824
+ return title + spacer + mutedStyle.Render(count)
825
+ }
826
+
827
+ func (m *model) renderResultsTable(width int, height int) string {
828
+ if len(m.results) == 0 {
829
+ if m.hasActiveFilters() {
830
+ return mutedStyle.Render(" no chats match the current filters")
831
+ }
832
+ if m.search.Value() == "" {
833
+ return mutedStyle.Render(" no sessions indexed; run gora import")
834
+ }
835
+ return mutedStyle.Render(" no matches")
836
+ }
837
+
838
+ m.configureResultsTable(width, height)
839
+ return m.resultsTable.View()
840
+ }
841
+
842
+ func (m *model) renderSelectedPreview(width int, height int) string {
843
+ row, ok := m.currentSelection()
844
+ if !ok {
845
+ return ""
846
+ }
847
+ title := row.Title
848
+ if title == "" {
849
+ title = row.SessionKey
850
+ }
851
+ meta := []string{
852
+ shortCwd(row.Cwd),
853
+ "created " + friendlyTime(row.CreatedAt),
854
+ "updated " + friendlyTime(row.UpdatedAt),
855
+ }
856
+ if row.Models != "" {
857
+ meta = append(meta, modelSummary(row.Models))
858
+ }
859
+ if row.MessageCount > 0 {
860
+ meta = append(meta, fmt.Sprintf("%d messages", row.MessageCount))
861
+ }
862
+ bodyWidth := max(20, width-4)
863
+ lines := []string{
864
+ sectionStyle.Render(fit("preview", bodyWidth)),
865
+ selectedStyle.Foreground(cyan).Render(fit(title, bodyWidth)),
866
+ mutedStyle.Render(fit(strings.Join(meta, " "), bodyWidth)),
867
+ }
868
+ if row.Preview != "" {
869
+ lines = append(lines, mutedStyle.Render(fit("match "+row.Preview, bodyWidth)))
870
+ }
871
+ lines = append(lines, lipgloss.NewStyle().Foreground(border).Render(strings.Repeat("─", min(bodyWidth, 32))))
872
+ lines = append(lines, m.transcriptPreviewLines(row, bodyWidth)...)
873
+ lines = clipStyledLines(lines, max(1, height-1))
874
+ return lipgloss.NewStyle().
875
+ Foreground(fg).
876
+ Border(lipgloss.NormalBorder(), false, false, false, true).
877
+ BorderForeground(border).
878
+ Padding(0, 0, 0, 2).
879
+ Width(max(1, width-3)).
880
+ Height(max(1, height)).
881
+ Render(strings.Join(lines, "\n"))
882
+ }
883
+
884
+ func clipStyledLines(lines []string, height int) []string {
885
+ if height <= 0 || len(lines) <= height {
886
+ return lines
887
+ }
888
+ if height == 1 {
889
+ return []string{mutedStyle.Render("...")}
890
+ }
891
+ clipped := append([]string{}, lines[:height-1]...)
892
+ clipped = append(clipped, mutedStyle.Render("... enter to open full transcript"))
893
+ return clipped
894
+ }
895
+
896
+ func (m *model) transcriptPreviewLines(row item, width int) []string {
897
+ if m.db == nil {
898
+ fallback := "session " + row.SessionKey
899
+ if row.Preview != "" {
900
+ fallback = "match " + row.Preview
901
+ }
902
+ return []string{mutedStyle.Render(fit(fallback, width))}
903
+ }
904
+ if m.previewKey == row.SessionKey && m.previewWidth == width && m.previewAllRoles == m.allRoles {
905
+ if m.previewErr != nil {
906
+ return []string{lipgloss.NewStyle().Foreground(red).Render(fit(m.previewErr.Error(), width))}
907
+ }
908
+ return m.previewLines
909
+ }
910
+
911
+ messages, err := loadTranscriptPreviewMessages(m.db, row.SessionKey, m.allRoles, 8)
912
+ m.previewKey = row.SessionKey
913
+ m.previewWidth = width
914
+ m.previewAllRoles = m.allRoles
915
+ m.previewErr = err
916
+ if err != nil {
917
+ m.previewLines = nil
918
+ return []string{lipgloss.NewStyle().Foreground(red).Render(fit(err.Error(), width))}
919
+ }
920
+ m.previewLines = formatTranscriptPreviewLines(messages, width)
921
+ return m.previewLines
922
+ }
923
+
924
+ func formatTranscriptPreviewLines(messages []transcriptMessage, width int) []string {
925
+ if len(messages) == 0 {
926
+ return []string{mutedStyle.Render("no transcript messages")}
927
+ }
928
+ lines := []string{}
929
+ for _, message := range messages {
930
+ headerParts := []string{strings.ToUpper(message.Role)}
931
+ if message.Timestamp != "" {
932
+ headerParts = append(headerParts, friendlyTime(message.Timestamp))
933
+ }
934
+ if message.Model != "" {
935
+ headerParts = append(headerParts, message.Model)
936
+ }
937
+ lines = append(lines, roleStyle(message.Role).Render(fit(strings.Join(headerParts, " "), width)))
938
+ textLines := 0
939
+ for _, raw := range strings.Split(message.Text, "\n") {
940
+ for _, wrapped := range wrap(raw, max(12, width-2)) {
941
+ lines = append(lines, baseStyle.Render(" "+fit(wrapped, max(1, width-2))))
942
+ textLines++
943
+ if textLines >= 3 {
944
+ break
945
+ }
946
+ }
947
+ if textLines >= 3 {
948
+ break
949
+ }
950
+ }
951
+ lines = append(lines, "")
952
+ }
953
+ return lines
954
+ }
955
+
956
+ func (m *model) configureResultsTable(width int, height int) {
957
+ if width <= 0 {
958
+ return
959
+ }
960
+ if m.resultsTable.Width() == 0 && m.resultsTable.Height() == 0 {
961
+ m.resultsTable = newResultsTable()
962
+ }
963
+ m.resultsTable.SetColumns(resultTableColumns(width))
964
+ m.selected = clamp(m.selected, 0, max(0, len(m.results)-1))
965
+ m.resultsTable.SetRows(resultTableRows(m.results, m.selected))
966
+ m.resultsTable.SetWidth(width)
967
+ m.resultsTable.SetHeight(max(4, height))
968
+ m.resultsTable.SetCursor(m.selected)
969
+ if m.focus == focusResults {
970
+ m.resultsTable.Focus()
971
+ } else {
972
+ m.resultsTable.Blur()
973
+ }
974
+ }
975
+
976
+ func resultTableColumns(width int) []table.Column {
977
+ inner := max(40, width-4)
978
+ markerWidth := 2
979
+ ageWidth := 8
980
+ updatedWidth := 16
981
+ createdWidth := 16
982
+ repoWidth := 22
983
+ harnessWidth := 11
984
+ messageWidth := 5
985
+
986
+ if inner < 96 {
987
+ createdWidth = 0
988
+ repoWidth = 18
989
+ }
990
+ if inner < 92 {
991
+ updatedWidth = 12
992
+ repoWidth = 0
993
+ messageWidth = 0
994
+ }
995
+ if inner < 64 {
996
+ createdWidth = 0
997
+ updatedWidth = 0
998
+ messageWidth = 0
999
+ }
1000
+
1001
+ columns := []table.Column{
1002
+ {Title: "", Width: markerWidth},
1003
+ {Title: "age", Width: ageWidth},
1004
+ {Title: "updated", Width: updatedWidth},
1005
+ {Title: "created", Width: createdWidth},
1006
+ {Title: "chat", Width: 0},
1007
+ {Title: "repo", Width: repoWidth},
1008
+ {Title: "harness", Width: harnessWidth},
1009
+ {Title: "msgs", Width: messageWidth},
1010
+ }
1011
+ fixed := 0
1012
+ visibleColumns := 0
1013
+ chatIndex := 4
1014
+ for index, column := range columns {
1015
+ if column.Width <= 0 {
1016
+ continue
1017
+ }
1018
+ visibleColumns++
1019
+ if index != chatIndex {
1020
+ fixed += column.Width
1021
+ }
1022
+ }
1023
+ padding := 2 * visibleColumns
1024
+ columns[chatIndex].Width = max(8, inner-fixed-padding)
1025
+ return columns
1026
+ }
1027
+
1028
+ func resultTableRows(items []item, selected int) []table.Row {
1029
+ rows := make([]table.Row, 0, len(items))
1030
+ for index, row := range items {
1031
+ title := row.Title
1032
+ if title == "" {
1033
+ title = row.SessionKey
1034
+ }
1035
+ marker := ""
1036
+ if index == selected {
1037
+ marker = ">"
1038
+ }
1039
+ rows = append(rows, table.Row{
1040
+ marker,
1041
+ relativeTime(row.UpdatedAt),
1042
+ friendlyTime(row.UpdatedAt),
1043
+ friendlyTime(row.CreatedAt),
1044
+ title,
1045
+ shortCwd(row.Cwd),
1046
+ "[" + providerBadge(row.Provider) + "]",
1047
+ messageCountLabel(row.MessageCount),
1048
+ })
1049
+ }
1050
+ return rows
1051
+ }
1052
+
1053
+ func messageCountLabel(count int) string {
1054
+ if count <= 0 {
1055
+ return ""
1056
+ }
1057
+ return fmt.Sprintf("%d", count)
1058
+ }
1059
+
1060
+ func (m *model) currentSelection() (item, bool) {
1061
+ if len(m.results) == 0 {
1062
+ return item{}, false
1063
+ }
1064
+ index := clamp(m.resultsTable.Cursor(), 0, len(m.results)-1)
1065
+ m.selected = index
1066
+ return m.results[index], true
1067
+ }
1068
+
1069
+ func (m *model) renderFooter(width int) string {
1070
+ m.helpBubble.Width = width
1071
+ return m.helpBubble.View(m.currentHelp())
1072
+ }
1073
+
1074
+ func (m *model) currentHelp() keySet {
1075
+ if m.filterOpen {
1076
+ return keySet{
1077
+ short: []key.Binding{m.keys.AdvanceFilter, m.keys.Select, m.keys.FilterHarness, m.keys.FilterRepos, m.keys.FilterModels, m.keys.FilterRoles, m.keys.Apply},
1078
+ full: [][]key.Binding{
1079
+ {m.keys.Move, m.keys.AdvanceFilter, m.keys.Select, m.keys.Focus},
1080
+ {m.keys.FilterHarness, m.keys.FilterRepos, m.keys.FilterModels, m.keys.FilterRoles},
1081
+ {m.keys.ClearSection, m.keys.Clear, m.keys.Apply, m.keys.Quit},
1082
+ },
1083
+ }
1084
+ }
1085
+ if m.detail {
1086
+ return keySet{
1087
+ short: []key.Binding{m.keys.Back, m.keys.ExportMarkdown, m.keys.ExportText, m.keys.Move, m.keys.Quit},
1088
+ full: [][]key.Binding{
1089
+ {m.keys.Back, m.keys.Move},
1090
+ {m.keys.ExportMarkdown, m.keys.ExportText, m.keys.Quit},
1091
+ },
1092
+ }
1093
+ }
1094
+ if m.focus == focusSearch {
1095
+ return keySet{
1096
+ short: []key.Binding{m.keys.Filter, m.keys.Focus, m.keys.Search, m.keys.Clear, m.keys.Quit},
1097
+ full: [][]key.Binding{
1098
+ {m.keys.Filter, m.keys.Focus, m.keys.Search, m.keys.Clear},
1099
+ {m.keys.Open, m.keys.Move, m.keys.Quit},
1100
+ },
1101
+ }
1102
+ }
1103
+ return keySet{
1104
+ short: []key.Binding{m.keys.Open, m.keys.ExportMarkdown, m.keys.ExportText, m.keys.Focus, m.keys.Search, m.keys.Quit},
1105
+ full: [][]key.Binding{
1106
+ {m.keys.Open, m.keys.Move, m.keys.Focus, m.keys.Search},
1107
+ {m.keys.ExportMarkdown, m.keys.ExportText, m.keys.Filter, m.keys.Clear, m.keys.Quit},
1108
+ },
1109
+ }
1110
+ }
1111
+
1112
+ func (m *model) renderBrowseActions(width int, y int) string {
1113
+ buttons := []actionButtonSpec{
1114
+ {Binding: m.keys.Open, Label: "Open", Action: actionOpen},
1115
+ {Binding: m.keys.ExportMarkdown, Label: "Markdown", Action: actionExportMarkdown},
1116
+ {Binding: m.keys.ExportText, Label: "Text", Action: actionExportText},
1117
+ }
1118
+ line, tracked := renderButtonRow(buttons, width, y)
1119
+ m.browseButtons = tracked
1120
+ return line
1121
+ }
1122
+
1123
+ func (m *model) renderDetailActions(width int, y int) string {
1124
+ buttons := []actionButtonSpec{
1125
+ {Binding: m.keys.Back, Label: "Back", Action: actionBack},
1126
+ {Binding: m.keys.ExportMarkdown, Label: "Markdown", Action: actionExportMarkdown},
1127
+ {Binding: m.keys.ExportText, Label: "Text", Action: actionExportText},
1128
+ }
1129
+ line, tracked := renderButtonRow(buttons, width, y)
1130
+ m.detailButtons = tracked
1131
+ return line
1132
+ }
1133
+
1134
+ type actionButtonSpec struct {
1135
+ Binding key.Binding
1136
+ Label string
1137
+ Action actionKind
1138
+ }
1139
+
1140
+ func renderButtonRow(specs []actionButtonSpec, width int, y int) (string, []actionButton) {
1141
+ x := 0
1142
+ parts := []string{}
1143
+ buttons := []actionButton{}
1144
+ for _, spec := range specs {
1145
+ rendered := renderButton(spec.Binding, spec.Label)
1146
+ visibleWidth := lipgloss.Width(rendered)
1147
+ if x > 0 {
1148
+ spacer := " "
1149
+ parts = append(parts, baseStyle.Render(spacer))
1150
+ x += lipgloss.Width(spacer)
1151
+ }
1152
+ if x+visibleWidth > width {
1153
+ break
1154
+ }
1155
+ parts = append(parts, rendered)
1156
+ buttons = append(buttons, actionButton{
1157
+ Label: spec.Label,
1158
+ Action: spec.Action,
1159
+ X: x,
1160
+ Y: y,
1161
+ Width: visibleWidth,
1162
+ })
1163
+ x += visibleWidth
1164
+ }
1165
+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...), buttons
1166
+ }
1167
+
1168
+ func renderButton(binding key.Binding, label string) string {
1169
+ helpText := binding.Help()
1170
+ keyText := strings.ToUpper(helpText.Key)
1171
+ if keyText == "" {
1172
+ keyText = " "
1173
+ }
1174
+ text := keyText + " " + label
1175
+ return lipgloss.NewStyle().
1176
+ Foreground(bg).
1177
+ Background(cyan).
1178
+ Bold(true).
1179
+ Padding(0, 1).
1180
+ Render(text)
1181
+ }
1182
+
1183
+ func renderFilterAction(_ key.Binding, focused bool) string {
1184
+ style := lipgloss.NewStyle().
1185
+ Foreground(bg).
1186
+ Background(cyan).
1187
+ Bold(true).
1188
+ Padding(0, 1)
1189
+ if focused {
1190
+ style = style.Background(yellow)
1191
+ }
1192
+ return style.Render("Ctrl-F Filters")
1193
+ }
1194
+
1195
+ func filterControl(label string, value string, active bool, color lipgloss.Color) string {
1196
+ marker := mutedStyle.Background(surface).Render("·")
1197
+ if active {
1198
+ marker = lipgloss.NewStyle().Foreground(color).Background(surface).Bold(true).Render("•")
1199
+ }
1200
+ labelStyle := lipgloss.NewStyle().
1201
+ Foreground(color).
1202
+ Background(surface).
1203
+ Bold(true)
1204
+ valueStyle := lipgloss.NewStyle().
1205
+ Foreground(fg).
1206
+ Background(surface)
1207
+ if active {
1208
+ valueStyle = valueStyle.Bold(true)
1209
+ }
1210
+ content := marker + baseStyle.Background(surface).Render(" ") + labelStyle.Render(label+": ") + valueStyle.Render(value)
1211
+ return lipgloss.NewStyle().
1212
+ Foreground(fg).
1213
+ Background(surface).
1214
+ Padding(0, 1).
1215
+ Render(content)
1216
+ }
1217
+
1218
+ func joinFilterControls(chips []string, width int) string {
1219
+ lines := []string{}
1220
+ current := ""
1221
+ for _, chip := range chips {
1222
+ next := chip
1223
+ if current != "" {
1224
+ next = current + baseStyle.Render(" ") + chip
1225
+ }
1226
+ if current != "" && lipgloss.Width(next) > width {
1227
+ lines = append(lines, current)
1228
+ current = chip
1229
+ continue
1230
+ }
1231
+ current = next
1232
+ }
1233
+ if current != "" {
1234
+ lines = append(lines, current)
1235
+ }
1236
+ return strings.Join(lines, "\n")
1237
+ }
1238
+
1239
+ func (m *model) filterView() string {
1240
+ width := max(72, m.width)
1241
+ title := sectionStyle.Render("Filters")
1242
+ step := selectedStyle.Foreground(cyan).Render(fmt.Sprintf("Step %d of %d %s", int(m.filterSection)+1, int(filterSectionCount), filterSectionTitle(m.filterSection)))
1243
+ summary := mutedStyle.Render(fit(m.filterSummary(), width))
1244
+ steps := m.renderFilterSteps(width)
1245
+ instructions := mutedStyle.Render(fit("Enter selects and advances. Space selects without moving. Esc applies filters.", width))
1246
+ rule := lipgloss.NewStyle().Foreground(border).Render(strings.Repeat("─", max(0, width-2)))
1247
+ top := []string{title, step, summary, steps, instructions, rule}
1248
+ bodyHeight := max(1, m.height-lipgloss.Height(strings.Join(top, "\n"))-2)
1249
+ lines := append(top, m.renderFilterOptions(width, bodyHeight))
1250
+ lines = append(lines, m.renderFooter(width))
1251
+ return baseStyle.Width(width).Height(m.height).Render(strings.Join(lines, "\n"))
1252
+ }
1253
+
1254
+ func (m *model) renderFilterSteps(_ int) string {
1255
+ labels := []string{
1256
+ "1 Harness " + selectionCount(len(m.providers)),
1257
+ "2 Repos " + selectionCount(len(m.repoFilters)),
1258
+ "3 Models " + selectionCount(len(m.modelFilters)),
1259
+ "4 Roles",
1260
+ }
1261
+ parts := make([]string, 0, len(labels))
1262
+ for index, label := range labels {
1263
+ active := filterSection(index) == m.filterSection
1264
+ parts = append(parts, filterStep(label, active))
1265
+ if index < len(labels)-1 {
1266
+ parts = append(parts, mutedStyle.Render(" > "))
1267
+ }
1268
+ }
1269
+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
1270
+ }
1271
+
1272
+ func (m *model) renderFilterOptions(width int, height int) string {
1273
+ m.configureFilterList(width, height)
1274
+ return m.filterList.View()
1275
+ }
1276
+
1277
+ func (m *model) configureFilterList(width int, height int) {
1278
+ if width <= 0 {
1279
+ return
1280
+ }
1281
+ if m.filterList.Width() == 0 && m.filterList.Height() == 0 {
1282
+ m.filterList = newFilterList(width, height)
1283
+ }
1284
+ m.filterList.Title = filterSectionTitle(m.filterSection)
1285
+ m.filterList.SetSize(width, max(3, height))
1286
+ _ = m.filterList.SetItems(m.filterListItems())
1287
+ }
1288
+
1289
+ func (m *model) filterListItems() []list.Item {
1290
+ options := m.currentFilterOptions()
1291
+ items := make([]list.Item, 0, len(options))
1292
+ for _, option := range options {
1293
+ description := option.Value
1294
+ if option.Count > 0 {
1295
+ description = fmt.Sprintf("%d chats %s", option.Count, option.Value)
1296
+ }
1297
+ if m.filterSection == sectionRoles {
1298
+ description = "controls whether transcripts include system, developer, and tool messages"
1299
+ }
1300
+ items = append(items, filterListItem{
1301
+ option: option,
1302
+ selected: m.filterOptionSelected(option),
1303
+ section: m.filterSection,
1304
+ description: description,
1305
+ })
1306
+ }
1307
+ return items
1308
+ }
1309
+
1310
+ func filterStep(label string, active bool) string {
1311
+ style := lipgloss.NewStyle().
1312
+ Foreground(muted).
1313
+ Background(bg).
1314
+ Padding(0, 1).
1315
+ Bold(true)
1316
+ if active {
1317
+ style = style.Foreground(bg).Background(cyan)
1318
+ }
1319
+ return style.Render(label)
1320
+ }
1321
+
1322
+ func filterSectionTitle(section filterSection) string {
1323
+ switch section {
1324
+ case sectionHarness:
1325
+ return "Choose harnesses"
1326
+ case sectionRepos:
1327
+ return "Choose repos"
1328
+ case sectionModels:
1329
+ return "Choose models"
1330
+ case sectionRoles:
1331
+ return "Choose roles"
1332
+ default:
1333
+ return "Choose filters"
1334
+ }
1335
+ }
1336
+
1337
+ func (m *model) detailView() string {
1338
+ width := max(72, m.width)
1339
+ row := m.currentDetailItem()
1340
+ badge := "[" + providerLabel(row.Provider) + "]"
1341
+ titleWidth := max(10, width-lipgloss.Width(badge)-1)
1342
+ header := providerStyle(row.Provider, false).Render(badge) + " " + sectionStyle.Render(fit(row.Title, titleWidth))
1343
+ meta := mutedStyle.Render(fit(friendlyTime(row.Timestamp)+" "+shortCwd(row.Cwd)+" "+modelSummary(row.Models), width))
1344
+ actions := m.renderDetailActions(width, 2)
1345
+ help := m.renderFooter(width)
1346
+ status := m.renderDetailStatus(width)
1347
+ rule := lipgloss.NewStyle().Foreground(border).Render(strings.Repeat("─", max(0, width-2)))
1348
+ top := []string{header, meta, actions, help}
1349
+ if status != "" {
1350
+ top = append(top, status)
1351
+ }
1352
+ top = append(top, rule)
1353
+
1354
+ m.transcriptView.Width = max(20, width-2)
1355
+ m.transcriptView.Height = max(1, m.height-len(top)-1)
1356
+ return baseStyle.Width(width).Height(m.height).Render(strings.Join(top, "\n") + "\n" + m.transcriptView.View())
1357
+ }
1358
+
1359
+ func (m *model) renderDetailStatus(width int) string {
1360
+ if m.err != nil {
1361
+ return lipgloss.NewStyle().Foreground(red).Background(bg).Render(fit(m.err.Error(), width))
1362
+ }
1363
+ if m.notice != "" {
1364
+ return mutedStyle.Render(fit(m.notice, width))
1365
+ }
1366
+ return ""
1367
+ }
1368
+
1369
+ func (m *model) move(delta int) {
1370
+ if len(m.results) == 0 {
1371
+ m.selected = 0
1372
+ return
1373
+ }
1374
+ if delta < 0 {
1375
+ m.resultsTable.MoveUp(-delta)
1376
+ } else {
1377
+ m.resultsTable.MoveDown(delta)
1378
+ }
1379
+ m.selected = clamp(m.resultsTable.Cursor(), 0, len(m.results)-1)
1380
+ }
1381
+
1382
+ func (m *model) openDetail() {
1383
+ if len(m.results) == 0 {
1384
+ return
1385
+ }
1386
+ row, ok := m.currentSelection()
1387
+ if !ok {
1388
+ return
1389
+ }
1390
+ content, err := loadTranscript(m.db, row.SessionKey, m.allRoles, max(40, m.width-4))
1391
+ if err != nil {
1392
+ m.err = err
1393
+ return
1394
+ }
1395
+ m.detailItem = row
1396
+ m.notice = ""
1397
+ m.transcriptView.SetContent(content)
1398
+ m.transcriptView.GotoTop()
1399
+ m.detail = true
1400
+ }
1401
+
1402
+ func (m *model) exportSelected(format string) {
1403
+ m.err = nil
1404
+ row, ok := m.exportTarget()
1405
+ if !ok {
1406
+ m.notice = "no chat selected"
1407
+ return
1408
+ }
1409
+ messages, err := loadTranscriptMessages(m.db, row.SessionKey, m.allRoles)
1410
+ if err != nil {
1411
+ m.err = err
1412
+ return
1413
+ }
1414
+ if len(messages) == 0 {
1415
+ m.notice = "selected chat has no exportable messages"
1416
+ return
1417
+ }
1418
+ content := formatTranscriptExport(row, messages, format)
1419
+ path, err := exportFilePath(row, format)
1420
+ if err != nil {
1421
+ m.err = err
1422
+ return
1423
+ }
1424
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
1425
+ m.err = err
1426
+ return
1427
+ }
1428
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
1429
+ m.err = err
1430
+ return
1431
+ }
1432
+ m.notice = "exported " + path
1433
+ }
1434
+
1435
+ func (m *model) exportTarget() (item, bool) {
1436
+ if m.detail && m.detailItem.SessionKey != "" {
1437
+ return m.detailItem, true
1438
+ }
1439
+ if len(m.results) == 0 {
1440
+ return item{}, false
1441
+ }
1442
+ return m.currentSelection()
1443
+ }
1444
+
1445
+ func (m *model) currentDetailItem() item {
1446
+ if m.detailItem.SessionKey != "" {
1447
+ return m.detailItem
1448
+ }
1449
+ if len(m.results) == 0 {
1450
+ return item{}
1451
+ }
1452
+ row, _ := m.currentSelection()
1453
+ return row
1454
+ }
1455
+
1456
+ func (m *model) openFilters() {
1457
+ m.filterOpen = true
1458
+ m.setFocus(focusFilters)
1459
+ m.configureFilterList(max(72, m.width), max(8, m.height-8))
1460
+ }
1461
+
1462
+ func (m *model) closeFilters() {
1463
+ m.filterOpen = false
1464
+ m.setFocus(focusFilters)
1465
+ m.resetPosition()
1466
+ m.reload()
1467
+ }
1468
+
1469
+ func (m *model) handleFilterKey(msg tea.KeyMsg) tea.Cmd {
1470
+ m.configureFilterList(max(72, m.width), max(8, m.height-8))
1471
+ switch msg.String() {
1472
+ case "ctrl+c":
1473
+ return tea.Quit
1474
+ case "esc", "left":
1475
+ m.closeFilters()
1476
+ case "tab", "right":
1477
+ m.advanceFilterSection()
1478
+ case "shift+tab":
1479
+ m.previousFilterSection()
1480
+ case "up", "k", "down", "j", "pgup", "pgdown":
1481
+ var cmd tea.Cmd
1482
+ m.filterList, cmd = m.filterList.Update(msg)
1483
+ return cmd
1484
+ case " ":
1485
+ m.toggleCurrentFilter()
1486
+ case "enter":
1487
+ m.toggleCurrentFilter()
1488
+ m.advanceFilterSection()
1489
+ case "c":
1490
+ m.clearCurrentFilterSection()
1491
+ case "ctrl+u":
1492
+ m.clearFilters()
1493
+ m.configureFilterList(max(72, m.width), max(8, m.height-8))
1494
+ case "1", "h":
1495
+ m.setFilterSection(sectionHarness)
1496
+ case "2", "r":
1497
+ m.setFilterSection(sectionRepos)
1498
+ case "3", "m":
1499
+ m.setFilterSection(sectionModels)
1500
+ case "4", "o":
1501
+ m.setFilterSection(sectionRoles)
1502
+ default:
1503
+ return nil
1504
+ }
1505
+ return nil
1506
+ }
1507
+
1508
+ func (m *model) handleFilterClick(msg tea.MouseMsg) {
1509
+ width := max(72, m.width)
1510
+ sectionWidth := max(18, width/int(filterSectionCount))
1511
+ if msg.Y == 3 {
1512
+ clickedSection := clamp(msg.X/sectionWidth, 0, int(filterSectionCount)-1)
1513
+ m.setFilterSection(filterSection(clickedSection))
1514
+ return
1515
+ }
1516
+ if msg.Y < 6 {
1517
+ return
1518
+ }
1519
+ listTop := 8
1520
+ visibleIndex := max(0, msg.Y-listTop) / 2
1521
+ index := m.filterList.Paginator.Page*m.filterList.Paginator.PerPage + visibleIndex
1522
+ options := m.currentFilterOptions()
1523
+ if index < len(options) {
1524
+ m.toggleFilterOption(options[index])
1525
+ m.configureFilterList(width, max(8, m.height-8))
1526
+ }
1527
+ }
1528
+
1529
+ func (m *model) setFilterSection(section filterSection) {
1530
+ m.filterSection = section
1531
+ m.filterList.GoToStart()
1532
+ m.configureFilterList(max(72, m.width), max(8, m.height-8))
1533
+ }
1534
+
1535
+ func (m *model) advanceFilterSection() {
1536
+ m.setFilterSection((m.filterSection + 1) % filterSectionCount)
1537
+ }
1538
+
1539
+ func (m *model) previousFilterSection() {
1540
+ m.setFilterSection((m.filterSection + filterSectionCount - 1) % filterSectionCount)
1541
+ }
1542
+
1543
+ func (m *model) toggleCurrentFilter() {
1544
+ selected, ok := m.filterList.SelectedItem().(filterListItem)
1545
+ if !ok {
1546
+ return
1547
+ }
1548
+ m.toggleFilterOption(selected.option)
1549
+ m.configureFilterList(max(72, m.width), max(8, m.height-8))
1550
+ }
1551
+
1552
+ func (m *model) toggleFilterOption(selected filterOption) {
1553
+ switch m.filterSection {
1554
+ case sectionHarness:
1555
+ toggleSetValue(m.providers, selected.Value)
1556
+ case sectionRepos:
1557
+ toggleSetValue(m.repoFilters, selected.Value)
1558
+ case sectionModels:
1559
+ toggleSetValue(m.modelFilters, selected.Value)
1560
+ case sectionRoles:
1561
+ m.allRoles = selected.Value == "all"
1562
+ }
1563
+ m.notice = "filters updated"
1564
+ }
1565
+
1566
+ func (m *model) clearCurrentFilterSection() {
1567
+ switch m.filterSection {
1568
+ case sectionHarness:
1569
+ clearSet(m.providers)
1570
+ case sectionRepos:
1571
+ clearSet(m.repoFilters)
1572
+ case sectionModels:
1573
+ clearSet(m.modelFilters)
1574
+ case sectionRoles:
1575
+ m.allRoles = true
1576
+ }
1577
+ m.notice = "filter section cleared"
1578
+ m.configureFilterList(max(72, m.width), max(8, m.height-8))
1579
+ }
1580
+
1581
+ func (m *model) clearFilters() {
1582
+ clearSet(m.providers)
1583
+ clearSet(m.repoFilters)
1584
+ clearSet(m.modelFilters)
1585
+ m.allRoles = true
1586
+ }
1587
+
1588
+ func (m *model) currentFilterOptions() []filterOption {
1589
+ switch m.filterSection {
1590
+ case sectionHarness:
1591
+ return m.providerOptions
1592
+ case sectionRepos:
1593
+ return m.repoOptions
1594
+ case sectionModels:
1595
+ return m.modelOptions
1596
+ case sectionRoles:
1597
+ return []filterOption{
1598
+ {Value: "all", Label: "all roles"},
1599
+ {Value: "conversation", Label: "user + assistant"},
1600
+ }
1601
+ default:
1602
+ return nil
1603
+ }
1604
+ }
1605
+
1606
+ func (m *model) filterOptionSelected(option filterOption) bool {
1607
+ switch m.filterSection {
1608
+ case sectionHarness:
1609
+ return m.providers[option.Value]
1610
+ case sectionRepos:
1611
+ return m.repoFilters[option.Value]
1612
+ case sectionModels:
1613
+ return m.modelFilters[option.Value]
1614
+ case sectionRoles:
1615
+ if option.Value == "all" {
1616
+ return m.allRoles
1617
+ }
1618
+ return !m.allRoles
1619
+ default:
1620
+ return false
1621
+ }
1622
+ }
1623
+
1624
+ func (m *model) hasActiveFilters() bool {
1625
+ return len(m.providers) > 0 || len(m.repoFilters) > 0 || len(m.modelFilters) > 0 || !m.allRoles
1626
+ }
1627
+
1628
+ func (m *model) providerSummary() string {
1629
+ return summarizeSet(m.providers, m.providerOptions, "all harnesses", "harnesses")
1630
+ }
1631
+
1632
+ func (m *model) compactProviderSummary() string {
1633
+ return compactSetSummary(m.providers, m.providerOptions, "all")
1634
+ }
1635
+
1636
+ func (m *model) repoSummary() string {
1637
+ return summarizeSet(m.repoFilters, m.repoOptions, "any repo", "repos")
1638
+ }
1639
+
1640
+ func (m *model) compactRepoSummary() string {
1641
+ return compactSetSummary(m.repoFilters, m.repoOptions, "any")
1642
+ }
1643
+
1644
+ func (m *model) modelSummary() string {
1645
+ return summarizeSet(m.modelFilters, m.modelOptions, "any model", "models")
1646
+ }
1647
+
1648
+ func (m *model) compactModelSummary() string {
1649
+ return compactSetSummary(m.modelFilters, m.modelOptions, "any")
1650
+ }
1651
+
1652
+ func (m *model) filterSummary() string {
1653
+ roles := "all roles"
1654
+ if !m.allRoles {
1655
+ roles = "user+assistant"
1656
+ }
1657
+ return strings.Join([]string{
1658
+ "harness " + m.providerSummary(),
1659
+ "repo " + m.repoSummary(),
1660
+ "model " + m.modelSummary(),
1661
+ "roles " + roles,
1662
+ }, " ")
1663
+ }
1664
+
1665
+ func summarizeSet(selected map[string]bool, options []filterOption, empty string, plural string) string {
1666
+ if len(selected) == 0 {
1667
+ return empty
1668
+ }
1669
+ labels := selectedLabels(selected, options)
1670
+ if len(labels) == 1 {
1671
+ return labels[0]
1672
+ }
1673
+ if len(labels) == 2 {
1674
+ return labels[0] + " + " + labels[1]
1675
+ }
1676
+ return fmt.Sprintf("%d %s", len(labels), plural)
1677
+ }
1678
+
1679
+ func compactSetSummary(selected map[string]bool, options []filterOption, empty string) string {
1680
+ if len(selected) == 0 {
1681
+ return empty
1682
+ }
1683
+ labels := selectedLabels(selected, options)
1684
+ if len(labels) == 1 {
1685
+ return fit(labels[0], 24)
1686
+ }
1687
+ return fmt.Sprintf("%d selected", len(labels))
1688
+ }
1689
+
1690
+ func selectedLabels(selected map[string]bool, options []filterOption) []string {
1691
+ labels := []string{}
1692
+ for _, option := range options {
1693
+ if selected[option.Value] {
1694
+ labels = append(labels, option.Label)
1695
+ }
1696
+ }
1697
+ if len(labels) > 0 {
1698
+ return labels
1699
+ }
1700
+ for value := range selected {
1701
+ labels = append(labels, value)
1702
+ }
1703
+ sort.Strings(labels)
1704
+ return labels
1705
+ }
1706
+
1707
+ func selectionCount(count int) string {
1708
+ if count == 0 {
1709
+ return "(all)"
1710
+ }
1711
+ return fmt.Sprintf("(%d)", count)
1712
+ }
1713
+
1714
+ func selectedSetValues(set map[string]bool) []string {
1715
+ values := make([]string, 0, len(set))
1716
+ for value := range set {
1717
+ values = append(values, value)
1718
+ }
1719
+ sort.Strings(values)
1720
+ return values
1721
+ }
1722
+
1723
+ func toggleSetValue(set map[string]bool, value string) {
1724
+ if set[value] {
1725
+ delete(set, value)
1726
+ return
1727
+ }
1728
+ set[value] = true
1729
+ }
1730
+
1731
+ func clearSet(set map[string]bool) {
1732
+ for value := range set {
1733
+ delete(set, value)
1734
+ }
1735
+ }
1736
+
1737
+ func (m *model) reload() {
1738
+ m.err = nil
1739
+ var rows []item
1740
+ var err error
1741
+ query := strings.TrimSpace(m.search.Value())
1742
+ providers := selectedSetValues(m.providers)
1743
+ models := selectedSetValues(m.modelFilters)
1744
+ repos := selectedSetValues(m.repoFilters)
1745
+ if query == "" {
1746
+ rows, err = listSessions(m.db, providers, models, repos)
1747
+ } else {
1748
+ rows, err = searchMessages(m.db, query, providers, models, repos, m.allRoles)
1749
+ }
1750
+ if err != nil {
1751
+ m.err = err
1752
+ m.results = nil
1753
+ return
1754
+ }
1755
+ m.results = rows
1756
+ if m.selected >= len(m.results) {
1757
+ m.selected = max(0, len(m.results)-1)
1758
+ }
1759
+ m.configureResultsTable(max(72, m.width), max(4, m.height/2))
1760
+ }
1761
+
1762
+ func (m *model) resetPosition() {
1763
+ m.selected = 0
1764
+ m.resultsTable.SetCursor(0)
1765
+ m.detail = false
1766
+ }
1767
+
1768
+ func (m *model) detailHeight() int {
1769
+ return max(1, m.height-5)
1770
+ }
1771
+
1772
+ func loadProviderOptions(db *sql.DB) ([]filterOption, error) {
1773
+ rows, err := db.Query(`
1774
+ SELECT s.provider, COUNT(*) AS sessions
1775
+ FROM sessions s
1776
+ WHERE ` + rootSessionFilter("s") + `
1777
+ AND ` + displayableSessionFilter("s") + `
1778
+ GROUP BY s.provider
1779
+ ORDER BY CASE s.provider WHEN 'codex' THEN 0 WHEN 'claude' THEN 1 WHEN 'pi' THEN 2 ELSE 3 END, s.provider
1780
+ `)
1781
+ if err != nil {
1782
+ return nil, err
1783
+ }
1784
+ defer rows.Close()
1785
+
1786
+ options := []filterOption{}
1787
+ for rows.Next() {
1788
+ var provider string
1789
+ var count int
1790
+ if err := rows.Scan(&provider, &count); err != nil {
1791
+ return nil, err
1792
+ }
1793
+ options = append(options, filterOption{
1794
+ Value: provider,
1795
+ Label: providerLabel(provider),
1796
+ Count: count,
1797
+ })
1798
+ }
1799
+ return options, rows.Err()
1800
+ }
1801
+
1802
+ func loadRepoOptions(db *sql.DB) ([]filterOption, error) {
1803
+ rows, err := db.Query(`
1804
+ SELECT COALESCE(s.cwd, ''), COUNT(*) AS sessions
1805
+ FROM sessions s
1806
+ WHERE COALESCE(s.cwd, '') != ''
1807
+ AND ` + rootSessionFilter("s") + `
1808
+ AND ` + displayableSessionFilter("s") + `
1809
+ GROUP BY s.cwd
1810
+ ORDER BY sessions DESC, MAX(COALESCE(s.updated_at, s.started_at, s.imported_at)) DESC, s.cwd ASC
1811
+ `)
1812
+ if err != nil {
1813
+ return nil, err
1814
+ }
1815
+ defer rows.Close()
1816
+
1817
+ options := []filterOption{}
1818
+ for rows.Next() {
1819
+ var cwd string
1820
+ var count int
1821
+ if err := rows.Scan(&cwd, &count); err != nil {
1822
+ return nil, err
1823
+ }
1824
+ options = append(options, filterOption{
1825
+ Value: cwd,
1826
+ Label: shortCwd(cwd),
1827
+ Count: count,
1828
+ })
1829
+ }
1830
+ return options, rows.Err()
1831
+ }
1832
+
1833
+ func loadModelOptions(db *sql.DB) ([]filterOption, error) {
1834
+ rows, err := db.Query(`
1835
+ SELECT sm.model, COUNT(DISTINCT sm.session_key) AS sessions
1836
+ FROM session_models sm
1837
+ JOIN sessions s ON s.session_key = sm.session_key
1838
+ WHERE sm.model != ''
1839
+ AND ` + rootSessionFilter("s") + `
1840
+ AND ` + displayableSessionFilter("s") + `
1841
+ GROUP BY sm.model
1842
+ ORDER BY sessions DESC, sm.model ASC
1843
+ `)
1844
+ if err != nil {
1845
+ return nil, err
1846
+ }
1847
+ defer rows.Close()
1848
+
1849
+ options := []filterOption{}
1850
+ for rows.Next() {
1851
+ var name string
1852
+ var count int
1853
+ if err := rows.Scan(&name, &count); err != nil {
1854
+ return nil, err
1855
+ }
1856
+ options = append(options, filterOption{Value: name, Label: name, Count: count})
1857
+ }
1858
+ return options, rows.Err()
1859
+ }
1860
+
1861
+ func listSessions(db *sql.DB, providers []string, models []string, repos []string) ([]item, error) {
1862
+ filters, args := sessionFilters("s", providers, models, repos)
1863
+ filters = append(filters, rootSessionFilter("s"))
1864
+ filters = append(filters, displayableSessionFilter("s"))
1865
+ where := ""
1866
+ if len(filters) > 0 {
1867
+ where = "WHERE " + strings.Join(filters, " AND ")
1868
+ }
1869
+ rows, err := db.Query(`
1870
+ SELECT s.session_key, s.provider, `+displayTitleExpr("s")+`,
1871
+ COALESCE(s.started_at, ''),
1872
+ COALESCE(s.updated_at, s.started_at, ''),
1873
+ COALESCE(s.cwd, ''), COALESCE(s.message_count, 0),
1874
+ COALESCE((SELECT GROUP_CONCAT(model) FROM (
1875
+ SELECT DISTINCT sm.model FROM session_models sm
1876
+ WHERE sm.session_key = s.session_key
1877
+ ORDER BY sm.model
1878
+ )), '')
1879
+ FROM sessions s
1880
+ `+where+`
1881
+ ORDER BY COALESCE(s.updated_at, s.started_at, s.imported_at) DESC
1882
+ `, args...)
1883
+ if err != nil {
1884
+ return nil, err
1885
+ }
1886
+ defer rows.Close()
1887
+
1888
+ items := []item{}
1889
+ for rows.Next() {
1890
+ var row item
1891
+ if err := rows.Scan(&row.SessionKey, &row.Provider, &row.Title, &row.CreatedAt, &row.UpdatedAt, &row.Cwd, &row.MessageCount, &row.Models); err != nil {
1892
+ return nil, err
1893
+ }
1894
+ row.Timestamp = row.UpdatedAt
1895
+ row.Title = oneLine(row.Title)
1896
+ items = append(items, row)
1897
+ }
1898
+ return items, rows.Err()
1899
+ }
1900
+
1901
+ func searchMessages(db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool) ([]item, error) {
1902
+ rows, err := searchMessagesFTS(db, query, providers, models, repos, allRoles)
1903
+ if err == nil {
1904
+ return rows, nil
1905
+ }
1906
+ return searchMessagesLike(db, query, providers, models, repos, allRoles)
1907
+ }
1908
+
1909
+ func searchMessagesFTS(db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool) ([]item, error) {
1910
+ args := []any{toFTSQuery(query)}
1911
+ filters := messageFilters("m", providers, models, repos, allRoles, &args)
1912
+ filters = append(filters, rootSessionFilter("s"))
1913
+ filters = append(filters, displayableSessionFilter("s"))
1914
+ where := "message_fts MATCH ?"
1915
+ if len(filters) > 0 {
1916
+ where += " AND " + strings.Join(filters, " AND ")
1917
+ }
1918
+ rows, err := db.Query(`
1919
+ SELECT m.session_key, m.provider, `+displayTitleExpr("s")+`,
1920
+ COALESCE(m.timestamp, s.updated_at, s.started_at, ''),
1921
+ COALESCE(s.started_at, ''),
1922
+ COALESCE(s.updated_at, s.started_at, ''),
1923
+ COALESCE(s.cwd, ''), COALESCE(s.message_count, 0),
1924
+ COALESCE((SELECT GROUP_CONCAT(model) FROM (
1925
+ SELECT DISTINCT sm.model FROM session_models sm
1926
+ WHERE sm.session_key = m.session_key
1927
+ ORDER BY sm.model
1928
+ )), ''),
1929
+ COALESCE(snippet(message_fts, 0, '[', ']', '...', 18), m.text),
1930
+ m.ordinal
1931
+ FROM message_fts
1932
+ JOIN messages m ON m.message_key = message_fts.message_key
1933
+ JOIN sessions s ON s.session_key = m.session_key
1934
+ WHERE `+where+`
1935
+ ORDER BY bm25(message_fts), COALESCE(m.timestamp, s.updated_at) DESC
1936
+ `, args...)
1937
+ if err != nil {
1938
+ return nil, err
1939
+ }
1940
+ defer rows.Close()
1941
+ items, err := scanItems(rows, true)
1942
+ if err != nil {
1943
+ return nil, err
1944
+ }
1945
+ return dedupeItems(items), nil
1946
+ }
1947
+
1948
+ func searchMessagesLike(db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool) ([]item, error) {
1949
+ args := []any{"%" + query + "%"}
1950
+ filters := []string{"m.text LIKE ?"}
1951
+ filters = append(filters, messageFilters("m", providers, models, repos, allRoles, &args)...)
1952
+ filters = append(filters, rootSessionFilter("s"))
1953
+ filters = append(filters, displayableSessionFilter("s"))
1954
+
1955
+ rows, err := db.Query(`
1956
+ SELECT m.session_key, m.provider, `+displayTitleExpr("s")+`,
1957
+ COALESCE(m.timestamp, s.updated_at, s.started_at, ''),
1958
+ COALESCE(s.started_at, ''),
1959
+ COALESCE(s.updated_at, s.started_at, ''),
1960
+ COALESCE(s.cwd, ''), COALESCE(s.message_count, 0),
1961
+ COALESCE((SELECT GROUP_CONCAT(model) FROM (
1962
+ SELECT DISTINCT sm.model FROM session_models sm
1963
+ WHERE sm.session_key = m.session_key
1964
+ ORDER BY sm.model
1965
+ )), ''),
1966
+ m.text,
1967
+ m.ordinal
1968
+ FROM messages m
1969
+ JOIN sessions s ON s.session_key = m.session_key
1970
+ WHERE `+strings.Join(filters, " AND ")+`
1971
+ ORDER BY COALESCE(m.timestamp, s.updated_at) DESC
1972
+ `, args...)
1973
+ if err != nil {
1974
+ return nil, err
1975
+ }
1976
+ defer rows.Close()
1977
+ items, err := scanItems(rows, true)
1978
+ if err != nil {
1979
+ return nil, err
1980
+ }
1981
+ return dedupeItems(items), nil
1982
+ }
1983
+
1984
+ func scanItems(rows *sql.Rows, isMatch bool) ([]item, error) {
1985
+ items := []item{}
1986
+ for rows.Next() {
1987
+ var row item
1988
+ if err := rows.Scan(&row.SessionKey, &row.Provider, &row.Title, &row.Timestamp, &row.CreatedAt, &row.UpdatedAt, &row.Cwd, &row.MessageCount, &row.Models, &row.Preview, &row.Ordinal); err != nil {
1989
+ return nil, err
1990
+ }
1991
+ if row.UpdatedAt == "" {
1992
+ row.UpdatedAt = row.Timestamp
1993
+ }
1994
+ row.IsMatch = isMatch
1995
+ row.Title = oneLine(row.Title)
1996
+ row.Preview = oneLine(row.Preview)
1997
+ items = append(items, row)
1998
+ }
1999
+ return items, rows.Err()
2000
+ }
2001
+
2002
+ func dedupeItems(items []item) []item {
2003
+ seen := map[string]bool{}
2004
+ deduped := []item{}
2005
+ for _, row := range items {
2006
+ if seen[row.SessionKey] {
2007
+ continue
2008
+ }
2009
+ seen[row.SessionKey] = true
2010
+ deduped = append(deduped, row)
2011
+ }
2012
+ return deduped
2013
+ }
2014
+
2015
+ func loadTranscript(db *sql.DB, sessionKey string, allRoles bool, width int) (string, error) {
2016
+ messages, err := loadTranscriptMessages(db, sessionKey, allRoles)
2017
+ if err != nil {
2018
+ return "", err
2019
+ }
2020
+ lines := []string{}
2021
+ for _, message := range messages {
2022
+ header := strings.ToUpper(message.Role) + " " + message.Timestamp
2023
+ if message.Model != "" {
2024
+ header += " " + message.Model
2025
+ }
2026
+ lines = append(lines, roleStyle(message.Role).Render(header))
2027
+ for _, raw := range strings.Split(message.Text, "\n") {
2028
+ for _, wrapped := range wrap(raw, max(24, width-4)) {
2029
+ lines = append(lines, baseStyle.Render(" "+wrapped))
2030
+ }
2031
+ }
2032
+ lines = append(lines, "")
2033
+ }
2034
+ if len(lines) == 0 {
2035
+ return mutedStyle.Render("No transcript messages matched the current role filter."), nil
2036
+ }
2037
+ return strings.Join(lines, "\n"), nil
2038
+ }
2039
+
2040
+ func loadTranscriptMessages(db *sql.DB, sessionKey string, allRoles bool) ([]transcriptMessage, error) {
2041
+ return loadTranscriptMessagesWithLimit(db, sessionKey, allRoles, 0)
2042
+ }
2043
+
2044
+ func loadTranscriptPreviewMessages(db *sql.DB, sessionKey string, allRoles bool, limit int) ([]transcriptMessage, error) {
2045
+ args := []any{sessionKey, "developer", "system"}
2046
+ filters := []string{
2047
+ "session_key = ?",
2048
+ "role NOT IN (?, ?)",
2049
+ "NOT " + hiddenTitleExpr("text"),
2050
+ }
2051
+ if !allRoles {
2052
+ filters = append(filters, "role IN (?, ?)")
2053
+ args = append(args, "user", "assistant")
2054
+ }
2055
+ if limit > 0 {
2056
+ args = append(args, limit)
2057
+ }
2058
+ limitSQL := ""
2059
+ if limit > 0 {
2060
+ limitSQL = "LIMIT ?"
2061
+ }
2062
+ rows, err := db.Query(`
2063
+ SELECT role, COALESCE(timestamp, ''), COALESCE(model, ''), text
2064
+ FROM messages
2065
+ WHERE `+strings.Join(filters, " AND ")+`
2066
+ ORDER BY
2067
+ CASE WHEN timestamp IS NULL OR timestamp = '' THEN 1 ELSE 0 END,
2068
+ timestamp ASC,
2069
+ ordinal ASC
2070
+ `+limitSQL+`
2071
+ `, args...)
2072
+ if err != nil {
2073
+ return nil, err
2074
+ }
2075
+ defer rows.Close()
2076
+
2077
+ messages := []transcriptMessage{}
2078
+ for rows.Next() {
2079
+ var message transcriptMessage
2080
+ if err := rows.Scan(&message.Role, &message.Timestamp, &message.Model, &message.Text); err != nil {
2081
+ return nil, err
2082
+ }
2083
+ messages = append(messages, message)
2084
+ }
2085
+ return messages, rows.Err()
2086
+ }
2087
+
2088
+ func loadTranscriptMessagesWithLimit(db *sql.DB, sessionKey string, allRoles bool, limit int) ([]transcriptMessage, error) {
2089
+ args := []any{sessionKey}
2090
+ roleSQL := ""
2091
+ if !allRoles {
2092
+ roleSQL = "AND role IN (?, ?)"
2093
+ args = append(args, "user", "assistant")
2094
+ }
2095
+ limitSQL := ""
2096
+ if limit > 0 {
2097
+ limitSQL = "LIMIT ?"
2098
+ args = append(args, limit)
2099
+ }
2100
+ rows, err := db.Query(`
2101
+ SELECT role, COALESCE(timestamp, ''), COALESCE(model, ''), text
2102
+ FROM messages
2103
+ WHERE session_key = ? `+roleSQL+`
2104
+ ORDER BY
2105
+ CASE WHEN timestamp IS NULL OR timestamp = '' THEN 1 ELSE 0 END,
2106
+ timestamp ASC,
2107
+ ordinal ASC
2108
+ `+limitSQL+`
2109
+ `, args...)
2110
+ if err != nil {
2111
+ return nil, err
2112
+ }
2113
+ defer rows.Close()
2114
+
2115
+ messages := []transcriptMessage{}
2116
+ for rows.Next() {
2117
+ var message transcriptMessage
2118
+ if err := rows.Scan(&message.Role, &message.Timestamp, &message.Model, &message.Text); err != nil {
2119
+ return nil, err
2120
+ }
2121
+ messages = append(messages, message)
2122
+ }
2123
+ return messages, rows.Err()
2124
+ }
2125
+
2126
+ func formatTranscriptExport(row item, messages []transcriptMessage, format string) string {
2127
+ if format == "md" {
2128
+ return formatTranscriptMarkdown(row, messages)
2129
+ }
2130
+ return formatTranscriptText(row, messages)
2131
+ }
2132
+
2133
+ func formatTranscriptMarkdown(row item, messages []transcriptMessage) string {
2134
+ var b strings.Builder
2135
+ title := row.Title
2136
+ if title == "" {
2137
+ title = row.SessionKey
2138
+ }
2139
+ b.WriteString("# " + title + "\n\n")
2140
+ writeMarkdownMeta(&b, "Harness", providerLabel(row.Provider))
2141
+ writeMarkdownMeta(&b, "Session", row.SessionKey)
2142
+ writeMarkdownMeta(&b, "Date", friendlyTime(row.Timestamp))
2143
+ writeMarkdownMeta(&b, "Repo", row.Cwd)
2144
+ writeMarkdownMeta(&b, "Models", row.Models)
2145
+ b.WriteString("\n## Transcript\n\n")
2146
+ for _, message := range messages {
2147
+ heading := strings.ToUpper(message.Role)
2148
+ b.WriteString("### " + heading + "\n\n")
2149
+ meta := strings.TrimSpace(strings.Join(nonEmpty([]string{message.Timestamp, message.Model}), " "))
2150
+ if meta != "" {
2151
+ b.WriteString("_" + escapeMarkdown(meta) + "_\n\n")
2152
+ }
2153
+ b.WriteString(strings.TrimRight(message.Text, "\n") + "\n\n")
2154
+ }
2155
+ return b.String()
2156
+ }
2157
+
2158
+ func writeMarkdownMeta(b *strings.Builder, label string, value string) {
2159
+ if strings.TrimSpace(value) == "" {
2160
+ return
2161
+ }
2162
+ b.WriteString("- **" + label + ":** " + escapeMarkdown(value) + "\n")
2163
+ }
2164
+
2165
+ func formatTranscriptText(row item, messages []transcriptMessage) string {
2166
+ var b strings.Builder
2167
+ title := row.Title
2168
+ if title == "" {
2169
+ title = row.SessionKey
2170
+ }
2171
+ b.WriteString(title + "\n")
2172
+ b.WriteString(strings.Repeat("=", len([]rune(title))) + "\n\n")
2173
+ writeTextMeta(&b, "Harness", providerLabel(row.Provider))
2174
+ writeTextMeta(&b, "Session", row.SessionKey)
2175
+ writeTextMeta(&b, "Date", friendlyTime(row.Timestamp))
2176
+ writeTextMeta(&b, "Repo", row.Cwd)
2177
+ writeTextMeta(&b, "Models", row.Models)
2178
+ b.WriteString("\nTranscript\n\n")
2179
+ for _, message := range messages {
2180
+ headerParts := nonEmpty([]string{strings.ToUpper(message.Role), message.Timestamp, message.Model})
2181
+ b.WriteString(strings.Join(headerParts, " ") + "\n")
2182
+ b.WriteString(strings.TrimRight(message.Text, "\n") + "\n\n")
2183
+ }
2184
+ return b.String()
2185
+ }
2186
+
2187
+ func writeTextMeta(b *strings.Builder, label string, value string) {
2188
+ if strings.TrimSpace(value) == "" {
2189
+ return
2190
+ }
2191
+ b.WriteString(label + ": " + value + "\n")
2192
+ }
2193
+
2194
+ func exportFilePath(row item, format string) (string, error) {
2195
+ ext := "txt"
2196
+ if format == "md" {
2197
+ ext = "md"
2198
+ }
2199
+ home, err := os.UserHomeDir()
2200
+ if err != nil {
2201
+ return "", err
2202
+ }
2203
+ dir := filepath.Join(home, "Downloads", "gora-exports")
2204
+ baseParts := []string{
2205
+ safeFilenamePart(row.Provider),
2206
+ safeFilenamePart(shortSessionKey(row.SessionKey)),
2207
+ safeFilenamePart(row.Title),
2208
+ }
2209
+ base := strings.Join(nonEmpty(baseParts), "-")
2210
+ if base == "" {
2211
+ base = "chat"
2212
+ }
2213
+ return uniqueFilePath(filepath.Join(dir, fitFilename(base, 120)+"."+ext)), nil
2214
+ }
2215
+
2216
+ func uniqueFilePath(path string) string {
2217
+ if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
2218
+ return path
2219
+ }
2220
+ ext := filepath.Ext(path)
2221
+ stem := strings.TrimSuffix(path, ext)
2222
+ for index := 2; ; index++ {
2223
+ candidate := fmt.Sprintf("%s-%d%s", stem, index, ext)
2224
+ if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
2225
+ return candidate
2226
+ }
2227
+ }
2228
+ }
2229
+
2230
+ func shortSessionKey(value string) string {
2231
+ value = strings.TrimSpace(value)
2232
+ if value == "" {
2233
+ return ""
2234
+ }
2235
+ if parts := strings.Split(value, ":"); len(parts) > 1 {
2236
+ value = parts[len(parts)-1]
2237
+ }
2238
+ if len(value) > 12 {
2239
+ return value[:12]
2240
+ }
2241
+ return value
2242
+ }
2243
+
2244
+ func safeFilenamePart(value string) string {
2245
+ value = strings.ToLower(oneLine(value))
2246
+ var b strings.Builder
2247
+ lastDash := false
2248
+ for _, r := range value {
2249
+ keep := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
2250
+ if keep {
2251
+ b.WriteRune(r)
2252
+ lastDash = false
2253
+ continue
2254
+ }
2255
+ if !lastDash {
2256
+ b.WriteRune('-')
2257
+ lastDash = true
2258
+ }
2259
+ }
2260
+ return strings.Trim(b.String(), "-")
2261
+ }
2262
+
2263
+ func fitFilename(value string, width int) string {
2264
+ if len(value) <= width {
2265
+ return value
2266
+ }
2267
+ return strings.Trim(value[:width], "-")
2268
+ }
2269
+
2270
+ func escapeMarkdown(value string) string {
2271
+ replacer := strings.NewReplacer(`\`, `\\`, `*`, `\*`, `_`, `\_`, "`", "\\`")
2272
+ return replacer.Replace(value)
2273
+ }
2274
+
2275
+ func nonEmpty(values []string) []string {
2276
+ out := []string{}
2277
+ for _, value := range values {
2278
+ if strings.TrimSpace(value) != "" {
2279
+ out = append(out, value)
2280
+ }
2281
+ }
2282
+ return out
2283
+ }
2284
+
2285
+ func sessionFilters(alias string, providers []string, models []string, repos []string) ([]string, []any) {
2286
+ args := []any{}
2287
+ filters := []string{}
2288
+ appendInFilter(&filters, &args, alias+".provider", providers)
2289
+ appendInFilter(&filters, &args, alias+".cwd", repos)
2290
+ if len(models) > 0 {
2291
+ filters = append(filters, "EXISTS (SELECT 1 FROM session_models sm_filter WHERE sm_filter.session_key = "+alias+".session_key AND sm_filter.model IN ("+placeholders(len(models))+"))")
2292
+ for _, modelName := range models {
2293
+ args = append(args, modelName)
2294
+ }
2295
+ }
2296
+ return filters, args
2297
+ }
2298
+
2299
+ func messageFilters(alias string, providers []string, models []string, repos []string, allRoles bool, args *[]any) []string {
2300
+ filters := []string{}
2301
+ appendInFilter(&filters, args, alias+".provider", providers)
2302
+ if !allRoles {
2303
+ filters = append(filters, alias+".role IN (?, ?)")
2304
+ *args = append(*args, "user", "assistant")
2305
+ }
2306
+ appendInFilter(&filters, args, "s.cwd", repos)
2307
+ if len(models) > 0 {
2308
+ filters = append(filters, "EXISTS (SELECT 1 FROM session_models sm_filter WHERE sm_filter.session_key = "+alias+".session_key AND sm_filter.model IN ("+placeholders(len(models))+"))")
2309
+ for _, modelName := range models {
2310
+ *args = append(*args, modelName)
2311
+ }
2312
+ }
2313
+ return filters
2314
+ }
2315
+
2316
+ func appendInFilter(filters *[]string, args *[]any, expr string, values []string) {
2317
+ if len(values) == 0 {
2318
+ return
2319
+ }
2320
+ if len(values) == 1 {
2321
+ *filters = append(*filters, expr+" = ?")
2322
+ *args = append(*args, values[0])
2323
+ return
2324
+ }
2325
+ *filters = append(*filters, expr+" IN ("+placeholders(len(values))+")")
2326
+ for _, value := range values {
2327
+ *args = append(*args, value)
2328
+ }
2329
+ }
2330
+
2331
+ func placeholders(count int) string {
2332
+ if count <= 0 {
2333
+ return ""
2334
+ }
2335
+ return strings.TrimRight(strings.Repeat("?,", count), ",")
2336
+ }
2337
+
2338
+ func displayTitleExpr(alias string) string {
2339
+ title := alias + ".title"
2340
+ fallback := firstDisplayableMessageExpr(alias)
2341
+ return `COALESCE(
2342
+ NULLIF(TRIM(CASE WHEN ` + hiddenTitleExpr(title) + ` THEN ` + fallback + ` ELSE ` + title + ` END), ''),
2343
+ NULLIF(TRIM(` + fallback + `), ''),
2344
+ ` + alias + `.session_key
2345
+ )`
2346
+ }
2347
+
2348
+ func firstDisplayableMessageExpr(alias string) string {
2349
+ return `(
2350
+ SELECT title_message.text FROM messages title_message
2351
+ WHERE title_message.session_key = ` + alias + `.session_key
2352
+ AND title_message.role IN ('user', 'assistant')
2353
+ AND NOT ` + hiddenTitleExpr("title_message.text") + `
2354
+ ORDER BY CASE title_message.role WHEN 'user' THEN 0 ELSE 1 END, title_message.ordinal ASC
2355
+ LIMIT 1
2356
+ )`
2357
+ }
2358
+
2359
+ func displayableSessionFilter(alias string) string {
2360
+ return `EXISTS (
2361
+ SELECT 1 FROM messages title_message
2362
+ WHERE title_message.session_key = ` + alias + `.session_key
2363
+ AND title_message.role IN ('user', 'assistant')
2364
+ AND NOT ` + hiddenTitleExpr("title_message.text") + `
2365
+ )`
2366
+ }
2367
+
2368
+ func rootSessionFilter(alias string) string {
2369
+ return "(" + alias + ".parent_session_key IS NULL OR " + alias + ".parent_session_key = '')"
2370
+ }
2371
+
2372
+ func hiddenTitleExpr(expr string) string {
2373
+ return `(
2374
+ ` + expr + ` IS NULL
2375
+ OR TRIM(` + expr + `) = ''
2376
+ OR LTRIM(` + expr + `) LIKE '# AGENTS.md instructions%'
2377
+ OR LTRIM(` + expr + `) LIKE 'AGENTS.md instructions%'
2378
+ OR LTRIM(` + expr + `) LIKE '<turn_aborted>%'
2379
+ OR LTRIM(` + expr + `) LIKE '<user_action>%'
2380
+ OR LTRIM(` + expr + `) LIKE '<environment_context>%'
2381
+ )`
2382
+ }
2383
+
2384
+ func toFTSQuery(query string) string {
2385
+ terms := strings.Fields(query)
2386
+ if len(terms) == 0 {
2387
+ return `""`
2388
+ }
2389
+ escaped := make([]string, 0, len(terms))
2390
+ for _, term := range terms {
2391
+ term = strings.Trim(term, `"`)
2392
+ term = strings.ReplaceAll(term, `"`, `""`)
2393
+ escaped = append(escaped, `"`+term+`"`)
2394
+ }
2395
+ return strings.Join(escaped, " AND ")
2396
+ }
2397
+
2398
+ func defaultDBPath() string {
2399
+ if value := os.Getenv("GORA_DB"); value != "" {
2400
+ return expandHome(value)
2401
+ }
2402
+ if value := os.Getenv("XDG_DATA_HOME"); value != "" {
2403
+ return filepath.Join(expandHome(value), "gora", "history.sqlite")
2404
+ }
2405
+ home, err := os.UserHomeDir()
2406
+ if err != nil {
2407
+ return "history.sqlite"
2408
+ }
2409
+ if runtime.GOOS == "darwin" {
2410
+ return filepath.Join(home, "Library", "Application Support", "gora", "history.sqlite")
2411
+ }
2412
+ return filepath.Join(home, ".local", "share", "gora", "history.sqlite")
2413
+ }
2414
+
2415
+ func expandHome(path string) string {
2416
+ if path == "~" {
2417
+ home, _ := os.UserHomeDir()
2418
+ return home
2419
+ }
2420
+ if strings.HasPrefix(path, "~/") {
2421
+ home, _ := os.UserHomeDir()
2422
+ return filepath.Join(home, path[2:])
2423
+ }
2424
+ return path
2425
+ }
2426
+
2427
+ func providerStyle(provider string, selectedRow bool) lipgloss.Style {
2428
+ providerColor := cyan
2429
+ switch provider {
2430
+ case "codex":
2431
+ providerColor = purple
2432
+ case "pi":
2433
+ providerColor = green
2434
+ case "claude":
2435
+ providerColor = cyan
2436
+ }
2437
+ return lipgloss.NewStyle().Foreground(providerColor).Background(bg).Bold(selectedRow)
2438
+ }
2439
+
2440
+ func roleStyle(role string) lipgloss.Style {
2441
+ switch role {
2442
+ case "user":
2443
+ return lipgloss.NewStyle().Foreground(cyan).Background(bg).Bold(true)
2444
+ case "assistant":
2445
+ return lipgloss.NewStyle().Foreground(purple).Background(bg).Bold(true)
2446
+ case "tool-call":
2447
+ return lipgloss.NewStyle().Foreground(green).Background(bg).Bold(true)
2448
+ case "tool":
2449
+ return lipgloss.NewStyle().Foreground(yellow).Background(bg).Bold(true)
2450
+ case "system", "developer":
2451
+ return lipgloss.NewStyle().Foreground(red).Background(bg).Bold(true)
2452
+ default:
2453
+ return lipgloss.NewStyle().Foreground(green).Background(bg).Bold(true)
2454
+ }
2455
+ }
2456
+
2457
+ func providerBadge(provider string) string {
2458
+ if provider == "claude" {
2459
+ return "Claude"
2460
+ }
2461
+ return providerLabel(provider)
2462
+ }
2463
+
2464
+ func providerLabel(provider string) string {
2465
+ switch provider {
2466
+ case "codex":
2467
+ return "Codex"
2468
+ case "claude":
2469
+ return "Claude Code"
2470
+ case "pi":
2471
+ return "Pi"
2472
+ default:
2473
+ return provider
2474
+ }
2475
+ }
2476
+
2477
+ func modelSummary(models string) string {
2478
+ if models == "" {
2479
+ return ""
2480
+ }
2481
+ parts := strings.Split(models, ",")
2482
+ if len(parts) <= 1 {
2483
+ return models
2484
+ }
2485
+ return parts[0] + fmt.Sprintf(" +%d", len(parts)-1)
2486
+ }
2487
+
2488
+ func friendlyTime(value string) string {
2489
+ if value == "" {
2490
+ return "-"
2491
+ }
2492
+ if parsed, err := parseTime(value); err == nil {
2493
+ return parsed.Format("2006-01-02 15:04")
2494
+ }
2495
+ if parts := strings.SplitN(value, "T", 2); len(parts) == 2 {
2496
+ timePart := strings.TrimSuffix(parts[1], "Z")
2497
+ if len(timePart) >= 5 {
2498
+ return parts[0] + " " + timePart[:5]
2499
+ }
2500
+ return parts[0] + " " + timePart
2501
+ }
2502
+ return value
2503
+ }
2504
+
2505
+ func relativeTime(value string) string {
2506
+ parsed, err := parseTime(value)
2507
+ if err != nil {
2508
+ return "-"
2509
+ }
2510
+ delta := time.Since(parsed)
2511
+ if delta < time.Minute {
2512
+ return "now"
2513
+ }
2514
+ if delta < time.Hour {
2515
+ return fmt.Sprintf("%dm ago", int(delta.Minutes()))
2516
+ }
2517
+ if delta < 24*time.Hour {
2518
+ return fmt.Sprintf("%dh ago", int(delta.Hours()))
2519
+ }
2520
+ days := int(delta.Hours() / 24)
2521
+ if days < 90 {
2522
+ return fmt.Sprintf("%dd ago", days)
2523
+ }
2524
+ weeks := days / 7
2525
+ if weeks < 52 {
2526
+ return fmt.Sprintf("%dw ago", weeks)
2527
+ }
2528
+ return fmt.Sprintf("%dy ago", days/365)
2529
+ }
2530
+
2531
+ func parseTime(value string) (time.Time, error) {
2532
+ if value == "" {
2533
+ return time.Time{}, errors.New("empty time")
2534
+ }
2535
+ if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
2536
+ return parsed, nil
2537
+ }
2538
+ return time.Parse("2006-01-02T15:04:05", strings.TrimSuffix(value, "Z"))
2539
+ }
2540
+
2541
+ func shortCwd(cwd string) string {
2542
+ if cwd == "" {
2543
+ return "no repo"
2544
+ }
2545
+ parts := strings.Split(filepath.Clean(cwd), string(filepath.Separator))
2546
+ if len(parts) <= 3 {
2547
+ return cwd
2548
+ }
2549
+ return filepath.Join(parts[len(parts)-3:]...)
2550
+ }
2551
+
2552
+ func oneLine(text string) string {
2553
+ withoutImages := imageTagPattern.ReplaceAllString(text, " ")
2554
+ cleaned := strings.Join(strings.Fields(withoutImages), " ")
2555
+ if cleaned == "" && imageTagPattern.MatchString(text) {
2556
+ return "image attachment"
2557
+ }
2558
+ return cleaned
2559
+ }
2560
+
2561
+ func padRight(text string, width int) string {
2562
+ runes := []rune(text)
2563
+ if len(runes) >= width {
2564
+ return string(runes[:width])
2565
+ }
2566
+ return text + strings.Repeat(" ", width-len(runes))
2567
+ }
2568
+
2569
+ func fit(text string, width int) string {
2570
+ if width <= 0 {
2571
+ return ""
2572
+ }
2573
+ runes := []rune(text)
2574
+ if len(runes) <= width {
2575
+ return text
2576
+ }
2577
+ if width <= 3 {
2578
+ return string(runes[:width])
2579
+ }
2580
+ return string(runes[:width-3]) + "..."
2581
+ }
2582
+
2583
+ func wrap(text string, width int) []string {
2584
+ text = strings.TrimRight(text, "\r")
2585
+ if text == "" {
2586
+ return []string{""}
2587
+ }
2588
+ words := strings.Fields(text)
2589
+ if len(words) == 0 {
2590
+ return []string{""}
2591
+ }
2592
+ lines := []string{}
2593
+ current := ""
2594
+ for _, word := range words {
2595
+ if current == "" {
2596
+ current = word
2597
+ continue
2598
+ }
2599
+ if len([]rune(current))+1+len([]rune(word)) > width {
2600
+ lines = append(lines, current)
2601
+ current = word
2602
+ continue
2603
+ }
2604
+ current += " " + word
2605
+ }
2606
+ if current != "" {
2607
+ lines = append(lines, current)
2608
+ }
2609
+ return lines
2610
+ }
2611
+
2612
+ func clamp(value int, low int, high int) int {
2613
+ if value < low {
2614
+ return low
2615
+ }
2616
+ if value > high {
2617
+ return high
2618
+ }
2619
+ return value
2620
+ }
2621
+
2622
+ func min(left int, right int) int {
2623
+ if left < right {
2624
+ return left
2625
+ }
2626
+ return right
2627
+ }
2628
+
2629
+ func max(left int, right int) int {
2630
+ if left > right {
2631
+ return left
2632
+ }
2633
+ return right
2634
+ }