Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -202,14 +220,22 @@ 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 {
enum CodingKeys: String, CodingKey {
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)
Expand All @@ -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) ?? []
}
}

Expand Down
127 changes: 127 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/WorkflowStrip.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
1 change: 1 addition & 0 deletions mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ struct MenuBarContent: View {
ActivitySection()
Divider().opacity(0.5)
ModelsSection()
WorkflowSection()
Divider().opacity(0.5)
ToolingSection()
Divider().opacity(0.5)
Expand Down
111 changes: 111 additions & 0 deletions mac/Sources/CodeBurnMenubar/Views/WorkflowSection.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Loading