detached c4251d8

tui: align status-bar spacing and indent assistant body

status bar:
  every horizontal gap is now exactly 2 spaces, applied uniformly
  via a single pad constant. before:
    1 leading | 6 between model+stats | 4 between stats+cwd
  after:
    2 leading | 2 between model+stats | 2 between stats+cwd
  produces e.g. "  (openai) gpt-5.4  $0.000 (sub) 0.0%/400k  ~/Sites/zot"
  matching the editor's left inset so the bar lines up vertically
  with the conversation column.

assistant body:
  user-role messages render their text with a 4-space indent under
  the "you" header. assistant text was rendering flush left, so the
  conversation column visibly broke at every assistant turn. now
  both renderMessage's RoleAssistant branch and Build's streaming
  path:
    - reduce the wrap width by the indent (4 cells)
    - prefix every produced line with the indent
  applies to plain text, markdown-rendered code fences / lists /
  blockquotes, and tool-call summary lines (>>= name args). tool
  result blocks were already indented and stay unchanged.
commit
c4251d88fd0697179e2b17d36b76d02352a459f8
author
patriceckhart · · 2026-04-19 11:10 UTC
parent
36e0efe
signature
unsigned
refs
tag: v0.0.18
1 file · +44 -20
M internal/tui/view.go +44 -20
@@ -152,11 +152,18 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
152152 // Stream the partial assistant text through the same markdown
153153 // renderer used for finalised messages so code fences, diffs,
154154 // lists, and inline styles look the same while streaming and
155- // don't suddenly reflow when the turn ends.
156- md := RenderMarkdown(v.Streaming, v.Theme, width)
155+ // don't suddenly reflow when the turn ends. Indent matches the
156+ // finalised assistant body in renderMessage so the column
157+ // stays consistent across the stream/finalise transition.
158+ const indent = " "
159+ inner := width - len(indent)
160+ if inner < 1 {
161+ inner = width
162+ }
163+ md := RenderMarkdown(v.Streaming, v.Theme, inner)
157164 for _, l := range strings.Split(md, "\n") {
158- for _, w := range wrapLine(l, width, "") {
159- out = append(out, w)
165+ for _, w := range wrapLine(l, inner, "") {
166+ out = append(out, indent+w)
160167 }
161168 }
162169 out = append(out, "")
@@ -306,28 +313,37 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
306313 switch b := c.(type) {
307314 case provider.TextBlock:
308315 for _, l := range strings.Split(b.Text, "\n") {
309- for _, w := range wrapLine(l, width-2, " ") {
310- lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))
316+ for _, w := range wrapLine(l, width-2, " ") {
317+ lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))
311318 }
312319 }
313320 case provider.ImageBlock:
314- lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))
321+ lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))
315322 }
316323 }
317324 case provider.RoleAssistant:
318325 header := v.Theme.FG256(v.Theme.Assistant, "▍ zot")
319326 lines = append(lines, header)
327+ // Indent assistant body the same 4 cells the user body uses,
328+ // so the conversation column lines up vertically. The width
329+ // passed into the markdown renderer / wrap is reduced by the
330+ // indent so long lines wrap inside the indented column.
331+ const indent = " "
332+ inner := width - len(indent)
333+ if inner < 1 {
334+ inner = width
335+ }
320336 for _, c := range m.Content {
321337 switch b := c.(type) {
322338 case provider.TextBlock:
323- md := RenderMarkdown(b.Text, v.Theme, width)
339+ md := RenderMarkdown(b.Text, v.Theme, inner)
324340 for _, l := range strings.Split(md, "\n") {
325- for _, w := range wrapLine(l, width, "") {
326- lines = append(lines, w)
341+ for _, w := range wrapLine(l, inner, "") {
342+ lines = append(lines, indent+w)
327343 }
328344 }
329345 case provider.ToolCallBlock:
330- lines = append(lines, v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments)))
346+ lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments)))
331347 }
332348 }
333349 case provider.RoleTool:
@@ -855,18 +871,27 @@ func StatusBar(p StatusBarParams) []string {
855871 stats = append(stats, th.FG256(ctxColor, ctx))
856872 }
857873
858- left := fmt.Sprintf(" (%s) %s ", p.Provider, p.Model)
859- middle := " " + strings.Join(stats, " ") + " "
874+ // Layout uses exactly 2 spaces of horizontal padding everywhere:
875+ // 2 spaces (openai) gpt-5.4 $0.000 (sub) 0.0%/400k ~/Sites/zot
876+ // matches the editor prompt's left inset so the bar lines up
877+ // vertically with the conversation column.
878+ const pad = " " // 2 spaces
879+
880+ left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
881+ middle := strings.Join(stats, " ")
860882
861883 var leftBuilder strings.Builder
862884 if p.BusyPrefix != "" {
863- leftBuilder.WriteString(th.FG256(th.Accent, " "+p.BusyPrefix+" "))
864- leftBuilder.WriteString(" ")
885+ leftBuilder.WriteString(th.FG256(th.Accent, pad+p.BusyPrefix))
886+ leftBuilder.WriteString(pad)
865887 }
888+ leftBuilder.WriteString(pad)
866889 leftBuilder.WriteString(th.FG256(th.Muted, left))
867- leftBuilder.WriteString(" ")
868- // `middle` already has colorized context segments; wrap the rest in muted.
869- leftBuilder.WriteString(th.FG256(th.Muted, middle))
890+ if middle != "" {
891+ leftBuilder.WriteString(pad)
892+ // `middle` already has colorized context segments; wrap the rest in muted.
893+ leftBuilder.WriteString(th.FG256(th.Muted, middle))
894+ }
870895
871896 cwd := shortenHome(p.CWD)
872897 if p.Locked && cwd != "" {
@@ -879,8 +904,7 @@ func StatusBar(p StatusBarParams) []string {
879904 }
880905
881906 cwdRendered := th.FG256(th.Muted, cwd)
882- const gap = " " // exactly three spaces between stats and cwd
883- combined := primary + gap + cwdRendered
907+ combined := primary + pad + cwdRendered
884908
885909 // Wrap to a second line when the combined width would overflow.
886910 if p.Cols > 0 && visibleWidth(combined) > p.Cols {
@@ -152,11 +152,18 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
152 // Stream the partial assistant text through the same markdown152 // Stream the partial assistant text through the same markdown
153 // renderer used for finalised messages so code fences, diffs,153 // renderer used for finalised messages so code fences, diffs,
154 // lists, and inline styles look the same while streaming and154 // lists, and inline styles look the same while streaming and
155- // don't suddenly reflow when the turn ends.155+ // don't suddenly reflow when the turn ends. Indent matches the
156- md := RenderMarkdown(v.Streaming, v.Theme, width)156+ // finalised assistant body in renderMessage so the column
157+ // stays consistent across the stream/finalise transition.
158+ const indent = " "
159+ inner := width - len(indent)
160+ if inner < 1 {
161+ inner = width
162+ }
163+ md := RenderMarkdown(v.Streaming, v.Theme, inner)
157 for _, l := range strings.Split(md, "\n") {164 for _, l := range strings.Split(md, "\n") {
158- for _, w := range wrapLine(l, width, "") {165+ for _, w := range wrapLine(l, inner, "") {
159- out = append(out, w)166+ out = append(out, indent+w)
160 }167 }
161 }168 }
162 out = append(out, "")169 out = append(out, "")
@@ -306,28 +313,37 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
306 switch b := c.(type) {313 switch b := c.(type) {
307 case provider.TextBlock:314 case provider.TextBlock:
308 for _, l := range strings.Split(b.Text, "\n") {315 for _, l := range strings.Split(b.Text, "\n") {
309- for _, w := range wrapLine(l, width-2, " ") {316+ for _, w := range wrapLine(l, width-2, " ") {
310- lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))317+ lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))
311 }318 }
312 }319 }
313 case provider.ImageBlock:320 case provider.ImageBlock:
314- lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))321+ lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))
315 }322 }
316 }323 }
317 case provider.RoleAssistant:324 case provider.RoleAssistant:
318 header := v.Theme.FG256(v.Theme.Assistant, "▍ zot")325 header := v.Theme.FG256(v.Theme.Assistant, "▍ zot")
319 lines = append(lines, header)326 lines = append(lines, header)
327+ // Indent assistant body the same 4 cells the user body uses,
328+ // so the conversation column lines up vertically. The width
329+ // passed into the markdown renderer / wrap is reduced by the
330+ // indent so long lines wrap inside the indented column.
331+ const indent = " "
332+ inner := width - len(indent)
333+ if inner < 1 {
334+ inner = width
335+ }
320 for _, c := range m.Content {336 for _, c := range m.Content {
321 switch b := c.(type) {337 switch b := c.(type) {
322 case provider.TextBlock:338 case provider.TextBlock:
323- md := RenderMarkdown(b.Text, v.Theme, width)339+ md := RenderMarkdown(b.Text, v.Theme, inner)
324 for _, l := range strings.Split(md, "\n") {340 for _, l := range strings.Split(md, "\n") {
325- for _, w := range wrapLine(l, width, "") {341+ for _, w := range wrapLine(l, inner, "") {
326- lines = append(lines, w)342+ lines = append(lines, indent+w)
327 }343 }
328 }344 }
329 case provider.ToolCallBlock:345 case provider.ToolCallBlock:
330- lines = append(lines, v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments)))346+ lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments)))
331 }347 }
332 }348 }
333 case provider.RoleTool:349 case provider.RoleTool:
@@ -855,18 +871,27 @@ func StatusBar(p StatusBarParams) []string {
855 stats = append(stats, th.FG256(ctxColor, ctx))871 stats = append(stats, th.FG256(ctxColor, ctx))
856 }872 }
857 873
858- left := fmt.Sprintf(" (%s) %s ", p.Provider, p.Model)874+ // Layout uses exactly 2 spaces of horizontal padding everywhere:
859- middle := " " + strings.Join(stats, " ") + " "875+ // 2 spaces (openai) gpt-5.4 $0.000 (sub) 0.0%/400k ~/Sites/zot
876+ // matches the editor prompt's left inset so the bar lines up
877+ // vertically with the conversation column.
878+ const pad = " " // 2 spaces
879+
880+ left := fmt.Sprintf("(%s) %s", p.Provider, p.Model)
881+ middle := strings.Join(stats, " ")
860 882
861 var leftBuilder strings.Builder883 var leftBuilder strings.Builder
862 if p.BusyPrefix != "" {884 if p.BusyPrefix != "" {
863- leftBuilder.WriteString(th.FG256(th.Accent, " "+p.BusyPrefix+" "))885+ leftBuilder.WriteString(th.FG256(th.Accent, pad+p.BusyPrefix))
864- leftBuilder.WriteString(" ")886+ leftBuilder.WriteString(pad)
865 }887 }
888+ leftBuilder.WriteString(pad)
866 leftBuilder.WriteString(th.FG256(th.Muted, left))889 leftBuilder.WriteString(th.FG256(th.Muted, left))
867- leftBuilder.WriteString(" ")890+ if middle != "" {
868- // `middle` already has colorized context segments; wrap the rest in muted.891+ leftBuilder.WriteString(pad)
869- leftBuilder.WriteString(th.FG256(th.Muted, middle))892+ // `middle` already has colorized context segments; wrap the rest in muted.
893+ leftBuilder.WriteString(th.FG256(th.Muted, middle))
894+ }
870 895
871 cwd := shortenHome(p.CWD)896 cwd := shortenHome(p.CWD)
872 if p.Locked && cwd != "" {897 if p.Locked && cwd != "" {
@@ -879,8 +904,7 @@ func StatusBar(p StatusBarParams) []string {
879 }904 }
880 905
881 cwdRendered := th.FG256(th.Muted, cwd)906 cwdRendered := th.FG256(th.Muted, cwd)
882- const gap = " " // exactly three spaces between stats and cwd907+ combined := primary + pad + cwdRendered
883- combined := primary + gap + cwdRendered
884 908
885 // Wrap to a second line when the combined width would overflow.909 // Wrap to a second line when the combined width would overflow.
886 if p.Cols > 0 && visibleWidth(combined) > p.Cols {910 if p.Cols > 0 && visibleWidth(combined) > p.Cols {

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