go/pkg/cli/stream.go
Claude 228170d610
feat(cli): TUI components, Frame AppShell, command builders (#11-15)
Issues #11-13: WithAppName for variant binaries, NewPassthrough builder
for flag.FlagSet commands, RegisterCommands test coverage with resetGlobals
helper. Fix pre-existing daemon_test.go break.

Issue #14: Rich Table with box-drawing borders (Normal, Rounded, Heavy,
Double), per-column CellStyleFn, WithMaxWidth responsive truncation.
Tree renderer with box-drawing connectors and styled nodes. Parallel
TaskTracker with braille spinners, thread-safe concurrent updates, and
non-TTY fallback. Streaming text renderer with word-wrap and channel
pattern support.

Issue #15: Frame live compositional AppShell using HLCRF regions with
Model interface, Navigate/Back content swapping, alt-screen live mode,
graceful non-TTY fallback. Built-in region components: StatusLine,
KeyHints, Breadcrumb. Zero new dependencies — pure ANSI + x/term.

68 tests, all passing with -race.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:45:23 +00:00

140 lines
2.8 KiB
Go

package cli
import (
"fmt"
"io"
"os"
"strings"
"sync"
"unicode/utf8"
)
// StreamOption configures a Stream.
type StreamOption func(*Stream)
// WithWordWrap sets the word-wrap column width.
func WithWordWrap(cols int) StreamOption {
return func(s *Stream) { s.wrap = cols }
}
// WithStreamOutput sets the output writer (default: os.Stdout).
func WithStreamOutput(w io.Writer) StreamOption {
return func(s *Stream) { s.out = w }
}
// Stream renders growing text as tokens arrive, with optional word-wrap.
// Safe for concurrent writes from a single producer goroutine.
//
// stream := cli.NewStream(cli.WithWordWrap(80))
// go func() {
// for token := range tokens {
// stream.Write(token)
// }
// stream.Done()
// }()
// stream.Wait()
type Stream struct {
out io.Writer
wrap int
col int // current column position (visible characters)
done chan struct{}
mu sync.Mutex
}
// NewStream creates a streaming text renderer.
func NewStream(opts ...StreamOption) *Stream {
s := &Stream{
out: os.Stdout,
done: make(chan struct{}),
}
for _, opt := range opts {
opt(s)
}
return s
}
// Write appends text to the stream. Handles word-wrap if configured.
func (s *Stream) Write(text string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.wrap <= 0 {
fmt.Fprint(s.out, text)
// Track column across newlines for Done() trailing-newline logic.
if idx := strings.LastIndex(text, "\n"); idx >= 0 {
s.col = utf8.RuneCountInString(text[idx+1:])
} else {
s.col += utf8.RuneCountInString(text)
}
return
}
for _, r := range text {
if r == '\n' {
fmt.Fprintln(s.out)
s.col = 0
continue
}
if s.col >= s.wrap {
fmt.Fprintln(s.out)
s.col = 0
}
fmt.Fprint(s.out, string(r))
s.col++
}
}
// WriteFrom reads from r and streams all content until EOF.
func (s *Stream) WriteFrom(r io.Reader) error {
buf := make([]byte, 256)
for {
n, err := r.Read(buf)
if n > 0 {
s.Write(string(buf[:n]))
}
if err == io.EOF {
return nil
}
if err != nil {
return err
}
}
}
// Done signals that no more text will arrive.
func (s *Stream) Done() {
s.mu.Lock()
if s.col > 0 {
fmt.Fprintln(s.out) // ensure trailing newline
}
s.mu.Unlock()
close(s.done)
}
// Wait blocks until Done is called.
func (s *Stream) Wait() {
<-s.done
}
// Content returns the current column position (for testing).
func (s *Stream) Column() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.col
}
// Captured returns the stream output as a string when using a bytes.Buffer.
// Panics if the output writer is not a *strings.Builder or fmt.Stringer.
func (s *Stream) Captured() string {
s.mu.Lock()
defer s.mu.Unlock()
if sb, ok := s.out.(*strings.Builder); ok {
return sb.String()
}
if st, ok := s.out.(fmt.Stringer); ok {
return st.String()
}
return ""
}