mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-08-01 18:26:06 -04:00
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:
@@ -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
32
src/agent/ansi.zig
Normal 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";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user