detached 42173ed

tui: user bubble layout, OSC 11 theme detection, image footprint fix

User message bubble redesign:

- No more "\u258c you" / "\u258c zot" speaker labels; turns are
  delimited by a tinted bubble panel for the user side and plain
  prose for the assistant side.
- The bubble is a full-width tinted row prefixed by an accent "\u258c "
  bar (BG behind the bar matches the panel so bar and panel read as
  one continuous coloured strip). One blank tinted row above and
  below the message gives the bubble vertical breathing room \u2014 the
  closest a terminal can get to css padding-top / padding-bottom.
- Theme.UserBubble + Theme.UserBubbleBG/FG carry the panel colours.
- /btw side-chat reuses the same bubble helper so it matches the
  main chat layout.
- One blank line above the slash popup, the dialog block, and the
  sliding-in chips so they don't sit flush against the chat above.
- One trailing blank inside the /btw frame after the editor.

Tool box outer/inner geometry:

- toolBoxOuterMargin pulls the box frame in from the terminal
  edges so user bubble, assistant prose, and box frames all share
  the same left/right column.
- Image-footprint rows (escape + reservation blanks + caption gap)
  are now tagged with imageFootprintSentinel by renderImageBlock
  and the three box-side wrappers strip the tag and still wrap the
  rows in \u2502 \u2026 \u2502 so the box edges stay continuous around the
  image. The escape is indented inside the frame instead of
  kissing the \u2502.
- toolBoxSide bypasses width measurement / truncation when the row
  carries an iTerm OSC 1337 or Kitty APC G escape: visibleWidth
  doesn't recognise OSC payloads and was destroying the image
  bytes when wrapping turned them into thousand-cell-wide rows.

Markdown / code fences:

- RenderMarkdown emits a FlushLeftSentinel byte at the start of
  every fenced code-block line so a future caller can opt those
  rows out of prose indent. The current consumer doesn't strip
  the sentinel, but stale callers in skills / changelog / btw /
  compaction summaries do, so leftover sentinels never leak into
  rendered output.

Theme detection:

- New tui.DetectThemeFromBackground(timeout) probes the terminal
  via OSC 11, parses rgb:RRRR/GGGG/BBBB, and returns Light if
  Rec. 709 luma >= 0.5 else Dark. ZOT_THEME=dark|light overrides
  the probe. Falls back to Dark when the terminal doesn't
  respond within the timeout (Linux console, certain VS Code
  configs, tmux without pass-through).
- cli.go now picks the theme via DetectThemeFromBackground
  instead of hard-coding tui.Dark.
- Light theme bubble palette tuned (UserBubbleBG 254 / FG 240) so
  user rows stay legible on a light terminal.

Editor / spinner polish:

- Editor prompt remains the AccentBar in Theme.Accent.
- One "funny working line" entry softened from "go generics" to
  "the code".
