gora-cli 0.1.2__tar.gz → 0.1.3__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.3
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.3"
@@ -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,25 @@ 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
+
52
72
  type item struct {
53
73
  SessionKey string
54
74
  Provider string
@@ -334,44 +354,54 @@ func newFilterList(width int, height int) list.Model {
334
354
  }
335
355
 
336
356
  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
357
+ db *sql.DB
358
+ dbPath string
359
+ search textinput.Model
360
+ transcriptView viewport.Model
361
+ helpBubble help.Model
362
+ resultsTable table.Model
363
+ filterList list.Model
364
+ keys keyMap
365
+ providers map[string]bool
366
+ repoFilters map[string]bool
367
+ modelFilters map[string]bool
368
+ providerOptions []filterOption
369
+ repoOptions []filterOption
370
+ modelOptions []filterOption
371
+ allRoles bool
372
+ detail bool
373
+ detailItem item
374
+ filterOpen bool
375
+ focus focusArea
376
+ filterSection filterSection
377
+ previewKey string
378
+ previewWidth int
379
+ previewAllRoles bool
380
+ previewLines []string
381
+ previewErr error
382
+ selected int
383
+ width int
384
+ height int
385
+ searchTop int
386
+ searchBottom int
387
+ filterTop int
388
+ filterBottom int
389
+ rowsTop int
390
+ browseButtons []actionButton
391
+ detailButtons []actionButton
392
+ results []item
393
+ loading bool
394
+ searchTooShort bool
395
+ resultsTruncated bool
396
+ reloadToken int
397
+ cancelReload context.CancelFunc
398
+ resultsVersion int
399
+ tableRowsVersion int
400
+ tableRowsWidth int
401
+ tableRowsHeight int
402
+ tableRowsSelected int
403
+ notice string
404
+ err error
375
405
  }
376
406
 
377
407
  func main() {
@@ -419,7 +449,6 @@ func main() {
419
449
  m.providerOptions, _ = loadProviderOptions(db)
420
450
  m.repoOptions, _ = loadRepoOptions(db)
421
451
  m.modelOptions, _ = loadModelOptions(db)
422
- m.reload()
423
452
 
424
453
  program := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
425
454
  if _, err := program.Run(); err != nil {
@@ -437,7 +466,7 @@ func configureTerminalColors() {
437
466
  }
438
467
 
439
468
  func (m *model) Init() tea.Cmd {
440
- return textinput.Blink
469
+ return tea.Batch(textinput.Blink, m.requestReload(0))
441
470
  }
442
471
 
443
472
  func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -455,6 +484,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
455
484
  return m, m.handleKey(msg)
456
485
  case tea.MouseMsg:
457
486
  return m, m.handleMouse(msg)
487
+ case reloadTickMsg:
488
+ if msg.token != m.reloadToken {
489
+ return m, nil
490
+ }
491
+ return m, m.loadResultsCmd(msg.token)
492
+ case resultsLoadedMsg:
493
+ m.applyResults(msg)
458
494
  }
459
495
  return m, nil
460
496
  }
@@ -489,15 +525,13 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
489
525
  if m.search.Value() != "" {
490
526
  m.search.SetValue("")
491
527
  m.resetPosition()
492
- m.reload()
493
- return nil
528
+ return m.requestReload(0)
494
529
  }
495
530
  if m.hasActiveFilters() {
496
531
  m.clearFilters()
497
532
  m.notice = "filters cleared"
498
533
  m.resetPosition()
499
- m.reload()
500
- return nil
534
+ return m.requestReload(0)
501
535
  }
502
536
  return tea.Quit
503
537
  case "tab":
@@ -533,27 +567,33 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
533
567
  case "ctrl+r":
534
568
  m.allRoles = !m.allRoles
535
569
  m.resetPosition()
536
- m.reload()
570
+ return m.requestReload(0)
537
571
  case "ctrl+u":
538
572
  m.search.SetValue("")
539
573
  m.resetPosition()
540
- m.reload()
574
+ return m.requestReload(0)
541
575
  default:
542
576
  if m.focus != focusSearch {
543
577
  if key == "m" {
544
578
  m.exportSelected("md")
579
+ return nil
545
580
  }
546
581
  if key == "t" {
547
582
  m.exportSelected("txt")
583
+ return nil
584
+ }
585
+ if isPrintableSearchKey(msg) {
586
+ m.setFocus(focusSearch)
587
+ } else {
588
+ return nil
548
589
  }
549
- return nil
550
590
  }
551
591
  before := m.search.Value()
552
592
  var cmd tea.Cmd
553
593
  m.search, cmd = m.search.Update(msg)
554
594
  if m.search.Value() != before {
555
595
  m.resetPosition()
556
- m.reload()
596
+ return tea.Batch(cmd, m.requestReload(tuiSearchDebounce))
557
597
  }
558
598
  return cmd
559
599
  }
