mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
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:
parent
37eb752cb2
commit
853a089fc5
9 changed files with 121 additions and 24 deletions
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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."),
|
||||
"",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue