const std = @import("std"); const context_mod = @import("context.zig"); const diagnostics_mod = @import("diagnostics.zig"); const input = @import("input.zig"); const job_mod = @import("job.zig"); const leader_mod = @import("leader.zig"); const lsp_mod = @import("lsp.zig"); const pi_bridge = @import("pi_bridge.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const repo_mod = @import("repo.zig"); const session_mod = @import("session.zig"); const symbol_mod = @import("symbol.zig"); const syntax_mod = @import("syntax.zig"); // First terminal thin client surface, scriptable for E2E-style tests. // req: session/001, session/003, ui/001, coding/001, testing/001, testing/002 test { _ = Client; } pub const EditorMode = enum { normal, insert, select, panel, prompt, }; const PrefixRail = enum { none, insert_space, match, go, repeat, delete, change, yank, replace, }; const PanelContext = enum { none, file_picker, project_search, language_provider_picker, code_actions, }; const EditSnapshot = struct { bytes: []u8, cursor_byte: usize, }; const ObjectRange = struct { start: usize, end: usize, }; const PairRange = struct { open: usize, close: usize, }; pub const Error = error{ ViewportTooSmall, InvalidResize, UnknownTraceEvent, ProtocolRejected, ClientQuit, NothingSaved, }; pub const Viewport = struct { width: usize, height: usize, pub fn validate(self: Viewport) !void { if (self.width == 0 or self.height < 2) return Error.ViewportTooSmall; } }; pub const RenderDiagnostics = struct { viewport_width: usize, viewport_height: usize, frame_bytes: usize, frame_lines: usize, max_line_cells: usize, clipped_sources: usize, }; pub const RenderResult = struct { frame: []u8, diagnostics: RenderDiagnostics, warning: []u8, pub fn deinit(self: RenderResult, allocator: std.mem.Allocator) void { allocator.free(self.frame); allocator.free(self.warning); } }; pub fn runTrace(allocator: std.mem.Allocator, viewport: Viewport, trace: []const u8) !TraceResult { var client = try Client.init(allocator, viewport); defer client.deinit(); var lines = std.mem.splitScalar(u8, trace, '\n'); while (lines.next()) |line| { if (line.len == 0) continue; try client.handleTraceLine(line); } const frame = try client.render(allocator); errdefer allocator.free(frame); const saved_bytes = if (client.saved_bytes) |bytes| try allocator.dupe(u8, bytes) else null; errdefer if (saved_bytes) |bytes| allocator.free(bytes); return .{ .frame = frame, .saved_bytes = saved_bytes, .quit = client.quit }; } pub const TraceResult = struct { frame: []u8, saved_bytes: ?[]u8, quit: bool, pub fn deinit(self: TraceResult, allocator: std.mem.Allocator) void { allocator.free(self.frame); if (self.saved_bytes) |bytes| allocator.free(bytes); } }; pub const Client = struct { allocator: std.mem.Allocator, session: session_mod.Session, leader: leader_mod.Leader, 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, quit: bool = false, mode: EditorMode = .normal, prefix: PrefixRail = .none, pending_count: usize = 0, last_rail: PrefixRail = .none, search_prompt_active: bool = false, search_prompt: std.ArrayList(u8) = .empty, search_query: []u8 = &.{}, search_matches: std.ArrayList(usize) = .empty, search_index: usize = 0, project_search_prompt_active: bool = false, project_search_prompt: std.ArrayList(u8) = .empty, panel_context: PanelContext = .none, hover_rows: std.ArrayList([]u8) = .empty, signature_rows: std.ArrayList([]u8) = .empty, diagnostic_rows: std.ArrayList([]u8) = .empty, language_edit_rows: std.ArrayList([]u8) = .empty, format_provider_default: ?[]u8 = null, format_on_save: bool = true, skip_next_save_format: bool = false, pending_language_capability: ?lsp_mod.ProviderCapability = null, diagnostic_filter: ?[]u8 = null, diagnostic_index: usize = 0, diagnostic_selected: bool = false, document_version: u64 = 1, undo_stack: std.ArrayList(EditSnapshot) = .empty, redo_stack: std.ArrayList(EditSnapshot) = .empty, yank_bytes: ?[]u8 = null, pub fn init(allocator: std.mem.Allocator, viewport: Viewport) !Client { return initWithIo(allocator, viewport, null); } pub fn initWithIo(allocator: std.mem.Allocator, viewport: Viewport, io: ?std.Io) !Client { try viewport.validate(); return .{ .allocator = allocator, .session = session_mod.Session.init(allocator), .leader = leader_mod.Leader.init(allocator), .repo = repo_mod.Index.init(allocator), .io = io, .viewport = viewport, }; } 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); self.search_prompt.deinit(self.allocator); self.project_search_prompt.deinit(self.allocator); self.allocator.free(self.search_query); self.search_matches.deinit(self.allocator); self.freeOwnedClientRows(&self.hover_rows); self.freeOwnedClientRows(&self.signature_rows); self.freeOwnedClientRows(&self.diagnostic_rows); self.freeOwnedClientRows(&self.language_edit_rows); if (self.format_provider_default) |provider| self.allocator.free(provider); if (self.diagnostic_filter) |filter| self.allocator.free(filter); self.freeSnapshotStack(&self.undo_stack); self.freeSnapshotStack(&self.redo_stack); self.repo.deinit(); self.leader.deinit(); self.session.deinit(); self.* = undefined; } pub fn handleTraceLine(self: *Client, line: []const u8) !void { if (std.mem.eql(u8, line, "quit")) { self.quit = true; return; } if (self.quit) return Error.ClientQuit; if (std.mem.startsWith(u8, line, "key ")) return self.handleTraceKey(line[4..]); if (std.mem.startsWith(u8, line, "type ")) return self.handleInput(line[5..]); if (std.mem.startsWith(u8, line, "repo_gitignore ")) return self.repo.addIgnorePattern(line[15..]); if (std.mem.startsWith(u8, line, "repo_file ")) return self.addRepoFile(line[10..]); if (std.mem.startsWith(u8, line, "lsp_hover_fixture ")) return self.addHoverFixture(line[18..]); 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, "language_edit ")) return self.addLanguageEditFixture(line[14..]); if (std.mem.startsWith(u8, line, "language_default_format ")) return self.setFormatProviderDefault(line[24..]); if (std.mem.startsWith(u8, line, "format_on_save ")) return self.setFormatOnSavePolicy(line[15..]); if (std.mem.eql(u8, line, "save_without_format")) return self.saveWithoutFormat(); 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); if (std.mem.eql(u8, line, "file_tree all")) return self.openRepoList("tree", true, true); if (std.mem.eql(u8, line, "file_open_selected")) return self.openSelectedRepoFile(); if (std.mem.startsWith(u8, line, "search_text all ")) return self.openSearchList(line[16..], true); if (std.mem.startsWith(u8, line, "search_text ")) return self.openSearchList(line[12..], false); if (std.mem.eql(u8, line, "search_open_selected")) return self.openSelectedSearchResult(); if (std.mem.startsWith(u8, line, "git_status ")) return self.openGitStatus(line[11..]); if (std.mem.startsWith(u8, line, "git_diff_selected ")) return self.openSelectedGitDiff(line[18..]); if (std.mem.startsWith(u8, line, "git_open_changed_selected ")) return self.openSelectedGitFile(line[26..]); if (std.mem.startsWith(u8, line, "job_run ")) return self.openJobRun(line[8..]); 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"); if (std.mem.eql(u8, line, "terminal_exit")) return self.closeTerminalPanel(); if (std.mem.startsWith(u8, line, "syntax_spans ")) return self.openSyntaxSpans(line[13..]); if (std.mem.startsWith(u8, line, "lsp_sync ")) return self.openLspSync(line[9..]); if (std.mem.startsWith(u8, line, "lsp_diagnostics ")) return self.openLspDiagnostics(line[16..]); if (std.mem.eql(u8, line, "lsp_diagnostics_open_selected")) return self.openSelectedLspDiagnostic(); if (std.mem.startsWith(u8, line, "lsp_definition ")) return self.openLspLocations("definition", line[15..]); if (std.mem.startsWith(u8, line, "lsp_references ")) return self.openLspLocations("references", line[15..]); if (std.mem.startsWith(u8, line, "lsp_document_symbols ")) return self.openLspSymbols("document", line[21..]); if (std.mem.startsWith(u8, line, "lsp_workspace_symbols ")) return self.openLspSymbols("workspace", line[22..]); if (std.mem.eql(u8, line, "lsp_navigation_open_selected")) return self.openSelectedLspNavigation(); if (std.mem.startsWith(u8, line, "lsp_rename ")) return self.openLspWorkspaceEdit("rename", line[11..]); if (std.mem.startsWith(u8, line, "lsp_code_actions ")) return self.openLspCodeActions(line[17..]); if (std.mem.eql(u8, line, "lsp_edit_apply_selected")) return self.applySelectedLspEdit(); if (std.mem.startsWith(u8, line, "lsp_hover ")) return self.openLspHover(line[10..]); if (std.mem.startsWith(u8, line, "lsp_signature ")) return self.openLspSignature(line[14..]); if (std.mem.eql(u8, line, "lsp_param_next")) return self.moveLspParameter(.next); if (std.mem.eql(u8, line, "lsp_param_previous")) return self.moveLspParameter(.previous); if (std.mem.startsWith(u8, line, "pi_run ")) return self.openPiRun(line[7..]); if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line); if (std.mem.startsWith(u8, line, "message ")) { self.message = line[8..]; return; } if (std.mem.eql(u8, line, "list_down")) return self.applyProtocol("command list_down"); if (std.mem.eql(u8, line, "list_up")) return self.applyProtocol("command list_up"); if (std.mem.eql(u8, line, "list_select")) return self.applyProtocol("command list_select"); if (std.mem.eql(u8, line, "list_cancel")) return self.applyProtocol("command list_cancel"); if (std.mem.eql(u8, line, "panel_close")) return self.applyProtocol("command panel_close"); if (std.mem.eql(u8, line, "panel_next")) return self.applyProtocol("command panel_next"); if (std.mem.eql(u8, line, "panel_prev")) return self.applyProtocol("command panel_prev"); if (std.mem.startsWith(u8, line, "open ")) return self.applyProtocol(line); if (std.mem.startsWith(u8, line, "insert ")) return self.applyProtocolCommand(line); if (std.mem.eql(u8, line, "left")) return self.applyProtocol("command move_left"); if (std.mem.eql(u8, line, "right")) return self.applyProtocol("command move_right"); if (std.mem.eql(u8, line, "backspace")) return self.applyMutatingProtocol("command delete_backward"); if (std.mem.eql(u8, line, "save")) return self.save(); if (std.mem.startsWith(u8, line, "resize ")) return self.resize(line[7..]); if (std.mem.eql(u8, line, "render")) return; return Error.UnknownTraceEvent; } pub fn render(self: *const Client, allocator: std.mem.Allocator) ![]u8 { try self.viewport.validate(); const snap = try self.session.snapshot(); if (snap.active_panel_title != null) return self.renderPanel(allocator, snap); return self.renderEditor(allocator, snap); } pub fn renderWithDiagnostics(self: *const Client, allocator: std.mem.Allocator) !RenderResult { const frame = try self.render(allocator); errdefer allocator.free(frame); var diagnostics = diagnoseFrame(self.viewport, frame); diagnostics.clipped_sources = try self.countClippedSources(allocator); const warning = try renderWarningAlloc(allocator, diagnostics); return .{ .frame = frame, .diagnostics = diagnostics, .warning = warning }; } fn renderEditor(self: *const Client, allocator: std.mem.Allocator, snap: session_mod.Snapshot) ![]u8 { var out = std.ArrayList(u8).empty; errdefer out.deinit(allocator); const max_body_lines = self.viewport.height - 1; const cursor_line = lineIndexAt(snap.bytes, snap.cursor_byte); const cursor_col = columnAt(snap.bytes, snap.cursor_byte); const total_lines = countDocumentLines(snap.bytes); const gutter_digits = @max(@as(usize, 2), decimalDigits(total_lines)); const gutter_width = @min(self.viewport.width, gutter_digits + 2); const content_width = if (self.viewport.width > gutter_width) self.viewport.width - gutter_width else 0; var visible_line_index: usize = 0; var body_lines_used: usize = 0; var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n'); while (line_iter.next()) |line| : (visible_line_index += 1) { if (body_lines_used >= max_body_lines) break; try appendEditorLine( allocator, &out, line, visible_line_index + 1, gutter_digits, content_width, visible_line_index == cursor_line, cursor_col, ); body_lines_used += 1; } while (body_lines_used < max_body_lines) : (body_lines_used += 1) { try appendVirtualLine(allocator, &out, gutter_digits, content_width); } const leader_status = self.leader.status(); const prefix_status = self.prefixStatus(); const search_status = self.searchStatus(); const status = if (self.message) |message| try std.fmt.allocPrint(allocator, "{s}", .{message}) else if (search_status.len != 0) try std.fmt.allocPrint(allocator, "mode:{s} {s}", .{ @tagName(self.effectiveMode()), search_status }) else if (leader_status.len != 0) try std.fmt.allocPrint( allocator, "mode:{s} {s}{s}{s}", .{ @tagName(self.effectiveMode()), leader_status, if (self.leader.promptText().len > 0) ": " else "", self.leader.promptText() }, ) else if (prefix_status.len != 0) try std.fmt.allocPrint(allocator, "mode:{s} {s}", .{ @tagName(self.effectiveMode()), prefix_status }) else try std.fmt.allocPrint( allocator, "mode:{s} row={d} col={d} bytes={d}{s}", .{ @tagName(self.effectiveMode()), cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" }, ); defer allocator.free(status); try appendStatusLine(allocator, &out, status, self.viewport.width); return out.toOwnedSlice(allocator); } fn renderPanel(self: *const Client, allocator: std.mem.Allocator, snap: session_mod.Snapshot) ![]u8 { var out = std.ArrayList(u8).empty; errdefer out.deinit(allocator); const max_body_lines = self.viewport.height - 1; var body_lines_used: usize = 0; const path = try self.session.panelPathAlloc(allocator); defer allocator.free(path); const header = try std.fmt.allocPrint(allocator, "panel {s}", .{path}); defer allocator.free(header); try appendVisibleCells(allocator, &out, header, self.viewport.width); try out.append(allocator, '\n'); body_lines_used += 1; const remaining_rows = max_body_lines - body_lines_used; const rows = self.session.activeListRowsAlloc(allocator, remaining_rows) catch null; if (rows) |list_rows| { defer { for (list_rows) |row| allocator.free(row); allocator.free(list_rows); } for (list_rows) |row| { if (body_lines_used >= max_body_lines) break; try appendVisibleCells(allocator, &out, row, self.viewport.width); try out.append(allocator, '\n'); body_lines_used += 1; } } else if (body_lines_used < max_body_lines) { const title = snap.active_panel_title.?; const detail = try std.fmt.allocPrint(allocator, "{s}: no content yet", .{title}); defer allocator.free(detail); try appendVisibleCells(allocator, &out, detail, self.viewport.width); try out.append(allocator, '\n'); body_lines_used += 1; } while (body_lines_used < max_body_lines) : (body_lines_used += 1) { try out.append(allocator, '~'); try out.append(allocator, '\n'); } const status = if (self.message) |message| try std.fmt.allocPrint(allocator, "{s}", .{message}) else try std.fmt.allocPrint( allocator, "panel {d}/{d} x close", .{ snap.active_panel_index.? + 1, snap.panel_depth }, ); defer allocator.free(status); try appendVisibleCells(allocator, &out, status, self.viewport.width); return out.toOwnedSlice(allocator); } pub fn handleInput(self: *Client, raw: []const u8) !void { if (self.quit) return Error.ClientQuit; const event = input.normalize(raw); self.message = null; if (self.search_prompt_active) return self.applySearchPromptInput(event); if (self.project_search_prompt_active) return self.applyProjectSearchPromptInput(event); const leader_trigger = isLeaderTrigger(event) and self.effectiveMode() != .insert; if (self.leader.capturesInput() or leader_trigger) { if (self.prefix == .insert_space and isLeaderTrigger(event)) { try self.insertText(" "); self.prefix = .none; return; } const action = try self.leader.handleEvent(event); defer action.deinit(self.allocator); try self.applyLeaderAction(action); return; } try self.applyModeInput(event); } pub fn modeName(self: *const Client) []const u8 { return @tagName(self.effectiveMode()); } pub fn enterInsertMode(self: *Client) void { self.mode = .insert; self.message = "insert"; } pub fn pendingRailName(self: *const Client) []const u8 { return @tagName(self.prefix); } pub fn pendingCount(self: *const Client) usize { return self.pending_count; } pub fn saved(self: *const Client) ![]const u8 { return self.saved_bytes orelse Error.NothingSaved; } pub fn snapshotBytesAlloc(self: *const Client, allocator: std.mem.Allocator) ![]u8 { const snap = try self.session.snapshot(); return allocator.dupe(u8, snap.bytes); } pub fn requestedQuit(self: *const Client) bool { return self.quit; } pub fn clearQuit(self: *Client) void { self.quit = false; } pub fn setStatusMessage(self: *Client, message: []const u8) void { if (self.owned_message) |old| self.allocator.free(old); self.owned_message = null; self.message = message; } fn setOwnedStatusMessage(self: *Client, message: []u8) void { if (self.owned_message) |old| self.allocator.free(old); self.owned_message = message; self.message = message; } fn effectiveMode(self: *const Client) EditorMode { if (self.search_prompt_active or self.project_search_prompt_active or self.leader.isPromptActive()) return .prompt; const snap = self.session.snapshot() catch return self.mode; if (snap.active_panel_title != null) return .panel; return self.mode; } fn searchStatus(self: *const Client) []const u8 { if (self.project_search_prompt_active) return "project search: type query, Enter accept, Esc cancel"; if (self.search_prompt_active) return "search: type query, Enter accept, Esc cancel"; if (self.search_query.len != 0) return "search active: n next N previous"; return ""; } fn prefixStatus(self: *const Client) []const u8 { return switch (self.prefix) { .none => if (self.pending_count != 0) "count pending" else "", .insert_space => "insert-space: n normal Space literal text commits space+text", .match => "match: m jump s inside a around ( { [ quotes", .go => "go: d definition r references e diagnostic a parameter", .repeat => "repeat: digits count . repeat-last", .delete => "delete: d line h previous-char l char", .change => "change: c line h previous-char l char", .yank => "yank: y line", .replace => "replace: next key replaces char", }; } fn freeOwnedClientRows(self: *Client, rows: *std.ArrayList([]u8)) void { for (rows.items) |row| self.allocator.free(row); rows.deinit(self.allocator); rows.* = .empty; } pub fn addRepoFileContent(self: *Client, path: []const u8, file_bytes: []const u8) !void { if (file_bytes.len > diagnostics_mod.max_file_bytes) { self.message = "diagnostic:file_too_large"; return Error.ProtocolRejected; } self.repo.addFile(path, file_bytes) catch |err| { self.message = "diagnostic:unsupported_file"; return err; }; self.message = null; } fn addHoverFixture(self: *Client, payload: []const u8) !void { var parts = std.mem.splitScalar(u8, payload, '|'); const provider = parts.next() orelse "fixture"; const headline = parts.next() orelse "hover"; const docs = parts.rest(); const row = try std.fmt.allocPrint(self.allocator, "provider:{s}:lsp:hover:{s}|{s}", .{ provider, headline, docs }); try self.hover_rows.append(self.allocator, row); } fn addSignatureFixture(self: *Client, payload: []const u8) !void { var parts = std.mem.splitScalar(u8, payload, '|'); const provider = parts.next() orelse "fixture"; const signature = parts.rest(); const row = try std.fmt.allocPrint(self.allocator, "provider:{s}:lsp:signature:{s}", .{ provider, signature }); try self.signature_rows.append(self.allocator, row); } fn addDiagnosticFixture(self: *Client, payload: []const u8) !void { var parts = std.mem.splitScalar(u8, payload, '|'); const provider = parts.next() orelse return Error.ProtocolRejected; const version_text = parts.next() orelse return Error.ProtocolRejected; const file = parts.next() orelse return Error.ProtocolRejected; const start_text = parts.next() orelse return Error.ProtocolRejected; const end_text = parts.next() orelse return Error.ProtocolRejected; const severity_text = parts.next() orelse return Error.ProtocolRejected; const message = parts.rest(); const version = std.fmt.parseUnsigned(u64, version_text, 10) catch return Error.ProtocolRejected; const start = std.fmt.parseUnsigned(usize, start_text, 10) catch return Error.ProtocolRejected; const end = std.fmt.parseUnsigned(usize, end_text, 10) catch return Error.ProtocolRejected; const severity = diagnostics_mod.Severity.parse(severity_text) catch return Error.ProtocolRejected; const row = diagnostics_mod.rowAlloc(self.allocator, provider, version, file, start, end, severity, message) catch return Error.ProtocolRejected; try self.diagnostic_rows.append(self.allocator, row); } fn setDiagnosticFilter(self: *Client, provider: []const u8) !void { if (self.diagnostic_filter) |old| self.allocator.free(old); self.diagnostic_filter = if (std.mem.eql(u8, provider, "all")) null else try self.allocator.dupe(u8, diagnostics_mod.sanitizeToken(provider)); self.diagnostic_index = 0; self.diagnostic_selected = false; try self.openDiagnosticsPanel(); } fn addRepoFile(self: *Client, payload: []const u8) !void { const separator = std.mem.indexOfScalar(u8, payload, '=') orelse { self.message = "diagnostic:unsupported_file:invalid_repo_file_payload"; return repo_mod.Error.InvalidPath; }; try self.addRepoFileContent(payload[0..separator], payload[separator + 1 ..]); } fn openRepoList(self: *Client, title: []const u8, tree: bool, include_ignored: bool) !void { const items = if (tree) try self.repo.treeItemsAlloc(self.allocator, include_ignored) else try self.repo.pickerItemsAlloc(self.allocator, include_ignored); defer self.allocator.free(items); try self.session.openListPanel(title, items); self.message = null; } fn openSelectedRepoFile(self: *Client) !void { 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; } fn openSearchList(self: *Client, query: []const u8, include_ignored: bool) !void { const rows = self.repo.searchRowsAlloc(self.allocator, query, include_ignored) catch return Error.ProtocolRejected; defer { for (rows) |row| self.allocator.free(row); self.allocator.free(rows); } const truncated = rowsContain(rows, "diagnostic:search_truncated"); try self.session.openListPanel("search", rows); self.message = if (truncated) "diagnostic:search_truncated" else null; } fn openSelectedSearchResult(self: *Client) !void { const selected = self.session.selectListPanel() catch return Error.ProtocolRejected; 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; defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("git-status", rows); self.message = null; } fn openSelectedGitDiff(self: *Client, cwd: []const u8) !void { const io = self.io orelse return Error.ProtocolRejected; const selected = self.session.selectListPanel() catch return Error.ProtocolRejected; const path = repo_mod.gitPathFromStatusRow(selected) catch return Error.ProtocolRejected; const rows = repo_mod.gitDiffRowsAlloc(self.allocator, io, cwd, path) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("git-diff", rows); self.message = null; } fn openSelectedGitFile(self: *Client, cwd: []const u8) !void { const io = self.io orelse return Error.ProtocolRejected; const selected = self.session.selectListPanel() catch return Error.ProtocolRejected; const path = repo_mod.gitPathFromStatusRow(selected) catch return Error.ProtocolRejected; const content = repo_mod.readRepoFileAlloc(self.allocator, io, cwd, path) catch return Error.ProtocolRejected; defer self.allocator.free(content); try self.session.openFixture(content); try self.session.closePanel(); self.message = null; } fn openJobRun(self: *Client, cwd_and_command: []const u8) !void { const io = self.io orelse return Error.ProtocolRejected; const split = splitCwdAndCommand(cwd_and_command) orelse return Error.ProtocolRejected; var argv = std.ArrayList([]const u8).empty; defer argv.deinit(self.allocator); var parts = std.mem.splitScalar(u8, split.command, ' '); while (parts.next()) |part| { if (part.len == 0) continue; try argv.append(self.allocator, part); } const rows = job_mod.runRowsAlloc(self.allocator, io, split.cwd, argv.items) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); try self.setJobCwd(split.cwd); try self.openJobPanel("job-output", rows); } fn openTerminalRun(self: *Client, cwd_and_command: []const u8) !void { const io = self.io orelse return Error.ProtocolRejected; 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.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; } fn openSyntaxSpans(self: *Client, language: []const u8) !void { const snap = try self.session.snapshot(); const rows = syntax_mod.rowsAlloc(self.allocator, language, snap.bytes) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("syntax-spans", rows); self.message = null; } fn openLspSync(self: *Client, args: []const u8) !void { const io = self.io orelse return Error.ProtocolRejected; const parsed = parseLspSyncArgs(args) orelse return Error.ProtocolRejected; const snap = try self.session.snapshot(); var argv = std.ArrayList([]const u8).empty; defer argv.deinit(self.allocator); var parts = std.mem.splitScalar(u8, parsed.command, ' '); while (parts.next()) |part| { if (part.len == 0) continue; try argv.append(self.allocator, part); } const rows = lsp_mod.runDocumentSyncRowsAlloc(self.allocator, io, parsed.cwd, argv.items, .{ .uri = parsed.uri, .language_id = parsed.language_id, .text = snap.bytes, }) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("lsp-sync", rows); self.message = null; } fn openLspDiagnostics(self: *Client, payload: []const u8) !void { const rows = lsp_mod.diagnosticRowsAlloc(self.allocator, payload) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("lsp-diagnostics", rows); self.message = null; } fn openSelectedLspDiagnostic(self: *Client) !void { const selected = self.session.selectListPanel() catch return Error.ProtocolRejected; const location = lsp_mod.diagnosticLocationFromRow(selected) catch return Error.ProtocolRejected; try self.jumpCurrentBufferToLspLocation(location); } fn openLspLocations(self: *Client, kind: []const u8, payload: []const u8) !void { const rows = lsp_mod.locationRowsAlloc(self.allocator, kind, payload) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); const title = if (std.mem.eql(u8, kind, "definition")) "lsp-definition" else "lsp-references"; try self.session.openListPanel(title, rows); self.message = null; } fn openLspSymbols(self: *Client, scope: []const u8, payload: []const u8) !void { const rows = lsp_mod.symbolRowsAlloc(self.allocator, scope, payload) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); const title = if (std.mem.eql(u8, scope, "document")) "lsp-document-symbols" else "lsp-workspace-symbols"; try self.session.openListPanel(title, rows); self.message = null; } fn openSelectedLspNavigation(self: *Client) !void { const selected = self.session.selectListPanel() catch return Error.ProtocolRejected; const location = lsp_mod.navigationLocationFromRow(selected) catch return Error.ProtocolRejected; try self.jumpCurrentBufferToLspLocation(location); } fn openLspWorkspaceEdit(self: *Client, kind: []const u8, payload: []const u8) !void { const rows = lsp_mod.workspaceEditRowsAlloc(self.allocator, kind, payload) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("lsp-edit-preview", rows); self.message = null; } fn openLspCodeActions(self: *Client, payload: []const u8) !void { const rows = lsp_mod.codeActionRowsAlloc(self.allocator, payload) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("lsp-code-actions", rows); self.message = null; } fn applySelectedLspEdit(self: *Client) !void { const selected = self.session.selectListPanel() catch return Error.ProtocolRejected; const snap = try self.session.snapshot(); const applied = lsp_mod.applyEditRowAlloc(self.allocator, snap.bytes, selected) catch return Error.ProtocolRejected; defer self.allocator.free(applied.bytes); try self.session.openFixtureAt(applied.bytes, applied.cursor); try self.session.closePanel(); self.message = null; } fn openLspHover(self: *Client, payload: []const u8) !void { const rows = lsp_mod.hoverRowsAlloc(self.allocator, payload, self.viewport.width) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("lsp-hover", rows); self.message = null; } fn openLspSignature(self: *Client, payload: []const u8) !void { const rows = lsp_mod.signatureRowsAlloc(self.allocator, payload, self.viewport.width) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("lsp-signature", rows); self.message = null; } fn moveLspParameter(self: *Client, direction: lsp_mod.ParameterDirection) !void { const snap = try self.session.snapshot(); const moved = lsp_mod.parameterMoveRowsAlloc(self.allocator, snap.bytes, snap.cursor_byte, direction) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, moved.rows); const bytes = try self.allocator.dupe(u8, snap.bytes); defer self.allocator.free(bytes); try self.session.openFixtureAt(bytes, moved.cursor); try self.session.openListPanel("lsp-parameter", moved.rows); self.message = null; } fn openPiRun(self: *Client, command_line: []const u8) !void { const io = self.io orelse return Error.ProtocolRejected; var argv = std.ArrayList([]const u8).empty; defer argv.deinit(self.allocator); var parts = std.mem.splitScalar(u8, std.mem.trim(u8, command_line, " "), ' '); while (parts.next()) |part| { if (part.len == 0) continue; try argv.append(self.allocator, part); } const context_text = try context_mod.sessionContextAlloc(self.allocator, &self.session, "pi_run", "bridge", .{}); defer self.allocator.free(context_text); const rows = pi_bridge.rowsAlloc(self.allocator, io, argv.items, context_text) catch return Error.ProtocolRejected; defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("pi-output", rows); self.message = null; } fn jumpCurrentBufferToLspLocation(self: *Client, location: lsp_mod.DiagnosticLocation) !void { const snap = try self.session.snapshot(); const bytes = try self.allocator.dupe(u8, snap.bytes); defer self.allocator.free(bytes); const offset = lsp_mod.byteOffsetForLineColumn(bytes, location.line, location.character) catch return Error.ProtocolRejected; try self.session.openFixtureAt(bytes, offset); try self.session.closePanel(); self.message = null; } fn openStaticJobRow(self: *Client, title: []const u8, row: []const u8) !void { try self.session.openListPanel(title, &.{row}); self.message = null; } fn openSelectedJobLocation(self: *Client, cwd: []const u8) !void { 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"); if (std.mem.eql(u8, key_name, "backspace")) return self.handleInput("\x7f"); if (std.mem.eql(u8, key_name, "escape")) return self.handleInput("\x1b"); if (std.mem.eql(u8, key_name, "left")) return self.handleInput("\x1b[D"); if (std.mem.eql(u8, key_name, "right")) return self.handleInput("\x1b[C"); if (std.mem.eql(u8, key_name, "up")) return self.handleInput("\x1b[A"); if (std.mem.eql(u8, key_name, "down")) return self.handleInput("\x1b[B"); if (std.mem.eql(u8, key_name, "home")) return self.handleInput("\x1b[H"); if (std.mem.eql(u8, key_name, "end")) return self.handleInput("\x1b[F"); if (std.mem.eql(u8, key_name, "page_up")) return self.handleInput("\x1b[5~"); if (std.mem.eql(u8, key_name, "page_down")) return self.handleInput("\x1b[6~"); if (key_name.len == 1) return self.handleInput(key_name); return Error.UnknownTraceEvent; } fn isLeaderTrigger(event: input.Event) bool { return switch (event) { .key => |key| key == .space, .text => |text| std.mem.eql(u8, text, " "), else => false, }; } fn applyModeInput(self: *Client, event: input.Event) !void { if (self.prefix != .none) return self.applyPrefixInput(event); if (self.applyCountPrefix(event)) return; switch (self.effectiveMode()) { .normal => try self.applyNormalModeInput(event), .insert => try self.applyInsertModeInput(event), .select => try self.applySelectModeInput(event), .panel => try self.applyPanelModeInput(event), .prompt => {}, } } fn applyCountPrefix(self: *Client, event: input.Event) bool { switch (event) { .text => |text| if (text.len == 1 and text[0] >= '0' and text[0] <= '9') { const digit = text[0] - '0'; if (self.pending_count != 0 or digit != 0) { self.pending_count = self.pending_count * 10 + digit; self.last_rail = .repeat; return true; } }, else => {}, } return false; } fn takeRepeat(self: *Client) usize { const repeat = if (self.pending_count == 0) 1 else self.pending_count; self.pending_count = 0; return repeat; } fn applyNormalModeInput(self: *Client, event: input.Event) !void { switch (event) { .text => |text| { if (std.mem.eql(u8, text, "i")) { self.mode = .insert; self.message = "insert"; self.pending_count = 0; return; } if (std.mem.eql(u8, text, "a")) { try self.applyProtocol("command move_right"); self.mode = .insert; self.message = "insert"; self.pending_count = 0; return; } if (std.mem.eql(u8, text, "o")) { try self.applyMutatingProtocol("command open_line_below"); self.mode = .insert; self.message = "insert"; self.pending_count = 0; return; } if (std.mem.eql(u8, text, "O")) { try self.applyMutatingProtocol("command open_line_above"); self.mode = .insert; self.message = "insert"; self.pending_count = 0; return; } if (std.mem.eql(u8, text, "s")) { self.mode = .select; self.message = "select"; self.pending_count = 0; return; } if (std.mem.eql(u8, text, "d")) return self.openPrefix(.delete); if (std.mem.eql(u8, text, "c")) return self.openPrefix(.change); if (std.mem.eql(u8, text, "y")) return self.openPrefix(.yank); if (std.mem.eql(u8, text, "r")) return self.openPrefix(.replace); if (std.mem.eql(u8, text, "/")) return self.openSearchPrompt(); if (std.mem.eql(u8, text, "n")) return self.nextSearchMatch(); if (std.mem.eql(u8, text, "N")) return self.previousSearchMatch(); if (std.mem.eql(u8, text, "p")) return self.pasteRegister(); if (std.mem.eql(u8, text, "u")) return self.undoEdit(); if (std.mem.eql(u8, text, "U")) return self.redoEdit(); if (std.mem.eql(u8, text, "0")) return self.applyProtocol("command move_line_start"); if (std.mem.eql(u8, text, "$")) return self.applyProtocol("command move_line_end"); if (std.mem.eql(u8, text, "%")) return self.jumpToMatch(null); if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match); if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go); if (std.mem.eql(u8, text, "j")) return self.repeatProtocol("command move_down", self.takeRepeat()); if (std.mem.eql(u8, text, "k")) return self.repeatProtocol("command move_up", self.takeRepeat()); if (std.mem.eql(u8, text, "h")) return self.repeatProtocol("command move_left", self.takeRepeat()); if (std.mem.eql(u8, text, "l")) return self.repeatProtocol("command move_right", self.takeRepeat()); if (std.mem.eql(u8, text, "w")) return self.repeatProtocol("command move_word_forward", self.takeRepeat()); if (std.mem.eql(u8, text, "b")) return self.repeatProtocol("command move_word_back", self.takeRepeat()); if (std.mem.eql(u8, text, "e")) return self.repeatProtocol("command move_word_end", self.takeRepeat()); self.unknownPrefixOrInput("normal"); }, .key => |key| switch (key) { .escape => { self.pending_count = 0; self.message = "normal"; }, .backspace => try self.applyMutatingProtocol("command delete_backward"), .arrow_left => try self.repeatProtocol("command move_left", self.takeRepeat()), .arrow_right => try self.repeatProtocol("command move_right", self.takeRepeat()), .arrow_up => try self.repeatProtocol("command move_up", self.takeRepeat()), .arrow_down => try self.repeatProtocol("command move_down", self.takeRepeat()), .home => try self.applyProtocol("command move_line_start"), .end => try self.applyProtocol("command move_line_end"), .page_up => try self.repeatProtocol("command move_up", @max(@as(usize, 1), self.viewport.height - 2)), .page_down => try self.repeatProtocol("command move_down", @max(@as(usize, 1), self.viewport.height - 2)), else => self.unknownPrefixOrInput("normal"), }, .unknown => self.unknownPrefixOrInput("normal"), } } fn applyInsertModeInput(self: *Client, event: input.Event) !void { switch (event) { .text => |text| if (std.mem.eql(u8, text, " ")) self.openPrefix(.insert_space) else try self.insertText(text), .key => |key| switch (key) { .space => self.openPrefix(.insert_space), .escape => { self.mode = .normal; self.message = "normal"; }, .backspace => try self.applyMutatingProtocol("command delete_backward"), .arrow_left => try self.applyProtocol("command move_left"), .arrow_right => try self.applyProtocol("command move_right"), .arrow_up => try self.applyProtocol("command move_up"), .arrow_down => try self.applyProtocol("command move_down"), .home => try self.applyProtocol("command move_line_start"), .end => try self.applyProtocol("command move_line_end"), else => {}, }, .unknown => {}, } } fn applySelectModeInput(self: *Client, event: input.Event) !void { switch (event) { .text => |text| { if (std.mem.eql(u8, text, "n")) { self.mode = .normal; self.message = "normal"; return; } if (std.mem.eql(u8, text, "%")) return self.jumpToMatch(null); if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match); if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go); if (text.len == 1) { if (self.objectRangeForKey(text[0])) |range| { try self.session.selectRange(range.start, range.end); self.message = "selected object"; return; } else |_| {} } self.unknownPrefixOrInput("select"); }, .key => |key| switch (key) { .escape => { self.mode = .normal; self.message = "normal"; }, .arrow_left => try self.applyProtocol("command move_left"), .arrow_right => try self.applyProtocol("command move_right"), .arrow_up => try self.applyProtocol("command move_up"), .arrow_down => try self.applyProtocol("command move_down"), .home => try self.applyProtocol("command move_line_start"), .end => try self.applyProtocol("command move_line_end"), else => {}, }, .unknown => self.unknownPrefixOrInput("select"), } } fn applyPanelModeInput(self: *Client, event: input.Event) !void { switch (event) { .text => |text| { if (std.mem.eql(u8, text, "j")) return self.applyProtocol("command list_down"); if (std.mem.eql(u8, text, "k")) return self.applyProtocol("command list_up"); if (std.mem.eql(u8, text, "o")) return self.openActivePanelItem(); if (std.mem.eql(u8, text, "q")) return self.closeActivePanel(); self.unknownPrefixOrInput("panel"); }, .key => |key| switch (key) { .arrow_down => try self.applyProtocol("command list_down"), .arrow_up => try self.applyProtocol("command list_up"), .page_down => try self.repeatProtocol("command list_down", @max(@as(usize, 1), self.viewport.height - 2)), .page_up => try self.repeatProtocol("command list_up", @max(@as(usize, 1), self.viewport.height - 2)), .enter => try self.openActivePanelItem(), .escape => try self.closeActivePanel(), else => {}, }, .unknown => self.unknownPrefixOrInput("panel"), } } fn applyPrefixInput(self: *Client, event: input.Event) !void { const active = self.prefix; self.prefix = .none; switch (active) { .none => {}, .insert_space => try self.applyInsertSpaceRail(event), .match => self.applyMatchRail(event) catch |err| { self.message = @errorName(err); }, .go => try self.applyGoRail(event), .repeat => self.applyKnownRailOrMessage(event, "repeat rail ready"), .delete => try self.applyDeleteRail(event), .change => try self.applyChangeRail(event), .yank => try self.applyYankRail(event), .replace => try self.applyReplaceRail(event), } } fn applyInsertSpaceRail(self: *Client, event: input.Event) !void { switch (event) { .text => |text| { if (std.mem.eql(u8, text, "n")) { self.mode = .normal; self.message = "normal"; return; } try self.insertText(" "); try self.insertText(text); }, .key => |key| switch (key) { .space => try self.insertText(" "), .escape => { self.mode = .normal; self.message = "normal"; }, else => try self.insertText(" "), }, .unknown => try self.insertText(" "), } } fn applyKnownRailOrMessage(self: *Client, event: input.Event, success: []const u8) void { switch (event) { .text => |text| if (text.len == 1 and std.mem.indexOfScalar(u8, "msadretpi(){}[]'\"`.", text[0]) != null) { self.message = success; self.pending_count = 0; return; }, .key => |key| if (key == .escape or key == .backspace) { self.message = "rail cancelled"; self.pending_count = 0; return; }, else => {}, } self.message = "unknown prefix key"; self.last_rail = .none; self.pending_count = 0; } fn openPrefix(self: *Client, prefix: PrefixRail) void { self.prefix = prefix; self.last_rail = prefix; self.message = null; } fn restoreLastRail(self: *Client) void { if (self.last_rail == .none) { self.message = "no previous rail"; return; } self.prefix = self.last_rail; self.message = null; } fn openFilePickerPanel(self: *Client) !void { try self.openRepoList("files", false, false); self.panel_context = .file_picker; } fn openListRows(self: *Client, title: []const u8, rows: []const []const u8, context: PanelContext) !void { if (rows.len == 0) { self.message = "empty list"; return; } try self.session.openListPanel(title, rows); self.message = null; self.panel_context = context; } fn openActivePanelItem(self: *Client) !void { const item = self.session.activeListItem() catch |err| { self.message = @errorName(err); return; }; const owned = try self.allocator.dupe(u8, item); defer self.allocator.free(owned); switch (self.panel_context) { .file_picker => try self.openRepoPathFromPanel(owned), .project_search => try self.openProjectSearchResult(owned), .language_provider_picker => try self.applyLanguageProviderChoice(owned), .code_actions => try self.applyCodeActionRow(owned), .none => self.message = "panel item has no action", } } fn closeActivePanel(self: *Client) !void { try self.applyProtocol("command list_cancel"); self.panel_context = .none; } fn openRepoPathFromPanel(self: *Client, row: []const u8) !void { const path = if (std.mem.startsWith(u8, row, "file ")) row[5..] else row; const bytes = self.repo.content(path) catch |err| { self.message = @errorName(err); return; }; try self.session.openFixtureAt(bytes, 0); try self.setCurrentPath(path); try self.closeActivePanel(); self.message = path; } fn openProjectSearchResult(self: *Client, row: []const u8) !void { const target = self.repo.searchOffsetFromRow(row) catch |err| { self.message = @errorName(err); return; }; const bytes = self.repo.content(target.path) catch |err| { self.message = @errorName(err); return; }; try self.session.openFixtureAt(bytes, target.offset); try self.setCurrentPath(target.path); try self.closeActivePanel(); self.message = row; } fn openProjectSearchPrompt(self: *Client) void { self.project_search_prompt_active = true; self.project_search_prompt.clearRetainingCapacity(); self.message = null; self.prefix = .none; } fn applyProjectSearchPromptInput(self: *Client, event: input.Event) !void { switch (event) { .text => |text| try self.project_search_prompt.appendSlice(self.allocator, text), .key => |key| switch (key) { .enter => try self.commitProjectSearchPrompt(), .escape => { self.project_search_prompt_active = false; self.message = "project search cancelled"; }, .backspace => { if (self.project_search_prompt.items.len > 0) _ = self.project_search_prompt.pop(); }, .space => try self.project_search_prompt.append(self.allocator, ' '), else => {}, }, .unknown => {}, } } fn commitProjectSearchPrompt(self: *Client) !void { self.project_search_prompt_active = false; const rows = try self.repo.searchRowsAlloc(self.allocator, self.project_search_prompt.items, false); defer freeOwnedRows(self.allocator, rows); if (rows.len == 0) { self.message = "no project matches"; return; } try self.openSearchList(self.project_search_prompt.items, false); self.panel_context = .project_search; } fn showCompactHover(self: *Client) !void { if (self.hover_rows.items.len == 0) { self.message = "NoProvider:hover"; return; } const card = try lsp_mod.compactHoverCardAlloc(self.allocator, self.hover_rows.items[0], self.viewport.width); self.setOwnedStatusMessage(card); } fn showCompactSignature(self: *Client) !void { if (self.signature_rows.items.len == 0) { self.message = "NoProvider:signature"; return; } const card = try lsp_mod.compactSignatureCardAlloc(self.allocator, self.signature_rows.items[0]); self.setOwnedStatusMessage(card); } fn openExpandedHover(self: *Client) !void { if (self.hover_rows.items.len == 0) { self.message = "NoProvider:hover"; return; } const rows = try lsp_mod.expandedHoverRowsAlloc(self.allocator, self.hover_rows.items[0], self.viewport.width); defer { for (rows) |row| self.allocator.free(row); self.allocator.free(rows); } try self.openListRows("hover", rows, .none); } fn addLanguageEditFixture(self: *Client, payload: []const u8) !void { var parts = std.mem.splitScalar(u8, payload, '|'); const provider = parts.next() orelse return Error.ProtocolRejected; const capability_name = parts.next() orelse return Error.ProtocolRejected; const version_text = parts.next() orelse return Error.ProtocolRejected; const start_text = parts.next() orelse return Error.ProtocolRejected; const end_text = parts.next() orelse return Error.ProtocolRejected; const replacement = parts.rest(); const capability = lsp_mod.providerCapabilityFromName(capability_name) catch return Error.ProtocolRejected; const version = std.fmt.parseUnsigned(u64, version_text, 10) catch return Error.ProtocolRejected; const start = std.fmt.parseUnsigned(usize, start_text, 10) catch return Error.ProtocolRejected; const end = std.fmt.parseUnsigned(usize, end_text, 10) catch return Error.ProtocolRejected; const row = lsp_mod.editRowAlloc(self.allocator, provider, capability, version, start, end, replacement) catch return Error.ProtocolRejected; try self.language_edit_rows.append(self.allocator, row); } fn setFormatProviderDefault(self: *Client, provider: []const u8) !void { if (self.format_provider_default) |old| self.allocator.free(old); self.format_provider_default = if (std.mem.eql(u8, provider, "none")) null else try self.allocator.dupe(u8, provider); const message = try std.fmt.allocPrint(self.allocator, "format default:{s}", .{self.format_provider_default orelse "none"}); self.setOwnedStatusMessage(message); } fn setFormatOnSavePolicy(self: *Client, policy: []const u8) !void { if (std.mem.eql(u8, policy, "on")) self.format_on_save = true else if (std.mem.eql(u8, policy, "off")) self.format_on_save = false else return Error.ProtocolRejected; try self.showFormatPolicy(); } fn showFormatPolicy(self: *Client) !void { const provider = self.format_provider_default orelse "select"; const message = try std.fmt.allocPrint(self.allocator, "format-on-save:{s} provider:{s} one-shot:save_without_format", .{ if (self.format_on_save) "on" else "off", provider }); self.setOwnedStatusMessage(message); } fn saveWithoutFormat(self: *Client) !void { self.skip_next_save_format = true; try self.save(); } fn formatCurrentBuffer(self: *Client) !void { try self.applyLanguageMutation(.format, null); } fn organizeImports(self: *Client) !void { if (!self.hasLanguageProvider(.organize_imports) and self.hover_rows.items.len != 0) return self.openExpandedHover(); try self.applyLanguageMutation(.organize_imports, null); } fn hasLanguageProvider(self: *Client, capability: lsp_mod.ProviderCapability) bool { for (self.language_edit_rows.items) |row| { const edit = lsp_mod.parseProviderEditRow(row) catch continue; if (edit.capability == capability) return true; } return false; } fn openCodeActions(self: *Client) !void { const rows = try self.actionRowsForCapability(.code_action); defer freeOwnedRows(self.allocator, rows); if (rows.len == 0) { self.message = "NoProvider:code_action"; return; } try self.openListRows("code-actions", rows, .code_actions); } fn actionRowsForCapability(self: *Client, capability: lsp_mod.ProviderCapability) ![][]const u8 { var rows = std.ArrayList([]const u8).empty; errdefer freeOwnedRows(self.allocator, rows.items); for (self.language_edit_rows.items) |row| { const edit = lsp_mod.parseProviderEditRow(row) catch continue; if (edit.capability != capability) continue; try rows.append(self.allocator, try lsp_mod.actionPanelRowAlloc(self.allocator, row)); } return rows.toOwnedSlice(self.allocator); } fn applyCodeActionRow(self: *Client, row: []const u8) !void { const provider = lsp_mod.providerFromActionPanelRow(row) catch return Error.ProtocolRejected; const capability = lsp_mod.capabilityFromActionPanelRow(row) catch return Error.ProtocolRejected; try self.applyLanguageMutation(capability, provider); } fn applyLanguageProviderChoice(self: *Client, row: []const u8) !void { const provider = lsp_mod.providerFromActionPanelRow(row) catch return Error.ProtocolRejected; const capability = self.pending_language_capability orelse return Error.ProtocolRejected; self.pending_language_capability = null; try self.applyLanguageMutation(capability, provider); } fn applyLanguageMutation(self: *Client, capability: lsp_mod.ProviderCapability, forced_provider: ?[]const u8) !void { const provider = try self.chooseLanguageProvider(capability, forced_provider); if (provider == null) return; try self.applyProviderEdits(capability, provider.?); } fn chooseLanguageProvider(self: *Client, capability: lsp_mod.ProviderCapability, forced_provider: ?[]const u8) !?[]const u8 { if (forced_provider) |provider| return provider; var providers = std.ArrayList([]const u8).empty; defer providers.deinit(self.allocator); for (self.language_edit_rows.items) |row| { const edit = lsp_mod.parseProviderEditRow(row) catch continue; if (edit.capability != capability) continue; var seen = false; for (providers.items) |provider| { if (std.mem.eql(u8, provider, edit.provider)) seen = true; } if (!seen) try providers.append(self.allocator, edit.provider); } if (providers.items.len == 0) { const message = try std.fmt.allocPrint(self.allocator, "NoProvider:{s}", .{@tagName(capability)}); self.setOwnedStatusMessage(message); return null; } if (capability == .format) { if (self.format_provider_default) |preferred| { for (providers.items) |provider| if (std.mem.eql(u8, provider, preferred)) return provider; } } if (providers.items.len == 1) return providers.items[0]; var rows = std.ArrayList([]const u8).empty; errdefer freeOwnedRows(self.allocator, rows.items); for (providers.items) |provider| try rows.append(self.allocator, try std.fmt.allocPrint(self.allocator, "action:{s}:provider:{s}:scope:buffer:choose", .{ @tagName(capability), provider })); const owned = try rows.toOwnedSlice(self.allocator); defer freeOwnedRows(self.allocator, owned); self.pending_language_capability = capability; try self.openListRows("language-providers", owned, .language_provider_picker); return null; } const EditOrder = struct { start: usize, row: []const u8 }; fn applyProviderEdits(self: *Client, capability: lsp_mod.ProviderCapability, provider: []const u8) !void { const snap = try self.session.snapshot(); var matches = std.ArrayList([]const u8).empty; defer matches.deinit(self.allocator); for (self.language_edit_rows.items) |row| { const edit = lsp_mod.parseProviderEditRow(row) catch { self.message = "InvalidEdit"; return; }; if (edit.capability == capability and std.mem.eql(u8, edit.provider, provider)) try matches.append(self.allocator, row); } if (matches.items.len == 0) { const message = try std.fmt.allocPrint(self.allocator, "NoProvider:{s}", .{@tagName(capability)}); self.setOwnedStatusMessage(message); return; } var ordered = std.ArrayList(EditOrder).empty; defer ordered.deinit(self.allocator); for (matches.items) |row| { const edit = lsp_mod.parseProviderEditRow(row) catch { self.message = "InvalidEdit"; return; }; if (edit.version != self.document_version) { self.message = "StaleEdit"; return; } if (edit.end > snap.bytes.len) { self.message = "InvalidEdit"; return; } for (ordered.items) |existing| { const prev = lsp_mod.parseProviderEditRow(existing.row) catch unreachable; if (!(edit.end <= prev.start or edit.start >= prev.end)) { self.message = "OverlappingEdit"; return; } } try ordered.append(self.allocator, .{ .start = edit.start, .row = row }); } std.mem.sort(EditOrder, ordered.items, {}, struct { fn lessThan(_: void, lhs: EditOrder, rhs: EditOrder) bool { return lhs.start > rhs.start; } }.lessThan); try self.recordUndo(); self.clearRedo(); for (ordered.items) |item| { const edit = lsp_mod.parseProviderEditRow(item.row) catch unreachable; self.session.replaceRange(edit.start, edit.end, edit.replacement) catch |err| { self.dropLastUndoSnapshot(); self.message = @errorName(err); return; }; } self.noteDocumentChanged(); const message = try std.fmt.allocPrint(self.allocator, "{s}:{s}:applied", .{ @tagName(capability), provider }); self.setOwnedStatusMessage(message); } const DiagnosticDirection = enum { next, previous }; fn diagnosticMatchesFilter(self: *const Client, row: []const u8) bool { const filter = self.diagnostic_filter orelse return true; const diagnostic = diagnostics_mod.parseRow(row) catch return false; return std.mem.eql(u8, diagnostic.provider, filter); } fn diagnosticPanelRowsAlloc(self: *const Client) ![][]const u8 { var rows = std.ArrayList([]const u8).empty; errdefer freeOwnedRows(self.allocator, rows.items); for (self.diagnostic_rows.items) |row| { if (!self.diagnosticMatchesFilter(row)) continue; const panel_row = diagnostics_mod.panelRowAlloc(self.allocator, row, self.document_version) catch try self.allocator.dupe(u8, "diag:invalid"); try rows.append(self.allocator, panel_row); } if (rows.items.len == 0) try rows.append(self.allocator, try self.allocator.dupe(u8, "diag:empty")); return rows.toOwnedSlice(self.allocator); } fn openDiagnosticsPanel(self: *Client) !void { const snap = try self.session.snapshot(); if (snap.active_panel_title) |title| { if (std.mem.eql(u8, title, "diagnostics")) self.session.closePanel() catch {}; } const rows = try self.diagnosticPanelRowsAlloc(); defer freeOwnedRows(self.allocator, rows); try self.session.openListPanel("diagnostics", rows); self.message = null; } fn filteredDiagnosticCount(self: *const Client) usize { var count: usize = 0; for (self.diagnostic_rows.items) |row| { if (self.diagnosticMatchesFilter(row)) count += 1; } return count; } fn filteredDiagnosticRow(self: *const Client, index: usize) ?[]const u8 { var seen: usize = 0; for (self.diagnostic_rows.items) |row| { if (!self.diagnosticMatchesFilter(row)) continue; if (seen == index) return row; seen += 1; } return null; } fn gotoDiagnostic(self: *Client, direction: DiagnosticDirection) !void { const count = self.filteredDiagnosticCount(); if (count == 0) { self.message = "diag:empty"; return; } self.diagnostic_index = if (self.diagnostic_selected) switch (direction) { .next => (self.diagnostic_index + 1) % count, .previous => if (self.diagnostic_index == 0) count - 1 else self.diagnostic_index - 1, } else switch (direction) { .next => 0, .previous => count - 1, }; self.diagnostic_selected = true; const row = self.filteredDiagnosticRow(self.diagnostic_index) orelse return; const range = self.diagnosticRangeFromRow(row) catch |err| { self.message = @errorName(err); return; }; try self.session.moveToByte(range.start); try self.session.selectRange(range.start, range.end); self.session.clearSelection(); try self.session.moveToByte(range.start); self.session.closePanel() catch {}; self.message = "diag:jumped"; } fn filterDiagnosticsFromPanel(self: *Client) !void { const selected = self.session.activeListItem() catch null; if (selected) |row| { if (diagnosticProviderFromPanelRow(row)) |provider| { try self.setDiagnosticFilter(provider); return; } } if (self.diagnostic_filter) |old| { self.allocator.free(old); self.diagnostic_filter = null; self.message = "diag:filter_all"; } else self.message = "diag:filter_select_source"; try self.openDiagnosticsPanel(); } fn diagnosticProviderFromPanelRow(row: []const u8) ?[]const u8 { if (!std.mem.startsWith(u8, row, "diag:")) return null; var parts = std.mem.splitScalar(u8, row, ':'); _ = parts.next() orelse return null; const freshness_or_empty = parts.next() orelse return null; if (std.mem.eql(u8, freshness_or_empty, "empty") or std.mem.eql(u8, freshness_or_empty, "invalid")) return null; return parts.next(); } fn diagnosticRangeFromRow(self: *Client, row: []const u8) !ObjectRange { const diagnostic = try diagnostics_mod.parseRow(row); const snap = try self.session.snapshot(); if (diagnostic.stale(self.document_version)) return error.StaleDiagnostic; if (diagnostic.end > snap.bytes.len) return error.InvalidDiagnosticRange; return .{ .start = diagnostic.start, .end = diagnostic.end }; } fn currentDiagnosticRange(self: *Client) !ObjectRange { const count = self.filteredDiagnosticCount(); if (count == 0) return error.NoDiagnostics; if (self.diagnostic_index >= count) self.diagnostic_index = 0; const row = self.filteredDiagnosticRow(self.diagnostic_index) orelse return error.NoDiagnostics; return self.diagnosticRangeFromRow(row); } fn selectCurrentDiagnosticRange(self: *Client) !void { const range = self.currentDiagnosticRange() catch |err| { self.message = @errorName(err); return; }; try self.session.selectRange(range.start, range.end); self.message = "selected diagnostic"; } fn noteDocumentChanged(self: *Client) void { self.document_version += 1; self.diagnostic_index = 0; self.diagnostic_selected = false; } fn openSearchPrompt(self: *Client) void { self.search_prompt_active = true; self.search_prompt.clearRetainingCapacity(); self.message = null; self.prefix = .none; } fn applySearchPromptInput(self: *Client, event: input.Event) !void { switch (event) { .text => |text| try self.search_prompt.appendSlice(self.allocator, text), .key => |key| switch (key) { .enter => try self.commitSearchPrompt(), .escape => { self.search_prompt_active = false; self.message = "search cancelled"; }, .backspace => { if (self.search_prompt.items.len > 0) _ = self.search_prompt.pop(); }, .space => try self.search_prompt.append(self.allocator, ' '), else => {}, }, .unknown => {}, } } fn commitSearchPrompt(self: *Client) !void { self.search_prompt_active = false; self.allocator.free(self.search_query); self.search_query = try self.allocator.dupe(u8, self.search_prompt.items); try self.rebuildSearchMatches(); if (self.search_matches.items.len == 0) { self.message = "no search matches"; return; } self.search_index = 0; try self.gotoSearchMatch(); } fn rebuildSearchMatches(self: *Client) !void { self.search_matches.clearRetainingCapacity(); if (self.search_query.len == 0) return; const snap = try self.session.snapshot(); var offset: usize = 0; while (offset <= snap.bytes.len) { const found = std.mem.indexOf(u8, snap.bytes[offset..], self.search_query) orelse break; const at = offset + found; try self.search_matches.append(self.allocator, at); offset = at + @max(self.search_query.len, 1); } } fn gotoSearchMatch(self: *Client) !void { if (self.search_matches.items.len == 0) return; const at = self.search_matches.items[self.search_index]; try self.session.moveToByte(at); try self.session.selectRange(at, at + self.search_query.len); } fn nextSearchMatch(self: *Client) !void { if (self.search_matches.items.len == 0) { self.message = "no active search"; return; } self.search_index = (self.search_index + 1) % self.search_matches.items.len; try self.gotoSearchMatch(); } fn previousSearchMatch(self: *Client) !void { if (self.search_matches.items.len == 0) { self.message = "no active search"; return; } self.search_index = if (self.search_index == 0) self.search_matches.items.len - 1 else self.search_index - 1; try self.gotoSearchMatch(); } fn applyGoRail(self: *Client, event: input.Event) !void { const text = eventText(event) orelse return self.unknownPrefixOrInput("normal"); if (std.mem.eql(u8, text, "a")) return self.moveParameter(.next); if (std.mem.eql(u8, text, "A")) return self.moveParameter(.previous); if (std.mem.eql(u8, text, "e")) return self.gotoDiagnostic(.next); self.applyKnownRailOrMessage(event, "go rail ready"); } const ParameterDirection = enum { next, previous }; fn moveParameter(self: *Client, direction: ParameterDirection) !void { const snap = try self.session.snapshot(); const pair = self.matchRange('(', false) catch { self.message = "NoParameterContext"; return; }; var target: ?usize = null; switch (direction) { .next => { var at = snap.cursor_byte; while (at < pair.end) : (at += 1) { if (snap.bytes[at] == ',') { target = at + 1; break; } } }, .previous => { var at = @min(snap.cursor_byte, pair.end); while (at > pair.start) { at -= 1; if (snap.bytes[at] == ',') { var prev = at; while (prev > pair.start and snap.bytes[prev - 1] != ',') prev -= 1; target = prev; break; } } }, } if (target) |byte| { var trimmed = byte; while (trimmed < pair.end and std.ascii.isWhitespace(snap.bytes[trimmed])) trimmed += 1; try self.session.moveToByte(trimmed); } else self.message = "NoParameterTarget"; } fn applyMatchRail(self: *Client, event: input.Event) !void { const text = eventText(event) orelse return self.unknownPrefixOrInput("normal"); if (std.mem.eql(u8, text, "m")) return self.jumpToMatch(null); if (std.mem.eql(u8, text, "s")) { const range = try self.matchRange(null, false); try self.session.selectRange(range.start, range.end); self.message = "selected inside pair"; return; } if (std.mem.eql(u8, text, "a")) { const range = try self.matchRange(null, true); try self.session.selectRange(range.start, range.end); self.message = "selected around pair"; return; } if (text.len == 1 and isPairSelector(text[0])) { if (self.effectiveMode() == .select) { const range = try self.matchRange(text[0], true); try self.session.selectRange(range.start, range.end); } else { try self.jumpToMatch(text[0]); } return; } self.unknownPrefixOrInput("normal"); } fn applyDeleteRail(self: *Client, event: input.Event) !void { const text = eventText(event) orelse return self.unknownPrefixOrInput("normal"); if (std.mem.eql(u8, text, "d")) { try self.yankCurrentLine(true); try self.applyMutatingProtocol("command delete_line"); return; } if (std.mem.eql(u8, text, "h")) return self.applyMutatingProtocol("command delete_backward"); if (std.mem.eql(u8, text, "l")) return self.applyMutatingProtocol("command delete_forward"); if (text.len == 1) { if (self.objectRangeForKey(text[0])) |range| return self.deleteRange(range) else |_| {} } self.unknownPrefixOrInput("normal"); } fn applyChangeRail(self: *Client, event: input.Event) !void { const text = eventText(event) orelse return self.unknownPrefixOrInput("normal"); if (std.mem.eql(u8, text, "c")) { try self.yankCurrentLine(false); try self.applyMutatingProtocol("command change_line"); self.mode = .insert; self.message = "insert"; return; } if (std.mem.eql(u8, text, "h")) { try self.applyMutatingProtocol("command delete_backward"); self.mode = .insert; self.message = "insert"; return; } if (std.mem.eql(u8, text, "l")) { try self.applyMutatingProtocol("command delete_forward"); self.mode = .insert; self.message = "insert"; return; } if (text.len == 1) { if (self.objectRangeForKey(text[0])) |range| { try self.changeRange(range); return; } else |_| {} } self.unknownPrefixOrInput("normal"); } fn applyYankRail(self: *Client, event: input.Event) !void { const text = eventText(event) orelse return self.unknownPrefixOrInput("normal"); if (std.mem.eql(u8, text, "y")) { try self.yankCurrentLine(true); self.message = "yanked line"; return; } if (text.len == 1) { if (self.objectRangeForKey(text[0])) |range| { try self.yankRange(range); self.message = "yanked object"; return; } else |_| {} } self.unknownPrefixOrInput("normal"); } fn applyReplaceRail(self: *Client, event: input.Event) !void { const text = eventText(event) orelse return self.unknownPrefixOrInput("normal"); const command = try std.fmt.allocPrint(self.allocator, "command replace_char {s}", .{text}); defer self.allocator.free(command); try self.applyMutatingProtocol(command); } fn eventText(event: input.Event) ?[]const u8 { return switch (event) { .text => |text| text, .key => |key| switch (key) { .space => " ", else => null, }, .unknown => null, }; } fn deleteRange(self: *Client, range: ObjectRange) !void { try self.yankRange(range); try self.recordUndo(); self.clearRedo(); self.session.replaceRange(range.start, range.end, "") catch |err| { self.dropLastUndoSnapshot(); return err; }; self.noteDocumentChanged(); } fn changeRange(self: *Client, range: ObjectRange) !void { try self.yankRange(range); try self.recordUndo(); self.clearRedo(); self.session.replaceRange(range.start, range.end, "") catch |err| { self.dropLastUndoSnapshot(); return err; }; self.noteDocumentChanged(); self.mode = .insert; self.message = "insert"; } fn yankRange(self: *Client, range: ObjectRange) !void { const snap = try self.session.snapshot(); if (range.start > range.end or range.end > snap.bytes.len) return error.SelectionOutOfBounds; const copy = try self.allocator.dupe(u8, snap.bytes[range.start..range.end]); if (self.yank_bytes) |old| self.allocator.free(old); self.yank_bytes = copy; } fn pasteRegister(self: *Client) !void { const bytes = self.yank_bytes orelse { self.message = "nothing yanked"; return; }; try self.recordUndo(); self.clearRedo(); self.session.dispatch(.{ .insert = bytes }) catch |err| { self.dropLastUndoSnapshot(); return err; }; self.noteDocumentChanged(); } fn yankCurrentLine(self: *Client, include_newline: bool) !void { const snap = try self.session.snapshot(); var start = snap.cursor_byte; while (start > 0 and snap.bytes[start - 1] != '\n') start -= 1; var end = snap.cursor_byte; while (end < snap.bytes.len and snap.bytes[end] != '\n') end += 1; if (include_newline and end < snap.bytes.len and snap.bytes[end] == '\n') end += 1; const copy = try self.allocator.dupe(u8, snap.bytes[start..end]); if (self.yank_bytes) |old| self.allocator.free(old); self.yank_bytes = copy; } fn undoEdit(self: *Client) !void { const previous = self.undo_stack.pop() orelse { self.message = "nothing to undo"; return; }; const current = try self.takeCurrentSnapshot(); try self.redo_stack.append(self.allocator, current); try self.restoreSnapshot(previous); self.allocator.free(previous.bytes); self.message = "undo"; } fn redoEdit(self: *Client) !void { const next = self.redo_stack.pop() orelse { self.message = "nothing to redo"; return; }; const current = try self.takeCurrentSnapshot(); try self.undo_stack.append(self.allocator, current); try self.restoreSnapshot(next); self.allocator.free(next.bytes); self.message = "redo"; } fn takeCurrentSnapshot(self: *Client) !EditSnapshot { const snap = try self.session.snapshot(); return .{ .bytes = try self.allocator.dupe(u8, snap.bytes), .cursor_byte = snap.cursor_byte }; } fn objectRangeForKey(self: *Client, key: u8) !ObjectRange { return switch (key) { 'w' => try self.wordRange(), 'l' => try self.lineRange(true), 'i' => try self.indentRange(), 'p' => try self.parameterRange(), 'f' => try self.enclosingFormRange(), 'd' => self.currentDiagnosticRange() catch try self.lineRange(false), else => error.UnsupportedObject, }; } fn wordRange(self: *Client) !ObjectRange { const snap = try self.session.snapshot(); var start = snap.cursor_byte; while (start > 0 and !isObjectSeparator(snap.bytes[previousBoundaryLocal(snap.bytes, start)])) start = previousBoundaryLocal(snap.bytes, start); var end = snap.cursor_byte; while (end < snap.bytes.len and !isObjectSeparator(snap.bytes[end])) end = nextBoundaryLocal(snap.bytes, end); return .{ .start = start, .end = end }; } fn lineRange(self: *Client, include_newline: bool) !ObjectRange { const snap = try self.session.snapshot(); var start = snap.cursor_byte; while (start > 0 and snap.bytes[start - 1] != '\n') start -= 1; var end = snap.cursor_byte; while (end < snap.bytes.len and snap.bytes[end] != '\n') end += 1; if (include_newline and end < snap.bytes.len) end += 1; return .{ .start = start, .end = end }; } fn indentRange(self: *Client) !ObjectRange { const snap = try self.session.snapshot(); const current = try self.lineRange(false); const indent = lineIndent(snap.bytes[current.start..current.end]); var start = current.start; while (start > 0) { const prev_end = start - 1; var prev_start = prev_end; while (prev_start > 0 and snap.bytes[prev_start - 1] != '\n') prev_start -= 1; if (lineContentLen(snap.bytes[prev_start..prev_end]) != 0 and lineIndent(snap.bytes[prev_start..prev_end]) < indent) break; start = prev_start; } var end = current.end; while (end < snap.bytes.len) { const next_start = if (end < snap.bytes.len and snap.bytes[end] == '\n') end + 1 else end; if (next_start >= snap.bytes.len) break; var next_end = next_start; while (next_end < snap.bytes.len and snap.bytes[next_end] != '\n') next_end += 1; if (lineContentLen(snap.bytes[next_start..next_end]) != 0 and lineIndent(snap.bytes[next_start..next_end]) < indent) break; end = next_end; } return .{ .start = start, .end = end }; } fn parameterRange(self: *Client) !ObjectRange { const around = try self.matchRange('(', false); const snap = try self.session.snapshot(); var start = around.start; var end = around.end; var at = around.start; while (at < around.end) : (at += 1) { if (snap.bytes[at] == ',' and at < snap.cursor_byte) start = at + 1; if (snap.bytes[at] == ',' and at >= snap.cursor_byte) { end = at; break; } } return trimRange(snap.bytes, .{ .start = start, .end = end }); } fn enclosingFormRange(self: *Client) !ObjectRange { return self.matchRange('{', true) catch self.matchRange('(', true); } fn jumpToMatch(self: *Client, selector: ?u8) !void { const pair = try self.matchPair(selector); const snap = try self.session.snapshot(); const target = if (snap.cursor_byte <= pair.open) pair.close else pair.open; try self.session.moveToByte(target); self.message = "matched pair"; } fn matchRange(self: *Client, selector: ?u8, around: bool) !ObjectRange { const pair = try self.matchPair(selector); return if (around) .{ .start = pair.open, .end = pair.close + 1 } else .{ .start = pair.open + 1, .end = pair.close }; } fn matchPair(self: *Client, selector: ?u8) !PairRange { const snap = try self.session.snapshot(); if (snap.bytes.len == 0) return error.NoMatch; if (selector) |sel| return findPairForSelector(snap.bytes, snap.cursor_byte, sel) orelse error.NoMatch; if (findPairAtOrNear(snap.bytes, snap.cursor_byte)) |pair| return pair; return error.NoMatch; } fn insertText(self: *Client, text: []const u8) !void { try self.recordUndo(); self.clearRedo(); self.session.dispatch(.{ .insert = text }) catch |err| { self.dropLastUndoSnapshot(); return err; }; self.noteDocumentChanged(); self.message = null; } fn applyMutatingProtocolCommand(self: *Client, line: []const u8) !void { try self.recordUndo(); self.clearRedo(); self.applyProtocolCommand(line) catch |err| { self.dropLastUndoSnapshot(); return err; }; self.noteDocumentChanged(); } fn applyMutatingProtocol(self: *Client, line: []const u8) !void { try self.recordUndo(); self.clearRedo(); self.applyProtocol(line) catch |err| { self.dropLastUndoSnapshot(); return err; }; self.noteDocumentChanged(); } fn repeatProtocol(self: *Client, line: []const u8, repeat: usize) !void { var i: usize = 0; while (i < repeat) : (i += 1) try self.applyProtocol(line); } fn repeatMutatingProtocol(self: *Client, line: []const u8, repeat: usize) !void { var i: usize = 0; while (i < repeat) : (i += 1) try self.applyMutatingProtocol(line); } fn recordUndo(self: *Client) !void { const snap = try self.session.snapshot(); const bytes = try self.allocator.dupe(u8, snap.bytes); errdefer self.allocator.free(bytes); try self.undo_stack.append(self.allocator, .{ .bytes = bytes, .cursor_byte = snap.cursor_byte, }); } fn dropLastUndoSnapshot(self: *Client) void { if (self.undo_stack.pop()) |snap| self.allocator.free(snap.bytes); } fn restoreSnapshot(self: *Client, snap: EditSnapshot) !void { try self.session.openFixtureAt(snap.bytes, snap.cursor_byte); } fn freeSnapshotStack(self: *Client, stack: *std.ArrayList(EditSnapshot)) void { for (stack.items) |snap| self.allocator.free(snap.bytes); stack.deinit(self.allocator); } fn clearRedo(self: *Client) void { self.freeSnapshotStack(&self.redo_stack); self.redo_stack = .empty; } fn unknownPrefixOrInput(self: *Client, mode: []const u8) void { self.pending_count = 0; self.message = if (std.mem.eql(u8, mode, "normal")) "unknown normal key" else if (std.mem.eql(u8, mode, "select")) "unknown select key" else "unknown panel key"; } fn applyLeaderAction(self: *Client, action: leader_mod.Action) !void { switch (action) { .none => {}, .save => try self.save(), .quit => self.quit = true, .open => |path| { const line = try std.fmt.allocPrint(self.allocator, "open {s}", .{path}); defer self.allocator.free(line); try self.applyProtocol(line); }, .symbol => |symbol| try self.applyProtocol(symbol_mod.protocolCommand(symbol)), .file_picker => try self.openFilePickerPanel(), .search_file => self.openSearchPrompt(), .search_project => self.openProjectSearchPrompt(), .hover => try self.showCompactHover(), .signature => try self.showCompactSignature(), .expand_hover => try self.openExpandedHover(), .language_format => try self.formatCurrentBuffer(), .language_format_policy => try self.showFormatPolicy(), .language_organize_imports => try self.organizeImports(), .language_code_actions => try self.openCodeActions(), .diagnostics_open => try self.openDiagnosticsPanel(), .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 => {}, } } fn applyProtocolCommand(self: *Client, line: []const u8) !void { const command = try std.fmt.allocPrint(self.allocator, "command {s}", .{line}); defer self.allocator.free(command); try self.applyProtocol(command); } fn applyProtocol(self: *Client, line: []const u8) !void { const response = try protocol.handleLine(self.allocator, &self.session, line); defer self.allocator.free(response); if (std.mem.startsWith(u8, response, "err ")) { self.message = protocolErrorMessage(response); return Error.ProtocolRejected; } self.message = null; } fn protocolErrorMessage(response: []const u8) []const u8 { if (std.mem.indexOf(u8, response, "invalid panel title") != null) return "error: invalid panel title"; if (std.mem.indexOf(u8, response, "no panel open") != null) return "error: no panel open"; if (std.mem.indexOf(u8, response, "invalid list") != null) return "error: invalid list"; if (std.mem.indexOf(u8, response, "active panel is not list") != null) return "error: active panel is not list"; return "error: command failed"; } fn resize(self: *Client, payload: []const u8) !void { const x = std.mem.indexOfScalar(u8, payload, 'x') orelse return Error.InvalidResize; const width = std.fmt.parseUnsigned(usize, payload[0..x], 10) catch return Error.InvalidResize; const height = std.fmt.parseUnsigned(usize, payload[x + 1 ..], 10) catch return Error.InvalidResize; const viewport = Viewport{ .width = width, .height = height }; try viewport.validate(); self.viewport = viewport; } fn save(self: *Client) !void { if (self.format_on_save and !self.skip_next_save_format and self.hasLanguageProvider(.format)) { const before_format_version = self.document_version; try self.applyLanguageMutation(.format, null); if (self.document_version == before_format_version) return; } self.skip_next_save_format = false; const snap = try self.session.snapshot(); if (snap.bytes.len > diagnostics_mod.max_file_bytes) { self.message = "diagnostic:save_failed:file_too_large"; return Error.ProtocolRejected; } const copy = try self.allocator.dupe(u8, snap.bytes); if (self.saved_bytes) |old| self.allocator.free(old); self.saved_bytes = copy; self.message = "saved"; } fn countClippedSources(self: *const Client, allocator: std.mem.Allocator) !usize { const snap = try self.session.snapshot(); var clipped: usize = 0; if (snap.active_panel_title == null) { var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n'); while (line_iter.next()) |line| { if (session_mod.cellWidth(line) > self.viewport.width) clipped += 1; } if (self.message) |message| { if (session_mod.cellWidth(message) > self.viewport.width) clipped += 1; } return clipped; } const path = try self.session.panelPathAlloc(allocator); defer allocator.free(path); const header = try std.fmt.allocPrint(allocator, "panel {s}", .{path}); defer allocator.free(header); if (session_mod.cellWidth(header) > self.viewport.width) clipped += 1; const remaining_rows = self.viewport.height - 1; const rows = self.session.activeListRowsAlloc(allocator, remaining_rows) catch null; if (rows) |list_rows| { defer { for (list_rows) |row| allocator.free(row); allocator.free(list_rows); } for (list_rows) |row| { if (session_mod.cellWidth(row) > self.viewport.width) clipped += 1; } } else if (snap.active_panel_title) |title| { const detail = try std.fmt.allocPrint(allocator, "{s}: no content yet", .{title}); defer allocator.free(detail); if (session_mod.cellWidth(detail) > self.viewport.width) clipped += 1; } const status = if (self.message) |message| try std.fmt.allocPrint(allocator, "{s}", .{message}) else try std.fmt.allocPrint(allocator, "panel {d}/{d} x close", .{ snap.active_panel_index.? + 1, snap.panel_depth }); defer allocator.free(status); if (session_mod.cellWidth(status) > self.viewport.width) clipped += 1; return clipped; } }; const ansi_reset = "\x1b[0m"; const ansi_gutter = "\x1b[38;2;88;96;112m"; const ansi_virtual = "\x1b[38;2;64;70;86m"; const ansi_text = "\x1b[38;2;214;222;235m"; const ansi_current_line = "\x1b[48;2;26;31;43m"; const ansi_cursor = "\x1b[38;2;18;22;30;48;2;245;197;92m"; const ansi_status = "\x1b[38;2;18;22;30;48;2;126;231;135m"; fn appendEditorLine( allocator: std.mem.Allocator, out: *std.ArrayList(u8), line: []const u8, line_no: usize, gutter_digits: usize, content_width: usize, is_cursor_line: bool, cursor_col: usize, ) !void { try appendLineNumber(allocator, out, line_no, gutter_digits); if (content_width == 0) { try out.append(allocator, '\n'); return; } if (is_cursor_line) try out.appendSlice(allocator, ansi_current_line); try out.appendSlice(allocator, ansi_text); try appendEditorCells(allocator, out, line, content_width, is_cursor_line, cursor_col); try out.appendSlice(allocator, ansi_reset); try out.append(allocator, '\n'); } fn appendLineNumber(allocator: std.mem.Allocator, out: *std.ArrayList(u8), line_no: usize, gutter_digits: usize) !void { const text = try std.fmt.allocPrint(allocator, "{d}", .{line_no}); defer allocator.free(text); try out.appendSlice(allocator, ansi_gutter); var pad: usize = text.len; while (pad < gutter_digits) : (pad += 1) try out.append(allocator, ' '); try out.appendSlice(allocator, text); try out.appendSlice(allocator, "│"); try out.appendSlice(allocator, ansi_reset); try out.append(allocator, ' '); } fn appendVirtualLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), gutter_digits: usize, content_width: usize) !void { _ = content_width; try out.appendSlice(allocator, ansi_gutter); var pad: usize = 0; while (pad < gutter_digits) : (pad += 1) try out.append(allocator, ' '); try out.appendSlice(allocator, "│"); try out.appendSlice(allocator, ansi_virtual); try out.appendSlice(allocator, " ·"); try out.appendSlice(allocator, ansi_reset); try out.append(allocator, '\n'); } fn appendEditorCells( allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize, is_cursor_line: bool, cursor_col: usize, ) !void { _ = cursor_col; var remaining = max_cells; if (is_cursor_line and remaining > 0) { try out.appendSlice(allocator, ansi_cursor); try out.appendSlice(allocator, "▌"); try out.appendSlice(allocator, ansi_reset); try out.appendSlice(allocator, ansi_current_line); try out.appendSlice(allocator, ansi_text); remaining -= 1; } var i: usize = 0; var col: usize = 0; while (i < bytes.len and col < remaining) { const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1; const end = @min(bytes.len, i + len); const width = @max(@as(usize, 1), session_mod.cellWidth(bytes[i..end])); if (col + width > remaining) break; try out.appendSlice(allocator, bytes[i..end]); col += width; i = end; } } fn appendStatusLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), status: []const u8, width: usize) !void { try out.appendSlice(allocator, ansi_status); try appendVisibleCells(allocator, out, status, width); try out.appendSlice(allocator, ansi_reset); } fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize) !void { var i: usize = 0; while (i < bytes.len) { const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1; const end = @min(bytes.len, i + len); const next = bytes[0..end]; if (session_mod.cellWidth(next) > max_cells) break; try out.appendSlice(allocator, bytes[i..end]); i = end; } } fn countDocumentLines(bytes: []const u8) usize { var lines: usize = 1; for (bytes) |byte| { if (byte == '\n') lines += 1; } return lines; } fn decimalDigits(value: usize) usize { var digits: usize = 1; var n = value; while (n >= 10) : (digits += 1) n /= 10; return digits; } fn visibleCellWidth(bytes: []const u8) usize { var i: usize = 0; var cells: usize = 0; while (i < bytes.len) { if (bytes[i] == 0x1b and i + 1 < bytes.len and bytes[i + 1] == '[') { i += 2; while (i < bytes.len and bytes[i] != 'm') : (i += 1) {} if (i < bytes.len) i += 1; continue; } const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1; const end = @min(bytes.len, i + len); cells += session_mod.cellWidth(bytes[i..end]); i = end; } return cells; } fn lineIndexAt(bytes: []const u8, cursor_byte: usize) usize { var line: usize = 0; for (bytes[0..@min(cursor_byte, bytes.len)]) |byte| { if (byte == '\n') line += 1; } return line; } fn columnAt(bytes: []const u8, cursor_byte: usize) usize { const prefix = bytes[0..@min(cursor_byte, bytes.len)]; const line_start = if (std.mem.lastIndexOfScalar(u8, prefix, '\n')) |idx| idx + 1 else 0; return session_mod.cellWidth(prefix[line_start..]); } fn diagnoseFrame(viewport: Viewport, frame: []const u8) RenderDiagnostics { var diagnostics = RenderDiagnostics{ .viewport_width = viewport.width, .viewport_height = viewport.height, .frame_bytes = frame.len, .frame_lines = 0, .max_line_cells = 0, .clipped_sources = 0, }; var lines = std.mem.splitScalar(u8, frame, '\n'); while (lines.next()) |line| { diagnostics.frame_lines += 1; diagnostics.max_line_cells = @max(diagnostics.max_line_cells, visibleCellWidth(line)); } return diagnostics; } fn renderWarningAlloc(allocator: std.mem.Allocator, diagnostics: RenderDiagnostics) ![]u8 { if (diagnostics.frame_lines != diagnostics.viewport_height) { return std.fmt.allocPrint( allocator, "render warning: expected {d} lines, rendered {d}", .{ diagnostics.viewport_height, diagnostics.frame_lines }, ); } if (diagnostics.max_line_cells > diagnostics.viewport_width) { return std.fmt.allocPrint( allocator, "render warning: line width {d} exceeds viewport {d}", .{ diagnostics.max_line_cells, diagnostics.viewport_width }, ); } if (diagnostics.clipped_sources > 0) { return std.fmt.allocPrint( allocator, "render warning: clipped {d} source line(s) to fit {d} columns", .{ diagnostics.clipped_sources, diagnostics.viewport_width }, ); } return std.fmt.allocPrint( allocator, "render ok: {d} lines, {d} bytes, max {d}/{d} cells", .{ diagnostics.frame_lines, diagnostics.frame_bytes, diagnostics.max_line_cells, diagnostics.viewport_width }, ); } fn assertLinesFit(frame: []const u8, width: usize) !void { var lines = std.mem.splitScalar(u8, frame, '\n'); while (lines.next()) |line| { try std.testing.expect(visibleCellWidth(line) <= width); } } fn rowsContain(rows: []const []const u8, needle: []const u8) bool { for (rows) |row| { if (std.mem.indexOf(u8, row, needle) != null) return true; } return false; } fn freeOwnedRows(allocator: std.mem.Allocator, rows: []const []const u8) void { for (rows) |row| allocator.free(row); allocator.free(rows); } fn splitCwdAndCommand(cwd_and_command: []const u8) ?struct { cwd: []const u8, command: []const u8 } { const first_space = std.mem.indexOfScalar(u8, cwd_and_command, ' ') orelse return null; const cwd = cwd_and_command[0..first_space]; const command = std.mem.trim(u8, cwd_and_command[first_space + 1 ..], " "); if (cwd.len == 0 or command.len == 0) return null; return .{ .cwd = cwd, .command = command }; } fn parseLspSyncArgs(args: []const u8) ?struct { cwd: []const u8, uri: []const u8, language_id: []const u8, command: []const u8 } { const first = std.mem.indexOfScalar(u8, args, ' ') orelse return null; const cwd = args[0..first]; const rest = std.mem.trim(u8, args[first + 1 ..], " "); const second = std.mem.indexOfScalar(u8, rest, ' ') orelse return null; const uri = rest[0..second]; const rest2 = std.mem.trim(u8, rest[second + 1 ..], " "); const third = std.mem.indexOfScalar(u8, rest2, ' ') orelse return null; const language_id = rest2[0..third]; const command = std.mem.trim(u8, rest2[third + 1 ..], " "); if (cwd.len == 0 or uri.len == 0 or language_id.len == 0 or command.len == 0) return null; return .{ .cwd = cwd, .uri = uri, .language_id = language_id, .command = command }; } test "regular: scripted narrow terminal trace edits saves exits and replays saved bytes" { const trace = \\open abc \\right \\insert é \\save \\quit \\ ; const result = try runTrace(std.testing.allocator, .{ .width = 12, .height = 4 }, trace); defer result.deinit(std.testing.allocator); try std.testing.expect(result.quit); try std.testing.expect(std.mem.indexOf(u8, result.frame, "aébc") != null); try assertLinesFit(result.frame, 12); try std.testing.expectEqualStrings("aébc", result.saved_bytes.?); const recording = \\protocol open abc \\protocol command move_right \\input insert é \\save aébc \\ ; const replay_result = try replay.replayText(std.testing.allocator, recording); defer replay_result.deinit(std.testing.allocator); switch (replay_result) { .ok => {}, .diverged => return error.ExpectedReplayOk, } } test "regular: backspace deletes whole UTF-8 codepoint through terminal client" { var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 }); defer client.deinit(); try client.handleTraceLine("open aéb"); try client.handleTraceLine("right"); try client.handleTraceLine("right"); try client.handleTraceLine("backspace"); try client.handleTraceLine("save"); try std.testing.expectEqualStrings("ab", try client.saved()); } test "regular: resize changes render width without changing buffer bytes" { var client = try Client.init(std.testing.allocator, .{ .width = 8, .height = 3 }); defer client.deinit(); try client.handleTraceLine("open abcdefghijk"); try client.handleTraceLine("resize 5x3"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try assertLinesFit(frame, 5); try client.handleTraceLine("save"); try std.testing.expectEqualStrings("abcdefghijk", try client.saved()); } test "adversarial: tiny or malformed resize is rejected" { var client = try Client.init(std.testing.allocator, .{ .width = 4, .height = 3 }); defer client.deinit(); try client.handleTraceLine("open abc"); try std.testing.expectError(Error.ViewportTooSmall, client.handleTraceLine("resize 0x3")); try std.testing.expectError(Error.InvalidResize, client.handleTraceLine("resize phone")); } test "adversarial: invalid UTF-8 insertion is rejected and previous bytes can still save" { var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 }); defer client.deinit(); try client.handleTraceLine("open safe"); const bad = [_]u8{ 'i', 'n', 's', 'e', 'r', 't', ' ', 0xc3, 0x28 }; try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine(&bad)); try client.handleTraceLine("save"); try std.testing.expectEqualStrings("safe", try client.saved()); } test "adversarial: quit prevents further edits" { var client = try Client.init(std.testing.allocator, .{ .width = 10, .height = 3 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("quit"); try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x")); } test "regular: leader rail is visible in terminal render and dispatches save" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("key space"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "leader: w save") != null); try client.handleTraceLine("key w"); try std.testing.expectEqualStrings("abc", try client.saved()); } test "regular: leader rail dispatches quit" { var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("key space"); try client.handleTraceLine("key q"); try std.testing.expect(client.quit); try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x")); } test "regular: leader open prompt opens typed UTF-8 payload" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open old"); try client.handleTraceLine("key space"); try client.handleTraceLine("key o"); try client.handleTraceLine("type café.zig"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "café.zig") != null); try client.handleTraceLine("key enter"); try client.handleTraceLine("key space"); try client.handleTraceLine("key w"); try std.testing.expectEqualStrings("café.zig", try client.saved()); } test "regular: leader search rail opens file search and panel-close recovers as not-built" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("key space"); try client.handleTraceLine("key s"); try client.handleTraceLine("key f"); const search_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(search_frame); try std.testing.expect(std.mem.indexOf(u8, search_frame, "search: type query") != null); try client.handleTraceLine("key escape"); try client.handleTraceLine("key space"); try client.handleTraceLine("key x"); const close_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(close_frame); try std.testing.expect(std.mem.indexOf(u8, close_frame, "panel close is not built") != null); } test "adversarial: unknown leader input recovers without saving or quitting" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("key space"); try client.handleTraceLine("key ?"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "unknown leader key") != null); try std.testing.expect(!client.quit); try std.testing.expectError(Error.NothingSaved, client.saved()); } fn expectSymbolTrace(symbol_key: []const u8, expected_bytes: []const u8, expected_cursor_byte: usize) !void { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open base"); try client.handleTraceLine("key space"); try client.handleTraceLine("key p"); const rail = try client.render(std.testing.allocator); defer std.testing.allocator.free(rail); try std.testing.expect(std.mem.indexOf(u8, rail, "symbols:") != null); const line = try std.fmt.allocPrint(std.testing.allocator, "key {s}", .{symbol_key}); defer std.testing.allocator.free(line); try client.handleTraceLine(line); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings(expected_bytes, snap.bytes); try std.testing.expectEqual(expected_cursor_byte, snap.cursor_byte); } test "regular: symbol rail inserts paired braces brackets parens and quotes with cursor inside" { try expectSymbolTrace("p", "()base", 1); try expectSymbolTrace("b", "[]base", 1); try expectSymbolTrace("c", "{}base", 1); try expectSymbolTrace("q", "\"\"base", 1); try expectSymbolTrace("e", "''base", 1); try expectSymbolTrace("t", "``base", 1); } test "regular: symbol rail inserts slash pipe and underscore as single symbols" { try expectSymbolTrace("s", "/base", 1); try expectSymbolTrace("v", "|base", 1); try expectSymbolTrace("u", "_base", 1); } test "adversarial: unknown symbol key does not mutate the buffer" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open base"); try client.handleTraceLine("key space"); try client.handleTraceLine("key p"); try client.handleTraceLine("key ?"); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("base", snap.bytes); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "unknown symbol key") != null); } test "regular: narrow terminal renders active panel instead of editor and returns cleanly" { var client = try Client.init(std.testing.allocator, .{ .width = 20, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open editor-text"); try client.handleTraceLine("panel_open files"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "panel [files]") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "editor-text") == null); try assertLinesFit(frame, 20); try client.handleTraceLine("panel_close"); const editor_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(editor_frame); try std.testing.expect(std.mem.indexOf(u8, editor_frame, "editor-text") != null); try std.testing.expect(std.mem.indexOf(u8, editor_frame, "panel [files]") == null); try assertLinesFit(editor_frame, 20); } test "regular: nested panel breadcrumbs render on narrow terminal without overflow" { var client = try Client.init(std.testing.allocator, .{ .width = 18, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("panel_open files"); try client.handleTraceLine("panel_open diagnostics"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "panel files>") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "[diagnostics]") == null); try assertLinesFit(frame, 18); try client.handleTraceLine("panel_prev"); const previous_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(previous_frame); try std.testing.expect(std.mem.indexOf(u8, previous_frame, "panel [files]") != null); try assertLinesFit(previous_frame, 18); } test "adversarial: invalid panel title and empty close recover without changing editor render" { var client = try Client.init(std.testing.allocator, .{ .width = 16, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_open bad title")); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_close")); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null); try assertLinesFit(frame, 16); } test "regular: list panel filters moves selects and renders rows" { var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 }); defer client.deinit(); try client.handleTraceLine("list_open files src/main.zig|src/panel.zig|README.md"); try client.handleTraceLine("list_filter src"); try client.handleTraceLine("list_down"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "panel [files]") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "> src/panel.zig") != null); try assertLinesFit(frame, 32); try client.handleTraceLine("list_select"); const summary = try client.session.panelSummaryAlloc(std.testing.allocator); defer std.testing.allocator.free(summary); try std.testing.expectEqualStrings("list:filter=src,cursor=1,visible=2,selected=src/panel.zig", summary); } test "regular: list cancel returns to previous editor surface" { var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("list_open files a.zig|b.zig"); try client.handleTraceLine("list_cancel"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "panel") == null); } test "adversarial: list no-match state and invalid actions recover" { var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 }); defer client.deinit(); try client.handleTraceLine("list_open files a.zig|b.zig"); try client.handleTraceLine("list_filter none"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "no matches") != null); try assertLinesFit(frame, 24); try client.handleTraceLine("list_cancel"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("list_down")); } test "regular: save status appears near editor status line" { var client = try Client.init(std.testing.allocator, .{ .width = 20, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("key space"); try client.handleTraceLine("key w"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "saved") != null); try assertLinesFit(frame, 20); } test "regular: empty generic panel shows local empty state" { var client = try Client.init(std.testing.allocator, .{ .width = 22, .height = 4 }); defer client.deinit(); try client.handleTraceLine("panel_open help"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "help: no content yet") != null); try assertLinesFit(frame, 22); } test "adversarial: protocol errors render recoverable local message" { var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_open bad title")); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "error: invalid panel") != null); try assertLinesFit(frame, 24); } test "adversarial: long feedback messages are clipped to viewport width" { var client = try Client.init(std.testing.allocator, .{ .width = 16, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("message this-message-is-way-too-long-for-the-phone-width"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try assertLinesFit(frame, 16); try std.testing.expect(std.mem.indexOf(u8, frame, "this-message-is-") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "phone-width") == null); } test "regular: render diagnostics report ok for representative narrow editor frame" { var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open fn main() {}"); try client.handleTraceLine("key space"); try client.handleTraceLine("key p"); try client.handleTraceLine("key c"); try client.handleTraceLine("type x"); const result = try client.renderWithDiagnostics(std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 5), result.diagnostics.frame_lines); try std.testing.expect(result.diagnostics.frame_bytes <= 32 * 5 * 4); try std.testing.expect(result.diagnostics.max_line_cells <= 32); try std.testing.expectEqual(@as(usize, 0), result.diagnostics.clipped_sources); try std.testing.expect(std.mem.indexOf(u8, result.warning, "render ok") != null); } test "regular: render diagnostics report ok for representative list panel frame" { var client = try Client.init(std.testing.allocator, .{ .width = 36, .height = 5 }); defer client.deinit(); try client.handleTraceLine("list_open files src/main.zig|src/panel.zig|README.md"); try client.handleTraceLine("list_filter src"); try client.handleTraceLine("list_down"); const result = try client.renderWithDiagnostics(std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 5), result.diagnostics.frame_lines); try std.testing.expect(result.diagnostics.frame_bytes <= 36 * 5 * 4); try std.testing.expect(result.diagnostics.max_line_cells <= 36); try std.testing.expectEqual(@as(usize, 0), result.diagnostics.clipped_sources); try std.testing.expect(std.mem.indexOf(u8, result.warning, "render ok") != null); } test "adversarial: render diagnostics name clipped editor sources" { var client = try Client.init(std.testing.allocator, .{ .width = 12, .height = 4 }); defer client.deinit(); try client.handleTraceLine("open this-line-is-far-too-long-for-phone"); const result = try client.renderWithDiagnostics(std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 4), result.diagnostics.frame_lines); try std.testing.expect(result.diagnostics.max_line_cells <= 12); try std.testing.expect(result.diagnostics.clipped_sources >= 1); try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null); try std.testing.expect(std.mem.indexOf(u8, result.warning, "12 columns") != null); } test "adversarial: render diagnostics name clipped panel sources" { var client = try Client.init(std.testing.allocator, .{ .width = 14, .height = 4 }); defer client.deinit(); try client.handleTraceLine("list_open files src/very-long-mobile-render-file-name.zig|b.zig"); const result = try client.renderWithDiagnostics(std.testing.allocator); defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 4), result.diagnostics.frame_lines); try std.testing.expect(result.diagnostics.max_line_cells <= 14); try std.testing.expect(result.diagnostics.clipped_sources >= 1); try std.testing.expect(std.mem.indexOf(u8, result.warning, "clipped") != null); } test "regular: file picker respects gitignore by default and opens selected file" { var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 }); defer client.deinit(); try client.handleTraceLine("repo_gitignore zig-out/"); try client.handleTraceLine("repo_gitignore *.o"); try client.handleTraceLine("repo_file src/main.zig=pubfnmain"); try client.handleTraceLine("repo_file zig-out/bin/mim=binary"); try client.handleTraceLine("repo_file build.o=object"); try client.handleTraceLine("file_picker"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out") == null); try std.testing.expect(std.mem.indexOf(u8, frame, "build.o") == null); } try client.handleTraceLine("file_open_selected"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "pubfnmain") != null); } } test "regular: file picker include ignored toggle exposes ignored files" { var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 }); defer client.deinit(); try client.handleTraceLine("repo_gitignore zig-out/"); try client.handleTraceLine("repo_file src/main.zig=main"); try client.handleTraceLine("repo_file zig-out/bin/mim=binary"); try client.handleTraceLine("file_picker all"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out/bin/mim") != null); } test "regular: file tree uses the same panel primitive and include ignored boundary" { var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 }); defer client.deinit(); try client.handleTraceLine("repo_gitignore tmp/"); try client.handleTraceLine("repo_file src/lib.zig=lib"); try client.handleTraceLine("repo_file tmp/log.txt=log"); try client.handleTraceLine("file_tree"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "src/lib.zig") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "tmp/log") == null); } try client.handleTraceLine("panel_close"); try client.handleTraceLine("file_tree all"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "tmp/log.txt") != null); } } test "adversarial: invalid repo paths and missing selections fail without corrupting editor" { var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open safe"); try std.testing.expectError(repo_mod.Error.InvalidPath, client.handleTraceLine("repo_file ../secret=x")); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("file_open_selected")); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "safe") != null); } test "regular: project text search lists matches filters results and jumps to match" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer client.deinit(); try client.handleTraceLine("repo_file src/main.zig=abc_needle"); try client.handleTraceLine("repo_file src/lib.zig=needle_lib"); try client.handleTraceLine("search_text needle"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig:1:5") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "src/lib.zig:1:1") != null); } try client.handleTraceLine("list_filter lib"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "src/lib.zig:1:1") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") == null); } try client.handleTraceLine("search_open_selected"); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("needle_lib", snap.bytes); try std.testing.expectEqual(@as(usize, 0), snap.cursor_byte); } test "regular: project text search respects ignored files unless include ignored is explicit" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 5 }); defer client.deinit(); try client.handleTraceLine("repo_gitignore zig-out/"); try client.handleTraceLine("repo_file src/main.zig=needle"); try client.handleTraceLine("repo_file zig-out/log.txt=needle"); try client.handleTraceLine("search_text needle"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out") == null); } try client.handleTraceLine("panel_close"); try client.handleTraceLine("search_text all needle"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "zig-out/log.txt") != null); } } test "adversarial: project text search failures do not corrupt current buffer" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open safe"); try client.handleTraceLine("repo_file src/main.zig=needle"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("search_text two words")); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("search_open_selected")); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); } fn makeTuiGitFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, cwd: []u8 } { var tmp = std.testing.tmpDir(.{}); errdefer tmp.cleanup(); var src = try tmp.dir.createDirPathOpen(std.testing.io, "src", .{}); src.close(std.testing.io); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "const old = 1;\n" }); const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path}); errdefer allocator.free(cwd); try runGitTuiTest(allocator, cwd, &.{ "init", "--quiet" }); try runGitTuiTest(allocator, cwd, &.{ "add", "src/main.zig" }); try runGitTuiTest(allocator, cwd, &.{ "-c", "user.email=test@example.invalid", "-c", "user.name=Test", "commit", "--quiet", "-m", "init" }); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "const new = 2;\n" }); return .{ .tmp = tmp, .cwd = cwd }; } fn runGitTuiTest(allocator: std.mem.Allocator, cwd: []const u8, args: []const []const u8) !void { var argv = try allocator.alloc([]const u8, args.len + 3); defer allocator.free(argv); argv[0] = "git"; argv[1] = "-C"; argv[2] = cwd; @memcpy(argv[3..], args); const result = try std.process.run(allocator, std.testing.io, .{ .argv = argv, .stdout_limit = .limited(64 * 1024), .stderr_limit = .limited(64 * 1024), }); defer allocator.free(result.stdout); defer allocator.free(result.stderr); try std.testing.expect(switch (result.term) { .exited => |code| code == 0, else => false, }); } test "regular: git status panel opens changed file into editor" { var fixture = try makeTuiGitFixture(std.testing.allocator); defer { std.testing.allocator.free(fixture.cwd); fixture.tmp.cleanup(); } var client = try Client.initWithIo(std.testing.allocator, .{ .width = 64, .height = 6 }, std.testing.io); defer client.deinit(); const status = try std.fmt.allocPrint(std.testing.allocator, "git_status {s}", .{fixture.cwd}); defer std.testing.allocator.free(status); try client.handleTraceLine(status); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "git-status") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "modified:src/main.zig") != null); } const open_changed = try std.fmt.allocPrint(std.testing.allocator, "git_open_changed_selected {s}", .{fixture.cwd}); defer std.testing.allocator.free(open_changed); try client.handleTraceLine(open_changed); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("const new = 2;\n", snap.bytes); } test "regular: git diff panel renders hunks for selected changed file" { var fixture = try makeTuiGitFixture(std.testing.allocator); defer { std.testing.allocator.free(fixture.cwd); fixture.tmp.cleanup(); } var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 7 }, std.testing.io); defer client.deinit(); const status = try std.fmt.allocPrint(std.testing.allocator, "git_status {s}", .{fixture.cwd}); defer std.testing.allocator.free(status); try client.handleTraceLine(status); const diff = try std.fmt.allocPrint(std.testing.allocator, "git_diff_selected {s}", .{fixture.cwd}); defer std.testing.allocator.free(diff); try client.handleTraceLine(diff); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "git-diff") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "hunk:@@") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "add:const_new_=_2;") != null); } test "adversarial: git status errors are visible in panel" { var client = try Client.initWithIo(std.testing.allocator, .{ .width = 64, .height = 5 }, std.testing.io); defer client.deinit(); try client.handleTraceLine("git_status /definitely/not/a/mim/repo"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "git_error:") != null); } fn makeTuiJobFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, cwd: []u8 } { var tmp = std.testing.tmpDir(.{}); errdefer tmp.cleanup(); var src = try tmp.dir.createDirPathOpen(std.testing.io, "src", .{}); src.close(std.testing.io); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "one\nabcdTARGET\n" }); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "fail.sh", .data = "printf 'src/main.zig:2:5:error:bad\\n'\nexit 1\n" }); const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path}); errdefer allocator.free(cwd); return .{ .tmp = tmp, .cwd = cwd }; } test "regular: job panel captures failing command output and jumps to file line" { 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 = 72, .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); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "job-output") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:exit_1") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "stdout:src/main.zig:2:5:error:bad") != null); } try client.handleTraceLine("list_filter src/main.zig"); const open = try std.fmt.allocPrint(std.testing.allocator, "job_open_selected {s}", .{fixture.cwd}); defer std.testing.allocator.free(open); try client.handleTraceLine(open); 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); } test "regular: job status and cancel expose honest foreground state rows" { var client = try Client.initWithIo(std.testing.allocator, .{ .width = 56, .height = 5 }, std.testing.io); defer client.deinit(); try client.handleTraceLine("job_status"); { 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:idle") != null); } try client.handleTraceLine("job_cancel"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "job_cancel:no_running_job") != null); } } test "adversarial: bad job command is visible and failed job open does not corrupt buffer" { 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 = 72, .height = 6 }, std.testing.io); defer client.deinit(); try client.handleTraceLine("open safe"); const run = try std.fmt.allocPrint(std.testing.allocator, "job_run {s} definitely-not-a-mim-command", .{fixture.cwd}); defer std.testing.allocator.free(run); try client.handleTraceLine(run); 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:spawn_error_") != null); const open = try std.fmt.allocPrint(std.testing.allocator, "job_open_selected {s}", .{fixture.cwd}); defer std.testing.allocator.free(open); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine(open)); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); } test "regular: terminal escape hatch runs shell command exits and returns editor" { var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 6 }, std.testing.io); defer client.deinit(); try client.handleTraceLine("open safe_editor"); try client.handleTraceLine("terminal_run . printf terminal_ok"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "terminal-output") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "terminal:status:exit_0") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "stdout:terminal_ok") != null); } try client.handleTraceLine("terminal_exit"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "terminal-output") == null); try std.testing.expect(std.mem.indexOf(u8, frame, "safe_editor") != null); } test "regular: terminal status and cancel are honest foreground lifecycle rows" { var client = try Client.initWithIo(std.testing.allocator, .{ .width = 64, .height = 5 }, std.testing.io); defer client.deinit(); try client.handleTraceLine("terminal_status"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "terminal-status") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "terminal_status:idle") != null); } try client.handleTraceLine("terminal_cancel"); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "terminal_cancel:no_running_terminal") != null); } } test "adversarial: terminal command rejection does not corrupt editor" { var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 5 }, std.testing.io); defer client.deinit(); try client.handleTraceLine("open safe"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("terminal_run . ")); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); try client.handleTraceLine("terminal_run . definitely-not-a-mim-command"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "terminal:status:exit_127") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "stderr:") != null); } test "regular: syntax spans panel renders zig highlight classes" { var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 7 }); defer client.deinit(); try client.handleTraceLine("open pub const answer = 42; // ok"); try client.handleTraceLine("syntax_spans zig"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "syntax-spans") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:keyword:0:3:pub") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:keyword:4:9:const") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:number:") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:zig:comment:") != null); } test "regular: syntax text fallback renders no spans row" { var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open pub_const_plain_text"); try client.handleTraceLine("syntax_spans text"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "syntax:text:fallback:no_spans") != null); } test "adversarial: unsupported syntax language is rejected without changing buffer" { var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open safe"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("syntax_spans python")); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); } fn makeTuiLspFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, cwd: []u8 } { var tmp = std.testing.tmpDir(.{}); errdefer tmp.cleanup(); const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path}); errdefer allocator.free(cwd); return .{ .tmp = tmp, .cwd = cwd }; } test "regular: lsp sync panel sends current buffer to fake server" { var fixture = try makeTuiLspFixture(std.testing.allocator); defer { std.testing.allocator.free(fixture.cwd); fixture.tmp.cleanup(); } var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 9 }, std.testing.io); defer client.deinit(); try client.handleTraceLine("open pub const x = 1;"); const sync = try std.fmt.allocPrint(std.testing.allocator, "lsp_sync {s} file:///tmp/main.zig zig sh -c cat>observed.lsp", .{fixture.cwd}); defer std.testing.allocator.free(sync); try client.handleTraceLine(sync); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-sync") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:initialize") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:textDocument/didOpen") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:textDocument/didChange") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:sent:textDocument/didSave") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:status:exit_0") != null); } const observed = try fixture.tmp.dir.readFileAlloc(std.testing.io, "observed.lsp", std.testing.allocator, .limited(64 * 1024)); defer std.testing.allocator.free(observed); try std.testing.expect(std.mem.indexOf(u8, observed, "pub const x = 1;") != null); try std.testing.expect(std.mem.indexOf(u8, observed, "textDocument/didOpen") != null); } test "adversarial: lsp sync rejection does not corrupt current buffer" { var client = try Client.initWithIo(std.testing.allocator, .{ .width = 72, .height = 5 }, std.testing.io); defer client.deinit(); try client.handleTraceLine("open safe"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_sync .")); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_sync . bad uri zig sh -c cat")); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); } test "regular: lsp diagnostics panel renders rows and jumps to diagnostic" { var client = try Client.init(std.testing.allocator, .{ .width = 96, .height = 7 }); defer client.deinit(); try client.handleTraceLine("open one\\nabcdTARGET"); const payload = \\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///tmp/main.zig","diagnostics":[{"range":{"start":{"line":0,"character":4},"end":{"line":1,"character":5}},"severity":1,"message":"expected semicolon"}]}} ; const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_diagnostics {s}", .{payload}); defer std.testing.allocator.free(command); try client.handleTraceLine(command); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-diagnostics") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:diagnostics:count_1") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:diag:error:1:5:file_///tmp/main.zig:expected_semicolon") != null); } try client.handleTraceLine("list_down"); try client.handleTraceLine("lsp_diagnostics_open_selected"); const snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte); } test "adversarial: malformed diagnostics and unselected summary row do not corrupt buffer" { var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open safe"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_diagnostics not-json")); const payload = \\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///tmp/main.zig","diagnostics":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"severity":2,"message":"warn"}]}} ; const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_diagnostics {s}", .{payload}); defer std.testing.allocator.free(command); try client.handleTraceLine(command); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_diagnostics_open_selected")); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); } test "regular: lsp definition panel selects and jumps current buffer" { var client = try Client.init(std.testing.allocator, .{ .width = 96, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open one abcTARGET"); const payload = \\{"jsonrpc":"2.0","id":2,"result":{"uri":"file:///tmp/main.zig","range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}}}} ; const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_definition {s}", .{payload}); defer std.testing.allocator.free(command); try client.handleTraceLine(command); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-definition") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:nav:definition:1:5:file_///tmp/main.zig:location") != null); } try client.handleTraceLine("lsp_navigation_open_selected"); const snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte); } test "regular: lsp references document symbols and workspace symbols render shared panels" { var client = try Client.init(std.testing.allocator, .{ .width = 112, .height = 7 }); defer client.deinit(); try client.handleTraceLine("open symbol body"); const references_payload = \\{"jsonrpc":"2.0","id":3,"result":[{"uri":"file:///tmp/main.zig","range":{"start":{"line":0,"character":0},"end":{"line":0,"character":3}}},{"uri":"file:///tmp/lib.zig","range":{"start":{"line":0,"character":7},"end":{"line":0,"character":11}}}]} ; const references_command = try std.fmt.allocPrint(std.testing.allocator, "lsp_references {s}", .{references_payload}); defer std.testing.allocator.free(references_command); try client.handleTraceLine(references_command); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-references") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:nav:references:1:1:file_///tmp/main.zig:location") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:nav:references:1:8:file_///tmp/lib.zig:location") != null); } const document_payload = \\{"jsonrpc":"2.0","id":4,"result":[{"name":"main","kind":12,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":6}},"selectionRange":{"start":{"line":0,"character":0},"end":{"line":0,"character":4}}}]} ; const document_command = try std.fmt.allocPrint(std.testing.allocator, "lsp_document_symbols {s}", .{document_payload}); defer std.testing.allocator.free(document_command); try client.handleTraceLine(document_command); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-document-symbols") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:symbol:document:kind_12:1:1:current:main") != null); } const workspace_payload = \\{"jsonrpc":"2.0","id":5,"result":[{"name":"helper","kind":12,"location":{"uri":"file:///tmp/lib.zig","range":{"start":{"line":0,"character":7},"end":{"line":0,"character":11}}}}]} ; const workspace_command = try std.fmt.allocPrint(std.testing.allocator, "lsp_workspace_symbols {s}", .{workspace_payload}); defer std.testing.allocator.free(workspace_command); try client.handleTraceLine(workspace_command); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-workspace-symbols") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:symbol:workspace:kind_12:1:8:file_///tmp/lib.zig:helper") != null); } } test "adversarial: lsp navigation failures do not corrupt buffer" { var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open safe"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_definition not-json")); const none_payload = \\{"jsonrpc":"2.0","id":2,"result":null} ; const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_definition {s}", .{none_payload}); defer std.testing.allocator.free(command); try client.handleTraceLine(command); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_navigation_open_selected")); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); } test "regular: lsp rename preview applies selected edit after confirmation" { var client = try Client.init(std.testing.allocator, .{ .width = 112, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open pub old = old;"); const payload = \\{"jsonrpc":"2.0","id":6,"result":{"changes":{"file:///tmp/main.zig":[{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}},"newText":"renamed"}]}}} ; const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_rename {s}", .{payload}); defer std.testing.allocator.free(command); try client.handleTraceLine(command); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-edit-preview") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:edit:rename:1:5:1:8:72656e616d6564") != null); } try client.handleTraceLine("lsp_edit_apply_selected"); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("pub renamed = old;", snap.bytes); try std.testing.expectEqual(@as(usize, 11), snap.cursor_byte); } test "regular: lsp code action preview applies selected workspace edit" { var client = try Client.init(std.testing.allocator, .{ .width = 112, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open var x = 1;"); const payload = \\{"jsonrpc":"2.0","id":7,"result":[{"title":"replace with const","kind":"quickfix","edit":{"changes":{"file:///tmp/main.zig":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":3}},"newText":"const"}]}}}]} ; const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_code_actions {s}", .{payload}); defer std.testing.allocator.free(command); try client.handleTraceLine(command); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-code-actions") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "replace_with_const") != null); } try client.handleTraceLine("lsp_edit_apply_selected"); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("const x = 1;", snap.bytes); } test "adversarial: lsp edit failures do not corrupt current buffer" { var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open safe"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_rename not-json")); const command_only_payload = \\{"jsonrpc":"2.0","id":7,"result":[{"title":"command only","command":{"title":"noop","command":"noop"}}]} ; const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_code_actions {s}", .{command_only_payload}); defer std.testing.allocator.free(command); try client.handleTraceLine(command); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_edit_apply_selected")); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); } test "regular: lsp hover wraps inside narrow viewport" { var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 7 }); defer client.deinit(); const payload = \\{"jsonrpc":"2.0","id":8,"result":{"contents":{"kind":"markdown","value":"pub fn add(lhs: i32, rhs: i32) i32"}}} ; const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_hover {s}", .{payload}); defer std.testing.allocator.free(command); try client.handleTraceLine(command); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-hover") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:hover:") != null); try assertLinesFit(frame, 32); } test "regular: lsp signature shows active parameter in shared panel" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 7 }); defer client.deinit(); const payload = \\{"jsonrpc":"2.0","id":9,"result":{"activeSignature":0,"activeParameter":1,"signatures":[{"label":"add(lhs: i32, rhs: i32)","parameters":[{"label":"lhs: i32"},{"label":"rhs: i32"}]}]}} ; const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_signature {s}", .{payload}); defer std.testing.allocator.free(command); try client.handleTraceLine(command); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-signature") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:signature:active_2") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:signature:param_2:active") != null); } test "regular: lsp parameter movement updates cursor and reports active parameter" { var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 6 }); defer client.deinit(); try client.session.openFixtureAt("add(alpha, beta, gamma)", 5); try client.handleTraceLine("lsp_param_next"); var snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 11), snap.cursor_byte); { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:param:active_2:11") != null); } try client.handleTraceLine("lsp_param_previous"); snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte); } test "adversarial: malformed hover signature and non-call parameter movement are safe" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open safe"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_hover not-json")); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_signature not-json")); try client.handleTraceLine("lsp_param_next"); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:param:outside_call") != null); } fn makeTuiPiFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, script: []u8 } { var tmp = std.testing.tmpDir(.{}); errdefer tmp.cleanup(); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "fake-pi.sh", .data = "case \"$1\" in *context:v1*) echo pi_ok;; *) echo missing; exit 2;; esac\n" }); const script = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}/fake-pi.sh", .{&tmp.sub_path}); errdefer allocator.free(script); return .{ .tmp = tmp, .script = script }; } test "regular: pi bridge sends context to fake pi and renders output panel" { var fixture = try makeTuiPiFixture(std.testing.allocator); defer { std.testing.allocator.free(fixture.script); fixture.tmp.cleanup(); } var client = try Client.initWithIo(std.testing.allocator, .{ .width = 80, .height = 7 }, std.testing.io); defer client.deinit(); try client.handleTraceLine("open working buffer"); const command = try std.fmt.allocPrint(std.testing.allocator, "pi_run sh {s}", .{fixture.script}); defer std.testing.allocator.free(command); try client.handleTraceLine(command); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "pi-output") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "pi:context:bytes_") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "pi:status:exit_0") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "stdout:pi_ok") != null); } test "adversarial: missing pi command is visible and invalid command preserves buffer" { var client = try Client.initWithIo(std.testing.allocator, .{ .width = 80, .height = 6 }, std.testing.io); defer client.deinit(); try client.handleTraceLine("open safe"); try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("pi_run ")); try client.handleTraceLine("pi_run definitely-not-a-mim-pi"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "pi:status:spawn_error_") != null); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("safe", snap.bytes); } test "regular: modal input exposes normal insert select prompt and panel modes" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open abc"); try std.testing.expectEqualStrings("normal", client.modeName()); try client.handleInput("i"); try std.testing.expectEqualStrings("insert", client.modeName()); try client.handleInput("x"); const inserted = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(inserted); try std.testing.expectEqualStrings("xabc", inserted); try client.handleInput(" "); try std.testing.expectEqualStrings("insert_space", client.pendingRailName()); try client.handleInput("n"); try std.testing.expectEqualStrings("normal", client.modeName()); try client.handleInput("s"); try std.testing.expectEqualStrings("select", client.modeName()); try client.handleInput("n"); try std.testing.expectEqualStrings("normal", client.modeName()); try client.handleInput(" "); try client.handleInput("o"); try std.testing.expectEqualStrings("prompt", client.modeName()); var panel_client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 }); defer panel_client.deinit(); try panel_client.handleTraceLine("panel_open files"); try std.testing.expectEqualStrings("panel", panel_client.modeName()); } test "regular: modal rails expose match go repeat and unknown recovery" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleInput("m"); try std.testing.expectEqualStrings("match", client.pendingRailName()); const match_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(match_frame); try std.testing.expect(std.mem.indexOf(u8, match_frame, "match:") != null); try client.handleInput("z"); try std.testing.expectEqualStrings("none", client.pendingRailName()); const unknown_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(unknown_frame); try std.testing.expect(std.mem.indexOf(u8, unknown_frame, "unknown normal key") != null); try client.handleInput("g"); try std.testing.expectEqualStrings("go", client.pendingRailName()); try client.handleInput("d"); try std.testing.expectEqualStrings("none", client.pendingRailName()); try client.handleInput(" "); try client.handleInput(" "); try std.testing.expectEqualStrings("go", client.pendingRailName()); try client.handleInput("d"); try client.handleInput(" "); try client.handleInput("r"); try std.testing.expectEqualStrings("repeat", client.pendingRailName()); try client.handleInput("."); try std.testing.expectEqualStrings("none", client.pendingRailName()); } test "regular: counts are pending visible and cleared by one movement" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open abcd"); try client.handleTraceLine("right"); try client.handleTraceLine("right"); try client.handleInput("2"); try std.testing.expectEqual(@as(usize, 2), client.pendingCount()); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "count pending") != null); try client.handleInput("h"); try std.testing.expectEqual(@as(usize, 0), client.pendingCount()); try client.handleInput("i"); try client.handleInput("X"); const bytes = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(bytes); try std.testing.expectEqualStrings("Xabcd", bytes); } test "regular: insert pending space commits literal space or returns normal" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open "); try client.handleInput("i"); try client.handleInput("a"); try client.handleInput(" "); try client.handleInput("b"); try client.handleInput(" "); try client.handleInput(" "); try client.handleInput("c"); const bytes = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(bytes); try std.testing.expectEqualStrings("a b c", bytes); try client.handleInput(" "); try client.handleInput("n"); try std.testing.expectEqualStrings("normal", client.modeName()); } test "regular: normal core editing delete yank paste undo redo" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open abc\ndef"); try client.handleInput("d"); try client.handleInput("d"); const after_delete = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_delete); try std.testing.expectEqualStrings("def", after_delete); try client.handleInput("p"); const after_paste = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_paste); try std.testing.expectEqualStrings("abc\ndef", after_paste); try client.handleInput("u"); const after_undo = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_undo); try std.testing.expectEqualStrings("def", after_undo); try client.handleInput("U"); const after_redo = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_redo); try std.testing.expectEqualStrings("abc\ndef", after_redo); } test "regular: normal change replace open lines and movement enter insert" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open abc\ndef"); try client.handleInput("r"); try client.handleInput("Z"); const after_replace = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_replace); try std.testing.expectEqualStrings("Zbc\ndef", after_replace); try client.handleInput("c"); try client.handleInput("c"); try std.testing.expectEqualStrings("insert", client.modeName()); try client.handleInput("X"); try client.handleInput(" "); try client.handleInput("n"); const after_change = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_change); try std.testing.expectEqualStrings("X\ndef", after_change); try client.handleInput("o"); try client.handleInput("Y"); try client.handleInput(" "); try client.handleInput("n"); const after_open = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_open); try std.testing.expectEqualStrings("X\nY\ndef", after_open); try client.handleInput("0"); try client.handleInput("l"); try client.handleInput("k"); try client.handleInput("w"); try client.handleInput("e"); try client.handleInput("b"); } test "adversarial: replace rejects invalid utf8 and undo preserves content" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open safe"); const bad = [_]u8{ 0xc3, 0x28 }; try client.handleInput("r"); try client.handleInput(&bad); const after_bad_replace = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_bad_replace); try std.testing.expectEqualStrings("safe", after_bad_replace); try client.handleInput("i"); try client.handleInput("!"); try client.handleInput(" "); try client.handleInput("n"); try client.handleInput("u"); const after_undo = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_undo); try std.testing.expectEqualStrings("safe", after_undo); } fn isObjectSeparator(byte: u8) bool { return std.ascii.isWhitespace(byte) or std.mem.indexOfScalar(u8, "(){}[]<>.,;:+-*/=\"'`", byte) != null; } fn previousBoundaryLocal(bytes: []const u8, cursor: usize) usize { return session_mod.boundaryAtOrBefore(bytes, if (cursor == 0) 0 else cursor - 1); } fn nextBoundaryLocal(bytes: []const u8, cursor: usize) usize { var at = cursor + 1; while (at < bytes.len and (bytes[at] & 0b1100_0000) == 0b1000_0000) at += 1; return @min(at, bytes.len); } fn lineIndent(bytes: []const u8) usize { var count: usize = 0; while (count < bytes.len and (bytes[count] == ' ' or bytes[count] == '\t')) count += 1; return count; } fn lineContentLen(bytes: []const u8) usize { for (bytes) |byte| if (!std.ascii.isWhitespace(byte)) return bytes.len; return 0; } fn trimRange(bytes: []const u8, range: ObjectRange) ObjectRange { var start = range.start; var end = range.end; while (start < end and std.ascii.isWhitespace(bytes[start])) start += 1; while (end > start and std.ascii.isWhitespace(bytes[end - 1])) end -= 1; return .{ .start = start, .end = end }; } fn isPairSelector(byte: u8) bool { return std.mem.indexOfScalar(u8, "({[\"'`", byte) != null; } fn pairChars(selector: u8) struct { open: u8, close: u8, quote: bool } { return switch (selector) { '(' => .{ .open = '(', .close = ')', .quote = false }, '{' => .{ .open = '{', .close = '}', .quote = false }, '[' => .{ .open = '[', .close = ']', .quote = false }, '\"' => .{ .open = '\"', .close = '\"', .quote = true }, '\'' => .{ .open = '\'', .close = '\'', .quote = true }, '`' => .{ .open = '`', .close = '`', .quote = true }, else => .{ .open = '(', .close = ')', .quote = false }, }; } fn findPairForSelector(bytes: []const u8, cursor: usize, selector: u8) ?PairRange { const chars = pairChars(selector); if (chars.quote) return findQuotePair(bytes, cursor, chars.open); var depth: usize = 0; var open: ?usize = null; var i: usize = @min(cursor, bytes.len); while (i > 0) { i -= 1; if (bytes[i] == chars.close) depth += 1 else if (bytes[i] == chars.open) { if (depth == 0) { open = i; break; } depth -= 1; } } const found_open = open orelse return null; depth = 0; i = found_open; while (i < bytes.len) : (i += 1) { if (bytes[i] == chars.open) depth += 1 else if (bytes[i] == chars.close) { depth -= 1; if (depth == 0) return .{ .open = found_open, .close = i }; } } return null; } fn findQuotePair(bytes: []const u8, cursor: usize, quote: u8) ?PairRange { var open: ?usize = null; var i: usize = 0; while (i < bytes.len) : (i += 1) { if (bytes[i] != quote or (i > 0 and bytes[i - 1] == '\\')) continue; if (open == null) open = i else { const start = open.?; if (cursor >= start and cursor <= i) return .{ .open = start, .close = i }; open = null; } } return null; } fn findPairAtOrNear(bytes: []const u8, cursor: usize) ?PairRange { const selectors = "({[\"'`"; if (cursor < bytes.len and isPairSelector(bytes[cursor])) { if (findPairForSelector(bytes, cursor + 1, bytes[cursor])) |pair| return pair; } if (cursor > 0 and isPairSelector(bytes[cursor - 1])) { if (findPairForSelector(bytes, cursor, bytes[cursor - 1])) |pair| return pair; } for (selectors) |selector| { if (findPairForSelector(bytes, cursor, selector)) |pair| return pair; } return null; } test "regular: match rail jumps and selects delimiter pairs" { var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 10 }); defer client.deinit(); try client.handleTraceLine("open fn main() { call(one, two); }"); try client.handleInput("w"); try client.handleInput("w"); try client.handleInput("w"); try client.handleInput("m"); try client.handleInput("("); var snap = try client.session.snapshot(); try std.testing.expect(snap.cursor_byte > 7); try client.handleInput("m"); try client.handleInput("s"); snap = try client.session.snapshot(); try std.testing.expect(snap.selection != null); try std.testing.expectEqualStrings("one, two", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); try client.handleInput("m"); try client.handleInput("a"); snap = try client.session.snapshot(); try std.testing.expectEqualStrings("(one, two)", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); } test "regular: select mode object grammar selects word line indent parameter and form" { var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 }); defer client.deinit(); try client.handleTraceLine("open fn main() {\n alpha(beta, gamma);\n next();\n}\n"); try client.handleInput("j"); try client.handleInput("w"); try client.handleInput("s"); try client.handleInput("w"); var snap = try client.session.snapshot(); try std.testing.expectEqualStrings("alpha", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); try client.handleInput("n"); try client.handleInput("s"); try client.handleInput("l"); snap = try client.session.snapshot(); try std.testing.expectEqualStrings(" alpha(beta, gamma);\n", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); try client.handleInput("n"); try client.handleInput("s"); try client.handleInput("i"); snap = try client.session.snapshot(); try std.testing.expectEqualStrings(" alpha(beta, gamma);\n next();", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); var param_client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 }); defer param_client.deinit(); try param_client.handleTraceLine("open call(beta, gamma)"); try param_client.handleTraceLine("right"); try param_client.handleTraceLine("right"); try param_client.handleTraceLine("right"); try param_client.handleTraceLine("right"); try param_client.handleTraceLine("right"); try param_client.handleTraceLine("right"); try param_client.handleInput("s"); try param_client.handleInput("p"); var param_snap = try param_client.session.snapshot(); try std.testing.expectEqualStrings("beta", param_snap.bytes[param_snap.selection.?.anchor..param_snap.selection.?.cursor]); var form_client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 12 }); defer form_client.deinit(); try form_client.handleTraceLine("open { alpha(); }"); try form_client.handleTraceLine("right"); try form_client.handleTraceLine("right"); try form_client.handleInput("s"); try form_client.handleInput("f"); var form_snap = try form_client.session.snapshot(); try std.testing.expect(std.mem.startsWith(u8, form_snap.bytes[form_snap.selection.?.anchor..form_snap.selection.?.cursor], "{")); } test "regular: delete change yank compose with shared object ranges" { var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 }); defer client.deinit(); try client.handleTraceLine("open alpha beta\n one\n two\nend"); try client.handleInput("d"); try client.handleInput("w"); const after_delete_word = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(after_delete_word); try std.testing.expectEqualStrings(" beta\n one\n two\nend", after_delete_word); try client.handleInput("p"); const pasted = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(pasted); try std.testing.expectEqualStrings("alpha beta\n one\n two\nend", pasted); try client.handleInput("j"); try client.handleInput("c"); try client.handleInput("i"); try client.handleInput("X"); try client.handleInput(" "); try client.handleInput("n"); const changed = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(changed); try std.testing.expectEqualStrings("alpha beta\nX\nend", changed); } test "adversarial: unmatched delimiter reports without corrupting buffer" { var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open call(one"); try client.handleInput("m"); try client.handleInput("("); const bytes = try client.snapshotBytesAlloc(std.testing.allocator); defer std.testing.allocator.free(bytes); try std.testing.expectEqualStrings("call(one", bytes); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "unknown normal key") != null or std.mem.indexOf(u8, frame, "NoMatch") != null); } test "regular: quote matching ignores escaped quote" { var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open say(\"a\\\"b\")"); try client.handleTraceLine("right"); try client.handleTraceLine("right"); try client.handleTraceLine("right"); try client.handleTraceLine("right"); try client.handleTraceLine("right"); try client.handleInput("m"); try client.handleInput("\""); const snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte); } test "regular: slash search selects first match and navigates wrap" { var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open alpha beta alpha"); try client.handleInput("/"); try std.testing.expectEqualStrings("prompt", client.modeName()); try client.handleInput("a"); try client.handleInput("l"); try client.handleInput("p"); try client.handleInput("h"); try client.handleInput("a"); try client.handleInput("\n"); var snap = try client.session.snapshot(); try std.testing.expect(snap.selection != null); try std.testing.expectEqual(@as(usize, 0), snap.selection.?.anchor); try std.testing.expectEqualStrings("alpha", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); try client.handleInput("n"); snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 11), snap.selection.?.anchor); try client.handleInput("n"); snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 0), snap.selection.?.anchor); try client.handleInput("N"); snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 11), snap.selection.?.anchor); } test "regular: space s f opens current-file search prompt without losing save path" { var client = try Client.init(std.testing.allocator, .{ .width = 60, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open one two one"); try client.handleInput(" "); try client.handleInput("s"); try client.handleInput("f"); try std.testing.expectEqualStrings("prompt", client.modeName()); try client.handleInput("t"); try client.handleInput("w"); try client.handleInput("o"); try client.handleInput("\n"); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("two", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); } test "adversarial: search no match cancel utf8 and long line remain safe" { var client = try Client.init(std.testing.allocator, .{ .width = 30, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open short élan veryveryveryverylongline élan"); try client.handleInput("/"); try client.handleInput("z"); try client.handleInput("\n"); const no_match_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(no_match_frame); try std.testing.expect(std.mem.indexOf(u8, no_match_frame, "no search matches") != null); try client.handleInput("/"); try client.handleInput("x"); try client.handleInput("\x1b"); const cancel_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(cancel_frame); try std.testing.expect(std.mem.indexOf(u8, cancel_frame, "search cancelled") != null); try client.handleInput("/"); try client.handleInput("é"); try client.handleInput("l"); try client.handleInput("a"); try client.handleInput("n"); try client.handleInput("\n"); var snap = try client.session.snapshot(); try std.testing.expectEqualStrings("élan", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]); try client.handleInput("n"); snap = try client.session.snapshot(); try std.testing.expect(snap.cursor_byte > 20); } test "regular: file picker panel opens selected repo file" { var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 }); defer client.deinit(); try client.handleTraceLine("repo_file src/main.zig=const main = 1;"); try client.handleTraceLine("repo_file src/lib.zig=const lib = 2;"); try client.handleInput(" "); try client.handleInput("f"); var snap = try client.session.snapshot(); try std.testing.expect(snap.active_panel_title != null); try client.handleInput("o"); snap = try client.session.snapshot(); try std.testing.expectEqualStrings("const main = 1;", snap.bytes); try std.testing.expect(snap.active_panel_title == null); } test "regular: project search panel opens selected result at match" { var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 }); defer client.deinit(); try client.handleTraceLine("repo_file src/main.zig=alpha\nneedle here\n"); try client.handleTraceLine("repo_file src/lib.zig=other\n"); try client.handleInput(" "); try client.handleInput("s"); try client.handleInput("p"); try std.testing.expectEqualStrings("prompt", client.modeName()); try client.handleInput("n"); try client.handleInput("e"); try client.handleInput("e"); try client.handleInput("d"); try client.handleInput("l"); try client.handleInput("e"); try client.handleInput("\n"); var snap = try client.session.snapshot(); try std.testing.expect(snap.active_panel_title != null); try client.handleInput("o"); snap = try client.session.snapshot(); try std.testing.expectEqualStrings("alpha\nneedle here\n", snap.bytes); try std.testing.expect(snap.cursor_byte >= 6); } test "adversarial: file and project panels handle ignored empty missing and close" { var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 10 }); defer client.deinit(); try client.handleTraceLine("repo_file src/main.zig=visible"); try client.handleInput(" "); try client.handleInput("f"); const picker_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(picker_frame); try std.testing.expect(std.mem.indexOf(u8, picker_frame, "src/main.zig") != null); try client.handleInput("q"); const snap = try client.session.snapshot(); try std.testing.expect(snap.active_panel_title == null); try client.handleInput(" "); try client.handleInput("s"); try client.handleInput("p"); try client.handleInput("missing"); try client.handleInput("\n"); const no_match_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(no_match_frame); try std.testing.expect(std.mem.indexOf(u8, no_match_frame, "no project matches") != null); } test "regular: language rail shows compact hover signature and expandable hover" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 }); defer client.deinit(); try client.handleTraceLine("lsp_hover_fixture zls|add(lhs, rhs)|Very long documentation that should be compact first and expanded only on request."); try client.handleTraceLine("lsp_signature_fixture zls|add(lhs: i32, rhs: i32) active=rhs"); try client.handleInput(" "); try client.handleInput("l"); try client.handleInput("h"); const hover_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(hover_frame); try std.testing.expect(std.mem.indexOf(u8, hover_frame, "[zls] add(lhs, rhs)") != null); try assertLinesFit(hover_frame, 48); try client.handleInput(" "); try client.handleInput("l"); try client.handleInput("s"); const signature_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(signature_frame); try std.testing.expect(std.mem.indexOf(u8, signature_frame, "[zls] add(lhs: i32, rhs: i32) active=rhs") != null); try client.handleInput(" "); try client.handleInput("l"); try client.handleInput("o"); const expanded_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(expanded_frame); try std.testing.expect(std.mem.indexOf(u8, expanded_frame, "hover_provider:zls") != null); try std.testing.expect(std.mem.indexOf(u8, expanded_frame, "Very_long_documentation") != null); } test "adversarial: missing hover and signature providers are visible" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 }); defer client.deinit(); try client.handleInput(" "); try client.handleInput("l"); try client.handleInput("h"); const hover_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(hover_frame); try std.testing.expect(std.mem.indexOf(u8, hover_frame, "NoProvider:hover") != null); try client.handleInput(" "); try client.handleInput("l"); try client.handleInput("s"); const signature_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(signature_frame); try std.testing.expect(std.mem.indexOf(u8, signature_frame, "NoProvider:signature") != null); } test "regular: go rail parameter movement uses keymap paths" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open call(alpha, beta, gamma)"); try client.handleTraceLine("right"); try client.handleTraceLine("right"); try client.handleTraceLine("right"); try client.handleTraceLine("right"); try client.handleTraceLine("right"); try client.handleInput("g"); try client.handleInput("a"); var snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 12), snap.cursor_byte); try client.handleInput("g"); try client.handleInput("A"); snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 5), snap.cursor_byte); } test "regular: diagnostics panel preserves providers filters and navigates" { var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open abcdef\nsecond"); try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|1|3|error|bad_token"); try client.handleTraceLine("diagnostic_fixture lint|1|main.zig|1|3|warning|style_duplicate"); try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|8|13|info|second_line"); try client.handleInput(" "); try client.handleInput("d"); const panel_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(panel_frame); try std.testing.expect(std.mem.indexOf(u8, panel_frame, "diag:fresh:zls:error:1-3:main.zig:bad_token") != null); try std.testing.expect(std.mem.indexOf(u8, panel_frame, "diag:fresh:lint:warning:1-3:main.zig:style_duplicate") != null); try client.handleInput("f"); const filtered_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(filtered_frame); try std.testing.expect(std.mem.indexOf(u8, filtered_frame, "diag:fresh:zls:error:1-3") != null); try std.testing.expect(std.mem.indexOf(u8, filtered_frame, "lint:warning") == null); try client.handleInput(" "); try client.handleInput("d"); try client.handleInput("n"); var snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 1), snap.cursor_byte); try client.handleInput(" "); try client.handleInput("d"); try client.handleInput("n"); snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte); } test "regular: go rail jumps to first diagnostic" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open abcdef"); try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|2|5|error|range"); try client.handleInput("g"); try client.handleInput("e"); const snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 2), snap.cursor_byte); } test "regular: select diagnostic object range with s d" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open abcdef"); try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|2|5|error|range"); try client.handleInput("s"); try client.handleInput("d"); const snap = try client.session.snapshot(); try std.testing.expect(snap.selection != null); try std.testing.expectEqual(@as(usize, 2), snap.selection.?.anchor); try std.testing.expectEqual(@as(usize, 5), snap.selection.?.cursor); } test "adversarial: diagnostics stale empty duplicate and invalid ranges are safe" { var empty_client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 8 }); defer empty_client.deinit(); try empty_client.handleTraceLine("open abcdef"); try empty_client.handleInput(" "); try empty_client.handleInput("d"); const empty_frame = try empty_client.render(std.testing.allocator); defer std.testing.allocator.free(empty_frame); try std.testing.expect(std.mem.indexOf(u8, empty_frame, "diag:empty") != null); var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 8 }); defer client.deinit(); try client.handleTraceLine("open abcdef"); try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|1|3|error|first"); try client.handleTraceLine("diagnostic_fixture lint|1|main.zig|1|3|warning|same_range_other_provider"); try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|20|25|hint|invalid_range"); try client.handleInput("i"); try client.handleInput("X"); try client.handleTraceLine("key escape"); try client.handleInput(" "); try client.handleInput("d"); const stale_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(stale_frame); try std.testing.expect(std.mem.indexOf(u8, stale_frame, "diag:stale:zls:error:1-3") != null); try std.testing.expect(std.mem.indexOf(u8, stale_frame, "diag:stale:lint:warning:1-3") != null); try client.handleTraceLine("diagnostic_filter all"); try client.handleInput("n"); const invalid_nav_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(invalid_nav_frame); try std.testing.expect(std.mem.indexOf(u8, invalid_nav_frame, "StaleDiagnostic") != null or std.mem.indexOf(u8, invalid_nav_frame, "InvalidDiagnosticRange") != null); } test "regular: diagnostic row model sanitizes and parses" { const row = try diagnostics_mod.rowAlloc(std.testing.allocator, "zls", 3, "main.zig", 1, 4, .err, "bad: token"); defer std.testing.allocator.free(row); try std.testing.expect(std.mem.indexOf(u8, row, "invalid") != null); const parsed = try diagnostics_mod.parseRow(row); try std.testing.expectEqual(@as(u64, 3), parsed.version); try std.testing.expectEqual(diagnostics_mod.Severity.err, parsed.severity); const panel = try diagnostics_mod.panelRowAlloc(std.testing.allocator, row, 4); 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); } test "regular: language format applies provider edit and undo restores" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open dirty"); try client.handleTraceLine("language_edit zls|format|1|0|5|clean"); try client.handleInput(" "); try client.handleInput("l"); try client.handleInput("f"); var snap = try client.session.snapshot(); try std.testing.expectEqualStrings("clean", snap.bytes); try client.handleInput("u"); snap = try client.session.snapshot(); try std.testing.expectEqualStrings("dirty", snap.bytes); } test "regular: format on save and one-shot save without format" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("language_default_format zls"); try client.handleTraceLine("language_edit zls|format|1|0|3|ABC"); try client.handleTraceLine("save"); try std.testing.expectEqualStrings("ABC", try client.saved()); var ambiguous = try Client.init(std.testing.allocator, .{ .width = 80, .height = 6 }); defer ambiguous.deinit(); try ambiguous.handleTraceLine("open abc"); try ambiguous.handleTraceLine("language_edit zls|format|1|0|3|ZLS"); try ambiguous.handleTraceLine("language_edit prettier|format|1|0|3|PRETTY"); try ambiguous.handleTraceLine("save"); try std.testing.expectError(Error.NothingSaved, ambiguous.saved()); const ambiguous_frame = try ambiguous.render(std.testing.allocator); defer std.testing.allocator.free(ambiguous_frame); try std.testing.expect(std.mem.indexOf(u8, ambiguous_frame, "action:format:provider:zls") != null); var skip = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer skip.deinit(); try skip.handleTraceLine("open abc"); try skip.handleTraceLine("language_default_format zls"); try skip.handleTraceLine("language_edit zls|format|1|0|3|ABC"); try skip.handleTraceLine("save_without_format"); try std.testing.expectEqualStrings("abc", try skip.saved()); } test "regular: provider picker applies selected formatter" { var client = try Client.init(std.testing.allocator, .{ .width = 80, .height = 7 }); defer client.deinit(); try client.handleTraceLine("open abc"); try client.handleTraceLine("language_edit zls|format|1|0|3|ZLS"); try client.handleTraceLine("language_edit prettier|format|1|0|3|PRETTY"); try client.handleInput(" "); try client.handleInput("l"); try client.handleInput("f"); const picker_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(picker_frame); try std.testing.expect(std.mem.indexOf(u8, picker_frame, "action:format:provider:zls") != null); try std.testing.expect(std.mem.indexOf(u8, picker_frame, "action:format:provider:prettier") != null); try client.handleTraceLine("list_filter prettier"); try client.handleTraceLine("key enter"); const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("PRETTY", snap.bytes); } test "regular: organize imports and code action show source before mutation" { var client = try Client.init(std.testing.allocator, .{ .width = 88, .height = 7 }); defer client.deinit(); try client.handleTraceLine("open imports\nbody"); try client.handleTraceLine("language_edit zls|organize_imports|1|0|7|sorted"); try client.handleInput(" "); try client.handleInput("l"); try client.handleInput("o"); var snap = try client.session.snapshot(); try std.testing.expectEqualStrings("sorted\nbody", snap.bytes); var action = try Client.init(std.testing.allocator, .{ .width = 88, .height = 7 }); defer action.deinit(); try action.handleTraceLine("open abcdef"); try action.handleTraceLine("language_edit zls|code_action|1|1|4|XYZ"); try action.handleInput(" "); try action.handleInput("l"); try action.handleInput("a"); const frame = try action.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "action:code_action:provider:zls:scope:1-4") != null); try action.handleTraceLine("key enter"); snap = try action.session.snapshot(); try std.testing.expectEqualStrings("aXYZef", snap.bytes); } test "adversarial: language mutations report missing stale and overlapping providers" { var missing = try Client.init(std.testing.allocator, .{ .width = 80, .height = 6 }); defer missing.deinit(); try missing.handleTraceLine("open abc"); try missing.handleInput(" "); try missing.handleInput("l"); try missing.handleInput("f"); const missing_frame = try missing.render(std.testing.allocator); defer std.testing.allocator.free(missing_frame); try std.testing.expect(std.mem.indexOf(u8, missing_frame, "NoProvider:format") != null); var stale = try Client.init(std.testing.allocator, .{ .width = 80, .height = 6 }); defer stale.deinit(); try stale.handleTraceLine("open abc"); try stale.handleTraceLine("language_edit zls|format|0|0|3|ABC"); try stale.handleInput(" "); try stale.handleInput("l"); try stale.handleInput("f"); const stale_frame = try stale.render(std.testing.allocator); defer std.testing.allocator.free(stale_frame); try std.testing.expect(std.mem.indexOf(u8, stale_frame, "StaleEdit") != null); var overlap = try Client.init(std.testing.allocator, .{ .width = 80, .height = 6 }); defer overlap.deinit(); try overlap.handleTraceLine("open abcdef"); try overlap.handleTraceLine("language_edit zls|format|1|0|3|AAA"); try overlap.handleTraceLine("language_edit zls|format|1|2|5|BBB"); try overlap.handleInput(" "); try overlap.handleInput("l"); try overlap.handleInput("f"); const overlap_frame = try overlap.render(std.testing.allocator); defer std.testing.allocator.free(overlap_frame); try std.testing.expect(std.mem.indexOf(u8, overlap_frame, "OverlappingEdit") != null); } test "regular: provider edit rows parse source scope and replacement" { const row = try lsp_mod.editRowAlloc(std.testing.allocator, "zls", .code_action, 7, 2, 5, "XYZ"); defer std.testing.allocator.free(row); const edit = try lsp_mod.parseProviderEditRow(row); try std.testing.expectEqualStrings("zls", edit.provider); try std.testing.expectEqual(lsp_mod.ProviderCapability.code_action, edit.capability); try std.testing.expectEqual(@as(u64, 7), edit.version); try std.testing.expectEqualStrings("XYZ", edit.replacement); const action_row = try lsp_mod.actionPanelRowAlloc(std.testing.allocator, row); defer std.testing.allocator.free(action_row); try std.testing.expectEqualStrings("zls", try lsp_mod.providerFromActionPanelRow(action_row)); } test "regular: physical arrows home end page and escape match editor movement" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 5 }); defer client.deinit(); try client.handleTraceLine("open one\ntwo\nthree\nfour\nfive"); var snap = try client.session.snapshot(); const start = snap.cursor_byte; try client.handleTraceLine("key page_down"); snap = try client.session.snapshot(); try std.testing.expect(snap.cursor_byte > start); try client.handleTraceLine("key home"); snap = try client.session.snapshot(); const line_start = snap.cursor_byte; try client.handleTraceLine("key end"); snap = try client.session.snapshot(); try std.testing.expect(snap.cursor_byte >= line_start); try client.handleTraceLine("key page_up"); snap = try client.session.snapshot(); try std.testing.expect(snap.cursor_byte <= line_start); try client.handleInput("i"); try client.handleInput("x"); try client.handleTraceLine("key escape"); try client.handleInput("u"); snap = try client.session.snapshot(); try std.testing.expect(std.mem.indexOf(u8, snap.bytes, "onxe") == null); } test "regular: physical digit count matches repeated arrow movement" { var physical = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer physical.deinit(); try physical.handleTraceLine("open a\nb\nc\nd"); try physical.handleInput("3"); try physical.handleTraceLine("key down"); const physical_snap = try physical.session.snapshot(); var repeated = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer repeated.deinit(); try repeated.handleTraceLine("open a\nb\nc\nd"); try repeated.handleTraceLine("key down"); try repeated.handleTraceLine("key down"); try repeated.handleTraceLine("key down"); const repeated_snap = try repeated.session.snapshot(); try std.testing.expectEqual(repeated_snap.cursor_byte, physical_snap.cursor_byte); } test "regular: physical percent and mobile match rail jump to same pair" { var physical = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer physical.deinit(); try physical.handleTraceLine("open (abc)"); try physical.handleInput("%"); var snap = try physical.session.snapshot(); try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte); var mobile = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer mobile.deinit(); try mobile.handleTraceLine("open (abc)"); try mobile.handleInput("m"); try mobile.handleInput("m"); snap = try mobile.session.snapshot(); try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte); } test "regular: insert/select modes accept arrows while Space n remains mobile escape" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open ab\ncd"); try client.handleInput("i"); try client.handleTraceLine("key end"); try client.handleInput("X"); try client.handleInput(" "); try client.handleInput("n"); var snap = try client.session.snapshot(); try std.testing.expectEqualStrings("abX\ncd", snap.bytes); try client.handleInput("s"); try client.handleTraceLine("key up"); try client.handleTraceLine("key home"); try client.handleInput("n"); snap = try client.session.snapshot(); try std.testing.expect(snap.selection != null or snap.cursor_byte == 0); } test "regular: panels accept physical page keys and enter without breaking arrows" { var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); defer client.deinit(); try client.handleTraceLine("list_open files a.zig|b.zig|c.zig|d.zig|e.zig"); try client.handleTraceLine("key page_down"); const after_down = try client.session.activeListItem(); try std.testing.expect(!std.mem.eql(u8, after_down, "a.zig")); try client.handleTraceLine("key up"); const after_up = try client.session.activeListItem(); try std.testing.expect(!std.mem.eql(u8, after_down, after_up)); } test "adversarial: unknown physical key names stay rejected and leader help stays narrow" { var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 }); defer client.deinit(); try std.testing.expectError(Error.UnknownTraceEvent, client.handleTraceLine("key f1")); try client.handleInput(" "); try std.testing.expect(std.mem.indexOf(u8, client.leader.status(), "digits/%/Esc ok") != null); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try assertLinesFit(frame, 72); } test "regular: editor render has colorscheme gutter cursor and status chrome" { var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open alpha\nbeta"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[38;2;") != null); try std.testing.expect(std.mem.indexOf(u8, frame, " 1│") != null); try std.testing.expect(std.mem.indexOf(u8, frame, " 2│") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "▌") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[38;2;18;22;30;48;2;126;231;135m") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "^\n") == null); try assertLinesFit(frame, 40); } test "regular: empty editor still shows first line gutter and cursor marker" { var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 5 }); defer client.deinit(); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, " 1│") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "▌") != null); try std.testing.expect(std.mem.indexOf(u8, frame, " ·") != null); try assertLinesFit(frame, 24); } test "regular: local launcher insert mode lets ordinary text with spaces insert" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open "); client.enterInsertMode(); for ("what is up") |byte| { const key_bytes = [_]u8{byte}; try client.handleInput(&key_bytes); } const snap = try client.session.snapshot(); try std.testing.expectEqualStrings("what is up", snap.bytes); } test "regular: normal mode Space leader still works after insert escape" { var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 }); defer client.deinit(); try client.handleTraceLine("open "); client.enterInsertMode(); for ("abc") |byte| { const key_bytes = [_]u8{byte}; try client.handleInput(&key_bytes); } try client.handleTraceLine("key escape"); try client.handleInput(" "); try client.handleInput("w"); try std.testing.expectEqualStrings("abc", try client.saved()); }