diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 14bb548..69cdd92 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -178,6 +178,24 @@ struct RoutingWaste: Codable, Sendable { let byModel: [RoutingWasteModelEntry] } +/// Workflow-intelligence rollup for the period. `correctionRate` and +/// `medianTimeToFirstEditMs` are null when not computable (no user turns / no +/// session ever edited), so both are optional. +struct WorkflowBlock: Codable, Sendable { + let corrections: Int + let correctionRate: Double? + let medianTimeToFirstEditMs: Double? +} + +/// One entry of `topReworkedFiles`. `path` is basename-only (the CLI trims it +/// for privacy before the payload can leave the machine); `sessions` is the +/// distinct-session count and `edits` the total edit-family calls. +struct ReworkedFileEntry: Codable, Sendable { + let path: String + let sessions: Int + let edits: Int +} + struct CurrentBlock: Codable, Sendable { let label: String let cost: Double @@ -202,6 +220,13 @@ struct CurrentBlock: Codable, Sendable { let skills: [SkillEntry] let subagents: [SubagentEntry] let mcpServers: [McpServerEntry] + /// Workflow-intelligence rollup. Optional so payloads from older CLIs + /// (which never emit it) still decode; absent -> the Workflow strip hides. + /// Declared last with a default so the memberwise initializer stays + /// backward-compatible for existing construction sites. + var workflow: WorkflowBlock? = nil + /// Files most reworked by edit-family calls. Empty on older CLIs. + var topReworkedFiles: [ReworkedFileEntry] = [] } extension CurrentBlock { @@ -209,7 +234,8 @@ extension CurrentBlock { case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens, cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, topProjects, modelEfficiency, topSessions, retryTax, routingWaste, - tools, skills, subagents, mcpServers + tools, skills, subagents, mcpServers, + workflow, topReworkedFiles } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -235,6 +261,8 @@ extension CurrentBlock { skills = try c.decodeIfPresent([SkillEntry].self, forKey: .skills) ?? [] subagents = try c.decodeIfPresent([SubagentEntry].self, forKey: .subagents) ?? [] mcpServers = try c.decodeIfPresent([McpServerEntry].self, forKey: .mcpServers) ?? [] + workflow = try c.decodeIfPresent(WorkflowBlock.self, forKey: .workflow) + topReworkedFiles = try c.decodeIfPresent([ReworkedFileEntry].self, forKey: .topReworkedFiles) ?? [] } } diff --git a/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift b/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift new file mode 100644 index 0000000..6f31afe --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift @@ -0,0 +1,127 @@ +import Foundation + +/// Pure, testable derivation of the menubar Workflow strip from the decoded +/// payload. The view renders exactly these values; all formatting and the +/// coaching-note selection live here so they can be unit-tested without SwiftUI. +/// +/// Every field is nil unless it carries real signal: a stat is never shown as a +/// zero placeholder. `isEmpty` is true when none of the three stats fire, and +/// the section renders nothing in that case. +struct WorkflowStripModel: Equatable { + /// Correction rate as a percent with the raw count, e.g. "3% (5)". + let corrections: String? + /// Median time to the first edit, e.g. "6m". + let firstEdit: String? + /// Most reworked file, basename only, e.g. "sdk.py". + let reworkedName: String? + /// Distinct-session count for the most reworked file, e.g. 15. + let reworkedSessions: Int? + /// One coaching note, or nil. Mirrors the first-firing note in + /// src/workflow-insights.ts buildCoachingNotes over the payload's signals. + let note: String? + + /// Nothing worth showing -> the section hides itself. + var isEmpty: Bool { + corrections == nil && firstEdit == nil && reworkedName == nil + } + + /// The reworked stat as one string, e.g. "sdk.py ×15" (used for the exact + /// rendered value and by tests). + var reworked: String? { + guard let reworkedName, let reworkedSessions else { return nil } + return "\(reworkedName) ×\(reworkedSessions)" + } +} + +extension WorkflowStripModel { + // Coaching-note thresholds, matching src/workflow-insights.ts exactly. + static let correctionHighRate = 0.15 + static let correctionMinCount = 3 + static let churnMinSessions = 3 + static let ttfeSlowMs: Double = 5 * 60 * 1000 + + init(workflow: WorkflowBlock?, topReworkedFiles: [ReworkedFileEntry]) { + let topFile = topReworkedFiles.first + + // Corrections: only when the displayed rate is a real, non-zero percent. + // The rate is the headline; a count of corrections that rounds to 0% (a + // handful over thousands of turns) carries no signal, so the whole stat + // hides rather than reading "0% (6)". + if let rate = workflow?.correctionRate, let count = workflow?.corrections, + count > 0, Int((rate * 100).rounded()) >= 1 { + corrections = "\(Self.percent(rate)) (\(count))" + } else { + corrections = nil + } + + // First edit: only a real, positive median. + if let ms = workflow?.medianTimeToFirstEditMs, ms > 0 { + firstEdit = Self.formatDuration(ms: ms) + } else { + firstEdit = nil + } + + // Top reworked file: basename ×sessions. + if let file = topFile, file.sessions > 0 { + reworkedName = Self.basename(file.path) + reworkedSessions = file.sessions + } else { + reworkedName = nil + reworkedSessions = nil + } + + note = Self.coachingNote( + corrections: workflow?.corrections ?? 0, + correctionRate: workflow?.correctionRate, + topFile: topFile, + medianTimeToFirstEditMs: workflow?.medianTimeToFirstEditMs + ) + } + + /// Picks the single coaching note: the first threshold that fires, in the + /// same order as buildCoachingNotes (the worst-one-shot note is omitted + /// because that signal is not in the menubar payload). Copy is byte-for-byte + /// the CLI's, no em-dashes. + static func coachingNote(corrections: Int, + correctionRate: Double?, + topFile: ReworkedFileEntry?, + medianTimeToFirstEditMs: Double?) -> String? { + if let rate = correctionRate, rate >= correctionHighRate, corrections >= correctionMinCount { + return "You corrected the assistant on \(percent(rate)) of prompts (\(corrections) times). State the requirements in the first message to cut the back and forth." + } + if let file = topFile, file.sessions >= churnMinSessions { + return "\(basename(file.path)) was reworked across \(file.sessions) sessions (\(file.edits) edits). A focused pass on it may cost less than the repeated churn." + } + if let ms = medianTimeToFirstEditMs, ms >= ttfeSlowMs { + return "Median time to first edit is \(formatDurationShort(ms: ms)). Point the assistant at the target file to cut the exploration before it starts editing." + } + return nil + } + + /// "First edit" stat duration: <60s -> "Ns", <60m -> "Nm", else "Nh Nm". + static func formatDuration(ms: Double) -> String { + if ms < 60_000 { return "\(Int((ms / 1000).rounded()))s" } + let totalMinutes = Int((ms / 60_000).rounded()) + if totalMinutes < 60 { return "\(totalMinutes)m" } + return "\(totalMinutes / 60)h \(totalMinutes % 60)m" + } + + /// Mirrors formatDurationShort in src/workflow-insights.ts: whole seconds + /// under a minute, whole minutes above. Used only inside the coaching note so + /// the copy matches the CLI exactly. + static func formatDurationShort(ms: Double) -> String { + if ms >= 60_000 { return "\(Int((ms / 60_000).rounded()))m" } + return "\(Int((ms / 1000).rounded()))s" + } + + /// Rounded percent from a 0-1 rate, matching JS `Math.round(rate * 100)`. + static func percent(_ rate: Double) -> String { + "\(Int((rate * 100).rounded()))%" + } + + /// Last path component. The payload is already basename-only, but an older + /// CLI could emit a fuller (forward-slash) path. + static func basename(_ path: String) -> String { + path.split(separator: "/").last.map(String.init) ?? path + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index b182561..d92bd74 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -36,6 +36,7 @@ struct MenuBarContent: View { ActivitySection() Divider().opacity(0.5) ModelsSection() + WorkflowSection() Divider().opacity(0.5) ToolingSection() Divider().opacity(0.5) diff --git a/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift b/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift new file mode 100644 index 0000000..6d7593f --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// Compact workflow-intelligence strip: one row of up to three stats plus one +/// coaching note. Reads whatever payload the store currently holds, so it +/// follows the selected agent tab automatically. Renders nothing when the +/// payload carries no workflow signal (older CLIs, or a period with nothing +/// measurable), so it never shows zero placeholders. +struct WorkflowSection: View { + @Environment(AppStore.self) private var store + + var body: some View { + let model = WorkflowStripModel( + workflow: store.payload.current.workflow, + topReworkedFiles: store.payload.current.topReworkedFiles + ) + if !model.isEmpty { + // Own the leading divider so the section slots into the popover's + // divider rhythm and leaves no doubled line when it's hidden. + VStack(spacing: 0) { + Divider().opacity(0.5) + VStack(alignment: .leading, spacing: 8) { + SectionCaption(text: "Workflow") + WorkflowStatRow(model: model) + if let note = model.note { + Text(note) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + } + } + } +} + +private enum WorkflowStat: Identifiable { + case labeled(id: String, label: String, value: String) + case reworked(name: String, sessions: Int) + + var id: String { + switch self { + case .labeled(let id, _, _): return id + case .reworked: return "reworked" + } + } +} + +private struct WorkflowStatRow: View { + let model: WorkflowStripModel + + private var stats: [WorkflowStat] { + var s: [WorkflowStat] = [] + if let corrections = model.corrections { + s.append(.labeled(id: "corrections", label: "Corrections", value: corrections)) + } + if let firstEdit = model.firstEdit { + s.append(.labeled(id: "firstEdit", label: "First edit", value: firstEdit)) + } + if let name = model.reworkedName, let sessions = model.reworkedSessions { + s.append(.reworked(name: name, sessions: sessions)) + } + return s + } + + var body: some View { + HStack(spacing: 7) { + ForEach(Array(stats.enumerated()), id: \.element.id) { index, stat in + if index > 0 { + Text("·") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + WorkflowStatCell(stat: stat) + } + Spacer(minLength: 0) + } + } +} + +private struct WorkflowStatCell: View { + let stat: WorkflowStat + + var body: some View { + HStack(spacing: 4) { + switch stat { + case .labeled(_, let label, let value): + Text(label) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + Text(value) + .font(.codeMono(size: 12, weight: .medium)) + .foregroundStyle(.primary) + .tracking(-0.2) + case .reworked(let name, let sessions): + Text(name) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.middle) + Text("×\(sessions)") + .font(.codeMono(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .tracking(-0.2) + } + } + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/WorkflowStripTests.swift b/mac/Tests/CodeBurnMenubarTests/WorkflowStripTests.swift new file mode 100644 index 0000000..e42968d --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/WorkflowStripTests.swift @@ -0,0 +1,235 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("WorkflowStrip -- duration formatter") +struct WorkflowStripDurationTests { + @Test("seconds under a minute") + func seconds() { + #expect(WorkflowStripModel.formatDuration(ms: 8_000) == "8s") + #expect(WorkflowStripModel.formatDuration(ms: 45_000) == "45s") + // Rounds to nearest second (away from zero on .5). + #expect(WorkflowStripModel.formatDuration(ms: 1_500) == "2s") + } + + @Test("whole minutes below an hour") + func minutes() { + #expect(WorkflowStripModel.formatDuration(ms: 60_000) == "1m") + #expect(WorkflowStripModel.formatDuration(ms: 360_000) == "6m") + // 1m30s rounds up to 2m. + #expect(WorkflowStripModel.formatDuration(ms: 90_000) == "2m") + } + + @Test("hours and minutes at and above an hour") + func hours() { + #expect(WorkflowStripModel.formatDuration(ms: 3_600_000) == "1h 0m") + #expect(WorkflowStripModel.formatDuration(ms: 3_900_000) == "1h 5m") + #expect(WorkflowStripModel.formatDuration(ms: 7_500_000) == "2h 5m") + } + + @Test("coaching-note short formatter matches the CLI (seconds/minutes only)") + func shortFormatter() { + #expect(WorkflowStripModel.formatDurationShort(ms: 45_000) == "45s") + #expect(WorkflowStripModel.formatDurationShort(ms: 300_000) == "5m") + // Above an hour the note still reads minutes, unlike the stat (which is "1h 5m"). + #expect(WorkflowStripModel.formatDurationShort(ms: 3_900_000) == "65m") + } +} + +@Suite("WorkflowStrip -- coaching note selection") +struct WorkflowStripNoteTests { + private func file(_ path: String, sessions: Int, edits: Int) -> ReworkedFileEntry { + ReworkedFileEntry(path: path, sessions: sessions, edits: edits) + } + + @Test("corrections note fires at threshold and wins over the others") + func correctionsWins() { + let note = WorkflowStripModel.coachingNote( + corrections: 5, + correctionRate: 0.2, + topFile: file("sdk.py", sessions: 15, edits: 42), + medianTimeToFirstEditMs: 600_000 + ) + #expect(note == "You corrected the assistant on 20% of prompts (5 times). State the requirements in the first message to cut the back and forth.") + } + + @Test("corrections note gated by rate and count") + func correctionsGating() { + // Rate below 0.15 -> falls through to churn. + #expect(WorkflowStripModel.coachingNote(corrections: 9, correctionRate: 0.1, topFile: file("a.py", sessions: 4, edits: 8), medianTimeToFirstEditMs: nil) + == "a.py was reworked across 4 sessions (8 edits). A focused pass on it may cost less than the repeated churn.") + // Count below 3 -> falls through to churn. + #expect(WorkflowStripModel.coachingNote(corrections: 2, correctionRate: 0.9, topFile: file("a.py", sessions: 4, edits: 8), medianTimeToFirstEditMs: nil) + == "a.py was reworked across 4 sessions (8 edits). A focused pass on it may cost less than the repeated churn.") + } + + @Test("churn note fires and uses the basename") + func churn() { + let note = WorkflowStripModel.coachingNote( + corrections: 0, + correctionRate: nil, + topFile: file("src/lib/sdk.py", sessions: 15, edits: 42), + medianTimeToFirstEditMs: nil + ) + #expect(note == "sdk.py was reworked across 15 sessions (42 edits). A focused pass on it may cost less than the repeated churn.") + } + + @Test("ttfe note fires at 5 minutes when nothing stronger does") + func ttfe() { + let note = WorkflowStripModel.coachingNote( + corrections: 0, + correctionRate: nil, + topFile: file("a.py", sessions: 2, edits: 3), + medianTimeToFirstEditMs: 300_000 + ) + #expect(note == "Median time to first edit is 5m. Point the assistant at the target file to cut the exploration before it starts editing.") + } + + @Test("no note when nothing crosses a threshold") + func none() { + #expect(WorkflowStripModel.coachingNote(corrections: 1, correctionRate: 0.05, topFile: file("a.py", sessions: 1, edits: 2), medianTimeToFirstEditMs: 120_000) == nil) + #expect(WorkflowStripModel.coachingNote(corrections: 0, correctionRate: nil, topFile: nil, medianTimeToFirstEditMs: nil) == nil) + } +} + +@Suite("WorkflowStrip -- model derivation") +struct WorkflowStripModelTests { + @Test("full payload renders all three stats and a note") + func full() { + let model = WorkflowStripModel( + workflow: WorkflowBlock(corrections: 5, correctionRate: 0.03, medianTimeToFirstEditMs: 360_000), + topReworkedFiles: [ReworkedFileEntry(path: "sdk.py", sessions: 15, edits: 42)] + ) + #expect(model.corrections == "3% (5)") + #expect(model.firstEdit == "6m") + #expect(model.reworked == "sdk.py ×15") + #expect(model.isEmpty == false) + // rate 0.03 < 0.15 so corrections note doesn't fire; churn does. + #expect(model.note == "sdk.py was reworked across 15 sessions (42 edits). A focused pass on it may cost less than the repeated churn.") + } + + @Test("basename is applied to the reworked stat") + func reworkedBasename() { + let model = WorkflowStripModel( + workflow: nil, + topReworkedFiles: [ReworkedFileEntry(path: "app/src/main.ts", sessions: 4, edits: 9)] + ) + #expect(model.reworkedName == "main.ts") + #expect(model.reworked == "main.ts ×4") + } + + @Test("nil workflow and empty files -> hidden") + func emptyHidden() { + let model = WorkflowStripModel(workflow: nil, topReworkedFiles: []) + #expect(model.isEmpty) + #expect(model.corrections == nil) + #expect(model.firstEdit == nil) + #expect(model.reworkedName == nil) + #expect(model.note == nil) + } + + @Test("all-empty workflow (zeros/nulls) -> hidden, no zero placeholders") + func allEmptyHidden() { + let model = WorkflowStripModel( + workflow: WorkflowBlock(corrections: 0, correctionRate: 0.0, medianTimeToFirstEditMs: nil), + topReworkedFiles: [] + ) + #expect(model.isEmpty) + #expect(model.corrections == nil) // 0 corrections is never "0% (0)" + #expect(model.firstEdit == nil) + } + + @Test("zero median and zero-session file are treated as absent") + func degenerateValues() { + let model = WorkflowStripModel( + workflow: WorkflowBlock(corrections: 0, correctionRate: nil, medianTimeToFirstEditMs: 0), + topReworkedFiles: [ReworkedFileEntry(path: "a.py", sessions: 0, edits: 0)] + ) + #expect(model.firstEdit == nil) + #expect(model.reworkedName == nil) + #expect(model.isEmpty) + } + + @Test("a correction rate that rounds to 0% hides the corrections stat") + func subOnePercentCorrections() { + // Mirrors real month data: 6 corrections over thousands of turns -> 0.17%. + let model = WorkflowStripModel( + workflow: WorkflowBlock(corrections: 6, correctionRate: 0.001692524682651622, medianTimeToFirstEditMs: 384_425), + topReworkedFiles: [ReworkedFileEntry(path: "sdk.py", sessions: 9, edits: 54)] + ) + #expect(model.corrections == nil) + #expect(model.firstEdit == "6m") + #expect(model.reworked == "sdk.py ×9") + #expect(model.note == "sdk.py was reworked across 9 sessions (54 edits). A focused pass on it may cost less than the repeated churn.") + #expect(model.isEmpty == false) + } + + @Test("only a reworked file present renders one stat and the churn note") + func onlyReworked() { + let model = WorkflowStripModel( + workflow: nil, + topReworkedFiles: [ReworkedFileEntry(path: "a.py", sessions: 4, edits: 10)] + ) + #expect(model.corrections == nil) + #expect(model.firstEdit == nil) + #expect(model.reworked == "a.py ×4") + #expect(model.isEmpty == false) + #expect(model.note == "a.py was reworked across 4 sessions (10 edits). A focused pass on it may cost less than the repeated churn.") + } +} + +@Suite("WorkflowStrip -- payload decoding") +struct WorkflowStripDecodeTests { + private func decodeCurrent(_ json: String) throws -> CurrentBlock { + try JSONDecoder().decode(CurrentBlock.self, from: Data(json.utf8)) + } + + @Test("decodes workflow and topReworkedFiles when present") + func present() throws { + let current = try decodeCurrent(""" + { + "label": "Month", "cost": 1, "calls": 2, "sessions": 3, + "inputTokens": 4, "outputTokens": 5, + "workflow": { "corrections": 5, "correctionRate": 0.03, "medianTimeToFirstEditMs": 360000 }, + "topReworkedFiles": [ { "path": "sdk.py", "sessions": 15, "edits": 42 } ] + } + """) + #expect(current.workflow?.corrections == 5) + #expect(current.workflow?.correctionRate == 0.03) + #expect(current.workflow?.medianTimeToFirstEditMs == 360_000) + #expect(current.topReworkedFiles.first?.path == "sdk.py") + #expect(current.topReworkedFiles.first?.sessions == 15) + #expect(current.topReworkedFiles.first?.edits == 42) + } + + @Test("decodes null workflow rates to nil") + func nulls() throws { + let current = try decodeCurrent(""" + { + "label": "Month", "cost": 1, "calls": 2, "sessions": 3, + "inputTokens": 4, "outputTokens": 5, + "workflow": { "corrections": 0, "correctionRate": null, "medianTimeToFirstEditMs": null }, + "topReworkedFiles": [] + } + """) + #expect(current.workflow?.corrections == 0) + #expect(current.workflow?.correctionRate == nil) + #expect(current.workflow?.medianTimeToFirstEditMs == nil) + #expect(current.topReworkedFiles.isEmpty) + } + + @Test("older payloads without the keys decode fine") + func backwardCompatible() throws { + let current = try decodeCurrent(""" + { + "label": "Month", "cost": 1, "calls": 2, "sessions": 3, + "inputTokens": 4, "outputTokens": 5 + } + """) + #expect(current.workflow == nil) + #expect(current.topReworkedFiles.isEmpty) + // And an older payload yields a hidden strip. + let model = WorkflowStripModel(workflow: current.workflow, topReworkedFiles: current.topReworkedFiles) + #expect(model.isEmpty) + } +}