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.
- {gora_cli-0.1.2 → gora_cli-0.1.4}/PKG-INFO +3 -1
- {gora_cli-0.1.2 → gora_cli-0.1.4}/README.md +2 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/gora/__init__.py +1 -1
- {gora_cli-0.1.2 → gora_cli-0.1.4}/gora/go_tui/main.go +471 -110
- {gora_cli-0.1.2 → gora_cli-0.1.4}/gora/go_tui/main_test.go +38 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/gora/tui.py +7 -4
- {gora_cli-0.1.2 → gora_cli-0.1.4}/pyproject.toml +1 -1
- {gora_cli-0.1.2 → gora_cli-0.1.4}/uv.lock +1 -1
- {gora_cli-0.1.2 → gora_cli-0.1.4}/.gitignore +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/gora/__main__.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/gora/cli.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/gora/go_tui/go.mod +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/gora/go_tui/go.sum +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/gora/parsers.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/gora/store.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/images/gora-header.png +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/main.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/tests/__init__.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/tests/test_parsers.py +0 -0
- {gora_cli-0.1.2 → gora_cli-0.1.4}/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.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
|
|
@@ -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
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
previewErr
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
584
|
+
return m.requestReload(0)
|
|
537
585
|
case "ctrl+u":
|
|
538
586
|
m.search.SetValue("")
|
|
539
587
|
m.resetPosition()
|
|
540
|
-
m.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
968
|
-
m.resultsTable.
|
|
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.
|
|
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)
|
|
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
|
-
|
|
1740
|
-
|
|
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
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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
|
-
|
|
1751
|
-
|
|
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
|
-
|
|
1947
|
+
m.resultsVersion++
|
|
1948
|
+
return nil
|
|
1754
1949
|
}
|
|
1755
|
-
m.
|
|
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.
|
|
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 :=
|
|
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
|
|
2133
|
+
return searchMessagesLikeFast(ctx, db, query, providers, models, repos, allRoles, limit)
|
|
1907
2134
|
}
|
|
1908
2135
|
|
|
1909
|
-
func
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|