agent: clean up terminal rendering and highlighting

- Extract ANSI escape codes to a dedicated `ansi.zig` module.
- Consolidate styled output rendering in `Terminal.zig`.
- Simplify dollar variable highlighting and table measurement.
This commit is contained in:
Adrià Arrufat
2026-07-15 09:25:33 +02:00
parent 8b1686f6fb
commit 813cf7f424
4 changed files with 101 additions and 102 deletions

View File

@@ -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 {};
}

32
src/agent/ansi.zig Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
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";

View File

@@ -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;
}

View File

@@ -17,25 +17,9 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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);
}