diff --git a/README.md b/README.md index 806f24d0c..9a880cba3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ brew install lightpanda-io/browser/lightpanda Latest nightly from Arch Linux User Repository: ```console -yay -S lightpanda-nightly-bi +yay -S lightpanda-nightly-bin ``` **Download from the nightly builds** @@ -200,6 +200,28 @@ Add to your MCP configuration: } ``` +#### HTTP transport and independent sessions + +For serving several agents from one process, start the MCP server over HTTP +instead of stdio by giving it a port (add `--host x.x.x.x` to specify the +interface to listen on): + +```bash +lightpanda mcp --port 9223 +``` + +Clients POST JSON-RPC to `http://host:9223/mcp`. Each connection is routed to +its own **browsing session** — its own page, cookies and memory — so agents no +longer clobber each other's page: + +- A client that `initialize`s without an `Mcp-Session-Id` header is assigned a + fresh session; the id comes back in the response's `Mcp-Session-Id` header. + Send it on subsequent requests to stay on that session (**isolation**). +- Two agents that send the **same** `Mcp-Session-Id` share one browsing context + (**sharing** — e.g. a workflow where several agents work the same page). +- The `session_new`, `session_list` and `session_close` tools manage sessions + explicitly. Sending `DELETE /mcp` with an `Mcp-Session-Id` closes that session. + [Read full documentation](https://lightpanda.io/docs/open-source/guides/mcp-server) A skill is available in [lightpanda-io/agent-skill](https://github.com/lightpanda-io/agent-skill). diff --git a/build.zig b/build.zig index c0dd98445..26f678679 100644 --- a/build.zig +++ b/build.zig @@ -176,6 +176,38 @@ pub fn build(b: *Build) !void { run_step.dependOn(&run_cmd.step); } + { + // skills generator + const exe = b.addExecutable(.{ + .name = "lightpanda-skills", + .use_llvm = true, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main_skills.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "lightpanda", .module = lightpanda_module }, + }, + }), + }); + + const exe_check = b.addLibrary(.{ + .name = "skills_check", + .root_module = exe.root_module, + }); + check.dependOn(&exe_check.step); + + const run_cmd = b.addRunArtifact(exe); + const out_dir = run_cmd.addOutputDirectoryArg("skills"); + const install = b.addInstallDirectory(.{ + .source_dir = out_dir, + .install_dir = .prefix, + .install_subdir = "skills", + }); + const skills_step = b.step("skills", "Generate LLM skill docs (zig-out/skills//SKILL.md)"); + skills_step.dependOn(&install.step); + } + { // test const tests = b.addTest(.{ diff --git a/build.zig.zon b/build.zig.zon index 92f960358..64aff55d1 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -35,8 +35,8 @@ .hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#e58635146733157eb4936388b21a071fcabfa687", - .hash = "zenai-0.0.0-iOY_VIf0BADH7PvSOlnVf564krXnAlns_1IIhh9ZDdwz", + .url = "git+https://github.com/lightpanda-io/zenai.git#1df180de89ef72535d973af7854bf24dc8ab94a7", + .hash = "zenai-0.0.0-iOY_VPyeBQDnDr5NOUWDK7HWpFTJR8JXZ5UYcwNGEMOu", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/ArenaPool.zig b/src/ArenaPool.zig index f6b5e85f1..b9f090373 100644 --- a/src/ArenaPool.zig +++ b/src/ArenaPool.zig @@ -111,25 +111,27 @@ pub fn deinit(self: *ArenaPool) void { // - Pass a BucketSize (.tiny, .small, .medium, .large) for explicit bucket selection // - Pass a usize for automatic bucket selection based on expected size pub fn acquire(self: *ArenaPool, size_or_bucket: anytype, debug: []const u8) !Allocator { - const bucket = blk: { + const bucket_size: BucketSize = blk: { const T = @TypeOf(size_or_bucket); if (T == BucketSize or T == @TypeOf(.enum_literal)) { - break :blk switch (@as(BucketSize, size_or_bucket)) { - .tiny => &self.tiny, - .small => &self.small, - .medium => &self.medium, - .large => &self.large, - }; + break :blk @as(BucketSize, size_or_bucket); } if (T == usize or T == comptime_int) { - if (size_or_bucket <= self.tiny.retain_bytes) break :blk &self.tiny; - if (size_or_bucket <= self.small.retain_bytes) break :blk &self.small; - if (size_or_bucket <= self.medium.retain_bytes) break :blk &self.medium; - break :blk &self.large; + if (size_or_bucket <= self.tiny.retain_bytes) break :blk .tiny; + if (size_or_bucket <= self.small.retain_bytes) break :blk .small; + if (size_or_bucket <= self.medium.retain_bytes) break :blk .medium; + break :blk .large; } @compileError("acquire expects BucketSize or usize, got " ++ @typeName(T)); }; + const bucket = switch (bucket_size) { + .tiny => &self.tiny, + .small => &self.small, + .medium => &self.medium, + .large => &self.large, + }; + self.mutex.lock(); defer self.mutex.unlock(); @@ -144,9 +146,12 @@ pub fn acquire(self: *ArenaPool, size_or_bucket: anytype, debug: []const u8) !Al } gop.value_ptr.* += 1; } + lp.metrics.arena_hit.incr(bucket_size); return entry.arena.allocator(); } + lp.metrics.arena_miss.incr(bucket_size); + const entry = try self.entry_pool.create(); entry.* = .{ .next = null, diff --git a/src/Config.zig b/src/Config.zig index 726f95b03..ff66a5cf8 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -203,6 +203,7 @@ const Commands = cli.Builder(.{ .{ .name = "cdp_max_message_size", .type = u32, .default = 1024 * 1024 }, // Don't widen this without growing the reader buffer in the HTTP path. .{ .name = "cdp_max_http_message_size", .type = u14, .default = 4096 }, + .{ .name = "disable_metrics", .type = bool }, }, .shared_options = CommonOptions, }, @@ -235,12 +236,15 @@ const Commands = cli.Builder(.{ }, .{ .name = "terminate_ms", .type = ?u32 }, .{ .name = "json", .type = bool }, + .{ .name = "metrics", .type = bool }, }, .shared_options = CommonOptions, }, .{ .name = "mcp", .options = .{ + .{ .name = "port", .type = ?u16 }, + .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, .{ .name = "cdp_port", .type = ?u16 }, }, .shared_options = CommonOptions, @@ -595,6 +599,20 @@ pub fn cdpMaxMessageSize(self: *const Config) u32 { }; } +pub fn metricsEndpointEnabled(self: *const Config) bool { + return switch (self.mode) { + .serve => |opts| !opts.disable_metrics, + else => unreachable, + }; +} + +pub fn dumpMetricsOnExit(self: *const Config) bool { + return switch (self.mode) { + .fetch => |opts| opts.metrics, + else => false, + }; +} + pub fn cdpMaxHTTPMessageSize(self: *const Config) u14 { return switch (self.mode) { .serve => |opts| opts.cdp_max_http_message_size, @@ -707,7 +725,7 @@ pub const HttpHeaders = struct { } }; -pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool) void { +pub fn printUsageAndExit(self: *const Config, allocator: Allocator, help_for: RunMode, success: bool) !void { const exec_name = self.exec_name; const Help = @import("help.zon"); const is_debug = builtin.mode == .Debug; @@ -715,36 +733,86 @@ pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool) const pretty_or_logfmt = if (comptime is_debug) "pretty" else "logfmt"; const comptimePrint = std.fmt.comptimePrint; - switch (help_for) { + const text = switch (help_for) { // Requested help for everything. - .help => { + .help => text: { const template = comptimePrint( \\{s} \\ , .{Help.general}); - std.debug.print(template, .{exec_name}); + break :text try std.fmt.allocPrint(allocator, template, .{exec_name}); }, - inline .fetch, .serve, .mcp, .agent, .run => |tag| { + inline .fetch, .serve, .mcp, .agent, .run => |tag| text: { const template = comptimePrint( \\{s} \\ \\{s} \\ , .{ @field(Help, @tagName(tag)), Help.common_options }); - std.debug.print(template, .{ exec_name, info_or_warn, pretty_or_logfmt }); + break :text try std.fmt.allocPrint(allocator, template, .{ exec_name, info_or_warn, pretty_or_logfmt }); }, - .version => { + .version => text: { const template = Help.version ++ "\n"; - std.debug.print(template, .{exec_name}); + break :text try std.fmt.allocPrint(allocator, template, .{exec_name}); }, - } + }; + defer allocator.free(text); if (success) { + printPaged(allocator, text); return std.process.cleanExit(); } + var stderr = std.fs.File.stderr().writer(&.{}); + stderr.interface.writeAll(text) catch {}; std.process.exit(1); } +fn printPlain(text: []const u8) void { + var stdout = std.fs.File.stdout().writer(&.{}); + stdout.interface.writeAll(text) catch {}; +} + +/// Pages explicitly requested help through $PAGER (fallback: less) when +/// stdout is an interactive terminal; prints plainly otherwise. +fn printPaged(allocator: Allocator, text: []const u8) void { + if (!std.posix.isatty(std.posix.STDOUT_FILENO)) { + return printPlain(text); + } + const term = std.posix.getenv("TERM") orelse ""; + if (term.len == 0 or std.mem.eql(u8, term, "dumb")) { + return printPlain(text); + } + + const pager = std.posix.getenv("PAGER") orelse ""; + const argv: []const []const u8 = if (pager.len > 0) + &.{ "/bin/sh", "-c", pager } + else + &.{ "less", "-FIRX" }; + + var child = std.process.Child.init(argv, allocator); + child.stdin_behavior = .Pipe; + child.stdout_behavior = .Inherit; + child.stderr_behavior = .Inherit; + child.spawn() catch return printPlain(text); + + if (child.stdin) |stdin| { + var writer = stdin.writer(&.{}); + // A write error here is the pager exiting early (user quit, or the + // command failed) — wait() below decides which. + writer.interface.writeAll(text) catch {}; + stdin.close(); + child.stdin = null; + } + + const term_result = child.wait() catch return printPlain(text); + const clean_exit = term_result == .Exited and term_result.Exited == 0; + // Quitting the pager early is still exit 0; a non-zero exit means the + // pager failed (e.g. $PAGER not found) and the help was never shown. + if (!clean_exit) { + printPlain(text); + } +} + pub fn parseArgs(allocator: Allocator) !Config { const exec_name, var command = try Commands.parse(allocator); if (command == .serve and command.serve.timeout != null) { diff --git a/src/Metrics.zig b/src/Metrics.zig new file mode 100644 index 000000000..e65148ee7 --- /dev/null +++ b/src/Metrics.zig @@ -0,0 +1,299 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const lp = @import("lightpanda"); + +const Metrics = @This(); + +cdp_connections: Counter = .{}, +cdp_connection_limit: Counter = .{}, +cdp_active_connections: Gauge = .{}, +cdp_commands: Counter = .{}, +cdp_unknown_commands: Counter = .{}, +js_heap_limits: Counter = .{}, +script_errors: Counter = .{}, +arena_hit: CounterEnum("size", @import("ArenaPool.zig").BucketSize) = .{}, +arena_miss: CounterEnum("size", @import("ArenaPool.zig").BucketSize) = .{}, +navigate: CounterEnum("type", @import("telemetry/telemetry.zig").Event.Navigate.Context) = .{}, +js_heap_size_bytes: Histogram(&.{ + 4 * 1024 * 1024, + 8 * 1024 * 1024, + 16 * 1024 * 1024, + 32 * 1024 * 1024, + 64 * 1024 * 1024, + 128 * 1024 * 1024, + 256 * 1024 * 1024, + 512 * 1024 * 1024, +}) = .{}, +http_requests: CounterEnum("mode", enum { sync, async }) = .{}, +http_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{}, +http_error: CounterEnum("reason", @import("network/http.zig").ErrorReason) = .{}, +http_cache: CounterEnum("result", enum { hit, miss, revalidated }) = .{}, +http_redirects: Counter = .{}, +http_duration_ms: Histogram(&.{ + 5, + 10, + 25, + 50, + 100, + 250, + 500, + 1000, + 2500, + 5000, + 10000, +}) = .{}, +http_response_size_bytes: Histogram(&.{ + 32 * 1024, + 64 * 1024, + 128 * 1024, + 256 * 1024, + 512 * 1024, + 1024 * 1024, + 2 * 1024 * 1024, + 4 * 1024 * 1024, +}) = .{}, +robots_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{}, +robots_access: CounterEnum("result", enum { allow, deny }) = .{}, + +// Emitted as each metric's "# HELP" line. A field without an entry is a +// compile error. +const help = .{ + .cdp_connections = "CDP websocket connections accepted", + .cdp_connection_limit = "Connections rejected because --cdp-max-connections was reached", + .cdp_active_connections = "Currently connected CDP clients", + .cdp_commands = "CDP commands dispatched", + .cdp_unknown_commands = "CDP commands rejected for an unknown domain or method", + .js_heap_limits = "Pages terminated for reaching the V8 heap limit", + .script_errors = "Scripts that failed to evaluate, e.g. an uncaught top-level exception", + .arena_hit = "Arena pool acquisitions served from the free list", + .arena_miss = "Arena pool acquisitions that had to allocate a new arena", + .navigate = "Navigations by initiating frame type", + .js_heap_size_bytes = "V8 heap physical size, sampled when a page is closed", + .http_requests = "HTTP requests submitted, by dispatch mode (excludes internal requests like robots.txt)", + .http_status = "Final HTTP response status category (redirects counted once, at the final hop)", + .http_error = "HTTP requests that failed before delivering a response, by cause", + .http_cache = "HTTP cache lookups by outcome", + .http_redirects = "HTTP redirect hops followed", + .http_duration_ms = "HTTP request wall-clock duration in milliseconds", + .http_response_size_bytes = "HTTP response body size in bytes", + .robots_status = "robots.txt response status", + .robots_access = "robots.txt result", +}; + +pub fn write(self: *const Metrics, writer: *std.Io.Writer) void { + self._write(writer) catch |err| { + lp.log.err(.app, "metrics write", .{ .err = err }); + }; +} + +fn _write(self: *const Metrics, writer: *std.Io.Writer) !void { + try writer.print( + "# HELP build_info Lightpanda build information\n" ++ + "# TYPE build_info gauge\nbuild_info{{version=\"{s}\"}} 1\n", + .{lp.build_config.version}, + ); + inline for (@typeInfo(Metrics).@"struct".fields) |f| { + try @field(self, f.name).write(f.name, @field(help, f.name), writer); + } +} + +const Counter = struct { + count: usize = 0, + + pub fn incr(self: *Counter) void { + self.incrBy(1); + } + + pub fn incrBy(self: *Counter, c: usize) void { + _ = @atomicRmw(usize, &self.count, .Add, c, .monotonic); + } + + fn write(self: *const Counter, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void { + try writer.writeAll("# HELP " ++ name ++ "_total " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ "_total counter\n"); + try writer.print(name ++ "_total {d}\n", .{self.get()}); + } + + fn get(self: *const Counter) usize { + return @atomicLoad(usize, &self.count, .monotonic); + } +}; + +const Gauge = struct { + value: isize = 0, + + pub fn incr(self: *Gauge) void { + _ = @atomicRmw(isize, &self.value, .Add, 1, .monotonic); + } + + pub fn decr(self: *Gauge) void { + _ = @atomicRmw(isize, &self.value, .Sub, 1, .monotonic); + } + + fn write(self: *const Gauge, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void { + try writer.writeAll("# HELP " ++ name ++ " " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ " gauge\n"); + try writer.print(name ++ " {d}\n", .{@atomicLoad(isize, &self.value, .monotonic)}); + } +}; + +fn CounterEnum(comptime label: []const u8, comptime T: type) type { + return struct { + counts: std.enums.EnumArray(T, Counter) = .initFill(.{}), + + pub const Tag = T; + pub const label_name = label; + + const Self = @This(); + + pub fn incr(self: *Self, tag: T) void { + self.incrBy(tag, 1); + } + + pub fn incrBy(self: *Self, tag: T, c: usize) void { + self.counts.getPtr(tag).incrBy(c); + } + + fn write(self: *const Self, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void { + try writer.writeAll("# HELP " ++ name ++ "_total " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ "_total counter\n"); + inline for (comptime std.enums.values(Tag)) |tag| { + try writer.print(name ++ "_total{{" ++ label ++ "=\"" ++ @tagName(tag) ++ "\"}} {d}\n", .{self.counts.getPtrConst(tag).get()}); + } + } + }; +} + +fn Histogram(comptime upper_bounds: []const usize) type { + comptime { + std.debug.assert(upper_bounds.len > 0); + for (upper_bounds[0 .. upper_bounds.len - 1], upper_bounds[1..]) |a, b| { + std.debug.assert(a < b); + } + } + + return struct { + sum: usize = 0, + count: usize = 0, + buckets: [upper_bounds.len]usize = @splat(0), + + const Self = @This(); + + pub fn observe(self: *Self, value: usize) void { + _ = @atomicRmw(usize, &self.count, .Add, 1, .monotonic); + _ = @atomicRmw(usize, &self.sum, .Add, value, .monotonic); + inline for (upper_bounds, 0..) |upper, i| { + if (value <= upper) { + _ = @atomicRmw(usize, &self.buckets[i], .Add, 1, .monotonic); + return; + } + } + // falls in the implicit +Inf bucket, which is derived from count + } + + fn write(self: *const Self, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void { + try writer.writeAll("# HELP " ++ name ++ " " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ " histogram\n"); + + // le buckets are cumulative + var sum: usize = 0; + inline for (upper_bounds, 0..) |upper, i| { + sum += @atomicLoad(usize, &self.buckets[i], .monotonic); + try writer.print(name ++ "_bucket{{le=\"" ++ std.fmt.comptimePrint("{d}", .{upper}) ++ "\"}} {d}\n", .{sum}); + } + + const count = @atomicLoad(usize, &self.count, .monotonic); + try writer.print( + name ++ "_bucket{{le=\"+Inf\"}} {d}\n" ++ name ++ "_sum {d}\n" ++ name ++ "_count {d}\n", + .{ count, @atomicLoad(usize, &self.sum, .monotonic), count }, + ); + } + }; +} + +const testing = @import("testing.zig"); + +test "Metrics: Counter" { + var w = std.Io.Writer.Allocating.init(testing.allocator); + defer w.deinit(); + + var c = Counter{}; + c.incr(); + c.incrBy(3); + try c.write("stat", "counts stats", &w.writer); + try testing.expectEqual( + \\# HELP stat_total counts stats + \\# TYPE stat_total counter + \\stat_total 4 + \\ + , w.written()); +} + +test "Metrics: Gauge" { + var w = std.Io.Writer.Allocating.init(testing.allocator); + defer w.deinit(); + + var g = Gauge{}; + g.incr(); + g.incr(); + g.decr(); + try g.write("stat", "counts stats", &w.writer); + try testing.expectEqual( + \\# HELP stat counts stats + \\# TYPE stat gauge + \\stat 1 + \\ + , w.written()); +} + +test "Metrics: CounterEnum" { + var w = std.Io.Writer.Allocating.init(testing.allocator); + defer w.deinit(); + + var c = CounterEnum("color", enum { red, blue }){}; + c.incr(.blue); + c.incrBy(.blue, 2); + try c.write("stat", "counts stats", &w.writer); + try testing.expectEqual( + \\# HELP stat_total counts stats + \\# TYPE stat_total counter + \\stat_total{color="red"} 0 + \\stat_total{color="blue"} 3 + \\ + , w.written()); +} + +test "Metrics: Histogram" { + var w = std.Io.Writer.Allocating.init(testing.allocator); + defer w.deinit(); + + var h = Histogram(&.{ 10, 100 }){}; + h.observe(5); + h.observe(10); // le is inclusive + h.observe(50); + h.observe(200); // +Inf only + try h.write("stat", "counts stats", &w.writer); + try testing.expectEqual( + \\# HELP stat counts stats + \\# TYPE stat histogram + \\stat_bucket{le="10"} 2 + \\stat_bucket{le="100"} 3 + \\stat_bucket{le="+Inf"} 4 + \\stat_sum 265 + \\stat_count 4 + \\ + , w.written()); +} diff --git a/src/Notification.zig b/src/Notification.zig index 4a96b2382..9368b27b6 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -21,8 +21,7 @@ const lp = @import("lightpanda"); const js = @import("browser/js/js.zig"); const Frame = @import("browser/Frame.zig"); -const Transfer = @import("browser/HttpClient.zig").Transfer; -const Response = @import("browser/HttpClient.zig").Response; +const Transfer = @import("network/HttpClient.zig").Transfer; const log = lp.log; const Execution = js.Execution; @@ -76,6 +75,7 @@ const EventListeners = struct { frame_created: List = .{}, frame_navigate: List = .{}, frame_navigated: List = .{}, + frame_navigated_within_document: List = .{}, frame_navigate_failed: List = .{}, frame_network_idle: List = .{}, frame_network_almost_idle: List = .{}, @@ -105,6 +105,7 @@ const Events = union(enum) { frame_created: *Frame, frame_navigate: *const FrameNavigate, frame_navigated: *const FrameNavigated, + frame_navigated_within_document: *const FrameNavigatedWithinDocument, frame_navigate_failed: *const FrameNavigateFailed, frame_network_idle: *const FrameNetworkIdle, frame_network_almost_idle: *const FrameNetworkAlmostIdle, @@ -155,6 +156,23 @@ pub const FrameNavigated = struct { opts: Frame.NavigatedOpts, }; +// A same-document navigation (History pushState/replaceState, fragment +// changes, or history traversal). The document and its execution context +// stay alive — only the URL changes — so CDP must emit +// Page.navigatedWithinDocument WITHOUT clearing the execution context or +// sending DOM.documentUpdated. +pub const FrameNavigatedWithinDocument = struct { + frame_id: u32, + url: [:0]const u8, + navigation_type: NavigationType = .other, + + pub const NavigationType = enum { + fragment, + historyApi, + other, + }; +}; + // A root navigation that failed before commit (DNS failure, connection // refused, malformed response, ...). Dispatched so CDP can answer the // pending Page.navigate command with an errorText instead of leaving the @@ -224,7 +242,6 @@ pub const ResponseData = struct { pub const ResponseHeaderDone = struct { transfer: *Transfer, - response: *const Response, }; pub const RequestDone = struct { diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 4adb987f8..f67affde4 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -139,7 +139,7 @@ fn walk( if (html_el.getHidden()) return; } } else if (node.is(CData.Text)) |text_node| { - const text = text_node.getWholeText(); + const text = text_node.ownData(); if (isAllWhitespace(text)) { return; } @@ -414,7 +414,7 @@ const JsonVisitor = struct { try self.jw.objectField("nodeType"); try self.jw.write(3); try self.jw.objectField("nodeValue"); - try self.jw.write(text_node.getWholeText()); + try self.jw.write(text_node.ownData()); } else { try self.jw.objectField("nodeType"); try self.jw.write(9); @@ -472,7 +472,7 @@ const TextVisitor = struct { name_to_print = n; } } else if (node.is(CData.Text)) |text_node| { - const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n"); + const trimmed = std.mem.trim(u8, text_node.ownData(), " \t\r\n"); if (trimmed.len > 0) { name_to_print = trimmed; } diff --git a/src/Server.zig b/src/Server.zig index 26ba5996a..c5b45474e 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -156,6 +156,7 @@ fn spawnWorker(self: *Server, socket: posix.socket_t) !void { while (current < self.max_connections) { current = self.active_threads.cmpxchgWeak(current, current + 1, .monotonic, .monotonic) orelse break; } else { + lp.metrics.cdp_connection_limit.incr(); return error.MaxThreadsReached; } errdefer _ = self.active_threads.fetchSub(1, .monotonic); @@ -218,6 +219,12 @@ fn handleConnection(self: *Server, socket: posix.socket_t) void { return; } + // only count websocket (i.e. CDP) connections, not HTTP requests like + // /json/version probes or /metrics scrapes + lp.metrics.cdp_connections.incr(); + lp.metrics.cdp_active_connections.incr(); + defer lp.metrics.cdp_active_connections.decr(); + { // Transition from .handshake state to .live // Lock needed even though the main thread hasn't seen this yet because @@ -276,6 +283,7 @@ fn buildJSONVersionResponse(app: *const App, port: u16) ![]const u8 { "\"Browser\": \"Lightpanda/1.0\", " ++ "\"Protocol-Version\": \"1.3\", " ++ "\"User-Agent\": \"Lightpanda/1.0\", " ++ + "\"Lightpanda-Version\": \"" ++ lp.build_config.version ++ "\", " ++ "\"webSocketDebuggerUrl\": \"ws://{s}:{d}/\"" ++ "}}"; const body_len = std.fmt.count(body_format, .{ host, port }); @@ -309,6 +317,7 @@ test "server: buildJSONVersionResponse" { try testing.expect(std.mem.indexOf(u8, res, "\"Browser\": \"Lightpanda/") != null); try testing.expect(std.mem.indexOf(u8, res, "\"Protocol-Version\": \"1.3\"") != null); try testing.expect(std.mem.indexOf(u8, res, "\"User-Agent\": \"Lightpanda/") != null); + try testing.expect(std.mem.indexOf(u8, res, "\"Lightpanda-Version\": \"" ++ lp.build_config.version ++ "\"") != null); try testing.expect(std.mem.indexOf(u8, res, "\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"") != null); } @@ -551,6 +560,18 @@ test "server: get /json/version" { } } +test "server: get /metrics" { + var c = try createTestClient(); + defer c.deinit(); + + const res = try c.httpRequest("GET /metrics HTTP/1.1\r\n\r\n"); + std.debug.print("1\n", .{}); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); + try testing.expect(std.mem.indexOf(u8, res, "Content-Type: text/plain; version=0.0.4") != null); + try testing.expect(std.mem.indexOf(u8, res, "build_info{version=") != null); + try testing.expect(std.mem.indexOf(u8, res, "# TYPE cdp_connections_total counter") != null); +} + fn assertHTTPError( comptime expected_status: u16, comptime expected_body: []const u8, @@ -648,7 +669,7 @@ fn createTestClient() !TestClient { const TestClient = struct { stream: std.net.Stream, - buf: [1024]u8 = undefined, + buf: [8192]u8 = undefined, reader: WS.Reader(false), const WS = @import("network/WS.zig"); @@ -664,10 +685,11 @@ const TestClient = struct { var pos: usize = 0; var total_length: ?usize = null; while (true) { - pos += try self.stream.read(self.buf[pos..]); - if (pos == 0) { - return error.NoMoreData; + const n = try self.stream.read(self.buf[pos..]); + if (n == 0) { + return if (pos == self.buf.len) error.MessageTooLarge else error.NoMoreData; } + pos += n; const response = self.buf[0..pos]; if (total_length == null) { const header_end = std.mem.indexOf(u8, response, "\r\n\r\n") orelse continue; diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index dc02488d1..6666b21b1 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -35,8 +35,10 @@ const App = @import("../App.zig"); const CDPNode = @import("../cdp/Node.zig"); const Conversation = @import("Conversation.zig"); const Terminal = @import("Terminal.zig"); +const ansi = @import("ansi.zig"); const SlashCommand = @import("SlashCommand.zig"); const settings = @import("settings.zig"); +const picker = @import("picker.zig"); const save = @import("save.zig"); const welcome = @import("welcome.zig"); const string = @import("../string.zig"); @@ -75,135 +77,15 @@ const default_system_prompt = browser_tools.driver_guidance ++ \\- If the user asks for account-scoped data (karma, profile, inbox, …) \\ and the page shows you're not signed in, log in proactively (per \\ the Credentials section above) before reporting unavailable. -; + \\ +++ lp.skill.semantics_note; -/// Skill-like documentation for writing Lightpanda agent script -/// Used in the system prompt of the `/save` command. -const script_skill = - \\# Writing Lightpanda agent scripts - \\ - \\Run with: - \\ - \\```console - \\./lightpanda agent script.js - \\``` - \\ - \\## Mental model (get this right first) - \\ - \\The script runs in its **own V8 context** — neither the page nor Node.js: - \\ - \\- `Page` is the only global. `new Page()` makes a page and `await page.goto(url)` navigates it; every other primitive is a **method on that page**: `const page = new Page(); await page.goto(url); page.extract({...}); page.click(sel);`. - \\- No `window`, `document`, DOM, `localStorage` — read pages with `page.extract(...)`, run page-side JS only via `page.evaluate("...")`. - \\- No `require`, `process`, `fs`, npm. Standard ECMAScript built-ins only (`JSON`, `Map`, template literals, …). - \\- `page.goto(...)` is **async — always `await` it**. Page methods are **synchronous**: `const data = page.extract({...})`, never `await page.extract(...)`. The script body runs as an async function, so top-level `await` is allowed. - \\- **Re-navigating reuses the same page**: `await page.goto(url2)` keeps `page` valid and points it at the new URL, discarding the old page — read it before navigating away. Independent URLs don't share a page: make a `new Page()` for each and load them in parallel (fan-out, best practice 2). - \\- Page `evaluate("...")` cannot see script variables — interpolate values into the string. Script code cannot see page variables. - \\- Variables persist across navigations within one run, so cross-page aggregation is plain JS. - \\- **`return ` is the script's output**, printed automatically (objects/arrays as JSON). End with `return page.extract({...});` or `return results;`. A bare trailing expression is NOT printed; neither is `console.log(JSON.stringify(...))`. - \\ - \\## Primitives - \\ - \\`Page` is the only global; `new Page()` makes a page and everything else is a method on it. - \\ - \\| Call | Notes | - \\|------|-------| - \\| `new Page()` | Makes a page object. No navigation yet — call `page.goto(url)` before any other method. Make several to navigate in parallel (fan-out, best practice 2). | - \\| `await page.goto(url[, { timeout }])` | **Async — must be `await`ed.** Navigates the page (re-navigating reuses the same object). Waits for `load`. Default timeout 10000 ms. Rejects on navigation failure; a **timeout does NOT reject** (the page may still be usable). | - \\| `page.close()` | Marks the page done; later method calls on it error. The page is otherwise reclaimed at script end. | - \\| `page.extract(schema)` | The only primitive returning a real JS value (object/array). See schema below. | - \\| `page.evaluate(script[, { url, timeout, save }])` | Page-side JS escape hatch; returns text (JSON for objects/arrays). | - \\| `page.click(sel)` / `page.hover(sel)` | | - \\| `page.fill(sel, value)` / `page.selectOption(sel, value)` | | - \\| `page.setChecked(sel[, checked])` | `checked` defaults to `true`. | - \\| `page.press(sel, key)` / `page.press(null, key)` / `page.press({ key })` | Selector first! `page.press("Enter")` binds "Enter" to `selector` and fails. | - \\| `page.scroll()` / `page.scroll({ x, y })` | | - \\| `page.waitForSelector(sel[, { timeout }])` | `waitFor*` default timeout 5000 ms. | - \\| `page.waitForScript(js[, { timeout }])` | Re-evaluates page JS until truthy. | - \\| `page.waitForState(state[, { timeout }])` | `"load"`, `"domcontentloaded"`, `"networkalmostidle"`, `"networkidle"`, `"done"`. | - \\ - \\Calling convention: leading positionals + optional trailing options object, or one object with everything (`page.waitForSelector("#row", { timeout: 2000 })` ≡ `page.waitForSelector({ selector: "#row", timeout: 2000 })`). A bare option positional (`page.waitForSelector("#row", 2000)`) and a field passed both ways are `invalid arguments`. `null` skips a positional. Arguments must be JSON-serializable. - \\ - \\CSS selectors only — `backendNodeId`s don't exist here. Standard CSS only: no jQuery `:contains()` or Playwright `:has-text()`. - \\ - \\## extract schema - \\ - \\Keys = output field names; values pick what to lift (not a JSON Schema): - \\ - \\```js - \\const { stories } = page.extract({ - \\ stories: [{ - \\ selector: "tr.athing", // one record per match - \\ limit: 5, - \\ fields: { // resolved relative to each match - \\ title: ".titleline > a", // first match's text (null if missing) - \\ url: { selector: ".titleline > a", attr: "href" }, - \\ text: "" // "" = the matched element's own text - \\ } - \\ }] - \\}); - \\``` - \\ - \\- `"sel"` → first match's text; `["sel"]` → all matches' text; `{ selector, attr }` / `[{ selector, attr }]` → attribute(s); `limit: N` caps any array form. - \\- Every value is a string or null — parse numbers in script logic. - \\- Empty arrays are valid results; if **every** field misses, extract throws ("no schema selector matched any element") → your selectors are wrong, not the page empty. - \\- An object schema always returns an object (destructure it); a bare array schema returns the array directly. - \\- No `save:` option in scripts — keep results in variables. - \\ - \\## Best practices - \\ - \\1. **Navigate, settle, read.** After `await page.goto` on a dynamic page (feeds, search results, comment threads), call `page.waitForState("networkidle")` or `page.waitForSelector(...)` before extracting. Most static pages are complete at `load` — don't wait blindly. - \\2. **List-to-detail — fan out independent pages.** Extract the list, then open one page per item and start every navigation together so the detail pages load in parallel instead of one-after-another: - \\ ```js - \\ const list = new Page(); - \\ await list.goto(listUrl); - \\ const { items } = list.extract({ items: [{ selector: "a.row", fields: { url: { attr: "href" } } }] }); - \\ - \\ const pages = items.map(() => new Page()); - \\ await Promise.all(pages.map((p, i) => p.goto(items[i].url))); // all in flight at once - \\ return pages.map((p, i) => ({ ...items[i], ...p.extract({ /* schema */ }) })); - \\ ``` - \\ - Concurrency is bounded by the HTTP connection pool (40 by default, `--http-max-concurrent`); extra navigations queue rather than fail. For long lists, fan out in batches and `page.close()` each page once read so its memory is reclaimed. - \\ - `Promise.all` rejects the whole batch if any `goto` *fails* (a timeout does not reject); use `Promise.allSettled` when partial results are fine. - \\ - Walk **serially** on one page (`for (const it of items) { await page.goto(it.url); … }`) only when the steps depend on each other — each page decides the next URL, or they share login/session state. - \\3. **`evaluate` is a last resort, not a reading tool.** A `querySelectorAll`-and-parse `page.evaluate` block is always wrong: lift the raw strings with `page.extract`, then trim/split/parse them in top-level JS. Reserve `page.evaluate` for behavior that must run inside the page and no builtin covers — and remember its state dies on every navigation/reload, while script variables persist. - \\4. **Credentials via `$LP_*` placeholders** in any string argument (`page.fill("#pw", "$LP_HN_PASSWORD")`). Never inline a real secret; placeholders resolve inside the Lightpanda process. - \\5. **Unique selectors.** Disambiguate with attributes/position: `input[type="submit"][value="login"]`, not `input[type="submit"]`. - \\6. **Let failures fail.** Primitives throw on error and stop the script — only `try/catch` where you have a real fallback (e.g. optional cookie banner: `try { page.click("#accept") } catch {}`). - \\7. **End with `return `.** `console.log` is for debug output only and doesn't JSON-format objects. - \\8. Modern, readable JS: `const`/`let`, `for (const x of xs)`, template literals, destructuring, 2-space indent. - \\9. **Comment the intent of each block.** Put a one-line `//` comment above each logical step describing what it accomplishes toward the goal (not restating the call). One comment per block, not per line — skip self-evident lines: - \\ ```js - \\ // Load the Hacker News front page - \\ const page = new Page(); - \\ await page.goto("https://news.ycombinator.com"); - \\ - \\ // Pull the top 5 stories (title + link) - \\ const { stories } = page.extract({ stories: [{ selector: "tr.athing", limit: 5, fields: { title: ".titleline > a", url: { selector: ".titleline > a", attr: "href" } } }] }); - \\ - \\ // Open each story page in parallel and read its text - \\ const pages = stories.map(() => new Page()); - \\ await Promise.all(pages.map((p, i) => p.goto(stories[i].url))); - \\ ``` - \\ - \\## Common errors - \\ - \\| Error | Cause / fix | - \\|-------|-------------| - \\| `extract is not defined` (or click/fill/…) | These are methods on the page object, not globals → `const page = new Page(); await page.goto(url); page.extract(...)` | - \\| `Page must be called with new` | `Page(...)` called without `new` → `const page = new Page();` | - \\| `page is not navigated or has been closed` | A method on a fresh `new Page()` (or a closed page) → `await page.goto(url)` first | - \\| `page handle is no longer valid` | Used a page after a later `goto` on the **same** page replaced it → read it before navigating away. Sibling pages from other `new Page()` calls stay valid. | - \\| `document is not defined` | DOM API in script context → use `page.extract` or `page.evaluate` | - \\| `require is not defined` | Not Node.js | - \\| `no page loaded - run page.goto(url) first` | Page method before navigation | - \\| `invalid arguments` | Wrong arity/shape, non-JSON value, or a field set both positionally and in options | - \\| `extract: no schema selector matched any element` | All schema fields missed → fix selectors | - \\| `press` fails with one string arg | Selector-first: use `page.press(null, "Enter")` or `page.press({ key: "Enter" })` | -; - -// Sytem prompt of the `/save` command -// With the save instructions and the skill-like agent script documentation. -const save_system_prompt = browser_tools.save_synthesis_prompt ++ "\n\n" ++ script_skill; +// System prompt of the `/save` command: the save instructions plus the +// script skill (`lp.skill`), whose primitives reference is rendered from +// the tool schemas at first use — hence lazy rather than comptime. +var save_prompts_once = std.once(initSavePrompts); +var save_system_prompt: []const u8 = undefined; +var save_revision_system_prompt: []const u8 = undefined; // Swapped in instead of `save_system_prompt` when the user message carries a // previously saved script: the "this session" framing above would otherwise @@ -216,7 +98,23 @@ const save_revision_note = \\a change, and add what this session contributes. Output the complete \\updated script — never a fragment, diff, or continuation. ; -const save_revision_system_prompt = browser_tools.save_synthesis_prompt ++ "\n" ++ save_revision_note ++ "\n" ++ script_skill; + +/// Panics on OOM like `Schema.all()` — the inputs are static, process-lifetime +/// strings, so failure is a build bug, not a runtime condition. +fn initSavePrompts() void { + const a = std.heap.page_allocator; + save_system_prompt = std.mem.concat(a, u8, &.{ + browser_tools.save_synthesis_prompt, "\n\n", lp.skill.text(), + }) catch @panic("OOM building save prompt"); + save_revision_system_prompt = std.mem.concat(a, u8, &.{ + browser_tools.save_synthesis_prompt, "\n", save_revision_note, "\n", lp.skill.text(), + }) catch @panic("OOM building save prompt"); +} + +fn savePrompt(revision: bool) []const u8 { + save_prompts_once.call(); + return if (revision) save_revision_system_prompt else save_system_prompt; +} const synthesis_prompt = \\You have used your tool budget or cannot finish the exploration. @@ -276,6 +174,15 @@ synthetic_tool_call_id: u32 = 0, total_usage: zenai.provider.Usage = .{}, /// Set when the last turn ended in a model refusal (safety stop). last_turn_refused: bool = false, +/// Whether assistant text streams to the terminal as the model produces it. +/// Toggled via `/stream`; persisted in `.lp-agent.zon`. +stream_enabled: bool, +/// True while assistant text is streaming to stdout (spinner paused). Cleared +/// by `endStreamedText`, which also emits the closing newline. +stream_active: bool = false, +/// True when any assistant text streamed during the current turn, so `runTurn` +/// skips the buffered `printAssistant` that would double-print it. +streamed_text: bool = false, available_providers: []const []const u8, /// Cached reachability of each `local_providers` entry, so the per-keystroke /// `/provider` hinter probes each local server at most once. @@ -388,10 +295,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credentials.provider else null); const verbosity = settings.resolveVerbosity(opts, remembered); + const stream_enabled = settings.resolveStream(remembered); if (resolved) |r| { if (r.source == .picked) { - settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity }) catch {}; + settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled }) catch {}; } // provider/model now live in the status bar; just space before the help std.debug.print("\n", .{}); @@ -427,6 +335,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent .conversation = .init(allocator, opts.system_prompt orelse default_system_prompt), .model = model, .effort = effort, + .stream_enabled = stream_enabled, .script_file = opts.script_file, .one_shot_task = opts.task, .one_shot_save = opts.save, @@ -449,12 +358,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt); if (will_repl) { - self.terminal.attachCompleter(); - self.terminal.completion_source = .{ + self.terminal.attachCompleter(.{ .context = @ptrCast(self), .providers = completionProviders, .models = completionModels, - }; + }); // The model-list cache fills lazily on the first `/model` completion, // so startup never blocks on the network. Terminal.setIdleCallback(&idlePump, @ptrCast(self)); @@ -564,12 +472,43 @@ fn drainCancellation(self: *Agent, baseline: usize) error{UserCancelled} { /// The side effects of `drainCancellation` without surfacing the error, for /// void callers (e.g. `/save` synthesis) that just need to clean up. fn resetAfterCancel(self: *Agent, baseline: usize) void { + self.endStreamedText(); self.conversation.rollback(baseline); self.browser.env.cancelTerminate(); self.cancel_requested.store(false, .release); self.http_interrupt.reset(); } +/// On the first delta it pauses the spinner, whose stderr frames would +/// otherwise interleave with this stdout text. +fn streamAssistantDelta(ctx: *anyopaque, delta: []const u8) void { + const self: *Agent = @ptrCast(@alignCast(ctx)); + if (delta.len == 0) return; + if (!self.stream_active) { + self.terminal.spinner.pause(); + self.stream_active = true; + } + self.streamed_text = true; + self.terminal.printAssistantDelta(delta); +} + +/// Close an in-progress streamed line with a newline. Idempotent, so every +/// `runTools` exit path can call it unconditionally. +fn endStreamedText(self: *Agent) void { + if (!self.stream_active) return; + self.terminal.endAssistantStream(); + self.stream_active = false; +} + +/// The text-delta hook, or null when `/stream off` — a null hook makes +/// `runTools` fall back to a buffered response. Streaming is an interactive +/// REPL affordance; one-shot (`--task`) and script modes keep stdout to the +/// buffered final answer so wrappers can parse it cleanly. +fn streamHook(self: *Agent) ?zenai.provider.Client.TextDeltaHook { + if (!self.stream_enabled or !self.terminal.isRepl()) return null; + return .{ .context = @ptrCast(self), .onText = streamAssistantDelta }; +} + /// One agent turn: the prompt sent to the model, plus optional context — a /// recorder comment to write before the turn, file attachments to bundle into /// the first user message, and a display label used in error output. @@ -639,9 +578,14 @@ fn runTurn(self: *Agent, input: TurnInput) bool { }, }; if (!input.suppress_answer) { - if (text) |t| - self.terminal.printAssistant(t) - else if (self.last_turn_refused) + if (text) |t| { + // Streaming already emitted the text incrementally (plus a closing + // newline); only the buffered path needs to print it here. Safe as a + // turn-wide flag: the stream accumulator reconstructs `t` from the + // same deltas it emitted, and the synthesis fallback also streams, so + // `streamed_text` implies `t` was already shown in full. + if (!self.streamed_text) self.terminal.printAssistant(t); + } else if (self.last_turn_refused) self.terminal.printInfo("(model declined to respond — safety refusal)", .{}) else self.terminal.printInfo("(no response from model)", .{}); @@ -654,8 +598,7 @@ fn runRepl(self: *Agent) void { log.debug(.app, "tools loaded", .{ .count = globalTools().len }); if (self.ai_client != null) { - const a = Terminal.ansi; - std.debug.print(" model: {s}{s} {s}effort: {s}{s}{s}\n", .{ a.dim, self.model, a.reset, a.dim, @tagName(self.effort), a.reset }); + std.debug.print(" model: {s}{s} {s}effort: {s}{s} {s}stream: {s}{s}{s}\n", .{ ansi.dim, self.model, ansi.reset, ansi.dim, @tagName(self.effort), ansi.reset, ansi.dim, if (self.stream_enabled) "on" else "off", ansi.reset }); } repl: while (true) { @@ -770,6 +713,7 @@ fn handleMeta(self: *Agent, arena: std.mem.Allocator, meta: *const SlashCommand. .help => self.printSlashHelp(arena, rest), .verbosity => self.setEnumOption("verbosity", &self.terminal.verbosity, rest), .effort => self.setEnumOption("effort", &self.effort, rest), + .stream => self.handleStream(rest), .usage => self.handleUsage(), .clear => self.handleClear(), .reset => self.handleReset(), @@ -798,6 +742,24 @@ fn setEnumOption(self: *Agent, comptime name: []const u8, target: anytype, rest: self.reportSaved(name, @tagName(level)); } +/// `/stream`: bare prints the current state; `on`/`off` sets and persists it. +fn handleStream(self: *Agent, rest: []const u8) void { + if (rest.len == 0) { + self.terminal.printInfo("stream: {s}", .{if (self.stream_enabled) "on" else "off"}); + return; + } + const on = if (std.ascii.eqlIgnoreCase(rest, "on") or std.ascii.eqlIgnoreCase(rest, "true")) + true + else if (std.ascii.eqlIgnoreCase(rest, "off") or std.ascii.eqlIgnoreCase(rest, "false")) + false + else { + self.terminal.printError("usage: /stream [on|off] (got {s})", .{rest}); + return; + }; + self.stream_enabled = on; + self.reportSaved("stream", if (on) "on" else "off"); +} + /// Print cumulative session token usage, broken down so the cache's effect is /// visible — the REPL otherwise never surfaces the `$usage` line `--task` /// prints. Reads `total_usage` (accumulated per turn by `processUserMessage`); @@ -908,7 +870,7 @@ fn reportSaved(self: *Agent, label: []const u8, value: []const u8) void { self.terminal.printInfo("{s}: {s}", .{ label, value }); return; } - if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity })) { + if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled })) { self.terminal.printInfo("{s}: {s} (saved to {s})", .{ label, value, settings.remembered_path }); } else |_| { self.terminal.printInfo("{s}: {s}", .{ label, value }); @@ -1131,7 +1093,7 @@ fn promptSaveMode(self: *Agent, path: []const u8) ?save.Mode { "append — add the recorded commands at the end", "replace — overwrite with the recorded commands", }; - const idx = Terminal.promptNumberedChoice(header, labels, 0) catch { + const idx = picker.promptNumberedChoice(header, labels, 0) catch { self.terminal.printInfo("Save cancelled.", .{}); return null; }; @@ -1212,7 +1174,7 @@ fn synthesizeSaveTo(self: *Agent, arena: std.mem.Allocator, path: []const u8, mo // regular turns keep the driver prompt. (`messages[0]` is the system // message — rollback and prune never touch it.) const plain_system = self.conversation.messages.items[0].content; - self.conversation.messages.items[0].content = if (previous_script != null) save_revision_system_prompt else save_system_prompt; + self.conversation.messages.items[0].content = savePrompt(previous_script != null); defer self.conversation.messages.items[0].content = plain_system; const ma = self.conversation.arena.allocator(); @@ -1366,6 +1328,10 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo "/effort " ++ Config.tagHint(Config.Effort) ++ " — set per-turn reasoning effort (currently: {s}); saved to {s}. Bare /effort prints the level.", .{ @tagName(self.effort), settings.remembered_path }, ), + .stream => self.terminal.printInfo( + "/stream [on|off] — stream assistant text as it's generated (currently: {s}); saved to {s}. Bare /stream prints the state.", + .{ if (self.stream_enabled) "on" else "off", settings.remembered_path }, + ), .usage => self.terminal.printInfo( "/usage — show cumulative token usage and cache hit rate for this session", .{}, @@ -1406,7 +1372,7 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo } } const tool_schema = Schema.findByName(target) orelse { - if (Terminal.closestCommand(target)) |near| { + if (SlashCommand.closestCommand(target)) |near| { self.terminal.printError("unknown command: {s}. Did you mean " ++ Terminal.highlightCmd("/help {s}") ++ "?", .{ target, near }); } else { self.terminal.printError("unknown command: {s}", .{target}); @@ -1601,6 +1567,11 @@ fn formatApiError(self: *Agent, client: zenai.provider.Client, err: anyerror) [] fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { const ma = self.conversation.arena.allocator(); self.api_error_detail = null; + self.streamed_text = false; + // Defensive: if an exit path ever missed `endStreamedText`, a stale-true + // `stream_active` would skip the spinner pause on the next turn's first + // delta and let frames interleave with the text. Reset it at the source. + self.stream_active = false; self.http_interrupt.reset(); try self.conversation.ensureSystemPrompt(); @@ -1646,8 +1617,12 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { // non-thinking models. .effort = self.effort, .cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel }, + // Suppressed turns (e.g. `--save` capture) must keep stdout clean; + // streaming would bypass the `suppress_answer` guard in `runTurn`. + .stream = if (input.suppress_answer) null else self.streamHook(), }, ) catch |err| { + self.endStreamedText(); self.terminal.spinner.cancel(); // Ctrl-C can land while runTools unwinds an HTTP error — surface // UserCancelled, not ApiError, so the user sees the outcome they asked for. @@ -1657,6 +1632,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { self.conversation.rollback(msg_baseline); return error.ApiError; }; + self.endStreamedText(); self.terminal.spinner.stop(); defer result.deinit(); self.total_usage.add(result.usage); @@ -1730,13 +1706,16 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 { // `.none` stays off to opt out on models that reject it. .effort = if (self.effort == .none) .none else .low, .cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel }, + .stream = if (input.suppress_answer) null else self.streamHook(), }, ) catch |err| { + self.endStreamedText(); if (self.cancel_requested.load(.acquire)) return self.drainCancellation(msg_baseline); log.err(.app, "AI synthesis error", .{ .err = err }); self.conversation.rollback(synth_baseline); break :blk null; }; + self.endStreamedText(); defer synth.deinit(); self.total_usage.add(synth.usage); @@ -1819,6 +1798,8 @@ fn capToolOutput(allocator: std.mem.Allocator, output: []const u8) []const u8 { fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []const u8, arguments: ?std.json.Value) zenai.provider.Client.ToolHandler.Result { const self: *Agent = @ptrCast(@alignCast(ctx)); + // Close any assistant text streamed this turn before the tool spinner shows. + self.endStreamedText(); // The spinner doesn't render args, and `agentToolDone` skips the body line // at low verbosity — don't pay for the stringify when nobody reads it. const needs_args = self.terminal.spinner.isEnabled() or self.terminal.verbosity != .low; @@ -1932,6 +1913,17 @@ fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u test { _ = save; _ = settings; + _ = picker; +} + +test "savePrompt: save instructions followed by the rendered script skill" { + const prompt = savePrompt(false); + try std.testing.expect(std.mem.startsWith(u8, prompt, browser_tools.save_synthesis_prompt)); + try std.testing.expect(std.mem.endsWith(u8, prompt, lp.skill.text())); + + const revision = savePrompt(true); + try std.testing.expect(std.mem.indexOf(u8, revision, save_revision_note) != null); + try std.testing.expect(std.mem.endsWith(u8, revision, lp.skill.text())); } test "capToolOutput: passes through when under cap" { diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig index 80cadf709..790f48fe7 100644 --- a/src/agent/SlashCommand.zig +++ b/src/agent/SlashCommand.zig @@ -17,7 +17,7 @@ // along with this program. If not, see . //! REPL-only meta slash commands (`/help`, `/quit`, `/verbosity`, `/effort`, -//! `/usage`, `/model`, `/provider`). Not tool slash commands — handled by +//! `/stream`, `/usage`, `/model`, `/provider`). Not tool slash commands — handled by //! `Agent.handleMeta`, never reaching the recorder. Tool slash-command schema //! primitives live in `lp.Schema`; import that directly. @@ -46,7 +46,7 @@ pub const MetaCommand = struct { /// Dispatched by `Agent.handleMeta` via an exhaustive switch, so a new meta /// command is a compile error until it's wired up there too. - const Tag = enum { help, quit, verbosity, effort, usage, clear, reset, save, load, model, provider }; + const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider }; }; const tagNames = Config.tagNames; @@ -57,6 +57,7 @@ pub const meta_commands = [_]MetaCommand{ .{ .tag = .quit, .name = "quit", .hint = "", .values = &.{}, .description = "Exit the REPL" }, .{ .tag = .verbosity, .name = "verbosity", .hint = tagHint(Config.AgentVerbosity), .values = tagNames(Config.AgentVerbosity), .description = "Set agent verbosity" }, .{ .tag = .effort, .name = "effort", .hint = tagHint(Config.Effort), .values = tagNames(Config.Effort), .description = "Set per-turn reasoning effort" }, + .{ .tag = .stream, .name = "stream", .hint = "[on|off]", .values = &.{ "on", "off" }, .description = "Toggle streaming of assistant text" }, .{ .tag = .usage, .name = "usage", .hint = "", .values = &.{}, .description = "Show token usage and cache stats for this session" }, .{ .tag = .clear, .name = "clear", .hint = "", .values = &.{}, .description = "Clear conversation history and usage (keeps page/cookies)" }, .{ .tag = .reset, .name = "reset", .hint = "", .values = &.{}, .description = "Reset conversation and browser session (drops page/cookies)" }, @@ -81,3 +82,56 @@ pub fn findMeta(name: []const u8) ?*const MetaCommand { } return null; } + +const browser_tools = lp.tools; +const llm_values = std.enums.values(Command.LlmCommand); + +/// Every slash-invocable name: browser tools, LLM triggers, meta commands. +pub const all_names: [browser_tools.names.len + meta_commands.len + llm_values.len][]const u8 = blk: { + var arr: [browser_tools.names.len + meta_commands.len + llm_values.len][]const u8 = undefined; + var idx: usize = 0; + for (browser_tools.names) |n| { + arr[idx] = n; + idx += 1; + } + for (llm_values) |lc| { + arr[idx] = @tagName(lc); + idx += 1; + } + for (meta_commands) |m| { + arr[idx] = m.name; + idx += 1; + } + break :blk arr; +}; + +/// Closest command name within two edits, or null — for "did you mean?" on typos. +pub fn closestCommand(name: []const u8) ?[]const u8 { + var best: ?[]const u8 = null; + var best_dist: usize = std.math.maxInt(usize); + for (all_names) |cand| { + const dist = editDistance(name, cand); + if (dist < best_dist) { + best_dist = dist; + best = cand; + } + } + return if (best_dist <= 2) best else null; +} + +/// Case-insensitive Levenshtein distance. Returns `maxInt` for inputs longer +/// than the table (no slash command is that long). +fn editDistance(a: []const u8, b: []const u8) usize { + const max = 32; + if (a.len >= max or b.len >= max) return std.math.maxInt(usize); + var dp: [max][max]usize = undefined; + for (0..a.len + 1) |i| dp[i][0] = i; + for (0..b.len + 1) |j| dp[0][j] = j; + for (a, 1..) |ca, i| { + for (b, 1..) |cb, j| { + const cost: usize = if (std.ascii.toLower(ca) == std.ascii.toLower(cb)) 0 else 1; + dp[i][j] = @min(@min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost); + } + } + return dp[a.len][b.len]; +} diff --git a/src/agent/Spinner.zig b/src/agent/Spinner.zig index 36d91381f..770184a4d 100644 --- a/src/agent/Spinner.zig +++ b/src/agent/Spinner.zig @@ -19,8 +19,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const log = lp.log; -const Terminal = @import("Terminal.zig"); -const ansi = Terminal.ansi; +const ansi = @import("ansi.zig"); const truncateUtf8 = @import("../string.zig").truncateUtf8; const Spinner = @This(); @@ -70,6 +69,11 @@ mu: std.Thread.Mutex = .{}, cv: std.Thread.Condition = .{}, state: State = .idle, frame: u8 = 0, +/// True while a caller streams assistant text to stdout: the worker stops +/// rendering and the current frame is cleared, so the stderr spinner does not +/// interleave with the streamed line. Turn state (timer, tool count) is +/// preserved for the eventual `stop`. +paused: bool = false, tool_calls: u32 = 0, turn_started_ns: i128 = 0, @@ -105,6 +109,7 @@ pub fn start(self: *Spinner) void { self.mu.lock(); defer self.mu.unlock(); self.state = .thinking; + self.paused = false; self.frame = 0; self.tool_calls = 0; self.turn_started_ns = std.time.nanoTimestamp(); @@ -143,6 +148,7 @@ pub fn stop(self: *Spinner) void { _ = std.posix.write(std.posix.STDERR_FILENO, summary) catch {}; self.state = .idle; + self.paused = false; self.last_render_len = 0; } @@ -155,9 +161,25 @@ pub fn cancel(self: *Spinner) void { if (self.state == .idle) return; _ = std.posix.write(std.posix.STDERR_FILENO, "\r" ++ clear_eol) catch {}; self.state = .idle; + self.paused = false; self.last_render_len = 0; } +/// Clear the current frame and stop rendering without ending the turn, so the +/// caller can stream text to stdout. The next explicit state change +/// (`setTool`/`stop`/`cancel`/`start`) clears `paused` and resumes the +/// animation. No-op when idle or already paused. +pub fn pause(self: *Spinner) void { + if (!self.isEnabled()) return; + self.mu.lock(); + defer self.mu.unlock(); + if (self.state == .idle or self.paused) return; + self.paused = true; + _ = std.posix.write(std.posix.STDERR_FILENO, "\r" ++ clear_eol) catch {}; + self.last_render_len = 0; + self.cv.signal(); +} + /// Switch the indicator to "running tool ". Counts toward the /// turn's tool-call total. Args are truncated to `max_args_bytes`. Called /// without a preceding `start()` (state `.idle`), the label drops the `agent:` @@ -166,6 +188,8 @@ pub fn setTool(self: *Spinner, name: []const u8, args: []const u8) void { if (!self.isEnabled()) return; self.mu.lock(); defer self.mu.unlock(); + // A tool call ends any in-progress text stream; resume normal rendering. + self.paused = false; const manual = self.state == .idle; self.tool_calls += 1; var tool: ToolState = .{ .set_ns = std.time.nanoTimestamp(), .manual = manual }; @@ -224,7 +248,7 @@ fn workerLoop(self: *Spinner) void { self.mu.lock(); defer self.mu.unlock(); while (!self.should_exit) { - while (!self.should_exit and self.state == .idle) self.cv.wait(&self.mu); + while (!self.should_exit and (self.state == .idle or self.paused)) self.cv.wait(&self.mu); if (self.should_exit) return; switch (self.state) { @@ -266,7 +290,7 @@ fn renderLocked(self: *Spinner) void { // (glyph, two spaces, `[`, `]`) around prefix+name+args. `\r` and // ANSI escapes are zero-width, so they don't count toward wrap. const decoration_cells: usize = 5 + prefix.len + name.len; - const cols: usize = Terminal.columns() orelse 80; + const cols: usize = columns() orelse 80; // Reserve one extra cell so the line is strictly less than `cols`: // auto-wrap (DECAWM) terminals advance past a row that exactly fills // the width. @@ -288,6 +312,21 @@ fn renderLocked(self: *Spinner) void { _ = std.posix.write(std.posix.STDERR_FILENO, written) catch {}; } +/// Current terminal width in columns, queried via TIOCGWINSZ on stderr. +/// Null when stderr isn't a tty, the ioctl fails, or the kernel reports 0 +/// (some pseudo-ttys leave the field unset). Cheap enough to call per render +/// frame; picks up resizes without SIGWINCH plumbing. +fn columns() ?u16 { + var ws: std.posix.winsize = undefined; + // bitcast via c_uint: on archs where `_IOR` sets the direction bit + // (MIPS/PPC/SPARC), `IOCGWINSZ` exceeds i32 range, so a plain @intCast + // panics; the bitcast preserves the bit pattern. + const req: c_int = @bitCast(@as(c_uint, std.posix.T.IOCGWINSZ)); + const rc = std.c.ioctl(std.posix.STDERR_FILENO, req, &ws); + if (rc != 0 or ws.col == 0) return null; + return ws.col; +} + /// Returns the byte length of `bytes` that fits in `max_cells` cells, rounded /// down to a whole UTF-8 codepoint. Multi-cell glyphs (CJK, wide emoji) count /// as 1 — args are typically ASCII, so the approximation is good enough. diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index c260fc447..654e644c7 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -18,46 +18,22 @@ const std = @import("std"); const lp = @import("lightpanda"); -const browser_tools = lp.tools; const Config = lp.Config; -const Command = lp.Command; const Schema = lp.Schema; const SlashCommand = @import("SlashCommand.zig"); const Spinner = @import("Spinner.zig"); +const md_term = @import("md_term.zig"); +const prompt_assist = @import("prompt_assist.zig"); +const ansi = @import("ansi.zig"); const c = @cImport({ @cInclude("isocline.h"); }); const Terminal = @This(); -const style_slash = "ps-slash"; -const style_string = "ps-string"; -const style_var = "ps-var"; -const style_url = "ps-url"; -const style_key = "ps-key"; -const style_num = "ps-num"; -const style_err = "ps-err"; -const style_jsmode = "ps-jsmode"; -const style_keyword = "ps-keyword"; -const style_comment = "ps-comment"; -const style_jsglobal = "ps-jsglobal"; - -pub const ansi = struct { - pub const reset = "\x1b[0m"; - pub const bold = "\x1b[1m"; - pub const dim = "\x1b[2m"; - pub const italic = "\x1b[3m"; - pub const cyan = "\x1b[36m"; - pub const green = "\x1b[32m"; - pub const yellow = "\x1b[33m"; - pub const red = "\x1b[31m"; - pub const clear_eol = "\x1b[K"; - pub const clear_line = "\x1b[2K"; -}; - /// Command styling shared with the `/help` listing. pub fn highlightCmd(comptime fragment: []const u8) []const u8 { - return ansi.bold ++ ansi.cyan ++ fragment ++ ansi.reset; + return ansi.bold ++ ansi.teal ++ fragment ++ ansi.reset; } const Verbosity = Config.AgentVerbosity; @@ -70,123 +46,46 @@ verbosity: Verbosity, /// only gates non-interactive runs. repl_arena: ?std.heap.ArenaAllocator, stderr_is_tty: bool, +stdout_is_tty: bool, spinner: Spinner, -completion_source: ?CompletionSource = null, -/// True while the REPL is in JS mode; set by isocline's mode callback. -js_mode: bool = false, -/// Per-mode history files (null outside REPL mode). `modeCallback` swaps the -/// active one so JS and normal recall stay separate. -history_paths: ?HistoryPaths = null, +/// Prompt-assist C-callback state; `attachCompleter` registers its address, +/// so the Terminal must sit at its final location by then. +assist: prompt_assist.State, +/// Line-buffered markdown state for streamed assistant deltas; its `close` +/// resets everything so fence state can't leak into the next message. Only +/// used on the styled (REPL tty) path, hence the placeholder. +md_stream: md_term.Stream = .{ .show_table_placeholder = true }, -/// Lets the completer/hinter pull dynamic candidates from the `Agent` without -/// `Terminal` depending on it (same idiom as `Session.cancel_hook`). -pub const CompletionSource = struct { - context: *anyopaque, - providers: *const fn (context: *anyopaque, arena: std.mem.Allocator) []const []const u8, - /// May block on an HTTP fetch. - models: *const fn (context: *anyopaque, arena: std.mem.Allocator) []const []const u8, -}; +pub const CompletionSource = prompt_assist.CompletionSource; +pub const HistoryPaths = prompt_assist.HistoryPaths; -const llm_values = std.enums.values(Command.LlmCommand); -const all_slash_names: [browser_tools.names.len + SlashCommand.meta_commands.len + llm_values.len][]const u8 = blk: { - var arr: [browser_tools.names.len + SlashCommand.meta_commands.len + llm_values.len][]const u8 = undefined; - var idx: usize = 0; - for (browser_tools.names) |n| { - arr[idx] = n; - idx += 1; - } - for (llm_values) |lc| { - arr[idx] = @tagName(lc); - idx += 1; - } - for (SlashCommand.meta_commands) |m| { - arr[idx] = m.name; - idx += 1; - } - break :blk arr; -}; - -/// Wires the isocline completer and hinter to `self` so the C callbacks can -/// reach the global schemas. Must run after the Terminal is in its final memory -/// location. -pub fn attachCompleter(self: *Terminal) void { - c.ic_set_default_completer(&completionCallback, self); - c.ic_set_default_hinter(&hintsCallback, self); - c.ic_set_mode_callback(&modeCallback, self); - c.ic_set_ctrl_d_hint(" press Ctrl-D again to exit"); - c.ic_set_esc_clear_hint(" esc again to clear"); - c.ic_set_mode_hint(" JS mode - esc to exit"); - c.ic_set_default_highlighter(&highlighterCallback, self); -} - -fn modeCallback(active: bool, arg: ?*anyopaque) callconv(.c) void { - const self: *Terminal = @ptrCast(@alignCast(arg orelse return)); - self.js_mode = active; - if (self.history_paths) |hp| { - c.ic_set_history((if (active) hp.js else hp.normal).ptr, -1); - } +/// Wires the isocline completer, hinter, and highlighter to `self.assist`. +/// Must run after the Terminal is in its final memory location and before +/// the first `readLine`. +pub fn attachCompleter(self: *Terminal, source: ?CompletionSource) void { + self.assist.completion_source = source; + prompt_assist.attach(&self.assist); } pub fn jsMode(self: *const Terminal) bool { - return self.js_mode; + return self.assist.js_mode; } -/// Separate history files for normal and JS prompt modes. isocline holds one -/// history list at a time, so we swap files on mode toggle rather than tag a -/// shared file. -pub const HistoryPaths = struct { - normal: [:0]const u8, - js: [:0]const u8, -}; - pub fn init(allocator: std.mem.Allocator, history_paths: ?HistoryPaths, verbosity: Verbosity, is_repl: bool) Terminal { - // Isocline probes the terminal on init (writes ESC[6n cursor-report on - // stdout), so skip setup in script-only mode — `ic_readline` is never - // reached there anyway. - if (is_repl) { - _ = c.ic_enable_multiline(true); - _ = c.ic_enable_hint(true); - _ = c.ic_enable_inline_help(true); - // Show ghost completions instantly; isocline's default is 400 ms. - _ = c.ic_set_hint_delay(0); - _ = c.ic_enable_brace_insertion(true); - // `ps-*` namespace avoids colliding with isocline's built-in `ic-*` styles. - c.ic_style_def(style_slash, "ansi-teal bold"); - c.ic_style_def(style_string, "ansi-green"); - c.ic_style_def(style_var, "ansi-yellow bold"); - c.ic_style_def(style_url, "ansi-blue underline"); - c.ic_style_def(style_key, "ansi-blue"); - c.ic_style_def(style_num, "ansi-magenta"); - c.ic_style_def(style_err, "ansi-red"); - c.ic_style_def(style_jsmode, "ansi-red bold"); - c.ic_style_def(style_keyword, "ansi-blue bold"); - c.ic_style_def(style_comment, "ansi-darkgray italic"); - c.ic_style_def(style_jsglobal, "ansi-cyan"); - // lighten the ghost/inline-hint color from isocline's default ansi-darkgray - c.ic_style_def("ic-hint", "ansi-color=244"); - // `!` on an empty prompt toggles JS mode; state callback wired in attachCompleter. - c.ic_set_prompt_mode("[" ++ style_jsmode ++ "]![/" ++ style_jsmode ++ "] ", '!'); - // Blank continuation marker so multiline input isn't prefixed with `>`. - c.ic_set_prompt_marker("❯ ", ""); - _ = c.ic_enable_highlight(true); - if (history_paths) |hp| { - // Mode inactive at launch, so load the normal file; modeCallback - // swaps to JS on mode entry. - c.ic_set_history(hp.normal.ptr, -1); // -1 → 200-entry default cap - } - } + if (is_repl) prompt_assist.setupRepl(); const stderr_is_tty = std.posix.isatty(std.posix.STDERR_FILENO); return .{ .allocator = allocator, .verbosity = verbosity, .repl_arena = if (is_repl) std.heap.ArenaAllocator.init(allocator) else null, .stderr_is_tty = stderr_is_tty, + .stdout_is_tty = std.posix.isatty(std.posix.STDOUT_FILENO), .spinner = .init(is_repl, stderr_is_tty), - .history_paths = history_paths, + .assist = .{ .history_paths = history_paths }, }; } -fn isRepl(self: *const Terminal) bool { +pub fn isRepl(self: *const Terminal) bool { return self.repl_arena != null; } @@ -229,7 +128,7 @@ pub fn agentToolDone(self: *Terminal, name: []const u8, args: []const u8, ok: bo } else { std.debug.print( "{s}{s}[tool: {s}]{s} {s}\n", - .{ ansi.dim, ansi.cyan, name, ansi.reset, args }, + .{ ansi.dim, ansi.teal, name, ansi.reset, args }, ); } } @@ -242,739 +141,6 @@ fn formatBulletLine(arena: std.mem.Allocator, name: []const u8, args: []const u8 return aw.written(); } -const completion_buf_len = 512; - -fn addPrefixedCompletion( - cenv: ?*c.ic_completion_env_t, - buf: *[completion_buf_len:0]u8, - input: []const u8, - prefix: []const u8, - name: []const u8, - suffix: []const u8, - partial: []const u8, -) void { - if (!std.ascii.startsWithIgnoreCase(name, partial)) return; - const text = std.fmt.bufPrintZ(buf, "{s}{s}{s}", .{ prefix, name, suffix }) catch return; - _ = c.ic_add_completion_prim(cenv, text.ptr, null, null, @intCast(input.len), 0); -} - -// Cap on tokens read out of the body; extra tokens are ignored. Real schemas -// and CLI inputs have far fewer fields. -const max_tokens = 32; - -const BodyAnalysis = struct { - used: [max_tokens][]const u8 = undefined, - used_len: usize = 0, - // Trailing in-progress token when the user is typing a key prefix (no `=` - // yet, not a positional binding). Null when the body is empty, ends with - // whitespace, or the trailing token is fully committed. - partial_key: ?[]const u8 = null, - - fn markUsed(self: *BodyAnalysis, name: []const u8) void { - if (self.used_len >= self.used.len) return; - self.used[self.used_len] = name; - self.used_len += 1; - } - - fn isUsed(self: *const BodyAnalysis, name: []const u8) bool { - for (self.used[0..self.used_len]) |u| { - if (std.mem.eql(u8, u, name)) return true; - } - return false; - } -}; - -fn analyzeBody(schema: *const Schema, body: []const u8, ends_ws: bool) BodyAnalysis { - var a: BodyAnalysis = .{}; - - var tokens: [max_tokens][]const u8 = undefined; - var n: usize = 0; - var it = std.mem.tokenizeAny(u8, body, &std.ascii.whitespace); - while (it.next()) |tok| { - if (n >= tokens.len) break; - tokens[n] = tok; - n += 1; - } - if (n == 0) return a; - - const last = n - 1; - for (tokens[0..n], 0..) |tok, i| { - if (std.mem.indexOfScalar(u8, tok, '=')) |eq| { - a.markUsed(tok[0..eq]); - continue; - } - // The first bare arg binds positionally to the schema's positional - // field (`/goto https://example.com`, `/getEnv LP_TOKEN`). - if (i == 0) if (schema.leadingPositionalField()) |pos| { - a.markUsed(pos); - continue; - }; - if (i == last and !ends_ws) a.partial_key = tok; - } - return a; -} - -const help_arg_prefix = "/help "; - -fn parseHelpArgPrefix(input: []const u8) ?[]const u8 { - if (!std.ascii.startsWithIgnoreCase(input, help_arg_prefix)) return null; - const arg = std.mem.trimLeft(u8, input[help_arg_prefix.len..], " "); - if (std.mem.indexOfScalar(u8, arg, ' ') != null) return null; - return arg; -} - -/// A field whose value the cursor is positioned to complete, plus the partial -/// value typed so far. Covers both the leading positional (`/waitForState net`) -/// and an explicit `key=` pair (`/waitForState state=net`). -const ValueAt = struct { - field: Schema.FieldEntry, - partial: []const u8, - /// `key=value` form rather than the bare leading positional. - kv: bool, -}; - -/// Classifies the token under the cursor as a value position for some schema -/// field. Null when the cursor isn't on a completable value (a key prefix, a -/// non-leading positional, or an unknown field). -fn valueAt(schema: *const Schema, body: []const u8, ends_ws: bool) ?ValueAt { - var last: []const u8 = ""; - var n: usize = 0; - var it = std.mem.tokenizeAny(u8, body, &std.ascii.whitespace); - while (it.next()) |tok| { - last = tok; - n += 1; - } - - // An empty body or a trailing space puts the cursor on a fresh token after - // the `n` committed ones; otherwise it sits on the last token. - const active: []const u8 = if (ends_ws or n == 0) "" else last; - const active_index: usize = if (ends_ws or n == 0) n else n - 1; - - if (std.mem.indexOfScalar(u8, active, '=')) |eq| { - const field = schema.findField(active[0..eq]) orelse return null; - return .{ .field = field, .partial = active[eq + 1 ..], .kv = true }; - } - // The leading bare token binds to the schema's positional field (lone - // required, or sole optional field like getEnv's `name`). - if (active_index == 0) if (schema.leadingPositionalField()) |pos| { - const field = schema.findField(pos) orelse return null; - return .{ .field = field, .partial = active, .kv = false }; - }; - return null; -} - -/// Returns true when it owns the completion, so the caller skips key hints. -fn addValueCompletions( - cenv: ?*c.ic_completion_env_t, - input: []const u8, - body: []const u8, - schema: *const Schema, - buf: *[completion_buf_len:0]u8, -) bool { - const ends_ws = input[input.len - 1] == ' '; - const v = valueAt(schema, body, ends_ws) orelse return false; - const prefix = input[0 .. input.len - v.partial.len]; - if (schema.tool == .getEnv) { - var name_buf: [2048]u8 = undefined; - const names = lpEnvNameList(&name_buf) orelse return true; - for (names) |name| addPrefixedCompletion(cenv, buf, input, prefix, name, "", v.partial); - return true; - } - if (v.field.enum_values.len == 0) return false; - for (v.field.enum_values) |val| addPrefixedCompletion(cenv, buf, input, prefix, val, "", v.partial); - return true; -} - -fn addPartialKeyCompletions( - cenv: ?*c.ic_completion_env_t, - input: []const u8, - body: []const u8, - schema: *const Schema, - buf: *[completion_buf_len:0]u8, -) void { - std.debug.assert(input.len > 0); - const ends_ws = input[input.len - 1] == ' '; - const a = analyzeBody(schema, body, ends_ws); - // Without a partial AND without trailing whitespace, the user is mid-typing - // a positional value or some other non-completable state — bail. - if (a.partial_key == null and !ends_ws) return; - - const partial = a.partial_key orelse ""; - const prefix = input[0 .. input.len - partial.len]; - for (schema.hints) |slot| { - if (a.isUsed(slot.name)) continue; - addPrefixedCompletion(cenv, buf, input, prefix, slot.name, "=", partial); - } -} - -fn addMetaValueCompletions( - self: *Terminal, - cenv: ?*c.ic_completion_env_t, - input: []const u8, - body: []const u8, - meta: *const SlashCommand.MetaCommand, - buf: *[completion_buf_len:0]u8, -) void { - // Past the first positional arg — don't offer value completions anymore. - if (std.mem.indexOfAny(u8, body, &std.ascii.whitespace) != null) return; - const prefix = input[0 .. input.len - body.len]; - - if (meta.tag == .load or meta.tag == .save) { - addPathCompletions(cenv, input, body, prefix, buf); - return; - } - - // `/provider` / `/model` candidates are resolved at runtime, not in `meta.values`. - if (self.completion_source) |src| switch (meta.tag) { - .provider, .model => { - var name_buf: [512]u8 = undefined; - var fba: std.heap.FixedBufferAllocator = .init(&name_buf); - const names = if (meta.tag == .provider) - src.providers(src.context, fba.allocator()) - else - src.models(src.context, fba.allocator()); - for (names) |v| addPrefixedCompletion(cenv, buf, input, prefix, v, "", body); - return; - }, - else => {}, - }; - - for (meta.values) |v| addPrefixedCompletion(cenv, buf, input, prefix, v, "", body); -} - -/// Completes a path argument against the filesystem. The directory part of -/// the partial path is kept verbatim in each candidate; the trailing basename -/// is matched against directory entries, and directories get a `/` suffix. -fn addPathCompletions( - cenv: ?*c.ic_completion_env_t, - input: []const u8, - body: []const u8, - prefix: []const u8, - buf: *[completion_buf_len:0]u8, -) void { - const slash = std.mem.lastIndexOfScalar(u8, body, '/'); - const dir_part = if (slash) |i| body[0 .. i + 1] else ""; - const open_path = if (dir_part.len == 0) "." else dir_part; - - var dir = std.fs.cwd().openDir(open_path, .{ .iterate = true }) catch return; - defer dir.close(); - - var name_buf: [completion_buf_len]u8 = undefined; - var it = dir.iterate(); - while (it.next() catch return) |entry| { - const suffix: []const u8 = if (entry.kind == .directory) "/" else ""; - const full = std.fmt.bufPrint(&name_buf, "{s}{s}", .{ dir_part, entry.name }) catch continue; - addPrefixedCompletion(cenv, buf, input, prefix, full, suffix, body); - } -} - -/// LP_* env var names (sorted) written into `buf`; null on enumeration failure. -/// Returned slices borrow `buf`, which must outlive them. -fn lpEnvNameList(buf: []u8) ?[]const []const u8 { - var fba: std.heap.FixedBufferAllocator = .init(buf); - return browser_tools.lpEnvNames(fba.allocator()) catch null; -} - -/// Completes `$LP_*` against the live process environment. -fn addEnvVarCompletions( - cenv: ?*c.ic_completion_env_t, - buf: *[completion_buf_len:0]u8, - input: []const u8, -) void { - const dollar = std.mem.lastIndexOfScalar(u8, input, '$') orelse return; - const partial = input[dollar + 1 ..]; - for (partial) |ch| { - if (!std.ascii.isAlphanumeric(ch) and ch != '_') return; - } - - var name_buf: [2048]u8 = undefined; - const names = lpEnvNameList(&name_buf) orelse return; - if (names.len == 0) return; - - const head = input[0 .. dollar + 1]; - for (names) |name| addPrefixedCompletion(cenv, buf, input, head, name, "", partial); -} - -fn completionCallback(cenv: ?*c.ic_completion_env_t, prefix: [*c]const u8) callconv(.c) void { - const self: *Terminal = @ptrCast(@alignCast(c.ic_completion_arg(cenv) orelse return)); - const input = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(prefix)), 0); - - var buf: [completion_buf_len:0]u8 = undefined; - - // `/help `: arg is a command name, not a value — skip env-var fallthrough. - if (parseHelpArgPrefix(input)) |partial| { - for (all_slash_names) |name| addPrefixedCompletion(cenv, &buf, input, help_arg_prefix, name, "", partial); - return; - } - - if (input.len == 0) return; - const has_space = std.mem.indexOfScalar(u8, input, ' ') != null; - const inside_block = Schema.hasUnclosedTripleQuote(input); - - if (input[0] == '/') { - if (!has_space) { - const partial = input[1..]; - // Trailing space on commands with params hands off to the hinter, - // which renders the full ` [timeout=…]` template uniformly - // whether the name was typed or Tab-completed. - for (all_slash_names) |name| { - const suffix: []const u8 = if (slashHasParams(name)) " " else ""; - addPrefixedCompletion(cenv, &buf, input, "/", name, suffix, partial); - } - return; - } else if (!inside_block) { - if (Schema.parseSlashCommand(input)) |parts| { - if (Schema.findByName(parts.name)) |schema| { - if (!addValueCompletions(cenv, input, parts.rest, schema, &buf)) { - addPartialKeyCompletions(cenv, input, parts.rest, schema, &buf); - } - } else if (SlashCommand.findMeta(parts.name)) |meta| { - self.addMetaValueCompletions(cenv, input, parts.rest, meta, &buf); - } - } - } - // Fall through so `value=$LP_` picks up env completions, including - // inside an unclosed `'''` block. - } - - addEnvVarCompletions(cenv, &buf, input); -} - -// File-scope so the buffer outlives the callback's stack frame. Isocline's -// `sbuf_replace` copies the returned string into its own stringbuf, so -// overwriting this on the next invocation is safe. Single-threaded: isocline's -// edit loop runs on the main thread, and we have one Terminal instance. -var hint_buf: [completion_buf_len:0]u8 = undefined; - -fn hintsCallback(input_c: [*c]const u8, arg: ?*anyopaque) callconv(.c) [*c]const u8 { - const self: *Terminal = @ptrCast(@alignCast(arg orelse return null)); - const input = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(input_c)), 0); - - // JS mode: the buffer is raw JS, so slash/kv hints don't apply. - if (self.js_mode) return null; - - if (input.len == 0) return null; - - if (parseHelpArgPrefix(input)) |partial| return ghostFirstMatch(&all_slash_names, partial, ""); - - // Inside an open `'''…'''` body the buffer is script text, not kv args. - if (Schema.hasUnclosedTripleQuote(input)) return null; - - if (std.mem.eql(u8, input, "/")) return ghostFirstMatch(&all_slash_names, "", ""); - - if (Schema.parseSlashCommand(input)) |parts| { - const ends_ws = input[input.len - 1] == ' '; - if (Schema.findByName(parts.name)) |schema| { - return renderSchemaHint(schema, parts.rest, ends_ws); - } - if (SlashCommand.findMeta(parts.name)) |meta| { - return self.renderMetaHint(meta, parts.rest, ends_ws); - } - if (std.mem.indexOfScalar(u8, input, ' ') == null) { - return ghostFirstMatch(&all_slash_names, parts.name, ""); - } - return null; - } - - // Non-slash lines are natural-language prompts to the LLM (REPL only). - // No syntactic hint to render — the LLM sees the line verbatim. - return null; -} - -/// Join `fragments` into `hint_buf` with single-space separators, prefixed by -/// `lead` (typically `""` or `" "`). Null-terminates and returns the isocline -/// C pointer, or null when nothing to render or the buffer would overflow. -fn writeHints(lead: []const u8, fragments: []const []const u8) [*c]const u8 { - if (fragments.len == 0) return null; - const cap = hint_buf.len - 1; - if (lead.len > cap) return null; - @memcpy(hint_buf[0..lead.len], lead); - var pos: usize = lead.len; - for (fragments, 0..) |frag, i| { - if (i > 0) { - if (pos + 1 > cap) return null; - hint_buf[pos] = ' '; - pos += 1; - } - if (pos + frag.len > cap) return null; - @memcpy(hint_buf[pos..][0..frag.len], frag); - pos += frag.len; - } - hint_buf[pos] = 0; - return @ptrCast(&hint_buf); -} - -/// Ghosts a meta command's argument: providers resolve synchronously, `/model` -/// needs a blocking fetch (placeholder until committed), static values match -/// `meta.values`. -fn renderMetaHint(self: *Terminal, meta: *const SlashCommand.MetaCommand, body: []const u8, ends_ws: bool) [*c]const u8 { - if (meta.hint.len == 0) return null; - if (ends_ws and body.len != 0) return null; // value already committed - - if (self.completion_source) |src| { - var name_buf: [512]u8 = undefined; - var fba: std.heap.FixedBufferAllocator = .init(&name_buf); - if (meta.tag == .provider) { - const lead: []const u8 = if (body.len == 0 and !ends_ws) " " else ""; - return ghostFirstMatch(src.providers(src.context, fba.allocator()), body, lead); - } - if (meta.tag == .model and (ends_ws or body.len != 0)) { - return ghostFirstMatch(src.models(src.context, fba.allocator()), body, ""); - } - } - - if (body.len == 0) { - var frags: [1][]const u8 = .{meta.hint}; - return writeHints(if (ends_ws) "" else " ", &frags); - } - if (ends_ws) return null; - if (meta.tag == .load or meta.tag == .save) return ghostPathFirstMatch(body); - return ghostFirstMatch(meta.values, body, ""); -} - -/// Ghosts the first filesystem entry that completes the partial path `body`, -/// appending `/` when the match is a directory. -fn ghostPathFirstMatch(body: []const u8) [*c]const u8 { - const slash = std.mem.lastIndexOfScalar(u8, body, '/'); - const dir_part = if (slash) |i| body[0 .. i + 1] else ""; - const base = body[dir_part.len..]; - const open_path = if (dir_part.len == 0) "." else dir_part; - - var dir = std.fs.cwd().openDir(open_path, .{ .iterate = true }) catch return null; - defer dir.close(); - - var it = dir.iterate(); - while (it.next() catch return null) |entry| { - if (!std.ascii.startsWithIgnoreCase(entry.name, base)) continue; - const suffix: []const u8 = if (entry.kind == .directory) "/" else ""; - const text = std.fmt.bufPrintZ(&hint_buf, "{s}{s}", .{ entry.name[base.len..], suffix }) catch return null; - return text.ptr; - } - return null; -} - -/// Ghosts `lead` + the suffix of the first `names` entry that prefix-matches -/// `body`. -fn ghostFirstMatch(names: []const []const u8, body: []const u8, lead: []const u8) [*c]const u8 { - for (names) |v| { - if (!std.ascii.startsWithIgnoreCase(v, body)) continue; - const text = std.fmt.bufPrintZ(&hint_buf, "{s}{s}", .{ lead, v[body.len..] }) catch return null; - return text.ptr; - } - return null; -} - -/// Renders `` and `[optional=…]` for each unused field, or -/// `=…` when the user is typing a key prefix. -fn renderSchemaHint(schema: *const Schema, body: []const u8, ends_ws: bool) [*c]const u8 { - // Ghost a matching enum value once the user is typing one. A bare leading - // positional with nothing typed keeps the ` …` template below — more - // informative than ghosting one arbitrary value. - if (valueAt(schema, body, ends_ws)) |v| { - if (v.field.enum_values.len > 0 and (v.kv or v.partial.len > 0)) { - return ghostFirstMatch(v.field.enum_values, v.partial, ""); - } - // getEnv's `name` ghosts a live LP_* var, like /provider ghosts a provider. - if (schema.tool == .getEnv) { - var name_buf: [2048]u8 = undefined; - if (lpEnvNameList(&name_buf)) |names| { - const lead: []const u8 = if (v.partial.len == 0 and !ends_ws) " " else ""; - if (ghostFirstMatch(names, v.partial, lead)) |hint| return hint; - } - } - } - - const a = analyzeBody(schema, body, ends_ws); - - if (a.partial_key) |pk| { - for (schema.hints) |slot| { - if (a.isUsed(slot.name)) continue; - if (!std.ascii.startsWithIgnoreCase(slot.name, pk)) continue; - const text = std.fmt.bufPrintZ(&hint_buf, "{s}=…", .{slot.name[pk.len..]}) catch return null; - return text.ptr; - } - return null; - } - - var frags: [Schema.max_hint_slots][]const u8 = undefined; - var n: usize = 0; - for (schema.hints) |slot| { - if (a.isUsed(slot.name)) continue; - frags[n] = slot.fragment; - n += 1; - } - return writeHints(if (ends_ws) "" else " ", frags[0..n]); -} - -/// Index of the next non-whitespace byte at or after `start`, or null if only -/// whitespace remains. -fn skipWhitespace(text: []const u8, start: usize) ?usize { - var i = start; - while (i < text.len and std.ascii.isWhitespace(text[i])) i += 1; - return if (i < text.len) i else null; -} - -/// Byte offsets to ic_highlight are not UTF-8 code points; safe because we -/// only tokenize on ASCII boundaries (whitespace, quotes, `=`, `$`). -fn highlighterCallback(henv: ?*c.ic_highlight_env_t, input: [*c]const u8, arg: ?*anyopaque) callconv(.c) void { - const self: *Terminal = @ptrCast(@alignCast(arg orelse return)); - const text = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(input)), 0); - // JS mode: the buffer is raw JS, so highlight it as such (plus `$LP_*` refs). - if (self.js_mode) { - highlightJavaScript(henv, text); - return; - } - const cmd_start = skipWhitespace(text, 0) orelse return; - var i = cmd_start; - while (i < text.len and !std.ascii.isWhitespace(text[i])) i += 1; - const cmd = text[cmd_start..i]; - // Commit to red once the cursor moves past the token, OR as soon as the - // prefix cannot complete to any known name. - const closed = i < text.len; - if (cmd.len > 0 and cmd[0] == '/') { - c.ic_highlight(henv, @intCast(cmd_start), 1, style_slash.ptr); - if (cmd.len > 1) { - const name = cmd[1..]; - const style: ?[*:0]const u8 = if (isKnownSlashName(name)) - style_slash - else if (closed or !slashHasPrefix(name)) - style_err - else - null; - if (style) |s| c.ic_highlight(henv, @intCast(cmd_start + 1), @intCast(cmd.len - 1), s); - } - highlightSlashArgs(henv, text, i); - } else { - // No leading `/`: a natural-language prompt, so no command validation. - // Start at `cmd_start`, not `i`, so a `$LP_*` first token highlights too. - highlightDollarVars(henv, text, cmd_start); - } -} - -fn isKnownSlashName(name: []const u8) bool { - for (all_slash_names) |n| { - if (std.ascii.eqlIgnoreCase(n, name)) return true; - } - return false; -} - -fn slashHasPrefix(name: []const u8) bool { - for (all_slash_names) |n| { - if (std.ascii.startsWithIgnoreCase(n, name)) return true; - } - return false; -} - -/// Closest command name within two edits, or null — for "did you mean?" on typos. -pub fn closestCommand(name: []const u8) ?[]const u8 { - var best: ?[]const u8 = null; - var best_dist: usize = std.math.maxInt(usize); - for (all_slash_names) |cand| { - const dist = editDistance(name, cand); - if (dist < best_dist) { - best_dist = dist; - best = cand; - } - } - return if (best_dist <= 2) best else null; -} - -/// Case-insensitive Levenshtein distance. Returns `maxInt` for inputs longer -/// than the table (no slash command is that long). -fn editDistance(a: []const u8, b: []const u8) usize { - const max = 32; - if (a.len >= max or b.len >= max) return std.math.maxInt(usize); - var dp: [max][max]usize = undefined; - for (0..a.len + 1) |i| dp[i][0] = i; - for (0..b.len + 1) |j| dp[0][j] = j; - for (a, 1..) |ca, i| { - for (b, 1..) |cb, j| { - const cost: usize = if (std.ascii.toLower(ca) == std.ascii.toLower(cb)) 0 else 1; - dp[i][j] = @min(@min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost); - } - } - return dp[a.len][b.len]; -} - -fn slashHasParams(name: []const u8) bool { - if (Schema.findByName(name)) |s| return s.hints.len > 0; - if (SlashCommand.findMeta(name)) |m| return m.hint.len > 0; - return false; -} - -fn highlightBareToken(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize, end: usize) void { - if (start >= end) return; - const tok = text[start..end]; - if (tok[0] == '$') { - c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_var.ptr); - return; - } - if (lp.URL.isCompleteHTTPUrl(tok)) { - c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_url.ptr); - return; - } - if (std.fmt.parseFloat(f64, tok)) |_| { - c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_num.ptr); - } else |_| {} -} - -/// Returns the index just past the matching closing quote, or `text.len` if -/// unterminated. Does not handle backslash escapes (matches Schema.tokenize). -fn scanQuoted(text: []const u8, start: usize) usize { - if (start >= text.len) return start; - const ch = text[start]; - const is_triple = start + 2 < text.len and text[start + 1] == ch and text[start + 2] == ch; - if (is_triple) { - const triple_delim = text[start .. start + 3]; - const close = std.mem.indexOfPos(u8, text, start + 3, triple_delim) orelse return text.len; - return close + 3; - } - const close = std.mem.indexOfScalarPos(u8, text, start + 1, ch) orelse return text.len; - return close + 1; -} - -/// Highlight `$LP_*` tokens appearing from `start` onward. -fn highlightDollarVars(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize) void { - highlightDollarVarsIn(henv, text, start, text.len); -} - -/// Highlight `$LP_*` tokens within `text[start..end]` — used both for whole -/// prompts and for repainting refs that fall inside a string literal. -fn highlightDollarVarsIn(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize, end: usize) void { - var i = start; - while (i < end) { - if (text[i] != '$') { - i += 1; - continue; - } - const tok_start = i; - i += 1; - while (i < end and (std.ascii.isAlphanumeric(text[i]) or text[i] == '_')) i += 1; - if (i > tok_start + 1) { - c.ic_highlight(henv, @intCast(tok_start), @intCast(i - tok_start), style_var.ptr); - } - // Don't post-step: the inner loop already landed on the char after the - // identifier (or end-of-text); auto-advancing would skip an adjacent `$LP_*`. - } -} - -const js_keywords = [_][]const u8{ - "function", "async", "await", "yield", "return", "if", "else", - "for", "while", "do", "switch", "case", "break", "continue", - "var", "let", "const", "new", "delete", "typeof", "instanceof", - "in", "of", "void", "this", "super", "class", "extends", - "import", "export", "from", "default", "try", "catch", "finally", - "throw", "true", "false", "null", "undefined", "NaN", "Infinity", -}; - -// Globals available in the JS-mode page context; highlighted so it's visible -// at the prompt that they're in scope. -const js_globals = [_][]const u8{ "document", "window", "globalThis", "console", "lp" }; - -fn isJsKeyword(tok: []const u8) bool { - for (js_keywords) |kw| { - if (std.mem.eql(u8, kw, tok)) return true; - } - return false; -} - -fn isJsGlobal(tok: []const u8) bool { - for (js_globals) |g| { - if (std.mem.eql(u8, g, tok)) return true; - } - return false; -} - -fn isIdChar(ch: u8) bool { - return std.ascii.isAlphanumeric(ch) or ch == '_' or ch == '$' or ch >= 0x80; -} - -/// Highlight the buffer as JavaScript: keywords, strings (incl. template -/// literals), numbers, comments, and `$LP_*` env-var refs. Byte offsets are -/// safe (see `highlighterCallback`): every token boundary is an ASCII byte and -/// non-ASCII bytes advance singly without being highlighted. -fn highlightJavaScript(henv: ?*c.ic_highlight_env_t, text: []const u8) void { - var i: usize = 0; - while (i < text.len) { - const ch = text[i]; - if (ch == '/' and i + 1 < text.len and (text[i + 1] == '/' or text[i + 1] == '*')) { - const start = i; - if (text[i + 1] == '/') { - i = std.mem.indexOfScalarPos(u8, text, i + 2, '\n') orelse text.len; - } else { - const close = std.mem.indexOfPos(u8, text, i + 2, "*/"); - i = if (close) |p| p + 2 else text.len; - } - c.ic_highlight(henv, @intCast(start), @intCast(i - start), style_comment.ptr); - continue; - } - if (ch == '\'' or ch == '"' or ch == '`') { - const start = i; - i = scanQuoted(text, i); - c.ic_highlight(henv, @intCast(start), @intCast(i - start), style_string.ptr); - // `$LP_*` repaints yellow over green: isocline merges per cell, so the later call wins. - highlightDollarVarsIn(henv, text, start, i); - continue; - } - if (ch == '$') { - const start = i; - i += 1; - while (i < text.len and (std.ascii.isAlphanumeric(text[i]) or text[i] == '_')) i += 1; - if (i > start + 1) { - c.ic_highlight(henv, @intCast(start), @intCast(i - start), style_var.ptr); - } - continue; - } - if (std.ascii.isDigit(ch) or (ch == '.' and i + 1 < text.len and std.ascii.isDigit(text[i + 1]))) { - const start = i; - i += 1; - while (i < text.len and (std.ascii.isHex(text[i]) or text[i] == '.' or text[i] == '_' or text[i] == 'x' or text[i] == 'X')) i += 1; - c.ic_highlight(henv, @intCast(start), @intCast(i - start), style_num.ptr); - continue; - } - if (std.ascii.isAlphabetic(ch) or ch == '_') { - const start = i; - i += 1; - while (i < text.len and isIdChar(text[i])) i += 1; - const tok = text[start..i]; - if (isJsKeyword(tok)) { - c.ic_highlight(henv, @intCast(start), @intCast(i - start), style_keyword.ptr); - } else if (isJsGlobal(tok) and (start == 0 or text[start - 1] != '.')) { - // `.document` is a property access, not the global - c.ic_highlight(henv, @intCast(start), @intCast(i - start), style_jsglobal.ptr); - } - continue; - } - i += 1; - } -} - -fn highlightSlashArgs(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize) void { - var i = start; - while (skipWhitespace(text, i)) |tok_start| { - i = tok_start; - if (text[i] == '\'' or text[i] == '"') { - i = scanQuoted(text, i); - c.ic_highlight(henv, @intCast(tok_start), @intCast(i - tok_start), style_string.ptr); - continue; - } - while (i < text.len and !std.ascii.isWhitespace(text[i]) and text[i] != '=') i += 1; - const key_end = i; - if (i < text.len and text[i] == '=') { - c.ic_highlight(henv, @intCast(tok_start), @intCast(key_end - tok_start), style_key.ptr); - i += 1; - const val_start = i; - if (i < text.len and (text[i] == '\'' or text[i] == '"')) { - i = scanQuoted(text, i); - c.ic_highlight(henv, @intCast(val_start), @intCast(i - val_start), style_string.ptr); - } else { - while (i < text.len and !std.ascii.isWhitespace(text[i])) i += 1; - highlightBareToken(henv, text, val_start, i); - } - } - } -} - pub fn setIdleCallback(fun: ?*const c.ic_idle_fun_t, arg: ?*anyopaque) void { c.ic_set_idle_callback(fun, arg); } @@ -984,8 +150,8 @@ pub fn readLine(prompt: [*:0]const u8) ?[]const u8 { // \r) only while isocline reads: while active, Ctrl-C arrives as a CSI-u // escape rather than raw \x03, so the tty driver raises no SIGINT. Leaving // it on during thinking/tool runs would make Ctrl-C unable to interrupt them. - _ = std.posix.write(std.posix.STDOUT_FILENO, "\x1b[>1u") catch {}; - defer _ = std.posix.write(std.posix.STDOUT_FILENO, "\x1b[ try promptNumberedChoiceLine(header, items, valid_default), - else => err, - }; - } - return promptNumberedChoiceLine(header, items, valid_default); +/// Style only for the interactive REPL on a real terminal; `--task`/piped +/// output stays verbatim so it can be consumed programmatically. +fn styledOutput(self: *const Terminal) bool { + return self.isRepl() and self.stdout_is_tty; } -/// Line-oriented fallback. Errors with NoChoice after 3 invalid attempts. -fn promptNumberedChoiceLine(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize { - var stdin_buf: [128]u8 = undefined; - var stdin = std.fs.File.stdin().reader(&stdin_buf); - - var attempt: u8 = 0; - while (attempt < 3) : (attempt += 1) { - std.debug.print("{s}\n", .{header}); - for (items, 0..) |item, idx| { - const marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else ""; - std.debug.print(" {d:>3}) {s}{s}\n", .{ idx + 1, item, marker }); - } - std.debug.print("> ", .{}); - - const line = stdin.interface.takeDelimiterInclusive('\n') catch |err| switch (err) { - error.EndOfStream, error.StreamTooLong, error.ReadFailed => return error.UserCancelled, - }; - const trimmed = std.mem.trim(u8, line, " \t\r\n"); - if (trimmed.len == 0) { - if (default) |d| return d; - std.debug.print("Invalid input — type a number.\n", .{}); - continue; - } - const choice = std.fmt.parseInt(usize, trimmed, 10) catch { - const hint: []const u8 = if (default != null) " (or press Enter for default)" else ""; - std.debug.print("Invalid input — type a number{s}.\n", .{hint}); - continue; - }; - if (choice >= 1 and choice <= items.len) return choice - 1; - std.debug.print("Out of range.\n", .{}); +/// Buffered, error-swallowing markdown write to stdout; only called on the +/// styled (REPL tty) path. +fn renderStyled(self: *Terminal, text: []const u8, op: enum { full, delta, end }) void { + var buf: [1024]u8 = undefined; + var fw = std.fs.File.stdout().writerStreaming(&buf); + const w = &fw.interface; + switch (op) { + .full => { + md_term.render(w, text) catch {}; + w.writeByte('\n') catch {}; + }, + .delta => self.md_stream.feed(w, text) catch {}, + .end => { + self.md_stream.close(w) catch {}; + w.writeByte('\n') catch {}; + }, } - return error.NoChoice; + w.flush() catch {}; } -const ChoiceInput = enum { up, down, enter, cancel, ignore }; - -const ChoiceState = struct { - selected: usize, - - fn init(default: ?usize) ChoiceState { - return .{ .selected = default orelse 0 }; - } - - fn apply(self: *ChoiceState, input: ChoiceInput, item_count: usize) ?usize { - switch (input) { - .up => self.selected = if (self.selected == 0) item_count - 1 else self.selected - 1, - .down => self.selected = (self.selected + 1) % item_count, - .enter => return self.selected, - .cancel, .ignore => {}, - } - return null; - } -}; - -const RawTerminal = struct { - original: std.posix.termios, - - fn enable() !RawTerminal { - if (!interactiveTty()) return error.NotInteractive; - const original = try std.posix.tcgetattr(std.posix.STDIN_FILENO); - var raw = original; - raw.iflag.BRKINT = false; - raw.iflag.ICRNL = false; - raw.iflag.INPCK = false; - raw.iflag.ISTRIP = false; - raw.iflag.IXON = false; - raw.oflag.OPOST = false; - raw.cflag.CSIZE = .CS8; - raw.lflag.ECHO = false; - raw.lflag.ICANON = false; - raw.lflag.IEXTEN = false; - raw.lflag.ISIG = false; - raw.cc[@intFromEnum(std.c.V.MIN)] = 0; - raw.cc[@intFromEnum(std.c.V.TIME)] = 1; - try std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, raw); - // Under the REPL's kitty "disambiguate" flag, cursor keys arrive as - // CSI-u the byte reader can't parse; push flag 0 to force legacy arrow - // encoding. restore() pops back to the REPL's flag. - _ = std.posix.write(std.posix.STDOUT_FILENO, "\x1b[>0u") catch {}; - return .{ .original = original }; - } - - fn restore(self: *const RawTerminal) void { - _ = std.posix.write(std.posix.STDOUT_FILENO, "\x1b[ 1) { - std.debug.print("\x1b[{d}F", .{line_count - 1}); - } else { - std.debug.print("\r", .{}); - } -} - -fn renderChoice(header: []const u8, items: []const [:0]const u8, default: ?usize, selected: usize, first_render: bool) void { - if (!first_render) moveChoiceRenderStart(items.len + 2); - std.debug.print(ansi.clear_line ++ "{s}\r\n", .{header}); - for (items, 0..) |item, idx| { - const on_row = idx == selected; - const marker: []const u8 = if (on_row) ">" else " "; - const style: []const u8 = if (on_row) ansi.bold ++ ansi.cyan else ""; - const reset: []const u8 = if (on_row) ansi.reset else ""; - const default_marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else ""; - std.debug.print(ansi.clear_line ++ " {s} {s}{s}{s}{s}\r\n", .{ marker, style, item, default_marker, reset }); - } - std.debug.print(ansi.clear_line ++ "{s}Use Up/Down then Enter. Esc cancels.{s}", .{ ansi.dim, ansi.reset }); -} - -fn readChoiceInput() !ChoiceInput { - while (true) { - const ch = try readChoiceByte() orelse continue; - return switch (ch) { - 3, 4, 27 => esc: { - if (ch != 27) break :esc .cancel; - const b1 = try readChoiceByte() orelse break :esc .cancel; - if (b1 != '[' and b1 != 'O') break :esc .cancel; - const b2 = try readChoiceByte() orelse break :esc .cancel; - break :esc switch (b2) { - 'A' => .up, - 'B' => .down, - else => .ignore, - }; - }, - '\r', '\n' => .enter, - else => .ignore, - }; - } -} - -fn readChoiceByte() !?u8 { - var buf: [1]u8 = undefined; - const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch |err| switch (err) { - error.WouldBlock => return null, - error.InputOutput => return error.ReadFailed, - else => return err, - }; - if (n == 0) return null; - return buf[0]; -} - -test "ChoiceState: arrows wrap and enter selects highlighted item" { - var state = ChoiceState.init(null); - try std.testing.expectEqual(@as(usize, 0), state.selected); - - try std.testing.expectEqual(@as(?usize, null), state.apply(.up, 3)); - try std.testing.expectEqual(@as(usize, 2), state.selected); - - try std.testing.expectEqual(@as(?usize, null), state.apply(.down, 3)); - try std.testing.expectEqual(@as(usize, 0), state.selected); - - try std.testing.expectEqual(@as(?usize, 0), state.apply(.enter, 3)); -} - -test "ChoiceState: starts on default and enter returns it" { - var state = ChoiceState.init(2); - try std.testing.expectEqual(@as(usize, 2), state.selected); - try std.testing.expectEqual(@as(?usize, 2), state.apply(.enter, 3)); -} - -test "valueAt: enum field via positional and kv, partial and empty" { - const schema = Schema.findByName("waitForState").?; - - // Leading positional, nothing typed: empty partial, not kv. - { - const v = valueAt(schema, "", true).?; - try std.testing.expect(v.field.enum_values.len > 0); - try std.testing.expectEqualStrings("", v.partial); - try std.testing.expect(!v.kv); - } - // Leading positional, partial value. - { - const v = valueAt(schema, "net", false).?; - try std.testing.expectEqualStrings("net", v.partial); - try std.testing.expect(!v.kv); - } - // Explicit `state=` with empty value. - { - const v = valueAt(schema, "state=", false).?; - try std.testing.expectEqualStrings("", v.partial); - try std.testing.expect(v.kv); - } - // Explicit `state=net`. - { - const v = valueAt(schema, "state=net", false).?; - try std.testing.expectEqualStrings("net", v.partial); - try std.testing.expect(v.kv); - } - // Past the only required field — timeout is not an enum, so no value match. - { - const v = valueAt(schema, "networkidle timeout=", false).?; - try std.testing.expectEqual(@as(usize, 0), v.field.enum_values.len); - } -} - -test "valueAt: getEnv binds the leading positional to its optional name field" { - const schema = Schema.findByName("getEnv").?; - // `/getEnv ` — fresh positional, even though `name` is optional (0 required). - { - const v = valueAt(schema, "", true).?; - try std.testing.expectEqualStrings("name", v.field.name); - try std.testing.expectEqualStrings("", v.partial); - try std.testing.expect(!v.kv); - } - // `/getEnv LP_H` — partial value bound to `name`. - { - const v = valueAt(schema, "LP_H", false).?; - try std.testing.expectEqualStrings("name", v.field.name); - try std.testing.expectEqualStrings("LP_H", v.partial); - } -} - -test "renderSchemaHint: ghosts enum value once typing, keeps template when empty" { - const schema = Schema.findByName("waitForState").?; - - const hintStr = struct { - fn f(p: [*c]const u8) ?[]const u8 { - if (p == null) return null; - return std.mem.sliceTo(@as([*:0]const u8, @ptrCast(p)), 0); - } - }.f; - - // Nothing typed yet: the ` …` template, not an arbitrary value. - try std.testing.expectEqualStrings(" [timeout=…]", hintStr(renderSchemaHint(schema, "", false)).?); - - // Partial positional ghosts the suffix of the first matching value. - try std.testing.expectEqualStrings("workalmostidle", hintStr(renderSchemaHint(schema, "net", false)).?); - - // `state=` ghosts the first value. - try std.testing.expectEqualStrings("load", hintStr(renderSchemaHint(schema, "state=", false)).?); -} - -pub fn printAssistant(_: *Terminal, text: []const u8) void { +pub fn printAssistant(self: *Terminal, text: []const u8) void { if (text.len == 0) return; - const fd = std.posix.STDOUT_FILENO; - _ = std.posix.write(fd, text) catch {}; - _ = std.posix.write(fd, "\n") catch {}; + if (self.styledOutput()) return self.renderStyled(text, .full); + _ = std.posix.write(std.posix.STDOUT_FILENO, text) catch {}; + _ = std.posix.write(std.posix.STDOUT_FILENO, "\n") catch {}; +} + +/// Write a streamed assistant-text delta (no trailing newline). Rendered +/// line-buffered through `md_stream` on a REPL tty, verbatim otherwise. The +/// caller must pause the spinner first (its stderr frames would otherwise +/// interleave with this stdout text) and call `endAssistantStream` when the +/// stream ends. +pub fn printAssistantDelta(self: *Terminal, text: []const u8) void { + if (text.len == 0) return; + if (self.styledOutput()) return self.renderStyled(text, .delta); + _ = std.posix.write(std.posix.STDOUT_FILENO, text) catch {}; +} + +/// Flush any partial streamed line, terminate it, and reset stream state. +pub fn endAssistantStream(self: *Terminal) void { + if (self.styledOutput()) return self.renderStyled("", .end); + _ = std.posix.write(std.posix.STDOUT_FILENO, "\n") catch {}; } // Must exceed the downstream LLM-judge's snapshot window for full grounding @@ -1480,7 +394,7 @@ pub fn printSlashParseError(self: *Terminal, err: Schema.ParseError, name: []con } const reason: []const u8 = switch (err) { error.UnknownTool => { - if (closestCommand(name)) |near| { + if (SlashCommand.closestCommand(name)) |near| { return self.printError("{s}: unknown command. Did you mean " ++ highlightCmd("/{s}") ++ "? Try /help.", .{ name, near }); } return self.printError("{s}: unknown command. Try /help.", .{name}); diff --git a/src/agent/ansi.zig b/src/agent/ansi.zig new file mode 100644 index 000000000..ce4790e60 --- /dev/null +++ b/src/agent/ansi.zig @@ -0,0 +1,39 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pub const reset = "\x1b[0m"; +pub const bold = "\x1b[1m"; +pub const dim = "\x1b[2m"; +pub const italic = "\x1b[3m"; +pub const underline = "\x1b[4m"; +pub const strike = "\x1b[9m"; +// Color names follow isocline's (bbcode_colors.c): teal is the dark cyan +// pair of bright cyan, like maroon/red or olive/yellow. +pub const teal = "\x1b[36m"; +pub const cyan = "\x1b[96m"; +pub const green = "\x1b[32m"; +pub const yellow = "\x1b[33m"; +pub const red = "\x1b[31m"; +pub const blue = "\x1b[34m"; +pub const magenta = "\x1b[35m"; +pub const clear_eol = "\x1b[K"; +pub const clear_line = "\x1b[2K"; +// Kitty keyboard protocol; each push must pair with a pop. +pub const kitty_disambiguate = "\x1b[>1u"; +pub const kitty_legacy = "\x1b[>0u"; +pub const kitty_pop = "\x1b[ +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const string = @import("../string.zig"); + +pub const Kind = enum { comment, string, variable, interpolation, number, keyword, global, function, method, type_name }; + +/// Carried between calls because fenced code is tokenized one line at a time: +/// block comments and template literals outlive a line boundary. +pub const State = enum { normal, block_comment, template }; + +pub const StringSpan = struct { end: usize, closed: bool }; + +/// Scan the quoted run opening at `text[start]`. Escapes are not honored — +/// good enough for coloring, not parsing. +pub fn scanString(text: []const u8, start: usize) StringSpan { + if (start >= text.len) return .{ .end = start, .closed = false }; + const close = std.mem.indexOfScalarPos(u8, text, start + 1, text[start]) orelse + return .{ .end = text.len, .closed = false }; + return .{ .end = close + 1, .closed = true }; +} + +/// Index just past the `$name` ref opening at `text[start]` (scanning within +/// `text[..end]`), or `start + 1` when the `$` is bare. A ref is a `$` followed +/// by at least one `[A-Za-z0-9_]`. +fn dollarRefEnd(text: []const u8, start: usize, end: usize) usize { + var i = start + 1; + while (i < end and (std.ascii.isAlphanumeric(text[i]) or text[i] == '_')) i += 1; + return i; +} + +pub const DollarRef = struct { start: usize, end: usize, kind: Kind }; + +/// Next `$name` (`.variable`) ref at or after `from` within `text[..end]`, +/// or null; bare `$`s are skipped. `interpolation` additionally recognizes +/// `${…}` (`.interpolation`, unclosed runs to `end`) — only template +/// literals interpolate, so callers pass their quote kind. +pub fn nextDollarRef(text: []const u8, from: usize, end: usize, interpolation: bool) ?DollarRef { + var i = from; + while (i < end) { + if (text[i] != '$') { + i += 1; + continue; + } + if (interpolation and i + 1 < end and text[i + 1] == '{') { + const close = std.mem.indexOfScalarPos(u8, text[0..end], i + 2, '}'); + return .{ .start = i, .end = if (close) |c| c + 1 else end, .kind = .interpolation }; + } + const ref_end = dollarRefEnd(text, i, end); + if (ref_end > i + 1) return .{ .start = i, .end = ref_end, .kind = .variable }; + i += 1; + } + return null; +} + +/// Tokenize `text` as JavaScript starting from `state`, reporting spans to +/// `sink.emit(start, len, kind)` and returning the state at end of input. +/// Spans arrive in order and never overlap — the gaps between them are plain +/// text — so an emitter that writes sequentially (ANSI) works as well as one +/// that paints cells (isocline). +pub fn tokenize(text: []const u8, state: State, sink: anytype) State { + var i: usize = 0; + + switch (state) { + .block_comment => { + const close = std.mem.indexOfPos(u8, text, 0, "*/"); + i = if (close) |p| p + 2 else text.len; + if (i > 0) sink.emit(0, i, .comment); + if (close == null) return .block_comment; + }, + .template => { + const close = std.mem.indexOfScalarPos(u8, text, 0, '`'); + i = if (close) |p| p + 1 else text.len; + emitString(text, 0, i, true, sink); + if (close == null) return .template; + }, + .normal => {}, + } + + while (i < text.len) { + const ch = text[i]; + if (ch == '/' and i + 1 < text.len and (text[i + 1] == '/' or text[i + 1] == '*')) { + const start = i; + if (text[i + 1] == '/') { + i = std.mem.indexOfScalarPos(u8, text, i + 2, '\n') orelse text.len; + sink.emit(start, i - start, .comment); + continue; + } + const close = std.mem.indexOfPos(u8, text, i + 2, "*/"); + i = if (close) |p| p + 2 else text.len; + sink.emit(start, i - start, .comment); + if (close == null) return .block_comment; + continue; + } + if (ch == '\'' or ch == '"' or ch == '`') { + const span = scanString(text, i); + emitString(text, i, span.end, ch == '`', sink); + i = span.end; + // Only a template literal may legally continue on the next line. + if (!span.closed and ch == '`') return .template; + continue; + } + if (ch == '$') { + const start = i; + i = dollarRefEnd(text, i, text.len); + if (i > start + 1) sink.emit(start, i - start, .variable); + continue; + } + if (std.ascii.isDigit(ch) or (ch == '.' and i + 1 < text.len and std.ascii.isDigit(text[i + 1]))) { + const start = i; + i += 1; + while (i < text.len and (std.ascii.isHex(text[i]) or text[i] == '.' or text[i] == '_' or text[i] == 'x' or text[i] == 'X')) i += 1; + sink.emit(start, i - start, .number); + continue; + } + if (std.ascii.isAlphabetic(ch) or ch == '_') { + const start = i; + i += 1; + while (i < text.len and isIdChar(text[i])) i += 1; + const tok = text[start..i]; + if (string.isOneOf(tok, &keywords)) { + sink.emit(start, i - start, .keyword); + } else if (string.isOneOf(tok, &globals) and (start == 0 or text[start - 1] != '.')) { + // `.document` is a property access, not the global + sink.emit(start, i - start, .global); + } else if (i < text.len and text[i] == '(') { + const kind: Kind = if (std.ascii.isUpper(tok[0])) + .type_name + else if (start > 0 and text[start - 1] == '.') + .method + else + .function; + sink.emit(start, i - start, kind); + } + continue; + } + i += 1; + } + return .normal; +} + +/// Emit `text[start..end]` as string spans split around any `$name` refs — +/// spans must never overlap, so a sequential writer (ANSI) works as well as +/// isocline's last-write-wins cell painting. +fn emitString(text: []const u8, start: usize, end: usize, interpolation: bool, sink: anytype) void { + var seg = start; + while (nextDollarRef(text, seg, end, interpolation)) |ref| { + if (ref.start > seg) sink.emit(seg, ref.start - seg, .string); + sink.emit(ref.start, ref.end - ref.start, ref.kind); + seg = ref.end; + } + if (end > seg) sink.emit(seg, end - seg, .string); +} + +const keywords = [_][]const u8{ + "function", "async", "await", "yield", "return", "if", "else", + "for", "while", "do", "switch", "case", "break", "continue", + "var", "let", "const", "new", "delete", "typeof", "instanceof", + "in", "of", "void", "this", "super", "class", "extends", + "import", "export", "from", "default", "try", "catch", "finally", + "throw", "true", "false", "null", "undefined", "NaN", "Infinity", +}; + +// Globals available in the JS-mode page context; highlighted so it's visible +// at the prompt that they're in scope. +const globals = [_][]const u8{ "document", "window", "globalThis", "console", "lp" }; + +fn isIdChar(ch: u8) bool { + return std.ascii.isAlphanumeric(ch) or ch == '_' or ch == '$' or ch >= 0x80; +} + +const testing = std.testing; + +/// Records spans as `kind:text` so tests read as the tokenization, not offsets. +const TestSink = struct { + text: []const u8, + buf: std.ArrayListUnmanaged(u8) = .empty, + last_end: usize = 0, + overlapped: bool = false, + + fn emit(self: *TestSink, start: usize, len: usize, kind: Kind) void { + if (start < self.last_end) self.overlapped = true; + self.last_end = start + len; + self.buf.writer(testing.allocator).print("{s}:{s} ", .{ + @tagName(kind), self.text[start..][0..len], + }) catch unreachable; + } +}; + +fn expectTokens(expected: []const u8, src: []const u8) !void { + var sink: TestSink = .{ .text = src }; + defer sink.buf.deinit(testing.allocator); + _ = tokenize(src, .normal, &sink); + try testing.expect(!sink.overlapped); + try testing.expectEqualStrings(expected, std.mem.trimRight(u8, sink.buf.items, " ")); +} + +test "js_highlight: keywords, globals, numbers" { + try expectTokens("keyword:const number:1", "const x = 1"); + try expectTokens("global:document", "document.body"); + // A property access is not the global. + try expectTokens("", "el.document"); +} + +test "js_highlight: comments and strings" { + try expectTokens("comment:// hi", "// hi"); + try expectTokens("comment:/* hi */ keyword:const", "/* hi */ const"); + try expectTokens("string:'hi'", "'hi'"); +} + +test "js_highlight: calls color by identifier case" { + try expectTokens("function:markdown string:'x'", "markdown('x')"); + try expectTokens("global:console method:log", "console.log(x)"); + try expectTokens("keyword:new type_name:URL", "new URL(u)"); + // A keyword before `(` stays a keyword; a bare identifier stays plain. + try expectTokens("keyword:if", "if (x)"); + try expectTokens("", "markdown"); +} + +test "js_highlight: $refs inside strings do not overlap" { + try expectTokens("string:'a variable:$LP_KEY string:'", "'a $LP_KEY'"); + try expectTokens("variable:$LP_KEY", "$LP_KEY"); +} + +test "js_highlight: template interpolations split string spans" { + try expectTokens("string:`a interpolation:${x.y} string:`", "`a ${x.y}`"); + // Unclosed interpolation runs to end of line. + try expectTokens("string:`a interpolation:${x", "`a ${x"); + // Only template literals interpolate. + try expectTokens("string:'a ${x}'", "'a ${x}'"); +} + +test "js_highlight: block comment spans lines" { + var sink: TestSink = .{ .text = "/* open" }; + defer sink.buf.deinit(testing.allocator); + try testing.expectEqual(State.block_comment, tokenize("/* open", .normal, &sink)); + + var sink2: TestSink = .{ .text = "still */ const" }; + defer sink2.buf.deinit(testing.allocator); + try testing.expectEqual(State.normal, tokenize("still */ const", .block_comment, &sink2)); + try testing.expectEqualStrings("comment:still */ keyword:const", std.mem.trimRight(u8, sink2.buf.items, " ")); +} + +test "js_highlight: template literal spans lines" { + var sink: TestSink = .{ .text = "`
" }; + defer sink.buf.deinit(testing.allocator); + try testing.expectEqual(State.template, tokenize("`
", .normal, &sink)); + + var sink2: TestSink = .{ .text = "
` + x" }; + defer sink2.buf.deinit(testing.allocator); + try testing.expectEqual(State.normal, tokenize("
` + x", .template, &sink2)); + try testing.expectEqualStrings("string:`", std.mem.trimRight(u8, sink2.buf.items, " ")); +} diff --git a/src/agent/md_term.zig b/src/agent/md_term.zig new file mode 100644 index 000000000..e4f1d1449 --- /dev/null +++ b/src/agent/md_term.zig @@ -0,0 +1,903 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const ansi = @import("ansi.zig"); +const js_highlight = @import("js_highlight.zig"); + +/// Render markdown `src` as ANSI-styled terminal output to `w` — one-shot +/// wrapper over `Stream`, so batch and streamed output share one dispatcher. +pub fn render(w: *std.Io.Writer, src: []const u8) !void { + var s: Stream = .{}; + try s.feed(w, src); + try s.close(w); +} + +const LineIterator = std.mem.SplitIterator(u8, .scalar); + +const max_table_columns = 16; + +/// Shown while a streamed table is withheld; written without a newline so +/// `clear_placeholder` can erase it in place before the table renders. +const table_placeholder = ansi.dim ++ "… rendering table" ++ ansi.reset; +const clear_placeholder = "\r" ++ ansi.clear_line; + +/// Cells are inline-rendered and padded to per-column visible width. Widths +/// count codepoints, so double-width glyphs (CJK, emoji) may misalign. +fn renderTable(w: *std.Io.Writer, header: []const u8, it: *LineIterator) !void { + var widths: [max_table_columns]usize = @splat(0); + const cols = measureTable(header, it.*, &widths) orelse + return renderTableVerbatim(w, header, it); + + try emitRow(w, header, widths[0..cols], true); + _ = it.next(); + try w.writeByte('\n'); + try emitSeparator(w, widths[0..cols]); + while (it.peek()) |line| { + if (!isTableRow(line)) break; + _ = it.next(); + try w.writeByte('\n'); + try emitRow(w, line, widths[0..cols], false); + } +} + +/// Fallback for tables the aligner can't measure (too many columns, or a +/// rendered cell overflowing its fixed buffer): rows pass through untouched. +fn renderTableVerbatim(w: *std.Io.Writer, header: []const u8, it: *LineIterator) !void { + try styled(w, header, ansi.bold); + while (it.peek()) |line| { + if (!isTableRow(line) and !isTableSeparator(line)) break; + _ = it.next(); + try w.writeByte('\n'); + if (isTableSeparator(line)) { + try styled(w, line, ansi.dim); + } else { + try w.writeAll(line); + } + } +} + +/// Per-column visible widths for the whole table (`it` positioned on the +/// separator row), or null when it can't be aligned: no cells, too many +/// columns, or a cell overflowing the rendering buffer. +fn measureTable(header: []const u8, it: LineIterator, widths: *[max_table_columns]usize) ?usize { + var cols = measureRow(header, widths, true) orelse return null; + var scan = it; + _ = scan.next(); + while (scan.peek()) |line| { + if (!isTableRow(line)) break; + _ = scan.next(); + cols = @max(cols, measureRow(line, widths, false) orelse return null); + } + return if (cols == 0) null else cols; +} + +/// Column count of `row`, or null when the table can't be aligned (too many +/// columns, or a cell overflowing the rendering buffer). +fn measureRow(row: []const u8, widths: *[max_table_columns]usize, is_header: bool) ?usize { + var cells = cellIterator(row); + var col: usize = 0; + while (cells.next()) |cell| { + if (col == max_table_columns) return null; + const width = cellDisplayWidth(cell, is_header) orelse return null; + widths[col] = @max(widths[col], width); + col += 1; + } + return col; +} + +fn emitRow(w: *std.Io.Writer, row: []const u8, widths: []const usize, is_header: bool) !void { + var cells = cellIterator(row); + for (widths) |width| { + try styled(w, "│", ansi.dim); + try w.writeByte(' '); + const cell = cells.next() orelse ""; + var used: usize = 0; + if (cell.len > 0) { + // Render into scratch so `used` is measured from the exact bytes + // written. measureRow rendered the identical bytes into an + // identically-sized buffer, so this cannot overflow here. + var buf: [cell_buf_len]u8 = undefined; + var fw: std.Io.Writer = .fixed(&buf); + try renderCell(&fw, cell, is_header); + try w.writeAll(fw.buffered()); + used = visibleWidth(fw.buffered()); + } + try w.splatByteAll(' ', width - used + 1); + } + try styled(w, "│", ansi.dim); +} + +fn renderCell(w: *std.Io.Writer, cell: []const u8, is_header: bool) std.Io.Writer.Error!void { + if (is_header) return span(w, cell, ansi.bold, null); + try renderInline(w, cell); +} + +fn emitSeparator(w: *std.Io.Writer, widths: []const usize) !void { + try w.writeAll(ansi.dim); + for (widths, 0..) |width, col| { + try w.writeAll(if (col == 0) "├" else "┼"); + try w.splatBytesAll("─", width + 2); + } + try w.writeAll("┤"); + try w.writeAll(ansi.reset); +} + +const CellIterator = struct { + row: []const u8, + pos: usize, + + fn next(self: *CellIterator) ?[]const u8 { + if (self.pos >= self.row.len) return null; + var i = self.pos; + var in_code = false; + while (i < self.row.len) : (i += 1) { + switch (self.row[i]) { + '`' => in_code = !in_code, + '\\' => i += 1, + '|' => if (!in_code) break, + else => {}, + } + } + const end = @min(i, self.row.len); + const cell = std.mem.trim(u8, self.row[self.pos..end], " \t"); + self.pos = i + 1; + // A row's trailing `|` leaves one empty last segment; drop it. + if (cell.len == 0 and i >= self.row.len) return null; + return cell; + } +}; + +fn cellIterator(row: []const u8) CellIterator { + const first_pipe = std.mem.indexOfScalar(u8, row, '|'); + return .{ .row = row, .pos = if (first_pipe) |p| p + 1 else 0 }; +} + +const cell_buf_len = 1024; + +fn cellDisplayWidth(cell: []const u8, is_header: bool) ?usize { + var buf: [cell_buf_len]u8 = undefined; + var fw: std.Io.Writer = .fixed(&buf); + renderCell(&fw, cell, is_header) catch return null; + return visibleWidth(fw.buffered()); +} + +/// Display columns of rendered output: escapes are zero, codepoints are one. +fn visibleWidth(s: []const u8) usize { + var n: usize = 0; + var i: usize = 0; + while (i < s.len) { + if (s[i] == 0x1b and i + 1 < s.len) { + switch (s[i + 1]) { + '[' => { + i += 2; + while (i < s.len and (s[i] < 0x40 or s[i] > 0x7e)) i += 1; + i += 1; + continue; + }, + ']' => { + i += 2; + while (i < s.len and s[i] != 0x07 and s[i] != 0x1b) i += 1; + i += @as(usize, if (i < s.len and s[i] == 0x1b) 2 else 1); + continue; + }, + '\\' => { + i += 2; + continue; + }, + else => {}, + } + } + if (s[i] & 0xc0 != 0x80) n += 1; + i += 1; + } + return n; +} + +fn isFenceDelimiter(line: []const u8) bool { + return std.mem.startsWith(u8, std.mem.trimLeft(u8, line, " \t"), "```"); +} + +/// Shared by the fence rules and the `---` horizontal rule so they line up. +const rule_width = 24; + +/// Dim `╭── lang ───` / `╰─────` rule marking where a fenced code block starts +/// and ends. The opening rule carries the fence's info-string language, if any. +fn renderFenceRule(w: *std.Io.Writer, opening: bool, delimiter: []const u8) !void { + try w.writeAll(ansi.dim); + try w.writeAll(if (opening) "╭" else "╰"); + var fill: usize = rule_width - 1; + if (opening) { + const info = std.mem.trim(u8, std.mem.trimLeft(u8, delimiter, " \t`"), " \t"); + const lang = info[0 .. std.mem.indexOfAny(u8, info, " \t") orelse info.len]; + if (lang.len > 0 and lang.len + 4 <= fill) { + try w.writeAll("─ "); + try w.writeAll(lang); + try w.writeByte(' '); + fill -= lang.len + 3; + } + } + try w.splatBytesAll("─", fill); + try w.writeAll(ansi.reset); +} + +fn isTableRow(line: []const u8) bool { + const trimmed = std.mem.trimLeft(u8, line, " \t"); + return trimmed.len >= 1 and trimmed[0] == '|'; +} + +/// A row of `-`, `:`, `|` and spaces with at least one dash and one pipe, +/// e.g. `| --- |:---:|`. +fn isTableSeparator(line: []const u8) bool { + var has_dash = false; + var has_pipe = false; + for (line) |c| switch (c) { + '-' => has_dash = true, + '|' => has_pipe = true, + ':', ' ', '\t' => {}, + else => return false, + }; + return has_dash and has_pipe; +} + +/// Incremental renderer for streamed deltas: buffers until each newline, +/// then renders the completed line. Fence state carries across lines. Table +/// rows are withheld and re-rendered aligned once the table ends — alignment +/// needs the whole table, so it can't stream row by row. +pub const Stream = struct { + /// Show a progress marker while a streamed table is withheld, erased in + /// place before the table renders. Only for streaming to an interactive + /// terminal (the erase assumes the cursor still sits on the marker); + /// off, withheld tables stay silent until they render. + show_table_placeholder: bool = false, + len: usize = 0, + in_fence: bool = false, + /// Fenced-code lexer state, carried across lines and chunks. + js_state: js_highlight.State = .normal, + /// A line that outgrew `buf` passes through unrendered to its newline. + raw: bool = false, + /// `held`: one pipe row buffered, pending the separator that confirms a + /// table. `table`: rows accumulate in `table_buf` until the table ends. + mode: enum { text, held, table } = .text, + table_len: usize = 0, + buf: [4096]u8 = undefined, + /// A table that outgrows this falls back to per-line rendering. + table_buf: [16384]u8 = undefined, + + pub fn feed(self: *Stream, w: *std.Io.Writer, data: []const u8) !void { + var rest = data; + while (std.mem.indexOfScalar(u8, rest, '\n')) |nl| { + const head = rest[0..nl]; + rest = rest[nl + 1 ..]; + if (self.raw) { + try w.writeAll(head); + try w.writeByte('\n'); + self.raw = false; + } else if (self.len == 0) { + try self.emitLine(w, head); + } else if (self.len + head.len <= self.buf.len) { + @memcpy(self.buf[self.len..][0..head.len], head); + const full = self.buf[0 .. self.len + head.len]; + self.len = 0; + try self.emitLine(w, full); + } else { + try w.writeAll(self.buf[0..self.len]); + try w.writeAll(head); + try w.writeByte('\n'); + self.len = 0; + } + } + if (rest.len == 0) return; + if (self.raw) { + try w.writeAll(rest); + } else if (self.len + rest.len <= self.buf.len) { + @memcpy(self.buf[self.len..][0..rest.len], rest); + self.len += rest.len; + } else { + try w.writeAll(self.buf[0..self.len]); + try w.writeAll(rest); + self.len = 0; + self.raw = true; + } + } + + /// Flush any withheld table and a trailing partial line (no newline is + /// written for it), then reset all state for the next message. + pub fn close(self: *Stream, w: *std.Io.Writer) !void { + defer self.* = .{ .show_table_placeholder = self.show_table_placeholder }; + // A message often ends inside a table: adopt the partial last row. + if (self.len > 0 and !self.raw) { + const partial = self.buf[0..self.len]; + const adopt = switch (self.mode) { + .table => isTableRow(partial), + .held => isTableSeparator(partial), + .text => false, + }; + if (adopt and self.appendTableLine(partial)) { + self.mode = .table; + self.len = 0; + } + } + switch (self.mode) { + .text => {}, + .held => try self.releaseHeldRow(w), + .table => try self.renderBufferedTable(w), + } + if (self.len == 0) return; + const partial = self.buf[0..self.len]; + if (isFenceDelimiter(partial)) { + if (self.in_fence) try w.writeByte('\n'); + return renderFenceRule(w, !self.in_fence, partial); + } + try renderLine(w, partial, if (self.in_fence) &self.js_state else null); + } + + fn emitLine(self: *Stream, w: *std.Io.Writer, text: []const u8) std.Io.Writer.Error!void { + switch (self.mode) { + .text => { + if (isFenceDelimiter(text)) { + self.in_fence = !self.in_fence; + self.js_state = .normal; + if (!self.in_fence) try w.writeByte('\n'); + try renderFenceRule(w, self.in_fence, text); + try w.writeByte('\n'); + if (self.in_fence) try w.writeByte('\n'); + return; + } + if (!self.in_fence and isTableRow(text) and self.appendTableLine(text)) { + self.mode = .held; + return; + } + try renderLine(w, text, if (self.in_fence) &self.js_state else null); + try w.writeByte('\n'); + }, + .held => { + if (isTableSeparator(text) and self.appendTableLine(text)) { + self.mode = .table; + // Withholding goes quiet; leave a marker until the + // table renders over it. + if (self.show_table_placeholder) try w.writeAll(table_placeholder); + return; + } + try self.releaseHeldRow(w); + try self.emitLine(w, text); + }, + .table => { + if (isTableRow(text)) { + if (self.appendTableLine(text)) return; + try self.flushTableUnaligned(w); + try renderLine(w, text, null); + try w.writeByte('\n'); + return; + } + try self.renderBufferedTable(w); + try self.emitLine(w, text); + }, + } + } + + /// No separator followed, so the held row wasn't a table header. + fn releaseHeldRow(self: *Stream, w: *std.Io.Writer) std.Io.Writer.Error!void { + try renderLine(w, self.tableText(), null); + try w.writeByte('\n'); + self.resetTable(); + } + + fn renderBufferedTable(self: *Stream, w: *std.Io.Writer) std.Io.Writer.Error!void { + if (self.show_table_placeholder) try w.writeAll(clear_placeholder); + var it: LineIterator = std.mem.splitScalar(u8, self.tableText(), '\n'); + const header = it.first(); + try renderTable(w, header, &it); + try w.writeByte('\n'); + self.resetTable(); + } + + fn flushTableUnaligned(self: *Stream, w: *std.Io.Writer) std.Io.Writer.Error!void { + if (self.show_table_placeholder) try w.writeAll(clear_placeholder); + var it = std.mem.splitScalar(u8, self.tableText(), '\n'); + while (it.next()) |line| { + try renderLine(w, line, null); + try w.writeByte('\n'); + } + self.resetTable(); + } + + fn appendTableLine(self: *Stream, line: []const u8) bool { + const sep: usize = if (self.table_len == 0) 0 else 1; + if (self.table_len + sep + line.len > self.table_buf.len) return false; + if (sep == 1) { + self.table_buf[self.table_len] = '\n'; + self.table_len += 1; + } + @memcpy(self.table_buf[self.table_len..][0..line.len], line); + self.table_len += line.len; + return true; + } + + fn tableText(self: *const Stream) []const u8 { + return self.table_buf[0..self.table_len]; + } + + fn resetTable(self: *Stream) void { + self.table_len = 0; + self.mode = .text; + } +}; + +/// `js` carries the fenced-code lexer state across lines; null outside a fence. +fn renderLine(w: *std.Io.Writer, line: []const u8, js: ?*js_highlight.State) !void { + if (js) |state| { + state.* = try renderCodeLine(w, line, state.*); + return; + } + + const indent_len = line.len - std.mem.trimLeft(u8, line, " \t").len; + const indent = line[0..indent_len]; + const trimmed = line[indent_len..]; + + if (trimmed.len >= 1 and trimmed[0] == '>') { + try styled(w, "│", ansi.dim); + try w.writeByte(' '); + try renderInline(w, std.mem.trimLeft(u8, trimmed[1..], " ")); + return; + } + + // Dashed, unlike the solid fence rules, so adjacent ones read differently. + if (isHorizontalRule(trimmed)) { + try styled(w, "┄" ** rule_width, ansi.dim); + return; + } + + var hashes: usize = 0; + while (hashes < trimmed.len and trimmed[hashes] == '#') hashes += 1; + if (hashes >= 1 and hashes <= 6 and hashes < trimmed.len and trimmed[hashes] == ' ') { + try span(w, std.mem.trimLeft(u8, trimmed[hashes..], " "), ansi.bold, null); + return; + } + + if (trimmed.len >= 2 and (trimmed[0] == '-' or trimmed[0] == '*' or trimmed[0] == '+') and trimmed[1] == ' ') { + try w.writeAll(indent); + try styled(w, "•", ansi.dim); + try w.writeByte(' '); + try renderInline(w, std.mem.trimLeft(u8, trimmed[2..], " ")); + return; + } + + var digits: usize = 0; + while (digits < trimmed.len and std.ascii.isDigit(trimmed[digits])) digits += 1; + if (digits > 0 and digits + 1 < trimmed.len and + (trimmed[digits] == '.' or trimmed[digits] == ')') and trimmed[digits + 1] == ' ') + { + try w.writeAll(indent); + try w.writeAll(trimmed[0 .. digits + 2]); + try renderInline(w, std.mem.trimLeft(u8, trimmed[digits + 2 ..], " ")); + return; + } + + try renderInline(w, line); +} + +/// Stack-threaded chain of enclosing span styles; a nested span's reset +/// re-applies the whole chain, so styling survives arbitrary nesting depth. +const Style = struct { + code: []const u8, + parent: ?*const Style, + + fn apply(self: *const Style, w: *std.Io.Writer) std.Io.Writer.Error!void { + if (self.parent) |p| try p.apply(w); + try w.writeAll(self.code); + } + + fn applyOpt(active: ?*const Style, w: *std.Io.Writer) std.Io.Writer.Error!void { + if (active) |a| try a.apply(w); + } +}; + +fn renderInline(w: *std.Io.Writer, text: []const u8) !void { + try renderInlineStyled(w, text, null); +} + +fn renderInlineStyled(w: *std.Io.Writer, text: []const u8, active: ?*const Style) std.Io.Writer.Error!void { + var i: usize = 0; + while (i < text.len) { + switch (text[i]) { + // Only unescape markdown-special chars; leave e.g. `C:\Users` intact. + '\\' => if (i + 1 < text.len and isEscapable(text[i + 1])) { + try w.writeByte(text[i + 1]); + i += 2; + continue; + }, + '`' => if (std.mem.indexOfPos(u8, text, i + 1, "`")) |end| { + try styled(w, text[i + 1 .. end], ansi.teal); + try Style.applyOpt(active, w); + i = end + 1; + continue; + }, + '*', '_' => |ch| { + const double = [2]u8{ ch, ch }; + if (i + 1 < text.len and text[i + 1] == ch) { + if (std.mem.indexOfPos(u8, text, i + 2, &double)) |end| { + try span(w, text[i + 2 .. end], ansi.bold, active); + i = end + 2; + continue; + } + } else if (std.mem.indexOfScalarPos(u8, text, i + 1, ch)) |end| { + try span(w, text[i + 1 .. end], ansi.italic, active); + i = end + 1; + continue; + } + }, + '~' => if (i + 1 < text.len and text[i + 1] == '~') { + if (std.mem.indexOfPos(u8, text, i + 2, "~~")) |end| { + try span(w, text[i + 2 .. end], ansi.strike, active); + i = end + 2; + continue; + } + }, + '[' => if (std.mem.indexOfPos(u8, text, i + 1, "](")) |mid| { + if (std.mem.indexOfScalarPos(u8, text, mid + 2, ')')) |end| { + try renderLink(w, text[i + 1 .. mid], text[mid + 2 .. end]); + try Style.applyOpt(active, w); + i = end + 1; + continue; + } + }, + else => {}, + } + try w.writeByte(text[i]); + i += 1; + } +} + +fn span(w: *std.Io.Writer, inner: []const u8, style: []const u8, active: ?*const Style) std.Io.Writer.Error!void { + try w.writeAll(style); + try renderInlineStyled(w, inner, &.{ .code = style, .parent = active }); + try w.writeAll(ansi.reset); + try Style.applyOpt(active, w); +} + +/// Writes `js_highlight` spans as ANSI, filling the gaps between them with +/// unstyled text. `emit` cannot fail, so a write error is stashed and returned +/// by `finish`. +const JsSink = struct { + w: *std.Io.Writer, + text: []const u8, + last: usize = 0, + err: ?std.Io.Writer.Error = null, + + fn color(kind: js_highlight.Kind) []const u8 { + return switch (kind) { + .comment => ansi.dim ++ ansi.italic, + .string => ansi.green, + .variable, .interpolation => ansi.yellow, + .number => ansi.magenta, + .keyword => ansi.blue ++ ansi.bold, + .global, .type_name => ansi.cyan, + .function => ansi.teal, + .method => ansi.teal ++ ansi.italic, + }; + } + + pub fn emit(self: *JsSink, start: usize, len: usize, kind: js_highlight.Kind) void { + self.write(start, len, kind) catch |err| { + self.err = self.err orelse err; + }; + } + + fn write(self: *JsSink, start: usize, len: usize, kind: js_highlight.Kind) !void { + if (start > self.last) try self.w.writeAll(self.text[self.last..start]); + try styled(self.w, self.text[start..][0..len], color(kind)); + self.last = start + len; + } + + fn finish(self: *JsSink) !void { + if (self.err) |err| return err; + if (self.last < self.text.len) try self.w.writeAll(self.text[self.last..]); + } +}; + +/// Syntax-highlight one line of fenced code as JavaScript — the only language +/// this agent emits in practice. Returns the lexer state for the next line. +fn renderCodeLine(w: *std.Io.Writer, line: []const u8, state: js_highlight.State) !js_highlight.State { + var sink: JsSink = .{ .w = w, .text = line }; + const next = js_highlight.tokenize(line, state, &sink); + try sink.finish(); + return next; +} + +fn styled(w: *std.Io.Writer, inner: []const u8, style: []const u8) !void { + try w.writeAll(style); + try w.writeAll(inner); + try w.writeAll(ansi.reset); +} + +fn isEscapable(c: u8) bool { + return switch (c) { + '*', '_', '`', '~', '[', ']', '(', ')', '|', '\\' => true, + else => false, + }; +} + +/// A left-trimmed line of 3+ identical `-`, `*` or `_` markers — the whole +/// line, so `***bold***` stays inline text. +fn isHorizontalRule(line: []const u8) bool { + if (line.len < 3) return false; + const first = line[0]; + if (first != '-' and first != '*' and first != '_') return false; + for (line[1..]) |c| { + if (c != first) return false; + } + return true; +} + +fn renderLink(w: *std.Io.Writer, label: []const u8, url: []const u8) !void { + // OSC 8 makes the label clickable where supported and is ignored elsewhere; + // the trailing dim url is the fallback for terminals without OSC 8. + try w.print("\x1b]8;;{s}\x1b\\", .{url}); + try styled(w, label, ansi.underline); + try w.writeAll("\x1b]8;;\x1b\\"); + if (!std.mem.eql(u8, label, url)) try w.print(" {s}({s}){s}", .{ ansi.dim, url, ansi.reset }); +} + +const testing = std.testing; + +fn expectRender(expected: []const u8, src: []const u8) !void { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try render(&aw.writer, src); + try testing.expectEqualStrings(expected, aw.written()); +} + +test "md_term: inline styles" { + try expectRender("say \x1b[1mhi\x1b[0m now", "say **hi** now"); + try expectRender("say \x1b[3mhi\x1b[0m now", "say *hi* now"); + try expectRender("say \x1b[3mhi\x1b[0m now", "say _hi_ now"); + try expectRender("run \x1b[36mls\x1b[0m ok", "run `ls` ok"); + try expectRender("no \x1b[9mway\x1b[0m", "no ~~way~~"); +} + +test "md_term: blocks" { + try expectRender("\x1b[1mTitle\x1b[0m", "# Title"); + try expectRender("\x1b[1mSub\x1b[0m", "### Sub"); + try expectRender("\x1b[2m•\x1b[0m item", "- item"); + try expectRender("1. item", "1. item"); +} + +test "md_term: alignment spaces after list marker collapse" { + try expectRender("\x1b[2m•\x1b[0m item", "- item"); + try expectRender("1. item", "1. item"); +} + +test "md_term: nested inline styles" { + // The code span's reset re-applies the enclosing bold. + try expectRender( + "\x1b[1muse \x1b[36mls\x1b[0m\x1b[1m now\x1b[0m", + "**use `ls` now**", + ); + try expectRender( + "\x1b[1m\x1b[36mgoto(url)\x1b[0m\x1b[1m\x1b[0m: nav", + "**`goto(url)`**: nav", + ); + // Inside a heading too: bold survives past the code span. + try expectRender( + "\x1b[1mrun \x1b[36mls\x1b[0m\x1b[1m first\x1b[0m", + "## run `ls` first", + ); + // Italic nested in bold stacks, then bold is restored. + try expectRender( + "\x1b[1ma \x1b[3mb\x1b[0m\x1b[1m c\x1b[0m", + "**a *b* c**", + ); + // Depth 2: the code span's reset re-applies the full bold+italic chain. + try expectRender( + "\x1b[1ma \x1b[3mb \x1b[36mc\x1b[0m\x1b[1m\x1b[3m d\x1b[0m\x1b[1m e\x1b[0m", + "**a *b `c` d* e**", + ); +} + +const open_rule = "\x1b[2m╭" ++ "─" ** 23 ++ "\x1b[0m"; +const close_rule = "\x1b[2m╰" ++ "─" ** 23 ++ "\x1b[0m"; + +test "md_term: fenced code block is highlighted as JavaScript" { + try expectRender( + open_rule ++ "\n\n\x1b[34m\x1b[1mlet\x1b[0m x = \x1b[35m1\x1b[0m;\n\n" ++ close_rule, + "```\nlet x = 1;\n```", + ); + // Untokenized text passes through unstyled. + try expectRender(open_rule ++ "\n\nplain\n\n" ++ close_rule, "```\nplain\n```"); +} + +test "md_term: fence rules carry the language tag" { + try expectRender( + "\x1b[2m╭─ js " ++ "─" ** 18 ++ "\x1b[0m\n\nx\n\n" ++ close_rule, + "```js\nx\n```", + ); + // An overlong info string doesn't fit the rule and is dropped. + try expectRender( + open_rule ++ "\n\nx\n\n" ++ close_rule, + "```" ++ "x" ** 20 ++ "\nx\n```", + ); +} + +test "md_term: fenced template literal spans lines" { + try expectRender( + open_rule ++ "\n\n\x1b[32m`
\x1b[0m\n\x1b[32m
`\x1b[0m\n\n" ++ close_rule, + "```\n`
\n
`\n```", + ); +} + +test "md_term: link" { + // OSC 8 hyperlink around the label, plus a dim fallback url. + try expectRender( + "\x1b]8;;https://x.io\x1b\\\x1b[4mLP\x1b[0m\x1b]8;;\x1b\\ \x1b[2m(https://x.io)\x1b[0m", + "[LP](https://x.io)", + ); + // A bare link (label == url) omits the redundant suffix. + try expectRender( + "\x1b]8;;https://x.io\x1b\\\x1b[4mhttps://x.io\x1b[0m\x1b]8;;\x1b\\", + "[https://x.io](https://x.io)", + ); +} + +test "md_term: blockquote" { + try expectRender("\x1b[2m│\x1b[0m quoted \x1b[1mnote\x1b[0m", "> quoted **note**"); +} + +test "md_term: horizontal rule" { + try expectRender("\x1b[2m" ++ "┄" ** 24 ++ "\x1b[0m", "---"); + try expectRender("\x1b[2m" ++ "┄" ** 24 ++ "\x1b[0m", "***"); + try expectRender("---x", "---x"); +} + +test "md_term: tables align columns and style cells" { + const B = "\x1b[1m"; + const C = "\x1b[36m"; + const D = "\x1b[2m"; + const R = "\x1b[0m"; + const pipe = D ++ "│" ++ R; + + // Source is already padded; rendering keeps the alignment after + // stripping the cell markers. + try expectRender( + pipe ++ " " ++ B ++ "Tool" ++ R ++ " " ++ pipe ++ " " ++ B ++ "Use" ++ R ++ " " ++ pipe ++ "\n" ++ + D ++ "├──────┼─────┤" ++ R ++ "\n" ++ + pipe ++ " " ++ C ++ "goto" ++ R ++ " " ++ pipe ++ " nav " ++ pipe ++ "\n", + "| Tool | Use |\n|--------|-----|\n| `goto` | nav |", + ); + // Gemini-style unpadded source: columns are padded to the widest + // rendered cell. + try expectRender( + pipe ++ " " ++ B ++ "Tool" ++ R ++ " " ++ pipe ++ " " ++ B ++ "Usage" ++ R ++ " " ++ pipe ++ "\n" ++ + D ++ "├──────┼─────────────┤" ++ R ++ "\n" ++ + pipe ++ " goto " ++ pipe ++ " " ++ B ++ "Nav:" ++ R ++ " to url " ++ pipe ++ "\n", + "| Tool | Usage |\n| :--- | :--- |\n| goto | **Nav:** to url |", + ); + // A pipe line without a separator row underneath is not a table. + try expectRender("| just \x1b[1mtext\x1b[0m |", "| just **text** |"); +} + +test "md_term: overwide table falls back to verbatim rows" { + const header = "|a" ** 17 ++ "|"; + const sep = "|-" ** 17 ++ "|"; + try expectRender( + "\x1b[1m" ++ header ++ "\x1b[0m\n\x1b[2m" ++ sep ++ "\x1b[0m\n", + header ++ "\n" ++ sep, + ); +} + +test "md_term: stream renders across chunk boundaries" { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + var s: Stream = .{}; + try s.feed(&aw.writer, "say **h"); + try s.feed(&aw.writer, "i** now\n- ite"); + try s.feed(&aw.writer, "m\ntail"); + try s.close(&aw.writer); + try testing.expectEqualStrings( + "say \x1b[1mhi\x1b[0m now\n\x1b[2m•\x1b[0m item\ntail", + aw.written(), + ); +} + +test "md_term: stream withholds tables and renders them aligned" { + const B = "\x1b[1m"; + const C = "\x1b[36m"; + const D = "\x1b[2m"; + const R = "\x1b[0m"; + const pipe = D ++ "│" ++ R; + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + var s: Stream = .{ .show_table_placeholder = true }; + try s.feed(&aw.writer, "| Tool | Use |\n| :--- | :-"); + try testing.expectEqualStrings("", aw.written()); + try s.feed(&aw.writer, "-- |\n| `goto` | nav |\ndone\n"); + try testing.expectEqualStrings( + D ++ "… rendering table" ++ R ++ "\r\x1b[2K" ++ + pipe ++ " " ++ B ++ "Tool" ++ R ++ " " ++ pipe ++ " " ++ B ++ "Use" ++ R ++ " " ++ pipe ++ "\n" ++ + D ++ "├──────┼─────┤" ++ R ++ "\n" ++ + pipe ++ " " ++ C ++ "goto" ++ R ++ " " ++ pipe ++ " nav " ++ pipe ++ "\n" ++ + "done\n", + aw.written(), + ); +} + +test "md_term: stream table ending at close adopts the partial row" { + const B = "\x1b[1m"; + const D = "\x1b[2m"; + const R = "\x1b[0m"; + const pipe = D ++ "│" ++ R; + + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + var s: Stream = .{ .show_table_placeholder = true }; + try s.feed(&aw.writer, "| A | B |\n|-|-|\n| x | y |"); + try s.close(&aw.writer); + try testing.expectEqualStrings( + D ++ "… rendering table" ++ R ++ "\r\x1b[2K" ++ + pipe ++ " " ++ B ++ "A" ++ R ++ " " ++ pipe ++ " " ++ B ++ "B" ++ R ++ " " ++ pipe ++ "\n" ++ + D ++ "├───┼───┤" ++ R ++ "\n" ++ + pipe ++ " x " ++ pipe ++ " y " ++ pipe ++ "\n", + aw.written(), + ); +} + +test "md_term: stream releases a lone pipe row" { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + var s: Stream = .{}; + try s.feed(&aw.writer, "| a |\nplain\n"); + try s.close(&aw.writer); + try testing.expectEqualStrings("| a |\nplain\n", aw.written()); +} + +test "md_term: stream fence state spans chunks" { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + var s: Stream = .{}; + try s.feed(&aw.writer, "```\ncode\n``"); + try s.feed(&aw.writer, "`\nafter\n"); + try s.close(&aw.writer); + try testing.expectEqualStrings( + open_rule ++ "\n\ncode\n\n" ++ close_rule ++ "\nafter\n", + aw.written(), + ); +} + +test "md_term: stream closing fence at message end still draws its rule" { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + var s: Stream = .{}; + try s.feed(&aw.writer, "```\ncode\n```"); + try s.close(&aw.writer); + try testing.expectEqualStrings( + open_rule ++ "\n\ncode\n\n" ++ close_rule, + aw.written(), + ); +} + +test "md_term: backslash escapes" { + try expectRender("a * b", "a \\* b"); + try expectRender("keep \\d and C:\\Users", "keep \\d and C:\\Users"); +} + +test "md_term: unterminated markers stay literal" { + try expectRender("say **hi now", "say **hi now"); + try expectRender("a * b", "a * b"); + try expectRender("see [x](y", "see [x](y"); +} diff --git a/src/agent/picker.zig b/src/agent/picker.zig new file mode 100644 index 000000000..4993e8dd2 --- /dev/null +++ b/src/agent/picker.zig @@ -0,0 +1,262 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Interactive numbered-choice picker for stdin/stderr prompts (provider +//! selection, /save mode, …). Self-contained raw-terminal handling; runs +//! before — or without — the isocline REPL. + +const std = @import("std"); +const ansi = @import("ansi.zig"); + +pub fn interactiveTty() bool { + return std.posix.isatty(std.posix.STDIN_FILENO) and std.posix.isatty(std.posix.STDERR_FILENO); +} + +/// Numbered TTY picker. `default` (if set) marks that row "(default)" and +/// makes Enter start on that index. Up/Down moves the active row; Enter +/// selects it. Numbered input still works for users who prefer typing. +pub fn promptNumberedChoice(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize { + if (items.len == 0) return error.NoChoice; + const valid_default: ?usize = if (default) |d| if (d < items.len) d else null else null; + if (interactiveTty()) { + return promptInteractiveChoice(header, items, valid_default) catch |err| switch (err) { + error.NotInteractive => try promptNumberedChoiceLine(header, items, valid_default), + else => err, + }; + } + return promptNumberedChoiceLine(header, items, valid_default); +} + +/// Line-oriented fallback. Errors with NoChoice after 3 invalid attempts. +fn promptNumberedChoiceLine(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize { + var stdin_buf: [128]u8 = undefined; + var stdin = std.fs.File.stdin().reader(&stdin_buf); + + var attempt: u8 = 0; + while (attempt < 3) : (attempt += 1) { + std.debug.print("{s}\n", .{header}); + for (items, 0..) |item, idx| { + const marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else ""; + std.debug.print(" {d:>3}) {s}{s}\n", .{ idx + 1, item, marker }); + } + std.debug.print("> ", .{}); + + const line = stdin.interface.takeDelimiterInclusive('\n') catch |err| switch (err) { + error.EndOfStream, error.StreamTooLong, error.ReadFailed => return error.UserCancelled, + }; + const trimmed = std.mem.trim(u8, line, " \t\r\n"); + if (trimmed.len == 0) { + if (default) |d| return d; + std.debug.print("Invalid input — type a number.\n", .{}); + continue; + } + const choice = std.fmt.parseInt(usize, trimmed, 10) catch { + const hint: []const u8 = if (default != null) " (or press Enter for default)" else ""; + std.debug.print("Invalid input — type a number{s}.\n", .{hint}); + continue; + }; + if (choice >= 1 and choice <= items.len) return choice - 1; + std.debug.print("Out of range.\n", .{}); + } + return error.NoChoice; +} + +const ChoiceInput = enum { up, down, enter, cancel, ignore }; + +const ChoiceState = struct { + selected: usize, + + fn init(default: ?usize) ChoiceState { + return .{ .selected = default orelse 0 }; + } + + fn apply(self: *ChoiceState, input: ChoiceInput, item_count: usize) ?usize { + switch (input) { + .up => self.selected = if (self.selected == 0) item_count - 1 else self.selected - 1, + .down => self.selected = (self.selected + 1) % item_count, + .enter => return self.selected, + .cancel, .ignore => {}, + } + return null; + } +}; + +const RawTerminal = struct { + original: std.posix.termios, + + fn enable() error{NotInteractive}!RawTerminal { + if (!interactiveTty()) return error.NotInteractive; + // A tty that refuses raw mode is non-interactive for our purposes. + const original = std.posix.tcgetattr(std.posix.STDIN_FILENO) catch return error.NotInteractive; + var raw = original; + raw.iflag.BRKINT = false; + raw.iflag.ICRNL = false; + raw.iflag.INPCK = false; + raw.iflag.ISTRIP = false; + raw.iflag.IXON = false; + raw.oflag.OPOST = false; + raw.cflag.CSIZE = .CS8; + raw.lflag.ECHO = false; + raw.lflag.ICANON = false; + raw.lflag.IEXTEN = false; + raw.lflag.ISIG = false; + raw.cc[@intFromEnum(std.c.V.MIN)] = 0; + raw.cc[@intFromEnum(std.c.V.TIME)] = 1; + std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, raw) catch return error.NotInteractive; + // Under `ansi.kitty_disambiguate` (pushed by `Terminal.readLine`), + // cursor keys arrive as CSI-u the byte reader can't parse; push the + // legacy encoding to force plain arrows. restore() pops back to + // whatever the REPL had pushed. + _ = std.posix.write(std.posix.STDOUT_FILENO, ansi.kitty_legacy) catch {}; + return .{ .original = original }; + } + + fn restore(self: *const RawTerminal) void { + _ = std.posix.write(std.posix.STDOUT_FILENO, ansi.kitty_pop) catch {}; + std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, self.original) catch {}; + } +}; + +fn promptInteractiveChoice(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize { + var raw: RawTerminal = try .enable(); + defer raw.restore(); + + var state: ChoiceState = .init(default); + const line_count = items.len + 2; + var first_render = true; + while (true) { + renderChoice(header, items, default, state.selected, first_render); + first_render = false; + + const input = readChoiceInput() catch return error.UserCancelled; + if (input == .cancel) { + clearChoiceRender(line_count); + return error.UserCancelled; + } + if (state.apply(input, items.len)) |idx| { + clearChoiceRender(line_count); + std.debug.print("{s} {s}\r\n", .{ header, items[idx] }); + return idx; + } + } +} + +/// Emit a whole redraw frame in one write — per-line writes can tear +/// visually mid-frame (same approach as Spinner's renderLocked). A frame +/// that outgrows the buffer is emitted truncated. +fn emitFrame(bytes: []const u8) void { + std.debug.lockStdErr(); + defer std.debug.unlockStdErr(); + _ = std.posix.write(std.posix.STDERR_FILENO, bytes) catch {}; +} + +const frame_buf_len = 4096; + +fn clearChoiceRender(line_count: usize) void { + var buf: [frame_buf_len]u8 = undefined; + var fw: std.Io.Writer = .fixed(&buf); + blk: { + moveChoiceRenderStart(&fw, line_count) catch break :blk; + for (0..line_count) |i| { + fw.writeAll(ansi.clear_line) catch break :blk; + if (i + 1 < line_count) fw.writeAll("\r\n") catch break :blk; + } + moveChoiceRenderStart(&fw, line_count) catch break :blk; + } + emitFrame(fw.buffered()); +} + +fn moveChoiceRenderStart(w: *std.Io.Writer, line_count: usize) std.Io.Writer.Error!void { + if (line_count > 1) { + try w.print("\x1b[{d}F", .{line_count - 1}); + } else { + try w.writeAll("\r"); + } +} + +fn renderChoice(header: []const u8, items: []const [:0]const u8, default: ?usize, selected: usize, first_render: bool) void { + var buf: [frame_buf_len]u8 = undefined; + var fw: std.Io.Writer = .fixed(&buf); + writeChoiceFrame(&fw, header, items, default, selected, first_render) catch {}; + emitFrame(fw.buffered()); +} + +fn writeChoiceFrame(w: *std.Io.Writer, header: []const u8, items: []const [:0]const u8, default: ?usize, selected: usize, first_render: bool) std.Io.Writer.Error!void { + if (!first_render) try moveChoiceRenderStart(w, items.len + 2); + try w.print(ansi.clear_line ++ "{s}\r\n", .{header}); + for (items, 0..) |item, idx| { + const on_row = idx == selected; + const marker: []const u8 = if (on_row) ">" else " "; + const style: []const u8 = if (on_row) ansi.bold ++ ansi.teal else ""; + const reset: []const u8 = if (on_row) ansi.reset else ""; + const default_marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else ""; + try w.print(ansi.clear_line ++ " {s} {s}{s}{s}{s}\r\n", .{ marker, style, item, default_marker, reset }); + } + try w.print(ansi.clear_line ++ "{s}Use Up/Down then Enter. Esc cancels.{s}", .{ ansi.dim, ansi.reset }); +} + +fn readChoiceInput() !ChoiceInput { + while (true) { + const ch = try readChoiceByte() orelse continue; + return switch (ch) { + 3, 4, 27 => esc: { + if (ch != 27) break :esc .cancel; + const b1 = try readChoiceByte() orelse break :esc .cancel; + if (b1 != '[' and b1 != 'O') break :esc .cancel; + const b2 = try readChoiceByte() orelse break :esc .cancel; + break :esc switch (b2) { + 'A' => .up, + 'B' => .down, + else => .ignore, + }; + }, + '\r', '\n' => .enter, + else => .ignore, + }; + } +} + +fn readChoiceByte() !?u8 { + var buf: [1]u8 = undefined; + const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch |err| switch (err) { + error.WouldBlock => return null, + error.InputOutput => return error.ReadFailed, + else => return err, + }; + if (n == 0) return null; + return buf[0]; +} + +test "ChoiceState: arrows wrap and enter selects highlighted item" { + var state: ChoiceState = .init(null); + try std.testing.expectEqual(@as(usize, 0), state.selected); + + try std.testing.expectEqual(@as(?usize, null), state.apply(.up, 3)); + try std.testing.expectEqual(@as(usize, 2), state.selected); + + try std.testing.expectEqual(@as(?usize, null), state.apply(.down, 3)); + try std.testing.expectEqual(@as(usize, 0), state.selected); + + try std.testing.expectEqual(@as(?usize, 0), state.apply(.enter, 3)); +} + +test "ChoiceState: starts on default and enter returns it" { + var state: ChoiceState = .init(2); + try std.testing.expectEqual(@as(usize, 2), state.selected); + try std.testing.expectEqual(@as(?usize, 2), state.apply(.enter, 3)); +} diff --git a/src/agent/prompt_assist.zig b/src/agent/prompt_assist.zig new file mode 100644 index 000000000..feda72dc9 --- /dev/null +++ b/src/agent/prompt_assist.zig @@ -0,0 +1,840 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The REPL prompt's input assistance: isocline completion, ghost hints, and +//! syntax highlighting over the slash-command schemas and JS mode. Terminal +//! owns the readline lifecycle; this module owns everything that reacts to +//! the buffer while the user types. + +const std = @import("std"); +const lp = @import("lightpanda"); +const browser_tools = lp.tools; +const Schema = lp.Schema; +const SlashCommand = @import("SlashCommand.zig"); +const js_highlight = @import("js_highlight.zig"); +const c = @cImport({ + @cInclude("isocline.h"); +}); + +const style_slash = "ps-slash"; +const style_string = "ps-string"; +const style_var = "ps-var"; +const style_url = "ps-url"; +const style_key = "ps-key"; +const style_num = "ps-num"; +const style_err = "ps-err"; +const style_jsmode = "ps-jsmode"; + +/// The prompt's style palette (`ps-*` avoids isocline's built-in `ic-*` +/// namespace). Registration and token-kind painting both derive from it, so +/// a painted style is registered by construction. +const Style = struct { + name: [:0]const u8, + spec: [:0]const u8, + /// `js_highlight` token kinds painted with this style; empty for styles + /// applied directly by name. + kinds: []const js_highlight.Kind = &.{}, +}; + +const styles = [_]Style{ + .{ .name = style_slash, .spec = "ansi-teal bold" }, + .{ .name = style_string, .spec = "ansi-green", .kinds = &.{.string} }, + .{ .name = style_var, .spec = "ansi-yellow", .kinds = &.{ .variable, .interpolation } }, + .{ .name = style_url, .spec = "ansi-blue underline" }, + .{ .name = style_key, .spec = "ansi-blue" }, + .{ .name = style_num, .spec = "ansi-magenta", .kinds = &.{.number} }, + .{ .name = style_err, .spec = "ansi-red" }, + .{ .name = style_jsmode, .spec = "ansi-red bold" }, + .{ .name = "ps-keyword", .spec = "ansi-blue bold", .kinds = &.{.keyword} }, + .{ .name = "ps-comment", .spec = "ansi-darkgray italic", .kinds = &.{.comment} }, + .{ .name = "ps-jsglobal", .spec = "ansi-cyan", .kinds = &.{.global} }, + .{ .name = "ps-fn", .spec = "ansi-teal", .kinds = &.{.function} }, + .{ .name = "ps-method", .spec = "ansi-teal italic", .kinds = &.{.method} }, + .{ .name = "ps-type", .spec = "ansi-cyan", .kinds = &.{.type_name} }, +}; + +/// Style name per token kind, derived from `styles`. Unmapped or +/// doubly-mapped kinds are compile errors. +const kind_styles = blk: { + const n = std.enums.values(js_highlight.Kind).len; + var arr: [n]?[:0]const u8 = @splat(null); + for (styles) |s| for (s.kinds) |kind| { + if (arr[@intFromEnum(kind)] != null) @compileError("kind styled twice: " ++ @tagName(kind)); + arr[@intFromEnum(kind)] = s.name; + }; + var out: [n][:0]const u8 = undefined; + for (arr, 0..) |name, i| { + out[i] = name orelse @compileError("js_highlight.Kind with no ps-* style: " ++ + @tagName(@as(js_highlight.Kind, @enumFromInt(i)))); + } + break :blk out; +}; + +/// Lets the completer/hinter pull dynamic candidates from the `Agent` without +/// this module depending on it (same idiom as `Session.cancel_hook`). +pub const CompletionSource = struct { + context: *anyopaque, + providers: *const fn (context: *anyopaque, arena: std.mem.Allocator) []const []const u8, + /// May block on an HTTP fetch. + models: *const fn (context: *anyopaque, arena: std.mem.Allocator) []const []const u8, +}; + +/// Separate history files for normal and JS prompt modes. isocline holds one +/// history list at a time, so we swap files on mode toggle rather than tag a +/// shared file. +pub const HistoryPaths = struct { + normal: [:0]const u8, + js: [:0]const u8, +}; + +/// The mutable state the C callbacks read: registered once via `attach`, so +/// it must live at a stable address. +pub const State = struct { + /// True while the REPL is in JS mode; set by isocline's mode callback. + js_mode: bool = false, + completion_source: ?CompletionSource = null, + /// Per-mode history files (null outside REPL mode). `modeCallback` swaps + /// the active one so JS and normal recall stay separate. + history_paths: ?HistoryPaths = null, +}; + +/// One-time isocline configuration for REPL mode. Probes the terminal +/// (isocline writes an ESC[6n cursor-report on stdout), so callers must skip +/// it in script-only mode. +pub fn setupRepl() void { + _ = c.ic_enable_multiline(true); + _ = c.ic_enable_hint(true); + _ = c.ic_enable_inline_help(true); + // Show ghost completions instantly; isocline's default is 400 ms. + _ = c.ic_set_hint_delay(0); + _ = c.ic_enable_brace_insertion(true); + for (styles) |s| c.ic_style_def(s.name.ptr, s.spec.ptr); + // lighten the ghost/inline-hint color from isocline's default ansi-darkgray + c.ic_style_def("ic-hint", "ansi-color=244"); + // `!` on an empty prompt toggles JS mode; state callback wired in attach. + c.ic_set_prompt_mode("[" ++ style_jsmode ++ "]![/" ++ style_jsmode ++ "] ", '!'); + // Blank continuation marker so multiline input isn't prefixed with `>`. + c.ic_set_prompt_marker("❯ ", ""); + _ = c.ic_enable_highlight(true); +} + +/// Wires the isocline completer, hinter, and highlighter to `state` and +/// loads the initial history. Must run after `state` is in its final memory +/// location and before the first readline. +pub fn attach(state: *State) void { + c.ic_set_default_completer(&completionCallback, state); + c.ic_set_default_hinter(&hintsCallback, state); + c.ic_set_mode_callback(&modeCallback, state); + c.ic_set_ctrl_d_hint(" press Ctrl-D again to exit"); + c.ic_set_esc_clear_hint(" esc again to clear"); + c.ic_set_mode_hint(" JS mode - esc to exit"); + c.ic_set_default_highlighter(&highlighterCallback, state); + if (state.history_paths) |hp| { + // Mode inactive at launch, so load the normal file; modeCallback + // swaps to JS on mode entry. + c.ic_set_history(hp.normal.ptr, -1); // -1 → 200-entry default cap + } +} + +fn modeCallback(active: bool, arg: ?*anyopaque) callconv(.c) void { + const state: *State = @ptrCast(@alignCast(arg orelse return)); + state.js_mode = active; + if (state.history_paths) |hp| { + c.ic_set_history((if (active) hp.js else hp.normal).ptr, -1); + } +} + +const completion_buf_len = 512; + +fn addPrefixedCompletion( + cenv: ?*c.ic_completion_env_t, + buf: *[completion_buf_len:0]u8, + input: []const u8, + prefix: []const u8, + name: []const u8, + suffix: []const u8, + partial: []const u8, +) void { + if (!std.ascii.startsWithIgnoreCase(name, partial)) return; + const text = std.fmt.bufPrintZ(buf, "{s}{s}{s}", .{ prefix, name, suffix }) catch return; + _ = c.ic_add_completion_prim(cenv, text.ptr, null, null, @intCast(input.len), 0); +} + +// Cap on tokens read out of the body; extra tokens are ignored. Real schemas +// and CLI inputs have far fewer fields. +const max_tokens = 32; + +const BodyAnalysis = struct { + used: [max_tokens][]const u8 = undefined, + used_len: usize = 0, + // Trailing in-progress token when the user is typing a key prefix (no `=` + // yet, not a positional binding). Null when the body is empty, ends with + // whitespace, or the trailing token is fully committed. + partial_key: ?[]const u8 = null, + + fn markUsed(self: *BodyAnalysis, name: []const u8) void { + if (self.used_len >= self.used.len) return; + self.used[self.used_len] = name; + self.used_len += 1; + } + + fn isUsed(self: *const BodyAnalysis, name: []const u8) bool { + for (self.used[0..self.used_len]) |u| { + if (std.mem.eql(u8, u, name)) return true; + } + return false; + } +}; + +fn analyzeBody(schema: *const Schema, body: []const u8, ends_ws: bool) BodyAnalysis { + var a: BodyAnalysis = .{}; + + var tokens: [max_tokens][]const u8 = undefined; + var n: usize = 0; + var it = std.mem.tokenizeAny(u8, body, &std.ascii.whitespace); + while (it.next()) |tok| { + if (n >= tokens.len) break; + tokens[n] = tok; + n += 1; + } + if (n == 0) return a; + + const last = n - 1; + for (tokens[0..n], 0..) |tok, i| { + if (std.mem.indexOfScalar(u8, tok, '=')) |eq| { + a.markUsed(tok[0..eq]); + continue; + } + // The first bare arg binds positionally to the schema's positional + // field (`/goto https://example.com`, `/getEnv LP_TOKEN`). + if (i == 0) if (schema.leadingPositionalField()) |pos| { + a.markUsed(pos); + continue; + }; + if (i == last and !ends_ws) a.partial_key = tok; + } + return a; +} + +const help_arg_prefix = "/help "; + +fn parseHelpArgPrefix(input: []const u8) ?[]const u8 { + if (!std.ascii.startsWithIgnoreCase(input, help_arg_prefix)) return null; + const arg = std.mem.trimLeft(u8, input[help_arg_prefix.len..], " "); + if (std.mem.indexOfScalar(u8, arg, ' ') != null) return null; + return arg; +} + +/// A field whose value the cursor is positioned to complete, plus the partial +/// value typed so far. Covers both the leading positional (`/waitForState net`) +/// and an explicit `key=` pair (`/waitForState state=net`). +const ValueAt = struct { + field: Schema.FieldEntry, + partial: []const u8, + /// `key=value` form rather than the bare leading positional. + kv: bool, +}; + +/// Classifies the token under the cursor as a value position for some schema +/// field. Null when the cursor isn't on a completable value (a key prefix, a +/// non-leading positional, or an unknown field). +fn valueAt(schema: *const Schema, body: []const u8, ends_ws: bool) ?ValueAt { + var last: []const u8 = ""; + var n: usize = 0; + var it = std.mem.tokenizeAny(u8, body, &std.ascii.whitespace); + while (it.next()) |tok| { + last = tok; + n += 1; + } + + // An empty body or a trailing space puts the cursor on a fresh token after + // the `n` committed ones; otherwise it sits on the last token. + const active: []const u8 = if (ends_ws or n == 0) "" else last; + const active_index: usize = if (ends_ws or n == 0) n else n - 1; + + if (std.mem.indexOfScalar(u8, active, '=')) |eq| { + const field = schema.findField(active[0..eq]) orelse return null; + return .{ .field = field, .partial = active[eq + 1 ..], .kv = true }; + } + // The leading bare token binds to the schema's positional field (lone + // required, or sole optional field like getEnv's `name`). + if (active_index == 0) if (schema.leadingPositionalField()) |pos| { + const field = schema.findField(pos) orelse return null; + return .{ .field = field, .partial = active, .kv = false }; + }; + return null; +} + +/// Returns true when it owns the completion, so the caller skips key hints. +fn addValueCompletions( + cenv: ?*c.ic_completion_env_t, + input: []const u8, + body: []const u8, + schema: *const Schema, + buf: *[completion_buf_len:0]u8, +) bool { + const ends_ws = input[input.len - 1] == ' '; + const v = valueAt(schema, body, ends_ws) orelse return false; + const prefix = input[0 .. input.len - v.partial.len]; + if (schema.tool == .getEnv) { + var name_buf: [2048]u8 = undefined; + const names = lpEnvNameList(&name_buf) orelse return true; + for (names) |name| addPrefixedCompletion(cenv, buf, input, prefix, name, "", v.partial); + return true; + } + if (v.field.enum_values.len == 0) return false; + for (v.field.enum_values) |val| addPrefixedCompletion(cenv, buf, input, prefix, val, "", v.partial); + return true; +} + +fn addPartialKeyCompletions( + cenv: ?*c.ic_completion_env_t, + input: []const u8, + body: []const u8, + schema: *const Schema, + buf: *[completion_buf_len:0]u8, +) void { + std.debug.assert(input.len > 0); + const ends_ws = input[input.len - 1] == ' '; + const a = analyzeBody(schema, body, ends_ws); + // Without a partial AND without trailing whitespace, the user is mid-typing + // a positional value or some other non-completable state — bail. + if (a.partial_key == null and !ends_ws) return; + + const partial = a.partial_key orelse ""; + const prefix = input[0 .. input.len - partial.len]; + for (schema.hints) |slot| { + if (a.isUsed(slot.name)) continue; + addPrefixedCompletion(cenv, buf, input, prefix, slot.name, "=", partial); + } +} + +fn addMetaValueCompletions( + state: *State, + cenv: ?*c.ic_completion_env_t, + input: []const u8, + body: []const u8, + meta: *const SlashCommand.MetaCommand, + buf: *[completion_buf_len:0]u8, +) void { + // Past the first positional arg — don't offer value completions anymore. + if (std.mem.indexOfAny(u8, body, &std.ascii.whitespace) != null) return; + const prefix = input[0 .. input.len - body.len]; + + if (meta.tag == .load or meta.tag == .save) { + addPathCompletions(cenv, input, body, prefix, buf); + return; + } + + // `/provider` / `/model` candidates are resolved at runtime, not in `meta.values`. + if (state.completion_source) |src| switch (meta.tag) { + .provider, .model => { + var name_buf: [512]u8 = undefined; + var fba: std.heap.FixedBufferAllocator = .init(&name_buf); + const names = if (meta.tag == .provider) + src.providers(src.context, fba.allocator()) + else + src.models(src.context, fba.allocator()); + for (names) |v| addPrefixedCompletion(cenv, buf, input, prefix, v, "", body); + return; + }, + else => {}, + }; + + for (meta.values) |v| addPrefixedCompletion(cenv, buf, input, prefix, v, "", body); +} + +/// Directory entries whose basename completes the partial path `body` +/// (`dir/ba` → entries of `dir/` prefix-matching `ba`). Returned names +/// borrow the iterator; use before the next `next()`. +const PathMatchIterator = struct { + dir: std.fs.Dir, + it: std.fs.Dir.Iterator, + /// `body` up to and including its last `/`; kept verbatim in candidates. + dir_part: []const u8, + base: []const u8, + + const Match = struct { name: []const u8, is_dir: bool }; + + fn init(body: []const u8) ?PathMatchIterator { + const slash = std.mem.lastIndexOfScalar(u8, body, '/'); + const dir_part = if (slash) |i| body[0 .. i + 1] else ""; + const open_path = if (dir_part.len == 0) "." else dir_part; + const dir = std.fs.cwd().openDir(open_path, .{ .iterate = true }) catch return null; + return .{ + .dir = dir, + .it = dir.iterate(), + .dir_part = dir_part, + .base = body[dir_part.len..], + }; + } + + fn deinit(self: *PathMatchIterator) void { + self.dir.close(); + } + + fn next(self: *PathMatchIterator) ?Match { + while (self.it.next() catch return null) |entry| { + if (!std.ascii.startsWithIgnoreCase(entry.name, self.base)) continue; + return .{ .name = entry.name, .is_dir = entry.kind == .directory }; + } + return null; + } +}; + +fn addPathCompletions( + cenv: ?*c.ic_completion_env_t, + input: []const u8, + body: []const u8, + prefix: []const u8, + buf: *[completion_buf_len:0]u8, +) void { + var matches = PathMatchIterator.init(body) orelse return; + defer matches.deinit(); + + var name_buf: [completion_buf_len]u8 = undefined; + while (matches.next()) |m| { + const suffix: []const u8 = if (m.is_dir) "/" else ""; + const full = std.fmt.bufPrint(&name_buf, "{s}{s}", .{ matches.dir_part, m.name }) catch continue; + addPrefixedCompletion(cenv, buf, input, prefix, full, suffix, body); + } +} + +/// LP_* env var names (sorted) written into `buf`; null on enumeration failure. +/// Returned slices borrow `buf`, which must outlive them. +fn lpEnvNameList(buf: []u8) ?[]const []const u8 { + var fba: std.heap.FixedBufferAllocator = .init(buf); + return browser_tools.lpEnvNames(fba.allocator()) catch null; +} + +/// Completes `$LP_*` against the live process environment. +fn addEnvVarCompletions( + cenv: ?*c.ic_completion_env_t, + buf: *[completion_buf_len:0]u8, + input: []const u8, +) void { + const dollar = std.mem.lastIndexOfScalar(u8, input, '$') orelse return; + const partial = input[dollar + 1 ..]; + for (partial) |ch| { + if (!std.ascii.isAlphanumeric(ch) and ch != '_') return; + } + + var name_buf: [2048]u8 = undefined; + const names = lpEnvNameList(&name_buf) orelse return; + if (names.len == 0) return; + + const head = input[0 .. dollar + 1]; + for (names) |name| addPrefixedCompletion(cenv, buf, input, head, name, "", partial); +} + +fn completionCallback(cenv: ?*c.ic_completion_env_t, prefix: [*c]const u8) callconv(.c) void { + const state: *State = @ptrCast(@alignCast(c.ic_completion_arg(cenv) orelse return)); + const input = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(prefix)), 0); + + var buf: [completion_buf_len:0]u8 = undefined; + + // `/help `: arg is a command name, not a value — skip env-var fallthrough. + if (parseHelpArgPrefix(input)) |partial| { + for (SlashCommand.all_names) |name| addPrefixedCompletion(cenv, &buf, input, help_arg_prefix, name, "", partial); + return; + } + + if (input.len == 0) return; + const has_space = std.mem.indexOfScalar(u8, input, ' ') != null; + const inside_block = Schema.hasUnclosedTripleQuote(input); + + if (input[0] == '/') { + if (!has_space) { + const partial = input[1..]; + // Trailing space on commands with params hands off to the hinter, + // which renders the full ` [timeout=…]` template uniformly + // whether the name was typed or Tab-completed. + for (SlashCommand.all_names) |name| { + const suffix: []const u8 = if (slashHasParams(name)) " " else ""; + addPrefixedCompletion(cenv, &buf, input, "/", name, suffix, partial); + } + return; + } else if (!inside_block) { + if (Schema.parseSlashCommand(input)) |parts| { + if (Schema.findByName(parts.name)) |schema| { + if (!addValueCompletions(cenv, input, parts.rest, schema, &buf)) { + addPartialKeyCompletions(cenv, input, parts.rest, schema, &buf); + } + } else if (SlashCommand.findMeta(parts.name)) |meta| { + addMetaValueCompletions(state, cenv, input, parts.rest, meta, &buf); + } + } + } + // Fall through so `value=$LP_` picks up env completions, including + // inside an unclosed `'''` block. + } + + addEnvVarCompletions(cenv, &buf, input); +} + +// File-scope so the buffer outlives the callback's stack frame. Isocline's +// `sbuf_replace` copies the returned string into its own stringbuf, so +// overwriting this on the next invocation is safe. Single-threaded: isocline's +// edit loop runs on the main thread, and we have one Terminal instance. +var hint_buf: [completion_buf_len:0]u8 = undefined; + +fn hintsCallback(input_c: [*c]const u8, arg: ?*anyopaque) callconv(.c) [*c]const u8 { + const state: *State = @ptrCast(@alignCast(arg orelse return null)); + const input = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(input_c)), 0); + + // JS mode: the buffer is raw JS, so slash/kv hints don't apply. + if (state.js_mode) return null; + + if (input.len == 0) return null; + + if (parseHelpArgPrefix(input)) |partial| return ghostFirstMatch(&SlashCommand.all_names, partial, ""); + + // Inside an open `'''…'''` body the buffer is script text, not kv args. + if (Schema.hasUnclosedTripleQuote(input)) return null; + + if (std.mem.eql(u8, input, "/")) return ghostFirstMatch(&SlashCommand.all_names, "", ""); + + if (Schema.parseSlashCommand(input)) |parts| { + const ends_ws = input[input.len - 1] == ' '; + if (Schema.findByName(parts.name)) |schema| { + return renderSchemaHint(schema, parts.rest, ends_ws); + } + if (SlashCommand.findMeta(parts.name)) |meta| { + return renderMetaHint(state, meta, parts.rest, ends_ws); + } + if (std.mem.indexOfScalar(u8, input, ' ') == null) { + return ghostFirstMatch(&SlashCommand.all_names, parts.name, ""); + } + return null; + } + + // Non-slash lines are natural-language prompts to the LLM (REPL only). + // No syntactic hint to render — the LLM sees the line verbatim. + return null; +} + +/// Join `fragments` into `hint_buf` with single-space separators, prefixed by +/// `lead` (typically `""` or `" "`). Null-terminates and returns the isocline +/// C pointer, or null when nothing to render or the buffer would overflow. +fn writeHints(lead: []const u8, fragments: []const []const u8) [*c]const u8 { + if (fragments.len == 0) return null; + const cap = hint_buf.len - 1; + if (lead.len > cap) return null; + @memcpy(hint_buf[0..lead.len], lead); + var pos: usize = lead.len; + for (fragments, 0..) |frag, i| { + if (i > 0) { + if (pos + 1 > cap) return null; + hint_buf[pos] = ' '; + pos += 1; + } + if (pos + frag.len > cap) return null; + @memcpy(hint_buf[pos..][0..frag.len], frag); + pos += frag.len; + } + hint_buf[pos] = 0; + return @ptrCast(&hint_buf); +} + +/// Ghosts a meta command's argument: providers resolve synchronously, `/model` +/// needs a blocking fetch (placeholder until committed), static values match +/// `meta.values`. +fn renderMetaHint(state: *State, meta: *const SlashCommand.MetaCommand, body: []const u8, ends_ws: bool) [*c]const u8 { + if (meta.hint.len == 0) return null; + if (ends_ws and body.len != 0) return null; // value already committed + + if (state.completion_source) |src| { + var name_buf: [512]u8 = undefined; + var fba: std.heap.FixedBufferAllocator = .init(&name_buf); + if (meta.tag == .provider) { + const lead: []const u8 = if (body.len == 0 and !ends_ws) " " else ""; + return ghostFirstMatch(src.providers(src.context, fba.allocator()), body, lead); + } + if (meta.tag == .model and (ends_ws or body.len != 0)) { + return ghostFirstMatch(src.models(src.context, fba.allocator()), body, ""); + } + } + + if (body.len == 0) { + var frags: [1][]const u8 = .{meta.hint}; + return writeHints(if (ends_ws) "" else " ", &frags); + } + if (ends_ws) return null; + if (meta.tag == .load or meta.tag == .save) return ghostPathFirstMatch(body); + return ghostFirstMatch(meta.values, body, ""); +} + +fn ghostPathFirstMatch(body: []const u8) [*c]const u8 { + var matches = PathMatchIterator.init(body) orelse return null; + defer matches.deinit(); + const m = matches.next() orelse return null; + const suffix: []const u8 = if (m.is_dir) "/" else ""; + const text = std.fmt.bufPrintZ(&hint_buf, "{s}{s}", .{ m.name[matches.base.len..], suffix }) catch return null; + return text.ptr; +} + +/// Ghosts `lead` + the suffix of the first `names` entry that prefix-matches +/// `body`. +fn ghostFirstMatch(names: []const []const u8, body: []const u8, lead: []const u8) [*c]const u8 { + for (names) |v| { + if (!std.ascii.startsWithIgnoreCase(v, body)) continue; + const text = std.fmt.bufPrintZ(&hint_buf, "{s}{s}", .{ lead, v[body.len..] }) catch return null; + return text.ptr; + } + return null; +} + +/// Renders `` and `[optional=…]` for each unused field, or +/// `=…` when the user is typing a key prefix. +fn renderSchemaHint(schema: *const Schema, body: []const u8, ends_ws: bool) [*c]const u8 { + // Ghost a matching enum value once the user is typing one. A bare leading + // positional with nothing typed keeps the ` …` template below — more + // informative than ghosting one arbitrary value. + if (valueAt(schema, body, ends_ws)) |v| { + if (v.field.enum_values.len > 0 and (v.kv or v.partial.len > 0)) { + return ghostFirstMatch(v.field.enum_values, v.partial, ""); + } + // getEnv's `name` ghosts a live LP_* var, like /provider ghosts a provider. + if (schema.tool == .getEnv) { + var name_buf: [2048]u8 = undefined; + if (lpEnvNameList(&name_buf)) |names| { + const lead: []const u8 = if (v.partial.len == 0 and !ends_ws) " " else ""; + if (ghostFirstMatch(names, v.partial, lead)) |hint| return hint; + } + } + } + + const a = analyzeBody(schema, body, ends_ws); + + if (a.partial_key) |pk| { + for (schema.hints) |slot| { + if (a.isUsed(slot.name)) continue; + if (!std.ascii.startsWithIgnoreCase(slot.name, pk)) continue; + const text = std.fmt.bufPrintZ(&hint_buf, "{s}=…", .{slot.name[pk.len..]}) catch return null; + return text.ptr; + } + return null; + } + + var frags: [Schema.max_hint_slots][]const u8 = undefined; + var n: usize = 0; + for (schema.hints) |slot| { + if (a.isUsed(slot.name)) continue; + frags[n] = slot.fragment; + n += 1; + } + return writeHints(if (ends_ws) "" else " ", frags[0..n]); +} + +/// Byte offsets to ic_highlight are not UTF-8 code points; safe because we +/// only tokenize on ASCII boundaries (whitespace, quotes, `=`, `$`). +fn highlighterCallback(henv: ?*c.ic_highlight_env_t, input: [*c]const u8, arg: ?*anyopaque) callconv(.c) void { + const state: *State = @ptrCast(@alignCast(arg orelse return)); + const text = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(input)), 0); + // JS mode: the buffer is raw JS, so highlight it as such (plus `$LP_*` refs). + if (state.js_mode) { + highlightJavaScript(henv, text); + return; + } + const cmd_start = std.mem.indexOfNonePos(u8, text, 0, &std.ascii.whitespace) orelse return; + var i = cmd_start; + while (i < text.len and !std.ascii.isWhitespace(text[i])) i += 1; + const cmd = text[cmd_start..i]; + // Commit to red once the cursor moves past the token, OR as soon as the + // prefix cannot complete to any known name. + const closed = i < text.len; + if (cmd.len > 0 and cmd[0] == '/') { + c.ic_highlight(henv, @intCast(cmd_start), 1, style_slash.ptr); + if (cmd.len > 1) { + const name = cmd[1..]; + const style: ?[*:0]const u8 = if (isKnownSlashName(name)) + style_slash + else if (closed or !slashHasPrefix(name)) + style_err + else + null; + if (style) |s| c.ic_highlight(henv, @intCast(cmd_start + 1), @intCast(cmd.len - 1), s); + } + highlightSlashArgs(henv, text, i); + } else { + // No leading `/`: a natural-language prompt, so no command validation. + // Start at `cmd_start`, not `i`, so a `$LP_*` first token highlights too. + highlightDollarVars(henv, text, cmd_start); + } +} + +fn isKnownSlashName(name: []const u8) bool { + for (SlashCommand.all_names) |n| { + if (std.ascii.eqlIgnoreCase(n, name)) return true; + } + return false; +} + +fn slashHasPrefix(name: []const u8) bool { + for (SlashCommand.all_names) |n| { + if (std.ascii.startsWithIgnoreCase(n, name)) return true; + } + return false; +} + +fn slashHasParams(name: []const u8) bool { + if (Schema.findByName(name)) |s| return s.hints.len > 0; + if (SlashCommand.findMeta(name)) |m| return m.hint.len > 0; + return false; +} + +fn highlightBareToken(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize, end: usize) void { + if (start >= end) return; + const tok = text[start..end]; + if (tok[0] == '$') { + c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_var.ptr); + return; + } + if (lp.URL.isCompleteHTTPUrl(tok)) { + c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_url.ptr); + return; + } + if (std.fmt.parseFloat(f64, tok)) |_| { + c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_num.ptr); + } else |_| {} +} + +/// Highlight `$LP_*` tokens appearing from `start` onward. `${…}` is not a +/// prompt substitution form, so interpolation stays off. +fn highlightDollarVars(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize) void { + var i = start; + while (js_highlight.nextDollarRef(text, i, text.len, false)) |ref| { + c.ic_highlight(henv, @intCast(ref.start), @intCast(ref.end - ref.start), style_var.ptr); + i = ref.end; + } +} + +/// Paints `js_highlight` spans onto isocline's cell attributes. +const IcSink = struct { + henv: ?*c.ic_highlight_env_t, + + pub fn emit(self: IcSink, start: usize, len: usize, kind: js_highlight.Kind) void { + c.ic_highlight(self.henv, @intCast(start), @intCast(len), kind_styles[@intFromEnum(kind)].ptr); + } +}; + +/// Highlight the buffer as JavaScript. Byte offsets are safe (see +/// `highlighterCallback`): every token boundary is an ASCII byte and non-ASCII +/// bytes advance singly without being highlighted. +fn highlightJavaScript(henv: ?*c.ic_highlight_env_t, text: []const u8) void { + const sink: IcSink = .{ .henv = henv }; + _ = js_highlight.tokenize(text, .normal, sink); +} + +fn highlightSlashArgs(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize) void { + var i = start; + while (std.mem.indexOfNonePos(u8, text, i, &std.ascii.whitespace)) |tok_start| { + i = tok_start; + if (text[i] == '\'' or text[i] == '"') { + i = Schema.quotedSpanEnd(text, i); + c.ic_highlight(henv, @intCast(tok_start), @intCast(i - tok_start), style_string.ptr); + continue; + } + while (i < text.len and !std.ascii.isWhitespace(text[i]) and text[i] != '=') i += 1; + const key_end = i; + if (i < text.len and text[i] == '=') { + c.ic_highlight(henv, @intCast(tok_start), @intCast(key_end - tok_start), style_key.ptr); + i += 1; + const val_start = i; + if (i < text.len and (text[i] == '\'' or text[i] == '"')) { + i = Schema.quotedSpanEnd(text, i); + c.ic_highlight(henv, @intCast(val_start), @intCast(i - val_start), style_string.ptr); + } else { + while (i < text.len and !std.ascii.isWhitespace(text[i])) i += 1; + highlightBareToken(henv, text, val_start, i); + } + } + } +} + +test "valueAt: enum field via positional and kv, partial and empty" { + const schema = Schema.findByName("waitForState").?; + + // Leading positional, nothing typed: empty partial, not kv. + { + const v = valueAt(schema, "", true).?; + try std.testing.expect(v.field.enum_values.len > 0); + try std.testing.expectEqualStrings("", v.partial); + try std.testing.expect(!v.kv); + } + // Leading positional, partial value. + { + const v = valueAt(schema, "net", false).?; + try std.testing.expectEqualStrings("net", v.partial); + try std.testing.expect(!v.kv); + } + // Explicit `state=` with empty value. + { + const v = valueAt(schema, "state=", false).?; + try std.testing.expectEqualStrings("", v.partial); + try std.testing.expect(v.kv); + } + // Explicit `state=net`. + { + const v = valueAt(schema, "state=net", false).?; + try std.testing.expectEqualStrings("net", v.partial); + try std.testing.expect(v.kv); + } + // Past the only required field — timeout is not an enum, so no value match. + { + const v = valueAt(schema, "networkidle timeout=", false).?; + try std.testing.expectEqual(@as(usize, 0), v.field.enum_values.len); + } +} + +test "valueAt: getEnv binds the leading positional to its optional name field" { + const schema = Schema.findByName("getEnv").?; + // `/getEnv ` — fresh positional, even though `name` is optional (0 required). + { + const v = valueAt(schema, "", true).?; + try std.testing.expectEqualStrings("name", v.field.name); + try std.testing.expectEqualStrings("", v.partial); + try std.testing.expect(!v.kv); + } + // `/getEnv LP_H` — partial value bound to `name`. + { + const v = valueAt(schema, "LP_H", false).?; + try std.testing.expectEqualStrings("name", v.field.name); + try std.testing.expectEqualStrings("LP_H", v.partial); + } +} + +test "renderSchemaHint: ghosts enum value once typing, keeps template when empty" { + const schema = Schema.findByName("waitForState").?; + + const hintStr = struct { + fn f(p: [*c]const u8) ?[]const u8 { + if (p == null) return null; + return std.mem.sliceTo(@as([*:0]const u8, @ptrCast(p)), 0); + } + }.f; + + // Nothing typed yet: the ` …` template, not an arbitrary value. + try std.testing.expectEqualStrings(" [timeout=…]", hintStr(renderSchemaHint(schema, "", false)).?); + + // Partial positional ghosts the suffix of the first matching value. + try std.testing.expectEqualStrings("workalmostidle", hintStr(renderSchemaHint(schema, "net", false)).?); + + // `state=` ghosts the first value. + try std.testing.expectEqualStrings("load", hintStr(renderSchemaHint(schema, "state=", false)).?); +} diff --git a/src/agent/settings.zig b/src/agent/settings.zig index 5d00bd2c2..ad265c72d 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -26,7 +26,7 @@ const std = @import("std"); const zenai = @import("zenai"); const lp = @import("lightpanda"); const Config = lp.Config; -const Terminal = @import("Terminal.zig"); +const picker = @import("picker.zig"); const string = @import("../string.zig"); const Credentials = zenai.provider.Credentials; @@ -156,14 +156,14 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme } // A single key needs no choice; non-interactive callers (--list-models, // one-shot tasks, pipes) must not block on a prompt — take the first. - if (!allow_pick or found.len == 1 or !Terminal.interactiveTty()) { + if (!allow_pick or found.len == 1 or !picker.interactiveTty()) { return try finishResolved(allocator, found[0], .detected); } var names: [zenai.provider.default_candidates.len][:0]const u8 = undefined; for (found, 0..) |cred, i| names[i] = @tagName(cred.provider); std.debug.print("\n", .{}); - const idx = Terminal.promptNumberedChoice(" Select a provider:", names[0..found.len], 0) catch { + const idx = picker.promptNumberedChoice(" Select a provider:", names[0..found.len], 0) catch { return try finishResolved(allocator, found[0], .detected); }; return try finishResolved(allocator, found[idx], .picked); @@ -186,12 +186,14 @@ pub const remembered_path = ".lp-agent.zon"; /// disabled the LLM (`/provider null`), so the REPL starts in basic mode without /// re-prompting. `effort`/`verbosity` are optional so files predating them still /// parse; null means "use the mode default" (see `Agent.resolveEffort` / -/// `Agent.resolveVerbosity`). +/// `Agent.resolveVerbosity`). `stream` is likewise optional: null means "use the +/// default" (see `resolveStream`). pub const Remembered = struct { provider: ?Config.AiProvider = null, model: []const u8, effort: ?Config.Effort = null, verbosity: ?Config.AgentVerbosity = null, + stream: ?bool = null, }; pub fn loadRemembered(allocator: std.mem.Allocator) ?Remembered { @@ -269,6 +271,13 @@ pub fn resolveVerbosity(opts: Config.Agent, remembered: ?Remembered) Config.Agen return Config.agentVerbosity(opts); } +/// Precedence: remembered `.lp-agent.zon` value > default (on). Streaming has no +/// CLI flag — the REPL `/stream` command toggles and persists it. +pub fn resolveStream(remembered: ?Remembered) bool { + if (remembered) |r| if (r.stream) |s| return s; + return true; +} + pub const ReconciledModel = union(enum) { /// Owned by the allocator passed to reconcileModel. use: []u8, @@ -329,4 +338,19 @@ test "parseRemembered: valid file round-trips" { defer std.zon.parse.free(testing.allocator, remembered); try testing.expect(remembered.provider == null); try testing.expectString("some-model", remembered.model); + // Absent `stream` is null so pre-streaming files still fall back to the default. + try testing.expect(remembered.stream == null); +} + +test "parseRemembered: stream field round-trips" { + const remembered = parseRemembered(testing.allocator, ".{ .model = \"m\", .stream = false }").?; + defer std.zon.parse.free(testing.allocator, remembered); + try testing.expect(remembered.stream == false); +} + +test "resolveStream: default on, remembered wins" { + try testing.expect(resolveStream(null)); + try testing.expect(resolveStream(.{ .model = "m", .stream = null })); + try testing.expect(resolveStream(.{ .model = "m", .stream = true })); + try testing.expect(!resolveStream(.{ .model = "m", .stream = false })); } diff --git a/src/agent/welcome.zig b/src/agent/welcome.zig index 403a7c7a3..517f045e5 100644 --- a/src/agent/welcome.zig +++ b/src/agent/welcome.zig @@ -22,7 +22,7 @@ const std = @import("std"); const lp = @import("lightpanda"); -const Terminal = @import("Terminal.zig"); +const ansi = @import("ansi.zig"); // A pre-colored (truecolor braille) panda. Each line carries its own ANSI and // resets at the end, so it prints as-is; non-empty lines are the visible rows. @@ -82,30 +82,28 @@ comptime { /// Prints the welcome banner: the logo on the left with the title and command /// hints beside it, vertically centered. `llm_active` picks the tagline. pub fn print(llm_active: bool) void { - const a = Terminal.ansi; - var version_buf: [192]u8 = undefined; - const version: []const u8 = std.fmt.bufPrint(&version_buf, a.dim ++ "{s}" ++ a.reset, .{lp.build_config.version}) catch ""; + const version: []const u8 = std.fmt.bufPrint(&version_buf, ansi.dim ++ "{s}" ++ ansi.reset, .{lp.build_config.version}) catch ""; var lines: [9][]const u8 = undefined; var n: usize = 0; - lines[n] = a.bold ++ "Lightpanda Agent" ++ a.reset; + lines[n] = ansi.bold ++ "Lightpanda Agent" ++ ansi.reset; n += 1; lines[n] = version; n += 1; lines[n] = ""; n += 1; if (llm_active) { - lines[n] = a.italic ++ banner_tagline_llm ++ a.reset; + lines[n] = ansi.italic ++ banner_tagline_llm ++ ansi.reset; n += 1; } else { - lines[n] = a.italic ++ banner_tagline_basic ++ a.reset; + lines[n] = ansi.italic ++ banner_tagline_basic ++ ansi.reset; n += 1; - lines[n] = a.dim ++ banner_setup ++ a.reset; + lines[n] = ansi.dim ++ banner_setup ++ ansi.reset; n += 1; } inline for (banner_hints) |t| { - lines[n] = a.dim ++ t ++ a.reset; + lines[n] = ansi.dim ++ t ++ ansi.reset; n += 1; } const text = lines[0..n]; diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index e79834aa0..b767140ed 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -28,7 +28,7 @@ const Watchdog = @import("../Watchdog.zig"); const Session = @import("Session.zig"); const Selector = @import("webapi/selector/Selector.zig"); const Viewport = @import("Viewport.zig"); -const HttpClient = @import("HttpClient.zig"); +const HttpClient = @import("../network/HttpClient.zig"); const PermissionState = @import("webapi/Permissions.zig").State; const ArenaPool = App.ArenaPool; diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 863923d66..f78889e3c 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -28,6 +28,7 @@ const Node = @import("webapi/Node.zig"); const Event = @import("webapi/Event.zig"); const EventTarget = @import("webapi/EventTarget.zig"); const Element = @import("webapi/Element.zig"); +const ShadowRoot = @import("webapi/ShadowRoot.zig"); const log = lp.log; const Allocator = std.mem.Allocator; @@ -111,10 +112,27 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co switch (target._type) { .node => |node| try self.dispatchNode(node, event, opts), + .xhr => |xhr| try self.dispatchDirect(target, event, xhr.inlineHandler(event._type_string), .{ .context = "dispatch" }), + .window => |w| try self.dispatchDirect(target, event, windowInlineHandler(w, event._type_string), .{ .context = "dispatch" }), else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }), } } +// Resolves the Window's property event handler for the given event type. +fn windowInlineHandler(window: *@import("webapi/Window.zig"), typ: lp.String) ?js.Function.Global { + const global_event_handlers = @import("webapi/global_event_handlers.zig"); + const handler_type = global_event_handlers.fromEventType(typ.str()) orelse return null; + return switch (handler_type) { + .onerror => window._on_error, + .onload => window._on_load, + .onblur => window._on_blur, + .onfocus => window._on_focus, + .onresize => window._on_resize, + .onscroll => window._on_scroll, + else => null, + }; +} + // There are a lot of events that can be attached via addEventListener or as // a property, like the XHR events, or window.onload. You might think that the // property is just a shortcut for calling addEventListener, but they are distinct. @@ -124,7 +142,7 @@ pub const DispatchDirectOptions = EventManagerBase.DispatchDirectOptions; // Direct dispatch for non-DOM targets (Window, XHR, AbortSignal) or DOM nodes with // property handlers. No propagation - just calls the handler and registered listeners. -// Handler can be: null, ?js.Function.Global, ?js.Function.Temp, or js.Function +// Handler can be: null, ?js.Function.Global or js.Function pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void { const frame = self.frame; @@ -144,12 +162,19 @@ pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []cons } fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void { - const ShadowRoot = @import("webapi/ShadowRoot.zig"); - { const et = target.asEventTarget(); event._target = et; event._dispatch_target = et; // Store original target for composedPath() + + // Retarget the relatedTarget against the dispatch target up front + // (DOM dispatch step 4); listeners observe the retargeted value and + // it survives the dispatch. + if (event.relatedTargetPtr()) |related_ptr| { + if (related_ptr.*) |related| { + related_ptr.* = getAdjustedTarget(related, et); + } + } } const frame = self.frame; @@ -179,6 +204,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts var path_len: usize = 0; var node_path_len: usize = 0; var path_buffer: [128]*EventTarget = undefined; + var clear_targets = false; // Defer runs even on early return - ensures event phase is reset // and default actions execute (unless prevented) @@ -187,7 +213,14 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts event._current_target = null; event._stop_propagation = false; event._stop_immediate_propagation = false; - if (event._needs_retargeting and node_path_len > 0) { + if (clear_targets) { + // Don't leak nodes living in a shadow tree: reset the targets + // (decided on the pre-dispatch tree, see below). + event._target = null; + if (event.relatedTargetPtr()) |related_ptr| { + related_ptr.* = null; + } + } else if (event._needs_retargeting and node_path_len > 0) { const adjusted = getAdjustedTarget(event._dispatch_target, path_buffer[node_path_len - 1]); event._target = if (rootIsShadowRoot(adjusted)) null else adjusted; } @@ -200,9 +233,16 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts if (event._prevent_default) { // can't return in a defer (╯°□°)╯︵ ┻━┻ } else if (event._type_string.eql(comptime .wrap("click"))) { - Frame.user_input.handleClick(frame, target) catch |err| { - log.warn(.event, "frame.click", .{ .err = err }); - }; + // Per spec, only a MouseEvent "click" is an activation event, and + // the activation target is the nearest inclusive ancestor with + // activation behavior (ancestors only for bubbling events). + if (event.is(@import("webapi/event/MouseEvent.zig")) != null) { + if (Frame.user_input.findClickActivationTarget(target, event._bubbles)) |activation_target| { + Frame.user_input.handleClick(frame, activation_target) catch |err| { + log.warn(.event, "frame.click", .{ .err = err }); + }; + } + } } else if (event._type_string.eql(comptime .wrap("keydown"))) { Frame.user_input.handleKeydown(frame, target, event) catch |err| { log.warn(.event, "frame.keydown", .{ .err = err }); @@ -258,6 +298,26 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts } } + // DOM dispatch: decide up front — on the pre-dispatch tree, so listener + // mutations can't affect it — whether target and relatedTarget must be + // reset after dispatch because they would expose nodes inside a shadow + // tree. + if (node_path_len > 0) { + const last = path_buffer[node_path_len - 1]; + if (event._needs_retargeting) { + if (rootIsShadowRoot(getAdjustedTarget(event._dispatch_target, last))) { + clear_targets = true; + } + } + if (event.relatedTargetPtr()) |related_ptr| { + if (related_ptr.*) |related| { + if (rootIsShadowRoot(getAdjustedTarget(related, last))) { + clear_targets = true; + } + } + } + } + const path = path_buffer[0..path_len]; // Phase 1: Capturing phase (root → target, excluding target) @@ -284,11 +344,18 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts was_handled = true; event._current_target = target_et; + const prev_current_event = window._current_event; + window._current_event = currentEventForTarget(target_et, event); + defer window._current_event = prev_current_event; + // Inline handlers (e.g. onclick property) follow the same "report, // don't propagate" rule as addEventListener listeners — see Listener.run. - ls.toLocal(inline_handler).callWithThis(void, target_et, .{event}) catch |err| { - log.warn(.event, "inline handler", .{ .err = err }); + var caught: js.TryCatch.Caught = undefined; + const handler_return: ?js.Value = ls.toLocal(inline_handler).tryCallWithThis(js.Value, target_et, .{event}, &caught) catch |err| ret: { + log.warn(.event, "inline handler", .{ .err = err, .caught = caught }); + break :ret null; }; + processHandlerReturnValue(event, handler_return); if (event._stop_propagation) { return; @@ -299,8 +366,18 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts } } + // Per spec, the target is invoked once during the capturing iteration + // and once during the bubbling iteration, each with its own snapshot + // of the listener list: a bubble listener added while running the + // target's capture listeners must run. if (self.base.getListeners(target_et, event._type_string)) |list| { - try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(null, opts)); + try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(true, opts)); + if (event._stop_propagation) { + return; + } + } + if (self.base.getListeners(target_et, event._type_string)) |list| { + try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(false, opts)); if (event._stop_propagation) { return; } @@ -320,14 +397,21 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts was_handled = true; event._current_target = current_target; + const prev_current_event = window._current_event; + window._current_event = currentEventForTarget(current_target, event); + defer window._current_event = prev_current_event; + const original_target = event._target; if (event._needs_retargeting) { event._target = getAdjustedTarget(original_target, current_target); } - ls.toLocal(inline_handler).callWithThis(void, current_target, .{event}) catch |err| { - log.warn(.event, "inline handler", .{ .err = err }); + var caught: js.TryCatch.Caught = undefined; + const handler_return: ?js.Value = ls.toLocal(inline_handler).tryCallWithThis(js.Value, current_target, .{event}, &caught) catch |err| ret: { + log.warn(.event, "inline handler", .{ .err = err, .caught = caught }); + break :ret null; }; + processHandlerReturnValue(event, handler_return); if (event._needs_retargeting) { event._target = original_target; @@ -348,6 +432,19 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts } } +fn processHandlerReturnValue(event: *Event, handler_return: ?js.Value) void { + const ret = handler_return orelse return; + if (ret.isFalse() and !event._type_string.eql(comptime .wrap("error"))) { + event.preventDefault(); + } +} + +// Per spec ("invocation target in shadow tree"), window.event is left +// undefined while invoking listeners whose target lives in a shadow tree. +fn currentEventForTarget(target: *EventTarget, event: *Event) ?*Event { + return if (rootIsShadowRoot(target)) null else event; +} + const DispatchPhaseOpts = struct { capture_only: ?bool = null, apply_ignore: bool = false, @@ -364,6 +461,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe const frame = self.frame; const base = &self.base; + const window = frame.window; + const prev_current_event = window._current_event; + window._current_event = currentEventForTarget(current_target, event); + defer window._current_event = prev_current_event; + // Track dispatch depth for deferred removal base.dispatch_depth += 1; defer { @@ -465,6 +567,17 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j // Look up the inline handler for this target const html_element = switch (target._type) { .node => |n| n.is(Element.Html) orelse return null, + // The Window stores its event handlers in dedicated fields; an event + // propagating to the window must fire them too. + .window => |w| return switch (handler_type) { + .onerror => w._on_error, + .onload => w._on_load, + .onblur => w._on_blur, + .onfocus => w._on_focus, + .onresize => w._on_resize, + .onscroll => w._on_scroll, + else => null, + }, else => return null, }; @@ -477,8 +590,6 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j // DOM spec "retarget": walk original_target out of shadow trees until the // node is visible from current_target's tree. fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget { - const ShadowRoot = @import("webapi/ShadowRoot.zig"); - const orig_node = switch ((original_target orelse return null)._type) { .node => |n| n, else => return original_target, @@ -500,8 +611,6 @@ fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarge } fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool { - const ShadowRoot = @import("webapi/ShadowRoot.zig"); - var n: ?*Node = node; while (n) |cur| { if (cur == ancestor) { @@ -519,8 +628,6 @@ fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool { // Whether the target's tree root (without crossing shadow boundaries) is a // shadow root. Used for the spec's post-dispatch "clear targets" step. fn rootIsShadowRoot(target_: ?*EventTarget) bool { - const ShadowRoot = @import("webapi/ShadowRoot.zig"); - const target = target_ orelse return false; var current: *Node = switch (target._type) { .node => |n| n, @@ -569,12 +676,18 @@ const ActivationState = struct { const Input = Element.Html.Input; - fn create(event: *const Event, target: *Node, frame: *Frame) !?ActivationState { + fn create(event: *Event, target: *Node, frame: *Frame) !?ActivationState { if (event._type_string.eql(comptime .wrap("click")) == false) { return null; } - const input = target.is(Element.Html.Input) orelse return null; + // Per spec, only a MouseEvent "click" is an activation event. + if (event.is(@import("webapi/event/MouseEvent.zig")) == null) { + return null; + } + + const activation_node = Frame.user_input.findClickActivationTarget(target, event._bubbles) orelse return null; + const input = activation_node.is(Element.Html.Input) orelse return null; if (input._input_type != .checkbox and input._input_type != .radio) { return null; } diff --git a/src/browser/EventManagerBase.zig b/src/browser/EventManagerBase.zig index c18253426..feff0eb35 100644 --- a/src/browser/EventManagerBase.zig +++ b/src/browser/EventManagerBase.zig @@ -223,7 +223,7 @@ pub const DispatchDirectOptions = struct { /// Direct dispatch for non-DOM targets. No propagation - just calls the property /// handler and registered listeners. Caller is responsible for event ref counting. -/// Handler can be: null, ?js.Function.Global, ?js.Function.Temp, or js.Function +/// Handler can be: null, ?js.Function.Global or js.Function pub fn dispatchDirect( self: *EventManagerBase, arena: Allocator, @@ -287,11 +287,12 @@ pub fn dispatchDirect( // Call the property handler (e.g., onmessage) if present if (getFunction(handler, &ls.local)) |func| { event._current_target = target; - _ = func.callWithThis(void, target, .{event}) catch |err| { + var caught: js.TryCatch.Caught = undefined; + _ = func.tryCallWithThis(void, target, .{event}, &caught) catch |err| { if (err == error.JsException) { event._listeners_did_throw = true; } else { - log.warn(.event, opts.context, .{ .err = err }); + log.warn(.event, opts.context, .{ .err = err, .caught = caught }); } }; } @@ -359,7 +360,6 @@ fn getFunction(handler: anytype, local: *const js.Local) ?js.Function { } return switch (T) { js.Function => handler, - js.Function.Temp => local.toLocal(handler), js.Function.Global => local.toLocal(handler), else => @compileError("handler must be null or \\??js.Function(\\.(Temp|Global))?"), }; @@ -435,12 +435,19 @@ pub const Listener = struct { comptime context: []const u8, ) error{OutOfMemory}!void { switch (self.function) { - .value => |value| local.toLocal(value).callWithThis(void, event._current_target.?, .{event}) catch |err| { - if (err == error.JsException) { - event._listeners_did_throw = true; - } else { - log.warn(.event, context, .{ .err = err }); - } + .value => |value| { + var try_catch: js.TryCatch = undefined; + try_catch.init(local); + defer try_catch.deinit(); + local.toLocal(value).callWithThisRethrow(void, event._current_target.?, .{event}) catch |err| switch (err) { + // The rethrow variant surfaces a thrown JS exception as + // TryCatchRethrow so our enclosing TryCatch holds it. + error.JsException, error.TryCatchRethrow => { + event._listeners_did_throw = true; + reportException(&try_catch, local); + }, + else => log.warn(.event, context, .{ .err = err }), + }; }, .string => |string| { const str = try arena.dupeZ(u8, string.str()); @@ -454,31 +461,64 @@ pub const Listener = struct { }, .object => |obj_global| { const obj = local.toLocal(obj_global); - const handle_event = obj.getFunction("handleEvent") catch |err| blk: { - // Getting "handleEvent" threw (e.g. a throwing getter). + + var try_catch: js.TryCatch = undefined; + try_catch.init(local); + defer try_catch.deinit(); + + // Get(handleEvent) can run a getter: a thrown exception is + // reported like an exception from the listener itself. + const handle_event_value = obj.get("handleEvent") catch |err| { if (err == error.JsException) { event._listeners_did_throw = true; + reportException(&try_catch, local); } else { log.warn(.event, context, .{ .err = err }); } - break :blk null; + return; }; - if (handle_event) |handleEvent| { - handleEvent.callWithThis(void, obj, .{event}) catch |err| { - if (err == error.JsException) { - event._listeners_did_throw = true; - } else { - log.warn(.event, context, .{ .err = err }); - } - }; - } else { - // The listener was an object without the handleEvent function - // This is throwing a TypeError + + if (!handle_event_value.isFunction()) { + // Per the callback interface invocation steps, a + // non-callable handleEvent is a reported TypeError. event._listeners_did_throw = true; + reportExceptionValue(local, .{ + .local = local, + .handle = local.isolate.createTypeError("handleEvent is not a function"), + }); + return; } + + const handle_event = js.Function{ + .local = local, + .handle = @ptrCast(handle_event_value.handle), + }; + handle_event.callWithThisRethrow(void, obj, .{event}) catch |err| switch (err) { + error.JsException, error.TryCatchRethrow => { + event._listeners_did_throw = true; + reportException(&try_catch, local); + }, + else => log.warn(.event, context, .{ .err = err }), + }; }, } } + + // Reports a listener exception to the relevant global (firing + // window.onerror / an "error" event) without stopping the dispatch. + fn reportException(try_catch: *js.TryCatch, local: *const js.Local) void { + const exc = try_catch.exceptionValue() orelse return; + reportExceptionValue(local, exc); + } + + fn reportExceptionValue(local: *const js.Local, exc: js.Value) void { + switch (local.ctx.global) { + .frame => |frame| frame.window.reportError(exc, frame) catch |err| { + log.warn(.event, "listener report error", .{ .err = err }); + }, + .worker => {}, + } + } }; pub const Function = union(enum) { diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig index 248ededab..5065b7141 100644 --- a/src/browser/Factory.zig +++ b/src/browser/Factory.zig @@ -35,6 +35,8 @@ const EventTarget = @import("webapi/EventTarget.zig"); const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig"); const Blob = @import("webapi/Blob.zig"); const AbstractRange = @import("webapi/AbstractRange.zig"); +const DOMRect = @import("webapi/DOMRect.zig"); +const DOMRectReadOnly = @import("webapi/DOMRectReadOnly.zig"); const log = lp.log; const String = lp.String; @@ -235,8 +237,9 @@ fn AutoPrototypeChain(comptime types: []const type) type { fn eventInit(arena: Allocator, typ: String, value: anytype) !Event { // Round to 2ms for privacy (browsers do this) - const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic); - const time_stamp = (raw_timestamp / 2) * 2; + // Same (already coarsened) clock as the performance time origin, so the + // timeStamp getter can report it relative to that origin. + const time_stamp = @import("webapi/Performance.zig").highResTimestamp(); return .{ ._rc = .{}, @@ -292,6 +295,22 @@ pub fn abstractRange(_: *const Factory, arena: Allocator, child: anytype, frame: return chain.get(1); } +pub fn domRect(self: *Factory, rect: DOMRectReadOnly.Data) !*DOMRect { + const chain = try PrototypeChain(&.{ DOMRectReadOnly, DOMRect }).allocate(self._slab.allocator()); + + const base = chain.get(0); + base.* = .{ + ._type = .{ .mutable = chain.get(1) }, + ._x = rect.x, + ._y = rect.y, + ._width = rect.width, + ._height = rect.height, + }; + chain.setLeaf(1, DOMRect{ ._proto = base }); + + return chain.get(1); +} + pub fn node(self: *Factory, child: anytype) !*@TypeOf(child) { const allocator = self._slab.allocator(); return try AutoPrototypeChain( @@ -335,33 +354,49 @@ pub fn htmlMediaElement(self: *Factory, child: anytype) !*@TypeOf(child) { } pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) { - const allocator = self._slab.allocator(); - const ChildT = @TypeOf(child); - - if (ChildT == Element.Svg) { - return self.element(child); - } - const chain = try PrototypeChain( - &.{ EventTarget, Node, Element, Element.Svg, ChildT }, - ).allocate(allocator); + &.{ EventTarget, Node, Element, Element.Svg, @TypeOf(child) }, + ).allocate(self._slab.allocator()); + try self.setSvgChainBase(chain, tag_name); + chain.setLeaf(4, child); + return chain.get(4); +} + +pub fn svgGraphicsElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) { + const chain = try PrototypeChain( + &.{ EventTarget, Node, Element, Element.Svg, Element.Svg.Graphics, @TypeOf(child) }, + ).allocate(self._slab.allocator()); + + try self.setSvgChainBase(chain, tag_name); + chain.setMiddle(4, Element.Svg.Graphics.Type); + chain.setLeaf(5, child); + return chain.get(5); +} + +pub fn svgGeometryElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) { + const chain = try PrototypeChain( + &.{ EventTarget, Node, Element, Element.Svg, Element.Svg.Graphics, Element.Svg.Graphics.Geometry, @TypeOf(child) }, + ).allocate(self._slab.allocator()); + + try self.setSvgChainBase(chain, tag_name); + chain.setMiddle(4, Element.Svg.Graphics.Type); + chain.setMiddle(5, Element.Svg.Graphics.Geometry.Type); + chain.setLeaf(6, child); + return chain.get(6); +} + +fn setSvgChainBase(self: *Factory, chain: anytype, tag_name: []const u8) !void { chain.setRoot(EventTarget.Type); chain.setMiddle(1, Node.Type); chain.setMiddle(2, Element.Type); - // will never allocate, can't fail - const tag_name_str = String.init(self._arena, tag_name, .{}) catch unreachable; - // Manually set Element.Svg with the tag_name chain.set(3, .{ ._proto = chain.get(2), - ._tag_name = tag_name_str, + ._tag_name = try String.init(self._arena, tag_name, .{}), ._type = unionInit(Element.Svg.Type, chain.get(4)), }); - - chain.setLeaf(4, child); - return chain.get(4); } pub fn xhrEventTarget(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) { diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index abe8642b9..0a78cd0e2 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -42,6 +42,7 @@ const Event = @import("webapi/Event.zig"); const EventTarget = @import("webapi/EventTarget.zig"); const Element = @import("webapi/Element.zig"); const HtmlElement = @import("webapi/element/Html.zig"); +const AnimatedString = @import("webapi/svg/AnimatedString.zig"); const Window = @import("webapi/Window.zig"); const Location = @import("webapi/Location.zig"); const Document = @import("webapi/Document.zig"); @@ -50,7 +51,9 @@ const Performance = @import("webapi/Performance.zig"); const Screen = @import("webapi/Screen.zig"); const VisualViewport = @import("webapi/VisualViewport.zig"); const AbstractRange = @import("webapi/AbstractRange.zig"); +const DOMNodeIterator = @import("webapi/DOMNodeIterator.zig"); const Worker = @import("webapi/Worker.zig"); +const MessagePort = @import("webapi/MessagePort.zig"); const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); @@ -60,7 +63,7 @@ const popover = @import("webapi/element/popover.zig"); const slotting = @import("webapi/element/slotting.zig"); const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; -const HttpClient = @import("HttpClient.zig"); +const HttpClient = @import("../network/HttpClient.zig"); const sys_url = @import("../sys/url.zig"); const timestamp = @import("../datetime.zig").timestamp; @@ -68,6 +71,7 @@ const milliTimestamp = @import("../datetime.zig").milliTimestamp; const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup; +pub const preload = @import("frame/preload.zig"); pub const observers = @import("frame/observers.zig"); pub const user_input = @import("frame/user_input.zig"); pub const node_factory = @import("frame/node_factory.zig"); @@ -133,10 +137,12 @@ _element_computed_styles: Element.StyleLookup = .empty, _element_datasets: Element.DatasetLookup = .empty, _element_class_lists: Element.ClassListLookup = .empty, _element_rel_lists: Element.RelListLookup = .empty, +_element_token_lists: Element.TokenListLookup = .empty, _element_shadow_roots: Element.ShadowRootLookup = .empty, _node_owner_documents: Node.OwnerDocumentLookup = .empty, _element_scroll_positions: Element.ScrollPositionLookup = .empty, _element_namespace_uris: Element.NamespaceUriLookup = .empty, +_svg_animated_strings: AnimatedString.Lookup = .empty, // Same as above, but for Nodes (slot assigments apply to both Element AND // Text nodes) @@ -186,11 +192,17 @@ _http_owner: HttpClient.Owner = .{}, // List of active live ranges (for mutation updates per DOM spec) _live_ranges: std.DoublyLinkedList = .{}, +// Live NodeIterators for the DOM pre-removing steps. Iterators are +// slab-allocated (frame lifetime) and never unlinked. +_live_node_iterators: std.DoublyLinkedList = .{}, // List of open BroadcastChannels, used to route postMessage between same-named // channels in this frame's origin _broadcast_channels: std.DoublyLinkedList = .{}, +// List of MessagePorts living in this frame's context. +_message_ports: std.DoublyLinkedList = .{}, + // MutationObserver / IntersectionObserver bookkeeping. See frame/observers.zig. _mutation: observers.Mutation = .{}, _intersection: observers.Intersection = .{}, @@ -209,6 +221,12 @@ _customized_builtin_disconnected_callback_invoked: std.AutoHashMapUnmanaged(*Ele // The constructor can access this to get the element being upgraded. _upgrading_element: ?*Node = null, +// Set when materializing the fragment parser's context element. The element +// is never inserted into the tree so if its a custom element ,we must not run +// its constructor (else we'll end up in an endless loop if the constructor +// sets this.innerHTML = '...', which happens). +_skip_custom_element_upgrade: bool = false, + // List of custom elements that were created before their definition was registered _undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{}, @@ -223,6 +241,10 @@ _factory: *Factory, _load_state: LoadState = .waiting, +// The navigation response's MIME essence, recorded when the first chunk +// arrives and applied to the new document (document.contentType) once the +// navigation commits. null means the text/html default. +_pending_content_type: ?[]const u8 = null, _parse_state: ParseState = .pre, /// `frameErrorCallback` swallows the failure into a placeholder page; @@ -460,6 +482,11 @@ pub fn deinit(self: *Frame) void { page.releaseArena(qn.arena); } + while (self._message_ports.first) |node| { + const port: *MessagePort = @alignCast(@fieldParentPtr("_node", node)); + port.close(); // removes from self._message_ports + } + { // Release all objects we're referencing { @@ -597,16 +624,16 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo self._load_state = .parsing; self._last_navigate_error = null; - const req_id = self._session.browser.http_client.nextReqId(); log.info(.frame, "navigate", .{ .url = request_url, .method = opts.method, .reason = opts.reason, .body = opts.body != null, - .req_id = req_id, .type = self._type, }); + const http_client = &session.browser.http_client; + // Handle synthetic navigations: about:blank and blob: URLs const is_about_blank = std.mem.eql(u8, "about:blank", request_url); const is_blob = !is_about_blank and std.mem.startsWith(u8, request_url, "blob:"); @@ -661,8 +688,11 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo }; const parse_arena = try self.getArena(.medium, "Frame.parseBlob"); defer self.releaseArena(parse_arena); + // A script executed mid-parse can revoke the blob URL, letting GC + // free the buffer under the parser; parse a copy. + const html = try parse_arena.dupe(u8, blob._slice); var parser = Parser.init(parse_arena, self.document.asNode(), self, .{ .allow_declarative_shadow = true }); - parser.parse(blob._slice); + parser.parse(html); } else { self.document.injectBlank(self) catch |err| { log.err(.browser, "inject blank", .{ .err = err }); @@ -670,6 +700,10 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo }; } + // No real request is made, but CDP still correlates the navigation + // events by request id, so consume one. + const req_id = http_client.incrReqId(); + session.notification.dispatch(.frame_navigate, &.{ .opts = opts, .req_id = req_id, @@ -694,9 +728,6 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo .timestamp = timestamp(.monotonic), }); - // force next request id manually b/c we won't create a real req. - _ = session.browser.http_client.incrReqId(); - if (self.parent == null) { session.navigation._current_navigation_kind = opts.kind; try session.navigation.commitNavigation(self); @@ -706,8 +737,6 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo return; } - const http_client = &session.browser.http_client; - self._http_status = null; self._http_headers = .empty; @@ -719,7 +748,6 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo }; self.origin = try URL.getOrigin(self.arena, self.url); - self._req_id = req_id; self._navigated_options = .{ .cdp_id = opts.cdp_id, .reason = opts.reason, @@ -728,14 +756,37 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo .header = if (opts.header) |h| try self.arena.dupeZ(u8, h) else null, }; - var headers = try http_client.newHeaders(); - try headers.add(lp.Config.HttpHeaders.navigation_accept); - if (opts.header) |hdr| { - try headers.add(hdr); - } - if (opts.referer) |ref| { - const ref_header = try std.mem.concatWithSentinel(self.arena, u8, &.{ "Referer: ", ref }, 0); - try headers.add(ref_header); + const transfer = try http_client.newRequest(.{ + .ctx = self, + .url = self.url, + .frame_id = self._frame_id, + .loader_id = self._loader_id, + .method = opts.method, + .body = opts.body, + .cookie_jar = &session.cookie_jar, + .cookie_origin = opts.initiator_url orelse self.url, + .resource_type = .document, + .notification = self._session.notification, + .header_callback = frameHeaderDoneCallback, + .data_callback = frameDataCallback, + .done_callback = frameDoneCallback, + .error_callback = frameErrorCallback, + // The frame tracks its navigation by id (_req_id), never by pointer. + .shutdown_callback = HttpClient.noopShutdown, + }, &self._http_owner); + self._req_id = transfer.id; + + { + // Ours until submit; clean up if header setup fails. + errdefer transfer.deinit(); + try transfer.req.headers.add(lp.Config.HttpHeaders.navigation_accept); + if (opts.header) |hdr| { + try transfer.req.headers.add(hdr); + } + if (opts.referer) |ref| { + const ref_header = try std.mem.concatWithSentinel(transfer.arena, u8, &.{ "Referer: ", ref }, 0); + try transfer.req.headers.add(ref_header); + } } // A root navigation issued against a pending Page (i.e. one allocated by @@ -751,7 +802,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo session.notification.dispatch(.frame_navigate, &.{ .opts = opts, .url = self.url, - .req_id = req_id, + .req_id = transfer.id, .frame_id = self._frame_id, .loader_id = self._loader_id, .timestamp = timestamp(.monotonic), @@ -763,37 +814,24 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo session.navigation._current_navigation_kind = opts.kind; - self.makeRequest(.{ - .ctx = self, - .url = self.url, - .frame_id = self._frame_id, - .loader_id = self._loader_id, - .method = opts.method, - .headers = headers, - .body = opts.body, - .cookie_jar = &session.cookie_jar, - .cookie_origin = opts.initiator_url orelse self.url, - .resource_type = .document, - .notification = self._session.notification, - .header_callback = frameHeaderDoneCallback, - .data_callback = frameDataCallback, - .done_callback = frameDoneCallback, - .error_callback = frameErrorCallback, - }) catch |err| { + transfer.submit() catch |err| { log.err(.frame, "navigate request", .{ .url = self.url, .err = err, .type = self._type }); return err; }; } fn recordNavigateTelemetry(self: *Frame, tls: bool) void { + const TE = @import("../telemetry/telemetry.zig").Event; + const context: TE.Navigate.Context = if (self.parent != null) + .iframe + else if (self.window._opener != null) + .popup + else + .page; + lp.metrics.navigate.incr(context); self._session.browser.app.telemetry.record(.{ .navigate = .{ .tls = tls, - .context = if (self.parent != null) - .iframe - else if (self.window._opener != null) - .popup - else - .page, + .context = context, } }); } @@ -856,6 +894,21 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url }; const session = target._session; + + // Re-navigating to the exact current URL is only a reload when the URL + // has no fragment. With a fragment it's a fragment navigation per the + // HTML "navigate" steps (url equals the document's URL excluding + // fragments and url's fragment is non-null): no reload, and since the + // fragment didn't change, no hashchange and no new history entry either. + if (!opts.force and + opts.kind != .reload and + std.mem.eql(u8, target.url, resolved_url) and + std.mem.indexOfScalar(u8, resolved_url, '#') != null) + { + session.releaseArena(arena); + return; + } + // Short-circuit only true fragment-only navigations (same path/query, different // fragment). Identical URLs fall through and trigger a real reload. const is_fragment_navigation = !std.mem.eql(u8, target.url, resolved_url) and URL.eqlDocument(target.url, resolved_url); @@ -962,6 +1015,11 @@ pub fn makeRequest(self: *Frame, req: HttpClient.Request) !void { return self._session.browser.http_client.request(req, &self._http_owner); } +// Two-phase variant; see HttpClient.newRequest for the ownership contract. +pub fn newRequest(self: *Frame, req: HttpClient.Request) !*HttpClient.Transfer { + return self._session.browser.http_client.newRequest(req, &self._http_owner); +} + // Synchronously abort every transfer and WebSocket owned by this frame // and all of its descendants. pub fn abortTransfers(self: *Frame) void { @@ -970,8 +1028,6 @@ pub fn abortTransfers(self: *Frame) void { } const http_client = &self._session.browser.http_client; http_client.abortOwner(&self._http_owner); - // abortOwner misses deferred contexts whose transfer already completed. - http_client.deferring_layer.cancelFrame(self._frame_id); } pub fn documentIsLoaded(self: *Frame) void { @@ -1144,8 +1200,8 @@ fn notifyParentLoadComplete(self: *Frame) void { parent.iframeCompletedLoading(self.iframe.?, self._delays_parent_load); } -fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResult { - var self: *Frame = @ptrCast(@alignCast(response.ctx)); +fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.HeaderResult { + var self: *Frame = @ptrCast(@alignCast(transfer.req.ctx)); // Commit point for a pending root navigation. The session has been // holding the OLD page alive during the round-trip; now that response @@ -1157,7 +1213,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu try self._session.commitPendingPage(self._page); } - const response_url = response.url(); + const response_url = transfer.req.url; if (std.mem.eql(u8, response_url, self.url) == false) { // would be different than self.url in the case of a redirect self.url = try self.arena.dupeZ(u8, response_url); @@ -1169,7 +1225,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu // Page.reload doesn't re-POST form data to the redirect target. Conservative // default — 307/308 technically preserve the method per RFC 7231, but // resubmitting form data is the more dangerous failure mode. - if ((response.redirectCount() orelse 0) > 0) { + if ((transfer.redirectCount() orelse 0) > 0) { if (self._navigated_options) |*no| { no.method = .GET; no.body = null; @@ -1186,14 +1242,14 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu if (comptime IS_DEBUG) { log.debug(.frame, "navigate header", .{ .url = self.url, - .status = response.status(), - .content_type = response.contentType(), + .status = transfer.responseStatus(), + .content_type = transfer.contentType(), .type = self._type, }); } - self._http_status = response.status(); - var it = response.headerIterator(); + self._http_status = transfer.responseStatus(); + var it = transfer.responseHeaderIterator(); while (it.next()) |hdr| { try self._http_headers.append(self.arena, .{ .name = try self.arena.dupe(u8, hdr.name), @@ -1218,7 +1274,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu // If the response is a file download, stream its body to disk instead of // parsing it as a page. This sets _parse_state to .download, which the // data/done callbacks below special-case. - _ = try self.maybeStartDownload(response); + _ = try self.maybeStartDownload(transfer); return .proceed; } @@ -1227,7 +1283,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu // treated as a download when Browser.setDownloadBehavior opted in // (allow/allowAndName) and the response carries Content-Disposition: attachment. // See issue #2701. -fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool { +fn maybeStartDownload(self: *Frame, transfer: *HttpClient.Transfer) !bool { const session = self._session; switch (session.download_behavior) { .allow, .allow_and_name => {}, @@ -1235,7 +1291,7 @@ fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool { } const disposition: HttpClient.Header = blk: { - var it = response.headerIterator(); + var it = transfer.responseHeaderIterator(); while (it.next()) |hdr| { if (std.ascii.eqlIgnoreCase(hdr.name, "content-disposition")) { break :blk hdr; @@ -1281,7 +1337,7 @@ fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool { return false; }; - const total: ?u64 = if (response.contentLength()) |cl| cl else null; + const total: ?u64 = if (transfer.getContentLength()) |cl| cl else null; self._parse_state = .{ .download = .{ .guid = guid, @@ -1364,14 +1420,14 @@ fn isUtf16Encoding(charset: []const u8) bool { return std.mem.eql(u8, charset, "UTF-16LE") or std.mem.eql(u8, charset, "UTF-16BE"); } -fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void { - var self: *Frame = @ptrCast(@alignCast(response.ctx)); +fn frameDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { + var self: *Frame = @ptrCast(@alignCast(transfer.req.ctx)); if (self._parse_state == .pre) { // we lazily do this, because we might need the first chunk of data // to sniff the content type var mime: Mime = blk: { - if (response.contentType()) |ct| { + if (transfer.contentType()) |ct| { break :blk try Mime.parse(ct); } break :blk Mime.sniff(data); @@ -1400,6 +1456,22 @@ fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void { }); } + // Record the response's MIME essence so the resulting document + // reports it (document.contentType). text/html keeps the default, so an + // HTML response (parsed from the header, or sniffed when there's no + // header) never needs an override; inside this branch the essence can + // no longer be text/html. + self._pending_content_type = null; + if (mime.content_type != .text_html) { + if (transfer.contentType()) |ct| { + const end = std.mem.indexOfScalarPos(u8, ct, 0, ';') orelse ct.len; + const essence = std.mem.trim(u8, ct[0..end], " \t"); + if (essence.len > 0) { + self._pending_content_type = try std.ascii.allocLowerString(self.arena, essence); + } + } + } + switch (mime.content_type) { .text_html => { // Normalize and store the charset using encoding_rs canonical names @@ -1474,6 +1546,11 @@ fn frameDoneCallback(ctx: *anyopaque) !void { //We need to handle different navigation types differently. try self._session.navigation.commitNavigation(self); + if (self._pending_content_type) |content_type| { + self.document._content_type = content_type; + self._pending_content_type = null; + } + defer if (comptime IS_DEBUG) { log.debug(.frame, "frame load complete", .{ .url = self.url, @@ -1497,6 +1574,8 @@ fn frameDoneCallback(ctx: *anyopaque) !void { const raw_html = html.buffer.items; + preload.prescan(self, raw_html); + if (std.mem.eql(u8, self.charset, "UTF-8")) { parser.parse(raw_html); } else { @@ -1824,6 +1903,7 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame { errdefer popup.deinit(); popup.window._opener = opts.opener; + if (opts.name.len > 0 and !std.ascii.eqlIgnoreCase(opts.name, "_blank") and !std.ascii.eqlIgnoreCase(opts.name, "_self") and @@ -1843,6 +1923,13 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame { return err; }; + // A bit hacky. The type of window we return depends on whether or not its + // origin is the same as the opener. I believe that we're supposed to do + // this check lazily, per function invocation. But we don't have that in + // place right now. So the best we can do is set the origin now, before the + // async navigation completed. + try popup.js.setOrigin(popup.origin); + return popup; } @@ -2027,40 +2114,6 @@ pub fn queueHashChange(self: *Frame, old_url: []const u8, new_url: []const u8) ! // splitting by route anyway). const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024; -// start prefetching ` -pub fn preloadScriptHint(self: *Frame, element: *Element.Html, href: []const u8) bool { - if (self.isGoingAway() or self._parse_mode == .fragment) { - return false; - } - - const arena = self.getArena(.small, "Frame.preloadScriptHint") catch return false; - defer self.releaseArena(arena); - - const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch return false; - if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) { - // data:/blob: are synthesized locally — no round-trip to hide. - return false; - } - return self._script_manager.preloadScript(element, resolved) catch false; -} - -// start prefetching -pub fn preloadModuleHint(self: *Frame, element: *Element.Html, href: []const u8) bool { - if (self.isGoingAway() or self._parse_mode == .fragment) { - return false; - } - - // The url becomes the imported_modules key, which must outlive the fetch - // so it lives on the frame arena - const resolved = URL.resolve(self.arena, self.base(), href, .{ .encoding = self.charset }) catch return false; - if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) { - // data:/blob: are synthesized locally — no round-trip to hide. - return false; - } - - return self._script_manager.base.preloadModuleHint(element, resolved, self.url) catch false; -} - // Synchronously fetch and parse an external ``. // href is passed in as an optimization since the [currently] only callsite has // it, so why look it up again? @@ -2096,18 +2149,10 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co const http_client = &session.browser.http_client; - // `syncRequest` below registers a blocking request for this frame, which - // makes the DeferringLayer hold back the completion callbacks of every - // OTHER in-flight transfer for the frame (e.g. a ` + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + \\ + ); + + const sm = &frame._script_manager; + try testing.expectEqual(2, sm.preloaded_scripts.count()); + try testing.expectEqual(true, sm.preloaded_scripts.contains("http://127.0.0.1:9582/serve-count/unit_a.js")); + try testing.expectEqual(true, sm.preloaded_scripts.contains("http://127.0.0.1:9582/serve-count/sub/based.js")); + + try testing.expectEqual(1, sm.base.imported_modules.count()); + const module = sm.base.imported_modules.get("http://127.0.0.1:9582/serve-count/unit_m.js") orelse return error.MissingModule; + try testing.expectEqual(true, module.hint); +} diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index a9c761a8f..a1734bc82 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -27,6 +27,7 @@ const lp = @import("lightpanda"); const builtin = @import("builtin"); const Frame = @import("../Frame.zig"); +const js = @import("../js/js.zig"); const Node = @import("../webapi/Node.zig"); const Event = @import("../webapi/Event.zig"); @@ -78,6 +79,7 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { }); } try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, 0); + try focusEditingHostForMouseDown(frame, target); } pub fn triggerMouseMove(frame: *Frame, x: f64, y: f64) !void { @@ -200,6 +202,128 @@ fn deltaToScroll(d: f64) i32 { } // callback when the "click" event reaches the frame. +// Whether the element has a click activation behavior that handleClick +// implements. +fn hasClickActivationBehavior(node: *Node) bool { + const element = node.is(Element) orelse return false; + const html_element = element.is(Element.Html) orelse return false; + return switch (html_element._type) { + .anchor => element.getAttributeSafe(comptime .wrap("href")) != null, + .input, .button, .select, .textarea, .label => true, + .generic => |generic| generic._tag == .summary, + else => false, + }; +} + +// Clicks on editable content are for editing: they don't activate the +// element or any enclosing link. +// "contenteditable" is 15 bytes — past the comptime SSO limit — so the +// String wrap runs at runtime, mirroring Html.getIsContentEditable. +fn isEditingHost(node: *Node) bool { + const element = node.is(Element) orelse return false; + const value = element.getAttributeSafe(.wrap("contenteditable")) orelse return false; + return std.ascii.eqlIgnoreCase(value, "false") == false; +} + +// A mousedown on editable content focuses its editing host: the outermost +// element of the contiguous editable chain containing the target. +pub fn focusEditingHostForMouseDown(frame: *Frame, target: *Element) !void { + var node: ?*Node = target.asNode(); + var editable: ?*Node = null; + while (node) |n| : (node = n._parent) { + if (isEditingHost(n)) { + editable = n; + break; + } + } + var host = editable orelse return; + while (host._parent) |p| { + if (!isEditingHost(p)) { + break; + } + host = p; + } + const host_element = host.is(Element) orelse return; + try host_element.focus(frame); +} + +// Per the DOM dispatch algorithm, a click's activation target is the event +// target itself when it has activation behavior, otherwise — for bubbling +// events only — the nearest ancestor that has one. +pub fn findClickActivationTarget(target: *Node, bubbles: bool) ?*Node { + if (isEditingHost(target)) { + return null; + } + if (hasClickActivationBehavior(target)) { + return target; + } + if (!bubbles) { + return null; + } + var node = target._parent; + while (node) |n| : (node = n._parent) { + if (isEditingHost(n)) { + return null; + } + if (hasClickActivationBehavior(n)) { + return n; + } + } + return null; +} + +fn runJavascriptUrl(frame: *Frame, source: []const u8) !void { + const arena = try frame.getArena(.tiny, "javascript-url"); + errdefer frame.releaseArena(arena); + + const task = try arena.create(JavascriptUrlTask); + task.* = .{ + .frame = frame, + .arena = arena, + // TODO: the URL body should be percent-decoded; hrefs written in + // markup rarely are. + .source = try arena.dupe(u8, source), + }; + try frame.js.scheduler.add(task, JavascriptUrlTask.run, 0, .{ + .name = "javascript-url", + .finalizer = JavascriptUrlTask.finalize, + }); +} + +const JavascriptUrlTask = struct { + frame: *Frame, + arena: std.mem.Allocator, + source: []const u8, + + fn run(ptr: *anyopaque) !?u32 { + const self: *JavascriptUrlTask = @ptrCast(@alignCast(ptr)); + const frame = self.frame; + defer self.deinit(); + + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + const script = ls.local.compile(self.source, "javascript:") catch |err| { + log.warn(.browser, "javascript-url compile", .{ .err = err, .type = frame._type, .url = frame.url }); + return null; + }; + _ = script.run() catch |err| { + log.warn(.browser, "javascript-url run", .{ .err = err, .type = frame._type, .url = frame.url }); + }; + return null; + } + + fn finalize(ptr: *anyopaque) void { + const self: *JavascriptUrlTask = @ptrCast(@alignCast(ptr)); + self.deinit(); + } + + fn deinit(self: *JavascriptUrlTask) void { + self.frame.releaseArena(self.arena); + } +}; + pub fn handleClick(frame: *Frame, target: *Node) !void { // TODO: Also support elements when implement const element = target.is(Element) orelse return; @@ -213,7 +337,10 @@ pub fn handleClick(frame: *Frame, target: *Node) !void { } if (std.mem.startsWith(u8, href, "javascript:")) { - return; + // Navigating to a javascript: URL evaluates the script in the + // node's frame as a queued task. (A string completion value + // would replace the document; we ignore results.) + return runJavascriptUrl(target.ownerFrame(frame), href["javascript:".len..]); } if (try element.hasAttribute(comptime .wrap("download"), frame)) { diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig index 45bd7d7b7..8c9c51e31 100644 --- a/src/browser/interactive.zig +++ b/src/browser/interactive.zig @@ -448,7 +448,7 @@ pub fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 { } if (child.is(Node.CData)) |cdata| { if (cdata.is(Node.CData.Text)) |text| { - const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace); + const content = std.mem.trim(u8, text.ownData(), &std.ascii.whitespace); if (content.len > 0) { if (single_chunk == null and arr.items.len == 0) { single_chunk = content; diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 794be75f1..a1844976f 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -209,59 +209,6 @@ fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, inf return handleIndexedReturn(T, F, true, local, ret, info, opts); } -pub fn setIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { - const local = &self.local; - - var hs: js.HandleScope = undefined; - hs.init(local.isolate); - defer hs.deinit(); - - const info = PropertyCallbackInfo{ .handle = handle }; - return _setIndex(T, local, func, idx, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| { - handleError(T, @TypeOf(func), local, err, info); - return js.Intercepted.no; - }; -} - -fn _setIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { - const F = @TypeOf(func); - var args: ParameterTypes(F) = undefined; - @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); - @field(args, "1") = idx; - @field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value); - if (@typeInfo(F).@"fn".params.len == 4) { - @field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx); - } - const ret = @call(.auto, func, args); - return handleIndexedReturn(T, F, false, local, ret, info, opts); -} - -pub fn deleteIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { - const local = &self.local; - - var hs: js.HandleScope = undefined; - hs.init(local.isolate); - defer hs.deinit(); - - const info = PropertyCallbackInfo{ .handle = handle }; - return _deleteIndex(T, local, func, idx, info, opts) catch |err| { - handleError(T, @TypeOf(func), local, err, info); - return js.Intercepted.no; - }; -} - -fn _deleteIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { - const F = @TypeOf(func); - var args: ParameterTypes(F) = undefined; - @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); - @field(args, "1") = idx; - if (@typeInfo(F).@"fn".params.len == 3) { - @field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); - } - const ret = @call(.auto, func, args); - return handleIndexedReturn(T, F, false, local, ret, info, opts); -} - pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { const local = &self.local; @@ -288,6 +235,59 @@ fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c return handleIndexedReturn(T, F, true, local, ret, info, opts); } +pub fn setIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { + const local = &self.local; + + var hs: js.HandleScope = undefined; + hs.init(local.isolate); + defer hs.deinit(); + + const info = PropertyCallbackInfo{ .handle = handle }; + return _setIndex(T, local, func, idx, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| { + handleError(T, @TypeOf(func), local, err, info); + return js.Intercepted.no; + }; +} + +fn _setIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { + const F = @TypeOf(func); + var args: ParameterTypes(F) = undefined; + @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); + @field(args, "1") = idx; + @field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value); + if (@typeInfo(F).@"fn".params.len == 4) { + @field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx); + } + const ret = @call(.auto, func, args); + return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts); +} + +pub fn deleteOrDefineIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { + const local = &self.local; + + var hs: js.HandleScope = undefined; + hs.init(local.isolate); + defer hs.deinit(); + + const info = PropertyCallbackInfo{ .handle = handle }; + return _deleteOrDefineIndex(T, local, func, idx, info, opts) catch |err| { + handleError(T, @TypeOf(func), local, err, info); + return js.Intercepted.no; + }; +} + +fn _deleteOrDefineIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { + const F = @TypeOf(func); + var args: ParameterTypes(F) = undefined; + @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); + @field(args, "1") = idx; + if (@typeInfo(F).@"fn".params.len == 3) { + @field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); + } + const ret = @call(.auto, func, args); + return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts); +} + pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { const local = &self.local; @@ -312,10 +312,10 @@ fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c @field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx); } const ret = @call(.auto, func, args); - return handleIndexedReturn(T, F, false, local, ret, info, opts); + return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts); } -pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { +pub fn deleteOrDefineNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { const local = &self.local; var hs: js.HandleScope = undefined; @@ -323,13 +323,13 @@ pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *c defer hs.deinit(); const info = PropertyCallbackInfo{ .handle = handle }; - return _deleteNamedIndex(T, local, func, name, info, opts) catch |err| { + return _deleteOrDefineNamedIndex(T, local, func, name, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info); return js.Intercepted.no; }; } -fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { +fn _deleteOrDefineNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 { const F = @TypeOf(func); var args: ParameterTypes(F) = undefined; @field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis()); @@ -338,7 +338,7 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: @field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); } const ret = @call(.auto, func, args); - return handleIndexedReturn(T, F, false, local, ret, info, opts); + return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts); } pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 { @@ -388,11 +388,7 @@ fn _getIndexQuery(comptime T: type, local: *const Local, func: anytype, idx: u32 if (@typeInfo(F).@"fn".params.len == 3) { @field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); } - if (@call(.auto, func, args) == false) { - return js.Intercepted.no; - } - info.getReturnValue().set(try local.zigValueToJs(@as(u32, v8.None), .{})); - return js.Intercepted.yes; + return queryReturn(local, @call(.auto, func, args), info); } pub fn getNamedQuery(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo) u32 { @@ -417,12 +413,33 @@ fn _getNamedQuery(comptime T: type, local: *const Local, func: anytype, name: *c if (@typeInfo(F).@"fn".params.len == 3) { @field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx); } - if (@call(.auto, func, args) == false) { - return js.Intercepted.no; + return queryReturn(local, @call(.auto, func, args), info); +} + +// A query callback either returns a bool (true -> the property exists as an +// enumerable, writable, configurable data property, PropertyAttribute.None) +// or the v8.PropertyAttribute bits directly (e.g. v8.ReadOnly). +// error.NotHandled falls through to the ordinary property lookup. +fn queryReturn(local: *const Local, ret: anytype, info: PropertyCallbackInfo) !u32 { + const val = switch (@typeInfo(@TypeOf(ret))) { + .error_union => |eu| ret catch |err| { + if (comptime isInErrorSet(error.NotHandled, eu.error_set)) { + if (err == error.NotHandled) { + return js.Intercepted.no; + } + } + return err; + }, + else => ret, + }; + if (@TypeOf(val) == bool) { + if (val == false) { + return js.Intercepted.no; + } + info.getReturnValue().set(try local.zigValueToJs(@as(u32, v8.None), .{})); + } else { + info.getReturnValue().set(try local.zigValueToJs(@as(u32, val), .{})); } - // The property exists as a supported property name; report it as an - // enumerable, writable, configurable data property (PropertyAttribute.None). - info.getReturnValue().set(try local.zigValueToJs(@as(u32, v8.None), .{})); return js.Intercepted.yes; } @@ -453,6 +470,18 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value: return js.Intercepted.yes; } +// Setter/deleter interceptors normally return void: intercepting is enough +// to mark the operation successful. When they return a bool instead, it is +// forwarded as the v8 return value; false marks the operation as failed, +// which makes v8 throw a TypeError in strict mode. +fn returnsBool(comptime F: type) bool { + const RT = @typeInfo(F).@"fn".return_type.?; + return switch (@typeInfo(RT)) { + .error_union => |eu| eu.payload == bool, + else => RT == bool, + }; +} + fn isInErrorSet(err: anyerror, comptime T: type) bool { inline for (@typeInfo(T).error_set.?) |e| { if (err == @field(anyerror, e.name)) return true; @@ -471,6 +500,65 @@ fn nameToString(local: *const Local, comptime T: type, name: *const v8.Name) !T return try js.String.toSlice(.{ .local = local, .handle = handle }); } +// Per Web IDL, exceptions belong to the operation's relevant realm — the +// receiver's — which differs from the calling realm for cross-realm calls +// (v8 API callbacks run in the caller's context, and our JS wrappers are +// shared across the page's contexts). For DOM nodes, the relevant realm is +// the node document's frame; otherwise fall back to the calling realm. +fn errorLocal(comptime T: type, local: *const Local, info: anytype) Local { + if (@TypeOf(info) != FunctionCallbackInfo) { + return local.*; + } + + const frame = switch (local.ctx.global) { + .frame => |f| f, + .worker => return local.*, + }; + + const Node = @import("../webapi/Node.zig"); + const Document = @import("../webapi/Document.zig"); + + const is_node_type = comptime blk: { + if (@typeInfo(T) != .@"struct" or !@hasDecl(T, "JsApi")) break :blk false; + break :blk @import("bridge.zig").inheritsOrIs(T.JsApi, Node.JsApi); + }; + if (comptime !is_node_type) { + return local.*; + } + + const instance = TaggedOpaque.fromJS(*T, info.getThis()) catch return local.*; + const node = protoNode(T, instance); + + const doc: *Document = node.ownerDocument(frame) orelse switch (node._type) { + .document => |d| d, + else => return local.*, + }; + const doc_frame = doc._frame orelse return local.*; + if (doc_frame == frame) { + return local.*; + } + + const ctx = doc_frame.js; + const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&ctx.handle, ctx.isolate.handle) orelse return local.*); + return .{ + .ctx = ctx, + .handle = local_v8_context, + .call_arena = ctx.call_arena, + .isolate = ctx.isolate, + }; +} + +// Upcast a Node-descendant instance to *Node by walking the _proto chain. +// Not every node type defines an asNode() helper (e.g. Comment, Text), but +// inheritsOrIs guarantees Node is in the chain +fn protoNode(comptime T: type, instance: *T) *@import("../webapi/Node.zig") { + if (T == @import("../webapi/Node.zig")) { + return instance; + } + const Proto = @typeInfo(std.meta.fieldInfo(T, ._proto).type).pointer.child; + return protoNode(Proto, instance._proto); +} + fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype) void { const isolate = local.isolate; @@ -484,14 +572,34 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any } } - const js_err: *const v8.Value = switch (err) { + // early exit + switch (err) { error.TryCatchRethrow => return, - error.InvalidArgument => isolate.createTypeError("invalid argument"), - error.TypeError => isolate.createTypeError(""), - error.RangeError => isolate.createRangeError(""), - error.OutOfMemory => isolate.createError("out of memory"), - error.IllegalConstructor => isolate.createError("Illegal Constructor"), - else => domExceptionToJs(local, err) orelse isolate.createError(@errorName(err)), + // A JS exception is already pending in the isolate (e.g. a value's + // toString threw during argument conversion); throwing anything here + // would replace the original exception the script expects to see. + error.JsException => return, + else => {}, + } + + const err_local = errorLocal(T, local, info); + + const js_err: *const v8.Value = blk: { + // Error constructors use the isolate's current context: enter the + // receiver's realm so the exception gets its prototypes. + const entered = err_local.ctx != local.ctx; + if (entered) v8.v8__Context__Enter(err_local.handle); + defer if (entered) v8.v8__Context__Exit(err_local.handle); + + break :blk switch (err) { + error.InvalidArgument => isolate.createTypeError("invalid argument"), + error.TypeError => isolate.createTypeError(""), + error.RangeError => isolate.createRangeError(""), + error.OutOfMemory => isolate.createError("out of memory"), + error.IllegalConstructor => isolate.createError("Illegal Constructor"), + error.TryCatchRethrow, error.JsException => unreachable, // early exited a few lines up + else => domExceptionToJs(&err_local, err) orelse isolate.createError(@errorName(err)), + }; }; const js_exception = isolate.throwException(js_err); @@ -686,6 +794,7 @@ pub const Function = struct { exposed: Exposed = .both, ce_reactions: bool = false, js_name: ?[:0]const u8 = null, + unforgeable: bool = false, pub const Exposed = enum { both, window, worker }; @@ -987,6 +1096,11 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: // type instantiation of jsValueToZig may not include such errors // in its inferred error set. @field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch |err| { + if (err == error.JsException) { + // an exception thrown by user code (e.g. a toString + // getter) is pending; propagate it untouched + return err; + } const DOMException = @import("../webapi/DOMException.zig"); if (DOMException.fromError(err) != null) { return err; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 085649ba3..ae7da2545 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -277,14 +277,6 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void { } } -pub fn trackGlobal(self: *Context, global: v8.Global) !void { - return self.page.globals.append(self.page.frame_arena, global); -} - -pub fn trackTemp(self: *Context, global: v8.Global) !void { - return self.page.temps.put(self.page.frame_arena, global.data_ptr, global); -} - pub const IdentityResult = struct { value_ptr: *v8.Global, found_existing: bool, @@ -1134,7 +1126,7 @@ fn enqueueMicrotask(self: *Context, callback: anytype) void { // this should be safe (I think). In whatever HandleScope a microtask is enqueued, // PerformCheckpoint should be run. So the v8::Local should remain // valid. If we have problems with this, a simple solution is to provide a Zig -// wrapper for these callbacks which references a js.Function.Temp, on callback +// wrapper for these callbacks which references a js.Function, on callback // it executes the function and then releases the global. pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void { // Use context-specific microtask queue instead of isolate queue diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index b39a0b1c8..e0dbc5cde 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -31,6 +31,7 @@ const App = @import("../../App.zig"); const Frame = @import("../Frame.zig"); const Window = @import("../webapi/Window.zig"); const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig"); +const SharedWorkerGlobalScope = @import("../webapi/SharedWorkerGlobalScope.zig"); const DedicatedWorkerGlobalScope = @import("../webapi/DedicatedWorkerGlobalScope.zig"); const v8 = js.v8; @@ -274,8 +275,12 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context break :blk @ptrCast(v8.v8__Global__Get(existing, isolate.handle)); }; - // Restore the context from the snapshot (0 = Page, 1 = Worker) - const snapshot_index: u32 = if (comptime is_frame) 0 else 1; + // Restore the context from the snapshot + // (0 = Page, 1 = DedicatedWorker, 2 = SharedWorker) + const snapshot_index: u32 = if (comptime is_frame) 0 else switch (global._type) { + .dedicated => 1, + .shared => 2, + }; const v8_context = v8.v8__Context__FromSnapshot__Config(isolate.handle, snapshot_index, &.{ .global_template = null, .global_object = reuse_global_object, @@ -297,11 +302,19 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context .prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr, .prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len), .subtype = .node, - } else .{ - .value = @ptrCast(global._type.dedicated), - .prototype_chain = (&DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr, - .prototype_len = @intCast(DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain.len), - .subtype = null, + } else switch (global._type) { + .dedicated => |scope| .{ + .value = @ptrCast(scope), + .prototype_chain = (&DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr, + .prototype_len = @intCast(DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain.len), + .subtype = null, + }, + .shared => |scope| .{ + .value = @ptrCast(scope), + .prototype_chain = (&SharedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr, + .prototype_len = @intCast(SharedWorkerGlobalScope.JsApi.Meta.prototype_chain.len), + .subtype = null, + }, }; v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao); @@ -349,7 +362,9 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context // Register in the identity map. Multiple contexts can be created for the // same global (via CDP), so we only register the first one. - const identity_ptr = if (comptime is_frame) @intFromPtr(global.window) else @intFromPtr(global._type.dedicated); + const identity_ptr = if (comptime is_frame) @intFromPtr(global.window) else switch (global._type) { + inline else => |scope| @intFromPtr(scope), + }; const gop = try params.identity.identity_map.getOrPut(params.identity_arena, identity_ptr); if (gop.found_existing == false) { var global_global: v8.Global = undefined; @@ -578,6 +593,7 @@ pub fn protectHeapLimit(self: *Env) void { // V8 expects. fn nearHeapLimit(data: ?*anyopaque, current_limit: usize, initial_limit: usize) callconv(.c) usize { const self: *Env = @ptrCast(@alignCast(data.?)); + lp.metrics.js_heap_limits.incr(); log.err(.app, "JS heap limit reached", .{ .initial_limit = initial_limit, .current_limit = current_limit, diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig index ee3abadf6..020175adc 100644 --- a/src/browser/js/Execution.zig +++ b/src/browser/js/Execution.zig @@ -33,7 +33,7 @@ const Scheduler = @import("Scheduler.zig"); const Page = @import("../Page.zig"); const Session = @import("../Session.zig"); const Factory = @import("../Factory.zig"); -const HttpClient = @import("../HttpClient.zig"); +const HttpClient = @import("../../network/HttpClient.zig"); const EventManagerBase = @import("../EventManagerBase.zig"); const Event = @import("../webapi/Event.zig"); @@ -102,12 +102,27 @@ pub fn makeRequest(self: *const Execution, req: HttpClient.Request) !void { }; } +// Two-phase variant; see HttpClient.newRequest for the ownership contract. +pub fn newRequest(self: *const Execution, req: HttpClient.Request) !*HttpClient.Transfer { + return switch (self.js.global) { + inline else => |g| g.newRequest(req), + }; +} + pub fn getBroadcastChannels(self: *const Execution) *std.DoublyLinkedList { return switch (self.js.global) { inline else => |g| &g._broadcast_channels, }; } +// The owning global's (Frame or WGS) list of live MessagePorts, walked at +// that global's teardown to sever cross-context entanglement. +pub fn messagePorts(self: *const Execution) *std.DoublyLinkedList { + return switch (self.js.global) { + inline else => |g| &g._message_ports, + }; +} + // The global's serialized origin (e.g. "https://example.com"), or null for an // opaque origin. pub fn origin(self: *const Execution) ?[]const u8 { diff --git a/src/browser/js/Function.zig b/src/browser/js/Function.zig index 7ab174835..49bf7101c 100644 --- a/src/browser/js/Function.zig +++ b/src/browser/js/Function.zig @@ -95,7 +95,11 @@ pub fn call(self: *const Function, comptime T: type, args: anytype) !T { pub fn callRethrow(self: *const Function, comptime T: type, args: anytype) !T { var caught: js.TryCatch.Caught = undefined; return self._tryCallWithThis(T, self.getThis(), args, &caught, .{ .rethrow = true }) catch |err| { - log.warn(.js, "call caught", .{ .err = err, .caught = caught }); + if (err != error.TryCatchRethrow) { + // error.TryCatchRethrow is a control flow (sorry!), not an actual + // error we want to log + log.warn(.js, "call caught", .{ .err = err, .caught = caught }); + } return err; }; } @@ -108,6 +112,14 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args }; } +// Like callWithThis, but a thrown JS exception is rethrown past the internal +// TryCatch, so an enclosing TryCatch of the caller can observe the exception +// value itself (e.g. to report it to window.onerror). +pub fn callWithThisRethrow(self: *const Function, comptime T: type, this: anytype, args: anytype) !T { + var caught: js.TryCatch.Caught = undefined; + return self._tryCallWithThis(T, this, args, &caught, .{ .rethrow = true }); +} + pub fn tryCall(self: *const Function, comptime T: type, args: anytype, caught: *js.TryCatch.Caught) !T { return self._tryCallWithThis(T, self.getThis(), args, caught, .{}); } @@ -227,29 +239,7 @@ pub fn getPropertyValue(self: *const Function, name: []const u8) !?js.Value { } pub fn persist(self: *const Function) !Global { - return self._persist(true); -} - -pub fn temp(self: *const Function) !Temp { - return self._persist(false); -} - -fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Global else Temp) { - var ctx = self.local.ctx; - - var global: v8.Global = undefined; - v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - if (comptime is_global) { - try ctx.trackGlobal(global); - return .{ .handle = global, .temps = {} }; - } - try ctx.trackTemp(global); - return .{ .handle = global, .temps = &ctx.page.temps }; -} - -pub fn tempWithThis(self: *const Function, value: anytype) !Temp { - const with_this = try self.withThis(value); - return with_this.temp(); + return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) }; } pub fn persistWithThis(self: *const Function, value: anytype) !Global { @@ -257,41 +247,23 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global { return with_this.persist(); } -pub const Temp = G(.temp); -pub const Global = G(.global); +// A cheap, copyable handle to a persisted function. See js.GlobalSlot. +pub const Global = struct { + slot: *js.GlobalSlot, -const GlobalType = enum(u8) { - temp, - global, + pub fn deinit(self: Global) void { + self.slot.release(); + } + pub const release = deinit; + + pub fn local(self: Global, l: *const js.Local) Function { + return .{ + .local = l, + .handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)), + }; + } + + pub fn isEqual(self: Global, other: Function) bool { + return v8.v8__Global__IsEqual(&self.slot.handle, other.handle); + } }; - -fn G(comptime global_type: GlobalType) type { - return struct { - handle: v8.Global, - temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void, - - const Self = @This(); - - pub fn deinit(self: *Self) void { - v8.v8__Global__Reset(&self.handle); - } - - pub fn local(self: *const Self, l: *const js.Local) Function { - return .{ - .local = l, - .handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)), - }; - } - - pub fn isEqual(self: *const Self, other: Function) bool { - return v8.v8__Global__IsEqual(&self.handle, other.handle); - } - - pub fn release(self: *const Self) void { - if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| { - var g = kv.value; - v8.v8__Global__Reset(&g); - } - } - }; -} diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index ad4ab25db..192537f32 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -275,7 +275,10 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object, const gop = try ctx.addIdentity(resolved_ptr_id); if (gop.found_existing) { // we've seen this instance before, return the same object - return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self); + return .{ + .local = self, + .handle = @ptrCast(v8.v8__Global__Get(gop.value_ptr, self.isolate.handle)), + }; } const isolate = self.isolate; @@ -468,12 +471,9 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts) inline js.Function.Global, - js.Function.Temp, js.Value.Global, - js.Value.Temp, js.Object.Global, js.Promise.Global, - js.Promise.Temp, js.PromiseResolver.Global, js.Module.Global => return .{ .local = self, .handle = @ptrCast(value.local(self).handle) }, @@ -742,15 +742,22 @@ pub fn jsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !T { // Extracted so that it can be used in both jsValueToZig and in // probeJsValueToZig. Avoids having to duplicate this logic when probing. fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T { + // js.Nullable(T): a required argument that accepts null. + if (@hasDecl(T, "js_nullable")) { + if (js_val.isNullOrUndefined()) { + return T{ .value = null }; + } + return T{ .value = try self.jsValueToZig(T.js_nullable, js_val) }; + } + return switch (T) { - js.Function, js.Function.Global, js.Function.Temp => { + js.Function, js.Function.Global => { if (!js_val.isFunction()) { return null; } const js_func = js.Function{ .local = self, .handle = @ptrCast(js_val.handle) }; return switch (T) { js.Function => js_func, - js.Function.Temp => try js_func.temp(), js.Function.Global => try js_func.persist(), else => unreachable, }; @@ -767,7 +774,6 @@ fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T { }, js.Value => js_val, js.Value.Global => return try js_val.persist(), - js.Value.Temp => return try js_val.temp(), js.Object => { if (!js_val.isObject()) { return null; @@ -788,7 +794,7 @@ fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T { return try obj.persist(); }, - js.Promise.Global, js.Promise.Temp => { + js.Promise.Global => { if (!js_val.isPromise()) { return null; } @@ -796,11 +802,7 @@ fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T { .local = self, .handle = @ptrCast(js_val.handle), }; - return switch (T) { - js.Promise.Temp => try js_promise.temp(), - js.Promise.Global => try js_promise.persist(), - else => unreachable, - }; + return try js_promise.persist(); }, js.String => return js_val.isString(), js.String.OneByte => { diff --git a/src/browser/js/Object.zig b/src/browser/js/Object.zig index 138997820..8869a9060 100644 --- a/src/browser/js/Object.zig +++ b/src/browser/js/Object.zig @@ -92,14 +92,7 @@ pub fn format(self: Object, writer: *std.Io.Writer) !void { } pub fn persist(self: Object) !Global { - var ctx = self.local.ctx; - - var global: v8.Global = undefined; - v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - - try ctx.trackGlobal(global); - - return .{ .handle = global }; + return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) }; } pub fn getFunction(self: Object, name: []const u8) !?js.Function { @@ -171,21 +164,24 @@ pub fn toZig(self: Object, comptime T: type) !T { } pub const Global = struct { - handle: v8.Global, + slot: *js.GlobalSlot, - pub fn deinit(self: *Global) void { - v8.v8__Global__Reset(&self.handle); + pub fn deinit(self: Global) void { + self.slot.release(); } - pub fn local(self: *const Global, l: *const js.Local) Object { + // TODO: deprecated. @GlobalSlot + pub const release = deinit; + + pub fn local(self: Global, l: *const js.Local) Object { return .{ .local = l, - .handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)), + .handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)), }; } - pub fn isEqual(self: *const Global, other: Object) bool { - return v8.v8__Global__IsEqual(&self.handle, other.handle); + pub fn isEqual(self: Global, other: Object) bool { + return v8.v8__Global__IsEqual(&self.slot.handle, other.handle); } }; diff --git a/src/browser/js/Promise.zig b/src/browser/js/Promise.zig index 20edc6516..78a509f9b 100644 --- a/src/browser/js/Promise.zig +++ b/src/browser/js/Promise.zig @@ -74,57 +74,21 @@ pub fn markAsHandled(self: Promise) void { } pub fn persist(self: Promise) !Global { - return self._persist(true); + return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) }; } -pub fn temp(self: Promise) !Temp { - return self._persist(false); -} +pub const Global = struct { + slot: *js.GlobalSlot, -fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Global else Temp) { - var ctx = self.local.ctx; - - var global: v8.Global = undefined; - v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - if (comptime is_global) { - try ctx.trackGlobal(global); - return .{ .handle = global, .temps = {} }; + pub fn deinit(self: Global) void { + self.slot.release(); } - try ctx.trackTemp(global); - return .{ .handle = global, .temps = &ctx.page.temps }; -} + pub const release = deinit; -pub const Temp = G(.temp); -pub const Global = G(.global); - -const GlobalType = enum(u8) { - temp, - global, + pub fn local(self: Global, l: *const js.Local) Promise { + return .{ + .local = l, + .handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)), + }; + } }; - -fn G(comptime global_type: GlobalType) type { - return struct { - handle: v8.Global, - temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void, - - const Self = @This(); - - pub fn deinit(self: *Self) void { - v8.v8__Global__Reset(&self.handle); - } - - pub fn local(self: *const Self, l: *const js.Local) Promise { - return .{ - .local = l, - .handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)), - }; - } - - pub fn release(self: *const Self) void { - if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| { - var g = kv.value; - v8.v8__Global__Reset(&g); - } - } - }; -} diff --git a/src/browser/js/PromiseResolver.zig b/src/browser/js/PromiseResolver.zig index e99a3d0b7..287ddd221 100644 --- a/src/browser/js/PromiseResolver.zig +++ b/src/browser/js/PromiseResolver.zig @@ -118,24 +118,22 @@ fn _reject(self: PromiseResolver, value: anytype) !void { } pub fn persist(self: PromiseResolver) !Global { - var ctx = self.local.ctx; - var global: v8.Global = undefined; - v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - try ctx.trackGlobal(global); - return .{ .handle = global }; + return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) }; } pub const Global = struct { - handle: v8.Global, + slot: *js.GlobalSlot, - pub fn deinit(self: *Global) void { - v8.v8__Global__Reset(&self.handle); + pub fn deinit(self: Global) void { + self.slot.release(); } - pub fn local(self: *const Global, l: *const js.Local) PromiseResolver { + pub const release = deinit; + + pub fn local(self: Global, l: *const js.Local) PromiseResolver { return .{ .local = l, - .handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)), + .handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)), }; } }; diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index 75ef4de46..bebd89a65 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -22,11 +22,18 @@ const lp = @import("lightpanda"); const js = @import("js.zig"); const bridge = @import("bridge.zig"); +// Not ideal, but its children have special constructor rules (can be extended +// can't be instantiated). And rather than coming up with a generic definition +// for this one case, we just hardcode it. +const HtmlElement = @import("../webapi/element/Html.zig"); + const v8 = js.v8; const log = lp.log; const JsApis = bridge.JsApis; const PageJsApis = bridge.PageJsApis; -const WorkerJsApis = bridge.WorkerJsApis; +const SharedWorkerJsApis = bridge.SharedWorkerJsApis; +const DedicatedWorkerJsApis = bridge.DedicatedWorkerJsApis; + const IS_DEBUG = @import("builtin").mode == .Debug; const Snapshot = @This(); @@ -204,9 +211,15 @@ pub fn create() !Snapshot { { const DedicatedWorkerGlobalScope = @import("../webapi/DedicatedWorkerGlobalScope.zig"); - const index = try createSnapshotContext(.worker, &WorkerJsApis, DedicatedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates); + const index = try createSnapshotContext(.worker, &DedicatedWorkerJsApis, DedicatedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates); std.debug.assert(index == 1); } + + { + const SharedWorkerGlobalScope = @import("../webapi/SharedWorkerGlobalScope.zig"); + const index = try createSnapshotContext(.worker, &SharedWorkerJsApis, SharedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates); + std.debug.assert(index == 2); + } } const blob = v8.v8__SnapshotCreator__createBlob(snapshot_creator, v8.kKeep); @@ -402,6 +415,9 @@ fn countExternalReferences() comptime_int { // +1 for the illegal constructor callback shared by various types count += 1; + // +1 for the upgrade constructor shared by built-in element interfaces + count += 1; + // +1 for the noop function shared by various types count += 1; @@ -446,12 +462,15 @@ fn countExternalReferences() comptime_int { if (value.setter != null) count += 1; if (value.deleter != null) count += 1; if (value.query != null) count += 1; + if (value.definer != null) count += 1; } else if (T == bridge.NamedIndexed) { count += 1; if (value.setter != null) count += 1; if (value.deleter != null) count += 1; - if (value.enumerator != null) count += 1; if (value.query != null) count += 1; + if (value.definer != null) count += 1; + if (value.descriptor != null) count += 1; + if (value.enumerator != null) count += 1; } } } @@ -474,6 +493,9 @@ fn collectExternalReferences() [countExternalReferences()]isize { references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback)); idx += 1; + references[idx] = @bitCast(@intFromPtr(HtmlElement.JsApi.upgrade_constructor.func)); + idx += 1; + references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction)); idx += 1; @@ -536,6 +558,10 @@ fn collectExternalReferences() [countExternalReferences()]isize { references[idx] = @bitCast(@intFromPtr(query)); idx += 1; } + if (value.definer) |definer| { + references[idx] = @bitCast(@intFromPtr(definer)); + idx += 1; + } } else if (T == bridge.NamedIndexed) { references[idx] = @bitCast(@intFromPtr(value.getter)); idx += 1; @@ -547,14 +573,22 @@ fn collectExternalReferences() [countExternalReferences()]isize { references[idx] = @bitCast(@intFromPtr(deleter)); idx += 1; } - if (value.enumerator) |enumerator| { - references[idx] = @bitCast(@intFromPtr(enumerator)); - idx += 1; - } if (value.query) |query| { references[idx] = @bitCast(@intFromPtr(query)); idx += 1; } + if (value.definer) |definer| { + references[idx] = @bitCast(@intFromPtr(definer)); + idx += 1; + } + if (value.descriptor) |descriptor| { + references[idx] = @bitCast(@intFromPtr(descriptor)); + idx += 1; + } + if (value.enumerator) |enumerator| { + references[idx] = @bitCast(@intFromPtr(enumerator)); + idx += 1; + } } } } @@ -676,14 +710,15 @@ fn protoIndexLookup(comptime JsApi: type) ?u16 { // Generate a constructor template for a JsApi type (public for reuse) pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate { - const callback = blk: { + const callback, const arity = comptime blk: { if (@hasDecl(JsApi, "constructor")) { - break :blk JsApi.constructor.func; + break :blk .{ JsApi.constructor.func, JsApi.constructor.arity }; } - break :blk illegalConstructorCallback; + if (inheritsFromHtmlElement(JsApi)) { + break :blk .{ HtmlElement.JsApi.upgrade_constructor.func, @as(c_int, HtmlElement.JsApi.upgrade_constructor.arity) }; + } + break :blk .{ &illegalConstructorCallback, @as(c_int, 0) }; }; - - const arity: c_int = if (@hasDecl(JsApi, "constructor")) JsApi.constructor.arity else 0; const template = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .length = arity, .callback = callback, @@ -704,12 +739,21 @@ pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8 return template; } -// Attach JsApi members to a template (public for reuse). This is called on all -// types. But, for globals (window, WGS) it's called twice. The first time, it's -// called like any other interface. The 2nd time, it's called with flatten == true -// and define_on != null. This is the "flattening" pass, and it defines all of -// the functions/accessors on directly on the global instance. Thus, globals have -// it defined on both their prototype (first pass) and their own instance (2nd pass). +// hard-coded special case for HtmlElement which can be extended but not +// instantiated. +fn inheritsFromHtmlElement(comptime JsApi: type) bool { + if (JsApi.bridge.type == HtmlElement) { + return false; + } + return bridge.inheritsOrIs(JsApi, HtmlElement.JsApi); +} + +const Unforgeable = struct { + Owner: type, + name: [:0]const u8, + accessor: bridge.Accessor, +}; + fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolate, template: *const v8.FunctionTemplate, define_on: ?*const v8.ObjectTemplate) void { const instance = v8.v8__FunctionTemplate__InstanceTemplate(template); const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template); @@ -745,49 +789,12 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat continue; } - const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); - const getter_signature = if (value.static) null else signature; - const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{ - .callback = value.getter, - .signature = getter_signature, - }).?; - // WebIDL: getter function's .name should be "get X" - const getter_name_str = "get " ++ name; - const getter_name_v8 = v8.v8__String__NewFromUtf8(isolate, getter_name_str.ptr, v8.kNormal, @intCast(getter_name_str.len)); - v8.v8__FunctionTemplate__SetClassName(getter_callback, getter_name_v8); - - const setter_callback = if (value.setter) |setter| blk: { - const cb = v8.v8__FunctionTemplate__New__Config(isolate, &.{ - .callback = setter, - .signature = getter_signature, - .length = 1, - }).?; - const setter_name_str = "set " ++ name; - const setter_name_v8 = v8.v8__String__NewFromUtf8(isolate, setter_name_str.ptr, v8.kNormal, @intCast(setter_name_str.len)); - v8.v8__FunctionTemplate__SetClassName(cb, setter_name_v8); - break :blk cb; - } else null; - - var attribute: v8.PropertyAttribute = 0; - if (value.setter == null) { - attribute |= v8.ReadOnly; - } - if (value.deletable == false) { - attribute |= v8.DontDelete; - } - - if (value.static) { - v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute); - } else { - // Web IDL: attributes on the interface prototype object - // (and mirrored onto [Global] instances) are enumerable. - v8.v8__ObjectTemplate__SetAccessorProperty__Config(define_on orelse prototype, &.{ - .key = js_name, - .getter = getter_callback, - .setter = setter_callback, - .attribute = attribute, - }); + if (comptime value.unforgeable) { + // this accessor will be handled in the unforgeables loop + // later in this function. + continue; } + attachAccessorProperty(name, value, isolate, template, signature, define_on orelse prototype); }, bridge.Function => { if (value.wpt_only and wpt_extensions_enabled == false) { @@ -820,7 +827,7 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat .setter = value.setter, .query = value.query, .deleter = value.deleter, - .definer = null, + .definer = if (value.definer) |definer| @ptrCast(definer) else null, .descriptor = null, .index_of = null, .data = null, @@ -835,8 +842,8 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat .query = value.query, .deleter = value.deleter, .enumerator = value.enumerator, - .definer = null, - .descriptor = null, + .definer = if (value.definer) |definer| @ptrCast(definer) else null, + .descriptor = if (value.descriptor) |descriptor| @ptrCast(descriptor) else null, .data = null, .flags = v8.kOnlyInterceptStrings | v8.kNonMasking, }; @@ -873,6 +880,16 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat } } + if (comptime flatten == false) { + inline for (unforgeables) |u| { + if (comptime bridge.inheritsOrIs(JsApi, u.Owner)) { + // unforgeables attributes aren't only applied directly on the + // instance, they're also applied directly on every child instance + attachAccessorProperty(u.name, u.accessor, isolate, template, signature, instance); + } + } + } + // The remaining per-class setup targets the class's own instance template; // in [Global] flattening mode the global already has these (or doesn't need // them), so skip it. @@ -923,3 +940,71 @@ fn globalScopeChain(comptime GlobalScopeApi: type) []const type { return chain; } } + +// Every [LegacyUnforgeable] accessor. We collect this once so we don't need to +// keep scanning for this in attachClass +const unforgeables: []const Unforgeable = blk: { + @setEvalBranchQuota(100_000); + var list: []const Unforgeable = &.{}; + for (JsApis) |Api| { + for (@typeInfo(Api).@"struct".decls) |d| { + const value = @field(Api, d.name); + if (@TypeOf(value) == bridge.Accessor and value.unforgeable and !value.static) { + list = list ++ &[_]Unforgeable{.{ .Owner = Api, .name = d.name, .accessor = value }}; + } + } + } + break :blk list; +}; + +// Attach JsApi members to a template (public for reuse). This is called on all +// types. But, for globals (window, WGS) it's called twice. The first time, it's +// called like any other interface. The 2nd time, it's called with flatten == true +// and define_on != null. This is the "flattening" pass, and it defines all of +// the functions/accessors on directly on the global instance. Thus, globals have +// it defined on both their prototype (first pass) and their own instance (2nd pass). +fn attachAccessorProperty(comptime name: [:0]const u8, value: bridge.Accessor, isolate: *v8.Isolate, template: *const v8.FunctionTemplate, signature: anytype, target: anytype) void { + const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); + const getter_signature = if (value.static) null else signature; + const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{ + .callback = value.getter, + .signature = getter_signature, + }).?; + // WebIDL: getter function's .name should be "get X" + const getter_name_str = "get " ++ name; + const getter_name_v8 = v8.v8__String__NewFromUtf8(isolate, getter_name_str.ptr, v8.kNormal, @intCast(getter_name_str.len)); + v8.v8__FunctionTemplate__SetClassName(getter_callback, getter_name_v8); + + const setter_callback = if (value.setter) |setter| blk: { + const cb = v8.v8__FunctionTemplate__New__Config(isolate, &.{ + .callback = setter, + .signature = getter_signature, + .length = 1, + }).?; + const setter_name_str = "set " ++ name; + const setter_name_v8 = v8.v8__String__NewFromUtf8(isolate, setter_name_str.ptr, v8.kNormal, @intCast(setter_name_str.len)); + v8.v8__FunctionTemplate__SetClassName(cb, setter_name_v8); + break :blk cb; + } else null; + + var attribute: v8.PropertyAttribute = 0; + if (value.setter == null) { + attribute |= v8.ReadOnly; + } + if (value.deletable == false) { + attribute |= v8.DontDelete; + } + + if (value.static) { + v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute); + } else { + // Web IDL: attributes on the interface prototype object + // (and mirrored onto [Global] instances) are enumerable. + v8.v8__ObjectTemplate__SetAccessorProperty__Config(target, &.{ + .key = js_name, + .getter = getter_callback, + .setter = setter_callback, + .attribute = attribute, + }); + } +} diff --git a/src/browser/js/TaggedOpaque.zig b/src/browser/js/TaggedOpaque.zig index 3a8250ce9..ca20b3ef5 100644 --- a/src/browser/js/TaggedOpaque.zig +++ b/src/browser/js/TaggedOpaque.zig @@ -106,7 +106,14 @@ pub fn fromJS(comptime R: type, js_obj_handle: *const v8.Object) !R { @compileError("unknown Zig type: " ++ @typeName(R)); } - const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(js_obj_handle, 0).?; + const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(js_obj_handle, 0) orelse return error.InvalidArgument; + // A wrapped object always embeds an aligned TaggedOpaque pointer. An + // unaligned value is a leftover v8 tagged value: the object was created + // from our template but never mapped to a Zig instance (e.g. a custom + // element whose constructor threw). + if (@intFromPtr(tao_ptr) % @alignOf(TaggedOpaque) != 0) { + return error.InvalidArgument; + } const tao: *TaggedOpaque = @ptrCast(@alignCast(tao_ptr)); const expected_type_index = bridge.JsApiLookup.getId(JsApi); diff --git a/src/browser/js/TryCatch.zig b/src/browser/js/TryCatch.zig index 0222e3c36..73681ed41 100644 --- a/src/browser/js/TryCatch.zig +++ b/src/browser/js/TryCatch.zig @@ -46,6 +46,12 @@ pub fn rethrow(self: *TryCatch) void { _ = v8.v8__TryCatch__ReThrow(&self.handle); } +// The raw caught exception value, e.g. to report it to a global's onerror. +pub fn exceptionValue(self: TryCatch) ?js.Value { + const handle = v8.v8__TryCatch__Exception(&self.handle) orelse return null; + return .{ .local = self.local, .handle = handle }; +} + pub fn caught(self: TryCatch, allocator: Allocator) ?Caught { if (self.hasCaught() == false) { return null; diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index 3ac76aa43..95dc641dc 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -425,6 +425,8 @@ const cloneable_types = .{ @import("../webapi/ImageData.zig"), @import("../webapi/DOMPointReadOnly.zig"), @import("../webapi/DOMPoint.zig"), + @import("../webapi/DOMRectReadOnly.zig"), + @import("../webapi/DOMRect.zig"), }; // Passed to a type's structuredSerialize hook to write its payload into the @@ -622,34 +624,17 @@ const CloneDelegate = struct { }; pub fn persist(self: Value) !Global { - return self._persist(true); + return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) }; } -pub fn temp(self: Value) !Temp { - return self._persist(false); -} - -// Like persist(), but not tracked on the context: the caller owns the handle -// and must deinit (Reset) it. A reset is only idempotent through the same -// instance — copies alias one v8 slot, so keep a single canonical instance and -// reset through it. -pub fn bare(self: Value) BareGlobal { +// like persist, but not tracked by the page. Caller takes responsibility for +// resetting and freeing the allocation. +pub fn persistBare(self: Value, arena: std.mem.Allocator) !*js.GlobalSlot { + const slot = try arena.create(js.GlobalSlot); var global: v8.Global = undefined; v8.v8__Global__New(self.local.ctx.isolate.handle, self.handle, &global); - return .{ .handle = global, .temps = {} }; -} - -fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Global else Temp) { - var ctx = self.local.ctx; - - var global: v8.Global = undefined; - v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - if (comptime is_global) { - try ctx.trackGlobal(global); - return .{ .handle = global, .temps = {} }; - } - try ctx.trackTemp(global); - return .{ .handle = global, .temps = &ctx.page.temps }; + slot.* = .{ .handle = global, .tracker = null, .gindex = undefined }; + return slot; } pub fn toZig(self: Value, comptime T: type) !T { @@ -696,48 +681,71 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void { return js_str.format(writer); } -pub const Temp = G(.temp); -pub const Global = G(.global); -pub const BareGlobal = G(.bare); +// Copyable handle to our v8::Global wrapper so that releasing a copy resets +// the underlying v8::Global +pub const Global = struct { + slot: *js.GlobalSlot, -const GlobalType = enum(u8) { - temp, - global, - bare, + pub fn deinit(self: Global) void { + self.slot.release(); + } + pub const release = deinit; + + pub fn local(self: Global, l: *const js.Local) Value { + return .{ + .local = l, + .handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)), + }; + } + + pub fn isEqual(self: Global, other: Value) bool { + return v8.v8__Global__IsEqual(&self.slot.handle, other.handle); + } }; -fn G(comptime global_type: GlobalType) type { - return struct { - handle: v8.Global, - temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void, +const testing = @import("../../testing.zig"); +test "Value: persisted handle early-release swap-removes and fixes up indices" { + const frame = try testing.createFrame(); + defer testing.test_session.closeAllPages(); - const Self = @This(); + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); - pub fn deinit(self: *Self) void { - v8.v8__Global__Reset(&self.handle); - } + const tracker = &frame.js.page.globals; + const base = tracker.list.items.len; - pub fn local(self: *const Self, l: *const js.Local) Value { - return .{ - .local = l, - .handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)), - }; - } + var a = try (try ls.local.exec("({a:1})", null)).persist(); + var b = try (try ls.local.exec("({b:2})", null)).persist(); + var c = try (try ls.local.exec("({c:3})", null)).persist(); - pub fn isEqual(self: *const Self, other: Value) bool { - return v8.v8__Global__IsEqual(&self.handle, other.handle); - } + try testing.expectEqual(base + 3, tracker.list.items.len); + try testing.expectEqual(base + 0, a.slot.gindex); + try testing.expectEqual(base + 1, b.slot.gindex); + try testing.expectEqual(base + 2, c.slot.gindex); - pub fn release(self: *const Self) void { - if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| { - var g = kv.value; - v8.v8__Global__Reset(&g); - } - } - }; + // Release the middle one: the last live slot (c) must move into b's spot and + // have its stored index rewritten to match, or a later release corrupts. + b.deinit(); + try testing.expectEqual(base + 2, tracker.list.items.len); + try testing.expectEqual(base + 1, c.slot.gindex); + try testing.expectEqual(c.slot, tracker.list.items[base + 1]); + try testing.expectEqual(a.slot, tracker.list.items[base + 0]); + + // a and c are still usable (right handle, not b's). + try testing.expect(a.local(&ls.local).isObject()); + try testing.expect(c.local(&ls.local).isObject()); + + // Release the remaining two via the moved indices — must not corrupt. + a.deinit(); + try testing.expectEqual(base + 1, tracker.list.items.len); + try testing.expectEqual(base + 0, c.slot.gindex); + try testing.expectEqual(c.slot, tracker.list.items[base + 0]); + + c.deinit(); + try testing.expectEqual(base, tracker.list.items.len); } -const testing = @import("../../testing.zig"); test "Value: jsonStringify maps unserializable JS values to null" { const frame = try testing.createFrame(); defer testing.test_session.closeAllPages(); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index c626770b7..78d089c8b 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -46,15 +46,23 @@ pub fn Builder(comptime T: type) type { } pub fn indexed(comptime getter_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed { - return Indexed.init(T, getter_func, null, null, null, enumerator_func, opts); + return Indexed.init(T, getter_func, null, null, null, null, enumerator_func, opts); } pub fn indexedReadWrite(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, query_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed { - return Indexed.init(T, getter_func, setter_func, deleter_func, query_func, enumerator_func, opts); + return Indexed.init(T, getter_func, setter_func, deleter_func, query_func, null, enumerator_func, opts); + } + + pub fn indexedFull(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, query_func: anytype, definer_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed { + return Indexed.init(T, getter_func, setter_func, deleter_func, query_func, definer_func, enumerator_func, opts); } pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, enumerator_func: anytype, query_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed { - return NamedIndexed.init(T, getter_func, setter_func, deleter_func, enumerator_func, query_func, opts); + return NamedIndexed.init(T, getter_func, setter_func, deleter_func, enumerator_func, query_func, null, null, opts); + } + + pub fn namedIndexedFull(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, enumerator_func: anytype, query_func: anytype, definer_func: anytype, descriptor_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed { + return NamedIndexed.init(T, getter_func, setter_func, deleter_func, enumerator_func, query_func, definer_func, descriptor_func, opts); } pub fn iterator(comptime func: anytype, comptime opts: Iterator.Opts) Iterator { @@ -222,6 +230,7 @@ pub const Accessor = struct { static: bool = false, deletable: bool = true, wpt_only: bool = false, + unforgeable: bool = false, // Web IDL [LegacyUnforgeable] exposed: Caller.Function.Opts.Exposed = .both, cache: ?Caller.Function.Opts.Caching = null, getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null, @@ -233,6 +242,7 @@ pub const Accessor = struct { .static = opts.static, .wpt_only = opts.wpt_only, .deletable = opts.deletable, + .unforgeable = opts.unforgeable, .exposed = opts.exposed, }; @@ -268,13 +278,16 @@ pub const Indexed = struct { setter: ?*const fn (idx: u32, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, deleter: ?*const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, query: ?*const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, + // v8 expects an Intercepted (u32) return; the stale void-returning + // typedef in binding.h is papered over with a @ptrCast at install time. + definer: ?*const fn (idx: u32, desc: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, const Opts = struct { as_typed_array: bool = false, null_as_undefined: bool = false, }; - fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, query: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed { + fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, query: anytype, definer: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed { var indexed = Indexed{ .enumerator = null, .getter = struct { @@ -336,7 +349,7 @@ pub const Indexed = struct { } defer caller.deinit(); - return caller.deleteIndex(T, deleter, idx, handle.?, .{ + return caller.deleteOrDefineIndex(T, deleter, idx, handle.?, .{ .as_typed_array = opts.as_typed_array, .null_as_undefined = opts.null_as_undefined, }); @@ -359,6 +372,22 @@ pub const Indexed = struct { }.wrap; } + if (@typeInfo(@TypeOf(definer)) != .null) { + indexed.definer = struct { + fn wrap(idx: u32, _: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { + const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; + var caller: Caller = undefined; + if (!caller.init(v8_isolate)) { + return js.Intercepted.no; + } + defer caller.deinit(); + + // same (self, index) -> bool shape as a deleter + return caller.deleteOrDefineIndex(T, definer, idx, handle.?, .{}); + } + }.wrap; + } + return indexed; } }; @@ -367,8 +396,12 @@ pub const NamedIndexed = struct { getter: *const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32, setter: ?*const fn (c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, deleter: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, - enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, query: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, + // v8 expects an Intercepted (u32) return; the stale void-returning + // typedefs in binding.h are papered over with a @ptrCast at install time. + definer: ?*const fn (c_name: ?*const v8.Name, desc: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, + descriptor: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, + enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null, const Opts = struct { as_typed_array: bool = false, @@ -379,7 +412,7 @@ pub const NamedIndexed = struct { ce_reactions: bool = false, }; - fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, enumerator: anytype, query: anytype, comptime opts: Opts) NamedIndexed { + fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, enumerator: anytype, query: anytype, definer: anytype, descriptor: anytype, comptime opts: Opts) NamedIndexed { const getter_fn = struct { fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; @@ -445,13 +478,42 @@ pub const NamedIndexed = struct { if (ce_frame) |frame| frame._ce_reactions.popAndInvoke(ce_checkpoint, frame); }; - return caller.deleteNamedIndex(T, deleter, c_name.?, handle.?, .{ + return caller.deleteOrDefineNamedIndex(T, deleter, c_name.?, handle.?, .{ .as_typed_array = opts.as_typed_array, .null_as_undefined = opts.null_as_undefined, }); } }.wrap; + const definer_fn = if (@typeInfo(@TypeOf(definer)) == .null) null else struct { + fn wrap(c_name: ?*const v8.Name, _: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { + const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; + var caller: Caller = undefined; + if (!caller.init(v8_isolate)) { + return js.Intercepted.no; + } + defer caller.deinit(); + + // same (self, name) -> bool shape as a deleter + return caller.deleteOrDefineNamedIndex(T, definer, c_name.?, handle.?, .{}); + } + }.wrap; + + const descriptor_fn = if (@typeInfo(@TypeOf(descriptor)) == .null) null else struct { + fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { + const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; + var caller: Caller = undefined; + if (!caller.init(v8_isolate)) { + return js.Intercepted.no; + } + defer caller.deinit(); + + // same (self, name) -> value shape as a getter; the returned + // struct is converted to a JS property descriptor object + return caller.getNamedIndex(T, descriptor, c_name.?, handle.?, .{}); + } + }.wrap; + const enumerator_fn = if (@typeInfo(@TypeOf(enumerator)) == .null) null else struct { fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 { const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?; @@ -482,8 +544,10 @@ pub const NamedIndexed = struct { .getter = getter_fn, .setter = setter_fn, .deleter = deleter_fn, - .enumerator = enumerator_fn, .query = query_fn, + .definer = definer_fn, + .descriptor = descriptor_fn, + .enumerator = enumerator_fn, }; } }; @@ -907,6 +971,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/DOMImplementation.zig"), @import("../webapi/DOMTreeWalker.zig"), @import("../webapi/DOMNodeIterator.zig"), + @import("../webapi/DOMRectReadOnly.zig"), @import("../webapi/DOMRect.zig"), @import("../webapi/DOMMatrixReadOnly.zig"), @import("../webapi/DOMMatrix.zig"), @@ -995,6 +1060,23 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/element/html/ValidityState.zig"), @import("../webapi/element/Svg.zig"), @import("../webapi/element/svg/Generic.zig"), + @import("../webapi/element/svg/Graphics.zig"), + @import("../webapi/element/svg/Geometry.zig"), + @import("../webapi/element/svg/Svg.zig"), + @import("../webapi/element/svg/G.zig"), + @import("../webapi/element/svg/A.zig"), + @import("../webapi/element/svg/Use.zig"), + @import("../webapi/element/svg/Image.zig"), + @import("../webapi/element/svg/Defs.zig"), + @import("../webapi/element/svg/Rect.zig"), + @import("../webapi/element/svg/Circle.zig"), + @import("../webapi/element/svg/Ellipse.zig"), + @import("../webapi/element/svg/Line.zig"), + @import("../webapi/element/svg/Path.zig"), + @import("../webapi/element/svg/Polygon.zig"), + @import("../webapi/element/svg/Polyline.zig"), + @import("../webapi/svg/AnimatedString.zig"), + @import("../webapi/svg/Number.zig"), @import("../webapi/encoding/TextDecoder.zig"), @import("../webapi/encoding/TextEncoder.zig"), @import("../webapi/encoding/TextEncoderStream.zig"), @@ -1009,6 +1091,12 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/event/PageTransitionEvent.zig"), @import("../webapi/event/PopStateEvent.zig"), @import("../webapi/event/HashChangeEvent.zig"), + @import("../webapi/event/BeforeUnloadEvent.zig"), + @import("../webapi/event/StorageEvent.zig"), + @import("../webapi/event/DeviceMotionEvent.zig"), + @import("../webapi/event/GamepadEvent.zig"), + @import("../webapi/event/DeviceOrientationEvent.zig"), + @import("../webapi/event/TouchEvent.zig"), @import("../webapi/event/UIEvent.zig"), @import("../webapi/event/MouseEvent.zig"), @import("../webapi/event/PointerEvent.zig"), @@ -1026,6 +1114,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/MessagePort.zig"), @import("../webapi/BroadcastChannel.zig"), @import("../webapi/Worker.zig"), + @import("../webapi/SharedWorker.zig"), @import("../webapi/media/MediaError.zig"), @import("../webapi/media/TextTrackCue.zig"), @import("../webapi/media/VTTCue.zig"), @@ -1045,6 +1134,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/net/XMLHttpRequestEventTarget.zig"), @import("../webapi/net/XMLHttpRequestUpload.zig"), @import("../webapi/net/WebSocket.zig"), + @import("../webapi/net/EventSource.zig"), @import("../webapi/event/CloseEvent.zig"), @import("../webapi/streams/ReadableStream.zig"), @import("../webapi/streams/ReadableStreamDefaultReader.zig"), @@ -1093,11 +1183,13 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/collections/DOMStringList.zig"), }); -// APIs available on Worker context globals (constructors like URL, Headers, etc.) +// APIs available on every Worker context global (constructors like URL, +// Headers, etc.), regardless of worker kind. Each kind's snapshot context +// adds its own global-scope type on top (DedicatedWorkerJsApis, +// SharedWorkerJsApis below). // This is a subset of PageJsApis plus WorkerGlobalScope. // TODO: Expand this list to include all worker-appropriate APIs. -pub const WorkerJsApis = flattenTypes(&.{ - @import("../webapi/DedicatedWorkerGlobalScope.zig"), +const worker_common_apis = [_]type{ @import("../webapi/WorkerGlobalScope.zig"), @import("../webapi/WorkerLocation.zig"), @import("../webapi/Navigator.zig"), @@ -1111,6 +1203,8 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/event/PromiseRejectionEvent.zig"), @import("../webapi/event/CloseEvent.zig"), @import("../webapi/DOMException.zig"), + @import("../webapi/DOMRectReadOnly.zig"), + @import("../webapi/DOMRect.zig"), @import("../webapi/DOMMatrixReadOnly.zig"), @import("../webapi/DOMMatrix.zig"), @import("../webapi/DOMPointReadOnly.zig"), @@ -1146,6 +1240,7 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/net/XMLHttpRequestEventTarget.zig"), @import("../webapi/net/XMLHttpRequestUpload.zig"), @import("../webapi/net/WebSocket.zig"), + @import("../webapi/net/EventSource.zig"), @import("../webapi/FileReader.zig"), @import("../webapi/ImageData.zig"), @import("../webapi/Performance.zig"), @@ -1161,7 +1256,10 @@ pub const WorkerJsApis = flattenTypes(&.{ @import("../webapi/MessageChannel.zig"), @import("../webapi/MessagePort.zig"), @import("../webapi/collections/DOMStringList.zig"), -}); +}; + +pub const DedicatedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/DedicatedWorkerGlobalScope.zig")} ++ worker_common_apis)); +pub const SharedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/SharedWorkerGlobalScope.zig")} ++ worker_common_apis)); // Master list of ALL JS APIs across all contexts. // Used by Env (class IDs, templates), JsApiLookup, and anywhere that needs @@ -1170,6 +1268,7 @@ pub const WorkerJsApis = flattenTypes(&.{ pub const JsApis = blk: { const base = PageJsApis ++ [_]type{ @import("../webapi/DedicatedWorkerGlobalScope.zig").JsApi, + @import("../webapi/SharedWorkerGlobalScope.zig").JsApi, @import("../webapi/WorkerGlobalScope.zig").JsApi, @import("../webapi/WorkerLocation.zig").JsApi, }; @@ -1178,3 +1277,20 @@ pub const JsApis = blk: { } break :blk base ++ [_]type{@import("../webapi/WebDriver.zig").JsApi}; }; + +// Whether Child is Ancestor or inherits from it, following the _proto chain. +pub fn inheritsOrIs(comptime Child: type, comptime Ancestor: type) bool { + comptime { + const target = Ancestor.bridge.type; + var T = Child.bridge.type; + while (true) { + if (T == target) { + return true; + } + if (!@hasField(T, "_proto")) { + return false; + } + T = @typeInfo(std.meta.fieldInfo(T, ._proto).type).pointer.child; + } + } +} diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 952c61f6d..a8bec4d91 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -67,6 +67,84 @@ pub fn Bridge(comptime T: type) type { return bridge.Builder(T); } +// Our wrapper around a v8::Global designed to be tracked (in a GlobalTracker). +pub const GlobalSlot = struct { + handle: v8.Global, + tracker: ?*GlobalTracker, // null for Bare globals (see IndexedDB) + gindex: u32, // position in GlobalTracker, used to efficiently remove + reuse + + pub fn reset(self: *GlobalSlot) void { + v8.v8__Global__Reset(&self.handle); + } + + // Eager free: reset the handle and, if page-tracked, drop the slot from the + // tracker and return it to the pool. Idempotent for bare slots. + pub fn release(self: *GlobalSlot) void { + self.reset(); + if (self.tracker) |t| { + t.untrack(self); + } + } + + pub fn local(self: *const GlobalSlot, l: *const Local) Value { + return .{ + .local = l, + .handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)), + }; + } +}; + +// Per-page owner of persisted v8 handles (v8::Global). Teardown resets all globals +pub const GlobalTracker = struct { + allocator: Allocator, + list: std.ArrayList(*GlobalSlot) = .empty, + pool: std.heap.MemoryPool(GlobalSlot), + + pub fn init(allocator: Allocator) GlobalTracker { + return .{ .allocator = allocator, .pool = std.heap.MemoryPool(GlobalSlot).init(allocator) }; + } + + pub fn deinit(self: *GlobalTracker) void { + for (self.list.items) |slot| { + slot.reset(); + } + self.list.deinit(self.allocator); + self.pool.deinit(); + } + + pub fn track(self: *GlobalTracker, handle: v8.Global) !*GlobalSlot { + const slot = try self.pool.create(); + errdefer self.pool.destroy(slot); + slot.* = .{ + .handle = handle, + .tracker = self, + .gindex = @intCast(self.list.items.len), + }; + try self.list.append(self.allocator, slot); + return slot; + } + + // swapRemove + updating the moved's index + fn untrack(self: *GlobalTracker, slot: *GlobalSlot) void { + const idx = slot.gindex; + const moved = self.list.pop().?; + if (moved != slot) { + self.list.items[idx] = moved; + // moved has..well...moved, we need to update its gindex + moved.gindex = idx; + } + self.pool.destroy(slot); + } +}; + +// Build a v8.Global from a live handle and track it on the context's page. +pub fn newTrackedSlot(ctx: *Context, handle: anytype) !*GlobalSlot { + var global: v8.Global = undefined; + v8.v8__Global__New(ctx.isolate.handle, handle, &global); + errdefer v8.v8__Global__Reset(&global); + return ctx.page.globals.track(global); +} + // If a function returns a []i32, should that map to a plain-old // JavaScript array, or a Int32Array? It's ambiguous. By default, we'll // map arrays/slices to the JavaScript arrays. If you want a TypedArray @@ -126,14 +204,16 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type { /// Persisted typed array. pub const Global = struct { - handle: v8.Global, + slot: *GlobalSlot, - pub fn deinit(self: *Global) void { - v8.v8__Global__Reset(&self.handle); + pub fn deinit(self: Global) void { + self.slot.release(); } - pub fn local(self: *const Global, l: *const Local) Self { - return .{ .local = l, .handle = v8.v8__Global__Get(&self.handle, l.isolate.handle).? }; + pub const release = deinit; + + pub fn local(self: Global, l: *const Local) Self { + return .{ .local = l, .handle = v8.v8__Global__Get(&self.slot.handle, l.isolate.handle).? }; } }; @@ -173,12 +253,7 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type { } pub fn persist(self: *const Self) !Global { - var ctx = self.local.ctx; - var global: v8.Global = undefined; - v8.v8__Global__New(ctx.isolate.handle, self.handle, &global); - try ctx.trackGlobal(global); - - return .{ .handle = global }; + return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) }; } // Direct view into the typed array's backing memory. @@ -209,6 +284,18 @@ pub const NullableString = struct { value: []const u8, }; +// A required argument that accepts null (Web IDL "T?"): unlike a Zig optional +// parameter, omitting the argument is a TypeError, while passing null or +// undefined yields .{ .value = null }. It also counts towards the JS-visible +// function length, which a plain optional would not. +pub fn Nullable(comptime T: type) type { + return struct { + value: ?T, + + pub const js_nullable = T; + }; +} + pub const Exception = struct { local: *const Local, handle: *const v8.Value, diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index b56d25bb3..48fbf495d 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -142,7 +142,7 @@ fn isStandaloneAnchor(el: *Element) bool { fn isSignificantText(node: *Node) bool { const text = node.is(Node.CData.Text) orelse return false; - return !isAllWhitespace(text.getWholeText()); + return !isAllWhitespace(text.ownData()); } fn isVisibleElement(el: *Element) bool { diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index 700dc5377..d252aa9db 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -171,6 +171,16 @@ const Error = struct { }; }; +pub const PrescanResource = h5e.PrescanResource; +pub const PrescanCallback = h5e.PrescanCallback; + +// Preload scanner: a tokenizer-only pass over a buffered document, reporting +// fetchable script resources (and the first ) through `callback`. +// Builds no tree; purely a hint source. +pub fn prescan(html: []const u8, charset: []const u8, ctx: *anyopaque, callback: PrescanCallback) void { + h5e.html5ever_prescan(html.ptr, html.len, charset.ptr, charset.len, ctx, callback); +} + pub fn parse(self: *Parser, html: []const u8) void { h5e.html5ever_parse_document( html.ptr, @@ -272,6 +282,7 @@ pub fn parseFragment(self: *Parser, html: []const u8) void { &self.container, self, createElementCallback, + createContextElementCallback, getDataCallback, appendCallback, parseErrorCallback, @@ -439,6 +450,25 @@ fn createXMLElementCallback(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualNa return _createElementCallbackWithDefaultnamespace(ctx, data, qname, attributes, .xml); } +// html5ever_parse_fragment materializes the fragment's context element through +// this dedicated callback, never through createElementCallback. The context +// element is a throwaway: html5ever only queries its name (and, for a +//