diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml
index fa3902ea9..a36ce15b4 100644
--- a/.github/workflows/bench.yml
+++ b/.github/workflows/bench.yml
@@ -44,3 +44,21 @@ jobs:
for run in 1 2 3; do
zig build run-bench-ui-activity -Doptimize=ReleaseSafe
done
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Run interactive terminal performance gate
+ working-directory: tests/e2e
+ env:
+ FX_TUI_PERFORMANCE: "1"
+ FX_TUI_PERFORMANCE_REPORT: ${{ runner.temp }}/tui-performance.json
+ run: bun test tui-performance.test.ts
+
+ - name: Upload interactive terminal performance evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: tui-performance
+ path: ${{ runner.temp }}/tui-performance.json
+ if-no-files-found: warn
diff --git a/scripts/pgso/corpus.json b/scripts/pgso/corpus.json
index cc37f1c68..8f4a1b39d 100644
--- a/scripts/pgso/corpus.json
+++ b/scripts/pgso/corpus.json
@@ -9,6 +9,7 @@
"tui-command-permissions.test.ts": "The file contains a sound scenario and cannot be run safely as a whole.",
"tui-direct-write-audit.test.ts": "The suite validates a repository audit tool rather than the candidate runtime.",
"tui-keybindings.test.ts": "The file-wide guard requires a real model credential.",
+ "tui-performance.test.ts": "The suite is an opt-in percentile benchmark with a live-provider smoke; the Benchmarks workflow owns its deterministic gate.",
"tui-render-lab.test.ts": "The suite validates render-lab infrastructure and owns separate native opt-in scenarios.",
"tui-render-live-stress.test.ts": "The suite requires a real model credential and explicit live stress opt-in.",
"web-fetch-live.test.ts": "The suite requires explicit live-network opt-in.",
diff --git a/scripts/pgso/tests/test_corpus.py b/scripts/pgso/tests/test_corpus.py
index bfefdf432..6ddab15e5 100644
--- a/scripts/pgso/tests/test_corpus.py
+++ b/scripts/pgso/tests/test_corpus.py
@@ -81,6 +81,7 @@
"tui-command-permissions.test.ts",
"tui-direct-write-audit.test.ts",
"tui-keybindings.test.ts",
+ "tui-performance.test.ts",
"tui-render-lab.test.ts",
"tui-render-live-stress.test.ts",
"web-fetch-live.test.ts",
diff --git a/src/acp/server.zig b/src/acp/server.zig
index 967ced77b..049446625 100644
--- a/src/acp/server.zig
+++ b/src/acp/server.zig
@@ -1411,9 +1411,11 @@ fn handleInitialize(state: *ServerState, alloc: Allocator, msg: *jsonrpc.Message
state.context_enabled = startup.context_enabled;
if (comptime !host_target.is_wasm) {
- const loaded_skills = try app_runtime_setup.loadSkills(alloc, state.workspace_root, builtin_skills.root_policy);
+ var loaded_skills = try app_runtime_setup.loadSkills(alloc, state.workspace_root, builtin_skills.root_policy);
+ errdefer loaded_skills.deinit(alloc);
skill_runtime.traceDiagnostics("acp_startup", loaded_skills.diagnostics);
- state.skills.replaceLoaded(alloc, loaded_skills.dir, loaded_skills.skills, loaded_skills.diagnostics);
+ try state.skills.replaceLoaded(alloc, loaded_skills.dir, loaded_skills.skills, loaded_skills.diagnostics);
+ loaded_skills = .{};
}
var catalog_cancel_flag = std.atomic.Value(bool).init(false);
diff --git a/src/core/app/app_agent_runtime.zig b/src/core/app/app_agent_runtime.zig
index c94bbd4dd..3cd0b6569 100644
--- a/src/core/app/app_agent_runtime.zig
+++ b/src/core/app/app_agent_runtime.zig
@@ -910,7 +910,10 @@ pub fn Runtime(comptime App: type) type {
try appendClaimedContextNotice(app, &preflight_context_notices.writer, notice);
}
- var bounded_skills = try app.skills.buildRoutedSystemPromptSection(
+ var skill_catalog = app.skills.acquireCatalog();
+ var skill_catalog_owned = true;
+ defer if (skill_catalog_owned) skill_catalog.deinit();
+ var bounded_skills = try skill_catalog.buildRoutedSystemPromptSection(
std.heap.c_allocator,
job.prompt,
if (comptime @hasField(App, "context_limits")) app.context_limits else .{},
@@ -930,12 +933,14 @@ pub fn Runtime(comptime App: type) type {
}
var explicit_skills = try skill_invocation.buildExplicitPromptSection(
std.heap.c_allocator,
- .{ .skills = app.skills.items, .diagnostics = app.skills.diagnostics },
+ .{ .skills = skill_catalog.items, .diagnostics = skill_catalog.diagnostics },
job.prompt,
explicit_bindings,
if (comptime @hasField(App, "context_limits")) app.context_limits else .{},
);
defer explicit_skills.deinit(std.heap.c_allocator);
+ skill_catalog.deinit();
+ skill_catalog_owned = false;
if (explicit_skills.notice) |notice| {
try appendClaimedContextNotice(app, &postflight_context_notices.writer, notice);
}
@@ -1031,7 +1036,10 @@ pub fn Runtime(comptime App: type) type {
) catch
return error.OutOfMemory;
defer child_projection.deinit(alloc);
- var bounded_skills = app.skills.buildRoutedSystemPromptSection(
+ var skill_catalog = app.skills.acquireCatalog();
+ var skill_catalog_owned = true;
+ defer if (skill_catalog_owned) skill_catalog.deinit();
+ var bounded_skills = skill_catalog.buildRoutedSystemPromptSection(
alloc,
message.content,
if (comptime @hasField(App, "context_limits")) app.context_limits else .{},
@@ -1039,12 +1047,14 @@ pub fn Runtime(comptime App: type) type {
defer bounded_skills.deinit(alloc);
var explicit_skills = skill_invocation.buildExplicitPromptSection(
alloc,
- .{ .skills = app.skills.items, .diagnostics = app.skills.diagnostics },
+ .{ .skills = skill_catalog.items, .diagnostics = skill_catalog.diagnostics },
message.content,
&.{},
if (comptime @hasField(App, "context_limits")) app.context_limits else .{},
) catch return error.OutOfMemory;
defer explicit_skills.deinit(alloc);
+ skill_catalog.deinit();
+ skill_catalog_owned = false;
const prompt_policy = app.promptPolicy();
const tool_context = childToolContext(app.subagentToolContextForAdmission(admission));
const providers = if (comptime @hasDecl(App, "providerSet"))
diff --git a/src/core/app/app_auth_runtime.zig b/src/core/app/app_auth_runtime.zig
index 3cfa808ff..21cbb4e59 100644
--- a/src/core/app/app_auth_runtime.zig
+++ b/src/core/app/app_auth_runtime.zig
@@ -112,9 +112,21 @@ pub fn Runtime(comptime App: type) type {
try beginSignIn(app, false);
return;
}
- try app.auth.refreshSourceInventory(app.alloc);
- app.auth.openPickerForProvider(app.alloc, provider_runtime.provider(app));
- app.shell.render_requests.request(.footer);
+ switch (app.auth.beginSourceInventoryRefresh(app.alloc, .{
+ .provider = provider_runtime.provider(app),
+ })) {
+ .started => {},
+ .busy => try writeAuthNotice(app, .{
+ .topic = "auth",
+ .tone = .warning,
+ .body = "Authentication inventory refresh is already in progress.",
+ }),
+ .failed => try writeAuthNotice(app, .{
+ .topic = "auth",
+ .tone = .@"error",
+ .body = "Authentication sources could not be checked. The picker remains closed.",
+ }),
+ }
}
pub fn runLogoutCommand(app: *App, target: []const u8) !void {
@@ -143,7 +155,7 @@ pub fn Runtime(comptime App: type) type {
else
.gateway;
const provider_inventory = if (comptime @hasDecl(@TypeOf(app.auth), "pickerView")) inventory: {
- try app.auth.refreshSourceInventory(app.alloc);
+ try app.auth.refreshSourceInventoryForLogout(app.alloc);
break :inventory app.auth.pickerView().available_sources;
} else @as(auth_runtime.SourceSet, .empty);
const logout_provider = auth_transition.decideLogoutProvider(.{
@@ -223,9 +235,38 @@ pub fn Runtime(comptime App: type) type {
}, true);
return;
}
- try app.auth.refreshSourceInventory(app.alloc);
- app.auth.openPickerForProvider(app.alloc, provider_runtime.provider(app));
- app.shell.render_requests.request(.footer);
+ switch (app.auth.beginSourceInventoryRefresh(app.alloc, .{
+ .provider = provider_runtime.provider(app),
+ })) {
+ .started => {},
+ .busy => try writeAuthNotice(app, .{
+ .topic = "auth",
+ .tone = .warning,
+ .body = "Authentication inventory refresh is already in progress.",
+ }),
+ .failed => try writeAuthNotice(app, .{
+ .topic = "auth",
+ .tone = .@"error",
+ .body = "Authentication sources could not be checked. The picker remains closed.",
+ }),
+ }
+ }
+
+ pub fn collectSourceInventoryFacts(app: *App) !void {
+ const result = app.auth.takeSourceInventoryRefresh() orelse return;
+ switch (result) {
+ .ready => |action| {
+ app.auth.openPickerForProvider(app.alloc, action.provider);
+ app.shell.render_requests.request(.footer);
+ },
+ .failed => {
+ try writeAuthNotice(app, .{
+ .topic = "auth",
+ .tone = .@"error",
+ .body = "Authentication sources could not be checked. The picker was not opened with stale data.",
+ });
+ },
+ }
}
fn applyLogoutResult(app: *App, result: login_flow.LogoutResult) !void {
@@ -1466,6 +1507,8 @@ const TestAuth = struct {
sign_in_code_toggle_succeeds: bool = true,
sign_in_code_submit_count: usize = 0,
sign_in_code_submit_succeeds: bool = true,
+ inventory_refresh_action: ?auth_runtime.InventoryRefreshAction = null,
+ inventory_refresh_fails: bool = false,
fn credentialSource(self: *const TestAuth) ?credentials.Source {
return self.active_source;
@@ -1585,6 +1628,32 @@ const TestAuth = struct {
self.source_inventory_refresh_count += 1;
}
+ fn refreshSourceInventoryForLogout(self: *TestAuth, _: std.mem.Allocator) !void {
+ self.source_inventory_refresh_count += 1;
+ }
+
+ fn beginSourceInventoryRefresh(
+ self: *TestAuth,
+ _: std.mem.Allocator,
+ action: auth_runtime.InventoryRefreshAction,
+ ) auth_runtime.InventoryRefreshStart {
+ if (self.inventory_refresh_action != null) return .busy;
+ self.source_inventory_refresh_count += 1;
+ self.inventory_refresh_action = action;
+ return .started;
+ }
+
+ fn takeSourceInventoryRefresh(
+ self: *TestAuth,
+ ) ?auth_runtime.InventoryRefreshResult {
+ const action = self.inventory_refresh_action orelse return null;
+ self.inventory_refresh_action = null;
+ return if (self.inventory_refresh_fails)
+ .{ .failed = action }
+ else
+ .{ .ready = action };
+ }
+
fn recordCredentialRefreshFailure(self: *TestAuth, source: credentials.Source) void {
self.refresh_failure_source = source;
}
@@ -1737,10 +1806,43 @@ test "setup hub projects the selected provider into the auth picker" {
try Runtime(TestApp).openSetupHub(&app);
+ try std.testing.expect(!app.auth.picker_opened);
+ try Runtime(TestApp).collectSourceInventoryFacts(&app);
try std.testing.expect(app.auth.picker_opened);
try std.testing.expectEqual(model_provider.ProviderId.codex, app.auth.picker_provider);
}
+test "login opens only after its asynchronous inventory refresh completes" {
+ var app: TestApp = .{ .selected_provider = .grok };
+ defer app.deinit();
+
+ try Runtime(TestApp).runLoginCommand(&app);
+
+ try std.testing.expectEqual(@as(usize, 1), app.auth.source_inventory_refresh_count);
+ try std.testing.expect(!app.auth.picker_opened);
+ try Runtime(TestApp).collectSourceInventoryFacts(&app);
+ try std.testing.expect(app.auth.picker_opened);
+ try std.testing.expectEqual(model_provider.ProviderId.grok, app.auth.picker_provider);
+ try std.testing.expect(app.shell.render_requests.footer_requested);
+}
+
+test "login inventory failure leaves the picker closed and reports one error" {
+ var app: TestApp = .{ .selected_provider = .gateway };
+ defer app.deinit();
+ app.auth.inventory_refresh_fails = true;
+
+ try Runtime(TestApp).runLoginCommand(&app);
+ try Runtime(TestApp).collectSourceInventoryFacts(&app);
+
+ try std.testing.expect(!app.auth.picker_opened);
+ try std.testing.expectEqual(@as(usize, 1), app.notice_write_count);
+ try std.testing.expect(std.mem.find(
+ u8,
+ app.transcript.items,
+ "picker was not opened with stale data",
+ ) != null);
+}
+
test "OAuth app gating accepts native auth or JS-host auth and rejects neither" {
const NativeApp = struct {
pub const host_profile = runtime_profile.native;
diff --git a/src/core/app/app_bootstrap_runtime.zig b/src/core/app/app_bootstrap_runtime.zig
index f1c15ef33..8c92ab44c 100644
--- a/src/core/app/app_bootstrap_runtime.zig
+++ b/src/core/app/app_bootstrap_runtime.zig
@@ -216,14 +216,14 @@ pub fn Runtime(comptime App: type) type {
startup.stored_key_status,
startup.credential_onboarding_skipped,
);
- if (comptime @hasDecl(@TypeOf(app.auth), "refreshChatGptSourceInventory")) {
- app.auth.refreshChatGptSourceInventory(app.alloc) catch |err| {
- debug_trace.logf("auth", "startup ChatGPT inventory refresh failed err={s}", .{@errorName(err)});
- };
- } else {
+ if (comptime @hasDecl(@TypeOf(app.auth), "refreshSourceInventory")) {
app.auth.refreshSourceInventory(app.alloc) catch |err| {
debug_trace.logf("auth", "startup source inventory refresh failed err={s}", .{@errorName(err)});
};
+ } else if (comptime @hasDecl(@TypeOf(app.auth), "refreshChatGptSourceInventory")) {
+ app.auth.refreshChatGptSourceInventory(app.alloc) catch |err| {
+ debug_trace.logf("auth", "startup source inventory refresh failed err={s}", .{@errorName(err)});
+ };
}
const startup_auth_view = app.auth.view();
if (startup_auth_view.active_source == null and !startup_auth_view.onboarding_skipped) {
@@ -328,13 +328,15 @@ pub fn Runtime(comptime App: type) type {
app.mcp_runtime = profile_mcp;
}
- const loaded = try deps.load_skills(
+ var loaded = try deps.load_skills(
std.heap.c_allocator,
app.workspace_root,
deps.skill_root_policy,
);
+ errdefer loaded.deinit(std.heap.c_allocator);
skill_runtime.traceDiagnostics("interactive_startup", loaded.diagnostics);
- app.skills.replaceLoaded(std.heap.c_allocator, loaded.dir, loaded.skills, loaded.diagnostics);
+ try app.skills.replaceLoaded(std.heap.c_allocator, loaded.dir, loaded.skills, loaded.diagnostics);
+ loaded = .{};
if (app.requested_resume == null) {
const welcome_message = try deps.welcome_message(app.alloc);
diff --git a/src/core/app/app_commands.zig b/src/core/app/app_commands.zig
index 78c725834..098000f6e 100644
--- a/src/core/app/app_commands.zig
+++ b/src/core/app/app_commands.zig
@@ -1721,7 +1721,64 @@ pub fn Handlers(comptime App: type) type {
const provider = app.skillsCommandProvider();
const command = provider.parseCommand(rest);
- try app.reloadSkills();
+ if (comptime @hasDecl(App, "requestSkillsRefresh")) switch (command) {
+ .list => {
+ const generation = try app.requestSkillsRefresh();
+ try app.skills.queueRefreshAction(app.alloc, generation, .list);
+ try collectSkillsRefreshFacts(app);
+ return;
+ },
+ .show => |name| {
+ const generation = try app.requestSkillsRefresh();
+ try app.skills.queueRefreshAction(
+ app.alloc,
+ generation,
+ .{ .show = name },
+ );
+ try collectSkillsRefreshFacts(app);
+ return;
+ },
+ .install, .create, .remove, .path, .usage => {},
+ };
+ try executeSkillsCommand(app, provider, command);
+ try collectSkillsRefreshFacts(app);
+ }
+
+ pub fn collectSkillsRefreshFacts(app: *App) !void {
+ var ready = app.skills.takeReadyRefreshAction() orelse return;
+ defer ready.deinit(app.alloc);
+ if (!ready.succeeded) {
+ try app.writeDomainNotice(.{
+ .topic = "skills",
+ .tone = .@"error",
+ .body = "Skills could not be refreshed. The previous catalog was not shown as current.",
+ }, true);
+ return;
+ }
+ switch (ready.action) {
+ .list => try executeSkillsCommand(
+ app,
+ app.skillsCommandProvider(),
+ .list,
+ ),
+ .show => |name| try executeSkillsCommand(
+ app,
+ app.skillsCommandProvider(),
+ .{ .show = name },
+ ),
+ .notice => |body| try app.writeDomainNotice(.{
+ .topic = "skills",
+ .tone = .neutral,
+ .body = body,
+ }, true),
+ }
+ }
+
+ fn executeSkillsCommand(
+ app: *App,
+ provider: skill_commands.Provider,
+ command: skill_commands.Command,
+ ) !void {
try writeSkillDiagnosticNotice(app);
switch (command) {
@@ -1812,12 +1869,15 @@ pub fn Handlers(comptime App: type) type {
}
},
.notice => |notice| {
- try app.writeDomainNotice(.{
- .topic = "skills",
- .tone = .neutral,
- .body = notice.text,
- }, true);
- if (notice.reload) try app.reloadSkills();
+ if (notice.reload and comptime @hasDecl(App, "requestSkillsRefresh")) {
+ try queueSkillsNoticeAfterRefresh(app, notice.text);
+ } else {
+ try app.writeDomainNotice(.{
+ .topic = "skills",
+ .tone = .neutral,
+ .body = notice.text,
+ }, true);
+ }
},
.installed => |install_result| {
var installed_notice: std.Io.Writer.Allocating = .init(app.alloc);
@@ -1829,16 +1889,31 @@ pub fn Handlers(comptime App: type) type {
const msg = try installed_notice.toOwnedSlice();
defer app.alloc.free(msg);
- try app.writeDomainNotice(.{
- .topic = "skills",
- .tone = .neutral,
- .body = std.mem.trimEnd(u8, msg, "\n"),
- }, true);
- try app.reloadSkills();
+ if (comptime @hasDecl(App, "requestSkillsRefresh")) {
+ try queueSkillsNoticeAfterRefresh(
+ app,
+ std.mem.trimEnd(u8, msg, "\n"),
+ );
+ } else {
+ try app.writeDomainNotice(.{
+ .topic = "skills",
+ .tone = .neutral,
+ .body = std.mem.trimEnd(u8, msg, "\n"),
+ }, true);
+ }
},
}
}
+ fn queueSkillsNoticeAfterRefresh(app: *App, body: []const u8) !void {
+ const generation = try app.requestSkillsRefresh();
+ try app.skills.queueRefreshAction(
+ app.alloc,
+ generation,
+ .{ .notice = body },
+ );
+ }
+
fn closeModelMenuIfPresent(app: *App) void {
if (comptime @hasField(App, "model_cache")) app.model_cache.closeMenu();
}
@@ -3961,8 +4036,10 @@ const SkillsInstallReplayApp = struct {
self.shell.deinit(self.alloc);
}
- fn reloadSkills(self: *SkillsInstallReplayApp) !void {
+ fn requestSkillsRefresh(self: *SkillsInstallReplayApp) !u64 {
self.reload_count += 1;
+ self.skills.fresh_through_generation = self.reload_count;
+ return self.reload_count;
}
noinline fn writeDomainNotice(self: *SkillsInstallReplayApp, notice: types.SemanticNotice, _: bool) !void {
@@ -4458,7 +4535,7 @@ test "skills install groups command notice fragments for entry replay" {
try std.testing.expectEqual(@as(usize, 2), app.shell.entries.items.len);
try std.testing.expectEqual(@as(usize, 2), app.write_count);
- try std.testing.expectEqual(@as(usize, 2), app.reload_count);
+ try std.testing.expectEqual(@as(usize, 1), app.reload_count);
try std.testing.expectEqual(types.NoticeTone.neutral, app.last_tone.?);
try std.testing.expect(app.shell.entries.items[0] == .semantic_notice);
try std.testing.expect(app.shell.entries.items[1] == .semantic_notice);
@@ -4657,7 +4734,7 @@ test "skills remove prefers a managed match after a workspace duplicate" {
const rendered = try transcript_runtime.renderEntriesToBytes(alloc, app.shell.entries.items, 80, .{});
defer alloc.free(rendered);
try std.testing.expect(std.mem.find(u8, rendered, "Removed skill 'review'.") != null);
- try std.testing.expectEqual(@as(usize, 2), app.reload_count);
+ try std.testing.expectEqual(@as(usize, 1), app.reload_count);
try std.testing.expectError(
error.FileNotFound,
tmp.dir.access(io_mod.getIo(), "home/.fx/skills/review", .{}),
diff --git a/src/core/app/app_input_runtime.zig b/src/core/app/app_input_runtime.zig
index 9e6f86c4a..772eb1348 100644
--- a/src/core/app/app_input_runtime.zig
+++ b/src/core/app/app_input_runtime.zig
@@ -604,6 +604,9 @@ pub fn Runtime(comptime App: type) type {
max_prompt_history: usize,
) !?u8 {
if (!ingress.has_routing_work()) return null;
+ if (terminalIngressCancelsPendingFullTranscriptOpen(ingress)) {
+ _ = full_transcript_rt.cancelPendingOpenForInput(app);
+ }
const file_picker_was_active = app.input_runtime.picker.activeFilePickerQuery(&app.input_runtime.edit_state) != null;
defer if (comptime runtime_profile.allows(App, .file_index))
@@ -734,6 +737,16 @@ pub fn Runtime(comptime App: type) type {
return false;
}
+ fn terminalIngressCancelsPendingFullTranscriptOpen(
+ ingress: input_action.TerminalInputIngress,
+ ) bool {
+ const event = ingress.event orelse return false;
+ return switch (event) {
+ .paste_byte, .raw => true,
+ .action => |decoded| decoded.action != .toggle_full_transcript,
+ };
+ }
+
pub fn routeActivePasteIngressByteWithLimits(
app: *App,
byte: u8,
@@ -2914,8 +2927,10 @@ pub fn Runtime(comptime App: type) type {
if (comptime !@hasField(App, "skills")) return;
if (!app.skills.menu.active) return;
if (!app.skills.menu.origin.isMention()) {
- app.skills.menu.setQuery(app.input_runtime.edit_state.input.items);
- app.skills.menu.clamp(app.skills.items);
+ app.skills.setMenuQuery(
+ app.alloc,
+ app.input_runtime.edit_state.input.items,
+ );
return;
}
const target = app.skills.menu.target orelse return;
@@ -2926,8 +2941,7 @@ pub fn Runtime(comptime App: type) type {
}
const end = skillTokenEnd(items, target.start + 1);
app.skills.menu.target = .{ .start = target.start, .end = end };
- app.skills.menu.setQuery(items[target.start + 1 .. end]);
- app.skills.menu.clamp(app.skills.items);
+ app.skills.setMenuQuery(app.alloc, items[target.start + 1 .. end]);
}
fn syncModelMenu(app: *App) void {
@@ -7941,6 +7955,35 @@ test "app_input_runtime decoded kitty Escape follows the raw Escape policy" {
}
}
+test "pending full transcript open cancels after complete escape input" {
+ const alloc = std.testing.allocator;
+ const sequences = [_][]const u8{
+ "\x1b[A",
+ "\x1b[B",
+ "\x1b[5~",
+ "\x1b[6~",
+ };
+
+ for (sequences) |sequence| {
+ var app = try RoutingFakeApp.init(alloc);
+ defer app.deinit();
+ _ = try app.shell.appendRawTranscriptEntryClassified(
+ alloc,
+ "pending transcript\n",
+ .unknown_raw,
+ );
+ try std.testing.expect(!app.shell.requestFullTranscriptOpen());
+
+ try feedRoutingBytes(&app, sequence);
+
+ try std.testing.expectEqualStrings(
+ "",
+ app.input_runtime.edit_state.input.items,
+ );
+ try std.testing.expect(!app.shell.cancelPendingFullTranscriptOpen());
+ }
+}
+
test "app_input_runtime Ghostty Escape press closes the usage dashboard" {
const alloc = std.testing.allocator;
var app = try RoutingFakeApp.init(alloc);
diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig
index 78566da69..dccc7aa09 100644
--- a/src/core/app/app_render_runtime.zig
+++ b/src/core/app/app_render_runtime.zig
@@ -1918,7 +1918,6 @@ pub fn Runtime(comptime App: type) type {
else
null;
full_transcript_projection = try presentation_shell.preparedFullTranscriptPageProjectionInterruptible(
- app.alloc,
full_diff_resolver,
full_transcript_capability,
checkpoint,
@@ -2187,8 +2186,12 @@ pub fn Runtime(comptime App: type) type {
.{ .top = area.top, .bottom = area.bottom },
checkpoint,
);
- owned_transcript_source = staged.source;
- transcript_source = &owned_transcript_source.?;
+ if (staged.owned_source) |source| {
+ owned_transcript_source = source;
+ transcript_source = &owned_transcript_source.?;
+ } else {
+ transcript_source = staged.borrowed_source.?;
+ }
prepared_transcript = staged.prepared;
footer_frame.paint.viewport = prepared_transcript.?.selection;
try validatePreparedTranscriptFitsPlan(&prepared_transcript.?, footer_frame.paint);
@@ -6564,13 +6567,13 @@ test "core.app_render_runtime lifecycle rewrite recovers normal buffer after fil
try std.testing.expect(try coordinatorGridContains(app.shell.shadow_vt.?.*, "stream completed"));
}
-test "core.app_render_runtime full transcript opens with a bounded loading frame" {
+test "core.app_render_runtime full transcript defers repaint until its page is ready" {
const alloc = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var file = try tmp.dir.createFile(
std.testing.io,
- "full-transcript-loading-frame.log",
+ "full-transcript-deferred-frame.log",
.{ .read = true },
);
defer file.close(io_mod.getIo());
@@ -6608,11 +6611,20 @@ test "core.app_render_runtime full transcript opens with a bounded loading frame
try app_lifecycle.openFullTranscript(app.alloc, &app.terminal, &app.shell, &app.metrics);
try Runtime(CoordinatorTestApp).flushRequestedFrame(&app);
- try std.testing.expect(try coordinatorGridContains(
+ try std.testing.expect(!try coordinatorGridContains(
app.shell.shadow_vt.?.*,
"Preparing full detail",
));
- try std.testing.expect(!try coordinatorGridContains(
+
+ for (0..100_000) |_| {
+ _ = try app.shell.pollFullTranscriptPageLoad();
+ if (app.shell.fullTranscriptPreparedForOpen()) break;
+ std.Thread.yield() catch std.atomic.spinLoopHint();
+ }
+ try std.testing.expect(app.shell.fullTranscriptPreparedForOpen());
+ app.shell.render_requests.request(.transcript);
+ try Runtime(CoordinatorTestApp).flushRequestedFrame(&app);
+ try std.testing.expect(try coordinatorGridContains(
app.shell.shadow_vt.?.*,
"FULL_ASYNC_SENTINEL",
));
diff --git a/src/core/app/app_runtime_setup.zig b/src/core/app/app_runtime_setup.zig
index aa84887c4..6f550c458 100644
--- a/src/core/app/app_runtime_setup.zig
+++ b/src/core/app/app_runtime_setup.zig
@@ -20,18 +20,21 @@ pub const LoadedSkills = struct {
}
};
+pub fn resolveSkillsHome(alloc: Allocator) Allocator.Error!?[]u8 {
+ const configured_home = io_mod.getenv("HOME") orelse return null;
+ return io_mod.realpathAlloc(alloc, configured_home) catch |err| switch (err) {
+ error.OutOfMemory => error.OutOfMemory,
+ else => try alloc.dupe(u8, configured_home),
+ };
+}
+
pub fn loadSkills(
alloc: Allocator,
workspace_root: []const u8,
root_policy: skill_contract.RootPolicy,
) LoadSkillsError!LoadedSkills {
- const configured_home = io_mod.getenv("HOME") orelse return .{};
- const canonical_home = io_mod.realpathAlloc(alloc, configured_home) catch |err| switch (err) {
- error.OutOfMemory => return error.OutOfMemory,
- else => null,
- };
- defer if (canonical_home) |home| alloc.free(home);
- const home = canonical_home orelse configured_home;
+ const home = (try resolveSkillsHome(alloc)) orelse return .{};
+ defer alloc.free(home);
const dir = try profile_paths.managedSkillsDir(alloc, home);
errdefer alloc.free(dir);
const discovery = try skill_runtime.loadVisibleSkills(alloc, workspace_root, home, dir, root_policy);
diff --git a/src/core/app/app_terminal_takeover_runtime.zig b/src/core/app/app_terminal_takeover_runtime.zig
index 9fe109e91..76cfeb5c0 100644
--- a/src/core/app/app_terminal_takeover_runtime.zig
+++ b/src/core/app/app_terminal_takeover_runtime.zig
@@ -357,6 +357,7 @@ pub const Controller = struct {
return error.TerminalTakeoverInputFull;
}
try self.input.appendSlice(alloc, bytes);
+ self.next_screen_ms = 0;
}
fn containFailure(
@@ -554,6 +555,7 @@ pub const Controller = struct {
return self.beginReturn(App, app, completionReason(completion), true);
};
self.inflight_write_bytes = 0;
+ self.next_screen_ms = 0;
if (terminalEnded(result.write.session.lifecycle)) {
try self.beginReturn(App, app, lifecycleReason(result.write.session.lifecycle), true);
}
@@ -658,7 +660,9 @@ pub const Controller = struct {
}
fn scheduleScreen(self: *Controller, app: anytype) !void {
- if (self.screen_correlation != null) return;
+ if (self.screen_correlation != null or
+ self.write_correlation != null or
+ self.input.items.len != 0) return;
const now_ms = io_mod.milliTimestamp();
if (now_ms < self.next_screen_ms) return;
if (takeoverFailureRequested("screen")) {
@@ -1059,12 +1063,16 @@ test "takeover prefix is raw data inside fragmented bracketed paste" {
try std.testing.expect(!parser.in_paste);
}
-test "takeover retains bounded input while lease acquisition is pending" {
- var controller = Controller{ .phase = .acquiring };
+test "takeover retains bounded input and requests an immediate screen refresh" {
+ var controller = Controller{
+ .phase = .acquiring,
+ .next_screen_ms = 100,
+ };
defer controller.deinit(std.testing.allocator);
try controller.retainInput(std.testing.allocator, "before-acquire");
try std.testing.expectEqualStrings("before-acquire", controller.input.items);
+ try std.testing.expectEqual(@as(i64, 0), controller.next_screen_ms);
try controller.input.ensureTotalCapacity(
std.testing.allocator,
diff --git a/src/core/app/input_full_transcript_runtime.zig b/src/core/app/input_full_transcript_runtime.zig
index 3cacdbc05..60885cf1c 100644
--- a/src/core/app/input_full_transcript_runtime.zig
+++ b/src/core/app/input_full_transcript_runtime.zig
@@ -79,10 +79,35 @@ pub fn Runtime(comptime App: type) type {
try transitionScreen(app, .toggle);
return true;
},
+ .close => {
+ if (childPresentationShell(app)) |child| {
+ if (child.cancelPendingFullTranscriptOpen()) return true;
+ }
+ if (comptime @hasDecl(
+ @TypeOf(app.shell),
+ "cancelPendingFullTranscriptOpen",
+ )) {
+ if (app.shell.cancelPendingFullTranscriptOpen()) return true;
+ }
+ return false;
+ },
else => false,
};
}
+ pub fn cancelPendingOpenForInput(app: *App) bool {
+ if (childPresentationShell(app)) |child| {
+ if (child.cancelPendingFullTranscriptOpen()) return true;
+ }
+ if (comptime @hasDecl(
+ @TypeOf(app.shell),
+ "cancelPendingFullTranscriptOpen",
+ )) {
+ return app.shell.cancelPendingFullTranscriptOpen();
+ }
+ return false;
+ }
+
fn transitionScreen(
app: *App,
event: transcript_presentation.Event,
@@ -91,6 +116,10 @@ pub fn Runtime(comptime App: type) type {
const from = childPresentationDepth(app);
const to = from.transition(event);
if (from == to) return;
+ if (from == .inline_mode and to == .full) {
+ const child = childPresentationShell(app) orelse return;
+ if (!child.requestFullTranscriptOpen()) return;
+ }
if (comptime @hasDecl(
@TypeOf(app.subagents),
"setChildTranscriptPresentationDepth",
@@ -118,6 +147,12 @@ pub fn Runtime(comptime App: type) type {
std.debug.assert(to == .full);
if (app.terminal.alternate_screen_owner != .none) return;
if (app.approval_prompt.isActive()) return;
+ if (comptime @hasDecl(
+ @TypeOf(app.shell),
+ "requestFullTranscriptOpen",
+ )) {
+ if (!app.shell.requestFullTranscriptOpen()) return;
+ }
try app_lifecycle.openFullTranscript(
app.alloc,
&app.terminal,
diff --git a/src/core/app/input_subagent_runtime.zig b/src/core/app/input_subagent_runtime.zig
index 6384e6deb..6d0f1c2c3 100644
--- a/src/core/app/input_subagent_runtime.zig
+++ b/src/core/app/input_subagent_runtime.zig
@@ -776,8 +776,10 @@ pub fn SubagentRuntime(comptime App: type) type {
fn syncChildSkillsQuery(app: *App) void {
const view = app.subagents.childPresentationView() orelse return;
- app.skills.menu.setQuery(view.editor.edit_state.input.items);
- app.skills.menu.clamp(app.skills.items);
+ app.skills.setMenuQuery(
+ app.alloc,
+ view.editor.edit_state.input.items,
+ );
}
fn moveChildModelMenu(app: *App, delta: i32) void {
diff --git a/src/core/app/input_submit_runtime.zig b/src/core/app/input_submit_runtime.zig
index 534ce6d38..dc6495117 100644
--- a/src/core/app/input_submit_runtime.zig
+++ b/src/core/app/input_submit_runtime.zig
@@ -24,6 +24,7 @@ const PendingPhaseError = error{InvalidPendingPhase};
pub const PendingSubmission = struct {
draft: worker_runtime.QueuedPromptDraft,
phase: PendingPhase = .awaiting_frame,
+ skill_refresh_generation: ?u64 = null,
fn init(draft: worker_runtime.QueuedPromptDraft) PendingSubmission {
std.debug.assert(draft.turn_id != 0);
@@ -57,6 +58,11 @@ pub const PendingSubmission = struct {
}
};
+pub const PendingSkillRefresh = enum {
+ pending,
+ current,
+};
+
pub const State = struct {
pending: ?PendingSubmission = null,
};
@@ -184,6 +190,14 @@ pub fn SubmitRuntime(comptime App: type) type {
}
if (pending.phase != .adopted) return;
+ if (comptime @hasDecl(App, "collectPendingSkillRefresh")) {
+ const readiness = App.collectPendingSkillRefresh(app, pending) catch |err| {
+ finishPendingSubmissionFailure(app, err);
+ return;
+ };
+ if (readiness == .pending) return;
+ }
+
if (pending.draft.prompt.len > 0 and pending.draft.images.len == 0) {
recordAcceptedInput(app, pending.draft.prompt);
}
@@ -2077,6 +2091,8 @@ const PendingLifecycleFake = struct {
finalization_count: usize = 0,
finalization_error: bool = false,
notice_count: usize = 0,
+ skill_refresh: enum { pending, current, failed } = .current,
+ skill_refresh_checks: usize = 0,
fn deinit(self: *PendingLifecycleFake) void {
SubmitRuntime(PendingLifecycleFake).clearPendingSubmission(self, "test_deinit");
@@ -2102,6 +2118,21 @@ const PendingLifecycleFake = struct {
self.worker.queued_turn_id = draft.turn_id;
}
+ pub fn collectPendingSkillRefresh(
+ self: *PendingLifecycleFake,
+ pending: *PendingSubmission,
+ ) !PendingSkillRefresh {
+ self.skill_refresh_checks += 1;
+ if (pending.skill_refresh_generation == null) {
+ pending.skill_refresh_generation = 1;
+ }
+ return switch (self.skill_refresh) {
+ .pending => .pending,
+ .current => .current,
+ .failed => error.InjectedSkillRefreshFailure,
+ };
+ }
+
pub fn writeDomainNotice(
self: *PendingLifecycleFake,
_: types.SemanticNotice,
@@ -2189,6 +2220,26 @@ test "post-commit adoption failure keeps one retryable owner and hold" {
try std.testing.expectEqual(@as(usize, 1), app.worker.release_count);
}
+test "pending submission waits for its skill catalog generation before queueing" {
+ const Runtime = SubmitRuntime(PendingLifecycleFake);
+ var app = try pendingLifecycleFake(std.testing.allocator, 502);
+ defer app.deinit();
+ app.skill_refresh = .pending;
+
+ Runtime.noteCommittedFrame(&app);
+ Runtime.collectPendingSubmissionFacts(&app);
+ try std.testing.expectEqual(PendingPhase.adopted, app.submission.pending.?.phase);
+ try std.testing.expect(app.worker.held);
+ try std.testing.expectEqual(@as(usize, 0), app.finalization_count);
+ try std.testing.expectEqual(@as(?u64, 1), app.submission.pending.?.skill_refresh_generation);
+
+ app.skill_refresh = .current;
+ Runtime.collectPendingSubmissionFacts(&app);
+ try std.testing.expectEqual(PendingPhase.queued, app.submission.pending.?.phase);
+ try std.testing.expect(!app.worker.held);
+ try std.testing.expectEqual(@as(usize, 1), app.finalization_count);
+}
+
test "post-ack finalization failure leaves notice and consumes pending owner" {
const Runtime = SubmitRuntime(PendingLifecycleFake);
var app = try pendingLifecycleFake(std.testing.allocator, 777);
diff --git a/src/core/auth/auth_runtime.zig b/src/core/auth/auth_runtime.zig
index d96b188b8..4f185f50f 100644
--- a/src/core/auth/auth_runtime.zig
+++ b/src/core/auth/auth_runtime.zig
@@ -37,6 +37,11 @@ const SourceProbeFn = *const fn (?*anyopaque, Allocator, credentials.Source) any
const CredentialLoaderFn = *const fn (?*anyopaque, Allocator, credentials.Source) anyerror!?credentials.Credential;
const StoredKeyStoreFn = *const fn (?*anyopaque, Allocator, []const u8) anyerror!void;
+const UnavailableSourcePolicy = enum {
+ fail,
+ omit,
+};
+
const max_api_key_entry_bytes: usize = 8 * 1024;
const max_api_key_mask_glyphs: usize = 32;
const max_manual_code_mask_glyphs: usize = 32;
@@ -333,6 +338,84 @@ const ApiKeySaveRuntime = struct {
}
};
+pub const InventoryRefreshAction = struct {
+ provider: model_provider.ProviderId,
+};
+
+pub const InventoryRefreshStart = enum {
+ started,
+ busy,
+ failed,
+};
+
+pub const InventoryRefreshResult = union(enum) {
+ ready: InventoryRefreshAction,
+ failed: InventoryRefreshAction,
+};
+
+const InventoryProbeFn = *const fn (
+ ctx: ?*anyopaque,
+ alloc: Allocator,
+ source: credentials.Source,
+) anyerror!bool;
+
+const InventoryRefreshDeps = struct {
+ ctx: ?*anyopaque,
+ probe: InventoryProbeFn,
+};
+
+const InventoryRefreshTask = struct {
+ alloc: Allocator,
+ thread: ?std.Thread = null,
+ done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ action: InventoryRefreshAction,
+ deps: InventoryRefreshDeps,
+ inventory: ?SourceSet = null,
+ failure: ?anyerror = null,
+
+ fn start(
+ alloc: Allocator,
+ action: InventoryRefreshAction,
+ deps: InventoryRefreshDeps,
+ ) !*InventoryRefreshTask {
+ const task = try alloc.create(InventoryRefreshTask);
+ task.* = .{
+ .alloc = alloc,
+ .action = action,
+ .deps = deps,
+ };
+ task.thread = std.Thread.spawn(.{}, workerMain, .{task}) catch |err| {
+ alloc.destroy(task);
+ return err;
+ };
+ return task;
+ }
+
+ fn workerMain(self: *InventoryRefreshTask) void {
+ var detected: SourceSet = .empty;
+ for (credential_source_order) |source| {
+ const present = self.deps.probe(
+ self.deps.ctx,
+ self.alloc,
+ source,
+ ) catch |err| {
+ self.failure = err;
+ self.done.store(true, .release);
+ return;
+ };
+ if (present) detected.insert(source);
+ }
+ self.inventory = detected;
+ self.done.store(true, .release);
+ }
+
+ fn deinit(self: *InventoryRefreshTask) void {
+ if (self.thread) |thread| thread.join();
+ const alloc = self.alloc;
+ alloc.destroy(self);
+ }
+};
+
const ApiKeyExitReason = enum {
cancel,
saved,
@@ -790,6 +873,7 @@ pub const Runtime = struct {
api_key_input: std.ArrayList(u8) = .empty,
api_key_returns_to_root: bool = false,
api_key_save: ApiKeySaveRuntime = .{},
+ inventory_refresh_task: ?*InventoryRefreshTask = null,
pub fn init(
validator: api_key_validator.Provider,
@@ -812,7 +896,7 @@ pub const Runtime = struct {
secret_store: host.SecretStore,
) void {
comptime {
- if (std.meta.fields(Self).len != 24) {
+ if (std.meta.fields(Self).len != 25) {
@compileError("update Runtime.initInto for the changed field set");
}
}
@@ -841,9 +925,12 @@ pub const Runtime = struct {
storage.api_key_input = .empty;
storage.api_key_returns_to_root = false;
storage.api_key_save = .{};
+ storage.inventory_refresh_task = null;
}
pub fn deinit(self: *Self, alloc: Allocator) void {
+ if (self.inventory_refresh_task) |task| task.deinit();
+ self.inventory_refresh_task = null;
self.api_key_save.deinit(alloc);
self.sign_in_flow.deinit(alloc);
self.clearSignInCodeInput(alloc, .runtime_deinit);
@@ -976,6 +1063,61 @@ pub const Runtime = struct {
try self.refreshSourceInventoryWithProbe(alloc, self, probeCredentialSource);
}
+ pub fn refreshSourceInventoryForLogout(self: *Self, alloc: Allocator) !void {
+ try self.refreshSourceInventoryWithProbe(alloc, self, probeCredentialSourceForLogout);
+ }
+
+ pub fn beginSourceInventoryRefresh(
+ self: *Self,
+ alloc: Allocator,
+ action: InventoryRefreshAction,
+ ) InventoryRefreshStart {
+ return self.beginSourceInventoryRefreshWithDeps(alloc, action, .{
+ .ctx = self,
+ .probe = probeCredentialSource,
+ });
+ }
+
+ fn beginSourceInventoryRefreshWithDeps(
+ self: *Self,
+ alloc: Allocator,
+ action: InventoryRefreshAction,
+ deps: InventoryRefreshDeps,
+ ) InventoryRefreshStart {
+ if (self.inventory_refresh_task != null) return .busy;
+ self.inventory_refresh_task = InventoryRefreshTask.start(
+ alloc,
+ action,
+ deps,
+ ) catch return .failed;
+ return .started;
+ }
+
+ pub fn takeSourceInventoryRefresh(
+ self: *Self,
+ ) ?InventoryRefreshResult {
+ const task = self.inventory_refresh_task orelse return null;
+ if (!task.done.load(.acquire)) return null;
+ if (task.thread) |thread| {
+ thread.join();
+ task.thread = null;
+ }
+ self.inventory_refresh_task = null;
+ defer task.deinit();
+ if (task.failure != null or task.inventory == null) {
+ return .{ .failed = task.action };
+ }
+ var detected = task.inventory.?;
+ self.fx_login_session_available = detected.contains(.fx_login);
+ if (self.credentialSource()) |source| detected.insert(source);
+ self.source_inventory = detected;
+ return .{ .ready = task.action };
+ }
+
+ pub fn sourceInventoryRefreshActive(self: *const Self) bool {
+ return self.inventory_refresh_task != null;
+ }
+
pub fn refreshChatGptSourceInventory(self: *Self, alloc: Allocator) !void {
if (try credentials.sourceExists(alloc, self.secret_store, .chatgpt_subscription)) {
self.source_inventory.insert(.chatgpt_subscription);
@@ -1720,7 +1862,7 @@ pub const Runtime = struct {
return self.reconcileAfterFxLoginLogoutWithDeps(
alloc,
self,
- probeCredentialSource,
+ probeCredentialSourceForLogout,
loadRuntimeCredentialSource,
);
}
@@ -1816,9 +1958,32 @@ test "auth in-place initialization preserves empty runtime state" {
try std.testing.expect(runtime.api_key_input.items.len == 0);
}
-fn probeCredentialSource(raw_context: ?*anyopaque, alloc: Allocator, source: credentials.Source) !bool {
+fn probeCredentialSource(raw_context: ?*anyopaque, _: Allocator, source: credentials.Source) !bool {
+ const self: *Runtime = @ptrCast(@alignCast(raw_context.?));
+ return sourcePresenceAvailable(credentials.sourcePresence(self.secret_store, source), .fail);
+}
+
+fn probeCredentialSourceForLogout(raw_context: ?*anyopaque, _: Allocator, source: credentials.Source) !bool {
const self: *Runtime = @ptrCast(@alignCast(raw_context.?));
- return credentials.sourceExists(alloc, self.secret_store, source);
+ const presence = credentials.sourcePresence(self.secret_store, source);
+ if (presence == .unavailable) {
+ debug_trace.logf("auth", "logout inventory omitted unavailable source={s}", .{@tagName(source)});
+ }
+ return sourcePresenceAvailable(presence, .omit);
+}
+
+fn sourcePresenceAvailable(
+ presence: host.SecretStorePresence,
+ unavailable_policy: UnavailableSourcePolicy,
+) error{CredentialSourceUnavailable}!bool {
+ return switch (presence) {
+ .present => true,
+ .missing => false,
+ .unavailable => switch (unavailable_policy) {
+ .fail => error.CredentialSourceUnavailable,
+ .omit => false,
+ },
+ };
}
fn loadCredentialSource(_: ?*anyopaque, alloc: Allocator, source: credentials.Source) !?credentials.Credential {
@@ -2322,6 +2487,81 @@ test "auth runtime detects only credential sources that exist" {
try std.testing.expect(!inventory.contains(.stored_key));
}
+test "credential inventory treats unavailable sources according to command policy" {
+ try std.testing.expect(try sourcePresenceAvailable(.present, .fail));
+ try std.testing.expect(!try sourcePresenceAvailable(.missing, .fail));
+ try std.testing.expectError(
+ error.CredentialSourceUnavailable,
+ sourcePresenceAvailable(.unavailable, .fail),
+ );
+ try std.testing.expect(!try sourcePresenceAvailable(.unavailable, .omit));
+}
+
+test "auth inventory worker publishes one current action and preserves state on failure" {
+ const Probe = struct {
+ existing: SourceSet,
+ fail: bool = false,
+
+ fn exists(ctx: ?*anyopaque, _: Allocator, source: credentials.Source) !bool {
+ const self: *@This() = @ptrCast(@alignCast(ctx.?));
+ if (self.fail) return error.InjectedInventoryFailure;
+ return self.existing.contains(source);
+ }
+ };
+
+ const alloc = std.testing.allocator;
+ var runtime: Runtime = .{};
+ defer runtime.deinit(alloc);
+ var probe = Probe{
+ .existing = SourceSet.initMany(&.{ .ai_gateway_api_key, .fx_login }),
+ };
+ const action = InventoryRefreshAction{ .provider = .codex };
+ try std.testing.expectEqual(
+ InventoryRefreshStart.started,
+ runtime.beginSourceInventoryRefreshWithDeps(alloc, action, .{
+ .ctx = &probe,
+ .probe = Probe.exists,
+ }),
+ );
+ var first: ?InventoryRefreshResult = null;
+ for (0..100_000) |_| {
+ first = runtime.takeSourceInventoryRefresh();
+ if (first != null) break;
+ std.Thread.yield() catch std.atomic.spinLoopHint();
+ }
+ try std.testing.expect(first != null);
+ switch (first.?) {
+ .ready => |ready| try std.testing.expectEqual(
+ model_provider.ProviderId.codex,
+ ready.provider,
+ ),
+ .failed => return error.UnexpectedInventoryFailure,
+ }
+ try std.testing.expect(runtime.source_inventory.contains(.fx_login));
+
+ const preserved = runtime.source_inventory;
+ probe.fail = true;
+ try std.testing.expectEqual(
+ InventoryRefreshStart.started,
+ runtime.beginSourceInventoryRefreshWithDeps(alloc, action, .{
+ .ctx = &probe,
+ .probe = Probe.exists,
+ }),
+ );
+ var second: ?InventoryRefreshResult = null;
+ for (0..100_000) |_| {
+ second = runtime.takeSourceInventoryRefresh();
+ if (second != null) break;
+ std.Thread.yield() catch std.atomic.spinLoopHint();
+ }
+ try std.testing.expect(second != null);
+ switch (second.?) {
+ .failed => {},
+ .ready => return error.ExpectedInventoryFailure,
+ }
+ try std.testing.expectEqual(preserved, runtime.source_inventory);
+}
+
test "auth runtime owns onboarding skip state" {
var runtime: Runtime = .{};
diff --git a/src/core/auth/chatgpt_session.zig b/src/core/auth/chatgpt_session.zig
index 6c9ed7249..5f9d2fb12 100644
--- a/src/core/auth/chatgpt_session.zig
+++ b/src/core/auth/chatgpt_session.zig
@@ -1,9 +1,11 @@
const std = @import("std");
const debug_trace = @import("../shared/debug_trace.zig");
const host_target = @import("../hosts/target.zig");
+const host = @import("../hosts/host.zig");
const io_mod = @import("../shared/io.zig");
const profile_paths = @import("../shared/profile_paths.zig");
const secret = @import("secret.zig");
+const session_presence = @import("session_presence.zig");
const Allocator = std.mem.Allocator;
const schema_version: i64 = 1;
@@ -15,6 +17,10 @@ const mutation_lock_deadline_ms: u64 = 2000;
pub const issuer = "https://auth.openai.com";
pub const auth_file_name = profile_paths.chatgpt_auth_file_name;
+pub fn presence() host.SecretStorePresence {
+ return session_presence.profileFile(auth_file_name, max_auth_file_bytes);
+}
+
pub fn refreshDeadlineMs(expires_at_ms: i64) i64 {
return @max(expires_at_ms - expiry_skew_ms, 0);
}
diff --git a/src/core/auth/credentials.zig b/src/core/auth/credentials.zig
index 563a11c04..514520904 100644
--- a/src/core/auth/credentials.zig
+++ b/src/core/auth/credentials.zig
@@ -1,7 +1,9 @@
const std = @import("std");
const builtin = @import("builtin");
const chatgpt_oauth = @import("chatgpt_oauth.zig");
+const chatgpt_session = @import("chatgpt_session.zig");
const grok_oauth = @import("grok_oauth.zig");
+const grok_session = @import("grok_session.zig");
const debug_trace = @import("../shared/debug_trace.zig");
const host = @import("../hosts/host.zig");
const io_mod = @import("../shared/io.zig");
@@ -9,6 +11,7 @@ const model_provider = @import("../config/model_provider.zig");
const oauth = @import("oauth.zig");
const oauth_session = @import("oauth_session.zig");
const oauth_transport = @import("oauth_transport.zig");
+const profile_paths = @import("../shared/profile_paths.zig");
const secret = @import("secret.zig");
const types = @import("../shared/types.zig");
@@ -438,20 +441,45 @@ pub fn sourceExists(
.grok_subscription => grok_oauth.sourceExists(alloc),
.stored_key => blk: {
if (secret_store.isDisabled()) break :blk false;
- const stored = secret_store.load(alloc) catch |err| switch (err) {
- error.OutOfMemory => return err,
- else => {
- debug_trace.logf("auth", "source probe failed source=stored_key err={s}", .{@errorName(err)});
- break :blk false;
+ break :blk switch (secret_store.presence()) {
+ .present => true,
+ .missing => false,
+ .unavailable => {
+ debug_trace.logf(
+ "auth",
+ "source probe failed source=stored_key err=StoredKeyUnreadable",
+ .{},
+ );
+ return error.StoredKeyUnreadable;
},
};
- const value = stored orelse break :blk false;
- secret.zeroAndFree(alloc, value);
- break :blk true;
},
};
}
+pub fn sourcePresence(
+ secret_store: host.SecretStore,
+ source: Source,
+) host.SecretStorePresence {
+ return switch (source) {
+ .vercel_oidc_token => if (nonEmptyEnvValue("VERCEL_OIDC_TOKEN") != null)
+ .present
+ else
+ .missing,
+ .ai_gateway_api_key => if (nonEmptyEnvValue("AI_GATEWAY_API_KEY") != null)
+ .present
+ else
+ .missing,
+ .fx_login => oauth_session.presence(),
+ .stored_key => if (secret_store.isDisabled())
+ .missing
+ else
+ secret_store.presence(),
+ .chatgpt_subscription => chatgpt_session.presence(),
+ .grok_subscription => grok_session.presence(),
+ };
+}
+
fn loadEnvCredential(
alloc: std.mem.Allocator,
name: []const u8,
@@ -855,12 +883,14 @@ const SecretStoreFixture = struct {
disabled: bool = false,
unreadable: bool = false,
load_calls: usize = 0,
+ presence_calls: usize = 0,
fn provider(self: *@This()) host.SecretStore {
return .{
.context = self,
.backend_label = "test credential store",
.is_disabled_fn = isDisabled,
+ .presence_fn = presence,
.load_fn = load,
.store_fn = store,
.store_interactive_fn = storeInteractive,
@@ -872,6 +902,13 @@ const SecretStoreFixture = struct {
return self.disabled;
}
+ fn presence(raw_context: ?*anyopaque) host.SecretStorePresence {
+ const self: *@This() = @ptrCast(@alignCast(raw_context.?));
+ self.presence_calls += 1;
+ if (self.unreadable) return .unavailable;
+ return if (self.value == null) .missing else .present;
+ }
+
fn load(
raw_context: ?*anyopaque,
alloc: std.mem.Allocator,
@@ -1025,6 +1062,68 @@ test "credential resolution loads a stored key only through the injected host po
try std.testing.expectEqualStrings("injected-test-value", resolution.credential.?.token);
}
+test "stored key existence never loads secret bytes" {
+ const alloc = std.testing.allocator;
+ const env = try CredentialTestEnv.install(alloc, &.{});
+ defer env.deinit();
+ var store_fixture = SecretStoreFixture{ .value = "presence-only-secret" };
+
+ try std.testing.expect(try sourceExists(
+ alloc,
+ store_fixture.provider(),
+ .stored_key,
+ ));
+ try std.testing.expectEqual(@as(usize, 0), store_fixture.load_calls);
+ try std.testing.expectEqual(@as(usize, 1), store_fixture.presence_calls);
+}
+
+test "credential source presence reads metadata without parsing session secrets" {
+ const alloc = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.createDirPath(io_mod.getIo(), ".fx");
+ const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "");
+ defer alloc.free(home);
+ const env = try CredentialTestEnv.install(alloc, &.{.{ "HOME", home }});
+ defer env.deinit();
+
+ const cases = [_]struct {
+ source: Source,
+ file_name: []const u8,
+ }{
+ .{ .source = .fx_login, .file_name = profile_paths.auth_file_name },
+ .{ .source = .chatgpt_subscription, .file_name = profile_paths.chatgpt_auth_file_name },
+ .{ .source = .grok_subscription, .file_name = profile_paths.grok_auth_file_name },
+ };
+ for (cases) |case| {
+ var path_buffer: [std.fs.max_path_bytes]u8 = undefined;
+ const relative_path = try std.fmt.bufPrint(
+ &path_buffer,
+ ".fx/{s}",
+ .{case.file_name},
+ );
+ var file = try tmp.dir.createFile(io_mod.getIo(), relative_path, .{
+ .truncate = true,
+ .permissions = std.Io.File.Permissions.fromMode(0o600),
+ });
+ defer file.close(io_mod.getIo());
+ try file.writeStreamingAll(io_mod.getIo(), "not valid session JSON");
+
+ try std.testing.expectEqual(
+ host.SecretStorePresence.present,
+ sourcePresence(host.unavailable_secret_store, case.source),
+ );
+ try file.setPermissions(
+ io_mod.getIo(),
+ std.Io.File.Permissions.fromMode(0o644),
+ );
+ try std.testing.expectEqual(
+ host.SecretStorePresence.unavailable,
+ sourcePresence(host.unavailable_secret_store, case.source),
+ );
+ }
+}
+
test "credential resolution preserves unreadable store classification" {
const alloc = std.testing.allocator;
const env = try CredentialTestEnv.install(alloc, &.{});
diff --git a/src/core/auth/grok_session.zig b/src/core/auth/grok_session.zig
index ac362338b..9600ce581 100644
--- a/src/core/auth/grok_session.zig
+++ b/src/core/auth/grok_session.zig
@@ -1,9 +1,11 @@
const std = @import("std");
const debug_trace = @import("../shared/debug_trace.zig");
const host_target = @import("../hosts/target.zig");
+const host = @import("../hosts/host.zig");
const io_mod = @import("../shared/io.zig");
const profile_paths = @import("../shared/profile_paths.zig");
const secret = @import("secret.zig");
+const session_presence = @import("session_presence.zig");
const Allocator = std.mem.Allocator;
const schema_version: i64 = 1;
@@ -15,6 +17,10 @@ const max_account_id_bytes: usize = 1024;
const auth_file_name = profile_paths.grok_auth_file_name;
+pub fn presence() host.SecretStorePresence {
+ return session_presence.profileFile(auth_file_name, max_auth_file_bytes);
+}
+
pub fn refreshDeadlineMs(expires_at_ms: i64) i64 {
return @max(expires_at_ms - expiry_skew_ms, 0);
}
diff --git a/src/core/auth/oauth_session.zig b/src/core/auth/oauth_session.zig
index cc052877c..80e9e9609 100644
--- a/src/core/auth/oauth_session.zig
+++ b/src/core/auth/oauth_session.zig
@@ -1,12 +1,14 @@
const std = @import("std");
const builtin = @import("builtin");
const debug_trace = @import("../shared/debug_trace.zig");
+const host_contract = @import("../hosts/host.zig");
const host_target = @import("../hosts/target.zig");
const native_keychain = @import("../hosts/native_keychain.zig");
const io_mod = @import("../shared/io.zig");
const profile_paths = @import("../shared/profile_paths.zig");
const js_host_auth = @import("js_host_auth.zig");
const secret = @import("secret.zig");
+const session_presence = @import("session_presence.zig");
const Allocator = std.mem.Allocator;
@@ -91,6 +93,23 @@ fn storageBackend() StorageBackend {
return selectStorageBackend(builtin.os.tag, native_keychain.isDisabled());
}
+pub fn presence() host_contract.SecretStorePresence {
+ const file_presence = session_presence.profileFile(
+ auth_file_name,
+ max_auth_file_bytes,
+ );
+ if (storageBackend() == .profile_file or file_presence == .present) {
+ return file_presence;
+ }
+ const keychain_presence = native_keychain.oauthSessionPresence() catch
+ return .unavailable;
+ if (keychain_presence == .present) return .present;
+ if (file_presence == .missing and keychain_presence == .missing) {
+ return .missing;
+ }
+ return .unavailable;
+}
+
fn selectResolution(file: FileState, keychain: KeychainState) Resolution {
return switch (file) {
.valid => if (keychain == .unavailable) .file_defer_migration else .file_migrate,
diff --git a/src/core/auth/session_presence.zig b/src/core/auth/session_presence.zig
new file mode 100644
index 000000000..14ab661d1
--- /dev/null
+++ b/src/core/auth/session_presence.zig
@@ -0,0 +1,60 @@
+const std = @import("std");
+const host = @import("../hosts/host.zig");
+const host_target = @import("../hosts/target.zig");
+const io_mod = @import("../shared/io.zig");
+const profile_paths = @import("../shared/profile_paths.zig");
+
+pub fn profileFile(
+ file_name: []const u8,
+ max_bytes: usize,
+) host.SecretStorePresence {
+ if (comptime host_target.is_wasm) return .missing;
+ return profileFileFromHome(io_mod.getenv("HOME"), file_name, max_bytes);
+}
+
+fn profileFileFromHome(
+ home_value: ?[]const u8,
+ file_name: []const u8,
+ max_bytes: usize,
+) host.SecretStorePresence {
+ const home = home_value orelse return .unavailable;
+ var home_dir = std.Io.Dir.openDirAbsolute(
+ io_mod.getIo(),
+ home,
+ .{ .iterate = true },
+ ) catch |err| return if (err == error.FileNotFound) .missing else .unavailable;
+ defer home_dir.close(io_mod.getIo());
+
+ var profile_dir = home_dir.openDir(
+ io_mod.getIo(),
+ profile_paths.root_dir_name,
+ .{ .iterate = true, .follow_symlinks = false },
+ ) catch |err| return if (err == error.FileNotFound) .missing else .unavailable;
+ defer profile_dir.close(io_mod.getIo());
+
+ var file = profile_dir.openFile(io_mod.getIo(), file_name, .{
+ .mode = .read_only,
+ .allow_directory = false,
+ .follow_symlinks = false,
+ .resolve_beneath = true,
+ }) catch |err| return if (err == error.FileNotFound) .missing else .unavailable;
+ defer file.close(io_mod.getIo());
+
+ const stat = file.stat(io_mod.getIo()) catch return .unavailable;
+ if (stat.kind != .file or
+ stat.nlink != 1 or
+ stat.permissions.toMode() & 0o077 != 0 or
+ stat.size == 0 or
+ stat.size > max_bytes)
+ {
+ return .unavailable;
+ }
+ return .present;
+}
+
+test "missing native profile root is unavailable" {
+ try std.testing.expectEqual(
+ host.SecretStorePresence.unavailable,
+ profileFileFromHome(null, "auth.json", 1024),
+ );
+}
diff --git a/src/core/hosts/host.zig b/src/core/hosts/host.zig
index 10b3eeb4d..dc4f4e247 100644
--- a/src/core/hosts/host.zig
+++ b/src/core/hosts/host.zig
@@ -87,10 +87,17 @@ pub const SecretStoreWriteError = std.mem.Allocator.Error || error{
StoredKeyWriteFailed,
};
+pub const SecretStorePresence = enum {
+ present,
+ missing,
+ unavailable,
+};
+
pub const SecretStore = struct {
context: ?*anyopaque = null,
backend_label: []const u8,
is_disabled_fn: *const fn (?*anyopaque) bool,
+ presence_fn: *const fn (?*anyopaque) SecretStorePresence = unavailableSecretStorePresence,
load_fn: *const fn (
?*anyopaque,
std.mem.Allocator,
@@ -108,6 +115,12 @@ pub const SecretStore = struct {
return self.is_disabled_fn(self.context);
}
+ /// Reports only whether a secret exists. No secret bytes are returned or
+ /// transferred across this host boundary.
+ pub fn presence(self: SecretStore) SecretStorePresence {
+ return self.presence_fn(self.context);
+ }
+
/// Returns an owned secret, or null when none is stored. The caller must
/// zero and free a returned secret with the allocator passed to this call.
pub fn load(
@@ -138,6 +151,7 @@ pub const SecretStore = struct {
pub const unavailable_secret_store: SecretStore = .{
.backend_label = "configured credential store",
.is_disabled_fn = unavailableSecretStoreIsDisabled,
+ .presence_fn = missingSecretStorePresence,
.load_fn = unavailableSecretStoreLoad,
.store_fn = unavailableSecretStoreWrite,
.store_interactive_fn = unavailableSecretStoreInteractiveWrite,
@@ -147,6 +161,14 @@ fn unavailableSecretStoreIsDisabled(_: ?*anyopaque) bool {
return false;
}
+fn unavailableSecretStorePresence(_: ?*anyopaque) SecretStorePresence {
+ return .unavailable;
+}
+
+fn missingSecretStorePresence(_: ?*anyopaque) SecretStorePresence {
+ return .missing;
+}
+
fn unavailableSecretStoreLoad(
_: ?*anyopaque,
_: std.mem.Allocator,
diff --git a/src/core/hosts/native_keychain.zig b/src/core/hosts/native_keychain.zig
index e894dccbc..bdb6c1a6e 100644
--- a/src/core/hosts/native_keychain.zig
+++ b/src/core/hosts/native_keychain.zig
@@ -1,9 +1,25 @@
const std = @import("std");
const builtin = @import("builtin");
const debug_trace = @import("../shared/debug_trace.zig");
+const host = @import("host.zig");
const io_mod = @import("../shared/io.zig");
const secret = @import("../auth/secret.zig");
+const err_sec_success: i32 = 0;
+const err_sec_item_not_found: i32 = -25300;
+const security_framework_path = "/System/Library/Frameworks/Security.framework/Security";
+
+const FindGenericPasswordFn = *const fn (
+ keychain_or_array: ?*const anyopaque,
+ service_name_length: u32,
+ service_name: [*]const u8,
+ account_name_length: u32,
+ account_name: [*]const u8,
+ password_length: ?*u32,
+ password_data: ?*?*anyopaque,
+ item_ref: ?*?*anyopaque,
+) callconv(.c) i32;
+
pub const service_name = "FX_AI_GATEWAY_API_KEY";
const mcp_credentials_service_name = "FX_MCP_OAUTH_CREDENTIALS_V1";
pub const oauth_session_service_name = "FX_OAUTH_SESSION_V1";
@@ -134,6 +150,52 @@ pub fn load(alloc: std.mem.Allocator) !?[]u8 {
return loadFromService(alloc, service_name);
}
+/// Checks Keychain metadata only. It never asks Security.framework for the
+/// secret value and never spawns the `security` command-line tool.
+pub fn contains() Error!host.SecretStorePresence {
+ return containsService(service_name);
+}
+
+pub fn oauthSessionPresence() Error!host.SecretStorePresence {
+ return containsService(oauth_session_service_name);
+}
+
+fn containsService(service: []const u8) Error!host.SecretStorePresence {
+ if (comptime builtin.os.tag != .macos) return .missing;
+ var account_buf: AccountBuffer = undefined;
+ const account = try accountName(&account_buf);
+ const service_len = std.math.cast(u32, service.len) orelse
+ return error.KeychainReadFailed;
+ const account_len = std.math.cast(u32, account.len) orelse
+ return error.KeychainReadFailed;
+ var security = std.DynLib.open(security_framework_path) catch |err| {
+ debug_trace.logf("keychain", "presence failed step=open err={s}", .{@errorName(err)});
+ return error.KeychainReadFailed;
+ };
+ defer security.close();
+ const find_generic_password = security.lookup(
+ FindGenericPasswordFn,
+ "SecKeychainFindGenericPassword",
+ ) orelse {
+ debug_trace.logf("keychain", "presence failed step=lookup", .{});
+ return error.KeychainReadFailed;
+ };
+ const status = find_generic_password(
+ null,
+ service_len,
+ service.ptr,
+ account_len,
+ account.ptr,
+ null,
+ null,
+ null,
+ );
+ if (status == err_sec_success) return .present;
+ if (status == err_sec_item_not_found) return .missing;
+ debug_trace.logf("keychain", "presence failed status={d}", .{status});
+ return error.KeychainReadFailed;
+}
+
pub fn loadMcpCredentials(alloc: std.mem.Allocator) !?[]u8 {
return loadMcpValueMacControlled(alloc, mcp_credentials_service_name, null);
}
diff --git a/src/core/hosts/native_secret_store.zig b/src/core/hosts/native_secret_store.zig
index d8b8719fe..067f3899c 100644
--- a/src/core/hosts/native_secret_store.zig
+++ b/src/core/hosts/native_secret_store.zig
@@ -21,6 +21,7 @@ const StoreError = host.SecretStoreWriteError;
pub const provider: host.SecretStore = .{
.backend_label = backend_label,
.is_disabled_fn = isDisabledCallback,
+ .presence_fn = presenceCallback,
.load_fn = loadCallback,
.store_fn = storeCallback,
.store_interactive_fn = storeInteractiveCallback,
@@ -61,6 +62,30 @@ fn isDisabledCallback(_: ?*anyopaque) bool {
return isDisabled();
}
+fn presenceCallback(_: ?*anyopaque) host.SecretStorePresence {
+ if (isDisabled()) return .missing;
+ if (comptime builtin.os.tag == .macos) {
+ return keychain.contains() catch .unavailable;
+ }
+ return presenceInProfile();
+}
+
+fn presenceInProfile() host.SecretStorePresence {
+ const home = io_mod.getenv("HOME") orelse return .unavailable;
+ var home_dir = std.Io.Dir.openDirAbsolute(io_mod.getIo(), home, .{}) catch
+ return .unavailable;
+ defer home_dir.close(io_mod.getIo());
+ var fx_dir = home_dir.openDir(io_mod.getIo(), profile_paths.root_dir_name, .{
+ .follow_symlinks = false,
+ }) catch |err| return if (err == error.FileNotFound) .missing else .unavailable;
+ defer fx_dir.close(io_mod.getIo());
+ const stat = fx_dir.statFile(io_mod.getIo(), profile_paths.api_key_file_name, .{
+ .follow_symlinks = false,
+ }) catch |err| return if (err == error.FileNotFound) .missing else .unavailable;
+ if (stat.kind != .file or stat.permissions.toMode() & 0o077 != 0) return .unavailable;
+ return if (stat.size == 0) .missing else .present;
+}
+
fn loadCallback(_: ?*anyopaque, alloc: Allocator) LoadError!?[]u8 {
return load(alloc);
}
diff --git a/src/core/output/full_transcript_page.zig b/src/core/output/full_transcript_page.zig
index 9ea433015..2bd0a065b 100644
--- a/src/core/output/full_transcript_page.zig
+++ b/src/core/output/full_transcript_page.zig
@@ -1,7 +1,6 @@
const std = @import("std");
pub const max_source_entries: usize = 256;
-pub const live_refresh_revision_stride: u64 = 8;
pub const Anchor = union(enum) {
tail,
@@ -49,10 +48,6 @@ pub fn sameSurface(lhs: Request, rhs: Request) bool {
return lhs.cols == rhs.cols and std.meta.eql(lhs.anchor, rhs.anchor);
}
-pub fn liveRefreshDue(installed_revision: u64, current_revision: u64) bool {
- return current_revision -% installed_revision >= live_refresh_revision_stride;
-}
-
pub fn previousAnchor(range: SourceRange) ?Anchor {
if (range.start == 0) return null;
return .{ .entry_index = range.start - 1 };
@@ -138,12 +133,6 @@ test "full transcript page surface ignores revisions but not width or anchor" {
try std.testing.expect(!sameSurface(original, changed));
}
-test "live full transcript refresh is bounded by revision stride" {
- try std.testing.expect(!liveRefreshDue(40, 47));
- try std.testing.expect(liveRefreshDue(40, 48));
- try std.testing.expect(liveRefreshDue(std.math.maxInt(u64) - 3, 4));
-}
-
test "full transcript page navigation stops at document boundaries" {
const previous = previousAnchor(.{ .start = 256, .end = 512 });
try std.testing.expect(previous != null);
diff --git a/src/core/output/transcript_presentation.zig b/src/core/output/transcript_presentation.zig
index 6ff8bf899..6ba68f106 100644
--- a/src/core/output/transcript_presentation.zig
+++ b/src/core/output/transcript_presentation.zig
@@ -121,6 +121,15 @@ pub const State = struct {
return next;
}
+ pub fn defer_full_open(self: State) State {
+ var next = self;
+ next.bookmark_pending = !next.follow_tail;
+ next.bookmark_entry_id = null;
+ next.bookmark_intra_row = 0;
+ next.depth = .inline_mode;
+ return next;
+ }
+
pub fn scroll(self: State, direction: ScrollDirection, rows: u32) State {
var next = self;
next.follow_tail = false;
@@ -199,7 +208,7 @@ pub const State = struct {
}
fn open_full(self: State) State {
- var next = self.reset_viewport();
+ var next = if (self.bookmark_pending) self else self.reset_viewport();
next.depth = .full;
// The identity remains available for retention retargeting, but a
// normal open starts at the tail instead of consuming the old anchor.
@@ -305,6 +314,27 @@ test "transcript presentation scroll saturates and leaves follow tail" {
try std.testing.expectEqual(std.math.maxInt(u32), at_end.scroll_rows);
}
+test "transcript presentation deferred full open retains its exact offset" {
+ const deferred = (State{
+ .depth = .full,
+ .scroll_rows = 47,
+ .follow_tail = false,
+ .bookmark_entry_id = 2,
+ .bookmark_intra_row = 7,
+ }).defer_full_open();
+ try std.testing.expectEqual(Depth.inline_mode, deferred.depth);
+ try std.testing.expect(deferred.bookmark_pending);
+ try std.testing.expectEqual(@as(?u32, null), deferred.bookmark_entry_id);
+
+ const reopened = deferred.with_depth(.full).select_visual_offset(
+ 100,
+ 10,
+ &.{},
+ );
+ try std.testing.expectEqual(@as(u32, 47), reopened.offset);
+ try std.testing.expect(!reopened.state.follow_tail);
+}
+
test "transcript presentation clamps bookmarks and selects retained neighbor" {
const item_rows = [_]ItemRow{
.{ .entry_id = 10, .row = 1 },
diff --git a/src/core/skills/skill_runtime.zig b/src/core/skills/skill_runtime.zig
index eed786a44..c6405983f 100644
--- a/src/core/skills/skill_runtime.zig
+++ b/src/core/skills/skill_runtime.zig
@@ -23,6 +23,9 @@ pub const Skill = struct {
source: SkillSource,
/// Owned with discovered catalog entries. Null keeps managed skills strict.
read_authority: ?[]const u8 = null,
+ metadata_inode: std.Io.File.INode = 0,
+ metadata_size: u64 = 0,
+ metadata_mtime: std.Io.Timestamp = .zero,
};
pub const BoundedPromptSection = struct {
@@ -294,6 +297,36 @@ const SkillEntry = struct {
linked: bool,
};
+fn freeSkillEntries(alloc: Allocator, entries: *std.ArrayList(SkillEntry)) void {
+ for (entries.items) |entry| alloc.free(entry.name);
+ entries.deinit(alloc);
+}
+
+fn collectSkillEntries(
+ alloc: Allocator,
+ dir: *std.Io.Dir,
+ allow_linked: bool,
+) !std.ArrayList(SkillEntry) {
+ var entries: std.ArrayList(SkillEntry) = .empty;
+ errdefer freeSkillEntries(alloc, &entries);
+ var it = dir.iterate();
+ while (try it.next(io_mod.getIo())) |entry| {
+ const linked = entry.kind == .sym_link;
+ if (entry.kind != .directory and !(linked and allow_linked)) continue;
+ const name = try alloc.dupe(u8, entry.name);
+ entries.append(alloc, .{ .name = name, .linked = linked }) catch |err| {
+ alloc.free(name);
+ return err;
+ };
+ }
+ sort_utils.sort(SkillEntry, entries.items, {}, struct {
+ fn lessThan(_: void, left: SkillEntry, right: SkillEntry) bool {
+ return std.mem.order(u8, left.name, right.name) == .lt;
+ }
+ }.lessThan);
+ return entries;
+}
+
/// Deduplicates filesystem aliases without collapsing distinct skills that
/// share metadata names. Ordered discovery makes the first logical root the
/// stable source and display path for each canonical candidate directory.
@@ -348,19 +381,14 @@ pub fn loadVisibleSkills(
roots.deinit(alloc);
}
- if (workspace_root) |root| {
- try appendWorkspaceRoots(alloc, &roots, root, home, root_policy.workspace_roots);
- }
-
- if (root_policy.managed_root_source) |source| {
- try appendDupeRoot(alloc, &roots, source, skills_dir);
- }
-
- if (home) |home_root| {
- for (root_policy.global_roots) |spec| {
- try appendSpecRoot(alloc, &roots, home_root, spec);
- }
- }
+ try appendConfiguredSkillRoots(
+ alloc,
+ &roots,
+ workspace_root,
+ home,
+ skills_dir,
+ root_policy,
+ );
for (roots.items) |root| {
try appendSkillsFromDir(alloc, &skills, &diagnostics, &canonical_skill_paths, root);
@@ -375,6 +403,133 @@ pub fn loadVisibleSkills(
};
}
+fn appendConfiguredSkillRoots(
+ alloc: Allocator,
+ roots: *std.ArrayList(SkillRoot),
+ workspace_root: ?[]const u8,
+ home: ?[]const u8,
+ skills_dir: []const u8,
+ root_policy: skill_contract.RootPolicy,
+) !void {
+ if (workspace_root) |root| {
+ try appendWorkspaceRoots(
+ alloc,
+ roots,
+ root,
+ home,
+ root_policy.workspace_roots,
+ );
+ }
+ if (root_policy.managed_root_source) |source| {
+ try appendDupeRoot(alloc, roots, source, skills_dir);
+ }
+ if (home) |home_root| {
+ for (root_policy.global_roots) |spec| {
+ try appendSpecRoot(alloc, roots, home_root, spec);
+ }
+ }
+}
+
+fn collectRootFingerprints(
+ alloc: Allocator,
+ workspace_root: ?[]const u8,
+ home: ?[]const u8,
+ skills_dir: []const u8,
+ root_policy: skill_contract.RootPolicy,
+) ![]RootFingerprint {
+ var roots: std.ArrayList(SkillRoot) = .empty;
+ defer {
+ for (roots.items) |root| alloc.free(root.path);
+ roots.deinit(alloc);
+ }
+ try appendConfiguredSkillRoots(
+ alloc,
+ &roots,
+ workspace_root,
+ home,
+ skills_dir,
+ root_policy,
+ );
+ const fingerprints = try alloc.alloc(RootFingerprint, roots.items.len);
+ var filled: usize = 0;
+ errdefer {
+ for (fingerprints[0..filled]) |*root| root.deinit(alloc);
+ if (fingerprints.len > 0) alloc.free(fingerprints);
+ }
+ while (filled < roots.items.len) : (filled += 1) {
+ const root = roots.items[filled];
+ const path = try alloc.dupe(u8, root.path);
+ errdefer alloc.free(path);
+ const stat = std.Io.Dir.cwd().statFile(
+ io_mod.getIo(),
+ root.path,
+ .{ .follow_symlinks = false },
+ ) catch |err| {
+ if (err == error.FileNotFound or err == error.NotDir) {
+ fingerprints[filled] = .{ .path = path, .exists = false };
+ continue;
+ }
+ return err;
+ };
+ const candidate_digest = if (stat.kind == .directory)
+ try candidateDirectoryDigest(alloc, root)
+ else
+ [_]u8{0} ** std.crypto.hash.sha2.Sha256.digest_length;
+ fingerprints[filled] = .{
+ .path = path,
+ .exists = true,
+ .inode = stat.inode,
+ .mtime = stat.mtime,
+ .candidate_digest = candidate_digest,
+ };
+ }
+ return fingerprints;
+}
+
+fn candidateDirectoryDigest(
+ alloc: Allocator,
+ root: SkillRoot,
+) ![std.crypto.hash.sha2.Sha256.digest_length]u8 {
+ var dir = try openSkillRoot(alloc, root, .{ .iterate = true });
+ defer dir.close(io_mod.getIo());
+ var entries = try collectSkillEntries(alloc, &dir, root.read_authority != null);
+ defer freeSkillEntries(alloc, &entries);
+
+ var hash = std.crypto.hash.sha2.Sha256.init(.{});
+ for (entries.items) |entry| {
+ hash.update(entry.name);
+ hash.update(if (entry.linked) "\x01" else "\x00");
+ const stat = if (entry.linked) linked: {
+ const candidate_path = try std.fs.path.join(alloc, &.{ root.path, entry.name });
+ defer alloc.free(candidate_path);
+ var candidate_dir = openContainedDir(
+ alloc,
+ candidate_path,
+ root.read_authority.?,
+ .{},
+ ) catch {
+ hash.update("unavailable");
+ continue;
+ };
+ defer candidate_dir.close(io_mod.getIo());
+ break :linked candidate_dir.stat(io_mod.getIo()) catch {
+ hash.update("unavailable");
+ continue;
+ };
+ } else dir.statFile(
+ io_mod.getIo(),
+ entry.name,
+ .{ .follow_symlinks = false },
+ ) catch {
+ hash.update("unavailable");
+ continue;
+ };
+ hash.update(std.mem.asBytes(&stat.inode));
+ hash.update(std.mem.asBytes(&stat.mtime.nanoseconds));
+ }
+ return hash.finalResult();
+}
+
fn appendWorkspaceRoots(
alloc: Allocator,
roots: *std.ArrayList(SkillRoot),
@@ -519,10 +674,7 @@ fn appendSkillsFromDir(
canonical_skill_paths: *CanonicalSkillPaths,
root: SkillRoot,
) !void {
- var dir = (if (root.read_authority) |read_authority|
- openContainedDir(alloc, root.path, read_authority, .{ .iterate = true })
- else
- io_mod.openDirAbsoluteNoFollow(root.path, .{ .iterate = true })) catch |err| {
+ var dir = openSkillRoot(alloc, root, .{ .iterate = true }) catch |err| {
if ((err == error.FileNotFound or err == error.NotDir) and rootPathIsMissing(root.path)) return;
if (err == error.OutOfMemory) return error.OutOfMemory;
if (diagnostics) |items| try appendSkillDiagnostic(alloc, items, root.path, root.source, .root, .unreadable);
@@ -530,40 +682,29 @@ fn appendSkillsFromDir(
};
defer dir.close(io_mod.getIo());
- var entries: std.ArrayList(SkillEntry) = .empty;
- defer {
- for (entries.items) |entry| alloc.free(entry.name);
- entries.deinit(alloc);
- }
-
- var it = dir.iterate();
- while (true) {
- const entry = it.next(io_mod.getIo()) catch |err| {
- if (err == error.OutOfMemory) return error.OutOfMemory;
- if (diagnostics) |items| try appendSkillDiagnostic(alloc, items, root.path, root.source, .root, .unreadable);
- return;
- } orelse break;
- const linked = entry.kind == .sym_link;
- if (entry.kind != .directory and !(linked and root.read_authority != null)) continue;
-
- const owned_name = try alloc.dupe(u8, entry.name);
- entries.append(alloc, .{ .name = owned_name, .linked = linked }) catch |err| {
- alloc.free(owned_name);
- return err;
- };
- }
-
- sort_utils.sort(SkillEntry, entries.items, {}, struct {
- fn lessThan(_: void, left: SkillEntry, right: SkillEntry) bool {
- return std.mem.order(u8, left.name, right.name) == .lt;
- }
- }.lessThan);
+ var entries = collectSkillEntries(alloc, &dir, root.read_authority != null) catch |err| {
+ if (err == error.OutOfMemory) return error.OutOfMemory;
+ if (diagnostics) |items| try appendSkillDiagnostic(alloc, items, root.path, root.source, .root, .unreadable);
+ return;
+ };
+ defer freeSkillEntries(alloc, &entries);
for (entries.items) |entry| {
try appendSkillCandidate(alloc, skills, diagnostics, canonical_skill_paths, root, &dir, entry.name, entry.linked);
}
}
+fn openSkillRoot(
+ alloc: Allocator,
+ root: SkillRoot,
+ options: std.Io.Dir.OpenOptions,
+) !std.Io.Dir {
+ return if (root.read_authority) |authority|
+ openContainedDir(alloc, root.path, authority, options)
+ else
+ io_mod.openDirAbsoluteNoFollow(root.path, options);
+}
+
fn rootPathIsMissing(path: []const u8) bool {
if (!std.fs.path.isAbsolute(path)) return false;
var components = std.fs.path.componentIterator(path);
@@ -704,6 +845,20 @@ fn appendSkillCandidate(
},
};
defer file.close(io_mod.getIo());
+ const file_stat = file.stat(io_mod.getIo()) catch |err| {
+ if (err == error.OutOfMemory) return error.OutOfMemory;
+ if (diagnostics) |items| {
+ try appendSkillDiagnostic(
+ alloc,
+ items,
+ candidate_path,
+ root.source,
+ .candidate,
+ .unreadable,
+ );
+ }
+ return;
+ };
if (!try canonical_skill_paths.remember(alloc, candidate_path)) return;
@@ -746,6 +901,9 @@ fn appendSkillCandidate(
.path = path,
.source = root.source,
.read_authority = read_authority,
+ .metadata_inode = file_stat.inode,
+ .metadata_size = file_stat.size,
+ .metadata_mtime = file_stat.mtime,
});
}
@@ -1093,6 +1251,59 @@ const SkillMenuViewEntry = struct {
actual_index: usize,
};
+/// Owns one materialized menu query. Rebuilding mutates only this private
+/// buffer; consumers borrow it until the next rebuild or deinit.
+pub const SkillMenuIndex = struct {
+ actual_indices: std.ArrayList(u32) = .empty,
+
+ pub fn deinit(self: *SkillMenuIndex, alloc: Allocator) void {
+ self.actual_indices.deinit(alloc);
+ self.* = .{};
+ }
+
+ pub fn rebuild(
+ self: *SkillMenuIndex,
+ alloc: Allocator,
+ skills: []const Skill,
+ filter: SkillMenuSourceFilter,
+ query: []const u8,
+ ) Allocator.Error!void {
+ try self.actual_indices.ensureTotalCapacity(alloc, skills.len);
+ self.rebuildAssumeCapacity(skills, filter, query);
+ }
+
+ fn rebuildAssumeCapacity(
+ self: *SkillMenuIndex,
+ skills: []const Skill,
+ filter: SkillMenuSourceFilter,
+ query: []const u8,
+ ) void {
+ std.debug.assert(self.actual_indices.capacity >= skills.len);
+ std.debug.assert(skills.len <= std.math.maxInt(u32));
+ self.actual_indices.clearRetainingCapacity();
+
+ var view = SkillMenuView.init(skills, filter, query);
+ while (view.next()) |entry| {
+ self.actual_indices.appendAssumeCapacity(@intCast(entry.actual_index));
+ }
+ }
+
+ pub fn count(self: *const SkillMenuIndex) usize {
+ return self.actual_indices.items.len;
+ }
+
+ pub fn skillAt(
+ self: *const SkillMenuIndex,
+ skills: []const Skill,
+ display_index: usize,
+ ) ?*const Skill {
+ if (display_index >= self.actual_indices.items.len) return null;
+ const actual_index: usize = self.actual_indices.items[display_index];
+ if (actual_index >= skills.len) return null;
+ return &skills[actual_index];
+ }
+};
+
const SkillMenuView = struct {
skills: []const Skill,
filter: SkillMenuSourceFilter,
@@ -1204,28 +1415,31 @@ pub const SkillMenu = struct {
}
pub fn openWithQuery(self: *SkillMenu, items: []const Skill, origin: SkillMenuOrigin, target: ?SkillMenuTarget, query_text: []const u8) void {
+ self.beginOpen(origin, target, query_text);
+ self.clamp(items);
+ }
+
+ fn beginOpen(self: *SkillMenu, origin: SkillMenuOrigin, target: ?SkillMenuTarget, query_text: []const u8) void {
self.active = true;
self.source_filter = .all;
self.origin = origin;
self.target = target;
self.setQuery(query_text);
- self.clamp(items);
}
pub fn openFocused(self: *SkillMenu, items: []const Skill, filter: SkillMenuSourceFilter, index: usize) void {
+ self.beginOpenFocused(filter, index);
+ self.clamp(items);
+ }
+
+ fn beginOpenFocused(self: *SkillMenu, filter: SkillMenuSourceFilter, index: usize) void {
self.active = true;
self.source_filter = filter;
self.origin = .command;
self.target = null;
self.setQuery("");
- const item_count = self.filteredItemCount(items);
- self.selected_index = if (item_count == 0) 0 else @min(index, item_count - 1);
- self.window_start = list_window.updateEdgeStart(
- self.window_start,
- item_count,
- self.selected_index,
- skill_menu_max_visible_rows,
- );
+ self.selected_index = index;
+ self.window_start = 0;
}
pub fn close(self: *SkillMenu) void {
@@ -1247,7 +1461,10 @@ pub const SkillMenu = struct {
}
pub fn moveVisibleRows(self: *SkillMenu, items: []const Skill, delta: i32, visible_rows: u16) bool {
- const item_count = self.filteredItemCount(items);
+ return self.moveVisibleRowsCount(self.filteredItemCount(items), delta, visible_rows);
+ }
+
+ fn moveVisibleRowsCount(self: *SkillMenu, item_count: usize, delta: i32, visible_rows: u16) bool {
if (!self.active or item_count == 0) return false;
const max_rows: u16 = @max(visible_rows, 1);
// Clamp at both ends instead of wrapping: at the top the selection
@@ -1267,6 +1484,12 @@ pub const SkillMenu = struct {
}
pub fn moveSourceFilter(self: *SkillMenu, items: []const Skill, delta: i32) bool {
+ if (!self.advanceSourceFilter(delta)) return false;
+ self.clamp(items);
+ return true;
+ }
+
+ fn advanceSourceFilter(self: *SkillMenu, delta: i32) bool {
if (!self.active) return false;
const count = skill_menu_source_filters.len;
const current = skillMenuSourceFilterIndex(self.source_filter);
@@ -1276,12 +1499,14 @@ pub const SkillMenu = struct {
self.source_filter = skill_menu_source_filters[@intCast(next)];
self.selected_index = 0;
self.window_start = 0;
- self.clamp(items);
return true;
}
pub fn clamp(self: *SkillMenu, items: []const Skill) void {
- const item_count = self.filteredItemCount(items);
+ self.clampCount(self.filteredItemCount(items));
+ }
+
+ fn clampCount(self: *SkillMenu, item_count: usize) void {
if (item_count == 0) {
self.selected_index = 0;
self.window_start = 0;
@@ -1301,160 +1526,1180 @@ pub const SkillMenu = struct {
}
};
-fn skillMenuSourceFilterIndex(filter: SkillMenuSourceFilter) usize {
- for (skill_menu_source_filters, 0..) |candidate, index| {
- if (candidate == filter) return index;
+const RootFingerprint = struct {
+ path: []u8,
+ exists: bool,
+ inode: std.Io.File.INode = 0,
+ mtime: std.Io.Timestamp = .zero,
+ candidate_digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 =
+ [_]u8{0} ** std.crypto.hash.sha2.Sha256.digest_length,
+
+ fn deinit(self: *RootFingerprint, alloc: Allocator) void {
+ alloc.free(self.path);
+ self.* = undefined;
}
- return 0;
+};
+
+fn freeRootFingerprints(alloc: Allocator, roots: []RootFingerprint) void {
+ for (roots) |*root| root.deinit(alloc);
+ if (roots.len > 0) alloc.free(roots);
}
-pub const Runtime = struct {
+pub const LoadedCatalog = struct {
dir: []u8 = &.{},
- items: []Skill = &.{},
+ skills: []Skill = &.{},
+ skill_backing: ?[]u8 = null,
diagnostics: []SkillDiagnostic = &.{},
- menu: SkillMenu = .{},
-
- pub fn deinit(self: *Runtime, alloc: Allocator) void {
- self.freeLoaded(alloc);
- self.menu.close();
- }
+ root_fingerprints: []RootFingerprint = &.{},
- fn freeLoaded(self: *Runtime, alloc: Allocator) void {
+ pub fn deinit(self: *LoadedCatalog, alloc: Allocator) void {
if (self.dir.len > 0) alloc.free(self.dir);
- freeSkills(alloc, self.items);
+ if (self.skill_backing) |backing| {
+ alloc.free(backing);
+ if (self.skills.len > 0) alloc.free(self.skills);
+ } else {
+ freeSkills(alloc, self.skills);
+ }
freeSkillDiagnostics(alloc, self.diagnostics);
- self.dir = &.{};
- self.items = &.{};
- self.diagnostics = &.{};
- }
-
- pub fn replaceLoaded(self: *Runtime, alloc: Allocator, dir: []u8, skills: []Skill, diagnostics: []SkillDiagnostic) void {
- self.freeLoaded(alloc);
- self.dir = dir;
- self.items = skills;
- self.diagnostics = diagnostics;
- self.menu.clamp(self.items);
- }
-
- pub fn openMenu(self: *Runtime) void {
- self.menu.open(self.items);
+ freeRootFingerprints(alloc, self.root_fingerprints);
+ self.* = .{};
}
+};
- pub fn openMenuWithQuery(self: *Runtime, origin: SkillMenuOrigin, target: ?SkillMenuTarget, query: []const u8) void {
- self.menu.openWithQuery(self.items, origin, target, query);
- }
+const CatalogGeneration = struct {
+ alloc: Allocator,
+ references: std.atomic.Value(usize) = std.atomic.Value(usize).init(1),
+ generation: u64,
+ catalog: LoadedCatalog,
- pub fn openMenuFocusedByName(self: *Runtime, name: []const u8) bool {
- var matched_index: ?usize = null;
- for (self.items, 0..) |skill, actual_index| {
- if (!std.mem.eql(u8, skill.name, name)) continue;
- if (matched_index != null) return false;
- matched_index = actual_index;
- }
- const actual_index = matched_index orelse return false;
- const skill = self.items[actual_index];
- const filter = skillMenuFilterForSource(skill.source);
- const display_index = skillMenuDisplayIndexForActual(self.items, filter, actual_index) orelse return false;
- self.menu.openFocused(self.items, filter, display_index);
- return true;
+ fn create(
+ alloc: Allocator,
+ generation: u64,
+ catalog: LoadedCatalog,
+ ) Allocator.Error!*CatalogGeneration {
+ const value = try alloc.create(CatalogGeneration);
+ value.* = .{
+ .alloc = alloc,
+ .generation = generation,
+ .catalog = catalog,
+ };
+ return value;
}
- pub fn closeMenu(self: *Runtime) void {
- self.menu.close();
+ fn retain(self: *CatalogGeneration) void {
+ _ = self.references.fetchAdd(1, .seq_cst);
}
- pub fn moveMenuSelection(self: *Runtime, delta: i32) bool {
- return self.menu.move(self.items, delta);
+ fn release(self: *CatalogGeneration) void {
+ if (self.references.fetchSub(1, .seq_cst) != 1) return;
+ const alloc = self.alloc;
+ self.catalog.deinit(alloc);
+ alloc.destroy(self);
}
- pub fn moveMenuSelectionVisibleRows(self: *Runtime, delta: i32, visible_rows: u16) bool {
- return self.menu.moveVisibleRows(self.items, delta, visible_rows);
+ fn referenceCount(self: *const CatalogGeneration) usize {
+ return self.references.load(.seq_cst);
}
+};
- pub fn moveMenuSourceFilter(self: *Runtime, delta: i32) bool {
- return self.menu.moveSourceFilter(self.items, delta);
- }
+pub const CatalogLease = struct {
+ generation: ?*CatalogGeneration = null,
+ items: []const Skill = &.{},
+ diagnostics: []const SkillDiagnostic = &.{},
- pub fn selectedMenuSkill(self: Runtime) ?Skill {
- if (!self.menu.active) return null;
- const item_count = self.menu.filteredItemCount(self.items);
- if (item_count == 0) return null;
- return skillMenuSkillAtQuery(self.items, self.menu.source_filter, self.menu.query(), self.menu.selected_index % item_count);
+ pub fn deinit(self: *CatalogLease) void {
+ if (self.generation) |generation| generation.release();
+ self.* = undefined;
}
pub fn buildRoutedSystemPromptSection(
- self: Runtime,
+ self: CatalogLease,
alloc: Allocator,
prompt: []const u8,
limits: context_limits.Values,
) !BoundedPromptSection {
const ordered = try orderSkillsForPrompt(alloc, self.items, prompt);
defer alloc.free(ordered);
- return self.attachDiagnostics(
+ return attachCatalogDiagnostics(
alloc,
try buildSkillsSystemPromptSectionWithLimits(alloc, ordered, limits),
+ self.diagnostics,
);
}
+};
- fn attachDiagnostics(
- self: Runtime,
- alloc: Allocator,
- section: BoundedPromptSection,
- ) !BoundedPromptSection {
- var result = section;
- errdefer result.deinit(alloc);
- if (self.diagnostics.len == 0) return result;
-
- var candidate_count: usize = 0;
- var root_count: usize = 0;
- for (self.diagnostics) |diagnostic| switch (diagnostic.scope) {
- .candidate => candidate_count += 1,
- .root => root_count += 1,
- };
- const marker = try std.fmt.allocPrint(
- alloc,
- "\n",
- .{ candidate_count, root_count, if (root_count > 0) "unknown" else "0" },
- );
- defer alloc.free(marker);
- const marked_text = try std.mem.concat(alloc, u8, &.{ marker, result.text });
- alloc.free(result.text);
- result.text = marked_text;
+const PendingCatalog = struct {
+ generation: u64,
+ catalog: LoadedCatalog,
- var diagnostic_notice: std.Io.Writer.Allocating = .init(alloc);
- defer diagnostic_notice.deinit();
- try writeDiagnosticSummary(alloc, &diagnostic_notice.writer, self.diagnostics);
- result.diagnostic_notice = try diagnostic_notice.toOwnedSlice();
- return result;
+ fn deinit(self: *PendingCatalog, alloc: Allocator) void {
+ self.catalog.deinit(alloc);
+ self.* = undefined;
}
};
-fn orderSkillsForPrompt(alloc: Allocator, skills: []const Skill, prompt: []const u8) ![]Skill {
- const ordered = try alloc.dupe(Skill, skills);
- errdefer alloc.free(ordered);
- if (skills.len < 2 or prompt.len == 0) return ordered;
+const PendingRefresh = struct {
+ alloc: Allocator,
+ generation: u64,
+ home: []u8,
- const query = lexical_relevance.prepare(prompt) catch return ordered;
- const documents = try alloc.alloc(capability_retrieval.Document, skills.len);
- defer alloc.free(documents);
- for (skills, 0..) |skill, index| {
- documents[index] = .{
- .identities = .{ skill.name, "" },
- .stable_key = skill.path,
- .primary = .{ skill.name, "", "", "" },
- .secondary = .{ skill.description, "", "" },
- };
+ fn deinit(self: *PendingRefresh) void {
+ self.alloc.free(self.home);
+ self.* = undefined;
}
- var page = try capability_retrieval.retrieve(
- alloc,
- .{
- .query = &query,
- .kind = .skill,
- .limit = capability_retrieval.max_limit,
- .relevance_policy = .intent,
- },
- .skill,
- documents,
+};
+
+const KnownCatalogRefresh = union(enum) {
+ full_discovery,
+ unchanged,
+ catalog: LoadedCatalog,
+};
+
+fn refreshKnownCatalog(
+ alloc: Allocator,
+ workspace_root: []const u8,
+ home: []const u8,
+ skills_dir: []const u8,
+ root_policy: skill_contract.RootPolicy,
+ base: CatalogLease,
+) !KnownCatalogRefresh {
+ const generation = base.generation orelse return .full_discovery;
+ if (base.diagnostics.len > 0 or
+ generation.catalog.root_fingerprints.len == 0)
+ {
+ return .full_discovery;
+ }
+ const current_roots = try collectRootFingerprints(
+ alloc,
+ workspace_root,
+ home,
+ skills_dir,
+ root_policy,
+ );
+ defer freeRootFingerprints(alloc, current_roots);
+ if (!rootFingerprintsEqual(
+ generation.catalog.root_fingerprints,
+ current_roots,
+ )) return .full_discovery;
+
+ const changed = try alloc.alloc(bool, base.items.len);
+ defer alloc.free(changed);
+ var changed_count: usize = 0;
+ for (base.items, 0..) |skill, index| {
+ const stat = statKnownSkill(skill) catch return .full_discovery;
+ const differs = stat.inode != skill.metadata_inode or
+ stat.size != skill.metadata_size or
+ !std.meta.eql(stat.mtime, skill.metadata_mtime);
+ changed[index] = differs;
+ changed_count += @intFromBool(differs);
+ }
+ if (changed_count == 0) return .unchanged;
+ if (changed_count > 8) return .full_discovery;
+
+ const replacements = try alloc.alloc(?Skill, base.items.len);
+ defer alloc.free(replacements);
+ @memset(replacements, null);
+ errdefer for (replacements) |maybe_skill| {
+ if (maybe_skill) |skill| freeSkill(alloc, skill);
+ };
+ for (base.items, 0..) |skill, index| {
+ if (!changed[index]) continue;
+ replacements[index] = (try loadKnownSkill(alloc, skill)) orelse {
+ for (replacements) |maybe_skill| {
+ if (maybe_skill) |owned| freeSkill(alloc, owned);
+ }
+ return .full_discovery;
+ };
+ }
+ const compact = try compactCloneSkills(alloc, base.items, replacements);
+ errdefer compact.deinit(alloc);
+ for (replacements) |*maybe_skill| {
+ if (maybe_skill.*) |skill| freeSkill(alloc, skill);
+ maybe_skill.* = null;
+ }
+ const roots = try cloneRootFingerprints(
+ alloc,
+ generation.catalog.root_fingerprints,
+ );
+ errdefer freeRootFingerprints(alloc, roots);
+ const dir = try alloc.dupe(u8, skills_dir);
+ return .{ .catalog = .{
+ .dir = dir,
+ .skills = compact.skills,
+ .skill_backing = compact.backing,
+ .root_fingerprints = roots,
+ } };
+}
+
+fn statKnownSkill(skill: Skill) !std.Io.File.Stat {
+ var path_buffer: [std.fs.max_path_bytes]u8 = undefined;
+ const path = try std.fmt.bufPrint(
+ &path_buffer,
+ "{s}" ++ std.fs.path.sep_str ++ "SKILL.md",
+ .{skill.path},
+ );
+ return std.Io.Dir.cwd().statFile(
+ io_mod.getIo(),
+ path,
+ .{ .follow_symlinks = false },
+ );
+}
+
+fn loadKnownSkill(alloc: Allocator, previous: Skill) !?Skill {
+ var candidate_dir = if (previous.read_authority) |authority|
+ openContainedDir(alloc, previous.path, authority, .{}) catch return null
+ else
+ io_mod.openDirAbsoluteNoFollow(previous.path, .{}) catch return null;
+ defer candidate_dir.close(io_mod.getIo());
+ var file = switch (try openPrimarySkillFile(
+ alloc,
+ &candidate_dir,
+ previous.read_authority,
+ )) {
+ .opened => |opened| opened,
+ .missing, .rejected => return null,
+ };
+ defer file.close(io_mod.getIo());
+ const stat = file.stat(io_mod.getIo()) catch return null;
+ const entry_name = std.fs.path.basename(previous.path);
+ const inspection = try inspectSkillCandidateFile(alloc, &file, entry_name);
+ const candidate = switch (inspection) {
+ .valid => |value| value,
+ .invalid, .unreadable, .oversized => return null,
+ };
+ defer candidate.deinit(alloc);
+ const name = try alloc.dupe(u8, candidate.metadata.name);
+ errdefer alloc.free(name);
+ const description = try alloc.alloc(u8, candidate.metadata.description_len());
+ errdefer alloc.free(description);
+ candidate.metadata.write_description(description);
+ const path = try alloc.dupe(u8, previous.path);
+ errdefer alloc.free(path);
+ const authority = if (previous.read_authority) |value|
+ try alloc.dupe(u8, value)
+ else
+ null;
+ return .{
+ .name = name,
+ .description = description,
+ .path = path,
+ .source = previous.source,
+ .read_authority = authority,
+ .metadata_inode = stat.inode,
+ .metadata_size = stat.size,
+ .metadata_mtime = stat.mtime,
+ };
+}
+
+const CompactSkills = struct {
+ skills: []Skill,
+ backing: []u8,
+
+ fn deinit(self: CompactSkills, alloc: Allocator) void {
+ alloc.free(self.backing);
+ if (self.skills.len > 0) alloc.free(self.skills);
+ }
+};
+
+fn compactCloneSkills(
+ alloc: Allocator,
+ source: []const Skill,
+ replacements: []const ?Skill,
+) !CompactSkills {
+ std.debug.assert(source.len == replacements.len);
+ var byte_count: usize = 0;
+ for (source, replacements) |current, replacement| {
+ const skill = replacement orelse current;
+ byte_count = std.math.add(usize, byte_count, skill.name.len) catch
+ return error.OutOfMemory;
+ byte_count = std.math.add(usize, byte_count, skill.description.len) catch
+ return error.OutOfMemory;
+ byte_count = std.math.add(usize, byte_count, skill.path.len) catch
+ return error.OutOfMemory;
+ if (skill.read_authority) |authority| {
+ byte_count = std.math.add(usize, byte_count, authority.len) catch
+ return error.OutOfMemory;
+ }
+ }
+ const skills = try alloc.alloc(Skill, source.len);
+ errdefer if (skills.len > 0) alloc.free(skills);
+ const backing = try alloc.alloc(u8, byte_count);
+ errdefer alloc.free(backing);
+ var cursor: usize = 0;
+ for (source, replacements, 0..) |current, replacement, index| {
+ const skill = replacement orelse current;
+ const name = copyCompactString(backing, &cursor, skill.name);
+ const description = copyCompactString(backing, &cursor, skill.description);
+ const path = copyCompactString(backing, &cursor, skill.path);
+ const authority = if (skill.read_authority) |value|
+ copyCompactString(backing, &cursor, value)
+ else
+ null;
+ skills[index] = .{
+ .name = name,
+ .description = description,
+ .path = path,
+ .source = skill.source,
+ .read_authority = authority,
+ .metadata_inode = skill.metadata_inode,
+ .metadata_size = skill.metadata_size,
+ .metadata_mtime = skill.metadata_mtime,
+ };
+ }
+ std.debug.assert(cursor == backing.len);
+ return .{ .skills = skills, .backing = backing };
+}
+
+fn copyCompactString(
+ backing: []u8,
+ cursor: *usize,
+ value: []const u8,
+) []const u8 {
+ const start = cursor.*;
+ const end = start + value.len;
+ @memcpy(backing[start..end], value);
+ cursor.* = end;
+ return backing[start..end];
+}
+
+fn cloneRootFingerprints(
+ alloc: Allocator,
+ roots: []const RootFingerprint,
+) ![]RootFingerprint {
+ const copy = try alloc.alloc(RootFingerprint, roots.len);
+ var filled: usize = 0;
+ errdefer {
+ for (copy[0..filled]) |*root| root.deinit(alloc);
+ if (copy.len > 0) alloc.free(copy);
+ }
+ while (filled < roots.len) : (filled += 1) {
+ copy[filled] = roots[filled];
+ copy[filled].path = try alloc.dupe(u8, roots[filled].path);
+ }
+ return copy;
+}
+
+pub const RefreshCompletion = enum {
+ none,
+ unchanged,
+ adopted,
+ failed,
+};
+
+pub const RefreshAction = union(enum) {
+ list,
+ show: []u8,
+ notice: []u8,
+
+ pub fn deinit(self: *RefreshAction, alloc: Allocator) void {
+ switch (self.*) {
+ .list => {},
+ .show => |value| alloc.free(value),
+ .notice => |value| alloc.free(value),
+ }
+ self.* = undefined;
+ }
+};
+
+pub const ReadyRefreshAction = struct {
+ action: RefreshAction,
+ succeeded: bool,
+
+ pub fn deinit(self: *ReadyRefreshAction, alloc: Allocator) void {
+ self.action.deinit(alloc);
+ self.* = undefined;
+ }
+};
+
+const PendingRefreshAction = struct {
+ generation: u64,
+ action: RefreshAction,
+
+ fn deinit(self: *PendingRefreshAction, alloc: Allocator) void {
+ self.action.deinit(alloc);
+ self.* = undefined;
+ }
+};
+
+const CatalogRefreshTask = struct {
+ alloc: Allocator,
+ thread: ?std.Thread = null,
+ done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ cancel_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ workspace_root: []u8,
+ home: []u8,
+ skills_dir: []u8,
+ root_policy: skill_contract.RootPolicy,
+ generation: u64,
+ base_catalog: CatalogLease,
+ catalog: ?LoadedCatalog = null,
+ unchanged: bool = false,
+ failure: ?anyerror = null,
+
+ fn create(
+ alloc: Allocator,
+ workspace_root: []const u8,
+ home: []const u8,
+ skills_dir: []const u8,
+ root_policy: skill_contract.RootPolicy,
+ generation: u64,
+ base_catalog: CatalogLease,
+ ) Allocator.Error!*CatalogRefreshTask {
+ const task = try alloc.create(CatalogRefreshTask);
+ errdefer alloc.destroy(task);
+ const owned_workspace = try alloc.dupe(u8, workspace_root);
+ errdefer alloc.free(owned_workspace);
+ const owned_home = try alloc.dupe(u8, home);
+ errdefer alloc.free(owned_home);
+ const owned_skills_dir = try alloc.dupe(u8, skills_dir);
+ errdefer alloc.free(owned_skills_dir);
+ task.* = .{
+ .alloc = alloc,
+ .workspace_root = owned_workspace,
+ .home = owned_home,
+ .skills_dir = owned_skills_dir,
+ .root_policy = root_policy,
+ .generation = generation,
+ .base_catalog = base_catalog,
+ };
+ return task;
+ }
+
+ fn start(self: *CatalogRefreshTask) !void {
+ if (comptime @import("builtin").single_threaded) {
+ self.run();
+ return;
+ }
+ self.thread = try std.Thread.spawn(.{}, run, .{self});
+ }
+
+ fn run(self: *CatalogRefreshTask) void {
+ if (self.cancel_requested.load(.acquire)) {
+ self.done.store(true, .release);
+ return;
+ }
+ const known_refresh = refreshKnownCatalog(
+ self.alloc,
+ self.workspace_root,
+ self.home,
+ self.skills_dir,
+ self.root_policy,
+ self.base_catalog,
+ ) catch |err| {
+ self.failure = err;
+ self.done.store(true, .release);
+ return;
+ };
+ switch (known_refresh) {
+ .unchanged => {
+ self.unchanged = true;
+ self.done.store(true, .release);
+ return;
+ },
+ .catalog => |catalog| {
+ self.catalog = catalog;
+ self.done.store(true, .release);
+ return;
+ },
+ .full_discovery => {},
+ }
+ const discovery = loadVisibleSkills(
+ self.alloc,
+ self.workspace_root,
+ self.home,
+ self.skills_dir,
+ self.root_policy,
+ ) catch |err| {
+ self.failure = err;
+ self.done.store(true, .release);
+ return;
+ };
+ const roots = collectRootFingerprints(
+ self.alloc,
+ self.workspace_root,
+ self.home,
+ self.skills_dir,
+ self.root_policy,
+ ) catch |err| {
+ var owned = discovery;
+ owned.deinit(self.alloc);
+ self.failure = err;
+ self.done.store(true, .release);
+ return;
+ };
+ const dir = self.alloc.dupe(u8, self.skills_dir) catch |err| {
+ freeRootFingerprints(self.alloc, roots);
+ var owned = discovery;
+ owned.deinit(self.alloc);
+ self.failure = err;
+ self.done.store(true, .release);
+ return;
+ };
+ var catalog = LoadedCatalog{
+ .dir = dir,
+ .skills = discovery.skills,
+ .diagnostics = discovery.diagnostics,
+ .root_fingerprints = roots,
+ };
+ if (self.cancel_requested.load(.acquire)) {
+ catalog.deinit(self.alloc);
+ } else {
+ self.catalog = catalog;
+ }
+ self.done.store(true, .release);
+ }
+
+ fn takeCatalog(self: *CatalogRefreshTask) ?LoadedCatalog {
+ const catalog = self.catalog orelse return null;
+ self.catalog = null;
+ return catalog;
+ }
+
+ fn deinit(self: *CatalogRefreshTask) void {
+ self.cancel_requested.store(true, .release);
+ if (self.thread) |thread| thread.join();
+ if (self.catalog) |*catalog| catalog.deinit(self.alloc);
+ self.alloc.free(self.workspace_root);
+ self.alloc.free(self.home);
+ self.alloc.free(self.skills_dir);
+ self.base_catalog.deinit();
+ const alloc = self.alloc;
+ alloc.destroy(self);
+ }
+};
+
+fn catalogMatches(runtime: *const Runtime, catalog: LoadedCatalog) bool {
+ if (!std.mem.eql(u8, runtime.dir, catalog.dir) or
+ runtime.items.len != catalog.skills.len or
+ runtime.diagnostics.len != catalog.diagnostics.len)
+ {
+ return false;
+ }
+ for (runtime.items, catalog.skills) |active, refreshed| {
+ if (!std.mem.eql(u8, active.name, refreshed.name) or
+ !std.mem.eql(u8, active.description, refreshed.description) or
+ !std.mem.eql(u8, active.path, refreshed.path) or
+ active.source != refreshed.source or
+ !optionalStringEqual(active.read_authority, refreshed.read_authority) or
+ !skillFingerprintEqual(active, refreshed))
+ {
+ return false;
+ }
+ }
+ for (runtime.diagnostics, catalog.diagnostics) |active, refreshed| {
+ if (!std.mem.eql(u8, active.path, refreshed.path) or
+ active.source != refreshed.source or
+ active.scope != refreshed.scope or
+ !std.meta.eql(active.cause, refreshed.cause))
+ {
+ return false;
+ }
+ }
+ const active_catalog = runtime.active_catalog orelse return true;
+ if (!rootFingerprintsEqual(
+ active_catalog.catalog.root_fingerprints,
+ catalog.root_fingerprints,
+ )) return false;
+ return true;
+}
+
+fn skillFingerprintEqual(left: Skill, right: Skill) bool {
+ return left.metadata_inode == right.metadata_inode and
+ left.metadata_size == right.metadata_size and
+ std.meta.eql(left.metadata_mtime, right.metadata_mtime);
+}
+
+fn rootFingerprintsEqual(
+ left: []const RootFingerprint,
+ right: []const RootFingerprint,
+) bool {
+ if (left.len != right.len) return false;
+ for (left, right) |a, b| {
+ if (!std.mem.eql(u8, a.path, b.path) or
+ a.exists != b.exists or
+ a.inode != b.inode or
+ !std.meta.eql(a.mtime, b.mtime) or
+ !std.mem.eql(u8, &a.candidate_digest, &b.candidate_digest)) return false;
+ }
+ return true;
+}
+
+fn optionalStringEqual(left: ?[]const u8, right: ?[]const u8) bool {
+ if (left == null or right == null) return left == null and right == null;
+ return std.mem.eql(u8, left.?, right.?);
+}
+
+fn skillMenuSourceFilterIndex(filter: SkillMenuSourceFilter) usize {
+ for (skill_menu_source_filters, 0..) |candidate, index| {
+ if (candidate == filter) return index;
+ }
+ return 0;
+}
+
+pub const Runtime = struct {
+ dir: []u8 = &.{},
+ items: []Skill = &.{},
+ diagnostics: []SkillDiagnostic = &.{},
+ menu: SkillMenu = .{},
+ menu_index: SkillMenuIndex = .{},
+ menu_index_ready: bool = false,
+ catalog_mutex: std.Io.Mutex = .init,
+ active_catalog: ?*CatalogGeneration = null,
+ retired_catalog: ?*CatalogGeneration = null,
+ pending_catalog: ?PendingCatalog = null,
+ next_refresh_generation: u64 = 0,
+ fresh_through_generation: u64 = 0,
+ failed_refresh_generation: ?u64 = null,
+ pending_refresh_action: ?PendingRefreshAction = null,
+ refresh_task: ?*CatalogRefreshTask = null,
+ refresh_pending: ?PendingRefresh = null,
+
+ pub fn deinit(self: *Runtime, alloc: Allocator) void {
+ if (self.refresh_task) |task| task.deinit();
+ self.refresh_task = null;
+ if (self.pending_catalog) |*pending| pending.deinit(alloc);
+ self.pending_catalog = null;
+ if (self.pending_refresh_action) |*action| action.deinit(alloc);
+ self.pending_refresh_action = null;
+ if (self.refresh_pending) |*pending| pending.deinit();
+ self.refresh_pending = null;
+ self.freeLoaded(alloc);
+ if (self.retired_catalog) |catalog| catalog.release();
+ self.retired_catalog = null;
+ self.menu.close();
+ self.menu_index.deinit(alloc);
+ }
+
+ pub fn requestRefresh(
+ self: *Runtime,
+ alloc: Allocator,
+ workspace_root: []const u8,
+ home: ?[]const u8,
+ root_policy: skill_contract.RootPolicy,
+ ) !u64 {
+ const configured_home = home orelse {
+ const generation = self.nextGeneration();
+ self.fresh_through_generation = @max(
+ self.fresh_through_generation,
+ generation,
+ );
+ return generation;
+ };
+ if (self.refresh_task != null or self.pending_catalog != null) {
+ if (self.refresh_pending) |pending| return pending.generation;
+ const owned_home = try alloc.dupe(u8, configured_home);
+ const generation = self.nextGeneration();
+ self.refresh_pending = .{
+ .alloc = alloc,
+ .generation = generation,
+ .home = owned_home,
+ };
+ return generation;
+ }
+ const generation = self.nextGeneration();
+ try self.startRefresh(
+ alloc,
+ workspace_root,
+ configured_home,
+ root_policy,
+ generation,
+ );
+ return generation;
+ }
+
+ fn startRefresh(
+ self: *Runtime,
+ alloc: Allocator,
+ workspace_root: []const u8,
+ home: []const u8,
+ root_policy: skill_contract.RootPolicy,
+ generation: u64,
+ ) !void {
+ var base_catalog = self.acquireCatalog();
+ var base_catalog_owned = true;
+ defer if (base_catalog_owned) base_catalog.deinit();
+ const task = try CatalogRefreshTask.create(
+ alloc,
+ workspace_root,
+ home,
+ self.dir,
+ root_policy,
+ generation,
+ base_catalog,
+ );
+ base_catalog_owned = false;
+ errdefer task.deinit();
+ try task.start();
+ self.refresh_task = task;
+ }
+
+ fn nextGeneration(self: *Runtime) u64 {
+ self.next_refresh_generation +|= 1;
+ return self.next_refresh_generation;
+ }
+
+ pub fn pollRefresh(
+ self: *Runtime,
+ alloc: Allocator,
+ workspace_root: []const u8,
+ root_policy: skill_contract.RootPolicy,
+ ) !RefreshCompletion {
+ self.reapRetiredCatalog();
+ var completion: RefreshCompletion = .none;
+ if (self.pending_catalog) |*pending| {
+ if (try self.adoptCatalog(alloc, pending.generation, &pending.catalog)) {
+ pending.catalog = .{};
+ self.pending_catalog = null;
+ completion = .adopted;
+ }
+ }
+ const task = self.refresh_task orelse {
+ try self.startPendingRefresh(
+ alloc,
+ workspace_root,
+ root_policy,
+ );
+ return completion;
+ };
+ if (!task.done.load(.acquire)) return .none;
+ if (task.thread) |thread| {
+ thread.join();
+ task.thread = null;
+ }
+ self.refresh_task = null;
+ defer task.deinit();
+ completion = .failed;
+ if (task.failure == null) {
+ if (task.unchanged) {
+ self.fresh_through_generation = @max(
+ self.fresh_through_generation,
+ task.generation,
+ );
+ completion = .unchanged;
+ } else if (task.takeCatalog()) |catalog_value| {
+ var catalog = catalog_value;
+ defer catalog.deinit(alloc);
+ if (catalogMatches(self, catalog)) {
+ self.fresh_through_generation = @max(
+ self.fresh_through_generation,
+ task.generation,
+ );
+ completion = .unchanged;
+ } else {
+ if (try self.adoptCatalog(alloc, task.generation, &catalog)) {
+ completion = .adopted;
+ } else {
+ self.pending_catalog = .{
+ .generation = task.generation,
+ .catalog = catalog,
+ };
+ catalog = .{};
+ completion = .none;
+ }
+ }
+ }
+ } else {
+ self.failed_refresh_generation = task.generation;
+ }
+ try self.startPendingRefresh(alloc, workspace_root, root_policy);
+ return completion;
+ }
+
+ fn startPendingRefresh(
+ self: *Runtime,
+ alloc: Allocator,
+ workspace_root: []const u8,
+ root_policy: skill_contract.RootPolicy,
+ ) !void {
+ if (self.refresh_task != null or self.pending_catalog != null) return;
+ var pending = self.refresh_pending orelse return;
+ self.refresh_pending = null;
+ defer pending.deinit();
+ try self.startRefresh(
+ alloc,
+ workspace_root,
+ pending.home,
+ root_policy,
+ pending.generation,
+ );
+ }
+
+ pub const GenerationStatus = enum {
+ pending,
+ current,
+ failed,
+ };
+
+ pub fn generationStatus(self: *const Runtime, generation: u64) GenerationStatus {
+ if (self.fresh_through_generation >= generation) return .current;
+ if (self.failed_refresh_generation) |failed| {
+ if (failed == generation) return .failed;
+ }
+ return .pending;
+ }
+
+ pub fn refreshActive(self: *const Runtime) bool {
+ return self.refresh_task != null or self.pending_catalog != null;
+ }
+
+ pub fn queueRefreshAction(
+ self: *Runtime,
+ alloc: Allocator,
+ generation: u64,
+ action: union(enum) {
+ list,
+ show: []const u8,
+ notice: []const u8,
+ },
+ ) !void {
+ const owned: RefreshAction = switch (action) {
+ .list => .list,
+ .show => |value| .{ .show = try alloc.dupe(u8, value) },
+ .notice => |value| .{ .notice = try alloc.dupe(u8, value) },
+ };
+ if (self.pending_refresh_action) |*pending| {
+ debug_trace.logf(
+ "skills",
+ "refresh action superseded prior_generation={d} prior_action={s} generation={d} action={s}",
+ .{
+ pending.generation,
+ @tagName(pending.action),
+ generation,
+ @tagName(owned),
+ },
+ );
+ pending.deinit(alloc);
+ self.pending_refresh_action = null;
+ }
+ self.pending_refresh_action = .{
+ .generation = generation,
+ .action = owned,
+ };
+ }
+
+ pub fn takeReadyRefreshAction(
+ self: *Runtime,
+ ) ?ReadyRefreshAction {
+ const pending = self.pending_refresh_action orelse return null;
+ const status = self.generationStatus(pending.generation);
+ if (status == .pending) return null;
+ self.pending_refresh_action = null;
+ return .{
+ .action = pending.action,
+ .succeeded = status == .current,
+ };
+ }
+
+ pub fn acquireCatalog(self: *Runtime) CatalogLease {
+ self.catalog_mutex.lockUncancelable(io_mod.getIo());
+ defer self.catalog_mutex.unlock(io_mod.getIo());
+ if (self.active_catalog) |catalog| {
+ catalog.retain();
+ return .{
+ .generation = catalog,
+ .items = catalog.catalog.skills,
+ .diagnostics = catalog.catalog.diagnostics,
+ };
+ }
+ return .{
+ .items = self.items,
+ .diagnostics = self.diagnostics,
+ };
+ }
+
+ fn reapRetiredCatalog(self: *Runtime) void {
+ const retired = self.retired_catalog orelse return;
+ if (retired.referenceCount() != 1) return;
+ self.retired_catalog = null;
+ retired.release();
+ }
+
+ fn freeLoaded(self: *Runtime, alloc: Allocator) void {
+ if (self.active_catalog) |catalog| {
+ self.active_catalog = null;
+ catalog.release();
+ } else {
+ if (self.dir.len > 0) alloc.free(self.dir);
+ freeSkills(alloc, self.items);
+ freeSkillDiagnostics(alloc, self.diagnostics);
+ }
+ self.dir = &.{};
+ self.items = &.{};
+ self.diagnostics = &.{};
+ }
+
+ /// Transfers `dir`, `skills`, and `diagnostics` only after the menu index
+ /// has reserved enough storage. On failure the caller retains all inputs
+ /// and the current runtime catalog remains unchanged.
+ pub fn replaceLoaded(
+ self: *Runtime,
+ alloc: Allocator,
+ dir: []u8,
+ skills: []Skill,
+ diagnostics: []SkillDiagnostic,
+ ) Allocator.Error!void {
+ try self.menu_index.actual_indices.ensureTotalCapacity(alloc, skills.len);
+ const generation = self.nextGeneration();
+ var catalog = LoadedCatalog{
+ .dir = dir,
+ .skills = skills,
+ .diagnostics = diagnostics,
+ };
+ const adopted = try self.adoptCatalog(alloc, generation, &catalog);
+ std.debug.assert(adopted);
+ }
+
+ fn adoptCatalog(
+ self: *Runtime,
+ alloc: Allocator,
+ generation: u64,
+ catalog: *LoadedCatalog,
+ ) Allocator.Error!bool {
+ try self.menu_index.actual_indices.ensureTotalCapacity(
+ alloc,
+ catalog.skills.len,
+ );
+ self.reapRetiredCatalog();
+ self.catalog_mutex.lockUncancelable(io_mod.getIo());
+ defer self.catalog_mutex.unlock(io_mod.getIo());
+ if (self.active_catalog) |active| {
+ if (active.referenceCount() > 1 and self.retired_catalog != null) {
+ return false;
+ }
+ }
+ const next = try CatalogGeneration.create(alloc, generation, catalog.*);
+ catalog.* = .{};
+ if (self.active_catalog) |active| {
+ if (active.referenceCount() > 1) {
+ self.retired_catalog = active;
+ } else {
+ active.release();
+ }
+ } else {
+ if (self.dir.len > 0) alloc.free(self.dir);
+ freeSkills(alloc, self.items);
+ freeSkillDiagnostics(alloc, self.diagnostics);
+ }
+ self.active_catalog = next;
+ self.dir = next.catalog.dir;
+ self.items = next.catalog.skills;
+ self.diagnostics = next.catalog.diagnostics;
+ self.fresh_through_generation = @max(
+ self.fresh_through_generation,
+ generation,
+ );
+ self.failed_refresh_generation = null;
+ self.menu_index.rebuildAssumeCapacity(
+ self.items,
+ self.menu.source_filter,
+ self.menu.query(),
+ );
+ self.menu_index_ready = true;
+ self.menu.clampCount(self.menu_index.count());
+ return true;
+ }
+
+ pub fn prepareMenuIndex(self: *Runtime, alloc: Allocator) Allocator.Error!void {
+ try self.menu_index.rebuild(
+ alloc,
+ self.items,
+ self.menu.source_filter,
+ self.menu.query(),
+ );
+ self.menu_index_ready = true;
+ }
+
+ fn rebuildPreparedMenuIndex(self: *Runtime) void {
+ if (self.menu_index.actual_indices.capacity < self.items.len) {
+ self.menu_index.actual_indices.clearRetainingCapacity();
+ self.menu_index_ready = false;
+ return;
+ }
+ self.menu_index.rebuildAssumeCapacity(
+ self.items,
+ self.menu.source_filter,
+ self.menu.query(),
+ );
+ self.menu_index_ready = true;
+ }
+
+ pub fn menuItemCount(self: Runtime) usize {
+ if (self.menu_index_ready) return self.menu_index.count();
+ return self.menu.filteredItemCount(self.items);
+ }
+
+ pub fn openMenu(self: *Runtime) void {
+ self.menu.beginOpen(.command, null, "");
+ self.rebuildPreparedMenuIndex();
+ self.menu.clampCount(self.menuItemCount());
+ }
+
+ pub fn openMenuWithQuery(self: *Runtime, origin: SkillMenuOrigin, target: ?SkillMenuTarget, query: []const u8) void {
+ self.menu.beginOpen(origin, target, query);
+ self.rebuildPreparedMenuIndex();
+ self.menu.clampCount(self.menuItemCount());
+ }
+
+ pub fn openMenuFocusedByName(self: *Runtime, name: []const u8) bool {
+ var matched_index: ?usize = null;
+ for (self.items, 0..) |skill, actual_index| {
+ if (!std.mem.eql(u8, skill.name, name)) continue;
+ if (matched_index != null) return false;
+ matched_index = actual_index;
+ }
+ const actual_index = matched_index orelse return false;
+ const skill = self.items[actual_index];
+ const filter = skillMenuFilterForSource(skill.source);
+ self.menu.beginOpenFocused(filter, 0);
+ self.rebuildPreparedMenuIndex();
+ const display_index = if (self.menu_index_ready)
+ std.mem.indexOfScalar(
+ u32,
+ self.menu_index.actual_indices.items,
+ @intCast(actual_index),
+ )
+ else
+ skillMenuDisplayIndexForActual(self.items, filter, actual_index);
+ self.menu.selected_index = display_index orelse return false;
+ self.menu.clampCount(self.menuItemCount());
+ return true;
+ }
+
+ pub fn closeMenu(self: *Runtime) void {
+ self.menu.close();
+ }
+
+ pub fn moveMenuSelection(self: *Runtime, delta: i32) bool {
+ return self.menu.moveVisibleRowsCount(
+ self.menuItemCount(),
+ delta,
+ skill_menu_max_visible_rows,
+ );
+ }
+
+ pub fn moveMenuSelectionVisibleRows(self: *Runtime, delta: i32, visible_rows: u16) bool {
+ return self.menu.moveVisibleRowsCount(self.menuItemCount(), delta, visible_rows);
+ }
+
+ pub fn moveMenuSourceFilter(self: *Runtime, delta: i32) bool {
+ if (!self.menu.advanceSourceFilter(delta)) return false;
+ self.rebuildPreparedMenuIndex();
+ self.menu.clampCount(self.menuItemCount());
+ return true;
+ }
+
+ pub fn setMenuQuery(
+ self: *Runtime,
+ _: Allocator,
+ query: []const u8,
+ ) void {
+ self.menu.setQuery(query);
+ self.rebuildPreparedMenuIndex();
+ const item_count = self.menuItemCount();
+ if (item_count == 0) {
+ self.menu.selected_index = 0;
+ self.menu.window_start = 0;
+ return;
+ }
+ if (self.menu.selected_index >= item_count) {
+ self.menu.selected_index = item_count - 1;
+ }
+ self.menu.window_start = list_window.updateEdgeStart(
+ self.menu.window_start,
+ item_count,
+ self.menu.selected_index,
+ skill_menu_max_visible_rows,
+ );
+ }
+
+ pub fn selectedMenuSkill(self: Runtime) ?Skill {
+ if (!self.menu.active) return null;
+ const item_count = self.menuItemCount();
+ if (item_count == 0) return null;
+ if (self.menu_index_ready) {
+ const skill = self.menu_index.skillAt(
+ self.items,
+ self.menu.selected_index % item_count,
+ ) orelse return null;
+ return skill.*;
+ }
+ return skillMenuSkillAtQuery(self.items, self.menu.source_filter, self.menu.query(), self.menu.selected_index % item_count);
+ }
+
+ pub fn buildRoutedSystemPromptSection(
+ self: Runtime,
+ alloc: Allocator,
+ prompt: []const u8,
+ limits: context_limits.Values,
+ ) !BoundedPromptSection {
+ const ordered = try orderSkillsForPrompt(alloc, self.items, prompt);
+ defer alloc.free(ordered);
+ return attachCatalogDiagnostics(
+ alloc,
+ try buildSkillsSystemPromptSectionWithLimits(alloc, ordered, limits),
+ self.diagnostics,
+ );
+ }
+};
+
+fn attachCatalogDiagnostics(
+ alloc: Allocator,
+ section: BoundedPromptSection,
+ diagnostics: []const SkillDiagnostic,
+) !BoundedPromptSection {
+ var result = section;
+ errdefer result.deinit(alloc);
+ if (diagnostics.len == 0) return result;
+
+ var candidate_count: usize = 0;
+ var root_count: usize = 0;
+ for (diagnostics) |diagnostic| switch (diagnostic.scope) {
+ .candidate => candidate_count += 1,
+ .root => root_count += 1,
+ };
+ const marker = try std.fmt.allocPrint(
+ alloc,
+ "\n",
+ .{ candidate_count, root_count, if (root_count > 0) "unknown" else "0" },
+ );
+ defer alloc.free(marker);
+ const marked_text = try std.mem.concat(alloc, u8, &.{ marker, result.text });
+ alloc.free(result.text);
+ result.text = marked_text;
+
+ var diagnostic_notice: std.Io.Writer.Allocating = .init(alloc);
+ defer diagnostic_notice.deinit();
+ try writeDiagnosticSummary(alloc, &diagnostic_notice.writer, diagnostics);
+ result.diagnostic_notice = try diagnostic_notice.toOwnedSlice();
+ return result;
+}
+
+fn orderSkillsForPrompt(alloc: Allocator, skills: []const Skill, prompt: []const u8) ![]Skill {
+ const ordered = try alloc.dupe(Skill, skills);
+ errdefer alloc.free(ordered);
+ if (skills.len < 2 or prompt.len == 0) return ordered;
+
+ const query = lexical_relevance.prepare(prompt) catch return ordered;
+ const documents = try alloc.alloc(capability_retrieval.Document, skills.len);
+ defer alloc.free(documents);
+ for (skills, 0..) |skill, index| {
+ documents[index] = .{
+ .identities = .{ skill.name, "" },
+ .stable_key = skill.path,
+ .primary = .{ skill.name, "", "", "" },
+ .secondary = .{ skill.description, "", "" },
+ };
+ }
+ var page = try capability_retrieval.retrieve(
+ alloc,
+ .{
+ .query = &query,
+ .kind = .skill,
+ .limit = capability_retrieval.max_limit,
+ .relevance_policy = .intent,
+ },
+ .skill,
+ documents,
);
defer page.deinit(alloc);
@@ -2064,6 +3309,258 @@ test "skill menu query ranks name matches before metadata matches" {
try std.testing.expectEqual(@as(usize, 3), skillMenuDisplayIndexForActualQuery(&skills, .all, "zig", 0).?);
}
+test "skill menu index materializes and reuses one stable query snapshot" {
+ const alloc = std.testing.allocator;
+ const skills = [_]Skill{
+ .{ .name = "zig-best-practices", .description = "Zig guidance", .path = "/skills/zig", .source = .global_fx },
+ .{ .name = "pure-core", .description = "Functional core", .path = "/skills/pure", .source = .global_codex },
+ .{ .name = "zig-review", .description = "Review Zig", .path = "/skills/review", .source = .workspace_agents },
+ };
+
+ var index: SkillMenuIndex = .{};
+ defer index.deinit(alloc);
+
+ try index.rebuild(alloc, &skills, .all, "zig");
+ try std.testing.expectEqual(@as(usize, 2), index.count());
+ try std.testing.expectEqualStrings("zig-best-practices", index.skillAt(&skills, 0).?.name);
+ try std.testing.expectEqualStrings("zig-review", index.skillAt(&skills, 1).?.name);
+ const retained_ptr = index.actual_indices.items.ptr;
+ const retained_capacity = index.actual_indices.capacity;
+
+ try index.rebuild(alloc, &skills, .all, "core");
+ try std.testing.expectEqual(@as(usize, 1), index.count());
+ try std.testing.expectEqualStrings("pure-core", index.skillAt(&skills, 0).?.name);
+ try std.testing.expectEqual(retained_ptr, index.actual_indices.items.ptr);
+ try std.testing.expectEqual(retained_capacity, index.actual_indices.capacity);
+}
+
+test "skill runtime keeps menu count selection and query on one index" {
+ const alloc = std.testing.allocator;
+ const skills = [_]Skill{
+ .{ .name = "zig-best-practices", .description = "Zig guidance", .path = "/skills/zig", .source = .global_fx },
+ .{ .name = "pure-core", .description = "Functional core", .path = "/skills/pure", .source = .global_codex },
+ .{ .name = "zig-review", .description = "Review Zig", .path = "/skills/review", .source = .workspace_agents },
+ };
+ var runtime = Runtime{ .items = @constCast(&skills) };
+ defer {
+ runtime.items = &.{};
+ runtime.deinit(alloc);
+ }
+
+ try runtime.prepareMenuIndex(alloc);
+ runtime.openMenu();
+ try std.testing.expectEqual(@as(usize, 3), runtime.menuItemCount());
+ try std.testing.expectEqualStrings("zig-best-practices", runtime.selectedMenuSkill().?.name);
+
+ runtime.setMenuQuery(alloc, "core");
+ try std.testing.expectEqual(@as(usize, 1), runtime.menuItemCount());
+ try std.testing.expectEqualStrings("pure-core", runtime.selectedMenuSkill().?.name);
+ try std.testing.expectEqualSlices(u32, &.{1}, runtime.menu_index.actual_indices.items);
+}
+
+test "skill menu query navigation and close stay allocation free for ten thousand cycles" {
+ const alloc = std.testing.allocator;
+ const skills = [_]Skill{
+ .{ .name = "alpha", .description = "first", .path = "/skills/alpha", .source = .global_fx },
+ .{ .name = "beta", .description = "second", .path = "/skills/beta", .source = .global_codex },
+ .{ .name = "gamma", .description = "third", .path = "/skills/gamma", .source = .workspace_agents },
+ };
+ var runtime = Runtime{ .items = @constCast(&skills) };
+ defer {
+ runtime.items = &.{};
+ runtime.deinit(alloc);
+ }
+ try runtime.prepareMenuIndex(alloc);
+ var failing = std.testing.FailingAllocator.init(alloc, .{ .fail_index = 0 });
+
+ for (0..10_000) |cycle| {
+ runtime.openMenu();
+ runtime.setMenuQuery(
+ failing.allocator(),
+ if (cycle & 1 == 0) "a" else "",
+ );
+ _ = runtime.moveMenuSelection(1);
+ _ = runtime.moveMenuSourceFilter(1);
+ runtime.closeMenu();
+ }
+}
+
+test "skill runtime replacement preserves the active catalog when index allocation fails" {
+ const alloc = std.testing.allocator;
+ const active = [_]Skill{
+ .{ .name = "active", .description = "active", .path = "/skills/active", .source = .global_fx },
+ };
+ const replacement = [_]Skill{.{
+ .name = "replacement",
+ .description = "replacement",
+ .path = "/skills/replacement",
+ .source = .global_fx,
+ }} ** 64;
+ var runtime = Runtime{ .items = @constCast(&active) };
+ defer {
+ runtime.items = &.{};
+ runtime.diagnostics = &.{};
+ runtime.dir = &.{};
+ runtime.deinit(alloc);
+ }
+ try runtime.prepareMenuIndex(alloc);
+
+ var failing = std.testing.FailingAllocator.init(alloc, .{ .fail_index = 0 });
+ try std.testing.expectError(
+ error.OutOfMemory,
+ runtime.replaceLoaded(
+ failing.allocator(),
+ @constCast("/replacement"),
+ @constCast(&replacement),
+ &.{},
+ ),
+ );
+ try std.testing.expect(runtime.items.ptr == active[0..].ptr);
+ try std.testing.expectEqual(@as(usize, 1), runtime.menu_index.count());
+ try std.testing.expectEqualStrings("active", runtime.menu_index.skillAt(runtime.items, 0).?.name);
+}
+
+test "skill catalog lease keeps one retired generation alive until release" {
+ const alloc = std.testing.allocator;
+ var runtime: Runtime = .{};
+ defer runtime.deinit(alloc);
+
+ const first = try alloc.alloc(Skill, 1);
+ first[0] = .{
+ .name = try alloc.dupe(u8, "first"),
+ .description = try alloc.dupe(u8, "first generation"),
+ .path = try alloc.dupe(u8, "/skills/first"),
+ .source = .global_fx,
+ };
+ try runtime.replaceLoaded(
+ alloc,
+ try alloc.dupe(u8, "/skills"),
+ first,
+ &.{},
+ );
+
+ var lease = runtime.acquireCatalog();
+ var lease_owned = true;
+ defer if (lease_owned) lease.deinit();
+ const second = try alloc.alloc(Skill, 1);
+ second[0] = .{
+ .name = try alloc.dupe(u8, "second"),
+ .description = try alloc.dupe(u8, "second generation"),
+ .path = try alloc.dupe(u8, "/skills/second"),
+ .source = .global_fx,
+ };
+ try runtime.replaceLoaded(
+ alloc,
+ try alloc.dupe(u8, "/skills"),
+ second,
+ &.{},
+ );
+
+ try std.testing.expectEqualStrings("first", lease.items[0].name);
+ try std.testing.expectEqualStrings("second", runtime.items[0].name);
+ try std.testing.expect(runtime.retired_catalog != null);
+ lease.deinit();
+ lease_owned = false;
+ runtime.reapRetiredCatalog();
+ try std.testing.expect(runtime.retired_catalog == null);
+}
+
+test "skill refresh publishes one generation and coalesces one latest request" {
+ const alloc = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try writeTempFile(
+ &tmp,
+ "home/.fx/skills/refreshable/SKILL.md",
+ "---\nname: refreshable\ndescription: refreshed off-thread\n---\nbody\n",
+ );
+ try tmp.dir.createDirPath(io_mod.getIo(), "home/.fx/skills/added");
+ const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home");
+ defer alloc.free(home);
+ const managed = try std.fs.path.join(alloc, &.{ home, ".fx", "skills" });
+ defer alloc.free(managed);
+ var runtime = Runtime{ .dir = try alloc.dupe(u8, managed) };
+ defer runtime.deinit(alloc);
+ const policy: skill_contract.RootPolicy = .{ .managed_root_source = .global_fx };
+
+ const first_generation = try runtime.requestRefresh(alloc, home, home, policy);
+ const pending_generation = try runtime.requestRefresh(alloc, home, home, policy);
+ try std.testing.expect(pending_generation > first_generation);
+ try std.testing.expectEqual(
+ pending_generation,
+ runtime.refresh_pending.?.generation,
+ );
+
+ var adopted = false;
+ for (0..100_000) |_| {
+ switch (try runtime.pollRefresh(alloc, home, policy)) {
+ .adopted => adopted = true,
+ .none, .unchanged, .failed => {},
+ }
+ if (adopted and runtime.refresh_task == null) break;
+ std.Thread.yield() catch std.atomic.spinLoopHint();
+ }
+ try std.testing.expect(adopted);
+ try std.testing.expectEqual(@as(usize, 1), runtime.items.len);
+ try std.testing.expectEqualStrings("refreshable", runtime.items[0].name);
+
+ var terminal: RefreshCompletion = .none;
+ _ = try runtime.requestRefresh(alloc, home, home, policy);
+ for (0..100_000) |_| {
+ terminal = try runtime.pollRefresh(alloc, home, policy);
+ if (terminal != .none) break;
+ std.Thread.yield() catch std.atomic.spinLoopHint();
+ }
+ try std.testing.expectEqual(RefreshCompletion.unchanged, terminal);
+
+ try writeTempFile(
+ &tmp,
+ "home/.fx/skills/refreshable/SKILL.md",
+ "---\nname: refreshable-v2\ndescription: one-file delta refresh with a new size\n---\nbody changed\n",
+ );
+ _ = try runtime.requestRefresh(alloc, home, home, policy);
+ for (0..100_000) |_| {
+ terminal = try runtime.pollRefresh(alloc, home, policy);
+ if (terminal != .none) break;
+ std.Thread.yield() catch std.atomic.spinLoopHint();
+ }
+ try std.testing.expectEqual(RefreshCompletion.adopted, terminal);
+ try std.testing.expectEqualStrings("refreshable-v2", runtime.items[0].name);
+
+ try writeTempFile(
+ &tmp,
+ "home/.fx/skills/added/SKILL.md",
+ "---\nname: added\ndescription: root manifest changed\n---\nbody\n",
+ );
+ _ = try runtime.requestRefresh(alloc, home, home, policy);
+ for (0..100_000) |_| {
+ terminal = try runtime.pollRefresh(alloc, home, policy);
+ if (terminal != .none) break;
+ std.Thread.yield() catch std.atomic.spinLoopHint();
+ }
+ try std.testing.expectEqual(RefreshCompletion.adopted, terminal);
+ try std.testing.expectEqual(@as(usize, 2), runtime.items.len);
+}
+
+test "overlapping skill refresh actions retain only the latest bounded action" {
+ const alloc = std.testing.allocator;
+ var runtime = Runtime{};
+ defer runtime.deinit(alloc);
+
+ try runtime.queueRefreshAction(alloc, 1, .list);
+ try runtime.queueRefreshAction(alloc, 2, .{ .show = "newest" });
+ runtime.fresh_through_generation = 2;
+
+ var ready = runtime.takeReadyRefreshAction() orelse
+ return error.MissingReadyRefreshAction;
+ defer ready.deinit(alloc);
+ try std.testing.expect(ready.succeeded);
+ switch (ready.action) {
+ .show => |name| try std.testing.expectEqualStrings("newest", name),
+ else => return error.ExpectedLatestShowAction,
+ }
+}
+
test "skill menu fills a bounded query range in display order" {
const skills = [_]Skill{
staticSkill("metadata-first", "zig workflow", .global_fx),
@@ -2278,12 +3775,12 @@ test "skill runtime replaces and frees owned discovery diagnostics" {
.scope = .candidate,
.cause = .{ .invalid_metadata = .missing_name },
};
- runtime.replaceLoaded(alloc, first_dir, &.{}, first_diagnostics);
+ try runtime.replaceLoaded(alloc, first_dir, &.{}, first_diagnostics);
try std.testing.expectEqual(@as(usize, 1), runtime.diagnostics.len);
try std.testing.expectEqualStrings("/tmp/first-skills/bad", runtime.diagnostics[0].path);
- runtime.replaceLoaded(
+ try runtime.replaceLoaded(
alloc,
try alloc.dupe(u8, "/tmp/second-skills"),
&.{},
diff --git a/src/main.zig b/src/main.zig
index b5314ce1d..e3e8c3a74 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -21,6 +21,7 @@ const app_entry_runtime = @import("core/app/app_entry_runtime.zig");
const acp_runner = @import("core/cli/acp_runner.zig");
const acp_server = @import("acp/server.zig");
const app_input_runtime = @import("core/app/app_input_runtime.zig");
+const input_full_transcript_runtime = @import("core/app/input_full_transcript_runtime.zig");
const input_submit_runtime = @import("core/app/input_submit_runtime.zig");
const core_input_runtime = @import("core/input/runtime.zig");
const input_queue_runtime = @import("core/app/input_queue_runtime.zig");
@@ -186,6 +187,7 @@ const RuntimeContextSnapshot = background_runtime.RuntimeContextSnapshot;
const footer_rows: u16 = 4;
const active_poll_timeout_ms: i32 = 8;
+const focused_ui_worker_poll_timeout_ms: i32 = 1;
const idle_wasm_poll_timeout_ms: i32 = 16;
const resize_debounce_ms: i64 = 100;
const max_transcript_bytes: usize = 256 * 1024;
@@ -198,6 +200,21 @@ const max_read_file_lines: usize = 400;
const max_read_file_line_len: usize = 2000;
const max_command_output_bytes: usize = 64 * 1024;
const input_escape_timeout_ms: i64 = 30;
+
+fn nativeLoopPollTimeoutMs(
+ default_timeout_ms: i32,
+ auth_refresh_active: bool,
+ skills_refresh_active: bool,
+ transcript_page_work_active: bool,
+) i32 {
+ return if (auth_refresh_active or
+ skills_refresh_active or
+ transcript_page_work_active)
+ @min(default_timeout_ms, focused_ui_worker_poll_timeout_ms)
+ else
+ default_timeout_ms;
+}
+
const max_prompt_history: usize = 100;
const ignored_list_entries = [_][]const u8{
@@ -395,6 +412,7 @@ const App = struct {
const HostConfigAppRuntime = app_host_config_runtime.Runtime(Self);
const BootstrapAppRuntime = app_bootstrap_runtime.Runtime(Self);
const InputAppRuntime = app_input_runtime.Runtime(Self);
+ const InputFullTranscriptRuntime = input_full_transcript_runtime.Runtime(Self);
const InputSubmitRuntime = input_submit_runtime.SubmitRuntime(Self);
const NotificationAppRuntime = app_notification_runtime.Runtime(
Self,
@@ -1007,11 +1025,25 @@ const App = struct {
}
pub fn loopPollTimeoutMs(ctx: *anyopaque, default_timeout_ms: i32) i32 {
- const self: *const App = @ptrCast(@alignCast(ctx));
- if (comptime !host_target.is_wasm) return default_timeout_ms;
+ const self: *App = @ptrCast(@alignCast(ctx));
+ if (comptime !host_target.is_wasm) {
+ return nativeLoopPollTimeoutMs(
+ default_timeout_ms,
+ self.auth.sourceInventoryRefreshActive(),
+ self.skills.refreshActive(),
+ self.fullTranscriptFocusedWorkActive(),
+ );
+ }
return if (self.pacer.hasPending()) default_timeout_ms else idle_wasm_poll_timeout_ms;
}
+ fn fullTranscriptFocusedWorkActive(self: *App) bool {
+ if (self.shell.fullTranscriptFocusedWorkActive()) return true;
+ const child = self.subagents.childConversationRuntime() orelse
+ return false;
+ return child.fullTranscriptFocusedWorkActive();
+ }
+
fn processNextCooperativePrompt(self: *App) !void {
if (comptime !host_target.is_wasm) return;
try app_process_runtime.Runtime(App).processNextCooperativePrompt(
@@ -1379,8 +1411,6 @@ const App = struct {
user_prompt_already_presented: bool,
intent: PromptSubmitIntent,
) !bool {
- try self.reloadSkills();
-
const source_images = if (recovery_checkpoint) |checkpoint|
checkpoint.user.images
else if (prompt_images) |images|
@@ -1881,10 +1911,47 @@ const App = struct {
return AgentAppRuntime.runSubagentChild(raw, turn, message, admission, cancel);
}
- pub fn reloadSkills(self: *App) !void {
- const loaded = try app_runtime_setup.loadSkills(std.heap.c_allocator, self.workspace_root, builtin_skills.root_policy);
- skill_runtime.traceDiagnostics("interactive_reload", loaded.diagnostics);
- self.skills.replaceLoaded(std.heap.c_allocator, loaded.dir, loaded.skills, loaded.diagnostics);
+ pub fn requestSkillsRefresh(self: *App) !u64 {
+ const home = try app_runtime_setup.resolveSkillsHome(std.heap.c_allocator);
+ defer if (home) |value| std.heap.c_allocator.free(value);
+ return self.skills.requestRefresh(
+ std.heap.c_allocator,
+ self.workspace_root,
+ home,
+ builtin_skills.root_policy,
+ );
+ }
+
+ pub fn collectPendingSkillRefresh(
+ self: *App,
+ pending: *input_submit_runtime.PendingSubmission,
+ ) !input_submit_runtime.PendingSkillRefresh {
+ if (comptime host_target.is_wasm) return .current;
+ const generation = pending.skill_refresh_generation orelse blk: {
+ const requested = try self.requestSkillsRefresh();
+ pending.skill_refresh_generation = requested;
+ break :blk requested;
+ };
+ return switch (self.skills.generationStatus(generation)) {
+ .pending => .pending,
+ .current => .current,
+ .failed => error.SkillCatalogRefreshFailed,
+ };
+ }
+
+ fn pollSkillsRefresh(self: *App) !skill_runtime.RefreshCompletion {
+ const completion = try self.skills.pollRefresh(
+ std.heap.c_allocator,
+ self.workspace_root,
+ builtin_skills.root_policy,
+ );
+ if (completion == .adopted) {
+ skill_runtime.traceDiagnostics(
+ "interactive_refresh",
+ self.skills.diagnostics,
+ );
+ }
+ return completion;
}
pub fn allowToolForSession(self: *App, tool_name: []const u8, target_path: []const u8) !void {
@@ -2814,6 +2881,17 @@ const App = struct {
pub fn loopCollectFacts(ctx: *anyopaque) !void {
const self: *App = @ptrCast(@alignCast(ctx));
if (!try WorkerAppRuntime.authorizeInteractiveAdmission(self)) return;
+
+ if (comptime !host_target.is_wasm) {
+ if (self.file_index.joinThreadIfDone(std.heap.c_allocator)) {
+ self.shell.render_requests.request(.footer);
+ }
+ switch (try self.pollSkillsRefresh()) {
+ .none, .unchanged => {},
+ .adopted, .failed => self.shell.render_requests.request(.footer),
+ }
+ try app_commands.Handlers(App).collectSkillsRefreshFacts(self);
+ }
InputSubmitRuntime.collectPendingSubmissionFacts(self);
if (!self.terminal_takeover.blocksFxSurface(&self.terminal)) {
@@ -2830,11 +2908,6 @@ const App = struct {
app_permission_runtime.monotonicMillis(),
);
- if (comptime !host_target.is_wasm) {
- if (self.file_index.joinThreadIfDone(std.heap.c_allocator)) {
- self.shell.render_requests.request(.footer);
- }
- }
if (try self.model_cache.pollLoadTransition()) {
RenderAppRuntime.requestActiveSurfaceFrame(self, .footer);
}
@@ -2854,6 +2927,7 @@ const App = struct {
try app_commands.Handlers(App).collectMcpAuthenticationFacts(self);
try app_commands.Handlers(App).collectMcpReloadFacts(self);
if (comptime host_profile.native_auth or host_profile.js_host_auth) {
+ try AuthAppRuntime.collectSourceInventoryFacts(self);
try AuthAppRuntime.collectSignInFacts(self);
}
if (comptime host_profile.native_auth) {
@@ -2900,13 +2974,78 @@ const App = struct {
if (comptime !host_target.is_wasm) {
try SessionAppRuntime.pollSessionPicker(self);
}
+ try self.shell.prewarmFullTranscriptPage(
+ self.fullTranscriptSidecarCapability(),
+ self.fullTranscriptDiffResolver(),
+ );
if (try self.shell.pollFullTranscriptPageLoad()) {
RenderAppRuntime.requestActiveSurfaceFrame(self, .modal);
}
+ if (!self.shell.fullTranscriptActive() and
+ self.shell.takeReadyFullTranscriptOpen() and
+ self.terminal.alternate_screen_owner == .none and
+ !self.approval_prompt.isActive())
+ {
+ try app_lifecycle.openFullTranscript(
+ self.alloc,
+ &self.terminal,
+ &self.shell,
+ &self.metrics,
+ );
+ debug_trace.logf(
+ "full_transcript",
+ "depth_transition from=inline to=full route=root trigger=ctrl_o",
+ .{},
+ );
+ RenderAppRuntime.requestActiveSurfaceFrame(self, .modal);
+ }
+ if (self.shell.takeFullTranscriptPreparationFailure()) {
+ if (self.shell.fullTranscriptActive()) {
+ try app_lifecycle.closeFullTranscript(
+ self.alloc,
+ &self.terminal,
+ &self.shell,
+ &self.metrics,
+ );
+ }
+ try self.writeDomainNotice(.{
+ .topic = "transcript",
+ .tone = .@"error",
+ .body = "Full transcript preparation failed. The reader was closed instead of showing stale content.",
+ }, true);
+ }
if (self.subagents.childConversationRuntime()) |child| {
+ try child.prewarmFullTranscriptPage(
+ null,
+ self.subagents.childFullTranscriptDiffResolver(),
+ );
if (try child.pollFullTranscriptPageLoad()) {
RenderAppRuntime.requestActiveSurfaceFrame(self, .modal);
}
+ if (!child.fullTranscriptActive() and
+ child.takeReadyFullTranscriptOpen())
+ {
+ _ = try self.subagents.setChildTranscriptPresentationDepth(
+ self.alloc,
+ .full,
+ );
+ debug_trace.logf(
+ "full_transcript",
+ "depth_transition from=inline to=full route=child trigger=ctrl_o",
+ .{},
+ );
+ RenderAppRuntime.requestActiveSurfaceFrame(self, .modal);
+ }
+ if (child.takeFullTranscriptPreparationFailure()) {
+ if (child.fullTranscriptActive()) {
+ _ = try self.subagents.closeChildTranscriptPresentation(self.alloc);
+ }
+ try self.writeDomainNotice(.{
+ .topic = "transcript",
+ .tone = .@"error",
+ .body = "The child full transcript reader was closed because its current page could not be prepared.",
+ }, true);
+ }
}
if (!self.terminal_takeover.blocksFxSurface(&self.terminal)) {
const input_now_ms = io_mod.milliTimestamp();
@@ -3572,6 +3711,14 @@ test "lightweight local commands do not request early threaded io" {
}
}
+test "focused UI workers retain a bounded native poll timeout" {
+ try std.testing.expectEqual(@as(i32, 8), nativeLoopPollTimeoutMs(8, false, false, false));
+ try std.testing.expectEqual(@as(i32, 1), nativeLoopPollTimeoutMs(8, true, false, false));
+ try std.testing.expectEqual(@as(i32, 1), nativeLoopPollTimeoutMs(8, false, true, false));
+ try std.testing.expectEqual(@as(i32, 1), nativeLoopPollTimeoutMs(8, false, false, true));
+ try std.testing.expectEqual(@as(i32, 1), nativeLoopPollTimeoutMs(8, true, true, true));
+}
+
test "footer runtime compatibility facade exports composeFooterFrame" {
_ = footer_runtime.composeFooterFrame;
}
diff --git a/src/ui/footer/render_input.zig b/src/ui/footer/render_input.zig
index 9c8eb022b..d7f3895d2 100644
--- a/src/ui/footer/render_input.zig
+++ b/src/ui/footer/render_input.zig
@@ -40,10 +40,40 @@ const SubagentStatus = @import("../../core/subagent/domain.zig").State;
pub const SkillsMenuProjection = struct {
active: bool = false,
items: []const skill_runtime.Skill = &.{},
+ actual_indices: []const u32 = &.{},
+ index_ready: bool = false,
source_filter: skill_runtime.SkillMenuSourceFilter = .all,
selected_index: usize = 0,
window_start: usize = 0,
query: []const u8 = "",
+
+ pub fn itemCount(self: SkillsMenuProjection) usize {
+ if (self.index_ready) return self.actual_indices.len;
+ return skill_runtime.skillMenuFilterQueryCount(
+ self.items,
+ self.source_filter,
+ self.query,
+ );
+ }
+
+ pub fn itemAt(
+ self: SkillsMenuProjection,
+ display_index: usize,
+ ) ?*const skill_runtime.Skill {
+ if (!self.index_ready) {
+ const actual_index = skill_runtime.skillMenuActualIndexAtQuery(
+ self.items,
+ self.source_filter,
+ self.query,
+ display_index,
+ ) orelse return null;
+ return &self.items[actual_index];
+ }
+ if (display_index >= self.actual_indices.len) return null;
+ const actual_index: usize = self.actual_indices[display_index];
+ if (actual_index >= self.items.len) return null;
+ return &self.items[actual_index];
+ }
};
pub const ModelMenuProjection = struct {
@@ -351,6 +381,8 @@ pub fn skillsMenuProjection(skills: *const skill_runtime.Runtime) SkillsMenuProj
return .{
.active = skills.menu.active,
.items = skills.items,
+ .actual_indices = skills.menu_index.actual_indices.items,
+ .index_ready = skills.menu_index_ready,
.source_filter = skills.menu.source_filter,
.selected_index = skills.menu.selected_index,
.window_start = skills.menu.window_start,
diff --git a/src/ui/footer/skills_menu_presentation.zig b/src/ui/footer/skills_menu_presentation.zig
index b541aa951..110d0d6c5 100644
--- a/src/ui/footer/skills_menu_presentation.zig
+++ b/src/ui/footer/skills_menu_presentation.zig
@@ -126,21 +126,28 @@ const SkillsMenuLayout = struct {
pub const PreparedSkillsMenu = struct {
layout: SkillsMenuLayout,
+ projection: SkillsMenuProjection,
source_filter: skill_runtime.SkillMenuSourceFilter,
catalog_empty: bool,
window_start: usize,
- visible_skills: []*const skill_runtime.Skill,
inline_name_column_width: ?usize,
scope_column_width: usize,
- pub fn deinit(self: *PreparedSkillsMenu, alloc: Allocator) void {
- if (self.visible_skills.len > 0) alloc.free(self.visible_skills);
+ pub fn deinit(self: *PreparedSkillsMenu, _: Allocator) void {
self.* = undefined;
}
pub fn rowCount(self: PreparedSkillsMenu) u16 {
return self.layout.row_count;
}
+
+ pub fn skillAtVisibleOffset(
+ self: PreparedSkillsMenu,
+ visible_offset: usize,
+ ) ?*const skill_runtime.Skill {
+ if (visible_offset >= self.layout.visible_items) return null;
+ return self.projection.itemAt(self.window_start + visible_offset);
+ }
};
pub fn prepareSkillsMenu(
@@ -164,7 +171,7 @@ pub fn prepareInlineSkillsMenu(
}
fn prepareSkillsMenuWithLayout(
- alloc: Allocator,
+ _: Allocator,
projection: SkillsMenuProjection,
layout: SkillsMenuLayout,
inline_mode: bool,
@@ -176,28 +183,18 @@ fn prepareSkillsMenuWithLayout(
layout.visible_items,
);
- var visible_skills: []*const skill_runtime.Skill = &.{};
- errdefer if (visible_skills.len > 0) alloc.free(visible_skills);
- if (layout.visible_items > 0) {
- visible_skills = try alloc.alloc(*const skill_runtime.Skill, layout.visible_items);
- const written = skill_runtime.fillSkillMenuRangeAtQuery(
- projection.items,
- projection.source_filter,
- projection.query,
- window_start,
- visible_skills,
- );
- if (written != visible_skills.len) return error.InconsistentSkillsMenuProjection;
- }
-
return .{
.layout = layout,
+ .projection = projection,
.source_filter = projection.source_filter,
.catalog_empty = projection.items.len == 0,
.window_start = window_start,
- .visible_skills = visible_skills,
.inline_name_column_width = if (inline_mode) matching_name_column_width(projection) else null,
- .scope_column_width = scopeColumnWidth(visible_skills),
+ .scope_column_width = scopeColumnWidth(
+ projection,
+ window_start,
+ layout.visible_items,
+ ),
};
}
@@ -262,9 +259,8 @@ pub fn composeSkillsMenuRow(
const visible_offset = body_offset / layout.item_stride;
if (visible_offset >= layout.visible_items) return row;
- if (visible_offset >= prepared.visible_skills.len) return row;
const display_index = prepared.window_start + visible_offset;
- const skill = prepared.visible_skills[visible_offset].*;
+ const skill = (prepared.skillAtVisibleOffset(visible_offset) orelse return row).*;
// item_stride is 1: every skill is a single row.
return composeSkillTitleRow(
@@ -279,9 +275,9 @@ pub fn composeSkillsMenuRow(
fn matching_name_column_width(projection: SkillsMenuProjection) usize {
var col: usize = 0;
- for (projection.items) |skill| {
- if (!skill_runtime.skillSourceMatchesFilter(skill.source, projection.source_filter)) continue;
- if (!skill_runtime.skill_matches_menu_query(skill, projection.query)) continue;
+ var display_index: usize = 0;
+ while (display_index < projection.itemCount()) : (display_index += 1) {
+ const skill = projection.itemAt(display_index) orelse continue;
col = @max(col, display_width.visibleWidth(skill.name));
}
return col;
@@ -289,9 +285,15 @@ fn matching_name_column_width(projection: SkillsMenuProjection) usize {
// Widest source-scope label across the visible skills, so the scope column
// lines up vertically instead of drifting with each row's own width.
-fn scopeColumnWidth(visible_skills: []const *const skill_runtime.Skill) usize {
+fn scopeColumnWidth(
+ projection: SkillsMenuProjection,
+ window_start: usize,
+ visible_items: u16,
+) usize {
var col: usize = 0;
- for (visible_skills) |skill| {
+ var visible_offset: usize = 0;
+ while (visible_offset < visible_items) : (visible_offset += 1) {
+ const skill = projection.itemAt(window_start + visible_offset) orelse continue;
col = @max(col, display_width.visibleWidth(skillSourceScopeLabel(skill.source)));
}
return col;
@@ -462,7 +464,7 @@ fn cloneClippedRow(alloc: Allocator, text: []const u8, width: u16) !std.ArrayLis
}
fn visibleSkillCount(projection: SkillsMenuProjection) usize {
- return skill_runtime.skillMenuFilterQueryCount(projection.items, projection.source_filter, projection.query);
+ return projection.itemCount();
}
test "skills menu labels native workspace skills with lowercase product name" {
@@ -605,6 +607,30 @@ test "inline skills menu shows six roomy items and prioritizes selection when ti
try std.testing.expect(std.mem.find(u8, tiny_row.items, "Skills") == null);
}
+test "prepared skills menu borrows one indexed query without allocating" {
+ const skills = [_]skill_runtime.Skill{
+ .{ .name = "ignored", .description = "", .path = "/skills/ignored", .source = .global_fx },
+ .{ .name = "selected", .description = "", .path = "/skills/selected", .source = .global_codex },
+ };
+ const actual_indices = [_]u32{1};
+ const projection: SkillsMenuProjection = .{
+ .active = true,
+ .items = &skills,
+ .selected_index = 0,
+ .actual_indices = &actual_indices,
+ .index_ready = true,
+ };
+ var failing = std.testing.FailingAllocator.init(
+ std.testing.allocator,
+ .{ .fail_index = 0 },
+ );
+
+ var prepared = try prepareInlineSkillsMenu(failing.allocator(), projection, 4);
+ defer prepared.deinit(failing.allocator());
+ try std.testing.expectEqual(@as(u16, 3), prepared.rowCount());
+ try std.testing.expectEqualStrings("selected", prepared.skillAtVisibleOffset(0).?.name);
+}
+
test "inline skills menu keeps an empty result visible at one row" {
const alloc = std.testing.allocator;
const skills = [_]skill_runtime.Skill{.{
@@ -728,9 +754,9 @@ test "prepared skills menu aligns sources beside the widest visible name" {
var prepared = try prepareInlineSkillsMenu(alloc, projection, 4);
defer prepared.deinit(alloc);
try std.testing.expectEqual(@as(usize, 2), prepared.window_start);
- try std.testing.expectEqual(@as(usize, 2), prepared.visible_skills.len);
- try std.testing.expectEqualStrings("a", prepared.visible_skills[0].name);
- try std.testing.expectEqualStrings("much-longer", prepared.visible_skills[1].name);
+ try std.testing.expectEqual(@as(u16, 2), prepared.layout.visible_items);
+ try std.testing.expectEqualStrings("a", prepared.skillAtVisibleOffset(0).?.name);
+ try std.testing.expectEqualStrings("much-longer", prepared.skillAtVisibleOffset(1).?.name);
var first = try composeSkillsMenuRow(alloc, prepared, 2, 80);
defer first.deinit(alloc);
diff --git a/src/ui/full_transcript_screen.zig b/src/ui/full_transcript_screen.zig
index 1c6d931e6..d4aad6020 100644
--- a/src/ui/full_transcript_screen.zig
+++ b/src/ui/full_transcript_screen.zig
@@ -259,6 +259,7 @@ test "interruptible full projection rendering retries after cancellation" {
std.math.maxInt(u16),
.{ .fixed_offset = 0 },
&checkpoint,
+ std.math.maxInt(usize),
),
);
@@ -3720,6 +3721,7 @@ const ProjectionRowWalker = struct {
row_has_bytes: bool = false,
window: ?Window = null,
build_checkpoint: ?*BuildCheckpoint = null,
+ max_output_bytes: usize = std.math.maxInt(usize),
const Window = struct {
writer: std.Io.Writer.Allocating,
@@ -3749,7 +3751,7 @@ const ProjectionRowWalker = struct {
alloc: Allocator,
cols: u16,
start_row: u32,
- visible_rows: u16,
+ visible_rows: u32,
start_checkpoint: ProjectionCheckpoint,
build_checkpoint_ptr: ?*BuildCheckpoint,
) ProjectionRowWalker {
@@ -3792,7 +3794,12 @@ const ProjectionRowWalker = struct {
fn emit(self: *ProjectionRowWalker, bytes: []const u8) !void {
if (self.window) |*window| {
- if (self.row >= window.start_row) try window.writer.writer.writeAll(bytes);
+ if (self.row >= window.start_row) {
+ if (bytes.len > self.max_output_bytes -| window.writer.written().len) {
+ return error.PreparedWindowTooLarge;
+ }
+ try window.writer.writer.writeAll(bytes);
+ }
}
}
@@ -5364,6 +5371,29 @@ pub fn renderProjectionViewportSourceInterruptible(
visible_rows,
.{ .fixed_offset = scroll_offset },
checkpoint,
+ std.math.maxInt(usize),
+ );
+}
+
+pub fn renderProjectionViewportSourceBoundedInterruptible(
+ alloc: Allocator,
+ projection: *Projection,
+ capability: ?*session_child_store.SessionChildCapability,
+ cols: u16,
+ visible_rows: u16,
+ scroll_offset: u32,
+ max_bytes: usize,
+ checkpoint: ?*BuildCheckpoint,
+) ![]u8 {
+ return renderProjectionViewportSourceWithSelection(
+ alloc,
+ projection,
+ capability,
+ cols,
+ visible_rows,
+ .{ .fixed_offset = scroll_offset },
+ checkpoint,
+ max_bytes,
);
}
@@ -5410,6 +5440,7 @@ pub fn renderProjectionViewportSourceWithSelectorInterruptible(
visible_rows,
.{ .selector = offset_selector },
checkpoint,
+ std.math.maxInt(usize),
);
}
@@ -5426,6 +5457,7 @@ fn renderProjectionViewportSourceWithSelection(
visible_rows: u16,
selection: ViewportSelection,
checkpoint: ?*BuildCheckpoint,
+ max_bytes: usize,
) ![]u8 {
if (cols == 0 or visible_rows == 0) return error.InvalidViewport;
while (true) {
@@ -5442,7 +5474,7 @@ fn renderProjectionViewportSourceWithSelection(
break :blk selector.select_offset(selector.context, measurement, visible_rows);
},
};
- return renderProjectionWindow(alloc, projection, capability, cols, visible_rows, offset, checkpoint) catch |err| switch (err) {
+ return renderProjectionWindow(alloc, projection, capability, cols, visible_rows, offset, checkpoint, max_bytes) catch |err| switch (err) {
error.StoredSegmentDegraded => continue,
else => |other| return other,
};
@@ -5457,6 +5489,7 @@ fn renderProjectionWindow(
visible_rows: u16,
scroll_offset: u32,
checkpoint: ?*BuildCheckpoint,
+ max_bytes: usize,
) ![]u8 {
const start = projection.windowStart(cols, scroll_offset);
debug_trace.logf(
@@ -5481,6 +5514,7 @@ fn renderProjectionWindow(
checkpoint,
);
defer walker.deinit();
+ walker.max_output_bytes = max_bytes;
_ = try walkProjectionSegments(
alloc,
projection,
diff --git a/src/ui/subagent/runtime.zig b/src/ui/subagent/runtime.zig
index a45aa3d62..7a419ee0a 100644
--- a/src/ui/subagent/runtime.zig
+++ b/src/ui/subagent/runtime.zig
@@ -2030,6 +2030,9 @@ pub const Runtime = struct {
);
runtime.restoreFullTranscriptViewport(bookmark);
self.child.presentation_transcript_depth = bookmark.presentation.depth;
+ if (bookmark.presentation.depth == .full) {
+ runtime.deferRestoredFullTranscriptOpen();
+ }
} else if (runtime.transcriptPresentationDepth() !=
self.child.presentation_transcript_depth)
{
diff --git a/src/ui/transcript/full_transcript_worker.zig b/src/ui/transcript/full_transcript_worker.zig
index 182ac4b50..bcde7ddc9 100644
--- a/src/ui/transcript/full_transcript_worker.zig
+++ b/src/ui/transcript/full_transcript_worker.zig
@@ -8,8 +8,51 @@ const full_transcript_screen = @import("../full_transcript_screen.zig");
const build_checkpoint = @import("../render_engine/build_checkpoint.zig");
const transcript_blocks = @import("../render_engine/transcript_blocks.zig");
const command_output_runtime = @import("command_output_runtime.zig");
+const source_preparation = @import("source_preparation.zig");
+const transcript_presentation = @import("../../core/output/transcript_presentation.zig");
const Allocator = std.mem.Allocator;
+pub const prepared_cache_overscan_max_rows: u16 = 192;
+pub const prepared_cache_max_bytes: usize = 2 * 1024 * 1024;
+
+pub fn preparedWindowRequest(
+ page_request: full_transcript_page.Request,
+ total_rows: u32,
+ target_offset: u32,
+ visible_rows: u16,
+) WindowRequest {
+ const bounded_visible_rows = @max(visible_rows, 1);
+ const cache_rows: u16 = @intCast(@max(
+ @as(u32, bounded_visible_rows),
+ @min(
+ @as(u32, prepared_cache_overscan_max_rows),
+ @as(u32, bounded_visible_rows) *| 3,
+ ),
+ ));
+ const overscan = (cache_rows -| bounded_visible_rows) / 2;
+ const max_start = total_rows -| cache_rows;
+ return .{
+ .page_request = page_request,
+ .target_offset = target_offset,
+ .start_row = @min(target_offset -| overscan, max_start),
+ .row_count = cache_rows,
+ };
+}
+
+test "prepared window always covers one complete visible viewport" {
+ const request = preparedWindowRequest(
+ .{ .content_revision = 1, .cols = 80, .anchor = .tail },
+ 1_000,
+ 780,
+ 220,
+ );
+ try std.testing.expectEqual(@as(u16, 220), request.row_count);
+ try std.testing.expect(request.start_row <= request.target_offset);
+ try std.testing.expect(
+ request.start_row + @as(u32, request.row_count) >=
+ request.target_offset + 220,
+ );
+}
pub const FullDiffSnapshot = struct {
marker_id: u32,
@@ -30,6 +73,8 @@ pub const Source = struct {
full_diff_lifecycles: std.ArrayList(types.ToolLifecycleId) = .empty,
styles: transcript_blocks.Styles,
capability: ?session_child_store.SessionChildCapability = null,
+ presentation: transcript_presentation.State = .{},
+ visible_rows: u16 = 1,
pub fn deinit(self: *Source, alloc: Allocator) void {
for (self.entries.items) |*entry| entry.deinit(alloc);
@@ -106,13 +151,35 @@ pub const Source = struct {
}
};
+pub const InstalledSource = struct {
+ request: full_transcript_page.Request,
+ range: full_transcript_page.SourceRange,
+ capability: ?session_child_store.SessionChildCapability = null,
+
+ pub fn deinit(self: *InstalledSource) void {
+ if (self.capability) |*capability| capability.deinit();
+ self.* = undefined;
+ }
+};
+
+pub const PreparedWindow = struct {
+ source: source_preparation.TranscriptPreparationSource,
+ start_row: u32,
+ target_offset: u32 = 0,
+
+ pub fn deinit(self: *PreparedWindow, alloc: Allocator) void {
+ self.source.deinit(alloc);
+ self.* = undefined;
+ }
+};
+
pub const Task = struct {
thread: ?std.Thread = null,
done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
cancel_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
source: Source,
- source_owned: bool = true,
projection: ?full_transcript_screen.Projection = null,
+ prepared_window: ?PreparedWindow = null,
failure: ?anyerror = null,
pub fn deinit(self: *Task) void {
@@ -121,7 +188,10 @@ pub const Task = struct {
if (self.projection) |*projection| {
projection.deinit(std.heap.c_allocator);
}
- if (self.source_owned) self.source.deinit(std.heap.c_allocator);
+ if (self.prepared_window) |*window| {
+ window.deinit(std.heap.c_allocator);
+ }
+ self.source.deinit(std.heap.c_allocator);
std.heap.c_allocator.destroy(self);
}
@@ -131,10 +201,22 @@ pub const Task = struct {
return projection;
}
- pub fn takeSource(self: *Task) Source {
- std.debug.assert(self.source_owned);
- self.source_owned = false;
- return self.source;
+ pub fn takeInstalledSource(self: *Task) InstalledSource {
+ const capability = self.source.capability;
+ self.source.capability = null;
+ return .{
+ .request = self.source.request,
+ .range = self.source.range,
+ .capability = capability,
+ };
+ }
+
+ pub fn takePreparedWindow(
+ self: *Task,
+ ) ?PreparedWindow {
+ const window = self.prepared_window orelse return null;
+ self.prepared_window = null;
+ return window;
}
fn cancelled(context: *anyopaque) bool {
@@ -187,6 +269,29 @@ pub const Task = struct {
self.done.store(true, .release);
return;
};
+ const visible_rows = @max(self.source.visible_rows, 1);
+ const selected = self.source.presentation.select_visual_offset(
+ measurement.total_rows,
+ visible_rows,
+ measurement.item_rows,
+ );
+ const window_request = preparedWindowRequest(
+ self.source.request,
+ measurement.total_rows,
+ selected.offset,
+ visible_rows,
+ );
+ const prepared_window = prepareWindowInterruptible(
+ alloc,
+ &projection,
+ if (self.source.capability) |*capability| capability else null,
+ window_request,
+ &checkpoint,
+ ) catch |err| {
+ self.failure = err;
+ self.done.store(true, .release);
+ return;
+ };
debug_trace.logf(
"full_transcript_cache",
"page_built revision={d} cols={d} entries={d} details={d} blocks={d} segments={d} rows={d}",
@@ -201,6 +306,7 @@ pub const Task = struct {
},
);
self.projection = projection;
+ self.prepared_window = prepared_window;
projection_owned = false;
self.done.store(true, .release);
}
@@ -303,3 +409,147 @@ pub const Load = struct {
self.task = task;
}
};
+
+pub const WindowRequest = struct {
+ page_request: full_transcript_page.Request,
+ target_offset: u32,
+ start_row: u32,
+ row_count: u16,
+};
+
+pub const WindowTask = struct {
+ thread: ?std.Thread = null,
+ done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ cancel_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
+ request: WindowRequest,
+ projection: *full_transcript_screen.Projection,
+ capability: ?*session_child_store.SessionChildCapability,
+ prepared_window: ?PreparedWindow = null,
+ failure: ?anyerror = null,
+
+ fn run(self: *WindowTask) void {
+ const alloc = std.heap.c_allocator;
+ var checkpoint = build_checkpoint.BuildCheckpoint.init(
+ self,
+ WindowTask.cancelled,
+ );
+ self.prepared_window = prepareWindowInterruptible(
+ alloc,
+ self.projection,
+ self.capability,
+ self.request,
+ &checkpoint,
+ ) catch |err| {
+ self.failure = err;
+ self.done.store(true, .release);
+ return;
+ };
+ self.done.store(true, .release);
+ }
+
+ fn cancelled(context: *anyopaque) bool {
+ const self: *WindowTask = @ptrCast(@alignCast(context));
+ return self.cancel_requested.load(.acquire);
+ }
+
+ pub fn takePreparedWindow(self: *WindowTask) ?PreparedWindow {
+ const window = self.prepared_window orelse return null;
+ self.prepared_window = null;
+ return window;
+ }
+
+ pub fn deinit(self: *WindowTask) void {
+ self.cancel_requested.store(true, .release);
+ if (self.thread) |thread| thread.join();
+ if (self.prepared_window) |*window| {
+ window.deinit(std.heap.c_allocator);
+ }
+ std.heap.c_allocator.destroy(self);
+ }
+};
+
+fn prepareWindowInterruptible(
+ alloc: Allocator,
+ projection: *full_transcript_screen.Projection,
+ capability: ?*session_child_store.SessionChildCapability,
+ request: WindowRequest,
+ checkpoint: ?*build_checkpoint.BuildCheckpoint,
+) !PreparedWindow {
+ const bytes = try full_transcript_screen.renderProjectionViewportSourceBoundedInterruptible(
+ alloc,
+ projection,
+ capability,
+ request.page_request.cols,
+ request.row_count,
+ request.start_row,
+ prepared_cache_max_bytes,
+ checkpoint,
+ );
+ const source = try source_preparation.prepareIndexedFullTranscriptWindowSourceInterruptible(
+ alloc,
+ bytes,
+ request.page_request.cols,
+ checkpoint,
+ );
+ return .{
+ .source = source,
+ .start_row = request.start_row,
+ .target_offset = request.target_offset,
+ };
+}
+
+pub const WindowLoad = struct {
+ task: ?*WindowTask = null,
+
+ pub fn deinit(self: *WindowLoad) void {
+ if (self.task) |task| task.deinit();
+ self.* = .{};
+ }
+
+ pub fn busy(self: *const WindowLoad) bool {
+ return self.task != null;
+ }
+
+ pub fn schedule(
+ self: *WindowLoad,
+ request: WindowRequest,
+ projection: *full_transcript_screen.Projection,
+ capability: ?*session_child_store.SessionChildCapability,
+ ) !void {
+ if (self.task != null) return error.FullTranscriptWindowWorkerBusy;
+ const task = try std.heap.c_allocator.create(WindowTask);
+ task.* = .{
+ .request = request,
+ .projection = projection,
+ .capability = capability,
+ };
+ if (comptime builtin.single_threaded) {
+ task.run();
+ self.task = task;
+ return;
+ }
+ task.thread = std.Thread.spawn(.{}, WindowTask.run, .{task}) catch |err| {
+ std.heap.c_allocator.destroy(task);
+ return err;
+ };
+ self.task = task;
+ }
+
+ pub fn cancelActive(self: *WindowLoad) void {
+ const task = self.task orelse return;
+ task.cancel_requested.store(true, .release);
+ }
+
+ pub fn hasTarget(self: *const WindowLoad, target_offset: u32) bool {
+ const task = self.task orelse return false;
+ return task.request.target_offset == target_offset and
+ !task.cancel_requested.load(.acquire);
+ }
+
+ pub fn takeCompleted(self: *WindowLoad) ?*WindowTask {
+ const task = self.task orelse return null;
+ if (!task.done.load(.acquire)) return null;
+ self.task = null;
+ return task;
+ }
+};
diff --git a/src/ui/transcript/painter.zig b/src/ui/transcript/painter.zig
index e640b8f9d..c66bf559d 100644
--- a/src/ui/transcript/painter.zig
+++ b/src/ui/transcript/painter.zig
@@ -1305,6 +1305,7 @@ fn prepareTranscriptSurfacePaintWithOwnedSource(
true,
false,
.apply,
+ null,
);
if (source.bytes.len > 0) {
prepared.owns_bytes = true;
@@ -1330,6 +1331,7 @@ pub fn prepareTranscriptSurfacePaintFromSourceForArea(
false,
false,
.apply,
+ null,
);
}
@@ -1350,6 +1352,7 @@ pub fn preparePreselectedTranscriptSurfacePaintFromSourceForArea(
false,
false,
.skip,
+ null,
);
}
@@ -1371,6 +1374,29 @@ pub fn prepareTranscriptSurfacePaintFromSourceForFrame(
false,
allow_projection_rebase,
.apply,
+ null,
+ );
+}
+
+pub fn prepareIndexedFullTranscriptSurfacePaintForArea(
+ self: anytype,
+ alloc: Allocator,
+ metrics: *Metrics,
+ source: *const TranscriptPreparationSource,
+ area: render_engine.frame_layout.FrameRect,
+ visual_offset: u32,
+) !PreparedTranscriptSurfacePaint {
+ return prepareTranscriptSurfacePaintInternal(
+ self,
+ alloc,
+ metrics,
+ 0,
+ area,
+ source,
+ false,
+ false,
+ .skip,
+ visual_offset,
);
}
@@ -1649,6 +1675,7 @@ fn prepareTranscriptSurfacePaintInternal(
commit_runtime_state: bool,
allow_projection_rebase: bool,
resize_history_policy: ResizeHistoryPolicy,
+ forced_visual_offset: ?u32,
) !PreparedTranscriptSurfacePaint {
if (commit_runtime_state) try self.ensurePaintReservation(alloc);
@@ -1789,7 +1816,6 @@ fn prepareTranscriptSurfacePaintInternal(
var repl_line_idx: usize = total_lines;
var tracked_visible_line: ?usize = null;
const precomputed_plain_lines =
- !self.fullTranscriptActive() and
!effective_replaceable_last_line and
tracked_entry_start_line == null and
total_lines == transcript_line_count and
@@ -2039,6 +2065,41 @@ fn prepareTranscriptSurfacePaintInternal(
self.tail_viewport_resolution = resolution;
}
}
+ if (forced_visual_offset) |visual_offset| {
+ const remaining_visual_rows = projectionRemainingVisualRows(
+ &prepared,
+ visual_offset,
+ ) orelse return error.InvalidTranscriptTransition;
+ const projection_rows = @min(
+ remaining_visual_rows,
+ @as(u32, visible_rows),
+ );
+ if (projection_rows == 0) return error.InvalidTranscriptTransition;
+ const visual_end = visual_offset + projection_rows;
+ const start = sourceStartPosition(&prepared, visual_offset) orelse
+ return error.InvalidTranscriptTransition;
+ const boundary = preparedProjectionBoundary(
+ &prepared,
+ self.layout.cols,
+ visual_end,
+ ) orelse return error.InvalidTranscriptTransition;
+ try replacePreparedProjectionResumeBytes(
+ alloc,
+ &prepared,
+ self.layout.cols,
+ visual_offset,
+ );
+ prepared.projection_visual_rows = @intCast(projection_rows);
+ prepared.projection_ends_with_newline = boundary.line_terminated;
+ viewport_selection_snapshot.start_line = start.line;
+ viewport_selection_snapshot.partial_skip_rows = start.intra_line_rows;
+ viewport_selection_snapshot.line_count = prepared.sourceVisibleLines().len;
+ viewport_selection_snapshot.last_visible_row = 0;
+ viewport_selection_snapshot.last_visible_row_blank = false;
+ viewport_selection_snapshot.replaceable_start_row = top_row;
+ rows_budget = visible_rows - @as(u16, @intCast(projection_rows));
+ welcome_decision = .none;
+ }
const start_line = viewport_selection_snapshot.start_line;
const partial_skip_rows = viewport_selection_snapshot.partial_skip_rows;
const render_total_lines = viewport_selection_snapshot.line_count;
diff --git a/src/ui/transcript/runtime.zig b/src/ui/transcript/runtime.zig
index 6f63cb660..37a9f7dde 100644
--- a/src/ui/transcript/runtime.zig
+++ b/src/ui/transcript/runtime.zig
@@ -331,6 +331,7 @@ pub const TranscriptTransition = struct {
body_disposition: TranscriptBodyDisposition = .paint,
target_flow: []u8,
target_flow_owned: bool = true,
+ borrows_full_page: bool = false,
presentation_resume_bytes: []u8 = &.{},
presentation_pending_wrap: bool = false,
presentation_valid: bool = false,
@@ -747,7 +748,7 @@ test "closing the full transcript resets bounded paging to the tail" {
));
}
-test "live full transcript content requests one frame per revision stride" {
+test "live command keeps the full transcript stable until completion" {
const alloc = std.testing.allocator;
var runtime = TranscriptRuntime{
.layout = .{
@@ -770,19 +771,267 @@ test "live full transcript content requests one frame per revision stride" {
.anchor = .tail,
},
.range = .{ .start = 0, .end = 0 },
- .styles = .{},
},
.projection = .{ .styles = .{} },
},
};
defer runtime.deinit(alloc);
- for (0..full_transcript_page.live_refresh_revision_stride - 1) |_| {
+ for (0..32) |_| {
runtime.markTranscriptContentDirty();
try std.testing.expect(!runtime.render_requests.hasReason(.transcript));
}
+ runtime.command_output_display.open_command_block = null;
runtime.markTranscriptContentDirty();
try std.testing.expect(runtime.render_requests.hasReason(.transcript));
+
+ runtime.command_output_display.open_command_block = 0;
+ runtime.full_transcript_open_request = runtime.desiredFullTranscriptPageRequest();
+ try runtime.prewarmFullTranscriptPage(null, null);
+ try std.testing.expect(runtime.full_transcript_page_load.busy());
+}
+
+test "installed full transcript stays stable only while its command is active" {
+ var runtime = TranscriptRuntime{
+ .layout = .{
+ .rows = 24,
+ .cols = 80,
+ .content_bottom = 20,
+ .divider_top_row = 21,
+ .input_row = 22,
+ .divider_bottom_row = 23,
+ .hint_row = 24,
+ },
+ .full_transcript_content_revision = 41,
+ .full_transcript_installed_page = .{
+ .source = .{
+ .request = .{
+ .content_revision = 40,
+ .cols = 80,
+ .anchor = .tail,
+ },
+ .range = .{ .start = 0, .end = 0 },
+ },
+ .projection = .{ .styles = .{} },
+ },
+ };
+ defer runtime.deinit(std.testing.allocator);
+
+ try std.testing.expect(runtime.installedFullTranscriptPageProjection() == null);
+ runtime.full_transcript_prepared_page_visible = true;
+ try std.testing.expect(runtime.installedFullTranscriptPageProjection() != null);
+ runtime.full_transcript_prepared_page_visible = false;
+ runtime.command_output_display.open_command_block = 0;
+ try std.testing.expect(runtime.installedFullTranscriptPageProjection() != null);
+ runtime.full_transcript_content_revision = 48;
+ try std.testing.expect(runtime.installedFullTranscriptPageProjection() != null);
+ runtime.command_output_display.open_command_block = null;
+ try std.testing.expect(runtime.installedFullTranscriptPageProjection() == null);
+}
+
+test "active full transcript keeps its installed page while replacement loads" {
+ const alloc = std.testing.allocator;
+ const page_alloc = std.heap.c_allocator;
+ const prepared_source = try source_preparation.prepareIndexedFullTranscriptWindowSourceInterruptible(
+ page_alloc,
+ try page_alloc.dupe(u8, "installed page\n"),
+ 80,
+ null,
+ );
+ var runtime = TranscriptRuntime{
+ .layout = .{
+ .rows = 24,
+ .cols = 80,
+ .content_bottom = 20,
+ .divider_top_row = 21,
+ .input_row = 22,
+ .divider_bottom_row = 23,
+ .hint_row = 24,
+ },
+ .full_transcript = .{ .depth = .full },
+ .full_transcript_content_revision = 41,
+ .full_transcript_installed_page = .{
+ .source = .{
+ .request = .{
+ .content_revision = 40,
+ .cols = 80,
+ .anchor = .tail,
+ },
+ .range = .{ .start = 0, .end = 0 },
+ },
+ .projection = .{ .styles = .{} },
+ .prepared_window = .{
+ .source = prepared_source,
+ .start_row = 0,
+ },
+ },
+ };
+ defer runtime.deinit(alloc);
+ const installed = &runtime.full_transcript_installed_page.?.projection;
+ const visible = try runtime.preparedFullTranscriptPageProjectionInterruptible(
+ null,
+ null,
+ null,
+ );
+ try std.testing.expect(visible == installed);
+ try std.testing.expect(runtime.full_transcript_page_load.busy());
+}
+
+test "active full transcript defers repaint while width replacement loads" {
+ const alloc = std.testing.allocator;
+ const page_alloc = std.heap.c_allocator;
+ const prepared_source = try source_preparation.prepareIndexedFullTranscriptWindowSourceInterruptible(
+ page_alloc,
+ try page_alloc.dupe(u8, "installed page\n"),
+ 80,
+ null,
+ );
+ var runtime = TranscriptRuntime{
+ .layout = .{
+ .rows = 24,
+ .cols = 100,
+ .content_bottom = 20,
+ .divider_top_row = 21,
+ .input_row = 22,
+ .divider_bottom_row = 23,
+ .hint_row = 24,
+ },
+ .full_transcript = .{ .depth = .full },
+ .full_transcript_content_revision = 41,
+ .full_transcript_installed_page = .{
+ .source = .{
+ .request = .{
+ .content_revision = 40,
+ .cols = 80,
+ .anchor = .tail,
+ },
+ .range = .{ .start = 0, .end = 0 },
+ },
+ .projection = .{ .styles = .{} },
+ .prepared_window = .{
+ .source = prepared_source,
+ .start_row = 0,
+ },
+ },
+ };
+ defer runtime.deinit(alloc);
+ try std.testing.expectError(
+ error.InputPending,
+ runtime.preparedFullTranscriptPageProjectionInterruptible(
+ null,
+ null,
+ null,
+ ),
+ );
+ try std.testing.expect(runtime.full_transcript_page_load.busy());
+}
+
+test "cross-page replacement preserves its pending boundary until adoption" {
+ const alloc = std.testing.allocator;
+ const page_alloc = std.heap.c_allocator;
+ const Case = struct {
+ installed_anchor: full_transcript_page.Anchor,
+ boundary_index: usize,
+ };
+ const cases = [_]Case{
+ .{ .installed_anchor = .tail, .boundary_index = 743 },
+ .{ .installed_anchor = .{ .entry_index = 743 }, .boundary_index = 871 },
+ };
+
+ for (cases) |case| {
+ var runtime = TranscriptRuntime{
+ .layout = .{
+ .rows = 24,
+ .cols = 80,
+ .content_bottom = 20,
+ .divider_top_row = 21,
+ .input_row = 22,
+ .divider_bottom_row = 23,
+ .hint_row = 24,
+ },
+ .owned_top_row = 1,
+ .full_transcript = .{ .depth = .full, .follow_tail = false },
+ };
+ defer runtime.deinit(alloc);
+ for (0..1_000) |_| {
+ _ = try runtime.appendRawTranscriptEntryClassified(
+ alloc,
+ "row\n",
+ .unknown_raw,
+ );
+ }
+
+ const installed_request = full_transcript_page.Request{
+ .content_revision = runtime.full_transcript_content_revision,
+ .cols = runtime.layout.cols,
+ .anchor = case.installed_anchor,
+ };
+ runtime.full_transcript_installed_page = .{
+ .source = .{
+ .request = installed_request,
+ .range = full_transcript_page.sourceRange(
+ installed_request,
+ runtime.entries.items.len,
+ ),
+ },
+ .projection = .{ .styles = .{} },
+ .prepared_window = .{
+ .source = try source_preparation.prepareIndexedFullTranscriptWindowSourceInterruptible(
+ page_alloc,
+ try page_alloc.dupe(u8, "installed page\n"),
+ runtime.layout.cols,
+ null,
+ ),
+ .start_row = 0,
+ },
+ };
+ runtime.full_transcript_page_anchor = .{ .entry_index = case.boundary_index };
+ runtime.full_transcript = runtime.full_transcript.select_page_boundary(
+ runtime.entries.items[case.boundary_index].id(),
+ );
+ const before = runtime.full_transcript.snapshot();
+
+ try std.testing.expectError(
+ error.InputPending,
+ runtime.preparedFullTranscriptPageProjectionInterruptible(
+ null,
+ null,
+ null,
+ ),
+ );
+ try std.testing.expectEqualDeep(before, runtime.full_transcript.snapshot());
+ try std.testing.expect(runtime.full_transcript_page_load.busy());
+ }
+}
+
+test "restored full transcript opens without replacing its exact offset" {
+ const alloc = std.testing.allocator;
+ var runtime = TranscriptRuntime{
+ .layout = .{
+ .rows = 12,
+ .cols = 60,
+ .content_bottom = 8,
+ .divider_top_row = 9,
+ .input_row = 10,
+ .divider_bottom_row = 11,
+ .hint_row = 12,
+ },
+ .full_transcript = .{
+ .depth = .full,
+ .scroll_rows = 47,
+ .follow_tail = false,
+ },
+ };
+ defer runtime.deinit(alloc);
+
+ runtime.deferRestoredFullTranscriptOpen();
+ try std.testing.expectEqual(
+ transcript_presentation.Depth.inline_mode,
+ runtime.full_transcript.depth,
+ );
+ try std.testing.expect(try runtime.setTranscriptPresentationDepth(alloc, .full));
+ const selected = runtime.full_transcript.select_visual_offset(100, 8, &.{});
+ try std.testing.expectEqual(@as(u32, 47), selected.offset);
}
test "full transcript viewport snapshot restores reading position" {
@@ -860,41 +1109,36 @@ test "full transcript page snapshot retains active command records" {
);
}
-test "full transcript loading projection preserves restored viewport intent" {
+test "full transcript prewarm rejects an oversized main-thread snapshot" {
const alloc = std.testing.allocator;
- var runtime = TranscriptRuntime{
- .layout = .{
- .rows = 12,
- .cols = 60,
- .content_bottom = 8,
- .divider_top_row = 9,
- .input_row = 10,
- .divider_bottom_row = 11,
- .hint_row = 12,
- },
- .owned_top_row = 1,
- .full_transcript = .{
- .depth = .full,
- .scroll_rows = 56,
- .follow_tail = false,
- },
- };
+ var runtime = TranscriptRuntime{ .layout = .{
+ .rows = 24,
+ .cols = 80,
+ .content_bottom = 20,
+ .divider_top_row = 21,
+ .input_row = 22,
+ .divider_bottom_row = 23,
+ .hint_row = 24,
+ } };
defer runtime.deinit(alloc);
- const loading = try runtime.fullTranscriptLoadingProjection(alloc);
- var metrics: Metrics = .{};
- var paint = try runtime.prepareFullTranscriptSurfacePaint(
+ const oversized = try alloc.alloc(
+ u8,
+ full_transcript_snapshot_clone_max_bytes + 1,
+ );
+ @memset(oversized, 'x');
+ _ = try runtime.appendRawBytesEntryClassified(
alloc,
- &metrics,
- loading,
- null,
- .{ .top = 1, .bottom = 8 },
+ oversized,
+ .unknown_raw,
);
- defer paint.source.deinit(alloc);
- defer paint.prepared.deinit(alloc);
- try std.testing.expectEqual(@as(u32, 56), runtime.full_transcript.scroll_rows);
- try std.testing.expect(!runtime.full_transcript.follow_tail);
+ try runtime.prewarmFullTranscriptPage(null, null);
+ try std.testing.expect(!runtime.full_transcript_page_load.busy());
+ try std.testing.expect(runtime.full_transcript_failed_request != null);
+ try std.testing.expect(!runtime.requestFullTranscriptOpen());
+ try std.testing.expect(runtime.takeFullTranscriptPreparationFailure());
+ try std.testing.expect(!runtime.fullTranscriptPreparedForOpen());
}
test "full transcript page navigation preserves tail intent while the page loads" {
@@ -994,7 +1238,6 @@ test "full transcript page boundaries preserve monotonic navigation" {
.source = .{
.request = request,
.range = range,
- .styles = .{},
},
.projection = .{ .styles = .{} },
};
@@ -1146,14 +1389,134 @@ test "full transcript staging paints its selected wheel viewport without reselec
null,
.{ .top = 1, .bottom = 4 },
);
- defer staged.source.deinit(alloc);
- defer staged.prepared.deinit(alloc);
+ defer staged.deinit(alloc);
try std.testing.expectEqual(@as(u32, 3), runtime.full_transcript.scroll_rows);
try std.testing.expect(!runtime.full_transcript.follow_tail);
try std.testing.expectEqual(@as(usize, 0), staged.prepared.selection.start_line);
- try std.testing.expect(std.mem.find(u8, staged.source.bytes, "row-3") != null);
- try std.testing.expect(std.mem.find(u8, staged.source.bytes, "row-0") == null);
+ try std.testing.expect(std.mem.find(u8, staged.source().bytes, "row-3") != null);
+ try std.testing.expect(std.mem.find(u8, staged.source().bytes, "row-0") == null);
+}
+
+test "installed full transcript paints from the worker indexed source" {
+ const alloc = std.testing.allocator;
+ const page_alloc = std.heap.c_allocator;
+ var runtime = TranscriptRuntime{
+ .layout = .{
+ .rows = 8,
+ .cols = 40,
+ .content_bottom = 4,
+ .divider_top_row = 5,
+ .input_row = 6,
+ .divider_bottom_row = 7,
+ .hint_row = 8,
+ },
+ .owned_top_row = 1,
+ .full_transcript = .{
+ .depth = .full,
+ .scroll_rows = 3,
+ .follow_tail = false,
+ },
+ };
+ defer runtime.deinit(alloc);
+ _ = try runtime.appendRawTranscriptEntryClassified(
+ alloc,
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\nrow-0\nrow-1\nrow-2\nrow-3\nrow-4\nrow-5\nrow-6\nrow-7\n",
+ .unknown_raw,
+ );
+ var projection = try runtime.buildFullTranscriptProjection(page_alloc, null);
+ const bytes = try full_transcript_screen.renderProjectionViewportSourceBoundedInterruptible(
+ page_alloc,
+ &projection,
+ null,
+ runtime.layout.cols,
+ 256,
+ 0,
+ 2 * 1024 * 1024,
+ null,
+ );
+ const prepared_source = try source_preparation.prepareIndexedFullTranscriptWindowSourceInterruptible(
+ page_alloc,
+ bytes,
+ runtime.layout.cols,
+ null,
+ );
+ runtime.full_transcript_installed_page = .{
+ .source = .{
+ .request = runtime.desiredFullTranscriptPageRequest(),
+ .range = .{ .start = 0, .end = runtime.entries.items.len },
+ },
+ .projection = projection,
+ .prepared_window = .{
+ .source = prepared_source,
+ .start_row = 0,
+ },
+ };
+ const installed = &runtime.full_transcript_installed_page.?;
+ var metrics: Metrics = .{};
+ var staged = try runtime.prepareFullTranscriptSurfacePaint(
+ alloc,
+ &metrics,
+ &installed.projection,
+ null,
+ .{ .top = 1, .bottom = 4 },
+ );
+ defer staged.deinit(alloc);
+
+ try std.testing.expect(staged.owned_source == null);
+ try std.testing.expect(staged.borrowed_source == &installed.prepared_window.?.source);
+ try std.testing.expectEqual(@as(?u16, 4), staged.prepared.projection_visual_rows);
+ try std.testing.expect(staged.prepared.selection.last_visible_row <= 4);
+ try std.testing.expect(std.mem.find(u8, staged.source().bytes, "row-0") != null);
+ try std.testing.expect(std.mem.find(u8, staged.source().bytes, "row-7") != null);
+}
+
+test "clear retains installed full transcript page until window worker terminates" {
+ const alloc = std.testing.allocator;
+ const page_alloc = std.heap.c_allocator;
+ var runtime = TranscriptRuntime{ .layout = .{
+ .rows = 8,
+ .cols = 40,
+ .content_bottom = 4,
+ .divider_top_row = 5,
+ .input_row = 6,
+ .divider_bottom_row = 7,
+ .hint_row = 8,
+ } };
+ defer runtime.deinit(alloc);
+ _ = try runtime.appendRawTranscriptEntryClassified(
+ alloc,
+ "row-0\nrow-1\nrow-2\nrow-3\nrow-4\n",
+ .unknown_raw,
+ );
+ runtime.full_transcript_installed_page = .{
+ .source = .{
+ .request = runtime.desiredFullTranscriptPageRequest(),
+ .range = .{ .start = 0, .end = runtime.entries.items.len },
+ },
+ .projection = try runtime.buildFullTranscriptProjection(page_alloc, null),
+ };
+ const page = &runtime.full_transcript_installed_page.?;
+ const task = try page_alloc.create(full_transcript_worker.WindowTask);
+ task.* = .{
+ .request = .{
+ .page_request = page.source.request,
+ .target_offset = 1,
+ .start_row = 0,
+ .row_count = 4,
+ },
+ .projection = &page.projection,
+ .capability = null,
+ };
+ runtime.full_transcript_window_load.task = task;
+
+ runtime.clearFullTranscriptDetails(alloc);
+ try std.testing.expect(runtime.full_transcript_installed_page != null);
+ try std.testing.expect(task.cancel_requested.load(.acquire));
+
+ task.done.store(true, .release);
+ try std.testing.expect(!try runtime.pollFullTranscriptPageLoad());
+ try std.testing.expect(runtime.full_transcript_installed_page == null);
}
test "compact transcript cache survives navigation and invalidates on content change" {
@@ -1293,7 +1656,6 @@ test "clearing a transcript releases compact source and installed page" {
.anchor = .tail,
},
.range = .{ .start = 0, .end = 0 },
- .styles = .{},
},
.projection = .{ .styles = .{} },
};
@@ -1503,11 +1865,10 @@ test "full transcript staging keeps its selected viewport visible during resize
null,
.{ .top = 1, .bottom = 4 },
);
- defer staged.source.deinit(alloc);
- defer staged.prepared.deinit(alloc);
+ defer staged.deinit(alloc);
- try std.testing.expect(std.mem.find(u8, staged.source.bytes, "row-3") != null);
- try std.testing.expect(std.mem.find(u8, staged.source.bytes, "row-0") == null);
+ try std.testing.expect(std.mem.find(u8, staged.source().bytes, "row-3") != null);
+ try std.testing.expect(std.mem.find(u8, staged.source().bytes, "row-0") == null);
try std.testing.expectEqual(@as(?i32, 22), runtime.resize_history_row_delta);
try std.testing.expectEqual(@as(usize, 0), staged.prepared.selection.start_line);
try std.testing.expect(staged.prepared.selection.last_visible_row >= staged.prepared.selection.top_row);
@@ -1679,8 +2040,7 @@ test "full transcript opening follows the tail instead of consuming its compact
null,
.{ .top = 1, .bottom = 4 },
);
- defer staged.source.deinit(alloc);
- defer staged.prepared.deinit(alloc);
+ defer staged.deinit(alloc);
try std.testing.expectEqual(@as(usize, 0), staged.prepared.selection.start_line);
try std.testing.expectEqual(
@@ -1688,9 +2048,9 @@ test "full transcript opening follows the tail instead of consuming its compact
runtime.full_transcript.scroll_rows,
);
try std.testing.expect(runtime.full_transcript.follow_tail);
- try std.testing.expect(std.mem.find(u8, staged.source.bytes, "row-7") != null);
- try std.testing.expect(std.mem.find(u8, staged.source.bytes, "anchor") == null);
- try std.testing.expect(std.mem.find(u8, staged.source.bytes, "row-0") == null);
+ try std.testing.expect(std.mem.find(u8, staged.source().bytes, "row-7") != null);
+ try std.testing.expect(std.mem.find(u8, staged.source().bytes, "anchor") == null);
+ try std.testing.expect(std.mem.find(u8, staged.source().bytes, "row-0") == null);
try std.testing.expect(!runtime.full_transcript.anchor_pending);
}
@@ -3568,6 +3928,7 @@ pub const PaintTestMode = enum {
const PaintTestModeField = if (@import("builtin").is_test) PaintTestMode else void;
const default_max_retained_transcript_bytes: usize = 1024 * 1024;
+const full_transcript_snapshot_clone_max_bytes: usize = 8 * 1024 * 1024;
const resume_publication_rows_per_frame: u32 = 64;
pub const PaintTraceState = struct {
@@ -3670,12 +4031,22 @@ pub const FullTranscriptPrimaryRestore = enum {
};
const InstalledFullTranscriptPage = struct {
- source: full_transcript_worker.Source,
+ source: full_transcript_worker.InstalledSource,
projection: full_transcript_screen.Projection,
+ prepared_window: ?full_transcript_worker.PreparedWindow = null,
+ measured_total_rows: u32 = 0,
+ measured_anchor_row: ?u32 = null,
+ measured_item_rows: []transcript_presentation.ItemRow = &.{},
+ measurement_snapshot_ready: bool = false,
+ presented_offset: u32 = 0,
fn deinit(self: *InstalledFullTranscriptPage) void {
+ if (self.prepared_window) |*window| window.deinit(std.heap.c_allocator);
+ if (self.measured_item_rows.len > 0) {
+ std.heap.c_allocator.free(self.measured_item_rows);
+ }
self.projection.deinit(std.heap.c_allocator);
- self.source.deinit(std.heap.c_allocator);
+ self.source.deinit();
self.* = undefined;
}
};
@@ -3813,9 +4184,15 @@ pub const TranscriptRuntime = struct {
full_transcript_open_rows: u16 = 0,
full_transcript_primary_recovery_entry_id: ?u32 = null,
full_transcript_page_load: full_transcript_worker.Load = .{},
+ full_transcript_window_load: full_transcript_worker.WindowLoad = .{},
full_transcript_page_anchor: full_transcript_page.Anchor = .tail,
full_transcript_installed_page: ?InstalledFullTranscriptPage = null,
- full_transcript_loading_projection: ?full_transcript_screen.Projection = null,
+ full_transcript_installed_page_retired: bool = false,
+ full_transcript_failed_request: ?full_transcript_page.Request = null,
+ full_transcript_failure_pending: bool = false,
+ full_transcript_open_request: ?full_transcript_page.Request = null,
+ full_transcript_restore_open_pending: bool = false,
+ full_transcript_prepared_page_visible: bool = false,
full_transcript_content_revision: u64 = 0,
compact_transcript_source_cache: CompactTranscriptSourceCache = .{},
/// When enabled, compact transcript tool groups render only their summary
@@ -3936,11 +4313,9 @@ pub const TranscriptRuntime = struct {
pub fn deinit(self: *TranscriptRuntime, alloc: Allocator) void {
self.disableShadowVt();
+ self.full_transcript_window_load.deinit();
self.full_transcript_page_load.deinit();
- if (self.full_transcript_installed_page) |*page| page.deinit();
- if (self.full_transcript_loading_projection) |*projection| {
- projection.deinit(alloc);
- }
+ self.discardInstalledFullTranscriptPage();
self.compact_transcript_source_cache.deinit(alloc);
self.lifecycle_state.deinit(alloc);
for (self.tool_details.items) |*detail| detail.deinit(alloc);
@@ -5453,12 +5828,22 @@ pub const TranscriptRuntime = struct {
pub fn clearFullTranscriptDetails(self: *TranscriptRuntime, alloc: Allocator) void {
self.compact_transcript_source_cache.deinit(alloc);
self.resetFullTranscriptPageNavigation();
- if (self.full_transcript_installed_page) |*page| page.deinit();
- self.full_transcript_installed_page = null;
+ if (self.full_transcript_window_load.busy()) {
+ self.full_transcript_installed_page_retired =
+ self.full_transcript_installed_page != null;
+ } else {
+ self.discardInstalledFullTranscriptPage();
+ }
self.clearToolDetails(alloc);
self.full_transcript = self.full_transcript.closed();
}
+ fn discardInstalledFullTranscriptPage(self: *TranscriptRuntime) void {
+ if (self.full_transcript_installed_page) |*page| page.deinit();
+ self.full_transcript_installed_page = null;
+ self.full_transcript_installed_page_retired = false;
+ }
+
pub fn resetCommandOutputDisplay(self: *TranscriptRuntime, alloc: Allocator, reason: []const u8) void {
return command_output_runtime.resetCommandOutputDisplay(self, alloc, reason);
}
@@ -5736,15 +6121,26 @@ pub const TranscriptRuntime = struct {
depth: transcript_presentation.Depth,
) !bool {
const current = self.full_transcript.depth;
- if (current == depth) return false;
+ if (current == depth) {
+ if (depth == .inline_mode) {
+ self.full_transcript_restore_open_pending = false;
+ }
+ return false;
+ }
+ const restored_open = current == .inline_mode and
+ depth != .inline_mode and
+ self.full_transcript_restore_open_pending;
if (current == .inline_mode and depth != .inline_mode) {
+ self.full_transcript_prepared_page_visible = false;
self.full_transcript_open_content_revision = self.full_transcript_content_revision;
self.full_transcript_open_cols = self.layout.cols;
self.full_transcript_open_rows = self.layout.rows;
- try self.captureFullTranscriptAnchor(alloc);
+ if (!restored_open) try self.captureFullTranscriptAnchor(alloc);
}
self.full_transcript = self.full_transcript.with_depth(depth);
+ if (depth != .inline_mode) self.full_transcript_restore_open_pending = false;
if (depth == .inline_mode) {
+ self.full_transcript_restore_open_pending = false;
self.full_transcript_open_content_revision = null;
self.full_transcript_open_cols = 0;
self.full_transcript_open_rows = 0;
@@ -5922,6 +6318,12 @@ pub const TranscriptRuntime = struct {
return self.full_transcript.depth.active();
}
+ pub fn fullTranscriptFocusedWorkActive(self: *const TranscriptRuntime) bool {
+ return self.full_transcript_window_load.busy() or
+ (self.full_transcript_page_load.busy() and
+ self.full_transcript_open_request != null);
+ }
+
pub fn transcriptPresentationDepth(
self: *const TranscriptRuntime,
) transcript_presentation.Depth {
@@ -5983,6 +6385,10 @@ pub const TranscriptRuntime = struct {
fn resetFullTranscriptPageNavigation(self: *TranscriptRuntime) void {
self.full_transcript_page_anchor = .tail;
+ self.full_transcript_prepared_page_visible = false;
+ self.full_transcript_open_request = null;
+ self.full_transcript_restore_open_pending = false;
+ self.full_transcript_window_load.cancelActive();
self.full_transcript_page_load.cancelActive();
}
@@ -6020,7 +6426,7 @@ pub const TranscriptRuntime = struct {
}, rows);
const local_visible_rows = @max(@as(u32, 1), self.layout.rows -| 4);
const local_total_rows = if (self.full_transcript_installed_page) |*page|
- page.projection.measured_total_rows
+ self.installedPageMeasurement(page).total_rows
else
0;
const local_max_offset = local_total_rows -| local_visible_rows;
@@ -7906,6 +8312,8 @@ pub const TranscriptRuntime = struct {
},
}
const borrows_pending_resume = resolved.borrows_pending_resume;
+ const borrows_full_page = self.borrowsInstalledFullTranscriptSource(source);
+ const borrows_source = borrows_pending_resume or borrows_full_page;
const scroll_plan = resolved.scroll_plan;
const scroll_facts = resolved.scroll_facts;
const accepted = resolved.accepted;
@@ -7937,7 +8345,7 @@ pub const TranscriptRuntime = struct {
const target_cache_start =
transcript_store.cappedTailStart(source.bytes, self.max_transcript_bytes);
const target_cache_bytes = source.bytes[target_cache_start..];
- if (!borrows_pending_resume) {
+ if (!borrows_source) {
try target_cache.ensureTotalCapacityPrecise(
alloc,
@max(target_cache_bytes.len, self.transcript.capacity),
@@ -7963,12 +8371,12 @@ pub const TranscriptRuntime = struct {
}
const target_flow = source.bytes;
- if (!borrows_pending_resume) source.bytes = &.{};
- errdefer if (!borrows_pending_resume and target_flow.len > 0) alloc.free(target_flow);
+ if (!borrows_source) source.bytes = &.{};
+ errdefer if (!borrows_source and target_flow.len > 0) alloc.free(target_flow);
const folded_summary_indices = source.folded_summary_indices;
- if (!borrows_pending_resume) source.folded_summary_indices = &.{};
- errdefer if (!borrows_pending_resume and folded_summary_indices.len > 0) {
+ if (!borrows_source) source.folded_summary_indices = &.{};
+ errdefer if (!borrows_source and folded_summary_indices.len > 0) {
alloc.free(folded_summary_indices);
};
const source_row_provenance: []const transcript_blocks.RowProvenance =
@@ -8219,12 +8627,13 @@ pub const TranscriptRuntime = struct {
.recovery_projection_deferred = scroll_facts.recovery_projection_deferred,
.body_disposition = target.body_disposition,
.target_flow = target_flow,
- .target_flow_owned = !borrows_pending_resume,
+ .target_flow_owned = !borrows_source,
+ .borrows_full_page = borrows_full_page,
.presentation_resume_bytes = presentation_resume_bytes,
.presentation_pending_wrap = presentation_pending_wrap,
.presentation_valid = presentation_valid,
.target_cache = target_cache,
- .target_cache_unchanged = borrows_pending_resume,
+ .target_cache_unchanged = borrows_source,
.target_cache_origin_untrimmed = target_cache_origin_untrimmed,
.folded_summary_indices = folded_summary_indices,
.target_layout = target_layout,
@@ -8320,6 +8729,18 @@ pub const TranscriptRuntime = struct {
self.committed_frame_layout = transition.target_layout;
}
+ if (transition.borrows_full_page) {
+ transition.target_flow = &.{};
+ transition.folded_summary_indices = &.{};
+ transition.consumed = true;
+ debug_trace.logf(
+ "scroll",
+ "transcript_transition_commit state=full_page_borrowed",
+ .{},
+ );
+ return;
+ }
+
const borrowed_resume_source = if (!transition.target_flow_owned)
if (self.pending_resume_source) |*source|
if (transition.target_flow.len == 0 or
@@ -9042,8 +9463,20 @@ pub const TranscriptRuntime = struct {
}
pub const FullTranscriptSurfacePaint = struct {
- source: TranscriptPreparationSource,
+ owned_source: ?TranscriptPreparationSource = null,
+ borrowed_source: ?*TranscriptPreparationSource = null,
prepared: transcript_painter.PreparedTranscriptSurfacePaint,
+
+ pub fn source(self: *FullTranscriptSurfacePaint) *TranscriptPreparationSource {
+ if (self.owned_source) |*owned| return owned;
+ return self.borrowed_source.?;
+ }
+
+ pub fn deinit(self: *FullTranscriptSurfacePaint, alloc: Allocator) void {
+ self.prepared.deinit(alloc);
+ if (self.owned_source) |*source_value| source_value.deinit(alloc);
+ self.* = undefined;
+ }
};
pub noinline fn buildFullTranscriptProjection(
@@ -9066,6 +9499,49 @@ pub const TranscriptRuntime = struct {
pub fn pollFullTranscriptPageLoad(
self: *TranscriptRuntime,
) !bool {
+ if (self.full_transcript_window_load.takeCompleted()) |window_task| {
+ defer window_task.deinit();
+ if (self.full_transcript_installed_page_retired) {
+ self.discardInstalledFullTranscriptPage();
+ return false;
+ }
+ const page = if (self.full_transcript_installed_page) |*value| value else null;
+ if (window_task.cancel_requested.load(.acquire)) {
+ // Superseded scroll windows are expected and have no visible
+ // failure state; the latest offset schedules on the next tick.
+ } else if (window_task.failure) |failure| {
+ self.full_transcript_failed_request = window_task.request.page_request;
+ self.full_transcript_failure_pending = true;
+ if (failure != error.InputPending) {
+ debug_trace.logf(
+ "full_transcript_cache",
+ "window_build_failed revision={d} cols={d} offset={d} err={s}",
+ .{
+ window_task.request.page_request.content_revision,
+ window_task.request.page_request.cols,
+ window_task.request.target_offset,
+ @errorName(failure),
+ },
+ );
+ }
+ } else if (page != null and
+ !window_task.cancel_requested.load(.acquire) and
+ full_transcript_page.sameRequest(
+ page.?.source.request,
+ window_task.request.page_request,
+ ))
+ {
+ if (window_task.takePreparedWindow()) |window| {
+ if (page.?.prepared_window) |*current| {
+ current.deinit(std.heap.c_allocator);
+ }
+ page.?.prepared_window = window;
+ page.?.presented_offset = window.target_offset;
+ self.full_transcript_failed_request = null;
+ return true;
+ }
+ }
+ }
const task = self.full_transcript_page_load.takeCompleted() orelse
return false;
defer task.deinit();
@@ -9073,7 +9549,12 @@ pub const TranscriptRuntime = struct {
const request = task.source.request;
const desired = self.desiredFullTranscriptPageRequest();
var installed = false;
- if (task.failure) |failure| {
+ if (task.cancel_requested.load(.acquire)) {
+ // Closing, resizing, or superseding a page intentionally cancels
+ // its worker. It must not poison the next open request.
+ } else if (task.failure) |failure| {
+ self.full_transcript_failed_request = request;
+ self.full_transcript_failure_pending = true;
if (failure != error.InputPending) {
debug_trace.logf(
"full_transcript_cache",
@@ -9085,12 +9566,30 @@ pub const TranscriptRuntime = struct {
full_transcript_page.sameSurface(desired, request))
{
if (task.takeProjection()) |projection| {
- const source = task.takeSource();
+ const prepared_window = task.takePreparedWindow() orelse
+ return error.MissingPreparedFullTranscriptSource;
+ const measured_item_rows = try std.heap.c_allocator.dupe(
+ transcript_presentation.ItemRow,
+ projection.measured_item_rows.items,
+ );
+ errdefer if (measured_item_rows.len > 0) {
+ std.heap.c_allocator.free(measured_item_rows);
+ };
+ const source = task.takeInstalledSource();
+ std.debug.assert(!self.full_transcript_installed_page_retired);
if (self.full_transcript_installed_page) |*page| page.deinit();
self.full_transcript_installed_page = .{
.source = source,
.projection = projection,
+ .prepared_window = prepared_window,
+ .measured_total_rows = projection.measured_total_rows,
+ .measured_anchor_row = projection.measured_anchor_row,
+ .measured_item_rows = measured_item_rows,
+ .measurement_snapshot_ready = true,
+ .presented_offset = prepared_window.target_offset,
};
+ self.full_transcript_installed_page_retired = false;
+ self.full_transcript_failed_request = null;
installed = true;
}
}
@@ -9098,9 +9597,100 @@ pub const TranscriptRuntime = struct {
return installed or self.full_transcript.depth == .full;
}
+ pub fn prewarmFullTranscriptPage(
+ self: *TranscriptRuntime,
+ capability: ?*session_child_store.SessionChildCapability,
+ full_diff_resolver: ?full_transcript_screen.FullDiffResolver,
+ ) !void {
+ try self.ensureFullTranscriptPageLoad(capability, full_diff_resolver);
+ }
+
+ pub fn fullTranscriptPreparedForOpen(self: *const TranscriptRuntime) bool {
+ if (self.entries.items.len == 0) return true;
+ if (self.full_transcript_installed_page_retired) return false;
+ const page = if (self.full_transcript_installed_page) |*value| value else return false;
+ return page.prepared_window != null and
+ full_transcript_page.sameRequest(
+ self.desiredFullTranscriptPageRequest(),
+ page.source.request,
+ );
+ }
+
+ pub fn requestFullTranscriptOpen(self: *TranscriptRuntime) bool {
+ self.full_transcript_restore_open_pending = false;
+ if (self.fullTranscriptPreparedForOpen()) {
+ self.full_transcript_open_request = null;
+ debug_trace.logf("full_transcript", "open_request state=ready", .{});
+ return true;
+ }
+ if (self.full_transcript_open_request != null) {
+ self.full_transcript_open_request = null;
+ debug_trace.logf("full_transcript", "open_request state=cancelled", .{});
+ return false;
+ }
+ const request = self.desiredFullTranscriptPageRequest();
+ if (self.full_transcript_failed_request) |failed| {
+ if (full_transcript_page.sameRequest(
+ request,
+ failed,
+ )) {
+ self.full_transcript_open_request = request;
+ self.full_transcript_failure_pending = true;
+ debug_trace.logf("full_transcript", "open_request state=failed", .{});
+ return false;
+ }
+ }
+ self.full_transcript_open_request = request;
+ debug_trace.logf("full_transcript", "open_request state=pending", .{});
+ return false;
+ }
+
+ pub fn deferRestoredFullTranscriptOpen(self: *TranscriptRuntime) void {
+ std.debug.assert(self.full_transcript.depth == .full);
+ self.full_transcript_open_request = self.desiredFullTranscriptPageRequest();
+ self.full_transcript = self.full_transcript.defer_full_open();
+ self.full_transcript_restore_open_pending = true;
+ debug_trace.logf(
+ "full_transcript",
+ "restored_open deferred scroll_rows={d} follow_tail={} bookmark_pending={} bookmark_entry_id={d}",
+ .{
+ self.full_transcript.scroll_rows,
+ self.full_transcript.follow_tail,
+ self.full_transcript.bookmark_pending,
+ self.full_transcript.bookmark_entry_id orelse 0,
+ },
+ );
+ }
+
+ pub fn cancelPendingFullTranscriptOpen(self: *TranscriptRuntime) bool {
+ if (self.full_transcript_open_request == null) return false;
+ self.full_transcript_open_request = null;
+ self.full_transcript_restore_open_pending = false;
+ debug_trace.logf("full_transcript", "open_request state=cancelled", .{});
+ return true;
+ }
+
+ pub fn takeReadyFullTranscriptOpen(self: *TranscriptRuntime) bool {
+ const requested = self.full_transcript_open_request orelse return false;
+ if (self.full_transcript_installed_page_retired) return false;
+ const page = if (self.full_transcript_installed_page) |*value| value else return false;
+ if (page.prepared_window == null or
+ !full_transcript_page.sameRequest(requested, page.source.request)) return false;
+ self.full_transcript_open_request = null;
+ return true;
+ }
+
+ pub fn takeFullTranscriptPreparationFailure(self: *TranscriptRuntime) bool {
+ const pending = self.full_transcript_failure_pending and
+ (self.fullTranscriptActive() or self.full_transcript_open_request != null);
+ if (!pending) return false;
+ self.full_transcript_failure_pending = false;
+ self.full_transcript_open_request = null;
+ return true;
+ }
+
pub fn preparedFullTranscriptPageProjectionInterruptible(
self: *TranscriptRuntime,
- alloc: Allocator,
full_diff_resolver: ?full_transcript_screen.FullDiffResolver,
capability: ?*session_child_store.SessionChildCapability,
checkpoint: ?*build_checkpoint.BuildCheckpoint,
@@ -9110,22 +9700,47 @@ pub const TranscriptRuntime = struct {
try self.ensureFullTranscriptPageLoad(capability, full_diff_resolver);
if (self.installedFullTranscriptPageProjection()) |projection| return projection;
- return try self.fullTranscriptLoadingProjection(alloc);
+ if (!self.full_transcript_installed_page_retired) {
+ if (self.full_transcript_installed_page) |*page| {
+ if (page.prepared_window != null and
+ full_transcript_page.sameSurface(
+ self.desiredFullTranscriptPageRequest(),
+ page.source.request,
+ ))
+ {
+ return &page.projection;
+ }
+ }
+ }
+ return error.InputPending;
}
fn installedFullTranscriptPageProjection(
self: *TranscriptRuntime,
) ?*full_transcript_screen.Projection {
+ if (self.full_transcript_installed_page_retired) return null;
const page = if (self.full_transcript_installed_page) |*value| value else return null;
- return if (full_transcript_page.sameSurface(
- self.desiredFullTranscriptPageRequest(),
- page.source.request,
- )) &page.projection else null;
+ const desired = self.desiredFullTranscriptPageRequest();
+ if (full_transcript_page.sameRequest(desired, page.source.request)) {
+ return &page.projection;
+ }
+ if (!full_transcript_page.sameSurface(desired, page.source.request)) {
+ return null;
+ }
+ if (self.full_transcript_prepared_page_visible or
+ (self.full_transcript_page_load.busy() and
+ self.command_output_display.open_command_block != null))
+ {
+ return &page.projection;
+ }
+ if (self.command_output_display.open_command_block == null) return null;
+ return &page.projection;
}
pub fn preparedFullTranscriptPageCapability(
self: *TranscriptRuntime,
) ?*session_child_store.SessionChildCapability {
+ if (self.full_transcript_installed_page_retired) return null;
const page = if (self.full_transcript_installed_page) |*value| value else return null;
return if (page.source.capability) |*capability| capability else null;
}
@@ -9145,35 +9760,65 @@ pub const TranscriptRuntime = struct {
capability: ?*session_child_store.SessionChildCapability,
full_diff_resolver: ?full_transcript_screen.FullDiffResolver,
) !void {
- const request = self.desiredFullTranscriptPageRequest();
+ if (self.full_transcript_installed_page_retired) {
+ if (self.full_transcript_window_load.busy()) {
+ self.full_transcript_window_load.cancelActive();
+ return;
+ }
+ self.discardInstalledFullTranscriptPage();
+ }
+ const request = self.full_transcript_open_request orelse
+ self.desiredFullTranscriptPageRequest();
+ if (self.full_transcript_failed_request) |failed| {
+ if (full_transcript_page.sameRequest(request, failed)) return;
+ self.full_transcript_failed_request = null;
+ }
if (self.full_transcript_installed_page) |*page| {
if (full_transcript_page.sameRequest(request, page.source.request)) return;
}
- if (self.command_output_display.open_command_block != null) {
+ if (self.command_output_display.open_command_block != null and
+ self.full_transcript_open_request == null)
+ {
if (self.full_transcript_installed_page) |*page| {
- if (full_transcript_page.sameSurface(request, page.source.request) and
- !full_transcript_page.liveRefreshDue(
- page.source.request.content_revision,
- self.full_transcript_content_revision,
- )) return;
+ if (full_transcript_page.sameSurface(
+ request,
+ page.source.request,
+ )) return;
}
}
if (self.full_transcript_page_load.hasRequest(request)) return;
+ if (self.full_transcript_window_load.busy()) {
+ self.full_transcript_window_load.cancelActive();
+ return;
+ }
if (self.full_transcript_page_load.busy()) {
if (!self.full_transcript_page_load.hasCompatibleRequest(request)) {
self.full_transcript_page_load.cancelActive();
}
return;
}
- var source = try self.snapshotFullTranscriptPage(
+ var source = self.snapshotFullTranscriptPage(
request,
capability,
full_diff_resolver,
- );
+ ) catch |err| switch (err) {
+ error.FullTranscriptPageSnapshotTooLarge => {
+ self.full_transcript_failed_request = request;
+ self.full_transcript_failure_pending = true;
+ debug_trace.logf(
+ "full_transcript_cache",
+ "page_snapshot_rejected revision={d} cols={d} err={s}",
+ .{ request.content_revision, request.cols, @errorName(err) },
+ );
+ return;
+ },
+ else => |other| return other,
+ };
var source_owned = true;
errdefer if (source_owned) source.deinit(std.heap.c_allocator);
try self.full_transcript_page_load.schedule(source);
+ self.full_transcript_failed_request = null;
source_owned = false;
}
@@ -9188,10 +9833,21 @@ pub const TranscriptRuntime = struct {
request,
self.entries.items.len,
);
+ if (self.fullTranscriptPageSnapshotBytes(
+ range,
+ full_diff_resolver,
+ ) > full_transcript_snapshot_clone_max_bytes) {
+ return error.FullTranscriptPageSnapshotTooLarge;
+ }
var source = full_transcript_worker.Source{
.request = request,
.range = range,
.styles = self.command_output_render.styles,
+ .presentation = self.full_transcript,
+ .visible_rows = @intCast(@min(
+ self.fullTranscriptPageRows(),
+ std.math.maxInt(u16),
+ )),
};
errdefer source.deinit(alloc);
try source.entries.ensureTotalCapacity(alloc, range.len());
@@ -9293,6 +9949,46 @@ pub const TranscriptRuntime = struct {
return source;
}
+ fn fullTranscriptPageSnapshotBytes(
+ self: *const TranscriptRuntime,
+ range: full_transcript_page.SourceRange,
+ full_diff_resolver: ?full_transcript_screen.FullDiffResolver,
+ ) usize {
+ const entries = self.entries.items[range.start..range.end];
+ var total: usize = entries.len *| @sizeOf(TranscriptEntry);
+ for (entries) |entry| {
+ total +|= transcript_store.entrySnapshotRetainedBytes(entry);
+ if (full_diff_resolver) |resolver| {
+ const raw = switch (entry) {
+ .raw_bytes => |value| value,
+ else => continue,
+ };
+ if (raw.class != .diff_block) continue;
+ const marker_id = diff_mod.markedDiffBlockId(raw.bytes) orelse continue;
+ if (resolver.full_for_marker(resolver.context, marker_id)) |content| {
+ total +|= content.len;
+ }
+ }
+ if (total > full_transcript_snapshot_clone_max_bytes) return total;
+ }
+ const bounds = snapshotEntryIdBounds(entries);
+ for (self.tool_details.items) |detail| {
+ if (bounds) |page_bounds| {
+ if (detail.entry_id < page_bounds.min or detail.entry_id > page_bounds.max) {
+ continue;
+ }
+ } else continue;
+ total +|= toolDetailSnapshotBytes(detail);
+ if (total > full_transcript_snapshot_clone_max_bytes) return total;
+ }
+ for (self.command_output_blocks.items) |block| {
+ if (!snapshotCommandBlockIntersects(entries, bounds, block)) continue;
+ total +|= command_output_runtime.commandOutputBlockRetainedBytes(block);
+ if (total > full_transcript_snapshot_clone_max_bytes) return total;
+ }
+ return total;
+ }
+
fn appendSnapshotFullDiffs(
alloc: Allocator,
source: *full_transcript_worker.Source,
@@ -9356,6 +10052,20 @@ pub const TranscriptRuntime = struct {
max: u32,
};
+ fn toolDetailSnapshotBytes(detail: ToolDetailRecord) usize {
+ var total = @sizeOf(ToolDetailRecord) +| detail.tool_name.len;
+ if (detail.arguments_json) |value| total +|= value.len;
+ if (detail.result) |value| total +|= value.len;
+ if (detail.result_handle) |value| total +|= value.len;
+ if (detail.command_artifact_handle) |value| total +|= value.len;
+ if (detail.command_output_replay) |replay| switch (replay) {
+ .available => |value| total +|= value.handle.len,
+ .unavailable => {},
+ };
+ if (detail.lifecycle_id) |value| total +|= value.call_id.len;
+ return total;
+ }
+
fn snapshotEntryIdBounds(
entries: []const TranscriptEntry,
) ?SnapshotEntryIdBounds {
@@ -9482,29 +10192,6 @@ pub const TranscriptRuntime = struct {
return source.command_blocks.items.len - 1;
}
- fn fullTranscriptLoadingProjection(
- self: *TranscriptRuntime,
- alloc: Allocator,
- ) !*full_transcript_screen.Projection {
- if (self.full_transcript_loading_projection == null) {
- const entries = [_]TranscriptEntry{.{ .raw_bytes = .{
- .id = 0,
- .bytes = "Preparing full detail…\n",
- .class = .unknown_raw,
- } }};
- self.full_transcript_loading_projection = try full_transcript_screen.buildProjection(
- alloc,
- &entries,
- &.{},
- &.{},
- self.command_output_render.styles,
- self.layout.cols,
- null,
- );
- }
- return &self.full_transcript_loading_projection.?;
- }
-
fn prepareFullTranscriptProjectionLayout(self: *TranscriptRuntime) void {
self.full_transcript = self.full_transcript.prepare_projection(
self.layout.cols,
@@ -9599,26 +10286,81 @@ pub const TranscriptRuntime = struct {
area: render_engine.frame_layout.FrameRect,
checkpoint: ?*build_checkpoint.BuildCheckpoint,
) !FullTranscriptSurfacePaint {
- const source_bytes = if (self.fullTranscriptProjectionIsLoading(projection))
- try full_transcript_screen.renderProjectionViewportSourceInterruptible(
- alloc,
- projection,
- capability,
- self.layout.cols,
+ if (self.installedFullTranscriptPreparedWindow(projection)) |window| {
+ self.full_transcript_prepared_page_visible = true;
+ const page = &self.full_transcript_installed_page.?;
+ const measurement = self.installedPageMeasurement(page);
+ const offset = selectProjectionViewportOffset(
+ self,
+ measurement,
area.height(),
- 0,
- checkpoint,
- )
- else
- try full_transcript_screen.renderProjectionViewportSourceWithSelectorInterruptible(
- alloc,
- projection,
- capability,
- self.layout.cols,
+ );
+ const cached_rows = @as(u32, window.source.preview.natural_visual_rows);
+ const cache_end = window.start_row +| cached_rows;
+ if (offset >= window.start_row and
+ (offset +| area.height() <= cache_end or
+ cache_end == measurement.total_rows))
+ {
+ const local_offset = offset - window.start_row;
+ debug_trace.logf(
+ "full_transcript_cache",
+ "window cols={d} offset={d} local_offset={d} visible={d} source=indexed rows={d} cache_start={d} cache_rows={d}",
+ .{
+ self.layout.cols,
+ offset,
+ local_offset,
+ area.height(),
+ measurement.total_rows,
+ window.start_row,
+ cached_rows,
+ },
+ );
+ const prepared = try transcript_painter.prepareIndexedFullTranscriptSurfacePaintForArea(
+ self,
+ alloc,
+ metrics,
+ &window.source,
+ area,
+ local_offset,
+ );
+ page.presented_offset = offset;
+ return .{
+ .borrowed_source = &window.source,
+ .prepared = prepared,
+ };
+ }
+ try self.ensureFullTranscriptWindowLoad(
+ page,
+ measurement,
+ offset,
area.height(),
- .{ .context = self, .select_offset = selectProjectionViewportOffset },
- checkpoint,
);
+ const stable_offset = @min(
+ @max(page.presented_offset, window.start_row),
+ cache_end -| @min(@as(u32, area.height()), cached_rows),
+ );
+ const prepared = try transcript_painter.prepareIndexedFullTranscriptSurfacePaintForArea(
+ self,
+ alloc,
+ metrics,
+ &window.source,
+ area,
+ stable_offset -| window.start_row,
+ );
+ return .{
+ .borrowed_source = &window.source,
+ .prepared = prepared,
+ };
+ }
+ const source_bytes = try full_transcript_screen.renderProjectionViewportSourceWithSelectorInterruptible(
+ alloc,
+ projection,
+ capability,
+ self.layout.cols,
+ area.height(),
+ .{ .context = self, .select_offset = selectProjectionViewportOffset },
+ checkpoint,
+ );
var source = try self.prepareFullTranscriptViewportSource(alloc, source_bytes);
errdefer source.deinit(alloc);
const prepared = try transcript_painter.preparePreselectedTranscriptSurfacePaintFromSourceForArea(
@@ -9628,18 +10370,72 @@ pub const TranscriptRuntime = struct {
&source,
area,
);
- return .{ .source = source, .prepared = prepared };
+ return .{ .owned_source = source, .prepared = prepared };
}
- fn fullTranscriptProjectionIsLoading(
+ fn installedFullTranscriptPreparedWindow(
self: *TranscriptRuntime,
projection: *const full_transcript_screen.Projection,
+ ) ?*full_transcript_worker.PreparedWindow {
+ if (self.full_transcript_installed_page_retired) return null;
+ const page = if (self.full_transcript_installed_page) |*value| value else return null;
+ if (&page.projection != projection) return null;
+ return if (page.prepared_window) |*window| window else null;
+ }
+
+ fn installedPageMeasurement(
+ self: *const TranscriptRuntime,
+ page: *const InstalledFullTranscriptPage,
+ ) full_transcript_screen.ProjectionMeasurement {
+ _ = self;
+ if (page.measurement_snapshot_ready) {
+ return .{
+ .total_rows = page.measured_total_rows,
+ .anchor_row = page.measured_anchor_row,
+ .item_rows = page.measured_item_rows,
+ };
+ }
+ return .{
+ .total_rows = page.projection.measured_total_rows,
+ .anchor_row = page.projection.measured_anchor_row,
+ .item_rows = page.projection.measured_item_rows.items,
+ };
+ }
+
+ fn ensureFullTranscriptWindowLoad(
+ self: *TranscriptRuntime,
+ page: *InstalledFullTranscriptPage,
+ measurement: full_transcript_screen.ProjectionMeasurement,
+ target_offset: u32,
+ visible_rows: u16,
+ ) !void {
+ if (self.full_transcript_page_load.busy()) return;
+ if (self.full_transcript_window_load.hasTarget(target_offset)) return;
+ if (self.full_transcript_window_load.busy()) {
+ self.full_transcript_window_load.cancelActive();
+ return;
+ }
+ const request = full_transcript_worker.preparedWindowRequest(
+ page.source.request,
+ measurement.total_rows,
+ target_offset,
+ visible_rows,
+ );
+ try self.full_transcript_window_load.schedule(
+ request,
+ &page.projection,
+ if (page.source.capability) |*capability| capability else null,
+ );
+ }
+
+ fn borrowsInstalledFullTranscriptSource(
+ self: *TranscriptRuntime,
+ source: *const TranscriptPreparationSource,
) bool {
- const loading = if (self.full_transcript_loading_projection) |*value|
- value
- else
- return false;
- return loading == projection;
+ if (self.full_transcript_installed_page_retired) return false;
+ const page = if (self.full_transcript_installed_page) |*value| value else return false;
+ const window = if (page.prepared_window) |*value| value else return false;
+ return &window.source == source;
}
fn selectProjectionViewportOffset(
@@ -9749,17 +10545,16 @@ pub const TranscriptRuntime = struct {
{
return false;
}
- if (self.full_transcript_page_load.busy()) return true;
+ if (self.full_transcript_page_load.busy() or
+ self.full_transcript_window_load.busy()) return true;
+ if (self.full_transcript_installed_page_retired) return false;
const page = if (self.full_transcript_installed_page) |*value| value else return false;
if (page.source.request.cols != self.layout.cols or
!std.meta.eql(page.source.request.anchor, self.full_transcript_page_anchor))
{
return false;
}
- return !full_transcript_page.liveRefreshDue(
- page.source.request.content_revision,
- self.full_transcript_content_revision,
- );
+ return true;
}
pub fn nativeHistoryActive(self: *const TranscriptRuntime) bool {
diff --git a/src/ui/transcript/source_preparation.zig b/src/ui/transcript/source_preparation.zig
index bd93fcce3..bc948a578 100644
--- a/src/ui/transcript/source_preparation.zig
+++ b/src/ui/transcript/source_preparation.zig
@@ -454,6 +454,41 @@ pub fn prepareFullTranscriptViewportSourceInterruptible(
};
}
+/// Takes ownership of one bounded width-rendered full-transcript window and
+/// builds its reusable line index once on the page worker.
+pub fn prepareIndexedFullTranscriptWindowSourceInterruptible(
+ alloc: Allocator,
+ bytes: []u8,
+ cols: u16,
+ checkpoint: ?*build_checkpoint.BuildCheckpoint,
+) !TranscriptPreparationSource {
+ var source = TranscriptPreparationSource{
+ .bytes = bytes,
+ .folded_summary_indices = &.{},
+ .preview = .{ .natural_visual_rows = 0 },
+ .tail_kind = null,
+ .tracked_entry_id = null,
+ .tracked_entry_start_line = null,
+ .replaceable_last_line = false,
+ .replaceable_start = 0,
+ .replaceable_row = 1,
+ .welcome_cut_line = null,
+ .welcome_boundary = null,
+ .cols = cols,
+ };
+ errdefer source.deinit(alloc);
+ try source.ensureLineIndexInterruptible(alloc, checkpoint);
+ const total_rows = if (source.transcript_visual_row_offsets.len > 0)
+ source.transcript_visual_row_offsets[source.transcript_visual_row_offsets.len - 1]
+ else
+ 0;
+ source.preview.natural_visual_rows = @intCast(@min(
+ total_rows,
+ std.math.maxInt(u16),
+ ));
+ return source;
+}
+
fn prepareTranscriptSourceInternal(
self: anytype,
alloc: Allocator,
diff --git a/src/ui/transcript/store.zig b/src/ui/transcript/store.zig
index 18728cd35..0c250c380 100644
--- a/src/ui/transcript/store.zig
+++ b/src/ui/transcript/store.zig
@@ -186,6 +186,10 @@ fn entryRetainedBytes(entry: TranscriptEntry) usize {
};
}
+pub fn entrySnapshotRetainedBytes(entry: TranscriptEntry) usize {
+ return @sizeOf(TranscriptEntry) +| entryRetainedBytes(entry);
+}
+
fn dupeSkillTokenSpans(alloc: Allocator, skill_tokens: []const SkillTokenSpan) ![]SkillTokenSpan {
if (skill_tokens.len == 0) return &.{};
const copy = try alloc.alloc(SkillTokenSpan, skill_tokens.len);
diff --git a/tests/e2e/ci-shard-weights.json b/tests/e2e/ci-shard-weights.json
index 157ac392c..88921541f 100644
--- a/tests/e2e/ci-shard-weights.json
+++ b/tests/e2e/ci-shard-weights.json
@@ -37,6 +37,7 @@
{ "file": "tui-keybindings.test.ts", "weight": 1 },
{ "file": "tui-native-clear-recovery.test.ts", "weight": 4 },
{ "file": "tui-permissions.test.ts", "weight": 127 },
+ { "file": "tui-performance.test.ts", "weight": 1 },
{ "file": "tui-render-lab.test.ts", "weight": 62 },
{ "file": "tui-render-live-stress.test.ts", "weight": 1 },
{ "file": "tui-render-replay.test.ts", "weight": 14 },
diff --git a/tests/e2e/tui-full-transcript-brutal.test.ts b/tests/e2e/tui-full-transcript-brutal.test.ts
index 4d0fa5845..0bbf86a9f 100644
--- a/tests/e2e/tui-full-transcript-brutal.test.ts
+++ b/tests/e2e/tui-full-transcript-brutal.test.ts
@@ -447,6 +447,22 @@ async function waitForTraceAfter(
);
}
+async function waitForAnyTraceAfter(
+ tracePath: string,
+ startByte: number,
+ needles: readonly string[],
+ timeoutMs = TIMEOUT,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const appended = readFileSync(tracePath).subarray(startByte).toString("utf8");
+ const matched = needles.find((needle) => appended.includes(needle));
+ if (matched) return matched;
+ await sleep(25);
+ }
+ throw new Error(`Timed out waiting for any trace marker ${JSON.stringify(needles)}.`);
+}
+
function projectionWindows(text: string): ProjectionWindowTrace[] {
return [...text.matchAll(
/\[full_transcript_cache\] window cols=(\d+) offset=(\d+)/g,
@@ -491,8 +507,15 @@ async function waitForScrolledViewport(
appended = readFileSync(tracePath).subarray(startByte).toString("utf8");
const scrollIndex = appended.indexOf("[full_transcript_cache] scroll ");
if (scrollIndex >= 0) {
- const windows = projectionWindows(appended.slice(scrollIndex));
+ const afterScroll = appended.slice(scrollIndex);
+ const windows = projectionWindows(afterScroll);
if (windows.some((window) => window.offset !== previousOffset)) return;
+ const after = afterScroll.match(/ after=(\d+)/)?.[1];
+ if (
+ after !== undefined &&
+ Number(after) !== previousOffset &&
+ /frame_plan\] build [^\n]*body=transcript transcript_body=paint/.test(afterScroll)
+ ) return;
}
await sleep(10);
}
@@ -510,7 +533,11 @@ async function waitForRenderedViewportAfter(
let appended = "";
while (Date.now() < deadline) {
appended = readFileSync(tracePath).subarray(startByte).toString("utf8");
- if (projectionWindows(appended).length > 0) return;
+ if (
+ projectionWindows(appended).length > 0 ||
+ /attempt_end outcome=committed reasons=[^\n]*modal/.test(appended) ||
+ /frame_plan\] build [^\n]*body=transcript transcript_body=paint/.test(appended)
+ ) return;
await sleep(10);
}
throw new Error(
@@ -860,7 +887,7 @@ async function runStress(config: StressConfig): Promise {
FX_RECORD_INPUT: "1",
FX_TRACE_LOG: paths.tracePath,
FX_TRACE_SCOPES:
- "full_transcript_cache,full_transcript,scroll,frame_render,terminal_diff,frame_schedule",
+ "full_transcript_cache,full_transcript,scroll,frame_render,terminal_diff,frame_schedule,frame_plan",
},
stderrPath: paths.stderrPath,
width: 104,
@@ -901,9 +928,18 @@ async function runStress(config: StressConfig): Promise {
const immediateTraceStart = traceSize(paths.tracePath);
const escapeStarted = performance.now();
- session.sendKeysImmediate(["C-o", "Escape"]);
- await waitForTraceAfter(paths.tracePath, immediateTraceStart, [
- "attempt_restore outcome=input_pending",
+ session.sendKeysImmediate(["C-o"]);
+ await waitForAnyTraceAfter(
+ paths.tracePath,
+ immediateTraceStart,
+ [
+ "open_request state=pending",
+ "depth_transition from=inline to=full route=root trigger=ctrl_o",
+ ],
+ );
+ session.sendKeysImmediate(["Escape"]);
+ await waitForAnyTraceAfter(paths.tracePath, immediateTraceStart, [
+ "open_request state=cancelled",
"depth_transition from=full to=inline route=root trigger=escape",
]);
await waitForMode(session, "main", DRAFT);
@@ -1094,7 +1130,7 @@ async function runStress(config: StressConfig): Promise {
...gatewayEnv(paths.home, resumedGateway),
FX_TRACE_LOG: paths.resumedTracePath,
FX_TRACE_SCOPES:
- "full_transcript_cache,full_transcript,scroll,frame_render,terminal_diff",
+ "full_transcript_cache,full_transcript,scroll,frame_render,terminal_diff,frame_plan",
},
stderrPath: paths.resumedStderrPath,
width: 96,
@@ -1173,6 +1209,56 @@ async function runStress(config: StressConfig): Promise {
}
}
+test.skipIf(!tmuxAvailable())(
+ "Ctrl-O fills a viewport taller than the prepared overscan cache",
+ async () => {
+ const paths = makeRoot("tall-viewport");
+ mkdirSync(join(paths.home, ".fx"), { recursive: true });
+ mkdirSync(paths.workspace);
+ writeFileSync(paths.stderrPath, "");
+ const tallTail = "TALL_TRANSCRIPT_TAIL";
+ const response = Array.from(
+ { length: 500 },
+ (_, index) => index === 499
+ ? tallTail
+ : `TALL_TRANSCRIPT_ROW_${String(index).padStart(3, "0")}`,
+ ).join("\n");
+ const tallGateway = startFakeGateway([fakeGatewayFinalText(response)]);
+ let active: TmuxSession | null = null;
+ try {
+ active = await TmuxSession.create({
+ cmd: FX_BIN,
+ cwd: realpathSync(paths.workspace),
+ env: gatewayEnv(paths.home, tallGateway),
+ stderrPath: paths.stderrPath,
+ width: 80,
+ height: 220,
+ minimumHistoryLines: 2_000,
+ });
+ await active.waitForComposer(TIMEOUT);
+ await active.sendText("Build a tall transcript.");
+ await active.waitForText(tallTail, TIMEOUT);
+ await active.sendHexBytes(CTRL_O);
+ const full = await active.waitForText(FULL_FOOTER, TIMEOUT);
+ const rows = full.split("\n");
+ const tail_row = rows.findIndex((row) => row.includes(tallTail));
+ const footer_row = rows.findIndex((row) => row.includes(FULL_FOOTER));
+ expect(tail_row).toBeGreaterThanOrEqual(0);
+ expect(footer_row).toBeGreaterThan(tail_row);
+ expect(footer_row - tail_row).toBeLessThanOrEqual(6);
+
+ await active.sendHexBytes(CTRL_O);
+ await active.waitForComposer(TIMEOUT);
+ expect(readFileSync(paths.stderrPath, "utf8")).toBe("");
+ } finally {
+ await active?.kill();
+ tallGateway.stop();
+ rmSync(paths.root, { recursive: true, force: true });
+ }
+ },
+ 60_000,
+);
+
test.skipIf(!tmuxAvailable())(
"Ctrl-O repeatedly survives mixed long chats, dense tool batches, live output, resize storms, and resume",
async () => {
diff --git a/tests/e2e/tui-performance.test.ts b/tests/e2e/tui-performance.test.ts
new file mode 100644
index 000000000..345e05f7d
--- /dev/null
+++ b/tests/e2e/tui-performance.test.ts
@@ -0,0 +1,1150 @@
+import { expect, test } from "bun:test";
+import { createHash } from "node:crypto";
+import { execFileSync } from "node:child_process";
+import {
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ realpathSync,
+ rmSync,
+ symlinkSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { FX_BIN } from "../evals/eval-helpers";
+import { readTapeFrames, type TapeFrame } from "./render-lab/tape";
+import {
+ composerContains,
+ FAKE_GATEWAY_MODEL,
+ fakeGatewayFinalText,
+ fakeGatewayToolCall,
+ hasEmptyComposer,
+ startFakeGateway,
+ TmuxSession,
+ tmuxAvailable,
+} from "./tmux-helpers";
+
+const ENABLED = process.env.FX_TUI_PERFORMANCE === "1";
+const LIVE_ENABLED = process.env.FX_E2E_REAL_API === "1" &&
+ typeof process.env.AI_GATEWAY_API_KEY === "string" &&
+ process.env.AI_GATEWAY_API_KEY.length > 0;
+const WARMUPS = 5;
+const SAMPLES = 50;
+const LOCAL_BUDGETS_MS = { p50: 8, p90: 12, p95: 17 } as const;
+const BACKGROUND_WORK_BUDGETS_MS = { p50: 12, p90: 17, p95: 17 } as const;
+const EXTERNAL_REFRESH_BUDGETS_MS = { p50: 17, p90: 17, p95: 17 } as const;
+const APP_PANE_BUDGETS_MS = { p50: 12, p90: 17, p95: 17 } as const;
+const ACTIVE_TURN_DESCRIPTOR_BUDGET = 7;
+const TIMEOUT = 60_000;
+
+const LOCAL_MENU_ACTIONS = [
+ { name: "helpOpen", command: "/help", marker: "Commands " },
+ { name: "settingsOpen", command: "/settings", marker: "Settings" },
+ { name: "modelOpen", command: "/model", marker: "Models " },
+ { name: "resumeOpen", command: "/resume", marker: "Sessions " },
+ { name: "mcpOpen", command: "/mcp", marker: "[Servers]" },
+ { name: "usageOpen", command: "/usage", marker: "[30 days]" },
+ { name: "statuslineOpen", command: "/statusline", marker: "Status line" },
+ { name: "workspaceOpen", command: "/workspace", marker: "Enter Use" },
+] as const;
+
+const MEASURED_ACTION_NAMES = [
+ "composerEdit",
+ "slashOpen",
+ "slashQuery",
+ "dollarOpen",
+ "dollarQuery",
+ "fileQuery",
+ "questionNavigate",
+ "approvalNavigate",
+ "hostedTerminalInput",
+ "subagentManagerOpen",
+ "fullOpen",
+ "fullScroll",
+ "fullScrollCacheMiss",
+ "skillsOpen",
+ "skillsQuery",
+ "loginOpen",
+ ...LOCAL_MENU_ACTIONS.map((action) => action.name),
+] as const;
+
+const INFORMATIONAL_PANE_ACTION_NAMES = new Set([
+ "hostedTerminalInput",
+]);
+
+const APP_PANE_ACTION_NAMES = new Set([
+ "subagentManagerOpen",
+ ...LOCAL_MENU_ACTIONS.map((action) => action.name),
+]);
+
+type Samples = {
+ firstPaint: number[];
+ contentReady: number[];
+};
+
+type ResourceSnapshot = {
+ rssKib: number;
+ threads: number;
+ descriptors: number;
+};
+
+function findSessionId(value: unknown): string | undefined {
+ if (typeof value === "string") {
+ try {
+ return findSessionId(JSON.parse(value));
+ } catch {
+ return undefined;
+ }
+ }
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const found = findSessionId(item);
+ if (found !== undefined) return found;
+ }
+ return undefined;
+ }
+ if (value === null || typeof value !== "object") return undefined;
+ const record = value as Record;
+ if (typeof record.session_id === "string") return record.session_id;
+ for (const child of Object.values(record)) {
+ const found = findSessionId(child);
+ if (found !== undefined) return found;
+ }
+ return undefined;
+}
+
+function percentile(values: readonly number[], fraction: number): number {
+ const sorted = [...values].sort((left, right) => left - right);
+ return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]!;
+}
+
+function summary(values: readonly number[]) {
+ return {
+ count: values.length,
+ p50: percentile(values, 0.5),
+ p90: percentile(values, 0.9),
+ p95: percentile(values, 0.95),
+ max: Math.max(...values),
+ failures: 0,
+ };
+}
+
+function readCompleteTape(path: string): TapeFrame[] {
+ let lastError: unknown;
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ try {
+ return readTapeFrames(path);
+ } catch (error) {
+ lastError = error;
+ }
+ }
+ throw lastError;
+}
+
+function frameLatency(
+ frames: readonly TapeFrame[],
+ fromIndex: number,
+ contentMarker?: string,
+): { firstPaint: number; contentReady: number } {
+ const inputIndex = frames.findIndex((frame, index) =>
+ index >= fromIndex && frame.kind === 2
+ );
+ if (inputIndex < 0) throw new Error("recording did not contain the measured stdin frame");
+
+ let elapsed = 0;
+ let firstPaint: number | undefined;
+ let lastPaint: number | undefined;
+ let output = "";
+ for (let index = inputIndex + 1; index < frames.length; index += 1) {
+ const frame = frames[index]!;
+ elapsed += frame.deltaMs;
+ if (frame.kind === 2) break;
+ if (frame.kind !== 1) continue;
+ firstPaint ??= elapsed;
+ lastPaint = elapsed;
+ output += frame.payload.toString("utf8");
+ if (contentMarker !== undefined && output.includes(contentMarker)) {
+ return { firstPaint, contentReady: elapsed };
+ }
+ }
+ if (firstPaint !== undefined && lastPaint !== undefined) {
+ return { firstPaint, contentReady: lastPaint };
+ }
+ throw new Error(
+ `recording did not contain content-ready stdout after input; marker=${JSON.stringify(contentMarker)}`,
+ );
+}
+
+async function measureAction(
+ tapePath: string,
+ action: () => void,
+ waitReady: () => Promise,
+ contentMarker?: string,
+): Promise<{ firstPaint: number; contentReady: number }> {
+ const fromIndex = readCompleteTape(tapePath).length;
+ action();
+ await waitReady();
+ return frameLatency(readCompleteTape(tapePath), fromIndex, contentMarker);
+}
+
+async function measurePaneAction(
+ action: () => void,
+ waitReady: () => Promise,
+): Promise<{ firstPaint: number; contentReady: number }> {
+ const started = performance.now();
+ action();
+ await waitReady();
+ const elapsed = Math.ceil(performance.now() - started);
+ return { firstPaint: elapsed, contentReady: elapsed };
+}
+
+async function waitForPaneChange(
+ session: TmuxSession,
+ before: string,
+ timeoutMs = TIMEOUT,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (await session.capturePane() !== before) return;
+ await Bun.sleep(1);
+ }
+ throw new Error("terminal pane did not change after input");
+}
+
+async function waitForPaneText(
+ session: TmuxSession,
+ marker: string,
+ before: string,
+ timeoutMs = TIMEOUT,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const pane = await session.capturePane();
+ if (pane !== before && pane.includes(marker)) return;
+ await Bun.sleep(1);
+ }
+ throw new Error(`terminal pane did not show ${JSON.stringify(marker)}`);
+}
+
+async function waitForTapeQuiescence(
+ tapePath: string,
+ quietMs = 20,
+ timeoutMs = TIMEOUT,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ let lastLength = readCompleteTape(tapePath).length;
+ let quietSince = Date.now();
+ while (Date.now() < deadline) {
+ await Bun.sleep(2);
+ const length = readCompleteTape(tapePath).length;
+ if (length !== lastLength) {
+ lastLength = length;
+ quietSince = Date.now();
+ continue;
+ }
+ if (Date.now() - quietSince >= quietMs) return;
+ }
+ throw new Error("recording did not become quiescent before the next action");
+}
+
+async function waitForEscapedPaneChange(
+ session: TmuxSession,
+ before: string,
+ label: string,
+ timeoutMs = TIMEOUT,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (await session.capturePaneEscapes() !== before) return;
+ await Bun.sleep(2);
+ }
+ throw new Error(`${label} did not produce a changed escaped pane`);
+}
+
+async function closeSurface(
+ session: TmuxSession,
+ visibleMarker: string,
+ closeKey = "Escape",
+): Promise {
+ session.sendKeysImmediate([closeKey, "C-u"]);
+ await session.waitForPane(
+ (pane) => hasEmptyComposer(pane) && !pane.includes(visibleMarker),
+ TIMEOUT,
+ );
+}
+
+function appendMeasured(samples: Samples, value: { firstPaint: number; contentReady: number }) {
+ samples.firstPaint.push(value.firstPaint);
+ samples.contentReady.push(value.contentReady);
+}
+
+function resourceSnapshot(pid: number): ResourceSnapshot {
+ const rssKib = Number.parseInt(
+ execFileSync("ps", ["-o", "rss=", "-p", String(pid)], { encoding: "utf8" }).trim(),
+ 10,
+ );
+ const threads = process.platform === "linux"
+ ? Number.parseInt(
+ readFileSync(`/proc/${pid}/status`, "utf8").match(/^Threads:\s+(\d+)$/m)?.[1] ?? "0",
+ 10,
+ )
+ : execFileSync("ps", ["-M", "-p", String(pid)], { encoding: "utf8" })
+ .trim().split("\n").length - 1;
+ const descriptors = process.platform === "linux"
+ ? readdirSync(`/proc/${pid}/fd`).length
+ : execFileSync("lsof", ["-p", String(pid), "-Fn"], { encoding: "utf8" })
+ .split("\n").filter((line) => line.startsWith("n")).length;
+ return { rssKib, threads, descriptors };
+}
+
+async function peakResourcesWhile(
+ pid: number,
+ work: () => Promise,
+): Promise {
+ let settled = false;
+ const pending = work().finally(() => {
+ settled = true;
+ });
+ let peak = resourceSnapshot(pid);
+ while (!settled) {
+ const current = resourceSnapshot(pid);
+ peak = {
+ rssKib: Math.max(peak.rssKib, current.rssKib),
+ threads: Math.max(peak.threads, current.threads),
+ descriptors: Math.max(peak.descriptors, current.descriptors),
+ };
+ await Bun.sleep(5);
+ }
+ await pending;
+ return peak;
+}
+
+async function waitForResourceQuiescence(
+ pid: number,
+ expected: ResourceSnapshot,
+ timeoutMs = 5_000,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ let current = resourceSnapshot(pid);
+ while (Date.now() < deadline) {
+ if (
+ current.threads === expected.threads &&
+ current.descriptors === expected.descriptors
+ ) return current;
+ await Bun.sleep(25);
+ current = resourceSnapshot(pid);
+ }
+ throw new Error(
+ `resources did not return to baseline: ` +
+ `expected threads/fds=${expected.threads}/${expected.descriptors} ` +
+ `received=${current.threads}/${current.descriptors}`,
+ );
+}
+
+async function waitForResourceStability(
+ pid: number,
+ quietMs = 500,
+ timeoutMs = 5_000,
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ let current = resourceSnapshot(pid);
+ let stableSince = Date.now();
+ while (Date.now() < deadline) {
+ await Bun.sleep(25);
+ const next = resourceSnapshot(pid);
+ if (
+ next.threads !== current.threads ||
+ next.descriptors !== current.descriptors
+ ) {
+ current = next;
+ stableSince = Date.now();
+ continue;
+ }
+ current = next;
+ if (Date.now() - stableSince >= quietMs) return current;
+ }
+ throw new Error("resources did not become stable after feature warmup");
+}
+
+function longTranscript(): string {
+ const rows: string[] = [];
+ for (let index = 0; index < 2_100; index += 1) {
+ if (index === 0) rows.push("PERF_TRANSCRIPT_HEAD");
+ else if (index === 1_050) rows.push("PERF_TRANSCRIPT_MIDDLE");
+ else if (index === 2_099) rows.push("PERF_TRANSCRIPT_TAIL");
+ else if (index % 17 === 0) rows.push(`| ${index} | wide unicode 𝒇x 漢字 | wrapped ${"x".repeat(96)} |`);
+ else if (index % 11 === 0) rows.push("");
+ else rows.push(`transcript row ${String(index).padStart(4, "0")}`);
+ }
+ return rows.join("\n");
+}
+
+function createFixture() {
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-performance-")));
+ const home = join(root, "home");
+ const workspace = join(root, "workspace");
+ const skillsRoot = join(workspace, ".agents", "skills");
+ mkdirSync(join(home, ".fx"), { recursive: true });
+ mkdirSync(skillsRoot, { recursive: true });
+ const hash = createHash("sha256");
+ let generationSkillPath = "";
+ for (let index = 0; index < 289; index += 1) {
+ const name = index === 0
+ ? "generation-skill-000"
+ : index % 2 === 0
+ ? `needle-skill-${String(index).padStart(3, "0")}`
+ : `other-skill-${String(index).padStart(3, "0")}`;
+ const body = `---\nname: ${name}\ndescription: performance fixture ${index}\n---\nbody ${index}\n`;
+ const dir = join(skillsRoot, name);
+ mkdirSync(dir, { recursive: true });
+ const skillPath = join(dir, "SKILL.md");
+ writeFileSync(skillPath, body);
+ if (index === 0) generationSkillPath = skillPath;
+ hash.update(body);
+ }
+ const transcript = longTranscript();
+ writeFileSync(join(workspace, "performance-target.txt"), "fixture\n");
+ hash.update(transcript);
+ return {
+ root,
+ home,
+ workspace: realpathSync(workspace),
+ tapePath: join(root, "performance.fxtape"),
+ stderrPath: join(root, "stderr.log"),
+ fixtureHash: hash.digest("hex"),
+ transcript,
+ generationSkillPath,
+ };
+}
+
+function writeGenerationSkill(path: string, generation: number): string {
+ const name = `generation-skill-${String(generation).padStart(3, "0")}`;
+ writeFileSync(
+ path,
+ `---\nname: ${name}\ndescription: current catalog generation ${generation}\n---\nbody ${generation}\n`,
+ );
+ return name;
+}
+
+function sendOverlappingSkillCommands(
+ session: TmuxSession,
+ newestSkill: string,
+): void {
+ execFileSync("tmux", [
+ "send-keys", "-t", session.name, "-l", "--", "/skills",
+ ";", "send-keys", "-t", session.name, "Enter",
+ ";", "send-keys", "-t", session.name, "-l", "--",
+ `/skills show ${newestSkill}`,
+ ";", "send-keys", "-t", session.name, "Enter",
+ ]);
+}
+
+test.skipIf(!tmuxAvailable())(
+ "overlapping skill commands keep the latest action and the shell alive",
+ async () => {
+ const fixture = createFixture();
+ let session: TmuxSession | null = null;
+ try {
+ session = await TmuxSession.create({
+ cmd: FX_BIN,
+ cwd: fixture.workspace,
+ env: {
+ HOME: fixture.home,
+ AI_GATEWAY_API_KEY: undefined,
+ VERCEL_OIDC_TOKEN: undefined,
+ FX_AUTO_UPGRADE: "0",
+ FX_SOUND: "0",
+ NO_COLOR: "1",
+ },
+ stderrPath: fixture.stderrPath,
+ width: 104,
+ height: 30,
+ });
+ await session.waitForComposer(TIMEOUT);
+ await session.sendText("/skills");
+ await session.waitForText("Skills 289", TIMEOUT);
+ await closeSurface(session, "Skills ");
+
+ const newest = writeGenerationSkill(fixture.generationSkillPath, 999);
+ sendOverlappingSkillCommands(session, newest);
+ await session.waitForText(newest, TIMEOUT);
+ await session.waitForText("Skills 289", TIMEOUT);
+ expect(session.isAlive()).toBe(true);
+ expect(readFileSync(fixture.stderrPath, "utf8")).toBe("");
+ } finally {
+ await session?.kill();
+ rmSync(fixture.root, { recursive: true, force: true });
+ }
+ },
+ 120_000,
+);
+
+test.skipIf(!tmuxAvailable())(
+ "prompt admission treats missing HOME as an empty optional skill catalog",
+ async () => {
+ const fixture = createFixture();
+ const noHomeGateway = startFakeGateway([
+ fakeGatewayFinalText("MISSING_HOME_PROMPT_OK"),
+ ]);
+ let active: TmuxSession | null = null;
+ try {
+ active = await TmuxSession.create({
+ cmd: FX_BIN,
+ cwd: fixture.workspace,
+ env: {
+ HOME: undefined,
+ AI_GATEWAY_API_KEY: "missing-home-key",
+ VERCEL_OIDC_TOKEN: undefined,
+ FX_GATEWAY_BASE_URL: noHomeGateway.baseUrl,
+ FX_GATEWAY_CHAT_URL: noHomeGateway.chatUrl,
+ FX_MODEL: FAKE_GATEWAY_MODEL,
+ FX_AUTO_UPGRADE: "0",
+ FX_DISABLE_KEYCHAIN: "1",
+ FX_SKIP_ONBOARDING: "1",
+ FX_SOUND: "0",
+ NO_COLOR: "1",
+ },
+ stderrPath: fixture.stderrPath,
+ width: 104,
+ height: 30,
+ });
+ await active.waitForComposer(TIMEOUT);
+ await active.sendText("Submit without an optional home directory.");
+ const pane = await active.waitForText("MISSING_HOME_PROMPT_OK", 5_000);
+ expect(pane).not.toContain("HomeNotSet");
+ expect(noHomeGateway.requestCount()).toBe(1);
+ expect(readFileSync(fixture.stderrPath, "utf8")).toBe("");
+ } finally {
+ await active?.kill();
+ noHomeGateway.stop();
+ rmSync(fixture.root, { recursive: true, force: true });
+ }
+ },
+ 30_000,
+);
+
+test.skipIf(!tmuxAvailable())(
+ "skills refresh discovers SKILL.md added inside an existing candidate directory",
+ async () => {
+ const fixture = createFixture();
+ const candidate = join(
+ fixture.workspace,
+ ".agents",
+ "skills",
+ "late-skill",
+ );
+ mkdirSync(candidate);
+ let active: TmuxSession | null = null;
+ try {
+ active = await TmuxSession.create({
+ cmd: FX_BIN,
+ cwd: fixture.workspace,
+ env: {
+ HOME: fixture.home,
+ AI_GATEWAY_API_KEY: undefined,
+ VERCEL_OIDC_TOKEN: undefined,
+ FX_AUTO_UPGRADE: "0",
+ FX_SOUND: "0",
+ NO_COLOR: "1",
+ },
+ stderrPath: fixture.stderrPath,
+ width: 104,
+ height: 30,
+ });
+ await active.waitForComposer(TIMEOUT);
+ await active.sendText("/skills");
+ await active.waitForText("Skills 289", TIMEOUT);
+ await closeSurface(active, "Skills ");
+
+ writeFileSync(
+ join(candidate, "SKILL.md"),
+ "---\nname: late-skill\ndescription: created inside an existing candidate\n---\nbody\n",
+ );
+ await active.sendText("/skills");
+ const refreshed = await active.waitForText("late-skill", TIMEOUT);
+ expect(refreshed).toContain("Skills 290");
+ expect(readFileSync(fixture.stderrPath, "utf8")).toBe("");
+ } finally {
+ await active?.kill();
+ rmSync(fixture.root, { recursive: true, force: true });
+ }
+ },
+ 30_000,
+);
+
+test.skipIf(!tmuxAvailable())(
+ "skills refresh preserves the canonical catalog for a symlinked HOME",
+ async () => {
+ const fixture = createFixture();
+ const linkedHome = join(fixture.root, "linked-home");
+ const globalSkill = join(
+ fixture.home,
+ ".agents",
+ "skills",
+ "global-skill",
+ );
+ mkdirSync(globalSkill, { recursive: true });
+ writeFileSync(
+ join(globalSkill, "SKILL.md"),
+ "---\nname: global-skill\ndescription: survives canonical home refresh\n---\nbody\n",
+ );
+ symlinkSync(fixture.home, linkedHome, "dir");
+ const linkedHomeGateway = startFakeGateway([
+ fakeGatewayFinalText("SYMLINKED_HOME_PROMPT_OK"),
+ ]);
+ let active: TmuxSession | null = null;
+ try {
+ active = await TmuxSession.create({
+ cmd: FX_BIN,
+ cwd: fixture.workspace,
+ env: {
+ HOME: linkedHome,
+ AI_GATEWAY_API_KEY: "symlinked-home-key",
+ VERCEL_OIDC_TOKEN: undefined,
+ FX_GATEWAY_BASE_URL: linkedHomeGateway.baseUrl,
+ FX_GATEWAY_CHAT_URL: linkedHomeGateway.chatUrl,
+ FX_MODEL: FAKE_GATEWAY_MODEL,
+ FX_AUTO_UPGRADE: "0",
+ FX_DISABLE_KEYCHAIN: "1",
+ FX_SKIP_ONBOARDING: "1",
+ FX_SOUND: "0",
+ NO_COLOR: "1",
+ },
+ stderrPath: fixture.stderrPath,
+ width: 104,
+ height: 30,
+ });
+ await active.waitForComposer(TIMEOUT);
+ active.sendLiteralImmediate("$global");
+ await active.waitForText("global-skill", TIMEOUT);
+ await closeSurface(active, "Skills ");
+
+ sendOverlappingSkillCommands(active, "global-skill");
+ await active.waitForText("global-skill", 5_000);
+ await active.waitForText("Skills 290", 5_000);
+ await closeSurface(active, "Skills ");
+
+ await active.sendText("Submit after canonical home refresh.");
+ await active.waitForText("SYMLINKED_HOME_PROMPT_OK", TIMEOUT);
+ expect(linkedHomeGateway.requestCount()).toBe(1);
+ expect(readFileSync(fixture.stderrPath, "utf8")).toBe("");
+ } finally {
+ await active?.kill();
+ linkedHomeGateway.stop();
+ rmSync(fixture.root, { recursive: true, force: true });
+ }
+ },
+ 60_000,
+);
+
+test.skipIf(!ENABLED || !tmuxAvailable())(
+ "interactive terminal surfaces stay within one frame at p95",
+ async () => {
+ const fixture = createFixture();
+ const secondTranscript = fixture.transcript.replace(
+ "PERF_TRANSCRIPT_TAIL",
+ "PERF_SECOND_TRANSCRIPT_TAIL",
+ );
+ let hostedTerminalSessionId = "";
+ const gateway = startFakeGateway([
+ fakeGatewayFinalText(fixture.transcript),
+ fakeGatewayToolCall("performance-question", "ask_user_question", {
+ questions: [{
+ question: "Which performance path should I use?",
+ options: [
+ { label: "Alpha path", description: "Use the first path." },
+ { label: "Beta path", description: "Use the second path." },
+ ],
+ }],
+ }),
+ fakeGatewayFinalText("PERF_QUESTION_DONE"),
+ fakeGatewayToolCall("performance-approval", "terminal", {
+ action: "exec",
+ command: "touch performance-approval.txt",
+ timeout_ms: 600_000,
+ }),
+ fakeGatewayFinalText("PERF_APPROVAL_DONE"),
+ fakeGatewayToolCall("performance-terminal", "terminal", {
+ action: "start",
+ cwd: fixture.workspace,
+ command:
+ "printf 'PERF_TERMINAL_READY\\n'; " +
+ "while :; do sleep 1; done",
+ backend: "native",
+ return_when: { kind: "match", pattern: "PERF_TERMINAL_READY" },
+ wait_ceiling_ms: 20_000,
+ dimensions: { rows: 24, columns: 80 },
+ }),
+ (body) => {
+ hostedTerminalSessionId = findSessionId(JSON.parse(body)) ?? "";
+ if (hostedTerminalSessionId.length === 0) {
+ throw new Error("terminal start result did not contain a session id");
+ }
+ return fakeGatewayFinalText("PERF_TERMINAL_AGENT_READY");
+ },
+ () => fakeGatewayToolCall("performance-terminal-close", "terminal", {
+ action: "close",
+ session_id: hostedTerminalSessionId,
+ close_policy: "force",
+ }),
+ fakeGatewayFinalText("PERF_TERMINAL_CLOSED"),
+ fakeGatewayFinalText(secondTranscript),
+ ]);
+ let session: TmuxSession | null = null;
+ try {
+ session = await TmuxSession.create({
+ cmd: FX_BIN,
+ cwd: fixture.workspace,
+ env: {
+ HOME: fixture.home,
+ AI_GATEWAY_API_KEY: "fake-performance-key",
+ VERCEL_OIDC_TOKEN: undefined,
+ FX_GATEWAY_BASE_URL: gateway.baseUrl,
+ FX_GATEWAY_CHAT_URL: gateway.chatUrl,
+ FX_MODEL: FAKE_GATEWAY_MODEL,
+ FX_PERMISSION_MODE: "ask",
+ FX_AUTO_UPGRADE: "0",
+ FX_SOUND: "0",
+ FX_RECORD: fixture.tapePath,
+ FX_RECORD_INPUT: "1",
+ FX_TERMINAL_HOST_IDLE_MS: "250",
+ NO_COLOR: "1",
+ },
+ stderrPath: fixture.stderrPath,
+ width: 104,
+ height: 30,
+ minimumHistoryLines: 20_000,
+ });
+ await session.waitForComposer(TIMEOUT);
+ await session.sendText("Build the performance transcript.");
+ await session.waitForText("PERF_TRANSCRIPT_TAIL", TIMEOUT);
+
+ // One correctness cycle also fences the inline prewarm before timing.
+ session.sendKeysImmediate(["C-o"]);
+ await session.waitForText("Full detail · ctrl o close", TIMEOUT);
+ expect(await session.captureFullScrollback()).toContain("PERF_TRANSCRIPT_HEAD");
+ session.sendKeysImmediate(["Escape"]);
+ await session.waitForComposer(TIMEOUT);
+
+ const samples = Object.fromEntries(
+ MEASURED_ACTION_NAMES.map((name) => [
+ name,
+ { firstPaint: [], contentReady: [] } as Samples,
+ ]),
+ ) as Record<(typeof MEASURED_ACTION_NAMES)[number], Samples>;
+ const pid = session.processPid();
+ const preFeatureResources = resourceSnapshot(pid);
+
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ const open = await measureAction(
+ fixture.tapePath,
+ () => session!.sendKeysImmediate(["C-o"]),
+ () => session!.waitForText("Full detail · ctrl o close", TIMEOUT),
+ "Full detail",
+ );
+ const beforeScroll = await session.capturePane();
+ const scroll = await measureAction(
+ fixture.tapePath,
+ () => session!.sendKeysImmediate(["Up"]),
+ () => session!.waitForPane(
+ (pane) => pane !== beforeScroll && pane.includes("Full detail"),
+ TIMEOUT,
+ ),
+ );
+ session.sendKeysImmediate(["Escape"]);
+ await session.waitForComposer(TIMEOUT);
+ if (cycle >= WARMUPS) {
+ appendMeasured(samples.fullOpen, open);
+ appendMeasured(samples.fullScroll, scroll);
+ }
+ }
+
+ session.sendKeysImmediate(["C-o"]);
+ await session.waitForText("Full detail · ctrl o close", TIMEOUT);
+ let beforePrime = await session.capturePane();
+ // The first key moves one viewport into the three-viewport prepared
+ // window. Each loop then moves to its edge before the measured key
+ // crosses that edge and waits for the replacement window.
+ session.sendKeysImmediate(["PageUp"]);
+ await session.waitForPane(
+ (pane) => pane !== beforePrime && pane.includes("Full detail"),
+ TIMEOUT,
+ );
+ await waitForTapeQuiescence(fixture.tapePath);
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ const key = cycle % 2 === 0 ? "PageUp" : "PageDown";
+ beforePrime = await session.capturePane();
+ session.sendKeysImmediate([key]);
+ await session.waitForPane(
+ (pane) => pane !== beforePrime && pane.includes("Full detail"),
+ TIMEOUT,
+ );
+ await waitForTapeQuiescence(fixture.tapePath);
+ const beforeMiss = await session.capturePane();
+ const cacheMiss = await measureAction(
+ fixture.tapePath,
+ () => session!.sendKeysImmediate([key]),
+ () => session!.waitForPane(
+ (pane) => pane !== beforeMiss && pane.includes("Full detail"),
+ TIMEOUT,
+ ),
+ );
+ if (cycle >= WARMUPS) {
+ appendMeasured(samples.fullScrollCacheMiss, cacheMiss);
+ }
+ }
+ session.sendKeysImmediate(["Escape"]);
+ await session.waitForComposer(TIMEOUT);
+
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ const generationName = writeGenerationSkill(
+ fixture.generationSkillPath,
+ cycle + 1,
+ );
+ await session.sendLiteralText("/skills");
+ const open = await measureAction(
+ fixture.tapePath,
+ () => session!.sendKeysImmediate(["Enter"]),
+ () => session!.waitForText(generationName, TIMEOUT),
+ generationName,
+ );
+ const query = await measureAction(
+ fixture.tapePath,
+ () => session!.sendLiteralImmediate("needle"),
+ () => session!.waitForText("Skills 144", TIMEOUT),
+ "Skills 144",
+ );
+ session.sendKeysImmediate(["Escape"]);
+ await session.waitForComposer(TIMEOUT);
+ if (cycle >= WARMUPS) {
+ appendMeasured(samples.skillsOpen, open);
+ appendMeasured(samples.skillsQuery, query);
+ }
+ }
+
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ session.sendKeysImmediate(["C-u"]);
+ await session.waitForComposer(TIMEOUT);
+ await session.sendLiteralText("/login");
+ const open = await measureAction(
+ fixture.tapePath,
+ () => session!.sendKeysImmediate(["Enter"]),
+ () => session!.waitForText("Connections", TIMEOUT),
+ "Connections",
+ );
+ session.sendKeysImmediate(["Escape"]);
+ await session.waitForComposer(TIMEOUT);
+ session.sendKeysImmediate(["C-u"]);
+ await session.waitForComposer(TIMEOUT);
+ if (cycle >= WARMUPS) appendMeasured(samples.loginOpen, open);
+ }
+
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ session.sendKeysImmediate(["C-u"]);
+ await session.waitForComposer(TIMEOUT);
+ const marker = `performance-edit-${cycle}`;
+ const edit = await measureAction(
+ fixture.tapePath,
+ () => session!.sendLiteralImmediate(marker),
+ () => session!.waitForText(marker, TIMEOUT),
+ marker,
+ );
+ session.sendKeysImmediate(["C-u"]);
+ await session.waitForComposer(TIMEOUT);
+ if (cycle >= WARMUPS) appendMeasured(samples.composerEdit, edit);
+ }
+
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ const slashOpen = await measureAction(
+ fixture.tapePath,
+ () => session!.sendLiteralImmediate("/"),
+ () => session!.waitForText("Results ", TIMEOUT),
+ "Results ",
+ );
+ const slashQuery = await measureAction(
+ fixture.tapePath,
+ () => session!.sendLiteralImmediate("he"),
+ () => session!.waitForPane(
+ (pane) => composerContains(pane, "/he") &&
+ pane.includes("show available slash commands"),
+ TIMEOUT,
+ ),
+ "┃ /he",
+ );
+ await closeSurface(session, "Results ");
+ if (cycle >= WARMUPS) {
+ appendMeasured(samples.slashOpen, slashOpen);
+ appendMeasured(samples.slashQuery, slashQuery);
+ }
+ }
+
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ const dollarOpen = await measureAction(
+ fixture.tapePath,
+ () => session!.sendLiteralImmediate("$"),
+ () => session!.waitForText("Skills 289", TIMEOUT),
+ "Skills 289",
+ );
+ const dollarQuery = await measureAction(
+ fixture.tapePath,
+ () => session!.sendLiteralImmediate("needle"),
+ () => session!.waitForText("Skills 144", TIMEOUT),
+ "Skills 144",
+ );
+ await closeSurface(session, "Skills ");
+ if (cycle >= WARMUPS) {
+ appendMeasured(samples.dollarOpen, dollarOpen);
+ appendMeasured(samples.dollarQuery, dollarQuery);
+ }
+ }
+
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ const fileQuery = await measureAction(
+ fixture.tapePath,
+ () => session!.sendLiteralImmediate("@performance"),
+ () => session!.waitForText("performance-target.txt", TIMEOUT),
+ "performance-target.txt",
+ );
+ await closeSurface(session, "performance-target.txt");
+ if (cycle >= WARMUPS) appendMeasured(samples.fileQuery, fileQuery);
+ }
+
+ for (const action of LOCAL_MENU_ACTIONS) {
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ await session.sendLiteralText(action.command);
+ const before = await session.capturePane();
+ const open = await measurePaneAction(
+ () => session!.sendKeysImmediate(["Enter"]),
+ () => waitForPaneText(session!, action.marker, before),
+ );
+ await closeSurface(session, action.marker);
+ if (cycle >= WARMUPS) appendMeasured(samples[action.name], open);
+ }
+ }
+
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ const before = await session.capturePane();
+ const open = await measurePaneAction(
+ () => session!.sendKeysImmediate(["C-x"]),
+ () => waitForPaneText(session!, "Agents & processes", before),
+ );
+ await closeSurface(session, "Agents & processes", "C-x");
+ if (cycle >= WARMUPS) appendMeasured(samples.subagentManagerOpen, open);
+ }
+
+ await session.sendText("Open the performance question.");
+ await session.waitForText("Which performance path should I use?", TIMEOUT);
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ const before = await session.capturePaneEscapes();
+ const key = cycle % 2 === 0 ? "Down" : "Up";
+ const navigation = await measureAction(
+ fixture.tapePath,
+ () => session!.sendKeysImmediate([key]),
+ () => waitForEscapedPaneChange(session!, before, `questionNavigate.${cycle}`),
+ );
+ if (cycle >= WARMUPS) appendMeasured(samples.questionNavigate, navigation);
+ }
+ session.sendKeysImmediate(["2"]);
+ await session.waitForText("PERF_QUESTION_DONE", TIMEOUT);
+ await session.waitForComposer(TIMEOUT);
+
+ await session.sendText("Open the performance approval.");
+ await session.waitForText("touch performance-approval.txt", TIMEOUT);
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ const before = await session.capturePaneEscapes();
+ const key = cycle % 2 === 0 ? "Down" : "Up";
+ const navigation = await measureAction(
+ fixture.tapePath,
+ () => session!.sendKeysImmediate([key]),
+ () => waitForEscapedPaneChange(session!, before, `approvalNavigate.${cycle}`),
+ );
+ if (cycle >= WARMUPS) appendMeasured(samples.approvalNavigate, navigation);
+ }
+ session.sendKeysImmediate(["3"]);
+ await session.waitForText("PERF_APPROVAL_DONE", TIMEOUT);
+ await session.waitForComposer(TIMEOUT);
+
+ await session.sendText("Start the performance terminal.");
+ await session.waitForText("PERF_TERMINAL_READY", TIMEOUT);
+ session.sendKeysImmediate(["1"]);
+ await session.waitForText("PERF_TERMINAL_AGENT_READY", TIMEOUT);
+ await session.waitForComposer(TIMEOUT);
+ session.sendKeysImmediate(["C-x"]);
+ await session.waitForText("Background processes", TIMEOUT);
+ session.sendKeysImmediate(["Enter"]);
+ await session.waitForText("PERF_TERMINAL_READY", TIMEOUT);
+ for (let cycle = 0; cycle < WARMUPS + SAMPLES; cycle += 1) {
+ const before = await session.capturePane();
+ const input = await measurePaneAction(
+ () => session!.sendLiteralImmediate(cycle % 2 === 0 ? "x" : "y"),
+ () => waitForPaneChange(session!, before),
+ );
+ if (cycle >= WARMUPS) appendMeasured(samples.hostedTerminalInput, input);
+ }
+ await session.sendHexBytes(["1d", "64"]);
+ await session.waitForText("Background processes", TIMEOUT);
+ session.sendKeysImmediate(["C-x"]);
+ await session.waitForComposer(TIMEOUT);
+ await session.sendText("Close the performance terminal.");
+ await session.waitForText("terminal close", TIMEOUT);
+ session.sendKeysImmediate(["1"]);
+ await session.waitForText("PERF_TERMINAL_CLOSED", TIMEOUT);
+ await session.waitForComposer(TIMEOUT);
+ const resourcesBefore = await waitForResourceStability(pid);
+ expect(resourcesBefore.threads - preFeatureResources.threads).toBeLessThanOrEqual(2);
+ expect(resourcesBefore.descriptors - preFeatureResources.descriptors).toBeLessThanOrEqual(3);
+ expect(resourcesBefore.rssKib - preFeatureResources.rssKib).toBeLessThan(16 * 1024);
+
+ const peakResources = await peakResourcesWhile(pid, async () => {
+ await session!.sendText("Build the second performance transcript.");
+ await session!.waitForText("PERF_SECOND_TRANSCRIPT_TAIL", TIMEOUT);
+ });
+ const resourcesAfter = await waitForResourceQuiescence(pid, resourcesBefore);
+ const report = {
+ boundary: "recorded application stdin frame to recorded stdout frame",
+ boundaryExceptions: {
+ catalogMenus: "user input dispatch to changed exclusive catalog pane",
+ subagentManagerOpen: "user input dispatch to changed manager pane",
+ hostedTerminalInput: "user input dispatch to changed hosted-terminal pane",
+ },
+ buildMode: "ReleaseSafe",
+ warmups: WARMUPS,
+ measuredSamples: SAMPLES,
+ actions: MEASURED_ACTION_NAMES,
+ terminal: { cols: 104, rows: 30 },
+ fixture: {
+ hash: fixture.fixtureHash,
+ skills: 289,
+ transcriptLines: 2_100,
+ transcriptBytes: Buffer.byteLength(fixture.transcript),
+ },
+ budgetsMs: {
+ local: LOCAL_BUDGETS_MS,
+ backgroundWork: BACKGROUND_WORK_BUDGETS_MS,
+ externalRefresh: EXTERNAL_REFRESH_BUDGETS_MS,
+ appPane: APP_PANE_BUDGETS_MS,
+ },
+ informationalActions: [...INFORMATIONAL_PANE_ACTION_NAMES],
+ results: Object.fromEntries(
+ Object.entries(samples).map(([name, values]) => [name, {
+ firstPaint: summary(values.firstPaint),
+ contentReady: summary(values.contentReady),
+ }]),
+ ),
+ resources: {
+ preFeature: preFeatureResources,
+ postWarmup: resourcesBefore,
+ peak: peakResources,
+ after: resourcesAfter,
+ },
+ };
+ const reportPath = process.env.FX_TUI_PERFORMANCE_REPORT;
+ if (reportPath) writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
+
+ for (const [name, values] of Object.entries(samples)) {
+ const actionBudget = APP_PANE_ACTION_NAMES.has(name)
+ ? APP_PANE_BUDGETS_MS
+ : name === "skillsOpen" || name === "loginOpen"
+ ? EXTERNAL_REFRESH_BUDGETS_MS
+ : name === "fullScrollCacheMiss"
+ ? BACKGROUND_WORK_BUDGETS_MS
+ : LOCAL_BUDGETS_MS;
+ for (const distribution of [values.firstPaint, values.contentReady]) {
+ const measured = summary(distribution);
+ expect(measured.count).toBe(SAMPLES);
+ if (INFORMATIONAL_PANE_ACTION_NAMES.has(name)) continue;
+ const budget = APP_PANE_ACTION_NAMES.has(name) || name === "fullScrollCacheMiss"
+ ? actionBudget
+ : distribution === values.firstPaint
+ ? LOCAL_BUDGETS_MS
+ : actionBudget;
+ const phase = distribution === values.firstPaint ? "firstPaint" : "contentReady";
+ if (measured.p50 > budget.p50 ||
+ measured.p90 > budget.p90 ||
+ measured.p95 > budget.p95) {
+ throw new Error(
+ `${name}.${phase} exceeded budget: ` +
+ `measured=${measured.p50}/${measured.p90}/${measured.p95}ms ` +
+ `budget=${budget.p50}/${budget.p90}/${budget.p95}ms`,
+ );
+ }
+ }
+ }
+ expect(resourcesAfter.threads).toBe(resourcesBefore.threads);
+ expect(resourcesAfter.descriptors).toBe(resourcesBefore.descriptors);
+ expect(resourcesAfter.rssKib - resourcesBefore.rssKib).toBeLessThan(16 * 1024);
+ expect(peakResources.threads - resourcesBefore.threads).toBeLessThanOrEqual(3);
+ expect(peakResources.descriptors - resourcesBefore.descriptors).toBeLessThanOrEqual(
+ ACTIVE_TURN_DESCRIPTOR_BUDGET,
+ );
+ expect(peakResources.rssKib - resourcesBefore.rssKib).toBeLessThan(32 * 1024);
+ expect(readFileSync(fixture.stderrPath, "utf8")).toBe("");
+ } finally {
+ await session?.kill();
+ gateway.stop();
+ if (process.env.FX_TUI_PERFORMANCE_KEEP !== "1") {
+ rmSync(fixture.root, { recursive: true, force: true });
+ } else {
+ console.error(`retained TUI performance fixture at ${fixture.root}`);
+ }
+ }
+ },
+ 900_000,
+);
+
+test.skipIf(!LIVE_ENABLED || !tmuxAvailable())(
+ "live provider preserves the fast menu and prepared transcript pipeline",
+ async () => {
+ const fixture = createFixture();
+ let session: TmuxSession | null = null;
+ try {
+ session = await TmuxSession.create({
+ cmd: FX_BIN,
+ cwd: fixture.workspace,
+ env: {
+ HOME: fixture.home,
+ AI_GATEWAY_API_KEY: process.env.AI_GATEWAY_API_KEY,
+ VERCEL_OIDC_TOKEN: process.env.VERCEL_OIDC_TOKEN,
+ FX_AUTO_UPGRADE: "0",
+ FX_SOUND: "0",
+ NO_COLOR: "1",
+ },
+ stderrPath: fixture.stderrPath,
+ width: 104,
+ height: 30,
+ minimumHistoryLines: 20_000,
+ });
+ await session.waitForComposer(TIMEOUT);
+ await session.sendText(
+ "Write 120 short numbered lines, then write LIVE_PERFORMANCE_DONE on its own line.",
+ );
+ await session.waitForText("LIVE_PERFORMANCE_DONE", TIMEOUT);
+
+ session.sendKeysImmediate(["C-o"]);
+ await session.waitForText("Full detail · ctrl o close", TIMEOUT);
+ session.sendKeysImmediate(["Up"]);
+ await Bun.sleep(25);
+ session.sendKeysImmediate(["Escape"]);
+ await session.waitForComposer(TIMEOUT);
+
+ await session.sendText("/skills");
+ await session.waitForText("Skills 289", TIMEOUT);
+ session.sendKeysImmediate(["Escape"]);
+ await session.waitForComposer(TIMEOUT);
+
+ session.sendKeysImmediate(["C-u"]);
+ await session.waitForComposer(TIMEOUT);
+ await session.sendText("/login");
+ await session.waitForText("Connections", TIMEOUT);
+ session.sendKeysImmediate(["Escape"]);
+ await session.waitForComposer(TIMEOUT);
+ expect(readFileSync(fixture.stderrPath, "utf8")).toBe("");
+ } finally {
+ await session?.kill();
+ rmSync(fixture.root, { recursive: true, force: true });
+ }
+ },
+ 180_000,
+);
diff --git a/tests/e2e/tui-resume.test.ts b/tests/e2e/tui-resume.test.ts
index fb7ba9c81..2d0244195 100644
--- a/tests/e2e/tui-resume.test.ts
+++ b/tests/e2e/tui-resume.test.ts
@@ -2440,7 +2440,7 @@ test.skipIf(!tmuxAvailable())(
await active.waitForText(streamMarker, TIMEOUT);
await active.sendKeys("C-o");
- await Bun.sleep(250);
+ await active.waitForText("┃ Full detail · ctrl o close", TIMEOUT);
const enterAlternate = Buffer.from("\x1b[?1049h");
const leaveAlternate = Buffer.from("\x1b[?1049l");
const tapeBeforeCancel = readFileSync(tapePath);