Skip to content

Commit 13e160a

Browse files
committed
feat: emoji-driven terminal UI β€” 🧠 πŸ”§ βœ… ❌ icon anchors for each phase
Rendering overhaul: - ⚑ Start header with kode brand, model name, task preview - ═══ Iter N/M Β· model ═══ iteration divider (double-line Unicode) - 🧠 Thinking text (dim italic, no label) - πŸ”§ Tool call with ─── separator to args - Tool results stay compact: gray, 1 line + … - βœ… Final answer (bold green) - ❌ Error (red) Removed old label()-based rendering. Each method is self-contained. Added render.Start(task) called from CLI before agent.Run.
1 parent e17f920 commit 13e160a

3 files changed

Lines changed: 104 additions & 62 deletions

File tree

β€Žcmd/kode/main.goβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ func run(args []string) error {
166166
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
167167
defer cancel()
168168

169+
rend.Start(f.Task)
169170
result, err := agent.Run(ctx, f.Task)
170171
if err != nil {
171172
return err

β€Žinternal/render/render.goβ€Ž

Lines changed: 35 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Package render provides colored terminal rendering for the kode agent loop.
1+
// Package render provides emoji-driven terminal rendering for the kode agent loop.
22
//
33
// It produces structured output for each phase of the ReAct cycle:
44
// thinking, tool calls, tool results, and the final answer. When a Renderer
@@ -8,8 +8,10 @@
88
// # Design
99
//
1010
// - Zero dependencies. Uses ANSI escape codes directly.
11+
// - Emoji icons as visual anchors (🧠 πŸ”§ βœ… ❌).
1112
// - Color detection: respects NO_COLOR env var and tty detection.
12-
// - Truncation: tool output is truncated to prevent flooding the terminal.
13+
// - Truncation: tool output is collapsed to one line to keep the
14+
// terminal scannable during multi-step tool chains.
1315
// - Maintainable: each rendering method is self-contained; adding a new
1416
// event type requires one constant + one method.
1517
package render
@@ -63,8 +65,6 @@ func (e Event) String() string {
6365

6466
// ── ANSI Styles ───────────────────────────────────────────────────────
6567

66-
// Style constants. Use the method form (e.g., r.dim(...)) so we can
67-
// skip codes when color is disabled.
6868
const (
6969
reset = "\033[0m"
7070
bold = "\033[1m"
@@ -81,19 +81,13 @@ const (
8181

8282
// ── Renderer ──────────────────────────────────────────────────────────
8383

84-
// MaxToolOutput is the maximum number of characters to print from tool results.
85-
// Output longer than this is truncated with an ellipsis.
86-
const MaxToolOutput = 2000
87-
8884
// Renderer writes formatted agent loop output to an io.Writer.
8985
// The zero value is usable but won't produce any output β€” call New()
9086
// to create a properly initialized Renderer.
9187
type Renderer struct {
9288
w io.Writer
9389
color bool
9490
model string
95-
iter int // current iteration number, set by Iteration()
96-
maxN int // max iterations, set by Iteration()
9791
}
9892

9993
// New creates a Renderer that writes to w. If color is false, ANSI escape
@@ -121,44 +115,55 @@ func (r *Renderer) disable() bool {
121115

122116
// ── Rendering methods ─────────────────────────────────────────────────
123117

124-
// Iteration prints the cycle header: "━━ iter 3/90 Β· deepseek-chat ━━".
118+
// Start prints the session header with task preview.
119+
func (r *Renderer) Start(task string) {
120+
if r.disable() {
121+
return
122+
}
123+
preview := r.truncate(strings.ReplaceAll(task, "\n", " "), 80)
124+
prefix := "⚑ kode"
125+
if r.model != "" {
126+
prefix += " Β· " + r.model
127+
}
128+
fmt.Fprintln(r.w, r.style(bold+blue, prefix))
129+
fmt.Fprintln(r.w, r.style(gray, " "+preview))
130+
fmt.Fprintln(r.w)
131+
}
132+
133+
// Iteration prints the cycle header.
125134
func (r *Renderer) Iteration(n, maxN int) {
126135
if r.disable() {
127136
return
128137
}
129-
r.iter, r.maxN = n, maxN
130-
// Build prefix: iter N/M Β· model
131138
var prefix string
132139
if r.model != "" {
133-
prefix = fmt.Sprintf("iter %d/%d Β· %s", n, maxN, r.model)
140+
prefix = fmt.Sprintf("Iter %d/%d Β· %s", n, maxN, r.model)
134141
} else {
135-
prefix = fmt.Sprintf("iter %d/%d", n, maxN)
142+
prefix = fmt.Sprintf("Iter %d/%d", n, maxN)
136143
}
137-
138-
// Horizontal rule framing
139-
bar := strings.Repeat("━", 4)
140-
line := fmt.Sprintf("%s %s %s", bar, prefix, bar)
144+
// Double-line rule framing
145+
rule := strings.Repeat("═", 3)
146+
line := fmt.Sprintf("%s %s %s", rule, prefix, rule)
141147
fmt.Fprintln(r.w)
142148
fmt.Fprintln(r.w, r.style(bold+blue, line))
143149
}
144150

145-
// Thinking prints the model's reasoning text in a dimmed, italic style.
146-
// This is the "thinking aloud" before the model decides on tool calls.
151+
// Thinking prints the model's reasoning text with a brain emoji.
147152
func (r *Renderer) Thinking(text string) {
148153
if r.disable() || text == "" {
149154
return
150155
}
151-
r.label("thinking", dim+italic, text)
156+
fmt.Fprintln(r.w, r.style(dim+italic, "🧠 "+text))
152157
}
153158

154-
// ToolCall prints a tool invocation with name and arguments.
155-
// Arguments are JSON β€” we pretty-print the name and show a compact arg summary.
159+
// ToolCall prints a tool invocation with wrench emoji, name, and compact args.
156160
func (r *Renderer) ToolCall(name, args string) {
157161
if r.disable() {
158162
return
159163
}
160-
label := r.style(cyan+bold, "βš™ "+name)
161-
fmt.Fprintf(r.w, "%s %s\n", label, r.style(gray, r.truncate(args, 120)))
164+
header := r.style(cyan+bold, "πŸ”§ "+name)
165+
argStr := r.style(gray, "─── "+r.truncate(args, 100))
166+
fmt.Fprintf(r.w, "%s %s\n", header, argStr)
162167
}
163168

164169
// ToolResult prints a one-line summary of the tool output in gray.
@@ -177,40 +182,26 @@ func (r *Renderer) ToolResult(output string) {
177182
fmt.Fprintf(r.w, "%s\n", r.style(gray, " "+summary))
178183
}
179184

180-
// FinalAnswer prints the model's concluding response.
185+
// FinalAnswer prints the model's concluding response with a checkmark emoji.
181186
func (r *Renderer) FinalAnswer(text string) {
182187
if r.disable() || text == "" {
183188
return
184189
}
185190
fmt.Fprintln(r.w)
186-
r.label("answer", bold, text)
191+
fmt.Fprintln(r.w, r.style(bold+green, "βœ… "+text))
187192
fmt.Fprintln(r.w)
188193
}
189194

190-
// Error prints a non-fatal loop error.
195+
// Error prints a non-fatal loop error with a cross emoji.
191196
func (r *Renderer) Error(err error) {
192197
if r.disable() || err == nil {
193198
return
194199
}
195-
msg := r.style(red, "error: "+err.Error())
196-
fmt.Fprintln(r.w, msg)
200+
fmt.Fprintln(r.w, r.style(red, "❌ "+err.Error()))
197201
}
198202

199203
// ── Helpers ───────────────────────────────────────────────────────────
200204

201-
// label prints a labeled block: "─label──" followed by indented text.
202-
func (r *Renderer) label(name, style, text string) {
203-
header := r.style(style, "── "+name+" ──")
204-
fmt.Fprintln(r.w, header)
205-
// Indent each line of text
206-
for _, line := range strings.Split(text, "\n") {
207-
if strings.TrimSpace(line) == "" {
208-
continue
209-
}
210-
fmt.Fprintln(r.w, " "+r.truncate(line, MaxToolOutput))
211-
}
212-
}
213-
214205
// style wraps text in ANSI codes. Returns plain text when color is off.
215206
func (r *Renderer) style(code, text string) string {
216207
if !r.color {

β€Žinternal/render/render_test.goβ€Ž

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,46 @@ import (
77
"testing"
88
)
99

10+
func TestRenderer_Start(t *testing.T) {
11+
var buf bytes.Buffer
12+
r := New(&buf, true).WithModel("deepseek-chat")
13+
14+
r.Start("list all files in this directory")
15+
16+
out := buf.String()
17+
if !strings.Contains(out, "kode") {
18+
t.Errorf("Start() missing kode brand: %q", out)
19+
}
20+
if !strings.Contains(out, "deepseek-chat") {
21+
t.Errorf("Start() missing model name: %q", out)
22+
}
23+
if !strings.Contains(out, "list all files") {
24+
t.Errorf("Start() missing task preview: %q", out)
25+
}
26+
}
27+
28+
func TestRenderer_Start_LongTask(t *testing.T) {
29+
var buf bytes.Buffer
30+
r := New(&buf, true).WithModel("deepseek-chat")
31+
32+
longTask := strings.Repeat("explain this code in great detail ", 10)
33+
r.Start(longTask)
34+
35+
out := buf.String()
36+
// Task preview should be truncated to ~80 chars
37+
if strings.Count(out, "explain") > 6 {
38+
t.Errorf("Start() should truncate long task: %q", out)
39+
}
40+
}
41+
1042
func TestRenderer_Iteration(t *testing.T) {
1143
var buf bytes.Buffer
1244
r := New(&buf, true).WithModel("deepseek-chat")
1345

1446
r.Iteration(3, 90)
1547

1648
out := buf.String()
17-
if !strings.Contains(out, "iter 3/90") {
49+
if !strings.Contains(out, "Iter 3/90") {
1850
t.Errorf("Iteration() missing iteration info: %q", out)
1951
}
2052
if !strings.Contains(out, "deepseek-chat") {
@@ -29,7 +61,7 @@ func TestRenderer_Iteration_NoModel(t *testing.T) {
2961
r.Iteration(1, 10)
3062

3163
out := buf.String()
32-
if !strings.Contains(out, "iter 1/10") {
64+
if !strings.Contains(out, "Iter 1/10") {
3365
t.Errorf("Iteration() missing iteration info: %q", out)
3466
}
3567
}
@@ -41,12 +73,12 @@ func TestRenderer_Thinking(t *testing.T) {
4173
r.Thinking("Let me check the file contents first.")
4274

4375
out := buf.String()
76+
if !strings.Contains(out, "🧠") {
77+
t.Errorf("Thinking() missing brain emoji: %q", out)
78+
}
4479
if !strings.Contains(out, "Let me check the file contents first.") {
4580
t.Errorf("Thinking() missing content: %q", out)
4681
}
47-
if !strings.Contains(out, "thinking") {
48-
t.Errorf("Thinking() missing label: %q", out)
49-
}
5082
}
5183

5284
func TestRenderer_Thinking_Empty(t *testing.T) {
@@ -67,6 +99,9 @@ func TestRenderer_ToolCall(t *testing.T) {
6799
r.ToolCall("shell", `{"command": "ls -la"}`)
68100

69101
out := buf.String()
102+
if !strings.Contains(out, "πŸ”§") {
103+
t.Errorf("ToolCall() missing wrench emoji: %q", out)
104+
}
70105
if !strings.Contains(out, "shell") {
71106
t.Errorf("ToolCall() missing tool name: %q", out)
72107
}
@@ -83,8 +118,9 @@ func TestRenderer_ToolCall_TruncatedArgs(t *testing.T) {
83118
r.ToolCall("read", longArgs)
84119

85120
out := buf.String()
86-
if len(out) > len(longArgs)+100 {
87-
t.Errorf("ToolCall() should truncate long args, got %d chars", len(out))
121+
// Args truncated to 100 chars
122+
if strings.Count(out, "x") >= 200 {
123+
t.Errorf("ToolCall() should truncate long args, got %d x chars", strings.Count(out, "x"))
88124
}
89125
if !strings.Contains(out, "…") {
90126
t.Errorf("ToolCall() missing truncation ellipsis: %q", out)
@@ -134,7 +170,7 @@ func TestRenderer_ToolResult_GrayColor(t *testing.T) {
134170
r.ToolResult("result text")
135171

136172
out := buf.String()
137-
// Should use gray (dim), not green
173+
// Should use gray, not green
138174
if strings.Contains(out, green) {
139175
t.Errorf("ToolResult() should use gray, not green: %q", out)
140176
}
@@ -175,12 +211,12 @@ func TestRenderer_FinalAnswer(t *testing.T) {
175211
r.FinalAnswer("The answer is 42.")
176212

177213
out := buf.String()
214+
if !strings.Contains(out, "βœ…") {
215+
t.Errorf("FinalAnswer() missing check emoji: %q", out)
216+
}
178217
if !strings.Contains(out, "The answer is 42.") {
179218
t.Errorf("FinalAnswer() missing content: %q", out)
180219
}
181-
if !strings.Contains(out, "answer") {
182-
t.Errorf("FinalAnswer() missing label: %q", out)
183-
}
184220
}
185221

186222
func TestRenderer_FinalAnswer_Empty(t *testing.T) {
@@ -201,6 +237,9 @@ func TestRenderer_Error(t *testing.T) {
201237
r.Error(errors.New("something went wrong"))
202238

203239
out := buf.String()
240+
if !strings.Contains(out, "❌") {
241+
t.Errorf("Error() missing cross emoji: %q", out)
242+
}
204243
if !strings.Contains(out, "something went wrong") {
205244
t.Errorf("Error() missing message: %q", out)
206245
}
@@ -227,7 +266,7 @@ func TestRenderer_NoColor(t *testing.T) {
227266
if strings.Contains(out, "\033[") {
228267
t.Errorf("NoColor should strip ANSI codes, got: %q", out)
229268
}
230-
if !strings.Contains(out, "iter 1/5") {
269+
if !strings.Contains(out, "Iter 1/5") {
231270
t.Errorf("NoColor should still render text: %q", out)
232271
}
233272
}
@@ -236,6 +275,7 @@ func TestRenderer_NilWriter(t *testing.T) {
236275
r := New(nil, true)
237276

238277
// None of these should panic
278+
r.Start("task")
239279
r.Iteration(1, 5)
240280
r.Thinking("hello")
241281
r.ToolCall("shell", "{}")
@@ -248,6 +288,7 @@ func TestRenderer_NilRenderer(t *testing.T) {
248288
var r *Renderer
249289

250290
// None of these should panic on nil receiver
291+
r.Start("task")
251292
r.Iteration(1, 5)
252293
r.Thinking("hello")
253294
r.ToolCall("shell", "{}")
@@ -260,7 +301,8 @@ func TestRenderer_FullCycle(t *testing.T) {
260301
var buf bytes.Buffer
261302
r := New(&buf, true).WithModel("deepseek-chat")
262303

263-
// Simulate one full iteration
304+
// Simulate one full session
305+
r.Start("what files are here?")
264306
r.Iteration(1, 90)
265307
r.Thinking("I need to read the file to understand its contents.")
266308
r.ToolCall("shell", `{"command": "cat main.go"}`)
@@ -270,11 +312,19 @@ func TestRenderer_FullCycle(t *testing.T) {
270312

271313
out := buf.String()
272314

273-
// Verify each phase is present
274-
phases := []string{"iter 1/90", "thinking", "shell", "package main", "iter 2/90", "answer"}
275-
for _, phase := range phases {
276-
if !strings.Contains(strings.ToLower(out), phase) {
277-
t.Errorf("FullCycle missing phase %q in output:\n%s", phase, out)
315+
// Verify each phase is present via its emoji
316+
emojis := []string{"🧠", "πŸ”§", "βœ…"}
317+
for _, emoji := range emojis {
318+
if !strings.Contains(out, emoji) {
319+
t.Errorf("FullCycle missing emoji %q in output:\n%s", emoji, out)
320+
}
321+
}
322+
323+
// Verify key text
324+
texts := []string{"Iter 1/90", "shell", "package main", "Iter 2/90"}
325+
for _, text := range texts {
326+
if !strings.Contains(out, text) {
327+
t.Errorf("FullCycle missing text %q in output:\n%s", text, out)
278328
}
279329
}
280330

0 commit comments

Comments
Β (0)