Skip to content

Commit 426c4b7

Browse files
committed
feat: session stats dashboard, server log capture and TUI polish
1 parent b5293eb commit 426c4b7

12 files changed

Lines changed: 987 additions & 57 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
run: go vet ./...
2929

3030
- name: Test (race + coverage)
31-
run: go test -race -coverpkg=./internal/... -coverprofile=coverage.out ./internal/...
31+
run: go test -race -coverpkg=./internal/... -coverprofile=coverage.out ./...
3232

3333
- name: Coverage summary
3434
run: go tool cover -func=coverage.out | tail -1

cmd/bodek/log.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"sync"
10+
)
11+
12+
// serverLogName is the fixed file the spawned server's stderr is captured to.
13+
// A stable, predictable path lets users `tail -f` it; it is truncated per run
14+
// so it never grows without bound.
15+
const serverLogName = "bodek-odek-serve.log"
16+
17+
// openServerLog opens (truncating) the server log file. On failure it falls
18+
// back to a discarding writer so a missing temp dir never breaks startup.
19+
func openServerLog() (w io.Writer, path string, closeFn func()) {
20+
path = filepath.Join(os.TempDir(), serverLogName)
21+
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
22+
if err != nil {
23+
return io.Discard, "", func() {}
24+
}
25+
return f, path, func() { _ = f.Close() }
26+
}
27+
28+
// ringWriter keeps the most recent lines written to it. It backs the in-memory
29+
// tail of the spawned server's stderr, which we surface if the server dies
30+
// before (or instead of) the TUI ever starting.
31+
type ringWriter struct {
32+
mu sync.Mutex
33+
maxLine int
34+
lines []string
35+
partial []byte
36+
}
37+
38+
func newRingWriter(maxLine int) *ringWriter {
39+
return &ringWriter{maxLine: maxLine}
40+
}
41+
42+
func (w *ringWriter) Write(p []byte) (int, error) {
43+
w.mu.Lock()
44+
defer w.mu.Unlock()
45+
w.partial = append(w.partial, p...)
46+
for {
47+
i := bytes.IndexByte(w.partial, '\n')
48+
if i < 0 {
49+
break
50+
}
51+
w.lines = append(w.lines, string(w.partial[:i]))
52+
w.partial = w.partial[i+1:]
53+
if len(w.lines) > w.maxLine {
54+
w.lines = w.lines[len(w.lines)-w.maxLine:]
55+
}
56+
}
57+
return len(p), nil
58+
}
59+
60+
// String returns the buffered tail, including any unterminated final line.
61+
func (w *ringWriter) String() string {
62+
w.mu.Lock()
63+
defer w.mu.Unlock()
64+
lines := w.lines
65+
if len(w.partial) > 0 {
66+
lines = append(append([]string(nil), lines...), string(w.partial))
67+
}
68+
return strings.Join(lines, "\n")
69+
}

