From e4d6837f78a634f3cddb3e9f4ab9b07613aa48b3 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 14:09:53 +0200 Subject: [PATCH] Add first-class job rail profiles --- KEYMAP.md | 3 +- src/job.zig | 136 +++++++++++++++++++++++++++++ src/leader.zig | 52 +++++++++++ src/tui.zig | 229 ++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 415 insertions(+), 5 deletions(-) diff --git a/KEYMAP.md b/KEYMAP.md index df40d0c..a17e469 100644 --- a/KEYMAP.md +++ b/KEYMAP.md @@ -376,6 +376,8 @@ Backed by current code/tests: - leader rail seed behavior and command-intent separation; - transient panel/rendering foundations; - protocol/session/job/LSP/diagnostic skeletons; +- `Space t` lint/build/test/check job rail backed by source-patched command profiles, + captured output panels, missing-tool/cancel/timeout rows, and jump/yank actions; - basic trace commands for open, insert, save, panels, diagnostics, repo files, and local context. @@ -390,7 +392,6 @@ Design-only and needing future implementation slices: - parameter navigation in function calls; - compact-first hover/signature cards with expandable scroll/search panels; - full provider arbitration for multiple LSP/tool sources; -- lint/format/build/test command execution through the job surface; - source-profile format-on-save policy and one-shot save-without-format; - contextual rails for all prefixes listed in this document; - Insert pending-space rail, including `Space` pause then `n` to Normal; diff --git a/src/job.zig b/src/job.zig index 8c5ad4a..fe54ee6 100644 --- a/src/job.zig +++ b/src/job.zig @@ -13,8 +13,144 @@ pub const Error = error{ InvalidCommand, InvalidJobRow, InvalidPath, + MissingCurrentFile, }; +pub const Profile = enum { + lint_file, + lint_project, + build, + tests, + check, + + pub fn name(self: Profile) []const u8 { + return switch (self) { + .lint_file => "lint_file", + .lint_project => "lint_project", + .build => "build", + .tests => "test", + .check => "check", + }; + } + + pub fn provider(self: Profile) []const u8 { + return switch (self) { + .lint_file, .lint_project => "zig-fmt", + .build, .tests, .check => "zig-build", + }; + } + + pub fn scope(self: Profile) []const u8 { + return switch (self) { + .lint_file => "file", + .lint_project, .build, .tests, .check => "project", + }; + } +}; + +pub fn argvForProfileAlloc(allocator: std.mem.Allocator, profile: Profile, current_file: ?[]const u8) ![][]const u8 { + var argv = std.ArrayList([]const u8).empty; + errdefer { + for (argv.items) |arg| allocator.free(arg); + argv.deinit(allocator); + } + switch (profile) { + .lint_file => { + const path = current_file orelse return Error.MissingCurrentFile; + try validatePath(path); + try argv.append(allocator, try allocator.dupe(u8, "zig")); + try argv.append(allocator, try allocator.dupe(u8, "fmt")); + try argv.append(allocator, try allocator.dupe(u8, "--check")); + try argv.append(allocator, try allocator.dupe(u8, path)); + }, + .lint_project => { + try argv.append(allocator, try allocator.dupe(u8, "zig")); + try argv.append(allocator, try allocator.dupe(u8, "fmt")); + try argv.append(allocator, try allocator.dupe(u8, "--check")); + try argv.append(allocator, try allocator.dupe(u8, "src")); + }, + .build => { + try argv.append(allocator, try allocator.dupe(u8, "zig")); + try argv.append(allocator, try allocator.dupe(u8, "build")); + }, + .tests => { + try argv.append(allocator, try allocator.dupe(u8, "zig")); + try argv.append(allocator, try allocator.dupe(u8, "build")); + try argv.append(allocator, try allocator.dupe(u8, "test")); + }, + .check => { + try argv.append(allocator, try allocator.dupe(u8, "zig")); + try argv.append(allocator, try allocator.dupe(u8, "build")); + try argv.append(allocator, try allocator.dupe(u8, "v1-smoke")); + }, + } + return argv.toOwnedSlice(allocator); +} + +pub fn freeArgv(allocator: std.mem.Allocator, argv: []const []const u8) void { + for (argv) |arg| allocator.free(arg); + allocator.free(argv); +} + +pub fn profileRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, profile: Profile, current_file: ?[]const u8) ![][]const u8 { + const argv = argvForProfileAlloc(allocator, profile, current_file) catch |err| { + if (err == Error.MissingCurrentFile) return missingCurrentFileRowsAlloc(allocator, profile); + return err; + }; + defer freeArgv(allocator, argv); + return runRowsWithPreviewAlloc(allocator, io, cwd, argv, "job", profilePreview(profile)); +} + +pub fn missingToolRowsAlloc(allocator: std.mem.Allocator, profile: Profile, tool: []const u8) ![][]const u8 { + var rows = std.ArrayList([]const u8).empty; + errdefer freeOwnedRows(allocator, rows.items); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() })); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:status:missing_tool:{s}:no_install_attempted", .{sanitizeToken(tool)})); + return rows.toOwnedSlice(allocator); +} + +pub fn cancelRowsAlloc(allocator: std.mem.Allocator, profile: Profile) ![][]const u8 { + var rows = std.ArrayList([]const u8).empty; + errdefer freeOwnedRows(allocator, rows.items); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() })); + try rows.append(allocator, try allocator.dupe(u8, "job:status:cancelled:user")); + return rows.toOwnedSlice(allocator); +} + +pub fn timeoutRowsAlloc(allocator: std.mem.Allocator, profile: Profile) ![][]const u8 { + var rows = std.ArrayList([]const u8).empty; + errdefer freeOwnedRows(allocator, rows.items); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() })); + try rows.append(allocator, try allocator.dupe(u8, "job:status:timeout:recoverable")); + return rows.toOwnedSlice(allocator); +} + +fn missingCurrentFileRowsAlloc(allocator: std.mem.Allocator, profile: Profile) ![][]const u8 { + var rows = std.ArrayList([]const u8).empty; + errdefer freeOwnedRows(allocator, rows.items); + try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() })); + try rows.append(allocator, try allocator.dupe(u8, "job:status:missing_current_file")); + return rows.toOwnedSlice(allocator); +} + +fn profilePreview(profile: Profile) []const u8 { + return switch (profile) { + .lint_file => "profile:lint_file:scope:file:provider:zig-fmt", + .lint_project => "profile:lint_project:scope:project:provider:zig-fmt", + .build => "profile:build:scope:project:provider:zig-build", + .tests => "profile:test:scope:project:provider:zig-build", + .check => "profile:check:scope:project:provider:zig-build", + }; +} + +fn sanitizeToken(text: []const u8) []const u8 { + if (text.len == 0) return "unknown"; + for (text) |byte| { + if (byte <= 0x20 or byte == ':' or byte == '|') return "invalid"; + } + return text; +} + pub const Location = struct { path: []const u8, line: usize, diff --git a/src/leader.zig b/src/leader.zig index ebbe172..2f56732 100644 --- a/src/leader.zig +++ b/src/leader.zig @@ -15,6 +15,7 @@ pub const Feature = enum { panel_close, lsp, diagnostics, + jobs, }; pub const Action = union(enum) { @@ -33,6 +34,14 @@ pub const Action = union(enum) { diagnostics_next, diagnostics_previous, diagnostics_filter, + job_lint_file, + job_lint_project, + job_build, + job_test, + job_check, + job_cancel, + job_jump, + job_yank, repeat_rail, restore_last, not_built: Feature, @@ -52,6 +61,7 @@ const Mode = enum { search_rail, language_rail, diagnostic_rail, + tool_rail, open_prompt, }; @@ -78,6 +88,7 @@ pub const Leader = struct { .search_rail => return self.handleSearchRail(event), .language_rail => return self.handleLanguageRail(event), .diagnostic_rail => return self.handleDiagnosticRail(event), + .tool_rail => return self.handleToolRail(event), .open_prompt => return self.handleOpenPrompt(event), } } @@ -91,6 +102,7 @@ pub const Leader = struct { .search_rail => "search: f current file p project s symbols", .language_rail => "language: h hover s signature o open hover", .diagnostic_rail => "diagnostics: d/open n next p previous f filter-source", + .tool_rail => "tools: l lint-file L lint-project b build t test c check x cancel j jump y yank", .open_prompt => "open: type path, Enter opens, Esc cancels", }; } @@ -143,6 +155,10 @@ pub const Leader = struct { self.mode = .diagnostic_rail; return .diagnostics_open; } + if (std.mem.eql(u8, text, "t")) { + self.mode = .tool_rail; + return .none; + } if (std.mem.eql(u8, text, "q")) { self.mode = .idle; return .quit; @@ -272,6 +288,42 @@ pub const Leader = struct { } } + fn handleToolRail(self: *Leader, event: input.Event) Action { + self.message = null; + switch (event) { + .text => |text| { + self.mode = .idle; + if (std.mem.eql(u8, text, "l")) return .job_lint_file; + if (std.mem.eql(u8, text, "L")) return .job_lint_project; + if (std.mem.eql(u8, text, "b")) return .job_build; + if (std.mem.eql(u8, text, "t")) return .job_test; + if (std.mem.eql(u8, text, "c")) return .job_check; + if (std.mem.eql(u8, text, "x")) return .job_cancel; + if (std.mem.eql(u8, text, "j")) return .job_jump; + if (std.mem.eql(u8, text, "y")) return .job_yank; + self.message = "unknown tool key"; + return .none; + }, + .key => |key| switch (key) { + .escape, .backspace => { + self.mode = .idle; + self.message = "tools cancelled"; + return .none; + }, + else => { + self.mode = .idle; + self.message = "unknown tool key"; + return .none; + }, + }, + .unknown => { + self.mode = .idle; + self.message = "unknown tool key"; + return .none; + }, + } + } + fn handleSearchRail(self: *Leader, event: input.Event) Action { self.message = null; switch (event) { diff --git a/src/tui.zig b/src/tui.zig index c580bc0..c69c908 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -134,6 +134,8 @@ pub const Client = struct { repo: repo_mod.Index, io: ?std.Io, viewport: Viewport, + current_path: ?[]u8 = null, + job_cwd: ?[]u8 = null, saved_bytes: ?[]u8 = null, message: ?[]const u8 = null, owned_message: ?[]u8 = null, @@ -178,6 +180,8 @@ pub const Client = struct { } pub fn deinit(self: *Client) void { + if (self.current_path) |path| self.allocator.free(path); + if (self.job_cwd) |cwd| self.allocator.free(cwd); if (self.saved_bytes) |bytes| self.allocator.free(bytes); if (self.owned_message) |bytes| self.allocator.free(bytes); if (self.yank_bytes) |bytes| self.allocator.free(bytes); @@ -212,6 +216,10 @@ pub const Client = struct { if (std.mem.startsWith(u8, line, "lsp_signature_fixture ")) return self.addSignatureFixture(line[22..]); if (std.mem.startsWith(u8, line, "diagnostic_fixture ")) return self.addDiagnosticFixture(line[19..]); if (std.mem.startsWith(u8, line, "diagnostic_filter ")) return self.setDiagnosticFilter(line[18..]); + if (std.mem.startsWith(u8, line, "current_file ")) return self.setCurrentPath(line[13..]); + if (std.mem.startsWith(u8, line, "job_profile ")) return self.openJobProfileByName(line[12..]); + if (std.mem.startsWith(u8, line, "job_missing_tool ")) return self.openMissingToolJob(line[17..]); + if (std.mem.startsWith(u8, line, "job_timeout ")) return self.openTimeoutJob(line[12..]); if (std.mem.eql(u8, line, "file_picker")) return self.openRepoList("files", false, false); if (std.mem.eql(u8, line, "file_picker all")) return self.openRepoList("files", false, true); if (std.mem.eql(u8, line, "file_tree")) return self.openRepoList("tree", true, false); @@ -227,6 +235,7 @@ pub const Client = struct { if (std.mem.eql(u8, line, "job_status")) return self.openStaticJobRow("job-status", "job_status:idle"); if (std.mem.eql(u8, line, "job_cancel")) return self.openStaticJobRow("job-status", "job_cancel:no_running_job"); if (std.mem.startsWith(u8, line, "job_open_selected ")) return self.openSelectedJobLocation(line[18..]); + if (std.mem.eql(u8, line, "job_yank_selected")) return self.yankSelectedJobLine(); if (std.mem.startsWith(u8, line, "terminal_run ")) return self.openTerminalRun(line[13..]); if (std.mem.eql(u8, line, "terminal_status")) return self.openStaticJobRow("terminal-status", "terminal_status:idle"); if (std.mem.eql(u8, line, "terminal_cancel")) return self.openStaticJobRow("terminal-status", "terminal_cancel:no_running_terminal"); @@ -570,6 +579,7 @@ pub const Client = struct { const selected = self.session.selectListPanel() catch return Error.ProtocolRejected; const content = self.repo.content(selected) catch return Error.ProtocolRejected; try self.session.openFixture(content); + try self.setCurrentPath(selected); try self.session.closePanel(); self.message = null; } @@ -590,10 +600,16 @@ pub const Client = struct { const match = self.repo.searchOffsetFromRow(selected) catch return Error.ProtocolRejected; const content = self.repo.content(match.path) catch return Error.ProtocolRejected; try self.session.openFixtureAt(content, match.offset); + try self.setCurrentPath(match.path); try self.session.closePanel(); self.message = null; } + fn setCurrentPath(self: *Client, path: []const u8) !void { + if (self.current_path) |old| self.allocator.free(old); + self.current_path = try self.allocator.dupe(u8, path); + } + fn openGitStatus(self: *Client, cwd: []const u8) !void { const io = self.io orelse return Error.ProtocolRejected; const rows = repo_mod.gitStatusRowsAlloc(self.allocator, io, cwd) catch return Error.ProtocolRejected; @@ -635,8 +651,8 @@ pub const Client = struct { } const rows = job_mod.runRowsAlloc(self.allocator, io, split.cwd, argv.items) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); - try self.session.openListPanel("job-output", rows); - self.message = null; + try self.setJobCwd(split.cwd); + try self.openJobPanel("job-output", rows); } fn openTerminalRun(self: *Client, cwd_and_command: []const u8) !void { @@ -644,10 +660,72 @@ pub const Client = struct { const split = splitCwdAndCommand(cwd_and_command) orelse return Error.ProtocolRejected; const rows = job_mod.shellRowsAlloc(self.allocator, io, split.cwd, split.command) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); - try self.session.openListPanel("terminal-output", rows); + try self.setJobCwd(split.cwd); + try self.openJobPanel("terminal-output", rows); + } + + fn openJobProfile(self: *Client, profile: job_mod.Profile) !void { + const io = self.io orelse return Error.ProtocolRejected; + const rows = job_mod.profileRowsAlloc(self.allocator, io, ".", profile, self.current_path) catch return Error.ProtocolRejected; + defer freeOwnedRows(self.allocator, rows); + try self.setJobCwd("."); + try self.openJobPanel("job-output", rows); + } + + fn openJobProfileByName(self: *Client, name: []const u8) !void { + const profile = parseJobProfile(name) orelse return Error.ProtocolRejected; + try self.openJobProfile(profile); + } + + fn openMissingToolJob(self: *Client, payload: []const u8) !void { + var parts = std.mem.splitScalar(u8, payload, ' '); + const profile_name = parts.next() orelse return Error.ProtocolRejected; + const tool = parts.next() orelse return Error.ProtocolRejected; + const profile = parseJobProfile(profile_name) orelse return Error.ProtocolRejected; + const rows = try job_mod.missingToolRowsAlloc(self.allocator, profile, tool); + defer freeOwnedRows(self.allocator, rows); + try self.setJobCwd("."); + try self.openJobPanel("job-output", rows); + } + + fn openTimeoutJob(self: *Client, name: []const u8) !void { + const profile = parseJobProfile(name) orelse return Error.ProtocolRejected; + const rows = try job_mod.timeoutRowsAlloc(self.allocator, profile); + defer freeOwnedRows(self.allocator, rows); + try self.setJobCwd("."); + try self.openJobPanel("job-output", rows); + } + + fn openCancelledJob(self: *Client) !void { + const rows = try job_mod.cancelRowsAlloc(self.allocator, .build); + defer freeOwnedRows(self.allocator, rows); + try self.setJobCwd("."); + try self.openJobPanel("job-output", rows); + } + + fn setJobCwd(self: *Client, cwd: []const u8) !void { + if (self.job_cwd) |old| self.allocator.free(old); + self.job_cwd = try self.allocator.dupe(u8, cwd); + } + + fn openJobPanel(self: *Client, title: []const u8, rows: []const []const u8) !void { + const snap = try self.session.snapshot(); + if (snap.active_panel_title) |active| { + if (std.mem.eql(u8, active, "job-output") or std.mem.eql(u8, active, "terminal-output")) self.session.closePanel() catch {}; + } + try self.session.openListPanel(title, rows); self.message = null; } + fn parseJobProfile(name: []const u8) ?job_mod.Profile { + if (std.mem.eql(u8, name, "lint_file")) return .lint_file; + if (std.mem.eql(u8, name, "lint_project")) return .lint_project; + if (std.mem.eql(u8, name, "build")) return .build; + if (std.mem.eql(u8, name, "test")) return .tests; + if (std.mem.eql(u8, name, "check")) return .check; + return null; + } + fn closeTerminalPanel(self: *Client) !void { try self.session.closePanel(); self.message = null; @@ -799,17 +877,37 @@ pub const Client = struct { } fn openSelectedJobLocation(self: *Client, cwd: []const u8) !void { - const io = self.io orelse return Error.ProtocolRejected; const selected = self.session.selectListPanel() catch return Error.ProtocolRejected; const location = job_mod.locationFromRow(selected) catch return Error.ProtocolRejected; + if (self.current_path) |path| { + if (std.mem.eql(u8, path, location.path)) { + const snap = try self.session.snapshot(); + const content = try self.allocator.dupe(u8, snap.bytes); + defer self.allocator.free(content); + const offset = job_mod.byteOffsetForLineColumn(content, location.line, location.column) catch return Error.ProtocolRejected; + try self.session.openFixtureAt(content, offset); + try self.session.closePanel(); + self.message = null; + return; + } + } + const io = self.io orelse return Error.ProtocolRejected; const content = repo_mod.readRepoFileAlloc(self.allocator, io, cwd, location.path) catch return Error.ProtocolRejected; defer self.allocator.free(content); const offset = job_mod.byteOffsetForLineColumn(content, location.line, location.column) catch return Error.ProtocolRejected; try self.session.openFixtureAt(content, offset); + try self.setCurrentPath(location.path); try self.session.closePanel(); self.message = null; } + fn yankSelectedJobLine(self: *Client) !void { + const selected = self.session.activeListItem() catch return Error.ProtocolRejected; + if (self.yank_bytes) |old| self.allocator.free(old); + self.yank_bytes = try self.allocator.dupe(u8, selected); + self.message = "yanked job line"; + } + fn handleTraceKey(self: *Client, key_name: []const u8) !void { if (std.mem.eql(u8, key_name, "space")) return self.handleInput(" "); if (std.mem.eql(u8, key_name, "enter")) return self.handleInput("\r"); @@ -1123,6 +1221,7 @@ pub const Client = struct { return; }; try self.session.openFixtureAt(bytes, 0); + try self.setCurrentPath(path); try self.closeActivePanel(); self.message = path; } @@ -1137,6 +1236,7 @@ pub const Client = struct { return; }; try self.session.openFixtureAt(bytes, target.offset); + try self.setCurrentPath(target.path); try self.closeActivePanel(); self.message = row; } @@ -1851,6 +1951,14 @@ pub const Client = struct { .diagnostics_next => try self.gotoDiagnostic(.next), .diagnostics_previous => try self.gotoDiagnostic(.previous), .diagnostics_filter => try self.filterDiagnosticsFromPanel(), + .job_lint_file => try self.openJobProfile(.lint_file), + .job_lint_project => try self.openJobProfile(.lint_project), + .job_build => try self.openJobProfile(.build), + .job_test => try self.openJobProfile(.tests), + .job_check => try self.openJobProfile(.check), + .job_cancel => try self.openCancelledJob(), + .job_jump => try self.openSelectedJobLocation(self.job_cwd orelse "."), + .job_yank => try self.yankSelectedJobLine(), .repeat_rail => self.openPrefix(.repeat), .restore_last => self.restoreLastRail(), .not_built => {}, @@ -4002,3 +4110,116 @@ test "regular: diagnostic row model sanitizes and parses" { defer std.testing.allocator.free(panel); try std.testing.expect(std.mem.indexOf(u8, panel, "diag:stale:zls:error:1-4") != null); } + +test "regular: job profiles produce source-patched command rows" { + const argv = try job_mod.argvForProfileAlloc(std.testing.allocator, .lint_file, "src/main.zig"); + defer job_mod.freeArgv(std.testing.allocator, argv); + try std.testing.expectEqualStrings("zig", argv[0]); + try std.testing.expectEqualStrings("fmt", argv[1]); + try std.testing.expectEqualStrings("--check", argv[2]); + try std.testing.expectEqualStrings("src/main.zig", argv[3]); + + const build_argv = try job_mod.argvForProfileAlloc(std.testing.allocator, .build, null); + defer job_mod.freeArgv(std.testing.allocator, build_argv); + try std.testing.expectEqualStrings("zig", build_argv[0]); + try std.testing.expectEqualStrings("build", build_argv[1]); + const test_argv = try job_mod.argvForProfileAlloc(std.testing.allocator, .tests, null); + defer job_mod.freeArgv(std.testing.allocator, test_argv); + try std.testing.expectEqualStrings("test", test_argv[2]); + const check_argv = try job_mod.argvForProfileAlloc(std.testing.allocator, .check, null); + defer job_mod.freeArgv(std.testing.allocator, check_argv); + try std.testing.expectEqualStrings("v1-smoke", check_argv[2]); + + const missing = try job_mod.profileRowsAlloc(std.testing.allocator, std.testing.io, ".", .lint_file, null); + defer freeOwnedRows(std.testing.allocator, missing); + try std.testing.expectEqualStrings("job:profile:lint_file:scope:file:provider:zig-fmt", missing[0]); + try std.testing.expectEqualStrings("job:status:missing_current_file", missing[1]); + + const cancel = try job_mod.cancelRowsAlloc(std.testing.allocator, .build); + defer freeOwnedRows(std.testing.allocator, cancel); + try std.testing.expectEqualStrings("job:status:cancelled:user", cancel[1]); + + const timeout = try job_mod.timeoutRowsAlloc(std.testing.allocator, .tests); + defer freeOwnedRows(std.testing.allocator, timeout); + try std.testing.expectEqualStrings("job:status:timeout:recoverable", timeout[1]); + + const missing_tool = try job_mod.missingToolRowsAlloc(std.testing.allocator, .check, "missing-zig"); + defer freeOwnedRows(std.testing.allocator, missing_tool); + try std.testing.expectEqualStrings("job:status:missing_tool:missing-zig:no_install_attempted", missing_tool[1]); +} + +test "regular: Space t rail runs distinct lint scopes and replaces job panel" { + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 88, .height = 7 }, std.testing.io); + defer client.deinit(); + + try client.handleInput(" "); + try client.handleInput("t"); + try client.handleInput("l"); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "job:profile:lint_file:scope:file:provider:zig-fmt") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:missing_current_file") != null); + } + + try client.handleTraceLine("current_file src/main.zig"); + try client.handleInput(" "); + try client.handleInput("t"); + try client.handleInput("L"); + const snap = try client.session.snapshot(); + try std.testing.expectEqual(@as(usize, 1), snap.panel_depth); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "job:spawned:profile:lint_project:scope:project:provider:zig-fmt") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "lint_file") == null); +} + +test "regular: Space t cancel yanks and timeout missing-tool rows are visible" { + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 88, .height = 6 }, std.testing.io); + defer client.deinit(); + + try client.handleInput(" "); + try client.handleInput("t"); + try client.handleInput("x"); + try client.handleInput(" "); + try client.handleInput("t"); + try client.handleInput("y"); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "yanked job line") != null); + } + + try client.handleTraceLine("job_timeout test"); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:timeout:recoverable") != null); + } + + try client.handleTraceLine("job_missing_tool build missing-zig"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:missing_tool:missing-zig:no_install_attempted") != null); +} + +test "regular: Space t jump opens selected diagnostic row and does not mix output" { + var fixture = try makeTuiJobFixture(std.testing.allocator); + defer { + std.testing.allocator.free(fixture.cwd); + fixture.tmp.cleanup(); + } + var client = try Client.initWithIo(std.testing.allocator, .{ .width = 88, .height = 7 }, std.testing.io); + defer client.deinit(); + + const run = try std.fmt.allocPrint(std.testing.allocator, "job_run {s} sh fail.sh", .{fixture.cwd}); + defer std.testing.allocator.free(run); + try client.handleTraceLine(run); + try client.handleTraceLine("list_filter src/main.zig"); + try client.handleInput(" "); + try client.handleInput("t"); + try client.handleInput("j"); + const snap = try client.session.snapshot(); + try std.testing.expectEqualStrings("one\nabcdTARGET\n", snap.bytes); + try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte); +}