From 853a089fc5208d0d47fc8dc6a087ebb2e915377a Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Mon, 27 Apr 2026 19:51:36 +0200 Subject: [PATCH] tui: unify accent bar, narrow status split, restore session usage UI polish: - Add Theme.AccentBar(c) helper that returns the half-block leader ("\u258c ") in colour c. Use it everywhere a speaker / prompt bar is drawn: main editor prompt, /btw editor + speaker labels, login code editor, welcome banner, --help headline, and the chat side speaker headers (you / zot, including the streaming overlay). Single source of truth for the bar style across the UI. - Insert one blank row between the status bar and the editor and one trailing blank below the editor so the input has breathing room from the surrounding chrome instead of sitting flush against the status line and the terminal edge. Cursor row math is bumped +1 to account for the inserted row. - Status bar narrow split: when the idle status line would exceed the terminal width, split it into provider/model on one row, token+cost+context stats on the next, then cwd, instead of letting the terminal hard-wrap mid-line. Mirrors the existing busy-prefix split. Session cost restoration: - Add core.SessionUsage(path) that scans a session file for the latest "usage" row and returns its cumulative usage (the running session total). Old sessions with no usage rows return zero. - Seed the agent with that cumulative usage on every load path: /sessions picker, --continue, --resume, --session. Previously loading a session restored the messages but not the cost, so the status bar showed \/bin/bash.000 until the next turn produced a fresh EvUsage event. - Mirror the seeded cost into i.cumUsage on NewInteractive (CLI startup loads) and applySessionSelection (in-tui /sessions load) so the status bar reflects the historical total immediately. --- internal/agent/args.go | 8 ++++- internal/agent/cli.go | 6 ++++ internal/agent/modes/btw_dialog.go | 6 ++-- internal/agent/modes/interactive.go | 22 +++++++++++--- internal/agent/modes/login_dialog.go | 4 +-- internal/agent/modes/welcome.go | 7 +++-- internal/core/session.go | 32 ++++++++++++++++++++ internal/tui/theme.go | 16 ++++++++++ internal/tui/view.go | 44 +++++++++++++++++++++------- 9 files changed, 121 insertions(+), 24 deletions(-) diff --git a/internal/agent/args.go b/internal/agent/args.go index 7345231..07eeb3a 100644 --- a/internal/agent/args.go +++ b/internal/agent/args.go @@ -300,7 +300,13 @@ func PrintHelp(version string) { } fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, assistant(tui.Bold("▌ i'm zot. yet another coding agent harness."))) + var headline string + if useColor { + headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("i'm zot. yet another coding agent harness.")) + } else { + headline = "i'm zot. yet another coding agent harness." + } + fmt.Fprintln(os.Stderr, headline) fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands.")) fmt.Fprintf(os.Stderr, "%s %s\n", muted("version:"), fg(version)) diff --git a/internal/agent/cli.go b/internal/agent/cli.go index 0973de3..f74df98 100644 --- a/internal/agent/cli.go +++ b/internal/agent/cli.go @@ -512,6 +512,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { } sess = newSess currentAg.SetMessages(msgs) + if usage, uerr := core.SessionUsage(path); uerr == nil { + currentAg.SeedCost(usage) + } sessBaselineMsgs = len(msgs) return nil } @@ -723,6 +726,9 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) } if s != nil { ag.SetMessages(msgs) + if usage, uerr := core.SessionUsage(s.Path); uerr == nil { + ag.SeedCost(usage) + } return s, nil } return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version) diff --git a/internal/agent/modes/btw_dialog.go b/internal/agent/modes/btw_dialog.go index 31ce73b..ce2cef4 100644 --- a/internal/agent/modes/btw_dialog.go +++ b/internal/agent/modes/btw_dialog.go @@ -100,7 +100,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed st d.turns = nil d.loading = false d.cancel = nil - d.editor = tui.NewEditor(th.FG256(th.Accent, "▌ ")) + d.editor = tui.NewEditor(th.AccentBar(th.Accent)) d.frozenSystem = system d.frozenMsgs = agent.Messages() d.client = agent.Client @@ -300,13 +300,13 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string { for _, t := range d.turns { out = append(out, "") - out = append(out, " "+th.FG256(th.User, "▌ you")) + out = append(out, " "+th.AccentBar(th.User)+th.FG256(th.User, "you")) for _, line := range strings.Split(t.User, "\n") { out = append(out, " "+th.FG256(th.Muted, line)) } if t.Assistant != "" { out = append(out, "") - out = append(out, " "+th.FG256(th.Assistant, "▌ zot")) + out = append(out, " "+th.AccentBar(th.Assistant)+th.FG256(th.Assistant, "zot")) md := tui.RenderMarkdown(t.Assistant, th, width-4) for _, line := range strings.Split(md, "\n") { out = append(out, " "+line) diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 7aec949..a9d1b80 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -282,7 +282,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { Theme: cfg.Theme, ImageProto: tui.DetectImageProtocol(), }, - ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")), + // Prompt is the standard half-block accent bar used by chat + // speaker labels too, so the input gutter matches the rest + // of the UI. + ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)), rend: tui.NewRenderer(cfg.Terminal), toolCalls: map[string]*tui.ToolCallView{}, dirty: make(chan struct{}, 8), @@ -306,6 +309,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { if cfg.Agent != nil { i.agent = cfg.Agent i.view.Messages = cfg.Agent.Messages() + i.cumUsage = cfg.Agent.Cost() } return i } @@ -812,13 +816,19 @@ func (i *Interactive) redraw() { queue = append(queue, "") } - // Bottom-sticky sections (always visible, never scroll). - bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1) + // Bottom-sticky sections (always visible, never scroll). A blank + // row is inserted between the status bar and the editor, and a + // trailing blank row is added at the very bottom, so the input + // has breathing room from the surrounding chrome instead of + // sitting flush against the status line and the terminal edge. + bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+3) bottom = append(bottom, dialog...) bottom = append(bottom, suggest...) bottom = append(bottom, queue...) bottom = append(bottom, statusLines...) + bottom = append(bottom, "") bottom = append(bottom, edLines...) + bottom = append(bottom, "") _, rows := i.cfg.Terminal.Size() chatRows := rows - len(bottom) @@ -931,7 +941,10 @@ func (i *Interactive) redraw() { // the blinking cursor shows where the user is actually typing. // Dialogs without a cursor (model picker, /help, /login, etc.) // return -1 and the main editor keeps the cursor. - cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR + // +1 accounts for the blank row inserted between statusLines + // and edLines above. Without it the rendered cursor would land + // on the blank instead of inside the editor row. + cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + 1 + curR cursorCol := curC if i.btwDialog.Active() { if r, c := i.btwDialog.CursorPos(cols); r >= 0 { @@ -2288,6 +2301,7 @@ func (i *Interactive) applySessionSelection(path string) { i.view.InvalidateRenderCache() if i.agent != nil { i.view.Messages = i.agent.Messages() + i.cumUsage = i.agent.Cost() } i.mu.Unlock() diff --git a/internal/agent/modes/login_dialog.go b/internal/agent/modes/login_dialog.go index e0da058..0de9143 100644 --- a/internal/agent/modes/login_dialog.go +++ b/internal/agent/modes/login_dialog.go @@ -148,7 +148,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { lines = append(lines, "") lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):")) if d.codeEd == nil { - d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ ")) + d.codeEd = tui.NewEditor(th.AccentBar(th.Accent)) } edLines, _, _ := d.codeEd.Render(width - 2) for _, l := range edLines { @@ -170,7 +170,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string { lines = append(lines, "") lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):")) if d.codeEd == nil { - d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ ")) + d.codeEd = tui.NewEditor(th.AccentBar(th.Accent)) } edLines, _, _ := d.codeEd.Render(width - 2) for _, l := range edLines { diff --git a/internal/agent/modes/welcome.go b/internal/agent/modes/welcome.go index c5d1f53..a35d6de 100644 --- a/internal/agent/modes/welcome.go +++ b/internal/agent/modes/welcome.go @@ -10,12 +10,13 @@ import "github.com/patriceckhart/zot/internal/tui" // the moment zot starts. After welcomeVersionDuration the caller // flips showVersion off and the headline reverts to plain text. func welcomeBanner(th tui.Theme, version string, showVersion bool) []string { - headline := "▌ i'm zot. yet another coding agent harness." + text := "i'm zot. yet another coding agent harness." if showVersion && version != "" { - headline = "▌ i'm zot (" + version + "). yet another coding agent harness." + text = "i'm zot (" + version + "). yet another coding agent harness." } + headline := th.AccentBar(th.Assistant) + th.FG256(th.Assistant, tui.Bold(text)) return []string{ - th.FG256(th.Assistant, tui.Bold(headline)), + headline, th.FG256(th.Muted, " ask anything, or type /help to see commands."), "", } diff --git a/internal/core/session.go b/internal/core/session.go index 44c954c..a774c9c 100644 --- a/internal/core/session.go +++ b/internal/core/session.go @@ -110,6 +110,38 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error return s, nil } +// SessionUsage returns the most recent cumulative usage row stored in +// a session file. Sessions append one usage row per completed turn; the +// latest row's cumulative field is the session total. Missing usage rows +// are valid for old/empty sessions and return the zero value. +func SessionUsage(path string) (provider.Usage, error) { + f, err := os.Open(path) + if err != nil { + return provider.Usage{}, err + } + defer f.Close() + + var usage provider.Usage + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) + for sc.Scan() { + var head sessionLineHead + if err := json.Unmarshal(sc.Bytes(), &head); err != nil || head.Type != "usage" { + continue + } + var row struct { + Cumulative provider.Usage `json:"cumulative"` + } + if err := json.Unmarshal(sc.Bytes(), &row); err == nil { + usage = row.Cumulative + } + } + if err := sc.Err(); err != nil { + return provider.Usage{}, err + } + return usage, nil +} + // OpenSession opens an existing session for appending. func OpenSession(path string) (*Session, []provider.Message, error) { f, err := os.Open(path) diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 4c8f4e8..5499ab9 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -57,6 +57,22 @@ func (t Theme) FG256(c int, s string) string { return sgrFG(c) + s + reset } +// BG256 wraps s in background color c using ANSI 256-color SGR. +// Useful when the visible cell needs a coloured fill but the +// underlying character should be a regular space (so mouse-copy +// from the terminal yields whitespace instead of a glyph). +func (t Theme) BG256(c int, s string) string { + return sgrBG(c) + s + reset +} + +// AccentBar returns a 2-cell-wide leader: a coloured half-block +// glyph followed by a plain space gutter. Used as the speaker-label +// prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so +// the bar reads consistently across the UI. +func (t Theme) AccentBar(c int) string { + return t.FG256(c, "▌ ") +} + // Highlight paints s with the theme's selection colors (foreground + // background). The caller is responsible for padding s to the desired // width; styling alone does not extend the background past content. diff --git a/internal/tui/view.go b/internal/tui/view.go index 5bcbae0..fd0390b 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -267,7 +267,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { break } if !turnOpen { - out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot")) + out = append(out, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot")) } // Stream the partial assistant text through the same markdown // renderer used for finalised messages so code fences, diffs, @@ -486,7 +486,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str switch m.Role { case provider.RoleUser: - header := v.Theme.FG256(v.Theme.User, "▍ you") + header := v.Theme.AccentBar(v.Theme.User) + v.Theme.FG256(v.Theme.User, "you") lines = append(lines, header) for _, c := range m.Content { switch b := c.(type) { @@ -505,7 +505,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str // assistant messages (e.g. another tool_use round-trip after a // tool result) reuse the header that's already on screen. if !turnOpen { - lines = append(lines, v.Theme.FG256(v.Theme.Assistant, "▍ zot")) + lines = append(lines, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot")) } // Indent assistant body the same 4 cells the user body uses, // so the conversation column lines up vertically. The width @@ -1742,17 +1742,39 @@ func StatusBar(p StatusBarParams) []string { // On narrow terminals the single line wraps badly. If the visible // width exceeds cols and we have a busy prefix, split: keep the - // busy prefix on line 1, put model+stats on line 2. + // busy prefix on line 1, then put model and (if still needed) + // stats on their own rows. This mirrors the idle split below. if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols { busyLine := pad + p.BusyPrefix - var infoBuilder strings.Builder - infoBuilder.WriteString(pad) - infoBuilder.WriteString(th.FG256(th.Muted, left)) - if middle != "" { - infoBuilder.WriteString(pad) - infoBuilder.WriteString(th.FG256(th.Muted, middle)) + modelLine := pad + th.FG256(th.Muted, left) + lines := []string{busyLine} + if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols { + lines = append(lines, modelLine) + lines = append(lines, pad+th.FG256(th.Muted, middle)) + } else { + var infoBuilder strings.Builder + infoBuilder.WriteString(modelLine) + if middle != "" { + infoBuilder.WriteString(pad) + infoBuilder.WriteString(th.FG256(th.Muted, middle)) + } + lines = append(lines, infoBuilder.String()) + } + if cwd != "" { + lines = append(lines, pad+th.FG256(th.Muted, cwd)) + } + return lines + } + + // Idle narrow split: keep provider/model on the first status line, + // move usage/cost/context stats to the next, then cwd below. This + // avoids the terminal's hard wrap cutting the stats or pushing cwd + // into an awkward position on small widths. + if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols { + lines := []string{ + pad + th.FG256(th.Muted, left), + pad + th.FG256(th.Muted, middle), } - lines := []string{busyLine, infoBuilder.String()} if cwd != "" { lines = append(lines, pad+th.FG256(th.Muted, cwd)) }