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);