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.
- {gora_cli-0.1.2 → gora_cli-0.1.3}/PKG-INFO +3 -1
- {gora_cli-0.1.2 → gora_cli-0.1.3}/README.md +2 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/gora/__init__.py +1 -1
- {gora_cli-0.1.2 → gora_cli-0.1.3}/gora/go_tui/main.go +249 -79
- {gora_cli-0.1.2 → gora_cli-0.1.3}/gora/go_tui/main_test.go +38 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/gora/tui.py +7 -4
- {gora_cli-0.1.2 → gora_cli-0.1.3}/pyproject.toml +1 -1
- {gora_cli-0.1.2 → gora_cli-0.1.3}/uv.lock +1 -1
- {gora_cli-0.1.2 → gora_cli-0.1.3}/.gitignore +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/gora/__main__.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/gora/cli.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/gora/go_tui/go.mod +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/gora/go_tui/go.sum +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/gora/parsers.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/gora/store.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/images/gora-header.png +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/main.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/tests/__init__.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/tests/test_parsers.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.3}/tests/test_store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gora-cli
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
@@ -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
|
|
338
|
-
dbPath
|
|
339
|
-
search
|
|
340
|
-
transcriptView
|
|
341
|
-
helpBubble
|
|
342
|
-
resultsTable
|
|
343
|
-
filterList
|
|
344
|
-
keys
|
|
345
|
-
providers
|
|
346
|
-
repoFilters
|
|
347
|
-
modelFilters
|
|
348
|
-
providerOptions
|
|
349
|
-
repoOptions
|
|
350
|
-
modelOptions
|
|
351
|
-
allRoles
|
|
352
|
-
detail
|
|
353
|
-
detailItem
|
|
354
|
-
filterOpen
|
|
355
|
-
focus
|
|
356
|
-
filterSection
|
|
357
|
-
previewKey
|
|
358
|
-
previewWidth
|
|
359
|
-
previewAllRoles
|
|
360
|
-
previewLines
|
|
361
|
-
previewErr
|
|
362
|
-
selected
|
|
363
|
-
width
|
|
364
|
-
height
|
|
365
|
-
searchTop
|
|
366
|
-
searchBottom
|
|
367
|
-
filterTop
|
|
368
|
-
filterBottom
|
|
369
|
-
rowsTop
|
|
370
|
-
browseButtons
|
|
371
|
-
detailButtons
|
|
372
|
-
results
|
|
373
|
-
|
|
374
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
570
|
+
return m.requestReload(0)
|
|
537
571
|
case "ctrl+u":
|
|
538
572
|
m.search.SetValue("")
|
|
539
573
|
m.resetPosition()
|
|
540
|
-
m.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
968
|
-
m.resultsTable.
|
|
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.
|
|
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)
|
|
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
|
-
|
|
1740
|
-
|
|
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
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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
|
-
|
|
1751
|
-
|
|
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.
|
|
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.
|
|
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 :=
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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,
|
|
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
|