Skip to content

Commit a1e0e1a

Browse files
authored
Merge pull request #3 from phiat/feature/enhanced-metadata
Add agent type labels, tool duration, and token usage display
2 parents e5dad78 + b564c70 commit a1e0e1a

File tree

6 files changed

+216
-57
lines changed

6 files changed

+216
-57
lines changed

internal/parser/parser.go

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,45 @@ const (
2222

2323
// StreamItem represents a single item in the output stream
2424
type StreamItem struct {
25-
Type StreamItemType
26-
SessionID string // which session this belongs to
27-
AgentID string // empty for main session, "abc123" for subagents
28-
AgentName string // human-readable name derived from agent type or ID
29-
Timestamp time.Time
30-
Content string
31-
ToolName string // for tool_input/tool_output
32-
ToolID string // to correlate input with output
25+
Type StreamItemType
26+
SessionID string // which session this belongs to
27+
AgentID string // empty for main session, "abc123" for subagents
28+
AgentName string // human-readable name derived from agent type or ID
29+
Timestamp time.Time
30+
Content string
31+
ToolName string // for tool_input/tool_output
32+
ToolID string // to correlate input with output
33+
DurationMs int64 // tool execution duration in ms (0 = not available)
34+
InputTokens int64 // usage.input_tokens from assistant messages
35+
OutputTokens int64 // usage.output_tokens from assistant messages
3336
}
3437

3538
// RawMessage represents a line from the JSONL file
3639
type RawMessage struct {
37-
Type string `json:"type"`
38-
AgentID string `json:"agentId,omitempty"`
39-
SessionID string `json:"sessionId"`
40-
Timestamp string `json:"timestamp"`
41-
Message json.RawMessage `json:"message"`
40+
Type string `json:"type"`
41+
AgentID string `json:"agentId,omitempty"`
42+
SessionID string `json:"sessionId"`
43+
Timestamp string `json:"timestamp"`
44+
Message json.RawMessage `json:"message"`
45+
ToolUseResult json.RawMessage `json:"toolUseResult,omitempty"`
46+
}
47+
48+
// RawToolUseResult represents the toolUseResult field on user messages
49+
type RawToolUseResult struct {
50+
DurationMs int64 `json:"durationMs"`
4251
}
4352

4453
// AssistantMessage represents the message field for assistant responses
4554
type AssistantMessage struct {
4655
Role string `json:"role"`
4756
Content []ContentBlock `json:"content"`
57+
Usage *UsageInfo `json:"usage,omitempty"`
58+
}
59+
60+
// UsageInfo represents token usage from assistant messages
61+
type UsageInfo struct {
62+
InputTokens int64 `json:"input_tokens"`
63+
OutputTokens int64 `json:"output_tokens"`
4864
}
4965

5066
// ContentBlock represents a single content item in assistant response
@@ -159,6 +175,12 @@ func parseAssistantMessage(raw RawMessage, timestamp time.Time) []StreamItem {
159175
}
160176
}
161177

178+
// Attach token usage to the first item only
179+
if len(items) > 0 && msg.Usage != nil {
180+
items[0].InputTokens = msg.Usage.InputTokens
181+
items[0].OutputTokens = msg.Usage.OutputTokens
182+
}
183+
162184
return items
163185
}
164186

@@ -171,6 +193,15 @@ func parseUserMessage(raw RawMessage, timestamp time.Time) []StreamItem {
171193
return nil
172194
}
173195

