Add compact language assistance rails

This commit is contained in:
slhx agent
2026-06-21 13:34:40 +02:00
parent 7e639dd87f
commit 264dedb47c
3 changed files with 280 additions and 11 deletions
+42
View File
@@ -13,6 +13,7 @@ test {
pub const Feature = enum { pub const Feature = enum {
search, search,
panel_close, panel_close,
lsp,
}; };
pub const Action = union(enum) { pub const Action = union(enum) {
@@ -24,6 +25,9 @@ pub const Action = union(enum) {
file_picker, file_picker,
search_file, search_file,
search_project, search_project,
hover,
signature,
expand_hover,
repeat_rail, repeat_rail,
restore_last, restore_last,
not_built: Feature, not_built: Feature,
@@ -41,6 +45,7 @@ const Mode = enum {
rail, rail,
symbol_rail, symbol_rail,
search_rail, search_rail,
language_rail,
open_prompt, open_prompt,
}; };
@@ -65,6 +70,7 @@ pub const Leader = struct {
.rail => return self.handleRail(event), .rail => return self.handleRail(event),
.symbol_rail => return self.handleSymbolRail(event), .symbol_rail => return self.handleSymbolRail(event),
.search_rail => return self.handleSearchRail(event), .search_rail => return self.handleSearchRail(event),
.language_rail => return self.handleLanguageRail(event),
.open_prompt => return self.handleOpenPrompt(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", .rail => "leader: w save q quit o open p symbols s search r repeat x close",
.symbol_rail => symbol_mod.rail_status, .symbol_rail => symbol_mod.rail_status,
.search_rail => "search: f current file p project s symbols", .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", .open_prompt => "open: type path, Enter opens, Esc cancels",
}; };
} }
@@ -120,6 +127,10 @@ pub const Leader = struct {
self.mode = .search_rail; self.mode = .search_rail;
return .none; return .none;
} }
if (std.mem.eql(u8, text, "l")) {
self.mode = .language_rail;
return .none;
}
if (std.mem.eql(u8, text, "q")) { if (std.mem.eql(u8, text, "q")) {
self.mode = .idle; self.mode = .idle;
return .quit; 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 { fn handleSearchRail(self: *Leader, event: input.Event) Action {
self.message = null; self.message = null;
switch (event) { switch (event) {
+46
View File
@@ -163,6 +163,52 @@ pub fn missingProviderRowAlloc(allocator: std.mem.Allocator, capability: Provide
return std.fmt.allocPrint(allocator, "provider:missing:{s}", .{@tagName(capability)}); 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 { pub fn providerPickerRowsAlloc(allocator: std.mem.Allocator, capability: ProviderCapability, providers: []const Provider) ![][]const u8 {
var rows = std.ArrayList([]const u8).empty; var rows = std.ArrayList([]const u8).empty;
errdefer { errdefer {
+192 -11
View File
@@ -136,6 +136,7 @@ pub const Client = struct {
viewport: Viewport, viewport: Viewport,
saved_bytes: ?[]u8 = null, saved_bytes: ?[]u8 = null,
message: ?[]const u8 = null, message: ?[]const u8 = null,
owned_message: ?[]u8 = null,
quit: bool = false, quit: bool = false,
mode: EditorMode = .normal, mode: EditorMode = .normal,
prefix: PrefixRail = .none, prefix: PrefixRail = .none,
@@ -149,6 +150,8 @@ pub const Client = struct {
project_search_prompt_active: bool = false, project_search_prompt_active: bool = false,
project_search_prompt: std.ArrayList(u8) = .empty, project_search_prompt: std.ArrayList(u8) = .empty,
panel_context: PanelContext = .none, panel_context: PanelContext = .none,
hover_rows: std.ArrayList([]u8) = .empty,
signature_rows: std.ArrayList([]u8) = .empty,
undo_stack: std.ArrayList(EditSnapshot) = .empty, undo_stack: std.ArrayList(EditSnapshot) = .empty,
redo_stack: std.ArrayList(EditSnapshot) = .empty, redo_stack: std.ArrayList(EditSnapshot) = .empty,
yank_bytes: ?[]u8 = null, yank_bytes: ?[]u8 = null,
@@ -171,11 +174,14 @@ pub const Client = struct {
pub fn deinit(self: *Client) void { pub fn deinit(self: *Client) void {
if (self.saved_bytes) |bytes| self.allocator.free(bytes); 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); if (self.yank_bytes) |bytes| self.allocator.free(bytes);
self.search_prompt.deinit(self.allocator); self.search_prompt.deinit(self.allocator);
self.project_search_prompt.deinit(self.allocator); self.project_search_prompt.deinit(self.allocator);
self.allocator.free(self.search_query); self.allocator.free(self.search_query);
self.search_matches.deinit(self.allocator); self.search_matches.deinit(self.allocator);
self.freeOwnedClientRows(&self.hover_rows);
self.freeOwnedClientRows(&self.signature_rows);
self.freeSnapshotStack(&self.undo_stack); self.freeSnapshotStack(&self.undo_stack);
self.freeSnapshotStack(&self.redo_stack); self.freeSnapshotStack(&self.redo_stack);
self.repo.deinit(); 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, "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_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, "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")) 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_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")) return self.openRepoList("tree", true, false);
@@ -432,6 +440,14 @@ pub const Client = struct {
} }
pub fn setStatusMessage(self: *Client, message: []const u8) void { 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; 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 { pub fn addRepoFileContent(self: *Client, path: []const u8, file_bytes: []const u8) !void {
if (file_bytes.len > diagnostics_mod.max_file_bytes) { if (file_bytes.len > diagnostics_mod.max_file_bytes) {
self.message = "diagnostic:file_too_large"; self.message = "diagnostic:file_too_large";
@@ -475,6 +497,23 @@ pub const Client = struct {
self.message = null; 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 { fn addRepoFile(self: *Client, payload: []const u8) !void {
const separator = std.mem.indexOfScalar(u8, payload, '=') orelse { const separator = std.mem.indexOfScalar(u8, payload, '=') orelse {
self.message = "diagnostic:unsupported_file:invalid_repo_file_payload"; self.message = "diagnostic:unsupported_file:invalid_repo_file_payload";
@@ -943,7 +982,7 @@ pub const Client = struct {
.match => self.applyMatchRail(event) catch |err| { .match => self.applyMatchRail(event) catch |err| {
self.message = @errorName(err); self.message = @errorName(err);
}, },
.go => self.applyKnownRailOrMessage(event, "go rail ready"), .go => try self.applyGoRail(event),
.repeat => self.applyKnownRailOrMessage(event, "repeat rail ready"), .repeat => self.applyKnownRailOrMessage(event, "repeat rail ready"),
.delete => try self.applyDeleteRail(event), .delete => try self.applyDeleteRail(event),
.change => try self.applyChangeRail(event), .change => try self.applyChangeRail(event),
@@ -1019,16 +1058,8 @@ pub const Client = struct {
self.message = "empty list"; self.message = "empty list";
return; return;
} }
var joined = std.ArrayList(u8).empty; try self.session.openListPanel(title, rows);
defer joined.deinit(self.allocator); self.message = null;
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);
self.panel_context = context; self.panel_context = context;
} }
@@ -1114,6 +1145,37 @@ pub const Client = struct {
self.panel_context = .project_search; 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 { fn openSearchPrompt(self: *Client) void {
self.search_prompt_active = true; self.search_prompt_active = true;
self.search_prompt.clearRetainingCapacity(); self.search_prompt.clearRetainingCapacity();
@@ -1191,6 +1253,52 @@ pub const Client = struct {
try self.gotoSearchMatch(); 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 { fn applyMatchRail(self: *Client, event: input.Event) !void {
const text = eventText(event) orelse return self.unknownPrefixOrInput("normal"); 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, "m")) return self.jumpToMatch(null);
@@ -1564,6 +1672,9 @@ pub const Client = struct {
.file_picker => try self.openFilePickerPanel(), .file_picker => try self.openFilePickerPanel(),
.search_file => self.openSearchPrompt(), .search_file => self.openSearchPrompt(),
.search_project => self.openProjectSearchPrompt(), .search_project => self.openProjectSearchPrompt(),
.hover => try self.showCompactHover(),
.signature => try self.showCompactSignature(),
.expand_hover => try self.openExpandedHover(),
.repeat_rail => self.openPrefix(.repeat), .repeat_rail => self.openPrefix(.repeat),
.restore_last => self.restoreLastRail(), .restore_last => self.restoreLastRail(),
.not_built => {}, .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); defer std.testing.allocator.free(no_match_frame);
try std.testing.expect(std.mem.indexOf(u8, no_match_frame, "no project matches") != null); 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);
}