gora-cli 0.1.3__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.3
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
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.1.3"
5
+ __version__ = "0.1.4"
@@ -69,6 +69,14 @@ type resultsLoadedMsg struct {
69
69
  err error
70
70
  }
71
71
 
72
+ type previewLoadedMsg struct {
73
+ token int
74
+ sessionKey string
75
+ allRoles bool
76
+ messages []transcriptMessage
77
+ err error
78
+ }
79
+
72
80
  type item struct {
73
81
  SessionKey string
74
82
  Provider string
@@ -375,10 +383,11 @@ type model struct {
375
383
  focus focusArea
376
384
  filterSection filterSection
377
385
  previewKey string
378
- previewWidth int
379
386
  previewAllRoles bool
380
- previewLines []string
387
+ previewLoading bool
388
+ previewMessages []transcriptMessage
381
389
  previewErr error
390
+ previewToken int
382
391
  selected int
383
392
  width int
384
393
  height int
@@ -400,6 +409,7 @@ type model struct {
400
409
  tableRowsWidth int
401
410
  tableRowsHeight int
402
411
  tableRowsSelected int
412
+ tableRows []table.Row
403
413
  notice string
404
414
  err error
405
415
  }
@@ -421,7 +431,9 @@ func main() {
421
431
  os.Exit(1)
422
432
  }
423
433
  defer db.Close()
424
- db.SetMaxOpenConns(1)
434
+ db.SetMaxOpenConns(4)
435
+ db.SetMaxIdleConns(4)
436
+ _, _ = db.Exec("PRAGMA busy_timeout = 1000")
425
437
 
426
438
  search := textinput.New()
427
439
  search.Placeholder = "type to search local Codex, Claude Code, and Pi chats"
@@ -490,7 +502,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
490
502
  }
491
503
  return m, m.loadResultsCmd(msg.token)
492
504
  case resultsLoadedMsg:
493
- m.applyResults(msg)
505
+ return m, m.applyResults(msg)
506
+ case previewLoadedMsg:
507
+ m.applyPreview(msg)
494
508
  }
495
509
  return m, nil
496
510
  }
@@ -544,17 +558,17 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
544
558
  case "/":
545
559
  m.setFocus(focusSearch)
546
560
  case "up", "k":
547
- m.move(-1)
548
561
  m.setFocus(focusResults)
562
+ return m.move(-1)
549
563
  case "down", "j":
550
- m.move(1)
551
564
  m.setFocus(focusResults)
565
+ return m.move(1)
552
566
  case "pgup":
553
- m.move(-10)
554
567
  m.setFocus(focusResults)
568
+ return m.move(-10)
555
569
  case "pgdown":
556
- m.move(10)
557
570
  m.setFocus(focusResults)
571
+ return m.move(10)
558
572
  case "enter", "right":
