detached 853a089

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.
commit
853a089fc5208d0d47fc8dc6a087ebb2e915377a
author
patriceckhart · · 2026-04-27 17:51 UTC
parent
37eb752
signature
unsigned
refs
tag: v0.1.31
9 files · +122 -25
M internal/agent/args.go +7 -1
@@ -300,7 +300,13 @@ func PrintHelp(version string) {
300300 }
301301
302302 fmt.Fprintln(os.Stderr)
303- fmt.Fprintln(os.Stderr, assistant(tui.Bold("▌ i'm zot. yet another coding agent harness.")))
303+ var headline string
304+ if useColor {
305+ headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("i'm zot. yet another coding agent harness."))
306+ } else {
307+ headline = "i'm zot. yet another coding agent harness."
308+ }
309+ fmt.Fprintln(os.Stderr, headline)
304310 fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands."))
305311 fmt.Fprintf(os.Stderr, "%s %s\n", muted("version:"), fg(version))
306312
@@ -300,7 +300,13 @@ func PrintHelp(version string) {
300 }300 }
301 301
302 fmt.Fprintln(os.Stderr)302 fmt.Fprintln(os.Stderr)
303- fmt.Fprintln(os.Stderr, assistant(tui.Bold("▌ i'm zot. yet another coding agent harness.")))303+ var headline string
304+ if useColor {
305+ headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("i'm zot. yet another coding agent harness."))
306+ } else {
307+ headline = "i'm zot. yet another coding agent harness."
308+ }
309+ fmt.Fprintln(os.Stderr, headline)
304 fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands."))310 fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands."))
305 fmt.Fprintf(os.Stderr, "%s %s\n", muted("version:"), fg(version))311 fmt.Fprintf(os.Stderr, "%s %s\n", muted("version:"), fg(version))
306 312
M internal/agent/cli.go +6 -0
@@ -512,6 +512,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
512512 }
513513 sess = newSess
514514 currentAg.SetMessages(msgs)
515+ if usage, uerr := core.SessionUsage(path); uerr == nil {
516+ currentAg.SeedCost(usage)
517+ }
515518 sessBaselineMsgs = len(msgs)
516519 return nil
517520 }
@@ -723,6 +726,9 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
723726 }
724727 if s != nil {
725728 ag.SetMessages(msgs)
729+ if usage, uerr := core.SessionUsage(s.Path); uerr == nil {
730+ ag.SeedCost(usage)
731+ }
726732 return s, nil
727733 }
728734 return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
@@ -512,6 +512,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
512 }512 }
513 sess = newSess513 sess = newSess
514 currentAg.SetMessages(msgs)514 currentAg.SetMessages(msgs)
515+ if usage, uerr := core.SessionUsage(path); uerr == nil {
516+ currentAg.SeedCost(usage)
517+ }
515 sessBaselineMsgs = len(msgs)518 sessBaselineMsgs = len(msgs)
516 return nil519 return nil
517 }520 }
@@ -723,6 +726,9 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
723 }726 }
724 if s != nil {727 if s != nil {
725 ag.SetMessages(msgs)728 ag.SetMessages(msgs)
729+ if usage, uerr := core.SessionUsage(s.Path); uerr == nil {
730+ ag.SeedCost(usage)
731+ }
726 return s, nil732 return s, nil
727 }733 }
728 return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)734 return core.NewSession(ZotHome(), args.CWD, r.Provider, r.Model, version)
M internal/agent/modes/btw_dialog.go +3 -3
@@ -100,7 +100,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed st
100100 d.turns = nil
101101 d.loading = false
102102 d.cancel = nil
103- d.editor = tui.NewEditor(th.FG256(th.Accent, "▌ "))
103+ d.editor = tui.NewEditor(th.AccentBar(th.Accent))
104104 d.frozenSystem = system
105105 d.frozenMsgs = agent.Messages()
106106 d.client = agent.Client
@@ -300,13 +300,13 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
300300
301301 for _, t := range d.turns {
302302 out = append(out, "")
303- out = append(out, " "+th.FG256(th.User, "▌ you"))
303+ out = append(out, " "+th.AccentBar(th.User)+th.FG256(th.User, "you"))
304304 for _, line := range strings.Split(t.User, "\n") {
305305 out = append(out, " "+th.FG256(th.Muted, line))
306306 }
307307 if t.Assistant != "" {
308308 out = append(out, "")
309- out = append(out, " "+th.FG256(th.Assistant, "▌ zot"))
309+ out = append(out, " "+th.AccentBar(th.Assistant)+th.FG256(th.Assistant, "zot"))
310310 md := tui.RenderMarkdown(t.Assistant, th, width-4)
311311 for _, line := range strings.Split(md, "\n") {
312312 out = append(out, " "+line)
@@ -100,7 +100,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed st
100 d.turns = nil100 d.turns = nil
101 d.loading = false101 d.loading = false
102 d.cancel = nil102 d.cancel = nil
103- d.editor = tui.NewEditor(th.FG256(th.Accent, "▌ "))103+ d.editor = tui.NewEditor(th.AccentBar(th.Accent))
104 d.frozenSystem = system104 d.frozenSystem = system
105 d.frozenMsgs = agent.Messages()105 d.frozenMsgs = agent.Messages()
106 d.client = agent.Client106 d.client = agent.Client
@@ -300,13 +300,13 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
300 300
301 for _, t := range d.turns {301 for _, t := range d.turns {
302 out = append(out, "")302 out = append(out, "")
303- out = append(out, " "+th.FG256(th.User, "▌ you"))303+ out = append(out, " "+th.AccentBar(th.User)+th.FG256(th.User, "you"))
304 for _, line := range strings.Split(t.User, "\n") {304 for _, line := range strings.Split(t.User, "\n") {
305 out = append(out, " "+th.FG256(th.Muted, line))305 out = append(out, " "+th.FG256(th.Muted, line))
306 }306 }
307 if t.Assistant != "" {307 if t.Assistant != "" {
308 out = append(out, "")308 out = append(out, "")
309- out = append(out, " "+th.FG256(th.Assistant, "▌ zot"))309+ out = append(out, " "+th.AccentBar(th.Assistant)+th.FG256(th.Assistant, "zot"))
310 md := tui.RenderMarkdown(t.Assistant, th, width-4)310 md := tui.RenderMarkdown(t.Assistant, th, width-4)
311 for _, line := range strings.Split(md, "\n") {311 for _, line := range strings.Split(md, "\n") {
312 out = append(out, " "+line)312 out = append(out, " "+line)
M internal/agent/modes/interactive.go +18 -4
@@ -282,7 +282,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
282282 Theme: cfg.Theme,
283283 ImageProto: tui.DetectImageProtocol(),
284284 },
285- ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),
285+ // Prompt is the standard half-block accent bar used by chat
286+ // speaker labels too, so the input gutter matches the rest
287+ // of the UI.
288+ ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
286289 rend: tui.NewRenderer(cfg.Terminal),
287290 toolCalls: map[string]*tui.ToolCallView{},
288291 dirty: make(chan struct{}, 8),
@@ -306,6 +309,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
306309 if cfg.Agent != nil {
307310 i.agent = cfg.Agent
308311 i.view.Messages = cfg.Agent.Messages()
312+ i.cumUsage = cfg.Agent.Cost()
309313 }
310314 return i
311315 }
@@ -812,13 +816,19 @@ func (i *Interactive) redraw() {
812816 queue = append(queue, "")
813817 }
814818
815- // Bottom-sticky sections (always visible, never scroll).
816- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)
819+ // Bottom-sticky sections (always visible, never scroll). A blank
820+ // row is inserted between the status bar and the editor, and a
821+ // trailing blank row is added at the very bottom, so the input
822+ // has breathing room from the surrounding chrome instead of
823+ // sitting flush against the status line and the terminal edge.
824+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+3)
817825 bottom = append(bottom, dialog...)
818826 bottom = append(bottom, suggest...)
819827 bottom = append(bottom, queue...)
820828 bottom = append(bottom, statusLines...)
829+ bottom = append(bottom, "")
821830 bottom = append(bottom, edLines...)
831+ bottom = append(bottom, "")
822832
823833 _, rows := i.cfg.Terminal.Size()
824834 chatRows := rows - len(bottom)
@@ -931,7 +941,10 @@ func (i *Interactive) redraw() {
931941 // the blinking cursor shows where the user is actually typing.
932942 // Dialogs without a cursor (model picker, /help, /login, etc.)
933943 // return -1 and the main editor keeps the cursor.
934- cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR
944+ // +1 accounts for the blank row inserted between statusLines
945+ // and edLines above. Without it the rendered cursor would land
946+ // on the blank instead of inside the editor row.
947+ cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + 1 + curR
935948 cursorCol := curC
936949 if i.btwDialog.Active() {
937950 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
@@ -2288,6 +2301,7 @@ func (i *Interactive) applySessionSelection(path string) {
22882301 i.view.InvalidateRenderCache()
22892302 if i.agent != nil {
22902303 i.view.Messages = i.agent.Messages()
2304+ i.cumUsage = i.agent.Cost()
22912305 }
22922306 i.mu.Unlock()
22932307
@@ -282,7 +282,10 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
282 Theme: cfg.Theme,282 Theme: cfg.Theme,
283 ImageProto: tui.DetectImageProtocol(),283 ImageProto: tui.DetectImageProtocol(),
284 },284 },
285- ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")),285+ // Prompt is the standard half-block accent bar used by chat
286+ // speaker labels too, so the input gutter matches the rest
287+ // of the UI.
288+ ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
286 rend: tui.NewRenderer(cfg.Terminal),289 rend: tui.NewRenderer(cfg.Terminal),
287 toolCalls: map[string]*tui.ToolCallView{},290 toolCalls: map[string]*tui.ToolCallView{},
288 dirty: make(chan struct{}, 8),291 dirty: make(chan struct{}, 8),
@@ -306,6 +309,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
306 if cfg.Agent != nil {309 if cfg.Agent != nil {
307 i.agent = cfg.Agent310 i.agent = cfg.Agent
308 i.view.Messages = cfg.Agent.Messages()311 i.view.Messages = cfg.Agent.Messages()
312+ i.cumUsage = cfg.Agent.Cost()
309 }313 }
310 return i314 return i
311 }315 }
@@ -812,13 +816,19 @@ func (i *Interactive) redraw() {
812 queue = append(queue, "")816 queue = append(queue, "")
813 }817 }
814 818
815- // Bottom-sticky sections (always visible, never scroll).819+ // Bottom-sticky sections (always visible, never scroll). A blank
816- bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1)820+ // row is inserted between the status bar and the editor, and a
821+ // trailing blank row is added at the very bottom, so the input
822+ // has breathing room from the surrounding chrome instead of
823+ // sitting flush against the status line and the terminal edge.
824+ bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+3)
817 bottom = append(bottom, dialog...)825 bottom = append(bottom, dialog...)
818 bottom = append(bottom, suggest...)826 bottom = append(bottom, suggest...)
819 bottom = append(bottom, queue...)827 bottom = append(bottom, queue...)
820 bottom = append(bottom, statusLines...)828 bottom = append(bottom, statusLines...)
829+ bottom = append(bottom, "")
821 bottom = append(bottom, edLines...)830 bottom = append(bottom, edLines...)
831+ bottom = append(bottom, "")
822 832
823 _, rows := i.cfg.Terminal.Size()833 _, rows := i.cfg.Terminal.Size()
824 chatRows := rows - len(bottom)834 chatRows := rows - len(bottom)
@@ -931,7 +941,10 @@ func (i *Interactive) redraw() {
931 // the blinking cursor shows where the user is actually typing.941 // the blinking cursor shows where the user is actually typing.
932 // Dialogs without a cursor (model picker, /help, /login, etc.)942 // Dialogs without a cursor (model picker, /help, /login, etc.)
933 // return -1 and the main editor keeps the cursor.943 // return -1 and the main editor keeps the cursor.
934- cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + curR944+ // +1 accounts for the blank row inserted between statusLines
945+ // and edLines above. Without it the rendered cursor would land
946+ // on the blank instead of inside the editor row.
947+ cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + len(statusLines) + 1 + curR
935 cursorCol := curC948 cursorCol := curC
936 if i.btwDialog.Active() {949 if i.btwDialog.Active() {
937 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {950 if r, c := i.btwDialog.CursorPos(cols); r >= 0 {
@@ -2288,6 +2301,7 @@ func (i *Interactive) applySessionSelection(path string) {
2288 i.view.InvalidateRenderCache()2301 i.view.InvalidateRenderCache()
2289 if i.agent != nil {2302 if i.agent != nil {
2290 i.view.Messages = i.agent.Messages()2303 i.view.Messages = i.agent.Messages()
2304+ i.cumUsage = i.agent.Cost()
2291 }2305 }
2292 i.mu.Unlock()2306 i.mu.Unlock()
2293 2307
M internal/agent/modes/login_dialog.go +2 -2
@@ -148,7 +148,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
148148 lines = append(lines, "")
149149 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
150150 if d.codeEd == nil {
151- d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ "))
151+ d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
152152 }
153153 edLines, _, _ := d.codeEd.Render(width - 2)
154154 for _, l := range edLines {
@@ -170,7 +170,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
170170 lines = append(lines, "")
171171 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
172172 if d.codeEd == nil {
173- d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ "))
173+ d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
174174 }
175175 edLines, _, _ := d.codeEd.Render(width - 2)
176176 for _, l := range edLines {
@@ -148,7 +148,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
148 lines = append(lines, "")148 lines = append(lines, "")
149 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))149 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
150 if d.codeEd == nil {150 if d.codeEd == nil {
151- d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ "))151+ d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
152 }152 }
153 edLines, _, _ := d.codeEd.Render(width - 2)153 edLines, _, _ := d.codeEd.Render(width - 2)
154 for _, l := range edLines {154 for _, l := range edLines {
@@ -170,7 +170,7 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
170 lines = append(lines, "")170 lines = append(lines, "")
171 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))171 lines = append(lines, th.FG256(th.Muted, "paste the authorization code (or full redirect URL / code#state):"))
172 if d.codeEd == nil {172 if d.codeEd == nil {
173- d.codeEd = tui.NewEditor(th.FG256(th.Accent, "▌ "))173+ d.codeEd = tui.NewEditor(th.AccentBar(th.Accent))
174 }174 }
175 edLines, _, _ := d.codeEd.Render(width - 2)175 edLines, _, _ := d.codeEd.Render(width - 2)
176 for _, l := range edLines {176 for _, l := range edLines {
M internal/agent/modes/welcome.go +4 -3
@@ -10,12 +10,13 @@ import "github.com/patriceckhart/zot/internal/tui"
1010 // the moment zot starts. After welcomeVersionDuration the caller
1111 // flips showVersion off and the headline reverts to plain text.
1212 func welcomeBanner(th tui.Theme, version string, showVersion bool) []string {
13- headline := "▌ i'm zot. yet another coding agent harness."
13+ text := "i'm zot. yet another coding agent harness."
1414 if showVersion && version != "" {
15- headline = "▌ i'm zot (" + version + "). yet another coding agent harness."
15+ text = "i'm zot (" + version + "). yet another coding agent harness."
1616 }
17+ headline := th.AccentBar(th.Assistant) + th.FG256(th.Assistant, tui.Bold(text))
1718 return []string{
18- th.FG256(th.Assistant, tui.Bold(headline)),
19+ headline,
1920 th.FG256(th.Muted, " ask anything, or type /help to see commands."),
2021 "",
2122 }
@@ -10,12 +10,13 @@ import "github.com/patriceckhart/zot/internal/tui"
10 // the moment zot starts. After welcomeVersionDuration the caller10 // the moment zot starts. After welcomeVersionDuration the caller
11 // flips showVersion off and the headline reverts to plain text.11 // flips showVersion off and the headline reverts to plain text.
12 func welcomeBanner(th tui.Theme, version string, showVersion bool) []string {12 func welcomeBanner(th tui.Theme, version string, showVersion bool) []string {
13- headline := "▌ i'm zot. yet another coding agent harness."13+ text := "i'm zot. yet another coding agent harness."
14 if showVersion && version != "" {14 if showVersion && version != "" {
15- headline = "▌ i'm zot (" + version + "). yet another coding agent harness."15+ text = "i'm zot (" + version + "). yet another coding agent harness."
16 }16 }
17+ headline := th.AccentBar(th.Assistant) + th.FG256(th.Assistant, tui.Bold(text))
17 return []string{18 return []string{
18- th.FG256(th.Assistant, tui.Bold(headline)),19+ headline,
19 th.FG256(th.Muted, " ask anything, or type /help to see commands."),20 th.FG256(th.Muted, " ask anything, or type /help to see commands."),
20 "",21 "",
21 }22 }
M internal/core/session.go +32 -0
@@ -110,6 +110,38 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error
110110 return s, nil
111111 }
112112
113+// SessionUsage returns the most recent cumulative usage row stored in
114+// a session file. Sessions append one usage row per completed turn; the
115+// latest row's cumulative field is the session total. Missing usage rows
116+// are valid for old/empty sessions and return the zero value.
117+func SessionUsage(path string) (provider.Usage, error) {
118+ f, err := os.Open(path)
119+ if err != nil {
120+ return provider.Usage{}, err
121+ }
122+ defer f.Close()
123+
124+ var usage provider.Usage
125+ sc := bufio.NewScanner(f)
126+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
127+ for sc.Scan() {
128+ var head sessionLineHead
129+ if err := json.Unmarshal(sc.Bytes(), &head); err != nil || head.Type != "usage" {
130+ continue
131+ }
132+ var row struct {
133+ Cumulative provider.Usage `json:"cumulative"`
134+ }
135+ if err := json.Unmarshal(sc.Bytes(), &row); err == nil {
136+ usage = row.Cumulative
137+ }
138+ }
139+ if err := sc.Err(); err != nil {
140+ return provider.Usage{}, err
141+ }
142+ return usage, nil
143+}
144+
113145 // OpenSession opens an existing session for appending.
114146 func OpenSession(path string) (*Session, []provider.Message, error) {
115147 f, err := os.Open(path)
@@ -110,6 +110,38 @@ func NewSession(root, cwd, providerName, model, version string) (*Session, error
110 return s, nil110 return s, nil
111 }111 }
112 112
113+// SessionUsage returns the most recent cumulative usage row stored in
114+// a session file. Sessions append one usage row per completed turn; the
115+// latest row's cumulative field is the session total. Missing usage rows
116+// are valid for old/empty sessions and return the zero value.
117+func SessionUsage(path string) (provider.Usage, error) {
118+ f, err := os.Open(path)
119+ if err != nil {
120+ return provider.Usage{}, err
121+ }
122+ defer f.Close()
123+
124+ var usage provider.Usage
125+ sc := bufio.NewScanner(f)
126+ sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
127+ for sc.Scan() {
128+ var head sessionLineHead
129+ if err := json.Unmarshal(sc.Bytes(), &head); err != nil || head.Type != "usage" {
130+ continue
131+ }
132+ var row struct {
133+ Cumulative provider.Usage `json:"cumulative"`
134+ }
135+ if err := json.Unmarshal(sc.Bytes(), &row); err == nil {
136+ usage = row.Cumulative
137+ }
138+ }
139+ if err := sc.Err(); err != nil {
140+ return provider.Usage{}, err
141+ }
142+ return usage, nil
143+}
144+
113 // OpenSession opens an existing session for appending.145 // OpenSession opens an existing session for appending.
114 func OpenSession(path string) (*Session, []provider.Message, error) {146 func OpenSession(path string) (*Session, []provider.Message, error) {
115 f, err := os.Open(path)147 f, err := os.Open(path)
M internal/tui/theme.go +16 -0
@@ -57,6 +57,22 @@ func (t Theme) FG256(c int, s string) string {
5757 return sgrFG(c) + s + reset
5858 }
5959
60+// BG256 wraps s in background color c using ANSI 256-color SGR.
61+// Useful when the visible cell needs a coloured fill but the
62+// underlying character should be a regular space (so mouse-copy
63+// from the terminal yields whitespace instead of a glyph).
64+func (t Theme) BG256(c int, s string) string {
65+ return sgrBG(c) + s + reset
66+}
67+
68+// AccentBar returns a 2-cell-wide leader: a coloured half-block
69+// glyph followed by a plain space gutter. Used as the speaker-label
70+// prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so
71+// the bar reads consistently across the UI.
72+func (t Theme) AccentBar(c int) string {
73+ return t.FG256(c, "▌ ")
74+}
75+
6076 // Highlight paints s with the theme's selection colors (foreground +
6177 // background). The caller is responsible for padding s to the desired
6278 // width; styling alone does not extend the background past content.
@@ -57,6 +57,22 @@ func (t Theme) FG256(c int, s string) string {
57 return sgrFG(c) + s + reset57 return sgrFG(c) + s + reset
58 }58 }
59 59
60+// BG256 wraps s in background color c using ANSI 256-color SGR.
61+// Useful when the visible cell needs a coloured fill but the
62+// underlying character should be a regular space (so mouse-copy
63+// from the terminal yields whitespace instead of a glyph).
64+func (t Theme) BG256(c int, s string) string {
65+ return sgrBG(c) + s + reset
66+}
67+
68+// AccentBar returns a 2-cell-wide leader: a coloured half-block
69+// glyph followed by a plain space gutter. Used as the speaker-label
70+// prefix in the chat ("▌ you", "▌ zot") and as the editor prompt so
71+// the bar reads consistently across the UI.
72+func (t Theme) AccentBar(c int) string {
73+ return t.FG256(c, "▌ ")
74+}
75+
60 // Highlight paints s with the theme's selection colors (foreground +76 // Highlight paints s with the theme's selection colors (foreground +
61 // background). The caller is responsible for padding s to the desired77 // background). The caller is responsible for padding s to the desired
62 // width; styling alone does not extend the background past content.78 // width; styling alone does not extend the background past content.
M internal/tui/view.go +34 -12
@@ -267,7 +267,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
267267 break
268268 }
269269 if !turnOpen {
270- out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot"))
270+ out = append(out, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot"))
271271 }
272272 // Stream the partial assistant text through the same markdown
273273 // 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
486486
487487 switch m.Role {
488488 case provider.RoleUser:
489- header := v.Theme.FG256(v.Theme.User, "▍ you")
489+ header := v.Theme.AccentBar(v.Theme.User) + v.Theme.FG256(v.Theme.User, "you")
490490 lines = append(lines, header)
491491 for _, c := range m.Content {
492492 switch b := c.(type) {
@@ -505,7 +505,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
505505 // assistant messages (e.g. another tool_use round-trip after a
506506 // tool result) reuse the header that's already on screen.
507507 if !turnOpen {
508- lines = append(lines, v.Theme.FG256(v.Theme.Assistant, "▍ zot"))
508+ lines = append(lines, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot"))
509509 }
510510 // Indent assistant body the same 4 cells the user body uses,
511511 // so the conversation column lines up vertically. The width
@@ -1742,17 +1742,39 @@ func StatusBar(p StatusBarParams) []string {
17421742
17431743 // On narrow terminals the single line wraps badly. If the visible
17441744 // width exceeds cols and we have a busy prefix, split: keep the
1745- // busy prefix on line 1, put model+stats on line 2.
1745+ // busy prefix on line 1, then put model and (if still needed)
1746+ // stats on their own rows. This mirrors the idle split below.
17461747 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
17471748 busyLine := pad + p.BusyPrefix
1748- var infoBuilder strings.Builder
1749- infoBuilder.WriteString(pad)
1750- infoBuilder.WriteString(th.FG256(th.Muted, left))
1751- if middle != "" {
1752- infoBuilder.WriteString(pad)
1753- infoBuilder.WriteString(th.FG256(th.Muted, middle))
1754- }
1755- lines := []string{busyLine, infoBuilder.String()}
1749+ modelLine := pad + th.FG256(th.Muted, left)
1750+ lines := []string{busyLine}
1751+ if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {
1752+ lines = append(lines, modelLine)
1753+ lines = append(lines, pad+th.FG256(th.Muted, middle))
1754+ } else {
1755+ var infoBuilder strings.Builder
1756+ infoBuilder.WriteString(modelLine)
1757+ if middle != "" {
1758+ infoBuilder.WriteString(pad)
1759+ infoBuilder.WriteString(th.FG256(th.Muted, middle))
1760+ }
1761+ lines = append(lines, infoBuilder.String())
1762+ }
1763+ if cwd != "" {
1764+ lines = append(lines, pad+th.FG256(th.Muted, cwd))
1765+ }
1766+ return lines
1767+ }
1768+
1769+ // Idle narrow split: keep provider/model on the first status line,
1770+ // move usage/cost/context stats to the next, then cwd below. This
1771+ // avoids the terminal's hard wrap cutting the stats or pushing cwd
1772+ // into an awkward position on small widths.
1773+ if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
1774+ lines := []string{
1775+ pad + th.FG256(th.Muted, left),
1776+ pad + th.FG256(th.Muted, middle),
1777+ }
17561778 if cwd != "" {
17571779 lines = append(lines, pad+th.FG256(th.Muted, cwd))
17581780 }
@@ -267,7 +267,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
267 break267 break
268 }268 }
269 if !turnOpen {269 if !turnOpen {
270- out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot"))270+ out = append(out, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot"))
271 }271 }
272 // Stream the partial assistant text through the same markdown272 // Stream the partial assistant text through the same markdown
273 // renderer used for finalised messages so code fences, diffs,273 // 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
486 486
487 switch m.Role {487 switch m.Role {
488 case provider.RoleUser:488 case provider.RoleUser:
489- header := v.Theme.FG256(v.Theme.User, "▍ you")489+ header := v.Theme.AccentBar(v.Theme.User) + v.Theme.FG256(v.Theme.User, "you")
490 lines = append(lines, header)490 lines = append(lines, header)
491 for _, c := range m.Content {491 for _, c := range m.Content {
492 switch b := c.(type) {492 switch b := c.(type) {
@@ -505,7 +505,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
505 // assistant messages (e.g. another tool_use round-trip after a505 // assistant messages (e.g. another tool_use round-trip after a
506 // tool result) reuse the header that's already on screen.506 // tool result) reuse the header that's already on screen.
507 if !turnOpen {507 if !turnOpen {
508- lines = append(lines, v.Theme.FG256(v.Theme.Assistant, "▍ zot"))508+ lines = append(lines, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot"))
509 }509 }
510 // Indent assistant body the same 4 cells the user body uses,510 // Indent assistant body the same 4 cells the user body uses,
511 // so the conversation column lines up vertically. The width511 // so the conversation column lines up vertically. The width
@@ -1742,17 +1742,39 @@ func StatusBar(p StatusBarParams) []string {
1742 1742
1743 // On narrow terminals the single line wraps badly. If the visible1743 // On narrow terminals the single line wraps badly. If the visible
1744 // width exceeds cols and we have a busy prefix, split: keep the1744 // width exceeds cols and we have a busy prefix, split: keep the
1745- // busy prefix on line 1, put model+stats on line 2.1745+ // busy prefix on line 1, then put model and (if still needed)
1746+ // stats on their own rows. This mirrors the idle split below.
1746 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {1747 if p.Cols > 0 && p.BusyPrefix != "" && visibleWidth(primary) > p.Cols {
1747 busyLine := pad + p.BusyPrefix1748 busyLine := pad + p.BusyPrefix
1748- var infoBuilder strings.Builder1749+ modelLine := pad + th.FG256(th.Muted, left)
1749- infoBuilder.WriteString(pad)1750+ lines := []string{busyLine}
1750- infoBuilder.WriteString(th.FG256(th.Muted, left))1751+ if middle != "" && visibleWidth(modelLine+pad+th.FG256(th.Muted, middle)) > p.Cols {
1751- if middle != "" {1752+ lines = append(lines, modelLine)
1752- infoBuilder.WriteString(pad)1753+ lines = append(lines, pad+th.FG256(th.Muted, middle))
1753- infoBuilder.WriteString(th.FG256(th.Muted, middle))1754+ } else {
1754- }1755+ var infoBuilder strings.Builder
1755- lines := []string{busyLine, infoBuilder.String()}1756+ infoBuilder.WriteString(modelLine)
1757+ if middle != "" {
1758+ infoBuilder.WriteString(pad)
1759+ infoBuilder.WriteString(th.FG256(th.Muted, middle))
1760+ }
1761+ lines = append(lines, infoBuilder.String())
1762+ }
1763+ if cwd != "" {
1764+ lines = append(lines, pad+th.FG256(th.Muted, cwd))
1765+ }
1766+ return lines
1767+ }
1768+
1769+ // Idle narrow split: keep provider/model on the first status line,
1770+ // move usage/cost/context stats to the next, then cwd below. This
1771+ // avoids the terminal's hard wrap cutting the stats or pushing cwd
1772+ // into an awkward position on small widths.
1773+ if p.Cols > 0 && p.BusyPrefix == "" && middle != "" && visibleWidth(primary) > p.Cols {
1774+ lines := []string{
1775+ pad + th.FG256(th.Muted, left),
1776+ pad + th.FG256(th.Muted, middle),
1777+ }
1756 if cwd != "" {1778 if cwd != "" {
1757 lines = append(lines, pad+th.FG256(th.Muted, cwd))1779 lines = append(lines, pad+th.FG256(th.Muted, cwd))
1758 }1780 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close