@@ -800,8 +840,21 @@ func (m *model) renderFilters(width int) string {
800
840
  }
801
841
 
802
842
  func (m *model) renderStatus(width int) string {
843
+ if m.loading {
844
+ query := strings.TrimSpace(m.search.Value())
845
+ if query != "" {
846
+ return m.renderStatusLine(fmt.Sprintf("searching for %q", query), width)
847
+ }
848
+ return m.renderStatusLine("loading recent chats", width)
849
+ }
850
+ if m.searchTooShort {
851
+ return m.renderStatusLine(fmt.Sprintf("type at least %d characters to search", tuiSearchMinRunes), width)
852
+ }
803
853
  if m.search.Value() != "" {
804
854
  status := fmt.Sprintf("%d matches for %q", len(m.results), m.search.Value())
855
+ if m.resultsTruncated {
856
+ status = fmt.Sprintf("showing first %d matches for %q", len(m.results), m.search.Value())
857
+ }
805
858
  return m.renderStatusLine(status, width)
806
859
  }
807
860
  return m.renderStatusLine(fmt.Sprintf("%d recent chats", len(m.results)), width)
@@ -826,6 +879,12 @@ func (m *model) renderResultsTitle(width int) string {
826
879
 
827
880
  func (m *model) renderResultsTable(width int, height int) string {
828
881
  if len(m.results) == 0 {
882
+ if m.loading {
883
+ return mutedStyle.Render(" loading...")
884
+ }
885
+ if m.searchTooShort {
886
+ return mutedStyle.Render(fmt.Sprintf(" type at least %d characters to search", tuiSearchMinRunes))
887
+ }
829
888
  if m.hasActiveFilters() {
830
889
  return mutedStyle.Render(" no chats match the current filters")
831
890
  }
@@ -960,12 +1019,24 @@ func (m *model) configureResultsTable(width int, height int) {
960
1019
  if m.resultsTable.Width() == 0 && m.resultsTable.Height() == 0 {
961
1020
  m.resultsTable = newResultsTable()
962
1021
  }
963
- m.resultsTable.SetColumns(resultTableColumns(width))
964
1022
  m.selected = clamp(m.selected, 0, max(0, len(m.results)-1))
965
- m.resultsTable.SetRows(resultTableRows(m.results, m.selected))
1023
+ height = max(4, height)
1024
+ if m.tableRowsVersion != m.resultsVersion ||
1025
+ m.tableRowsWidth != width ||
1026
+ m.tableRowsHeight != height ||
1027
+ m.tableRowsSelected != m.selected {
1028
+ m.resultsTable.SetColumns(resultTableColumns(width))
1029
+ m.resultsTable.SetRows(resultTableRows(m.results, m.selected))
1030
+ m.tableRowsVersion = m.resultsVersion
1031
+ m.tableRowsWidth = width
1032
+ m.tableRowsHeight = height
1033
+ m.tableRowsSelected = m.selected
1034
+ }
966
1035
  m.resultsTable.SetWidth(width)
967
- m.resultsTable.SetHeight(max(4, height))
968
- m.resultsTable.SetCursor(m.selected)
1036
+ m.resultsTable.SetHeight(height)
1037
+ if m.resultsTable.Cursor() != m.selected {
1038
+ m.resultsTable.SetCursor(m.selected)
1039
+ }
969
1040
  if m.focus == focusResults {
970
1041
  m.resultsTable.Focus()
971
1042
  } else {
@@ -1459,11 +1530,11 @@ func (m *model) openFilters() {
1459
1530
  m.configureFilterList(max(72, m.width), max(8, m.height-8))
1460
1531
  }
1461
1532
 
1462
- func (m *model) closeFilters() {
1533
+ func (m *model) closeFilters() tea.Cmd {
1463
1534
  m.filterOpen = false
1464
1535
  m.setFocus(focusFilters)
1465
1536
  m.resetPosition()
1466
- m.reload()
1537
+ return m.requestReload(0)
1467
1538
  }
1468
1539
 
1469
1540
  func (m *model) handleFilterKey(msg tea.KeyMsg) tea.Cmd {
@@ -1472,7 +1543,7 @@ func (m *model) handleFilterKey(msg tea.KeyMsg) tea.Cmd {
1472
1543
  case "ctrl+c":
1473
1544
  return tea.Quit
1474
1545
  case "esc", "left":
1475
- m.closeFilters()
1546
+ return m.closeFilters()
1476
1547
  case "tab", "right":
1477
1548
  m.advanceFilterSection()
1478
1549
  case "shift+tab":
@@ -1734,28 +1805,86 @@ func clearSet(set map[string]bool) {
1734
1805
  }
1735
1806
  }
1736
1807
 
1737
- func (m *model) reload() {
1808
+ func (m *model) requestReload(delay time.Duration) tea.Cmd {
1809
+ if m.cancelReload != nil {
1810
+ m.cancelReload()
1811
+ m.cancelReload = nil
1812
+ }
1738
1813
  m.err = nil
1739
- var rows []item
1740
- var err error
1814
+ m.loading = true
1815
+ m.searchTooShort = false
1816
+ m.resultsTruncated = false
1817
+ m.reloadToken++
1818
+ token := m.reloadToken
1819
+
1820
+ query := strings.TrimSpace(m.search.Value())
1821
+ if query != "" && runeCount(query) < tuiSearchMinRunes {
1822
+ return func() tea.Msg {
1823
+ return resultsLoadedMsg{token: token, query: query, tooShort: true}
1824
+ }
1825
+ }
1826
+ if delay > 0 {
1827
+ return tea.Tick(delay, func(time.Time) tea.Msg {
1828
+ return reloadTickMsg{token: token}
1829
+ })
1830
+ }
1831
+ return m.loadResultsCmd(token)
1832
+ }
1833
+
1834
+ func (m *model) loadResultsCmd(token int) tea.Cmd {
1835
+ ctx, cancel := context.WithCancel(context.Background())
1836
+ m.cancelReload = cancel
1837
+
1741
1838
  query := strings.TrimSpace(m.search.Value())
1742
1839
  providers := selectedSetValues(m.providers)
1743
1840
  models := selectedSetValues(m.modelFilters)
1744
1841
  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)
1842
+ allRoles := m.allRoles
1843
+ db := m.db
1844
+
1845
+ return func() tea.Msg {
1846
+ var rows []item
1847
+ var truncated bool
1848
+ var err error
1849
+ if query == "" {
1850
+ rows, err = listSessionsContext(ctx, db, providers, models, repos)
1851
+ } else {
1852
+ rows, truncated, err = searchMessagesContext(ctx, db, query, providers, models, repos, allRoles, tuiSearchLimit)
1853
+ }
1854
+ return resultsLoadedMsg{
1855
+ token: token,
1856
+ query: query,
1857
+ rows: rows,
1858
+ truncated: truncated,
1859
+ err: err,
1860
+ }
1749
1861
  }
1750
- if err != nil {
1751
- m.err = err
1862
+ }
1863
+
1864
+ func (m *model) applyResults(msg resultsLoadedMsg) {
1865
+ if msg.token != m.reloadToken {
1866
+ return
1867
+ }
1868
+ m.loading = false
1869
+ m.searchTooShort = msg.tooShort
1870
+ m.resultsTruncated = msg.truncated
1871
+ m.cancelReload = nil
1872
+ if msg.err != nil {
1873
+ if errors.Is(msg.err, context.Canceled) {
1874
+ return
1875
+ }
1876
+ m.err = msg.err
1752
1877
  m.results = nil
1878
+ m.resultsVersion++
1753
1879
  return
1754
1880
  }
1755
- m.results = rows
1881
+ m.err = nil
1882
+ m.results = msg.rows
1756
1883
  if m.selected >= len(m.results) {
1757
1884
  m.selected = max(0, len(m.results)-1)
1758
1885
  }
1886
+ m.resultsVersion++
1887
+ m.previewKey = ""
1759
1888
  m.configureResultsTable(max(72, m.width), max(4, m.height/2))
1760
1889
  }
1761
1890
 
@@ -1765,6 +1894,17 @@ func (m *model) resetPosition() {
1765
1894
  m.detail = false
1766
1895
  }
1767
1896
 
1897
+ func isPrintableSearchKey(msg tea.KeyMsg) bool {
1898
+ if msg.Type != tea.KeyRunes {
1899
+ return false
1900
+ }
1901
+ return len(msg.Runes) > 0
1902
+ }
1903
+
1904
+ func runeCount(value string) int {
1905
+ return len([]rune(strings.TrimSpace(value)))
1906
+ }
1907
+
1768
1908
  func (m *model) detailHeight() int {
1769
1909
  return max(1, m.height-5)
1770
1910
  }
@@ -1859,6 +1999,10 @@ func loadModelOptions(db *sql.DB) ([]filterOption, error) {
1859
1999
  }
1860
2000
 
1861
2001
  func listSessions(db *sql.DB, providers []string, models []string, repos []string) ([]item, error) {
2002
+ return listSessionsContext(context.Background(), db, providers, models, repos)
2003
+ }
2004
+
2005
+ func listSessionsContext(ctx context.Context, db *sql.DB, providers []string, models []string, repos []string) ([]item, error) {
1862
2006
  filters, args := sessionFilters("s", providers, models, repos)
1863
2007
  filters = append(filters, rootSessionFilter("s"))
1864
2008
  filters = append(filters, displayableSessionFilter("s"))
@@ -1866,7 +2010,7 @@ func listSessions(db *sql.DB, providers []string, models []string, repos []strin
1866
2010
  if len(filters) > 0 {
1867
2011
  where = "WHERE " + strings.Join(filters, " AND ")
1868
2012
  }
1869
- rows, err := db.Query(`
2013
+ rows, err := db.QueryContext(ctx, `
1870
2014
  SELECT s.session_key, s.provider, `+displayTitleExpr("s")+`,
1871
2015
  COALESCE(s.started_at, ''),
1872
2016
  COALESCE(s.updated_at, s.started_at, ''),
@@ -1899,14 +2043,19 @@ func listSessions(db *sql.DB, providers []string, models []string, repos []strin
1899
2043
  }
1900
2044
 
1901
2045
  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)
2046
+ rows, _, err := searchMessagesContext(context.Background(), db, query, providers, models, repos, allRoles, 0)
2047
+ return rows, err
2048
+ }
2049
+
2050
+ func searchMessagesContext(ctx context.Context, db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool, limit int) ([]item, bool, error) {
2051
+ rows, truncated, err := searchMessagesFTS(ctx, db, query, providers, models, repos, allRoles, limit)
1903
2052
  if err == nil {
1904
- return rows, nil
2053
+ return rows, truncated, nil
1905
2054
  }
1906
- return searchMessagesLike(db, query, providers, models, repos, allRoles)
2055
+ return searchMessagesLike(ctx, db, query, providers, models, repos, allRoles, limit)
1907
2056
  }
1908
2057
 
1909
- func searchMessagesFTS(db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool) ([]item, error) {
2058
+ func searchMessagesFTS(ctx context.Context, db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool, limit int) ([]item, bool, error) {
1910
2059
  args := []any{toFTSQuery(query)}
1911
2060
  filters := messageFilters("m", providers, models, repos, allRoles, &args)
1912
2061
  filters = append(filters, rootSessionFilter("s"))
@@ -1915,7 +2064,12 @@ func searchMessagesFTS(db *sql.DB, query string, providers []string, models []st
1915
2064
  if len(filters) > 0 {
1916
2065
  where += " AND " + strings.Join(filters, " AND ")
1917
2066
  }
1918
- rows, err := db.Query(`
2067
+ limitSQL := ""
2068
+ if limit > 0 {
2069
+ limitSQL = " LIMIT ?"
2070
+ args = append(args, limit*6)
2071
+ }
2072
+ rows, err := db.QueryContext(ctx, `
1919
2073
  SELECT m.session_key, m.provider, `+displayTitleExpr("s")+`,
1920
2074
  COALESCE(m.timestamp, s.updated_at, s.started_at, ''),
1921
2075
  COALESCE(s.started_at, ''),
@@ -1933,26 +2087,33 @@ func searchMessagesFTS(db *sql.DB, query string, providers []string, models []st
1933
2087
  JOIN sessions s ON s.session_key = m.session_key
1934
2088
  WHERE `+where+`
1935
2089
  ORDER BY bm25(message_fts), COALESCE(m.timestamp, s.updated_at) DESC
2090
+ `+limitSQL+`
1936
2091
  `, args...)
1937
2092
  if err != nil {
1938
- return nil, err
2093
+ return nil, false, err
1939
2094
  }
1940
2095
  defer rows.Close()
1941
2096
  items, err := scanItems(rows, true)
1942
2097
  if err != nil {
1943
- return nil, err
2098
+ return nil, false, err
1944
2099
  }
1945
- return dedupeItems(items), nil
2100
+ deduped := dedupeItems(items)
2101
+ return limitItems(deduped, limit)
1946
2102
  }
1947
2103
 
1948
- func searchMessagesLike(db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool) ([]item, error) {
2104
+ func searchMessagesLike(ctx context.Context, db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool, limit int) ([]item, bool, error) {
1949
2105
  args := []any{"%" + query + "%"}
1950
2106
  filters := []string{"m.text LIKE ?"}
1951
2107
  filters = append(filters, messageFilters("m", providers, models, repos, allRoles, &args)...)
1952
2108
  filters = append(filters, rootSessionFilter("s"))
1953
2109
  filters = append(filters, displayableSessionFilter("s"))
2110
+ limitSQL := ""
2111
+ if limit > 0 {
2112
+ limitSQL = " LIMIT ?"
2113
+ args = append(args, limit*6)
2114
+ }
1954
2115
 
1955
- rows, err := db.Query(`
2116
+ rows, err := db.QueryContext(ctx, `
1956
2117
  SELECT m.session_key, m.provider, `+displayTitleExpr("s")+`,
1957
2118
  COALESCE(m.timestamp, s.updated_at, s.started_at, ''),
1958
2119
  COALESCE(s.started_at, ''),
@@ -1969,16 +2130,18 @@ func searchMessagesLike(db *sql.DB, query string, providers []string, models []s
1969
2130
  JOIN sessions s ON s.session_key = m.session_key
1970
2131
  WHERE `+strings.Join(filters, " AND ")+`
1971
2132
  ORDER BY COALESCE(m.timestamp, s.updated_at) DESC
2133
+ `+limitSQL+`
1972
2134
  `, args...)
1973
2135
  if err != nil {
1974
- return nil, err
2136
+ return nil, false, err
1975
2137
  }
1976
2138
  defer rows.Close()
1977
2139
  items, err := scanItems(rows, true)
1978
2140
  if err != nil {
1979
- return nil, err
2141
+ return nil, false, err
1980
2142
  }
1981
- return dedupeItems(items), nil
2143
+ deduped := dedupeItems(items)
2144
+ return limitItems(deduped, limit)
1982
2145
  }
1983
2146
 
1984
2147
  func scanItems(rows *sql.Rows, isMatch bool) ([]item, error) {
@@ -2012,6 +2175,13 @@ func dedupeItems(items []item) []item {
2012
2175
  return deduped
2013
2176
  }
2014
2177
 
2178
+ func limitItems(items []item, limit int) ([]item, bool, error) {
2179
+ if limit <= 0 || len(items) <= limit {
2180
+ return items, false, nil
2181
+ }
2182
+ return items[:limit], true, nil
2183
+ }
2184
+
2015
2185
  func loadTranscript(db *sql.DB, sessionKey string, allRoles bool, width int) (string, error) {
2016
2186
  messages, err := loadTranscriptMessages(db, sessionKey, allRoles)
2017
2187
  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-v2"
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.3"
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.3"
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