cmd/bodek/log_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestRingWriterKeepsRecentLines(t *testing.T) {
11+
w := newRingWriter(3)
12+
for i := range 5 {
13+
if _, err := w.Write([]byte("line" + string(rune('0'+i)) + "\n")); err != nil {
14+
t.Fatalf("write: %v", err)
15+
}
16+
}
17+
got := w.String()
18+
want := "line2\nline3\nline4"
19+
if got != want {
20+
t.Fatalf("tail = %q, want %q", got, want)
21+
}
22+
}
23+
24+
func TestRingWriterPartialLine(t *testing.T) {
25+
w := newRingWriter(5)
26+
// Write a line split across calls, plus an unterminated tail.
27+
for _, s := range []string{"hel", "lo\nwor", "ld"} {
28+
if _, err := w.Write([]byte(s)); err != nil {
29+
t.Fatalf("write: %v", err)
30+
}
31+
}
32+
if got, want := w.String(), "hello\nworld"; got != want {
33+
t.Fatalf("tail = %q, want %q", got, want)
34+
}
35+
}
36+
37+
func TestRingWriterEmpty(t *testing.T) {
38+
if got := newRingWriter(3).String(); got != "" {
39+
t.Fatalf("empty tail = %q, want \"\"", got)
40+
}
41+
}
42+
43+
func TestOpenServerLogTruncates(t *testing.T) {
44+
// Redirect TempDir to an isolated location for the test.
45+
t.Setenv("TMPDIR", t.TempDir())
46+
47+
w, path, closeLog := openServerLog()
48+
if path == "" {
49+
t.Fatal("openServerLog returned empty path")
50+
}
51+
if filepath.Base(path) != serverLogName {
52+
t.Fatalf("log name = %q, want %q", filepath.Base(path), serverLogName)
53+
}
54+
if _, err := w.Write([]byte("first run\n")); err != nil {
55+
t.Fatalf("write: %v", err)
56+
}
57+
closeLog()
58+
59+
// A second open must truncate the prior contents.
60+
w2, _, closeLog2 := openServerLog()
61+
if _, err := w2.Write([]byte("second")); err != nil {
62+
t.Fatalf("write: %v", err)
63+
}
64+
closeLog2()
65+
66+
data, err := os.ReadFile(path)
67+
if err != nil {
68+
t.Fatalf("read log: %v", err)
69+
}
70+
if got := string(data); got != "second" {
71+
t.Fatalf("log = %q, want %q (not truncated?)", got, "second")
72+
}
73+
if strings.Contains(string(data), "first run") {
74+
t.Fatal("prior run's log was not truncated")
75+
}
76+
}

