gora-cli 0.1.2__tar.gz → 0.1.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gora-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Local CLI chat history index for Codex, Claude Code, and Pi.
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -124,6 +124,8 @@ go -C gora/go_tui build -o ../../dist/gora-tui .
124
124
  GORA_TUI_BIN=dist/gora-tui gora
125
125
  ```
126
126
 
127
+ Live search starts after two characters and updates as you type.
128
+
127
129
  Controls:
128
130
 
129
131
  ```text
@@ -111,6 +111,8 @@ go -C gora/go_tui build -o ../../dist/gora-tui .
111
111
  GORA_TUI_BIN=dist/gora-tui gora
112
112
  ```
113
113
 
114
+ Live search starts after two characters and updates as you type.
115
+
114
116
  Controls:
115
117
 
116
118
  ```text
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.1.2"
5
+ __version__ = "0.1.4"
@@ -1,6 +1,7 @@
1
1
  package main
2
2
 
3
3
  import (
4
+ "context"
4
5
  "database/sql"
5
6
  "errors"
6
7
  "flag"
@@ -49,6 +50,33 @@ var (
49
50
  imageTagPattern = regexp.MustCompile(`(?s)</?image\b[^>]*(>|$)`)
50
51
  )
51
52
 
53
+ const (
54
+ tuiSearchDebounce = 180 * time.Millisecond
55
+ tuiSearchMinRunes = 2
56
+ tuiSearchLimit = 500
57
+ )
58
+
59
+ type reloadTickMsg struct {
60
+ token int
61
+ }
62
+
63
+ type resultsLoadedMsg struct {
64
+ token int
65
+ query string
66
+ rows []item
67
+ truncated bool
68
+ tooShort bool
69
+ err error
70
+ }
71
+
72
+ type previewLoadedMsg struct {
73
+ token int
74
+ sessionKey string
75
+ allRoles bool
76
+ messages []transcriptMessage
77
+ err error
78
+ }
79
+
52
80
  type item struct {
53
81
  SessionKey string
54
82
  Provider string
@@ -334,44 +362,56 @@ func newFilterList(width int, height int) list.Model {
334
362
  }
335
363
 
336
364
  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
365
+ db *sql.DB
366
+ dbPath string
367
+ search textinput.Model
368
+ transcriptView viewport.Model
369
+ helpBubble help.Model
370
+ resultsTable table.Model
371
+ filterList list.Model
372
+ keys keyMap
373
+ providers map[string]bool
374
+ repoFilters map[string]bool
375
+ modelFilters map[string]bool
376
+ providerOptions []filterOption
377
+ repoOptions []filterOption
378
+ modelOptions []filterOption
379
+ allRoles bool
380
+ detail bool
381
+ detailItem item
382
+ filterOpen bool
383
+ focus focusArea
384
+ filterSection filterSection
385
+ previewKey string
386
+ previewAllRoles bool
387
+ previewLoading bool
388
+ previewMessages []transcriptMessage
389
+ previewErr error
390
+ previewToken int
391
+ selected int
392
+ width int
393
+ height int
394
+ searchTop int
395
+ searchBottom int
396
+ filterTop int
397
+ filterBottom int
398
+ rowsTop int
399
+ browseButtons []actionButton
400
+ detailButtons []actionButton
401
+ results []item
402
+ loading bool
403
+ searchTooShort bool
404
+ resultsTruncated bool
405
+ reloadToken int
406
+ cancelReload context.CancelFunc
407
+ resultsVersion int
408
+ tableRowsVersion int
409
+ tableRowsWidth int
410
+ tableRowsHeight int
411
+ tableRowsSelected int
412
+ tableRows []table.Row
413
+ notice string
414
+ err error
375
415
  }
376
416
 
377
417
  func main() {
@@ -391,7 +431,9 @@ func main() {
391
431
  os.Exit(1)
392
432
  }
393
433
  defer db.Close()
394
- db.SetMaxOpenConns(1)
434
+ db.SetMaxOpenConns(4)
435
+ db.SetMaxIdleConns(4)
436
+ _, _ = db.Exec("PRAGMA busy_timeout = 1000")
395
437
 
396
438
  search := textinput.New()
397
439
  search.Placeholder = "type to search local Codex, Claude Code, and Pi chats"
@@ -419,7 +461,6 @@ func main() {
419
461
  m.providerOptions, _ = loadProviderOptions(db)
420
462
  m.repoOptions, _ = loadRepoOptions(db)
421
463
  m.modelOptions, _ = loadModelOptions(db)
422
- m.reload()
423
464
 
424
465
  program := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
425
466
  if _, err := program.Run(); err != nil {
@@ -437,7 +478,7 @@ func configureTerminalColors() {
437
478
  }
438
479
 
439
480
  func (m *model) Init() tea.Cmd {
440
- return textinput.Blink
481
+ return tea.Batch(textinput.Blink, m.requestReload(0))
441
482
  }
442
483
 
443
484
  func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -455,6 +496,15 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
455
496
  return m, m.handleKey(msg)
456
497
  case tea.MouseMsg:
457
498
  return m, m.handleMouse(msg)
499
+ case reloadTickMsg:
500
+ if msg.token != m.reloadToken {
501
+ return m, nil
502
+ }
503
+ return m, m.loadResultsCmd(msg.token)
504
+ case resultsLoadedMsg:
505
+ return m, m.applyResults(msg)
506
+ case previewLoadedMsg:
507
+ m.applyPreview(msg)
458
508
  }
459
509
  return m, nil
460
510
  }
@@ -489,15 +539,13 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
489
539
  if m.search.Value() != "" {
490
540
  m.search.SetValue("")
491
541
  m.resetPosition()
492
- m.reload()
493
- return nil
542
+ return m.requestReload(0)
494
543
  }
495
544
  if m.hasActiveFilters() {
496
545
  m.clearFilters()
497
546
  m.notice = "filters cleared"
498
547
  m.resetPosition()
499
- m.reload()
500
- return nil
548
+ return m.requestReload(0)
501
549
  }
502
550
  return tea.Quit
503
551
  case "tab":
@@ -510,17 +558,17 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
510
558
  case "/":
511
559
  m.setFocus(focusSearch)
512
560
  case "up", "k":
513
- m.move(-1)
514
561
  m.setFocus(focusResults)
562
+ return m.move(-1)
515
563
  case "down", "j":
516
- m.move(1)
517
564
  m.setFocus(focusResults)
565
+ return m.move(1)
518
566
  case "pgup":
519
- m.move(-10)
520
567
  m.setFocus(focusResults)
568
+ return m.move(-10)
521
569
  case "pgdown":
522
- m.move(10)
523
570
  m.setFocus(focusResults)
571
+ return m.move(10)
524
572
  case "enter", "right":
525
573
  if m.focus == focusFilters {
526
574
  m.openFilters()
@@ -533,27 +581,36 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
533
581
  case "ctrl+r":
534
582
  m.allRoles = !m.allRoles
535
583
  m.resetPosition()
536
- m.reload()
584
+ return m.requestReload(0)
537
585
  case "ctrl+u":
538
586
  m.search.SetValue("")
539
587
  m.resetPosition()
540
- m.reload()
588
+ return m.requestReload(0)
541
589
  default:
542
590
  if m.focus != focusSearch {
591
+ if key == "q" {
592
+ return tea.Quit
593
+ }
543
594
  if key == "m" {
544
595
  m.exportSelected("md")
596
+ return nil
545
597
  }
546
598
  if key == "t" {
547
599
  m.exportSelected("txt")
600
+ return nil
601
+ }
602
+ if isPrintableSearchKey(msg) {
603
+ m.setFocus(focusSearch)
604
+ } else {
605
+ return nil
548
606
  }
549
- return nil
550
607
  }
551
608
  before := m.search.Value()
552
609
  var cmd tea.Cmd
553
610
  m.search, cmd = m.search.Update(msg)
554
611
  if m.search.Value() != before {
555
612
  m.resetPosition()
556
- m.reload()
613
+ return tea.Batch(cmd, m.requestReload(tuiSearchDebounce))
557
614
  }
558
615
  return cmd
559
616
  }
@@ -573,14 +630,12 @@ func (m *model) handleMouse(msg tea.MouseMsg) tea.Cmd {
573
630
  return cmd
574
631
  }
575
632
  if msg.Button == tea.MouseButtonWheelUp {
576
- m.move(-3)
577
633
  m.setFocus(focusResults)
578
- return nil
634
+ return m.move(-3)
579
635
  }
580
636
  if msg.Button == tea.MouseButtonWheelDown {
581
- m.move(3)
582
637
  m.setFocus(focusResults)
583
- return nil
638
+ return m.move(3)
584
639
  }
585
640
  if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress {
586
641
  return nil
@@ -800,8 +855,21 @@ func (m *model) renderFilters(width int) string {
800
855
  }
801
856
 
802
857
  func (m *model) renderStatus(width int) string {
858
+ if m.loading {
859
+ query := strings.TrimSpace(m.search.Value())
860
+ if query != "" {
861
+ return m.renderStatusLine(fmt.Sprintf("searching for %q", query), width)
862
+ }
863
+ return m.renderStatusLine("loading recent chats", width)
864
+ }
865
+ if m.searchTooShort {
866
+ return m.renderStatusLine(fmt.Sprintf("type at least %d characters to search", tuiSearchMinRunes), width)
867
+ }
803
868
  if m.search.Value() != "" {
804
869
  status := fmt.Sprintf("%d matches for %q", len(m.results), m.search.Value())
870
+ if m.resultsTruncated {
871
+ status = fmt.Sprintf("showing first %d matches for %q", len(m.results), m.search.Value())
872
+ }
805
873
  return m.renderStatusLine(status, width)
806
874
  }
807
875
  return m.renderStatusLine(fmt.Sprintf("%d recent chats", len(m.results)), width)
@@ -826,6 +894,12 @@ func (m *model) renderResultsTitle(width int) string {
826
894
 
827
895
  func (m *model) renderResultsTable(width int, height int) string {
828
896
  if len(m.results) == 0 {
897
+ if m.loading {
898
+ return mutedStyle.Render(" loading...")
899
+ }
900
+ if m.searchTooShort {
901
+ return mutedStyle.Render(fmt.Sprintf(" type at least %d characters to search", tuiSearchMinRunes))
902
+ }
829
903
  if m.hasActiveFilters() {
830
904
  return mutedStyle.Render(" no chats match the current filters")
831
905
  }
@@ -894,31 +968,20 @@ func clipStyledLines(lines []string, height int) []string {
894
968
  }
895
969
 
896
970
  func (m *model) transcriptPreviewLines(row item, width int) []string {
897
- if m.db == nil {
971
+ if m.previewKey != row.SessionKey || m.previewAllRoles != m.allRoles || m.previewLoading {
972
+ return []string{mutedStyle.Render("loading preview...")}
973
+ }
974
+ if m.previewErr != nil {
975
+ return []string{lipgloss.NewStyle().Foreground(red).Render(fit(m.previewErr.Error(), width))}
976
+ }
977
+ if len(m.previewMessages) == 0 {
898
978
  fallback := "session " + row.SessionKey
899
979
  if row.Preview != "" {
900
980
  fallback = "match " + row.Preview
901
981
  }
902
982
  return []string{mutedStyle.Render(fit(fallback, width))}
903
983
  }
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
984
+ return formatTranscriptPreviewLines(m.previewMessages, width)
922
985
  }
923
986
 
924
987
  func formatTranscriptPreviewLines(messages []transcriptMessage, width int) []string {
@@ -960,12 +1023,28 @@ func (m *model) configureResultsTable(width int, height int) {
960
1023
  if m.resultsTable.Width() == 0 && m.resultsTable.Height() == 0 {
961
1024
  m.resultsTable = newResultsTable()
962
1025
  }
963
- m.resultsTable.SetColumns(resultTableColumns(width))
964
1026
  m.selected = clamp(m.selected, 0, max(0, len(m.results)-1))
965
- m.resultsTable.SetRows(resultTableRows(m.results, m.selected))
1027
+ height = max(4, height)
1028
+ if m.tableRowsVersion != m.resultsVersion ||
1029
+ m.tableRowsWidth != width ||
1030
+ m.tableRowsHeight != height {
1031
+ m.resultsTable.SetColumns(resultTableColumns(width))
1032
+ m.tableRows = resultTableRows(m.results, m.selected)
1033
+ m.resultsTable.SetRows(m.tableRows)
1034
+ m.tableRowsVersion = m.resultsVersion
1035
+ m.tableRowsWidth = width
1036
+ m.tableRowsHeight = height
1037
+ m.tableRowsSelected = m.selected
1038
+ } else if m.tableRowsSelected != m.selected {
1039
+ updateSelectedMarker(m.tableRows, m.tableRowsSelected, m.selected)
1040
+ m.resultsTable.SetRows(m.tableRows)
1041
+ m.tableRowsSelected = m.selected
1042
+ }
966
1043
  m.resultsTable.SetWidth(width)
967
- m.resultsTable.SetHeight(max(4, height))
968
- m.resultsTable.SetCursor(m.selected)
1044
+ m.resultsTable.SetHeight(height)
1045
+ if m.resultsTable.Cursor() != m.selected {
1046
+ m.resultsTable.SetCursor(m.selected)
1047
+ }
969
1048
  if m.focus == focusResults {
970
1049
  m.resultsTable.Focus()
971
1050
  } else {
@@ -973,6 +1052,15 @@ func (m *model) configureResultsTable(width int, height int) {
973
1052
  }
974
1053
  }
975
1054
 
1055
+ func updateSelectedMarker(rows []table.Row, oldSelected int, newSelected int) {
1056
+ if oldSelected >= 0 && oldSelected < len(rows) && len(rows[oldSelected]) > 0 {
1057
+ rows[oldSelected][0] = ""
1058
+ }
1059
+ if newSelected >= 0 && newSelected < len(rows) && len(rows[newSelected]) > 0 {
1060
+ rows[newSelected][0] = ">"
1061
+ }
1062
+ }
1063
+
976
1064
  func resultTableColumns(width int) []table.Column {
977
1065
  inner := max(40, width-4)
978
1066
  markerWidth := 2
@@ -1366,17 +1454,23 @@ func (m *model) renderDetailStatus(width int) string {
1366
1454
  return ""
1367
1455
  }
1368
1456
 
1369
- func (m *model) move(delta int) {
1457
+ func (m *model) move(delta int) tea.Cmd {
1370
1458
  if len(m.results) == 0 {
1371
1459
  m.selected = 0
1372
- return
1460
+ return nil
1373
1461
  }
1462
+ before := m.selected
1374
1463
  if delta < 0 {
1375
1464
  m.resultsTable.MoveUp(-delta)
1376
1465
  } else {
1377
1466
  m.resultsTable.MoveDown(delta)
1378
1467
  }
1379
1468
  m.selected = clamp(m.resultsTable.Cursor(), 0, len(m.results)-1)
1469
+ m.configureResultsTable(max(72, m.tableRowsWidth), max(4, m.tableRowsHeight))
1470
+ if m.selected == before {
1471
+ return nil
1472
+ }
1473
+ return m.requestPreview()
1380
1474
  }
1381
1475
 
1382
1476
  func (m *model) openDetail() {
@@ -1459,11 +1553,11 @@ func (m *model) openFilters() {
1459
1553
  m.configureFilterList(max(72, m.width), max(8, m.height-8))
1460
1554
  }
1461
1555
 
1462
- func (m *model) closeFilters() {
1556
+ func (m *model) closeFilters() tea.Cmd {
1463
1557
  m.filterOpen = false
1464
1558
  m.setFocus(focusFilters)
1465
1559
  m.resetPosition()
1466
- m.reload()
1560
+ return m.requestReload(0)
1467
1561
  }
1468
1562
 
1469
1563
  func (m *model) handleFilterKey(msg tea.KeyMsg) tea.Cmd {
@@ -1472,7 +1566,7 @@ func (m *model) handleFilterKey(msg tea.KeyMsg) tea.Cmd {
1472
1566
  case "ctrl+c":
1473
1567
  return tea.Quit
1474
1568
  case "esc", "left":
1475
- m.closeFilters()
1569
+ return m.closeFilters()
1476
1570
  case "tab", "right":
1477
1571
  m.advanceFilterSection()
1478
1572
  case "shift+tab":
@@ -1734,29 +1828,134 @@ func clearSet(set map[string]bool) {
1734
1828
  }
1735
1829
  }
1736
1830
 
1737
- func (m *model) reload() {
1831
+ func (m *model) requestReload(delay time.Duration) tea.Cmd {
1832
+ if m.cancelReload != nil {
1833
+ m.cancelReload()
1834
+ m.cancelReload = nil
1835
+ }
1738
1836
  m.err = nil
1739
- var rows []item
1740
- var err error
1837
+ m.loading = true
1838
+ m.searchTooShort = false
1839
+ m.resultsTruncated = false
1840
+ m.reloadToken++
1841
+ token := m.reloadToken
1842
+
1843
+ query := strings.TrimSpace(m.search.Value())
1844
+ if query != "" && runeCount(query) < tuiSearchMinRunes {
1845
+ return func() tea.Msg {
1846
+ return resultsLoadedMsg{token: token, query: query, tooShort: true}
1847
+ }
1848
+ }
1849
+ if delay > 0 {
1850
+ return tea.Tick(delay, func(time.Time) tea.Msg {
1851
+ return reloadTickMsg{token: token}
1852
+ })
1853
+ }
1854
+ return m.loadResultsCmd(token)
1855
+ }
1856
+
1857
+ func (m *model) requestPreview() tea.Cmd {
1858
+ row, ok := m.currentSelection()
1859
+ if !ok || m.db == nil {
1860
+ m.previewKey = ""
1861
+ m.previewMessages = nil
1862
+ m.previewErr = nil
1863
+ m.previewLoading = false
1864
+ return nil
1865
+ }
1866
+ if m.previewKey == row.SessionKey && m.previewAllRoles == m.allRoles && !m.previewLoading && m.previewErr == nil && len(m.previewMessages) > 0 {
1867
+ return nil
1868
+ }
1869
+
1870
+ m.previewToken++
1871
+ token := m.previewToken
1872
+ sessionKey := row.SessionKey
1873
+ allRoles := m.allRoles
1874
+ db := m.db
1875
+
1876
+ m.previewKey = sessionKey
1877
+ m.previewAllRoles = allRoles
1878
+ m.previewMessages = nil
1879
+ m.previewErr = nil
1880
+ m.previewLoading = true
1881
+
1882
+ return func() tea.Msg {
1883
+ messages, err := loadTranscriptPreviewMessages(db, sessionKey, allRoles, 8)
1884
+ return previewLoadedMsg{
1885
+ token: token,
1886
+ sessionKey: sessionKey,
1887
+ allRoles: allRoles,
1888
+ messages: messages,
1889
+ err: err,
1890
+ }
1891
+ }
1892
+ }
1893
+
1894
+ func (m *model) applyPreview(msg previewLoadedMsg) {
1895
+ if msg.token != m.previewToken || msg.sessionKey != m.previewKey || msg.allRoles != m.previewAllRoles {
1896
+ return
1897
+ }
1898
+ m.previewLoading = false
1899
+ m.previewMessages = msg.messages
1900
+ m.previewErr = msg.err
1901
+ }
1902
+
1903
+ func (m *model) loadResultsCmd(token int) tea.Cmd {
1904
+ ctx, cancel := context.WithCancel(context.Background())
1905
+ m.cancelReload = cancel
1906
+
1741
1907
  query := strings.TrimSpace(m.search.Value())
1742
1908
  providers := selectedSetValues(m.providers)
1743
1909
  models := selectedSetValues(m.modelFilters)
1744
1910
  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)
1911
+ allRoles := m.allRoles
1912
+ db := m.db
1913
+
1914
+ return func() tea.Msg {
1915
+ var rows []item
1916
+ var truncated bool
1917
+ var err error
1918
+ if query == "" {
1919
+ rows, err = listSessionsContext(ctx, db, providers, models, repos)
1920
+ } else {
1921
+ rows, truncated, err = searchMessagesTUIContext(ctx, db, query, providers, models, repos, allRoles, tuiSearchLimit)
1922
+ }
1923
+ return resultsLoadedMsg{
1924
+ token: token,
1925
+ query: query,
1926
+ rows: rows,
1927
+ truncated: truncated,
1928
+ err: err,
1929
+ }
1749
1930
  }
1750
- if err != nil {
1751
- m.err = err
1931
+ }
1932
+
1933
+ func (m *model) applyResults(msg resultsLoadedMsg) tea.Cmd {
1934
+ if msg.token != m.reloadToken {
1935
+ return nil
1936
+ }
1937
+ m.loading = false
1938
+ m.searchTooShort = msg.tooShort
1939
+ m.resultsTruncated = msg.truncated
1940
+ m.cancelReload = nil
1941
+ if msg.err != nil {
1942
+ if errors.Is(msg.err, context.Canceled) {
1943
+ return nil
1944
+ }
1945
+ m.err = msg.err
1752
1946
  m.results = nil
1753
- return
1947
+ m.resultsVersion++
1948
+ return nil
1754
1949
  }
1755
- m.results = rows
1950
+ m.err = nil
1951
+ m.results = msg.rows
1756
1952
  if m.selected >= len(m.results) {
1757
1953
  m.selected = max(0, len(m.results)-1)
1758
1954
  }
1955
+ m.resultsVersion++
1956
+ m.previewKey = ""
1759
1957
  m.configureResultsTable(max(72, m.width), max(4, m.height/2))
1958
+ return m.requestPreview()
1760
1959
  }
1761
1960
 
1762
1961
  func (m *model) resetPosition() {
@@ -1765,6 +1964,17 @@ func (m *model) resetPosition() {
1765
1964
  m.detail = false
1766
1965
  }
1767
1966
 
1967
+ func isPrintableSearchKey(msg tea.KeyMsg) bool {
1968
+ if msg.Type != tea.KeyRunes {
1969
+ return false
1970
+ }
1971
+ return len(msg.Runes) > 0
1972
+ }
1973
+
1974
+ func runeCount(value string) int {
1975
+ return len([]rune(strings.TrimSpace(value)))
1976
+ }
1977
+
1768
1978
  func (m *model) detailHeight() int {
1769
1979
  return max(1, m.height-5)
1770
1980
  }
@@ -1859,6 +2069,10 @@ func loadModelOptions(db *sql.DB) ([]filterOption, error) {
1859
2069
  }
1860
2070
 
1861
2071
  func listSessions(db *sql.DB, providers []string, models []string, repos []string) ([]item, error) {
2072
+ return listSessionsContext(context.Background(), db, providers, models, repos)
2073
+ }
2074
+
2075
+ func listSessionsContext(ctx context.Context, db *sql.DB, providers []string, models []string, repos []string) ([]item, error) {
1862
2076
  filters, args := sessionFilters("s", providers, models, repos)
1863
2077
  filters = append(filters, rootSessionFilter("s"))
1864
2078
  filters = append(filters, displayableSessionFilter("s"))
@@ -1866,7 +2080,7 @@ func listSessions(db *sql.DB, providers []string, models []string, repos []strin
1866
2080
  if len(filters) > 0 {
1867
2081
  where = "WHERE " + strings.Join(filters, " AND ")
1868
2082
  }
1869
- rows, err := db.Query(`
2083
+ rows, err := db.QueryContext(ctx, `
1870
2084
  SELECT s.session_key, s.provider, `+displayTitleExpr("s")+`,
1871
2085
  COALESCE(s.started_at, ''),
1872
2086
  COALESCE(s.updated_at, s.started_at, ''),
@@ -1899,14 +2113,28 @@ func listSessions(db *sql.DB, providers []string, models []string, repos []strin
1899
2113
  }
1900
2114
 
1901
2115
  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)
2116
+ rows, _, err := searchMessagesContext(context.Background(), db, query, providers, models, repos, allRoles, 0)
2117
+ return rows, err
2118
+ }
2119
+
2120
+ func searchMessagesContext(ctx context.Context, db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool, limit int) ([]item, bool, error) {
2121
+ rows, truncated, err := searchMessagesFTS(ctx, db, query, providers, models, repos, allRoles, limit)
2122
+ if err == nil {
2123
+ return rows, truncated, nil
2124
+ }
2125
+ return searchMessagesLike(ctx, db, query, providers, models, repos, allRoles, limit)
2126
+ }
2127
+
2128
+ func searchMessagesTUIContext(ctx context.Context, db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool, limit int) ([]item, bool, error) {
2129
+ rows, truncated, err := searchMessagesFTSFast(ctx, db, query, providers, models, repos, allRoles, limit)
1903
2130
  if err == nil {
1904
- return rows, nil
2131
+ return rows, truncated, nil
1905
2132
  }
1906
- return searchMessagesLike(db, query, providers, models, repos, allRoles)
2133
+ return searchMessagesLikeFast(ctx, db, query, providers, models, repos, allRoles, limit)
1907
2134
  }
1908
2135
 
1909
- func searchMessagesFTS(db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool) ([]item, error) {
2136
+ func searchMessagesFTSFast(ctx context.Context, db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool, limit int) ([]item, bool, error) {
2137
+ scanLimit := max(limit*6, limit)
1910
2138
  args := []any{toFTSQuery(query)}
1911
2139
  filters := messageFilters("m", providers, models, repos, allRoles, &args)
1912
2140
  filters = append(filters, rootSessionFilter("s"))
@@ -1915,7 +2143,110 @@ func searchMessagesFTS(db *sql.DB, query string, providers []string, models []st
1915
2143
  if len(filters) > 0 {
1916
2144
  where += " AND " + strings.Join(filters, " AND ")
1917
2145
  }
1918
- rows, err := db.Query(`
2146
+ if scanLimit > 0 {
2147
+ args = append(args, scanLimit)
2148
+ }
2149
+ limitSQL := ""
2150
+ if scanLimit > 0 {
2151
+ limitSQL = " LIMIT ?"
2152
+ }
2153
+ rows, err := db.QueryContext(ctx, `
2154
+ SELECT m.session_key, m.provider, `+displayTitleExpr("s")+`,
2155
+ COALESCE(m.timestamp, s.updated_at, s.started_at, ''),
2156
+ COALESCE(s.started_at, ''),
2157
+ COALESCE(s.updated_at, s.started_at, ''),
2158
+ COALESCE(s.cwd, ''), COALESCE(s.message_count, 0),
2159
+ COALESCE((SELECT GROUP_CONCAT(model) FROM (
2160
+ SELECT DISTINCT sm.model FROM session_models sm
2161
+ WHERE sm.session_key = m.session_key
2162
+ ORDER BY sm.model
2163
+ )), ''),
2164
+ COALESCE(snippet(message_fts, 0, '[', ']', '...', 18), m.text),
2165
+ m.ordinal
2166
+ FROM message_fts
2167
+ JOIN messages m ON m.message_key = message_fts.message_key
2168
+ JOIN sessions s ON s.session_key = m.session_key
2169
+ WHERE `+where+limitSQL+`
2170
+ `, args...)
2171
+ if err != nil {
2172
+ return nil, false, err
2173
+ }
2174
+ defer rows.Close()
2175
+ items, err := scanItems(rows, true)
2176
+ if err != nil {
2177
+ return nil, false, err
2178
+ }
2179
+ return finalizeTUIItems(items, limit, scanLimit)
2180
+ }
2181
+
2182
+ func searchMessagesLikeFast(ctx context.Context, db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool, limit int) ([]item, bool, error) {
2183
+ scanLimit := max(limit*6, limit)
2184
+ args := []any{"%" + query + "%"}
2185
+ filters := []string{"m.text LIKE ?"}
2186
+ filters = append(filters, messageFilters("m", providers, models, repos, allRoles, &args)...)
2187
+ filters = append(filters, rootSessionFilter("s"))
2188
+ filters = append(filters, displayableSessionFilter("s"))
2189
+ if scanLimit > 0 {
2190
+ args = append(args, scanLimit)
2191
+ }
2192
+ limitSQL := ""
2193
+ if scanLimit > 0 {
2194
+ limitSQL = " LIMIT ?"
2195
+ }
2196
+
2197
+ rows, err := db.QueryContext(ctx, `
2198
+ SELECT m.session_key, m.provider, `+displayTitleExpr("s")+`,
2199
+ COALESCE(m.timestamp, s.updated_at, s.started_at, ''),
2200
+ COALESCE(s.started_at, ''),
2201
+ COALESCE(s.updated_at, s.started_at, ''),
2202
+ COALESCE(s.cwd, ''), COALESCE(s.message_count, 0),
2203
+ COALESCE((SELECT GROUP_CONCAT(model) FROM (
2204
+ SELECT DISTINCT sm.model FROM session_models sm
2205
+ WHERE sm.session_key = m.session_key
2206
+ ORDER BY sm.model
2207
+ )), ''),
2208
+ m.text,
2209
+ m.ordinal
2210
+ FROM messages m
2211
+ JOIN sessions s ON s.session_key = m.session_key
2212
+ WHERE `+strings.Join(filters, " AND ")+limitSQL+`
2213
+ `, args...)
2214
+ if err != nil {
2215
+ return nil, false, err
2216
+ }
2217
+ defer rows.Close()
2218
+ items, err := scanItems(rows, true)
2219
+ if err != nil {
2220
+ return nil, false, err
2221
+ }
2222
+ return finalizeTUIItems(items, limit, scanLimit)
2223
+ }
2224
+
2225
+ func finalizeTUIItems(items []item, limit int, scanLimit int) ([]item, bool, error) {
2226
+ deduped := dedupeItems(items)
2227
+ sortItemsByRecency(deduped)
2228
+ rows, truncated, err := limitItems(deduped, limit)
2229
+ if err != nil {
2230
+ return nil, false, err
2231
+ }
2232
+ return rows, truncated || (scanLimit > 0 && len(items) >= scanLimit), nil
2233
+ }
2234
+
2235
+ func searchMessagesFTS(ctx context.Context, db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool, limit int) ([]item, bool, error) {
2236
+ args := []any{toFTSQuery(query)}
2237
+ filters := messageFilters("m", providers, models, repos, allRoles, &args)
2238
+ filters = append(filters, rootSessionFilter("s"))
2239
+ filters = append(filters, displayableSessionFilter("s"))
2240
+ where := "message_fts MATCH ?"
2241
+ if len(filters) > 0 {
2242
+ where += " AND " + strings.Join(filters, " AND ")
2243
+ }
2244
+ limitSQL := ""
2245
+ if limit > 0 {
2246
+ limitSQL = " LIMIT ?"
2247
+ args = append(args, limit*6)
2248
+ }
2249
+ rows, err := db.QueryContext(ctx, `
1919
2250
  SELECT m.session_key, m.provider, `+displayTitleExpr("s")+`,
1920
2251
  COALESCE(m.timestamp, s.updated_at, s.started_at, ''),
1921
2252
  COALESCE(s.started_at, ''),
@@ -1933,26 +2264,33 @@ func searchMessagesFTS(db *sql.DB, query string, providers []string, models []st
1933
2264
  JOIN sessions s ON s.session_key = m.session_key
1934
2265
  WHERE `+where+`
1935
2266
  ORDER BY bm25(message_fts), COALESCE(m.timestamp, s.updated_at) DESC
2267
+ `+limitSQL+`
1936
2268
  `, args...)
1937
2269
  if err != nil {
1938
- return nil, err
2270
+ return nil, false, err
1939
2271
  }
1940
2272
  defer rows.Close()
1941
2273
  items, err := scanItems(rows, true)
1942
2274
  if err != nil {
1943
- return nil, err
2275
+ return nil, false, err
1944
2276
  }
1945
- return dedupeItems(items), nil
2277
+ deduped := dedupeItems(items)
2278
+ return limitItems(deduped, limit)
1946
2279
  }
1947
2280
 
1948
- func searchMessagesLike(db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool) ([]item, error) {
2281
+ func searchMessagesLike(ctx context.Context, db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool, limit int) ([]item, bool, error) {
1949
2282
  args := []any{"%" + query + "%"}
1950
2283
  filters := []string{"m.text LIKE ?"}
1951
2284
  filters = append(filters, messageFilters("m", providers, models, repos, allRoles, &args)...)
1952
2285
  filters = append(filters, rootSessionFilter("s"))
1953
2286
  filters = append(filters, displayableSessionFilter("s"))
2287
+ limitSQL := ""
2288
+ if limit > 0 {
2289
+ limitSQL = " LIMIT ?"
2290
+ args = append(args, limit*6)
2291
+ }
1954
2292
 
1955
- rows, err := db.Query(`
2293
+ rows, err := db.QueryContext(ctx, `
1956
2294
  SELECT m.session_key, m.provider, `+displayTitleExpr("s")+`,
1957
2295
  COALESCE(m.timestamp, s.updated_at, s.started_at, ''),
1958
2296
  COALESCE(s.started_at, ''),
@@ -1969,16 +2307,18 @@ func searchMessagesLike(db *sql.DB, query string, providers []string, models []s
1969
2307
  JOIN sessions s ON s.session_key = m.session_key
1970
2308
  WHERE `+strings.Join(filters, " AND ")+`
1971
2309
  ORDER BY COALESCE(m.timestamp, s.updated_at) DESC
2310
+ `+limitSQL+`
1972
2311
  `, args...)
1973
2312
  if err != nil {
1974
- return nil, err
2313
+ return nil, false, err
1975
2314
  }
1976
2315
  defer rows.Close()
1977
2316
  items, err := scanItems(rows, true)
1978
2317
  if err != nil {
1979
- return nil, err
2318
+ return nil, false, err
1980
2319
  }
1981
- return dedupeItems(items), nil
2320
+ deduped := dedupeItems(items)
2321
+ return limitItems(deduped, limit)
1982
2322
  }
1983
2323
 
1984
2324
  func scanItems(rows *sql.Rows, isMatch bool) ([]item, error) {
@@ -2012,6 +2352,27 @@ func dedupeItems(items []item) []item {
2012
2352
  return deduped
2013
2353
  }
2014
2354
 
2355
+ func sortItemsByRecency(items []item) {
2356
+ sort.SliceStable(items, func(i int, j int) bool {
2357
+ left := items[i].UpdatedAt
2358
+ if left == "" {
2359
+ left = items[i].Timestamp
2360
+ }
2361
+ right := items[j].UpdatedAt
2362
+ if right == "" {
2363
+ right = items[j].Timestamp
2364
+ }
2365
+ return left > right
2366
+ })
2367
+ }
2368
+
2369
+ func limitItems(items []item, limit int) ([]item, bool, error) {
2370
+ if limit <= 0 || len(items) <= limit {
2371
+ return items, false, nil
2372
+ }
2373
+ return items[:limit], true, nil
2374
+ }
2375
+
2015
2376
  func loadTranscript(db *sql.DB, sessionKey string, allRoles bool, width int) (string, error) {
2016
2377
  messages, err := loadTranscriptMessages(db, sessionKey, allRoles)
2017
2378
  if err != nil {
@@ -559,6 +559,44 @@ func TestExportShortcutsDoNotStealSearchTyping(t *testing.T) {
559
559
  }
560
560
  }
561
561
 
562
+ func TestShortSearchDoesNotTouchDatabase(t *testing.T) {
563
+ search := textinput.New()
564
+ search.SetValue("l")
565
+ m := &model{search: search}
566
+
567
+ cmd := m.requestReload(0)
568
+ raw := cmd()
569
+ msg, ok := raw.(resultsLoadedMsg)
570
+ if !ok {
571
+ t.Fatalf("message = %T, want resultsLoadedMsg", raw)
572
+ }
573
+ if !msg.tooShort {
574
+ t.Fatalf("tooShort = false, want true: %#v", msg)
575
+ }
576
+
577
+ m.applyResults(msg)
578
+ if !m.searchTooShort {
579
+ t.Fatal("model did not mark short search")
580
+ }
581
+ if m.loading {
582
+ t.Fatal("model stayed loading after short search")
583
+ }
584
+ }
585
+
586
+ func TestPrintableTypingStartsSearchFromResultsFocus(t *testing.T) {
587
+ search := textinput.New()
588
+ m := &model{search: search, focus: focusResults}
589
+
590
+ _ = m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
591
+
592
+ if m.focus != focusSearch {
593
+ t.Fatalf("focus = %v, want search", m.focus)
594
+ }
595
+ if m.search.Value() != "l" {
596
+ t.Fatalf("search value = %q, want l", m.search.Value())
597
+ }
598
+ }
599
+
562
600
  func TestDedupeItemsDoesNotCapResults(t *testing.T) {
563
601
  items := make([]item, 0, 300)
564
602
  for index := range 300 {
@@ -6,7 +6,7 @@ import shutil
6
6
  import subprocess
7
7
  import sys
8
8
 
9
- TUI_CACHE_VERSION = "bubbletea-v1"
9
+ TUI_CACHE_VERSION = "bubbletea-v3"
10
10
 
11
11
 
12
12
  def run_tui(db_path: Path | None) -> int:
@@ -24,12 +24,13 @@ def run_tui(db_path: Path | None) -> int:
24
24
  command.extend(["--db", str(db_path)])
25
25
 
26
26
  try:
27
- return subprocess.run(command, cwd=cwd).returncode
28
- except KeyboardInterrupt:
29
- return 130
27
+ if cwd is not None:
28
+ os.chdir(cwd)
29
+ os.execvpe(command[0], command, os.environ.copy())
30
30
  except OSError as exc:
31
31
  print(f"failed to launch Bubble Tea TUI: {exc}", file=sys.stderr)
32
32
  return 1
33
+ return 1
33
34
 
34
35
 
35
36
  def _tui_command() -> tuple[list[str] | None, Path | None]:
@@ -95,6 +96,8 @@ def _build_tui(source_dir: Path, target: Path) -> bool:
95
96
  target.parent.mkdir(parents=True, exist_ok=True)
96
97
  tmp = target.with_suffix(".tmp")
97
98
  try:
99
+ if sys.stderr.isatty():
100
+ print("Building Gora TUI helper...", file=sys.stderr)
98
101
  result = subprocess.run(
99
102
  ["go", "build", "-o", str(tmp), "."],
100
103
  cwd=source_dir,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gora-cli"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Local CLI chat history index for Codex, Claude Code, and Pi."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -4,5 +4,5 @@ requires-python = ">=3.13"
4
4
 
5
5
  [[package]]
6
6
  name = "gora-cli"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  source = { editable = "." }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes