Skip to content

Commit 6747cb0

Browse files
committed
feat: colored terminal rendering layer for agent loop steps
Add internal/render package with zero-dependency ANSI rendering: - Iteration headers with model name (bold blue rules) - Thinking text (dim italic) - Tool calls with name + args (cyan) - Tool results with truncation (green, max 2000 chars) - Final answer (bold) - Error output (red) Features: - Respects NO_COLOR env var + tty detection - Nil renderer safe (programmatic API stays silent) - 90.3% test coverage - --no-color CLI flag to force-disable colors
1 parent 92c46ce commit 6747cb0

11 files changed

Lines changed: 608 additions & 58 deletions

File tree

cmd/kode/main.go

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111

1212
"github.com/BackendStack21/kode"
13+
"github.com/BackendStack21/kode/internal/render"
1314
)
1415

1516
// version is set at build time via ldflags: -ldflags "-X main.version=v0.2.1"
@@ -53,6 +54,7 @@ type runFlags struct {
5354
Thinking string
5455
MaxIter int
5556
Sandbox bool
57+
NoColor bool
5658
Task string
5759
}
5860

@@ -83,6 +85,9 @@ func parseRunFlags(args []string) (runFlags, error) {
8385
case "--sandbox":
8486
f.Sandbox = true
8587
i++
88+
case "--no-color":
89+
f.NoColor = true
90+
i++
8691
default:
8792
// Not a flag — treat remaining as the task
8893
goto done
@@ -107,6 +112,7 @@ Flags:
107112
--max-iter <n> Max think->act cycles (default: 90)
108113
--thinking <level> Reasoning depth: enabled|disabled (Deepseek) or low|medium|high (OpenAI o-series)
109114
--sandbox Run in isolated Docker container
115+
--no-color Disable colored terminal output
110116
--system <prompt> System prompt override`)
111117
}
112118

@@ -134,6 +140,14 @@ func run(args []string) error {
134140
sandboxCleanup = cleanup
135141
}
136142

143+
// Create terminal renderer for colored step-by-step output.
144+
modelName := f.Model
145+
if modelName == "" {
146+
modelName = "deepseek-chat"
147+
}
148+
color := !f.NoColor && render.ColorEnabled()
149+
rend := render.New(os.Stderr, color)
150+
137151
agent, err := kode.New(kode.Config{
138152
Model: f.Model,
139153
BaseURL: f.BaseURL,
@@ -142,18 +156,13 @@ func run(args []string) error {
142156
Thinking: f.Thinking,
143157
Tools: tools,
144158
SandboxCleanup: sandboxCleanup,
159+
Renderer: rend.WithModel(modelName),
145160
})
146161
if err != nil {
147162
return err
148163
}
149164
defer agent.Close()
150165

151-
modelName := f.Model
152-
if modelName == "" {
153-
modelName = "deepseek-chat"
154-
}
155-
fmt.Fprintf(os.Stderr, "kode: %s thinking...\n", modelName)
156-
157166
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
158167
defer cancel()
159168

@@ -177,14 +186,14 @@ func setupSandbox(tools []kode.Tool) (func() error, error) {
177186
}
178187

179188
createCmd := exec.Command("docker", "run",
180-
"--rm", // destroy on exit
181-
"--detach", // run in background
189+
"--rm", // destroy on exit
190+
"--detach", // run in background
182191
"--name", containerName,
183-
"--cap-drop", "ALL", // no capabilities
184-
"--security-opt", "no-new-privileges", // no privilege escalation
185-
"--network", "none", // no network
186-
"--tmpfs", "/tmp:noexec", // no executable temp files
187-
"-v", wd+":/workspace", // working dir (read-write inside sandbox)
192+
"--cap-drop", "ALL", // no capabilities
193+
"--security-opt", "no-new-privileges", // no privilege escalation
194+
"--network", "none", // no network
195+
"--tmpfs", "/tmp:noexec", // no executable temp files
196+
"-v", wd+":/workspace", // working dir (read-write inside sandbox)
188197
"alpine:latest",
189198
"sleep", "infinity",
190199
)
@@ -208,11 +217,12 @@ func builtinTools() []kode.Tool {
208217
&shellTool{},
209218
}
210219
}
220+
211221
// getVersion returns the version string. Resolution order:
212-
// 1. ldflags override (-X main.version=v0.2.1)
213-
// 2. VCS tag from debug.ReadBuildInfo (when built with go install)
214-
// 3. VCS revision (short commit hash)
215-
// 4. "dev" (local go build without VCS info)
222+
// 1. ldflags override (-X main.version=v0.2.1)
223+
// 2. VCS tag from debug.ReadBuildInfo (when built with go install)
224+
// 3. VCS revision (short commit hash)
225+
// 4. "dev" (local go build without VCS info)
216226
func getVersion() string {
217227
if version != "" {
218228
return version

cmd/kode/main_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,8 +415,8 @@ func TestGetVersion_ReturnsNonEmpty(t *testing.T) {
415415
// Test parseRunFlags with edge cases.
416416
func TestParseRunFlags_EdgeCases(t *testing.T) {
417417
tests := []struct {
418-
name string
419-
args []string
418+
name string
419+
args []string
420420
check func(*testing.T, runFlags)
421421
}{
422422
{

internal/llm/client.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,12 @@ type FunctionDef struct {
6767

6868
// CallParams is the request body for /chat/completions.
6969
type CallParams struct {
70-
Model string `json:"model"`
71-
Messages []Message `json:"messages"`
72-
Tools []ToolDef `json:"tools,omitempty"`
73-
Stream bool `json:"stream"`
74-
Thinking *ThinkingConfig `json:"thinking,omitempty"`
75-
ReasoningEffort string `json:"reasoning_effort,omitempty"`
70+
Model string `json:"model"`
71+
Messages []Message `json:"messages"`
72+
Tools []ToolDef `json:"tools,omitempty"`
73+
Stream bool `json:"stream"`
74+
Thinking *ThinkingConfig `json:"thinking,omitempty"`
75+
ReasoningEffort string `json:"reasoning_effort,omitempty"`
7676
}
7777

7878
// ThinkingConfig controls Deepseek's extended thinking feature.

internal/llm/client_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ func TestCallParamsMarshaling_ReasoningEffort(t *testing.T) {
100100

101101
for _, level := range tests {
102102
body := CallParams{
103-
Model: "o1",
104-
Messages: []Message{{Role: "user", Content: "hello"}},
105-
Stream: false,
103+
Model: "o1",
104+
Messages: []Message{{Role: "user", Content: "hello"}},
105+
Stream: false,
106106
ReasoningEffort: level,
107107
}
108108

internal/loop/loop.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,25 @@ import (
77
"strings"
88

99
"github.com/BackendStack21/kode/internal/llm"
10+
"github.com/BackendStack21/kode/internal/render"
1011
"github.com/BackendStack21/kode/internal/tool"
1112
)
1213

1314
// Engine runs the agent loop: observe → think → act → repeat.
1415
type Engine struct {
1516
client *llm.Client
1617
registry *tool.Registry
18+
renderer *render.Renderer // optional: colored terminal output
1719
maxIter int
1820
system string
1921
}
2022

2123
// New creates a new loop Engine.
22-
func New(client *llm.Client, registry *tool.Registry, maxIterations int, systemMessage string) *Engine {
24+
func New(client *llm.Client, registry *tool.Registry, maxIterations int, systemMessage string, renderer *render.Renderer) *Engine {
2325
return &Engine{
2426
client: client,
2527
registry: registry,
28+
renderer: renderer,
2629
maxIter: maxIterations,
2730
system: systemMessage,
2831
}
@@ -46,6 +49,11 @@ func (e *Engine) Run(ctx context.Context, task string) (string, error) {
4649
default:
4750
}
4851

52+
// Render iteration header (1-indexed for humans)
53+
if e.renderer != nil {
54+
e.renderer.Iteration(i+1, e.maxIter)
55+
}
56+
4957
// THINK
5058
result, err := e.client.Call(ctx, messages, tools)
5159
if err != nil {
@@ -54,9 +62,17 @@ func (e *Engine) Run(ctx context.Context, task string) (string, error) {
5462

5563
// No tool calls = final answer
5664
if len(result.ToolCalls) == 0 {
65+
if e.renderer != nil {
66+
e.renderer.FinalAnswer(result.Content)
67+
}
5768
return result.Content, nil
5869
}
5970

71+
// Render the model's thinking (reasoning before tool calls)
72+
if e.renderer != nil && result.Content != "" {
73+
e.renderer.Thinking(result.Content)
74+
}
75+
6076
// Build assistant message with tool calls
6177
assistantMsg := llm.Message{
6278
Role: "assistant",
@@ -67,6 +83,10 @@ func (e *Engine) Run(ctx context.Context, task string) (string, error) {
6783

6884
// ACT: execute each tool call
6985
for _, tc := range result.ToolCalls {
86+
if e.renderer != nil {
87+
e.renderer.ToolCall(tc.Function.Name, tc.Function.Arguments)
88+
}
89+
7090
t := e.registry.Get(tc.Function.Name)
7191
output := fmt.Sprintf("error: tool %q not found", tc.Function.Name)
7292
if t != nil {
@@ -78,6 +98,10 @@ func (e *Engine) Run(ctx context.Context, task string) (string, error) {
7898
}
7999
}
80100

101+
if e.renderer != nil {
102+
e.renderer.ToolResult(output)
103+
}
104+
81105
messages = append(messages, llm.Message{
82106
Role: "tool",
83107
Content: output,

internal/loop/loop_test.go

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ type fakeTool struct {
1919
output string
2020
}
2121

22-
func (f *fakeTool) Name() string { return f.name }
23-
func (f *fakeTool) Description() string { return f.description }
22+
func (f *fakeTool) Name() string { return f.name }
23+
func (f *fakeTool) Description() string { return f.description }
2424
func (f *fakeTool) Schema() any {
2525
return map[string]any{
2626
"type": "object",
@@ -38,7 +38,7 @@ func TestEngine_Run_SimpleAnswer(t *testing.T) {
3838

3939
client := llm.New(server.URL, "sk-test", "test-model", "")
4040
registry := tool.NewRegistry(nil)
41-
engine := New(client, registry, 10, "")
41+
engine := New(client, registry, 10, "", nil)
4242

4343
result, err := engine.Run(context.Background(), "Say hello")
4444
if err != nil {
@@ -80,7 +80,7 @@ func TestEngine_Run_ToolCallLoop(t *testing.T) {
8080
echoTool := &fakeTool{name: "echo", description: "echoes input", output: "hello output"}
8181
registry := tool.NewRegistry([]tool.Tool{echoTool})
8282
client := llm.New(server.URL, "sk-test", "test-model", "")
83-
engine := New(client, registry, 10, "")
83+
engine := New(client, registry, 10, "", nil)
8484

8585
result, err := engine.Run(context.Background(), "Echo hello")
8686
if err != nil {
@@ -117,7 +117,7 @@ func TestEngine_Run_MaxIterations(t *testing.T) {
117117
echoTool := &fakeTool{name: "echo", description: "echo", output: "ok"}
118118
registry := tool.NewRegistry([]tool.Tool{echoTool})
119119
client := llm.New(server.URL, "sk-test", "test-model", "")
120-
engine := New(client, registry, 3, "")
120+
engine := New(client, registry, 3, "", nil)
121121

122122
_, err := engine.Run(context.Background(), "Loop forever")
123123
if err == nil {
@@ -132,7 +132,7 @@ func TestEngine_Run_ContextCancellation(t *testing.T) {
132132
defer server.Close()
133133

134134
client := llm.New(server.URL, "sk-test", "test-model", "")
135-
engine := New(client, tool.NewRegistry(nil), 10, "")
135+
engine := New(client, tool.NewRegistry(nil), 10, "", nil)
136136

137137
ctx, cancel := context.WithCancel(context.Background())
138138
cancel() // cancel immediately
@@ -166,7 +166,7 @@ func TestEngine_Run_SystemMessage(t *testing.T) {
166166
defer server.Close()
167167

168168
client := llm.New(server.URL, "sk-test", "test-model", "")
169-
engine := New(client, tool.NewRegistry(nil), 10, "You are a test bot.")
169+
engine := New(client, tool.NewRegistry(nil), 10, "You are a test bot.", nil)
170170

171171
result, err := engine.Run(context.Background(), "hi")
172172
if err != nil {
@@ -198,7 +198,7 @@ func TestEngine_Run_ToolNotFound(t *testing.T) {
198198

199199
// No tools registered — the tool call will fail
200200
client := llm.New(server.URL, "sk-test", "test-model", "")
201-
engine := New(client, tool.NewRegistry(nil), 10, "")
201+
engine := New(client, tool.NewRegistry(nil), 10, "", nil)
202202

203203
// The loop should handle the missing tool gracefully — the tool error
204204
// is fed back to the model as a tool response message. The test server
@@ -214,7 +214,7 @@ func TestEngine_BuildToolDefs(t *testing.T) {
214214
t2 := &fakeTool{name: "write", description: "write files"}
215215
registry := tool.NewRegistry([]tool.Tool{t1, t2})
216216

217-
engine := New(nil, registry, 10, "")
217+
engine := New(nil, registry, 10, "", nil)
218218
defs := engine.buildToolDefs()
219219

220220
if len(defs) != 2 {
@@ -239,7 +239,7 @@ func TestEngine_BuildToolDefs_StringSchema(t *testing.T) {
239239
st := &stringSchemaTool{name: "custom", description: "custom tool", schemaStr: `{"type":"object"}`}
240240
registry := tool.NewRegistry([]tool.Tool{st})
241241

242-
engine := New(nil, registry, 10, "")
242+
engine := New(nil, registry, 10, "", nil)
243243
defs := engine.buildToolDefs()
244244

245245
if len(defs) != 1 {
@@ -254,7 +254,7 @@ func TestEngine_BuildToolDefs_EmptyStringSchema(t *testing.T) {
254254
st := &stringSchemaTool{name: "empty", description: "empty", schemaStr: ""}
255255
registry := tool.NewRegistry([]tool.Tool{st})
256256

257-
engine := New(nil, registry, 10, "")
257+
engine := New(nil, registry, 10, "", nil)
258258
defs := engine.buildToolDefs()
259259

260260
if len(defs) != 1 {
@@ -270,9 +270,9 @@ type stringSchemaTool struct {
270270
schemaStr string
271271
}
272272

273-
func (s *stringSchemaTool) Name() string { return s.name }
274-
func (s *stringSchemaTool) Description() string { return s.description }
275-
func (s *stringSchemaTool) Schema() any { return s.schemaStr }
273+
func (s *stringSchemaTool) Name() string { return s.name }
274+
func (s *stringSchemaTool) Description() string { return s.description }
275+
func (s *stringSchemaTool) Schema() any { return s.schemaStr }
276276
func (s *stringSchemaTool) Call(args string) (string, error) { return "ok", nil }
277277

278278
// Test context cancellation inside the iteration loop (not before start).
@@ -304,7 +304,7 @@ func TestEngine_Run_ContextCancelDuringLoop(t *testing.T) {
304304
echoTool := &fakeTool{name: "echo", description: "echo", output: "ok"}
305305
registry := tool.NewRegistry([]tool.Tool{echoTool})
306306
client := llm.New(server.URL, "sk-test", "test-model", "")
307-
engine := New(client, registry, 10, "")
307+
engine := New(client, registry, 10, "", nil)
308308

309309
_, err := engine.Run(ctx, "task")
310310
if err == nil {
@@ -335,7 +335,7 @@ func TestEngine_Run_ToolCallError(t *testing.T) {
335335
failingTool := &errorTool{name: "failing", description: "always fails"}
336336
registry := tool.NewRegistry([]tool.Tool{failingTool})
337337
client := llm.New(server.URL, "sk-test", "test-model", "")
338-
engine := New(client, registry, 10, "")
338+
engine := New(client, registry, 10, "", nil)
339339

340340
// Tool error is fed back as a tool response; server only returns one
341341
// response, so we hit max iterations.

0 commit comments

Comments
 (0)