v0.3.88 ed7e2b4

chat_timestamps_test.go RawHistoryFolder

1package modes
2
3import (
4	"errors"
5	"reflect"
6	"testing"
7	"time"
8
9	"github.com/patriceckhart/zot/packages/core"
10	"github.com/patriceckhart/zot/packages/provider"
11	"github.com/patriceckhart/zot/packages/tui"
12)
13
14type timestampSettingsTestStore struct {
15	SettingsStore
16	enabled  bool
17	minutes  int
18	dateMode string
19	err      error
20}
21
22func (s *timestampSettingsTestStore) SetChatTimestamps(enabled bool) error {
23	s.enabled = enabled
24	return s.err
25}
26
27func (s *timestampSettingsTestStore) SetChatTimestampInterval(minutes int) error {
28	s.minutes = minutes
29	return s.err
30}
31
32func (s *timestampSettingsTestStore) SetChatTimestampDate(mode string) error {
33	s.dateMode = mode
34	return s.err
35}
36
37func TestChatTimestampDateSetting(t *testing.T) {
38	// Exercise repainting independently of the developer's terminal environment.
39	t.Setenv("TERM_PROGRAM", "")
40	store := &timestampSettingsTestStore{}
41	i := NewInteractive(InteractiveConfig{Terminal: &cleanupTestTerminal{}, SettingsStore: store})
42	item := i.chatTimestampDateSetting()
43	if item.options[item.choice].value != "day_start" {
44		t.Fatal("incorrect date default")
45	}
46	before, _ := i.chatCacheKeyLocked(80)
47	i.applySettingChange(settingsAction{Key: "chat_timestamp_date", StringValue: "every_message"})
48	if store.dateMode != "every_message" || i.view.ChatTimestampDate != "every_message" || i.cfg.ChatTimestampDate != "every_message" {
49		t.Fatal("date mode not applied or persisted")
50	}
51	after, _ := i.chatCacheKeyLocked(80)
52	if before == after {
53		t.Fatal("date setting missing from cache key")
54	}
55	store.err = errors.New("cannot save")
56	i.applyChatTimestampDate("day_start")
57	if i.view.ChatTimestampDate != "every_message" || i.statusErr == "" {
58		t.Fatal("failed save changed live date setting")
59	}
60	restarted := NewInteractive(InteractiveConfig{ChatTimestampDate: "every_message"})
61	if restarted.view.ChatTimestampDate != "every_message" {
62		t.Fatal("saved date mode not restored")
63	}
64}
65
66func TestChatTimestampSettings(t *testing.T) {
67	// Exercise repainting independently of the developer's terminal environment.
68	t.Setenv("TERM_PROGRAM", "")
69	store := &timestampSettingsTestStore{}
70	i := NewInteractive(InteractiveConfig{Terminal: &cleanupTestTerminal{}, SettingsStore: store})
71	if i.view.ChatTimestamps || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 {
72		t.Fatal("incorrect startup defaults")
73	}
74	i.openSettingsDialog()
75	found := false
76	for idx, item := range i.settingsDialog.items {
77		if item.key == "chat_timestamps" || item.key == "chat_timestamp_interval_minutes" {
78			t.Fatal("timestamp controls must not be top-level settings")
79		}
80		if item.key == "timestamps" {
81			found = true
82			i.settingsDialog.cursor = idx
83		}
84	}
85	if !found {
86		t.Fatal("missing timestamps submenu")
87	}
88	i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
89	if i.settingsDialog.title != "settings: timestamps" || len(i.settingsDialog.items) != 3 {
90		t.Fatal("timestamps submenu did not open")
91	}
92	if item := i.settingsDialog.items[0]; item.key != "chat_timestamps" || item.value {
93		t.Fatal("missing disabled-by-default toggle")
94	}
95	item := i.settingsDialog.items[1]
96	if item.key != "chat_timestamp_interval_minutes" || item.options[item.choice].value != "0" {
97		t.Fatal("incorrect default interval selection")
98	}
99	dateItem := i.settingsDialog.items[2]
100	if dateItem.key != "chat_timestamp_date" || dateItem.options[dateItem.choice].value != "day_start" {
101		t.Fatal("missing date display setting")
102	}
103	i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEsc})
104	if !i.settingsDialog.Active() || i.settingsDialog.title != "settings" {
105		t.Fatal("escape should return to parent settings")
106	}
107	before, _ := i.chatCacheKeyLocked(80)
108	i.applySettingChange(settingsAction{Key: "chat_timestamps", Value: true})
109	if !store.enabled || !i.cfg.ChatTimestamps || !i.view.ChatTimestamps {
110		t.Fatal("toggle not persisted or applied")
111	}
112	after, _ := i.chatCacheKeyLocked(80)
113	if before == after {
114		t.Fatal("toggle did not affect chat cache key")
115	}
116	i.applySettingChange(settingsAction{Key: "chat_timestamp_interval_minutes", StringValue: "0"})
117	if store.minutes != 0 || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 {
118		t.Fatal("zero interval not applied")
119	}
120	for _, value := range []string{"-1", "invalid"} {
121		i.applyChatTimestampInterval(value)
122		if tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 {
123			t.Fatal("invalid interval changed state")
124		}
125	}
126	store.err = errors.New("cannot save")
127	i.applyChatTimestamps(false)
128	i.applyChatTimestampInterval("10")
129	if !i.view.ChatTimestamps || !i.cfg.ChatTimestamps || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 || i.statusErr == "" {
130		t.Fatal("failed save changed live state or lost the error")
131	}
132}
133
134func TestChatTimestampsSurviveSessionResume(t *testing.T) {
135	root := t.TempDir()
136	session, err := core.NewSession(root, root, "ollama", "test", "test")
137	if err != nil {
138		t.Fatal(err)
139	}
140	t.Cleanup(func() { _ = session.Close() })
141	start := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
142	messages := []provider.Message{
143		{Role: provider.RoleUser, Time: start, Content: []provider.Content{provider.TextBlock{Text: "hello"}}},
144		{Role: provider.RoleAssistant, Time: start.Add(5 * time.Minute), Content: []provider.Content{provider.TextBlock{Text: "world"}}},
145		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "legacy message"}}},
146	}
147	for _, message := range messages {
148		if err := session.AppendMessage(message); err != nil {
149			t.Fatal(err)
150		}
151	}
152	if err := session.Close(); err != nil {
153		t.Fatal(err)
154	}
155	reopened, restored, err := core.OpenSession(session.Path)
156	if err != nil {
157		t.Fatal(err)
158	}
159	t.Cleanup(func() { _ = reopened.Close() })
160	before := tui.View{Theme: tui.Dark, ChatTimestamps: true, Messages: messages}
161	after := tui.View{Theme: tui.Dark, ChatTimestamps: true, Messages: restored}
162	if !reflect.DeepEqual(before.Build(80), after.Build(80)) {
163		t.Fatal("resumed transcript changed timestamps")
164	}
165}
166
167func TestChatTimestampCustomIntervalStartup(t *testing.T) {
168	minutes := 7
169	i := NewInteractive(InteractiveConfig{ChatTimestamps: true, ChatTimestampIntervalMinutes: &minutes})
170	if !i.view.ChatTimestamps || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 7 {
171		t.Fatal("saved settings not applied on startup")
172	}
173	item := i.chatTimestampIntervalSetting()
174	if item.options[item.choice].value != "7" {
175		t.Fatal("custom interval not represented in picker")
176	}
177	before, _ := i.chatCacheKeyLocked(80)
178	i.view.ChatTimestampIntervalMinutes = nil
179	after, _ := i.chatCacheKeyLocked(80)
180	if before == after {
181		t.Fatal("interval missing from cache key")
182	}
183}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close