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 | |||
| 477 | 477 | ||
| 478 | 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 | 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 | 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 | 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 | 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). | |
| @@ -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 { | |||
| 1345 | 1345 | RespectGitignore: initialCfg.RespectGitignore, | |
| 1346 | 1346 | CompactMode: initialCfg.CompactMode, | |
| 1347 | 1347 | CollapseToolCall: initialCfg.CollapseToolCall, | |
| 1348 | + | ChatTimestamps: initialCfg.ChatTimestamps, | |
| 1349 | + | ChatTimestampDate: initialCfg.ChatTimestampDate, | |
| 1350 | + | ChatTimestampIntervalMinutes: initialCfg.ChatTimestampIntervalMinutes, | |
| 1348 | 1351 | TUIInputStyle: initialCfg.TUIInputStyle, | |
| 1349 | 1352 | TUIStatusPosition: initialCfg.TUIStatusPosition, | |
| 1350 | 1353 | 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 { | |||
| 113 | 113 | // Toggle from /settings. | |
| 114 | 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 | 123 | // ShowInstructionsAtStartup lists loaded context files, extensions, | |
| 117 | 124 | // and user-installed skills above the transcript. Off by default; | |
| 118 | 125 | // 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 := ×tampSettingsTestStore{} | |
| 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 := ×tampSettingsTestStore{} | |
| 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 := ×tampSettingsTestStore{} | |||
| 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 := ×tampSettingsTestStore{} | |||
| 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 { | |||
| 124 | 124 | // and failure status while hiding the rest of the body. | |
| 125 | 125 | CollapseToolCall *bool | |
| 126 | 126 | ||
| 127 | + | ChatTimestamps bool | |
| 128 | + | ChatTimestampIntervalMinutes *int | |
| 129 | + | ChatTimestampDate string | |
| 130 | + | ||
| 127 | 131 | // TUIInputStyle controls the main input rendering: plain or lines. | |
| 128 | 132 | TUIInputStyle string | |
| 129 | 133 | ||
| @@ -353,20 +357,23 @@ type modelRefreshResult struct{ err error } | |||
| 353 | 357 | ||
| 354 | 358 | // Interactive is the TUI chat loop. | |
| 355 | 359 | 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 | |
| 370 | 377 | } | |
| 371 | 378 | ||
| 372 | 379 | // QuickModelShortcut is one configured quick model switch slot. | |
| @@ -666,16 +673,19 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 666 | 673 | i := &Interactive{ | |
| 667 | 674 | cfg: cfg, | |
| 668 | 675 | 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, | |
| 679 | 689 | }, | |
| 680 | 690 | // Prompt is the standard half-block accent bar used by chat | |
| 681 | 691 | // 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 | 1060 | showVer := len(i.view.Messages) == 0 && !i.streamOn && len(i.toolOrder) == 0 && !i.welcomeStart.IsZero() && time.Since(i.welcomeStart) < welcomeVersionDuration | |
| 1051 | 1061 | 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), | |
| 1066 | 1079 | }, true | |
| 1067 | 1080 | } | |
| 1068 | 1081 | ||
| @@ -3697,6 +3710,21 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3697 | 3710 | desc: "retain tool headers, a final preview line, and failure status; ctrl+o expands the full output", | |
| 3698 | 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 | 3729 | key: "show_instructions_at_startup", | |
| 3702 | 3730 | label: "show loaded resources at startup", | |
| @@ -3760,6 +3788,10 @@ func (i *Interactive) applySettingChange(act settingsAction) { | |||
| 3760 | 3788 | i.applyReasoningSetting(act.StringValue) | |
| 3761 | 3789 | case act.Key == "theme": | |
| 3762 | 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 | 3795 | case act.Key == "auto_compact_threshold": | |
| 3764 | 3796 | i.applyAutoCompactThresholdSetting(act.StringValue) | |
| 3765 | 3797 | case act.Key == "tui_input_style": | |
| @@ -3966,6 +3998,10 @@ func (i *Interactive) refreshQuickModelSettingsItem(slot int) { | |||
| 3966 | 3998 | } | |
| 3967 | 3999 | ||
| 3968 | 4000 | func (i *Interactive) applySettingToggle(key string, value bool) { | |
| 4001 | + | if key == "chat_timestamps" { | |
| 4002 | + | i.applyChatTimestamps(value) | |
| 4003 | + | return | |
| 4004 | + | } | |
| 3969 | 4005 | // Every setting toggle forces a full repaint at the end — same | |
| 3970 | 4006 | // effect as the user pressing Ctrl+L — so any per-setting visual | |
| 3971 | 4007 | // 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 *bool | 125 | 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 string | 132 | 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 int | 360 | + | cols int |
| 357 | - | agentRev uint64 | 361 | + | agentRev uint64 |
| 358 | - | statusOK string | 362 | + | statusOK string |
| 359 | - | statusErr string | 363 | + | statusErr string |
| 360 | - | help string | 364 | + | help string |
| 361 | - | extNotes string | 365 | + | extNotes string |
| 362 | - | reloadErrors string | 366 | + | reloadErrors string |
| 363 | - | updateAvailable bool | 367 | + | updateAvailable bool |
| 364 | - | updateCurrent string | 368 | + | updateCurrent string |
| 365 | - | updateLatest string | 369 | + | updateLatest string |
| 366 | - | updateURL string | 370 | + | updateURL string |
| 367 | - | welcomeShowVer bool | 371 | + | welcomeShowVer bool |
| 368 | - | expandAll bool | 372 | + | expandAll bool |
| 369 | - | tailLimit int | 373 | + | 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 chat | 690 | // Prompt is the standard half-block accent bar used by chat | ||
| 681 | // speaker labels too, so the input gutter matches the rest | 691 | // 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) < welcomeVersionDuration | 1060 | 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 | }, true | 1079 | }, 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 — same | 4005 | // Every setting toggle forces a full repaint at the end — same | ||
| 3970 | // effect as the user pressing Ctrl+L — so any per-setting visual | 4006 | // effect as the user pressing Ctrl+L — so any per-setting visual | ||
| 3971 | // change (image rendering, status copy, future toggles) lands | 4007 | // 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 { | |||
| 131 | 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 | 162 | func (configSettingsStore) SetShowInstructionsAtStartup(enabled bool) error { | |
| 135 | 163 | cfg, err := LoadConfig() | |
| 136 | 164 | 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 { | |||
| 162 | 162 | // preview line, and an explicit error indicator for failed calls. | |
| 163 | 163 | 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 | 170 | // ExpandAll forces every long tool result to render in full. | |
| 166 | 171 | // Toggled from the tui by ctrl+o. When false, results longer than | |
| 167 | 172 | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | |
| @@ -434,6 +439,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 434 | 439 | out := make([]string, 0, total+16) | |
| 435 | 440 | out = append(out, v.renderStartupResources(width)...) | |
| 436 | 441 | anchors := make([]MessageAnchor, 0, len(v.Messages)) | |
| 442 | + | timestamps := v.messageTimestamps() | |
| 437 | 443 | for idx := range v.Messages { | |
| 438 | 444 | anchors = append(anchors, MessageAnchor{MessageIdx: idx, Row: len(out)}) | |
| 439 | 445 | out = append(out, rendered[idx]...) | |
| @@ -446,7 +452,11 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 446 | 452 | if len(rendered[idx]) == 0 { | |
| 447 | 453 | 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 | 461 | // Only render the streaming header/body when there's actual | |
| 452 | 462 | // 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 bool | 163 | 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 than | 171 | // Toggled from the tui by ctrl+o. When false, results longer than | ||
| 167 | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | 172 | // 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 | continue | 453 | 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 actual | 461 | // 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 | + | } | |||