diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index b923aab14..dc02488d1 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -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 diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index a81716bca..c260fc447 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -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; diff --git a/src/agent/save.zig b/src/agent/save.zig index ab3d23822..09c2c8551 100644 --- a/src/agent/save.zig +++ b/src/agent/save.zig @@ -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();