From 264dedb47c70223d064548094bc56232272de8ec Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 13:34:40 +0200 Subject: [PATCH] Add compact language assistance rails --- src/leader.zig | 42 ++++++++++ src/lsp.zig | 46 +++++++++++ src/tui.zig | 203 ++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 280 insertions(+), 11 deletions(-) diff --git a/src/leader.zig b/src/leader.zig index 18e9ad0..2bbb6cb 100644 --- a/src/leader.zig +++ b/src/leader.zig @@ -13,6 +13,7 @@ test { pub const Feature = enum { search, panel_close, + lsp, }; pub const Action = union(enum) { @@ -24,6 +25,9 @@ pub const Action = union(enum) { file_picker, search_file, search_project, + hover, + signature, + expand_hover, repeat_rail, restore_last, not_built: Feature, @@ -41,6 +45,7 @@ const Mode = enum { rail, symbol_rail, search_rail, + language_rail, open_prompt, }; @@ -65,6 +70,7 @@ pub const Leader = struct { .rail => return self.handleRail(event), .symbol_rail => return self.handleSymbolRail(event), .search_rail => return self.handleSearchRail(event), + .language_rail => return self.handleLanguageRail(event), .open_prompt => return self.handleOpenPrompt(event), } } @@ -76,6 +82,7 @@ pub const Leader = struct { .rail => "leader: w save q quit o open p symbols s search r repeat x close", .symbol_rail => symbol_mod.rail_status, .search_rail => "search: f current file p project s symbols", + .language_rail => "language: h hover s signature o open hover", .open_prompt => "open: type path, Enter opens, Esc cancels", }; } @@ -120,6 +127,10 @@ pub const Leader = struct { self.mode = .search_rail; return .none; } + if (std.mem.eql(u8, text, "l")) { + self.mode = .language_rail; + return .none; + } if (std.mem.eql(u8, text, "q")) { self.mode = .idle; return .quit; @@ -174,6 +185,37 @@ pub const Leader = struct { } } + fn handleLanguageRail(self: *Leader, event: input.Event) Action { + self.message = null; + switch (event) { + .text => |text| { + self.mode = .idle; + if (std.mem.eql(u8, text, "h")) return .hover; + if (std.mem.eql(u8, text, "s")) return .signature; + if (std.mem.eql(u8, text, "o")) return .expand_hover; + self.message = "language action is not built in this profile yet"; + return .{ .not_built = .lsp }; + }, + .key => |key| switch (key) { + .escape, .backspace => { + self.mode = .idle; + self.message = "language cancelled"; + return .none; + }, + else => { + self.mode = .idle; + self.message = "unknown language key"; + return .none; + }, + }, + .unknown => { + self.mode = .idle; + self.message = "unknown language key"; + return .none; + }, + } + } + fn handleSearchRail(self: *Leader, event: input.Event) Action { self.message = null; switch (event) { diff --git a/src/lsp.zig b/src/lsp.zig index 1a069c7..f141799 100644 --- a/src/lsp.zig +++ b/src/lsp.zig @@ -163,6 +163,52 @@ pub fn missingProviderRowAlloc(allocator: std.mem.Allocator, capability: Provide return std.fmt.allocPrint(allocator, "provider:missing:{s}", .{@tagName(capability)}); } +pub fn compactHoverCardAlloc(allocator: std.mem.Allocator, row: []const u8, width: usize) ![]u8 { + const provider = providerIdFromRow(row) catch "unknown"; + const payload = providerPayloadFromRow(row) catch row; + const prefix = "lsp:hover:"; + const body = if (std.mem.startsWith(u8, payload, prefix)) payload[prefix.len..] else payload; + const split = std.mem.indexOfScalar(u8, body, '|') orelse body.len; + const headline = body[0..split]; + const docs = if (split < body.len) body[split + 1 ..] else ""; + const budget = if (width > provider.len + headline.len + 8) width - provider.len - headline.len - 8 else 0; + const excerpt_len = @min(docs.len, budget); + return std.fmt.allocPrint(allocator, "[{s}] {s}{s}{s}", .{ provider, headline, if (excerpt_len > 0) " — " else "", docs[0..excerpt_len] }); +} + +pub fn expandedHoverRowsAlloc(allocator: std.mem.Allocator, row: []const u8, width: usize) ![][]const u8 { + const provider = providerIdFromRow(row) catch "unknown"; + const payload = providerPayloadFromRow(row) catch row; + const prefix = "lsp:hover:"; + const body = if (std.mem.startsWith(u8, payload, prefix)) payload[prefix.len..] else payload; + var rows = std.ArrayList([]const u8).empty; + errdefer { + for (rows.items) |item| allocator.free(item); + rows.deinit(allocator); + } + try rows.append(allocator, try std.fmt.allocPrint(allocator, "hover_provider:{s}", .{provider})); + var start: usize = 0; + const wrap = @max(width, 8); + while (start < body.len) { + const end = @min(start + wrap, body.len); + const wrapped = try allocator.dupe(u8, body[start..end]); + for (wrapped) |*byte| { + if (byte.* <= 0x20 or byte.* == '|') byte.* = '_'; + } + try rows.append(allocator, wrapped); + start = end; + } + return rows.toOwnedSlice(allocator); +} + +pub fn compactSignatureCardAlloc(allocator: std.mem.Allocator, row: []const u8) ![]u8 { + const provider = providerIdFromRow(row) catch "unknown"; + const payload = providerPayloadFromRow(row) catch row; + const prefix = "lsp:signature:"; + const body = if (std.mem.startsWith(u8, payload, prefix)) payload[prefix.len..] else payload; + return std.fmt.allocPrint(allocator, "[{s}] {s}", .{ provider, body }); +} + pub fn providerPickerRowsAlloc(allocator: std.mem.Allocator, capability: ProviderCapability, providers: []const Provider) ![][]const u8 { var rows = std.ArrayList([]const u8).empty; errdefer { diff --git a/src/tui.zig b/src/tui.zig index 34cf237..f597715 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -136,6 +136,7 @@ pub const Client = struct { viewport: Viewport, saved_bytes: ?[]u8 = null, message: ?[]const u8 = null, + owned_message: ?[]u8 = null, quit: bool = false, mode: EditorMode = .normal, prefix: PrefixRail = .none, @@ -149,6 +150,8 @@ pub const Client = struct { 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, undo_stack: std.ArrayList(EditSnapshot) = .empty, redo_stack: std.ArrayList(EditSnapshot) = .empty, yank_bytes: ?[]u8 = null, @@ -171,11 +174,14 @@ pub const Client = struct { pub fn deinit(self: *Client) void { 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.freeSnapshotStack(&self.undo_stack); self.freeSnapshotStack(&self.redo_stack); self.repo.deinit(); @@ -195,6 +201,8 @@ pub const Client = struct { 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.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); @@ -432,6 +440,14 @@ pub const Client = struct { } 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; } @@ -463,6 +479,12 @@ pub const Client = struct { }; } + 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"; @@ -475,6 +497,23 @@ pub const Client = struct { 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 addRepoFile(self: *Client, payload: []const u8) !void { const separator = std.mem.indexOfScalar(u8, payload, '=') orelse { self.message = "diagnostic:unsupported_file:invalid_repo_file_payload"; @@ -943,7 +982,7 @@ pub const Client = struct { .match => self.applyMatchRail(event) catch |err| { self.message = @errorName(err); }, - .go => self.applyKnownRailOrMessage(event, "go rail ready"), + .go => try self.applyGoRail(event), .repeat => self.applyKnownRailOrMessage(event, "repeat rail ready"), .delete => try self.applyDeleteRail(event), .change => try self.applyChangeRail(event), @@ -1019,16 +1058,8 @@ pub const Client = struct { self.message = "empty list"; return; } - var joined = std.ArrayList(u8).empty; - defer joined.deinit(self.allocator); - try joined.appendSlice(self.allocator, "list_open "); - try joined.appendSlice(self.allocator, title); - try joined.append(self.allocator, ' '); - for (rows, 0..) |row, i| { - if (i != 0) try joined.append(self.allocator, '|'); - try joined.appendSlice(self.allocator, row); - } - try self.applyProtocol(joined.items); + try self.session.openListPanel(title, rows); + self.message = null; self.panel_context = context; } @@ -1114,6 +1145,37 @@ pub const Client = struct { 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 openSearchPrompt(self: *Client) void { self.search_prompt_active = true; self.search_prompt.clearRetainingCapacity(); @@ -1191,6 +1253,52 @@ pub const Client = struct { 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); + 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); @@ -1564,6 +1672,9 @@ pub const Client = struct { .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(), .repeat_rail => self.openPrefix(.repeat), .restore_last => self.restoreLastRail(), .not_built => {}, @@ -3539,3 +3650,73 @@ test "adversarial: file and project panels handle ignored empty missing and clos 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); +}