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>
140 lines
2.8 KiB
Go
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 ""
|
|
}
|