gora-cli 0.1.2__py3-none-any.whl
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/__init__.py +5 -0
- gora/__main__.py +13 -0
- gora/cli.py +459 -0
- gora/go_tui/go.mod +42 -0
- gora/go_tui/go.sum +106 -0
- gora/go_tui/main.go +2634 -0
- gora/go_tui/main_test.go +626 -0
- gora/parsers.py +626 -0
- gora/store.py +935 -0
- gora/tui.py +115 -0
- gora_cli-0.1.2.dist-info/METADATA +282 -0
- gora_cli-0.1.2.dist-info/RECORD +14 -0
- gora_cli-0.1.2.dist-info/WHEEL +4 -0
- gora_cli-0.1.2.dist-info/entry_points.txt +2 -0
gora/go_tui/main.go
ADDED
|
@@ -0,0 +1,2634 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"database/sql"
|
|
5
|
+
"errors"
|
|
6
|
+
"flag"
|
|
7
|
+
"fmt"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"regexp"
|
|
11
|
+
"runtime"
|
|
12
|
+
"sort"
|
|
13
|
+
"strings"
|
|
14
|
+
"time"
|
|
15
|
+
|
|
16
|
+
"github.com/charmbracelet/bubbles/help"
|
|
17
|
+
"github.com/charmbracelet/bubbles/key"
|
|
18
|
+
"github.com/charmbracelet/bubbles/list"
|
|
19
|
+
"github.com/charmbracelet/bubbles/table"
|
|
20
|
+
"github.com/charmbracelet/bubbles/textinput"
|
|
21
|
+
"github.com/charmbracelet/bubbles/viewport"
|
|
22
|
+
tea "github.com/charmbracelet/bubbletea"
|
|
23
|
+
"github.com/charmbracelet/lipgloss"
|
|
24
|
+
"github.com/muesli/termenv"
|
|
25
|
+
_ "modernc.org/sqlite"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
var (
|
|
29
|
+
bg = lipgloss.Color("#1a1b26")
|
|
30
|
+
surface = lipgloss.Color("#1f2335")
|
|
31
|
+
fg = lipgloss.Color("#c0caf5")
|
|
32
|
+
muted = lipgloss.Color("#565f89")
|
|
33
|
+
border = lipgloss.Color("#3b4261")
|
|
34
|
+
cyan = lipgloss.Color("#7dcfff")
|
|
35
|
+
purple = lipgloss.Color("#bb9af7")
|
|
36
|
+
green = lipgloss.Color("#9ece6a")
|
|
37
|
+
yellow = lipgloss.Color("#e0af68")
|
|
38
|
+
red = lipgloss.Color("#f7768e")
|
|
39
|
+
|
|
40
|
+
baseStyle = lipgloss.NewStyle().Foreground(fg).Background(bg)
|
|
41
|
+
mutedStyle = lipgloss.NewStyle().Foreground(muted).Background(bg)
|
|
42
|
+
sectionStyle = lipgloss.NewStyle().Foreground(cyan).Background(bg).Bold(true)
|
|
43
|
+
selectedStyle = lipgloss.NewStyle().Foreground(fg).Background(bg).Bold(true)
|
|
44
|
+
searchStyle = lipgloss.NewStyle().Foreground(fg).Background(surface).Border(lipgloss.NormalBorder()).BorderForeground(border).Padding(0, 1)
|
|
45
|
+
searchText = lipgloss.NewStyle().Foreground(fg).Background(surface)
|
|
46
|
+
searchHint = lipgloss.NewStyle().Foreground(muted).Background(surface)
|
|
47
|
+
searchCursor = lipgloss.NewStyle().Foreground(bg).Background(yellow)
|
|
48
|
+
|
|
49
|
+
imageTagPattern = regexp.MustCompile(`(?s)</?image\b[^>]*(>|$)`)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
type item struct {
|
|
53
|
+
SessionKey string
|
|
54
|
+
Provider string
|
|
55
|
+
Title string
|
|
56
|
+
Timestamp string
|
|
57
|
+
CreatedAt string
|
|
58
|
+
UpdatedAt string
|
|
59
|
+
Cwd string
|
|
60
|
+
Models string
|
|
61
|
+
Preview string
|
|
62
|
+
MessageCount int
|
|
63
|
+
Ordinal int
|
|
64
|
+
IsMatch bool
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type focusArea int
|
|
68
|
+
|
|
69
|
+
const (
|
|
70
|
+
focusSearch focusArea = iota
|
|
71
|
+
focusFilters
|
|
72
|
+
focusResults
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
type filterSection int
|
|
76
|
+
|
|
77
|
+
const (
|
|
78
|
+
sectionHarness filterSection = iota
|
|
79
|
+
sectionRepos
|
|
80
|
+
sectionModels
|
|
81
|
+
sectionRoles
|
|
82
|
+
filterSectionCount
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
type filterOption struct {
|
|
86
|
+
Value string
|
|
87
|
+
Label string
|
|
88
|
+
Count int
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type filterListItem struct {
|
|
92
|
+
option filterOption
|
|
93
|
+
selected bool
|
|
94
|
+
section filterSection
|
|
95
|
+
description string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func (i filterListItem) FilterValue() string {
|
|
99
|
+
return i.option.Label + " " + i.option.Value
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func (i filterListItem) Title() string {
|
|
103
|
+
check := "[ ]"
|
|
104
|
+
if i.selected {
|
|
105
|
+
check = "[x]"
|
|
106
|
+
}
|
|
107
|
+
label := i.option.Label
|
|
108
|
+
if i.section == sectionRepos {
|
|
109
|
+
label = shortCwd(i.option.Value)
|
|
110
|
+
}
|
|
111
|
+
return check + " " + label
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func (i filterListItem) Description() string {
|
|
115
|
+
if i.description != "" {
|
|
116
|
+
return i.description
|
|
117
|
+
}
|
|
118
|
+
if i.option.Count > 0 {
|
|
119
|
+
return fmt.Sprintf("%d chats", i.option.Count)
|
|
120
|
+
}
|
|
121
|
+
return i.option.Value
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type transcriptMessage struct {
|
|
125
|
+
Role string
|
|
126
|
+
Timestamp string
|
|
127
|
+
Model string
|
|
128
|
+
Text string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type actionKind int
|
|
132
|
+
|
|
133
|
+
const (
|
|
134
|
+
actionNone actionKind = iota
|
|
135
|
+
actionOpen
|
|
136
|
+
actionBack
|
|
137
|
+
actionExportMarkdown
|
|
138
|
+
actionExportText
|
|
139
|
+
actionFilters
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
type actionButton struct {
|
|
143
|
+
Label string
|
|
144
|
+
Action actionKind
|
|
145
|
+
X int
|
|
146
|
+
Y int
|
|
147
|
+
Width int
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type keySet struct {
|
|
151
|
+
short []key.Binding
|
|
152
|
+
full [][]key.Binding
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func (k keySet) ShortHelp() []key.Binding {
|
|
156
|
+
return k.short
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func (k keySet) FullHelp() [][]key.Binding {
|
|
160
|
+
return k.full
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type keyMap struct {
|
|
164
|
+
Open key.Binding
|
|
165
|
+
Back key.Binding
|
|
166
|
+
ExportMarkdown key.Binding
|
|
167
|
+
ExportText key.Binding
|
|
168
|
+
Filter key.Binding
|
|
169
|
+
FilterHarness key.Binding
|
|
170
|
+
FilterRepos key.Binding
|
|
171
|
+
FilterModels key.Binding
|
|
172
|
+
FilterRoles key.Binding
|
|
173
|
+
AdvanceFilter key.Binding
|
|
174
|
+
Search key.Binding
|
|
175
|
+
Focus key.Binding
|
|
176
|
+
Move key.Binding
|
|
177
|
+
Select key.Binding
|
|
178
|
+
Clear key.Binding
|
|
179
|
+
ClearSection key.Binding
|
|
180
|
+
Apply key.Binding
|
|
181
|
+
Quit key.Binding
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
func newKeyMap() keyMap {
|
|
185
|
+
return keyMap{
|
|
186
|
+
Open: key.NewBinding(
|
|
187
|
+
key.WithKeys("enter", "right"),
|
|
188
|
+
key.WithHelp("enter", "open"),
|
|
189
|
+
),
|
|
190
|
+
Back: key.NewBinding(
|
|
191
|
+
key.WithKeys("esc", "left"),
|
|
192
|
+
key.WithHelp("esc/left", "back"),
|
|
193
|
+
),
|
|
194
|
+
ExportMarkdown: key.NewBinding(
|
|
195
|
+
key.WithKeys("m"),
|
|
196
|
+
key.WithHelp("m", "export md"),
|
|
197
|
+
),
|
|
198
|
+
ExportText: key.NewBinding(
|
|
199
|
+
key.WithKeys("t"),
|
|
200
|
+
key.WithHelp("t", "export txt"),
|
|
201
|
+
),
|
|
202
|
+
Filter: key.NewBinding(
|
|
203
|
+
key.WithKeys("ctrl+f", "enter"),
|
|
204
|
+
key.WithHelp("ctrl+f", "filters"),
|
|
205
|
+
),
|
|
206
|
+
FilterHarness: key.NewBinding(
|
|
207
|
+
key.WithKeys("1", "h"),
|
|
208
|
+
key.WithHelp("1/h", "harness"),
|
|
209
|
+
),
|
|
210
|
+
FilterRepos: key.NewBinding(
|
|
211
|
+
key.WithKeys("2", "r"),
|
|
212
|
+
key.WithHelp("2/r", "repos"),
|
|
213
|
+
),
|
|
214
|
+
FilterModels: key.NewBinding(
|
|
215
|
+
key.WithKeys("3", "m"),
|
|
216
|
+
key.WithHelp("3/m", "models"),
|
|
217
|
+
),
|
|
218
|
+
FilterRoles: key.NewBinding(
|
|
219
|
+
key.WithKeys("4", "o"),
|
|
220
|
+
key.WithHelp("4/o", "roles"),
|
|
221
|
+
),
|
|
222
|
+
AdvanceFilter: key.NewBinding(
|
|
223
|
+
key.WithKeys("enter"),
|
|
224
|
+
key.WithHelp("enter", "select + next"),
|
|
225
|
+
),
|
|
226
|
+
Search: key.NewBinding(
|
|
227
|
+
key.WithKeys("/"),
|
|
228
|
+
key.WithHelp("/", "search"),
|
|
229
|
+
),
|
|
230
|
+
Focus: key.NewBinding(
|
|
231
|
+
key.WithKeys("tab", "shift+tab"),
|
|
232
|
+
key.WithHelp("tab", "focus"),
|
|
233
|
+
),
|
|
234
|
+
Move: key.NewBinding(
|
|
235
|
+
key.WithKeys("up", "down", "k", "j", "pgup", "pgdown"),
|
|
236
|
+
key.WithHelp("↑/↓", "move"),
|
|
237
|
+
),
|
|
238
|
+
Select: key.NewBinding(
|
|
239
|
+
key.WithKeys(" ", "enter"),
|
|
240
|
+
key.WithHelp("space", "select"),
|
|
241
|
+
),
|
|
242
|
+
Clear: key.NewBinding(
|
|
243
|
+
key.WithKeys("ctrl+u"),
|
|
244
|
+
key.WithHelp("ctrl+u", "clear"),
|
|
245
|
+
),
|
|
246
|
+
ClearSection: key.NewBinding(
|
|
247
|
+
key.WithKeys("c"),
|
|
248
|
+
key.WithHelp("c", "clear section"),
|
|
249
|
+
),
|
|
250
|
+
Apply: key.NewBinding(
|
|
251
|
+
key.WithKeys("esc", "left"),
|
|
252
|
+
key.WithHelp("esc", "apply"),
|
|
253
|
+
),
|
|
254
|
+
Quit: key.NewBinding(
|
|
255
|
+
key.WithKeys("ctrl+c", "q"),
|
|
256
|
+
key.WithHelp("ctrl+c", "quit"),
|
|
257
|
+
),
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func newHelpModel() help.Model {
|
|
262
|
+
model := help.New()
|
|
263
|
+
model.ShortSeparator = " "
|
|
264
|
+
model.FullSeparator = " "
|
|
265
|
+
model.Styles.ShortKey = lipgloss.NewStyle().Foreground(cyan).Background(bg).Bold(true)
|
|
266
|
+
model.Styles.ShortDesc = lipgloss.NewStyle().Foreground(muted).Background(bg)
|
|
267
|
+
model.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(border).Background(bg)
|
|
268
|
+
model.Styles.FullKey = model.Styles.ShortKey
|
|
269
|
+
model.Styles.FullDesc = model.Styles.ShortDesc
|
|
270
|
+
model.Styles.FullSeparator = model.Styles.ShortSeparator
|
|
271
|
+
model.Styles.Ellipsis = lipgloss.NewStyle().Foreground(muted).Background(bg)
|
|
272
|
+
return model
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
func newResultsTable() table.Model {
|
|
276
|
+
model := table.New(table.WithFocused(false), table.WithHeight(12), table.WithWidth(80))
|
|
277
|
+
model.SetStyles(resultsTableStyles())
|
|
278
|
+
return model
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func resultsTableStyles() table.Styles {
|
|
282
|
+
styles := table.DefaultStyles()
|
|
283
|
+
styles.Header = lipgloss.NewStyle().
|
|
284
|
+
Foreground(cyan).
|
|
285
|
+
Background(bg).
|
|
286
|
+
Bold(true).
|
|
287
|
+
Padding(0, 1).
|
|
288
|
+
BorderStyle(lipgloss.NormalBorder()).
|
|
289
|
+
BorderForeground(border).
|
|
290
|
+
BorderBottom(true)
|
|
291
|
+
styles.Cell = lipgloss.NewStyle().
|
|
292
|
+
Foreground(fg).
|
|
293
|
+
Background(bg).
|
|
294
|
+
Padding(0, 1)
|
|
295
|
+
styles.Selected = lipgloss.NewStyle().
|
|
296
|
+
Foreground(fg).
|
|
297
|
+
Background(surface).
|
|
298
|
+
Bold(true)
|
|
299
|
+
return styles
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func newFilterList(width int, height int) list.Model {
|
|
303
|
+
delegate := list.NewDefaultDelegate()
|
|
304
|
+
delegate.SetHeight(2)
|
|
305
|
+
delegate.SetSpacing(0)
|
|
306
|
+
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(fg).Background(bg).Padding(0, 0, 0, 2)
|
|
307
|
+
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(muted).Background(bg).Padding(0, 0, 0, 2)
|
|
308
|
+
delegate.Styles.SelectedTitle = lipgloss.NewStyle().
|
|
309
|
+
Foreground(bg).
|
|
310
|
+
Background(cyan).
|
|
311
|
+
Bold(true).
|
|
312
|
+
Padding(0, 1)
|
|
313
|
+
delegate.Styles.SelectedDesc = lipgloss.NewStyle().
|
|
314
|
+
Foreground(fg).
|
|
315
|
+
Background(surface).
|
|
316
|
+
Padding(0, 1)
|
|
317
|
+
delegate.Styles.DimmedTitle = mutedStyle.Padding(0, 0, 0, 2)
|
|
318
|
+
delegate.Styles.DimmedDesc = mutedStyle.Padding(0, 0, 0, 2)
|
|
319
|
+
delegate.Styles.FilterMatch = lipgloss.NewStyle().Foreground(yellow).Bold(true)
|
|
320
|
+
|
|
321
|
+
model := list.New(nil, delegate, width, height)
|
|
322
|
+
model.SetShowHelp(false)
|
|
323
|
+
model.SetShowFilter(false)
|
|
324
|
+
model.SetFilteringEnabled(false)
|
|
325
|
+
model.SetShowStatusBar(true)
|
|
326
|
+
model.SetShowPagination(true)
|
|
327
|
+
model.SetStatusBarItemName("option", "options")
|
|
328
|
+
model.DisableQuitKeybindings()
|
|
329
|
+
model.Styles.Title = lipgloss.NewStyle().Foreground(cyan).Background(bg).Bold(true)
|
|
330
|
+
model.Styles.StatusBar = lipgloss.NewStyle().Foreground(muted).Background(bg)
|
|
331
|
+
model.Styles.PaginationStyle = lipgloss.NewStyle().Foreground(muted).Background(bg)
|
|
332
|
+
model.Styles.HelpStyle = mutedStyle
|
|
333
|
+
return model
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
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
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
func main() {
|
|
378
|
+
configureTerminalColors()
|
|
379
|
+
|
|
380
|
+
dbPath := flag.String("db", "", "SQLite index path")
|
|
381
|
+
flag.Parse()
|
|
382
|
+
|
|
383
|
+
path := *dbPath
|
|
384
|
+
if path == "" {
|
|
385
|
+
path = defaultDBPath()
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
db, err := sql.Open("sqlite", path)
|
|
389
|
+
if err != nil {
|
|
390
|
+
fmt.Fprintf(os.Stderr, "open database: %v\n", err)
|
|
391
|
+
os.Exit(1)
|
|
392
|
+
}
|
|
393
|
+
defer db.Close()
|
|
394
|
+
db.SetMaxOpenConns(1)
|
|
395
|
+
|
|
396
|
+
search := textinput.New()
|
|
397
|
+
search.Placeholder = "type to search local Codex, Claude Code, and Pi chats"
|
|
398
|
+
search.Prompt = ""
|
|
399
|
+
search.Focus()
|
|
400
|
+
search.CharLimit = 512
|
|
401
|
+
search.PlaceholderStyle = mutedStyle
|
|
402
|
+
search.TextStyle = baseStyle
|
|
403
|
+
search.Cursor.Style = lipgloss.NewStyle().Foreground(yellow).Background(bg)
|
|
404
|
+
|
|
405
|
+
m := &model{
|
|
406
|
+
db: db,
|
|
407
|
+
dbPath: path,
|
|
408
|
+
search: search,
|
|
409
|
+
transcriptView: viewport.New(80, 20),
|
|
410
|
+
helpBubble: newHelpModel(),
|
|
411
|
+
resultsTable: newResultsTable(),
|
|
412
|
+
filterList: newFilterList(80, 20),
|
|
413
|
+
keys: newKeyMap(),
|
|
414
|
+
providers: map[string]bool{},
|
|
415
|
+
repoFilters: map[string]bool{},
|
|
416
|
+
modelFilters: map[string]bool{},
|
|
417
|
+
allRoles: true,
|
|
418
|
+
}
|
|
419
|
+
m.providerOptions, _ = loadProviderOptions(db)
|
|
420
|
+
m.repoOptions, _ = loadRepoOptions(db)
|
|
421
|
+
m.modelOptions, _ = loadModelOptions(db)
|
|
422
|
+
m.reload()
|
|
423
|
+
|
|
424
|
+
program := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
|
425
|
+
if _, err := program.Run(); err != nil {
|
|
426
|
+
fmt.Fprintf(os.Stderr, "run tui: %v\n", err)
|
|
427
|
+
os.Exit(1)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
func configureTerminalColors() {
|
|
432
|
+
if os.Getenv("GORA_NO_COLOR") == "1" {
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
lipgloss.SetColorProfile(termenv.TrueColor)
|
|
436
|
+
lipgloss.SetHasDarkBackground(true)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
func (m *model) Init() tea.Cmd {
|
|
440
|
+
return textinput.Blink
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
444
|
+
switch msg := msg.(type) {
|
|
445
|
+
case tea.WindowSizeMsg:
|
|
446
|
+
m.width = msg.Width
|
|
447
|
+
m.height = msg.Height
|
|
448
|
+
m.helpBubble.Width = msg.Width
|
|
449
|
+
m.search.Width = max(10, msg.Width-8)
|
|
450
|
+
m.transcriptView.Width = max(20, msg.Width-2)
|
|
451
|
+
m.transcriptView.Height = m.detailHeight()
|
|
452
|
+
m.configureResultsTable(max(72, msg.Width), max(4, msg.Height/2))
|
|
453
|
+
m.configureFilterList(max(72, msg.Width), max(8, msg.Height-8))
|
|
454
|
+
case tea.KeyMsg:
|
|
455
|
+
return m, m.handleKey(msg)
|
|
456
|
+
case tea.MouseMsg:
|
|
457
|
+
return m, m.handleMouse(msg)
|
|
458
|
+
}
|
|
459
|
+
return m, nil
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd {
|
|
463
|
+
key := msg.String()
|
|
464
|
+
if m.filterOpen {
|
|
465
|
+
return m.handleFilterKey(msg)
|
|
466
|
+
}
|
|
467
|
+
if m.detail {
|
|
468
|
+
switch key {
|
|
469
|
+
case "esc", "left":
|
|
470
|
+
m.detail = false
|
|
471
|
+
case "ctrl+c", "q":
|
|
472
|
+
return tea.Quit
|
|
473
|
+
case "m":
|
|
474
|
+
m.exportSelected("md")
|
|
475
|
+
case "t":
|
|
476
|
+
m.exportSelected("txt")
|
|
477
|
+
default:
|
|
478
|
+
var cmd tea.Cmd
|
|
479
|
+
m.transcriptView, cmd = m.transcriptView.Update(msg)
|
|
480
|
+
return cmd
|
|
481
|
+
}
|
|
482
|
+
return nil
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
switch key {
|
|
486
|
+
case "ctrl+c":
|
|
487
|
+
return tea.Quit
|
|
488
|
+
case "esc":
|
|
489
|
+
if m.search.Value() != "" {
|
|
490
|
+
m.search.SetValue("")
|
|
491
|
+
m.resetPosition()
|
|
492
|
+
m.reload()
|
|
493
|
+
return nil
|
|
494
|
+
}
|
|
495
|
+
if m.hasActiveFilters() {
|
|
496
|
+
m.clearFilters()
|
|
497
|
+
m.notice = "filters cleared"
|
|
498
|
+
m.resetPosition()
|
|
499
|
+
m.reload()
|
|
500
|
+
return nil
|
|
501
|
+
}
|
|
502
|
+
return tea.Quit
|
|
503
|
+
case "tab":
|
|
504
|
+
m.nextFocus()
|
|
505
|
+
case "shift+tab":
|
|
506
|
+
m.previousFocus()
|
|
507
|
+
case "ctrl+f":
|
|
508
|
+
m.setFocus(focusFilters)
|
|
509
|
+
m.openFilters()
|
|
510
|
+
case "/":
|
|
511
|
+
m.setFocus(focusSearch)
|
|
512
|
+
case "up", "k":
|
|
513
|
+
m.move(-1)
|
|
514
|
+
m.setFocus(focusResults)
|
|
515
|
+
case "down", "j":
|
|
516
|
+
m.move(1)
|
|
517
|
+
m.setFocus(focusResults)
|
|
518
|
+
case "pgup":
|
|
519
|
+
m.move(-10)
|
|
520
|
+
m.setFocus(focusResults)
|
|
521
|
+
case "pgdown":
|
|
522
|
+
m.move(10)
|
|
523
|
+
m.setFocus(focusResults)
|
|
524
|
+
case "enter", "right":
|
|
525
|
+
if m.focus == focusFilters {
|
|
526
|
+
m.openFilters()
|
|
527
|
+
return nil
|
|
528
|
+
}
|
|
529
|
+
if len(m.results) > 0 {
|
|
530
|
+
m.setFocus(focusResults)
|
|
531
|
+
m.openDetail()
|
|
532
|
+
}
|
|
533
|
+
case "ctrl+r":
|
|
534
|
+
m.allRoles = !m.allRoles
|
|
535
|
+
m.resetPosition()
|
|
536
|
+
m.reload()
|
|
537
|
+
case "ctrl+u":
|
|
538
|
+
m.search.SetValue("")
|
|
539
|
+
m.resetPosition()
|
|
540
|
+
m.reload()
|
|
541
|
+
default:
|
|
542
|
+
if m.focus != focusSearch {
|
|
543
|
+
if key == "m" {
|
|
544
|
+
m.exportSelected("md")
|
|
545
|
+
}
|
|
546
|
+
if key == "t" {
|
|
547
|
+
m.exportSelected("txt")
|
|
548
|
+
}
|
|
549
|
+
return nil
|
|
550
|
+
}
|
|
551
|
+
before := m.search.Value()
|
|
552
|
+
var cmd tea.Cmd
|
|
553
|
+
m.search, cmd = m.search.Update(msg)
|
|
554
|
+
if m.search.Value() != before {
|
|
555
|
+
m.resetPosition()
|
|
556
|
+
m.reload()
|
|
557
|
+
}
|
|
558
|
+
return cmd
|
|
559
|
+
}
|
|
560
|
+
return nil
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
func (m *model) handleMouse(msg tea.MouseMsg) tea.Cmd {
|
|
564
|
+
if m.detail {
|
|
565
|
+
if msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress {
|
|
566
|
+
if action := buttonActionAt(m.detailButtons, msg.X, msg.Y); action != actionNone {
|
|
567
|
+
m.performAction(action)
|
|
568
|
+
return nil
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
var cmd tea.Cmd
|
|
572
|
+
m.transcriptView, cmd = m.transcriptView.Update(msg)
|
|
573
|
+
return cmd
|
|
574
|
+
}
|
|
575
|
+
if msg.Button == tea.MouseButtonWheelUp {
|
|
576
|
+
m.move(-3)
|
|
577
|
+
m.setFocus(focusResults)
|
|
578
|
+
return nil
|
|
579
|
+
}
|
|
580
|
+
if msg.Button == tea.MouseButtonWheelDown {
|
|
581
|
+
m.move(3)
|
|
582
|
+
m.setFocus(focusResults)
|
|
583
|
+
return nil
|
|
584
|
+
}
|
|
585
|
+
if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress {
|
|
586
|
+
return nil
|
|
587
|
+
}
|
|
588
|
+
if m.filterOpen {
|
|
589
|
+
m.handleFilterClick(msg)
|
|
590
|
+
return nil
|
|
591
|
+
}
|
|
592
|
+
if action := buttonActionAt(m.browseButtons, msg.X, msg.Y); action != actionNone {
|
|
593
|
+
m.performAction(action)
|
|
594
|
+
return nil
|
|
595
|
+
}
|
|
596
|
+
switch {
|
|
597
|
+
case msg.Y >= m.searchTop && msg.Y <= m.searchBottom:
|
|
598
|
+
m.setFocus(focusSearch)
|
|
599
|
+
case msg.Y >= m.filterTop && msg.Y <= m.filterBottom:
|
|
600
|
+
m.setFocus(focusFilters)
|
|
601
|
+
m.openFilters()
|
|
602
|
+
case msg.Y >= m.rowsTop:
|
|
603
|
+
m.setFocus(focusResults)
|
|
604
|
+
}
|
|
605
|
+
return nil
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
func (m *model) performAction(action actionKind) {
|
|
609
|
+
switch action {
|
|
610
|
+
case actionOpen:
|
|
611
|
+
if len(m.results) > 0 {
|
|
612
|
+
m.setFocus(focusResults)
|
|
613
|
+
m.openDetail()
|
|
614
|
+
}
|
|
615
|
+
case actionBack:
|
|
616
|
+
m.detail = false
|
|
617
|
+
case actionExportMarkdown:
|
|
618
|
+
m.exportSelected("md")
|
|
619
|
+
case actionExportText:
|
|
620
|
+
m.exportSelected("txt")
|
|
621
|
+
case actionFilters:
|
|
622
|
+
m.setFocus(focusFilters)
|
|
623
|
+
m.openFilters()
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
func buttonActionAt(buttons []actionButton, x int, y int) actionKind {
|
|
628
|
+
for _, button := range buttons {
|
|
629
|
+
if y == button.Y && x >= button.X && x < button.X+button.Width {
|
|
630
|
+
return button.Action
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return actionNone
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
func (m *model) setFocus(focus focusArea) {
|
|
637
|
+
m.focus = focus
|
|
638
|
+
if focus == focusSearch {
|
|
639
|
+
m.search.Focus()
|
|
640
|
+
m.resultsTable.Blur()
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
m.search.Blur()
|
|
644
|
+
if focus == focusResults {
|
|
645
|
+
m.resultsTable.Focus()
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
m.resultsTable.Blur()
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
func (m *model) nextFocus() {
|
|
652
|
+
switch m.focus {
|
|
653
|
+
case focusSearch:
|
|
654
|
+
m.setFocus(focusFilters)
|
|
655
|
+
case focusFilters:
|
|
656
|
+
m.setFocus(focusResults)
|
|
657
|
+
default:
|
|
658
|
+
m.setFocus(focusSearch)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
func (m *model) previousFocus() {
|
|
663
|
+
switch m.focus {
|
|
664
|
+
case focusSearch:
|
|
665
|
+
m.setFocus(focusResults)
|
|
666
|
+
case focusFilters:
|
|
667
|
+
m.setFocus(focusSearch)
|
|
668
|
+
default:
|
|
669
|
+
m.setFocus(focusFilters)
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
func (m *model) View() string {
|
|
674
|
+
if m.width == 0 || m.height == 0 {
|
|
675
|
+
return ""
|
|
676
|
+
}
|
|
677
|
+
if m.filterOpen {
|
|
678
|
+
return m.filterView()
|
|
679
|
+
}
|
|
680
|
+
if m.detail {
|
|
681
|
+
return m.detailView()
|
|
682
|
+
}
|
|
683
|
+
return m.browseView()
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
func (m *model) browseView() string {
|
|
687
|
+
width := max(72, m.width)
|
|
688
|
+
searchBlock := m.renderSearch(width)
|
|
689
|
+
filterBlock := m.renderFilters(width)
|
|
690
|
+
statusBlock := m.renderStatus(width)
|
|
691
|
+
m.searchTop = 0
|
|
692
|
+
m.searchBottom = lipgloss.Height(searchBlock) - 1
|
|
693
|
+
m.filterTop = m.searchBottom + 1
|
|
694
|
+
m.filterBottom = m.filterTop + lipgloss.Height(filterBlock) - 1
|
|
695
|
+
m.rowsTop = m.filterBottom + lipgloss.Height(statusBlock) + 2
|
|
696
|
+
|
|
697
|
+
lines := []string{searchBlock, filterBlock, statusBlock}
|
|
698
|
+
footer := m.renderFooter(width)
|
|
699
|
+
bodyHeight := max(6, m.height-lipgloss.Height(strings.Join(lines, "\n"))-lipgloss.Height(footer)-2)
|
|
700
|
+
lines = append(lines, m.renderBrowseBody(width, bodyHeight))
|
|
701
|
+
actionY := lipgloss.Height(strings.Join(lines, "\n"))
|
|
702
|
+
lines = append(lines, m.renderBrowseActions(width, actionY))
|
|
703
|
+
lines = append(lines, footer)
|
|
704
|
+
return baseStyle.Width(width).Height(m.height).Render(strings.Join(lines, "\n"))
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
func (m *model) renderBrowseBody(width int, height int) string {
|
|
708
|
+
if width >= 104 && len(m.results) > 0 {
|
|
709
|
+
gap := 2
|
|
710
|
+
rightWidth := clamp(width/3, 38, 52)
|
|
711
|
+
leftWidth := max(48, width-rightWidth-gap)
|
|
712
|
+
leftTitle := m.renderResultsTitle(leftWidth)
|
|
713
|
+
left := strings.Join([]string{
|
|
714
|
+
leftTitle,
|
|
715
|
+
m.renderResultsTable(leftWidth, max(4, height-lipgloss.Height(leftTitle))),
|
|
716
|
+
}, "\n")
|
|
717
|
+
right := m.renderSelectedPreview(rightWidth, height)
|
|
718
|
+
return lipgloss.JoinHorizontal(lipgloss.Top, left, baseStyle.Render(strings.Repeat(" ", gap)), right)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
title := m.renderResultsTitle(width)
|
|
722
|
+
tableHeight := max(4, height-lipgloss.Height(title)-5)
|
|
723
|
+
preview := m.renderSelectedPreview(width, max(4, height-lipgloss.Height(title)-tableHeight))
|
|
724
|
+
return strings.Join([]string{title, m.renderResultsTable(width, tableHeight), preview}, "\n")
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
func (m *model) renderSearch(width int) string {
|
|
728
|
+
contentWidth := max(20, width-8)
|
|
729
|
+
m.search.Width = contentWidth
|
|
730
|
+
style := searchStyle
|
|
731
|
+
if m.focus == focusSearch {
|
|
732
|
+
style = style.BorderForeground(cyan)
|
|
733
|
+
}
|
|
734
|
+
return style.Width(contentWidth).Render(m.renderSearchInput(contentWidth))
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
func (m *model) renderSearchInput(width int) string {
|
|
738
|
+
value := m.search.Value()
|
|
739
|
+
if value == "" {
|
|
740
|
+
return searchHint.Render(padRight(m.search.Placeholder, width))
|
|
741
|
+
}
|
|
742
|
+
if m.focus != focusSearch {
|
|
743
|
+
return searchText.Render(padRight(fit(value, width), width))
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
runes := []rune(value)
|
|
747
|
+
position := clamp(m.search.Position(), 0, len(runes))
|
|
748
|
+
start := 0
|
|
749
|
+
if position >= width {
|
|
750
|
+
start = position - width + 1
|
|
751
|
+
}
|
|
752
|
+
end := min(len(runes), start+width)
|
|
753
|
+
visible := runes[start:end]
|
|
754
|
+
cursor := position - start
|
|
755
|
+
|
|
756
|
+
if len(visible) >= width && cursor >= width {
|
|
757
|
+
visible = visible[:width-1]
|
|
758
|
+
cursor = width - 1
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
before := string(visible[:min(cursor, len(visible))])
|
|
762
|
+
cursorChar := " "
|
|
763
|
+
after := ""
|
|
764
|
+
if cursor < len(visible) {
|
|
765
|
+
cursorChar = string(visible[cursor])
|
|
766
|
+
after = string(visible[cursor+1:])
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
rendered := searchText.Render(before) + searchCursor.Render(cursorChar) + searchText.Render(after)
|
|
770
|
+
return rendered + searchText.Render(strings.Repeat(" ", max(0, width-lipgloss.Width(rendered))))
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
func (m *model) renderFilters(width int) string {
|
|
774
|
+
provider := m.compactProviderSummary()
|
|
775
|
+
modelName := m.compactModelSummary()
|
|
776
|
+
repo := m.compactRepoSummary()
|
|
777
|
+
roles := "user+assistant"
|
|
778
|
+
if m.allRoles {
|
|
779
|
+
roles = "all roles"
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
title := sectionStyle.Render("Filters")
|
|
783
|
+
if m.focus == focusFilters {
|
|
784
|
+
title = selectedStyle.Foreground(cyan).Render("Filters")
|
|
785
|
+
}
|
|
786
|
+
action := renderFilterAction(m.keys.Filter, m.focus == focusFilters)
|
|
787
|
+
help := mutedStyle.Render("Tab focus Enter edit 1/2/3/4 inside editor")
|
|
788
|
+
top := lipgloss.JoinHorizontal(lipgloss.Center, title, baseStyle.Render(" "), action, baseStyle.Render(" "), help)
|
|
789
|
+
if lipgloss.Width(top) > width {
|
|
790
|
+
top = lipgloss.JoinHorizontal(lipgloss.Center, title, baseStyle.Render(" "), action)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
chips := []string{
|
|
794
|
+
filterControl("Harness", provider, len(m.providers) > 0, purple),
|
|
795
|
+
filterControl("Repo", repo, len(m.repoFilters) > 0, green),
|
|
796
|
+
filterControl("Model", modelName, len(m.modelFilters) > 0, yellow),
|
|
797
|
+
filterControl("Roles", roles, !m.allRoles, cyan),
|
|
798
|
+
}
|
|
799
|
+
return top + "\n" + joinFilterControls(chips, width)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
func (m *model) renderStatus(width int) string {
|
|
803
|
+
if m.search.Value() != "" {
|
|
804
|
+
status := fmt.Sprintf("%d matches for %q", len(m.results), m.search.Value())
|
|
805
|
+
return m.renderStatusLine(status, width)
|
|
806
|
+
}
|
|
807
|
+
return m.renderStatusLine(fmt.Sprintf("%d recent chats", len(m.results)), width)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
func (m *model) renderStatusLine(status string, width int) string {
|
|
811
|
+
if m.notice != "" {
|
|
812
|
+
status += " " + m.notice
|
|
813
|
+
}
|
|
814
|
+
if m.err != nil {
|
|
815
|
+
status += " " + m.err.Error()
|
|
816
|
+
}
|
|
817
|
+
return mutedStyle.Render(fit(status, width))
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
func (m *model) renderResultsTitle(width int) string {
|
|
821
|
+
count := fmt.Sprintf("%d chats", len(m.results))
|
|
822
|
+
title := sectionStyle.Render("found chats")
|
|
823
|
+
spacer := mutedStyle.Render(strings.Repeat(" ", max(1, width-lipgloss.Width(title)-lipgloss.Width(count))))
|
|
824
|
+
return title + spacer + mutedStyle.Render(count)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
func (m *model) renderResultsTable(width int, height int) string {
|
|
828
|
+
if len(m.results) == 0 {
|
|
829
|
+
if m.hasActiveFilters() {
|
|
830
|
+
return mutedStyle.Render(" no chats match the current filters")
|
|
831
|
+
}
|
|
832
|
+
if m.search.Value() == "" {
|
|
833
|
+
return mutedStyle.Render(" no sessions indexed; run gora import")
|
|
834
|
+
}
|
|
835
|
+
return mutedStyle.Render(" no matches")
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
m.configureResultsTable(width, height)
|
|
839
|
+
return m.resultsTable.View()
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
func (m *model) renderSelectedPreview(width int, height int) string {
|
|
843
|
+
row, ok := m.currentSelection()
|
|
844
|
+
if !ok {
|
|
845
|
+
return ""
|
|
846
|
+
}
|
|
847
|
+
title := row.Title
|
|
848
|
+
if title == "" {
|
|
849
|
+
title = row.SessionKey
|
|
850
|
+
}
|
|
851
|
+
meta := []string{
|
|
852
|
+
shortCwd(row.Cwd),
|
|
853
|
+
"created " + friendlyTime(row.CreatedAt),
|
|
854
|
+
"updated " + friendlyTime(row.UpdatedAt),
|
|
855
|
+
}
|
|
856
|
+
if row.Models != "" {
|
|
857
|
+
meta = append(meta, modelSummary(row.Models))
|
|
858
|
+
}
|
|
859
|
+
if row.MessageCount > 0 {
|
|
860
|
+
meta = append(meta, fmt.Sprintf("%d messages", row.MessageCount))
|
|
861
|
+
}
|
|
862
|
+
bodyWidth := max(20, width-4)
|
|
863
|
+
lines := []string{
|
|
864
|
+
sectionStyle.Render(fit("preview", bodyWidth)),
|
|
865
|
+
selectedStyle.Foreground(cyan).Render(fit(title, bodyWidth)),
|
|
866
|
+
mutedStyle.Render(fit(strings.Join(meta, " "), bodyWidth)),
|
|
867
|
+
}
|
|
868
|
+
if row.Preview != "" {
|
|
869
|
+
lines = append(lines, mutedStyle.Render(fit("match "+row.Preview, bodyWidth)))
|
|
870
|
+
}
|
|
871
|
+
lines = append(lines, lipgloss.NewStyle().Foreground(border).Render(strings.Repeat("─", min(bodyWidth, 32))))
|
|
872
|
+
lines = append(lines, m.transcriptPreviewLines(row, bodyWidth)...)
|
|
873
|
+
lines = clipStyledLines(lines, max(1, height-1))
|
|
874
|
+
return lipgloss.NewStyle().
|
|
875
|
+
Foreground(fg).
|
|
876
|
+
Border(lipgloss.NormalBorder(), false, false, false, true).
|
|
877
|
+
BorderForeground(border).
|
|
878
|
+
Padding(0, 0, 0, 2).
|
|
879
|
+
Width(max(1, width-3)).
|
|
880
|
+
Height(max(1, height)).
|
|
881
|
+
Render(strings.Join(lines, "\n"))
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
func clipStyledLines(lines []string, height int) []string {
|
|
885
|
+
if height <= 0 || len(lines) <= height {
|
|
886
|
+
return lines
|
|
887
|
+
}
|
|
888
|
+
if height == 1 {
|
|
889
|
+
return []string{mutedStyle.Render("...")}
|
|
890
|
+
}
|
|
891
|
+
clipped := append([]string{}, lines[:height-1]...)
|
|
892
|
+
clipped = append(clipped, mutedStyle.Render("... enter to open full transcript"))
|
|
893
|
+
return clipped
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
func (m *model) transcriptPreviewLines(row item, width int) []string {
|
|
897
|
+
if m.db == nil {
|
|
898
|
+
fallback := "session " + row.SessionKey
|
|
899
|
+
if row.Preview != "" {
|
|
900
|
+
fallback = "match " + row.Preview
|
|
901
|
+
}
|
|
902
|
+
return []string{mutedStyle.Render(fit(fallback, width))}
|
|
903
|
+
}
|
|
904
|
+
if m.previewKey == row.SessionKey && m.previewWidth == width && m.previewAllRoles == m.allRoles {
|
|
905
|
+
if m.previewErr != nil {
|
|
906
|
+
return []string{lipgloss.NewStyle().Foreground(red).Render(fit(m.previewErr.Error(), width))}
|
|
907
|
+
}
|
|
908
|
+
return m.previewLines
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
messages, err := loadTranscriptPreviewMessages(m.db, row.SessionKey, m.allRoles, 8)
|
|
912
|
+
m.previewKey = row.SessionKey
|
|
913
|
+
m.previewWidth = width
|
|
914
|
+
m.previewAllRoles = m.allRoles
|
|
915
|
+
m.previewErr = err
|
|
916
|
+
if err != nil {
|
|
917
|
+
m.previewLines = nil
|
|
918
|
+
return []string{lipgloss.NewStyle().Foreground(red).Render(fit(err.Error(), width))}
|
|
919
|
+
}
|
|
920
|
+
m.previewLines = formatTranscriptPreviewLines(messages, width)
|
|
921
|
+
return m.previewLines
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
func formatTranscriptPreviewLines(messages []transcriptMessage, width int) []string {
|
|
925
|
+
if len(messages) == 0 {
|
|
926
|
+
return []string{mutedStyle.Render("no transcript messages")}
|
|
927
|
+
}
|
|
928
|
+
lines := []string{}
|
|
929
|
+
for _, message := range messages {
|
|
930
|
+
headerParts := []string{strings.ToUpper(message.Role)}
|
|
931
|
+
if message.Timestamp != "" {
|
|
932
|
+
headerParts = append(headerParts, friendlyTime(message.Timestamp))
|
|
933
|
+
}
|
|
934
|
+
if message.Model != "" {
|
|
935
|
+
headerParts = append(headerParts, message.Model)
|
|
936
|
+
}
|
|
937
|
+
lines = append(lines, roleStyle(message.Role).Render(fit(strings.Join(headerParts, " "), width)))
|
|
938
|
+
textLines := 0
|
|
939
|
+
for _, raw := range strings.Split(message.Text, "\n") {
|
|
940
|
+
for _, wrapped := range wrap(raw, max(12, width-2)) {
|
|
941
|
+
lines = append(lines, baseStyle.Render(" "+fit(wrapped, max(1, width-2))))
|
|
942
|
+
textLines++
|
|
943
|
+
if textLines >= 3 {
|
|
944
|
+
break
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if textLines >= 3 {
|
|
948
|
+
break
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
lines = append(lines, "")
|
|
952
|
+
}
|
|
953
|
+
return lines
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
func (m *model) configureResultsTable(width int, height int) {
|
|
957
|
+
if width <= 0 {
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
if m.resultsTable.Width() == 0 && m.resultsTable.Height() == 0 {
|
|
961
|
+
m.resultsTable = newResultsTable()
|
|
962
|
+
}
|
|
963
|
+
m.resultsTable.SetColumns(resultTableColumns(width))
|
|
964
|
+
m.selected = clamp(m.selected, 0, max(0, len(m.results)-1))
|
|
965
|
+
m.resultsTable.SetRows(resultTableRows(m.results, m.selected))
|
|
966
|
+
m.resultsTable.SetWidth(width)
|
|
967
|
+
m.resultsTable.SetHeight(max(4, height))
|
|
968
|
+
m.resultsTable.SetCursor(m.selected)
|
|
969
|
+
if m.focus == focusResults {
|
|
970
|
+
m.resultsTable.Focus()
|
|
971
|
+
} else {
|
|
972
|
+
m.resultsTable.Blur()
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
func resultTableColumns(width int) []table.Column {
|
|
977
|
+
inner := max(40, width-4)
|
|
978
|
+
markerWidth := 2
|
|
979
|
+
ageWidth := 8
|
|
980
|
+
updatedWidth := 16
|
|
981
|
+
createdWidth := 16
|
|
982
|
+
repoWidth := 22
|
|
983
|
+
harnessWidth := 11
|
|
984
|
+
messageWidth := 5
|
|
985
|
+
|
|
986
|
+
if inner < 96 {
|
|
987
|
+
createdWidth = 0
|
|
988
|
+
repoWidth = 18
|
|
989
|
+
}
|
|
990
|
+
if inner < 92 {
|
|
991
|
+
updatedWidth = 12
|
|
992
|
+
repoWidth = 0
|
|
993
|
+
messageWidth = 0
|
|
994
|
+
}
|
|
995
|
+
if inner < 64 {
|
|
996
|
+
createdWidth = 0
|
|
997
|
+
updatedWidth = 0
|
|
998
|
+
messageWidth = 0
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
columns := []table.Column{
|
|
1002
|
+
{Title: "", Width: markerWidth},
|
|
1003
|
+
{Title: "age", Width: ageWidth},
|
|
1004
|
+
{Title: "updated", Width: updatedWidth},
|
|
1005
|
+
{Title: "created", Width: createdWidth},
|
|
1006
|
+
{Title: "chat", Width: 0},
|
|
1007
|
+
{Title: "repo", Width: repoWidth},
|
|
1008
|
+
{Title: "harness", Width: harnessWidth},
|
|
1009
|
+
{Title: "msgs", Width: messageWidth},
|
|
1010
|
+
}
|
|
1011
|
+
fixed := 0
|
|
1012
|
+
visibleColumns := 0
|
|
1013
|
+
chatIndex := 4
|
|
1014
|
+
for index, column := range columns {
|
|
1015
|
+
if column.Width <= 0 {
|
|
1016
|
+
continue
|
|
1017
|
+
}
|
|
1018
|
+
visibleColumns++
|
|
1019
|
+
if index != chatIndex {
|
|
1020
|
+
fixed += column.Width
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
padding := 2 * visibleColumns
|
|
1024
|
+
columns[chatIndex].Width = max(8, inner-fixed-padding)
|
|
1025
|
+
return columns
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
func resultTableRows(items []item, selected int) []table.Row {
|
|
1029
|
+
rows := make([]table.Row, 0, len(items))
|
|
1030
|
+
for index, row := range items {
|
|
1031
|
+
title := row.Title
|
|
1032
|
+
if title == "" {
|
|
1033
|
+
title = row.SessionKey
|
|
1034
|
+
}
|
|
1035
|
+
marker := ""
|
|
1036
|
+
if index == selected {
|
|
1037
|
+
marker = ">"
|
|
1038
|
+
}
|
|
1039
|
+
rows = append(rows, table.Row{
|
|
1040
|
+
marker,
|
|
1041
|
+
relativeTime(row.UpdatedAt),
|
|
1042
|
+
friendlyTime(row.UpdatedAt),
|
|
1043
|
+
friendlyTime(row.CreatedAt),
|
|
1044
|
+
title,
|
|
1045
|
+
shortCwd(row.Cwd),
|
|
1046
|
+
"[" + providerBadge(row.Provider) + "]",
|
|
1047
|
+
messageCountLabel(row.MessageCount),
|
|
1048
|
+
})
|
|
1049
|
+
}
|
|
1050
|
+
return rows
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
func messageCountLabel(count int) string {
|
|
1054
|
+
if count <= 0 {
|
|
1055
|
+
return ""
|
|
1056
|
+
}
|
|
1057
|
+
return fmt.Sprintf("%d", count)
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
func (m *model) currentSelection() (item, bool) {
|
|
1061
|
+
if len(m.results) == 0 {
|
|
1062
|
+
return item{}, false
|
|
1063
|
+
}
|
|
1064
|
+
index := clamp(m.resultsTable.Cursor(), 0, len(m.results)-1)
|
|
1065
|
+
m.selected = index
|
|
1066
|
+
return m.results[index], true
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
func (m *model) renderFooter(width int) string {
|
|
1070
|
+
m.helpBubble.Width = width
|
|
1071
|
+
return m.helpBubble.View(m.currentHelp())
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
func (m *model) currentHelp() keySet {
|
|
1075
|
+
if m.filterOpen {
|
|
1076
|
+
return keySet{
|
|
1077
|
+
short: []key.Binding{m.keys.AdvanceFilter, m.keys.Select, m.keys.FilterHarness, m.keys.FilterRepos, m.keys.FilterModels, m.keys.FilterRoles, m.keys.Apply},
|
|
1078
|
+
full: [][]key.Binding{
|
|
1079
|
+
{m.keys.Move, m.keys.AdvanceFilter, m.keys.Select, m.keys.Focus},
|
|
1080
|
+
{m.keys.FilterHarness, m.keys.FilterRepos, m.keys.FilterModels, m.keys.FilterRoles},
|
|
1081
|
+
{m.keys.ClearSection, m.keys.Clear, m.keys.Apply, m.keys.Quit},
|
|
1082
|
+
},
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if m.detail {
|
|
1086
|
+
return keySet{
|
|
1087
|
+
short: []key.Binding{m.keys.Back, m.keys.ExportMarkdown, m.keys.ExportText, m.keys.Move, m.keys.Quit},
|
|
1088
|
+
full: [][]key.Binding{
|
|
1089
|
+
{m.keys.Back, m.keys.Move},
|
|
1090
|
+
{m.keys.ExportMarkdown, m.keys.ExportText, m.keys.Quit},
|
|
1091
|
+
},
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
if m.focus == focusSearch {
|
|
1095
|
+
return keySet{
|
|
1096
|
+
short: []key.Binding{m.keys.Filter, m.keys.Focus, m.keys.Search, m.keys.Clear, m.keys.Quit},
|
|
1097
|
+
full: [][]key.Binding{
|
|
1098
|
+
{m.keys.Filter, m.keys.Focus, m.keys.Search, m.keys.Clear},
|
|
1099
|
+
{m.keys.Open, m.keys.Move, m.keys.Quit},
|
|
1100
|
+
},
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return keySet{
|
|
1104
|
+
short: []key.Binding{m.keys.Open, m.keys.ExportMarkdown, m.keys.ExportText, m.keys.Focus, m.keys.Search, m.keys.Quit},
|
|
1105
|
+
full: [][]key.Binding{
|
|
1106
|
+
{m.keys.Open, m.keys.Move, m.keys.Focus, m.keys.Search},
|
|
1107
|
+
{m.keys.ExportMarkdown, m.keys.ExportText, m.keys.Filter, m.keys.Clear, m.keys.Quit},
|
|
1108
|
+
},
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
func (m *model) renderBrowseActions(width int, y int) string {
|
|
1113
|
+
buttons := []actionButtonSpec{
|
|
1114
|
+
{Binding: m.keys.Open, Label: "Open", Action: actionOpen},
|
|
1115
|
+
{Binding: m.keys.ExportMarkdown, Label: "Markdown", Action: actionExportMarkdown},
|
|
1116
|
+
{Binding: m.keys.ExportText, Label: "Text", Action: actionExportText},
|
|
1117
|
+
}
|
|
1118
|
+
line, tracked := renderButtonRow(buttons, width, y)
|
|
1119
|
+
m.browseButtons = tracked
|
|
1120
|
+
return line
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
func (m *model) renderDetailActions(width int, y int) string {
|
|
1124
|
+
buttons := []actionButtonSpec{
|
|
1125
|
+
{Binding: m.keys.Back, Label: "Back", Action: actionBack},
|
|
1126
|
+
{Binding: m.keys.ExportMarkdown, Label: "Markdown", Action: actionExportMarkdown},
|
|
1127
|
+
{Binding: m.keys.ExportText, Label: "Text", Action: actionExportText},
|
|
1128
|
+
}
|
|
1129
|
+
line, tracked := renderButtonRow(buttons, width, y)
|
|
1130
|
+
m.detailButtons = tracked
|
|
1131
|
+
return line
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
type actionButtonSpec struct {
|
|
1135
|
+
Binding key.Binding
|
|
1136
|
+
Label string
|
|
1137
|
+
Action actionKind
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
func renderButtonRow(specs []actionButtonSpec, width int, y int) (string, []actionButton) {
|
|
1141
|
+
x := 0
|
|
1142
|
+
parts := []string{}
|
|
1143
|
+
buttons := []actionButton{}
|
|
1144
|
+
for _, spec := range specs {
|
|
1145
|
+
rendered := renderButton(spec.Binding, spec.Label)
|
|
1146
|
+
visibleWidth := lipgloss.Width(rendered)
|
|
1147
|
+
if x > 0 {
|
|
1148
|
+
spacer := " "
|
|
1149
|
+
parts = append(parts, baseStyle.Render(spacer))
|
|
1150
|
+
x += lipgloss.Width(spacer)
|
|
1151
|
+
}
|
|
1152
|
+
if x+visibleWidth > width {
|
|
1153
|
+
break
|
|
1154
|
+
}
|
|
1155
|
+
parts = append(parts, rendered)
|
|
1156
|
+
buttons = append(buttons, actionButton{
|
|
1157
|
+
Label: spec.Label,
|
|
1158
|
+
Action: spec.Action,
|
|
1159
|
+
X: x,
|
|
1160
|
+
Y: y,
|
|
1161
|
+
Width: visibleWidth,
|
|
1162
|
+
})
|
|
1163
|
+
x += visibleWidth
|
|
1164
|
+
}
|
|
1165
|
+
return lipgloss.JoinHorizontal(lipgloss.Top, parts...), buttons
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
func renderButton(binding key.Binding, label string) string {
|
|
1169
|
+
helpText := binding.Help()
|
|
1170
|
+
keyText := strings.ToUpper(helpText.Key)
|
|
1171
|
+
if keyText == "" {
|
|
1172
|
+
keyText = " "
|
|
1173
|
+
}
|
|
1174
|
+
text := keyText + " " + label
|
|
1175
|
+
return lipgloss.NewStyle().
|
|
1176
|
+
Foreground(bg).
|
|
1177
|
+
Background(cyan).
|
|
1178
|
+
Bold(true).
|
|
1179
|
+
Padding(0, 1).
|
|
1180
|
+
Render(text)
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
func renderFilterAction(_ key.Binding, focused bool) string {
|
|
1184
|
+
style := lipgloss.NewStyle().
|
|
1185
|
+
Foreground(bg).
|
|
1186
|
+
Background(cyan).
|
|
1187
|
+
Bold(true).
|
|
1188
|
+
Padding(0, 1)
|
|
1189
|
+
if focused {
|
|
1190
|
+
style = style.Background(yellow)
|
|
1191
|
+
}
|
|
1192
|
+
return style.Render("Ctrl-F Filters")
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
func filterControl(label string, value string, active bool, color lipgloss.Color) string {
|
|
1196
|
+
marker := mutedStyle.Background(surface).Render("·")
|
|
1197
|
+
if active {
|
|
1198
|
+
marker = lipgloss.NewStyle().Foreground(color).Background(surface).Bold(true).Render("•")
|
|
1199
|
+
}
|
|
1200
|
+
labelStyle := lipgloss.NewStyle().
|
|
1201
|
+
Foreground(color).
|
|
1202
|
+
Background(surface).
|
|
1203
|
+
Bold(true)
|
|
1204
|
+
valueStyle := lipgloss.NewStyle().
|
|
1205
|
+
Foreground(fg).
|
|
1206
|
+
Background(surface)
|
|
1207
|
+
if active {
|
|
1208
|
+
valueStyle = valueStyle.Bold(true)
|
|
1209
|
+
}
|
|
1210
|
+
content := marker + baseStyle.Background(surface).Render(" ") + labelStyle.Render(label+": ") + valueStyle.Render(value)
|
|
1211
|
+
return lipgloss.NewStyle().
|
|
1212
|
+
Foreground(fg).
|
|
1213
|
+
Background(surface).
|
|
1214
|
+
Padding(0, 1).
|
|
1215
|
+
Render(content)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
func joinFilterControls(chips []string, width int) string {
|
|
1219
|
+
lines := []string{}
|
|
1220
|
+
current := ""
|
|
1221
|
+
for _, chip := range chips {
|
|
1222
|
+
next := chip
|
|
1223
|
+
if current != "" {
|
|
1224
|
+
next = current + baseStyle.Render(" ") + chip
|
|
1225
|
+
}
|
|
1226
|
+
if current != "" && lipgloss.Width(next) > width {
|
|
1227
|
+
lines = append(lines, current)
|
|
1228
|
+
current = chip
|
|
1229
|
+
continue
|
|
1230
|
+
}
|
|
1231
|
+
current = next
|
|
1232
|
+
}
|
|
1233
|
+
if current != "" {
|
|
1234
|
+
lines = append(lines, current)
|
|
1235
|
+
}
|
|
1236
|
+
return strings.Join(lines, "\n")
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
func (m *model) filterView() string {
|
|
1240
|
+
width := max(72, m.width)
|
|
1241
|
+
title := sectionStyle.Render("Filters")
|
|
1242
|
+
step := selectedStyle.Foreground(cyan).Render(fmt.Sprintf("Step %d of %d %s", int(m.filterSection)+1, int(filterSectionCount), filterSectionTitle(m.filterSection)))
|
|
1243
|
+
summary := mutedStyle.Render(fit(m.filterSummary(), width))
|
|
1244
|
+
steps := m.renderFilterSteps(width)
|
|
1245
|
+
instructions := mutedStyle.Render(fit("Enter selects and advances. Space selects without moving. Esc applies filters.", width))
|
|
1246
|
+
rule := lipgloss.NewStyle().Foreground(border).Render(strings.Repeat("─", max(0, width-2)))
|
|
1247
|
+
top := []string{title, step, summary, steps, instructions, rule}
|
|
1248
|
+
bodyHeight := max(1, m.height-lipgloss.Height(strings.Join(top, "\n"))-2)
|
|
1249
|
+
lines := append(top, m.renderFilterOptions(width, bodyHeight))
|
|
1250
|
+
lines = append(lines, m.renderFooter(width))
|
|
1251
|
+
return baseStyle.Width(width).Height(m.height).Render(strings.Join(lines, "\n"))
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
func (m *model) renderFilterSteps(_ int) string {
|
|
1255
|
+
labels := []string{
|
|
1256
|
+
"1 Harness " + selectionCount(len(m.providers)),
|
|
1257
|
+
"2 Repos " + selectionCount(len(m.repoFilters)),
|
|
1258
|
+
"3 Models " + selectionCount(len(m.modelFilters)),
|
|
1259
|
+
"4 Roles",
|
|
1260
|
+
}
|
|
1261
|
+
parts := make([]string, 0, len(labels))
|
|
1262
|
+
for index, label := range labels {
|
|
1263
|
+
active := filterSection(index) == m.filterSection
|
|
1264
|
+
parts = append(parts, filterStep(label, active))
|
|
1265
|
+
if index < len(labels)-1 {
|
|
1266
|
+
parts = append(parts, mutedStyle.Render(" > "))
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
func (m *model) renderFilterOptions(width int, height int) string {
|
|
1273
|
+
m.configureFilterList(width, height)
|
|
1274
|
+
return m.filterList.View()
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
func (m *model) configureFilterList(width int, height int) {
|
|
1278
|
+
if width <= 0 {
|
|
1279
|
+
return
|
|
1280
|
+
}
|
|
1281
|
+
if m.filterList.Width() == 0 && m.filterList.Height() == 0 {
|
|
1282
|
+
m.filterList = newFilterList(width, height)
|
|
1283
|
+
}
|
|
1284
|
+
m.filterList.Title = filterSectionTitle(m.filterSection)
|
|
1285
|
+
m.filterList.SetSize(width, max(3, height))
|
|
1286
|
+
_ = m.filterList.SetItems(m.filterListItems())
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
func (m *model) filterListItems() []list.Item {
|
|
1290
|
+
options := m.currentFilterOptions()
|
|
1291
|
+
items := make([]list.Item, 0, len(options))
|
|
1292
|
+
for _, option := range options {
|
|
1293
|
+
description := option.Value
|
|
1294
|
+
if option.Count > 0 {
|
|
1295
|
+
description = fmt.Sprintf("%d chats %s", option.Count, option.Value)
|
|
1296
|
+
}
|
|
1297
|
+
if m.filterSection == sectionRoles {
|
|
1298
|
+
description = "controls whether transcripts include system, developer, and tool messages"
|
|
1299
|
+
}
|
|
1300
|
+
items = append(items, filterListItem{
|
|
1301
|
+
option: option,
|
|
1302
|
+
selected: m.filterOptionSelected(option),
|
|
1303
|
+
section: m.filterSection,
|
|
1304
|
+
description: description,
|
|
1305
|
+
})
|
|
1306
|
+
}
|
|
1307
|
+
return items
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
func filterStep(label string, active bool) string {
|
|
1311
|
+
style := lipgloss.NewStyle().
|
|
1312
|
+
Foreground(muted).
|
|
1313
|
+
Background(bg).
|
|
1314
|
+
Padding(0, 1).
|
|
1315
|
+
Bold(true)
|
|
1316
|
+
if active {
|
|
1317
|
+
style = style.Foreground(bg).Background(cyan)
|
|
1318
|
+
}
|
|
1319
|
+
return style.Render(label)
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
func filterSectionTitle(section filterSection) string {
|
|
1323
|
+
switch section {
|
|
1324
|
+
case sectionHarness:
|
|
1325
|
+
return "Choose harnesses"
|
|
1326
|
+
case sectionRepos:
|
|
1327
|
+
return "Choose repos"
|
|
1328
|
+
case sectionModels:
|
|
1329
|
+
return "Choose models"
|
|
1330
|
+
case sectionRoles:
|
|
1331
|
+
return "Choose roles"
|
|
1332
|
+
default:
|
|
1333
|
+
return "Choose filters"
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
func (m *model) detailView() string {
|
|
1338
|
+
width := max(72, m.width)
|
|
1339
|
+
row := m.currentDetailItem()
|
|
1340
|
+
badge := "[" + providerLabel(row.Provider) + "]"
|
|
1341
|
+
titleWidth := max(10, width-lipgloss.Width(badge)-1)
|
|
1342
|
+
header := providerStyle(row.Provider, false).Render(badge) + " " + sectionStyle.Render(fit(row.Title, titleWidth))
|
|
1343
|
+
meta := mutedStyle.Render(fit(friendlyTime(row.Timestamp)+" "+shortCwd(row.Cwd)+" "+modelSummary(row.Models), width))
|
|
1344
|
+
actions := m.renderDetailActions(width, 2)
|
|
1345
|
+
help := m.renderFooter(width)
|
|
1346
|
+
status := m.renderDetailStatus(width)
|
|
1347
|
+
rule := lipgloss.NewStyle().Foreground(border).Render(strings.Repeat("─", max(0, width-2)))
|
|
1348
|
+
top := []string{header, meta, actions, help}
|
|
1349
|
+
if status != "" {
|
|
1350
|
+
top = append(top, status)
|
|
1351
|
+
}
|
|
1352
|
+
top = append(top, rule)
|
|
1353
|
+
|
|
1354
|
+
m.transcriptView.Width = max(20, width-2)
|
|
1355
|
+
m.transcriptView.Height = max(1, m.height-len(top)-1)
|
|
1356
|
+
return baseStyle.Width(width).Height(m.height).Render(strings.Join(top, "\n") + "\n" + m.transcriptView.View())
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
func (m *model) renderDetailStatus(width int) string {
|
|
1360
|
+
if m.err != nil {
|
|
1361
|
+
return lipgloss.NewStyle().Foreground(red).Background(bg).Render(fit(m.err.Error(), width))
|
|
1362
|
+
}
|
|
1363
|
+
if m.notice != "" {
|
|
1364
|
+
return mutedStyle.Render(fit(m.notice, width))
|
|
1365
|
+
}
|
|
1366
|
+
return ""
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
func (m *model) move(delta int) {
|
|
1370
|
+
if len(m.results) == 0 {
|
|
1371
|
+
m.selected = 0
|
|
1372
|
+
return
|
|
1373
|
+
}
|
|
1374
|
+
if delta < 0 {
|
|
1375
|
+
m.resultsTable.MoveUp(-delta)
|
|
1376
|
+
} else {
|
|
1377
|
+
m.resultsTable.MoveDown(delta)
|
|
1378
|
+
}
|
|
1379
|
+
m.selected = clamp(m.resultsTable.Cursor(), 0, len(m.results)-1)
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
func (m *model) openDetail() {
|
|
1383
|
+
if len(m.results) == 0 {
|
|
1384
|
+
return
|
|
1385
|
+
}
|
|
1386
|
+
row, ok := m.currentSelection()
|
|
1387
|
+
if !ok {
|
|
1388
|
+
return
|
|
1389
|
+
}
|
|
1390
|
+
content, err := loadTranscript(m.db, row.SessionKey, m.allRoles, max(40, m.width-4))
|
|
1391
|
+
if err != nil {
|
|
1392
|
+
m.err = err
|
|
1393
|
+
return
|
|
1394
|
+
}
|
|
1395
|
+
m.detailItem = row
|
|
1396
|
+
m.notice = ""
|
|
1397
|
+
m.transcriptView.SetContent(content)
|
|
1398
|
+
m.transcriptView.GotoTop()
|
|
1399
|
+
m.detail = true
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
func (m *model) exportSelected(format string) {
|
|
1403
|
+
m.err = nil
|
|
1404
|
+
row, ok := m.exportTarget()
|
|
1405
|
+
if !ok {
|
|
1406
|
+
m.notice = "no chat selected"
|
|
1407
|
+
return
|
|
1408
|
+
}
|
|
1409
|
+
messages, err := loadTranscriptMessages(m.db, row.SessionKey, m.allRoles)
|
|
1410
|
+
if err != nil {
|
|
1411
|
+
m.err = err
|
|
1412
|
+
return
|
|
1413
|
+
}
|
|
1414
|
+
if len(messages) == 0 {
|
|
1415
|
+
m.notice = "selected chat has no exportable messages"
|
|
1416
|
+
return
|
|
1417
|
+
}
|
|
1418
|
+
content := formatTranscriptExport(row, messages, format)
|
|
1419
|
+
path, err := exportFilePath(row, format)
|
|
1420
|
+
if err != nil {
|
|
1421
|
+
m.err = err
|
|
1422
|
+
return
|
|
1423
|
+
}
|
|
1424
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
1425
|
+
m.err = err
|
|
1426
|
+
return
|
|
1427
|
+
}
|
|
1428
|
+
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
1429
|
+
m.err = err
|
|
1430
|
+
return
|
|
1431
|
+
}
|
|
1432
|
+
m.notice = "exported " + path
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
func (m *model) exportTarget() (item, bool) {
|
|
1436
|
+
if m.detail && m.detailItem.SessionKey != "" {
|
|
1437
|
+
return m.detailItem, true
|
|
1438
|
+
}
|
|
1439
|
+
if len(m.results) == 0 {
|
|
1440
|
+
return item{}, false
|
|
1441
|
+
}
|
|
1442
|
+
return m.currentSelection()
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
func (m *model) currentDetailItem() item {
|
|
1446
|
+
if m.detailItem.SessionKey != "" {
|
|
1447
|
+
return m.detailItem
|
|
1448
|
+
}
|
|
1449
|
+
if len(m.results) == 0 {
|
|
1450
|
+
return item{}
|
|
1451
|
+
}
|
|
1452
|
+
row, _ := m.currentSelection()
|
|
1453
|
+
return row
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
func (m *model) openFilters() {
|
|
1457
|
+
m.filterOpen = true
|
|
1458
|
+
m.setFocus(focusFilters)
|
|
1459
|
+
m.configureFilterList(max(72, m.width), max(8, m.height-8))
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
func (m *model) closeFilters() {
|
|
1463
|
+
m.filterOpen = false
|
|
1464
|
+
m.setFocus(focusFilters)
|
|
1465
|
+
m.resetPosition()
|
|
1466
|
+
m.reload()
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
func (m *model) handleFilterKey(msg tea.KeyMsg) tea.Cmd {
|
|
1470
|
+
m.configureFilterList(max(72, m.width), max(8, m.height-8))
|
|
1471
|
+
switch msg.String() {
|
|
1472
|
+
case "ctrl+c":
|
|
1473
|
+
return tea.Quit
|
|
1474
|
+
case "esc", "left":
|
|
1475
|
+
m.closeFilters()
|
|
1476
|
+
case "tab", "right":
|
|
1477
|
+
m.advanceFilterSection()
|
|
1478
|
+
case "shift+tab":
|
|
1479
|
+
m.previousFilterSection()
|
|
1480
|
+
case "up", "k", "down", "j", "pgup", "pgdown":
|
|
1481
|
+
var cmd tea.Cmd
|
|
1482
|
+
m.filterList, cmd = m.filterList.Update(msg)
|
|
1483
|
+
return cmd
|
|
1484
|
+
case " ":
|
|
1485
|
+
m.toggleCurrentFilter()
|
|
1486
|
+
case "enter":
|
|
1487
|
+
m.toggleCurrentFilter()
|
|
1488
|
+
m.advanceFilterSection()
|
|
1489
|
+
case "c":
|
|
1490
|
+
m.clearCurrentFilterSection()
|
|
1491
|
+
case "ctrl+u":
|
|
1492
|
+
m.clearFilters()
|
|
1493
|
+
m.configureFilterList(max(72, m.width), max(8, m.height-8))
|
|
1494
|
+
case "1", "h":
|
|
1495
|
+
m.setFilterSection(sectionHarness)
|
|
1496
|
+
case "2", "r":
|
|
1497
|
+
m.setFilterSection(sectionRepos)
|
|
1498
|
+
case "3", "m":
|
|
1499
|
+
m.setFilterSection(sectionModels)
|
|
1500
|
+
case "4", "o":
|
|
1501
|
+
m.setFilterSection(sectionRoles)
|
|
1502
|
+
default:
|
|
1503
|
+
return nil
|
|
1504
|
+
}
|
|
1505
|
+
return nil
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
func (m *model) handleFilterClick(msg tea.MouseMsg) {
|
|
1509
|
+
width := max(72, m.width)
|
|
1510
|
+
sectionWidth := max(18, width/int(filterSectionCount))
|
|
1511
|
+
if msg.Y == 3 {
|
|
1512
|
+
clickedSection := clamp(msg.X/sectionWidth, 0, int(filterSectionCount)-1)
|
|
1513
|
+
m.setFilterSection(filterSection(clickedSection))
|
|
1514
|
+
return
|
|
1515
|
+
}
|
|
1516
|
+
if msg.Y < 6 {
|
|
1517
|
+
return
|
|
1518
|
+
}
|
|
1519
|
+
listTop := 8
|
|
1520
|
+
visibleIndex := max(0, msg.Y-listTop) / 2
|
|
1521
|
+
index := m.filterList.Paginator.Page*m.filterList.Paginator.PerPage + visibleIndex
|
|
1522
|
+
options := m.currentFilterOptions()
|
|
1523
|
+
if index < len(options) {
|
|
1524
|
+
m.toggleFilterOption(options[index])
|
|
1525
|
+
m.configureFilterList(width, max(8, m.height-8))
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
func (m *model) setFilterSection(section filterSection) {
|
|
1530
|
+
m.filterSection = section
|
|
1531
|
+
m.filterList.GoToStart()
|
|
1532
|
+
m.configureFilterList(max(72, m.width), max(8, m.height-8))
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
func (m *model) advanceFilterSection() {
|
|
1536
|
+
m.setFilterSection((m.filterSection + 1) % filterSectionCount)
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
func (m *model) previousFilterSection() {
|
|
1540
|
+
m.setFilterSection((m.filterSection + filterSectionCount - 1) % filterSectionCount)
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
func (m *model) toggleCurrentFilter() {
|
|
1544
|
+
selected, ok := m.filterList.SelectedItem().(filterListItem)
|
|
1545
|
+
if !ok {
|
|
1546
|
+
return
|
|
1547
|
+
}
|
|
1548
|
+
m.toggleFilterOption(selected.option)
|
|
1549
|
+
m.configureFilterList(max(72, m.width), max(8, m.height-8))
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
func (m *model) toggleFilterOption(selected filterOption) {
|
|
1553
|
+
switch m.filterSection {
|
|
1554
|
+
case sectionHarness:
|
|
1555
|
+
toggleSetValue(m.providers, selected.Value)
|
|
1556
|
+
case sectionRepos:
|
|
1557
|
+
toggleSetValue(m.repoFilters, selected.Value)
|
|
1558
|
+
case sectionModels:
|
|
1559
|
+
toggleSetValue(m.modelFilters, selected.Value)
|
|
1560
|
+
case sectionRoles:
|
|
1561
|
+
m.allRoles = selected.Value == "all"
|
|
1562
|
+
}
|
|
1563
|
+
m.notice = "filters updated"
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
func (m *model) clearCurrentFilterSection() {
|
|
1567
|
+
switch m.filterSection {
|
|
1568
|
+
case sectionHarness:
|
|
1569
|
+
clearSet(m.providers)
|
|
1570
|
+
case sectionRepos:
|
|
1571
|
+
clearSet(m.repoFilters)
|
|
1572
|
+
case sectionModels:
|
|
1573
|
+
clearSet(m.modelFilters)
|
|
1574
|
+
case sectionRoles:
|
|
1575
|
+
m.allRoles = true
|
|
1576
|
+
}
|
|
1577
|
+
m.notice = "filter section cleared"
|
|
1578
|
+
m.configureFilterList(max(72, m.width), max(8, m.height-8))
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
func (m *model) clearFilters() {
|
|
1582
|
+
clearSet(m.providers)
|
|
1583
|
+
clearSet(m.repoFilters)
|
|
1584
|
+
clearSet(m.modelFilters)
|
|
1585
|
+
m.allRoles = true
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
func (m *model) currentFilterOptions() []filterOption {
|
|
1589
|
+
switch m.filterSection {
|
|
1590
|
+
case sectionHarness:
|
|
1591
|
+
return m.providerOptions
|
|
1592
|
+
case sectionRepos:
|
|
1593
|
+
return m.repoOptions
|
|
1594
|
+
case sectionModels:
|
|
1595
|
+
return m.modelOptions
|
|
1596
|
+
case sectionRoles:
|
|
1597
|
+
return []filterOption{
|
|
1598
|
+
{Value: "all", Label: "all roles"},
|
|
1599
|
+
{Value: "conversation", Label: "user + assistant"},
|
|
1600
|
+
}
|
|
1601
|
+
default:
|
|
1602
|
+
return nil
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
func (m *model) filterOptionSelected(option filterOption) bool {
|
|
1607
|
+
switch m.filterSection {
|
|
1608
|
+
case sectionHarness:
|
|
1609
|
+
return m.providers[option.Value]
|
|
1610
|
+
case sectionRepos:
|
|
1611
|
+
return m.repoFilters[option.Value]
|
|
1612
|
+
case sectionModels:
|
|
1613
|
+
return m.modelFilters[option.Value]
|
|
1614
|
+
case sectionRoles:
|
|
1615
|
+
if option.Value == "all" {
|
|
1616
|
+
return m.allRoles
|
|
1617
|
+
}
|
|
1618
|
+
return !m.allRoles
|
|
1619
|
+
default:
|
|
1620
|
+
return false
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
func (m *model) hasActiveFilters() bool {
|
|
1625
|
+
return len(m.providers) > 0 || len(m.repoFilters) > 0 || len(m.modelFilters) > 0 || !m.allRoles
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
func (m *model) providerSummary() string {
|
|
1629
|
+
return summarizeSet(m.providers, m.providerOptions, "all harnesses", "harnesses")
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
func (m *model) compactProviderSummary() string {
|
|
1633
|
+
return compactSetSummary(m.providers, m.providerOptions, "all")
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
func (m *model) repoSummary() string {
|
|
1637
|
+
return summarizeSet(m.repoFilters, m.repoOptions, "any repo", "repos")
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
func (m *model) compactRepoSummary() string {
|
|
1641
|
+
return compactSetSummary(m.repoFilters, m.repoOptions, "any")
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
func (m *model) modelSummary() string {
|
|
1645
|
+
return summarizeSet(m.modelFilters, m.modelOptions, "any model", "models")
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
func (m *model) compactModelSummary() string {
|
|
1649
|
+
return compactSetSummary(m.modelFilters, m.modelOptions, "any")
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
func (m *model) filterSummary() string {
|
|
1653
|
+
roles := "all roles"
|
|
1654
|
+
if !m.allRoles {
|
|
1655
|
+
roles = "user+assistant"
|
|
1656
|
+
}
|
|
1657
|
+
return strings.Join([]string{
|
|
1658
|
+
"harness " + m.providerSummary(),
|
|
1659
|
+
"repo " + m.repoSummary(),
|
|
1660
|
+
"model " + m.modelSummary(),
|
|
1661
|
+
"roles " + roles,
|
|
1662
|
+
}, " ")
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
func summarizeSet(selected map[string]bool, options []filterOption, empty string, plural string) string {
|
|
1666
|
+
if len(selected) == 0 {
|
|
1667
|
+
return empty
|
|
1668
|
+
}
|
|
1669
|
+
labels := selectedLabels(selected, options)
|
|
1670
|
+
if len(labels) == 1 {
|
|
1671
|
+
return labels[0]
|
|
1672
|
+
}
|
|
1673
|
+
if len(labels) == 2 {
|
|
1674
|
+
return labels[0] + " + " + labels[1]
|
|
1675
|
+
}
|
|
1676
|
+
return fmt.Sprintf("%d %s", len(labels), plural)
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
func compactSetSummary(selected map[string]bool, options []filterOption, empty string) string {
|
|
1680
|
+
if len(selected) == 0 {
|
|
1681
|
+
return empty
|
|
1682
|
+
}
|
|
1683
|
+
labels := selectedLabels(selected, options)
|
|
1684
|
+
if len(labels) == 1 {
|
|
1685
|
+
return fit(labels[0], 24)
|
|
1686
|
+
}
|
|
1687
|
+
return fmt.Sprintf("%d selected", len(labels))
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
func selectedLabels(selected map[string]bool, options []filterOption) []string {
|
|
1691
|
+
labels := []string{}
|
|
1692
|
+
for _, option := range options {
|
|
1693
|
+
if selected[option.Value] {
|
|
1694
|
+
labels = append(labels, option.Label)
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
if len(labels) > 0 {
|
|
1698
|
+
return labels
|
|
1699
|
+
}
|
|
1700
|
+
for value := range selected {
|
|
1701
|
+
labels = append(labels, value)
|
|
1702
|
+
}
|
|
1703
|
+
sort.Strings(labels)
|
|
1704
|
+
return labels
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
func selectionCount(count int) string {
|
|
1708
|
+
if count == 0 {
|
|
1709
|
+
return "(all)"
|
|
1710
|
+
}
|
|
1711
|
+
return fmt.Sprintf("(%d)", count)
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
func selectedSetValues(set map[string]bool) []string {
|
|
1715
|
+
values := make([]string, 0, len(set))
|
|
1716
|
+
for value := range set {
|
|
1717
|
+
values = append(values, value)
|
|
1718
|
+
}
|
|
1719
|
+
sort.Strings(values)
|
|
1720
|
+
return values
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
func toggleSetValue(set map[string]bool, value string) {
|
|
1724
|
+
if set[value] {
|
|
1725
|
+
delete(set, value)
|
|
1726
|
+
return
|
|
1727
|
+
}
|
|
1728
|
+
set[value] = true
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
func clearSet(set map[string]bool) {
|
|
1732
|
+
for value := range set {
|
|
1733
|
+
delete(set, value)
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
func (m *model) reload() {
|
|
1738
|
+
m.err = nil
|
|
1739
|
+
var rows []item
|
|
1740
|
+
var err error
|
|
1741
|
+
query := strings.TrimSpace(m.search.Value())
|
|
1742
|
+
providers := selectedSetValues(m.providers)
|
|
1743
|
+
models := selectedSetValues(m.modelFilters)
|
|
1744
|
+
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)
|
|
1749
|
+
}
|
|
1750
|
+
if err != nil {
|
|
1751
|
+
m.err = err
|
|
1752
|
+
m.results = nil
|
|
1753
|
+
return
|
|
1754
|
+
}
|
|
1755
|
+
m.results = rows
|
|
1756
|
+
if m.selected >= len(m.results) {
|
|
1757
|
+
m.selected = max(0, len(m.results)-1)
|
|
1758
|
+
}
|
|
1759
|
+
m.configureResultsTable(max(72, m.width), max(4, m.height/2))
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
func (m *model) resetPosition() {
|
|
1763
|
+
m.selected = 0
|
|
1764
|
+
m.resultsTable.SetCursor(0)
|
|
1765
|
+
m.detail = false
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
func (m *model) detailHeight() int {
|
|
1769
|
+
return max(1, m.height-5)
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
func loadProviderOptions(db *sql.DB) ([]filterOption, error) {
|
|
1773
|
+
rows, err := db.Query(`
|
|
1774
|
+
SELECT s.provider, COUNT(*) AS sessions
|
|
1775
|
+
FROM sessions s
|
|
1776
|
+
WHERE ` + rootSessionFilter("s") + `
|
|
1777
|
+
AND ` + displayableSessionFilter("s") + `
|
|
1778
|
+
GROUP BY s.provider
|
|
1779
|
+
ORDER BY CASE s.provider WHEN 'codex' THEN 0 WHEN 'claude' THEN 1 WHEN 'pi' THEN 2 ELSE 3 END, s.provider
|
|
1780
|
+
`)
|
|
1781
|
+
if err != nil {
|
|
1782
|
+
return nil, err
|
|
1783
|
+
}
|
|
1784
|
+
defer rows.Close()
|
|
1785
|
+
|
|
1786
|
+
options := []filterOption{}
|
|
1787
|
+
for rows.Next() {
|
|
1788
|
+
var provider string
|
|
1789
|
+
var count int
|
|
1790
|
+
if err := rows.Scan(&provider, &count); err != nil {
|
|
1791
|
+
return nil, err
|
|
1792
|
+
}
|
|
1793
|
+
options = append(options, filterOption{
|
|
1794
|
+
Value: provider,
|
|
1795
|
+
Label: providerLabel(provider),
|
|
1796
|
+
Count: count,
|
|
1797
|
+
})
|
|
1798
|
+
}
|
|
1799
|
+
return options, rows.Err()
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
func loadRepoOptions(db *sql.DB) ([]filterOption, error) {
|
|
1803
|
+
rows, err := db.Query(`
|
|
1804
|
+
SELECT COALESCE(s.cwd, ''), COUNT(*) AS sessions
|
|
1805
|
+
FROM sessions s
|
|
1806
|
+
WHERE COALESCE(s.cwd, '') != ''
|
|
1807
|
+
AND ` + rootSessionFilter("s") + `
|
|
1808
|
+
AND ` + displayableSessionFilter("s") + `
|
|
1809
|
+
GROUP BY s.cwd
|
|
1810
|
+
ORDER BY sessions DESC, MAX(COALESCE(s.updated_at, s.started_at, s.imported_at)) DESC, s.cwd ASC
|
|
1811
|
+
`)
|
|
1812
|
+
if err != nil {
|
|
1813
|
+
return nil, err
|
|
1814
|
+
}
|
|
1815
|
+
defer rows.Close()
|
|
1816
|
+
|
|
1817
|
+
options := []filterOption{}
|
|
1818
|
+
for rows.Next() {
|
|
1819
|
+
var cwd string
|
|
1820
|
+
var count int
|
|
1821
|
+
if err := rows.Scan(&cwd, &count); err != nil {
|
|
1822
|
+
return nil, err
|
|
1823
|
+
}
|
|
1824
|
+
options = append(options, filterOption{
|
|
1825
|
+
Value: cwd,
|
|
1826
|
+
Label: shortCwd(cwd),
|
|
1827
|
+
Count: count,
|
|
1828
|
+
})
|
|
1829
|
+
}
|
|
1830
|
+
return options, rows.Err()
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
func loadModelOptions(db *sql.DB) ([]filterOption, error) {
|
|
1834
|
+
rows, err := db.Query(`
|
|
1835
|
+
SELECT sm.model, COUNT(DISTINCT sm.session_key) AS sessions
|
|
1836
|
+
FROM session_models sm
|
|
1837
|
+
JOIN sessions s ON s.session_key = sm.session_key
|
|
1838
|
+
WHERE sm.model != ''
|
|
1839
|
+
AND ` + rootSessionFilter("s") + `
|
|
1840
|
+
AND ` + displayableSessionFilter("s") + `
|
|
1841
|
+
GROUP BY sm.model
|
|
1842
|
+
ORDER BY sessions DESC, sm.model ASC
|
|
1843
|
+
`)
|
|
1844
|
+
if err != nil {
|
|
1845
|
+
return nil, err
|
|
1846
|
+
}
|
|
1847
|
+
defer rows.Close()
|
|
1848
|
+
|
|
1849
|
+
options := []filterOption{}
|
|
1850
|
+
for rows.Next() {
|
|
1851
|
+
var name string
|
|
1852
|
+
var count int
|
|
1853
|
+
if err := rows.Scan(&name, &count); err != nil {
|
|
1854
|
+
return nil, err
|
|
1855
|
+
}
|
|
1856
|
+
options = append(options, filterOption{Value: name, Label: name, Count: count})
|
|
1857
|
+
}
|
|
1858
|
+
return options, rows.Err()
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
func listSessions(db *sql.DB, providers []string, models []string, repos []string) ([]item, error) {
|
|
1862
|
+
filters, args := sessionFilters("s", providers, models, repos)
|
|
1863
|
+
filters = append(filters, rootSessionFilter("s"))
|
|
1864
|
+
filters = append(filters, displayableSessionFilter("s"))
|
|
1865
|
+
where := ""
|
|
1866
|
+
if len(filters) > 0 {
|
|
1867
|
+
where = "WHERE " + strings.Join(filters, " AND ")
|
|
1868
|
+
}
|
|
1869
|
+
rows, err := db.Query(`
|
|
1870
|
+
SELECT s.session_key, s.provider, `+displayTitleExpr("s")+`,
|
|
1871
|
+
COALESCE(s.started_at, ''),
|
|
1872
|
+
COALESCE(s.updated_at, s.started_at, ''),
|
|
1873
|
+
COALESCE(s.cwd, ''), COALESCE(s.message_count, 0),
|
|
1874
|
+
COALESCE((SELECT GROUP_CONCAT(model) FROM (
|
|
1875
|
+
SELECT DISTINCT sm.model FROM session_models sm
|
|
1876
|
+
WHERE sm.session_key = s.session_key
|
|
1877
|
+
ORDER BY sm.model
|
|
1878
|
+
)), '')
|
|
1879
|
+
FROM sessions s
|
|
1880
|
+
`+where+`
|
|
1881
|
+
ORDER BY COALESCE(s.updated_at, s.started_at, s.imported_at) DESC
|
|
1882
|
+
`, args...)
|
|
1883
|
+
if err != nil {
|
|
1884
|
+
return nil, err
|
|
1885
|
+
}
|
|
1886
|
+
defer rows.Close()
|
|
1887
|
+
|
|
1888
|
+
items := []item{}
|
|
1889
|
+
for rows.Next() {
|
|
1890
|
+
var row item
|
|
1891
|
+
if err := rows.Scan(&row.SessionKey, &row.Provider, &row.Title, &row.CreatedAt, &row.UpdatedAt, &row.Cwd, &row.MessageCount, &row.Models); err != nil {
|
|
1892
|
+
return nil, err
|
|
1893
|
+
}
|
|
1894
|
+
row.Timestamp = row.UpdatedAt
|
|
1895
|
+
row.Title = oneLine(row.Title)
|
|
1896
|
+
items = append(items, row)
|
|
1897
|
+
}
|
|
1898
|
+
return items, rows.Err()
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
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)
|
|
1903
|
+
if err == nil {
|
|
1904
|
+
return rows, nil
|
|
1905
|
+
}
|
|
1906
|
+
return searchMessagesLike(db, query, providers, models, repos, allRoles)
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
func searchMessagesFTS(db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool) ([]item, error) {
|
|
1910
|
+
args := []any{toFTSQuery(query)}
|
|
1911
|
+
filters := messageFilters("m", providers, models, repos, allRoles, &args)
|
|
1912
|
+
filters = append(filters, rootSessionFilter("s"))
|
|
1913
|
+
filters = append(filters, displayableSessionFilter("s"))
|
|
1914
|
+
where := "message_fts MATCH ?"
|
|
1915
|
+
if len(filters) > 0 {
|
|
1916
|
+
where += " AND " + strings.Join(filters, " AND ")
|
|
1917
|
+
}
|
|
1918
|
+
rows, err := db.Query(`
|
|
1919
|
+
SELECT m.session_key, m.provider, `+displayTitleExpr("s")+`,
|
|
1920
|
+
COALESCE(m.timestamp, s.updated_at, s.started_at, ''),
|
|
1921
|
+
COALESCE(s.started_at, ''),
|
|
1922
|
+
COALESCE(s.updated_at, s.started_at, ''),
|
|
1923
|
+
COALESCE(s.cwd, ''), COALESCE(s.message_count, 0),
|
|
1924
|
+
COALESCE((SELECT GROUP_CONCAT(model) FROM (
|
|
1925
|
+
SELECT DISTINCT sm.model FROM session_models sm
|
|
1926
|
+
WHERE sm.session_key = m.session_key
|
|
1927
|
+
ORDER BY sm.model
|
|
1928
|
+
)), ''),
|
|
1929
|
+
COALESCE(snippet(message_fts, 0, '[', ']', '...', 18), m.text),
|
|
1930
|
+
m.ordinal
|
|
1931
|
+
FROM message_fts
|
|
1932
|
+
JOIN messages m ON m.message_key = message_fts.message_key
|
|
1933
|
+
JOIN sessions s ON s.session_key = m.session_key
|
|
1934
|
+
WHERE `+where+`
|
|
1935
|
+
ORDER BY bm25(message_fts), COALESCE(m.timestamp, s.updated_at) DESC
|
|
1936
|
+
`, args...)
|
|
1937
|
+
if err != nil {
|
|
1938
|
+
return nil, err
|
|
1939
|
+
}
|
|
1940
|
+
defer rows.Close()
|
|
1941
|
+
items, err := scanItems(rows, true)
|
|
1942
|
+
if err != nil {
|
|
1943
|
+
return nil, err
|
|
1944
|
+
}
|
|
1945
|
+
return dedupeItems(items), nil
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
func searchMessagesLike(db *sql.DB, query string, providers []string, models []string, repos []string, allRoles bool) ([]item, error) {
|
|
1949
|
+
args := []any{"%" + query + "%"}
|
|
1950
|
+
filters := []string{"m.text LIKE ?"}
|
|
1951
|
+
filters = append(filters, messageFilters("m", providers, models, repos, allRoles, &args)...)
|
|
1952
|
+
filters = append(filters, rootSessionFilter("s"))
|
|
1953
|
+
filters = append(filters, displayableSessionFilter("s"))
|
|
1954
|
+
|
|
1955
|
+
rows, err := db.Query(`
|
|
1956
|
+
SELECT m.session_key, m.provider, `+displayTitleExpr("s")+`,
|
|
1957
|
+
COALESCE(m.timestamp, s.updated_at, s.started_at, ''),
|
|
1958
|
+
COALESCE(s.started_at, ''),
|
|
1959
|
+
COALESCE(s.updated_at, s.started_at, ''),
|
|
1960
|
+
COALESCE(s.cwd, ''), COALESCE(s.message_count, 0),
|
|
1961
|
+
COALESCE((SELECT GROUP_CONCAT(model) FROM (
|
|
1962
|
+
SELECT DISTINCT sm.model FROM session_models sm
|
|
1963
|
+
WHERE sm.session_key = m.session_key
|
|
1964
|
+
ORDER BY sm.model
|
|
1965
|
+
)), ''),
|
|
1966
|
+
m.text,
|
|
1967
|
+
m.ordinal
|
|
1968
|
+
FROM messages m
|
|
1969
|
+
JOIN sessions s ON s.session_key = m.session_key
|
|
1970
|
+
WHERE `+strings.Join(filters, " AND ")+`
|
|
1971
|
+
ORDER BY COALESCE(m.timestamp, s.updated_at) DESC
|
|
1972
|
+
`, args...)
|
|
1973
|
+
if err != nil {
|
|
1974
|
+
return nil, err
|
|
1975
|
+
}
|
|
1976
|
+
defer rows.Close()
|
|
1977
|
+
items, err := scanItems(rows, true)
|
|
1978
|
+
if err != nil {
|
|
1979
|
+
return nil, err
|
|
1980
|
+
}
|
|
1981
|
+
return dedupeItems(items), nil
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
func scanItems(rows *sql.Rows, isMatch bool) ([]item, error) {
|
|
1985
|
+
items := []item{}
|
|
1986
|
+
for rows.Next() {
|
|
1987
|
+
var row item
|
|
1988
|
+
if err := rows.Scan(&row.SessionKey, &row.Provider, &row.Title, &row.Timestamp, &row.CreatedAt, &row.UpdatedAt, &row.Cwd, &row.MessageCount, &row.Models, &row.Preview, &row.Ordinal); err != nil {
|
|
1989
|
+
return nil, err
|
|
1990
|
+
}
|
|
1991
|
+
if row.UpdatedAt == "" {
|
|
1992
|
+
row.UpdatedAt = row.Timestamp
|
|
1993
|
+
}
|
|
1994
|
+
row.IsMatch = isMatch
|
|
1995
|
+
row.Title = oneLine(row.Title)
|
|
1996
|
+
row.Preview = oneLine(row.Preview)
|
|
1997
|
+
items = append(items, row)
|
|
1998
|
+
}
|
|
1999
|
+
return items, rows.Err()
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
func dedupeItems(items []item) []item {
|
|
2003
|
+
seen := map[string]bool{}
|
|
2004
|
+
deduped := []item{}
|
|
2005
|
+
for _, row := range items {
|
|
2006
|
+
if seen[row.SessionKey] {
|
|
2007
|
+
continue
|
|
2008
|
+
}
|
|
2009
|
+
seen[row.SessionKey] = true
|
|
2010
|
+
deduped = append(deduped, row)
|
|
2011
|
+
}
|
|
2012
|
+
return deduped
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
func loadTranscript(db *sql.DB, sessionKey string, allRoles bool, width int) (string, error) {
|
|
2016
|
+
messages, err := loadTranscriptMessages(db, sessionKey, allRoles)
|
|
2017
|
+
if err != nil {
|
|
2018
|
+
return "", err
|
|
2019
|
+
}
|
|
2020
|
+
lines := []string{}
|
|
2021
|
+
for _, message := range messages {
|
|
2022
|
+
header := strings.ToUpper(message.Role) + " " + message.Timestamp
|
|
2023
|
+
if message.Model != "" {
|
|
2024
|
+
header += " " + message.Model
|
|
2025
|
+
}
|
|
2026
|
+
lines = append(lines, roleStyle(message.Role).Render(header))
|
|
2027
|
+
for _, raw := range strings.Split(message.Text, "\n") {
|
|
2028
|
+
for _, wrapped := range wrap(raw, max(24, width-4)) {
|
|
2029
|
+
lines = append(lines, baseStyle.Render(" "+wrapped))
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
lines = append(lines, "")
|
|
2033
|
+
}
|
|
2034
|
+
if len(lines) == 0 {
|
|
2035
|
+
return mutedStyle.Render("No transcript messages matched the current role filter."), nil
|
|
2036
|
+
}
|
|
2037
|
+
return strings.Join(lines, "\n"), nil
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
func loadTranscriptMessages(db *sql.DB, sessionKey string, allRoles bool) ([]transcriptMessage, error) {
|
|
2041
|
+
return loadTranscriptMessagesWithLimit(db, sessionKey, allRoles, 0)
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
func loadTranscriptPreviewMessages(db *sql.DB, sessionKey string, allRoles bool, limit int) ([]transcriptMessage, error) {
|
|
2045
|
+
args := []any{sessionKey, "developer", "system"}
|
|
2046
|
+
filters := []string{
|
|
2047
|
+
"session_key = ?",
|
|
2048
|
+
"role NOT IN (?, ?)",
|
|
2049
|
+
"NOT " + hiddenTitleExpr("text"),
|
|
2050
|
+
}
|
|
2051
|
+
if !allRoles {
|
|
2052
|
+
filters = append(filters, "role IN (?, ?)")
|
|
2053
|
+
args = append(args, "user", "assistant")
|
|
2054
|
+
}
|
|
2055
|
+
if limit > 0 {
|
|
2056
|
+
args = append(args, limit)
|
|
2057
|
+
}
|
|
2058
|
+
limitSQL := ""
|
|
2059
|
+
if limit > 0 {
|
|
2060
|
+
limitSQL = "LIMIT ?"
|
|
2061
|
+
}
|
|
2062
|
+
rows, err := db.Query(`
|
|
2063
|
+
SELECT role, COALESCE(timestamp, ''), COALESCE(model, ''), text
|
|
2064
|
+
FROM messages
|
|
2065
|
+
WHERE `+strings.Join(filters, " AND ")+`
|
|
2066
|
+
ORDER BY
|
|
2067
|
+
CASE WHEN timestamp IS NULL OR timestamp = '' THEN 1 ELSE 0 END,
|
|
2068
|
+
timestamp ASC,
|
|
2069
|
+
ordinal ASC
|
|
2070
|
+
`+limitSQL+`
|
|
2071
|
+
`, args...)
|
|
2072
|
+
if err != nil {
|
|
2073
|
+
return nil, err
|
|
2074
|
+
}
|
|
2075
|
+
defer rows.Close()
|
|
2076
|
+
|
|
2077
|
+
messages := []transcriptMessage{}
|
|
2078
|
+
for rows.Next() {
|
|
2079
|
+
var message transcriptMessage
|
|
2080
|
+
if err := rows.Scan(&message.Role, &message.Timestamp, &message.Model, &message.Text); err != nil {
|
|
2081
|
+
return nil, err
|
|
2082
|
+
}
|
|
2083
|
+
messages = append(messages, message)
|
|
2084
|
+
}
|
|
2085
|
+
return messages, rows.Err()
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
func loadTranscriptMessagesWithLimit(db *sql.DB, sessionKey string, allRoles bool, limit int) ([]transcriptMessage, error) {
|
|
2089
|
+
args := []any{sessionKey}
|
|
2090
|
+
roleSQL := ""
|
|
2091
|
+
if !allRoles {
|
|
2092
|
+
roleSQL = "AND role IN (?, ?)"
|
|
2093
|
+
args = append(args, "user", "assistant")
|
|
2094
|
+
}
|
|
2095
|
+
limitSQL := ""
|
|
2096
|
+
if limit > 0 {
|
|
2097
|
+
limitSQL = "LIMIT ?"
|
|
2098
|
+
args = append(args, limit)
|
|
2099
|
+
}
|
|
2100
|
+
rows, err := db.Query(`
|
|
2101
|
+
SELECT role, COALESCE(timestamp, ''), COALESCE(model, ''), text
|
|
2102
|
+
FROM messages
|
|
2103
|
+
WHERE session_key = ? `+roleSQL+`
|
|
2104
|
+
ORDER BY
|
|
2105
|
+
CASE WHEN timestamp IS NULL OR timestamp = '' THEN 1 ELSE 0 END,
|
|
2106
|
+
timestamp ASC,
|
|
2107
|
+
ordinal ASC
|
|
2108
|
+
`+limitSQL+`
|
|
2109
|
+
`, args...)
|
|
2110
|
+
if err != nil {
|
|
2111
|
+
return nil, err
|
|
2112
|
+
}
|
|
2113
|
+
defer rows.Close()
|
|
2114
|
+
|
|
2115
|
+
messages := []transcriptMessage{}
|
|
2116
|
+
for rows.Next() {
|
|
2117
|
+
var message transcriptMessage
|
|
2118
|
+
if err := rows.Scan(&message.Role, &message.Timestamp, &message.Model, &message.Text); err != nil {
|
|
2119
|
+
return nil, err
|
|
2120
|
+
}
|
|
2121
|
+
messages = append(messages, message)
|
|
2122
|
+
}
|
|
2123
|
+
return messages, rows.Err()
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
func formatTranscriptExport(row item, messages []transcriptMessage, format string) string {
|
|
2127
|
+
if format == "md" {
|
|
2128
|
+
return formatTranscriptMarkdown(row, messages)
|
|
2129
|
+
}
|
|
2130
|
+
return formatTranscriptText(row, messages)
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
func formatTranscriptMarkdown(row item, messages []transcriptMessage) string {
|
|
2134
|
+
var b strings.Builder
|
|
2135
|
+
title := row.Title
|
|
2136
|
+
if title == "" {
|
|
2137
|
+
title = row.SessionKey
|
|
2138
|
+
}
|
|
2139
|
+
b.WriteString("# " + title + "\n\n")
|
|
2140
|
+
writeMarkdownMeta(&b, "Harness", providerLabel(row.Provider))
|
|
2141
|
+
writeMarkdownMeta(&b, "Session", row.SessionKey)
|
|
2142
|
+
writeMarkdownMeta(&b, "Date", friendlyTime(row.Timestamp))
|
|
2143
|
+
writeMarkdownMeta(&b, "Repo", row.Cwd)
|
|
2144
|
+
writeMarkdownMeta(&b, "Models", row.Models)
|
|
2145
|
+
b.WriteString("\n## Transcript\n\n")
|
|
2146
|
+
for _, message := range messages {
|
|
2147
|
+
heading := strings.ToUpper(message.Role)
|
|
2148
|
+
b.WriteString("### " + heading + "\n\n")
|
|
2149
|
+
meta := strings.TrimSpace(strings.Join(nonEmpty([]string{message.Timestamp, message.Model}), " "))
|
|
2150
|
+
if meta != "" {
|
|
2151
|
+
b.WriteString("_" + escapeMarkdown(meta) + "_\n\n")
|
|
2152
|
+
}
|
|
2153
|
+
b.WriteString(strings.TrimRight(message.Text, "\n") + "\n\n")
|
|
2154
|
+
}
|
|
2155
|
+
return b.String()
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
func writeMarkdownMeta(b *strings.Builder, label string, value string) {
|
|
2159
|
+
if strings.TrimSpace(value) == "" {
|
|
2160
|
+
return
|
|
2161
|
+
}
|
|
2162
|
+
b.WriteString("- **" + label + ":** " + escapeMarkdown(value) + "\n")
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
func formatTranscriptText(row item, messages []transcriptMessage) string {
|
|
2166
|
+
var b strings.Builder
|
|
2167
|
+
title := row.Title
|
|
2168
|
+
if title == "" {
|
|
2169
|
+
title = row.SessionKey
|
|
2170
|
+
}
|
|
2171
|
+
b.WriteString(title + "\n")
|
|
2172
|
+
b.WriteString(strings.Repeat("=", len([]rune(title))) + "\n\n")
|
|
2173
|
+
writeTextMeta(&b, "Harness", providerLabel(row.Provider))
|
|
2174
|
+
writeTextMeta(&b, "Session", row.SessionKey)
|
|
2175
|
+
writeTextMeta(&b, "Date", friendlyTime(row.Timestamp))
|
|
2176
|
+
writeTextMeta(&b, "Repo", row.Cwd)
|
|
2177
|
+
writeTextMeta(&b, "Models", row.Models)
|
|
2178
|
+
b.WriteString("\nTranscript\n\n")
|
|
2179
|
+
for _, message := range messages {
|
|
2180
|
+
headerParts := nonEmpty([]string{strings.ToUpper(message.Role), message.Timestamp, message.Model})
|
|
2181
|
+
b.WriteString(strings.Join(headerParts, " ") + "\n")
|
|
2182
|
+
b.WriteString(strings.TrimRight(message.Text, "\n") + "\n\n")
|
|
2183
|
+
}
|
|
2184
|
+
return b.String()
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
func writeTextMeta(b *strings.Builder, label string, value string) {
|
|
2188
|
+
if strings.TrimSpace(value) == "" {
|
|
2189
|
+
return
|
|
2190
|
+
}
|
|
2191
|
+
b.WriteString(label + ": " + value + "\n")
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
func exportFilePath(row item, format string) (string, error) {
|
|
2195
|
+
ext := "txt"
|
|
2196
|
+
if format == "md" {
|
|
2197
|
+
ext = "md"
|
|
2198
|
+
}
|
|
2199
|
+
home, err := os.UserHomeDir()
|
|
2200
|
+
if err != nil {
|
|
2201
|
+
return "", err
|
|
2202
|
+
}
|
|
2203
|
+
dir := filepath.Join(home, "Downloads", "gora-exports")
|
|
2204
|
+
baseParts := []string{
|
|
2205
|
+
safeFilenamePart(row.Provider),
|
|
2206
|
+
safeFilenamePart(shortSessionKey(row.SessionKey)),
|
|
2207
|
+
safeFilenamePart(row.Title),
|
|
2208
|
+
}
|
|
2209
|
+
base := strings.Join(nonEmpty(baseParts), "-")
|
|
2210
|
+
if base == "" {
|
|
2211
|
+
base = "chat"
|
|
2212
|
+
}
|
|
2213
|
+
return uniqueFilePath(filepath.Join(dir, fitFilename(base, 120)+"."+ext)), nil
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
func uniqueFilePath(path string) string {
|
|
2217
|
+
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
|
2218
|
+
return path
|
|
2219
|
+
}
|
|
2220
|
+
ext := filepath.Ext(path)
|
|
2221
|
+
stem := strings.TrimSuffix(path, ext)
|
|
2222
|
+
for index := 2; ; index++ {
|
|
2223
|
+
candidate := fmt.Sprintf("%s-%d%s", stem, index, ext)
|
|
2224
|
+
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
|
2225
|
+
return candidate
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
func shortSessionKey(value string) string {
|
|
2231
|
+
value = strings.TrimSpace(value)
|
|
2232
|
+
if value == "" {
|
|
2233
|
+
return ""
|
|
2234
|
+
}
|
|
2235
|
+
if parts := strings.Split(value, ":"); len(parts) > 1 {
|
|
2236
|
+
value = parts[len(parts)-1]
|
|
2237
|
+
}
|
|
2238
|
+
if len(value) > 12 {
|
|
2239
|
+
return value[:12]
|
|
2240
|
+
}
|
|
2241
|
+
return value
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
func safeFilenamePart(value string) string {
|
|
2245
|
+
value = strings.ToLower(oneLine(value))
|
|
2246
|
+
var b strings.Builder
|
|
2247
|
+
lastDash := false
|
|
2248
|
+
for _, r := range value {
|
|
2249
|
+
keep := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
|
2250
|
+
if keep {
|
|
2251
|
+
b.WriteRune(r)
|
|
2252
|
+
lastDash = false
|
|
2253
|
+
continue
|
|
2254
|
+
}
|
|
2255
|
+
if !lastDash {
|
|
2256
|
+
b.WriteRune('-')
|
|
2257
|
+
lastDash = true
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
return strings.Trim(b.String(), "-")
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
func fitFilename(value string, width int) string {
|
|
2264
|
+
if len(value) <= width {
|
|
2265
|
+
return value
|
|
2266
|
+
}
|
|
2267
|
+
return strings.Trim(value[:width], "-")
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
func escapeMarkdown(value string) string {
|
|
2271
|
+
replacer := strings.NewReplacer(`\`, `\\`, `*`, `\*`, `_`, `\_`, "`", "\\`")
|
|
2272
|
+
return replacer.Replace(value)
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
func nonEmpty(values []string) []string {
|
|
2276
|
+
out := []string{}
|
|
2277
|
+
for _, value := range values {
|
|
2278
|
+
if strings.TrimSpace(value) != "" {
|
|
2279
|
+
out = append(out, value)
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
return out
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
func sessionFilters(alias string, providers []string, models []string, repos []string) ([]string, []any) {
|
|
2286
|
+
args := []any{}
|
|
2287
|
+
filters := []string{}
|
|
2288
|
+
appendInFilter(&filters, &args, alias+".provider", providers)
|
|
2289
|
+
appendInFilter(&filters, &args, alias+".cwd", repos)
|
|
2290
|
+
if len(models) > 0 {
|
|
2291
|
+
filters = append(filters, "EXISTS (SELECT 1 FROM session_models sm_filter WHERE sm_filter.session_key = "+alias+".session_key AND sm_filter.model IN ("+placeholders(len(models))+"))")
|
|
2292
|
+
for _, modelName := range models {
|
|
2293
|
+
args = append(args, modelName)
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
return filters, args
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
func messageFilters(alias string, providers []string, models []string, repos []string, allRoles bool, args *[]any) []string {
|
|
2300
|
+
filters := []string{}
|
|
2301
|
+
appendInFilter(&filters, args, alias+".provider", providers)
|
|
2302
|
+
if !allRoles {
|
|
2303
|
+
filters = append(filters, alias+".role IN (?, ?)")
|
|
2304
|
+
*args = append(*args, "user", "assistant")
|
|
2305
|
+
}
|
|
2306
|
+
appendInFilter(&filters, args, "s.cwd", repos)
|
|
2307
|
+
if len(models) > 0 {
|
|
2308
|
+
filters = append(filters, "EXISTS (SELECT 1 FROM session_models sm_filter WHERE sm_filter.session_key = "+alias+".session_key AND sm_filter.model IN ("+placeholders(len(models))+"))")
|
|
2309
|
+
for _, modelName := range models {
|
|
2310
|
+
*args = append(*args, modelName)
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
return filters
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
func appendInFilter(filters *[]string, args *[]any, expr string, values []string) {
|
|
2317
|
+
if len(values) == 0 {
|
|
2318
|
+
return
|
|
2319
|
+
}
|
|
2320
|
+
if len(values) == 1 {
|
|
2321
|
+
*filters = append(*filters, expr+" = ?")
|
|
2322
|
+
*args = append(*args, values[0])
|
|
2323
|
+
return
|
|
2324
|
+
}
|
|
2325
|
+
*filters = append(*filters, expr+" IN ("+placeholders(len(values))+")")
|
|
2326
|
+
for _, value := range values {
|
|
2327
|
+
*args = append(*args, value)
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
func placeholders(count int) string {
|
|
2332
|
+
if count <= 0 {
|
|
2333
|
+
return ""
|
|
2334
|
+
}
|
|
2335
|
+
return strings.TrimRight(strings.Repeat("?,", count), ",")
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
func displayTitleExpr(alias string) string {
|
|
2339
|
+
title := alias + ".title"
|
|
2340
|
+
fallback := firstDisplayableMessageExpr(alias)
|
|
2341
|
+
return `COALESCE(
|
|
2342
|
+
NULLIF(TRIM(CASE WHEN ` + hiddenTitleExpr(title) + ` THEN ` + fallback + ` ELSE ` + title + ` END), ''),
|
|
2343
|
+
NULLIF(TRIM(` + fallback + `), ''),
|
|
2344
|
+
` + alias + `.session_key
|
|
2345
|
+
)`
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
func firstDisplayableMessageExpr(alias string) string {
|
|
2349
|
+
return `(
|
|
2350
|
+
SELECT title_message.text FROM messages title_message
|
|
2351
|
+
WHERE title_message.session_key = ` + alias + `.session_key
|
|
2352
|
+
AND title_message.role IN ('user', 'assistant')
|
|
2353
|
+
AND NOT ` + hiddenTitleExpr("title_message.text") + `
|
|
2354
|
+
ORDER BY CASE title_message.role WHEN 'user' THEN 0 ELSE 1 END, title_message.ordinal ASC
|
|
2355
|
+
LIMIT 1
|
|
2356
|
+
)`
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
func displayableSessionFilter(alias string) string {
|
|
2360
|
+
return `EXISTS (
|
|
2361
|
+
SELECT 1 FROM messages title_message
|
|
2362
|
+
WHERE title_message.session_key = ` + alias + `.session_key
|
|
2363
|
+
AND title_message.role IN ('user', 'assistant')
|
|
2364
|
+
AND NOT ` + hiddenTitleExpr("title_message.text") + `
|
|
2365
|
+
)`
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
func rootSessionFilter(alias string) string {
|
|
2369
|
+
return "(" + alias + ".parent_session_key IS NULL OR " + alias + ".parent_session_key = '')"
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
func hiddenTitleExpr(expr string) string {
|
|
2373
|
+
return `(
|
|
2374
|
+
` + expr + ` IS NULL
|
|
2375
|
+
OR TRIM(` + expr + `) = ''
|
|
2376
|
+
OR LTRIM(` + expr + `) LIKE '# AGENTS.md instructions%'
|
|
2377
|
+
OR LTRIM(` + expr + `) LIKE 'AGENTS.md instructions%'
|
|
2378
|
+
OR LTRIM(` + expr + `) LIKE '<turn_aborted>%'
|
|
2379
|
+
OR LTRIM(` + expr + `) LIKE '<user_action>%'
|
|
2380
|
+
OR LTRIM(` + expr + `) LIKE '<environment_context>%'
|
|
2381
|
+
)`
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
func toFTSQuery(query string) string {
|
|
2385
|
+
terms := strings.Fields(query)
|
|
2386
|
+
if len(terms) == 0 {
|
|
2387
|
+
return `""`
|
|
2388
|
+
}
|
|
2389
|
+
escaped := make([]string, 0, len(terms))
|
|
2390
|
+
for _, term := range terms {
|
|
2391
|
+
term = strings.Trim(term, `"`)
|
|
2392
|
+
term = strings.ReplaceAll(term, `"`, `""`)
|
|
2393
|
+
escaped = append(escaped, `"`+term+`"`)
|
|
2394
|
+
}
|
|
2395
|
+
return strings.Join(escaped, " AND ")
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
func defaultDBPath() string {
|
|
2399
|
+
if value := os.Getenv("GORA_DB"); value != "" {
|
|
2400
|
+
return expandHome(value)
|
|
2401
|
+
}
|
|
2402
|
+
if value := os.Getenv("XDG_DATA_HOME"); value != "" {
|
|
2403
|
+
return filepath.Join(expandHome(value), "gora", "history.sqlite")
|
|
2404
|
+
}
|
|
2405
|
+
home, err := os.UserHomeDir()
|
|
2406
|
+
if err != nil {
|
|
2407
|
+
return "history.sqlite"
|
|
2408
|
+
}
|
|
2409
|
+
if runtime.GOOS == "darwin" {
|
|
2410
|
+
return filepath.Join(home, "Library", "Application Support", "gora", "history.sqlite")
|
|
2411
|
+
}
|
|
2412
|
+
return filepath.Join(home, ".local", "share", "gora", "history.sqlite")
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
func expandHome(path string) string {
|
|
2416
|
+
if path == "~" {
|
|
2417
|
+
home, _ := os.UserHomeDir()
|
|
2418
|
+
return home
|
|
2419
|
+
}
|
|
2420
|
+
if strings.HasPrefix(path, "~/") {
|
|
2421
|
+
home, _ := os.UserHomeDir()
|
|
2422
|
+
return filepath.Join(home, path[2:])
|
|
2423
|
+
}
|
|
2424
|
+
return path
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
func providerStyle(provider string, selectedRow bool) lipgloss.Style {
|
|
2428
|
+
providerColor := cyan
|
|
2429
|
+
switch provider {
|
|
2430
|
+
case "codex":
|
|
2431
|
+
providerColor = purple
|
|
2432
|
+
case "pi":
|
|
2433
|
+
providerColor = green
|
|
2434
|
+
case "claude":
|
|
2435
|
+
providerColor = cyan
|
|
2436
|
+
}
|
|
2437
|
+
return lipgloss.NewStyle().Foreground(providerColor).Background(bg).Bold(selectedRow)
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
func roleStyle(role string) lipgloss.Style {
|
|
2441
|
+
switch role {
|
|
2442
|
+
case "user":
|
|
2443
|
+
return lipgloss.NewStyle().Foreground(cyan).Background(bg).Bold(true)
|
|
2444
|
+
case "assistant":
|
|
2445
|
+
return lipgloss.NewStyle().Foreground(purple).Background(bg).Bold(true)
|
|
2446
|
+
case "tool-call":
|
|
2447
|
+
return lipgloss.NewStyle().Foreground(green).Background(bg).Bold(true)
|
|
2448
|
+
case "tool":
|
|
2449
|
+
return lipgloss.NewStyle().Foreground(yellow).Background(bg).Bold(true)
|
|
2450
|
+
case "system", "developer":
|
|
2451
|
+
return lipgloss.NewStyle().Foreground(red).Background(bg).Bold(true)
|
|
2452
|
+
default:
|
|
2453
|
+
return lipgloss.NewStyle().Foreground(green).Background(bg).Bold(true)
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
func providerBadge(provider string) string {
|
|
2458
|
+
if provider == "claude" {
|
|
2459
|
+
return "Claude"
|
|
2460
|
+
}
|
|
2461
|
+
return providerLabel(provider)
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
func providerLabel(provider string) string {
|
|
2465
|
+
switch provider {
|
|
2466
|
+
case "codex":
|
|
2467
|
+
return "Codex"
|
|
2468
|
+
case "claude":
|
|
2469
|
+
return "Claude Code"
|
|
2470
|
+
case "pi":
|
|
2471
|
+
return "Pi"
|
|
2472
|
+
default:
|
|
2473
|
+
return provider
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
func modelSummary(models string) string {
|
|
2478
|
+
if models == "" {
|
|
2479
|
+
return ""
|
|
2480
|
+
}
|
|
2481
|
+
parts := strings.Split(models, ",")
|
|
2482
|
+
if len(parts) <= 1 {
|
|
2483
|
+
return models
|
|
2484
|
+
}
|
|
2485
|
+
return parts[0] + fmt.Sprintf(" +%d", len(parts)-1)
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
func friendlyTime(value string) string {
|
|
2489
|
+
if value == "" {
|
|
2490
|
+
return "-"
|
|
2491
|
+
}
|
|
2492
|
+
if parsed, err := parseTime(value); err == nil {
|
|
2493
|
+
return parsed.Format("2006-01-02 15:04")
|
|
2494
|
+
}
|
|
2495
|
+
if parts := strings.SplitN(value, "T", 2); len(parts) == 2 {
|
|
2496
|
+
timePart := strings.TrimSuffix(parts[1], "Z")
|
|
2497
|
+
if len(timePart) >= 5 {
|
|
2498
|
+
return parts[0] + " " + timePart[:5]
|
|
2499
|
+
}
|
|
2500
|
+
return parts[0] + " " + timePart
|
|
2501
|
+
}
|
|
2502
|
+
return value
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
func relativeTime(value string) string {
|
|
2506
|
+
parsed, err := parseTime(value)
|
|
2507
|
+
if err != nil {
|
|
2508
|
+
return "-"
|
|
2509
|
+
}
|
|
2510
|
+
delta := time.Since(parsed)
|
|
2511
|
+
if delta < time.Minute {
|
|
2512
|
+
return "now"
|
|
2513
|
+
}
|
|
2514
|
+
if delta < time.Hour {
|
|
2515
|
+
return fmt.Sprintf("%dm ago", int(delta.Minutes()))
|
|
2516
|
+
}
|
|
2517
|
+
if delta < 24*time.Hour {
|
|
2518
|
+
return fmt.Sprintf("%dh ago", int(delta.Hours()))
|
|
2519
|
+
}
|
|
2520
|
+
days := int(delta.Hours() / 24)
|
|
2521
|
+
if days < 90 {
|
|
2522
|
+
return fmt.Sprintf("%dd ago", days)
|
|
2523
|
+
}
|
|
2524
|
+
weeks := days / 7
|
|
2525
|
+
if weeks < 52 {
|
|
2526
|
+
return fmt.Sprintf("%dw ago", weeks)
|
|
2527
|
+
}
|
|
2528
|
+
return fmt.Sprintf("%dy ago", days/365)
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
func parseTime(value string) (time.Time, error) {
|
|
2532
|
+
if value == "" {
|
|
2533
|
+
return time.Time{}, errors.New("empty time")
|
|
2534
|
+
}
|
|
2535
|
+
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
|
2536
|
+
return parsed, nil
|
|
2537
|
+
}
|
|
2538
|
+
return time.Parse("2006-01-02T15:04:05", strings.TrimSuffix(value, "Z"))
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
func shortCwd(cwd string) string {
|
|
2542
|
+
if cwd == "" {
|
|
2543
|
+
return "no repo"
|
|
2544
|
+
}
|
|
2545
|
+
parts := strings.Split(filepath.Clean(cwd), string(filepath.Separator))
|
|
2546
|
+
if len(parts) <= 3 {
|
|
2547
|
+
return cwd
|
|
2548
|
+
}
|
|
2549
|
+
return filepath.Join(parts[len(parts)-3:]...)
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
func oneLine(text string) string {
|
|
2553
|
+
withoutImages := imageTagPattern.ReplaceAllString(text, " ")
|
|
2554
|
+
cleaned := strings.Join(strings.Fields(withoutImages), " ")
|
|
2555
|
+
if cleaned == "" && imageTagPattern.MatchString(text) {
|
|
2556
|
+
return "image attachment"
|
|
2557
|
+
}
|
|
2558
|
+
return cleaned
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
func padRight(text string, width int) string {
|
|
2562
|
+
runes := []rune(text)
|
|
2563
|
+
if len(runes) >= width {
|
|
2564
|
+
return string(runes[:width])
|
|
2565
|
+
}
|
|
2566
|
+
return text + strings.Repeat(" ", width-len(runes))
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
func fit(text string, width int) string {
|
|
2570
|
+
if width <= 0 {
|
|
2571
|
+
return ""
|
|
2572
|
+
}
|
|
2573
|
+
runes := []rune(text)
|
|
2574
|
+
if len(runes) <= width {
|
|
2575
|
+
return text
|
|
2576
|
+
}
|
|
2577
|
+
if width <= 3 {
|
|
2578
|
+
return string(runes[:width])
|
|
2579
|
+
}
|
|
2580
|
+
return string(runes[:width-3]) + "..."
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
func wrap(text string, width int) []string {
|
|
2584
|
+
text = strings.TrimRight(text, "\r")
|
|
2585
|
+
if text == "" {
|
|
2586
|
+
return []string{""}
|
|
2587
|
+
}
|
|
2588
|
+
words := strings.Fields(text)
|
|
2589
|
+
if len(words) == 0 {
|
|
2590
|
+
return []string{""}
|
|
2591
|
+
}
|
|
2592
|
+
lines := []string{}
|
|
2593
|
+
current := ""
|
|
2594
|
+
for _, word := range words {
|
|
2595
|
+
if current == "" {
|
|
2596
|
+
current = word
|
|
2597
|
+
continue
|
|
2598
|
+
}
|
|
2599
|
+
if len([]rune(current))+1+len([]rune(word)) > width {
|
|
2600
|
+
lines = append(lines, current)
|
|
2601
|
+
current = word
|
|
2602
|
+
continue
|
|
2603
|
+
}
|
|
2604
|
+
current += " " + word
|
|
2605
|
+
}
|
|
2606
|
+
if current != "" {
|
|
2607
|
+
lines = append(lines, current)
|
|
2608
|
+
}
|
|
2609
|
+
return lines
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
func clamp(value int, low int, high int) int {
|
|
2613
|
+
if value < low {
|
|
2614
|
+
return low
|
|
2615
|
+
}
|
|
2616
|
+
if value > high {
|
|
2617
|
+
return high
|
|
2618
|
+
}
|
|
2619
|
+
return value
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
func min(left int, right int) int {
|
|
2623
|
+
if left < right {
|
|
2624
|
+
return left
|
|
2625
|
+
}
|
|
2626
|
+
return right
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
func max(left int, right int) int {
|
|
2630
|
+
if left > right {
|
|
2631
|
+
return left
|
|
2632
|
+
}
|
|
2633
|
+
return right
|
|
2634
|
+
}
|