first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+52
View File
@@ -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
}
+27
View File
@@ -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")
}
}
+119
View File
@@ -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 `![alt](<path>)`. 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)
// - ![alt](<path>) (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 ""
}
}
+122
View File
@@ -0,0 +1,122 @@
// Tests for the prompt image-reference syntax (US-010, #126): @image:<path> and
// the Markdown ![alt](<path>) 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: ![alt](path) attaches an ImageContent.
func TestBuildUserContentMarkdownSyntax(t *testing.T) {
path := writeTempPNG(t)
content, err := BuildUserContent("before ![a pixel](" + path + ") 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)
}
}
+72
View File
@@ -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
}
+23
View File
@@ -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)
}
}
}
+58
View File
@@ -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
}
+16
View File
@@ -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)
}
+33
View File
@@ -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)
}
}