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".
This commit is contained in:
patriceckhart 2026-05-03 10:18:48 +02:00
parent a07e43dfd7
commit 42173ed45d
9 changed files with 405 additions and 118 deletions

View file

@ -637,7 +637,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
iv = modes.NewInteractive(modes.InteractiveConfig{
Terminal: term,
Theme: tui.Dark,
Theme: tui.DetectThemeFromBackground(80 * time.Millisecond),
Model: r.Model,
Provider: r.Provider,
AuthMethod: r.AuthMethod,

View file

@ -300,15 +300,14 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
for _, t := range d.turns {
out = append(out, "")
out = append(out, " "+th.AccentBar(th.User)+th.FG256(th.User, "you"))
for _, line := range strings.Split(t.User, "\n") {
out = append(out, " "+th.FG256(th.Muted, line))
}
out = append(out, btwUserBubbleRows(th, t.User, width-2)...)
if t.Assistant != "" {
out = append(out, "")
out = append(out, " "+th.AccentBar(th.Assistant)+th.FG256(th.Assistant, "zot"))
md := tui.RenderMarkdown(t.Assistant, th, width-4)
for _, line := range strings.Split(md, "\n") {
if len(line) > 0 && line[0] == tui.FlushLeftSentinel {
line = line[1:]
}
out = append(out, " "+line)
}
}
@ -339,6 +338,7 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
// marker, so just two cells of pad.
out = append(out, " "+l)
}
out = append(out, "") // breathing room between editor and frame rule
}
out = append(out, frameRuleColor(th, width, th.Accent))
return out
@ -362,11 +362,9 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
}
for _, t := range d.turns {
editorOffset++ // blank
editorOffset++ // "you" header
editorOffset += len(strings.Split(t.User, "\n"))
editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2))
if t.Assistant != "" {
editorOffset++ // blank
editorOffset++ // "zot" header
editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))
}
if t.Err != "" {
@ -382,6 +380,39 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
return editorOffset + eRow, eCol + 2 /* matches render indent */
}
// btwUserBubbleRows renders a user message inside the /btw dialog
// using the same bubble layout the main chat uses (full-width tinted
// panel, left-edge ▌ bar, padding rows above and below). The frame
// padding is the caller's job; bubbleWidth is the available row
// width inside the frame.
func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string {
const leftGutter = 0
const rightGutter = 2
innerWidth := bubbleWidth - 2 - leftGutter - rightGutter // 2 = bar's two cells
if innerWidth < 1 {
innerWidth = 1
}
bar := th.BG256(th.UserBubbleBG, th.FG256(th.Accent, "▌ "))
row := func(content string) string {
inner := strings.Repeat(" ", leftGutter) + content
return " " + bar + th.UserBubble(inner, bubbleWidth-2)
}
var bubble []string
for _, l := range strings.Split(text, "\n") {
for _, w := range tui.WrapANSILine(l, innerWidth) {
bubble = append(bubble, row(w))
}
}
if len(bubble) == 0 {
return nil
}
out := make([]string, 0, len(bubble)+2)
out = append(out, row(""))
out = append(out, bubble...)
out = append(out, row(""))
return out
}
// errMessage is a tiny helper for the future when we want to surface
// retryable failures in a styled way.
func errMessage(err error) string {

View file

@ -93,6 +93,9 @@ func (d *changelogDialog) Render(th tui.Theme, width int) []string {
// Regular line: render through markdown for bullet points etc.
rendered := tui.RenderMarkdown(l, th, width-4)
for _, rl := range strings.Split(rendered, "\n") {
if len(rl) > 0 && rl[0] == tui.FlushLeftSentinel {
rl = rl[1:]
}
bodyLines = append(bodyLines, rl)
}
}

View file

@ -137,6 +137,11 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
rendered := tui.RenderMarkdown(s.Body, th, width-4)
bodyLines := strings.Split(rendered, "\n")
for i, l := range bodyLines {
if len(l) > 0 && l[0] == tui.FlushLeftSentinel {
bodyLines[i] = l[1:]
}
}
const maxRows = 16
if d.scroll > len(bodyLines)-1 {

View file

@ -38,7 +38,7 @@ var funnyWorkingLines = []string{
"picking a fight with syntax",
"reading between the bits",
"tasting the semicolons",
"pretending to understand go generics",
"pretending to understand the code",
"petting the cache",
"drafting clever replies",
"warming up the GPU choir",

161
internal/tui/detect_bg.go Normal file
View file

@ -0,0 +1,161 @@
package tui
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"golang.org/x/term"
)
// DetectThemeFromBackground queries the controlling tty for its
// current background colour using the OSC 11 escape sequence and
// returns Dark or Light based on the response's perceived
// luminance. Falls back to Dark when the terminal does not
// reply within timeout, which is the expected behaviour for
// terminals that do not implement OSC 11 (Linux console, VS Code's
// integrated terminal in some configurations, tmux without
// pass-through, very old emulators).
//
// The query / parse runs synchronously before the TUI is
// initialised so the returned theme can drive the entire session.
// We briefly put stdin into raw mode and disable echo so the OSC
// reply doesn't leak onto the user's screen as visible bytes.
func DetectThemeFromBackground(timeout time.Duration) Theme {
// Honour explicit override env var first; some users / CI envs
// know better than the heuristic.
switch strings.ToLower(strings.TrimSpace(os.Getenv("ZOT_THEME"))) {
case "dark":
return Dark
case "light":
return Light
}
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
return Dark
}
fd := int(os.Stdin.Fd())
st, err := term.MakeRaw(fd)
if err != nil {
return Dark
}
defer term.Restore(fd, st)
// Send the query. ST (\x1b\\) and BEL (\x07) are both accepted
// terminators; some terminals only honour one of them, so we
// send BEL which is more widely supported.
if _, err := os.Stdout.Write([]byte("\x1b]11;?\x07")); err != nil {
return Dark
}
deadline := time.Now().Add(timeout)
resp := readOSCResponse(deadline)
if resp == "" {
return Dark
}
r, g, b, ok := parseOSC11Reply(resp)
if !ok {
return Dark
}
// Rec. 709 luma. Threshold at 0.5: anything brighter than
// mid-grey gets the light theme.
luma := 0.2126*float64(r) + 0.7152*float64(g) + 0.0722*float64(b)
if luma >= 0.5 {
return Light
}
return Dark
}
// readOSCResponse drains stdin into a small buffer until either a
// terminator (BEL or ST) is seen, the deadline expires, or stdin
// hits EOF. Returns whatever was collected, or "" on no usable
// response.
func readOSCResponse(deadline time.Time) string {
var buf [128]byte
n := 0
for time.Now().Before(deadline) {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
b, ok, err := peekStdin(os.Stdin, remaining)
if err != nil || !ok {
return string(buf[:n])
}
if n < len(buf) {
buf[n] = b
n++
}
// BEL terminator
if b == 0x07 {
return string(buf[:n])
}
// ST terminator: ESC then '\\'. We saw it the moment the
// previous byte was ESC and the current byte is '\\'.
if n >= 2 && buf[n-2] == 0x1b && buf[n-1] == '\\' {
return string(buf[:n])
}
}
return string(buf[:n])
}
// parseOSC11Reply extracts the (r, g, b) colour components from an
// OSC 11 reply of the form "\x1b]11;rgb:RRRR/GGGG/BBBB\x07" (or with
// ST terminator). The component widths can be 1, 2, 3, or 4 hex
// digits per channel; we normalise them into the 0..1 range.
func parseOSC11Reply(s string) (float64, float64, float64, bool) {
// Locate "rgb:" within the response.
i := strings.Index(s, "rgb:")
if i < 0 {
return 0, 0, 0, false
}
body := s[i+len("rgb:"):]
// Trim trailing terminator(s).
body = strings.TrimRight(body, "\x07")
if strings.HasSuffix(body, "\x1b\\") {
body = strings.TrimSuffix(body, "\x1b\\")
}
parts := strings.Split(body, "/")
if len(parts) != 3 {
return 0, 0, 0, false
}
parse := func(hexstr string) (float64, bool) {
if len(hexstr) == 0 || len(hexstr) > 4 {
return 0, false
}
v, err := strconv.ParseUint(hexstr, 16, 32)
if err != nil {
return 0, false
}
// Normalise to 0..1: max value is (16^len - 1).
max := uint64(1)
for j := 0; j < len(hexstr); j++ {
max *= 16
}
max--
if max == 0 {
return 0, false
}
return float64(v) / float64(max), true
}
r, ok1 := parse(parts[0])
g, ok2 := parse(parts[1])
b, ok3 := parse(parts[2])
if !ok1 || !ok2 || !ok3 {
return 0, 0, 0, false
}
return r, g, b, true
}
// debugDetect is used only in development to help diagnose
// detection issues; never invoked in production code paths.
func debugDetect() {
fmt.Fprintf(os.Stderr, "zot theme detection: stdin tty=%v stdout tty=%v\n",
term.IsTerminal(int(os.Stdin.Fd())),
term.IsTerminal(int(os.Stdout.Fd())))
}

View file

@ -5,6 +5,13 @@ import (
"strings"
)
// FlushLeftSentinel was used previously to opt fenced code blocks
// out of the prose indent. The current rendering keeps fences
// aligned with surrounding prose, so the sentinel is no longer
// emitted; the constant is kept (and exported) so any older
// caller that still strips it remains a harmless no-op.
const FlushLeftSentinel = '\x1c'
// RenderMarkdown renders a small subset of Markdown to styled terminal
// text using theme colors. Supported: headings, bold, italic, inline
// code, fenced code blocks, bullet lists, numbered lists, blockquotes.

View file

@ -8,48 +8,54 @@ import "strings"
// ANSI 256-color palette used by zot. Defined as numeric codes so we
// can swap themes without changing any render code.
type Theme struct {
FG int
Muted int
Accent int
User int // label color for the user role
Assistant int // label color for the zot role
Tool int
ToolOut int
Error int
Warning int
Spinner int // spinner + funny working line
SelectionBG int // background for highlighted rows
SelectionFG int // foreground for highlighted rows
FG int
Muted int
Accent int
User int // label color for the user role
UserBubbleBG int // background tint behind user message rows
UserBubbleFG int // foreground colour for user message rows
Assistant int // label color for the zot role
Tool int
ToolOut int
Error int
Warning int
Spinner int // spinner + funny working line
SelectionBG int // background for highlighted rows
SelectionFG int // foreground for highlighted rows
}
var Dark = Theme{
FG: 253,
Muted: 244,
Accent: 111, // soft blue
User: 180, // warm tan
Assistant: 117, // bright cyan — the zot label color
Tool: 114, // green
ToolOut: 245,
Error: 203,
Warning: 214,
Spinner: 183, // soft purple
SelectionBG: 24, // deep blue background
SelectionFG: 231, // near-white foreground
FG: 253,
Muted: 244,
Accent: 111, // soft blue
User: 180, // warm tan (unused now that the speaker label is gone, kept for skin compat)
UserBubbleBG: 237, // soft mid-dark grey panel behind user rows
UserBubbleFG: 246, // matches Theme.Muted (status bar text colour)
Assistant: 117, // bright cyan — the zot label color
Tool: 114, // green
ToolOut: 245,
Error: 203,
Warning: 214,
Spinner: 183, // soft purple
SelectionBG: 24, // deep blue background
SelectionFG: 231, // near-white foreground
}
var Light = Theme{
FG: 236,
Muted: 244,
Accent: 33,
User: 94,
Assistant: 31, // deep cyan
Tool: 28,
ToolOut: 240,
Error: 160,
Warning: 166,
Spinner: 91, // purple
SelectionBG: 153, // light blue
SelectionFG: 232, // near-black
FG: 236,
Muted: 244,
Accent: 33,
User: 94,
UserBubbleBG: 254, // very pale grey panel behind user rows on light theme
UserBubbleFG: 240, // dark grey text, legible on the pale panel
Assistant: 31, // deep cyan
Tool: 28,
ToolOut: 240,
Error: 160,
Warning: 166,
Spinner: 91, // purple
SelectionBG: 153, // light blue
SelectionFG: 232, // near-black
}
// 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 {
return sgrFG(t.SelectionFG) + sgrBG(t.SelectionBG) + s + reset
}
// UserBubble paints a single user message row with the bubble
// background colour, padding to width so the tint extends to the
// full terminal width. Foreground stays in UserBubbleFG so text
// remains legible against the tint.
func (t Theme) UserBubble(s string, width int) string {
visible := visibleWidth(s)
if visible < width {
s += strings.Repeat(" ", width-visible)
}
return sgrFG(t.UserBubbleFG) + sgrBG(t.UserBubbleBG) + s + reset
}
// UserBubbleRow renders one user-bubble row prefixed with a coloured
// half-block accent bar ("▌ ") so every line of the bubble has the
// zot-blue gutter at the very left. The bar lives outside the bubble
// tint (chat bg) so the bubble itself sits inside it. Width is the
// outer width including the bar; the bubble content is padded to
// width-2 (the bar + its trailing space).
func (t Theme) UserBubbleRow(content string, width int) string {
// Bar plus a single space gutter, in the assistant accent colour
// so it matches the tool-box / app accent and reads as zot's voice
// marker. Two cells wide.
bar := t.FG256(t.Assistant, "▌ ")
bubbleW := width - 2
if bubbleW < 1 {
bubbleW = 1
}
return bar + t.UserBubble(content, bubbleW)
}
// Bold wraps s in bold SGR.
func Bold(s string) string { return "\x1b[1m" + s + "\x1b[22m" }

