mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
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:
parent
a07e43dfd7
commit
42173ed45d
9 changed files with 405 additions and 118 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
161
internal/tui/detect_bg.go
Normal 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())))
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue