Skip to content

Commit 9d76478

Browse files
feat(cli): add terminal turn logger (#34)
1 parent 5b58541 commit 9d76478

6 files changed

Lines changed: 148 additions & 1 deletion

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ For structured turn logs, pass `-log-json`. Named agents append logs to `~/.viku
7777
vikusha chat -log-json writer
7878
```
7979

80+
For readable turn logs during a terminal session, pass `-log-terminal`.
81+
82+
```bash
83+
vikusha chat -log-terminal writer
84+
```
85+
8086
You can load the same character from Go.
8187

8288
```go

cmd/vikusha/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,14 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
6666
userID := fs.String("user", os.Getenv("USER"), "user id for the conversation")
6767
timeout := fs.Duration("timeout", 2*time.Minute, "timeout per turn")
6868
logJSON := fs.Bool("log-json", false, "write structured turn logs to stderr")
69+
logTerminal := fs.Bool("log-terminal", false, "write human-readable turn logs to stderr")
70+
noColor := fs.Bool("no-color", false, "disable color in terminal turn logs")
6971
if err := fs.Parse(args[1:]); err != nil {
7072
return err
7173
}
74+
if *logJSON && *logTerminal {
75+
return fmt.Errorf("-log-json and -log-terminal cannot be used together")
76+
}
7277
if fs.NArg() != 1 {
7378
return fmt.Errorf("usage: vikusha %s <character.yaml|agent>", args[0])
7479
}
@@ -90,6 +95,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
9095
logWriter = logFile
9196
}
9297
logger = agent.NewJSONLogger(logWriter)
98+
} else if *logTerminal {
99+
logger = agent.NewTerminalLogger(stderr, !*noColor)
93100
}
94101
a, err := buildAgent(path, logger)
95102
if err != nil {

cmd/vikusha/main_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ func TestMissingCommand(t *testing.T) {
3333
}
3434
}
3535

36+
func TestLogModesAreMutuallyExclusive(t *testing.T) {
37+
var out, errOut bytes.Buffer
38+
err := run([]string{"chat", "-log-json", "-log-terminal", "character.yaml"}, strings.NewReader(""), &out, &errOut)
39+
if err == nil {
40+
t.Fatal("expected error")
41+
}
42+
if !strings.Contains(err.Error(), "-log-json and -log-terminal") {
43+
t.Fatalf("error = %q, want log mode conflict", err)
44+
}
45+
}
46+
3647
func TestCreateAgent(t *testing.T) {
3748
home := t.TempDir()
3849
t.Setenv("HOME", home)

core/agent/log.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package agent
33
import (
44
"context"
55
"encoding/json"
6+
"fmt"
67
"io"
8+
"strings"
79
"sync"
810
)
911

@@ -29,3 +31,66 @@ func (l *JSONLogger) LogTurn(ctx context.Context, event TurnEvent) {
2931
defer l.mu.Unlock()
3032
_, _ = l.w.Write(append(data, '\n'))
3133
}
34+
35+
type TerminalLogger struct {
36+
mu sync.Mutex
37+
w io.Writer
38+
color bool
39+
}
40+
41+
func NewTerminalLogger(w io.Writer, color bool) *TerminalLogger {
42+
return &TerminalLogger{w: w, color: color}
43+
}
44+
45+
func (l *TerminalLogger) LogTurn(ctx context.Context, event TurnEvent) {
46+
if l == nil || l.w == nil {
47+
return
48+
}
49+
50+
line := terminalTurnLine(event)
51+
if l.color {
52+
line = colorizeTurnLine(event, line)
53+
}
54+
55+
l.mu.Lock()
56+
defer l.mu.Unlock()
57+
_, _ = fmt.Fprintln(l.w, line)
58+
}
59+
60+
func terminalTurnLine(event TurnEvent) string {
61+
parts := []string{
62+
fmt.Sprintf("turn %s", event.FinishReason),
63+
fmt.Sprintf("duration=%s", event.Duration),
64+
fmt.Sprintf("iterations=%d", event.Iterations),
65+
}
66+
if event.InputTokens > 0 || event.OutputTokens > 0 {
67+
parts = append(parts, fmt.Sprintf("tokens=%d/%d", event.InputTokens, event.OutputTokens))
68+
}
69+
if event.CacheReadTokens > 0 || event.CacheWriteTokens > 0 {
70+
parts = append(parts, fmt.Sprintf("cache=%d/%d", event.CacheReadTokens, event.CacheWriteTokens))
71+
}
72+
if event.ReasoningTokens > 0 {
73+
parts = append(parts, fmt.Sprintf("reasoning=%d", event.ReasoningTokens))
74+
}
75+
if len(event.Tools) > 0 {
76+
parts = append(parts, "tools="+strings.Join(event.Tools, ","))
77+
}
78+
if event.Truncated {
79+
parts = append(parts, "truncated=true")
80+
}
81+
if event.Error != "" {
82+
parts = append(parts, "error="+event.Error)
83+
}
84+
return strings.Join(parts, " ")
85+
}
86+
87+
func colorizeTurnLine(event TurnEvent, line string) string {
88+
switch event.FinishReason {
89+
case "stop":
90+
return "\x1b[32m" + line + "\x1b[0m"
91+
case "error", "max_iterations":
92+
return "\x1b[31m" + line + "\x1b[0m"
93+
default:
94+
return "\x1b[36m" + line + "\x1b[0m"
95+
}
96+
}

core/agent/log_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"strings"
78
"testing"
89
)
910

@@ -39,3 +40,60 @@ func TestJSONLoggerWritesTurnEvent(t *testing.T) {
3940
t.Fatalf("reasoning tokens = %d, want 2", got.ReasoningTokens)
4041
}
4142
}
43+
44+
func TestTerminalLoggerWritesReadableTurnEvent(t *testing.T) {
45+
var buf bytes.Buffer
46+
logger := NewTerminalLogger(&buf, false)
47+
48+
logger.LogTurn(context.Background(), TurnEvent{
49+
Duration: "12ms",
50+
Iterations: 2,
51+
InputTokens: 20,
52+
OutputTokens: 5,
53+
CacheReadTokens: 3,
54+
CacheWriteTokens: 1,
55+
ReasoningTokens: 2,
56+
Tools: []string{"file_list", "file_read"},
57+
Truncated: true,
58+
FinishReason: "stop",
59+
})
60+
61+
got := buf.String()
62+
for _, want := range []string{
63+
"turn stop",
64+
"duration=12ms",
65+
"iterations=2",
66+
"tokens=20/5",
67+
"cache=3/1",
68+
"reasoning=2",
69+
"tools=file_list,file_read",
70+
"truncated=true",
71+
} {
72+
if !strings.Contains(got, want) {
73+
t.Fatalf("terminal log = %q, want %q", got, want)
74+
}
75+
}
76+
if strings.Contains(got, "\x1b[") {
77+
t.Fatalf("terminal log should not contain color escapes: %q", got)
78+
}
79+
}
80+
81+
func TestTerminalLoggerCanColorize(t *testing.T) {
82+
var buf bytes.Buffer
83+
logger := NewTerminalLogger(&buf, true)
84+
85+
logger.LogTurn(context.Background(), TurnEvent{
86+
Duration: "1ms",
87+
Iterations: 1,
88+
FinishReason: "error",
89+
Error: "failed",
90+
})
91+
92+
got := buf.String()
93+
if !strings.Contains(got, "\x1b[31m") || !strings.Contains(got, "\x1b[0m") {
94+
t.Fatalf("terminal log = %q, want red color escapes", got)
95+
}
96+
if !strings.Contains(got, "error=failed") {
97+
t.Fatalf("terminal log = %q, want error", got)
98+
}
99+
}

docs/ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ A single agent you can talk to from the terminal, backed by a core harness that
9393
- [x] Structured JSON log line per turn: tools used, duration, loop iterations, finish reason, errors.
9494
- [x] Token and cache fields in turn logs.
9595
- [ ] Cost fields in turn logs.
96-
- [ ] Colored terminal logger for interactive sessions.
96+
- [x] Colored terminal logger for interactive sessions.
9797
- [ ] Cost estimation per provider and model.
9898

9999
### CLI

0 commit comments

Comments
 (0)