first commit
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
// Package ui holds the leaf terminal-UI helpers shared across the cmd/pigo and
|
||||
// internal/cli subpackages: ANSI color gating (color.go), turn-end Markdown
|
||||
// rendering (markdown.go), and prompt image-reference parsing (imageref.go).
|
||||
// These were moved verbatim from cmd/pigo (US-002, #358) and exported so the
|
||||
// repl, btw, status and goal layers style output through one owner.
|
||||
package ui
|
||||
|
||||
import "os"
|
||||
|
||||
// ANSI SGR escape sequences used by the REPL. This is a handful of codes, not a
|
||||
// general-purpose styling library.
|
||||
const (
|
||||
Reset = "\033[0m"
|
||||
Bold = "\033[1m"
|
||||
Dim = "\033[2m"
|
||||
Cyan = "\033[36m"
|
||||
Green = "\033[32m"
|
||||
Red = "\033[31m"
|
||||
Yellow = "\033[33m"
|
||||
)
|
||||
|
||||
// StdoutIsTerminal reports whether stdout is an interactive terminal (not a
|
||||
// pipe/file). It gates color output and is also used to decide print vs
|
||||
// interactive mode.
|
||||
func StdoutIsTerminal() bool {
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
// Enabled reports whether ANSI color should be emitted. Color is on only when
|
||||
// stdout is an interactive terminal and NO_COLOR is unset (mirrors the
|
||||
// https://no-color.org convention). This keeps piped/redirected output and CI
|
||||
// logs free of escape codes.
|
||||
func Enabled() bool {
|
||||
if _, ok := os.LookupEnv("NO_COLOR"); ok {
|
||||
return false
|
||||
}
|
||||
return StdoutIsTerminal()
|
||||
}
|
||||
|
||||
// Colorize wraps s in the given SGR code(s) and a reset when color is enabled,
|
||||
// and returns s unchanged otherwise. Callers decide the code; an empty code
|
||||
// returns s as-is so it is safe to call unconditionally.
|
||||
func Colorize(enabled bool, code, s string) string {
|
||||
if !enabled || code == "" {
|
||||
return s
|
||||
}
|
||||
return code + s + Reset
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package ui
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestColorizeGating verifies Colorize wraps text in SGR codes only when
|
||||
// enabled, returns text unchanged when disabled, and treats an empty code as a
|
||||
// no-op regardless of the enabled flag.
|
||||
func TestColorizeGating(t *testing.T) {
|
||||
if got := Colorize(true, Cyan, "/help"); got != Cyan+"/help"+Reset {
|
||||
t.Errorf("enabled: got %q", got)
|
||||
}
|
||||
if got := Colorize(false, Cyan, "/help"); got != "/help" {
|
||||
t.Errorf("disabled should be plain, got %q", got)
|
||||
}
|
||||
if got := Colorize(true, "", "/help"); got != "/help" {
|
||||
t.Errorf("empty code should be plain, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestColorEnabledRespectsNoColor verifies NO_COLOR forces color off even on a
|
||||
// terminal (mirrors https://no-color.org).
|
||||
func TestColorEnabledRespectsNoColor(t *testing.T) {
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
if Enabled() {
|
||||
t.Error("NO_COLOR set: Enabled must be false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// This file implements the local-image input syntax for prompts (US-010/#126).
|
||||
// A prompt may reference local image files with either `@image:<path>` or the
|
||||
// Markdown image form ``. Each reference is read from disk,
|
||||
// base64-encoded, and attached to the user message as an agentcore.ImageContent
|
||||
// block so a multimodal model can see it. The remaining (non-reference) text is
|
||||
// kept as a TextContent block. References that cannot be read are reported as an
|
||||
// error rather than silently dropped, so the user knows the image was not sent.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// imageRefPattern matches the two supported image-reference syntaxes:
|
||||
// - @image:<path> (path runs to the next whitespace)
|
||||
// -  (Markdown image; alt text is ignored)
|
||||
//
|
||||
// The path in the Markdown form may contain spaces; the @image form may not
|
||||
// (whitespace terminates it), matching the convention that @image is a bare
|
||||
// token while the Markdown form is explicitly delimited by parentheses.
|
||||
var imageRefPattern = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)|@image:(\S+)`)
|
||||
|
||||
// BuildUserContent turns a raw prompt line into a content list. When the prompt
|
||||
// contains no image references it returns a single TextContent (the common
|
||||
// case). Otherwise it returns the interleaved text and image blocks, reading and
|
||||
// base64-encoding each referenced file. It returns an error if any referenced
|
||||
// file cannot be read, so the caller can surface it instead of sending a prompt
|
||||
// that silently omits the image.
|
||||
func BuildUserContent(prompt string) (agentcore.ContentList, error) {
|
||||
locs := imageRefPattern.FindAllStringSubmatchIndex(prompt, -1)
|
||||
if len(locs) == 0 {
|
||||
return agentcore.ContentList{agentcore.NewTextContent(prompt)}, nil
|
||||
}
|
||||
|
||||
var content agentcore.ContentList
|
||||
// addText appends a text block for the given [lo,hi) slice of prompt,
|
||||
// trimming surrounding whitespace and skipping empties so we don't emit
|
||||
// blank text blocks around the references.
|
||||
addText := func(lo, hi int) {
|
||||
if lo >= hi {
|
||||
return
|
||||
}
|
||||
if t := strings.TrimSpace(prompt[lo:hi]); t != "" {
|
||||
content = append(content, agentcore.NewTextContent(t))
|
||||
}
|
||||
}
|
||||
|
||||
prev := 0
|
||||
for _, loc := range locs {
|
||||
start, end := loc[0], loc[1]
|
||||
addText(prev, start)
|
||||
// Group 1 is the Markdown path; group 2 is the @image path. Exactly one
|
||||
// is set per match.
|
||||
var path string
|
||||
if loc[2] >= 0 {
|
||||
path = prompt[loc[2]:loc[3]]
|
||||
} else if loc[4] >= 0 {
|
||||
path = prompt[loc[4]:loc[5]]
|
||||
}
|
||||
img, err := loadImageContent(strings.TrimSpace(path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content = append(content, img)
|
||||
prev = end
|
||||
}
|
||||
addText(prev, len(prompt))
|
||||
|
||||
if len(content) == 0 {
|
||||
content = agentcore.ContentList{agentcore.NewTextContent("")}
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// loadImageContent reads an image file and returns an ImageContent with the
|
||||
// data base64-encoded and the mime type sniffed from the file extension (with a
|
||||
// content-sniff fallback). It errors if the file cannot be read or is not a
|
||||
// recognizable image type.
|
||||
func loadImageContent(path string) (agentcore.ImageContent, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return agentcore.ImageContent{}, fmt.Errorf("read image %q: %w", path, err)
|
||||
}
|
||||
mime := mimeFromPath(path)
|
||||
if mime == "" {
|
||||
// Fall back to content sniffing when the extension is unknown.
|
||||
mime = http.DetectContentType(data)
|
||||
}
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
return agentcore.ImageContent{}, fmt.Errorf("%q is not a recognized image (detected %q)", path, mime)
|
||||
}
|
||||
enc := base64.StdEncoding.EncodeToString(data)
|
||||
return agentcore.NewImageContent(enc, mime), nil
|
||||
}
|
||||
|
||||
// mimeFromPath maps a file extension to an image mime type. It returns "" for
|
||||
// unknown extensions so the caller can fall back to content sniffing.
|
||||
func mimeFromPath(path string) string {
|
||||
lower := strings.ToLower(path)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".png"):
|
||||
return "image/png"
|
||||
case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"):
|
||||
return "image/jpeg"
|
||||
case strings.HasSuffix(lower, ".gif"):
|
||||
return "image/gif"
|
||||
case strings.HasSuffix(lower, ".webp"):
|
||||
return "image/webp"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Tests for the prompt image-reference syntax (US-010, #126): @image:<path> and
|
||||
// the Markdown  form. They write a tiny real PNG to a temp file so
|
||||
// loadImageContent exercises the real read + base64 + mime path, and assert that
|
||||
// a missing file is an error (not a silently dropped image).
|
||||
package ui
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// pngBytes is a minimal 1x1 PNG (valid signature + IHDR) so mime detection and
|
||||
// the image/ prefix check pass without pulling in an image library.
|
||||
var pngBytes = []byte{
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
|
||||
0x89,
|
||||
}
|
||||
|
||||
func writeTempPNG(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "pixel.png")
|
||||
if err := os.WriteFile(path, pngBytes, 0o644); err != nil {
|
||||
t.Fatalf("write temp png: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// TestBuildUserContentNoImage: a plain prompt yields a single text block.
|
||||
func TestBuildUserContentNoImage(t *testing.T) {
|
||||
content, err := BuildUserContent("just some text")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildUserContent: %v", err)
|
||||
}
|
||||
if len(content) != 1 {
|
||||
t.Fatalf("content len = %d, want 1", len(content))
|
||||
}
|
||||
if tc, ok := content[0].(agentcore.TextContent); !ok || tc.Text != "just some text" {
|
||||
t.Errorf("content[0] = %#v, want text \"just some text\"", content[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildUserContentAtImageSyntax: @image:<path> attaches an ImageContent and
|
||||
// keeps the surrounding text.
|
||||
func TestBuildUserContentAtImageSyntax(t *testing.T) {
|
||||
path := writeTempPNG(t)
|
||||
content, err := BuildUserContent("look at @image:" + path + " please")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildUserContent: %v", err)
|
||||
}
|
||||
assertTextThenImage(t, content, "look at", "image/png")
|
||||
}
|
||||
|
||||
// TestBuildUserContentMarkdownSyntax:  attaches an ImageContent.
|
||||
func TestBuildUserContentMarkdownSyntax(t *testing.T) {
|
||||
path := writeTempPNG(t)
|
||||
content, err := BuildUserContent("before  after")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildUserContent: %v", err)
|
||||
}
|
||||
// text "before", image, text "after"
|
||||
if len(content) != 3 {
|
||||
t.Fatalf("content len = %d, want 3", len(content))
|
||||
}
|
||||
if _, ok := content[1].(agentcore.ImageContent); !ok {
|
||||
t.Errorf("content[1] = %T, want ImageContent", content[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildUserContentMissingFile: a missing referenced file is an error.
|
||||
func TestBuildUserContentMissingFile(t *testing.T) {
|
||||
_, err := BuildUserContent("@image:/no/such/file.png")
|
||||
if err == nil {
|
||||
t.Fatal("BuildUserContent accepted a missing image file, want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadImageContentBase64: the encoded data must match the file bytes.
|
||||
func TestLoadImageContentBase64(t *testing.T) {
|
||||
path := writeTempPNG(t)
|
||||
img, err := loadImageContent(path)
|
||||
if err != nil {
|
||||
t.Fatalf("loadImageContent: %v", err)
|
||||
}
|
||||
if img.MimeType != "image/png" {
|
||||
t.Errorf("mime = %q, want image/png", img.MimeType)
|
||||
}
|
||||
if want := base64.StdEncoding.EncodeToString(pngBytes); img.Data != want {
|
||||
t.Errorf("data mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func assertTextThenImage(t *testing.T, content agentcore.ContentList, wantText, wantMime string) {
|
||||
t.Helper()
|
||||
var sawText, sawImage bool
|
||||
for _, c := range content {
|
||||
switch b := c.(type) {
|
||||
case agentcore.TextContent:
|
||||
if strings.Contains(b.Text, wantText) {
|
||||
sawText = true
|
||||
}
|
||||
case agentcore.ImageContent:
|
||||
if b.MimeType == wantMime {
|
||||
sawImage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawText {
|
||||
t.Errorf("no text block containing %q in %#v", wantText, content)
|
||||
}
|
||||
if !sawImage {
|
||||
t.Errorf("no image block with mime %q in %#v", wantMime, content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// This file renders assistant replies as Markdown for interactive terminals.
|
||||
// The line-oriented REPL streams model text token-by-token, but Markdown can
|
||||
// only be laid out once the whole block is known (a table or fenced code span
|
||||
// needs its full extent). So rendering is a turn-end concern: the caller buffers
|
||||
// the streamed text and calls RenderMarkdown once the assistant turn closes.
|
||||
//
|
||||
// Rendering is gated exactly like color (Enabled): only an interactive,
|
||||
// NO_COLOR-unset stdout gets styled output. Pipes, files, CI, and tests receive
|
||||
// the raw Markdown source unchanged, so machine consumers and golden tests are
|
||||
// unaffected. Any renderer failure also falls back to the raw source — pretty
|
||||
// output is never allowed to lose content.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
)
|
||||
|
||||
// mdRenderer is the lazily-built glamour renderer. Building it parses a style
|
||||
// and compiles a chroma lexer set, so it is created once and reused across
|
||||
// turns. A build failure leaves it nil, degrading to raw output.
|
||||
var (
|
||||
mdOnce sync.Once
|
||||
mdRenderer *glamour.TermRenderer
|
||||
)
|
||||
|
||||
// initMarkdown builds the shared renderer on first use. It uses glamour's
|
||||
// auto style, which follows the terminal's dark/light background.
|
||||
//
|
||||
// WithWordWrap(0) disables glamour's hard word-wrap. That matters: with a fixed
|
||||
// wrap width glamour pads every line with trailing-space background cells out to
|
||||
// the full column count, so a three-line reply balloons into kilobytes of ANSI
|
||||
// noise (measured: ~8KB for a short block at width 100 vs. ~0.5KB unwrapped).
|
||||
// Disabling the wrap lets the terminal soft-wrap long lines itself and keeps the
|
||||
// rendered output tight — the REPL doesn't track terminal size anyway.
|
||||
func initMarkdown() {
|
||||
mdOnce.Do(func() {
|
||||
r, err := glamour.NewTermRenderer(
|
||||
glamour.WithAutoStyle(),
|
||||
glamour.WithWordWrap(0),
|
||||
)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mdRenderer = r
|
||||
})
|
||||
}
|
||||
|
||||
// RenderMarkdown returns src rendered as styled terminal Markdown when output
|
||||
// is an interactive terminal, and src unchanged otherwise. A nil/broken
|
||||
// renderer or a render error also returns src, so content is never dropped in
|
||||
// favor of styling. The returned string carries its own trailing newline from
|
||||
// glamour; callers should not add another.
|
||||
func RenderMarkdown(src string) string {
|
||||
if !Enabled() {
|
||||
return src
|
||||
}
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return src
|
||||
}
|
||||
initMarkdown()
|
||||
if mdRenderer == nil {
|
||||
return src
|
||||
}
|
||||
out, err := mdRenderer.Render(src)
|
||||
if err != nil {
|
||||
return src
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package ui
|
||||
|
||||
import "testing"
|
||||
|
||||
// In tests stdout is not a terminal, so Enabled() is false and RenderMarkdown
|
||||
// must return the source verbatim — this is the contract that keeps piped
|
||||
// output, CI logs, and golden tests free of ANSI escapes.
|
||||
func TestRenderMarkdownRawWhenNotTerminal(t *testing.T) {
|
||||
src := "# Heading\n\nSome **bold** text.\n"
|
||||
if got := RenderMarkdown(src); got != src {
|
||||
t.Fatalf("RenderMarkdown on non-terminal = %q, want raw source unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty (or whitespace-only) reply must pass through untouched so the caller
|
||||
// never prints a stray rendered blank block.
|
||||
func TestRenderMarkdownEmptyPassthrough(t *testing.T) {
|
||||
for _, src := range []string{"", " ", "\n\t\n"} {
|
||||
if got := RenderMarkdown(src); got != src {
|
||||
t.Fatalf("RenderMarkdown(%q) = %q, want unchanged", src, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// This file holds the compact tool-activity renderers shared by the REPL, the
|
||||
// /goal autonomous loop, and /btw side threads: a tool call is shown as a green
|
||||
// "→ tool:" line and a tool result as a green "← result:" (or red "← error:")
|
||||
// line, with multi-line output collapsed to one line. The todo tool is the one
|
||||
// exception — its result is printed in full so the live checklist stays visible.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// RenderToolResult prints a tool result to out: the todo tool's result is shown
|
||||
// in full (indented) so the live checklist stays visible; every other result is
|
||||
// collapsed to a single "← result:"/"← error:" line.
|
||||
func RenderToolResult(out io.Writer, tr agentcore.ToolResultMessage) {
|
||||
text := agentcore.ContentToText(tr.Content)
|
||||
color := Enabled()
|
||||
if tr.ToolName == "todo" && !tr.IsError {
|
||||
fmt.Fprintln(out, " "+Colorize(color, Green, "← todo:"))
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
fmt.Fprintf(out, " %s\n", line)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tr.IsError {
|
||||
fmt.Fprintf(out, " %s %s\n", Colorize(color, Red, "← error:"), OneLine(text))
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " %s %s\n", Colorize(color, Green, "← result:"), OneLine(text))
|
||||
}
|
||||
|
||||
// ToolCallLabel renders a tool call as "name args" for the compact "→ tool:"
|
||||
// status. Empty or "{}" arguments collapse to just the name.
|
||||
func ToolCallLabel(c agentcore.ToolCallContent) string {
|
||||
args := strings.TrimSpace(string(c.Arguments))
|
||||
if args == "" || args == "{}" {
|
||||
return c.Name
|
||||
}
|
||||
return c.Name + " " + OneLine(args)
|
||||
}
|
||||
|
||||
// OneLine collapses a possibly multi-line string into a single trimmed line,
|
||||
// truncating very long values, for the compact tool-activity statuses.
|
||||
func OneLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i] + " …"
|
||||
}
|
||||
const max = 120
|
||||
if len(s) > max {
|
||||
s = s[:max] + " …"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ui
|
||||
|
||||
import "charm.land/lipgloss/v2"
|
||||
|
||||
// Width reports the number of terminal cells the rendered string s occupies. It
|
||||
// delegates to lipgloss v2's width measurement, which strips ANSI escape
|
||||
// sequences and counts East Asian wide / fullwidth runes (CJK, emoji) as two
|
||||
// columns. This is the single width primitive the TUI and REPL layers style
|
||||
// through (per the tui-agent SPEC), so alignment stays consistent across the
|
||||
// codebase instead of each caller hand-rolling its own East-Asian-width table.
|
||||
//
|
||||
// For multi-line input, Width returns the width of the widest line (lipgloss
|
||||
// measures the bounding box), matching how the renderer lays text out.
|
||||
func Width(s string) int {
|
||||
return lipgloss.Width(s)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ui
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestWidthASCII pins that plain ASCII counts one cell per rune.
|
||||
func TestWidthASCII(t *testing.T) {
|
||||
if got := Width("hello"); got != 5 {
|
||||
t.Errorf("Width(\"hello\") = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWidthCJK pins that East Asian wide runes count as two cells, which is the
|
||||
// whole reason we route width through lipgloss instead of len/RuneCount.
|
||||
func TestWidthCJK(t *testing.T) {
|
||||
// Three CJK ideographs = 6 cells.
|
||||
if got := Width("达克克"); got != 6 {
|
||||
t.Errorf("Width(CJK x3) = %d, want 6", got)
|
||||
}
|
||||
// Mixed: "a达" = 1 + 2 = 3 cells.
|
||||
if got := Width("a达"); got != 3 {
|
||||
t.Errorf("Width(\"a达\") = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWidthStripsANSI pins that SGR escape sequences do not add to the width, so
|
||||
// a colored string aligns the same as its plain form.
|
||||
func TestWidthStripsANSI(t *testing.T) {
|
||||
plain := "error"
|
||||
colored := Cyan + plain + Reset
|
||||
if got, want := Width(colored), Width(plain); got != want {
|
||||
t.Errorf("Width(colored) = %d, want %d (ANSI must not count)", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user