Something went wrong. Try again.
My version of https://github.com/vercel-labs/fx
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589const std = @import("std");const types = @import("../shared/types.zig");
pub const StreamState = types.StreamState;const compaction_activity = @import("compaction_activity.zig");const ActivityProjection = @import("activity_runtime.zig").ActivityProjection;
/// Selects the display clock without changing the underlying turn or its accounting.pub fn compactionClock(stream: StreamState, operation: compaction_activity.Operation) StreamState { return if (operation.origin == .manual) .{ .turn_started_ms = operation.started_at_ms } else stream;}
/// Borrows either static feedback or the caller's label buffer.pub fn compactionProjection( buf: []u8, snapshot: compaction_activity.Snapshot, stream: StreamState, now_ms: i64,) ActivityProjection { const op = snapshot.operation orelse return .none; if (!op.visible(now_ms)) return .none; const label: []const u8 = switch (op.phase) { .preparing => "• Preparing compaction", .running => |stage| if (stage == .preparation) "• Preparing compaction" else "• Compacting", .stopping => "• Stopping compaction", .terminal => |feedback| return .{ .turn_thinking = .{ .label = compactionFeedbackLabel(feedback), .tone = switch (feedback.outcome) { .failed => .danger, .busy => .warning, .succeeded, .no_op, .cancelled => .neutral, }, } }, }; var out: std.Io.Writer = .fixed(buf); out.writeAll(label) catch return .{ .turn_thinking = .{ .label = label } }; appendTurnElapsedSuffix(&out, compactionClock(stream, op), now_ms) catch {}; return .{ .turn_thinking = .{ .label = out.buffered() } };}
fn compactionFeedbackLabel(feedback: compaction_activity.Feedback) []const u8 { if (feedback.publication == .uncertain) return "Compaction could not confirm saved context. Reopen the session before retrying."; if (feedback.publication == .committed) return "Compaction saved context, but could not finish. Reopen the session before retrying."; if (feedback.outcome == .failed) { if (feedback.err) |err| { switch (err) { error.CompactionAuthenticationRejected => return "Compaction was not started. Check authentication and try /compact again.", error.ContextCapacityExceeded => return "Context is too large to compact. Choose a model with a larger context window.", else => {}, } } } return switch (feedback.outcome) { .succeeded => "", .no_op => "No context to compact.", .busy => "Wait for the active work to finish before compacting context.", .cancelled => "Compaction cancelled. Try /compact again when ready.", .failed => if (feedback.stage == .preparation) "Compaction was not started. Try /compact again." else "Compaction failed. Try /compact again.", };}
test "compaction feedback distinguishes authentication and capacity from other setup failures" { try std.testing.expectEqualStrings( "Compaction was not started. Check authentication and try /compact again.", compactionFeedbackLabel(compaction_activity.failure(error.CompactionAuthenticationRejected, .preparation, false)), ); try std.testing.expectEqualStrings( "Context is too large to compact. Choose a model with a larger context window.", compactionFeedbackLabel(compaction_activity.failure(error.ContextCapacityExceeded, .preparation, false)), ); try std.testing.expectEqualStrings( "Compaction was not started. Try /compact again.", compactionFeedbackLabel(compaction_activity.failure(error.OutOfMemory, .preparation, false)), );}
const ActivityPart = struct { count: usize, singular: []const u8, plural: []const u8, verb: []const u8,};
fn streamStatusVerb(stream: StreamState) []const u8 { return switch (stream.last_activity_kind orelse return "thinking") { .list => "listing", .read => "reading", .write => "writing", .edit => "editing", .command => "running", .subagent => "delegating", .open => "opening", .ask => "asking", };}
fn markedTurnPhaseLabel(phase: types.TurnPhase) []const u8 { return switch (phase) { .thinking => "• Thinking", .generating => "• Generating", .running, .waiting_for_subagent => "• Running", };}
pub fn buildTurnLabel(buf: []u8, stream: StreamState, now_ms: i64) ?[]const u8 { if (stream.last_activity_kind != null) return null; var out: std.Io.Writer = .fixed(buf); const marked_phase = markedTurnPhaseLabel(stream.phase); out.writeAll(marked_phase) catch return marked_phase; appendTurnElapsedSuffix(&out, stream, now_ms) catch return out.buffered(); appendTurnTokenSuffix(&out, stream) catch return out.buffered(); return out.buffered();}
pub const activity_blink_half_period_ms: i64 = 500;
/// The instant the turn clock reads. While fx waits on user input the/// clock is frozen at the moment the wait began, so time spent on an approval/// or question never counts toward active work.fn turnClockNow(stream: StreamState, now_ms: i64) i64 { if (stream.waiting_since_ms > 0 and stream.waiting_since_ms < now_ms) { return stream.waiting_since_ms; } return now_ms;}
/// Marker visibility locked to the elapsed counter's clock: on for the first/// half of every elapsed second, so the marker relights exactly when the/// seconds digit advances. Steady on while waiting on user input (a frozen/// clock would otherwise strand the marker dark). Null when no turn is being/// timed.pub fn activityBlinkVisible(stream: StreamState, now_ms: i64) ?bool { if (stream.turn_started_ms <= 0 or now_ms < stream.turn_started_ms) return null; if (stream.waiting_since_ms > 0) return true; const half_periods = @divTrunc(now_ms - stream.turn_started_ms, activity_blink_half_period_ms); return @mod(half_periods, 2) == 0;}
fn appendTurnElapsedSuffix(writer: *std.Io.Writer, stream: StreamState, now_ms: i64) !void { if (stream.turn_started_ms <= 0 or now_ms < stream.turn_started_ms) return; // One displayed second spans exactly one on/off blink period. const ms_per_second = 2 * activity_blink_half_period_ms; const seconds = @divTrunc(turnClockNow(stream, now_ms) - stream.turn_started_ms, ms_per_second); try writer.writeAll(" ("); try writeElapsed(writer, seconds); try writer.writeByte(')');}
/// Formats an elapsed duration as `1h2m3s`, dropping leading units that are/// zero so short turns stay `5s` while long ones read `18m0s` instead of a/// wall of seconds.fn writeElapsed(writer: *std.Io.Writer, seconds: i64) !void { const secs = @mod(seconds, 60); const total_minutes = @divTrunc(seconds, 60); const mins = @mod(total_minutes, 60); const hours = @divTrunc(total_minutes, 60); if (hours > 0) { try writer.print("{d}h{d}m{d}s", .{ hours, mins, secs }); } else if (mins > 0) { try writer.print("{d}m{d}s", .{ mins, secs }); } else { try writer.print("{d}s", .{secs}); }}
/// Tracks whether fx is waiting on user input (approval prompt or question)/// and keeps the turn clock honest: the clock freezes when the wait/// begins, and on resume the whole wait is excluded by shifting/// turn_started_ms forward. Call whenever the waiting state may have changed.pub fn syncWaitingClock(stream: *StreamState, waiting: bool, now_ms: i64) void { if (waiting) { if (stream.active and stream.waiting_since_ms == 0) { stream.waiting_since_ms = now_ms; } return; } if (stream.waiting_since_ms == 0) return; const waited = now_ms - stream.waiting_since_ms; if (waited > 0 and stream.turn_started_ms > 0) { stream.turn_started_ms += waited; } stream.waiting_since_ms = 0;}
// Width of the "• " marker every other activity row carries.const marker_indent = " ";
// The completed turn summary occupies the same marker column without retaining// the active phase label or blink.pub fn buildCompletedTurnLabel(buf: []u8, stream: StreamState) []const u8 { const progress = stream.token_progress; if (progress.input_tokens == 0 and progress.output_tokens == 0) return marker_indent; var out: std.Io.Writer = .fixed(buf); out.writeAll(marker_indent) catch return marker_indent; writeTokenProgress(&out, progress) catch return out.buffered(); return out.buffered();}
pub fn buildProgressLabel(buf: []u8, stream: StreamState) ?[]const u8 { if (!stream.active) return null; return buildThinkingLabelFull(buf, stream);}
fn buildThinkingLabelFull(buf: []u8, stream: StreamState) []const u8 { var out: std.Io.Writer = .fixed(buf); out.writeAll(streamStatusVerb(stream)) catch return streamStatusVerb(stream);
const parts = activityParts(stream); if (!hasActivityParts(&parts)) { appendTurnTokenSuffix(&out, stream) catch return out.buffered(); return out.buffered(); }
out.writeAll(" | ") catch return out.buffered();
var first = true; for (parts) |part| { if (part.count == 0) continue; writeActivityPart(&out, &first, part) catch return out.buffered(); }
appendTurnTokenSuffix(&out, stream) catch return out.buffered(); return out.buffered();}
fn activityParts(stream: StreamState) [7]ActivityPart { return .{ .{ .count = stream.read_count, .singular = "file", .plural = "files", .verb = "read" }, .{ .count = stream.list_count, .singular = "directory", .plural = "directories", .verb = "listed" }, .{ .count = stream.write_count, .singular = "file", .plural = "files", .verb = "wrote" }, .{ .count = stream.edit_count, .singular = "file", .plural = "files", .verb = "edited" }, .{ .count = stream.command_count, .singular = "command", .plural = "commands", .verb = "started" }, .{ .count = stream.subagent_count, .singular = "subagent", .plural = "subagents", .verb = "created" }, .{ .count = stream.open_count, .singular = "file", .plural = "files", .verb = "opened" }, };}
fn hasActivityParts(parts: []const ActivityPart) bool { for (parts) |part| { if (part.count > 0) return true; } return false;}
fn writeActivityPart(writer: *std.Io.Writer, first: *bool, part: ActivityPart) !void { if (!first.*) try writer.writeAll(", "); first.* = false; try writer.print("{d} {s} {s}", .{ part.count, if (part.count == 1) part.singular else part.plural, part.verb });}
pub fn formatTokenCountCompact(buf: []u8, tokens: u64) []const u8 { if (tokens < 1000) return std.fmt.bufPrint(buf, "{d}", .{tokens}) catch "0";
const whole = tokens / 1000; const tenths = (tokens % 1000) / 100; if (whole < 10 and tenths > 0) { return std.fmt.bufPrint(buf, "{d}.{d}k", .{ whole, tenths }) catch "1k"; } return std.fmt.bufPrint(buf, "{d}k", .{whole}) catch "1k";}
fn writeTokenProgress(writer: *std.Io.Writer, progress: types.TurnTokenProgress) !void { var input_buf: [24]u8 = undefined; var output_buf: [24]u8 = undefined; try writer.print("(↑{s} ↓{s})", .{ formatTokenCountCompact(&input_buf, progress.input_tokens), formatTokenCountCompact(&output_buf, progress.output_tokens), });}
pub fn appendTokenProgressSuffix(writer: *std.Io.Writer, progress: types.TurnTokenProgress) !void { if (progress.input_tokens == 0 and progress.output_tokens == 0) return; try writer.writeByte(' '); try writeTokenProgress(writer, progress);}
pub fn appendTurnTokenSuffix(writer: *std.Io.Writer, stream: StreamState) !void { try appendTokenProgressSuffix(writer, stream.token_progress);}
test "compaction projection preserves turn accounting and selects the origin clock" { var state: compaction_activity.State = .{}; const stream: StreamState = .{ .active = true, .phase = .running, .last_activity_kind = .read, .turn_started_ms = 1_000, .token_progress = .{ .input_tokens = 100, .output_tokens = 20 }, }; var buf: [256]u8 = undefined; for ([_]compaction_activity.Origin{ .manual, .automatic, .provider_overflow }) |origin| { const id = state.begin(origin, 1, 3_500); state.running(id, .summary); const projection = compactionProjection(&buf, state.snapshot, stream, 4_000); try std.testing.expectEqualStrings(if (origin == .manual) "• Compacting (0s)" else "• Compacting (3s)", projection.turn_thinking.label); try std.testing.expectEqual(@as(?bool, origin != .manual), activityBlinkVisible(compactionClock(stream, state.snapshot.operation.?), 4_000)); state.stopping(id); try std.testing.expect(std.mem.startsWith(u8, compactionProjection(&buf, state.snapshot, stream, 4_000).turn_thinking.label, "• Stopping compaction")); state.settle(id, .{ .outcome = .succeeded }, 4_000); try std.testing.expect(compactionProjection(&buf, state.snapshot, stream, 4_000) == .none); } try std.testing.expectEqual(@as(u64, 100), stream.token_progress.input_tokens); try std.testing.expectEqualStrings("• Running (3s) (↑100 ↓20)", buildTurnLabel(&buf, .{ .phase = stream.phase, .turn_started_ms = stream.turn_started_ms, .token_progress = stream.token_progress, }, 4_000).?);}
test "compaction terminal feedback is static scoped and publication honest" { var state: compaction_activity.State = .{}; var buf: [256]u8 = undefined; const id = state.begin(.manual, null, 1_000); try std.testing.expectEqualStrings("• Preparing compaction (0s)", compactionProjection(&buf, state.snapshot, .{}, 1_000).turn_thinking.label); state.settle(id, .{ .outcome = .failed, .stage = .publication, .publication = .uncertain }, 1_500); const failed = compactionProjection(&buf, state.snapshot, .{}, 9_000).turn_thinking; try std.testing.expectEqual(ActivityProjection.Tone.danger, failed.tone); try std.testing.expectEqualStrings("Compaction could not confirm saved context. Reopen the session before retrying.", failed.label); try std.testing.expect(state.dismiss(id, state.snapshot.revision)); try std.testing.expect(compactionProjection(&buf, state.snapshot, .{}, 9_000) == .none); const next = state.begin(.manual, null, 10_000); state.settle(next, .{ .outcome = .no_op }, 10_000); try std.testing.expectEqual(ActivityProjection.Tone.neutral, compactionProjection(&buf, state.snapshot, .{}, 11_499).turn_thinking.tone); try std.testing.expect(compactionProjection(&buf, state.snapshot, .{}, 11_500) == .none);}
test "subagent wait label preserves turn time and token accounting" { var buf: [128]u8 = undefined; const stream: StreamState = .{ .active = true, .phase = .waiting_for_subagent, .turn_started_ms = 1_000, .token_progress = .{ .input_tokens = 32, .output_tokens = 253 }, }; try std.testing.expectEqualStrings("• Running (30s) (↑32 ↓253)", buildTurnLabel(&buf, stream, 31_000).?); var resumed = stream; resumed.phase = .generating; try std.testing.expectEqualStrings("• Generating (31s) (↑32 ↓253)", buildTurnLabel(&buf, resumed, 32_000).?);}
test "buildTurnLabel returns thinking when no tool activity" { var buf: [32]u8 = undefined; const label = buildTurnLabel(&buf, .{ .active = true }, 0); try std.testing.expectEqualStrings("• Thinking", label.?);}
test "buildTurnLabel renders turn token suffix with input only" { var buf: [64]u8 = undefined; const label = buildTurnLabel(&buf, .{ .active = true, .token_progress = .{ .input_tokens = 10 }, }, 0); try std.testing.expectEqualStrings("• Thinking (↑10 ↓0)", label.?);}
test "buildTurnLabel renders turn token suffix with input and output" { var buf: [64]u8 = undefined; const label = buildTurnLabel(&buf, .{ .active = true, .token_progress = .{ .input_tokens = 10, .output_tokens = 20 }, }, 0); try std.testing.expectEqualStrings("• Thinking (↑10 ↓20)", label.?);}
test "buildTurnLabel renders elapsed seconds for the running turn" { var buf: [64]u8 = undefined; const label = buildTurnLabel(&buf, .{ .active = true, .turn_started_ms = 1_000, }, 6_500); try std.testing.expectEqualStrings("• Thinking (5s)", label.?);}
test "buildTurnLabel renders elapsed minutes and seconds for long turns" { var buf: [64]u8 = undefined; // 1080s of blink periods elapsed → 18m0s instead of a raw second count. const label = buildTurnLabel(&buf, .{ .active = true, .turn_started_ms = 1_000, }, 1_000 + 1_080 * 1_000); try std.testing.expectEqualStrings("• Thinking (18m0s)", label.?);}
test "buildTurnLabel renders elapsed hours for very long turns" { var buf: [64]u8 = undefined; // 3663s → 1h1m3s. const label = buildTurnLabel(&buf, .{ .active = true, .turn_started_ms = 1_000, }, 1_000 + 3_663 * 1_000); try std.testing.expectEqualStrings("• Thinking (1h1m3s)", label.?);}
test "buildTurnLabel renders elapsed seconds before the token suffix" { var buf: [64]u8 = undefined; const label = buildTurnLabel(&buf, .{ .active = true, .turn_started_ms = 1_000, .token_progress = .{ .input_tokens = 10, .output_tokens = 20 }, }, 13_000); try std.testing.expectEqualStrings("• Thinking (12s) (↑10 ↓20)", label.?);}
test "activity blink relights exactly when the seconds digit advances" { const stream: StreamState = .{ .active = true, .turn_started_ms = 1_000 }; try std.testing.expectEqual(@as(?bool, true), activityBlinkVisible(stream, 1_000)); try std.testing.expectEqual(@as(?bool, true), activityBlinkVisible(stream, 1_499)); try std.testing.expectEqual(@as(?bool, false), activityBlinkVisible(stream, 1_500)); try std.testing.expectEqual(@as(?bool, false), activityBlinkVisible(stream, 1_999)); try std.testing.expectEqual(@as(?bool, true), activityBlinkVisible(stream, 2_000));
var label_buf: [64]u8 = undefined; const at_relight = buildTurnLabel(&label_buf, stream, 2_000).?; try std.testing.expectEqualStrings("• Thinking (1s)", at_relight);}
test "activity blink has no clock without a running turn" { try std.testing.expectEqual(@as(?bool, null), activityBlinkVisible(.{ .active = true }, 5_000)); try std.testing.expectEqual( @as(?bool, null), activityBlinkVisible(.{ .active = true, .turn_started_ms = 6_000 }, 5_000), );}
test "turn clock freezes while waiting on user input and excludes the wait" { var stream: StreamState = .{ .active = true, .turn_started_ms = 1_000 }; var buf: [64]u8 = undefined;
syncWaitingClock(&stream, true, 5_000); try std.testing.expectEqual(@as(i64, 5_000), stream.waiting_since_ms);
try std.testing.expectEqualStrings("• Thinking (4s)", buildTurnLabel(&buf, stream, 900_000).?); try std.testing.expectEqual(@as(?bool, true), activityBlinkVisible(stream, 900_000));
syncWaitingClock(&stream, false, 900_000); try std.testing.expectEqual(@as(i64, 0), stream.waiting_since_ms); try std.testing.expectEqualStrings("• Thinking (4s)", buildTurnLabel(&buf, stream, 900_000).?); try std.testing.expectEqualStrings("• Thinking (7s)", buildTurnLabel(&buf, stream, 903_000).?);}
test "waiting clock accumulates across repeated prompts in one turn" { var stream: StreamState = .{ .active = true, .turn_started_ms = 0 }; syncWaitingClock(&stream, true, 1_000); syncWaitingClock(&stream, false, 9_000); try std.testing.expectEqual(@as(i64, 0), stream.turn_started_ms); try std.testing.expectEqual(@as(i64, 0), stream.waiting_since_ms);
stream = .{ .active = true, .turn_started_ms = 1_000 }; syncWaitingClock(&stream, true, 2_000); syncWaitingClock(&stream, false, 10_000); syncWaitingClock(&stream, true, 11_000); syncWaitingClock(&stream, false, 20_000); var buf: [64]u8 = undefined; try std.testing.expectEqualStrings("• Thinking (2s)", buildTurnLabel(&buf, stream, 20_000).?);}
test "waiting clock does not start without an active stream" { var stream: StreamState = .{ .active = false }; syncWaitingClock(&stream, true, 5_000); try std.testing.expectEqual(@as(i64, 0), stream.waiting_since_ms);}
test "buildTurnLabel hides elapsed seconds when the clock runs behind the mark" { var buf: [64]u8 = undefined; const label = buildTurnLabel(&buf, .{ .active = true, .turn_started_ms = 2_000, }, 1_000); try std.testing.expectEqualStrings("• Thinking", label.?);}
test "buildCompletedTurnLabel renders token progress without approximation markers" { var buf: [64]u8 = undefined; const label = buildCompletedTurnLabel(&buf, .{ .active = true, .token_progress = .{ .input_tokens = 1_500, .output_tokens = 20, .input_exact = false, .output_exact = false, }, }); try std.testing.expectEqualStrings(" (↑1.5k ↓20)", label);}
test "buildCompletedTurnLabel holds the marker column before the first usage report" { var buf: [64]u8 = undefined; try std.testing.expectEqualStrings(" ", buildCompletedTurnLabel(&buf, .{ .active = true }));}
test "buildTurnLabel returns null when tool activity is set" { var buf: [32]u8 = undefined; const label = buildTurnLabel(&buf, .{ .active = true, .last_activity_kind = .write }, 0); try std.testing.expect(label == null);}
test "buildThinkingLabelFull renders ordered activity counts" { var buf: [256]u8 = undefined; const label = buildThinkingLabelFull(&buf, .{ .active = true, .last_activity_kind = .list, .read_count = 2, .list_count = 3, .write_count = 4, .edit_count = 5, .command_count = 6, .subagent_count = 7, .open_count = 8, });
try std.testing.expectEqualStrings( "listing | 2 files read, 3 directories listed, 4 files wrote, 5 files edited, 6 commands started, 7 subagents created, 8 files opened", label, );}
test "buildProgressLabel surfaces active phase and command count" { var buf: [256]u8 = undefined; const label = buildProgressLabel(&buf, .{ .active = true, .last_activity_kind = .command, .command_count = 1, });
try std.testing.expectEqualStrings("running | 1 command started", label.?);}
test "buildProgressLabel keeps ask status semantic" { var buf: [256]u8 = undefined; const label = buildProgressLabel(&buf, .{ .active = true, .last_activity_kind = .ask, });
try std.testing.expectEqualStrings("asking", label.?);}
test "buildProgressLabel appends turn token suffix after activity list" { var buf: [256]u8 = undefined; const label = buildProgressLabel(&buf, .{ .active = true, .last_activity_kind = .list, .read_count = 2, .list_count = 3, .token_progress = .{ .input_tokens = 10, .output_tokens = 20 }, });
try std.testing.expectEqualStrings("listing | 2 files read, 3 directories listed (↑10 ↓20)", label.?);}
test "buildProgressLabel keeps worst case activity and token suffix within buffer" { var buf: [256]u8 = undefined; const label = buildProgressLabel(&buf, .{ .active = true, .last_activity_kind = .command, .read_count = 9, .list_count = 9, .write_count = 9, .edit_count = 9, .command_count = 9, .subagent_count = 9, .open_count = 9, .token_progress = .{ .input_tokens = 999_999, .output_tokens = 999_999 }, });
try std.testing.expect(std.mem.find(u8, label.?, "(↑999k ↓999k)") != null); try std.testing.expect(!std.mem.eql(u8, label.?, "running"));}
test "token progress suffix abbreviates values without exactness markers" { var buf: [64]u8 = undefined; var out: std.Io.Writer = .fixed(&buf); try appendTokenProgressSuffix(&out, .{ .input_tokens = 50_000, .output_tokens = 1_250, .input_exact = true, .output_exact = false, }); try std.testing.expectEqualStrings(" (↑50k ↓1.2k)", out.buffered());}