diff --git a/src/agent/Terminal.zig b/src/agent/Terminal.zig index 57f05aa46..764a73ce0 100644 --- a/src/agent/Terminal.zig +++ b/src/agent/Terminal.zig @@ -44,7 +44,7 @@ const style_keyword = "ps-keyword"; const style_comment = "ps-comment"; const style_jsglobal = "ps-jsglobal"; -pub const ansi = md_term.ansi; +pub const ansi = @import("ansi.zig"); /// Command styling shared with the `/help` listing. pub fn highlightCmd(comptime fragment: []const u8) []const u8 { @@ -814,32 +814,20 @@ fn highlightBareToken(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usi } else |_| {} } -/// Index just past the matching closing quote, or `text.len` if unterminated. -fn scanQuoted(text: []const u8, start: usize) usize { - return js_highlight.scanString(text, start).end; -} - /// 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) { + while (i < text.len) { 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; + i = js_highlight.dollarRefEnd(text, i, text.len); 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 + // Don't post-step: dollarRefEnd already landed on the char after the // identifier (or end-of-text); auto-advancing would skip an adjacent `$LP_*`. } } @@ -874,7 +862,7 @@ fn highlightSlashArgs(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usi while (skipWhitespace(text, i)) |tok_start| { i = tok_start; if (text[i] == '\'' or text[i] == '"') { - i = scanQuoted(text, i); + i = js_highlight.scanString(text, i).end; c.ic_highlight(henv, @intCast(tok_start), @intCast(i - tok_start), style_string.ptr); continue; } @@ -885,7 +873,7 @@ fn highlightSlashArgs(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usi i += 1; const val_start = i; if (i < text.len and (text[i] == '\'' or text[i] == '"')) { - i = scanQuoted(text, i); + i = js_highlight.scanString(text, i).end; 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; @@ -1276,18 +1264,29 @@ fn styledOutput(self: *const Terminal) bool { return self.isRepl() and self.stdout_is_tty; } +/// 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 {}; + }, + } + w.flush() catch {}; +} + pub fn printAssistant(self: *Terminal, text: []const u8) void { if (text.len == 0) return; - if (self.styledOutput()) { - var buf: [1024]u8 = undefined; - var fw = std.fs.File.stdout().writerStreaming(&buf); - const w = &fw.interface; - md_term.render(w, text) catch {}; - w.writeByte('\n') catch {}; - w.flush() catch {}; - return; - } - + 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 {}; } @@ -1299,28 +1298,13 @@ pub fn printAssistant(self: *Terminal, text: []const u8) void { /// stream ends. pub fn printAssistantDelta(self: *Terminal, text: []const u8) void { if (text.len == 0) return; - if (self.styledOutput()) { - var buf: [1024]u8 = undefined; - var fw = std.fs.File.stdout().writerStreaming(&buf); - const w = &fw.interface; - self.md_stream.feed(w, text) catch {}; - w.flush() catch {}; - 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()) { - var buf: [1024]u8 = undefined; - var fw = std.fs.File.stdout().writerStreaming(&buf); - const w = &fw.interface; - self.md_stream.close(w) catch {}; - w.writeByte('\n') catch {}; - w.flush() catch {}; - return; - } + if (self.styledOutput()) return self.renderStyled("", .end); _ = std.posix.write(std.posix.STDOUT_FILENO, "\n") catch {}; } diff --git a/src/agent/ansi.zig b/src/agent/ansi.zig new file mode 100644 index 000000000..c1ab453f2 --- /dev/null +++ b/src/agent/ansi.zig @@ -0,0 +1,32 @@ +// 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"; +pub const cyan = "\x1b[36m"; +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"; diff --git a/src/agent/js_highlight.zig b/src/agent/js_highlight.zig index 630fb16d8..b9cfd6338 100644 --- a/src/agent/js_highlight.zig +++ b/src/agent/js_highlight.zig @@ -42,6 +42,15 @@ pub fn scanString(text: []const u8, start: usize) StringSpan { 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_]`. +pub 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; +} + /// 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 @@ -91,8 +100,7 @@ pub fn tokenize(text: []const u8, state: State, sink: anytype) State { } if (ch == '$') { const start = i; - i += 1; - while (i < text.len and (std.ascii.isAlphanumeric(text[i]) or text[i] == '_')) i += 1; + i = dollarRefEnd(text, i, text.len); if (i > start + 1) sink.emit(start, i - start, .variable); continue; } @@ -121,10 +129,9 @@ pub fn tokenize(text: []const u8, state: State, sink: anytype) State { return .normal; } -/// Emit `text[start..end]` as string spans split around any `$name` refs, so -/// the two never overlap. The prompt highlighter used to paint the ref over the -/// string and lean on isocline's last-write-wins per cell; splitting keeps the -/// same result while staying safe for a sequential writer. +/// 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, sink: anytype) void { var seg = start; var i = start; @@ -134,8 +141,7 @@ fn emitString(text: []const u8, start: usize, end: usize, sink: anytype) void { continue; } const tok = i; - i += 1; - while (i < end and (std.ascii.isAlphanumeric(text[i]) or text[i] == '_')) i += 1; + i = dollarRefEnd(text, i, end); if (i > tok + 1) { if (tok > seg) sink.emit(seg, tok - seg, .string); sink.emit(tok, i - tok, .variable); @@ -158,21 +164,21 @@ const keywords = [_][]const u8{ // at the prompt that they're in scope. const globals = [_][]const u8{ "document", "window", "globalThis", "console", "lp" }; -pub fn isKeyword(tok: []const u8) bool { +fn isKeyword(tok: []const u8) bool { for (keywords) |kw| { if (std.mem.eql(u8, kw, tok)) return true; } return false; } -pub fn isGlobal(tok: []const u8) bool { +fn isGlobal(tok: []const u8) bool { for (globals) |g| { if (std.mem.eql(u8, g, tok)) return true; } return false; } -pub fn isIdChar(ch: u8) bool { +fn isIdChar(ch: u8) bool { return std.ascii.isAlphanumeric(ch) or ch == '_' or ch == '$' or ch >= 0x80; } diff --git a/src/agent/md_term.zig b/src/agent/md_term.zig index d9b697a53..a81e1a1cc 100644 --- a/src/agent/md_term.zig +++ b/src/agent/md_term.zig @@ -17,25 +17,9 @@ // along with this program. If not, see . const std = @import("std"); +const ansi = @import("ansi.zig"); const js_highlight = @import("js_highlight.zig"); -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 underline = "\x1b[4m"; - pub const strike = "\x1b[9m"; - pub const cyan = "\x1b[36m"; - 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"; -}; - /// Render markdown `src` as ANSI-styled terminal output to `w`. pub fn render(w: *std.Io.Writer, src: []const u8) !void { var in_fence = false; @@ -74,31 +58,32 @@ const clear_placeholder = "\r" ++ ansi.clear_line; /// 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); - var ncols: usize = 0; - var ok = measureRow(header, &widths, &ncols); - if (ok) { + var ncols: ?usize = measureRow(header, &widths); + if (ncols != null) { var scan = it.*; _ = scan.next(); while (scan.peek()) |line| { if (!isTableRow(line)) break; _ = scan.next(); - if (!measureRow(line, &widths, &ncols)) { - ok = false; + const n = measureRow(line, &widths) orelse { + ncols = null; break; - } + }; + ncols = @max(ncols.?, n); } } - if (!ok or ncols == 0) return renderTableVerbatim(w, header, it); + const cols = ncols orelse 0; + if (cols == 0) return renderTableVerbatim(w, header, it); - try emitRow(w, header, widths[0..ncols], true); + try emitRow(w, header, widths[0..cols], true); _ = it.next(); try w.writeByte('\n'); - try emitSeparator(w, widths[0..ncols]); + 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..ncols], false); + try emitRow(w, line, widths[0..cols], false); } } @@ -118,19 +103,18 @@ fn renderTableVerbatim(w: *std.Io.Writer, header: []const u8, it: *LineIterator) } } -fn measureRow(row: []const u8, widths: *[max_table_columns]usize, ncols: *usize) bool { +/// Column count of `row`, or null when the table can't be aligned (too many +/// columns, or a cell overflowing the measuring buffer). +fn measureRow(row: []const u8, widths: *[max_table_columns]usize) ?usize { var cells = cellIterator(row); var col: usize = 0; while (cells.next()) |cell| { - // A row's trailing `|` leaves one empty last segment; drop it. - if (cell.len == 0 and cells.pos >= cells.row.len) break; - if (col == max_table_columns) return false; - const width = cellDisplayWidth(cell) orelse return false; + if (col == max_table_columns) return null; + const width = cellDisplayWidth(cell) orelse return null; widths[col] = @max(widths[col], width); col += 1; } - ncols.* = @max(ncols.*, col); - return true; + return col; } fn emitRow(w: *std.Io.Writer, row: []const u8, widths: []const usize, is_header: bool) !void { @@ -162,13 +146,8 @@ fn emitRow(w: *std.Io.Writer, row: []const u8, widths: []const usize, is_header: } fn renderCell(w: *std.Io.Writer, cell: []const u8, is_header: bool) std.Io.Writer.Error!void { - if (is_header) { - try w.writeAll(ansi.bold); - try renderInlineStyled(w, cell, &bold_style); - try w.writeAll(ansi.reset); - } else { - try renderInline(w, cell); - } + if (is_header) return span(w, cell, ansi.bold, null); + try renderInline(w, cell); } fn emitSeparator(w: *std.Io.Writer, widths: []const usize) !void { @@ -200,6 +179,8 @@ const CellIterator = struct { 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; } }; @@ -473,9 +454,7 @@ fn renderLine(w: *std.Io.Writer, line: []const u8, js: ?*js_highlight.State) !vo 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 w.writeAll(ansi.bold); - try renderInlineStyled(w, std.mem.trimLeft(u8, trimmed[hashes..], " "), &bold_style); - try w.writeAll(ansi.reset); + try span(w, std.mem.trimLeft(u8, trimmed[hashes..], " "), ansi.bold, null); return; } @@ -517,8 +496,6 @@ const Style = struct { } }; -const bold_style: Style = .{ .code = ansi.bold, .parent = null }; - fn renderInline(w: *std.Io.Writer, text: []const u8) !void { try renderInlineStyled(w, text, null); }