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.
@@ -0,0 +1,626 @@
1
+ package main
2
+
3
+ import (
4
+ "database/sql"
5
+ "fmt"
6
+ "regexp"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/charmbracelet/bubbles/textinput"
11
+ tea "github.com/charmbracelet/bubbletea"
12
+ _ "modernc.org/sqlite"
13
+ )
14
+
15
+ var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
16
+
17
+ func TestResultTableColumnsIncludeCreatedAndUpdated(t *testing.T) {
18
+ columns := resultTableColumns(120)
19
+ widths := map[string]int{}
20
+ for _, column := range columns {
21
+ widths[column.Title] = column.Width
22
+ }
23
+
24
+ if widths["updated"] <= 0 {
25
+ t.Fatalf("updated column hidden at normal width: %#v", widths)
26
+ }
27
+ if widths["created"] <= 0 {
28
+ t.Fatalf("created column hidden at normal width: %#v", widths)
29
+ }
30
+
31
+ rows := resultTableRows([]item{{
32
+ SessionKey: "codex:abc",
33
+ Provider: "codex",
34
+ Title: "fix the terminal table layout",
35
+ CreatedAt: "2026-06-18T19:00:00Z",
36
+ UpdatedAt: "2026-06-18T20:09:35Z",
37
+ Cwd: "/Users/mertdeveci/Desktop/Code/gora",
38
+ MessageCount: 42,
39
+ }}, 0)
40
+ if len(rows) != 1 {
41
+ t.Fatalf("rows = %d, want 1", len(rows))
42
+ }
43
+ got := strings.Join(rows[0], " ")
44
+ for _, expected := range []string{">", "2026-06-18 20:09", "2026-06-18 19:00", "fix the terminal table layout", "Desktop/Code/gora", "[Codex]", "42"} {
45
+ if !strings.Contains(got, expected) {
46
+ t.Fatalf("table row missing %q:\n%s", expected, got)
47
+ }
48
+ }
49
+ }
50
+
51
+ func TestRenderSelectedPreviewShowsMeaningfulContent(t *testing.T) {
52
+ row := item{
53
+ SessionKey: "codex:abc",
54
+ Provider: "codex",
55
+ Title: "fix the terminal table layout",
56
+ Timestamp: "2026-06-18T20:09:35Z",
57
+ CreatedAt: "2026-06-18T19:00:00Z",
58
+ UpdatedAt: "2026-06-18T20:09:35Z",
59
+ Cwd: "/Users/mertdeveci/Desktop/Code/gora",
60
+ MessageCount: 42,
61
+ Preview: "the row should show the matched prompt",
62
+ }
63
+ m := &model{results: []item{row}, resultsTable: newResultsTable()}
64
+ m.configureResultsTable(100, 10)
65
+
66
+ got := stripANSI(m.renderSelectedPreview(100, 10))
67
+ for _, expected := range []string{
68
+ "preview",
69
+ "fix the terminal table layout",
70
+ "Desktop/Code/gora",
71
+ "created 2026-06-18 19:00",
72
+ "updated 2026-06-18 20:09",
73
+ "42 messages",
74
+ "match the row should show the matched prompt",
75
+ } {
76
+ if !strings.Contains(got, expected) {
77
+ t.Fatalf("selected preview missing %q:\n%s", expected, got)
78
+ }
79
+ }
80
+ }
81
+
82
+ func TestResultTableRowsOnlyMarksSelected(t *testing.T) {
83
+ rows := resultTableRows([]item{
84
+ {SessionKey: "codex:first", Provider: "codex", Title: "first"},
85
+ {SessionKey: "codex:second", Provider: "codex", Title: "second"},
86
+ }, 1)
87
+
88
+ if rows[0][0] != "" {
89
+ t.Fatalf("first row marker = %q, want empty", rows[0][0])
90
+ }
91
+ if rows[1][0] != ">" {
92
+ t.Fatalf("second row marker = %q, want >", rows[1][0])
93
+ }
94
+ }
95
+
96
+ func TestRenderSearchInputFillsBackground(t *testing.T) {
97
+ search := textinput.New()
98
+ search.Placeholder = "type to search"
99
+ m := &model{search: search}
100
+
101
+ got := stripANSI(m.renderSearchInput(30))
102
+ if len([]rune(got)) != 30 {
103
+ t.Fatalf("placeholder width = %d, want 30: %q", len([]rune(got)), got)
104
+ }
105
+ if !strings.Contains(got, "type to search") {
106
+ t.Fatalf("placeholder missing: %q", got)
107
+ }
108
+ }
109
+
110
+ func TestOneLineStripsImageTags(t *testing.T) {
111
+ got := oneLine(`<image name=[Image #1] path="/tmp/screen.png"`)
112
+ if got != "image attachment" {
113
+ t.Fatalf("truncated image tag = %q, want image attachment", got)
114
+ }
115
+
116
+ got = oneLine(`<image name=[Image #1] path="/tmp/screen.png"> fix this layout`)
117
+ if got != "fix this layout" {
118
+ t.Fatalf("image tag text = %q, want prompt text", got)
119
+ }
120
+
121
+ got = oneLine(`</image> added liteparse in here`)
122
+ if got != "added liteparse in here" {
123
+ t.Fatalf("closing image tag text = %q, want prompt text", got)
124
+ }
125
+ }
126
+
127
+ func TestListSessionsFallsBackToFirstPrompt(t *testing.T) {
128
+ db, err := sql.Open("sqlite", ":memory:")
129
+ if err != nil {
130
+ t.Fatal(err)
131
+ }
132
+ defer db.Close()
133
+ mustExec(t, db, `
134
+ CREATE TABLE sessions (
135
+ session_key TEXT PRIMARY KEY,
136
+ provider TEXT NOT NULL,
137
+ parent_session_key TEXT,
138
+ title TEXT,
139
+ started_at TEXT,
140
+ updated_at TEXT,
141
+ imported_at TEXT,
142
+ cwd TEXT,
143
+ message_count INTEGER
144
+ );
145
+ CREATE TABLE messages (
146
+ session_key TEXT NOT NULL,
147
+ role TEXT NOT NULL,
148
+ ordinal INTEGER NOT NULL,
149
+ text TEXT NOT NULL
150
+ );
151
+ CREATE TABLE session_models (
152
+ session_key TEXT NOT NULL,
153
+ model TEXT NOT NULL
154
+ );
155
+ `)
156
+ mustExec(t, db, `
157
+ INSERT INTO sessions VALUES
158
+ ('codex:context', 'codex', NULL, '# AGENTS.md instructions for repo', NULL, '2026-06-18T10:00:00Z', NULL, '/repo', 2),
159
+ ('codex:control', 'codex', NULL, '<turn_aborted> previous turn stopped', NULL, '2026-06-18T09:00:00Z', NULL, '/repo', 1),
160
+ ('codex:child', 'codex', 'codex:context', 'child review output', NULL, '2026-06-18T08:00:00Z', NULL, '/repo', 1);
161
+ INSERT INTO messages VALUES
162
+ ('codex:context', 'user', 0, '# AGENTS.md instructions for repo'),
163
+ ('codex:context', 'user', 1, 'real user prompt'),
164
+ ('codex:control', 'user', 0, '<turn_aborted> previous turn stopped'),
165
+ ('codex:child', 'assistant', 0, 'child review output');
166
+ `)
167
+
168
+ rows, err := listSessions(db, nil, nil, nil)
169
+ if err != nil {
170
+ t.Fatal(err)
171
+ }
172
+ if len(rows) != 1 {
173
+ t.Fatalf("rows = %d, want 1 displayable session", len(rows))
174
+ }
175
+ if rows[0].Title != "real user prompt" {
176
+ t.Fatalf("context title = %q, want real user prompt", rows[0].Title)
177
+ }
178
+ }
179
+
180
+ func TestListSessionsReturnsAllRows(t *testing.T) {
181
+ db := openListTestDB(t)
182
+ defer db.Close()
183
+
184
+ for index := range 250 {
185
+ key := "codex:session-" + stringID(index)
186
+ mustExec(t, db, `
187
+ INSERT INTO sessions VALUES (?, 'codex', NULL, ?, NULL, '2026-06-18T10:00:00Z', NULL, '/repo', 1);
188
+ INSERT INTO messages VALUES (?, 'user', 0, ?);
189
+ `, key, "prompt "+stringID(index), key, "prompt "+stringID(index))
190
+ }
191
+
192
+ rows, err := listSessions(db, nil, nil, nil)
193
+ if err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ if len(rows) != 250 {
197
+ t.Fatalf("rows = %d, want 250", len(rows))
198
+ }
199
+ }
200
+
201
+ func TestListSessionsOrdersByUpdatedAndScansCreated(t *testing.T) {
202
+ db := openListTestDB(t)
203
+ defer db.Close()
204
+
205
+ mustExec(t, db, `
206
+ INSERT INTO sessions VALUES
207
+ ('codex:old-created-new-updated', 'codex', NULL, 'new update', '2026-01-01T09:00:00Z', '2026-06-18T12:00:00Z', NULL, '/repo', 1),
208
+ ('codex:new-created-old-updated', 'codex', NULL, 'old update', '2026-06-18T09:00:00Z', '2026-06-18T10:00:00Z', NULL, '/repo', 1);
209
+ INSERT INTO messages VALUES
210
+ ('codex:old-created-new-updated', 'user', 0, 'new update'),
211
+ ('codex:new-created-old-updated', 'user', 0, 'old update');
212
+ `)
213
+
214
+ rows, err := listSessions(db, nil, nil, nil)
215
+ if err != nil {
216
+ t.Fatal(err)
217
+ }
218
+ if len(rows) != 2 {
219
+ t.Fatalf("rows = %d, want 2", len(rows))
220
+ }
221
+ if rows[0].SessionKey != "codex:old-created-new-updated" {
222
+ t.Fatalf("first session = %q, want most recently updated", rows[0].SessionKey)
223
+ }
224
+ if rows[0].CreatedAt != "2026-01-01T09:00:00Z" {
225
+ t.Fatalf("created = %q", rows[0].CreatedAt)
226
+ }
227
+ if rows[0].UpdatedAt != "2026-06-18T12:00:00Z" {
228
+ t.Fatalf("updated = %q", rows[0].UpdatedAt)
229
+ }
230
+ }
231
+
232
+ func TestListSessionsSupportsMultiSelectFilters(t *testing.T) {
233
+ db := openListTestDB(t)
234
+ defer db.Close()
235
+
236
+ insertListSession(t, db, "claude:a", "claude", "/repo/a", "claude prompt a", "opus")
237
+ insertListSession(t, db, "claude:b", "claude", "/repo/b", "claude prompt b", "opus")
238
+ insertListSession(t, db, "claude:c", "claude", "/repo/c", "claude prompt c", "sonnet")
239
+ insertListSession(t, db, "codex:a", "codex", "/repo/a", "codex prompt a", "gpt")
240
+
241
+ rows, err := listSessions(db, []string{"claude"}, []string{"opus"}, []string{"/repo/a", "/repo/b"})
242
+ if err != nil {
243
+ t.Fatal(err)
244
+ }
245
+ if len(rows) != 2 {
246
+ t.Fatalf("rows = %d, want 2", len(rows))
247
+ }
248
+ got := map[string]bool{}
249
+ for _, row := range rows {
250
+ got[row.SessionKey] = true
251
+ }
252
+ for _, key := range []string{"claude:a", "claude:b"} {
253
+ if !got[key] {
254
+ t.Fatalf("missing %s in filtered rows: %#v", key, got)
255
+ }
256
+ }
257
+ }
258
+
259
+ func TestRenderFiltersUsesEditableLabels(t *testing.T) {
260
+ m := &model{
261
+ providers: map[string]bool{"claude": true},
262
+ repoFilters: map[string]bool{"/repo/a": true, "/repo/b": true},
263
+ modelFilters: map[string]bool{},
264
+ providerOptions: []filterOption{
265
+ {Value: "claude", Label: "Claude Code"},
266
+ },
267
+ repoOptions: []filterOption{
268
+ {Value: "/repo/a", Label: "repo/a"},
269
+ {Value: "/repo/b", Label: "repo/b"},
270
+ },
271
+ focus: focusFilters,
272
+ }
273
+
274
+ got := stripANSI(m.renderFilters(100))
275
+ for _, forbidden := range []string{"F1", "F2", "F3", "F4", "F5"} {
276
+ if strings.Contains(got, forbidden) {
277
+ t.Fatalf("filter strip should not expose %s:\n%s", forbidden, got)
278
+ }
279
+ }
280
+ for _, expected := range []string{"Filters", "Ctrl-F Filters", "Harness: Claude Code", "Repo: 2 selected", "Enter edit"} {
281
+ if !strings.Contains(got, expected) {
282
+ t.Fatalf("filter strip missing %q:\n%s", expected, got)
283
+ }
284
+ }
285
+ }
286
+
287
+ func TestFilterListClickDoesNotSwitchSections(t *testing.T) {
288
+ m := &model{
289
+ providers: map[string]bool{},
290
+ repoFilters: map[string]bool{},
291
+ modelFilters: map[string]bool{},
292
+ providerOptions: []filterOption{
293
+ {Value: "codex", Label: "Codex"},
294
+ },
295
+ repoOptions: []filterOption{
296
+ {Value: "/repo/a", Label: "repo/a"},
297
+ },
298
+ filterSection: sectionRepos,
299
+ width: 100,
300
+ }
301
+
302
+ m.handleFilterClick(tea.MouseMsg{X: 1, Y: 6, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
303
+
304
+ if m.filterSection != sectionRepos {
305
+ t.Fatalf("filter section = %v, want repos", m.filterSection)
306
+ }
307
+ if !m.repoFilters["/repo/a"] {
308
+ t.Fatalf("repo filter was not toggled: %#v", m.repoFilters)
309
+ }
310
+ if len(m.providers) != 0 {
311
+ t.Fatalf("provider filters changed on repo click: %#v", m.providers)
312
+ }
313
+ }
314
+
315
+ func TestFilterEnterSelectsAndAdvances(t *testing.T) {
316
+ m := &model{
317
+ providers: map[string]bool{},
318
+ providerOptions: []filterOption{
319
+ {Value: "claude", Label: "Claude Code"},
320
+ },
321
+ filterOpen: true,
322
+ filterSection: sectionHarness,
323
+ }
324
+
325
+ _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyEnter})
326
+
327
+ if !m.providers["claude"] {
328
+ t.Fatalf("provider was not selected: %#v", m.providers)
329
+ }
330
+ if m.filterSection != sectionRepos {
331
+ t.Fatalf("filter section = %v, want repos", m.filterSection)
332
+ }
333
+ }
334
+
335
+ func TestFilterShortcutsJumpSections(t *testing.T) {
336
+ m := &model{filterOpen: true}
337
+
338
+ _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}})
339
+ if m.filterSection != sectionModels {
340
+ t.Fatalf("filter section = %v, want models", m.filterSection)
341
+ }
342
+
343
+ _ = m.handleFilterKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
344
+ if m.filterSection != sectionRepos {
345
+ t.Fatalf("filter section = %v, want repos", m.filterSection)
346
+ }
347
+ }
348
+
349
+ func TestCtrlFOpenFiltersFromBrowse(t *testing.T) {
350
+ m := &model{search: textinput.New(), focus: focusSearch}
351
+
352
+ _ = m.handleKey(tea.KeyMsg{Type: tea.KeyCtrlF})
353
+
354
+ if !m.filterOpen {
355
+ t.Fatal("ctrl+f did not open filters")
356
+ }
357
+ if m.focus != focusFilters {
358
+ t.Fatalf("focus = %v, want filters", m.focus)
359
+ }
360
+ }
361
+
362
+ func TestRenderFilterStepsUsesFullLabels(t *testing.T) {
363
+ m := &model{filterSection: sectionRepos}
364
+ got := stripANSI(m.renderFilterSteps(120))
365
+
366
+ for _, expected := range []string{"1 Harness", "2 Repos", "3 Models", "4 Roles"} {
367
+ if !strings.Contains(got, expected) {
368
+ t.Fatalf("filter steps missing %q:\n%s", expected, got)
369
+ }
370
+ }
371
+ if strings.Contains(got, "Re...") {
372
+ t.Fatalf("filter steps should not truncate repos label:\n%s", got)
373
+ }
374
+ }
375
+
376
+ func TestTranscriptExportFormats(t *testing.T) {
377
+ row := item{
378
+ SessionKey: "codex:abc123",
379
+ Provider: "codex",
380
+ Title: "Fix export button",
381
+ Timestamp: "2026-06-18T10:00:00Z",
382
+ Cwd: "/repo",
383
+ Models: "gpt-test",
384
+ }
385
+ messages := []transcriptMessage{
386
+ {Role: "user", Timestamp: "2026-06-18T10:00:01Z", Text: "please export this"},
387
+ {Role: "assistant", Timestamp: "2026-06-18T10:00:02Z", Model: "gpt-test", Text: "done"},
388
+ }
389
+
390
+ markdown := formatTranscriptExport(row, messages, "md")
391
+ for _, expected := range []string{"# Fix export button", "- **Harness:** Codex", "### USER", "please export this", "### ASSISTANT"} {
392
+ if !strings.Contains(markdown, expected) {
393
+ t.Fatalf("markdown export missing %q:\n%s", expected, markdown)
394
+ }
395
+ }
396
+
397
+ text := formatTranscriptExport(row, messages, "txt")
398
+ for _, expected := range []string{"Fix export button", "Harness: Codex", "USER 2026-06-18T10:00:01Z", "ASSISTANT 2026-06-18T10:00:02Z gpt-test"} {
399
+ if !strings.Contains(text, expected) {
400
+ t.Fatalf("text export missing %q:\n%s", expected, text)
401
+ }
402
+ }
403
+ }
404
+
405
+ func TestLoadTranscriptMessagesIncludesToolRoles(t *testing.T) {
406
+ db, err := sql.Open("sqlite", ":memory:")
407
+ if err != nil {
408
+ t.Fatal(err)
409
+ }
410
+ defer db.Close()
411
+ mustExec(t, db, `
412
+ CREATE TABLE messages (
413
+ session_key TEXT NOT NULL,
414
+ role TEXT NOT NULL,
415
+ timestamp TEXT,
416
+ model TEXT,
417
+ text TEXT NOT NULL,
418
+ ordinal INTEGER NOT NULL
419
+ );
420
+ `)
421
+ mustExec(t, db, `INSERT INTO messages VALUES ('codex:abc', 'user', '2026-06-18T10:00:00Z', NULL, 'start', 0)`)
422
+ mustExec(t, db, `INSERT INTO messages VALUES ('codex:abc', 'tool-call', '2026-06-18T10:00:01Z', NULL, 'Tool call: exec_command', 1)`)
423
+ mustExec(t, db, `INSERT INTO messages VALUES ('codex:abc', 'tool', '2026-06-18T10:00:02Z', NULL, 'Tool result', 2)`)
424
+ mustExec(t, db, `INSERT INTO messages VALUES ('codex:abc', 'assistant', '2026-06-18T10:00:03Z', 'gpt-test', 'done', 3)`)
425
+
426
+ all, err := loadTranscriptMessages(db, "codex:abc", true)
427
+ if err != nil {
428
+ t.Fatal(err)
429
+ }
430
+ narrowed, err := loadTranscriptMessages(db, "codex:abc", false)
431
+ if err != nil {
432
+ t.Fatal(err)
433
+ }
434
+
435
+ if got := transcriptRoles(all); got != "user,tool-call,tool,assistant" {
436
+ t.Fatalf("all roles = %q", got)
437
+ }
438
+ if got := transcriptRoles(narrowed); got != "user,assistant" {
439
+ t.Fatalf("narrowed roles = %q", got)
440
+ }
441
+ }
442
+
443
+ func TestLoadTranscriptPreviewMessagesSkipsHarnessNoise(t *testing.T) {
444
+ db, err := sql.Open("sqlite", ":memory:")
445
+ if err != nil {
446
+ t.Fatal(err)
447
+ }
448
+ defer db.Close()
449
+ mustExec(t, db, `
450
+ CREATE TABLE messages (
451
+ session_key TEXT NOT NULL,
452
+ role TEXT NOT NULL,
453
+ timestamp TEXT,
454
+ model TEXT,
455
+ text TEXT NOT NULL,
456
+ ordinal INTEGER NOT NULL
457
+ );
458
+ `)
459
+ mustExec(t, db, `INSERT INTO messages VALUES ('codex:abc', 'developer', '2026-06-18T10:00:00Z', NULL, 'developer rules', 0)`)
460
+ mustExec(t, db, `INSERT INTO messages VALUES ('codex:abc', 'user', '2026-06-18T10:00:01Z', NULL, '# AGENTS.md instructions for repo', 1)`)
461
+ mustExec(t, db, `INSERT INTO messages VALUES ('codex:abc', 'user', '2026-06-18T10:00:02Z', NULL, 'real user prompt', 2)`)
462
+ mustExec(t, db, `INSERT INTO messages VALUES ('codex:abc', 'tool', '2026-06-18T10:00:03Z', NULL, 'tool output', 3)`)
463
+
464
+ messages, err := loadTranscriptPreviewMessages(db, "codex:abc", true, 10)
465
+ if err != nil {
466
+ t.Fatal(err)
467
+ }
468
+ if got := transcriptRoles(messages); got != "user,tool" {
469
+ t.Fatalf("preview roles = %q", got)
470
+ }
471
+ if messages[0].Text != "real user prompt" {
472
+ t.Fatalf("first preview text = %q", messages[0].Text)
473
+ }
474
+ }
475
+
476
+ func TestDetailWheelDoesNotChangeExportTarget(t *testing.T) {
477
+ first := item{SessionKey: "codex:first", Title: "first"}
478
+ second := item{SessionKey: "codex:second", Title: "second"}
479
+ m := &model{
480
+ results: []item{first, second},
481
+ selected: 0,
482
+ detail: true,
483
+ detailItem: first,
484
+ }
485
+
486
+ _ = m.handleMouse(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress})
487
+ row, ok := m.exportTarget()
488
+ if !ok {
489
+ t.Fatal("expected export target")
490
+ }
491
+ if row.SessionKey != first.SessionKey {
492
+ t.Fatalf("export target = %q, want %q", row.SessionKey, first.SessionKey)
493
+ }
494
+ if m.selected != 0 {
495
+ t.Fatalf("selected changed to %d while detail was open", m.selected)
496
+ }
497
+ }
498
+
499
+ func transcriptRoles(messages []transcriptMessage) string {
500
+ roles := make([]string, 0, len(messages))
501
+ for _, message := range messages {
502
+ roles = append(roles, message.Role)
503
+ }
504
+ return strings.Join(roles, ",")
505
+ }
506
+
507
+ func TestDetailViewShowsExportStatus(t *testing.T) {
508
+ m := &model{
509
+ keys: newKeyMap(),
510
+ helpBubble: newHelpModel(),
511
+ detail: true,
512
+ detailItem: item{
513
+ SessionKey: "codex:first",
514
+ Provider: "codex",
515
+ Title: "first thread",
516
+ Timestamp: "2026-06-18T10:00:00Z",
517
+ Cwd: "/repo",
518
+ },
519
+ notice: "exported /tmp/thread.md",
520
+ width: 100,
521
+ height: 20,
522
+ }
523
+
524
+ got := stripANSI(m.detailView())
525
+ if !strings.Contains(got, "exported /tmp/thread.md") {
526
+ t.Fatalf("detail view missing export notice:\n%s", got)
527
+ }
528
+ }
529
+
530
+ func TestRenderBrowseActionsTracksExportButtons(t *testing.T) {
531
+ m := &model{keys: newKeyMap()}
532
+
533
+ got := stripANSI(m.renderBrowseActions(100, 12))
534
+ for _, expected := range []string{"ENTER Open", "M Markdown", "T Text"} {
535
+ if !strings.Contains(got, expected) {
536
+ t.Fatalf("action row missing %q:\n%s", expected, got)
537
+ }
538
+ }
539
+ if len(m.browseButtons) < 3 {
540
+ t.Fatalf("tracked buttons = %d, want at least 3", len(m.browseButtons))
541
+ }
542
+ if buttonActionAt(m.browseButtons, m.browseButtons[1].X, 12) != actionExportMarkdown {
543
+ t.Fatalf("markdown button hit did not map to export action: %#v", m.browseButtons)
544
+ }
545
+ }
546
+
547
+ func TestExportShortcutsDoNotStealSearchTyping(t *testing.T) {
548
+ db := openListTestDB(t)
549
+ defer db.Close()
550
+ search := textinput.New()
551
+ search.Focus()
552
+ m := &model{db: db, search: search, focus: focusSearch}
553
+
554
+ _ = m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}})
555
+ _ = m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}})
556
+
557
+ if m.search.Value() != "mt" {
558
+ t.Fatalf("search value = %q, want mt", m.search.Value())
559
+ }
560
+ }
561
+
562
+ func TestDedupeItemsDoesNotCapResults(t *testing.T) {
563
+ items := make([]item, 0, 300)
564
+ for index := range 300 {
565
+ items = append(items, item{SessionKey: "codex:session-" + stringID(index)})
566
+ }
567
+
568
+ deduped := dedupeItems(items)
569
+ if len(deduped) != 300 {
570
+ t.Fatalf("deduped rows = %d, want 300", len(deduped))
571
+ }
572
+ }
573
+
574
+ func openListTestDB(t *testing.T) *sql.DB {
575
+ t.Helper()
576
+ db, err := sql.Open("sqlite", ":memory:")
577
+ if err != nil {
578
+ t.Fatal(err)
579
+ }
580
+ mustExec(t, db, `
581
+ CREATE TABLE sessions (
582
+ session_key TEXT PRIMARY KEY,
583
+ provider TEXT NOT NULL,
584
+ parent_session_key TEXT,
585
+ title TEXT,
586
+ started_at TEXT,
587
+ updated_at TEXT,
588
+ imported_at TEXT,
589
+ cwd TEXT,
590
+ message_count INTEGER
591
+ );
592
+ CREATE TABLE messages (
593
+ session_key TEXT NOT NULL,
594
+ role TEXT NOT NULL,
595
+ ordinal INTEGER NOT NULL,
596
+ text TEXT NOT NULL
597
+ );
598
+ CREATE TABLE session_models (
599
+ session_key TEXT NOT NULL,
600
+ model TEXT NOT NULL
601
+ );
602
+ `)
603
+ return db
604
+ }
605
+
606
+ func insertListSession(t *testing.T, db *sql.DB, key string, provider string, cwd string, title string, modelName string) {
607
+ t.Helper()
608
+ mustExec(t, db, `INSERT INTO sessions VALUES (?, ?, NULL, ?, NULL, '2026-06-18T10:00:00Z', NULL, ?, 1)`, key, provider, title, cwd)
609
+ mustExec(t, db, `INSERT INTO messages VALUES (?, 'user', 0, ?)`, key, title)
610
+ mustExec(t, db, `INSERT INTO session_models VALUES (?, ?)`, key, modelName)
611
+ }
612
+
613
+ func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
614
+ t.Helper()
615
+ if _, err := db.Exec(query, args...); err != nil {
616
+ t.Fatal(err)
617
+ }
618
+ }
619
+
620
+ func stringID(value int) string {
621
+ return fmt.Sprintf("%03d", value)
622
+ }
623
+
624
+ func stripANSI(value string) string {
625
+ return ansiPattern.ReplaceAllString(value, "")
626
+ }