commit
42173ed45d7c83a30bd7de1a7be01cc78d39ecce
author
patriceckhart · · 2026-05-03 08:18 UTC
parent
a07e43d
signature
unsigned
refs
tag: v0.1.42
9 files · +405 -118
M internal/agent/cli.go +1 -1
@@ -637,7 +637,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
637637
638638 iv = modes.NewInteractive(modes.InteractiveConfig{
639639 Terminal: term,
640- Theme: tui.Dark,
640+ Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
641641 Model: r.Model,
642642 Provider: r.Provider,
643643 AuthMethod: r.AuthMethod,
@@ -637,7 +637,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
637 637
638 iv = modes.NewInteractive(modes.InteractiveConfig{638 iv = modes.NewInteractive(modes.InteractiveConfig{
639 Terminal: term,639 Terminal: term,
640- Theme: tui.Dark,640+ Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
641 Model: r.Model,641 Model: r.Model,
642 Provider: r.Provider,642 Provider: r.Provider,
643 AuthMethod: r.AuthMethod,643 AuthMethod: r.AuthMethod,
M internal/agent/modes/btw_dialog.go +39 -8
@@ -300,15 +300,14 @@ 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.AccentBar(th.User)+th.FG256(th.User, "you"))
304- for _, line := range strings.Split(t.User, "\n") {
305- out = append(out, " "+th.FG256(th.Muted, line))
306- }
303+ out = append(out, btwUserBubbleRows(th, t.User, width-2)...)
307304 if t.Assistant != "" {
308305 out = append(out, "")
309- out = append(out, " "+th.AccentBar(th.Assistant)+th.FG256(th.Assistant, "zot"))
310306 md := tui.RenderMarkdown(t.Assistant, th, width-4)
311307 for _, line := range strings.Split(md, "\n") {
308+ if len(line) > 0 && line[0] == tui.FlushLeftSentinel {
309+ line = line[1:]
310+ }
312311 out = append(out, " "+line)
313312 }
314313 }
@@ -339,6 +338,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
339338 // marker, so just two cells of pad.
340339 out = append(out, " "+l)
341340 }
341+ out = append(out, "") // breathing room between editor and frame rule
342342 }
343343 out = append(out, frameRuleColor(th, width, th.Accent))
344344 return out
@@ -362,11 +362,9 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
362362 }
363363 for _, t := range d.turns {
364364 editorOffset++ // blank
365- editorOffset++ // "you" header
366- editorOffset += len(strings.Split(t.User, "\n"))
365+ editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2))
367366 if t.Assistant != "" {
368367 editorOffset++ // blank
369- editorOffset++ // "zot" header
370368 editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))
371369 }
372370 if t.Err != "" {
@@ -382,6 +380,39 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
382380 return editorOffset + eRow, eCol + 2 /* matches render indent */
383381 }
384382
383+// btwUserBubbleRows renders a user message inside the /btw dialog
384+// using the same bubble layout the main chat uses (full-width tinted
385+// panel, left-edge ▌ bar, padding rows above and below). The frame
386+// padding is the caller's job; bubbleWidth is the available row
387+// width inside the frame.
388+func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string {
389+ const leftGutter = 0
390+ const rightGutter = 2
391+ innerWidth := bubbleWidth - 2 - leftGutter - rightGutter // 2 = bar's two cells
392+ if innerWidth < 1 {
393+ innerWidth = 1
394+ }
395+ bar := th.BG256(th.UserBubbleBG, th.FG256(th.Accent, "▌ "))
396+ row := func(content string) string {
397+ inner := strings.Repeat(" ", leftGutter) + content
398+ return " " + bar + th.UserBubble(inner, bubbleWidth-2)
399+ }
400+ var bubble []string
401+ for _, l := range strings.Split(text, "\n") {
402+ for _, w := range tui.WrapANSILine(l, innerWidth) {
403+ bubble = append(bubble, row(w))
404+ }
405+ }
406+ if len(bubble) == 0 {
407+ return nil
408+ }
409+ out := make([]string, 0, len(bubble)+2)
410+ out = append(out, row(""))
411+ out = append(out, bubble...)
412+ out = append(out, row(""))
413+ return out
414+}
415+
385416 // errMessage is a tiny helper for the future when we want to surface
386417 // retryable failures in a styled way.
387418 func errMessage(err error) string {
@@ -300,15 +300,14 @@ 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.AccentBar(th.User)+th.FG256(th.User, "you"))303+ out = append(out, btwUserBubbleRows(th, t.User, width-2)...)
304- for _, line := range strings.Split(t.User, "\n") {
305- out = append(out, " "+th.FG256(th.Muted, line))
306- }
307 if t.Assistant != "" {304 if t.Assistant != "" {
308 out = append(out, "")305 out = append(out, "")
309- out = append(out, " "+th.AccentBar(th.Assistant)+th.FG256(th.Assistant, "zot"))
310 md := tui.RenderMarkdown(t.Assistant, th, width-4)306 md := tui.RenderMarkdown(t.Assistant, th, width-4)
311 for _, line := range strings.Split(md, "\n") {307 for _, line := range strings.Split(md, "\n") {
308+ if len(line) > 0 && line[0] == tui.FlushLeftSentinel {
309+ line = line[1:]
310+ }
312 out = append(out, " "+line)311 out = append(out, " "+line)
313 }312 }
314 }313 }
@@ -339,6 +338,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
339 // marker, so just two cells of pad.338 // marker, so just two cells of pad.
340 out = append(out, " "+l)339 out = append(out, " "+l)
341 }340 }
341+ out = append(out, "") // breathing room between editor and frame rule
342 }342 }
343 out = append(out, frameRuleColor(th, width, th.Accent))343 out = append(out, frameRuleColor(th, width, th.Accent))
344 return out344 return out
@@ -362,11 +362,9 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
362 }362 }
363 for _, t := range d.turns {363 for _, t := range d.turns {
364 editorOffset++ // blank364 editorOffset++ // blank
365- editorOffset++ // "you" header365+ editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2))
366- editorOffset += len(strings.Split(t.User, "\n"))
367 if t.Assistant != "" {366 if t.Assistant != "" {
368 editorOffset++ // blank367 editorOffset++ // blank
369- editorOffset++ // "zot" header
370 editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))368 editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))
371 }369 }
372 if t.Err != "" {370 if t.Err != "" {
@@ -382,6 +380,39 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
382 return editorOffset + eRow, eCol + 2 /* matches render indent */380 return editorOffset + eRow, eCol + 2 /* matches render indent */
383 }381 }
384 382
383+// btwUserBubbleRows renders a user message inside the /btw dialog
384+// using the same bubble layout the main chat uses (full-width tinted
385+// panel, left-edge ▌ bar, padding rows above and below). The frame
386+// padding is the caller's job; bubbleWidth is the available row
387+// width inside the frame.
388+func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string {
389+ const leftGutter = 0
390+ const rightGutter = 2
391+ innerWidth := bubbleWidth - 2 - leftGutter - rightGutter // 2 = bar's two cells
392+ if innerWidth < 1 {
393+ innerWidth = 1
394+ }
395+ bar := th.BG256(th.UserBubbleBG, th.FG256(th.Accent, "▌ "))
396+ row := func(content string) string {
397+ inner := strings.Repeat(" ", leftGutter) + content
398+ return " " + bar + th.UserBubble(inner, bubbleWidth-2)
399+ }
400+ var bubble []string
401+ for _, l := range strings.Split(text, "\n") {
402+ for _, w := range tui.WrapANSILine(l, innerWidth) {
403+ bubble = append(bubble, row(w))
404+ }
405+ }
406+ if len(bubble) == 0 {
407+ return nil
408+ }
409+ out := make([]string, 0, len(bubble)+2)
410+ out = append(out, row(""))
411+ out = append(out, bubble...)
412+ out = append(out, row(""))
413+ return out
414+}
415+
385 // errMessage is a tiny helper for the future when we want to surface416 // errMessage is a tiny helper for the future when we want to surface
386 // retryable failures in a styled way.417 // retryable failures in a styled way.
387 func errMessage(err error) string {418 func errMessage(err error) string {
M internal/agent/modes/changelog_dialog.go +3 -0
@@ -93,6 +93,9 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string {
9393 // Regular line: render through markdown for bullet points etc.
9494 rendered := tui.RenderMarkdown(l, th, width-4)
9595 for _, rl := range strings.Split(rendered, "\n") {
96+ if len(rl) > 0 && rl[0] == tui.FlushLeftSentinel {
97+ rl = rl[1:]
98+ }
9699 bodyLines = append(bodyLines, rl)
97100 }
98101 }
@@ -93,6 +93,9 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string {
93 // Regular line: render through markdown for bullet points etc.93 // Regular line: render through markdown for bullet points etc.
94 rendered := tui.RenderMarkdown(l, th, width-4)94 rendered := tui.RenderMarkdown(l, th, width-4)
95 for _, rl := range strings.Split(rendered, "\n") {95 for _, rl := range strings.Split(rendered, "\n") {
96+ if len(rl) > 0 && rl[0] == tui.FlushLeftSentinel {
97+ rl = rl[1:]
98+ }
96 bodyLines = append(bodyLines, rl)99 bodyLines = append(bodyLines, rl)
97 }100 }
98 }101 }
M internal/agent/modes/skills_dialog.go +5 -0
@@ -137,6 +137,11 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
137137
138138 rendered := tui.RenderMarkdown(s.Body, th, width-4)
139139 bodyLines := strings.Split(rendered, "\n")
140+ for i, l := range bodyLines {
141+ if len(l) > 0 && l[0] == tui.FlushLeftSentinel {
142+ bodyLines[i] = l[1:]
143+ }
144+ }
140145
141146 const maxRows = 16
142147 if d.scroll > len(bodyLines)-1 {
@@ -137,6 +137,11 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
137 137
138 rendered := tui.RenderMarkdown(s.Body, th, width-4)138 rendered := tui.RenderMarkdown(s.Body, th, width-4)
139 bodyLines := strings.Split(rendered, "\n")139 bodyLines := strings.Split(rendered, "\n")
140+ for i, l := range bodyLines {
141+ if len(l) > 0 && l[0] == tui.FlushLeftSentinel {
142+ bodyLines[i] = l[1:]
143+ }
144+ }
140 145
141 const maxRows = 16146 const maxRows = 16
142 if d.scroll > len(bodyLines)-1 {147 if d.scroll > len(bodyLines)-1 {
M internal/agent/modes/spinner.go +1 -1
@@ -38,7 +38,7 @@ var funnyWorkingLines = []string{
3838 "picking a fight with syntax",
3939 "reading between the bits",
4040 "tasting the semicolons",
41- "pretending to understand go generics",
41+ "pretending to understand the code",
4242 "petting the cache",
4343 "drafting clever replies",
4444 "warming up the GPU choir",
@@ -38,7 +38,7 @@ var funnyWorkingLines = []string{
38 "picking a fight with syntax",38 "picking a fight with syntax",
39 "reading between the bits",39 "reading between the bits",
40 "tasting the semicolons",40 "tasting the semicolons",
41- "pretending to understand go generics",41+ "pretending to understand the code",
42 "petting the cache",42 "petting the cache",
43 "drafting clever replies",43 "drafting clever replies",
44 "warming up the GPU choir",44 "warming up the GPU choir",
A internal/tui/detect_bg.go +161 -0
@@ -0,0 +1,161 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "golang.org/x/term"
11+)
12+
13+// DetectThemeFromBackground queries the controlling tty for its
14+// current background colour using the OSC 11 escape sequence and
15+// returns Dark or Light based on the response's perceived
16+// luminance. Falls back to Dark when the terminal does not
17+// reply within timeout, which is the expected behaviour for
18+// terminals that do not implement OSC 11 (Linux console, VS Code's
19+// integrated terminal in some configurations, tmux without
20+// pass-through, very old emulators).
21+//
22+// The query / parse runs synchronously before the TUI is
23+// initialised so the returned theme can drive the entire session.
24+// We briefly put stdin into raw mode and disable echo so the OSC
25+// reply doesn't leak onto the user's screen as visible bytes.
26+func DetectThemeFromBackground(timeout time.Duration) Theme {
27+ // Honour explicit override env var first; some users / CI envs
28+ // know better than the heuristic.
29+ switch strings.ToLower(strings.TrimSpace(os.Getenv("ZOT_THEME"))) {
30+ case "dark":
31+ return Dark
32+ case "light":
33+ return Light
34+ }
35+
36+ if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
37+ return Dark
38+ }
39+
40+ fd := int(os.Stdin.Fd())
41+ st, err := term.MakeRaw(fd)
42+ if err != nil {
43+ return Dark
44+ }
45+ defer term.Restore(fd, st)
46+
47+ // Send the query. ST (\x1b\\) and BEL (\x07) are both accepted
48+ // terminators; some terminals only honour one of them, so we
49+ // send BEL which is more widely supported.
50+ if _, err := os.Stdout.Write([]byte("\x1b]11;?\x07")); err != nil {
51+ return Dark
52+ }
53+
54+ deadline := time.Now().Add(timeout)
55+ resp := readOSCResponse(deadline)
56+ if resp == "" {
57+ return Dark
58+ }
59+
60+ r, g, b, ok := parseOSC11Reply(resp)
61+ if !ok {
62+ return Dark
63+ }
64+
65+ // Rec. 709 luma. Threshold at 0.5: anything brighter than
66+ // mid-grey gets the light theme.
67+ luma := 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)
68+ if luma >= 0.5 {
69+ return Light
70+ }
71+ return Dark
72+}
73+
74+// readOSCResponse drains stdin into a small buffer until either a
75+// terminator (BEL or ST) is seen, the deadline expires, or stdin
76+// hits EOF. Returns whatever was collected, or "" on no usable
77+// response.
78+func readOSCResponse(deadline time.Time) string {
79+ var buf [128]byte
80+ n := 0
81+ for time.Now().Before(deadline) {
82+ remaining := time.Until(deadline)
83+ if remaining <= 0 {
84+ break
85+ }
86+ b, ok, err := peekStdin(os.Stdin, remaining)
87+ if err != nil || !ok {
88+ return string(buf[:n])
89+ }
90+ if n < len(buf) {
91+ buf[n] = b
92+ n++
93+ }
94+ // BEL terminator
95+ if b == 0x07 {
96+ return string(buf[:n])
97+ }
98+ // ST terminator: ESC then '\\'. We saw it the moment the
99+ // previous byte was ESC and the current byte is '\\'.
100+ if n >= 2 && buf[n-2] == 0x1b && buf[n-1] == '\\' {
101+ return string(buf[:n])
102+ }
103+ }
104+ return string(buf[:n])
105+}
106+
107+// parseOSC11Reply extracts the (r, g, b) colour components from an
108+// OSC 11 reply of the form "\x1b]11;rgb:RRRR/GGGG/BBBB\x07" (or with
109+// ST terminator). The component widths can be 1, 2, 3, or 4 hex
110+// digits per channel; we normalise them into the 0..1 range.
111+func parseOSC11Reply(s string) (float64, float64, float64, bool) {
112+ // Locate "rgb:" within the response.
113+ i := strings.Index(s, "rgb:")
114+ if i < 0 {
115+ return 0, 0, 0, false
116+ }
117+ body := s[i+len("rgb:"):]
118+ // Trim trailing terminator(s).
119+ body = strings.TrimRight(body, "\x07")
120+ if strings.HasSuffix(body, "\x1b\\") {
121+ body = strings.TrimSuffix(body, "\x1b\\")
122+ }
123+ parts := strings.Split(body, "/")
124+ if len(parts) != 3 {
125+ return 0, 0, 0, false
126+ }
127+ parse := func(hexstr string) (float64, bool) {
128+ if len(hexstr) == 0 || len(hexstr) > 4 {
129+ return 0, false
130+ }
131+ v, err := strconv.ParseUint(hexstr, 16, 32)
132+ if err != nil {
133+ return 0, false
134+ }
135+ // Normalise to 0..1: max value is (16^len - 1).
136+ max := uint64(1)
137+ for j := 0; j < len(hexstr); j++ {
138+ max *= 16
139+ }
140+ max--
141+ if max == 0 {
142+ return 0, false
143+ }
144+ return float64(v) / float64(max), true
145+ }
146+ r, ok1 := parse(parts[0])
147+ g, ok2 := parse(parts[1])
148+ b, ok3 := parse(parts[2])
149+ if !ok1 || !ok2 || !ok3 {
150+ return 0, 0, 0, false
151+ }
152+ return r, g, b, true
153+}
154+
155+// debugDetect is used only in development to help diagnose
156+// detection issues; never invoked in production code paths.
157+func debugDetect() {
158+ fmt.Fprintf(os.Stderr, "zot theme detection: stdin tty=%v stdout tty=%v\n",
159+ term.IsTerminal(int(os.Stdin.Fd())),
160+ term.IsTerminal(int(os.Stdout.Fd())))
161+}
@@ -0,0 +1,161 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strconv"
7+ "strings"
8+ "time"
9+
10+ "golang.org/x/term"
11+)
12+
13+// DetectThemeFromBackground queries the controlling tty for its
14+// current background colour using the OSC 11 escape sequence and
15+// returns Dark or Light based on the response's perceived
16+// luminance. Falls back to Dark when the terminal does not
17+// reply within timeout, which is the expected behaviour for
18+// terminals that do not implement OSC 11 (Linux console, VS Code's
19+// integrated terminal in some configurations, tmux without
20+// pass-through, very old emulators).
21+//
22+// The query / parse runs synchronously before the TUI is
23+// initialised so the returned theme can drive the entire session.
24+// We briefly put stdin into raw mode and disable echo so the OSC
25+// reply doesn't leak onto the user's screen as visible bytes.
26+func DetectThemeFromBackground(timeout time.Duration) Theme {
27+ // Honour explicit override env var first; some users / CI envs
28+ // know better than the heuristic.
29+ switch strings.ToLower(strings.TrimSpace(os.Getenv("ZOT_THEME"))) {
30+ case "dark":
31+ return Dark
32+ case "light":
33+ return Light
34+ }
35+
36+ if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
37+ return Dark
38+ }
39+
40+ fd := int(os.Stdin.Fd())
41+ st, err := term.MakeRaw(fd)
42+ if err != nil {
43+ return Dark
44+ }
45+ defer term.Restore(fd, st)
46+
47+ // Send the query. ST (\x1b\\) and BEL (\x07) are both accepted
48+ // terminators; some terminals only honour one of them, so we
49+ // send BEL which is more widely supported.
50+ if _, err := os.Stdout.Write([]byte("\x1b]11;?\x07")); err != nil {
51+ return Dark
52+ }
53+
54+ deadline := time.Now().Add(timeout)
55+ resp := readOSCResponse(deadline)
56+ if resp == "" {
57+ return Dark
58+ }
59+
60+ r, g, b, ok := parseOSC11Reply(resp)
61+ if !ok {
62+ return Dark
63+ }
64+
65+ // Rec. 709 luma. Threshold at 0.5: anything brighter than
66+ // mid-grey gets the light theme.
67+ luma := 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)
68+ if luma >= 0.5 {
69+ return Light
70+ }
71+ return Dark
72+}
73+
74+// readOSCResponse drains stdin into a small buffer until either a
75+// terminator (BEL or ST) is seen, the deadline expires, or stdin
76+// hits EOF. Returns whatever was collected, or "" on no usable
77+// response.
78+func readOSCResponse(deadline time.Time) string {
79+ var buf [128]byte
80+ n := 0
81+ for time.Now().Before(deadline) {
82+ remaining := time.Until(deadline)
83+ if remaining <= 0 {
84+ break
85+ }
86+ b, ok, err := peekStdin(os.Stdin, remaining)
87+ if err != nil || !ok {
88+ return string(buf[:n])
89+ }
90+ if n < len(buf) {
91+ buf[n] = b
92+ n++
93+ }
94+ // BEL terminator
95+ if b == 0x07 {
96+ return string(buf[:n])
97+ }
98+ // ST terminator: ESC then '\\'. We saw it the moment the
99+ // previous byte was ESC and the current byte is '\\'.
100+ if n >= 2 && buf[n-2] == 0x1b && buf[n-1] == '\\' {
101+ return string(buf[:n])
102+ }
103+ }
104+ return string(buf[:n])
105+}
106+
107+// parseOSC11Reply extracts the (r, g, b) colour components from an
108+// OSC 11 reply of the form "\x1b]11;rgb:RRRR/GGGG/BBBB\x07" (or with
109+// ST terminator). The component widths can be 1, 2, 3, or 4 hex
110+// digits per channel; we normalise them into the 0..1 range.
111+func parseOSC11Reply(s string) (float64, float64, float64, bool) {
112+ // Locate "rgb:" within the response.
113+ i := strings.Index(s, "rgb:")
114+ if i < 0 {
115+ return 0, 0, 0, false
116+ }
117+ body := s[i+len("rgb:"):]
118+ // Trim trailing terminator(s).
119+ body = strings.TrimRight(body, "\x07")
120+ if strings.HasSuffix(body, "\x1b\\") {
121+ body = strings.TrimSuffix(body, "\x1b\\")
122+ }
123+ parts := strings.Split(body, "/")
124+ if len(parts) != 3 {
125+ return 0, 0, 0, false
126+ }
127+ parse := func(hexstr string) (float64, bool) {
128+ if len(hexstr) == 0 || len(hexstr) > 4 {
129+ return 0, false
130+ }
131+ v, err := strconv.ParseUint(hexstr, 16, 32)
132+ if err != nil {
133+ return 0, false
134+ }
135+ // Normalise to 0..1: max value is (16^len - 1).
136+ max := uint64(1)
137+ for j := 0; j < len(hexstr); j++ {
138+ max *= 16
139+ }
140+ max--
141+ if max == 0 {
142+ return 0, false
143+ }
144+ return float64(v) / float64(max), true
145+ }
146+ r, ok1 := parse(parts[0])
147+ g, ok2 := parse(parts[1])
148+ b, ok3 := parse(parts[2])
149+ if !ok1 || !ok2 || !ok3 {
150+ return 0, 0, 0, false
151+ }
152+ return r, g, b, true
153+}
154+
155+// debugDetect is used only in development to help diagnose
156+// detection issues; never invoked in production code paths.
157+func debugDetect() {
158+ fmt.Fprintf(os.Stderr, "zot theme detection: stdin tty=%v stdout tty=%v\n",
159+ term.IsTerminal(int(os.Stdin.Fd())),
160+ term.IsTerminal(int(os.Stdout.Fd())))
161+}
M internal/tui/markdown.go +7 -0
@@ -5,6 +5,13 @@ import (
55 "strings"
66 )
77
8+// FlushLeftSentinel was used previously to opt fenced code blocks
9+// out of the prose indent. The current rendering keeps fences
10+// aligned with surrounding prose, so the sentinel is no longer
11+// emitted; the constant is kept (and exported) so any older
12+// caller that still strips it remains a harmless no-op.
13+const FlushLeftSentinel = '\x1c'
14+
815 // RenderMarkdown renders a small subset of Markdown to styled terminal
916 // text using theme colors. Supported: headings, bold, italic, inline
1017 // code, fenced code blocks, bullet lists, numbered lists, blockquotes.
@@ -5,6 +5,13 @@ import (
5 "strings"5 "strings"
6 )6 )
7 7
8+// FlushLeftSentinel was used previously to opt fenced code blocks
9+// out of the prose indent. The current rendering keeps fences
10+// aligned with surrounding prose, so the sentinel is no longer
11+// emitted; the constant is kept (and exported) so any older
12+// caller that still strips it remains a harmless no-op.
13+const FlushLeftSentinel = '\x1c'
14+
8 // RenderMarkdown renders a small subset of Markdown to styled terminal15 // RenderMarkdown renders a small subset of Markdown to styled terminal
9 // text using theme colors. Supported: headings, bold, italic, inline16 // text using theme colors. Supported: headings, bold, italic, inline
10 // code, fenced code blocks, bullet lists, numbered lists, blockquotes.17 // code, fenced code blocks, bullet lists, numbered lists, blockquotes.
M internal/tui/theme.go +72 -36
@@ -8,48 +8,54 @@ import "strings"
88 // ANSI 256-color palette used by zot. Defined as numeric codes so we
99 // can swap themes without changing any render code.
1010 type Theme struct {
11- FG int
12- Muted int
13- Accent int
14- User int // label color for the user role
15- Assistant int // label color for the zot role
16- Tool int
17- ToolOut int
18- Error int
19- Warning int
20- Spinner int // spinner + funny working line
21- SelectionBG int // background for highlighted rows
22- SelectionFG int // foreground for highlighted rows
11+ FG int
12+ Muted int
13+ Accent int
14+ User int // label color for the user role
15+ UserBubbleBG int // background tint behind user message rows
16+ UserBubbleFG int // foreground colour for user message rows
17+ Assistant int // label color for the zot role
18+ Tool int
19+ ToolOut int
20+ Error int
21+ Warning int
22+ Spinner int // spinner + funny working line
23+ SelectionBG int // background for highlighted rows
24+ SelectionFG int // foreground for highlighted rows
2325 }
2426
2527 var Dark = Theme{
26- FG: 253,
27- Muted: 244,
28- Accent: 111, // soft blue
29- User: 180, // warm tan
30- Assistant: 117, // bright cyan — the zot label color
31- Tool: 114, // green
32- ToolOut: 245,
33- Error: 203,
34- Warning: 214,
35- Spinner: 183, // soft purple
36- SelectionBG: 24, // deep blue background
37- SelectionFG: 231, // near-white foreground
28+ FG: 253,
29+ Muted: 244,
30+ Accent: 111, // soft blue
31+ User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat)
32+ UserBubbleBG: 237, // soft mid-dark grey panel behind user rows
33+ UserBubbleFG: 246, // matches Theme.Muted (status bar text colour)
34+ Assistant: 117, // bright cyan — the zot label color
35+ Tool: 114, // green
36+ ToolOut: 245,
37+ Error: 203,
38+ Warning: 214,
39+ Spinner: 183, // soft purple
40+ SelectionBG: 24, // deep blue background
41+ SelectionFG: 231, // near-white foreground
3842 }
3943
4044 var Light = Theme{
41- FG: 236,
42- Muted: 244,
43- Accent: 33,
44- User: 94,
45- Assistant: 31, // deep cyan
46- Tool: 28,
47- ToolOut: 240,
48- Error: 160,
49- Warning: 166,
50- Spinner: 91, // purple
51- SelectionBG: 153, // light blue
52- SelectionFG: 232, // near-black
45+ FG: 236,
46+ Muted: 244,
47+ Accent: 33,
48+ User: 94,
49+ UserBubbleBG: 254, // very pale grey panel behind user rows on light theme
50+ UserBubbleFG: 240, // dark grey text, legible on the pale panel
51+ Assistant: 31, // deep cyan
52+ Tool: 28,
53+ ToolOut: 240,
54+ Error: 160,
55+ Warning: 166,
56+ Spinner: 91, // purple
57+ SelectionBG: 153, // light blue
58+ SelectionFG: 232, // near-black
5359 }
5460
5561 // FG256 wraps s in foreground color c using ANSI 256-color SGR.
@@ -91,6 +97,36 @@ func (t Theme) PadHighlight(s string, width int) string {
9197 return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset
9298 }
9399
100+// UserBubble paints a single user message row with the bubble
101+// background colour, padding to width so the tint extends to the
102+// full terminal width. Foreground stays in UserBubbleFG so text
103+// remains legible against the tint.
104+func (t Theme) UserBubble(s string, width int) string {
105+ visible := visibleWidth(s)
106+ if visible < width {
107+ s += strings.Repeat(" ", width-visible)
108+ }
109+ return sgrFG(t.UserBubbleFG) + sgrBG(t.UserBubbleBG) + s + reset
110+}
111+
112+// UserBubbleRow renders one user-bubble row prefixed with a coloured
113+// half-block accent bar ("▌ ") so every line of the bubble has the
114+// zot-blue gutter at the very left. The bar lives outside the bubble
115+// tint (chat bg) so the bubble itself sits inside it. Width is the
116+// outer width including the bar; the bubble content is padded to
117+// width-2 (the bar + its trailing space).
118+func (t Theme) UserBubbleRow(content string, width int) string {
119+ // Bar plus a single space gutter, in the assistant accent colour
120+ // so it matches the tool-box / app accent and reads as zot's voice
121+ // marker. Two cells wide.
122+ bar := t.FG256(t.Assistant, "▌ ")
123+ bubbleW := width - 2
124+ if bubbleW < 1 {
125+ bubbleW = 1
126+ }
127+ return bar + t.UserBubble(content, bubbleW)
128+}
129+
94130 // Bold wraps s in bold SGR.
95131 func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
96132
@@ -8,48 +8,54 @@ import "strings"
8 // ANSI 256-color palette used by zot. Defined as numeric codes so we8 // ANSI 256-color palette used by zot. Defined as numeric codes so we
9 // can swap themes without changing any render code.9 // can swap themes without changing any render code.
10 type Theme struct {10 type Theme struct {
11- FG int11+ FG int
12- Muted int12+ Muted int
13- Accent int13+ Accent int
14- User int // label color for the user role14+ User int // label color for the user role
15- Assistant int // label color for the zot role15+ UserBubbleBG int // background tint behind user message rows
16- Tool int16+ UserBubbleFG int // foreground colour for user message rows
17- ToolOut int17+ Assistant int // label color for the zot role
18- Error int18+ Tool int
19- Warning int19+ ToolOut int
20- Spinner int // spinner + funny working line20+ Error int
21- SelectionBG int // background for highlighted rows21+ Warning int
22- SelectionFG int // foreground for highlighted rows22+ Spinner int // spinner + funny working line
23+ SelectionBG int // background for highlighted rows
24+ SelectionFG int // foreground for highlighted rows
23 }25 }
24 26
25 var Dark = Theme{27 var Dark = Theme{
26- FG: 253,28+ FG: 253,
27- Muted: 244,29+ Muted: 244,
28- Accent: 111, // soft blue30+ Accent: 111, // soft blue
29- User: 180, // warm tan31+ User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat)
30- Assistant: 117, // bright cyan — the zot label color32+ UserBubbleBG: 237, // soft mid-dark grey panel behind user rows
31- Tool: 114, // green33+ UserBubbleFG: 246, // matches Theme.Muted (status bar text colour)
32- ToolOut: 245,34+ Assistant: 117, // bright cyan — the zot label color
33- Error: 203,35+ Tool: 114, // green
34- Warning: 214,36+ ToolOut: 245,
35- Spinner: 183, // soft purple37+ Error: 203,
36- SelectionBG: 24, // deep blue background38+ Warning: 214,
37- SelectionFG: 231, // near-white foreground39+ Spinner: 183, // soft purple
40+ SelectionBG: 24, // deep blue background
41+ SelectionFG: 231, // near-white foreground
38 }42 }
39 43
40 var Light = Theme{44 var Light = Theme{
41- FG: 236,45+ FG: 236,
42- Muted: 244,46+ Muted: 244,
43- Accent: 33,47+ Accent: 33,
44- User: 94,48+ User: 94,
45- Assistant: 31, // deep cyan49+ UserBubbleBG: 254, // very pale grey panel behind user rows on light theme
46- Tool: 28,50+ UserBubbleFG: 240, // dark grey text, legible on the pale panel
47- ToolOut: 240,51+ Assistant: 31, // deep cyan
48- Error: 160,52+ Tool: 28,
49- Warning: 166,53+ ToolOut: 240,
50- Spinner: 91, // purple54+ Error: 160,
51- SelectionBG: 153, // light blue55+ Warning: 166,
52- SelectionFG: 232, // near-black56+ Spinner: 91, // purple
57+ SelectionBG: 153, // light blue
58+ SelectionFG: 232, // near-black
53 }59 }
54 60
55 // FG256 wraps s in foreground color c using ANSI 256-color SGR.61 // FG256 wraps s in foreground color c using ANSI 256-color SGR.
@@ -91,6 +97,36 @@ func (t Theme) PadHighlight(s string, width int) string {
91 return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset97 return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset
92 }98 }
93 99
100+// UserBubble paints a single user message row with the bubble
101+// background colour, padding to width so the tint extends to the
102+// full terminal width. Foreground stays in UserBubbleFG so text
103+// remains legible against the tint.
104+func (t Theme) UserBubble(s string, width int) string {
105+ visible := visibleWidth(s)
106+ if visible < width {
107+ s += strings.Repeat(" ", width-visible)
108+ }
109+ return sgrFG(t.UserBubbleFG) + sgrBG(t.UserBubbleBG) + s + reset
110+}
111+
112+// UserBubbleRow renders one user-bubble row prefixed with a coloured
113+// half-block accent bar ("▌ ") so every line of the bubble has the
114+// zot-blue gutter at the very left. The bar lives outside the bubble
115+// tint (chat bg) so the bubble itself sits inside it. Width is the
116+// outer width including the bar; the bubble content is padded to
117+// width-2 (the bar + its trailing space).
118+func (t Theme) UserBubbleRow(content string, width int) string {
119+ // Bar plus a single space gutter, in the assistant accent colour
120+ // so it matches the tool-box / app accent and reads as zot's voice
121+ // marker. Two cells wide.
122+ bar := t.FG256(t.Assistant, "▌ ")
123+ bubbleW := width - 2
124+ if bubbleW < 1 {
125+ bubbleW = 1
126+ }
127+ return bar + t.UserBubble(content, bubbleW)
128+}
129+
94 // Bold wraps s in bold SGR.130 // Bold wraps s in bold SGR.
95 func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }131 func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }
96 132
M internal/tui/view.go +116 -72
@@ -250,34 +250,16 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
250250 // overlay below is the real content and a naked "zot" bar
251251 // above it reads as a stray empty message.
252252 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
253- // Suppress the streaming "▍ zot" header if the turn is
254- // already open (a previous assistant/tool message in this
255- // turn already rendered one). Otherwise the streaming text
256- // would jump from headerless to under a new header once it
257- // finalises into a transcript message.
258- turnOpen := false
259- for j := len(v.Messages) - 1; j >= 0; j-- {
260- prev := v.Messages[j]
261- if prev.Meta["compaction"] == "true" {
262- continue
263- }
264- if prev.Role == provider.RoleAssistant || prev.Role == provider.RoleTool {
265- turnOpen = true
266- }
267- break
268- }
269- if !turnOpen {
270- out = append(out, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot"))
271- }
272253 // Stream the partial assistant text through the same markdown
273254 // renderer used for finalised messages so code fences, diffs,
274255 // lists, and inline styles look the same while streaming and
275- // don't suddenly reflow when the turn ends. Indent matches the
276- // finalised assistant body in renderMessage so the column
277- // stays consistent across the stream/finalise transition.
278- // Width is capped so ultra-wide terminals don't produce
279- // edge-to-edge rules / unreadably long prose lines.
280- const indent = " "
256+ // don't suddenly reflow when the turn ends. No speaker header
257+ // is drawn; the indent matches the finalised assistant body in
258+ // renderMessage so the column stays consistent across the
259+ // stream/finalise transition. Width is capped so ultra-wide
260+ // terminals don't produce edge-to-edge code-fence rules or
261+ // unreadably long prose lines.
262+ const indent = " "
281263 inner := assistantBodyWidth(width - len(indent))
282264 md := RenderMarkdown(v.Streaming, v.Theme, inner)
283265 for _, l := range strings.Split(md, "\n") {
@@ -438,10 +420,8 @@ const (
438420 )
439421
440422 // assistantBodyRightPad is the blank gutter kept on the right
441-// side of every assistant prose line so text doesn't kiss the
442-// terminal edge. Matches the 4-cell left indent, so a line of
443-// fully-wrapped prose sits in a symmetric column.
444-const assistantBodyRightPad = 4
423+// side of every assistant prose line.
424+const assistantBodyRightPad = 2
445425
446426 // assistantBodyWidth returns the usable width for the assistant
447427 // message body (markdown prose + code fences). Uses the full
@@ -486,34 +466,56 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
486466
487467 switch m.Role {
488468 case provider.RoleUser:
489- header := v.Theme.AccentBar(v.Theme.User) + v.Theme.FG256(v.Theme.User, "you")
490- lines = append(lines, header)
469+ // User rows: no speaker label. Each line is painted with a
470+ // faint bubble background tint (UserBubble) so the user's
471+ // turns visually segment the chat without needing a header.
472+ // One tinted blank row above and below approximates CSS
473+ // padding-top / padding-bottom (terminals can't do fractional
474+ // rows, so a full row of bubble-coloured whitespace is the
475+ // closest analogue and gives the bubble visible breathing
476+ // room).
477+ // User rows: a 1-cell "▌" accent bar at column 0 painted on
478+ // the bubble bg, so the bar's cell shares the panel tint and
479+ // there is no visible gap between bar and panel. (Some terminals
480+ // — iTerm2, Apple Terminal — may smear the cell-0 bg into the
481+ // window inset to the left; that's a terminal-side rendering
482+ // quirk we accept in exchange for a clean bar-to-panel join.)
483+ const leftGutter = 0 // cells of bubble bg between bar and text
484+ const rightGutter = 2 // cells of bubble bg between text and right edge
485+ innerWidth := width - 2 - leftGutter - rightGutter // 2 = bar's two cells (▌ + trailing space)
486+ if innerWidth < 1 {
487+ innerWidth = 1
488+ }
489+ row := func(content string) string {
490+ inner := strings.Repeat(" ", leftGutter) + content
491+ padded := v.Theme.UserBubble(inner, width-2)
492+ bar := v.Theme.BG256(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
493+ return bar + padded
494+ }
495+ var bubble []string
491496 for _, c := range m.Content {
492497 switch b := c.(type) {
493498 case provider.TextBlock:
494499 for _, l := range strings.Split(b.Text, "\n") {
495- for _, w := range wrapLine(l, width-4, "") {
496- lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))
500+ for _, w := range wrapLine(l, innerWidth, "") {
501+ bubble = append(bubble, row(w))
497502 }
498503 }
499504 case provider.ImageBlock:
500- lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))
505+ bubble = append(bubble, row(fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))
501506 }
502507 }
508+ if len(bubble) > 0 {
509+ lines = append(lines, row(""))
510+ lines = append(lines, bubble...)
511+ lines = append(lines, row(""))
512+ }
503513 case provider.RoleAssistant:
504- // Only draw the agent header at the start of a turn. Mid-turn
505- // assistant messages (e.g. another tool_use round-trip after a
506- // tool result) reuse the header that's already on screen.
507- if !turnOpen {
508- lines = append(lines, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot"))
509- }
510- // Indent assistant body the same 4 cells the user body uses,
511- // so the conversation column lines up vertically. The width
512- // passed into the markdown renderer / wrap is reduced by the
513- // indent so long lines wrap inside the indented column, and
514- // capped so ultra-wide terminals don't produce edge-to-edge
515- // code-fence rules or unreadably long prose lines.
516- const indent = " "
514+ // Assistant rows: no speaker label either. Prose still gets a
515+ // small left indent so it visually aligns with tool box body
516+ // content, but no "zot" header.
517+ _ = turnOpen
518+ const indent = " "
517519 inner := assistantBodyWidth(width - len(indent))
518520 for _, c := range m.Content {
519521 switch b := c.(type) {
@@ -565,17 +567,18 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
565567 if tr.IsError {
566568 lines = append(lines, toolBoxSide(v.Theme, v.Theme.FG256(color, " error"), width))
567569 }
568- for _, body := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
569- // Image escapes paint into a graphics layer that
570- // doesn't share the text grid; wrapping such a row
571- // in │ … │ produces visible artefacts on iTerm /
572- // Kitty, so leave image rows un-bordered. The
573- // surrounding lines still close the box top + bottom.
574- if hasImageEscapeLine(body) {
575- lines = append(lines, body)
576- continue
570+ for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
571+ // Image-footprint rows (the escape row, the blank
572+ // reservation rows beneath it, and the gap row
573+ // before the metadata caption) are tagged with the
574+ // imageFootprintSentinel by renderImageBlock. Strip
575+ // the tag, then wrap the row in the usual │ … │ box
576+ // edges so the box frame stays continuous around
577+ // the image.
578+ if strings.HasPrefix(line, imageFootprintSentinel) {
579+ line = line[len(imageFootprintSentinel):]
577580 }
578- lines = append(lines, toolBoxSide(v.Theme, body, width))
581+ lines = append(lines, toolBoxSide(v.Theme, line, width))
579582 }
580583 lines = append(lines, toolBoxSide(v.Theme, "", width))
581584 lines = append(lines, toolBoxBottom(v.Theme, width))
@@ -643,9 +646,8 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
643646 }
644647 body := toolResultBlock(v.Theme, tc.Result, width, color)
645648 for _, l := range v.collapseToolBody(body, false) {
646- if hasImageEscapeLine(l) {
647- lines = append(lines, l)
648- continue
649+ if strings.HasPrefix(l, imageFootprintSentinel) {
650+ l = l[len(imageFootprintSentinel):]
649651 }
650652 lines = append(lines, toolBoxSide(v.Theme, l, width))
651653 }
@@ -696,9 +698,8 @@ func (v *View) wrapLiveBody(body []string, width int) []string {
696698 body = v.collapseToolBody(body, false)
697699 out := make([]string, 0, len(body))
698700 for _, l := range body {
699- if hasImageEscapeLine(l) {
700- out = append(out, l)
701- continue
701+ if strings.HasPrefix(l, imageFootprintSentinel) {
702+ l = l[len(imageFootprintSentinel):]
702703 }
703704 out = append(out, toolBoxSide(v.Theme, l, width))
704705 }
@@ -725,6 +726,13 @@ func toolBlockRule(th Theme, width int) string {
725726 // the corner, and gives body lines a tiny gutter from the left side.
726727 const toolBoxInnerPad = 1
727728
729+// toolBoxOuterMargin is the number of blank cells kept to the left of
730+// the opening corner and to the right of the closing corner. Aligns
731+// the box's frame with the column where user-bubble text begins, so
732+// the conversation reads as one column instead of having tool boxes
733+// running edge-to-edge while user/assistant rows sit indented.
734+const toolBoxOuterMargin = 2
735+
728736 // toolBoxTop renders the labelled top edge of a tool block:
729737 //
730738 // ┌─ bash xcrun simctl list devices ─────────────────────────────┐
@@ -734,10 +742,11 @@ const toolBoxInnerPad = 1
734742 // of the line so the right corner sits at column width-1, matching the
735743 // closing edge.
736744 func toolBoxTop(th Theme, label string, width int) string {
737- w := width
745+ w := width - 2*toolBoxOuterMargin
738746 if w < 12 {
739747 w = 12
740748 }
749+ margin := strings.Repeat(" ", toolBoxOuterMargin)
741750 innerPad := strings.Repeat(" ", toolBoxInnerPad)
742751 // "┌─" + innerPad + " " + label + " " + innerPad = used; pad
743752 // with ─ to width-1, then "┐".
@@ -763,7 +772,7 @@ func toolBoxTop(th Theme, label string, width int) string {
763772 }
764773 }
765774 line := prefix + label + suffix + strings.Repeat("─", fill) + "┐"
766- return th.FG256(th.Muted, line)
775+ return margin + th.FG256(th.Muted, line) + margin
767776 }
768777
769778 // toolBoxBottom renders the bottom edge of a tool block:
@@ -772,12 +781,13 @@ func toolBoxTop(th Theme, label string, width int) string {
772781 //
773782 // Spans the same width as toolBoxTop so the corners line up.
774783 func toolBoxBottom(th Theme, width int) string {
775- w := width
784+ w := width - 2*toolBoxOuterMargin
776785 if w < 12 {
777786 w = 12
778787 }
788+ margin := strings.Repeat(" ", toolBoxOuterMargin)
779789 line := "└" + strings.Repeat("─", w-2) + "┘"
780- return th.FG256(th.Muted, line)
790+ return margin + th.FG256(th.Muted, line) + margin
781791 }
782792
783793 // hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or
@@ -788,6 +798,16 @@ func hasImageEscapeLine(s string) bool {
788798 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
789799 }
790800
801+// imageFootprintSentinel marks rows that belong to an inline-image's
802+// reserved footprint — the escape row plus the blank rows below it
803+// plus the gap row before the metadata caption. Any consumer that
804+// wraps content in box edges (│ … │) detects the sentinel, strips
805+// it, and emits the row un-bordered so the box's vertical edges
806+// don't show through next to the image's graphics rectangle. Uses a
807+// non-printing C0 control byte so it can never appear in normal
808+// text or in an ANSI escape sequence body.
809+const imageFootprintSentinel = "\x1e"
810+
791811 // toolBoxBodyTrimLeft is the number of leading literal spaces stripped
792812 // from each body line as it enters toolBoxSide. Body renderers
793813 // (renderToolText, renderRawFile, the diff/bash helpers, ...) all
@@ -807,14 +827,26 @@ const toolBoxBodyTrimLeft = 2
807827 // of inner padding sits on each side so the content breathes from the
808828 // edges; the right padding fills to column width-1.
809829 func toolBoxSide(th Theme, line string, width int) string {
810- w := width
830+ w := width - 2*toolBoxOuterMargin
811831 if w < 12 {
812832 w = 12
813833 }
834+ margin := strings.Repeat(" ", toolBoxOuterMargin)
814835 left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad)
815836 right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│")
816837 inner := w - 2 - 2*toolBoxInnerPad // available between the two pads
817838 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
839+
840+ // Inline-image escapes (iTerm OSC 1337, Kitty APC G) carry
841+ // thousands of bytes of base64 payload that visibleWidth and
842+ // stripANSI don't recognise as ANSI. Measuring or truncating
843+ // such a row destroys the escape and the image disappears.
844+ // Pass the row through with edges + a fixed inner padding
845+ // based on the visible prefix only.
846+ if hasImageEscapeLine(line) {
847+ return margin + left + line + right + margin
848+ }
849+
818850 cur := visibleWidth(line)
819851 if cur > inner {
820852 // Last-resort truncation. Strips ANSI styling at the cut
@@ -830,7 +862,7 @@ func toolBoxSide(th Theme, line string, width int) string {
830862 if pad < 0 {
831863 pad = 0
832864 }
833- return left + line + strings.Repeat(" ", pad) + right
865+ return margin + left + line + strings.Repeat(" ", pad) + right + margin
834866 }
835867
836868 // trimLeadingSpaces removes up to n literal space characters from the
@@ -1154,12 +1186,21 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
11541186 // below the reserved rectangle is more stable and easier to
11551187 // read. One extra blank row before the metadata gives the
11561188 // caption breathing room from the image's last pixel row.
1189+ //
1190+ // Every footprint row (escape, reservations, gap) is tagged
1191+ // with imageFootprintSentinel so callers wrapping content in
1192+ // box edges can recognise these rows and emit them bare —
1193+ // the image's graphics rectangle paints over them and any
1194+ // │ character drawn alongside would visibly bleed through.
1195+ // 4 leading cells push the escape past the box's left
1196+ // edge plus a small interior gutter so the image rectangle
1197+ // sits visibly inside the frame instead of kissing the │.
11571198 out := make([]string, 0, rows+3)
1158- out = append(out, " "+seq)
1199+ out = append(out, imageFootprintSentinel+" "+seq)
11591200 for i := 1; i < rows; i++ {
1160- out = append(out, "")
1201+ out = append(out, imageFootprintSentinel)
11611202 }
1162- out = append(out, "")
1203+ out = append(out, imageFootprintSentinel)
11631204 out = append(out, v.Theme.FG256(v.Theme.Muted, info))
11641205 return out
11651206 }
@@ -1589,6 +1630,9 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
15891630 }
15901631 md := RenderMarkdown(text, th, width-4)
15911632 for _, l := range strings.Split(md, "\n") {
1633+ if len(l) > 0 && l[0] == FlushLeftSentinel {
1634+ l = l[1:]
1635+ }
15921636 lines = append(lines, indent+l)
15931637 }
15941638 }
@@ -250,34 +250,16 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
250 // overlay below is the real content and a naked "zot" bar250 // overlay below is the real content and a naked "zot" bar
251 // above it reads as a stray empty message.251 // above it reads as a stray empty message.
252 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {252 if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
253- // Suppress the streaming "▍ zot" header if the turn is
254- // already open (a previous assistant/tool message in this
255- // turn already rendered one). Otherwise the streaming text
256- // would jump from headerless to under a new header once it
257- // finalises into a transcript message.
258- turnOpen := false
259- for j := len(v.Messages) - 1; j >= 0; j-- {
260- prev := v.Messages[j]
261- if prev.Meta["compaction"] == "true" {
262- continue
263- }
264- if prev.Role == provider.RoleAssistant || prev.Role == provider.RoleTool {
265- turnOpen = true
266- }
267- break
268- }
269- if !turnOpen {
270- out = append(out, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot"))
271- }
272 // Stream the partial assistant text through the same markdown253 // Stream the partial assistant text through the same markdown
273 // renderer used for finalised messages so code fences, diffs,254 // renderer used for finalised messages so code fences, diffs,
274 // lists, and inline styles look the same while streaming and255 // lists, and inline styles look the same while streaming and
275- // don't suddenly reflow when the turn ends. Indent matches the256+ // don't suddenly reflow when the turn ends. No speaker header
276- // finalised assistant body in renderMessage so the column257+ // is drawn; the indent matches the finalised assistant body in
277- // stays consistent across the stream/finalise transition.258+ // renderMessage so the column stays consistent across the
278- // Width is capped so ultra-wide terminals don't produce259+ // stream/finalise transition. Width is capped so ultra-wide
279- // edge-to-edge rules / unreadably long prose lines.260+ // terminals don't produce edge-to-edge code-fence rules or
280- const indent = " "261+ // unreadably long prose lines.
262+ const indent = " "
281 inner := assistantBodyWidth(width - len(indent))263 inner := assistantBodyWidth(width - len(indent))
282 md := RenderMarkdown(v.Streaming, v.Theme, inner)264 md := RenderMarkdown(v.Streaming, v.Theme, inner)
283 for _, l := range strings.Split(md, "\n") {265 for _, l := range strings.Split(md, "\n") {
@@ -438,10 +420,8 @@ const (
438 )420 )
439 421
440 // assistantBodyRightPad is the blank gutter kept on the right422 // assistantBodyRightPad is the blank gutter kept on the right
441-// side of every assistant prose line so text doesn't kiss the423+// side of every assistant prose line.
442-// terminal edge. Matches the 4-cell left indent, so a line of424+const assistantBodyRightPad = 2
443-// fully-wrapped prose sits in a symmetric column.
444-const assistantBodyRightPad = 4
445 425
446 // assistantBodyWidth returns the usable width for the assistant426 // assistantBodyWidth returns the usable width for the assistant
447 // message body (markdown prose + code fences). Uses the full427 // message body (markdown prose + code fences). Uses the full
@@ -486,34 +466,56 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
486 466
487 switch m.Role {467 switch m.Role {
488 case provider.RoleUser:468 case provider.RoleUser:
489- header := v.Theme.AccentBar(v.Theme.User) + v.Theme.FG256(v.Theme.User, "you")469+ // User rows: no speaker label. Each line is painted with a
490- lines = append(lines, header)470+ // faint bubble background tint (UserBubble) so the user's
471+ // turns visually segment the chat without needing a header.
472+ // One tinted blank row above and below approximates CSS
473+ // padding-top / padding-bottom (terminals can't do fractional
474+ // rows, so a full row of bubble-coloured whitespace is the
475+ // closest analogue and gives the bubble visible breathing
476+ // room).
477+ // User rows: a 1-cell "▌" accent bar at column 0 painted on
478+ // the bubble bg, so the bar's cell shares the panel tint and
479+ // there is no visible gap between bar and panel. (Some terminals
480+ // — iTerm2, Apple Terminal — may smear the cell-0 bg into the
481+ // window inset to the left; that's a terminal-side rendering
482+ // quirk we accept in exchange for a clean bar-to-panel join.)
483+ const leftGutter = 0 // cells of bubble bg between bar and text
484+ const rightGutter = 2 // cells of bubble bg between text and right edge
485+ innerWidth := width - 2 - leftGutter - rightGutter // 2 = bar's two cells (▌ + trailing space)
486+ if innerWidth < 1 {
487+ innerWidth = 1
488+ }
489+ row := func(content string) string {
490+ inner := strings.Repeat(" ", leftGutter) + content
491+ padded := v.Theme.UserBubble(inner, width-2)
492+ bar := v.Theme.BG256(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
493+ return bar + padded
494+ }
495+ var bubble []string
491 for _, c := range m.Content {496 for _, c := range m.Content {
492 switch b := c.(type) {497 switch b := c.(type) {
493 case provider.TextBlock:498 case provider.TextBlock:
494 for _, l := range strings.Split(b.Text, "\n") {499 for _, l := range strings.Split(b.Text, "\n") {
495- for _, w := range wrapLine(l, width-4, "") {500+ for _, w := range wrapLine(l, innerWidth, "") {
496- lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))501+ bubble = append(bubble, row(w))
497 }502 }
498 }503 }
499 case provider.ImageBlock:504 case provider.ImageBlock:
500- lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))505+ bubble = append(bubble, row(fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))
501 }506 }
502 }507 }
508+ if len(bubble) > 0 {
509+ lines = append(lines, row(""))
510+ lines = append(lines, bubble...)
511+ lines = append(lines, row(""))
512+ }
503 case provider.RoleAssistant:513 case provider.RoleAssistant:
504- // Only draw the agent header at the start of a turn. Mid-turn514+ // Assistant rows: no speaker label either. Prose still gets a
505- // assistant messages (e.g. another tool_use round-trip after a515+ // small left indent so it visually aligns with tool box body
506- // tool result) reuse the header that's already on screen.516+ // content, but no "zot" header.
507- if !turnOpen {517+ _ = turnOpen
508- lines = append(lines, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot"))518+ const indent = " "
509- }
510- // Indent assistant body the same 4 cells the user body uses,
511- // so the conversation column lines up vertically. The width
512- // passed into the markdown renderer / wrap is reduced by the
513- // indent so long lines wrap inside the indented column, and
514- // capped so ultra-wide terminals don't produce edge-to-edge
515- // code-fence rules or unreadably long prose lines.
516- const indent = " "
517 inner := assistantBodyWidth(width - len(indent))519 inner := assistantBodyWidth(width - len(indent))
518 for _, c := range m.Content {520 for _, c := range m.Content {
519 switch b := c.(type) {521 switch b := c.(type) {
@@ -565,17 +567,18 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
565 if tr.IsError {567 if tr.IsError {
566 lines = append(lines, toolBoxSide(v.Theme, v.Theme.FG256(color, " error"), width))568 lines = append(lines, toolBoxSide(v.Theme, v.Theme.FG256(color, " error"), width))
567 }569 }
568- for _, body := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {570+ for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
569- // Image escapes paint into a graphics layer that571+ // Image-footprint rows (the escape row, the blank
570- // doesn't share the text grid; wrapping such a row572+ // reservation rows beneath it, and the gap row
571- // in │ … │ produces visible artefacts on iTerm /573+ // before the metadata caption) are tagged with the
572- // Kitty, so leave image rows un-bordered. The574+ // imageFootprintSentinel by renderImageBlock. Strip
573- // surrounding lines still close the box top + bottom.575+ // the tag, then wrap the row in the usual │ … │ box
574- if hasImageEscapeLine(body) {576+ // edges so the box frame stays continuous around
575- lines = append(lines, body)577+ // the image.
576- continue578+ if strings.HasPrefix(line, imageFootprintSentinel) {
579+ line = line[len(imageFootprintSentinel):]
577 }580 }
578- lines = append(lines, toolBoxSide(v.Theme, body, width))581+ lines = append(lines, toolBoxSide(v.Theme, line, width))
579 }582 }
580 lines = append(lines, toolBoxSide(v.Theme, "", width))583 lines = append(lines, toolBoxSide(v.Theme, "", width))
581 lines = append(lines, toolBoxBottom(v.Theme, width))584 lines = append(lines, toolBoxBottom(v.Theme, width))
@@ -643,9 +646,8 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
643 }646 }
644 body := toolResultBlock(v.Theme, tc.Result, width, color)647 body := toolResultBlock(v.Theme, tc.Result, width, color)
645 for _, l := range v.collapseToolBody(body, false) {648 for _, l := range v.collapseToolBody(body, false) {
646- if hasImageEscapeLine(l) {649+ if strings.HasPrefix(l, imageFootprintSentinel) {
647- lines = append(lines, l)650+ l = l[len(imageFootprintSentinel):]
648- continue
649 }651 }
650 lines = append(lines, toolBoxSide(v.Theme, l, width))652 lines = append(lines, toolBoxSide(v.Theme, l, width))
651 }653 }
@@ -696,9 +698,8 @@ func (v *View) wrapLiveBody(body []string, width int) []string {
696 body = v.collapseToolBody(body, false)698 body = v.collapseToolBody(body, false)
697 out := make([]string, 0, len(body))699 out := make([]string, 0, len(body))
698 for _, l := range body {700 for _, l := range body {
699- if hasImageEscapeLine(l) {701+ if strings.HasPrefix(l, imageFootprintSentinel) {
700- out = append(out, l)702+ l = l[len(imageFootprintSentinel):]
701- continue
702 }703 }
703 out = append(out, toolBoxSide(v.Theme, l, width))704 out = append(out, toolBoxSide(v.Theme, l, width))
704 }705 }
@@ -725,6 +726,13 @@ func toolBlockRule(th Theme, width int) string {
725 // the corner, and gives body lines a tiny gutter from the left side.726 // the corner, and gives body lines a tiny gutter from the left side.
726 const toolBoxInnerPad = 1727 const toolBoxInnerPad = 1
727 728
729+// toolBoxOuterMargin is the number of blank cells kept to the left of
730+// the opening corner and to the right of the closing corner. Aligns
731+// the box's frame with the column where user-bubble text begins, so
732+// the conversation reads as one column instead of having tool boxes
733+// running edge-to-edge while user/assistant rows sit indented.
734+const toolBoxOuterMargin = 2
735+
728 // toolBoxTop renders the labelled top edge of a tool block:736 // toolBoxTop renders the labelled top edge of a tool block:
729 //737 //
730 // ┌─ bash xcrun simctl list devices ─────────────────────────────┐738 // ┌─ bash xcrun simctl list devices ─────────────────────────────┐
@@ -734,10 +742,11 @@ const toolBoxInnerPad = 1
734 // of the line so the right corner sits at column width-1, matching the742 // of the line so the right corner sits at column width-1, matching the
735 // closing edge.743 // closing edge.
736 func toolBoxTop(th Theme, label string, width int) string {744 func toolBoxTop(th Theme, label string, width int) string {
737- w := width745+ w := width - 2*toolBoxOuterMargin
738 if w < 12 {746 if w < 12 {
739 w = 12747 w = 12
740 }748 }
749+ margin := strings.Repeat(" ", toolBoxOuterMargin)
741 innerPad := strings.Repeat(" ", toolBoxInnerPad)750 innerPad := strings.Repeat(" ", toolBoxInnerPad)
742 // "┌─" + innerPad + " " + label + " " + innerPad = used; pad751 // "┌─" + innerPad + " " + label + " " + innerPad = used; pad
743 // with ─ to width-1, then "┐".752 // with ─ to width-1, then "┐".
@@ -763,7 +772,7 @@ func toolBoxTop(th Theme, label string, width int) string {
763 }772 }
764 }773 }
765 line := prefix + label + suffix + strings.Repeat("─", fill) + "┐"774 line := prefix + label + suffix + strings.Repeat("─", fill) + "┐"
766- return th.FG256(th.Muted, line)775+ return margin + th.FG256(th.Muted, line) + margin
767 }776 }
768 777
769 // toolBoxBottom renders the bottom edge of a tool block:778 // toolBoxBottom renders the bottom edge of a tool block:
@@ -772,12 +781,13 @@ func toolBoxTop(th Theme, label string, width int) string {
772 //781 //
773 // Spans the same width as toolBoxTop so the corners line up.782 // Spans the same width as toolBoxTop so the corners line up.
774 func toolBoxBottom(th Theme, width int) string {783 func toolBoxBottom(th Theme, width int) string {
775- w := width784+ w := width - 2*toolBoxOuterMargin
776 if w < 12 {785 if w < 12 {
777 w = 12786 w = 12
778 }787 }
788+ margin := strings.Repeat(" ", toolBoxOuterMargin)
779 line := "└" + strings.Repeat("─", w-2) + "┘"789 line := "└" + strings.Repeat("─", w-2) + "┘"
780- return th.FG256(th.Muted, line)790+ return margin + th.FG256(th.Muted, line) + margin
781 }791 }
782 792
783 // hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or793 // hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or
@@ -788,6 +798,16 @@ func hasImageEscapeLine(s string) bool {
788 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")798 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
789 }799 }
790 800
801+// imageFootprintSentinel marks rows that belong to an inline-image's
802+// reserved footprint — the escape row plus the blank rows below it
803+// plus the gap row before the metadata caption. Any consumer that
804+// wraps content in box edges (│ … │) detects the sentinel, strips
805+// it, and emits the row un-bordered so the box's vertical edges
806+// don't show through next to the image's graphics rectangle. Uses a
807+// non-printing C0 control byte so it can never appear in normal
808+// text or in an ANSI escape sequence body.
809+const imageFootprintSentinel = "\x1e"
810+
791 // toolBoxBodyTrimLeft is the number of leading literal spaces stripped811 // toolBoxBodyTrimLeft is the number of leading literal spaces stripped
792 // from each body line as it enters toolBoxSide. Body renderers812 // from each body line as it enters toolBoxSide. Body renderers
793 // (renderToolText, renderRawFile, the diff/bash helpers, ...) all813 // (renderToolText, renderRawFile, the diff/bash helpers, ...) all
@@ -807,14 +827,26 @@ const toolBoxBodyTrimLeft = 2
807 // of inner padding sits on each side so the content breathes from the827 // of inner padding sits on each side so the content breathes from the
808 // edges; the right padding fills to column width-1.828 // edges; the right padding fills to column width-1.
809 func toolBoxSide(th Theme, line string, width int) string {829 func toolBoxSide(th Theme, line string, width int) string {
810- w := width830+ w := width - 2*toolBoxOuterMargin
811 if w < 12 {831 if w < 12 {
812 w = 12832 w = 12
813 }833 }
834+ margin := strings.Repeat(" ", toolBoxOuterMargin)
814 left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad)835 left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad)
815 right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│")836 right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│")
816 inner := w - 2 - 2*toolBoxInnerPad // available between the two pads837 inner := w - 2 - 2*toolBoxInnerPad // available between the two pads
817 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)838 line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
839+
840+ // Inline-image escapes (iTerm OSC 1337, Kitty APC G) carry
841+ // thousands of bytes of base64 payload that visibleWidth and
842+ // stripANSI don't recognise as ANSI. Measuring or truncating
843+ // such a row destroys the escape and the image disappears.
844+ // Pass the row through with edges + a fixed inner padding
845+ // based on the visible prefix only.
846+ if hasImageEscapeLine(line) {
847+ return margin + left + line + right + margin
848+ }
849+
818 cur := visibleWidth(line)850 cur := visibleWidth(line)
819 if cur > inner {851 if cur > inner {
820 // Last-resort truncation. Strips ANSI styling at the cut852 // Last-resort truncation. Strips ANSI styling at the cut
@@ -830,7 +862,7 @@ func toolBoxSide(th Theme, line string, width int) string {
830 if pad < 0 {862 if pad < 0 {
831 pad = 0863 pad = 0
832 }864 }
833- return left + line + strings.Repeat(" ", pad) + right865+ return margin + left + line + strings.Repeat(" ", pad) + right + margin
834 }866 }
835 867
836 // trimLeadingSpaces removes up to n literal space characters from the868 // trimLeadingSpaces removes up to n literal space characters from the
@@ -1154,12 +1186,21 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
1154 // below the reserved rectangle is more stable and easier to1186 // below the reserved rectangle is more stable and easier to
1155 // read. One extra blank row before the metadata gives the1187 // read. One extra blank row before the metadata gives the
1156 // caption breathing room from the image's last pixel row.1188 // caption breathing room from the image's last pixel row.
1189+ //
1190+ // Every footprint row (escape, reservations, gap) is tagged
1191+ // with imageFootprintSentinel so callers wrapping content in
1192+ // box edges can recognise these rows and emit them bare —
1193+ // the image's graphics rectangle paints over them and any
1194+ // │ character drawn alongside would visibly bleed through.
1195+ // 4 leading cells push the escape past the box's left
1196+ // edge plus a small interior gutter so the image rectangle
1197+ // sits visibly inside the frame instead of kissing the │.
1157 out := make([]string, 0, rows+3)1198 out := make([]string, 0, rows+3)
1158- out = append(out, " "+seq)1199+ out = append(out, imageFootprintSentinel+" "+seq)
1159 for i := 1; i < rows; i++ {1200 for i := 1; i < rows; i++ {
1160- out = append(out, "")1201+ out = append(out, imageFootprintSentinel)
1161 }1202 }
1162- out = append(out, "")1203+ out = append(out, imageFootprintSentinel)
1163 out = append(out, v.Theme.FG256(v.Theme.Muted, info))1204 out = append(out, v.Theme.FG256(v.Theme.Muted, info))
1164 return out1205 return out
1165 }1206 }
@@ -1589,6 +1630,9 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
1589 }1630 }
1590 md := RenderMarkdown(text, th, width-4)1631 md := RenderMarkdown(text, th, width-4)
1591 for _, l := range strings.Split(md, "\n") {1632 for _, l := range strings.Split(md, "\n") {
1633+ if len(l) > 0 && l[0] == FlushLeftSentinel {
1634+ l = l[1:]
1635+ }
1592 lines = append(lines, indent+l)1636 lines = append(lines, indent+l)
1593 }1637 }
1594 }1638 }

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