Skip to content

Commit c6a161c

Browse files
committed
feat: add visual feedback for AI thinking and tool usage
- Show thinking messages by default (not just verbose mode) - Add animated spinner during AI processing and tool execution - Improve tool call display with contextual input formatting - Add activity indicator for task transitions - Use emoji symbols for better visual hierarchy (💭 🔧 ▶) Signed-off-by: leocavalcante <[email protected]>
1 parent 0b056a2 commit c6a161c

File tree

3 files changed

+129
-11
lines changed

3 files changed

+129
-11
lines changed

src/builder.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ export class Builder {
219219
const { providerID, modelID } = parseModel(model)
220220

221221
this.logger.logVerbose(`${phase} with ${model}...`)
222+
this.logger.startSpinner(`${phase}...`)
222223

223224
const result = await this.client.session.prompt({
224225
path: { id: sessionId },
@@ -228,6 +229,8 @@ export class Builder {
228229
},
229230
})
230231

232+
this.logger.stopSpinner()
233+
231234
if (!result.data) {
232235
throw new Error("No response from OpenCode")
233236
}
@@ -280,6 +283,7 @@ export class Builder {
280283
// Stream text output in real-time
281284
const text = properties?.text
282285
if (typeof text === "string") {
286+
this.logger.stopSpinner()
283287
this.logger.stream(text)
284288
}
285289
break
@@ -289,13 +293,16 @@ export class Builder {
289293
const name = properties?.name
290294
const input = properties?.input
291295
if (typeof name === "string") {
296+
this.logger.stopSpinner()
292297
this.logger.streamEnd()
293298
this.logger.toolCall(name, input)
299+
this.logger.startSpinner(`Running ${name}...`)
294300
}
295301
break
296302
}
297303

298304
case "message.part.tool.result": {
305+
this.logger.stopSpinner()
299306
const output = properties?.output
300307
if (typeof output === "string" && output.length > 0) {
301308
this.logger.toolResult(output)
@@ -306,12 +313,14 @@ export class Builder {
306313
case "message.part.thinking": {
307314
const text = properties?.text
308315
if (typeof text === "string") {
316+
this.logger.stopSpinner()
309317
this.logger.thinking(text)
310318
}
311319
break
312320
}
313321

314322
case "message.complete": {
323+
this.logger.stopSpinner()
315324
this.logger.streamEnd()
316325
const usage = properties?.usage as { input?: number; output?: number } | undefined
317326
if (usage?.input !== undefined && usage?.output !== undefined) {
@@ -321,6 +330,7 @@ export class Builder {
321330
}
322331

323332
case "message.error": {
333+
this.logger.stopSpinner()
324334
const message = properties?.message
325335
if (typeof message === "string") {
326336
this.logger.logError(message)
@@ -331,6 +341,7 @@ export class Builder {
331341
case "session.complete":
332342
case "session.abort":
333343
// Session ended
344+
this.logger.stopSpinner()
334345
this.logger.logVerbose(`Session ${type}`)
335346
break
336347

src/logger.ts

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,38 @@ const ANSI = {
1111
reset: "\x1b[0m",
1212
bold: "\x1b[1m",
1313
dim: "\x1b[2m",
14+
italic: "\x1b[3m",
1415
red: "\x1b[31m",
1516
green: "\x1b[32m",
1617
yellow: "\x1b[33m",
1718
blue: "\x1b[34m",
19+
magenta: "\x1b[35m",
1820
cyan: "\x1b[36m",
21+
gray: "\x1b[90m",
1922
clearLine: "\r\x1b[K",
2023
}
2124

25+
/** Symbols for visual indicators */
26+
const SYMBOLS = {
27+
thinking: "💭",
28+
tool: "🔧",
29+
result: " →",
30+
success: "✓",
31+
error: "✗",
32+
arrow: "▶",
33+
spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
34+
}
35+
2236
export class Logger {
2337
private paths: Paths
2438
private verbose: boolean
2539
private cycleLogFile?: string
2640
private logBuffer: string[] = []
2741
private readonly BUFFER_SIZE = 2048
42+
private spinnerInterval?: ReturnType<typeof setInterval>
43+
private spinnerIndex = 0
44+
private currentSpinnerMessage = ""
45+
private isSpinning = false
2846

2947
constructor(paths: Paths, verbose: boolean) {
3048
this.paths = paths
@@ -145,28 +163,62 @@ export class Logger {
145163
* Log a tool call
146164
*/
147165
toolCall(name: string, input?: unknown): void {
148-
const inputStr = input ? `: ${JSON.stringify(input)}` : ""
149-
const truncated = inputStr.length > 100 ? `${inputStr.slice(0, 100)}...` : inputStr
150-
console.log(`${ANSI.cyan}[TOOL] ${name}${truncated}${ANSI.reset}`)
166+
this.stopSpinner()
167+
const inputStr = input ? this.formatToolInput(input) : ""
168+
console.log(
169+
`${ANSI.cyan}${SYMBOLS.tool} ${ANSI.bold}${name}${ANSI.reset}${ANSI.dim}${inputStr}${ANSI.reset}`,
170+
)
151171
this.writeToBuffer(this.formatForFile(`[TOOL] ${name}${inputStr}`))
152172
}
153173

174+
/**
175+
* Format tool input for display
176+
*/
177+
private formatToolInput(input: unknown): string {
178+
if (typeof input === "string") {
179+
return input.length > 80 ? ` ${input.slice(0, 80)}...` : ` ${input}`
180+
}
181+
if (typeof input === "object" && input !== null) {
182+
const obj = input as Record<string, unknown>
183+
// Show key parameters for common tools
184+
if ("filePath" in obj) return ` ${obj.filePath}`
185+
if ("path" in obj) return ` ${obj.path}`
186+
if ("pattern" in obj) return ` ${obj.pattern}`
187+
if ("command" in obj) {
188+
const cmd = String(obj.command)
189+
return cmd.length > 60 ? ` ${cmd.slice(0, 60)}...` : ` ${cmd}`
190+
}
191+
if ("query" in obj) return ` "${obj.query}"`
192+
// Fallback: stringify and truncate
193+
const str = JSON.stringify(input)
194+
return str.length > 80 ? ` ${str.slice(0, 80)}...` : ` ${str}`
195+
}
196+
return ""
197+
}
198+
154199
/**
155200
* Log a tool result
156201
*/
157202
toolResult(output: string): void {
158-
const truncated = output.length > 200 ? `${output.slice(0, 200)}...` : output
159-
console.log(`${ANSI.dim}[RESULT] ${truncated}${ANSI.reset}`)
203+
// Only show tool results in verbose mode - they can be noisy
204+
if (this.verbose) {
205+
const truncated = output.length > 200 ? `${output.slice(0, 200)}...` : output
206+
const firstLine = truncated.split("\n")[0] || truncated
207+
console.log(`${ANSI.gray}${SYMBOLS.result} ${firstLine}${ANSI.reset}`)
208+
}
160209
this.writeToBuffer(this.formatForFile(`[RESULT] ${output}`))
161210
}
162211

163212
/**
164-
* Log thinking/reasoning (only in verbose mode to console)
213+
* Log thinking/reasoning - shown by default for visibility
165214
*/
166215
thinking(text: string): void {
167-
if (this.verbose) {
168-
console.log(`${ANSI.dim}[THINKING] ${text}${ANSI.reset}`)
169-
}
216+
this.stopSpinner()
217+
// Show thinking in a visually distinct way
218+
const lines = text.split("\n")
219+
const firstLine = lines[0] || text
220+
const display = firstLine.length > 100 ? `${firstLine.slice(0, 100)}...` : firstLine
221+
console.log(`${ANSI.magenta}${SYMBOLS.thinking} ${ANSI.italic}${display}${ANSI.reset}`)
170222
this.writeToBuffer(this.formatForFile(`[THINKING] ${text}`))
171223
}
172224

@@ -181,6 +233,60 @@ export class Logger {
181233
this.writeToBuffer(this.formatForFile(msg))
182234
}
183235

236+
/**
237+
* Start a spinner with a message (for long-running operations)
238+
*/
239+
startSpinner(message: string): void {
240+
if (this.isSpinning) {
241+
this.stopSpinner()
242+
}
243+
244+
this.isSpinning = true
245+
this.currentSpinnerMessage = message
246+
this.spinnerIndex = 0
247+
248+
this.spinnerInterval = setInterval(() => {
249+
const frame = SYMBOLS.spinner[this.spinnerIndex % SYMBOLS.spinner.length]
250+
process.stdout.write(
251+
`${ANSI.clearLine}${ANSI.cyan}${frame}${ANSI.reset} ${this.currentSpinnerMessage}`,
252+
)
253+
this.spinnerIndex++
254+
}, 80)
255+
}
256+
257+
/**
258+
* Update spinner message without stopping it
259+
*/
260+
updateSpinner(message: string): void {
261+
if (this.isSpinning) {
262+
this.currentSpinnerMessage = message
263+
}
264+
}
265+
266+
/**
267+
* Stop the spinner and clear the line
268+
*/
269+
stopSpinner(): void {
270+
if (this.spinnerInterval) {
271+
clearInterval(this.spinnerInterval)
272+
this.spinnerInterval = undefined
273+
}
274+
if (this.isSpinning) {
275+
process.stdout.write(ANSI.clearLine)
276+
this.isSpinning = false
277+
}
278+
}
279+
280+
/**
281+
* Log activity indicator (brief visual feedback)
282+
*/
283+
activity(action: string, detail?: string): void {
284+
this.stopSpinner()
285+
const detailStr = detail ? ` ${ANSI.dim}${detail}${ANSI.reset}` : ""
286+
console.log(`${ANSI.blue}${SYMBOLS.arrow} ${action}${detailStr}${ANSI.reset}`)
287+
this.writeToBuffer(this.formatForFile(`[ACTIVITY] ${action} ${detail || ""}`))
288+
}
289+
184290
/**
185291
* Format message for file output with timestamp
186292
*/

src/loop.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export async function runLoop(config: Config): Promise<void> {
112112
// Cleanup
113113
logger.flush()
114114
await builder.shutdown()
115-
logger.say("Opencoder stopped.")
115+
logger.say("OpenCoder stopped.")
116116
}
117117
}
118118

@@ -123,7 +123,7 @@ function logStartupInfo(logger: Logger, config: Config): void {
123123
// Get version from build-time define or package.json
124124
const version = typeof VERSION !== "undefined" ? VERSION : "1.0.0"
125125

126-
logger.say(`\nOpencoder v${version}`)
126+
logger.say(`\nOpenCoder v${version}`)
127127
logger.say(`Project: ${config.projectDir}`)
128128
logger.say(`Plan model: ${config.planModel}`)
129129
logger.say(`Build model: ${config.buildModel}`)
@@ -277,6 +277,7 @@ async function runBuildPhase(
277277
if (shutdownRequested) return
278278

279279
// Build the task
280+
logger.activity("Building task", `${state.currentTaskNum}/${tasks.length}`)
280281
const result = await builder.runTask(
281282
nextTask.description,
282283
state.cycle,

0 commit comments

Comments
 (0)