Skip to content

Commit f860da4

Browse files
authored
Merge pull request #10 from sky-xo/pretty
feat(tui): rich formatting for Codex and Gemini tool logs
2 parents 66d3d4b + de1bee4 commit f860da4

6 files changed

Lines changed: 462 additions & 19 deletions

File tree

internal/codex/transcript.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import (
1010

1111
// TranscriptEntry represents a parsed entry from a Codex session file
1212
type TranscriptEntry struct {
13-
Type string
14-
Content string
13+
Type string
14+
Content string
15+
ToolName string // Tool name for function_call entries
16+
ToolInput map[string]interface{} // Tool arguments for function_call entries
1517
}
1618

1719
// ReadTranscript reads a Codex session file from the given line offset
@@ -84,8 +86,24 @@ func parseEntry(data []byte) TranscriptEntry {
8486
// Skip agent_message - it duplicates "message" response_item
8587
case "function_call":
8688
// response_item with payload.type = "function_call", payload.name = tool name
87-
if name, ok := payload["name"].(string); ok {
88-
return TranscriptEntry{Type: "tool", Content: fmt.Sprintf("[tool: %s]", name)}
89+
name, _ := payload["name"].(string)
90+
if name == "" {
91+
return TranscriptEntry{}
92+
}
93+
94+
// Parse arguments JSON string into map
95+
var toolInput map[string]interface{}
96+
if argsStr, ok := payload["arguments"].(string); ok && argsStr != "" {
97+
if err := json.Unmarshal([]byte(argsStr), &toolInput); err != nil {
98+
fmt.Fprintf(os.Stderr, "codex: failed to unmarshal tool arguments for %s: %v\n", name, err)
99+
}
100+
}
101+
102+
return TranscriptEntry{
103+
Type: "tool",
104+
Content: fmt.Sprintf("[tool: %s]", name), // Keep for backwards compat
105+
ToolName: name,
106+
ToolInput: toolInput,
89107
}
90108
case "function_call_output":
91109
// response_item with payload.type = "function_call_output", payload.output = result

internal/codex/transcript_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,48 @@ func TestParseEntryFunctionCall(t *testing.T) {
3636
}
3737
}
3838

39+
func TestParseEntryFunctionCallWithArguments(t *testing.T) {
40+
data := []byte(`{"type":"response_item","payload":{"type":"function_call","name":"shell_command","arguments":"{\"command\":\"go test ./...\",\"workdir\":\"/tmp\"}"}}`)
41+
42+
entry := parseEntry(data)
43+
44+
if entry.Type != "tool" {
45+
t.Errorf("Type = %q, want %q", entry.Type, "tool")
46+
}
47+
if entry.ToolName != "shell_command" {
48+
t.Errorf("ToolName = %q, want %q", entry.ToolName, "shell_command")
49+
}
50+
if entry.ToolInput == nil {
51+
t.Fatal("ToolInput is nil, want map with command")
52+
}
53+
if cmd, ok := entry.ToolInput["command"].(string); !ok || cmd != "go test ./..." {
54+
t.Errorf("ToolInput[command] = %v, want %q", entry.ToolInput["command"], "go test ./...")
55+
}
56+
}
57+
58+
func TestParseEntryFunctionCallMalformedArguments(t *testing.T) {
59+
// Malformed JSON in arguments field - should handle gracefully (not panic)
60+
data := []byte(`{"type":"response_item","payload":{"type":"function_call","name":"shell_command","arguments":"{invalid json here"}}`)
61+
62+
entry := parseEntry(data)
63+
64+
// Should still return a valid tool entry
65+
if entry.Type != "tool" {
66+
t.Errorf("Type = %q, want %q", entry.Type, "tool")
67+
}
68+
if entry.ToolName != "shell_command" {
69+
t.Errorf("ToolName = %q, want %q", entry.ToolName, "shell_command")
70+
}
71+
// ToolInput should be nil when JSON parsing fails
72+
if entry.ToolInput != nil {
73+
t.Errorf("ToolInput = %v, want nil for malformed JSON", entry.ToolInput)
74+
}
75+
// Content should still be populated
76+
if entry.Content != "[tool: shell_command]" {
77+
t.Errorf("Content = %q, want %q", entry.Content, "[tool: shell_command]")
78+
}
79+
}
80+
3981
func TestParseEntryFunctionCallOutput(t *testing.T) {
4082
// Actual Codex format: type is "response_item", payload.type is "function_call_output"
4183
data := []byte(`{"type":"response_item","payload":{"type":"function_call_output","output":"Exit code: 0\nOutput: hello"}}`)

internal/gemini/transcript.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import (
1010

1111
// TranscriptEntry represents a parsed entry from a Gemini session file
1212
type TranscriptEntry struct {
13-
Type string
14-
Content string
13+
Type string
14+
Content string
15+
ToolName string // Tool name for tool_use entries
16+
ToolInput map[string]interface{} // Tool parameters for tool_use entries
1517
}
1618

1719
// ReadTranscript reads a Gemini session file from the given line offset.
@@ -73,12 +75,13 @@ func ReadTranscript(path string, fromLine int) ([]TranscriptEntry, int, error) {
7375

7476
func parseEntry(data []byte) TranscriptEntry {
7577
var raw struct {
76-
Type string `json:"type"`
77-
Role string `json:"role"`
78-
Content string `json:"content"`
79-
ToolName string `json:"tool_name"`
80-
Output string `json:"output"`
81-
Status string `json:"status"`
78+
Type string `json:"type"`
79+
Role string `json:"role"`
80+
Content string `json:"content"`
81+
ToolName string `json:"tool_name"`
82+
Output string `json:"output"`
83+
Status string `json:"status"`
84+
Parameters map[string]interface{} `json:"parameters"`
8285
}
8386
if err := json.Unmarshal(data, &raw); err != nil {
8487
return TranscriptEntry{}
@@ -93,7 +96,12 @@ func parseEntry(data []byte) TranscriptEntry {
9396
return TranscriptEntry{Type: "message", Content: raw.Content}
9497

9598
case "tool_use":
96-
return TranscriptEntry{Type: "tool", Content: fmt.Sprintf("[tool: %s]", raw.ToolName)}
99+
return TranscriptEntry{
100+
Type: "tool",
101+
Content: fmt.Sprintf("[tool: %s]", raw.ToolName), // Keep for backwards compat
102+
ToolName: raw.ToolName,
103+
ToolInput: raw.Parameters,
104+
}
97105

98106
case "tool_result":
99107
output := raw.Output

internal/gemini/transcript_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,21 @@ func TestReadTranscriptAccumulatesDeltas(t *testing.T) {
111111
t.Errorf("entries[2].Type = %q, want tool", entries[2].Type)
112112
}
113113
}
114+
115+
func TestParseEntryToolUseWithParameters(t *testing.T) {
116+
data := []byte(`{"type":"tool_use","timestamp":"...","tool_name":"read_file","tool_id":"abc","parameters":{"path":"main.go","encoding":"utf-8"}}`)
117+
entry := parseEntry(data)
118+
119+
if entry.Type != "tool" {
120+
t.Errorf("Type = %q, want %q", entry.Type, "tool")
121+
}
122+
if entry.ToolName != "read_file" {
123+
t.Errorf("ToolName = %q, want %q", entry.ToolName, "read_file")
124+
}
125+
if entry.ToolInput == nil {
126+
t.Fatal("ToolInput is nil, want map with path")
127+
}
128+
if path, ok := entry.ToolInput["path"].(string); !ok || path != "main.go" {
129+
t.Errorf("ToolInput[path] = %v, want %q", entry.ToolInput["path"], "main.go")
130+
}
131+
}

internal/tui/commands.go

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,72 @@ func loadTranscriptCmd(a agent.Agent) tea.Cmd {
8181
}
8282
}
8383

84+
// normalizeCodexTool converts Codex tool names/params to Claude equivalents
85+
func normalizeCodexTool(name string, input map[string]interface{}) (string, map[string]interface{}) {
86+
normalized := make(map[string]interface{})
87+
for k, v := range input {
88+
normalized[k] = v
89+
}
90+
91+
switch name {
92+
case "shell_command":
93+
return "Bash", normalized
94+
case "read_file":
95+
if path, ok := normalized["path"]; ok {
96+
delete(normalized, "path")
97+
normalized["file_path"] = path
98+
}
99+
return "Read", normalized
100+
case "write_file":
101+
if path, ok := normalized["path"]; ok {
102+
delete(normalized, "path")
103+
normalized["file_path"] = path
104+
}
105+
return "Write", normalized
106+
case "edit_file":
107+
if path, ok := normalized["path"]; ok {
108+
delete(normalized, "path")
109+
normalized["file_path"] = path
110+
}
111+
return "Edit", normalized
112+
default:
113+
return name, normalized
114+
}
115+
}
116+
117+
// normalizeGeminiTool converts Gemini tool names/params to Claude equivalents
118+
func normalizeGeminiTool(name string, input map[string]interface{}) (string, map[string]interface{}) {
119+
normalized := make(map[string]interface{})
120+
for k, v := range input {
121+
normalized[k] = v
122+
}
123+
124+
switch name {
125+
case "shell":
126+
return "Bash", normalized
127+
case "read_file":
128+
if path, ok := normalized["path"]; ok {
129+
delete(normalized, "path")
130+
normalized["file_path"] = path
131+
}
132+
return "Read", normalized
133+
case "write_file":
134+
if path, ok := normalized["path"]; ok {
135+
delete(normalized, "path")
136+
normalized["file_path"] = path
137+
}
138+
return "Write", normalized
139+
case "edit_file":
140+
if path, ok := normalized["path"]; ok {
141+
delete(normalized, "path")
142+
normalized["file_path"] = path
143+
}
144+
return "Edit", normalized
145+
default:
146+
return name, normalized
147+
}
148+
}
149+
84150
// convertCodexEntries converts Codex transcript entries to Claude entry format for TUI display.
85151
func convertCodexEntries(codexEntries []codex.TranscriptEntry) []claude.Entry {
86152
entries := make([]claude.Entry, 0, len(codexEntries))
@@ -116,15 +182,17 @@ func convertCodexEntries(codexEntries []codex.TranscriptEntry) []claude.Entry {
116182
},
117183
}
118184
case "tool":
119-
// Codex tool call -> Claude assistant with tool_use (displayed as summary)
185+
// Normalize Codex tool name/params to Claude equivalents for rich formatting
186+
normalizedName, normalizedInput := normalizeCodexTool(ce.ToolName, ce.ToolInput)
120187
entry = claude.Entry{
121188
Type: "assistant",
122189
Message: claude.Message{
123190
Role: "assistant",
124191
Content: []interface{}{
125192
map[string]interface{}{
126-
"type": "text",
127-
"text": ce.Content, // Already formatted as "[tool: name]"
193+
"type": "tool_use",
194+
"name": normalizedName,
195+
"input": normalizedInput,
128196
},
129197
},
130198
},
@@ -186,15 +254,17 @@ func convertGeminiEntries(geminiEntries []gemini.TranscriptEntry) []claude.Entry
186254
},
187255
}
188256
case "tool":
189-
// Gemini tool call -> Claude assistant with tool info
257+
// Normalize Gemini tool name/params to Claude equivalents for rich formatting
258+
normalizedName, normalizedInput := normalizeGeminiTool(ge.ToolName, ge.ToolInput)
190259
entry = claude.Entry{
191260
Type: "assistant",
192261
Message: claude.Message{
193262
Role: "assistant",
194263
Content: []interface{}{
195264
map[string]interface{}{
196-
"type": "text",
197-
"text": ge.Content, // Already formatted as "[tool: name]"
265+
"type": "tool_use",
266+
"name": normalizedName,
267+
"input": normalizedInput,
198268
},
199269
},
200270
},

0 commit comments

Comments
 (0)