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.
This commit is contained in:
patriceckhart 2026-04-27 19:51:36 +02:00
parent 37eb752cb2
commit 853a089fc5
9 changed files with 121 additions and 24 deletions

View file

@ -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))

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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 {

View file

@ -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."),
"",
}

View file

@ -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)

View file

@ -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.

View file

@ -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))
}