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