diff --git a/internal/agent/cli.go b/internal/agent/cli.go index 9bb778a..d9a5d76 100644 --- a/internal/agent/cli.go +++ b/internal/agent/cli.go @@ -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, diff --git a/internal/agent/modes/btw_dialog.go b/internal/agent/modes/btw_dialog.go index ce2cef4..3d3ef50 100644 --- a/internal/agent/modes/btw_dialog.go +++ b/internal/agent/modes/btw_dialog.go @@ -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 { diff --git a/internal/agent/modes/changelog_dialog.go b/internal/agent/modes/changelog_dialog.go index d1f9f71..64bcbb9 100644 --- a/internal/agent/modes/changelog_dialog.go +++ b/internal/agent/modes/changelog_dialog.go @@ -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) } } diff --git a/internal/agent/modes/skills_dialog.go b/internal/agent/modes/skills_dialog.go index febe563..de2c856 100644 --- a/internal/agent/modes/skills_dialog.go +++ b/internal/agent/modes/skills_dialog.go @@ -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 { diff --git a/internal/agent/modes/spinner.go b/internal/agent/modes/spinner.go index 57b73aa..1a436b1 100644 --- a/internal/agent/modes/spinner.go +++ b/internal/agent/modes/spinner.go @@ -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", diff --git a/internal/tui/detect_bg.go b/internal/tui/detect_bg.go new file mode 100644 index 0000000..9de010b --- /dev/null +++ b/internal/tui/detect_bg.go @@ -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()))) +} diff --git a/internal/tui/markdown.go b/internal/tui/markdown.go index 75f7fb3..7471106 100644 --- a/internal/tui/markdown.go +++ b/internal/tui/markdown.go @@ -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. diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 5499ab9..cd31709 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -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" } diff --git a/internal/tui/view.go b/internal/tui/view.go index d43a203..a28db83 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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) } }