detached 40a7947

feat: add configurable chat timestamps

commit
40a794781b05452a92c525dc861607dd5931dd05
author
patriceckhart · · 2026-09-22 14:16 UTC
parent
98b5493
signature
unsigned
11 files · +740 -39
M README.md +3 -0
@@ -477,6 +477,9 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
477477
478478 - **fuzzy skill suggestions**: opt in to relevance-ranked, case-insensitive subsequence matching for `/skill:` names. For example, `/skill:review` or `/skill:crv` can find `code-review`. Matching characters appear in bold. Empty queries stay alphabetical, score ties use alphabetical order, and exact names retain priority. Off by default; missing or `false` keeps prefix matching. Changes apply immediately and persist as `fuzzy_skill_suggest` in `$ZOT_HOME/config.json`. Other slash commands and skill invocation lookup are unchanged. See [Skills](docs/skills.md).
479479 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
480+- **timestamps**: opens a sub-view with an **enabled** on/off toggle, an **interval** selector, and **date display** options. Show stored message times in muted text on existing separator rows, aligned with the tool box's right edge and leaving the same two-column right margin, without adding rows. Off by default, persisted as `chat_timestamps`. Times use the local timezone in `HH:MM` format, with a date on the first timestamp and when the day changes. User messages, completed assistant messages, and tool results are eligible. Missing timestamps and compaction summaries are skipped. Labels that do not fit in a narrow terminal are omitted. Timestamps appear after messages complete, not during streaming, and do not mark unread portions of a long response. Existing session timestamps are reused on resume.
481+- **timestamps > date display**: choose **Start of each day** (default) to include `YYYY-MM-DD` on the first timestamp and when the local day changes, or **Every timestamp** to display `YYYY-MM-DD HH:MM` on every shown timestamp. Stored as `chat_timestamp_date` with values `day_start` or `every_message`. Missing or unknown values use `day_start`. This does not change which messages qualify under the interval setting.
482+- **timestamps > interval**: minimum minutes since the last selected timestamp, default 0 (every eligible message). The first eligible message and day changes always qualify. Choose every message (0), 1, 5, 10, 15, 30, or 60 minutes in `/settings` > timestamps > interval, or set any nonnegative integer in `chat_timestamp_interval_minutes` in `config.json`. Missing or negative values use 0. Explicitly saved intervals are preserved. All timestamp settings apply immediately to the transcript. They affect only the interactive display, not model input or print/JSON/RPC output.
480483 - **collapse tool call rendering**: retain each tool-call header and the last non-empty rendered preview line, hiding the rest of the body. The preview may be an exit status, output line, or truncation summary; failed calls also retain an explicit error indicator. Boxed, flat, and compact layouts are preserved. `ctrl+o` expands the full output. Off by default. Changes apply immediately and persist to `config.json` as `collapse_tool_call`.
481484 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
482485 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
@@ -477,6 +477,9 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
477 477
478 - **fuzzy skill suggestions**: opt in to relevance-ranked, case-insensitive subsequence matching for `/skill:` names. For example, `/skill:review` or `/skill:crv` can find `code-review`. Matching characters appear in bold. Empty queries stay alphabetical, score ties use alphabetical order, and exact names retain priority. Off by default; missing or `false` keeps prefix matching. Changes apply immediately and persist as `fuzzy_skill_suggest` in `$ZOT_HOME/config.json`. Other slash commands and skill invocation lookup are unchanged. See [Skills](docs/skills.md).478 - **fuzzy skill suggestions**: opt in to relevance-ranked, case-insensitive subsequence matching for `/skill:` names. For example, `/skill:review` or `/skill:crv` can find `code-review`. Matching characters appear in bold. Empty queries stay alphabetical, score ties use alphabetical order, and exact names retain priority. Off by default; missing or `false` keeps prefix matching. Changes apply immediately and persist as `fuzzy_skill_suggest` in `$ZOT_HOME/config.json`. Other slash commands and skill invocation lookup are unchanged. See [Skills](docs/skills.md).
479 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.479 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
480+- **timestamps**: opens a sub-view with an **enabled** on/off toggle, an **interval** selector, and **date display** options. Show stored message times in muted text on existing separator rows, aligned with the tool box's right edge and leaving the same two-column right margin, without adding rows. Off by default, persisted as `chat_timestamps`. Times use the local timezone in `HH:MM` format, with a date on the first timestamp and when the day changes. User messages, completed assistant messages, and tool results are eligible. Missing timestamps and compaction summaries are skipped. Labels that do not fit in a narrow terminal are omitted. Timestamps appear after messages complete, not during streaming, and do not mark unread portions of a long response. Existing session timestamps are reused on resume.
481+- **timestamps > date display**: choose **Start of each day** (default) to include `YYYY-MM-DD` on the first timestamp and when the local day changes, or **Every timestamp** to display `YYYY-MM-DD HH:MM` on every shown timestamp. Stored as `chat_timestamp_date` with values `day_start` or `every_message`. Missing or unknown values use `day_start`. This does not change which messages qualify under the interval setting.
482+- **timestamps > interval**: minimum minutes since the last selected timestamp, default 0 (every eligible message). The first eligible message and day changes always qualify. Choose every message (0), 1, 5, 10, 15, 30, or 60 minutes in `/settings` > timestamps > interval, or set any nonnegative integer in `chat_timestamp_interval_minutes` in `config.json`. Missing or negative values use 0. Explicitly saved intervals are preserved. All timestamp settings apply immediately to the transcript. They affect only the interactive display, not model input or print/JSON/RPC output.
480 - **collapse tool call rendering**: retain each tool-call header and the last non-empty rendered preview line, hiding the rest of the body. The preview may be an exit status, output line, or truncation summary; failed calls also retain an explicit error indicator. Boxed, flat, and compact layouts are preserved. `ctrl+o` expands the full output. Off by default. Changes apply immediately and persist to `config.json` as `collapse_tool_call`.483 - **collapse tool call rendering**: retain each tool-call header and the last non-empty rendered preview line, hiding the rest of the body. The preview may be an exit status, output line, or truncation summary; failed calls also retain an explicit error indicator. Boxed, flat, and compact layouts are preserved. `ctrl+o` expands the full output. Off by default. Changes apply immediately and persist to `config.json` as `collapse_tool_call`.
481 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.484 - **show loaded resources at startup**: list the active Zotfile agent (for `zot run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
482 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).485 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
A packages/agent/chat_timestamps_test.go +49 -0
@@ -0,0 +1,49 @@
1+package agent
2+
3+import "testing"
4+
5+func TestChatTimestampSettingsPersistence(t *testing.T) {
6+ t.Setenv("ZOT_HOME", t.TempDir())
7+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
8+ t.Fatal(err)
9+ }
10+ cfg, err := LoadConfig()
11+ if err != nil || cfg.ChatTimestamps || cfg.ChatTimestampIntervalMinutes != nil {
12+ t.Fatalf("unexpected defaults: %+v, %v", cfg, err)
13+ }
14+ store := configSettingsStore{}
15+ for _, mode := range []string{"every_message", "day_start", "", "invalid"} {
16+ if err := store.SetChatTimestampDate(mode); err != nil {
17+ t.Fatal(err)
18+ }
19+ cfg, err := LoadConfig()
20+ want := "day_start"
21+ if mode == "every_message" {
22+ want = mode
23+ }
24+ if err != nil || cfg.ChatTimestampDate != want || cfg.Theme != "dark" {
25+ t.Fatalf("date mode not persisted: %+v, %v", cfg, err)
26+ }
27+ }
28+ for _, enabled := range []bool{true, false} {
29+ if err := store.SetChatTimestamps(enabled); err != nil {
30+ t.Fatal(err)
31+ }
32+ for _, minutes := range []int{0, 1, 5, 7, -1} {
33+ if err := store.SetChatTimestampInterval(minutes); err != nil {
34+ t.Fatal(err)
35+ }
36+ cfg, err := LoadConfig()
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ want := minutes
41+ if want < 0 {
42+ want = 0
43+ }
44+ if cfg.ChatTimestamps != enabled || cfg.ChatTimestampIntervalMinutes == nil || *cfg.ChatTimestampIntervalMinutes != want || cfg.Theme != "dark" {
45+ t.Fatalf("settings not preserved: %+v", cfg)
46+ }
47+ }
48+ }
49+}
@@ -0,0 +1,49 @@
1+package agent
2+
3+import "testing"
4+
5+func TestChatTimestampSettingsPersistence(t *testing.T) {
6+ t.Setenv("ZOT_HOME", t.TempDir())
7+ if err := SaveConfig(Config{Theme: "dark"}); err != nil {
8+ t.Fatal(err)
9+ }
10+ cfg, err := LoadConfig()
11+ if err != nil || cfg.ChatTimestamps || cfg.ChatTimestampIntervalMinutes != nil {
12+ t.Fatalf("unexpected defaults: %+v, %v", cfg, err)
13+ }
14+ store := configSettingsStore{}
15+ for _, mode := range []string{"every_message", "day_start", "", "invalid"} {
16+ if err := store.SetChatTimestampDate(mode); err != nil {
17+ t.Fatal(err)
18+ }
19+ cfg, err := LoadConfig()
20+ want := "day_start"
21+ if mode == "every_message" {
22+ want = mode
23+ }
24+ if err != nil || cfg.ChatTimestampDate != want || cfg.Theme != "dark" {
25+ t.Fatalf("date mode not persisted: %+v, %v", cfg, err)
26+ }
27+ }
28+ for _, enabled := range []bool{true, false} {
29+ if err := store.SetChatTimestamps(enabled); err != nil {
30+ t.Fatal(err)
31+ }
32+ for _, minutes := range []int{0, 1, 5, 7, -1} {
33+ if err := store.SetChatTimestampInterval(minutes); err != nil {
34+ t.Fatal(err)
35+ }
36+ cfg, err := LoadConfig()
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ want := minutes
41+ if want < 0 {
42+ want = 0
43+ }
44+ if cfg.ChatTimestamps != enabled || cfg.ChatTimestampIntervalMinutes == nil || *cfg.ChatTimestampIntervalMinutes != want || cfg.Theme != "dark" {
45+ t.Fatalf("settings not preserved: %+v", cfg)
46+ }
47+ }
48+ }
49+}
M packages/agent/cli.go +3 -0
@@ -1345,6 +1345,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
13451345 RespectGitignore: initialCfg.RespectGitignore,
13461346 CompactMode: initialCfg.CompactMode,
13471347 CollapseToolCall: initialCfg.CollapseToolCall,
1348+ ChatTimestamps: initialCfg.ChatTimestamps,
1349+ ChatTimestampDate: initialCfg.ChatTimestampDate,
1350+ ChatTimestampIntervalMinutes: initialCfg.ChatTimestampIntervalMinutes,
13481351 TUIInputStyle: initialCfg.TUIInputStyle,
13491352 TUIStatusPosition: initialCfg.TUIStatusPosition,
13501353 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
@@ -1345,6 +1345,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1345 RespectGitignore: initialCfg.RespectGitignore,1345 RespectGitignore: initialCfg.RespectGitignore,
1346 CompactMode: initialCfg.CompactMode,1346 CompactMode: initialCfg.CompactMode,
1347 CollapseToolCall: initialCfg.CollapseToolCall,1347 CollapseToolCall: initialCfg.CollapseToolCall,
1348+ ChatTimestamps: initialCfg.ChatTimestamps,
1349+ ChatTimestampDate: initialCfg.ChatTimestampDate,
1350+ ChatTimestampIntervalMinutes: initialCfg.ChatTimestampIntervalMinutes,
1348 TUIInputStyle: initialCfg.TUIInputStyle,1351 TUIInputStyle: initialCfg.TUIInputStyle,
1349 TUIStatusPosition: initialCfg.TUIStatusPosition,1352 TUIStatusPosition: initialCfg.TUIStatusPosition,
1350 TUIWorkingPosition: initialCfg.TUIWorkingPosition,1353 TUIWorkingPosition: initialCfg.TUIWorkingPosition,
M packages/agent/config.go +7 -0
@@ -113,6 +113,13 @@ type Config struct {
113113 // Toggle from /settings.
114114 CollapseToolCall *bool `json:"collapse_tool_call,omitempty"`
115115
116+ // ChatTimestamps shows stored message times on existing transcript separators.
117+ ChatTimestamps bool `json:"chat_timestamps,omitempty"`
118+ // ChatTimestampIntervalMinutes defaults to zero, showing every message.
119+ ChatTimestampIntervalMinutes *int `json:"chat_timestamp_interval_minutes,omitempty"`
120+ // ChatTimestampDate is day_start (default) or every_message.
121+ ChatTimestampDate string `json:"chat_timestamp_date,omitempty"`
122+
116123 // ShowInstructionsAtStartup lists loaded context files, extensions,
117124 // and user-installed skills above the transcript. Off by default;
118125 // nil/missing means disabled. Toggle from /settings.
@@ -113,6 +113,13 @@ type Config struct {
113 // Toggle from /settings.113 // Toggle from /settings.
114 CollapseToolCall *bool `json:"collapse_tool_call,omitempty"`114 CollapseToolCall *bool `json:"collapse_tool_call,omitempty"`
115 115
116+ // ChatTimestamps shows stored message times on existing transcript separators.
117+ ChatTimestamps bool `json:"chat_timestamps,omitempty"`
118+ // ChatTimestampIntervalMinutes defaults to zero, showing every message.
119+ ChatTimestampIntervalMinutes *int `json:"chat_timestamp_interval_minutes,omitempty"`
120+ // ChatTimestampDate is day_start (default) or every_message.
121+ ChatTimestampDate string `json:"chat_timestamp_date,omitempty"`
122+
116 // ShowInstructionsAtStartup lists loaded context files, extensions,123 // ShowInstructionsAtStartup lists loaded context files, extensions,
117 // and user-installed skills above the transcript. Off by default;124 // and user-installed skills above the transcript. Off by default;
118 // nil/missing means disabled. Toggle from /settings.125 // nil/missing means disabled. Toggle from /settings.
A packages/agent/modes/chat_timestamps.go +130 -0
@@ -0,0 +1,130 @@
1+package modes
2+
3+import (
4+ "strconv"
5+
6+ "github.com/patriceckhart/zot/packages/tui"
7+)
8+
9+// Optional to preserve compatibility with embedders implementing SettingsStore.
10+type chatTimestampSettingsStore interface {
11+ SetChatTimestamps(bool) error
12+ SetChatTimestampInterval(int) error
13+}
14+
15+type chatTimestampDateSettingsStore interface {
16+ SetChatTimestampDate(string) error
17+}
18+
19+func (i *Interactive) chatTimestampDateSetting() settingsItem {
20+ choice := 0
21+ if tui.ChatTimestampDateMode(i.cfg.ChatTimestampDate) == "every_message" {
22+ choice = 1
23+ }
24+ return settingsItem{
25+ key: "chat_timestamp_date",
26+ label: "date display",
27+ desc: "choose when to include the date beside the time",
28+ choice: choice,
29+ options: []settingsOption{
30+ {value: "day_start", label: "Start of each day", desc: "show the date on the first timestamp and when the day changes"},
31+ {value: "every_message", label: "Every timestamp", desc: "include the date on every displayed timestamp"},
32+ },
33+ }
34+}
35+
36+func (i *Interactive) applyChatTimestampDate(value string) {
37+ mode := tui.ChatTimestampDateMode(value)
38+ if store, ok := i.cfg.SettingsStore.(chatTimestampDateSettingsStore); ok {
39+ if err := store.SetChatTimestampDate(mode); err != nil {
40+ i.chatTimestampSettingError(err)
41+ return
42+ }
43+ }
44+ i.mu.Lock()
45+ i.cfg.ChatTimestampDate = mode
46+ i.view.ChatTimestampDate = mode
47+ i.statusOK = "timestamp date display updated"
48+ i.statusErr = ""
49+ i.mu.Unlock()
50+ i.repaintChatTimestamps()
51+}
52+
53+func (i *Interactive) chatTimestampIntervalSetting() settingsItem {
54+ minutes := tui.ChatTimestampInterval(i.cfg.ChatTimestampIntervalMinutes)
55+ item := settingsItem{
56+ key: "chat_timestamp_interval_minutes",
57+ label: "interval",
58+ desc: "minimum minutes since the last timestamp, zero shows every completed message",
59+ }
60+ values := []int{0, 1, 5, 10, 15, 30, 60}
61+ found := false
62+ for _, value := range values {
63+ found = found || value == minutes
64+ }
65+ if !found {
66+ values = append(values, minutes)
67+ }
68+ for idx, value := range values {
69+ label := strconv.Itoa(value) + " min"
70+ if value == 0 {
71+ label = "every message"
72+ }
73+ item.options = append(item.options, settingsOption{value: strconv.Itoa(value), label: label})
74+ if value == minutes {
75+ item.choice = idx
76+ }
77+ }
78+ return item
79+}
80+
81+func (i *Interactive) applyChatTimestamps(enabled bool) {
82+ if store, ok := i.cfg.SettingsStore.(chatTimestampSettingsStore); ok {
83+ if err := store.SetChatTimestamps(enabled); err != nil {
84+ i.chatTimestampSettingError(err)
85+ return
86+ }
87+ }
88+ i.mu.Lock()
89+ i.cfg.ChatTimestamps = enabled
90+ i.view.ChatTimestamps = enabled
91+ i.statusOK = "chat timestamps " + onOff(enabled)
92+ i.statusErr = ""
93+ i.mu.Unlock()
94+ i.repaintChatTimestamps()
95+}
96+
97+func (i *Interactive) applyChatTimestampInterval(value string) {
98+ minutes, err := strconv.Atoi(value)
99+ if err != nil || minutes < 0 {
100+ return
101+ }
102+ if store, ok := i.cfg.SettingsStore.(chatTimestampSettingsStore); ok {
103+ if err := store.SetChatTimestampInterval(minutes); err != nil {
104+ i.chatTimestampSettingError(err)
105+ return
106+ }
107+ }
108+ i.mu.Lock()
109+ i.cfg.ChatTimestampIntervalMinutes = &minutes
110+ i.view.ChatTimestampIntervalMinutes = &minutes
111+ i.statusOK = "chat timestamp interval " + value + " min"
112+ i.statusErr = ""
113+ i.mu.Unlock()
114+ i.repaintChatTimestamps()
115+}
116+
117+func (i *Interactive) chatTimestampSettingError(err error) {
118+ i.mu.Lock()
119+ i.statusErr = "settings: " + err.Error()
120+ i.statusOK = ""
121+ i.mu.Unlock()
122+ i.invalidate()
123+}
124+
125+func (i *Interactive) repaintChatTimestamps() {
126+ if i.rend != nil {
127+ i.rend.Clear()
128+ }
129+ i.invalidate()
130+}
@@ -0,0 +1,130 @@
1+package modes
2+
3+import (
4+ "strconv"
5+
6+ "github.com/patriceckhart/zot/packages/tui"
7+)
8+
9+// Optional to preserve compatibility with embedders implementing SettingsStore.
10+type chatTimestampSettingsStore interface {
11+ SetChatTimestamps(bool) error
12+ SetChatTimestampInterval(int) error
13+}
14+
15+type chatTimestampDateSettingsStore interface {
16+ SetChatTimestampDate(string) error
17+}
18+
19+func (i *Interactive) chatTimestampDateSetting() settingsItem {
20+ choice := 0
21+ if tui.ChatTimestampDateMode(i.cfg.ChatTimestampDate) == "every_message" {
22+ choice = 1
23+ }
24+ return settingsItem{
25+ key: "chat_timestamp_date",
26+ label: "date display",
27+ desc: "choose when to include the date beside the time",
28+ choice: choice,
29+ options: []settingsOption{
30+ {value: "day_start", label: "Start of each day", desc: "show the date on the first timestamp and when the day changes"},
31+ {value: "every_message", label: "Every timestamp", desc: "include the date on every displayed timestamp"},
32+ },
33+ }
34+}
35+
36+func (i *Interactive) applyChatTimestampDate(value string) {
37+ mode := tui.ChatTimestampDateMode(value)
38+ if store, ok := i.cfg.SettingsStore.(chatTimestampDateSettingsStore); ok {
39+ if err := store.SetChatTimestampDate(mode); err != nil {
40+ i.chatTimestampSettingError(err)
41+ return
42+ }
43+ }
44+ i.mu.Lock()
45+ i.cfg.ChatTimestampDate = mode
46+ i.view.ChatTimestampDate = mode
47+ i.statusOK = "timestamp date display updated"
48+ i.statusErr = ""
49+ i.mu.Unlock()
50+ i.repaintChatTimestamps()
51+}
52+
53+func (i *Interactive) chatTimestampIntervalSetting() settingsItem {
54+ minutes := tui.ChatTimestampInterval(i.cfg.ChatTimestampIntervalMinutes)
55+ item := settingsItem{
56+ key: "chat_timestamp_interval_minutes",
57+ label: "interval",
58+ desc: "minimum minutes since the last timestamp, zero shows every completed message",
59+ }
60+ values := []int{0, 1, 5, 10, 15, 30, 60}
61+ found := false
62+ for _, value := range values {
63+ found = found || value == minutes
64+ }
65+ if !found {
66+ values = append(values, minutes)
67+ }
68+ for idx, value := range values {
69+ label := strconv.Itoa(value) + " min"
70+ if value == 0 {
71+ label = "every message"
72+ }
73+ item.options = append(item.options, settingsOption{value: strconv.Itoa(value), label: label})
74+ if value == minutes {
75+ item.choice = idx
76+ }
77+ }
78+ return item
79+}
80+
81+func (i *Interactive) applyChatTimestamps(enabled bool) {
82+ if store, ok := i.cfg.SettingsStore.(chatTimestampSettingsStore); ok {
83+ if err := store.SetChatTimestamps(enabled); err != nil {
84+ i.chatTimestampSettingError(err)
85+ return
86+ }
87+ }
88+ i.mu.Lock()
89+ i.cfg.ChatTimestamps = enabled
90+ i.view.ChatTimestamps = enabled
91+ i.statusOK = "chat timestamps " + onOff(enabled)
92+ i.statusErr = ""
93+ i.mu.Unlock()
94+ i.repaintChatTimestamps()
95+}
96+
97+func (i *Interactive) applyChatTimestampInterval(value string) {
98+ minutes, err := strconv.Atoi(value)
99+ if err != nil || minutes < 0 {
100+ return
101+ }
102+ if store, ok := i.cfg.SettingsStore.(chatTimestampSettingsStore); ok {
103+ if err := store.SetChatTimestampInterval(minutes); err != nil {
104+ i.chatTimestampSettingError(err)
105+ return
106+ }
107+ }
108+ i.mu.Lock()
109+ i.cfg.ChatTimestampIntervalMinutes = &minutes
110+ i.view.ChatTimestampIntervalMinutes = &minutes
111+ i.statusOK = "chat timestamp interval " + value + " min"
112+ i.statusErr = ""
113+ i.mu.Unlock()
114+ i.repaintChatTimestamps()
115+}
116+
117+func (i *Interactive) chatTimestampSettingError(err error) {
118+ i.mu.Lock()
119+ i.statusErr = "settings: " + err.Error()
120+ i.statusOK = ""
121+ i.mu.Unlock()
122+ i.invalidate()
123+}
124+
125+func (i *Interactive) repaintChatTimestamps() {
126+ if i.rend != nil {
127+ i.rend.Clear()
128+ }
129+ i.invalidate()
130+}
A packages/agent/modes/chat_timestamps_test.go +179 -0
@@ -0,0 +1,179 @@
1+package modes
2+
3+import (
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+
14+type timestampSettingsTestStore struct {
15+ SettingsStore
16+ enabled bool
17+ minutes int
18+ dateMode string
19+ err error
20+}
21+
22+func (s *timestampSettingsTestStore) SetChatTimestamps(enabled bool) error {
23+ s.enabled = enabled
24+ return s.err
25+}
26+
27+func (s *timestampSettingsTestStore) SetChatTimestampInterval(minutes int) error {
28+ s.minutes = minutes
29+ return s.err
30+}
31+
32+func (s *timestampSettingsTestStore) SetChatTimestampDate(mode string) error {
33+ s.dateMode = mode
34+ return s.err
35+}
36+
37+func TestChatTimestampDateSetting(t *testing.T) {
38+ store := &timestampSettingsTestStore{}
39+ i := NewInteractive(InteractiveConfig{SettingsStore: store})
40+ item := i.chatTimestampDateSetting()
41+ if item.options[item.choice].value != "day_start" {
42+ t.Fatal("incorrect date default")
43+ }
44+ before, _ := i.chatCacheKeyLocked(80)
45+ i.applySettingChange(settingsAction{Key: "chat_timestamp_date", StringValue: "every_message"})
46+ if store.dateMode != "every_message" || i.view.ChatTimestampDate != "every_message" || i.cfg.ChatTimestampDate != "every_message" {
47+ t.Fatal("date mode not applied or persisted")
48+ }
49+ after, _ := i.chatCacheKeyLocked(80)
50+ if before == after {
51+ t.Fatal("date setting missing from cache key")
52+ }
53+ store.err = errors.New("cannot save")
54+ i.applyChatTimestampDate("day_start")
55+ if i.view.ChatTimestampDate != "every_message" || i.statusErr == "" {
56+ t.Fatal("failed save changed live date setting")
57+ }
58+ restarted := NewInteractive(InteractiveConfig{ChatTimestampDate: "every_message"})
59+ if restarted.view.ChatTimestampDate != "every_message" {
60+ t.Fatal("saved date mode not restored")
61+ }
62+}
63+
64+func TestChatTimestampSettings(t *testing.T) {
65+ store := &timestampSettingsTestStore{}
66+ i := NewInteractive(InteractiveConfig{SettingsStore: store})
67+ if i.view.ChatTimestamps || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 {
68+ t.Fatal("incorrect startup defaults")
69+ }
70+ i.openSettingsDialog()
71+ found := false
72+ for idx, item := range i.settingsDialog.items {
73+ if item.key == "chat_timestamps" || item.key == "chat_timestamp_interval_minutes" {
74+ t.Fatal("timestamp controls must not be top-level settings")
75+ }
76+ if item.key == "timestamps" {
77+ found = true
78+ i.settingsDialog.cursor = idx
79+ }
80+ }
81+ if !found {
82+ t.Fatal("missing timestamps submenu")
83+ }
84+ i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
85+ if i.settingsDialog.title != "settings: timestamps" || len(i.settingsDialog.items) != 3 {
86+ t.Fatal("timestamps submenu did not open")
87+ }
88+ if item := i.settingsDialog.items[0]; item.key != "chat_timestamps" || item.value {
89+ t.Fatal("missing disabled-by-default toggle")
90+ }
91+ item := i.settingsDialog.items[1]
92+ if item.key != "chat_timestamp_interval_minutes" || item.options[item.choice].value != "0" {
93+ t.Fatal("incorrect default interval selection")
94+ }
95+ dateItem := i.settingsDialog.items[2]
96+ if dateItem.key != "chat_timestamp_date" || dateItem.options[dateItem.choice].value != "day_start" {
97+ t.Fatal("missing date display setting")
98+ }
99+ i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEsc})
100+ if !i.settingsDialog.Active() || i.settingsDialog.title != "settings" {
101+ t.Fatal("escape should return to parent settings")
102+ }
103+ before, _ := i.chatCacheKeyLocked(80)
104+ i.applySettingChange(settingsAction{Key: "chat_timestamps", Value: true})
105+ if !store.enabled || !i.cfg.ChatTimestamps || !i.view.ChatTimestamps {
106+ t.Fatal("toggle not persisted or applied")
107+ }
108+ after, _ := i.chatCacheKeyLocked(80)
109+ if before == after {
110+ t.Fatal("toggle did not affect chat cache key")
111+ }
112+ i.applySettingChange(settingsAction{Key: "chat_timestamp_interval_minutes", StringValue: "0"})
113+ if store.minutes != 0 || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 {
114+ t.Fatal("zero interval not applied")
115+ }
116+ for _, value := range []string{"-1", "invalid"} {
117+ i.applyChatTimestampInterval(value)
118+ if tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 {
119+ t.Fatal("invalid interval changed state")
120+ }
121+ }
122+ store.err = errors.New("cannot save")
123+ i.applyChatTimestamps(false)
124+ i.applyChatTimestampInterval("10")
125+ if !i.view.ChatTimestamps || !i.cfg.ChatTimestamps || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 || i.statusErr == "" {
126+ t.Fatal("failed save changed live state or lost the error")
127+ }
128+}
129+
130+func TestChatTimestampsSurviveSessionResume(t *testing.T) {
131+ root := t.TempDir()
132+ session, err := core.NewSession(root, root, "ollama", "test", "test")
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ t.Cleanup(func() { _ = session.Close() })
137+ start := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
138+ messages := []provider.Message{
139+ {Role: provider.RoleUser, Time: start, Content: []provider.Content{provider.TextBlock{Text: "hello"}}},
140+ {Role: provider.RoleAssistant, Time: start.Add(5 * time.Minute), Content: []provider.Content{provider.TextBlock{Text: "world"}}},
141+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "legacy message"}}},
142+ }
143+ for _, message := range messages {
144+ if err := session.AppendMessage(message); err != nil {
145+ t.Fatal(err)
146+ }
147+ }
148+ if err := session.Close(); err != nil {
149+ t.Fatal(err)
150+ }
151+ reopened, restored, err := core.OpenSession(session.Path)
152+ if err != nil {
153+ t.Fatal(err)
154+ }
155+ t.Cleanup(func() { _ = reopened.Close() })
156+ before := tui.View{Theme: tui.Dark, ChatTimestamps: true, Messages: messages}
157+ after := tui.View{Theme: tui.Dark, ChatTimestamps: true, Messages: restored}
158+ if !reflect.DeepEqual(before.Build(80), after.Build(80)) {
159+ t.Fatal("resumed transcript changed timestamps")
160+ }
161+}
162+
163+func TestChatTimestampCustomIntervalStartup(t *testing.T) {
164+ minutes := 7
165+ i := NewInteractive(InteractiveConfig{ChatTimestamps: true, ChatTimestampIntervalMinutes: &minutes})
166+ if !i.view.ChatTimestamps || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 7 {
167+ t.Fatal("saved settings not applied on startup")
168+ }
169+ item := i.chatTimestampIntervalSetting()
170+ if item.options[item.choice].value != "7" {
171+ t.Fatal("custom interval not represented in picker")
172+ }
173+ before, _ := i.chatCacheKeyLocked(80)
174+ i.view.ChatTimestampIntervalMinutes = nil
175+ after, _ := i.chatCacheKeyLocked(80)
176+ if before == after {
177+ t.Fatal("interval missing from cache key")
178+ }
179+}
@@ -0,0 +1,179 @@
1+package modes
2+
3+import (
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+
14+type timestampSettingsTestStore struct {
15+ SettingsStore
16+ enabled bool
17+ minutes int
18+ dateMode string
19+ err error
20+}
21+
22+func (s *timestampSettingsTestStore) SetChatTimestamps(enabled bool) error {
23+ s.enabled = enabled
24+ return s.err
25+}
26+
27+func (s *timestampSettingsTestStore) SetChatTimestampInterval(minutes int) error {
28+ s.minutes = minutes
29+ return s.err
30+}
31+
32+func (s *timestampSettingsTestStore) SetChatTimestampDate(mode string) error {
33+ s.dateMode = mode
34+ return s.err
35+}
36+
37+func TestChatTimestampDateSetting(t *testing.T) {
38+ store := &timestampSettingsTestStore{}
39+ i := NewInteractive(InteractiveConfig{SettingsStore: store})
40+ item := i.chatTimestampDateSetting()
41+ if item.options[item.choice].value != "day_start" {
42+ t.Fatal("incorrect date default")
43+ }
44+ before, _ := i.chatCacheKeyLocked(80)
45+ i.applySettingChange(settingsAction{Key: "chat_timestamp_date", StringValue: "every_message"})
46+ if store.dateMode != "every_message" || i.view.ChatTimestampDate != "every_message" || i.cfg.ChatTimestampDate != "every_message" {
47+ t.Fatal("date mode not applied or persisted")
48+ }
49+ after, _ := i.chatCacheKeyLocked(80)
50+ if before == after {
51+ t.Fatal("date setting missing from cache key")
52+ }
53+ store.err = errors.New("cannot save")
54+ i.applyChatTimestampDate("day_start")
55+ if i.view.ChatTimestampDate != "every_message" || i.statusErr == "" {
56+ t.Fatal("failed save changed live date setting")
57+ }
58+ restarted := NewInteractive(InteractiveConfig{ChatTimestampDate: "every_message"})
59+ if restarted.view.ChatTimestampDate != "every_message" {
60+ t.Fatal("saved date mode not restored")
61+ }
62+}
63+
64+func TestChatTimestampSettings(t *testing.T) {
65+ store := &timestampSettingsTestStore{}
66+ i := NewInteractive(InteractiveConfig{SettingsStore: store})
67+ if i.view.ChatTimestamps || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 {
68+ t.Fatal("incorrect startup defaults")
69+ }
70+ i.openSettingsDialog()
71+ found := false
72+ for idx, item := range i.settingsDialog.items {
73+ if item.key == "chat_timestamps" || item.key == "chat_timestamp_interval_minutes" {
74+ t.Fatal("timestamp controls must not be top-level settings")
75+ }
76+ if item.key == "timestamps" {
77+ found = true
78+ i.settingsDialog.cursor = idx
79+ }
80+ }
81+ if !found {
82+ t.Fatal("missing timestamps submenu")
83+ }
84+ i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
85+ if i.settingsDialog.title != "settings: timestamps" || len(i.settingsDialog.items) != 3 {
86+ t.Fatal("timestamps submenu did not open")
87+ }
88+ if item := i.settingsDialog.items[0]; item.key != "chat_timestamps" || item.value {
89+ t.Fatal("missing disabled-by-default toggle")
90+ }
91+ item := i.settingsDialog.items[1]
92+ if item.key != "chat_timestamp_interval_minutes" || item.options[item.choice].value != "0" {
93+ t.Fatal("incorrect default interval selection")
94+ }
95+ dateItem := i.settingsDialog.items[2]
96+ if dateItem.key != "chat_timestamp_date" || dateItem.options[dateItem.choice].value != "day_start" {
97+ t.Fatal("missing date display setting")
98+ }
99+ i.settingsDialog.HandleKey(tui.Key{Kind: tui.KeyEsc})
100+ if !i.settingsDialog.Active() || i.settingsDialog.title != "settings" {
101+ t.Fatal("escape should return to parent settings")
102+ }
103+ before, _ := i.chatCacheKeyLocked(80)
104+ i.applySettingChange(settingsAction{Key: "chat_timestamps", Value: true})
105+ if !store.enabled || !i.cfg.ChatTimestamps || !i.view.ChatTimestamps {
106+ t.Fatal("toggle not persisted or applied")
107+ }
108+ after, _ := i.chatCacheKeyLocked(80)
109+ if before == after {
110+ t.Fatal("toggle did not affect chat cache key")
111+ }
112+ i.applySettingChange(settingsAction{Key: "chat_timestamp_interval_minutes", StringValue: "0"})
113+ if store.minutes != 0 || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 {
114+ t.Fatal("zero interval not applied")
115+ }
116+ for _, value := range []string{"-1", "invalid"} {
117+ i.applyChatTimestampInterval(value)
118+ if tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 {
119+ t.Fatal("invalid interval changed state")
120+ }
121+ }
122+ store.err = errors.New("cannot save")
123+ i.applyChatTimestamps(false)
124+ i.applyChatTimestampInterval("10")
125+ if !i.view.ChatTimestamps || !i.cfg.ChatTimestamps || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 0 || i.statusErr == "" {
126+ t.Fatal("failed save changed live state or lost the error")
127+ }
128+}
129+
130+func TestChatTimestampsSurviveSessionResume(t *testing.T) {
131+ root := t.TempDir()
132+ session, err := core.NewSession(root, root, "ollama", "test", "test")
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ t.Cleanup(func() { _ = session.Close() })
137+ start := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
138+ messages := []provider.Message{
139+ {Role: provider.RoleUser, Time: start, Content: []provider.Content{provider.TextBlock{Text: "hello"}}},
140+ {Role: provider.RoleAssistant, Time: start.Add(5 * time.Minute), Content: []provider.Content{provider.TextBlock{Text: "world"}}},
141+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "legacy message"}}},
142+ }
143+ for _, message := range messages {
144+ if err := session.AppendMessage(message); err != nil {
145+ t.Fatal(err)
146+ }
147+ }
148+ if err := session.Close(); err != nil {
149+ t.Fatal(err)
150+ }
151+ reopened, restored, err := core.OpenSession(session.Path)
152+ if err != nil {
153+ t.Fatal(err)
154+ }
155+ t.Cleanup(func() { _ = reopened.Close() })
156+ before := tui.View{Theme: tui.Dark, ChatTimestamps: true, Messages: messages}
157+ after := tui.View{Theme: tui.Dark, ChatTimestamps: true, Messages: restored}
158+ if !reflect.DeepEqual(before.Build(80), after.Build(80)) {
159+ t.Fatal("resumed transcript changed timestamps")
160+ }
161+}
162+
163+func TestChatTimestampCustomIntervalStartup(t *testing.T) {
164+ minutes := 7
165+ i := NewInteractive(InteractiveConfig{ChatTimestamps: true, ChatTimestampIntervalMinutes: &minutes})
166+ if !i.view.ChatTimestamps || tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes) != 7 {
167+ t.Fatal("saved settings not applied on startup")
168+ }
169+ item := i.chatTimestampIntervalSetting()
170+ if item.options[item.choice].value != "7" {
171+ t.Fatal("custom interval not represented in picker")
172+ }
173+ before, _ := i.chatCacheKeyLocked(80)
174+ i.view.ChatTimestampIntervalMinutes = nil
175+ after, _ := i.chatCacheKeyLocked(80)
176+ if before == after {
177+ t.Fatal("interval missing from cache key")
178+ }
179+}
M packages/agent/modes/interactive.go +74 -38
@@ -124,6 +124,10 @@ type InteractiveConfig struct {
124124 // and failure status while hiding the rest of the body.
125125 CollapseToolCall *bool
126126
127+ ChatTimestamps bool
128+ ChatTimestampIntervalMinutes *int
129+ ChatTimestampDate string
130+
127131 // TUIInputStyle controls the main input rendering: plain or lines.
128132 TUIInputStyle string
129133
@@ -353,20 +357,23 @@ type modelRefreshResult struct{ err error }
353357
354358 // Interactive is the TUI chat loop.
355359 type chatCacheKey struct {
356- cols int
357- agentRev uint64
358- statusOK string
359- statusErr string
360- help string
361- extNotes string
362- reloadErrors string
363- updateAvailable bool
364- updateCurrent string
365- updateLatest string
366- updateURL string
367- welcomeShowVer bool
368- expandAll bool
369- tailLimit int
360+ cols int
361+ agentRev uint64
362+ statusOK string
363+ statusErr string
364+ help string
365+ extNotes string
366+ reloadErrors string
367+ updateAvailable bool
368+ updateCurrent string
369+ updateLatest string
370+ updateURL string
371+ welcomeShowVer bool
372+ expandAll bool
373+ tailLimit int
374+ chatTimestamps bool
375+ chatTimestampInterval int
376+ chatTimestampDate string
370377 }
371378
372379 // QuickModelShortcut is one configured quick model switch slot.
@@ -666,16 +673,19 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
666673 i := &Interactive{
667674 cfg: cfg,
668675 view: &tui.View{
669- Theme: cfg.Theme,
670- ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
671- FlatTools: cfg.FlatTools,
672- CompactUser: cfg.CompactUser,
673- CompactMode: cfg.CompactMode != nil && *cfg.CompactMode,
674- CollapseToolCall: cfg.CollapseToolCall != nil && *cfg.CollapseToolCall,
675- StartupAgentName: startupAgentName,
676- StartupContextPaths: startupContextPaths,
677- StartupExtensionNames: startupExtensionNames,
678- StartupSkillNames: startupSkillNames,
676+ Theme: cfg.Theme,
677+ ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
678+ FlatTools: cfg.FlatTools,
679+ CompactUser: cfg.CompactUser,
680+ CompactMode: cfg.CompactMode != nil && *cfg.CompactMode,
681+ CollapseToolCall: cfg.CollapseToolCall != nil && *cfg.CollapseToolCall,
682+ ChatTimestamps: cfg.ChatTimestamps,
683+ ChatTimestampDate: tui.ChatTimestampDateMode(cfg.ChatTimestampDate),
684+ ChatTimestampIntervalMinutes: cfg.ChatTimestampIntervalMinutes,
685+ StartupAgentName: startupAgentName,
686+ StartupContextPaths: startupContextPaths,
687+ StartupExtensionNames: startupExtensionNames,
688+ StartupSkillNames: startupSkillNames,
679689 },
680690 // Prompt is the standard half-block accent bar used by chat
681691 // speaker labels too, so the input gutter matches the rest
@@ -1049,20 +1059,23 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
10491059 }
10501060 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
10511061 return chatCacheKey{
1052- cols: cols,
1053- agentRev: rev,
1054- statusOK: i.statusOK,
1055- statusErr: i.statusErr,
1056- help: strings.Join(i.helpBlock, "\n"),
1057- extNotes: strings.Join(i.extNotes, "\n"),
1058- reloadErrors: strings.Join(i.reloadErrors, "\n"),
1059- updateAvailable: i.updateInfo.Available,
1060- updateCurrent: i.updateInfo.Current,
1061- updateLatest: i.updateInfo.Latest,
1062- updateURL: i.updateInfo.URL,
1063- welcomeShowVer: showVer,
1064- expandAll: i.view.ExpandAll,
1065- tailLimit: i.view.TailLimit,
1062+ cols: cols,
1063+ agentRev: rev,
1064+ statusOK: i.statusOK,
1065+ statusErr: i.statusErr,
1066+ help: strings.Join(i.helpBlock, "\n"),
1067+ extNotes: strings.Join(i.extNotes, "\n"),
1068+ reloadErrors: strings.Join(i.reloadErrors, "\n"),
1069+ updateAvailable: i.updateInfo.Available,
1070+ updateCurrent: i.updateInfo.Current,
1071+ updateLatest: i.updateInfo.Latest,
1072+ updateURL: i.updateInfo.URL,
1073+ welcomeShowVer: showVer,
1074+ expandAll: i.view.ExpandAll,
1075+ tailLimit: i.view.TailLimit,
1076+ chatTimestamps: i.view.ChatTimestamps,
1077+ chatTimestampDate: i.view.ChatTimestampDate,
1078+ chatTimestampInterval: tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes),
10661079 }, true
10671080 }
10681081
@@ -3697,6 +3710,21 @@ func (i *Interactive) openSettingsDialog() {
36973710 desc: "retain tool headers, a final preview line, and failure status; ctrl+o expands the full output",
36983711 value: collapseToolCall,
36993712 },
3713+ {
3714+ key: "timestamps",
3715+ label: "timestamps",
3716+ desc: "configure chat timestamps and their interval",
3717+ children: []settingsItem{
3718+ {
3719+ key: "chat_timestamps",
3720+ label: "enabled",
3721+ desc: "show stored message times on existing separator rows after messages complete",
3722+ value: i.cfg.ChatTimestamps,
3723+ },
3724+ i.chatTimestampIntervalSetting(),
3725+ i.chatTimestampDateSetting(),
3726+ },
3727+ },
37003728 {
37013729 key: "show_instructions_at_startup",
37023730 label: "show loaded resources at startup",
@@ -3760,6 +3788,10 @@ func (i *Interactive) applySettingChange(act settingsAction) {
37603788 i.applyReasoningSetting(act.StringValue)
37613789 case act.Key == "theme":
37623790 i.applyThemeSetting(act.StringValue)
3791+ case act.Key == "chat_timestamp_date":
3792+ i.applyChatTimestampDate(act.StringValue)
3793+ case act.Key == "chat_timestamp_interval_minutes":
3794+ i.applyChatTimestampInterval(act.StringValue)
37633795 case act.Key == "auto_compact_threshold":
37643796 i.applyAutoCompactThresholdSetting(act.StringValue)
37653797 case act.Key == "tui_input_style":
@@ -3966,6 +3998,10 @@ func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
39663998 }
39673999
39684000 func (i *Interactive) applySettingToggle(key string, value bool) {
4001+ if key == "chat_timestamps" {
4002+ i.applyChatTimestamps(value)
4003+ return
4004+ }
39694005 // Every setting toggle forces a full repaint at the end — same
39704006 // effect as the user pressing Ctrl+L — so any per-setting visual
39714007 // change (image rendering, status copy, future toggles) lands
@@ -124,6 +124,10 @@ type InteractiveConfig struct {
124 // and failure status while hiding the rest of the body.124 // and failure status while hiding the rest of the body.
125 CollapseToolCall *bool125 CollapseToolCall *bool
126 126
127+ ChatTimestamps bool
128+ ChatTimestampIntervalMinutes *int
129+ ChatTimestampDate string
130+
127 // TUIInputStyle controls the main input rendering: plain or lines.131 // TUIInputStyle controls the main input rendering: plain or lines.
128 TUIInputStyle string132 TUIInputStyle string
129 133
@@ -353,20 +357,23 @@ type modelRefreshResult struct{ err error }
353 357
354 // Interactive is the TUI chat loop.358 // Interactive is the TUI chat loop.
355 type chatCacheKey struct {359 type chatCacheKey struct {
356- cols int360+ cols int
357- agentRev uint64361+ agentRev uint64
358- statusOK string362+ statusOK string
359- statusErr string363+ statusErr string
360- help string364+ help string
361- extNotes string365+ extNotes string
362- reloadErrors string366+ reloadErrors string
363- updateAvailable bool367+ updateAvailable bool
364- updateCurrent string368+ updateCurrent string
365- updateLatest string369+ updateLatest string
366- updateURL string370+ updateURL string
367- welcomeShowVer bool371+ welcomeShowVer bool
368- expandAll bool372+ expandAll bool
369- tailLimit int373+ tailLimit int
374+ chatTimestamps bool
375+ chatTimestampInterval int
376+ chatTimestampDate string
370 }377 }
371 378
372 // QuickModelShortcut is one configured quick model switch slot.379 // QuickModelShortcut is one configured quick model switch slot.
@@ -666,16 +673,19 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
666 i := &Interactive{673 i := &Interactive{
667 cfg: cfg,674 cfg: cfg,
668 view: &tui.View{675 view: &tui.View{
669- Theme: cfg.Theme,676+ Theme: cfg.Theme,
670- ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),677+ ImageProto: effectiveImageProtocol(cfg.InlineImagesEnabled),
671- FlatTools: cfg.FlatTools,678+ FlatTools: cfg.FlatTools,
672- CompactUser: cfg.CompactUser,679+ CompactUser: cfg.CompactUser,
673- CompactMode: cfg.CompactMode != nil && *cfg.CompactMode,680+ CompactMode: cfg.CompactMode != nil && *cfg.CompactMode,
674- CollapseToolCall: cfg.CollapseToolCall != nil && *cfg.CollapseToolCall,681+ CollapseToolCall: cfg.CollapseToolCall != nil && *cfg.CollapseToolCall,
675- StartupAgentName: startupAgentName,682+ ChatTimestamps: cfg.ChatTimestamps,
676- StartupContextPaths: startupContextPaths,683+ ChatTimestampDate: tui.ChatTimestampDateMode(cfg.ChatTimestampDate),
677- StartupExtensionNames: startupExtensionNames,684+ ChatTimestampIntervalMinutes: cfg.ChatTimestampIntervalMinutes,
678- StartupSkillNames: startupSkillNames,685+ StartupAgentName: startupAgentName,
686+ StartupContextPaths: startupContextPaths,
687+ StartupExtensionNames: startupExtensionNames,
688+ StartupSkillNames: startupSkillNames,
679 },689 },
680 // Prompt is the standard half-block accent bar used by chat690 // Prompt is the standard half-block accent bar used by chat
681 // speaker labels too, so the input gutter matches the rest691 // speaker labels too, so the input gutter matches the rest
@@ -1049,20 +1059,23 @@ func (i *Interactive) chatCacheKeyLocked(cols int) (chatCacheKey, bool) {
1049 }1059 }
1050 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration1060 showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration
1051 return chatCacheKey{1061 return chatCacheKey{
1052- cols: cols,1062+ cols: cols,
1053- agentRev: rev,1063+ agentRev: rev,
1054- statusOK: i.statusOK,1064+ statusOK: i.statusOK,
1055- statusErr: i.statusErr,1065+ statusErr: i.statusErr,
1056- help: strings.Join(i.helpBlock, "\n"),1066+ help: strings.Join(i.helpBlock, "\n"),
1057- extNotes: strings.Join(i.extNotes, "\n"),1067+ extNotes: strings.Join(i.extNotes, "\n"),
1058- reloadErrors: strings.Join(i.reloadErrors, "\n"),1068+ reloadErrors: strings.Join(i.reloadErrors, "\n"),
1059- updateAvailable: i.updateInfo.Available,1069+ updateAvailable: i.updateInfo.Available,
1060- updateCurrent: i.updateInfo.Current,1070+ updateCurrent: i.updateInfo.Current,
1061- updateLatest: i.updateInfo.Latest,1071+ updateLatest: i.updateInfo.Latest,
1062- updateURL: i.updateInfo.URL,1072+ updateURL: i.updateInfo.URL,
1063- welcomeShowVer: showVer,1073+ welcomeShowVer: showVer,
1064- expandAll: i.view.ExpandAll,1074+ expandAll: i.view.ExpandAll,
1065- tailLimit: i.view.TailLimit,1075+ tailLimit: i.view.TailLimit,
1076+ chatTimestamps: i.view.ChatTimestamps,
1077+ chatTimestampDate: i.view.ChatTimestampDate,
1078+ chatTimestampInterval: tui.ChatTimestampInterval(i.view.ChatTimestampIntervalMinutes),
1066 }, true1079 }, true
1067 }1080 }
1068 1081
@@ -3697,6 +3710,21 @@ func (i *Interactive) openSettingsDialog() {
3697 desc: "retain tool headers, a final preview line, and failure status; ctrl+o expands the full output",3710 desc: "retain tool headers, a final preview line, and failure status; ctrl+o expands the full output",
3698 value: collapseToolCall,3711 value: collapseToolCall,
3699 },3712 },
3713+ {
3714+ key: "timestamps",
3715+ label: "timestamps",
3716+ desc: "configure chat timestamps and their interval",
3717+ children: []settingsItem{
3718+ {
3719+ key: "chat_timestamps",
3720+ label: "enabled",
3721+ desc: "show stored message times on existing separator rows after messages complete",
3722+ value: i.cfg.ChatTimestamps,
3723+ },
3724+ i.chatTimestampIntervalSetting(),
3725+ i.chatTimestampDateSetting(),
3726+ },
3727+ },
3700 {3728 {
3701 key: "show_instructions_at_startup",3729 key: "show_instructions_at_startup",
3702 label: "show loaded resources at startup",3730 label: "show loaded resources at startup",
@@ -3760,6 +3788,10 @@ func (i *Interactive) applySettingChange(act settingsAction) {
3760 i.applyReasoningSetting(act.StringValue)3788 i.applyReasoningSetting(act.StringValue)
3761 case act.Key == "theme":3789 case act.Key == "theme":
3762 i.applyThemeSetting(act.StringValue)3790 i.applyThemeSetting(act.StringValue)
3791+ case act.Key == "chat_timestamp_date":
3792+ i.applyChatTimestampDate(act.StringValue)
3793+ case act.Key == "chat_timestamp_interval_minutes":
3794+ i.applyChatTimestampInterval(act.StringValue)
3763 case act.Key == "auto_compact_threshold":3795 case act.Key == "auto_compact_threshold":
3764 i.applyAutoCompactThresholdSetting(act.StringValue)3796 i.applyAutoCompactThresholdSetting(act.StringValue)
3765 case act.Key == "tui_input_style":3797 case act.Key == "tui_input_style":
@@ -3966,6 +3998,10 @@ func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
3966 }3998 }
3967 3999
3968 func (i *Interactive) applySettingToggle(key string, value bool) {4000 func (i *Interactive) applySettingToggle(key string, value bool) {
4001+ if key == "chat_timestamps" {
4002+ i.applyChatTimestamps(value)
4003+ return
4004+ }
3969 // Every setting toggle forces a full repaint at the end — same4005 // Every setting toggle forces a full repaint at the end — same
3970 // effect as the user pressing Ctrl+L — so any per-setting visual4006 // effect as the user pressing Ctrl+L — so any per-setting visual
3971 // change (image rendering, status copy, future toggles) lands4007 // change (image rendering, status copy, future toggles) lands
M packages/agent/settings_store.go +28 -0
@@ -131,6 +131,34 @@ func (configSettingsStore) SetCollapseToolCall(enabled bool) error {
131131 return SaveConfig(cfg)
132132 }
133133
134+func (configSettingsStore) SetChatTimestamps(enabled bool) error {
135+ cfg, err := LoadConfig()
136+ if err != nil {
137+ return err
138+ }
139+ cfg.ChatTimestamps = enabled
140+ return SaveConfig(cfg)
141+}
142+
143+func (configSettingsStore) SetChatTimestampDate(mode string) error {
144+ cfg, err := LoadConfig()
145+ if err != nil {
146+ return err
147+ }
148+ cfg.ChatTimestampDate = tui.ChatTimestampDateMode(mode)
149+ return SaveConfig(cfg)
150+}
151+
152+func (configSettingsStore) SetChatTimestampInterval(minutes int) error {
153+ cfg, err := LoadConfig()
154+ if err != nil {
155+ return err
156+ }
157+ minutes = tui.ChatTimestampInterval(&minutes)
158+ cfg.ChatTimestampIntervalMinutes = &minutes
159+ return SaveConfig(cfg)
160+}
161+
134162 func (configSettingsStore) SetShowInstructionsAtStartup(enabled bool) error {
135163 cfg, err := LoadConfig()
136164 if err != nil {
@@ -131,6 +131,34 @@ func (configSettingsStore) SetCollapseToolCall(enabled bool) error {
131 return SaveConfig(cfg)131 return SaveConfig(cfg)
132 }132 }
133 133
134+func (configSettingsStore) SetChatTimestamps(enabled bool) error {
135+ cfg, err := LoadConfig()
136+ if err != nil {
137+ return err
138+ }
139+ cfg.ChatTimestamps = enabled
140+ return SaveConfig(cfg)
141+}
142+
143+func (configSettingsStore) SetChatTimestampDate(mode string) error {
144+ cfg, err := LoadConfig()
145+ if err != nil {
146+ return err
147+ }
148+ cfg.ChatTimestampDate = tui.ChatTimestampDateMode(mode)
149+ return SaveConfig(cfg)
150+}
151+
152+func (configSettingsStore) SetChatTimestampInterval(minutes int) error {
153+ cfg, err := LoadConfig()
154+ if err != nil {
155+ return err
156+ }
157+ minutes = tui.ChatTimestampInterval(&minutes)
158+ cfg.ChatTimestampIntervalMinutes = &minutes
159+ return SaveConfig(cfg)
160+}
161+
134 func (configSettingsStore) SetShowInstructionsAtStartup(enabled bool) error {162 func (configSettingsStore) SetShowInstructionsAtStartup(enabled bool) error {
135 cfg, err := LoadConfig()163 cfg, err := LoadConfig()
136 if err != nil {164 if err != nil {
M packages/tui/view.go +11 -1
@@ -162,6 +162,11 @@ type View struct {
162162 // preview line, and an explicit error indicator for failed calls.
163163 CollapseToolCall bool
164164
165+ // ChatTimestamps decorates existing separator rows with stored message times.
166+ ChatTimestamps bool
167+ ChatTimestampIntervalMinutes *int
168+ ChatTimestampDate string
169+
165170 // ExpandAll forces every long tool result to render in full.
166171 // Toggled from the tui by ctrl+o. When false, results longer than
167172 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
@@ -434,6 +439,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
434439 out := make([]string, 0, total+16)
435440 out = append(out, v.renderStartupResources(width)...)
436441 anchors := make([]MessageAnchor, 0, len(v.Messages))
442+ timestamps := v.messageTimestamps()
437443 for idx := range v.Messages {
438444 anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)})
439445 out = append(out, rendered[idx]...)
@@ -446,7 +452,11 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
446452 if len(rendered[idx]) == 0 {
447453 continue
448454 }
449- out = append(out, "")
455+ separator := ""
456+ if timestamps != nil {
457+ separator = v.timestampRow(timestamps[idx], width)
458+ }
459+ out = append(out, separator)
450460 }
451461 // Only render the streaming header/body when there's actual
452462 // text to show. An empty streaming block (streamOn=true,
@@ -162,6 +162,11 @@ type View struct {
162 // preview line, and an explicit error indicator for failed calls.162 // preview line, and an explicit error indicator for failed calls.
163 CollapseToolCall bool163 CollapseToolCall bool
164 164
165+ // ChatTimestamps decorates existing separator rows with stored message times.
166+ ChatTimestamps bool
167+ ChatTimestampIntervalMinutes *int
168+ ChatTimestampDate string
169+
165 // ExpandAll forces every long tool result to render in full.170 // ExpandAll forces every long tool result to render in full.
166 // Toggled from the tui by ctrl+o. When false, results longer than171 // Toggled from the tui by ctrl+o. When false, results longer than
167 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a172 // ToolCollapseLines collapse to ToolCollapsePreview lines plus a
@@ -434,6 +439,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
434 out := make([]string, 0, total+16)439 out := make([]string, 0, total+16)
435 out = append(out, v.renderStartupResources(width)...)440 out = append(out, v.renderStartupResources(width)...)
436 anchors := make([]MessageAnchor, 0, len(v.Messages))441 anchors := make([]MessageAnchor, 0, len(v.Messages))
442+ timestamps := v.messageTimestamps()
437 for idx := range v.Messages {443 for idx := range v.Messages {
438 anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)})444 anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)})
439 out = append(out, rendered[idx]...)445 out = append(out, rendered[idx]...)
@@ -446,7 +452,11 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
446 if len(rendered[idx]) == 0 {452 if len(rendered[idx]) == 0 {
447 continue453 continue
448 }454 }
449- out = append(out, "")455+ separator := ""
456+ if timestamps != nil {
457+ separator = v.timestampRow(timestamps[idx], width)
458+ }
459+ out = append(out, separator)
450 }460 }
451 // Only render the streaming header/body when there's actual461 // Only render the streaming header/body when there's actual
452 // text to show. An empty streaming block (streamOn=true,462 // text to show. An empty streaming block (streamOn=true,
A packages/tui/view_timestamps.go +85 -0
@@ -0,0 +1,85 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "time"
6+
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+// ChatTimestampInterval returns the interval in minutes. Missing or negative
11+// values use zero minutes, showing every eligible message.
12+func ChatTimestampInterval(minutes *int) int {
13+ if minutes == nil || *minutes < 0 {
14+ return 0
15+ }
16+ return *minutes
17+}
18+
19+// ChatTimestampDateMode normalizes the date display preference.
20+func ChatTimestampDateMode(mode string) string {
21+ if mode == "every_message" {
22+ return mode
23+ }
24+ return "day_start"
25+}
26+
27+// messageTimestamps computes labels over the whole transcript, including the
28+// history outside TailLimit. Decoration stays outside the content render cache.
29+func (v *View) messageTimestamps() []string {
30+ if !v.ChatTimestamps {
31+ return nil
32+ }
33+ labels := make([]string, len(v.Messages))
34+ interval := ChatTimestampInterval(v.ChatTimestampIntervalMinutes)
35+ var last time.Time
36+ for idx, m := range v.Messages {
37+ if m.Time.IsZero() || !timestampMessageVisible(m) {
38+ continue
39+ }
40+ stamp := m.Time.Local()
41+ newDay := !last.IsZero() && stamp.Format("2006-01-02") != last.Format("2006-01-02")
42+ if interval > 0 && !last.IsZero() && !newDay && stamp.Sub(last).Minutes() < float64(interval) {
43+ continue
44+ }
45+ format := "15:04"
46+ if ChatTimestampDateMode(v.ChatTimestampDate) == "every_message" || last.IsZero() || newDay {
47+ format = "2006-01-02 15:04"
48+ }
49+ labels[idx] = stamp.Format(format)
50+ last = stamp
51+ }
52+ return labels
53+}
54+
55+func timestampMessageVisible(m provider.Message) bool {
56+ if m.Meta["compaction"] == "true" {
57+ return false
58+ }
59+ for _, c := range m.Content {
60+ switch b := c.(type) {
61+ case provider.TextBlock:
62+ if (m.Role == provider.RoleUser || m.Role == provider.RoleAssistant) && strings.TrimSpace(b.Text) != "" {
63+ return true
64+ }
65+ case provider.ImageBlock:
66+ if m.Role == provider.RoleUser {
67+ return true
68+ }
69+ case provider.ToolResultBlock:
70+ if m.Role == provider.RoleTool {
71+ return true
72+ }
73+ }
74+ }
75+ return false
76+}
77+
78+// timestampRow occupies the already existing separator row, never content or
79+// extra vertical space. Narrow terminals omit labels that cannot fit in full.
80+func (v *View) timestampRow(label string, width int) string {
81+ if label == "" || width-2*toolBoxOuterMargin < len(label) {
82+ return ""
83+ }
84+ return strings.Repeat(" ", width-toolBoxOuterMargin-len(label)) + v.Theme.FG256(v.Theme.Muted, label) + strings.Repeat(" ", toolBoxOuterMargin)
85+}
@@ -0,0 +1,85 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "time"
6+
7+ "github.com/patriceckhart/zot/packages/provider"
8+)
9+
10+// ChatTimestampInterval returns the interval in minutes. Missing or negative
11+// values use zero minutes, showing every eligible message.
12+func ChatTimestampInterval(minutes *int) int {
13+ if minutes == nil || *minutes < 0 {
14+ return 0
15+ }
16+ return *minutes
17+}
18+
19+// ChatTimestampDateMode normalizes the date display preference.
20+func ChatTimestampDateMode(mode string) string {
21+ if mode == "every_message" {
22+ return mode
23+ }
24+ return "day_start"
25+}
26+
27+// messageTimestamps computes labels over the whole transcript, including the
28+// history outside TailLimit. Decoration stays outside the content render cache.
29+func (v *View) messageTimestamps() []string {
30+ if !v.ChatTimestamps {
31+ return nil
32+ }
33+ labels := make([]string, len(v.Messages))
34+ interval := ChatTimestampInterval(v.ChatTimestampIntervalMinutes)
35+ var last time.Time
36+ for idx, m := range v.Messages {
37+ if m.Time.IsZero() || !timestampMessageVisible(m) {
38+ continue
39+ }
40+ stamp := m.Time.Local()
41+ newDay := !last.IsZero() && stamp.Format("2006-01-02") != last.Format("2006-01-02")
42+ if interval > 0 && !last.IsZero() && !newDay && stamp.Sub(last).Minutes() < float64(interval) {
43+ continue
44+ }
45+ format := "15:04"
46+ if ChatTimestampDateMode(v.ChatTimestampDate) == "every_message" || last.IsZero() || newDay {
47+ format = "2006-01-02 15:04"
48+ }
49+ labels[idx] = stamp.Format(format)
50+ last = stamp
51+ }
52+ return labels
53+}
54+
55+func timestampMessageVisible(m provider.Message) bool {
56+ if m.Meta["compaction"] == "true" {
57+ return false
58+ }
59+ for _, c := range m.Content {
60+ switch b := c.(type) {
61+ case provider.TextBlock:
62+ if (m.Role == provider.RoleUser || m.Role == provider.RoleAssistant) && strings.TrimSpace(b.Text) != "" {
63+ return true
64+ }
65+ case provider.ImageBlock:
66+ if m.Role == provider.RoleUser {
67+ return true
68+ }
69+ case provider.ToolResultBlock:
70+ if m.Role == provider.RoleTool {
71+ return true
72+ }
73+ }
74+ }
75+ return false
76+}
77+
78+// timestampRow occupies the already existing separator row, never content or
79+// extra vertical space. Narrow terminals omit labels that cannot fit in full.
80+func (v *View) timestampRow(label string, width int) string {
81+ if label == "" || width-2*toolBoxOuterMargin < len(label) {
82+ return ""
83+ }
84+ return strings.Repeat(" ", width-toolBoxOuterMargin-len(label)) + v.Theme.FG256(v.Theme.Muted, label) + strings.Repeat(" ", toolBoxOuterMargin)
85+}
A packages/tui/view_timestamps_test.go +171 -0
@@ -0,0 +1,171 @@
1+package tui
2+
3+import (
4+ "reflect"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+func timestampTestMessage(at time.Time) provider.Message {
13+ return provider.Message{Role: provider.RoleAssistant, Time: at, Content: []provider.Content{provider.TextBlock{Text: "same text"}}}
14+}
15+
16+func TestChatTimestampDateDisplayAndMargin(t *testing.T) {
17+ start := time.Date(2026, 9, 22, 23, 58, 0, 0, time.Local)
18+ v := View{Theme: Dark, ChatTimestamps: true, Messages: []provider.Message{
19+ timestampTestMessage(start), timestampTestMessage(start.Add(time.Minute)), timestampTestMessage(start.Add(2 * time.Minute)),
20+ }}
21+ for _, mode := range []string{"", "day_start", "every_message", "invalid"} {
22+ v.ChatTimestampDate = mode
23+ want := []string{"2026-09-22 23:58", "23:59", "2026-09-23 00:00"}
24+ if mode == "every_message" {
25+ want[1] = "2026-09-22 23:59"
26+ }
27+ if got := v.messageTimestamps(); !reflect.DeepEqual(got, want) {
28+ t.Fatalf("mode %q: got %v, want %v", mode, got, want)
29+ }
30+ for _, width := range []int{20, 40, 80} {
31+ row := stripANSI(v.timestampRow(want[0], width))
32+ box := stripANSI(toolBoxBottom(Dark, width))
33+ if len(strings.TrimRight(row, " ")) != visibleWidth(strings.TrimRight(box, " ")) {
34+ t.Fatalf("width %d: timestamp does not align with box right edge", width)
35+ }
36+ }
37+ }
38+}
39+
40+func TestChatTimestampIntervals(t *testing.T) {
41+ start := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
42+ five := 5
43+ v := View{ChatTimestamps: true, ChatTimestampIntervalMinutes: &five}
44+ for _, delta := range []time.Duration{0, 2 * time.Minute, 5*time.Minute - time.Second, 5 * time.Minute, 6 * time.Minute, 10 * time.Minute} {
45+ v.Messages = append(v.Messages, timestampTestMessage(start.Add(delta)))
46+ }
47+ want := []string{"2026-09-22 12:00", "", "", "12:05", "", "12:10"}
48+ if got := v.messageTimestamps(); !reflect.DeepEqual(got, want) {
49+ t.Fatalf("labels = %v, want %v", got, want)
50+ }
51+ zero := 0
52+ v.ChatTimestampIntervalMinutes = nil
53+ for idx, label := range v.messageTimestamps() {
54+ if label == "" {
55+ t.Fatalf("every-message mode omitted message %d", idx)
56+ }
57+ }
58+ negative := -1
59+ if ChatTimestampInterval(nil) != 0 || ChatTimestampInterval(&negative) != 0 || ChatTimestampInterval(&zero) != 0 {
60+ t.Fatal("incorrect interval defaults")
61+ }
62+}
63+
64+func TestChatTimestampMissingHiddenAndDayChange(t *testing.T) {
65+ start := time.Date(2026, 9, 22, 23, 59, 0, 0, time.Local)
66+ hidden := timestampTestMessage(start.Add(time.Minute))
67+ hidden.Meta = map[string]string{"compaction": "true"}
68+ toolOnly := provider.Message{Role: provider.RoleAssistant, Time: start.Add(time.Minute), Content: []provider.Content{provider.ToolCallBlock{ID: "call"}}}
69+ v := View{ChatTimestamps: true, Messages: []provider.Message{
70+ timestampTestMessage(time.Time{}), timestampTestMessage(start), hidden, toolOnly,
71+ timestampTestMessage(start.Add(time.Minute)),
72+ }}
73+ want := []string{"", "2026-09-22 23:59", "", "", "2026-09-23 00:00"}
74+ if got := v.messageTimestamps(); !reflect.DeepEqual(got, want) {
75+ t.Fatalf("labels = %v, want %v", got, want)
76+ }
77+}
78+
79+func TestChatTimestampsPreserveRowsAndCaches(t *testing.T) {
80+ start := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
81+ for _, compact := range []bool{false, true} {
82+ v := View{Theme: Dark, CompactMode: compact, Messages: []provider.Message{
83+ timestampTestMessage(start), timestampTestMessage(start.Add(5 * time.Minute)),
84+ }}
85+ v.Messages[0].Role = provider.RoleUser
86+ plain, anchors := v.BuildWithAnchors(40)
87+ v.ChatTimestamps = true
88+ stamped, stampedAnchors := v.BuildWithAnchors(40)
89+ if len(plain) != len(stamped) || !reflect.DeepEqual(anchors, stampedAnchors) {
90+ t.Fatal("timestamps changed transcript height or anchors")
91+ }
92+ for idx, line := range stamped {
93+ if line != plain[idx] && (plain[idx] != "" || visibleWidth(line) != 40) {
94+ t.Fatalf("timestamp overwrote content or was not right aligned: %q", line)
95+ }
96+ }
97+ if !strings.Contains(strings.Join(stamped, "\n"), v.Theme.FG256(v.Theme.Muted, "12:05")) {
98+ t.Fatal("missing muted timestamp")
99+ }
100+ v.Messages[1].Time = start.Add(6 * time.Minute)
101+ if !strings.Contains(strings.Join(v.Build(40), "\n"), "12:06") {
102+ t.Fatal("content cache reused the wrong timestamp")
103+ }
104+ v.ChatTimestamps = false
105+ if got := v.Build(40); !reflect.DeepEqual(got, plain) {
106+ t.Fatal("disabling timestamps did not restore original rows")
107+ }
108+ }
109+}
110+
111+func TestChatTimestampTailAndNarrowWidths(t *testing.T) {
112+ start := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
113+ v := View{Theme: Dark, ChatTimestamps: true, Messages: []provider.Message{
114+ timestampTestMessage(start), timestampTestMessage(start.Add(time.Minute)), timestampTestMessage(start.Add(5 * time.Minute)),
115+ }}
116+ all, anchors := v.BuildWithAnchors(40)
117+ v.TailLimit = 2
118+ if got := v.Build(40); !reflect.DeepEqual(got, all[anchors[1].Row:]) {
119+ t.Fatal("tail rendering changed timestamp selection")
120+ }
121+ for _, width := range []int{0, 1, 4, 5, 15, 16, 40} {
122+ for _, label := range []string{"12:00", "2026-09-22 12:00"} {
123+ line := v.timestampRow(label, width)
124+ if visibleWidth(line) > width || (width-2*toolBoxOuterMargin < len(label) && line != "") {
125+ t.Fatalf("width %d: label overflow: %q", width, line)
126+ }
127+ }
128+ }
129+}
130+
131+func TestChatTimestampsToolLayoutsAndClockRegression(t *testing.T) {
132+ start := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
133+ zero := 0
134+ for _, layout := range []string{"box", "flat", "compact"} {
135+ v := View{Theme: Dark, FlatTools: layout == "flat", CompactMode: layout == "compact", CollapseToolCall: true,
136+ ChatTimestampIntervalMinutes: &zero, Messages: []provider.Message{
137+ timestampTestMessage(start),
138+ {Role: provider.RoleTool, Time: start.Add(-time.Minute), Content: []provider.Content{
139+ provider.ToolResultBlock{CallID: "call", Content: []provider.Content{provider.TextBlock{Text: "tool output"}}},
140+ }},
141+ },
142+ }
143+ plain := v.Build(80)
144+ v.ChatTimestamps = true
145+ stamped := v.Build(80)
146+ if len(plain) != len(stamped) || !strings.Contains(strings.Join(stamped, "\n"), "11:59") {
147+ t.Fatalf("%s: tool timestamp missing or added rows", layout)
148+ }
149+ five := 5
150+ v.ChatTimestampIntervalMinutes = &five
151+ if v.messageTimestamps()[1] != "" {
152+ t.Fatal("clock regression bypassed positive minimum interval")
153+ }
154+ }
155+}
156+
157+func TestChatTimestampsWaitForCompletedMessages(t *testing.T) {
158+ v := View{Theme: Dark, StreamingActive: true, Streaming: "streaming text"}
159+ before := v.Build(80)
160+ liveBefore := v.BuildLive(80)
161+ v.ChatTimestamps = true
162+ if !reflect.DeepEqual(before, v.Build(80)) || !reflect.DeepEqual(liveBefore, v.BuildLive(80)) {
163+ t.Fatal("timestamps modified in-progress streaming output")
164+ }
165+ v.StreamingActive = false
166+ v.Streaming = ""
167+ v.Messages = []provider.Message{timestampTestMessage(time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local))}
168+ if !strings.Contains(strings.Join(v.Build(80), "\n"), "2026-09-22 12:00") {
169+ t.Fatal("completed message missing timestamp")
170+ }
171+}
@@ -0,0 +1,171 @@
1+package tui
2+
3+import (
4+ "reflect"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+func timestampTestMessage(at time.Time) provider.Message {
13+ return provider.Message{Role: provider.RoleAssistant, Time: at, Content: []provider.Content{provider.TextBlock{Text: "same text"}}}
14+}
15+
16+func TestChatTimestampDateDisplayAndMargin(t *testing.T) {
17+ start := time.Date(2026, 9, 22, 23, 58, 0, 0, time.Local)
18+ v := View{Theme: Dark, ChatTimestamps: true, Messages: []provider.Message{
19+ timestampTestMessage(start), timestampTestMessage(start.Add(time.Minute)), timestampTestMessage(start.Add(2 * time.Minute)),
20+ }}
21+ for _, mode := range []string{"", "day_start", "every_message", "invalid"} {
22+ v.ChatTimestampDate = mode
23+ want := []string{"2026-09-22 23:58", "23:59", "2026-09-23 00:00"}
24+ if mode == "every_message" {
25+ want[1] = "2026-09-22 23:59"
26+ }
27+ if got := v.messageTimestamps(); !reflect.DeepEqual(got, want) {
28+ t.Fatalf("mode %q: got %v, want %v", mode, got, want)
29+ }
30+ for _, width := range []int{20, 40, 80} {
31+ row := stripANSI(v.timestampRow(want[0], width))
32+ box := stripANSI(toolBoxBottom(Dark, width))
33+ if len(strings.TrimRight(row, " ")) != visibleWidth(strings.TrimRight(box, " ")) {
34+ t.Fatalf("width %d: timestamp does not align with box right edge", width)
35+ }
36+ }
37+ }
38+}
39+
40+func TestChatTimestampIntervals(t *testing.T) {
41+ start := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
42+ five := 5
43+ v := View{ChatTimestamps: true, ChatTimestampIntervalMinutes: &five}
44+ for _, delta := range []time.Duration{0, 2 * time.Minute, 5*time.Minute - time.Second, 5 * time.Minute, 6 * time.Minute, 10 * time.Minute} {
45+ v.Messages = append(v.Messages, timestampTestMessage(start.Add(delta)))
46+ }
47+ want := []string{"2026-09-22 12:00", "", "", "12:05", "", "12:10"}
48+ if got := v.messageTimestamps(); !reflect.DeepEqual(got, want) {
49+ t.Fatalf("labels = %v, want %v", got, want)
50+ }
51+ zero := 0
52+ v.ChatTimestampIntervalMinutes = nil
53+ for idx, label := range v.messageTimestamps() {
54+ if label == "" {
55+ t.Fatalf("every-message mode omitted message %d", idx)
56+ }
57+ }
58+ negative := -1
59+ if ChatTimestampInterval(nil) != 0 || ChatTimestampInterval(&negative) != 0 || ChatTimestampInterval(&zero) != 0 {
60+ t.Fatal("incorrect interval defaults")
61+ }
62+}
63+
64+func TestChatTimestampMissingHiddenAndDayChange(t *testing.T) {
65+ start := time.Date(2026, 9, 22, 23, 59, 0, 0, time.Local)
66+ hidden := timestampTestMessage(start.Add(time.Minute))
67+ hidden.Meta = map[string]string{"compaction": "true"}
68+ toolOnly := provider.Message{Role: provider.RoleAssistant, Time: start.Add(time.Minute), Content: []provider.Content{provider.ToolCallBlock{ID: "call"}}}
69+ v := View{ChatTimestamps: true, Messages: []provider.Message{
70+ timestampTestMessage(time.Time{}), timestampTestMessage(start), hidden, toolOnly,
71+ timestampTestMessage(start.Add(time.Minute)),
72+ }}
73+ want := []string{"", "2026-09-22 23:59", "", "", "2026-09-23 00:00"}
74+ if got := v.messageTimestamps(); !reflect.DeepEqual(got, want) {
75+ t.Fatalf("labels = %v, want %v", got, want)
76+ }
77+}
78+
79+func TestChatTimestampsPreserveRowsAndCaches(t *testing.T) {
80+ start := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
81+ for _, compact := range []bool{false, true} {
82+ v := View{Theme: Dark, CompactMode: compact, Messages: []provider.Message{
83+ timestampTestMessage(start), timestampTestMessage(start.Add(5 * time.Minute)),
84+ }}
85+ v.Messages[0].Role = provider.RoleUser
86+ plain, anchors := v.BuildWithAnchors(40)
87+ v.ChatTimestamps = true
88+ stamped, stampedAnchors := v.BuildWithAnchors(40)
89+ if len(plain) != len(stamped) || !reflect.DeepEqual(anchors, stampedAnchors) {
90+ t.Fatal("timestamps changed transcript height or anchors")
91+ }
92+ for idx, line := range stamped {
93+ if line != plain[idx] && (plain[idx] != "" || visibleWidth(line) != 40) {
94+ t.Fatalf("timestamp overwrote content or was not right aligned: %q", line)
95+ }
96+ }
97+ if !strings.Contains(strings.Join(stamped, "\n"), v.Theme.FG256(v.Theme.Muted, "12:05")) {
98+ t.Fatal("missing muted timestamp")
99+ }
100+ v.Messages[1].Time = start.Add(6 * time.Minute)
101+ if !strings.Contains(strings.Join(v.Build(40), "\n"), "12:06") {
102+ t.Fatal("content cache reused the wrong timestamp")
103+ }
104+ v.ChatTimestamps = false
105+ if got := v.Build(40); !reflect.DeepEqual(got, plain) {
106+ t.Fatal("disabling timestamps did not restore original rows")
107+ }
108+ }
109+}
110+
111+func TestChatTimestampTailAndNarrowWidths(t *testing.T) {
112+ start := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
113+ v := View{Theme: Dark, ChatTimestamps: true, Messages: []provider.Message{
114+ timestampTestMessage(start), timestampTestMessage(start.Add(time.Minute)), timestampTestMessage(start.Add(5 * time.Minute)),
115+ }}
116+ all, anchors := v.BuildWithAnchors(40)
117+ v.TailLimit = 2
118+ if got := v.Build(40); !reflect.DeepEqual(got, all[anchors[1].Row:]) {
119+ t.Fatal("tail rendering changed timestamp selection")
120+ }
121+ for _, width := range []int{0, 1, 4, 5, 15, 16, 40} {
122+ for _, label := range []string{"12:00", "2026-09-22 12:00"} {
123+ line := v.timestampRow(label, width)
124+ if visibleWidth(line) > width || (width-2*toolBoxOuterMargin < len(label) && line != "") {
125+ t.Fatalf("width %d: label overflow: %q", width, line)
126+ }
127+ }
128+ }
129+}
130+
131+func TestChatTimestampsToolLayoutsAndClockRegression(t *testing.T) {
132+ start := time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local)
133+ zero := 0
134+ for _, layout := range []string{"box", "flat", "compact"} {
135+ v := View{Theme: Dark, FlatTools: layout == "flat", CompactMode: layout == "compact", CollapseToolCall: true,
136+ ChatTimestampIntervalMinutes: &zero, Messages: []provider.Message{
137+ timestampTestMessage(start),
138+ {Role: provider.RoleTool, Time: start.Add(-time.Minute), Content: []provider.Content{
139+ provider.ToolResultBlock{CallID: "call", Content: []provider.Content{provider.TextBlock{Text: "tool output"}}},
140+ }},
141+ },
142+ }
143+ plain := v.Build(80)
144+ v.ChatTimestamps = true
145+ stamped := v.Build(80)
146+ if len(plain) != len(stamped) || !strings.Contains(strings.Join(stamped, "\n"), "11:59") {
147+ t.Fatalf("%s: tool timestamp missing or added rows", layout)
148+ }
149+ five := 5
150+ v.ChatTimestampIntervalMinutes = &five
151+ if v.messageTimestamps()[1] != "" {
152+ t.Fatal("clock regression bypassed positive minimum interval")
153+ }
154+ }
155+}
156+
157+func TestChatTimestampsWaitForCompletedMessages(t *testing.T) {
158+ v := View{Theme: Dark, StreamingActive: true, Streaming: "streaming text"}
159+ before := v.Build(80)
160+ liveBefore := v.BuildLive(80)
161+ v.ChatTimestamps = true
162+ if !reflect.DeepEqual(before, v.Build(80)) || !reflect.DeepEqual(liveBefore, v.BuildLive(80)) {
163+ t.Fatal("timestamps modified in-progress streaming output")
164+ }
165+ v.StreamingActive = false
166+ v.Streaming = ""
167+ v.Messages = []provider.Message{timestampTestMessage(time.Date(2026, 9, 22, 12, 0, 0, 0, time.Local))}
168+ if !strings.Contains(strings.Join(v.Build(80), "\n"), "2026-09-22 12:00") {
169+ t.Fatal("completed message missing timestamp")
170+ }
171+}

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 476

↑↓ moveenter openesc close