From 0f686381dd2f9cc5ceca90b8f51b28205b4c55a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 10 Jul 2026 11:34:53 +0200 Subject: [PATCH 1/2] agent: append .js to --save destinations like /save does The one-shot --save flag bypassed save.parseCommand, so '--save test' wrote a file literally named 'test' while the REPL's /save appended the extension. Share one ensureJsExtension helper so the paths can't drift. --- src/agent/Agent.zig | 3 ++- src/agent/save.zig | 24 ++++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index 207d84699..ea52e7435 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -1165,7 +1165,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/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(); From 03f6577efa4f82f93859446178bc645908e06582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Fri, 10 Jul 2026 11:35:06 +0200 Subject: [PATCH 2/2] agent: continue slash commands across lines inside '''...''' blocks The docs promise triple-quoted values that span multiple lines, but the REPL dispatched every physical line immediately, so the multi-line /extract form died with 'unterminated quote' on its first line. A slash command that leaves a '''...''' block open now keeps reading lines at a continuation prompt until the block closes; Ctrl-D abandons it. --- src/agent/Agent.zig | 14 +++++++++++--- src/agent/Terminal.zig | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index ea52e7435..31d1be4cd 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -692,7 +692,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; @@ -701,7 +709,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.", .{}); @@ -733,7 +741,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) }); }; }, 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;