Merge pull request #2912 from lightpanda-io/agent-repl-fixes

agent: multi-line slash commands and --save .js extension
This commit is contained in:
Karl Seguin
2026-07-11 07:26:29 +08:00
committed by GitHub
3 changed files with 50 additions and 8 deletions

View File

@@ -705,7 +705,15 @@ fn runRepl(self: *Agent) void {
continue :repl;
}
const slash_split: ?Schema.Split = Schema.parseSlashCommand(trimmed);
// A slash command whose `'''…'''` body is still open continues on the
// following lines until the block closes (the multi-line /extract
// form). Ctrl-D on the continuation prompt abandons the command.
const command_text: []const u8 = if (trimmed[0] == '/' and Schema.hasUnclosedTripleQuote(trimmed))
Terminal.readContinuation(aa, trimmed) orelse continue :repl
else
trimmed;
const slash_split: ?Schema.Split = Schema.parseSlashCommand(command_text);
if (slash_split) |split| {
if (SlashCommand.findMeta(split.name)) |meta| {
if (self.handleMeta(aa, meta, split.rest)) break :repl;
@@ -714,7 +722,7 @@ fn runRepl(self: *Agent) void {
}
var diag: Schema.Diag = .{};
const cmd = Command.parseDiag(aa, line, &diag) catch |err| switch (err) {
const cmd = Command.parseDiag(aa, command_text, &diag) catch |err| switch (err) {
error.NotASlashCommand => {
if (self.ai_client == null) {
self.terminal.printError("Basic REPL (LLM disabled) accepts only commands. Try /help, or " ++ llm_setup_hint ++ " to enable natural-language prompts.", .{});
@@ -746,7 +754,7 @@ fn runRepl(self: *Agent) void {
if (!result.is_error) {
self.recordSaveCommand(navigationGoto(aa, tc.tool, tc.args) orelse cmd);
}
self.recordSlashToolCall(trimmed, tc.name(), tc.args, result) catch |err| {
self.recordSlashToolCall(command_text, tc.name(), tc.args, result) catch |err| {
self.terminal.printWarning("LLM conversation out of sync (/{s}: {s}); next prompt may not see this action", .{ tc.name(), @errorName(err) });
};
},
@@ -1178,7 +1186,8 @@ fn synthesizeSave(self: *Agent, arena: std.mem.Allocator, filename: ?[]const u8,
fn saveOneShot(self: *Agent) void {
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();
self.synthesizeSaveTo(arena.allocator(), self.one_shot_save.?, .replace, self.one_shot_task.?);
const path = save.ensureJsExtension(arena.allocator(), self.one_shot_save.?) catch self.one_shot_save.?;
self.synthesizeSaveTo(arena.allocator(), path, .replace, self.one_shot_task.?);
}
/// LLM synthesis + write for an already-resolved destination. Shared by the

View File

@@ -995,6 +995,23 @@ pub fn freeLine(line: []const u8) void {
c.ic_free(@ptrCast(@constCast(line.ptr)));
}
const continuation_prompt = "... ";
/// Read the follow-up lines of an input whose `'''…'''` body is still open,
/// joined with newlines until the block closes. Null abandons the input
/// (EOF on the continuation prompt, or out of memory).
pub fn readContinuation(arena: std.mem.Allocator, first: []const u8) ?[]const u8 {
var buf: std.ArrayList(u8) = .empty;
buf.appendSlice(arena, first) catch return null;
while (Schema.hasUnclosedTripleQuote(buf.items)) {
const next = readLine(continuation_prompt) orelse return null;
defer freeLine(next);
buf.append(arena, '\n') catch return null;
buf.appendSlice(arena, next) catch return null;
}
return buf.items;
}
// Free-function `lp.log.sink` can't capture self; the agent sets this
// before installing the sink and clears it on teardown.
var active_for_log: ?*Terminal = null;

View File

@@ -53,11 +53,19 @@ pub fn parseCommand(arena: std.mem.Allocator, rest: []const u8) !Command {
after = trimmed[tok_end..];
}
if (name.len == 0) return error.EmptyFilename;
if (!std.mem.endsWith(u8, name, ".js")) {
name = try std.mem.concat(arena, u8, &.{ name, ".js" });
}
const prompt = std.mem.trim(u8, after, &std.ascii.whitespace);
return .{ .filename = name, .prompt = if (prompt.len == 0) null else prompt };
return .{
.filename = try ensureJsExtension(arena, name),
.prompt = if (prompt.len == 0) null else prompt,
};
}
/// `name` with `.js` appended when missing; may alias `name` or be
/// arena-allocated. Shared by `/save` parsing and the one-shot `--save` flag
/// so the two paths can't drift.
pub fn ensureJsExtension(arena: std.mem.Allocator, name: []const u8) ![]const u8 {
if (std.mem.endsWith(u8, name, ".js")) return name;
return std.mem.concat(arena, u8, &.{ name, ".js" });
}
pub fn randomFilename(arena: std.mem.Allocator) ![]const u8 {
@@ -119,6 +127,14 @@ test "parseCommand: filename only" {
try std.testing.expect(r.prompt == null);
}
test "ensureJsExtension appends only when missing" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
try std.testing.expectEqualStrings("out.js", try ensureJsExtension(arena.allocator(), "out"));
try std.testing.expectEqualStrings("out.js", try ensureJsExtension(arena.allocator(), "out.js"));
try std.testing.expectEqualStrings("a/b.thing.js", try ensureJsExtension(arena.allocator(), "a/b.thing"));
}
test "parseCommand: filename and prompt" {
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();