nvrsion: promote trunk to dev

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-06-27 19:33:40 -07:00
co-authored by nucleic
parent 6069366ed4
commit 5e00404575
25 changed files with 1672 additions and 184 deletions
+65
View File
@@ -0,0 +1,65 @@
import AppKit
import NucleicCore
/// Drives the "escalating alarm": while a chat needs the user and they haven't opened it,
/// repeat the "Submarine" system sound with a short gap between plays until they return
/// to that chat. `AppStore` tracks *which* chats are blocked-and-unviewed and calls
/// `setActive(_:)` as that set flips between empty and non-empty (`AppStore.chatAlarmHook`,
/// installed in `NucleicApp.init`); this type owns the repeating playback and honors the
/// off-by-default Settings switch. A singleton so the one alarm loop is shared app-wide.
@MainActor
final class ChatAlarm {
static let shared = ChatAlarm()
private init() {}
/// Silence between consecutive plays, so the sound repeats as a deliberate pulse rather
/// than the next copy stacking on top of the last while it's still sounding.
private let gap = Duration.seconds(0.5)
/// The running repeat loop, or nil when silent. Cancelling it stops scheduling new plays;
/// `current?.stop()` cuts off whatever is sounding right now for instant silence on return.
private var loop: Task<Void, Never>?
private var current: NSSound?
/// Start or stop the alarm. Honors the Settings switch: with the alarm turned off,
/// `active == true` is a no-op, so a blocked chat still rings only its one-shot "Pop" cue.
/// `active == false` always stops, so toggling the switch off mid-alarm silences it at once.
func setActive(_ active: Bool) {
if active && ChatStatusSounds.escalatingAlarm {
start()
} else {
stop()
}
}
private func start() {
guard loop == nil else { return } // already ringing don't stack loops
loop = Task { @MainActor [weak self] in
guard let self else { return }
// Pace each cycle by the clip's own length plus the gap, so a ~1.5s sound doesn't
// pile copies on itself; fall back to a sane length if the system can't report one.
let probe = NSSound(named: NSSound.Name(ChatStatusSounds.alarmSoundName))
let clip: Duration = (probe?.duration ?? 0) > 0 ? .seconds(probe!.duration) : .seconds(1.5)
while !Task.isCancelled {
self.ring()
try? await Task.sleep(for: clip + self.gap)
}
}
}
private func stop() {
loop?.cancel()
loop = nil
current?.stop()
current = nil
}
/// Play one fresh copy of the alarm sound, holding the reference so `stop()` can cut it off
/// the instant the user returns to the chat.
private func ring() {
guard let sound = NSSound(named: NSSound.Name(ChatStatusSounds.alarmSoundName))?.copy() as? NSSound
else { return }
current = sound
sound.play()
}
}
+16 -7
View File
@@ -3,15 +3,19 @@ import NucleicCore
/// Plays a macOS system sound when a chat finishes its work or starts needing the
/// user. Installed on `AppStore.chatStatusSoundHook` at launch (see `NucleicApp.init`),
/// so it fires only on live transitions never on launch/resume. Each cue has its own
/// on/off switch in Settings Chats, both on by default.
/// so it fires only on live transitions never on launch/resume. Each one-shot cue has
/// its own on/off switch in Settings Chats, both on by default; a separate, off-by-default
/// "escalating alarm" (driven by `ChatAlarm`) instead *repeats* until the user returns to
/// the chat that needs them.
enum ChatStatusSounds {
/// `@AppStorage` keys for the two cues. Defaults are *on*, so we read with the
/// `object(forKey:) as? Bool ?? true` dance: until the Settings view first binds
/// (and writes a value), the key is unset, and a plain `bool(forKey:)` would read
/// `false` silencing the cue before the user has ever opened Settings.
/// `@AppStorage` keys for the cues. The two one-shot cues default *on*, so we read with
/// the `object(forKey:) as? Bool ?? true` dance: until the Settings view first binds (and
/// writes a value), the key is unset, and a plain `bool(forKey:)` would read `false`
/// silencing the cue before the user has ever opened Settings. The escalating alarm is the
/// opposite strictly opt-in so it defaults *off* (`?? false`).
static let playOnDoneKey = "nucleic.chat.sound.done"
static let playOnBlockedKey = "nucleic.chat.sound.blocked"
static let escalatingAlarmKey = "nucleic.chat.sound.alarm"
static var playOnDone: Bool {
UserDefaults.standard.object(forKey: playOnDoneKey) as? Bool ?? true
@@ -19,11 +23,16 @@ enum ChatStatusSounds {
static var playOnBlocked: Bool {
UserDefaults.standard.object(forKey: playOnBlockedKey) as? Bool ?? true
}
static var escalatingAlarm: Bool {
UserDefaults.standard.object(forKey: escalatingAlarmKey) as? Bool ?? false
}
/// macOS system sounds (in /System/Library/Sounds), resolved by name. "Funk" marks
/// a finished chat; "Pop" marks one that now needs the user.
/// a finished chat; "Pop" marks one that now needs the user; "Submarine" is the
/// escalating alarm's repeating pulse (played by `ChatAlarm`, not `play(_:)`).
private static let doneSoundName = "Funk"
private static let blockedSoundName = "Pop"
static let alarmSoundName = "Submarine"
/// Sound the cue for a settled chat transition, if its switch is on. Plays a fresh
/// copy each time so back-to-back cues (e.g. two chats finishing at once) overlap
+51 -15
View File
@@ -93,6 +93,10 @@ struct HomeView: View {
StatCard(value: store.dashboard.chats, label: "Chats", icon: "bubble.left.and.bubble.right")
StatCard(value: store.dashboard.activeChats, label: "Active", icon: "bolt")
StatCard(value: store.dashboard.messages, label: "Messages", icon: "paperplane")
StatCard(
value: TokenCount.abbreviated(store.dashboard.tokens),
label: "Tokens", icon: "number",
help: "\(store.dashboard.tokens.formatted()) tokens used across all chats")
}
TodoSection()
@@ -136,7 +140,9 @@ struct HomeView: View {
private func activityColumn(frozenDays: Set<Date>) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Activity").font(.headline)
ActivityGrid(activityByDay: store.activityByDay, frozenDays: frozenDays)
ActivityGrid(
activityByDay: store.activityByDay, tokensByDay: store.tokensByDay,
frozenDays: frozenDays)
HStack(spacing: 5) {
Text("Less").font(.caption2).foregroundStyle(.secondary)
ForEach(0..<4, id: \.self) { level in
@@ -656,14 +662,31 @@ struct StreakBadge: View {
}
private struct StatCard: View {
let value: Int
let value: String
let label: String
let icon: String
/// Help text shown on hover used to spell out an abbreviated value (e.g. the exact token
/// count behind "12.3K"). Nil for the plain integer cards, whose value is already exact.
var help: String?
init(value: Int, label: String, icon: String) {
self.value = value.formatted()
self.label = label
self.icon = icon
self.help = nil
}
init(value: String, label: String, icon: String, help: String? = nil) {
self.value = value
self.label = label
self.icon = icon
self.help = help
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: icon).foregroundStyle(.secondary)
Text("\(value)").font(.system(size: 26, weight: .semibold).monospacedDigit())
Text(value).font(.system(size: 26, weight: .semibold).monospacedDigit())
Text(label).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -671,14 +694,19 @@ private struct StatCard: View {
.padding(.vertical, 10)
.background(AppTheme.surface, in: .rect(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(AppTheme.hairline, lineWidth: 1))
.help(help ?? "")
}
}
/// Squares for the last `weeks` weeks, columns = weeks (old new), rows = SunSat,
/// shaded by that day's message count.
/// shaded by that day's token usage or, for days with no recorded usage, its message count
/// (see `ActivityShading`).
struct ActivityGrid: View {
@Environment(\.appPalette) private var palette
let activityByDay: [Date: Int]
/// Tokens used per day the primary intensity signal. Days absent here fall back to their
/// `activityByDay` message count, so older transcripts still shade sensibly.
var tokensByDay: [Date: Int] = [:]
/// Missed days a streak freeze kept alive drawn in the frozen blue instead of the
/// empty-day gray, so a bridged gap reads as "saved" rather than skipped.
var frozenDays: Set<Date> = []
@@ -696,6 +724,7 @@ struct ActivityGrid: View {
private struct HoveredCell: Equatable {
let date: Date
let count: Int
let tokens: Int
let isFrozen: Bool
let column: Int
let row: Int
@@ -725,15 +754,16 @@ struct ActivityGrid: View {
let startOfThisWeek = calendar.date(byAdding: .day, value: -(weekday - 1), to: today)!
let gridStart = calendar.date(byAdding: .day, value: -7 * (weeks - 1), to: startOfThisWeek)!
// Brightness is relative to *this user's* activity, with outliers excluded so a few
// marathon days don't crush the everyday range. Computed once for the whole grid.
let scale = ActivityScale(activityByDay: activityByDay)
// marathon days don't crush the everyday range. Shades by tokens-per-day, falling back to
// message count for days without recorded usage. Computed once for the whole grid.
let shading = ActivityShading(tokensByDay: tokensByDay, messagesByDay: activityByDay)
return HStack(alignment: .top, spacing: gap) {
ForEach(0..<weeks, id: \.self) { column in
VStack(spacing: gap) {
ForEach(0..<7, id: \.self) { row in
let date = calendar.date(byAdding: .day, value: column * 7 + row, to: gridStart)!
cellView(date: date, today: today, scale: scale, column: column, row: row)
cellView(date: date, today: today, shading: shading, column: column, row: row)
}
}
}
@@ -741,12 +771,14 @@ struct ActivityGrid: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
private func cellView(date: Date, today: Date, scale: ActivityScale, column: Int, row: Int) -> some View {
private func cellView(date: Date, today: Date, shading: ActivityShading, column: Int, row: Int) -> some View {
let isFuture = date > today
let isFrozen = !isFuture && frozenDays.contains(date)
let count = activityByDay[date] ?? 0
let tokens = tokensByDay[date] ?? 0
let fill = isFuture ? Color.clear
: (isFrozen ? palette.frozen : palette.activity(intensity: scale.intensity(for: count)))
: (isFrozen ? palette.frozen
: palette.activity(intensity: shading.intensity(tokens: tokens, messages: count)))
let isHovered = hovered?.date == date
return RoundedRectangle(cornerRadius: 2)
.fill(fill)
@@ -754,11 +786,11 @@ struct ActivityGrid: View {
// A faint outline marks the square the tooltip is describing.
.overlay(RoundedRectangle(cornerRadius: 2)
.strokeBorder(Color.primary.opacity(isHovered ? 0.35 : 0), lineWidth: 1))
.accessibilityLabel(isFuture ? "" : Self.tooltipText(date: date, count: count, isFrozen: isFrozen))
.accessibilityLabel(isFuture ? "" : Self.tooltipText(date: date, count: count, tokens: tokens, isFrozen: isFrozen))
.onHover { inside in
guard !isFuture else { return }
if inside {
hovered = HoveredCell(date: date, count: count, isFrozen: isFrozen, column: column, row: row)
hovered = HoveredCell(date: date, count: count, tokens: tokens, isFrozen: isFrozen, column: column, row: row)
} else if hovered?.date == date {
hovered = nil
}
@@ -789,7 +821,7 @@ struct ActivityGrid: View {
}
private func bubble(_ h: HoveredCell) -> some View {
Text(Self.tooltipText(date: h.date, count: h.count, isFrozen: h.isFrozen))
Text(Self.tooltipText(date: h.date, count: h.count, tokens: h.tokens, isFrozen: h.isFrozen))
.font(.caption)
.foregroundStyle(.primary)
.padding(.horizontal, 8)
@@ -807,9 +839,13 @@ struct ActivityGrid: View {
}()
static func dayLabel(_ date: Date) -> String { formatter.string(from: date) }
/// "Jun 12, 2026 · 5 messages" the day and its activity count, or a streak-freeze note.
static func tooltipText(date: Date, count: Int, isFrozen: Bool) -> String {
/// "Jun 12, 2026 · 12.3K tokens · 5 messages" the day with its token usage (when recorded)
/// and message count, or a streak-freeze note. Tokens lead since they drive the shade; the
/// clause is dropped on days with no recorded usage so older days still read cleanly.
static func tooltipText(date: Date, count: Int, tokens: Int, isFrozen: Bool) -> String {
if isFrozen { return "\(dayLabel(date)) · Streak freeze" }
return "\(dayLabel(date)) · \(count) message\(count == 1 ? "" : "s")"
let messages = "\(count) message\(count == 1 ? "" : "s")"
guard tokens > 0 else { return "\(dayLabel(date)) · \(messages)" }
return "\(dayLabel(date)) · \(TokenCount.abbreviated(tokens)) tokens · \(messages)"
}
}
+206 -24
View File
@@ -28,9 +28,21 @@ struct HostExecCard: View {
private let maxHeight: CGFloat = 220
@State private var contentHeight: CGFloat = 0
/// The deterministic, command-derived breakdown (program, actions, flags, inferred purpose),
/// parsed from the command string alone never from anything the agent claimed it would do.
private var parsed: HostCommandSummary.Summary? { HostCommandSummary.summary(for: command) }
var body: some View {
VStack(alignment: .leading, spacing: 9) {
VStack(alignment: .leading, spacing: 10) {
header
// What this looks like, laid out so the user can actually check it: the inferred
// purpose and the program/actions/flags, then the exact command beneath as the
// ground truth the breakdown is derived from.
if let parsed {
HostCommandBreakdown(summary: parsed, accent: accent, showPurpose: true)
Text("Exact command")
.font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
}
commandBlock
}
.padding(.horizontal, 12).padding(.vertical, 10)
@@ -127,20 +139,44 @@ struct HostExecToolCard: View {
private var accent: Color { palette.accent }
@State private var expanded = false
/// Expandable only when there's output to reveal.
/// The deterministic, command-derived breakdown (program, actions, flags, inferred purpose)
/// parsed from the command string alone, so the collapsed line can lead with what the call
/// *does* rather than the raw command. Recomputed cheaply from the command on each render.
private var parsed: HostCommandSummary.Summary? { HostCommandSummary.summary(for: command) }
/// Whether there's output to reveal.
private var hasOutput: Bool { !(output ?? "").isEmpty }
/// Whether the parse carries structure worth revealing (a working dir, more than one step, or
/// any flags/args/env) so a richer command is expandable even before it has output.
private var hasBreakdown: Bool {
guard let parsed else { return false }
if parsed.workingDirectory != nil || parsed.invocations.count > 1 { return true }
return parsed.invocations.contains {
!$0.flags.isEmpty || !$0.arguments.isEmpty || !$0.env.isEmpty
}
}
private var canExpand: Bool { hasOutput || hasBreakdown }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
.contentShape(Rectangle())
.onTapGesture {
guard hasOutput else { return }
guard canExpand else { return }
withAnimation(.easeInOut(duration: 0.15)) { expanded.toggle() }
}
if expanded, let output, !output.isEmpty {
Divider().overlay(accent.opacity(0.14))
outputBody(output)
if expanded {
if hasBreakdown, let parsed {
Divider().overlay(accent.opacity(0.14))
HostCommandBreakdown(summary: parsed, accent: accent, showPurpose: false)
.padding(.horizontal, 10).padding(.vertical, 8)
}
if let output, !output.isEmpty {
Divider().overlay(accent.opacity(0.14))
outputBody(output)
}
}
}
.background(accent.opacity(0.06))
@@ -148,15 +184,49 @@ struct HostExecToolCard: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
}
/// The host glyph, a "Host" tag, and the command in a `$`-prefixed monospaced run one line,
/// truncated, when collapsed; wrapping in full once expanded plus a running spinner and,
/// when there's output, a disclosure chevron. Mirrors `ToolCallRow`'s header layout.
/// The host glyph, a "Host" tag, the inferred purpose (the easy-to-read headline), and the
/// `$`-prefixed command beneath it as the literal plus a running spinner and, when there's
/// detail to reveal, a disclosure chevron.
private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "desktopcomputer")
.font(.caption).foregroundStyle(accent).frame(width: 15)
Text("Host")
.font(.callout.weight(.medium)).foregroundStyle(AppTheme.primaryText)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("Host")
.font(.callout.weight(.medium)).foregroundStyle(AppTheme.primaryText)
if let parsed {
// The command-derived purpose, rendered with its `code` spans, as the line
// the user reads first what the call looks like it does.
Text(TranscriptRow.inlineMarkdown(parsed.purpose))
.font(.callout).foregroundStyle(AppTheme.primaryText)
.lineLimit(1).truncationMode(.tail)
}
}
commandLine
}
Spacer(minLength: 0)
if !finished {
ProgressView().controlSize(.small).scaleEffect(0.7)
}
if canExpand {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(expanded ? 90 : 0))
.padding(.top, 2)
}
}
.padding(.horizontal, 10).padding(.vertical, 6)
.animation(.easeInOut(duration: 0.15), value: expanded)
}
/// The literal command in a `$`-prefixed monospaced run one line, truncated, when
/// collapsed; wrapping in full once expanded so the exact command stays visible beneath the
/// human-readable purpose.
private var commandLine: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("$")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(accent.opacity(0.7))
@@ -167,19 +237,7 @@ struct HostExecToolCard: View {
.truncationMode(.middle)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: expanded)
Spacer(minLength: 0)
if !finished {
ProgressView().controlSize(.small).scaleEffect(0.7)
}
if hasOutput {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(expanded ? 90 : 0))
}
}
.padding(.horizontal, 10).padding(.vertical, 6)
.animation(.easeInOut(duration: 0.15), value: expanded)
}
/// The command's output, revealed on expand: a return glyph and the text in a monospaced
@@ -200,6 +258,130 @@ struct HostExecToolCard: View {
}
}
// MARK: - Command breakdown
/// The shared, deterministic breakdown of a `host_exec` command driven entirely by
/// ``HostCommandSummary`` (parsed from the command string, never from the agent). It lays out
/// the inferred purpose (optionally), the working directory, and each program invocation as its
/// program + actions + flags + operands, so the user can verify *exactly* what's about to run.
/// Used by ``HostExecCard`` (the approval, with the purpose) and ``HostExecToolCard`` (the
/// in-chat call, where the purpose already heads the row so it's shown without it).
struct HostCommandBreakdown: View {
@Environment(\.appPalette) private var palette
let summary: HostCommandSummary.Summary
/// The card's accent (the program/action chips and glyphs).
let accent: Color
/// Whether to render the prominent "Likely purpose" row and any `sudo`/destructive banner.
/// The chat card heads its row with the purpose already, so it passes `false`.
var showPurpose: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: 9) {
if showPurpose {
purposeRow
if summary.isElevated || summary.isDestructive { riskBanner }
}
if let dir = summary.workingDirectory {
detailRow(icon: "folder", lines: ["in \(dir)"])
}
ForEach(Array(summary.invocations.enumerated()), id: \.element.id) { index, inv in
if index > 0 { Divider().overlay(accent.opacity(0.12)) }
invocationView(inv)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
/// The deterministic, command-derived purpose labeled so the user knows it's *Nucleic's*
/// reading of the command, not the agent's claim about it.
private var purposeRow: some View {
HStack(alignment: .top, spacing: 9) {
Image(systemName: "sparkles")
.font(.callout).foregroundStyle(accent).frame(width: 16)
VStack(alignment: .leading, spacing: 2) {
Text("Likely purpose · inferred from the command")
.font(.caption2.weight(.semibold)).foregroundStyle(.secondary)
Text(TranscriptRow.inlineMarkdown(summary.purpose))
.font(.callout.weight(.semibold))
.foregroundStyle(AppTheme.primaryText)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}
Spacer(minLength: 0)
}
.help("Inferred by Nucleic from the command itself — not from anything the agent said.")
}
/// A red banner for the two things a host command most needs flagged before "Allow": running
/// as root (`sudo`) and deleting files (`rm`).
private var riskBanner: some View {
let icon = summary.isElevated ? "lock.shield" : "trash"
let text = summary.isElevated
? "Runs with elevated privileges (sudo)."
: "Deletes files on the host."
return HStack(alignment: .firstTextBaseline, spacing: 7) {
Image(systemName: icon).font(.caption).foregroundStyle(palette.danger).frame(width: 16)
Text(text).font(.caption.weight(.medium)).foregroundStyle(palette.danger)
Spacer(minLength: 0)
}
.padding(.horizontal, 8).padding(.vertical, 5)
.background(palette.danger.opacity(0.08), in: .rect(cornerRadius: 6))
}
/// One program invocation: the program name as a filled badge, its actions as outlined chips,
/// and beneath its env, flags, and operands each as a labeled, monospaced list so every
/// part of the command is individually legible.
private func invocationView(_ inv: HostCommandSummary.Invocation) -> some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
if inv.elevated { chip("sudo", color: palette.danger, filled: true) }
chip(inv.program, color: accent, filled: true)
ForEach(inv.actions, id: \.self) { chip($0, color: accent, filled: false) }
if inv.destructive { chip("deletes", color: palette.danger, filled: false) }
Spacer(minLength: 0)
}
if !inv.env.isEmpty {
detailRow(icon: "leaf", lines: inv.env.map(\.display))
}
if !inv.flags.isEmpty {
detailRow(icon: "slider.horizontal.3", lines: inv.flags.map(\.display))
}
if !inv.arguments.isEmpty {
detailRow(icon: "chevron.right", lines: [inv.arguments.joined(separator: " ")])
}
}
}
/// A small chip a filled program badge or an outlined action/marker pill.
private func chip(_ text: String, color: Color, filled: Bool) -> some View {
Text(text)
.font(.system(.caption2, design: .monospaced).weight(.medium))
.foregroundStyle(color)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(color.opacity(filled ? 0.16 : 0), in: Capsule())
.overlay(Capsule().strokeBorder(color.opacity(filled ? 0 : 0.4), lineWidth: 0.75))
}
/// A labeled detail block: a small tertiary glyph and a monospaced list (env vars, one flag
/// per line, or the operands) the granular pieces the user scans to verify the command.
private func detailRow(icon: String, lines: [String]) -> some View {
HStack(alignment: .top, spacing: 7) {
Image(systemName: icon).font(.caption2).foregroundStyle(.tertiary)
.frame(width: 14).padding(.top, 1)
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
}
// MARK: - Host-command conflict (HOST_EXEC §concurrency)
/// View-model for the host-command conflict override prompt: the command this session is about to
+4
View File
@@ -100,6 +100,10 @@ struct NucleicApp: App {
// user (Pop). The handler honors the per-cue toggles in Settings Chats; the
// store fires only on live transitions, so launch/resume stay silent.
store.chatStatusSoundHook = { ChatStatusSounds.play($0) }
// Drive the escalating alarm: the store flips this true while a chat sits blocked
// and unviewed, false once the user opens it (or it resolves). `ChatAlarm` honors
// the off-by-default Settings switch and owns the repeating "Submarine" pulse.
store.chatAlarmHook = { ChatAlarm.shared.setActive($0) }
store.defaultModel = ModelCatalog.storedDefaultModel
store.defaultEffort = ModelCatalog.storedDefaultEffort
store.defaultAuto = ModelCatalog.storedDefaultAuto
+8
View File
@@ -84,6 +84,7 @@ private struct GeneralSettingsTab: View {
@AppStorage(StreakBadge.showKey) private var showStreak = true
@AppStorage(ChatStatusSounds.playOnDoneKey) private var playDoneSound = true
@AppStorage(ChatStatusSounds.playOnBlockedKey) private var playBlockedSound = true
@AppStorage(ChatStatusSounds.escalatingAlarmKey) private var escalatingAlarm = false
@AppStorage(TriageLabelStyle.encouragingKey) private var encouragingTriageLabels = true
@AppStorage(SingularityPreparation.enabledKey) private var singularityPreparation = false
@AppStorage(MoveDiagnostics.loggingEnabledKey) private var smartMoveLogging = false
@@ -120,6 +121,13 @@ private struct GeneralSettingsTab: View {
Toggle("Play a sound when a chat needs you", isOn: $playBlockedSound)
Text("Plays the “Pop” system sound when a chat becomes blocked on you — waiting for a reply or an approval.")
.settingsCaption()
Toggle("Escalating alarm until you return", isOn: $escalatingAlarm)
.onChange(of: escalatingAlarm) { _, isOn in
if !isOn { ChatAlarm.shared.setActive(false) } // silence any alarm in progress
}
Text("Instead of a single cue, repeats the “Submarine” system sound until you open the chat that needs you. Off by default.")
.settingsCaption()
}
Section("To-dos") {
+13 -35
View File
@@ -422,45 +422,23 @@ private struct TodoRow: View {
@ViewBuilder
private var dispatchControl: some View {
if let project = taggedProject {
// A hand-built split button: the main face fires the agent and stays put; the
// trailing chevron drops a menu whose one action also follows into the new
// session. Built by hand rather than Menu(primaryAction:) because the system
// split button jams its disclosure chevron flush against the right edge with no
// trailing inset here the chevron carries its own padding inside the fill.
HStack(spacing: 0) {
// A split button: the main face fires the agent and stays put; the trailing
// chevron drops a menu whose one action also follows into the new session.
// (.borderedProminent reads gray here because .menuStyle(.button) overrides the
// prominent tint kept intentionally; the alternative teal is harder to read.)
Menu {
Button {
Task { await store.dispatchTodo(todo.id, in: project, follow: false) }
Task { await store.dispatchTodo(todo.id, in: project, follow: true) }
} label: {
Label("Dispatch", systemImage: "paperplane.fill")
.padding(.vertical, 5)
.padding(.leading, 11)
.padding(.trailing, 9)
.contentShape(Rectangle())
Label("Dispatch and Follow", systemImage: "paperplane.fill")
}
Rectangle()
.fill(Color.white.opacity(0.35))
.frame(width: 1, height: 14)
Menu {
Button {
Task { await store.dispatchTodo(todo.id, in: project, follow: true) }
} label: {
Label("Dispatch and Follow", systemImage: "paperplane.fill")
}
} label: {
Image(systemName: "chevron.down")
.font(.caption2)
.padding(.vertical, 5)
.padding(.leading, 8)
.padding(.trailing, 10)
.contentShape(Rectangle())
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
} label: {
Label("Dispatch", systemImage: "paperplane.fill")
} primaryAction: {
Task { await store.dispatchTodo(todo.id, in: project, follow: false) }
}
.buttonStyle(.plain)
.foregroundStyle(.white)
.background(palette.accent, in: .rect(cornerRadius: 6))
.menuStyle(.button)
.buttonStyle(.borderedProminent)
.fixedSize()
.help("Start an agent on this in \(project.name)")
} else if store.projects.isEmpty {
+105 -22
View File
@@ -8,13 +8,20 @@ public struct DashboardStats: Sendable, Equatable {
public var activeChats: Int
public var messages: Int
public var activeDays: Int
/// Total tokens used across every session (`Usage.totalTokens` summed). Surfaced as its own
/// home stat card; the per-day breakdown lives in `AppStore.tokensByDay`.
public var tokens: Int
public init(projects: Int = 0, chats: Int = 0, activeChats: Int = 0, messages: Int = 0, activeDays: Int = 0) {
public init(
projects: Int = 0, chats: Int = 0, activeChats: Int = 0, messages: Int = 0,
activeDays: Int = 0, tokens: Int = 0
) {
self.projects = projects
self.chats = chats
self.activeChats = activeChats
self.messages = messages
self.activeDays = activeDays
self.tokens = tokens
}
public static let empty = DashboardStats()
@@ -283,11 +290,17 @@ public final class AppStore: ConflictArbiter {
private var sessionsCreatedThisRun: Set<SessionID> = []
/// Home-dashboard rollup across all projects/sessions (recomputed on demand).
public private(set) var dashboard: DashboardStats = .empty
/// User-message counts per calendar day, for the activity grid.
/// User-message counts per calendar day, for the activity grid and the streak.
public private(set) var activityByDay: [Date: Int] = [:]
/// Tokens used per calendar day the activity grid's primary intensity signal (it falls back
/// to `activityByDay` for days with no recorded usage), and the per-day series behind the
/// dashboard's total-tokens metric.
public private(set) var tokensByDay: [Date: Int] = [:]
public var openSessionID: SessionID? {
didSet {
guard oldValue != openSessionID else { return }
// Opening a chat is the user "returning to view it" silence its escalating alarm.
if let opened = openSessionID { updateAlarm(clearing: opened) }
// Reset synchronously so the (possibly shorter) newly-selected session's
// transcript can replace the previous one; the async reload then fills it.
openTranscript = []
@@ -421,6 +434,12 @@ public final class AppStore: ConflictArbiter {
/// Called on the main actor; the per-cue on/off switches live in the app layer.
/// Left `nil` in tests/headless, where firing is a no-op.
public var chatStatusSoundHook: (@MainActor (ChatStatusSound) -> Void)?
/// Installed by NucleicApp to drive the *escalating* alarm a system sound repeated
/// every few seconds while a chat needs the user and they aren't looking, until they
/// open it. Called with `true` when at least one chat is blocked-and-unviewed and
/// `false` once none remain (see `alarmingSessions`). Off by default; the app layer
/// owns the repeating timer and the Settings switch. Left `nil` in tests/headless.
public var chatAlarmHook: (@MainActor (Bool) -> Void)?
private var summaryToken = 0
private var namedSessions: Set<SessionID> = []
/// Per-session bookkeeping for the one-time post-run rename (see `maybeRenameSession`):
@@ -790,7 +809,8 @@ public final class AppStore: ConflictArbiter {
if let prep {
try? await database.saveActivityCache(session.id, ActivityCacheEntry(
lastSeq: session.lastSeq,
messageCount: prep.messageCount, activityByDay: prep.activityByDay))
messageCount: prep.messageCount, activityByDay: prep.activityByDay,
tokenCount: prep.tokenCount, tokensByDay: prep.tokensByDay))
}
}
if !failed.isEmpty {
@@ -815,6 +835,8 @@ public final class AppStore: ConflictArbiter {
let lastReply: String?
let messageCount: Int
let activityByDay: [Date: Int]
let tokenCount: Int
let tokensByDay: [Date: Int]
}
/// Read every session's transcript concurrently OFF the main actor (this is the work
@@ -830,7 +852,8 @@ public final class AppStore: ConflictArbiter {
let contribution = activityContribution(events: events, calendar: calendar)
return PreparedSession(
id: item.id, events: events, lastReply: lastReply(in: events),
messageCount: contribution.messages, activityByDay: contribution.byDay)
messageCount: contribution.messages, activityByDay: contribution.messagesByDay,
tokenCount: contribution.tokens, tokensByDay: contribution.tokensByDay)
}
}
var result: [SessionID: PreparedSession] = [:]
@@ -848,21 +871,35 @@ public final class AppStore: ConflictArbiter {
return reply
}
/// One session's contribution to the home dashboard: total user messages and their
/// per-day (start-of-day) histogram. Pure over an already-read event list, so it runs
/// off the main actor (Phase 2 warm-up and the dashboard's stale-entry recompute).
/// One session's contribution to the home dashboard: total user messages and total tokens
/// used, each with its per-day (start-of-day) histogram. Pure over an already-read event list,
/// so it runs off the main actor (Phase 2 warm-up and the dashboard's stale-entry recompute).
///
/// Tokens are summed from `.usage` events one per turn, carrying that turn's cumulative
/// totals (`Usage.totalTokens`). Only `.usage` is counted; `turnCompleted`/`runFinished` carry
/// copies of the same numbers, so tallying them too would double-count.
nonisolated static func activityContribution(
events: [AgentEvent], calendar: Calendar
) -> (messages: Int, byDay: [Date: Int]) {
var byDay: [Date: Int] = [:]
) -> (messages: Int, messagesByDay: [Date: Int], tokens: Int, tokensByDay: [Date: Int]) {
var messagesByDay: [Date: Int] = [:]
var tokensByDay: [Date: Int] = [:]
var messages = 0
var tokens = 0
for event in events {
if case .userText = event.kind {
switch event.kind {
case .userText:
messages += 1
byDay[calendar.startOfDay(for: event.at), default: 0] += 1
messagesByDay[calendar.startOfDay(for: event.at), default: 0] += 1
case .usage(let usage):
let used = usage.totalTokens
guard used > 0 else { continue }
tokens += used
tokensByDay[calendar.startOfDay(for: event.at), default: 0] += used
default:
break
}
}
return (messages, byDay)
return (messages, messagesByDay, tokens, tokensByDay)
}
private func reconstructController(
@@ -1139,6 +1176,7 @@ public final class AppStore: ConflictArbiter {
}
try? await database.deleteSession(id: session.id)
summaries.removeAll { $0.id == session.id }
updateAlarm(clearing: session.id) // the chat is gone stop ringing for it
if openSessionID == session.id { openSessionID = nil }
}
reportSandboxCleanupFailures(failedContainers)
@@ -3224,7 +3262,9 @@ public final class AppStore: ConflictArbiter {
let calendar = Calendar.current
var activity: [Date: Int] = [:]
var tokensByDay: [Date: Int] = [:]
var messages = 0
var tokens = 0
// A cache entry is fresh only when its cursor exactly matches the session's live
// lastSeq so a grown OR truncated/reverted transcript both invalidate it. A missing
// or stale entry is recomputed off-main below.
@@ -3234,12 +3274,16 @@ public final class AppStore: ConflictArbiter {
lastSeqByID[session.id] = session.lastSeq
if let entry = cache[session.id], entry.lastSeq == session.lastSeq {
messages += entry.messageCount
tokens += entry.tokenCount
for (day, count) in entry.activityByDay { activity[day, default: 0] += count }
for (day, used) in entry.tokensByDay { tokensByDay[day, default: 0] += used }
} else {
stale.append((id: session.id, path: session.transcriptPath))
}
}
publishDashboard(projects: projects, sessions: sessions, activity: activity, messages: messages)
publishDashboard(
projects: projects, sessions: sessions, activity: activity, messages: messages,
tokensByDay: tokensByDay, tokens: tokens)
// Refresh any cold/stale sessions off the main actor, persist them, and republish so
// the grid fills in without ever blocking the UI.
@@ -3247,19 +3291,25 @@ public final class AppStore: ConflictArbiter {
let prepared = await Self.prepareSessions(stale, calendar: calendar)
for (id, prep) in prepared {
messages += prep.messageCount
tokens += prep.tokenCount
for (day, count) in prep.activityByDay { activity[day, default: 0] += count }
for (day, used) in prep.tokensByDay { tokensByDay[day, default: 0] += used }
try? await database.saveActivityCache(id, ActivityCacheEntry(
lastSeq: lastSeqByID[id] ?? 0,
messageCount: prep.messageCount, activityByDay: prep.activityByDay))
messageCount: prep.messageCount, activityByDay: prep.activityByDay,
tokenCount: prep.tokenCount, tokensByDay: prep.tokensByDay))
}
publishDashboard(projects: projects, sessions: sessions, activity: activity, messages: messages)
publishDashboard(
projects: projects, sessions: sessions, activity: activity, messages: messages,
tokensByDay: tokensByDay, tokens: tokens)
}
/// Publish the dashboard rollup to the observable state. The status-derived counts
/// (chats/active) come straight from the cheap session list; the message count and
/// activity grid are supplied by the caller (cache-served, refined as stale entries land).
private func publishDashboard(
projects: [Project], sessions: [Session], activity: [Date: Int], messages: Int
projects: [Project], sessions: [Session], activity: [Date: Int], messages: Int,
tokensByDay: [Date: Int], tokens: Int
) {
let visible = sessions.filter { !$0.archived }
let active = visible.filter { !$0.status.isTerminal }.count
@@ -3268,8 +3318,10 @@ public final class AppStore: ConflictArbiter {
chats: visible.count,
activeChats: active,
messages: messages,
activeDays: activity.count)
activeDays: activity.count,
tokens: tokens)
activityByDay = activity
self.tokensByDay = tokensByDay
}
/// Non-archived chats for a project, favorites first, then by most recent user
@@ -3671,6 +3723,7 @@ public final class AppStore: ConflictArbiter {
forgetShipState(id)
try? await database.deleteSession(id: id)
summaries.removeAll { $0.id == id }
updateAlarm(clearing: id) // a deleted chat can't be returned to don't keep ringing for it
if openSessionID == id { openSessionID = nil }
await reapSharedControlContainerIfUnused()
}
@@ -4339,17 +4392,45 @@ public final class AppStore: ConflictArbiter {
/// only when a chat *transitions* into done/blocked, never repeatedly while it sits there.
private var lastChatCueState: [SessionID: ChatCueState] = [:]
/// Sessions that are blocked-and-unviewed the escalating alarm rings while this set is
/// non-empty. A chat joins when it transitions into "needs you" away from the user's view;
/// it leaves when the user opens it (`openSessionID`'s `didSet`), when it settles out of
/// blocked (`announceChatCue`), or when it's deleted.
private var alarmingSessions: Set<SessionID> = []
/// Add and/or remove a session from the alarm set and, when that flips the set between
/// empty and non-empty, tell the app layer to start or stop ringing. The app layer owns
/// the repeating sound and the off-by-default switch, so a `true` here is a no-op when the
/// user hasn't opted in. Clearing a session that wasn't alarming is a harmless no-op.
private func updateAlarm(adding: SessionID? = nil, clearing: SessionID? = nil) {
let wasRinging = !alarmingSessions.isEmpty
if let adding { alarmingSessions.insert(adding) }
if let clearing { alarmingSessions.remove(clearing) }
let nowRinging = !alarmingSessions.isEmpty
if nowRinging != wasRinging { chatAlarmHook?(nowRinging) }
}
/// Ring the matching system sound when a chat's displayed status transitions into "Done"
/// (Funk) or "needs you" (Pop). Called for every summary the sidebar shows, so it covers
/// all blocked states uniformly. The first time a session is seen its state is seeded
/// silently launch/resume restore already-done/blocked chats without a fanfare; only
/// live transitions ring. `pending` never fires and never overwrites the baseline.
private func announceChatCue(_ s: SessionSummary) {
guard chatStatusSoundHook != nil else { return }
let new = chatCueState(s)
if new == .pending { return }
if new == .pending { return } // unsettled leave the baseline (and the alarm) untouched
let old = lastChatCueState.updateValue(new, forKey: s.id)
guard let old, old != new else { return } // unseen-before seed silently; unchanged ignore
// Escalating alarm: a live transition *into* blocked starts it unless the user is
// already looking at that chat, in which case there's nothing to call them back to.
// Any transition *out* of blocked clears it; opening the chat clears it separately
// (`openSessionID`'s `didSet`).
if new == .blocked {
if s.id != openSessionID { updateAlarm(adding: s.id) }
} else if old == .blocked {
updateAlarm(clearing: s.id)
}
switch new {
case .done: chatStatusSoundHook?(.done)
case .blocked: chatStatusSoundHook?(.blocked)
@@ -4723,9 +4804,11 @@ extension AppStore: SyncHostBridge {
public func dashboardSnapshot() async -> DashboardSnapshot {
let counts = DashboardCounts(
projects: dashboard.projects, chats: dashboard.chats, activeChats: dashboard.activeChats,
messages: dashboard.messages, activeDays: dashboard.activeDays)
let activity = activityByDay
.map { ActivityDay(day: $0.key, count: $0.value) }
messages: dashboard.messages, activeDays: dashboard.activeDays, tokens: dashboard.tokens)
// Union the message and token day sets so a day that recorded only one of the two still
// ships; each `ActivityDay` carries both (tokens 0 the grid shades it by message count).
let activity = Set(activityByDay.keys).union(tokensByDay.keys)
.map { ActivityDay(day: $0, count: activityByDay[$0] ?? 0, tokens: tokensByDay[$0] ?? 0) }
.sorted { $0.day < $1.day }
let projects = self.projects.map { project -> WireProject in
let sessions = summaries.filter { $0.projectID == project.id && !$0.archived }
@@ -0,0 +1,496 @@
import Foundation
/// Parses a `host_exec` shell command into a structured, **deterministic** breakdown the
/// program, its actions (subcommands/targets), its flags, its operands, and an *inferred
/// purpose* so the approval UI can tell the user, in plain language, what the agent is about
/// to run on their machine. The point is verification: a host command escapes the sandbox, and
/// "Allow" is too easy to click blindly, so the user needs the command laid out, not a raw blob.
///
/// Everything here is derived **only from the command string** never from anything the agent
/// said about it. The agent's own description of its intent may be wrong (or a lie); this
/// inference is computed from the literal tokens, so "Likely purpose: Build the Swift package"
/// is something the user can check against the command shown right beside it.
///
/// The command-agnostic lexing (segment splitting, tokenizing, heredocs, env/`sudo` prefixes)
/// is reused from ``ShellLexer`` the same tokenizer ``GitCommandSummary`` and
/// ``ShellCommandSummary`` use, so there's one source of truth, not three that drift.
public enum HostCommandSummary {
/// A `NAME=value` environment assignment prefixed before a program (`NUCLEIC_CHANNEL=dev swift `).
public struct EnvAssignment: Equatable, Sendable, Identifiable {
public let name: String
public let value: String
public var id: String { name }
public var display: String { "\(name)=\(value)" }
}
/// A parsed flag: its name (leading dashes kept) and its value when it takes one, recovered
/// from `--flag value`, `--flag=value`, or `-f value`. A boolean flag has a nil value.
public struct Flag: Equatable, Sendable, Identifiable {
public let name: String
public let value: String?
public var id: String { name + "\u{1}" + (value ?? "") }
/// "--product nucleic-local" / "--verbose" how the flag reads back to the user.
public var display: String { value.map { "\(name) \($0)" } ?? name }
}
/// One program invocation within a command pipeline `cd && FOO=bar swift build ` yields
/// one of these per real command (the `cd` becomes ``Summary/workingDirectory``).
public struct Invocation: Equatable, Sendable, Identifiable {
public let id: Int
/// The program name, reduced to its basename (`/usr/bin/swift` `swift`).
public let program: String
/// The subcommands / targets that follow it, in order (`build`, `run`, `test`).
public let actions: [String]
/// The parsed flags, in order.
public let flags: [Flag]
/// Positional operands that are neither actions nor flag values (a script path, a branch).
public let arguments: [String]
/// `NAME=value` assignments prefixed before the program.
public let env: [EnvAssignment]
/// True when the invocation is `sudo`-elevated a privilege escalation worth flagging.
public let elevated: Bool
/// True for an inherently destructive program (`rm`/`rmdir`) flagged in the UI.
public let destructive: Bool
/// "swift build", for a compact one-liner.
public var headline: String {
([program] + actions).joined(separator: " ")
}
}
/// A whole `host_exec` command, parsed. The `invocations` list the real programs the
/// pipeline runs (in order); `workingDirectory` is the directory it `cd`s into first, if
/// any; and `purpose` is the deterministic, command-derived one-liner described above.
public struct Summary: Equatable, Sendable {
public let invocations: [Invocation]
public let workingDirectory: String?
/// A plain-language guess at what the command does, inferred from the parsed tokens
/// (never from the agent). Always present; a fallback restates the command when no
/// known program is recognized.
public let purpose: String
/// Whether any step runs under `sudo` surfaced so the UI can warn before "Allow".
public var isElevated: Bool { invocations.contains { $0.elevated } }
/// Whether any step is an inherently destructive program (`rm`/`rmdir`).
public var isDestructive: Bool { invocations.contains { $0.destructive } }
}
/// Parses `command` into a structured ``Summary``. Returns nil only for a blank command;
/// otherwise it always yields a summary (with a restated-command `purpose` for an
/// unrecognized program), since a host_exec call always has a command worth laying out.
public static func summary(for command: String) -> Summary? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
// Drop heredoc bodies so a here-doc payload isn't parsed as commands, then walk each
// segment of the pipeline. A `cd` segment becomes the working directory, not an op.
let (flattened, _) = ShellLexer.stripHeredocs(command)
var invocations: [Invocation] = []
var workingDirectory: String?
var nextID = 0
for segment in ShellLexer.splitSegments(flattened) {
let tokens = ShellLexer.tokenize(segment)
let (env, elevated, rest) = stripPrefixes(tokens)
guard let head = rest.first else { continue }
if basename(head).lowercased() == "cd" {
if workingDirectory == nil, rest.count >= 2 {
workingDirectory = normalizeDirectory(rest[1])
}
continue
}
invocations.append(makeInvocation(
id: nextID, program: head, args: Array(rest.dropFirst()),
env: env, elevated: elevated, raw: segment))
nextID += 1
}
let purpose = inferPurpose(
invocations: invocations, workingDirectory: workingDirectory, command: trimmed)
return Summary(invocations: invocations, workingDirectory: workingDirectory, purpose: purpose)
}
// MARK: - Segment parsing
/// Pulls the leading `sudo` and `NAME=value` env assignments off a tokenized segment (in any
/// order), returning the captured env, whether it was `sudo`-elevated, and the remaining
/// tokens starting at the real program word.
private static func stripPrefixes(_ tokens: [String]) -> (env: [EnvAssignment], elevated: Bool, rest: [String]) {
var env: [EnvAssignment] = []
var elevated = false
var rest = tokens[...]
loop: while let first = rest.first {
if first == "sudo" {
elevated = true
rest = rest.dropFirst()
} else if ShellLexer.isEnvAssignment(first), let eq = first.firstIndex(of: "=") {
env.append(EnvAssignment(
name: String(first[..<eq]), value: String(first[first.index(after: eq)...])))
rest = rest.dropFirst()
} else {
break loop
}
}
return (env, elevated, Array(rest))
}
/// Builds one ``Invocation`` from a program token and the tokens after it: it splits off the
/// program's known actions, then the flags and operands.
private static func makeInvocation(
id: Int, program rawProgram: String, args: [String],
env: [EnvAssignment], elevated: Bool, raw: String
) -> Invocation {
let program = basename(rawProgram)
let spec = spec(for: program)
let (actions, rest) = splitActions(args, spec: spec)
let (flags, arguments) = splitFlags(rest, valueFlags: spec.valueFlags)
let destructive = program == "rm" || program == "rmdir"
return Invocation(
id: id, program: program, actions: actions, flags: flags, arguments: arguments,
env: env, elevated: elevated, destructive: destructive)
}
/// Peels the leading action tokens (subcommands/targets) off a program's arguments, per its
/// ``ProgramSpec``: a fixed-vocabulary tool (swift, git, npm) takes only tokens it knows as
/// subcommands; an open-action tool (make, and any unknown program) takes leading
/// identifier-like words as targets. Capped at `spec.maxActions` so an operand that happens
/// to look like a word (a branch, a product name) doesn't get swallowed as an action.
private static func splitActions(_ args: [String], spec: ProgramSpec) -> (actions: [String], rest: [String]) {
var actions: [String] = []
var index = 0
while index < args.count, actions.count < spec.maxActions {
let token = args[index]
guard isIdentifierLike(token) else { break }
guard spec.openActions || spec.subcommands.contains(token) else { break }
actions.append(token)
index += 1
}
return (actions, Array(args[index...]))
}
/// Splits the remaining tokens into flags and positional operands. A flag is `-x` / `--flag`;
/// it takes a value when written `--flag=value`, or `--flag value` for a known value-flag
/// whose next token isn't itself a flag. A bare `--` ends option parsing.
private static func splitFlags(_ tokens: [String], valueFlags: Set<String>) -> (flags: [Flag], arguments: [String]) {
var flags: [Flag] = []
var arguments: [String] = []
var index = 0
var endOfOptions = false
while index < tokens.count {
let token = tokens[index]
if endOfOptions { arguments.append(token); index += 1; continue }
if token == "--" { endOfOptions = true; index += 1; continue }
if token.hasPrefix("-"), token.count > 1 {
if let eq = token.firstIndex(of: "=") {
flags.append(Flag(name: String(token[..<eq]),
value: String(token[token.index(after: eq)...])))
index += 1
} else if valueFlags.contains(token), index + 1 < tokens.count,
!tokens[index + 1].hasPrefix("-") {
flags.append(Flag(name: token, value: tokens[index + 1]))
index += 2
} else {
flags.append(Flag(name: token, value: nil))
index += 1
}
} else {
arguments.append(token)
index += 1
}
}
return (flags, arguments)
}
// MARK: - Program specs
/// How a given program exposes its actions and which of its flags take a value enough to
/// parse the common build/dev tools precisely while degrading gracefully for the rest.
private struct ProgramSpec {
/// When true, leading identifier-like words are actions/targets without a fixed
/// vocabulary (make targets, an unknown CLI's subcommand). When false, only tokens in
/// `subcommands` count, so operands aren't mistaken for actions.
var openActions: Bool = false
/// The recognized subcommands, when `openActions` is false.
var subcommands: Set<String> = []
/// How many leading action tokens to take at most.
var maxActions: Int = 1
/// The flags whose following token is a value (`--product nucleic-local`), so it's paired
/// with the flag rather than read as an operand.
var valueFlags: Set<String> = []
}
/// The spec for a program (by basename). An unrecognized program gets the open-action
/// fallback: one leading word as its action, no value-flags.
private static func spec(for program: String) -> ProgramSpec {
switch program {
case "swift":
return ProgramSpec(
subcommands: ["build", "run", "test", "package", "sdk"], maxActions: 2,
valueFlags: ["--product", "--target", "--build-system", "-c", "--configuration",
"--filter", "--package-path", "--scratch-path", "--jobs", "-j",
"-Xswiftc", "-Xcc", "-Xlinker", "--triple"])
case "make":
return ProgramSpec(openActions: true, maxActions: 4, valueFlags: ["-f", "-C", "-j"])
case "npm", "pnpm", "yarn", "bun":
return ProgramSpec(
subcommands: ["install", "i", "ci", "run", "run-script", "test", "build", "start",
"exec", "publish", "add", "remove", "update", "lint", "dev"],
valueFlags: ["--prefix", "-w", "--workspace", "--filter"])
case "npx":
return ProgramSpec(openActions: true, maxActions: 1)
case "cargo":
return ProgramSpec(
subcommands: ["build", "test", "run", "check", "clippy", "fmt", "bench", "doc",
"install", "update", "publish", "clean"],
valueFlags: ["--package", "-p", "--bin", "--example", "--features", "--target",
"--manifest-path", "--jobs", "-j"])
case "git":
return ProgramSpec(
subcommands: ["commit", "push", "pull", "fetch", "clone", "merge", "rebase",
"checkout", "switch", "branch", "tag", "stash", "restore", "reset",
"revert", "cherry-pick", "add", "rm", "mv", "status", "log", "diff",
"show", "blame", "rev-parse", "worktree", "init", "clean"],
maxActions: 2,
valueFlags: ["-m", "--message", "-F", "--file", "-b", "-B", "-C", "-c"])
case "xcodebuild":
return ProgramSpec(
openActions: true, maxActions: 3,
valueFlags: ["-scheme", "-project", "-workspace", "-configuration", "-sdk",
"-destination", "-derivedDataPath", "-arch", "-target"])
case "python", "python3", "python2":
return ProgramSpec(valueFlags: ["-m", "-c", "-W", "-X"])
case "pip", "pip3":
return ProgramSpec(
subcommands: ["install", "uninstall", "download", "list", "show", "freeze",
"wheel", "check"],
valueFlags: ["-r", "--requirement", "-c", "--constraint", "-t", "--target"])
case "node":
return ProgramSpec(valueFlags: ["-e", "--eval", "-r", "--require"])
case "docker", "podman":
return ProgramSpec(
subcommands: ["build", "run", "exec", "compose", "push", "pull", "up", "down",
"start", "stop", "ps", "images", "logs"],
maxActions: 2,
valueFlags: ["-t", "--tag", "-f", "--file", "-p", "--publish", "-v", "--volume"])
case "gh":
return ProgramSpec(
subcommands: ["pr", "issue", "repo", "release", "run", "workflow", "auth", "api",
"create", "list", "view", "merge", "checkout", "status"],
maxActions: 3)
case "brew":
return ProgramSpec(
subcommands: ["install", "uninstall", "upgrade", "update", "list", "info",
"search", "tap", "bundle"],
maxActions: 2)
case "sh", "bash", "zsh", "fish":
return ProgramSpec(valueFlags: ["-c"])
default:
return ProgramSpec(openActions: true, maxActions: 1)
}
}
// MARK: - Inferred purpose
/// The deterministic, command-derived purpose line. With no real op (a bare `cd`), it names
/// the directory change; otherwise it describes the highest-salience invocation the one
/// that most defines what the command is *for* (a destructive `rm`, a build/test/deploy)
/// rather than the plumbing around it.
private static func inferPurpose(
invocations: [Invocation], workingDirectory: String?, command: String
) -> String {
guard !invocations.isEmpty else {
if let workingDirectory { return "Change directory to \(workingDirectory)" }
return "Run a host command"
}
let interpreted = invocations.map { interpret($0) }
let best = interpreted.max { $0.salience < $1.salience }
return best?.purpose ?? "Run \(invocations[0].headline)"
}
/// A program-aware reading of a single invocation: a plain-language purpose and a salience
/// that ranks it against the pipeline's other steps for the headline. The phrasing is
/// imperative ("Build the Swift package") so it reads as "what this does".
private static func interpret(_ inv: Invocation) -> (purpose: String, salience: Int) {
// A destructive or elevated step dominates the headline it's what the user most needs
// to notice before allowing the command.
if inv.destructive {
let target = inv.arguments.first.map { " \($0)" } ?? " files"
return ("Delete\(target) on the host", 100)
}
let action = inv.actions.first
switch inv.program {
case "swift":
return swiftPurpose(inv, action: action)
case "make":
let targets = inv.actions
if targets.isEmpty { return ("Run the default make target", 60) }
let list = backticked(targets.joined(separator: ", "))
return ("Run the make target\(targets.count == 1 ? "" : "s") \(list)", 64)
case "npm", "pnpm", "yarn", "bun":
return packageManagerPurpose(inv, action: action)
case "npx":
let tool = inv.arguments.first ?? inv.actions.first
return (tool.map { "Run \(backticked($0)) via npx" } ?? "Run a tool via npx", 55)
case "cargo":
return cargoPurpose(action: action)
case "git":
// Reuse the git phrasing so a host git command reads like git does everywhere else.
let purpose = GitCommandSummary.summary(for: inv.headline + flagsSuffix(inv))
?? "Run a git command"
return (purpose, 75)
case "xcodebuild":
let testing = inv.actions.contains("test") || inv.actions.contains("test-without-building")
let scheme = flagValue(inv, ["-scheme"]).map { " (scheme \($0))" } ?? ""
return (testing ? "Run Xcode tests\(scheme)" : "Build the Xcode project\(scheme)", testing ? 70 : 66)
case "python", "python3", "python2":
if let module = flagValue(inv, ["-m"]) { return ("Run the Python module \(backticked(module))", 62) }
if let script = inv.arguments.first { return ("Run the Python script \(backticked(script))", 62) }
if flagValue(inv, ["-c"]) != nil { return ("Run inline Python code", 58) }
return ("Run Python", 50)
case "pip", "pip3":
if action == "install" {
let pkgs = inv.arguments.isEmpty ? "" : " " + backticked(inv.arguments.prefix(3).joined(separator: ", "))
return ("Install Python packages\(pkgs)", 60)
}
return ("Run pip\(action.map { " \($0)" } ?? "")", 45)
case "node":
if let script = inv.arguments.first { return ("Run the Node script \(backticked(script))", 60) }
if flagValue(inv, ["-e", "--eval"]) != nil { return ("Run inline Node code", 56) }
return ("Run Node", 50)
case "docker", "podman":
let sub = inv.actions.joined(separator: " ")
return (sub.isEmpty ? "Run a container command" : "Run `\(inv.program) \(sub)`", 60)
case "gh":
let sub = inv.actions.joined(separator: " ")
return (sub.isEmpty ? "Run a GitHub CLI command" : "Run `gh \(sub)`", 55)
case "brew":
switch action {
case "install": return ("Install Homebrew packages", 60)
case "uninstall": return ("Uninstall Homebrew packages", 60)
case "upgrade", "update": return ("Update Homebrew packages", 55)
default: return ("Run a Homebrew command", 45)
}
case "sh", "bash", "zsh", "fish":
if let script = inv.arguments.first { return ("Run the shell script \(backticked(script))", 58) }
if flagValue(inv, ["-c"]) != nil { return ("Run an inline shell command", 54) }
return ("Start a \(inv.program) shell", 40)
default:
return defaultPurpose(inv)
}
}
private static func swiftPurpose(_ inv: Invocation, action: String?) -> (String, Int) {
let channel = flagValue(inv, [], env: "NUCLEIC_CHANNEL")
let channelSuffix = channel.map { " (\($0) channel)" } ?? ""
let release = (flagValue(inv, ["-c", "--configuration"]) == "release")
let releaseSuffix = release ? " in release" : ""
switch action {
case "build":
let what = flagValue(inv, ["--product"]).map { "the Swift product \(backticked($0))" }
?? "the Swift package"
return ("Build \(what)\(releaseSuffix)\(channelSuffix)", 66)
case "test":
let filter = flagValue(inv, ["--filter"]).map { " (filter: \($0))" } ?? ""
return ("Run the Swift test suite\(filter)", 70)
case "run":
let what = (inv.arguments.first ?? flagValue(inv, ["--product"])).map(backticked) ?? "the Swift package"
return ("Build and run \(what)\(channelSuffix)", 68)
case "package":
let sub = inv.actions.count > 1 ? inv.actions[1] : (inv.arguments.first ?? "")
return (sub.isEmpty ? "Run a SwiftPM package command" : "Run SwiftPM `package \(sub)`", 55)
default:
return defaultPurpose(inv)
}
}
private static func packageManagerPurpose(_ inv: Invocation, action: String?) -> (String, Int) {
let pm = inv.program
switch action {
case "install", "i", "ci", "add":
return ("Install \(pm) dependencies", 60)
case "run", "run-script", "exec", "dev":
let script = inv.arguments.first ?? (inv.actions.count > 1 ? inv.actions[1] : nil)
return (script.map { "Run the \(backticked($0)) \(pm) script" } ?? "Run an \(pm) script", 60)
case "test":
return ("Run \(pm) tests", 68)
case "build":
return ("Run the \(pm) build", 66)
case "start":
return ("Start the \(pm) app", 60)
case "publish":
return ("Publish the \(pm) package", 80)
case "lint":
return ("Lint with \(pm)", 50)
default:
return defaultPurpose(inv)
}
}
private static func cargoPurpose(action: String?) -> (String, Int) {
switch action {
case "build": return ("Build the Rust crate", 66)
case "test": return ("Run the Rust tests", 70)
case "run": return ("Build and run the Rust crate", 68)
case "check": return ("Type-check the Rust crate", 60)
case "clippy": return ("Lint the Rust crate", 55)
case "fmt": return ("Format the Rust code", 50)
case "publish": return ("Publish the Rust crate", 80)
default: return ("Run cargo\(action.map { " \($0)" } ?? "")", 50)
}
}
/// The fallback purpose for an unrecognized program (or a recognized one in an unmodeled
/// mode): restate the command as imperatively as the tokens allow.
private static func defaultPurpose(_ inv: Invocation) -> (String, Int) {
let readOnly: Set<String> = ["ls", "cat", "pwd", "echo", "which", "head", "tail", "grep",
"find", "file", "stat", "env", "printenv", "whoami", "date"]
if readOnly.contains(inv.program) {
return ("Inspect the host (\(backticked(inv.program)))", 15)
}
return ("Run \(backticked(inv.headline))", 30)
}
// MARK: - Helpers
/// The value of the first flag whose name is in `names`, or when `env` is given the value
/// of that environment assignment. Lets a purpose read both `--configuration release` and
/// `NUCLEIC_CHANNEL=dev`.
private static func flagValue(_ inv: Invocation, _ names: [String], env: String? = nil) -> String? {
if let env, let assignment = inv.env.first(where: { $0.name == env }) { return assignment.value }
for name in names {
if let flag = inv.flags.first(where: { $0.name == name }), let value = flag.value {
return value
}
}
return nil
}
/// The flags re-rendered as a trailing string, so a git invocation can be re-summarized by
/// ``GitCommandSummary`` (which parses a command line) from its parsed pieces.
private static func flagsSuffix(_ inv: Invocation) -> String {
let parts = inv.flags.map(\.display) + inv.arguments
return parts.isEmpty ? "" : " " + parts.joined(separator: " ")
}
/// Wraps text in backticks for the `code`-span markdown the UI renders inline.
private static func backticked(_ text: String) -> String { "`\(text)`" }
/// A token's basename its last path component so `/usr/bin/swift` and `./scripts/x.sh`
/// read as `swift` and `x.sh`.
private static func basename(_ token: String) -> String {
(token as NSString).lastPathComponent
}
/// Whether a token is a bare identifier word (a subcommand/target) rather than a flag, path,
/// or operand: letters/digits/`-`/`_`, leading with a letter, no dots or slashes.
private static func isIdentifierLike(_ token: String) -> Bool {
guard let first = token.first, first.isLetter else { return false }
return token.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
}
/// A readable working directory: the common `cd "$(git rev-parse --show-toplevel )"` form
/// is recognized and named, since it's noise to print verbatim; anything else shows as-is.
private static func normalizeDirectory(_ raw: String) -> String {
if raw.contains("git rev-parse --show-toplevel") { return "the repository root" }
return raw
}
}
+155 -42
View File
@@ -228,41 +228,140 @@ public actor NvrsionTrunk {
observed[session] = nil
}
// MARK: Promotion checkout (NVRSION §6) make promotion independent of the primary checkout
/// Where a promotion's git runs and how it finalizes onto `base`. A promotion has to land a commit
/// on `base`, which needs a worktree holding `base`'s content. Historically that was *always* the
/// project **root** checkout which silently required the user to have left the root parked on
/// `base`. If the root sat on a feature branch (or the trunk, or a detached HEAD), the squash /
/// `checkout --` would build the promotion on the wrong branch, so promotion just failed.
/// `PromoteCheckout` decouples the two: use the root only when it's *already* on `base` (unchanged
/// behavior the commit advances `base` itself), otherwise run in a dedicated, Nucleic-managed
/// worktree **detached at `base`** and advance the real `base` ref ourselves. So where the user
/// parks the primary checkout no longer matters, and a detached worktree never *holds* the `base`
/// branch the user stays free to `git checkout <base>` in the primary.
private struct PromoteCheckout {
/// Directory to run the promotion's git in.
let dir: String
/// True when `dir` is the dedicated worktree (detached at `base`): the commit lands on a
/// detached HEAD, so `base` is advanced explicitly via `advanceBase`. False when `dir` is the
/// root already on `base`, where committing moves `base` on its own.
let detached: Bool
/// `base`'s tip when the checkout was resolved the compare-and-swap old value for the
/// detached `update-ref` (guards against `base` moving under us). Unused on the root path.
let baseBefore: String
}
/// Outcome of resolving a promotion checkout: a ready `PromoteCheckout`, or a reason `base`
/// couldn't be prepared (surfaced as the promote's `.failed`).
private enum PromoteCheckoutResolution {
case ready(PromoteCheckout)
case unavailable(String)
}
/// The dedicated promote worktree path: `<repo>/.nucleic/promote`, a sibling of the trunk.
private func promoteWorktreePath(trunkPath: String) -> String {
((trunkPath as NSString).deletingLastPathComponent as NSString)
.appendingPathComponent("promote")
}
/// Resolve the `PromoteCheckout` for `root`/`base`. Root-on-`base` promote in the root (legacy
/// path, unchanged). Otherwise ensure `<repo>/.nucleic/promote` is a clean worktree detached at
/// `base`'s tip and use that so promotion never depends on, nor disturbs, the primary checkout.
/// `.failure` if `base` is missing or the worktree can't be prepared. Call inside `withGate`.
private func resolvePromotionCheckout(
root: String, trunkPath: String, base: String
) async -> PromoteCheckoutResolution {
let baseTip = (try? await git.run(["rev-parse", "--verify", "--quiet", "refs/heads/\(base)"], in: root))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !baseTip.isEmpty else { return .unavailable("base branch \(base) does not exist") }
let onBranch = (try? await git.run(["rev-parse", "--abbrev-ref", "HEAD"], in: root))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
if onBranch == base {
return .ready(PromoteCheckout(dir: root, detached: false, baseBefore: baseTip))
}
// Root is parked off `base`, so `base` is checked out nowhere we can stand up a private
// worktree on it. Detached, so it never holds the `base` branch and advancing `base` (below)
// can't desync any working tree.
let promotePath = promoteWorktreePath(trunkPath: trunkPath)
let dotGit = (promotePath as NSString).appendingPathComponent(".git")
if FileManager.default.fileExists(atPath: dotGit) {
// Reuse: force it back to a clean checkout detached at the current base tip, dropping any
// residue an aborted prior promote may have left.
let det = try? await git.run(
["checkout", "--force", "--detach", "refs/heads/\(base)"], in: promotePath)
guard det?.ok == true else {
return .unavailable("could not ground promote worktree at \(base): \(det?.stderr ?? "git did not run")")
}
_ = try? await git.run(["clean", "-ffd"], in: promotePath)
} else {
let parent = (promotePath as NSString).deletingLastPathComponent
try? FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
_ = try? await git.run(["worktree", "prune"], in: root)
let add = try? await git.run(
["worktree", "add", "--detach", promotePath, "refs/heads/\(base)"], in: root)
guard add?.ok == true else {
return .unavailable("could not create promote worktree at \(promotePath): \(add?.stderr ?? "git did not run")")
}
}
nvrsionLog.notice("nvrsion promote via detached worktree path=\(promotePath, privacy: .public) base=\(base, privacy: .public)")
return .ready(PromoteCheckout(dir: promotePath, detached: true, baseBefore: baseTip))
}
/// After a promotion commit on the detached promote worktree, fast-forward the real `base` ref to
/// it (compare-and-swap on the tip we grounded at, so a concurrent `base` move is caught rather
/// than clobbered). No-op on the root path committing there already moved `base`. Returns an
/// error string on failure, else `nil`.
private func advanceBase(_ checkout: PromoteCheckout, base: String, to head: String) async -> String? {
guard checkout.detached else { return nil }
let upd = try? await git.run(
["update-ref", "refs/heads/\(base)", head, checkout.baseBefore], in: checkout.dir)
guard upd?.ok == true else {
return "could not advance \(base) to \(head): \(upd?.stderr ?? "update-ref did not run")"
}
return nil
}
// MARK: Promotion trunk base (NVRSION §6)
/// Squash the trunk's accumulated work into the real `base` branch as one clean commit, run in
/// the project's root checkout (which is on `base` and untouched by nvrsion agents). Then
/// best-effort merge `base` back into the trunk so the next promotion is incremental. Conflicts
/// (e.g. `base` edited outside the trunk) leave both branches untouched and are reported.
/// Squash the trunk's accumulated work into the real `base` branch as one clean commit. Promotion
/// runs wherever `base` can be committed onto **without depending on where the user parked the
/// primary checkout** (`PromoteCheckout`): in the root when it's already on `base`, else in a
/// private worktree detached at `base`. Then best-effort merge `base` back into the trunk so the
/// next promotion is incremental. Conflicts (e.g. `base` edited outside the trunk) leave both
/// branches untouched and are reported.
public func promote(
root: String, trunkPath: String, trunkBranch: String, base: String, message: String
) async -> NvrPromoteResult {
await withGate {
// The squash merges trunk INTO whatever `root` has checked out require that to be `base`,
// so we never land trunk work on the wrong branch.
let onBranch = (try? await git.run(["rev-parse", "--abbrev-ref", "HEAD"], in: root))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
guard onBranch == base else {
return .failed("root checkout is on \(onBranch ?? "?"), not \(base) — can't promote")
// The squash merges trunk into a checkout of `base`. Resolve one that doesn't depend on
// where the primary checkout is parked (root if it's on `base`, else a detached worktree).
let checkout: PromoteCheckout
switch await resolvePromotionCheckout(root: root, trunkPath: trunkPath, base: base) {
case .ready(let c): checkout = c
case .unavailable(let why): return .failed(why)
}
let dir = checkout.dir
// Nothing to do when the trunk's content already equals base.
if let diff = try? await git.run(["diff", "--quiet", base, trunkBranch], in: root), diff.status == 0 {
if let diff = try? await git.run(["diff", "--quiet", base, trunkBranch], in: dir), diff.status == 0 {
return .nothingToPromote
}
let merge = try? await git.run(["merge", "--squash", trunkBranch], in: root)
let merge = try? await git.run(["merge", "--squash", trunkBranch], in: dir)
guard let merge else { return .failed("git did not run") }
if !merge.ok {
// Capture the conflicted paths, then reset ( --squash sets no MERGE_HEAD, so only a
// hard reset is the true inverse, mirroring WorktreeManager.integrate).
let conflicts = (try? await git.run(
["diff", "--name-only", "--diff-filter=U"], in: root))?
["diff", "--name-only", "--diff-filter=U"], in: dir))?
.stdout.split(separator: "\n").map(String.init) ?? []
_ = try? await git.run(["reset", "--hard", "HEAD"], in: root)
_ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
nvrsionLog.error("nvrsion promote CONFLICT base=\(base, privacy: .public) files=\(conflicts.joined(separator: ","), privacy: .public)")
return .conflicted(conflicts.isEmpty ? ["<unknown>"] : conflicts)
}
// --squash stages without committing; commit unless the squash was a no-op.
if let staged = try? await git.run(["diff", "--cached", "--quiet"], in: root), staged.status == 0 {
if let staged = try? await git.run(["diff", "--cached", "--quiet"], in: dir), staged.status == 0 {
return .nothingToPromote
}
// Sign the promotion with the configured Managed Git key when "Sign commits with this
@@ -277,13 +376,19 @@ public actor NvrsionTrunk {
gpgArgs + ["commit", "-m", message,
"--trailer", "Nucleic-Promote: 1",
"--trailer", Self.coauthorTrailer],
in: root, env: signEnv)
in: dir, env: signEnv)
guard let commit, commit.ok else {
_ = try? await git.run(["reset", "--hard", "HEAD"], in: root)
_ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
return .failed(commit?.stderr ?? "commit failed")
}
let head = (try? await git.run(["rev-parse", "HEAD"], in: root))?
let head = (try? await git.run(["rev-parse", "HEAD"], in: dir))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// On the detached-worktree path the commit landed on a detached HEAD advance the real
// base ref to it (the root path already moved base by committing on it).
if let err = await advanceBase(checkout, base: base, to: head) {
_ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
return .failed(err)
}
// Resync the trunk onto the new base so the next promotion squashes only NEW work
// (advances the merge-base). Best-effort: skipped harmlessly if the trunk is dirty.
_ = try? await git.run(["merge", base], in: trunkPath)
@@ -298,9 +403,10 @@ public actor NvrsionTrunk {
/// finished chat can ship without waiting for the rest of the project's chats to fall quiet
/// (NVRSION §6 per-session promotion; the deferred autoship-on-completion, now implemented).
///
/// Runs entirely in the project's root checkout (on `base`) and the trunk *branch ref* it
/// **never touches the trunk working copy**, so every other session's locks, warm files, and
/// re-ground state are left completely intact (the property the user relies on: siblings stay
/// Runs in a checkout of `base` chosen so it never depends on where the primary checkout is parked
/// (`PromoteCheckout` the root if it's on `base`, else a private detached worktree) plus the
/// trunk *branch ref* it **never touches the trunk working copy**, so every other session's
/// locks, warm files, and re-ground state are left completely intact (the property the user relies on: siblings stay
/// aware of each other's work, NVRSION §4, §7). The model is nvrsion-native: because the trunk
/// is always conflict-free composed content (serialized per-file writes + re-ground, NVRSION
/// §0.3), promoting a chat = lifting the **current trunk content of the files that chat is the
@@ -321,22 +427,23 @@ public actor NvrsionTrunk {
session: SessionID, message: String
) async -> NvrPromoteResult {
await withGate {
// We write to whatever `root` has checked out require that to be `base`, so a chat's
// work never lands on the wrong branch (mirrors `promote`).
let onBranch = (try? await git.run(["rev-parse", "--abbrev-ref", "HEAD"], in: root))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
guard onBranch == base else {
return .failed("root checkout is on \(onBranch ?? "?"), not \(base) — can't promote")
// We commit a chat's content onto `base`. Resolve a checkout for that which doesn't depend
// on where the primary checkout is parked (root if it's on `base`, else a detached worktree).
let checkout: PromoteCheckout
switch await resolvePromotionCheckout(root: root, trunkPath: trunkPath, base: base) {
case .ready(let c): checkout = c
case .unavailable(let why): return .failed(why)
}
let dir = checkout.dir
// No trunk branch yet (no session has landed) nothing this chat could have shipped.
let exists = (try? await git.run(
["rev-parse", "--verify", "--quiet", "\(trunkBranch)^{commit}"], in: root))?.ok == true
["rev-parse", "--verify", "--quiet", "\(trunkBranch)^{commit}"], in: dir))?.ok == true
guard exists else { return .nothingToPromote }
// The unshipped files: where the trunk's tree differs from base. (`--no-renames` keeps a
// rename as delete+add so per-file attribution stays simple.) None trunk already shipped.
let diff = try? await git.run(
["diff", "--name-only", "--no-renames", base, trunkBranch], in: root)
["diff", "--name-only", "--no-renames", base, trunkBranch], in: dir)
guard let diff else { return .failed("git did not run") }
let unshipped = diff.stdout.split(separator: "\n").map(String.init).filter { !$0.isEmpty }
guard !unshipped.isEmpty else { return .nothingToPromote }
@@ -346,7 +453,7 @@ public actor NvrsionTrunk {
for f in unshipped {
let owner = (try? await git.run(
["log", "-1", "--format=%(trailers:key=Nucleic-Session,valueonly,separator=%x2C)",
trunkBranch, "--", f], in: root))?
trunkBranch, "--", f], in: dir))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let owners = owner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
if owners.contains(session.rawValue) { mine.append(f) }
@@ -357,11 +464,11 @@ public actor NvrsionTrunk {
// trunk since the fork, a content overwrite would clobber that edit decline and report
// it, exactly like the whole-trunk promote's conflict path. Common case (root untouched
// by agents, NVRSION §6): no divergence, so this is empty and we ship cleanly.
let mergeBase = (try? await git.run(["merge-base", base, trunkBranch], in: root))?
let mergeBase = (try? await git.run(["merge-base", base, trunkBranch], in: dir))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? base
var conflicted: [String] = []
for f in mine {
if let d = try? await git.run(["diff", "--quiet", mergeBase, base, "--", f], in: root),
if let d = try? await git.run(["diff", "--quiet", mergeBase, base, "--", f], in: dir),
d.status != 0 { conflicted.append(f) }
}
if !conflicted.isEmpty {
@@ -373,21 +480,21 @@ public actor NvrsionTrunk {
// trunk tree is checked out (add/modify); one the chat deleted (absent in trunk) is removed.
var present: [String] = [], absent: [String] = []
for f in mine {
let inTrunk = (try? await git.run(["cat-file", "-e", "\(trunkBranch):\(f)"], in: root))?.ok == true
let inTrunk = (try? await git.run(["cat-file", "-e", "\(trunkBranch):\(f)"], in: dir))?.ok == true
if inTrunk { present.append(f) } else { absent.append(f) }
}
if !present.isEmpty {
let co = try? await git.run(["checkout", trunkBranch, "--"] + present, in: root)
let co = try? await git.run(["checkout", trunkBranch, "--"] + present, in: dir)
if co?.ok != true {
_ = try? await git.run(["reset", "--hard", "HEAD"], in: root)
_ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
return .failed(co?.stderr ?? "could not stage trunk content")
}
}
for f in absent { _ = try? await git.run(["rm", "-q", "--ignore-unmatch", "--", f], in: root) }
for f in absent { _ = try? await git.run(["rm", "-q", "--ignore-unmatch", "--", f], in: dir) }
// Nothing actually staged (base already matched the trunk for this chat's files) no-op.
if let staged = try? await git.run(["diff", "--cached", "--quiet"], in: root), staged.status == 0 {
_ = try? await git.run(["reset", "--hard", "HEAD"], in: root)
if let staged = try? await git.run(["diff", "--cached", "--quiet"], in: dir), staged.status == 0 {
_ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
return .nothingToPromote
}
// Sign the per-session promotion with the Managed Git key when signing is enabled (it
@@ -399,14 +506,20 @@ public actor NvrsionTrunk {
gpgArgs + ["commit", "-m", message,
"--trailer", "Nucleic-Promote: 1", "--trailer", "Nucleic-Session: \(session.rawValue)",
"--trailer", Self.coauthorTrailer],
in: root, env: signEnv)
in: dir, env: signEnv)
guard let commit, commit.ok else {
_ = try? await git.run(["reset", "--hard", "HEAD"], in: root)
_ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
return .failed(commit?.stderr ?? "commit failed")
}
let head = (try? await git.run(["rev-parse", "HEAD"], in: root))?
let head = (try? await git.run(["rev-parse", "HEAD"], in: dir))?
.stdout.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// No resync needed: idempotence comes from the content comparison above (a re-promote
// On the detached-worktree path the commit landed on a detached HEAD advance the real
// base ref to it (the root path already moved base by committing on it).
if let err = await advanceBase(checkout, base: base, to: head) {
_ = try? await git.run(["reset", "--hard", "HEAD"], in: dir)
return .failed(err)
}
// No trunk resync needed: idempotence comes from the content comparison above (a re-promote
// finds these files already in base), and the trunk is never touched so this can't
// disturb any sibling session's in-flight work.
nvrsionLog.notice("nvrsion promoteSession session=\(session.rawValue, privacy: .public) base=\(base, privacy: .public) files=\(mine.joined(separator: ","), privacy: .public) sha=\(head, privacy: .public)")
@@ -182,6 +182,22 @@ public final class GRDBMetadataStore: SessionMetadataStore {
);
""")
}
migrator.registerMigration("v22-activity-tokens") { db in
// Extend the per-session rollup with token usage: a grand total and a per-day
// histogram (same shape as activity_json), so the dashboard can show total tokens and
// the activity grid can shade by tokens-per-day.
try db.execute(sql: """
ALTER TABLE session_activity ADD COLUMN token_count INTEGER NOT NULL DEFAULT 0;
""")
try db.execute(sql: """
ALTER TABLE session_activity ADD COLUMN tokens_json TEXT NOT NULL DEFAULT '{}';
""")
// Existing rows carry no token data but still match their session's last_seq, so they'd
// be served as "fresh" and never rescanned leaving historical tokens permanently 0
// for idle sessions. The rollup is a pure cache, so clear it: the next dashboard load
// treats every session as stale and rebuilds it (off-main) with token data folded in.
try db.execute(sql: "DELETE FROM session_activity;")
}
return migrator
}
@@ -550,27 +566,42 @@ private struct ActivityRow: Codable, FetchableRecord, PersistableRecord {
var session_id: String
var last_seq: Int64
var message_count: Int
var activity_json: String // JSON map of day-epoch-seconds (string) count
var activity_json: String // JSON map of day-epoch-seconds (string) message count
var token_count: Int
var tokens_json: String // JSON map of day-epoch-seconds (string) tokens used
init(sessionID: SessionID, entry: ActivityCacheEntry) {
session_id = sessionID.rawValue
last_seq = Int64(entry.lastSeq)
message_count = entry.messageCount
let map = Dictionary(uniqueKeysWithValues: entry.activityByDay.map {
(String(Int($0.key.timeIntervalSince1970)), $0.value)
})
activity_json = (try? JSONEncoder().encode(map))
.map { String(decoding: $0, as: UTF8.self) } ?? "{}"
activity_json = Self.encodeByDay(entry.activityByDay)
token_count = entry.tokenCount
tokens_json = Self.encodeByDay(entry.tokensByDay)
}
func toEntry() -> ActivityCacheEntry {
let map = (try? JSONDecoder().decode([String: Int].self, from: Data(activity_json.utf8))) ?? [:]
let byDay = Dictionary(uniqueKeysWithValues: map.compactMap { key, value -> (Date, Int)? in
ActivityCacheEntry(
lastSeq: UInt64(max(0, last_seq)), messageCount: message_count,
activityByDay: Self.decodeByDay(activity_json),
tokenCount: token_count, tokensByDay: Self.decodeByDay(tokens_json))
}
/// A `[Date: Int]` day histogram as a JSON object keyed by integer epoch-seconds (as a
/// string, since JSON object keys are strings). Shared by the message and token series.
private static func encodeByDay(_ byDay: [Date: Int]) -> String {
let map = Dictionary(uniqueKeysWithValues: byDay.map {
(String(Int($0.key.timeIntervalSince1970)), $0.value)
})
return (try? JSONEncoder().encode(map))
.map { String(decoding: $0, as: UTF8.self) } ?? "{}"
}
private static func decodeByDay(_ json: String) -> [Date: Int] {
let map = (try? JSONDecoder().decode([String: Int].self, from: Data(json.utf8))) ?? [:]
return Dictionary(uniqueKeysWithValues: map.compactMap { key, value -> (Date, Int)? in
guard let secs = TimeInterval(key) else { return nil }
return (Date(timeIntervalSince1970: secs), value)
})
return ActivityCacheEntry(
lastSeq: UInt64(max(0, last_seq)), messageCount: message_count, activityByDay: byDay)
}
}
@@ -12,11 +12,23 @@ public struct ActivityCacheEntry: Sendable, Equatable {
public let messageCount: Int
/// User-message counts bucketed by start-of-day.
public let activityByDay: [Date: Int]
/// Total tokens used across the transcript (every turn's input + cache + output + reasoning;
/// see `Usage.totalTokens`). Drives the dashboard's total-tokens metric.
public let tokenCount: Int
/// Tokens-used totals bucketed by start-of-day the per-day series the activity grid shades
/// by (falling back to `activityByDay` for days with no recorded usage) and the backing for a
/// token time series. Empty for transcripts that predate usage capture.
public let tokensByDay: [Date: Int]
public init(lastSeq: UInt64, messageCount: Int, activityByDay: [Date: Int]) {
public init(
lastSeq: UInt64, messageCount: Int, activityByDay: [Date: Int],
tokenCount: Int = 0, tokensByDay: [Date: Int] = [:]
) {
self.lastSeq = lastSeq
self.messageCount = messageCount
self.activityByDay = activityByDay
self.tokenCount = tokenCount
self.tokensByDay = tokensByDay
}
}
@@ -0,0 +1,35 @@
import Foundation
/// Shades the activity grid by each day's **token usage** the truest measure of how much work
/// a day actually saw and falls back to **message count** for any day with no token data
/// (transcripts predating usage capture, or a backend that doesn't report it).
///
/// Tokens and messages live on wildly different magnitudes (thousandsmillions vs. single
/// digits), so they can't share one scale: dropped into the same distribution, every
/// message-only day would collapse to near-black under the token range. Instead each metric is
/// normalized against *its own* ``ActivityScale``, and the per-day choice picks the matching
/// brightness so a heavy token day and a heavy message-only day both read as a bright square,
/// each relative to its own kind of activity.
///
/// Pure and platform-agnostic, so the macOS host and the iPhone client shade their grids
/// identically (mirrors ``ActivityScale`` / ``StreakState``).
public struct ActivityShading: Sendable, Equatable {
/// Brightness scale over days that recorded token usage.
public let tokenScale: ActivityScale
/// Brightness scale over message counts the fallback for days without token data.
public let messageScale: ActivityScale
public init(
tokensByDay: [Date: Int], messagesByDay: [Date: Int],
floor: Double = ActivityScale.defaultFloor
) {
self.tokenScale = ActivityScale(activityByDay: tokensByDay, floor: floor)
self.messageScale = ActivityScale(activityByDay: messagesByDay, floor: floor)
}
/// Continuous brightness in `01` for a day: by its `tokens` when any were recorded,
/// otherwise by its `messages`. A day with neither is `0` (the neutral empty-day gray).
public func intensity(tokens: Int, messages: Int) -> Double {
tokens > 0 ? tokenScale.intensity(for: tokens) : messageScale.intensity(for: messages)
}
}
+12
View File
@@ -217,6 +217,18 @@ public struct Usage: Sendable, Codable, Equatable {
self.costUSD = costUSD
self.contextInputTokens = contextInputTokens
}
/// Every token this turn moved new input, cache reads, cache writes, output, and any
/// separately-reported reasoning folded into one "tokens used" figure. The categories are
/// disjoint (a provider's `inputTokens` excludes the cached portion, and `reasoningTokens` is
/// reported apart from `outputTokens` only when it isn't already folded into it), so summing
/// them never double-counts. This is what the home dashboard totals and what shades the
/// activity grid by day. Distinct from ``contextInputTokens``, which is a single call's
/// window occupancy, not the turn's throughput.
public var totalTokens: Int {
(inputTokens ?? 0) + (cachedInputTokens ?? 0) + (cacheCreationInputTokens ?? 0)
+ (outputTokens ?? 0) + (reasoningTokens ?? 0)
}
}
/// Rate-limit telemetry the CLI emits out of band (`rate_limit_event`, claude
+39 -6
View File
@@ -11,25 +11,58 @@ public struct DashboardCounts: Sendable, Codable, Equatable {
public let activeChats: Int
public let messages: Int
public let activeDays: Int
/// Total tokens used across every session (mirrors `DashboardStats.tokens`).
public let tokens: Int
public init(projects: Int, chats: Int, activeChats: Int, messages: Int, activeDays: Int) {
public init(
projects: Int, chats: Int, activeChats: Int, messages: Int, activeDays: Int, tokens: Int = 0
) {
self.projects = projects
self.chats = chats
self.activeChats = activeChats
self.messages = messages
self.activeDays = activeDays
self.tokens = tokens
}
public static let empty = DashboardCounts(
projects: 0, chats: 0, activeChats: 0, messages: 0, activeDays: 0, tokens: 0)
private enum CodingKeys: String, CodingKey {
case projects, chats, activeChats, messages, activeDays, tokens
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.projects = try c.decode(Int.self, forKey: .projects)
self.chats = try c.decode(Int.self, forKey: .chats)
self.activeChats = try c.decode(Int.self, forKey: .activeChats)
self.messages = try c.decode(Int.self, forKey: .messages)
self.activeDays = try c.decode(Int.self, forKey: .activeDays)
// Tolerate a host that predates token tracking its dashboards report no token total.
self.tokens = try c.decodeIfPresent(Int.self, forKey: .tokens) ?? 0
}
public static let empty = DashboardCounts(projects: 0, chats: 0, activeChats: 0, messages: 0, activeDays: 0)
}
/// One day's message count for the activity grid. (An array of these, not a `[Date:Int]` map,
/// since CBOR map keys are strings keeps the wire shape clean and ordered.)
/// One day's activity for the grid: its message count and tokens used. (An array of these, not a
/// `[Date:Int]` map, since CBOR map keys are strings keeps the wire shape clean and ordered.)
public struct ActivityDay: Sendable, Codable, Equatable {
public let day: Date // start-of-day
public let count: Int
public init(day: Date, count: Int) {
public let count: Int // user messages
public let tokens: Int // tokens used that day (0 when none recorded grid falls back to count)
public init(day: Date, count: Int, tokens: Int = 0) {
self.day = day
self.count = count
self.tokens = tokens
}
private enum CodingKeys: String, CodingKey { case day, count, tokens }
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.day = try c.decode(Date.self, forKey: .day)
self.count = try c.decode(Int.self, forKey: .count)
// Tolerate days from a host that predates token tracking they shade by message count.
self.tokens = try c.decodeIfPresent(Int.self, forKey: .tokens) ?? 0
}
}
+30
View File
@@ -0,0 +1,30 @@
import Foundation
/// Compact, human-readable rendering of token totals for the dashboards, where a day's usage can
/// run from a handful to many millions and a full grouped number (`12,345,678`) would overflow a
/// small stat card. Mirrors the GitHub-style "12.3K / 4.5M" abbreviation both clients show.
public enum TokenCount {
/// `942` · `12.3K` · `4.5M` · `1.2B`. Exact below 1,000; thereafter one decimal, with a
/// trailing `.0` trimmed (`5K`, not `5.0K`). Negative inputs clamp to `0`.
public static func abbreviated(_ count: Int) -> String {
let n = max(0, count)
switch n {
case ..<1_000:
return "\(n)"
case ..<1_000_000:
return scaled(n, by: 1_000, suffix: "K")
case ..<1_000_000_000:
return scaled(n, by: 1_000_000, suffix: "M")
default:
return scaled(n, by: 1_000_000_000, suffix: "B")
}
}
private static func scaled(_ n: Int, by divisor: Int, suffix: String) -> String {
// One decimal, but drop a pointless ".0" so round magnitudes read cleanly (and so a value
// that rounds *up* to a whole 12.95 "13.0" "13" doesn't keep a stray decimal).
var text = String(format: "%.1f", Double(n) / Double(divisor))
if text.hasSuffix(".0") { text = String(text.dropLast(2)) }
return text + suffix
}
}
@@ -2299,6 +2299,40 @@ struct AppStoreTests {
#expect(store.dashboard.activeChats == 0)
}
@Test func activityContributionTalliesMessagesAndTokensByDay() {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
let dayA = Date(timeIntervalSince1970: 1_750_000_000) // some day
let dayB = cal.date(byAdding: .day, value: 1, to: dayA)! // the next day
func event(_ at: Date, _ kind: AgentEvent.Kind) -> AgentEvent {
AgentEvent(sessionID: SessionID(rawValue: "s"), seq: 0, at: at,
backend: .claudeCode, nativeType: nil, kind: kind)
}
let chunk = TextChunk(messageID: "m", text: "hi", isPartial: false)
let usageA = Usage(inputTokens: 10, cachedInputTokens: 90, outputTokens: 20) // 120
let usageB = Usage(inputTokens: 5, outputTokens: 5) // 10
let events = [
event(dayA, .userText(chunk)),
event(dayA, .userText(chunk)),
event(dayA, .usage(usageA)),
// The matching turnCompleted carries a *copy* of the same usage it must NOT be
// tallied again, or the day's tokens would double.
event(dayA, .turnCompleted(TurnCompleted(stopReason: "end_turn", usage: usageA))),
event(dayB, .userText(chunk)),
event(dayB, .usage(usageB)),
]
let c = AppStore.activityContribution(events: events, calendar: cal)
#expect(c.messages == 3)
#expect(c.tokens == 130) // 120 + 10, no double-count
let startA = cal.startOfDay(for: dayA)
let startB = cal.startOfDay(for: dayB)
#expect(c.messagesByDay[startA] == 2)
#expect(c.messagesByDay[startB] == 1)
#expect(c.tokensByDay[startA] == 120)
#expect(c.tokensByDay[startB] == 10)
}
@Test func newChatsInheritDefaults() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
@@ -318,12 +318,17 @@ struct GRDBMetadataStoreTests {
try await store.saveSession(session) // cache rows reference a real session (FK)
let day = Calendar.current.startOfDay(for: now)
let entry = ActivityCacheEntry(lastSeq: 12, messageCount: 3, activityByDay: [day: 3])
// Carries both the message and the token (v22) series round-trips intact.
let entry = ActivityCacheEntry(
lastSeq: 12, messageCount: 3, activityByDay: [day: 3],
tokenCount: 4_200, tokensByDay: [day: 4_200])
try await store.saveActivityCache(session.id, entry)
#expect(try await store.loadActivityCache()[session.id] == entry) // v21 round-trip
#expect(try await store.loadActivityCache()[session.id] == entry)
// Upsert by session id: the cursor + rollup are replaced, not duplicated.
let grown = ActivityCacheEntry(lastSeq: 20, messageCount: 5, activityByDay: [day: 5])
let grown = ActivityCacheEntry(
lastSeq: 20, messageCount: 5, activityByDay: [day: 5],
tokenCount: 9_001, tokensByDay: [day: 9_001])
try await store.saveActivityCache(session.id, grown)
let reloaded = try await store.loadActivityCache()
#expect(reloaded.count == 1)
@@ -0,0 +1,101 @@
import Testing
@testable import NucleicCore
@Suite struct HostCommandSummaryTests {
/// The motivating case: the exact shape the agent runs to build the app a `cd` to the repo
/// root, an env-prefixed `swift build` with paired value-flags. It decomposes into a named
/// working directory, one `swift build` invocation with its env and flags parsed, and a
/// command-derived purpose that names the product and channel.
@Test func realWorldSwiftBuild() {
let command = #"cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" && NUCLEIC_CHANNEL=dev swift build --build-system native --product nucleic-local"#
let summary = HostCommandSummary.summary(for: command)
#expect(summary?.workingDirectory == "the repository root")
#expect(summary?.invocations.count == 1)
let inv = summary?.invocations.first
#expect(inv?.program == "swift")
#expect(inv?.actions == ["build"])
#expect(inv?.env.map(\.display) == ["NUCLEIC_CHANNEL=dev"])
#expect(inv?.flags.map(\.display) == ["--build-system native", "--product nucleic-local"])
#expect(inv?.arguments.isEmpty == true)
#expect(summary?.purpose == "Build the Swift product `nucleic-local` (dev channel)")
}
/// A value-flag written `--flag value` pairs with its value (not read as an operand), and the
/// filter surfaces in the purpose. A release configuration is called out too.
@Test func swiftTestAndConfiguration() {
let test = HostCommandSummary.summary(for: "swift test --filter HostCommandSummaryTests")
#expect(test?.invocations.first?.actions == ["test"])
#expect(test?.invocations.first?.flags.map(\.display) == ["--filter HostCommandSummaryTests"])
#expect(test?.purpose == "Run the Swift test suite (filter: HostCommandSummaryTests)")
let release = HostCommandSummary.summary(for: "swift build -c release")
#expect(release?.purpose == "Build the Swift package in release")
}
/// make targets are open-ended actions (no fixed vocabulary), so several targets all parse.
@Test func makeTargets() {
#expect(HostCommandSummary.summary(for: "make")?.purpose == "Run the default make target")
let multi = HostCommandSummary.summary(for: "make clean build")
#expect(multi?.invocations.first?.actions == ["clean", "build"])
#expect(multi?.purpose == "Run the make targets `clean, build`")
}
/// `npm run <script>` keeps `run` as the action and the script name as an operand (not a
/// second action), and the purpose names the script.
@Test func npmRunScript() {
let summary = HostCommandSummary.summary(for: "npm run build")
#expect(summary?.invocations.first?.program == "npm")
#expect(summary?.invocations.first?.actions == ["run"])
#expect(summary?.invocations.first?.arguments == ["build"])
#expect(summary?.purpose == "Run the `build` npm script")
}
/// A git host command is parsed without swallowing the remote/branch operands as actions, and
/// its purpose reuses the shared git phrasing.
@Test func gitReusesGitPhrasing() {
let summary = HostCommandSummary.summary(for: "git push origin main")
#expect(summary?.invocations.first?.actions == ["push"])
#expect(summary?.invocations.first?.arguments == ["origin", "main"])
#expect(summary?.purpose == "Push to origin/main")
}
/// A destructive `rm` dominates the headline (highest salience) even amid other ops, and is
/// flagged on its invocation exactly what a user must notice before allowing a host command.
@Test func destructiveRemoveLeads() {
let summary = HostCommandSummary.summary(for: "swift build && rm -rf .build")
#expect(summary?.isDestructive == true)
#expect(summary?.invocations.contains { $0.destructive && $0.program == "rm" } == true)
#expect(summary?.purpose == "Delete .build on the host")
}
/// A `sudo` prefix is stripped to find the real program but recorded as an elevation flag.
@Test func sudoIsFlagged() {
let summary = HostCommandSummary.summary(for: "sudo make install")
#expect(summary?.isElevated == true)
#expect(summary?.invocations.first?.program == "make")
#expect(summary?.invocations.first?.actions == ["install"])
}
/// A bare script invocation: the program is its basename, a script operand isn't mistaken for
/// an action, and the purpose names the script.
@Test func interpreterScripts() {
let py = HostCommandSummary.summary(for: "python3 tools/gen.py --check")
#expect(py?.invocations.first?.program == "python3")
#expect(py?.invocations.first?.actions.isEmpty == true)
#expect(py?.invocations.first?.arguments == ["tools/gen.py"])
#expect(py?.purpose == "Run the Python script `tools/gen.py`")
let sh = HostCommandSummary.summary(for: "./scripts/package-app.sh dev")
#expect(sh?.invocations.first?.program == "package-app.sh")
#expect(sh?.purpose == "Run `package-app.sh dev`")
}
/// An unrecognized read-only command falls back to a restated purpose, ranked low so it never
/// outshouts a real op in a pipeline.
@Test func unknownAndReadOnlyFallback() {
#expect(HostCommandSummary.summary(for: "ls -la")?.purpose == "Inspect the host (`ls`)")
#expect(HostCommandSummary.summary(for: "")?.purpose == nil) // blank no summary
#expect(HostCommandSummary.summary(for: " ") == nil)
}
}
@@ -418,6 +418,93 @@ struct NvrsionTrunkTests {
#expect(try await repo.revParse("refs/heads/\(trunkBranch)") == after)
}
@Test func promoteShipsEvenWhenPrimaryCheckoutIsParkedOffBase() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
// Land work on the trunk.
try repo.write("a.txt", "from A\n", in: trunkPath)
_ = await trunk.land(trunkPath: trunkPath, session: .generate(), paths: ["a.txt"], message: "A")
// Park the PRIMARY checkout on a *different* branch the situation that used to fail promotion
// outright ("root checkout is on feature, not main"). Promotion must no longer depend on it.
try await repo.run(["checkout", "-b", "feature"])
let baseBefore = try await repo.revParse("refs/heads/main")
let result = await trunk.promote(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
message: "promote")
guard case .promoted = result else { Issue.record("expected .promoted, got \(result)"); return }
// main advanced and carries the work, even though no checkout was parked on it.
#expect(try await repo.revParse("refs/heads/main") != baseBefore)
#expect(try await repo.run(["show", "main:a.txt"]).stdout == "from A\n")
let body = try await repo.run(["log", "-1", "--format=%B", "main"]).stdout
#expect(body.contains("Nucleic-Promote: 1"))
#expect(body.contains(NvrsionTrunk.coauthorTrailer))
// The primary checkout is untouched still on `feature` and `main` is NOT held by the
// dedicated promote worktree (it's detached), so the user can freely check `main` out in the
// primary. This is the property the whole fix turns on.
#expect(try await repo.run(["rev-parse", "--abbrev-ref", "HEAD"]).stdout
.trimmingCharacters(in: .whitespacesAndNewlines) == "feature")
#expect(try await repo.run(["checkout", "main"]).status == 0)
#expect(repo.read("a.txt", in: repo.root) == "from A\n")
// Re-promoting with no new trunk work is a no-op (the trunk was resynced onto the new base),
// exercising the reuse path of the detached promote worktree.
try await repo.run(["checkout", "feature"]) // park off base again
#expect(await trunk.promote(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
message: "promote again") == .nothingToPromote)
}
@Test func promoteSessionShipsEvenWhenPrimaryCheckoutIsParkedOffBase() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
let a = SessionID.generate()
let b = SessionID.generate()
// Two chats land disjoint work on the shared trunk.
try repo.write("a.txt", "a1\n", in: trunkPath)
_ = await trunk.land(trunkPath: trunkPath, session: a, paths: ["a.txt"], message: "A1")
try repo.write("b.txt", "b1\n", in: trunkPath)
_ = await trunk.land(trunkPath: trunkPath, session: b, paths: ["b.txt"], message: "B1")
// Park the primary checkout off base per-session promote must work regardless.
try await repo.run(["checkout", "-b", "feature"])
let baseBefore = try await repo.revParse("refs/heads/main")
let result = await trunk.promoteSession(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
session: a, message: "promote A")
guard case .promoted = result else { Issue.record("expected .promoted, got \(result)"); return }
// main carries A's file and NOT B's, and the commit is attributed to A.
#expect(try await repo.revParse("refs/heads/main") != baseBefore)
#expect(try await repo.run(["show", "main:a.txt"]).stdout == "a1\n")
#expect((try? await repo.run(["cat-file", "-e", "main:b.txt"]))?.ok != true) // B never shipped
let body = try await repo.run(["log", "-1", "--format=%B", "main"]).stdout
#expect(body.contains("Nucleic-Session: \(a.rawValue)"))
#expect(body.contains("Nucleic-Promote: 1"))
// Primary still on `feature`, untouched; B's work still on the trunk.
#expect(try await repo.run(["rev-parse", "--abbrev-ref", "HEAD"]).stdout
.trimmingCharacters(in: .whitespacesAndNewlines) == "feature")
#expect(repo.read("b.txt", in: trunkPath) == "b1\n")
// Re-promoting A is a no-op (idempotent); B still ships independently afterwards.
#expect(await trunk.promoteSession(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
session: a, message: "promote A again") == .nothingToPromote)
guard case .promoted = await trunk.promoteSession(
root: repo.root, trunkPath: trunkPath, trunkBranch: trunkBranch, base: "main",
session: b, message: "promote B") else {
Issue.record("expected B to promote"); return
}
#expect(try await repo.run(["show", "main:b.txt"]).stdout == "b1\n")
}
@Test func regroundFiresOnlyAfterAFileMovesUnderASession() async throws {
let (repo, trunk, trunkPath) = try await makeTrunk()
defer { repo.cleanup() }
@@ -0,0 +1,94 @@
import Foundation
import Testing
@testable import NucleicProtocol
@Suite struct ActivityShadingTests {
private let cal: Calendar = {
var c = Calendar(identifier: .gregorian)
c.timeZone = TimeZone(identifier: "UTC")!
return c
}()
private let epoch = Date(timeIntervalSince1970: 0)
private func days(_ counts: [Int]) -> [Date: Int] {
Dictionary(uniqueKeysWithValues: counts.enumerated().map { i, n in
(cal.date(byAdding: .day, value: i, to: epoch)!, n)
})
}
@Test func tokensDriveIntensityWhenPresent() {
// A day with token usage is shaded by the token distribution, not the message one.
let shading = ActivityShading(
tokensByDay: days([1_000, 2_000, 3_000, 4_000]), // token upperBound 4_000
messagesByDay: days([1, 2, 3, 4]))
// 4_000 tokens is a peak token day full brightness, regardless of message count.
#expect(shading.intensity(tokens: 4_000, messages: 1) == 1.0)
#expect(abs(shading.intensity(tokens: 2_000, messages: 1)
- ActivityScale(activityByDay: days([1_000, 2_000, 3_000, 4_000])).intensity(for: 2_000)) < 1e-9)
}
@Test func fallsBackToMessagesWhenNoTokens() {
// A day that recorded no tokens shades by its message count against the message scale.
let shading = ActivityShading(
tokensByDay: days([1_000, 2_000, 3_000, 4_000]),
messagesByDay: days([1, 2, 3, 4]))
let messageScale = ActivityScale(activityByDay: days([1, 2, 3, 4]))
#expect(shading.intensity(tokens: 0, messages: 4) == messageScale.intensity(for: 4))
#expect(shading.intensity(tokens: 0, messages: 4) == 1.0) // peak message day = full
}
@Test func messageOnlyDaysDoNotCollapseUnderTokenScale() {
// The whole point of dual scales: a busy message-only day stays bright even though token
// counts are ~1000× larger. A single shared scale would crush it toward the floor.
let shading = ActivityShading(
tokensByDay: days([500_000, 800_000]), // huge token days
messagesByDay: days([1, 2, 3, 4, 5])) // ordinary message days
let bright = shading.intensity(tokens: 0, messages: 5) // peak message-only day
#expect(bright == 1.0)
#expect(bright >= ActivityScale.defaultFloor)
}
@Test func emptyDayIsZero() {
let shading = ActivityShading(tokensByDay: days([1_000]), messagesByDay: days([3]))
#expect(shading.intensity(tokens: 0, messages: 0) == 0)
}
@Test func noDataAtAllIsInert() {
let shading = ActivityShading(tokensByDay: [:], messagesByDay: [:])
#expect(shading.intensity(tokens: 0, messages: 0) == 0)
}
}
@Suite struct UsageTotalTokensTests {
@Test func sumsAllDisjointTokenCategories() {
let usage = Usage(
inputTokens: 10, cachedInputTokens: 100, cacheCreationInputTokens: 5,
outputTokens: 20, reasoningTokens: 7)
#expect(usage.totalTokens == 142)
}
@Test func absentCategoriesCountAsZero() {
#expect(Usage(outputTokens: 42).totalTokens == 42)
#expect(Usage().totalTokens == 0)
}
}
@Suite struct TokenCountTests {
@Test func exactBelowOneThousand() {
#expect(TokenCount.abbreviated(0) == "0")
#expect(TokenCount.abbreviated(942) == "942")
#expect(TokenCount.abbreviated(-5) == "0") // clamps negatives
}
@Test func abbreviatesThousandsMillionsBillions() {
#expect(TokenCount.abbreviated(1_000) == "1K")
#expect(TokenCount.abbreviated(12_345) == "12.3K")
#expect(TokenCount.abbreviated(4_500_000) == "4.5M")
#expect(TokenCount.abbreviated(1_200_000_000) == "1.2B")
}
@Test func trimsTrailingPointZero() {
#expect(TokenCount.abbreviated(5_000) == "5K")
#expect(TokenCount.abbreviated(2_000_000) == "2M")
}
}
@@ -137,8 +137,8 @@ import Testing
@Test func dashboardRoundTrips() throws {
let snapshot = DashboardSnapshot(
counts: DashboardCounts(projects: 2, chats: 7, activeChats: 3, messages: 140, activeDays: 5),
activity: [ActivityDay(day: Date(timeIntervalSince1970: 1_700_000_000), count: 4)],
counts: DashboardCounts(projects: 2, chats: 7, activeChats: 3, messages: 140, activeDays: 5, tokens: 98_765),
activity: [ActivityDay(day: Date(timeIntervalSince1970: 1_700_000_000), count: 4, tokens: 12_345)],
projects: [WireProject(id: ProjectID(rawValue: "p1"), name: "ProjA", defaultBranch: "main", sessionCount: 3, activeCount: 1)],
todos: [WireTodo(
id: TodoID(rawValue: "t1"), text: "ship it", summary: "ship",
+18 -9
View File
@@ -245,21 +245,30 @@ by promotion**, which keeps the user in command (NUCLEIC_CONCEPT) and the real h
per-edit churn.
- **Whole-trunk promotion** *(implemented)*`AppStore.promoteNvrsionTrunk` / `NvrsionTrunk.promote`,
surfaced as the **"Integrate trunk → `<base>`"** button. `git merge --squash nucleic/trunk` in the
project's root checkout (which is on `base` and untouched by nvrsion agents) → **one clean commit**
on the real branch (trailer `Nucleic-Promote: 1`), then a best-effort merge of `base` back into the
trunk so the *next* promotion squashes only new work. Conflicts (e.g. `base` edited outside the
trunk) reset cleanly and are reported; an unchanged trunk returns *nothing-to-promote*. It waits for
the project to fall quiet (no chat mid-turn), and an auto-integrate countdown ships it on its own
once it does.
surfaced as the **"Integrate trunk → `<base>`"** button. `git merge --squash nucleic/trunk` into a
checkout of `base`**one clean commit** on the real branch (trailer `Nucleic-Promote: 1`), then a
best-effort merge of `base` back into the trunk so the *next* promotion squashes only new work.
Conflicts (e.g. `base` edited outside the trunk) reset cleanly and are reported; an unchanged trunk
returns *nothing-to-promote*. It waits for the project to fall quiet (no chat mid-turn), and an
auto-integrate countdown ships it on its own once it does. **Where it runs (`PromoteCheckout`):**
promotion must commit onto `base`, which once required the user to have parked the project's primary
checkout *on `base`* — if it sat on a feature branch (or the trunk, or detached) the squash built on
the wrong branch and promotion failed. It now uses the primary checkout **only when it's already on
`base`** (the commit advances `base` itself) and otherwise stands up a private worktree
`<repo>/.nucleic/promote` **detached at `base`**, squashes there, and advances the real `base` ref
with `git update-ref`. Detached so it never *holds* the `base` branch (the user can still
`git checkout <base>` in the primary) and so moving the ref can't desync any working tree — promotion
is fully independent of where the primary checkout sits.
- **Per-session promotion** *(implemented)*`AppStore.promoteNvrsionSession` / `NvrsionTrunk.promoteSession`,
surfaced as the **"Integrate this chat → `<base>`"** button in a chat's menu. Ships **one finished
chat's work without waiting** for a long-running sibling — the slowness the whole-trunk gate imposes
when several chats are done but one is mid-marathon. Because the trunk is always conflict-free
composed content (§0.3), this is *not* a fragile replay of a session's interleaved commits; it
**lifts the current trunk content of the files the chat is the latest author of** onto base as one
commit (trailers `Nucleic-Promote: 1` + `Nucleic-Session: <S>`). Concretely, in the root checkout:
of the files where trunk differs from base, take those whose most-recent trunk commit carries this
commit (trailers `Nucleic-Promote: 1` + `Nucleic-Session: <S>`). Concretely, in the same
`PromoteCheckout` of `base` the whole-trunk promote uses (the primary checkout if it's on `base`,
else the detached `<repo>/.nucleic/promote` worktree): of the files where trunk differs from base,
take those whose most-recent trunk commit carries this
session's trailer, and `git checkout nucleic/trunk -- <those>` (a direct content overwrite — no
3-way merge, so **no conflict and idempotent**: a re-promote finds them already in base and ships
nothing). A file a *later* sibling re-touched is that sibling's to ship, not this chat's; a file
@@ -128,10 +128,14 @@ final class RemoteStore: ObservableObject {
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let activity = (0..<40).map { i -> ActivityDay in
ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!, count: (i * 7) % 6)
let count = (i * 7) % 6
// Sample tokens roughly track messages so the preview grid shades by usage.
ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!,
count: count, tokens: count * 8_500 + (i * 137) % 4_000)
}
dashboard = DashboardSnapshot(
counts: DashboardCounts(projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9),
counts: DashboardCounts(
projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9, tokens: 1_284_000),
activity: activity,
projects: [
WireProject(id: p1, name: "nucleic", defaultBranch: "main", sessionCount: 3, activeCount: 2),
@@ -18,6 +18,12 @@ struct HomeView: View {
Dictionary(store.dashboard.activity.map { (Calendar.current.startOfDay(for: $0.day), $0.count) },
uniquingKeysWith: +)
}
/// Tokens used per day the activity grid's primary intensity signal (falls back to message
/// count for days without recorded usage). Mirrors the Mac home.
private var tokensByDay: [Date: Int] {
Dictionary(store.dashboard.activity.map { (Calendar.current.startOfDay(for: $0.day), $0.tokens) },
uniquingKeysWith: +)
}
/// Current activity streak with streak freezes folded in the run of consecutive active
/// days, where a banked freeze (one earned per five days) bridges a missed day. Shared with
@@ -55,7 +61,9 @@ struct HomeView: View {
VStack(alignment: .leading, spacing: 10) {
Text("Activity").font(.headline)
ActivityGrid(activityByDay: activityByDay, frozenDays: streak.frozenDays)
ActivityGrid(
activityByDay: activityByDay, tokensByDay: tokensByDay,
frozenDays: streak.frozenDays)
}
.frame(maxWidth: .infinity, alignment: .leading)
.card()
@@ -88,6 +96,7 @@ struct HomeView: View {
StatCard(value: running.count, label: "In progress", icon: "circle.lefthalf.filled")
StatCard(value: counts.activeChats, label: "Active", icon: "bolt")
StatCard(value: counts.messages, label: "Messages", icon: "paperplane")
StatCard(value: TokenCount.abbreviated(counts.tokens), label: "Tokens", icon: "number")
}
}
}
@@ -119,13 +128,26 @@ private struct InProgressSessions: View {
}
private struct StatCard: View {
let value: Int
let value: String
let label: String
let icon: String
init(value: Int, label: String, icon: String) {
self.value = "\(value)"
self.label = label
self.icon = icon
}
init(value: String, label: String, icon: String) {
self.value = value
self.label = label
self.icon = icon
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: icon).foregroundStyle(Palette.accent)
Text("\(value)").font(.title2.weight(.semibold).monospacedDigit())
Text(value).font(.title2.weight(.semibold).monospacedDigit())
Text(label).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -172,6 +194,9 @@ struct StreakBadge: View {
/// GitHub-style activity grid: columns = weeks (oldnew), rows = SunSat.
struct ActivityGrid: View {
let activityByDay: [Date: Int]
/// Tokens used per day the primary intensity signal; days absent here fall back to their
/// message count (mirrors the Mac grid via `ActivityShading`).
var tokensByDay: [Date: Int] = [:]
/// Missed days a streak freeze kept alive drawn in the frozen blue rather than the
/// empty-day gray (mirrors the Mac grid).
var frozenDays: Set<Date> = []
@@ -193,8 +218,9 @@ struct ActivityGrid: View {
let startOfWeek = cal.date(byAdding: .day, value: -(weekday - 1), to: today)!
let gridStart = cal.date(byAdding: .day, value: -7 * (weeks - 1), to: startOfWeek)!
// Brightness relative to *this user's* activity, outliers excluded so a few marathon
// days don't crush the everyday range. Computed once for the whole grid (mirrors Mac).
let scale = ActivityScale(activityByDay: activityByDay)
// days don't crush the everyday range. Shades by tokens-per-day, falling back to message
// count for days without recorded usage. Computed once for the whole grid (mirrors Mac).
let shading = ActivityShading(tokensByDay: tokensByDay, messagesByDay: activityByDay)
return HStack(alignment: .top, spacing: gap) {
ForEach(0..<weeks, id: \.self) { col in
VStack(spacing: gap) {
@@ -205,7 +231,8 @@ struct ActivityGrid: View {
RoundedRectangle(cornerRadius: 2)
.fill(isFuture ? Color.clear
: isFrozen ? Palette.frozen
: Palette.activity(intensity: scale.intensity(for: activityByDay[date] ?? 0)))
: Palette.activity(intensity: shading.intensity(
tokens: tokensByDay[date] ?? 0, messages: activityByDay[date] ?? 0)))
.frame(width: cell, height: cell)
}
}