From cf00b498bde0e5c8f7ec5fc53bb36fbee6d81947 Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:51:19 +0200 Subject: [PATCH 1/3] feat(tui): info bar with model/context/subagent/skill stats + /infobar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-line footer below the status bar, toggled with /infobar and persisted as config show-info-bar. Segments: late version, project folder, focused agent's provider/profile ref + model (config agent_models lookup), context usage bar, running subagent count, discovered skills with estimated token footprint (SkillsInfo plumbed from cmd/late/main.go), tokens to the compaction threshold, and session uptime. The row reserves one layout row, truncates to window width, and hides while the file picker is open. Note: state.go and update.go also carry the /timestamps command def and toggle handler (feature (b)) — the info bar is the dominant feature in both files, so they are committed here per the no-hunk-split rule. Note: config show-info-bar field lands with the compaction commit (config.go is dominantly compaction). Port notes (branch surgery): - Config fields: the original note deferred show-info-bar/show-timestamps to a later compaction-era commit; here both fields (with their doc comments) are ported directly into internal/config/config.go so the /infobar and /timestamps toggles persist via SaveConfig and every commit builds. - Headroom segment: upstream/main has no compaction config, so config.ResolveCompactionThreshold/config.DefaultCompactionThresholdPercent are replaced by a package-private tui constant defaultContextThresholdPercent = 80 (mirroring local/full's default); the segment itself is unchanged and uses only token counts plus this percentage. - cmd/late/main.go: skillsInfoForTUI + model.SkillsInfo assignment ported from local/full (pathutil/skill/common were already available upstream). --- cmd/late/main.go | 31 +++ internal/config/config.go | 12 ++ internal/tui/infobar_test.go | 362 +++++++++++++++++++++++++++++++++++ internal/tui/model.go | 23 ++- internal/tui/state.go | 47 +++++ internal/tui/update.go | 70 ++++++- internal/tui/view.go | 198 ++++++++++++++++++- 7 files changed, 729 insertions(+), 14 deletions(-) create mode 100644 internal/tui/infobar_test.go diff --git a/cmd/late/main.go b/cmd/late/main.go index bb16d27e..4532834a 100644 --- a/cmd/late/main.go +++ b/cmd/late/main.go @@ -25,6 +25,7 @@ import ( "late/internal/pathutil" "late/internal/plugin" "late/internal/session" + "late/internal/skill" "late/internal/tool" "late/internal/tui" @@ -665,6 +666,12 @@ func main() { model.ShowCWD = *showCWDReq model.LazyHistory = true + // SkillsInfo: estimated size of the skill surface for the TUI info bar. + // executor.RegisterTools builds the activate_skill map from the same + // directories; re-discovering here (read-only) keeps the executor API + // untouched while giving the TUI the same view of the skills on disk. + model.SkillsInfo = skillsInfoForTUI() + pOpts := []tea.ProgramOption{ tea.WithFPS(tui.FrameRate), } @@ -801,6 +808,30 @@ func newModelClient(ctx context.Context, setting appconfig.ModelSetting, enableI return c } +// skillsInfoForTUI estimates the token footprint of the skill instructions +// available to agents (user + project skills directories, the same source +// executor.RegisterTools uses) so the TUI info bar can display it without +// re-reading skill files per frame. Count is the number of discovered +// skills; Tokens uses the common cl100k estimator over each skill's +// instruction body. +func skillsInfoForTUI() tui.SkillsInfo { + info := tui.SkillsInfo{} + skillDirs := []string{} + if userSkillsDir, err := pathutil.LateSkillsDir(); err == nil { + skillDirs = append(skillDirs, userSkillsDir) + } + skillDirs = append(skillDirs, pathutil.LateProjectSkillsDir()) + skills, err := skill.DiscoverSkills(skillDirs) + if err != nil { + return info + } + for _, s := range skills { + info.Count++ + info.Tokens += common.EstimateTokenCount(s.Instructions) + } + return info +} + func validateSuppressThinkingWords(suppressThinkingWords bool, orchestratorModel, subagentModel string, appConfig *appconfig.Config) error { if !suppressThinkingWords { return nil diff --git a/internal/config/config.go b/internal/config/config.go index 4b0463fc..31065da0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -86,6 +86,18 @@ type Config struct { Theme string `json:"theme,omitempty"` Models []ModelSetting `json:"models,omitempty"` AgentModels map[string]string `json:"agent_models,omitempty"` + + // ShowInfoBar toggles the single-line info footer rendered below the + // TUI status bar (version, model, context usage, uptime, ...). Toggled + // at runtime with the /infobar slash command, which persists the new + // value back to config.json. + ShowInfoBar bool `json:"show-info-bar,omitempty"` + + // ShowTimestamps toggles the [HH:MM:SS] prefix rendered at the start + // of each transcript message block. Toggled at runtime with the + // /timestamps slash command, which persists the new value back to + // config.json. + ShowTimestamps bool `json:"show-timestamps,omitempty"` } func defaultConfig() Config { diff --git a/internal/tui/infobar_test.go b/internal/tui/infobar_test.go new file mode 100644 index 00000000..223e4b82 --- /dev/null +++ b/internal/tui/infobar_test.go @@ -0,0 +1,362 @@ +package tui + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "late/internal/common" + "late/internal/config" +) + +// setUserConfigEnv isolates config.json writes (the /infobar toggle persists +// via config.SaveConfig) into a temp dir, mirroring the config package's own +// test helper. +func setUserConfigEnv(t *testing.T, configRoot string) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", configRoot) + t.Setenv("APPDATA", configRoot) + if runtime.GOOS != "windows" { + t.Setenv("HOME", configRoot) + } +} + +func pressEnter(t *testing.T, m Model) Model { + t.Helper() + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + next, ok := updated.(Model) + if !ok { + t.Fatalf("Update returned %T, want tui.Model", updated) + } + return next +} + +func TestInfoBarTogglePersistsToConfig(t *testing.T) { + configRoot := t.TempDir() + setUserConfigEnv(t, configRoot) + // Resolve the config dir the way pathutil does (os.UserConfigDir — + // XDG_CONFIG_HOME is ignored on darwin, HOME/APPDATA are not). + userConfigDir, err := os.UserConfigDir() + if err != nil { + t.Fatalf("UserConfigDir() error = %v", err) + } + if err := os.MkdirAll(filepath.Join(userConfigDir, "late"), 0o700); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{} + m := NewModel(&mockOrchestrator{}, nil, cfg) + m.SetSize(120, 30) + if m.ShowInfoBar { + t.Fatal("ShowInfoBar should default to false when unset in config") + } + + // Toggle on: view flag, config struct, and the persisted file all flip. + m.Input.SetValue("/infobar") + m = pressEnter(t, m) + if !m.ShowInfoBar { + t.Fatal("expected ShowInfoBar to be true after /infobar") + } + if m.ToastMessage != "info bar on" { + t.Fatalf("toast = %q, want %q", m.ToastMessage, "info bar on") + } + if !cfg.ShowInfoBar { + t.Fatal("expected cfg.ShowInfoBar to be true after /infobar") + } + loaded, err := config.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if !loaded.ShowInfoBar { + t.Fatal("expected show-info-bar to be persisted as true") + } + + // Toggle off again. + m.Input.SetValue("/infobar") + m = pressEnter(t, m) + if m.ShowInfoBar { + t.Fatal("expected ShowInfoBar to be false after second /infobar") + } + if m.ToastMessage != "info bar off" { + t.Fatalf("toast = %q, want %q", m.ToastMessage, "info bar off") + } + if cfg.ShowInfoBar { + t.Fatal("expected cfg.ShowInfoBar to be false after second /infobar") + } + loaded, err = config.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if loaded.ShowInfoBar { + t.Fatal("expected show-info-bar to be persisted as false") + } +} + +func TestInfoBarToggleSaveFailureSurfacesStatusText(t *testing.T) { + configRoot := t.TempDir() + setUserConfigEnv(t, configRoot) + // Make the late config dir path a regular file so SaveConfig cannot + // create the atomic temp file inside it. + blocking := filepath.Join(configRoot, "late") + if err := os.WriteFile(blocking, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{} + m := NewModel(&mockOrchestrator{}, nil, cfg) + m.SetSize(120, 30) + m.Input.SetValue("/infobar") + m = pressEnter(t, m) + + if !m.ShowInfoBar { + t.Fatal("view toggle should still apply when saving fails") + } + state := m.GetAgentState(m.Focused.ID()) + if state.StatusText != "failed to save info bar setting" { + t.Fatalf("StatusText = %q, want save-failure status", state.StatusText) + } +} + +func TestInfoBarToggleReservesLayoutRow(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, &config.Config{}) + m.SetSize(80, 24) + + if m.infoBarHeight() != 0 { + t.Fatalf("infoBarHeight() = %d, want 0 while disabled", m.infoBarHeight()) + } + vpOff := m.Viewport.Height() + fpOff := m.FilePicker.Height() + + m.ShowInfoBar = true + m.updateLayout() + + if m.infoBarHeight() != InfoBarHeight { + t.Fatalf("infoBarHeight() = %d, want %d", m.infoBarHeight(), InfoBarHeight) + } + if got := m.Viewport.Height(); got != vpOff-1 { + t.Fatalf("viewport height = %d, want %d (one row reserved for the info bar)", got, vpOff-1) + } + if got := m.FilePicker.Height(); got != fpOff-1 { + t.Fatalf("file picker height = %d, want %d", got, fpOff-1) + } +} + +// typedOrchestrator lets a test focus a subagent-style ID ("-subagent-n") +// so the config.AgentModels lookup path is exercised. +type typedOrchestrator struct { + mockOrchestrator + id string +} + +func (m *typedOrchestrator) ID() string { return m.id } + +func TestInfoBarRenderContents(t *testing.T) { + cfg := &config.Config{ + Models: []config.ModelSetting{{ID: "provider-a", URL: "https://a.example/v1", Key: "k", Model: "gpt-test"}}, + AgentModels: map[string]string{"researcher": "provider-a"}, + } + m := NewModel(&mockOrchestrator{}, nil, cfg) + m.ShowInfoBar = true + m.Width = 200 + m.CWD = "/home/user/myproject" + m.ModelName = "fallback-model" + m.SkillsInfo = SkillsInfo{Count: 2, Tokens: 2500} + // Focus a researcher subagent: its type has an explicit agent_models entry. + m.Focused = &typedOrchestrator{mockOrchestrator{}, "researcher-subagent-0"} + + state := m.GetAgentState(m.Focused.ID()) + state.CumulativeTokenCount = 20 // mockOrchestrator.MaxTokens() == 100 + state.CreatedAt = time.Now().Add(-2 * time.Hour) + + plain := ansi.Strip(m.infoBarView()) + + if !strings.Contains(plain, "late v"+common.Version) { + t.Errorf("expected version segment in %q", plain) + } + if !strings.Contains(plain, "myproject") { + t.Errorf("expected project folder basename in %q", plain) + } + // Focused agent type "orchestrator" has an explicit entry: stable ref · model name. + if !strings.Contains(plain, "provider-a · gpt-test") { + t.Errorf("expected provider/profile ref and model name in %q", plain) + } + if !strings.Contains(plain, "20%") || !strings.Contains(plain, "20/100") { + t.Errorf("expected context usage bar (20%% of 100) in %q", plain) + } + if !strings.Contains(plain, "subagents: 0 running") { + t.Errorf("expected running-subagent count in %q", plain) + } + if !strings.Contains(plain, "skills: 2 (~2k tok)") { + t.Errorf("expected skills segment with estimate in %q", plain) + } + // Headroom to the default 80%% threshold: 100*80/100 - 20 = 60 tokens. + if !strings.Contains(plain, "~60 tokens to threshold") { + t.Errorf("expected threshold headroom segment in %q", plain) + } + if !strings.Contains(plain, "up 2h") { + t.Errorf("expected uptime segment in %q", plain) + } + + // Subagents with work in flight are counted; the root state is not. + m.GetAgentState("researcher-subagent-0").State = StateStreaming + m.GetAgentState("coder-subagent-1").State = StateThinking + m.GetAgentState("coder-subagent-2").State = StateConfirmTool // waiting, not running + plain = ansi.Strip(m.infoBarView()) + if !strings.Contains(plain, "subagents: 2 running") { + t.Errorf("expected 2 running subagents in %q", plain) + } +} + +// unknownCtxOrchestrator reports an unknown context size (ContextSize -1) +// so the info bar's threshold-headroom segment can be exercised as omitted. +type unknownCtxOrchestrator struct { + mockOrchestrator +} + +func (m *unknownCtxOrchestrator) MaxTokens() int { return -1 } + +func TestInfoBarSegmentsOmittedWhenUnknown(t *testing.T) { + m := NewModel(&unknownCtxOrchestrator{mockOrchestrator{}}, nil, &config.Config{}) + m.ShowInfoBar = true + m.Width = 140 + m.ModelName = "solo-model" + m.SkillsInfo = SkillsInfo{} // no skills discovered + + plain := ansi.Strip(m.infoBarView()) + if strings.Contains(plain, "skills:") { + t.Errorf("skills segment should be omitted when no skills exist: %q", plain) + } + if strings.Contains(plain, "tokens to threshold") { + t.Errorf("threshold segment should be omitted when ctx size is unknown: %q", plain) + } + if !strings.Contains(plain, "solo-model") { + t.Errorf("expected fallback orchestrator model name in %q", plain) + } + if !strings.Contains(plain, "late v"+common.Version) { + t.Errorf("expected version segment in %q", plain) + } +} + +func TestInfoBarHiddenWhenDisabled(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, &config.Config{}) + m.Width = 120 + m.CWD = "/tmp/project" + if got := m.infoBarView(); got != "" { + t.Fatalf("infoBarView() = %q, want empty while disabled", got) + } + + // Even when enabled, the bar never renders while the file picker is open. + m.ShowInfoBar = true + m.ShowFilePicker = true + if got := m.infoBarView(); got != "" { + t.Fatalf("infoBarView() = %q, want empty while the file picker is open", got) + } + if got := m.infoBarHeight(); got != 0 { + t.Fatalf("infoBarHeight() = %d, want 0 while the file picker is open", got) + } +} + +func TestInfoBarSingleLineTruncatesToWidth(t *testing.T) { + m := NewModel(&mockOrchestrator{}, nil, nil) + m.ShowInfoBar = true + m.Width = 30 + m.CWD = "/home/user/some-repo" + m.ModelName = "a-very-long-model-name-that-will-not-fit" + m.SkillsInfo = SkillsInfo{Count: 3, Tokens: 42000} + + view := m.infoBarView() + if strings.Contains(view, "\n") { + t.Fatalf("info bar must never wrap, got %q", view) + } + if got := lipgloss.Width(view); got != m.Width { + t.Fatalf("info bar width = %d, want %d (padded to full row)", got, m.Width) + } + plain := ansi.Strip(view) + if !strings.Contains(plain, "…") { + t.Errorf("expected an ellipsis on the truncated row, got %q", plain) + } +} + +func TestFormatUptime(t *testing.T) { + cases := []struct { + d time.Duration + want string + }{ + {0, "0s"}, + {42 * time.Second, "42s"}, + {time.Minute, "1m"}, + {75 * time.Minute, "1h15m"}, + {3 * time.Hour, "3h"}, + {26 * time.Hour, "1d2h"}, + {48 * time.Hour, "2d"}, + } + for _, tc := range cases { + if got := formatUptime(tc.d); got != tc.want { + t.Errorf("formatUptime(%v) = %q, want %q", tc.d, got, tc.want) + } + } +} + +func TestCompactionHeadroomTokens(t *testing.T) { + cases := []struct { + name string + current, max, pct int + want int + }{ + {"below threshold", 20, 100, 80, 60}, + {"at threshold", 80, 100, 80, 0}, + {"over threshold clamps at zero", 120, 100, 80, 0}, + {"custom threshold", 10, 1000, 50, 490}, + {"unknown ctx size", 10, -1, 80, 0}, + {"unlimited ctx size", 10, 0, 80, 0}, + {"zero pct falls back to default", 0, 100, 0, 80}, + {"pct over 100 clamps", 0, 100, 150, 100}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := compactionHeadroomTokens(tc.current, tc.max, tc.pct); got != tc.want { + t.Fatalf("compactionHeadroomTokens(%d, %d, %d) = %d, want %d", + tc.current, tc.max, tc.pct, got, tc.want) + } + }) + } +} + +func TestAgentTypeForID(t *testing.T) { + cases := []struct { + id string + want string + }{ + {common.MainAgentID, "orchestrator"}, + {"", "orchestrator"}, + {"researcher-subagent-3", "researcher"}, + {"subagent-9", ""}, // no "-subagent-" separator → unknown type + } + for _, tc := range cases { + if got := agentTypeForID(tc.id); got != tc.want { + t.Errorf("agentTypeForID(%q) = %q, want %q", tc.id, got, tc.want) + } + } +} + +func TestInfoBarCommandListed(t *testing.T) { + found := false + for _, cmd := range AvailableCommands { + if cmd.Name == "/infobar" { + found = true + if cmd.Description == "" { + t.Fatal("/infobar should carry a description for the help view") + } + } + } + if !found { + t.Fatal("/infobar missing from AvailableCommands") + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 6b0d0501..f9c1903b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -76,16 +76,16 @@ func NewModel(root common.Orchestrator, renderer *glamour.TermRenderer, cfg *con } m := Model{ - Mode: ViewChat, - Root: root, - Focused: root, - Input: ti, - Viewport: vp, - Renderer: renderer, - Width: 80, - Height: 24, // Default start height - AgentStates: make(map[string]*AppState), - InspectingTool: false, + Mode: ViewChat, + Root: root, + Focused: root, + Input: ti, + Viewport: vp, + Renderer: renderer, + Width: 80, + Height: 24, // Default start height + AgentStates: make(map[string]*AppState), + InspectingTool: false, Spinner: spinner.New(spinner.WithSpinner(spinner.Spinner{ Frames: spinner.Dot.Frames, FPS: 40 * time.Millisecond, @@ -100,6 +100,8 @@ func NewModel(root common.Orchestrator, renderer *glamour.TermRenderer, cfg *con AppConfig: cfg, SelectedTheme: "default", activeThemeStyles: LateTheme, + ShowInfoBar: cfg != nil && cfg.ShowInfoBar, + ShowTimestamps: cfg != nil && cfg.ShowTimestamps, } fp := filepicker.New() @@ -130,6 +132,7 @@ func NewModel(root common.Orchestrator, renderer *glamour.TermRenderer, cfg *con StatusText: "Ready", CumulativeTokenCount: cumulativeTokens, CachedWidth: -1, + CreatedAt: time.Now(), } return m diff --git a/internal/tui/state.go b/internal/tui/state.go index c7757015..ba5fd1a7 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -48,6 +48,10 @@ const ( const ( StatusBarHeight = 2 AppPadding = 0 + // InfoBarHeight is the extra footer row reserved when the info bar is + // shown (toggled via /infobar). The info bar is a borderless single + // line rendered below the status bar. + InfoBarHeight = 1 ) // CommandDef defines a slash command and its description. @@ -60,12 +64,14 @@ type CommandDef struct { var AvailableCommands = []CommandDef{ {Name: "/compose", Description: "Compose a message with an editor"}, {Name: "/help", Description: "Show help and shortcuts"}, + {Name: "/infobar", Description: "Toggle the info bar footer"}, {Name: "/log", Description: "View git commit log"}, {Name: "/model", Description: "Select AI model for agents"}, {Name: "/new", Description: "Start fresh conversation"}, {Name: "/quit", Description: "Exit the application"}, {Name: "/rewind", Description: "Rewind conversation history"}, {Name: "/themes", Description: "List and switch themes"}, + {Name: "/timestamps", Description: "Toggle message timestamps"}, {Name: "/todos", Description: "Toggle live todo progress pane"}, } @@ -143,6 +149,12 @@ type AppState struct { ContextWarningShown bool // Whether the preflight context warning has been shown for the current input Error error + // CreatedAt is when this agent state was created (session start for the + // root agent). It backs the info bar's "up " segment and is + // deliberately not reset by /new: it measures session uptime, not + // conversation age. + CreatedAt time.Time + // RetryVerb records the failure class of the retry an agent is in: // retryVerbConnectionLost ("connection lost") for infra failures or // retryVerbRejectedByAPI ("request rejected by the API") for HTTP 400s. @@ -152,6 +164,17 @@ type AppState struct { RetryVerb string } +// SkillsInfo summarizes the agent skills discovered at startup (user + +// project skills directories, the same source executor.RegisterTools uses to +// build the activate_skill tool). Count is the number of skills; Tokens is +// the estimated token footprint of their instruction bodies, computed once in +// cmd/late/main.go with the common token estimator so the info bar can show +// it without touching disk per frame. +type SkillsInfo struct { + Count int + Tokens int +} + type Model struct { cachedScreen tea.View screenReady bool @@ -189,6 +212,19 @@ type Model struct { TodoPaneFocused bool TodoScrollOffset int + // Info bar (toggled via /infobar, persisted as config show-info-bar). + // A view-level setting, not per-agent. SkillsInfo summarizes the + // agent skills discovered at startup so the info bar can show how much + // skill content is on disk without re-reading it on every frame. + ShowInfoBar bool + SkillsInfo SkillsInfo + + // Timestamps (toggled via /timestamps, persisted as config + // show-timestamps). A view-level setting, not per-agent: when set, + // every rendered transcript block that carries a message timestamp is + // prefixed with its [HH:MM:SS] render of the recorded receive time. + ShowTimestamps bool + // Double-click copy & Toast tracking LastClickX int LastClickY int @@ -301,11 +337,22 @@ func (m *Model) GetAgentState(id string) *AppState { State: StateIdle, StatusText: "Ready", PendingStop: false, + CreatedAt: time.Now(), } m.AgentStates[id] = s return s } +// infoBarHeight returns the number of extra footer rows the info bar +// occupies right now. It mirrors the status bar's own visibility rule: the +// bar is hidden while the file picker is open, so no row is reserved. +func (m *Model) infoBarHeight() int { + if m.ShowInfoBar && !m.ShowFilePicker { + return InfoBarHeight + } + return 0 +} + func (m *Model) hasActiveAgent() bool { for _, state := range m.AgentStates { if state.State != StateIdle && state.State != StateContextWarning { diff --git a/internal/tui/update.go b/internal/tui/update.go index 892d1957..a6b0ed25 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1092,6 +1092,64 @@ func (m Model) updateChat(msg tea.Msg) (Model, tea.Cmd) { m.updateLayout() return m, nil } + if cmd == "/infobar" { + m.Input.Reset() + m.Input.SetValue("") + m.ShowInfoBar = !m.ShowInfoBar + m.updateLayout() + feedback := "info bar off" + if m.ShowInfoBar { + feedback = "info bar on" + } + // Persist best-effort, mirroring the theme-apply SaveConfig + // call sites; a save failure keeps the view toggle but is + // surfaced as status text. + if m.AppConfig != nil { + m.AppConfig.ShowInfoBar = m.ShowInfoBar + if err := config.SaveConfig(m.AppConfig); err != nil { + focusedState.StatusText = "failed to save info bar setting" + } + } + m.ToastMessage = feedback + m.ToastWarning = false + m.ToastExpireTime = time.Now().UnixMilli() + 3000 + clearCmd := tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return clearToastMsg{} + }) + m.updateViewport() + return m, clearCmd + } + if cmd == "/timestamps" { + m.Input.Reset() + m.Input.SetValue("") + m.ShowTimestamps = !m.ShowTimestamps + feedback := "timestamps off" + if m.ShowTimestamps { + feedback = "timestamps on" + } + // Persist best-effort, mirroring the /infobar SaveConfig + // call site; a save failure keeps the view toggle but is + // surfaced as status text. + if m.AppConfig != nil { + m.AppConfig.ShowTimestamps = m.ShowTimestamps + if err := config.SaveConfig(m.AppConfig); err != nil { + focusedState.StatusText = "failed to save timestamps setting" + } + } + // The toggle changes how every cached block renders; mark + // the transcript dirty. The render pass also discards the + // block cache whenever the timestamps flag differs from + // the state it was rendered with. + m.refreshTranscript() + m.ToastMessage = feedback + m.ToastWarning = false + m.ToastExpireTime = time.Now().UnixMilli() + 3000 + clearCmd := tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return clearToastMsg{} + }) + m.updateViewport() + return m, clearCmd + } if cmd == "/model" { m.Input.Reset() m.Input.SetValue("") @@ -1845,9 +1903,13 @@ func (m *Model) updateLayout() { s.RenderedHistory = nil } } - vHeight := m.Height - (m.Input.Height() + 1) - StatusBarHeight - AppPadding + // Reserve one extra footer row while the info bar is shown + // (mirrors the StatusBarHeight reservation above). + infoH := m.infoBarHeight() + + vHeight := m.Height - (m.Input.Height() + 1) - StatusBarHeight - AppPadding - infoH if m.Mode == ViewModelPicker { - vHeight = m.Height - 3 - StatusBarHeight - AppPadding + vHeight = m.Height - 3 - StatusBarHeight - AppPadding - infoH } // Reserve space for autocomplete dropdown @@ -1864,7 +1926,9 @@ func (m *Model) updateLayout() { // Ensure file picker also respects the layout height to prevent pushing the status bar off-screen // We subtract StatusBarHeight. If we have a 2-line picker status bar, we subtract 3. - fpHeight := m.Height - 3 + // The info bar never renders while the picker is open, so infoBarHeight() + // contributes nothing in that mode. + fpHeight := m.Height - 3 - m.infoBarHeight() if fpHeight < 1 { fpHeight = 1 } diff --git a/internal/tui/view.go b/internal/tui/view.go index 8f7bf367..0d03d7ae 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -97,6 +97,11 @@ func (m Model) buildScreen() tea.View { content += "\n" + aStr } content += "\n" + iStr + "\n" + sStr + // Info bar sits below the status bar (one extra footer row, reserved by + // updateLayout via infoBarHeight). Empty while the file picker is open. + if infoStr := m.infoBarView(); infoStr != "" { + content += "\n" + infoStr + } v := tea.NewView(sanitizeVTE(content, m.Width)) v.AltScreen = true @@ -360,7 +365,7 @@ func (m *Model) renderMinimalEqualizerAt(now time.Time) string { // Gentle incommensurate harmonic (golden ratio 1.618) creates organic, non-repeating crests // Low amplitude ensures it never causes erratic snap or jitter - w2 := 0.35 * math.Sin(t*0.93 + float64(i)*0.55 + 1.2) + w2 := 0.35 * math.Sin(t*0.93+float64(i)*0.55+1.2) // Breathing envelope gives gentle natural cadence swell := 0.88 + 0.20*math.Sin(t*0.38+float64(i)*0.25) @@ -717,6 +722,197 @@ func (m *Model) statusBarView() string { return statusBarBaseStyle.Width(w).Render(" " + content + " ") } +// agentTypeForID maps an orchestrator ID to the agent type used by +// config.AgentModels lookups: the root agent ("main") maps to "orchestrator" +// and "-subagent-" (the NextChildID scheme) maps to "". +// Unrecognized IDs return "" (no config lookup possible). +func agentTypeForID(id string) string { + if id == "" || id == common.MainAgentID { + return "orchestrator" + } + if idx := strings.Index(id, "-subagent-"); idx > 0 { + return id[:idx] + } + return "" +} + +// focusedModelInfo resolves the provider/profile reference and model name +// shown for the focused agent. An explicit config.AgentModels entry for the +// focused agent's type wins (ModelSetting.Reference is the stable ID the +// /model picker stores); otherwise the orchestrator model surfaced by +// main.go (ModelName, kept current by the picker) is used. For a subagent +// without its own entry, SubagentInfo names the subagent backend when it is +// a single model (it is a comma-joined "type:model" list only when several +// subagent types have explicit entries). +func (m *Model) focusedModelInfo() (ref, name string) { + agentType := agentTypeForID(m.Focused.ID()) + if agentType != "" && m.AppConfig != nil { + if setting, ok := m.AppConfig.GetModelForAgent(agentType); ok { + return setting.Reference(), setting.Model + } + } + name = m.ModelName + if agentType != "orchestrator" && m.SubagentInfo != "" && + !strings.Contains(m.SubagentInfo, ":") && !strings.Contains(m.SubagentInfo, ",") { + name = m.SubagentInfo + } + if name == "" { + name = "default" + } + return "", name +} + +// runningSubagentCount counts non-root agent states that are actively doing +// work (thinking, streaming, or stopping). Approximation: subagents waiting +// for tool confirmation are not counted as running, and StateContextWarning +// is a preflight notice on an otherwise idle agent, so neither counts. +func (m *Model) runningSubagentCount() int { + rootID := "" + if m.Root != nil { + rootID = m.Root.ID() + } + n := 0 + for id, s := range m.AgentStates { + if id == rootID || id == common.MainAgentID { + continue + } + switch s.State { + case StateThinking, StateStreaming, StateStopping: + n++ + } + } + return n +} + +// defaultContextThresholdPercent is the context-usage percentage the info +// bar's headroom segment assumes. The branch base (upstream/main) has no +// compaction configuration, so instead of config.ResolveCompactionThreshold +// the value mirrors local/full's config.DefaultCompactionThresholdPercent +// (80) as a package-private constant. +const defaultContextThresholdPercent = 80 + +// compactionHeadroomTokens returns how many tokens remain before the context +// reaches the compaction threshold (maxTokens * thresholdPct / 100), clamped +// at zero. Unknown or unlimited context (maxTokens <= 0) yields 0; callers +// omit the segment in that case. +func compactionHeadroomTokens(current, maxTokens, thresholdPct int) int { + if maxTokens <= 0 { + return 0 + } + if thresholdPct <= 0 { + thresholdPct = defaultContextThresholdPercent + } + if thresholdPct > 100 { + thresholdPct = 100 + } + headroom := (maxTokens*thresholdPct)/100 - current + if headroom < 0 { + return 0 + } + return headroom +} + +// formatUptime renders coarse session uptime for the info bar: 42s, 12m, +// 3h5m, 2d4h. +func formatUptime(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + if days > 0 { + if hours > 0 { + return fmt.Sprintf("%dd%dh", days, hours) + } + return fmt.Sprintf("%dd", days) + } + if minutes > 0 { + return fmt.Sprintf("%dh%dm", hours, minutes) + } + return fmt.Sprintf("%dh", hours) +} + +// infoBarView renders the optional single-row info footer toggled by +// /infobar and persisted as config show-info-bar. It reuses the status bar +// base style family and renders the following " · "-separated segments: +// +// late · · · +// ctx · subagents: N running · skills: N (~T tok) · +// ~N tokens to threshold · up +// +// Data sources and approximations: +// - "used" tokens: the focused agent's AppState.CumulativeTokenCount — the +// session-total estimate the status bar context bar uses (not +// client.Usage, which only reflects the last turn). +// - context max: the focused agent's MaxTokens() (client.ContextSize(); +// -1 = unknown, 0 = unlimited — the headroom segment is omitted then). +// - skills: SkillsInfo, estimated once at startup from skill instructions. +// +// The row is truncated (never wrapped) to the window width and padded to it +// so the background stays opaque, mirroring renderActivityAt. +func (m *Model) infoBarView() string { + if !m.ShowInfoBar || m.ShowFilePicker { + return "" + } + w := max(m.Width, 1) + + brandStyle := lipgloss.NewStyle().Foreground(primaryColor).Background(appBgColor).Bold(true) + labelStyle := lipgloss.NewStyle().Foreground(mutedTextColor).Background(appBgColor) + valueStyle := lipgloss.NewStyle().Foreground(subtextColor).Background(appBgColor) + sep := labelStyle.Render(" · ") + + var parts []string + parts = append(parts, + brandStyle.Render("late ")+valueStyle.Render("v"+common.Version), + ) + if m.CWD != "" { + parts = append(parts, valueStyle.Render(filepath.Base(m.CWD))) + } + if ref, name := m.focusedModelInfo(); ref != "" { + parts = append(parts, valueStyle.Render(ref+" · "+name)) + } else { + parts = append(parts, valueStyle.Render(name)) + } + + s := m.GetAgentState(m.Focused.ID()) + // Same source the status bar context bar uses (m.Focused.MaxTokens() is + // client.ContextSize(): -1 unknown, 0 unlimited). + maxTokens := m.Focused.MaxTokens() + parts = append(parts, labelStyle.Render("ctx ")+m.renderContextBar(s.CumulativeTokenCount, maxTokens)) + + parts = append(parts, + labelStyle.Render("subagents: ")+valueStyle.Render(fmt.Sprintf("%d running", m.runningSubagentCount())), + ) + + if m.SkillsInfo.Count > 0 { + parts = append(parts, labelStyle.Render("skills: ")+ + valueStyle.Render(fmt.Sprintf("%d (~%s tok)", m.SkillsInfo.Count, m.formatTokenCount(m.SkillsInfo.Tokens)))) + } + + if maxTokens > 0 { + headroom := compactionHeadroomTokens(s.CumulativeTokenCount, maxTokens, defaultContextThresholdPercent) + parts = append(parts, valueStyle.Render(fmt.Sprintf("~%s tokens to threshold", m.formatTokenCount(headroom)))) + } + + uptime := "0s" + if !s.CreatedAt.IsZero() { + uptime = formatUptime(time.Since(s.CreatedAt)) + } + parts = append(parts, labelStyle.Render("up ")+valueStyle.Render(uptime)) + + row := strings.Join(parts, sep) + truncated := ansi.Truncate(row, max(1, w), "…") + rw := ansi.StringWidth(truncated) + if rw < w { + truncated += lipgloss.NewStyle().Background(appBgColor).Render(strings.Repeat(" ", w-rw)) + } + return truncated +} + func (m *Model) updateViewport() { if m.Focused == nil { return From b03343189982f4e3e454262191a3fb09fa3093e1 Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:52:08 +0200 Subject: [PATCH 2/3] feat(tui): optional [HH:MM:SS] timestamps on transcript blocks + /timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session stamps every appended history message with an RFC3339 receive time (appendMessage is the single path; caller-supplied timestamps are preserved, legacy entries stay unprefixed). The TUI renders the time as a muted [HH:MM:SS] row at the start of user and assistant blocks when ShowTimestamps is on; toggling /timestamps invalidates the transcript block cache and persists show-timestamps to config.json (handled by the update.go change in the info-bar commit). Port notes (branch surgery): - Docs: carries the README developer-ergonomics bullet and the Quickstart "Info Bar & Timestamps" section for both features (the first commit where both exist). The Quickstart headroom wording reads "context threshold" instead of "compaction threshold" — this branch's base has no compaction configuration. --- README.md | 1 + docs/quickstart.md | 8 ++ internal/client/types.go | 4 + internal/session/session.go | 32 ++++-- internal/session/timestamp_test.go | 49 +++++++++ internal/tui/timestamps_test.go | 161 +++++++++++++++++++++++++++++ internal/tui/transcript.go | 35 ++++++- 7 files changed, 274 insertions(+), 16 deletions(-) create mode 100644 internal/session/timestamp_test.go create mode 100644 internal/tui/timestamps_test.go diff --git a/README.md b/README.md index 1ecaf3f0..184c2a4b 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,7 @@ export OPENAI_MODEL="model-name" * **Developer Ergonomics:** * `/compose`: Pop open your preferred `$EDITOR` (Neovim, Vim, Helix, VS Code) to draft complex, multi-line instructions. * `/rewind`: Visual history scrubber to roll back turns and branch conversational states. + * `/infobar` / `/timestamps`: Toggle the info footer (version, model, context, subagents, skills, uptime) and `[HH:MM:SS]` message timestamps; both persist to `config.json`. * `late --prompt "..."`: Start a session with a pre-given prompt, useful for running it from other scripts. * **Auditable Subagent History:** Full subagent conversation transcripts and metadata are persisted to disk for total transparency and debugging—without poisoning the orchestrator's active context window. Opt-in persistence protects your disk space while ensuring you can debug overnight autopilot runs. * **Exact-Match Diffs & Autonomous Healing:** Strict `search`/`replace` editing with automatic self-healing on mismatch. Edits fail loud; files are never silently corrupted. diff --git a/docs/quickstart.md b/docs/quickstart.md index 9493131a..b39b0b95 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -161,6 +161,14 @@ When Late creates subagents, each appears in its own tab while it works and disa --- +## Info Bar & Timestamps + +* `/infobar` toggles a single-line footer under the status bar: Late version, project folder, focused model, context usage, running subagents, discovered skills, tokens to the context threshold (80% of the model's context size), and session uptime. +* `/timestamps` toggles `[HH:MM:SS]` prefixes on transcript message blocks. +* Both toggles persist to `config.json` (`show-info-bar`, `show-timestamps`). + +--- + ## Tool Approval Potentially destructive commands and file changes require approval unless you have already granted permission for that scope. diff --git a/internal/client/types.go b/internal/client/types.go index 8961b8c1..3ae2d2ae 100644 --- a/internal/client/types.go +++ b/internal/client/types.go @@ -27,6 +27,10 @@ type ChatMessage struct { ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` // For tool responses AttachedFiles []string `json:"-"` // Purely for UI display + // Timestamp records when the session appended the message to history + // (RFC3339). Optional: legacy history entries predate the field and + // render without a time prefix in the transcript. + Timestamp string `json:"timestamp,omitempty"` } type MessageContent struct { diff --git a/internal/session/session.go b/internal/session/session.go index b1812fff..c53610b5 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -104,25 +104,36 @@ func (s *Session) ExecuteTool(ctx context.Context, tc client.ToolCall) (string, return t.Execute(ctx, json.RawMessage(tc.Function.Arguments)) } +// appendMessage is the single path every Add* helper uses to record a +// message: it stamps the message's receive time (RFC3339) unless the caller +// supplied one, appends to history, and persists. The timestamp drives the +// optional [HH:MM:SS] prefix in the TUI transcript; legacy history entries +// that predate the field keep an empty Timestamp. +func (s *Session) appendMessage(msg client.ChatMessage) error { + if msg.Timestamp == "" { + msg.Timestamp = time.Now().Format(time.RFC3339) + } + s.History = append(s.History, msg) + return s.saveAndNotify() +} + // AddToolResultMessage adds a tool response message to history. func (s *Session) AddToolResultMessage(toolCallID, content string) error { - s.History = append(s.History, client.ChatMessage{ + return s.appendMessage(client.ChatMessage{ Role: "tool", ToolCallID: toolCallID, Content: client.TextContent(content), }) - return s.saveAndNotify() } // AddAssistantMessageWithTools adds an assistant message with tool calls. func (s *Session) AddAssistantMessageWithTools(content string, reasoning string, toolCalls []client.ToolCall) error { - s.History = append(s.History, client.ChatMessage{ + return s.appendMessage(client.ChatMessage{ Role: "assistant", Content: client.TextContent(content), ReasoningContent: reasoning, ToolCalls: toolCalls, }) - return s.saveAndNotify() } func (s *Session) GetToolDefinitions() []client.ToolDefinition { @@ -143,24 +154,23 @@ func (s *Session) GetToolDefinitions() []client.ToolDefinition { // AddUserMessage adds a user message to history and persists it. func (s *Session) AddUserMessage(content string) error { - s.History = append(s.History, client.ChatMessage{Role: "user", Content: client.TextContent(content)}) - return s.saveAndNotify() + return s.appendMessage(client.ChatMessage{Role: "user", Content: client.TextContent(content)}) } -// AddMessage adds an arbitrary message to history and persists it. +// AddMessage adds an arbitrary message to history and persists it. A caller +// supplied Timestamp is preserved; otherwise the message is stamped like any +// other add. func (s *Session) AddMessage(msg client.ChatMessage) error { - s.History = append(s.History, msg) - return s.saveAndNotify() + return s.appendMessage(msg) } // AddAssistantMessage adds an assistant message to history and persists it. func (s *Session) AddAssistantMessage(content, reasoning string) error { - s.History = append(s.History, client.ChatMessage{ + return s.appendMessage(client.ChatMessage{ Role: "assistant", Content: client.TextContent(content), ReasoningContent: reasoning, }) - return s.saveAndNotify() } // PopLastUserMessage removes the trailing user message from history and diff --git a/internal/session/timestamp_test.go b/internal/session/timestamp_test.go new file mode 100644 index 00000000..f2cc3ada --- /dev/null +++ b/internal/session/timestamp_test.go @@ -0,0 +1,49 @@ +package session + +import ( + "testing" + "time" + + "late/internal/client" +) + +// Add* helpers stamp the message receive time (RFC3339) in one shared path +// (appendMessage) so the TUI transcript can prefix blocks with [HH:MM:SS]. +func TestAddUserMessageStampsRFC3339Timestamp(t *testing.T) { + s := New(nil, "", nil, "", false) // no history path: nothing is persisted + if err := s.AddUserMessage("Hello"); err != nil { + t.Fatalf("AddUserMessage() error = %v", err) + } + ts := s.History[0].Timestamp + if ts == "" { + t.Fatal("expected AddUserMessage to stamp a non-empty Timestamp") + } + parsed, err := time.Parse(time.RFC3339, ts) + if err != nil { + t.Fatalf("Timestamp %q is not RFC3339: %v", ts, err) + } + if elapsed := time.Since(parsed); elapsed < 0 || elapsed > time.Minute { + t.Fatalf("Timestamp %q is not a recent receive time (elapsed %v)", ts, elapsed) + } +} + +func TestAddMessageStampsEmptyAndPreservesCallerTimestamp(t *testing.T) { + s := New(nil, "", nil, "", false) + + // An unstamped message added via AddMessage is stamped like any other. + if err := s.AddMessage(client.ChatMessage{Role: "tool", Content: client.TextContent("stamped")}); err != nil { + t.Fatalf("AddMessage() error = %v", err) + } + if s.History[0].Timestamp == "" { + t.Fatal("expected AddMessage to stamp an empty Timestamp") + } + + // A caller supplied timestamp survives the round trip. + fixed := "2024-05-06T07:08:09Z" + if err := s.AddMessage(client.ChatMessage{Role: "assistant", Content: client.TextContent("preserved"), Timestamp: fixed}); err != nil { + t.Fatalf("AddMessage() error = %v", err) + } + if got := s.History[1].Timestamp; got != fixed { + t.Fatalf("AddMessage overwrote caller timestamp: got %q, want %q", got, fixed) + } +} diff --git a/internal/tui/timestamps_test.go b/internal/tui/timestamps_test.go new file mode 100644 index 00000000..f9386341 --- /dev/null +++ b/internal/tui/timestamps_test.go @@ -0,0 +1,161 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "late/internal/client" + "late/internal/config" +) + +// Rendered transcript blocks are prefixed with [HH:MM:SS] from the message's +// recorded receive time when ShowTimestamps is on. Messages without a +// timestamp (legacy history entries) always render unprefixed. +func TestTranscriptTimestampPrefix(t *testing.T) { + history := []client.ChatMessage{ + {Role: "user", Content: client.TextContent("TIMESTAMPEDPROMPT"), Timestamp: "2025-01-02T03:04:05Z"}, + {Role: "assistant", Content: client.TextContent("TIMESTAMPEDREPLY"), Timestamp: "2025-01-02T03:04:06Z"}, + {Role: "user", Content: client.TextContent("LEGACYPROMPT")}, + } + m, s := newViewportBenchmarkModel(history) + + // Off (default): no prefixes anywhere. + if content := testTranscriptContent(m); strings.Contains(ansi.Strip(content), "[03:04:05]") || strings.Contains(ansi.Strip(content), "[03:04:06]") { + t.Fatalf("timestamps rendered while ShowTimestamps is off:\n%s", ansi.Strip(content)) + } + + // On: the stamped user and assistant blocks carry their prefixes; the + // legacy block stays unprefixed. + m.ShowTimestamps = true + content := ansi.Strip(testTranscriptContent(m)) + if !strings.Contains(content, "[03:04:05]") { + t.Fatalf("missing [03:04:05] user prefix:\n%s", content) + } + if !strings.Contains(content, "[03:04:06]") { + t.Fatalf("missing [03:04:06] assistant prefix:\n%s", content) + } + assertBlockPrefix(t, s, 0, "[03:04:05]") + assertBlockPrefix(t, s, 1, "[03:04:06]") + assertBlockWithoutPrefix(t, s, 2) +} + +// assertBlockPrefix checks that the first row of the block for the history +// message at index starts with the expected [HH:MM:SS] prefix. +func assertBlockPrefix(t *testing.T, s *AppState, index int, want string) { + t.Helper() + block := renderBlockForIndex(s, index) + if block == nil { + t.Fatalf("no rendered block for history index %d", index) + } + first := strings.TrimSpace(ansi.Strip(s.Transcript.rows[block.StartLine])) + if !strings.HasPrefix(first, want) { + t.Fatalf("block %d starts with %q, want prefix %q", index, first, want) + } +} + +// assertBlockWithoutPrefix checks that no row of the block for the history +// message at index carries a timestamp prefix ([HH:MM:SS] is 10 characters). +func assertBlockWithoutPrefix(t *testing.T, s *AppState, index int) { + t.Helper() + block := renderBlockForIndex(s, index) + if block == nil { + t.Fatalf("no rendered block for history index %d", index) + } + for _, row := range s.Transcript.rows[block.StartLine : block.EndLine+1] { + trimmed := strings.TrimSpace(ansi.Strip(row)) + if len(trimmed) == 10 && strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + t.Fatalf("legacy block %d unexpectedly carries timestamp prefix %q", index, trimmed) + } + } +} + +func renderBlockForIndex(s *AppState, index int) *RenderBlock { + for i := range s.RenderBlocks { + if s.RenderBlocks[i].MessageIndex == index { + return &s.RenderBlocks[i] + } + } + return nil +} + +// TestTimestampsTogglePersistsToConfig mirrors the /infobar toggle test: the +// slash command flips the view flag, the in-memory config, and the persisted +// config.json value. +func TestTimestampsTogglePersistsToConfig(t *testing.T) { + configRoot := t.TempDir() + setUserConfigEnv(t, configRoot) + // SaveConfig refuses to write into a missing late config dir (the + // permission tightening fails), so create it up front the way the + // /infobar persistence test does. + userConfigDir, err := os.UserConfigDir() + if err != nil { + t.Fatalf("UserConfigDir() error = %v", err) + } + if err := os.MkdirAll(filepath.Join(userConfigDir, "late"), 0o700); err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + m := NewModel(&mockOrchestrator{}, nil, cfg) + m.SetSize(120, 30) + if m.ShowTimestamps { + t.Fatal("ShowTimestamps should default to false when unset in config") + } + + m.Input.SetValue("/timestamps") + m = pressEnter(t, m) + if !m.ShowTimestamps { + t.Fatal("expected ShowTimestamps to be true after /timestamps") + } + if m.ToastMessage != "timestamps on" { + t.Fatalf("toast = %q, want %q", m.ToastMessage, "timestamps on") + } + if !cfg.ShowTimestamps { + t.Fatal("expected cfg.ShowTimestamps to be true after /timestamps") + } + loaded, err := config.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if !loaded.ShowTimestamps { + t.Fatal("expected show-timestamps to be persisted as true") + } + + // Toggle off again. + m.Input.SetValue("/timestamps") + m = pressEnter(t, m) + if m.ShowTimestamps { + t.Fatal("expected ShowTimestamps to be false after second /timestamps") + } + if m.ToastMessage != "timestamps off" { + t.Fatalf("toast = %q, want %q", m.ToastMessage, "timestamps off") + } + if cfg.ShowTimestamps { + t.Fatal("expected cfg.ShowTimestamps to be false after second /timestamps") + } + loaded, err = config.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if loaded.ShowTimestamps { + t.Fatal("expected show-timestamps to be persisted as false") + } +} + +func TestTimestampsCommandListed(t *testing.T) { + found := false + for _, cmd := range AvailableCommands { + if cmd.Name == "/timestamps" { + found = true + if cmd.Description == "" { + t.Fatal("/timestamps should carry a description for the help view") + } + } + } + if !found { + t.Fatal("/timestamps missing from AvailableCommands") + } +} diff --git a/internal/tui/transcript.go b/internal/tui/transcript.go index f6490cc7..76404d8a 100644 --- a/internal/tui/transcript.go +++ b/internal/tui/transcript.go @@ -38,6 +38,7 @@ type transcriptState struct { generation uint64 width int theme string + timestamps bool } type transcriptRenderedMsg struct { @@ -50,6 +51,7 @@ type transcriptRenderedMsg struct { generation uint64 width int theme string + timestamps bool rows []string blocks []RenderBlock cache map[string][]string @@ -67,6 +69,7 @@ type transcriptEntry struct { content string reasoning string labels []transcriptLabel + timestamp string // RFC3339 receive time from the history message; empty for ephemeral/legacy entries } const ( @@ -246,6 +249,7 @@ func (m *Model) applyTranscript(result transcriptRenderedMsg) { t.thinking, t.thinkingLine = result.thinking, result.thinkingLine t.activities = result.activities t.width, t.theme = result.width, result.theme + t.timestamps = result.timestamps t.offset = min(t.offset, max(0, len(t.rows)-m.Viewport.Height())) s.RenderBlocks = result.blocks if result.partial { @@ -265,7 +269,11 @@ func (m *Model) renderTranscriptCmd() tea.Cmd { styles = LateTheme } width := max(1, m.Viewport.Width()) - if t.width != width || t.theme != string(styles) { + // The timestamps flag participates in the render-invalidation checks: + // toggling /timestamps must discard cached block rows so prefixes are + // added or removed on the next pass. + timestampsChanged := t.timestamps != m.ShowTimestamps + if t.width != width || t.theme != string(styles) || timestampsChanged { t.dirty = true } if t.busy || !t.dirty { @@ -275,10 +283,11 @@ func (m *Model) renderTranscriptCmd() tea.Cmd { t.dirty = false id, generation := m.Focused.ID(), t.generation oldCache := t.cache - if t.width != width || t.theme != string(styles) { + if t.width != width || t.theme != string(styles) || timestampsChanged { oldCache = nil } theme := string(styles) + showTimestamps := m.ShowTimestamps entries := make([]transcriptEntry, 0, len(m.Focused.History())+3) toolWidth := toolCallWidth(width) toolLabels := func(calls []client.ToolCall, active bool) []transcriptLabel { @@ -343,7 +352,7 @@ func (m *Model) renderTranscriptCmd() tea.Cmd { } labels = append(labels, item) } - entry := transcriptEntry{index: i, role: msg.Role, content: content, reasoning: msg.ReasoningContent, labels: labels} + entry := transcriptEntry{index: i, role: msg.Role, content: content, reasoning: msg.ReasoningContent, labels: labels, timestamp: msg.Timestamp} if len(msg.AttachedFiles) > 0 { names := make([]string, len(msg.AttachedFiles)) for j, f := range msg.AttachedFiles { @@ -405,7 +414,7 @@ func (m *Model) renderTranscriptCmd() tea.Cmd { answerStyle := assistantReplyStyle(width) return func() tea.Msg { renderer, err := glamour.NewTermRenderer(glamour.WithStylesFromJSONBytes([]byte(theme)), glamour.WithWordWrap(assistantReplyContentWidth(width)), glamour.WithPreservedNewLines()) - result := transcriptRenderedMsg{activities: make(map[int]string), partial: partial, welcome: welcome, id: id, generation: generation, width: width, theme: theme, cache: make(map[string][]string, len(entries))} + result := transcriptRenderedMsg{activities: make(map[int]string), partial: partial, welcome: welcome, id: id, generation: generation, width: width, theme: theme, timestamps: showTimestamps, cache: make(map[string][]string, len(entries))} markdown := func(source string) string { if err != nil { return source @@ -438,10 +447,20 @@ func (m *Model) renderTranscriptCmd() tea.Cmd { return strings.Join(parts, "\n") } for _, entry := range entries { - key := fmt.Sprintf("%t:%d:%s:%d:%s:%d:%s:%v", entry.active, len(entry.role), entry.role, len(entry.content), entry.content, len(entry.reasoning), entry.reasoning, entry.labels) + key := fmt.Sprintf("%t:%d:%s:%d:%s:%d:%s:%v:%s", entry.active, len(entry.role), entry.role, len(entry.content), entry.content, len(entry.reasoning), entry.reasoning, entry.labels, entry.timestamp) rows, ok := oldCache[key] if !ok { parts := make([]string, 0, 4) + // The [HH:MM:SS] prefix is rendered as its own muted row at + // the start of user and assistant message blocks, from the + // receive time the session recorded when the message was + // added. Legacy messages without a timestamp stay unprefixed. + prefix := "" + if showTimestamps && entry.timestamp != "" { + if ts, err := time.Parse(time.RFC3339, entry.timestamp); err == nil { + prefix = attachmentStyle.Render("[" + ts.Format("15:04:05") + "]") + } + } switch entry.role { case "user": text := strings.TrimRight(entry.content, "\r\n") @@ -456,9 +475,15 @@ func (m *Model) renderTranscriptCmd() tea.Cmd { block += "\n" + label.rendered } } + if prefix != "" { + parts = append(parts, prefix) + } parts = append(parts, "\n"+userPromptStyle(width).Render(block)+"\n") } case "assistant": + if prefix != "" { + parts = append(parts, prefix) + } if entry.reasoning != "" { header := headerStyle.Render("· thinking") if entry.active && entry.content == "" && len(entry.labels) == 0 { From 998aa4b83a6d02065d4158a3d7e6bd6f946719f2 Mon Sep 17 00:00:00 2001 From: Emasoft <713559+Emasoft@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:13:06 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(tui):=20ANSI-aware=20truncateWithEllips?= =?UTF-8?q?is=20=E2=80=94=20never=20cut=20styled=20strings=20mid-CSI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status bar and info bar truncate rendered, lipgloss-styled segments on narrow terminals. The old rune walker counted escape bytes as content and could cut INSIDE a CSI sequence, emitting garbage like "ESC[3..." into the bars. Replace it with ansi.Truncate, which measures the same visible width, keeps sequences intact, and already accounts for the tail width. Review fix cherry-picked from 0a97d2f (truncate helper + view_truncate_test.go only). --- internal/tui/view.go | 21 ++++----- internal/tui/view_truncate_test.go | 72 ++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 internal/tui/view_truncate_test.go diff --git a/internal/tui/view.go b/internal/tui/view.go index 0d03d7ae..2c815986 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1127,19 +1127,14 @@ func (m *Model) truncateWithEllipsis(s string, w int) string { if w <= 3 { return "..." } - - limit := w - 3 - res := "" - currW := 0 - for _, r := range s { - rw := lipgloss.Width(string(r)) - if currW+rw > limit { - break - } - res += string(r) - currW += rw - } - return res + "..." + // ansi.Truncate is escape-sequence aware: it measures the same visible + // width lipgloss.Width does, never cuts inside a CSI sequence (the old + // rune walker counted escape bytes as content and could split one), + // keeps any trailing style resets, and already accounts for the tail + // width, so the result stays <= w cells. Callers pass both plain and + // lipgloss-styled text (the status bar truncates rendered, styled + // status strings on narrow terminals). + return ansi.Truncate(s, w, "...") } func (m *Model) renderMarkdownBlock(content string, innerWidth int) string { diff --git a/internal/tui/view_truncate_test.go b/internal/tui/view_truncate_test.go new file mode 100644 index 00000000..ff01acb6 --- /dev/null +++ b/internal/tui/view_truncate_test.go @@ -0,0 +1,72 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// TestTruncateWithEllipsisPlainStringCompat pins the pre-existing plain-text +// contract the status bar and info bar rely on: visible width is capped, the +// ellipsis is appended, and short strings pass through untouched. +func TestTruncateWithEllipsisPlainStringCompat(t *testing.T) { + m := Model{} + + if got := m.truncateWithEllipsis("short", 40); got != "short" { + t.Fatalf("short string mangled: %q", got) + } + long := strings.Repeat("x", 200) + got := m.truncateWithEllipsis(long, 40) + if ansi.StringWidth(got) > 40 { + t.Fatalf("width = %d, want <= 40", ansi.StringWidth(got)) + } + if !strings.HasSuffix(got, "...") { + t.Fatalf("truncated string %q must end with the ellipsis", got) + } + // The visible prefix is exactly the width-3 leading characters. + if got != strings.Repeat("x", 37)+"..." { + t.Fatalf("unexpected truncation result: %q", got) + } +} + +// TestTruncateWithEllipsisANSISafe pins the escape-sequence fix: the old +// implementation walked raw runes, so a lipgloss-styled string (which the +// status bar truncates AFTER styling) had its escape bytes counted as content +// and could be cut INSIDE a CSI sequence, emitting garbage like "ESC[3..." +// into the rendered bar. ansi.Truncate keeps sequences intact and measures +// the same visible width lipgloss.Width does. +func TestTruncateWithEllipsisANSISafe(t *testing.T) { + m := Model{} + + styled := "\x1b[31m" + strings.Repeat("x", 50) + "\x1b[0m" + got := m.truncateWithEllipsis(styled, 10) + + if w := ansi.StringWidth(got); w > 10 { + t.Fatalf("visible width = %d, want <= 10", w) + } + plain := ansi.Strip(got) + if !strings.HasSuffix(plain, "...") { + t.Fatalf("truncated text %q must end with the ellipsis", plain) + } + // No CSI fragments may survive in the visible text: a cut inside an + // escape sequence leaves pieces like "[3" behind. + if strings.ContainsAny(plain, "\x1b[") { + t.Fatalf("escape sequence cut mid-stream, visible text = %q", plain) + } + // Exactly width-3 visible characters before the ellipsis. + if plain != strings.Repeat("x", 7)+"..." { + t.Fatalf("visible content = %q, want %q", plain, strings.Repeat("x", 7)+"...") + } +} + +// TestTruncateWithEllipsisANSIUnderFlow ensures a styled string that fits is +// returned untouched (styling preserved, no ellipsis injected). +func TestTruncateWithEllipsisANSIUnderFlow(t *testing.T) { + m := Model{} + + styled := "\x1b[31mred\x1b[0m" + if got := m.truncateWithEllipsis(styled, 40); got != styled { + t.Fatalf("styled under-limit string mangled: %q", got) + } +}