Skip to content

Commit b2c568c

Browse files
authored
feat: copy session details button in Context Health (#116)
- New clipboard button in Context Health header (next to refresh) copies full session details: exact token counts (not abbreviated), model, project, branch, duration, velocity, warnings, and actions. - SessionInfoFormatter.copyableDetails() formats as aligned plain text with header separator for clean terminal/chat pasting. - 6 new tests for copyableDetails: header, session ID, model, project/ branch, exact token counts, empty field omission. - Specs updated (ARCHITECTURE.md, UI_SPEC.md)
1 parent fc46a18 commit b2c568c

File tree

5 files changed

+111
-1
lines changed

5 files changed

+111
-1
lines changed

AIBattery/Views/TokenHealthSection.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ struct TokenHealthSection: View {
5353
sessionToggle
5454
}
5555

56+
if !collapsed {
57+
copyDetailsButton
58+
}
5659
if !collapsed, let onRefresh {
5760
RefreshButton(action: onRefresh)
5861
}
@@ -276,6 +279,28 @@ struct TokenHealthSection: View {
276279
private var sessionIdPrefix: String? { SessionInfoFormatter.idPrefix(for: health) }
277280
private var sessionBottomParts: [String] { SessionInfoFormatter.bottomParts(for: health) }
278281

282+
@State private var detailsCopied = false
283+
284+
private var copyDetailsButton: some View {
285+
Button(action: {
286+
let text = SessionInfoFormatter.copyableDetails(for: health)
287+
NSPasteboard.general.clearContents()
288+
NSPasteboard.general.setString(text, forType: .string)
289+
detailsCopied = true
290+
Task {
291+
try? await Task.sleep(nanoseconds: 1_500_000_000)
292+
detailsCopied = false
293+
}
294+
}) {
295+
Image(systemName: detailsCopied ? "doc.on.clipboard.fill" : "doc.on.clipboard")
296+
.font(.system(size: 10))
297+
.foregroundStyle(detailsCopied ? .green : .secondary)
298+
}
299+
.buttonStyle(.plain)
300+
.help("Copy session details to clipboard")
301+
.accessibilityLabel("Copy session details")
302+
}
303+
279304
private var healthBadge: some View {
280305
HStack(spacing: 4) {
281306
Circle()

AIBattery/Views/TokenHealthSessionInfo.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,47 @@ enum SessionInfoFormatter {
6868
return parts.joined(separator: "\n")
6969
}
7070

71+
/// Markdown-formatted session details for clipboard export.
72+
/// Includes exact token counts (not abbreviated) and all visible metadata.
73+
static func copyableDetails(for health: TokenHealthStatus) -> String {
74+
var lines: [String] = []
75+
lines.append("Context Health")
76+
lines.append("─────────────")
77+
if !health.id.isEmpty { lines.append("Session: \(health.id)") }
78+
if !health.model.isEmpty { lines.append("Model: \(ModelNameMapper.displayName(for: health.model))") }
79+
if let name = health.projectName { lines.append("Project: \(name)") }
80+
if let branch = health.gitBranch, branch != "HEAD", !branch.isEmpty {
81+
lines.append("Branch: \(branch)")
82+
}
83+
lines.append("Context: \(health.totalUsed)/\(health.usableWindow) (\(Int(health.usagePercentage))%)")
84+
lines.append("Input: \(health.inputTokens)")
85+
lines.append("Output: \(health.outputTokens)")
86+
if health.cacheReadTokens > 0 { lines.append("Cache R: \(health.cacheReadTokens)") }
87+
if health.cacheWriteTokens > 0 { lines.append("Cache W: \(health.cacheWriteTokens)") }
88+
lines.append("Turns: \(health.turnCount)")
89+
if let duration = health.sessionDuration {
90+
lines.append("Duration: \(DurationFormatter.compact(duration))")
91+
}
92+
if let velocity = health.tokensPerMinute, velocity > 0 {
93+
lines.append("Velocity: \(Int(velocity)) tok/min")
94+
}
95+
if let start = health.sessionStart {
96+
lines.append("Started: \(formatSessionTime(start))")
97+
}
98+
if let lastActivity = health.lastActivity {
99+
lines.append("Last: \(formatSessionTime(lastActivity))")
100+
}
101+
if !health.warnings.isEmpty {
102+
for w in health.warnings {
103+
lines.append("\(w.message)")
104+
}
105+
}
106+
if let action = health.suggestedAction {
107+
lines.append("\(action)")
108+
}
109+
return lines.joined(separator: "\n")
110+
}
111+
71112
// MARK: - Time formatting
72113

73114
private static let timeFormatter: DateFormatter = {

Tests/AIBatteryCoreTests/Views/SessionInfoFormatterTests.swift

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,49 @@ struct SessionInfoFormatterTests {
139139
#expect(result.hasPrefix("Today"))
140140
}
141141

142+
// MARK: - Copyable details
143+
144+
@Test func copyableDetails_includesHeader() {
145+
let health = makeHealth()
146+
let text = SessionInfoFormatter.copyableDetails(for: health)
147+
#expect(text.contains("Context Health"))
148+
#expect(text.contains("─────────────"))
149+
}
150+
151+
@Test func copyableDetails_includesSessionId() {
152+
let health = makeHealth(id: "abc-12345")
153+
let text = SessionInfoFormatter.copyableDetails(for: health)
154+
#expect(text.contains("Session: abc-12345"))
155+
}
156+
157+
@Test func copyableDetails_includesModel() {
158+
let health = makeHealth(model: "claude-sonnet-4-5-20250929")
159+
let text = SessionInfoFormatter.copyableDetails(for: health)
160+
#expect(text.contains("Model:"))
161+
}
162+
163+
@Test func copyableDetails_includesProjectAndBranch() {
164+
let health = makeHealth(projectName: "MyApp", gitBranch: "feat/login")
165+
let text = SessionInfoFormatter.copyableDetails(for: health)
166+
#expect(text.contains("Project: MyApp"))
167+
#expect(text.contains("Branch: feat/login"))
168+
}
169+
170+
@Test func copyableDetails_includesExactTokenCounts() {
171+
let health = makeHealth(totalUsed: 50000, usableWindow: 160000)
172+
let text = SessionInfoFormatter.copyableDetails(for: health)
173+
#expect(text.contains("Context: 50000/160000"))
174+
}
175+
176+
@Test func copyableDetails_omitsEmptyFields() {
177+
let health = makeHealth(id: "", model: "", projectName: nil, gitBranch: nil)
178+
let text = SessionInfoFormatter.copyableDetails(for: health)
179+
#expect(!text.contains("Session:"))
180+
#expect(!text.contains("Model:"))
181+
#expect(!text.contains("Project:"))
182+
#expect(!text.contains("Branch:"))
183+
}
184+
142185
// MARK: - Helpers
143186

144187
private func makeHealth(

spec/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ AIBattery/
111111
TutorialOverlay.swift — First-launch 3-step walkthrough overlay
112112
UsageBarsSection.swift — FiveHourBarSection + SevenDayBarSection rate limit bars
113113
TokenHealthSection.swift — Context health gauge + warnings + multi-session chevron toggle
114-
TokenHealthSessionInfo.swift — Session detail computation: label parts, tooltip, idle detection, time formatting
114+
TokenHealthSessionInfo.swift — Session detail computation: label parts, tooltip, idle detection, time formatting, clipboard export
115115
TokenUsageSection.swift — Per-model token breakdown with token type tags + optional cost
116116
ProjectUsageSection.swift — Per-project token breakdown with optional cost
117117
ActivityChartView.swift — 24H/7D/12M activity chart (Swift Charts, rolling windows) + insight rows (All Time, Longest, Period)

spec/UI_SPEC.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ Takes `sessions: [TokenHealthStatus]` array (top 5 by highest context usage). Ba
226226
- **VoiceOver**: `.accessibilityAdjustableAction` on section — increment/decrement maps to next/previous session
227227
- **Stale session badge** (if lastActivity > 30 min and band != .green): amber dot (6pt) + `"Idle Xm"` (.caption2, .orange)
228228
- **Expanded tooltip**: `.help()` on session info label with full details — session ID, model, context window, all timestamps, all token counts, warnings
229+
- **Copy details button**: `doc.on.clipboard` 10pt, .secondary → green `doc.on.clipboard.fill` for 1.5s after click. Copies full session details (exact token counts, model, project, branch, warnings) to clipboard via `SessionInfoFormatter.copyableDetails(for:)`.
229230
- **Refresh button**: `arrow.clockwise` 10pt, .secondary
230231
- **Health badge**: 8pt colored circle + percentage in monospaced subheadline semibold
231232
- **Gauge bar**: same style as usage bars (8pt, 3pt radius), width proportional to usagePercentage

0 commit comments

Comments
 (0)