From aa83b1d759b5a59f55b7392ee65043caf31221df Mon Sep 17 00:00:00 2001 From: Michael Chapman Date: Mon, 16 Mar 2026 20:01:57 -0500 Subject: [PATCH 1/4] feat: extract and display model info for Copilot sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse model from three sources in priority order: 1. session.shutdown modelMetrics: accumulate request counts across all shutdown events (Copilot appends one per reconnect) and pick the majority — stored as main_model on the session 2. tool.execution_complete model field: backfills the preceding assistant message and keeps currentModel current for subsequent messages 3. session.model_change: sets currentModel for future messages - Add ComputeMainModel helper as a fallback when no shutdown metrics are available (counts model assignments across assistant messages) - Add main_model column to sessions table with idempotent migration; bump dataVersion to 5 to trigger full resync - Add MainModel to db.Session, ParsedSession, and plumb through UpsertSession, all column scan sites, and toDBSession in sync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/db/db.go | 6 +- internal/db/schema.sql | 1 + internal/db/sessions.go | 20 ++-- internal/parser/copilot.go | 85 ++++++++++++-- internal/parser/copilot_test.go | 192 ++++++++++++++++++++++++++++++++ internal/parser/types.go | 23 ++++ internal/sync/engine.go | 1 + 7 files changed, 311 insertions(+), 17 deletions(-) diff --git a/internal/db/db.go b/internal/db/db.go index b37dec4b..6ddadaa1 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -23,7 +23,7 @@ import ( // formatting changes). Old databases with a lower user_version // trigger a non-destructive re-sync (mtime reset + skip cache // clear) so existing session data is preserved. -const dataVersion = 4 +const dataVersion = 5 //go:embed schema.sql var schemaSQL string @@ -307,6 +307,10 @@ func (db *DB) migrateColumns() error { "sessions", "local_modified_at", "ALTER TABLE sessions ADD COLUMN local_modified_at TEXT", }, + { + "sessions", "main_model", + "ALTER TABLE sessions ADD COLUMN main_model TEXT NOT NULL DEFAULT ''", + }, } for _, m := range migrations { diff --git a/internal/db/schema.sql b/internal/db/schema.sql index 0b434593..b8ec8820 100644 --- a/internal/db/schema.sql +++ b/internal/db/schema.sql @@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS sessions ( relationship_type TEXT NOT NULL DEFAULT '', total_output_tokens INTEGER NOT NULL DEFAULT 0, peak_context_tokens INTEGER NOT NULL DEFAULT 0, + main_model TEXT NOT NULL DEFAULT '', deleted_at TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); diff --git a/internal/db/sessions.go b/internal/db/sessions.go index d70b14be..10d3babb 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -27,7 +27,7 @@ const sessionBaseCols = `id, project, machine, agent, first_message, display_name, started_at, ended_at, message_count, user_message_count, parent_session_id, relationship_type, - total_output_tokens, peak_context_tokens, + total_output_tokens, peak_context_tokens, main_model, deleted_at, created_at` // sessionPruneCols extends sessionBaseCols with file metadata @@ -36,7 +36,7 @@ const sessionPruneCols = `id, project, machine, agent, first_message, display_name, started_at, ended_at, message_count, user_message_count, parent_session_id, relationship_type, - total_output_tokens, peak_context_tokens, + total_output_tokens, peak_context_tokens, main_model, deleted_at, file_path, file_size, created_at` // sessionFullCols includes all columns for a complete session record. @@ -44,7 +44,7 @@ const sessionFullCols = `id, project, machine, agent, first_message, display_name, started_at, ended_at, message_count, user_message_count, parent_session_id, relationship_type, - total_output_tokens, peak_context_tokens, + total_output_tokens, peak_context_tokens, main_model, deleted_at, file_path, file_size, file_mtime, file_hash, local_modified_at, created_at` @@ -69,7 +69,7 @@ func scanSessionRow(rs rowScanner) (Session, error) { &s.FirstMessage, &s.DisplayName, &s.StartedAt, &s.EndedAt, &s.MessageCount, &s.UserMessageCount, &s.ParentSessionID, &s.RelationshipType, - &s.TotalOutputTokens, &s.PeakContextTokens, + &s.TotalOutputTokens, &s.PeakContextTokens, &s.MainModel, &s.DeletedAt, &s.CreatedAt, ) return s, err @@ -91,6 +91,7 @@ type Session struct { RelationshipType string `json:"relationship_type,omitempty"` TotalOutputTokens int `json:"total_output_tokens"` PeakContextTokens int `json:"peak_context_tokens"` + MainModel string `json:"main_model,omitempty"` DeletedAt *string `json:"deleted_at,omitempty"` FilePath *string `json:"file_path,omitempty"` FileSize *int64 `json:"file_size,omitempty"` @@ -459,7 +460,7 @@ func (db *DB) GetSessionFull( &s.FirstMessage, &s.DisplayName, &s.StartedAt, &s.EndedAt, &s.MessageCount, &s.UserMessageCount, &s.ParentSessionID, &s.RelationshipType, - &s.TotalOutputTokens, &s.PeakContextTokens, + &s.TotalOutputTokens, &s.PeakContextTokens, &s.MainModel, &s.DeletedAt, &s.FilePath, &s.FileSize, &s.FileMtime, &s.FileHash, &s.LocalModifiedAt, &s.CreatedAt, ) @@ -518,9 +519,9 @@ func (db *DB) UpsertSession(s Session) error { started_at, ended_at, message_count, user_message_count, parent_session_id, relationship_type, - total_output_tokens, peak_context_tokens, + total_output_tokens, peak_context_tokens, main_model, file_path, file_size, file_mtime, file_hash - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET project = excluded.project, machine = excluded.machine, @@ -534,6 +535,7 @@ func (db *DB) UpsertSession(s Session) error { relationship_type = excluded.relationship_type, total_output_tokens = excluded.total_output_tokens, peak_context_tokens = excluded.peak_context_tokens, + main_model = excluded.main_model, file_path = excluded.file_path, file_size = excluded.file_size, file_mtime = excluded.file_mtime, @@ -542,7 +544,7 @@ func (db *DB) UpsertSession(s Session) error { s.StartedAt, s.EndedAt, s.MessageCount, s.UserMessageCount, s.ParentSessionID, s.RelationshipType, - s.TotalOutputTokens, s.PeakContextTokens, + s.TotalOutputTokens, s.PeakContextTokens, s.MainModel, s.FilePath, s.FileSize, s.FileMtime, s.FileHash) if err != nil { return fmt.Errorf("upserting session %s: %w", s.ID, err) @@ -1054,7 +1056,7 @@ func (db *DB) FindPruneCandidates( &s.FirstMessage, &s.DisplayName, &s.StartedAt, &s.EndedAt, &s.MessageCount, &s.UserMessageCount, &s.ParentSessionID, &s.RelationshipType, - &s.TotalOutputTokens, &s.PeakContextTokens, + &s.TotalOutputTokens, &s.PeakContextTokens, &s.MainModel, &s.DeletedAt, &s.FilePath, &s.FileSize, &s.CreatedAt, ) if err != nil { diff --git a/internal/parser/copilot.go b/internal/parser/copilot.go index d7595370..1a0e7610 100644 --- a/internal/parser/copilot.go +++ b/internal/parser/copilot.go @@ -13,6 +13,8 @@ import ( // Copilot JSONL event types. const ( copilotEventSessionStart = "session.start" + copilotEventModelChange = "session.model_change" + copilotEventSessionShutdown = "session.shutdown" copilotEventUserMessage = "user.message" copilotEventAssistantMsg = "assistant.message" copilotEventToolComplete = "tool.execution_complete" @@ -22,13 +24,15 @@ const ( // copilotSessionBuilder accumulates state while scanning a // Copilot JSONL session file line by line. type copilotSessionBuilder struct { - messages []ParsedMessage - firstMessage string - startedAt time.Time - endedAt time.Time - sessionID string - project string - ordinal int + messages []ParsedMessage + firstMessage string + startedAt time.Time + endedAt time.Time + sessionID string + project string + currentModel string + shutdownModelCounts map[string]int64 // accumulated across all session.shutdown events + ordinal int } func newCopilotSessionBuilder() *copilotSessionBuilder { @@ -52,6 +56,10 @@ func (b *copilotSessionBuilder) processLine(line string) { switch gjson.Get(line, "type").Str { case copilotEventSessionStart: b.handleSessionStart(data) + case copilotEventModelChange: + b.handleModelChange(data) + case copilotEventSessionShutdown: + b.handleSessionShutdown(data) case copilotEventUserMessage: b.handleUserMessage(data, ts) case copilotEventAssistantMsg: @@ -81,6 +89,39 @@ func (b *copilotSessionBuilder) handleSessionStart( } } +func (b *copilotSessionBuilder) handleModelChange( + data gjson.Result, +) { + if m := data.Get("newModel").Str; m != "" { + b.currentModel = m + } +} + +// handleSessionShutdown accumulates model request counts from +// modelMetrics across all shutdown events in the file (Copilot +// appends a new shutdown on each reconnect). The model with the +// highest total requests across all shutdowns wins. +func (b *copilotSessionBuilder) handleSessionShutdown( + data gjson.Result, +) { + // currentModel in the shutdown payload is a secondary + // fallback: use it only if we haven't learned the model yet. + if m := data.Get("currentModel").Str; m != "" && b.currentModel == "" { + b.currentModel = m + } + + // Accumulate per-model request counts across all shutdowns. + data.Get("modelMetrics").ForEach(func(key, val gjson.Result) bool { + if count := val.Get("requests.count").Int(); count > 0 { + if b.shutdownModelCounts == nil { + b.shutdownModelCounts = make(map[string]int64) + } + b.shutdownModelCounts[key.Str] += count + } + return true + }) +} + func (b *copilotSessionBuilder) handleUserMessage( data gjson.Result, ts time.Time, ) { @@ -165,6 +206,7 @@ func (b *copilotSessionBuilder) handleAssistantMessage( HasToolUse: hasToolUse, ContentLength: len(displayContent), ToolCalls: toolCalls, + Model: b.currentModel, }) b.ordinal++ } @@ -196,6 +238,21 @@ func (b *copilotSessionBuilder) handleToolComplete( }}, }) b.ordinal++ + + // Use the model field to keep currentModel current and to + // backfill the model on the preceding assistant message + // (which was appended before the tool result arrived). + if m := data.Get("model").Str; m != "" { + b.currentModel = m + for i := len(b.messages) - 1; i >= 0; i-- { + if b.messages[i].Role == RoleAssistant { + if b.messages[i].Model == "" { + b.messages[i].Model = m + } + break + } + } + } } func (b *copilotSessionBuilder) handleAssistantReasoning() { @@ -294,6 +351,20 @@ func ParseCopilotSession( EndedAt: b.endedAt, MessageCount: len(b.messages), UserMessageCount: userCount, + MainModel: func() string { + if len(b.shutdownModelCounts) > 0 { + var best string + var bestCount int64 + for model, count := range b.shutdownModelCounts { + if count > bestCount || (count == bestCount && model < best) { + best = model + bestCount = count + } + } + return best + } + return ComputeMainModel(b.messages) + }(), File: FileInfo{ Path: path, Size: info.Size(), diff --git a/internal/parser/copilot_test.go b/internal/parser/copilot_test.go index fe12073a..6d526666 100644 --- a/internal/parser/copilot_test.go +++ b/internal/parser/copilot_test.go @@ -330,3 +330,195 @@ func TestSessionIDFromPath(t *testing.T) { }) } } + +func TestParseCopilotSession_ModelChange(t *testing.T) { + path := writeCopilotJSONL(t, + `{"type":"session.start","data":{"sessionId":"model-test"},"timestamp":"2025-01-15T10:00:00Z"}`, + `{"type":"session.model_change","data":{"newModel":"claude-sonnet-4.6"},"timestamp":"2025-01-15T10:00:01Z"}`, + `{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:02Z"}`, + `{"type":"assistant.message","data":{"content":"Hi there"},"timestamp":"2025-01-15T10:00:03Z"}`, + ) + + sess, msgs := parseAndValidateHelper(t, path, "m", 2) + + // Model should be tracked on assistant message. + assertEqual(t, "claude-sonnet-4.6", msgs[1].Model, "msgs[1].Model") + // User messages have no model. + assertEqual(t, "", msgs[0].Model, "msgs[0].Model") + // MainModel is the single model used. + assertEqual(t, "claude-sonnet-4.6", sess.MainModel, "sess.MainModel") +} + +func TestParseCopilotSession_NoModel(t *testing.T) { + // Sessions without a session.model_change event should have + // empty model fields — model data is simply not available. + path := writeCopilotJSONL(t, + `{"type":"session.start","data":{"sessionId":"no-model"},"timestamp":"2025-01-15T10:00:00Z"}`, + `{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`, + `{"type":"assistant.message","data":{"content":"Hi"},"timestamp":"2025-01-15T10:00:02Z"}`, + ) + + sess, msgs := parseAndValidateHelper(t, path, "m", 2) + + assertEqual(t, "", msgs[1].Model, "msgs[1].Model") + assertEqual(t, "", sess.MainModel, "sess.MainModel") +} + +func TestParseCopilotSession_ModelMidSessionChange(t *testing.T) { + // Model switches mid-session: first two assistant messages use + // sonnet, then user switches to haiku for the last one. + // MainModel should be sonnet (majority). + path := writeCopilotJSONL(t, + `{"type":"session.start","data":{"sessionId":"switch-test"},"timestamp":"2025-01-15T10:00:00Z"}`, + `{"type":"session.model_change","data":{"newModel":"claude-sonnet-4.6"},"timestamp":"2025-01-15T10:00:01Z"}`, + `{"type":"user.message","data":{"content":"First"},"timestamp":"2025-01-15T10:00:02Z"}`, + `{"type":"assistant.message","data":{"content":"Reply one"},"timestamp":"2025-01-15T10:00:03Z"}`, + `{"type":"user.message","data":{"content":"Second"},"timestamp":"2025-01-15T10:00:04Z"}`, + `{"type":"assistant.message","data":{"content":"Reply two"},"timestamp":"2025-01-15T10:00:05Z"}`, + `{"type":"session.model_change","data":{"newModel":"claude-haiku-4.5"},"timestamp":"2025-01-15T10:00:06Z"}`, + `{"type":"user.message","data":{"content":"Third"},"timestamp":"2025-01-15T10:00:07Z"}`, + `{"type":"assistant.message","data":{"content":"Reply three"},"timestamp":"2025-01-15T10:00:08Z"}`, + ) + + sess, msgs := parseAndValidateHelper(t, path, "m", 6) + + assertEqual(t, "claude-sonnet-4.6", msgs[1].Model, "msgs[1].Model") + assertEqual(t, "claude-sonnet-4.6", msgs[3].Model, "msgs[3].Model") + assertEqual(t, "claude-haiku-4.5", msgs[5].Model, "msgs[5].Model") + // Majority is sonnet (2 vs 1). + assertEqual(t, "claude-sonnet-4.6", sess.MainModel, "sess.MainModel") +} + +func TestParseCopilotSession_ModelFromMultipleShutdowns(t *testing.T) { + // A long session with multiple session.shutdown events (Copilot + // appends one per reconnect). The last shutdown alone shows haiku + // winning, but accumulated counts show sonnet as dominant. + path := writeCopilotJSONL(t, + `{"type":"session.start","data":{"sessionId":"multi-shutdown"},"timestamp":"2025-01-15T10:00:00Z"}`, + `{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`, + `{"type":"assistant.message","data":{"content":"Hi"},"timestamp":"2025-01-15T10:00:02Z"}`, + // First shutdown: sonnet=29, haiku=9 → sonnet leads + `{"type":"session.shutdown","data":{"shutdownType":"routine","modelMetrics":{"claude-sonnet-4.6":{"requests":{"count":29}},"claude-haiku-4.5":{"requests":{"count":9}}}},"timestamp":"2025-01-15T10:01:00Z"}`, + // Second shutdown: sonnet=137 only + `{"type":"session.shutdown","data":{"shutdownType":"routine","modelMetrics":{"claude-sonnet-4.6":{"requests":{"count":137}}}},"timestamp":"2025-01-15T10:02:00Z"}`, + // Third shutdown: haiku=39, sonnet=20 → haiku wins in isolation + `{"type":"session.shutdown","data":{"shutdownType":"routine","modelMetrics":{"claude-haiku-4.5":{"requests":{"count":39}},"claude-sonnet-4.6":{"requests":{"count":20}}}},"timestamp":"2025-01-15T10:03:00Z"}`, + ) + + // Accumulated: sonnet=29+137+20=186, haiku=9+39=48 → sonnet wins + sess, _ := parseAndValidateHelper(t, path, "m", 2) + assertEqual(t, "claude-sonnet-4.6", sess.MainModel, "sess.MainModel") +} + +func TestParseCopilotSession_ModelFromShutdown(t *testing.T) { + // No session.model_change — main model derived from modelMetrics + // in session.shutdown (sonnet has more requests than haiku). + path := writeCopilotJSONL(t, + `{"type":"session.start","data":{"sessionId":"shutdown-test"},"timestamp":"2025-01-15T10:00:00Z"}`, + `{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`, + `{"type":"assistant.message","data":{"content":"Hi"},"timestamp":"2025-01-15T10:00:02Z"}`, + `{"type":"session.shutdown","data":{"shutdownType":"routine","modelMetrics":{"claude-sonnet-4.6":{"requests":{"count":5,"cost":1},"usage":{"inputTokens":1000,"outputTokens":200}},"claude-haiku-4.5":{"requests":{"count":2,"cost":0},"usage":{"inputTokens":400,"outputTokens":80}}}},"timestamp":"2025-01-15T10:00:10Z"}`, + ) + + sess, _ := parseAndValidateHelper(t, path, "m", 2) + assertEqual(t, "claude-sonnet-4.6", sess.MainModel, "sess.MainModel") +} + +func TestParseCopilotSession_ModelFromShutdownCurrentModel(t *testing.T) { + // session.shutdown has currentModel but empty modelMetrics. + // currentModel should be used as a fallback for b.currentModel + // so that ComputeMainModel can pick it up from assistant messages + // IF there were tool completions that set it, but here we test + // the shutdownMainModel path is empty and falls back gracefully. + path := writeCopilotJSONL(t, + `{"type":"session.start","data":{"sessionId":"cur-model-test"},"timestamp":"2025-01-15T10:00:00Z"}`, + `{"type":"user.message","data":{"content":"Hello"},"timestamp":"2025-01-15T10:00:01Z"}`, + `{"type":"assistant.message","data":{"content":"Hi"},"timestamp":"2025-01-15T10:00:02Z"}`, + `{"type":"session.shutdown","data":{"shutdownType":"routine","currentModel":"claude-sonnet-4.6","modelMetrics":{}},"timestamp":"2025-01-15T10:00:10Z"}`, + ) + + // No modelMetrics and no model_change — MainModel stays empty + // because the assistant message was already emitted before the + // shutdown event and has no model assigned. (currentModel only + // affects future assistant messages.) + sess, _ := parseAndValidateHelper(t, path, "m", 2) + // shutdownMainModel is empty (empty modelMetrics), currentModel + // set too late. MainModel will be "" in this edge case. + _ = sess // just verify it parses without panic +} + +func TestParseCopilotSession_ModelFromToolComplete(t *testing.T) { + // tool.execution_complete carries a model field that should + // backfill the preceding assistant message and update currentModel. + path := writeCopilotJSONL(t, + `{"type":"session.start","data":{"sessionId":"tool-model"},"timestamp":"2025-01-15T10:00:00Z"}`, + `{"type":"user.message","data":{"content":"Do something"},"timestamp":"2025-01-15T10:00:01Z"}`, + `{"type":"assistant.message","data":{"content":"","toolRequests":[{"toolCallId":"tc1","name":"bash","arguments":{"command":"ls"}}]},"timestamp":"2025-01-15T10:00:02Z"}`, + `{"type":"tool.execution_complete","data":{"toolCallId":"tc1","result":"file.txt","model":"claude-sonnet-4.6"},"timestamp":"2025-01-15T10:00:03Z"}`, + `{"type":"assistant.message","data":{"content":"Done"},"timestamp":"2025-01-15T10:00:04Z"}`, + ) + + // 4 messages: user, assistant (tool-only), tool-result user, assistant (final) + sess, msgs := parseAndValidateHelper(t, path, "m", 4) + + // The tool-only assistant message should be backfilled. + assertEqual(t, "claude-sonnet-4.6", msgs[1].Model, "msgs[1].Model (backfilled)") + // The final assistant message gets currentModel set from the tool complete. + assertEqual(t, "claude-sonnet-4.6", msgs[3].Model, "msgs[3].Model") + // ComputeMainModel over these 2 assistant messages → sonnet. + assertEqual(t, "claude-sonnet-4.6", sess.MainModel, "sess.MainModel") +} + +func TestComputeMainModel(t *testing.T) { + tests := []struct { + name string + messages []ParsedMessage + want string + }{ + { + name: "empty", + messages: nil, + want: "", + }, + { + name: "no model data", + messages: []ParsedMessage{ + {Role: RoleAssistant, Model: ""}, + {Role: RoleUser, Model: ""}, + }, + want: "", + }, + { + name: "single model", + messages: []ParsedMessage{ + {Role: RoleAssistant, Model: "claude-sonnet-4.6"}, + {Role: RoleUser, Model: ""}, + }, + want: "claude-sonnet-4.6", + }, + { + name: "majority wins", + messages: []ParsedMessage{ + {Role: RoleAssistant, Model: "claude-sonnet-4.6"}, + {Role: RoleAssistant, Model: "claude-sonnet-4.6"}, + {Role: RoleAssistant, Model: "claude-haiku-4.5"}, + }, + want: "claude-sonnet-4.6", + }, + { + name: "user role model ignored", + messages: []ParsedMessage{ + {Role: RoleUser, Model: "some-model"}, + {Role: RoleAssistant, Model: "claude-sonnet-4.6"}, + }, + want: "claude-sonnet-4.6", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ComputeMainModel(tt.messages) + assertEqual(t, tt.want, got, "ComputeMainModel") + }) + } +} diff --git a/internal/parser/types.go b/internal/parser/types.go index dc8d6336..0596b8c4 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -272,6 +272,29 @@ type ParsedSession struct { TotalOutputTokens int PeakContextTokens int + + // MainModel is the most frequently used model across assistant + // messages in this session, or empty if no model data is available. + MainModel string +} + +// ComputeMainModel returns the most frequently occurring model +// string across assistant messages. Empty-string models are +// ignored. Returns "" when no model data is present. +func ComputeMainModel(messages []ParsedMessage) string { + counts := make(map[string]int) + for _, m := range messages { + if m.Role == RoleAssistant && m.Model != "" { + counts[m.Model]++ + } + } + best, bestN := "", 0 + for model, n := range counts { + if n > bestN || (n == bestN && model < best) { + best, bestN = model, n + } + } + return best } // ParsedToolCall holds a single tool invocation extracted from diff --git a/internal/sync/engine.go b/internal/sync/engine.go index d09b1720..c81e6a78 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -2095,6 +2095,7 @@ func toDBSession(pw pendingWrite) db.Session { RelationshipType: string(pw.sess.RelationshipType), TotalOutputTokens: pw.sess.TotalOutputTokens, PeakContextTokens: pw.sess.PeakContextTokens, + MainModel: pw.sess.MainModel, FilePath: strPtr(pw.sess.File.Path), FileSize: int64Ptr(pw.sess.File.Size), FileMtime: int64Ptr(pw.sess.File.Mtime), From a6ba917e5451a49da300c4818f7d31868f45b502 Mon Sep 17 00:00:00 2001 From: Michael Chapman Date: Mon, 16 Mar 2026 20:41:24 -0500 Subject: [PATCH 2/4] feat: surface model information in the UI (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add main_model field to frontend Session interface (core.ts) - Add shortModelName() utility to format.ts: strips claude-/gpt-/gemini- vendor prefixes for compact display; full name preserved as tooltip title - SessionBreadcrumb: show model-badge next to agent/token badges when session.main_model is set (e.g. "sonnet-4.6" for claude-sonnet-4.6) - SubagentInline: show toggle-model label alongside token info in the subagent header row - MessageContent: derive offMainModel — when an assistant message's model differs from the owning session's main_model, show a small muted label next to the timestamp (non-invasive; hidden when all messages use the same model) - Tests: add shortModelName unit tests; add model-badge show/hide tests to SessionBreadcrumb.test.ts; update all session mock objects with main_model field (736 tests pass) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontend/src/lib/api/types/core.ts | 1 + .../command-palette/CommandPalette.test.ts | 1 + .../components/content/MessageContent.svelte | 26 ++++++++++++++- .../components/content/SubagentInline.svelte | 12 ++++++- .../layout/SessionBreadcrumb.svelte | 15 ++++++++- .../layout/SessionBreadcrumb.test.ts | 32 +++++++++++++++++++ .../sidebar/session-list-utils.test.ts | 1 + frontend/src/lib/stores/messages.test.ts | 1 + frontend/src/lib/stores/sessions.test.ts | 3 ++ frontend/src/lib/utils/format.test.ts | 29 +++++++++++++++++ frontend/src/lib/utils/format.ts | 16 ++++++++++ frontend/src/lib/utils/keyboard.test.ts | 1 + 12 files changed, 135 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/api/types/core.ts b/frontend/src/lib/api/types/core.ts index 98c41913..918baffd 100644 --- a/frontend/src/lib/api/types/core.ts +++ b/frontend/src/lib/api/types/core.ts @@ -25,6 +25,7 @@ export interface Session { file_mtime?: number; total_output_tokens: number; peak_context_tokens: number; + main_model?: string; created_at: string; } diff --git a/frontend/src/lib/components/command-palette/CommandPalette.test.ts b/frontend/src/lib/components/command-palette/CommandPalette.test.ts index 257643d4..df23de4c 100644 --- a/frontend/src/lib/components/command-palette/CommandPalette.test.ts +++ b/frontend/src/lib/components/command-palette/CommandPalette.test.ts @@ -73,6 +73,7 @@ function makeSession(id: string, agent: string) { user_message_count: 1, total_output_tokens: 0, peak_context_tokens: 0, + main_model: "", created_at: "2026-02-20T12:30:00Z", }; } diff --git a/frontend/src/lib/components/content/MessageContent.svelte b/frontend/src/lib/components/content/MessageContent.svelte index 0ed3c80e..63ade20f 100644 --- a/frontend/src/lib/components/content/MessageContent.svelte +++ b/frontend/src/lib/components/content/MessageContent.svelte @@ -4,7 +4,7 @@ parseContent, enrichSegments, } from "../../utils/content-parser.js"; - import { formatTimestamp } from "../../utils/format.js"; + import { formatTimestamp, shortModelName } from "../../utils/format.js"; import { copyToClipboard } from "../../utils/clipboard.js"; import ThinkingBlock from "./ThinkingBlock.svelte"; import ToolBlock from "./ToolBlock.svelte"; @@ -43,6 +43,16 @@ sessions.activeSession, ); + /** + * Show the message's model name when it differs from the session's main + * model. Only applies to assistant messages — user messages carry no model. + */ + let offMainModel = $derived.by((): string => { + if (isUser || !message.model) return ""; + const mainModel = owningSession?.main_model ?? ""; + return message.model !== mainModel ? message.model : ""; + }); + /** Walk the parent chain to check if any ancestor has the teammate tag. */ function isTeammateAncestry(s: Session, all: Session[]): boolean { if ((s.first_message ?? "").includes(" {formatTimestamp(message.timestamp)} + {#if offMainModel} + {shortModelName(offMainModel)} + {/if}
@@ -295,6 +308,17 @@ margin-left: auto; } + .message-model { + font-size: 10px; + color: var(--text-muted); + padding: 1px 4px; + border-radius: 3px; + background: var(--bg-tertiary); + white-space: nowrap; + flex-shrink: 0; + opacity: 0.8; + } + .copy-btn { display: flex; align-items: center; diff --git a/frontend/src/lib/components/content/SubagentInline.svelte b/frontend/src/lib/components/content/SubagentInline.svelte index 7d3e14e2..2081e5af 100644 --- a/frontend/src/lib/components/content/SubagentInline.svelte +++ b/frontend/src/lib/components/content/SubagentInline.svelte @@ -3,7 +3,7 @@