559
573
  if m.focus == focusFilters {
560
574
  m.openFilters()
@@ -574,6 +588,9 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
574
588
  return m.requestReload(0)
575
589
  default:
576
590
  if m.focus != focusSearch {
591
+ if key == "q" {
592
+ return tea.Quit
593
+ }
577
594
  if key == "m" {
578
595
  m.exportSelected("md")
579
596
  return nil
@@ -613,14 +630,12 @@ func (m *model) handleMouse(msg tea.MouseMsg) tea.Cmd {
613
630
  return cmd
614
631
  }
615
632
  if msg.Button == tea.MouseButtonWheelUp {
616
- m.move(-3)
617
633
  m.setFocus(focusResults)
618
- return nil
634
+ return m.move(-3)
619
635
  }
620
636
  if msg.Button == tea.MouseButtonWheelDown {
621
- m.move(3)
622
637
  m.setFocus(focusResults)
623
- return nil
638
+ return m.move(3)
624
639
  }
625
640
  if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress {
626
641
  return nil
@@ -953,31 +968,20 @@ func clipStyledLines(lines []string, height int) []string {
953
968
  }
954
969
 
955
970
  func (m *model) transcriptPreviewLines(row item, width int) []string {
956
- 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 {
957
978
  fallback := "session " + row.SessionKey
958
979
  if row.Preview != "" {
959
980
  fallback = "match " + row.Preview
960
981
  }
961
982
  return []string{mutedStyle.Render(fit(fallback, width))}
962
983
  }
963
- if m.previewKey == row.SessionKey && m.previewWidth == width && m.previewAllRoles == m.allRoles {
964
- if m.previewErr != nil {
965
- return []string{lipgloss.NewStyle().Foreground(red).Render(fit(m.previewErr.Error(), width))}
966
- }
967
- return m.previewLines
968
- }
969
-
970
- messages, err := loadTranscriptPreviewMessages(m.db, row.SessionKey, m.allRoles, 8)
971
- m.previewKey = row.SessionKey
972
- m.previewWidth = width
973
- m.previewAllRoles = m.allRoles
974
- m.previewErr = err
975
- if err != nil {
976
- m.previewLines = nil
977
- return []string{lipgloss.NewStyle().Foreground(red).Render(fit(err.Error(), width))}
978
- }
979
- m.previewLines = formatTranscriptPreviewLines(messages, width)
980
- return m.previewLines
984
+ return formatTranscriptPreviewLines(m.previewMessages, width)
981
985
  }
982
986
 
983
987
  func formatTranscriptPreviewLines(messages []transcriptMessage, width int) []string {
@@ -1023,14 +1027,18 @@ func (m *model) configureResultsTable(width int, height int) {
1023
1027
  height = max(4, height)
1024
1028
  if m.tableRowsVersion != m.resultsVersion ||
1025
1029
  m.tableRowsWidth != width ||
1026
- m.tableRowsHeight != height ||
1027
- m.tableRowsSelected != m.selected {
1030
+ m.tableRowsHeight != height {
1028
1031
  m.resultsTable.SetColumns(resultTableColumns(width))
1029
- m.resultsTable.SetRows(resultTableRows(m.results, m.selected))
1032
+ m.tableRows = resultTableRows(m.results, m.selected)
1033
+ m.resultsTable.SetRows(m.tableRows)
1030
1034
  m.tableRowsVersion = m.resultsVersion
1031
1035
  m.tableRowsWidth = width
1032
1036
  m.tableRowsHeight = height
1033
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
1034
1042
  }
1035
1043
  m.resultsTable.SetWidth(width)
1036
1044
  m.resultsTable.SetHeight(height)
@@ -1044,6 +1052,15 @@ func (m *model) configureResultsTable(width int, height int) {
1044
1052
  }
1045
1053
  }
1046
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
+
1047
1064
  func resultTableColumns(width int) []table.Column {
1048
1065
  inner := max(40, width-4)
1049
1066
  markerWidth := 2
@@ -1437,17 +1454,23 @@ func (m *model) renderDetailStatus(width int) string {
1437
1454
  return ""
1438
1455
  }
1439
1456
 
1440
- func (m *model) move(delta int) {
1457
+ func (m *model) move(delta int) tea.Cmd {
1441
1458
  if len(m.results) == 0 {
1442
1459
  m.selected = 0
1443
- return
1460
+ return nil
1444
1461
  }
1462
+ before := m.selected
1445
1463
  if delta < 0 {
1446
1464
  m.resultsTable.MoveUp(-delta)
1447
1465
  } else {
1448
1466
  m.resultsTable.MoveDown(delta)
1449
1467
  }
1450
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()
1451
1474
  }
1452
1475
 
1453
1476
  func (m *model) openDetail() {
@@ -1831,6 +1854,52 @@ func (m *model) requestReload(delay time.Duration) tea.Cmd {
1831
1854
  return m.loadResultsCmd(token)
1832
1855
  }
1833
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
+
1834
1903
  func (m *model) loadResultsCmd(token int) tea.Cmd {
1835
1904
  ctx, cancel := context.WithCancel(context.Background())
1836
1905
  m.cancelReload = cancel
@@ -1849,7 +1918,7 @@ func (m *model) loadResultsCmd(token int) tea.Cmd {
1849
1918
  if query == "" {
1850
1919
  rows, err = listSessionsContext(ctx, db, providers, models, repos)
1851
1920
  } else {
1852
- rows, truncated, err = searchMessagesContext(ctx, db, query, providers, models, repos, allRoles, tuiSearchLimit)
1921
+ rows, truncated, err = searchMessagesTUIContext(ctx, db, query, providers, models, repos, allRoles, tuiSearchLimit)
1853
1922
  }
1854
1923
  return resultsLoadedMsg{
1855
1924
  token: token,
@@ -1861,9 +1930,9 @@ func (m *model) loadResultsCmd(token int) tea.Cmd {
1861
1930
  }
1862
1931
  }
1863
1932
 
1864
- func (m *model) applyResults(msg resultsLoadedMsg) {
1933
+ func (m *model) applyResults(msg resultsLoadedMsg) tea.Cmd {
1865
1934
  if msg.token != m.reloadToken {
1866
- return
1935
+ return nil
1867
1936
  }
1868
1937
  m.loading = false
1869
1938
  m.searchTooShort = msg.tooShort
@@ -1871,12 +1940,12 @@ func (m *model) applyResults(msg resultsLoadedMsg) {
1871
1940
  m.cancelReload = nil
1872
1941
  if msg.err != nil {
1873
1942
  if errors.Is(msg.err, context.Canceled) {
1874
- return
1943
+ return nil
1875
1944
  }
1876
1945
  m.err = msg.err
1877
1946
  m.results = nil
1878
1947
  m.resultsVersion++
1879
- return
1948
+ return nil
1880
1949
  }
1881
1950
  m.err = nil
1882
1951
  m.results = msg.rows
@@ -1886,6 +1955,7 @@ func (m *model) applyResults(msg resultsLoadedMsg) {
1886
1955
  m.resultsVersion++
1887
1956
  m.previewKey = ""
1888
1957
  m.configureResultsTable(max(72, m.width), max(4, m.height/2))
1958
+ return m.requestPreview()
1889
1959
  }
1890
1960
 
1891
1961
  func (m *model) resetPosition() {
@@ -2055,6 +2125,113 @@ func searchMessagesContext(ctx context.Context, db *sql.DB, query string, provid
2055
2125
  return searchMessagesLike(ctx, db, query, providers, models, repos, allRoles, limit)
2056
2126
  }
2057
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)
2130
+ if err == nil {
2131
+ return rows, truncated, nil
2132
+ }
2133
+ return searchMessagesLikeFast(ctx, db, query, providers, models, repos, allRoles, limit)
2134
+ }
2135
+
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)
2138
+ args := []any{toFTSQuery(query)}
2139
+ filters := messageFilters("m", providers, models, repos, allRoles, &args)
2140
+ filters = append(filters, rootSessionFilter("s"))
2141
+ filters = append(filters, displayableSessionFilter("s"))
2142
+ where := "message_fts MATCH ?"
2143
+ if len(filters) > 0 {
2144
+ where += " AND " + strings.Join(filters, " AND ")
2145
+ }
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
+
2058
2235
  func searchMessagesFTS(ctx context.Context, db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool, limit int) ([]item, bool, error) {
2059
2236
  args := []any{toFTSQuery(query)}
2060
2237
  filters := messageFilters("m", providers, models, repos, allRoles, &args)
@@ -2175,6 +2352,20 @@ func dedupeItems(items []item) []item {
2175
2352
  return deduped
2176
2353
  }
2177
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
+
2178
2369
  func limitItems(items []item, limit int) ([]item, bool, error) {
2179
2370
  if limit <= 0 || len(items) <= limit {
2180
2371
  return items, false, nil
@@ -6,7 +6,7 @@ import shutil
6
6
  import subprocess
7
7
  import sys
8
8
 
9
- TUI_CACHE_VERSION = "bubbletea-v2"
9
+ TUI_CACHE_VERSION = "bubbletea-v3"
10
10
 
11
11
 
12
12
  def run_tui(db_path: Path | None) -> int:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gora-cli"
3
- version = "0.1.3"
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.3"
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
File without changes