196+
// Parse toolUseResult for duration
197+
var durationMs int64
198+
if len(raw.ToolUseResult) > 0 {
199+
var tur RawToolUseResult
200+
if err := json.Unmarshal(raw.ToolUseResult, &tur); err == nil {
201+
durationMs = tur.DurationMs
202+
}
203+
}
204+
174205
var items []StreamItem
175206
agentName := "Main"
176207
if raw.AgentID != "" {
@@ -180,12 +211,13 @@ func parseUserMessage(raw RawMessage, timestamp time.Time) []StreamItem {
180211
for _, result := range results {
181212
if result.Type == "tool_result" {
182213
items = append(items, StreamItem{
183-
Type: TypeToolOutput,
184-
AgentID: raw.AgentID,
185-
AgentName: agentName,
186-
Timestamp: timestamp,
187-
Content: extractToolResultContent(result.Content),
188-
ToolID: result.ToolUseID,
214+
Type: TypeToolOutput,
215+
AgentID: raw.AgentID,
216+
AgentName: agentName,
217+
Timestamp: timestamp,
218+
Content: extractToolResultContent(result.Content),
219+
ToolID: result.ToolUseID,
220+
DurationMs: durationMs,
189221
})
190222
}
191223
}

internal/tui/model.go

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,21 @@ const (
2222

2323
// Model is the main TUI model
2424
type Model struct {
25-
tree *TreeView
26-
stream *StreamView
27-
watcher *watcher.Watcher
28-
focus Focus
29-
showTree bool
30-
width int
31-
height int
32-
treeWidth int
33-
sessionID string
34-
skipHistory bool
35-
pollInterval time.Duration
36-
err error
37-
quitting bool
25+
tree *TreeView
26+
stream *StreamView
27+
watcher *watcher.Watcher
28+
focus Focus
29+
showTree bool
30+
width int
31+
height int
32+
treeWidth int
33+
sessionID string
34+
skipHistory bool
35+
pollInterval time.Duration
36+
err error
37+
quitting bool
38+
totalInputTokens int64
39+
totalOutputTokens int64
3840
}
3941

4042
// NewModel creates a new TUI model
@@ -87,7 +89,8 @@ func (m *Model) initWatcher() tea.Cmd {
8789
for _, session := range w.GetSessions() {
8890
m.tree.AddSession(session.ID, session.ProjectPath)
8991
for agentID := range session.Subagents {
90-
m.tree.AddAgent(session.ID, agentID)
92+
agentType := session.SubagentTypes[agentID]
93+
m.tree.AddAgent(session.ID, agentID, agentType)
9194
}
9295
}
9396

@@ -125,11 +128,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
125128
m.updateActivityStatus()
126129

127130
case streamItemMsg:
128-
m.stream.AddItem(parser.StreamItem(msg))
131+
item := parser.StreamItem(msg)
132+
// Accumulate token usage (includes history — shows total session cost)
133+
if item.InputTokens > 0 {
134+
m.totalInputTokens += item.InputTokens
135+
}
136+
if item.OutputTokens > 0 {
137+
m.totalOutputTokens += item.OutputTokens
138+
}
139+
m.stream.AddItem(item)
129140
m.stream.SetEnabledFilters(m.tree.GetEnabledFilters())
130141

131142
case newAgentMsg:
132-
m.tree.AddAgent(msg.SessionID, msg.AgentID)
143+
m.tree.AddAgent(msg.SessionID, msg.AgentID, msg.AgentType)
133144
m.stream.SetEnabledFilters(m.tree.GetEnabledFilters())
134145

135146
case newSessionMsg:
@@ -391,14 +402,36 @@ func (m *Model) renderHeader() string {
391402
}
392403
}
393404

405+
// Token usage display
406+
tokenInfo := ""
407+
if m.totalInputTokens > 0 || m.totalOutputTokens > 0 {
408+
tokenInfo = fmt.Sprintf("│ %s in / %s out",
409+
formatTokenCount(m.totalInputTokens),
410+
formatTokenCount(m.totalOutputTokens))
411+
}
412+
394413
// Build header - use plain text and apply headerStyle uniformly (like Rust version)
395414
// Don't use Width() as it causes truncation on narrow terminals
396415
headerText := fmt.Sprintf("%s │ %s", toggles, sessionInfo)
416+
if tokenInfo != "" {
417+
headerText += " " + tokenInfo
418+
}
397419
header := headerStyle.Render(headerText)
398420

399421
return header
400422
}
401423

424+
// formatTokenCount formats token counts for display
425+
func formatTokenCount(n int64) string {
426+
if n < 1000 {
427+
return fmt.Sprintf("%d", n)
428+
}
429+
if n < 1000000 {
430+
return fmt.Sprintf("%.1fk", float64(n)/1000.0)
431+
}
432+
return fmt.Sprintf("%.1fm", float64(n)/1000000.0)
433+
}
434+
402435
func (m *Model) renderToggle(name string, enabled bool, key string) string {
403436
checkbox := "☐"
404437
if enabled {

internal/tui/stream.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,11 @@ func (s *StreamView) renderItem(item parser.StreamItem, width int) string {
215215
b.WriteString(toolInputContentStyle.Render(content))
216216

217217
case parser.TypeToolOutput:
218-
header := toolOutputStyle.Render(toolOutputIcon + " Output")
218+
outputLabel := toolOutputIcon + " Output"
219+
if item.DurationMs > 0 {
220+
outputLabel += " " + formatDuration(item.DurationMs)
221+
}
222+
header := toolOutputStyle.Render(outputLabel)
219223
b.WriteString(fmt.Sprintf("%s%s%s\n", agentName, sep, header))
220224
content := s.truncateContent(item.Content, width)
221225
b.WriteString(toolOutputContentStyle.Render(content))
@@ -263,6 +267,19 @@ func (s *StreamView) truncateContent(content string, width int) string {
263267
return strings.Join(wrapped, "\n")
264268
}
265269

270+
// formatDuration formats a duration in milliseconds to a human-readable string
271+
func formatDuration(ms int64) string {
272+
if ms < 1000 {
273+
return fmt.Sprintf("(%dms)", ms)
274+
}
275+
secs := float64(ms) / 1000.0
276+
if secs < 60 {
277+
return fmt.Sprintf("(%.1fs)", secs)
278+
}
279+
mins := secs / 60.0
280+
return fmt.Sprintf("(%.1fm)", mins)
281+
}
282+
266283
// View renders the stream
267284
func (s *StreamView) View() string {
268285
return s.viewport.View()

internal/tui/tree.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,10 @@ func (t *TreeView) AddSession(sessionID, projectPath string) *TreeNode {
105105
return session
106106
}
107107

108-
// AddAgent adds a subagent under a session
109-
func (t *TreeView) AddAgent(sessionID, agentID string) {
108+
// AddAgent adds a subagent under a session.
109+
// If agentType is non-empty, it is used as the display name.
110+
// For compound types like "feature-dev:code-reviewer", only the part after ":" is used.
111+
func (t *TreeView) AddAgent(sessionID, agentID, agentType string) {
110112
// Find the session node
111113
var session *TreeNode
112114
for _, child := range t.Root.Children {
@@ -127,11 +129,21 @@ func (t *TreeView) AddAgent(sessionID, agentID string) {
127129
}
128130
}
129131

132+
displayName := fmt.Sprintf("Agent-%s", agentID[:min(AgentIDDisplayLength, len(agentID))])
133+
if agentType != "" {
134+
// For compound types like "feature-dev:code-reviewer", use part after ":"
135+
if idx := strings.LastIndex(agentType, ":"); idx >= 0 && idx < len(agentType)-1 {
136+
displayName = agentType[idx+1:]
137+
} else {
138+
displayName = agentType
139+
}
140+
}
141+
130142
node := &TreeNode{
131143
Type: NodeTypeAgent,
132144
ID: agentID,
133145
SessionID: sessionID,
134-
Name: fmt.Sprintf("Agent-%s", agentID[:min(AgentIDDisplayLength, len(agentID))]),
146+
Name: displayName,
135147
Enabled: true,
136148
IsActive: true,
137149
Parent: session,

internal/tui/tree_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func TestTreeView_AddSessionDuplicate(t *testing.T) {
4444
func TestTreeView_AddAgent(t *testing.T) {
4545
tv := NewTreeView()
4646
tv.AddSession("sess1", "project")
47-
tv.AddAgent("sess1", "agent123456789")
47+
tv.AddAgent("sess1", "agent123456789", "")
4848

4949
session := tv.Root.Children[0]
5050
if len(session.Children) != 2 {
@@ -65,7 +65,7 @@ func TestTreeView_AddAgent(t *testing.T) {
6565
func TestTreeView_AddAgentNoSession(t *testing.T) {
6666
tv := NewTreeView()
6767
// Should not panic when adding agent to non-existent session
68-
tv.AddAgent("nonexistent", "agent1")
68+
tv.AddAgent("nonexistent", "agent1", "")
6969
if len(tv.Root.Children) != 0 {
7070
t.Error("should not add anything for non-existent session")
7171
}
@@ -74,8 +74,8 @@ func TestTreeView_AddAgentNoSession(t *testing.T) {
7474
func TestTreeView_AddAgentDuplicate(t *testing.T) {
7575
tv := NewTreeView()
7676
tv.AddSession("sess1", "project")
77-
tv.AddAgent("sess1", "agent1")
78-
tv.AddAgent("sess1", "agent1")
77+
tv.AddAgent("sess1", "agent1", "")
78+
tv.AddAgent("sess1", "agent1", "")
7979

8080
session := tv.Root.Children[0]
8181
if len(session.Children) != 2 {
@@ -107,7 +107,7 @@ func TestTreeView_AddBackgroundTask(t *testing.T) {
107107
func TestTreeView_AddBackgroundTaskUnderAgent(t *testing.T) {
108108
tv := NewTreeView()
109109
tv.AddSession("sess1", "project")
110-
tv.AddAgent("sess1", "agent1")
110+
tv.AddAgent("sess1", "agent1", "")
111111
tv.AddBackgroundTask("sess1", "agent1", "toolu_456", "Task: explore", "/path/out.txt", true)
112112

113113
agent := tv.Root.Children[0].Children[1]
@@ -180,7 +180,7 @@ func TestTreeView_RemoveNonExistent(t *testing.T) {
180180
func TestTreeView_GetEnabledFilters(t *testing.T) {
181181
tv := NewTreeView()
182182
tv.AddSession("sess1", "project")
183-
tv.AddAgent("sess1", "agent1")
183+
tv.AddAgent("sess1", "agent1", "")
184184

185185
filters := tv.GetEnabledFilters()
186186
if len(filters) != 2 {
@@ -222,7 +222,7 @@ func TestTreeView_GetEnabledFiltersDisabled(t *testing.T) {
222222
func TestTreeView_Navigation(t *testing.T) {
223223
tv := NewTreeView()
224224
tv.AddSession("sess1", "project")
225-
tv.AddAgent("sess1", "agent1")
225+
tv.AddAgent("sess1", "agent1", "")
226226
// Nodes: session, main, agent = 3 nodes
227227

228228
if tv.cursor != 0 {
@@ -300,7 +300,7 @@ func TestTreeView_GetSelectedNode(t *testing.T) {
300300
func TestTreeView_GetSelectedSession(t *testing.T) {
301301
tv := NewTreeView()
302302
tv.AddSession("sess1", "project")
303-
tv.AddAgent("sess1", "agent1")
303+
tv.AddAgent("sess1", "agent1", "")
304304

305305
// At session
306306
tv.cursor = 0

0 commit comments

Comments
 (0)