From 580250719a0e0eec8af4e8308771bdd71cbeb837 Mon Sep 17 00:00:00 2001 From: nwebbot Date: Thu, 6 Aug 2026 23:41:40 +1000 Subject: [PATCH 1/2] feat(log): add a rotating log file and show its path in the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library captures the process log stream in a LogBuffer for the TUI panel, but nothing reaches disk — once the TUI exits the run leaves no record to debug from. Add LogFileConfig and OpenLogFile, which return an io.Writer callers tee into their existing slog handler so the same lines land in both places. Rotation follows the Unix convention: the live file keeps its name and older generations shift down through .1 … .N before being discarded. A single write is never split across two generations, so a log line always lands whole in one file. *LogFile is nil-safe on every method and OpenLogFile returns nil when the file is disabled, so callers need no branching to turn it off. TUIConfig gains LogPath and ShowLogPath (default true), rendering " Logs (/path/to/file.log) " on the log panel divider. The path elides from the left and drops entirely on a terminal too narrow to hold it, so the divider always fits one row. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 22 +++++ README.md | 39 ++++++++ config.go | 15 ++- logfile.go | 167 ++++++++++++++++++++++++++++++++ logfile_test.go | 212 +++++++++++++++++++++++++++++++++++++++++ tui/logdivider_test.go | 72 ++++++++++++++ tui/tui.go | 47 ++++++++- 7 files changed, 569 insertions(+), 5 deletions(-) create mode 100644 logfile.go create mode 100644 logfile_test.go create mode 100644 tui/logdivider_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 03f3a40..89cb915 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -138,8 +138,13 @@ cfg := agentfleet.DefaultConfig() // cfg.Fleet.LogDir = "/tmp" (default; empty = no log file) // cfg.TUI.Columns = 3 // cfg.TUI.RefreshRate = 500ms +// cfg.TUI.ShowLogPath = true (render cfg.TUI.LogPath in the log divider) // cfg.Agent.PTYRows = 24 // cfg.Agent.PTYCols = 220 +// cfg.LogFile.Enabled = true +// cfg.LogFile.Path = "" (empty = /agentfleet.log) +// cfg.LogFile.MaxBytes = 10MB (0 = never rotate) +// cfg.LogFile.Backups = 5 (0 = truncate, keep no generations) cfg.Agent = agentfleet.AgentConfigFromTerminal() // read from actual terminal ``` @@ -276,3 +281,20 @@ ANTHROPIC_API_KEY=sk-... go run ./examples/generate-manager/ --generate "Run 5 c ### Log a session Set `FleetConfig.LogDir` to a directory path (e.g., `cfg.Fleet.LogDir = "/var/log/agentfleet"`). The Runner writes to `{LogDir}/agentfleet-{task-id}.log`. Set to `""` to disable. + +### Persist the process log stream + +Session logs (above) record agent PTY traffic. The *process* log stream — whatever the host writes through `slog` — is separate, and `LogFileConfig` covers it: + +```go +logFile, err := agentfleet.OpenLogFile(cfg.LogFile) // nil, nil when Enabled is false +defer logFile.Close() + +logBuf := agentfleet.NewLogBuffer(500) +cfg.TUI.Log = logBuf +cfg.TUI.LogPath = logFile.Path() // divider renders " Logs () " + +logger := slog.New(slog.NewTextHandler(io.MultiWriter(logBuf, logFile), nil)) +``` + +`*LogFile` is nil-safe on every method, so a disabled log file needs no branching at the call site. Rotation is Unix-style: the live file keeps its name and older generations shift down through `.1` … `.N`. diff --git a/README.md b/README.md index b67bb23..79e4a09 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,45 @@ func main() { } ``` +## Log File + +`OpenLogFile` returns a rotating `io.Writer` for the process log stream. Tee it +into whatever handler already feeds the TUI panel and the same lines land on +disk; set `TUI.LogPath` and the panel divider shows where they went. + +```go +cfg := agentfleet.DefaultConfig() +cfg.LogFile.Path = "retask.log" // default: /agentfleet.log + +logFile, err := agentfleet.OpenLogFile(cfg.LogFile) +if err != nil { + return err +} +defer logFile.Close() + +logBuf := agentfleet.NewLogBuffer(500) +cfg.TUI.Log = logBuf +cfg.TUI.LogPath = logFile.Path() + +logger := slog.New(slog.NewTextHandler(io.MultiWriter(logBuf, logFile), nil)) +``` + +``` +── Logs (/work/session-abc/retask.log) ─────────────────────────── +``` + +Rotation follows the Unix convention: the live file keeps its name and older +generations shift down through `retask.log.1`, `retask.log.2`, … up to +`LogFile.Backups` before being discarded. + +| Setting | Default | Meaning | +|---------|---------|---------| +| `LogFile.Enabled` | `true` | Write the log stream to a file. `false` makes `OpenLogFile` return a nil no-op writer. | +| `LogFile.Path` | `/agentfleet.log` | Live log file. Relative paths resolve against the working directory. | +| `LogFile.MaxBytes` | `10MB` | Rotate once the live file exceeds this. `0` disables rotation. | +| `LogFile.Backups` | `5` | Rotated generations kept. `0` truncates instead of keeping any. | +| `TUI.ShowLogPath` | `true` | Render `TUI.LogPath` in the log panel divider. | + ## Examples | Example | Purpose | diff --git a/config.go b/config.go index 39dc012..4674baa 100644 --- a/config.go +++ b/config.go @@ -9,9 +9,10 @@ import ( // Config holds all configuration for a fleet run. type Config struct { - Fleet FleetConfig - TUI TUIConfig - Agent AgentConfig + Fleet FleetConfig + TUI TUIConfig + Agent AgentConfig + LogFile LogFileConfig } // FleetConfig controls task scheduling and I/O paths. @@ -30,6 +31,8 @@ type TUIConfig struct { AutoOpen bool // auto-open a tab for each task when it starts — default: true MaxDoneTasks int // done/failed tasks kept in list; 0 = no limit — default: 10 Log *LogBuffer // nil = no log panel + LogPath string // log file shown in the log panel divider; empty = none + ShowLogPath bool // render LogPath in the divider — default: true OnClose func(taskID string) // called when user presses x on a selected task; nil = no-op FilterLines func([]string) []string // pre-process runner output before preview; nil = default chrome filter } @@ -54,8 +57,14 @@ func DefaultConfig() Config { RefreshRate: 500 * time.Millisecond, AutoOpen: true, MaxDoneTasks: 10, + ShowLogPath: true, }, Agent: AgentConfig{PTYRows: 24, PTYCols: 220}, + LogFile: LogFileConfig{ + Enabled: true, + MaxBytes: DefaultLogMaxBytes, + Backups: DefaultLogBackups, + }, } } diff --git a/logfile.go b/logfile.go new file mode 100644 index 0000000..51be684 --- /dev/null +++ b/logfile.go @@ -0,0 +1,167 @@ +package agentfleet + +import ( + "fmt" + "os" + "path/filepath" + "sync" +) + +// DefaultLogFileName is used when LogFileConfig.Path is empty. +const DefaultLogFileName = "agentfleet.log" + +// Default rotation thresholds used by DefaultConfig. +const ( + DefaultLogMaxBytes = 10 << 20 // 10MB + DefaultLogBackups = 5 +) + +// LogFileConfig controls the on-disk copy of the process log stream. +// +// The library never installs a logger of its own: OpenLogFile hands back an +// io.Writer that the caller tees into whatever handler it already uses, so the +// same lines reach the TUI log panel and the file. +type LogFileConfig struct { + Enabled bool // write the log stream to a file — default: true + Path string // log file path; empty = /agentfleet.log + MaxBytes int64 // rotate once the live file exceeds this — default: 10MB; <= 0 disables rotation + Backups int // rotated generations kept (.1 … .N) — default: 5; 0 truncates instead +} + +// LogFile is an io.Writer that appends to a file and rotates it Unix-style: +// the live file keeps its name, and older generations shift down through +// .1, .2, … up to Backups before being discarded. +// +// A nil *LogFile is a valid no-op writer, so callers can pass the result of +// OpenLogFile straight to io.MultiWriter without a nil check. +type LogFile struct { + mu sync.Mutex + path string + maxBytes int64 + backups int + f *os.File + size int64 +} + +// OpenLogFile opens (creating or appending to) the configured log file. +// It returns a nil *LogFile when cfg.Enabled is false — writes to that value +// are discarded, so disabling the file needs no branching at the call site. +func OpenLogFile(cfg LogFileConfig) (lf *LogFile, err error) { + if !cfg.Enabled { + return nil, nil + } + path := cfg.Path + if path == "" { + path = DefaultLogFileName + } + abs, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("resolve log file path %q: %w", path, err) + } + backups := cfg.Backups + if backups < 0 { + backups = 0 + } + l := &LogFile{path: abs, maxBytes: cfg.MaxBytes, backups: backups} + if err := l.open(); err != nil { + return nil, err + } + return l, nil +} + +// Path returns the absolute path of the live log file, or "" for a nil LogFile. +func (l *LogFile) Path() string { + if l == nil { + return "" + } + return l.path +} + +// Write appends p to the log file, rotating first when the write would push +// the live file past MaxBytes. A single write is never split across two +// generations, so a log line always lands in one file. +func (l *LogFile) Write(p []byte) (n int, err error) { + if l == nil { + return len(p), nil + } + l.mu.Lock() + defer l.mu.Unlock() + if l.f == nil { + return 0, os.ErrClosed + } + if l.maxBytes > 0 && l.size > 0 && l.size+int64(len(p)) > l.maxBytes { + if err := l.rotate(); err != nil { + return 0, err + } + } + n, err = l.f.Write(p) + l.size += int64(n) + return n, err +} + +// Close closes the live file. Writes after Close return os.ErrClosed. +func (l *LogFile) Close() error { + if l == nil { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + if l.f == nil { + return nil + } + err := l.f.Close() + l.f = nil + return err +} + +// open opens the live file for append and records its current size. +// Callers other than OpenLogFile must hold l.mu. +func (l *LogFile) open() error { + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("open log file %q: %w", l.path, err) + } + size := int64(0) + if st, err := f.Stat(); err == nil { + size = st.Size() + } + l.f, l.size = f, size + return nil +} + +// rotate shifts the log generations down and reopens an empty live file. +// The caller must hold l.mu. +func (l *LogFile) rotate() error { + if err := l.f.Close(); err != nil { + return fmt.Errorf("close log file %q: %w", l.path, err) + } + l.f = nil + + if l.backups == 0 { + // No generations kept: start the live file over. + if err := os.Remove(l.path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove log file %q: %w", l.path, err) + } + return l.open() + } + + // Drop the oldest generation, then shift the rest down: .N-1 -> .N, … , .1 -> .2. + if err := os.Remove(l.backupPath(l.backups)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove log file %q: %w", l.backupPath(l.backups), err) + } + for i := l.backups - 1; i >= 1; i-- { + from, to := l.backupPath(i), l.backupPath(i+1) + if err := os.Rename(from, to); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("rotate log file %q -> %q: %w", from, to, err) + } + } + if err := os.Rename(l.path, l.backupPath(1)); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("rotate log file %q -> %q: %w", l.path, l.backupPath(1), err) + } + return l.open() +} + +// backupPath returns the path of the nth rotated generation (.n). +func (l *LogFile) backupPath(n int) string { + return fmt.Sprintf("%s.%d", l.path, n) +} diff --git a/logfile_test.go b/logfile_test.go new file mode 100644 index 0000000..99991c2 --- /dev/null +++ b/logfile_test.go @@ -0,0 +1,212 @@ +package agentfleet_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + agentfleet "github.com/hoaitan/agentfleet" +) + +func TestOpenLogFileDisabledReturnsNilNoOpWriter(t *testing.T) { + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: false}) + require.NoError(t, err) + require.Nil(t, lf) + + // A nil *LogFile stays usable so callers need no nil check. + n, err := lf.Write([]byte("dropped")) + require.NoError(t, err) + assert.Equal(t, len("dropped"), n) + assert.Equal(t, "", lf.Path()) + assert.NoError(t, lf.Close()) +} + +func TestOpenLogFileResolvesRelativePath(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: true, Path: "retask.log"}) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + assert.True(t, filepath.IsAbs(lf.Path()), "Path() must be absolute, got %q", lf.Path()) + assert.Equal(t, "retask.log", filepath.Base(lf.Path())) +} + +func TestOpenLogFileDefaultsToAgentfleetLog(t *testing.T) { + t.Chdir(t.TempDir()) + + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: true}) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + assert.Equal(t, agentfleet.DefaultLogFileName, filepath.Base(lf.Path())) +} + +func TestLogFileAppendsAcrossOpens(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + + first, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: true, Path: path}) + require.NoError(t, err) + _, err = first.Write([]byte("one\n")) + require.NoError(t, err) + require.NoError(t, first.Close()) + + second, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: true, Path: path}) + require.NoError(t, err) + _, err = second.Write([]byte("two\n")) + require.NoError(t, err) + require.NoError(t, second.Close()) + + assert.Equal(t, "one\ntwo\n", readFile(t, path)) +} + +func TestLogFileRotatesAtMaxBytes(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 10, Backups: 2, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + // Each write is 6 bytes, so every second write trips the 10-byte threshold. + for _, line := range []string{"aaaaa\n", "bbbbb\n", "ccccc\n"} { + _, err := lf.Write([]byte(line)) + require.NoError(t, err) + } + + assert.Equal(t, "ccccc\n", readFile(t, path), "live file holds the newest write") + assert.Equal(t, "bbbbb\n", readFile(t, path+".1"), ".1 holds the previous generation") + assert.Equal(t, "aaaaa\n", readFile(t, path+".2"), ".2 holds the oldest kept generation") +} + +func TestLogFileDiscardsGenerationsBeyondBackups(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 4, Backups: 1, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + for _, line := range []string{"aaaaa\n", "bbbbb\n", "ccccc\n"} { + _, err := lf.Write([]byte(line)) + require.NoError(t, err) + } + + assert.Equal(t, "ccccc\n", readFile(t, path)) + assert.Equal(t, "bbbbb\n", readFile(t, path+".1")) + assert.NoFileExists(t, path+".2", "only Backups generations are kept") +} + +func TestLogFileZeroBackupsTruncates(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 4, Backups: 0, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + _, err = lf.Write([]byte("aaaaa\n")) + require.NoError(t, err) + _, err = lf.Write([]byte("bbbbb\n")) + require.NoError(t, err) + + assert.Equal(t, "bbbbb\n", readFile(t, path)) + assert.NoFileExists(t, path+".1") +} + +func TestLogFileZeroMaxBytesNeverRotates(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 0, Backups: 3, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + _, err = lf.Write([]byte(strings.Repeat("x", 4096))) + require.NoError(t, err) + _, err = lf.Write([]byte(strings.Repeat("y", 4096))) + require.NoError(t, err) + + assert.Len(t, readFile(t, path), 8192) + assert.NoFileExists(t, path+".1") +} + +func TestLogFileWriteIsNeverSplitAcrossGenerations(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 8, Backups: 1, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + _, err = lf.Write([]byte("short\n")) + require.NoError(t, err) + long := strings.Repeat("z", 64) + "\n" + n, err := lf.Write([]byte(long)) + require.NoError(t, err) + assert.Equal(t, len(long), n) + + // An oversized line rotates first, then lands whole in the new live file. + assert.Equal(t, long, readFile(t, path)) + assert.Equal(t, "short\n", readFile(t, path+".1")) +} + +func TestLogFileWriteAfterCloseFails(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{Enabled: true, Path: path}) + require.NoError(t, err) + require.NoError(t, lf.Close()) + require.NoError(t, lf.Close(), "Close is idempotent") + + _, err = lf.Write([]byte("nope")) + assert.ErrorIs(t, err, os.ErrClosed) +} + +func TestLogFileConcurrentWrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "retask.log") + lf, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: path, MaxBytes: 64, Backups: 3, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = lf.Close() }) + + done := make(chan struct{}) + for i := 0; i < 8; i++ { + go func() { + defer func() { done <- struct{}{} }() + for j := 0; j < 50; j++ { + _, _ = lf.Write([]byte("concurrent line\n")) + } + }() + } + for i := 0; i < 8; i++ { + <-done + } +} + +func TestOpenLogFileUnwritablePath(t *testing.T) { + _, err := agentfleet.OpenLogFile(agentfleet.LogFileConfig{ + Enabled: true, Path: filepath.Join(t.TempDir(), "missing-dir", "retask.log"), + }) + require.Error(t, err) +} + +func TestDefaultConfigEnablesLogFileAndPathDisplay(t *testing.T) { + cfg := agentfleet.DefaultConfig() + assert.True(t, cfg.LogFile.Enabled) + assert.Equal(t, int64(agentfleet.DefaultLogMaxBytes), cfg.LogFile.MaxBytes) + assert.Equal(t, agentfleet.DefaultLogBackups, cfg.LogFile.Backups) + assert.True(t, cfg.TUI.ShowLogPath) +} + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + require.NoError(t, err) + return string(b) +} diff --git a/tui/logdivider_test.go b/tui/logdivider_test.go new file mode 100644 index 0000000..0c440b2 --- /dev/null +++ b/tui/logdivider_test.go @@ -0,0 +1,72 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" + + agentfleet "github.com/hoaitan/agentfleet" +) + +func TestLogLabelWithoutPath(t *testing.T) { + cfg := agentfleet.TUIConfig{ShowLogPath: true} + assert.Equal(t, " Logs ", logLabel(cfg, 120)) +} + +func TestLogLabelPathDisplayDisabled(t *testing.T) { + cfg := agentfleet.TUIConfig{ShowLogPath: false, LogPath: "/work/retask.log"} + assert.Equal(t, " Logs ", logLabel(cfg, 120)) +} + +func TestLogLabelShowsPath(t *testing.T) { + cfg := agentfleet.TUIConfig{ShowLogPath: true, LogPath: "/work/session-1/retask.log"} + assert.Equal(t, " Logs (/work/session-1/retask.log) ", logLabel(cfg, 120)) +} + +func TestLogLabelElidesLongPathFromTheLeft(t *testing.T) { + path := "/very/deeply/nested/workspace/session-abc/retask.log" + cfg := agentfleet.TUIConfig{ShowLogPath: true, LogPath: path} + + label := logLabel(cfg, 40) + + assert.LessOrEqual(t, lipgloss.Width(label), 38, "label must leave room for both dashes") + assert.True(t, strings.HasPrefix(label, " Logs (…"), "elided from the left, got %q", label) + assert.True(t, strings.HasSuffix(label, "retask.log) "), "keeps the file name, got %q", label) +} + +func TestLogLabelDropsPathOnNarrowTerminal(t *testing.T) { + cfg := agentfleet.TUIConfig{ShowLogPath: true, LogPath: "/work/session-1/retask.log"} + assert.Equal(t, " Logs ", logLabel(cfg, 16)) +} + +func TestElideLeftKeepsShortStrings(t *testing.T) { + assert.Equal(t, "retask.log", elideLeft("retask.log", 20)) + assert.Equal(t, "retask.log", elideLeft("retask.log", 10)) +} + +func TestElideLeftFitsWidth(t *testing.T) { + got := elideLeft("/a/b/c/retask.log", 12) + assert.Equal(t, 12, lipgloss.Width(got)) + assert.Equal(t, "…/retask.log", got) +} + +func TestRenderLogDividerFitsTerminalWidth(t *testing.T) { + buf := agentfleet.NewLogBuffer(10) + _, _ = buf.Write([]byte("hello\n")) + m := model{ + termW: 60, + cfg: agentfleet.TUIConfig{ + Log: buf, + LogPath: "/very/deeply/nested/workspace/session-abc/retask.log", + ShowLogPath: true, + }, + } + + rows := strings.Split(renderLog(m, 4, ""), "\n") + + assert.Len(t, rows, 4) + assert.Equal(t, 60, lipgloss.Width(rows[0]), "divider spans exactly the terminal width") + assert.Contains(t, rows[0], "retask.log") +} diff --git a/tui/tui.go b/tui/tui.go index 6f54360..94c060c 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -639,8 +639,8 @@ func renderLog(m model, logH int, invis string) string { start = 0 } - label := " Logs " - dashW := w - len([]rune(label)) - 2 + label := logLabel(m.cfg, w) + dashW := w - lipgloss.Width(label) - 2 if dashW < 0 { dashW = 0 } @@ -659,6 +659,49 @@ func renderLog(m model, logH int, invis string) string { return strings.Join(rows, "\n") } +// minLogPathW is the narrowest elided path still worth showing in the divider. +const minLogPathW = 8 + +// logLabel builds the log panel divider label — " Logs " on its own, or +// " Logs (/path/to/file.log) " once a log file is configured. The path is +// elided from the left, and dropped entirely on a terminal too narrow to hold +// it, so the divider always fits one row. +func logLabel(cfg agentfleet.TUIConfig, w int) string { + const ( + plain = " Logs " + prefix = " Logs (" + suffix = ") " + ) + if !cfg.ShowLogPath || cfg.LogPath == "" { + return plain + } + // Two columns are reserved for the dashes bracketing the label. + avail := w - 2 - lipgloss.Width(prefix) - lipgloss.Width(suffix) + if avail < minLogPathW { + return plain + } + return prefix + elideLeft(cfg.LogPath, avail) + suffix +} + +// elideLeft trims s from the left to at most maxW display columns, marking the +// cut with a leading ellipsis. Paths keep the informative tail that way. +func elideLeft(s string, maxW int) string { + if lipgloss.Width(s) <= maxW || maxW <= 0 { + return s + } + runes := []rune(s) + w, i := 0, len(runes) + for i > 0 { + cw := lipgloss.Width(string(runes[i-1])) + if w+cw > maxW-1 { // one column for the ellipsis + break + } + w += cw + i-- + } + return "…" + string(runes[i:]) +} + // wrapLine splits s into visual segments each at most maxW display columns wide. func wrapLine(s string, maxW int) []string { if maxW <= 0 { From 6cad0b510be3dd5b2c257f2e82452e7dcca1ef1e Mon Sep 17 00:00:00 2001 From: nwebbot Date: Fri, 7 Aug 2026 23:52:39 +1000 Subject: [PATCH 2/2] build: bump go directive to 1.26.5 to fix GO-2026-5856 govulncheck fails the pipeline on GO-2026-5856 (Encrypted Client Hello privacy leak in crypto/tls), which is fixed in go1.26.5. CI installs the toolchain from the go directive, so bumping it clears the finding. Same fix as db264ab, which bumped to 1.26.4 for GO-2026-5039. Co-Authored-By: Claude Opus 5 (1M context) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2e1c3ce..d24a2f6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/hoaitan/agentfleet -go 1.26.4 +go 1.26.5 require ( github.com/charmbracelet/bubbletea v1.3.10