View file

@ -250,34 +250,16 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
// overlay below is the real content and a naked "zot" bar
// above it reads as a stray empty message.
if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
// Suppress the streaming "▍ zot" header if the turn is
// already open (a previous assistant/tool message in this
// turn already rendered one). Otherwise the streaming text
// would jump from headerless to under a new header once it
// finalises into a transcript message.
turnOpen := false
for j := len(v.Messages) - 1; j >= 0; j-- {
prev := v.Messages[j]
if prev.Meta["compaction"] == "true" {
continue
}
if prev.Role == provider.RoleAssistant || prev.Role == provider.RoleTool {
turnOpen = true
}
break
}
if !turnOpen {
out = append(out, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot"))
}
// Stream the partial assistant text through the same markdown
// renderer used for finalised messages so code fences, diffs,
// lists, and inline styles look the same while streaming and
// don't suddenly reflow when the turn ends. Indent matches the
// finalised assistant body in renderMessage so the column
// stays consistent across the stream/finalise transition.
// Width is capped so ultra-wide terminals don't produce
// edge-to-edge rules / unreadably long prose lines.
const indent = " "
// don't suddenly reflow when the turn ends. No speaker header
// is drawn; the indent matches the finalised assistant body in
// renderMessage so the column stays consistent across the
// stream/finalise transition. Width is capped so ultra-wide
// terminals don't produce edge-to-edge code-fence rules or
// unreadably long prose lines.
const indent = " "
inner := assistantBodyWidth(width - len(indent))
md := RenderMarkdown(v.Streaming, v.Theme, inner)
for _, l := range strings.Split(md, "\n") {
@ -438,10 +420,8 @@ const (
)
// assistantBodyRightPad is the blank gutter kept on the right
// side of every assistant prose line so text doesn't kiss the
// terminal edge. Matches the 4-cell left indent, so a line of
// fully-wrapped prose sits in a symmetric column.
const assistantBodyRightPad = 4
// side of every assistant prose line.
const assistantBodyRightPad = 2
// assistantBodyWidth returns the usable width for the assistant
// 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
switch m.Role {
case provider.RoleUser:
header := v.Theme.AccentBar(v.Theme.User) + v.Theme.FG256(v.Theme.User, "you")
lines = append(lines, header)
// User rows: no speaker label. Each line is painted with a
// faint bubble background tint (UserBubble) so the user's
// turns visually segment the chat without needing a header.
// One tinted blank row above and below approximates CSS
// padding-top / padding-bottom (terminals can't do fractional
// rows, so a full row of bubble-coloured whitespace is the
// closest analogue and gives the bubble visible breathing
// room).
// User rows: a 1-cell "▌" accent bar at column 0 painted on
// the bubble bg, so the bar's cell shares the panel tint and
// there is no visible gap between bar and panel. (Some terminals
// — iTerm2, Apple Terminal — may smear the cell-0 bg into the
// window inset to the left; that's a terminal-side rendering
// quirk we accept in exchange for a clean bar-to-panel join.)
const leftGutter = 0 // cells of bubble bg between bar and text
const rightGutter = 2 // cells of bubble bg between text and right edge
innerWidth := width - 2 - leftGutter - rightGutter // 2 = bar's two cells (▌ + trailing space)
if innerWidth < 1 {
innerWidth = 1
}
row := func(content string) string {
inner := strings.Repeat(" ", leftGutter) + content
padded := v.Theme.UserBubble(inner, width-2)
bar := v.Theme.BG256(v.Theme.UserBubbleBG, v.Theme.FG256(v.Theme.Accent, "▌ "))
return bar + padded
}
var bubble []string
for _, c := range m.Content {
switch b := c.(type) {
case provider.TextBlock:
for _, l := range strings.Split(b.Text, "\n") {
for _, w := range wrapLine(l, width-4, "") {
lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, w))
for _, w := range wrapLine(l, innerWidth, "") {
bubble = append(bubble, row(w))
}
}
case provider.ImageBlock:
lines = append(lines, " "+v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))
bubble = append(bubble, row(fmt.Sprintf("[image %s, %d bytes]", b.MimeType, len(b.Data))))
}
}
case provider.RoleAssistant:
// Only draw the agent header at the start of a turn. Mid-turn
// assistant messages (e.g. another tool_use round-trip after a
// tool result) reuse the header that's already on screen.
if !turnOpen {
lines = append(lines, v.Theme.AccentBar(v.Theme.Assistant)+v.Theme.FG256(v.Theme.Assistant, "zot"))
if len(bubble) > 0 {
lines = append(lines, row(""))
lines = append(lines, bubble...)
lines = append(lines, row(""))
}
// Indent assistant body the same 4 cells the user body uses,
// so the conversation column lines up vertically. The width
// passed into the markdown renderer / wrap is reduced by the
// indent so long lines wrap inside the indented column, and
// capped so ultra-wide terminals don't produce edge-to-edge
// code-fence rules or unreadably long prose lines.
const indent = " "
case provider.RoleAssistant:
// Assistant rows: no speaker label either. Prose still gets a
// small left indent so it visually aligns with tool box body
// content, but no "zot" header.
_ = turnOpen
const indent = " "
inner := assistantBodyWidth(width - len(indent))
for _, c := range m.Content {
switch b := c.(type) {
@ -565,17 +567,18 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
if tr.IsError {
lines = append(lines, toolBoxSide(v.Theme, v.Theme.FG256(color, " error"), width))
}
for _, body := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
// Image escapes paint into a graphics layer that
// doesn't share the text grid; wrapping such a row
// in │ … │ produces visible artefacts on iTerm /
// Kitty, so leave image rows un-bordered. The
// surrounding lines still close the box top + bottom.
if hasImageEscapeLine(body) {
lines = append(lines, body)
continue
for _, line := range v.renderToolResultContent(tr.Content, width, color, path, startLine) {
// Image-footprint rows (the escape row, the blank
// reservation rows beneath it, and the gap row
// before the metadata caption) are tagged with the
// imageFootprintSentinel by renderImageBlock. Strip
// the tag, then wrap the row in the usual │ … │ box
// edges so the box frame stays continuous around
// the image.
if strings.HasPrefix(line, imageFootprintSentinel) {
line = line[len(imageFootprintSentinel):]
}
lines = append(lines, toolBoxSide(v.Theme, body, width))
lines = append(lines, toolBoxSide(v.Theme, line, width))
}
lines = append(lines, toolBoxSide(v.Theme, "", width))
lines = append(lines, toolBoxBottom(v.Theme, width))
@ -643,9 +646,8 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
}
body := toolResultBlock(v.Theme, tc.Result, width, color)
for _, l := range v.collapseToolBody(body, false) {
if hasImageEscapeLine(l) {
lines = append(lines, l)
continue
if strings.HasPrefix(l, imageFootprintSentinel) {
l = l[len(imageFootprintSentinel):]
}
lines = append(lines, toolBoxSide(v.Theme, l, width))
}
@ -696,9 +698,8 @@ func (v *View) wrapLiveBody(body []string, width int) []string {
body = v.collapseToolBody(body, false)
out := make([]string, 0, len(body))
for _, l := range body {
if hasImageEscapeLine(l) {
out = append(out, l)
continue
if strings.HasPrefix(l, imageFootprintSentinel) {
l = l[len(imageFootprintSentinel):]
}
out = append(out, toolBoxSide(v.Theme, l, width))
}
@ -725,6 +726,13 @@ func toolBlockRule(th Theme, width int) string {
// the corner, and gives body lines a tiny gutter from the left side.
const toolBoxInnerPad = 1
// toolBoxOuterMargin is the number of blank cells kept to the left of
// the opening corner and to the right of the closing corner. Aligns
// the box's frame with the column where user-bubble text begins, so
// the conversation reads as one column instead of having tool boxes
// running edge-to-edge while user/assistant rows sit indented.
const toolBoxOuterMargin = 2
// toolBoxTop renders the labelled top edge of a tool block:
//
// ┌─ bash xcrun simctl list devices ─────────────────────────────┐
@ -734,10 +742,11 @@ const toolBoxInnerPad = 1
// of the line so the right corner sits at column width-1, matching the
// closing edge.
func toolBoxTop(th Theme, label string, width int) string {
w := width
w := width - 2*toolBoxOuterMargin
if w < 12 {
w = 12
}
margin := strings.Repeat(" ", toolBoxOuterMargin)
innerPad := strings.Repeat(" ", toolBoxInnerPad)
// "┌─" + innerPad + " " + label + " " + innerPad = used; pad
// with ─ to width-1, then "┐".
@ -763,7 +772,7 @@ func toolBoxTop(th Theme, label string, width int) string {
}
}
line := prefix + label + suffix + strings.Repeat("─", fill) + "┐"
return th.FG256(th.Muted, line)
return margin + th.FG256(th.Muted, line) + margin
}
// toolBoxBottom renders the bottom edge of a tool block:
@ -772,12 +781,13 @@ func toolBoxTop(th Theme, label string, width int) string {
//
// Spans the same width as toolBoxTop so the corners line up.
func toolBoxBottom(th Theme, width int) string {
w := width
w := width - 2*toolBoxOuterMargin
if w < 12 {
w = 12
}
margin := strings.Repeat(" ", toolBoxOuterMargin)
line := "└" + strings.Repeat("─", w-2) + "┘"
return th.FG256(th.Muted, line)
return margin + th.FG256(th.Muted, line) + margin
}
// hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or
@ -788,6 +798,16 @@ func hasImageEscapeLine(s string) bool {
return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
}
// imageFootprintSentinel marks rows that belong to an inline-image's
// reserved footprint — the escape row plus the blank rows below it
// plus the gap row before the metadata caption. Any consumer that
// wraps content in box edges (│ … │) detects the sentinel, strips
// it, and emits the row un-bordered so the box's vertical edges
// don't show through next to the image's graphics rectangle. Uses a
// non-printing C0 control byte so it can never appear in normal
// text or in an ANSI escape sequence body.
const imageFootprintSentinel = "\x1e"
// toolBoxBodyTrimLeft is the number of leading literal spaces stripped
// from each body line as it enters toolBoxSide. Body renderers
// (renderToolText, renderRawFile, the diff/bash helpers, ...) all
@ -807,14 +827,26 @@ const toolBoxBodyTrimLeft = 2
// of inner padding sits on each side so the content breathes from the
// edges; the right padding fills to column width-1.
func toolBoxSide(th Theme, line string, width int) string {
w := width
w := width - 2*toolBoxOuterMargin
if w < 12 {
w = 12
}
margin := strings.Repeat(" ", toolBoxOuterMargin)
left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad)
right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│")
inner := w - 2 - 2*toolBoxInnerPad // available between the two pads
line = trimLeadingSpaces(line, toolBoxBodyTrimLeft)
// Inline-image escapes (iTerm OSC 1337, Kitty APC G) carry
// thousands of bytes of base64 payload that visibleWidth and
// stripANSI don't recognise as ANSI. Measuring or truncating
// such a row destroys the escape and the image disappears.
// Pass the row through with edges + a fixed inner padding
// based on the visible prefix only.
if hasImageEscapeLine(line) {
return margin + left + line + right + margin
}
cur := visibleWidth(line)
if cur > inner {
// Last-resort truncation. Strips ANSI styling at the cut
@ -830,7 +862,7 @@ func toolBoxSide(th Theme, line string, width int) string {
if pad < 0 {
pad = 0
}
return left + line + strings.Repeat(" ", pad) + right
return margin + left + line + strings.Repeat(" ", pad) + right + margin
}
// trimLeadingSpaces removes up to n literal space characters from the
@ -1154,12 +1186,21 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
// below the reserved rectangle is more stable and easier to
// read. One extra blank row before the metadata gives the
// caption breathing room from the image's last pixel row.
//
// Every footprint row (escape, reservations, gap) is tagged
// with imageFootprintSentinel so callers wrapping content in
// box edges can recognise these rows and emit them bare —
// the image's graphics rectangle paints over them and any
// │ character drawn alongside would visibly bleed through.
// 4 leading cells push the escape past the box's left
// edge plus a small interior gutter so the image rectangle
// sits visibly inside the frame instead of kissing the │.
out := make([]string, 0, rows+3)
out = append(out, " "+seq)
out = append(out, imageFootprintSentinel+" "+seq)
for i := 1; i < rows; i++ {
out = append(out, "")
out = append(out, imageFootprintSentinel)
}
out = append(out, "")
out = append(out, imageFootprintSentinel)
out = append(out, v.Theme.FG256(v.Theme.Muted, info))
return out
}
@ -1589,6 +1630,9 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string {
}
md := RenderMarkdown(text, th, width-4)
for _, l := range strings.Split(md, "\n") {
if len(l) > 0 && l[0] == FlushLeftSentinel {
l = l[1:]
}
lines = append(lines, indent+l)
}
}