cmd/bodek/main.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ package main
66
import (
77
"flag"
88
"fmt"
9+
"io"
910
"os"
1011
"os/signal"
12+
"strings"
1113
"syscall"
1214

1315
tea "github.com/charmbracelet/bubbletea"
@@ -45,15 +47,44 @@ func run() error {
4547

4648
extraArgs := flag.Args()
4749

50+
// A spawned `odek serve` logs to stderr. Routing that to our own terminal
51+
// would corrupt the Bubble Tea alt-screen (stray writes desync the diff
52+
// renderer), so we capture it to a log file and keep a short in-memory tail
53+
// to surface if the server dies during startup. When attaching to an
54+
// external server (--url) there is no subprocess, so nothing is captured.
55+
var (
56+
logTail *ringWriter
57+
logPath string
58+
)
59+
// io.Writer(io.Discard) keeps the interface type (serverErr is reassigned to
60+
// io.MultiWriter / *ringWriter below) without the redundant typed-var
61+
// declaration that staticcheck's QF1011 flags.
62+
serverErr := io.Writer(io.Discard)
63+
if *url == "" {
64+
logTail = newRingWriter(50)
65+
if f, path, closeLog := openServerLog(); path != "" {
66+
defer closeLog()
67+
logPath = path
68+
serverErr = io.MultiWriter(f, logTail)
69+
} else {
70+
serverErr = logTail
71+
}
72+
}
73+
4874
// Spawn or attach to the odek serve backend.
4975
srv, err := server.Connect(server.Options{
5076
URL: *url,
5177
Bin: *bin,
5278
Sandbox: *sandbox,
5379
ExtraArgs: extraArgs,
54-
Stderr: os.Stderr,
80+
Stderr: serverErr,
5581
})
5682
if err != nil {
83+
if logTail != nil {
84+
if tail := strings.TrimSpace(logTail.String()); tail != "" {
85+
return fmt.Errorf("%w\n\nodek serve output:\n%s", err, tail)
86+
}
87+
}
5788
return err
5889
}
5990
defer srv.Stop()
@@ -76,9 +107,14 @@ func run() error {
76107
model := tui.New(cl, tui.Options{
77108
Sandbox: *sandbox,
78109
CWD: cwd,
110+
LogPath: logPath,
79111
})
80112

81-
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
113+
// No mouse capture: with mouse reporting on, the terminal cannot do native
114+
// click-drag text selection. Leaving it off lets users select & copy text
115+
// (copy-on-select where the terminal supports it). Scrolling stays on the
116+
// keyboard (PgUp/PgDn, ^U/^D).
117+
p := tea.NewProgram(model, tea.WithAltScreen())
82118
if _, err := p.Run(); err != nil {
83119
return fmt.Errorf("TUI exited: %w", err)
84120
}

internal/tui/banner.go

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tui
22

33
import (
4+
"os"
45
"strings"
56

67
"github.com/charmbracelet/lipgloss"
@@ -22,38 +23,65 @@ var (
2223
)
2324

2425
// welcome renders the splash shown in the conversation area before the first
25-
// prompt: the wordmark, a tagline, and a few key bindings.
26-
func welcome(th theme, width int) string {
26+
// prompt: the wordmark, a tagline, the working directory, and a few key
27+
// bindings — left-aligned with a gentle margin.
28+
func welcome(th theme, width int, cwd string) string {
2729
var b strings.Builder
2830
for _, line := range bannerArt {
2931
b.WriteString(gradient(line, gradFrom, gradTo))
3032
b.WriteByte('\n')
3133
}
3234
b.WriteByte('\n')
3335
b.WriteString(th.tagline.Render("a beautiful terminal interface for the odek agent"))
34-
b.WriteString("\n\n")
36+
b.WriteByte('\n')
37+
if dir := shortenHome(cwd); dir != "" {
38+
b.WriteString(th.statsDim.Render(dir))
39+
b.WriteByte('\n')
40+
}
41+
b.WriteByte('\n')
3542

43+
// key column is right-aligned to a fixed width so the descriptions line up.
3644
tips := [][2]string{
3745
{"type a task", "and press enter to run the agent"},
3846
{"/ commands", "type / for commands, e.g. /help /sessions /model"},
47+
{"/stats", "session metrics & live context-window gauge"},
3948
{"@ to attach", "attach files, e.g. @main.go"},
4049
{"⏎ send", "· ^J newline · ^T toggle thinking"},
4150
{"^L clear", "· PgUp/PgDn scroll · ^C quit"},
4251
{"approvals", "answer with [a]pprove [d]eny [t]rust"},
4352
}
53+
const keyW = 11
4454
for _, t := range tips {
45-
b.WriteString(" " + th.tipKey.Render(pad(t[0], 12)) + th.tipText.Render(t[1]) + "\n")
55+
b.WriteString(th.tipKey.Render(padLeft(t[0], keyW)) + " " + th.tipText.Render(t[1]) + "\n")
4656
}
4757

48-
block := b.String()
49-
// Center the splash block within the available width for a polished look.
50-
return lipgloss.NewStyle().Width(width).Align(lipgloss.Center).Render(block)
58+
block := strings.TrimRight(b.String(), "\n")
59+
// Left-aligned (no centering) with a small left margin for breathing room.
60+
return lipgloss.NewStyle().Width(width).PaddingLeft(2).Render(block)
5161
}
5262

53-
// pad right-pads s with spaces to width n.
54-
func pad(s string, n int) string {
55-
if lipgloss.Width(s) >= n {
63+
// padLeft left-pads s with spaces to width n (right-aligns within the column).
64+
func padLeft(s string, n int) string {
65+
w := lipgloss.Width(s)
66+
if w >= n {
5667
return s
5768
}
58-
return s + strings.Repeat(" ", n-lipgloss.Width(s))
69+
return strings.Repeat(" ", n-w) + s
70+
}
71+
72+
// shortenHome replaces a leading $HOME with "~" for a compact, readable path.
73+
func shortenHome(p string) string {
74+
p = strings.TrimSpace(p)
75+
if p == "" {
76+
return ""
77+
}
78+
if home, err := os.UserHomeDir(); err == nil && home != "" {
79+
if p == home {
80+
return "~"
81+
}
82+
if strings.HasPrefix(p, home+string(os.PathSeparator)) {
83+
return "~" + p[len(home):]
84+
}
85+
}
86+
return p
5987
}

0 commit comments

Comments
 (0)