Streaming overlay: chat GUI for agent responses
- overlay.zig: frameless translucent panel (fixed bg + rounded corners, only bubbles scroll), drag handle, hover-to-full-opacity, top-right spawn - chat layout: per-message QLabel bubbles (user purple / agent green / tool steps grey mono), transcript retained across turns, End-conversation resets - agent reply streams into its own growing bubble (re-render per chunk) - basic markdown in all messages: **bold**, *italic*, `code`, HTML-escaped - tool steps truncated to 90 chars, click to expand/collapse - polite autoscroll: pins to bottom unless user scrolled up - letta.zig: streamInfer() — --output-format stream-json, per-event callback (tool calls, returns, reply chunks), full reply still returned for TTS - whisper: --language auto (no more hardcoded English) - fix: stderr_log leak crashing Quit via debug allocator 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code <noreply@letta.com>
This commit is contained in:
1 parent
5b050c09e3
commit
2b2235ff86
5 files changed
+500
-1
No files matched your search
@@ -16,8 +16,19 @@ pub fn build(b: *std.Build) void {
|
||||
"qcoreapplication",
|
||||
"qguiapplication",
|
||||
"qaction",
|
||||
"qabstractscrollarea",
|
||||
"qabstractslider",
|
||||
"qboxlayout",
|
||||
"qcursor",
|
||||
"qevent",
|
||||
"qlabel",
|
||||
"qlayout",
|
||||
"qobject",
|
||||
"qbrush",
|
||||
"qcolor",
|
||||
"qpoint",
|
||||
"qscreen",
|
||||
"qscrollarea",
|
||||
"qicon",
|
||||
"qpainter",
|
||||
"qpixmap",
|
||||
@@ -25,6 +36,7 @@ pub fn build(b: *std.Build) void {
|
||||
"qrect",
|
||||
"qsystemtrayicon",
|
||||
"qt6zigthreading",
|
||||
"qtextedit",
|
||||
"qwidget",
|
||||
};
|
||||
|
||||
|
||||
+127
@@ -41,3 +41,130 @@ pub fn infer(
|
||||
|
||||
return output.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------------- streaming ---
|
||||
|
||||
pub const Event = union(enum) {
|
||||
step: []const u8, // tool call / return, pre-formatted line
|
||||
chunk: []const u8, // streamed reply text
|
||||
};
|
||||
|
||||
/// Like infer(), but spawns letta with --output-format stream-json and calls
|
||||
/// `onEvent` (on THIS thread — caller marshals to Qt) for every step/chunk.
|
||||
/// Returns the final full reply text (allocated, caller frees).
|
||||
pub fn streamInfer(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
agent: []const u8,
|
||||
prompt: []const u8,
|
||||
ctx: ?*anyopaque,
|
||||
onEvent: *const fn (ctx: ?*anyopaque, ev: Event) void,
|
||||
) ![]u8 {
|
||||
var child = try std.process.spawn(io, .{
|
||||
.argv = &.{
|
||||
LETTA_PATH, "--agent", agent,
|
||||
"-p", prompt, "--conversation",
|
||||
"default", "--toolset", "default",
|
||||
"--output-format", "stream-json",
|
||||
},
|
||||
.stdout = .pipe,
|
||||
.stderr = .inherit,
|
||||
});
|
||||
const stdout_file = child.stdout.?;
|
||||
|
||||
var reply: std.ArrayList(u8) = .empty;
|
||||
errdefer reply.deinit(allocator);
|
||||
|
||||
var line_buf: [16 * 1024]u8 = undefined;
|
||||
var line_len: usize = 0;
|
||||
var buf: [16 * 1024]u8 = undefined;
|
||||
|
||||
while (true) {
|
||||
const n = stdout_file.readStreaming(io, &.{&buf}) catch break;
|
||||
if (n == 0) break;
|
||||
for (buf[0..n]) |ch| {
|
||||
if (ch == '\n') {
|
||||
if (line_len > 0) {
|
||||
handleLine(allocator, line_buf[0..line_len], ctx, onEvent, &reply);
|
||||
line_len = 0;
|
||||
}
|
||||
} else if (line_len < line_buf.len) {
|
||||
line_buf[line_len] = ch;
|
||||
line_len += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = child.wait(io) catch {};
|
||||
|
||||
return reply.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Minimal hand parse of one stream-json line (we control the producer).
|
||||
fn handleLine(
|
||||
allocator: std.mem.Allocator,
|
||||
line: []const u8,
|
||||
ctx: ?*anyopaque,
|
||||
onEvent: *const fn (ctx: ?*anyopaque, ev: Event) void,
|
||||
reply: *std.ArrayList(u8),
|
||||
) void {
|
||||
const mt = dupeStr(allocator, line, "message_type") orelse {
|
||||
// Non-message line; the "result" line holds the full text as backup.
|
||||
if (std.mem.indexOf(u8, line, "\"type\":\"result\"") != null and reply.items.len == 0) {
|
||||
if (dupeStr(allocator, line, "result")) |r| {
|
||||
defer allocator.free(r);
|
||||
reply.appendSlice(allocator, r) catch {};
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
defer allocator.free(mt);
|
||||
|
||||
if (std.mem.eql(u8, mt, "tool_call_message")) {
|
||||
const name = dupeStr(allocator, line, "name") orelse allocator.dupe(u8, "?") catch return;
|
||||
defer allocator.free(name);
|
||||
const args = dupeStr(allocator, line, "command") orelse
|
||||
dupeStr(allocator, line, "description") orelse allocator.dupe(u8, "") catch return;
|
||||
defer allocator.free(args);
|
||||
const text = std.fmt.allocPrint(allocator, "> {s} {s}", .{ name, args }) catch return;
|
||||
defer allocator.free(text);
|
||||
onEvent(ctx, .{ .step = text });
|
||||
} else if (std.mem.eql(u8, mt, "tool_return_message")) {
|
||||
const ret = dupeStr(allocator, line, "tool_return") orelse allocator.dupe(u8, "") catch return;
|
||||
defer allocator.free(ret);
|
||||
const text = std.fmt.allocPrint(allocator, " {s}", .{ret}) catch return;
|
||||
defer allocator.free(text);
|
||||
onEvent(ctx, .{ .step = text });
|
||||
} else if (std.mem.eql(u8, mt, "assistant_message")) {
|
||||
const txt = dupeStr(allocator, line, "text") orelse return;
|
||||
defer allocator.free(txt);
|
||||
reply.appendSlice(allocator, txt) catch {};
|
||||
onEvent(ctx, .{ .chunk = txt });
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract "key":"value" (with escape handling), allocated with `allocator`.
|
||||
fn dupeStr(allocator: std.mem.Allocator, line: []const u8, key: []const u8) ?[]u8 {
|
||||
var pat_buf: [64]u8 = undefined;
|
||||
const pat = std.fmt.bufPrint(&pat_buf, "\"{s}\":\"", .{key}) catch return null;
|
||||
const start = std.mem.indexOf(u8, line, pat) orelse return null;
|
||||
var it = line[start + pat.len ..];
|
||||
|
||||
var out: std.ArrayList(u8) = .empty;
|
||||
errdefer out.deinit(allocator);
|
||||
while (it.len > 0 and it[0] != '"') {
|
||||
if (it[0] == '\\' and it.len > 1) {
|
||||
const esc: u8 = switch (it[1]) {
|
||||
'n' => '\n',
|
||||
't' => '\t',
|
||||
else => it[1],
|
||||
};
|
||||
out.append(allocator, esc) catch return null;
|
||||
it = it[2..];
|
||||
} else {
|
||||
out.append(allocator, it[0]) catch return null;
|
||||
it = it[1..];
|
||||
}
|
||||
}
|
||||
return out.toOwnedSlice(allocator) catch null;
|
||||
}
|
||||
+57
-1
@@ -19,6 +19,7 @@ const QBrush = qt6.QBrush;
|
||||
const QRectF = qt6.QRectF;
|
||||
const threading = qt6.threading;
|
||||
const letta = @import("letta.zig");
|
||||
const overlay_mod = @import("overlay.zig");
|
||||
|
||||
// ------------------------------------------------------------- constants ---
|
||||
|
||||
@@ -82,6 +83,49 @@ var current_pixmap: ?QPixmap = null;
|
||||
var main_thread_id: std.Thread.Id = 0;
|
||||
var pending_state: State = .idle;
|
||||
|
||||
// --------------------------------------------------------------- overlay ---
|
||||
|
||||
var overlay: ?overlay_mod.Overlay = null;
|
||||
|
||||
const OverlayKind = enum { begin, user, step, chunk };
|
||||
const OverlayMsg = struct { kind: OverlayKind, text: ?[]u8 };
|
||||
|
||||
/// Worker-thread side: queue an overlay update onto the Qt main loop.
|
||||
fn pushOverlay(kind: OverlayKind, text: ?[]u8) void {
|
||||
const msg = allocator.create(OverlayMsg) catch {
|
||||
if (text) |t| allocator.free(t);
|
||||
return;
|
||||
};
|
||||
msg.* = .{ .kind = kind, .text = text };
|
||||
threading.async(msg, onOverlayMsg);
|
||||
}
|
||||
|
||||
fn onOverlayMsg(ctx: ?*anyopaque) callconv(.c) void {
|
||||
const msg: *OverlayMsg = @ptrCast(@alignCast(ctx orelse return));
|
||||
defer allocator.destroy(msg);
|
||||
if (overlay) |*ov| switch (msg.kind) {
|
||||
.begin => ov.begin(),
|
||||
.user => ov.user(msg.text orelse ""),
|
||||
.step => ov.step(msg.text orelse ""),
|
||||
.chunk => ov.chunk(msg.text orelse ""),
|
||||
};
|
||||
if (msg.text) |t| allocator.free(t);
|
||||
}
|
||||
|
||||
|
||||
fn onLettaEvent(_: ?*anyopaque, ev: letta.Event) void {
|
||||
switch (ev) {
|
||||
.step => |s| {
|
||||
const dup = allocator.dupe(u8, s) catch return;
|
||||
pushOverlay(.step, dup);
|
||||
},
|
||||
.chunk => |c| {
|
||||
const dup = allocator.dupe(u8, c) catch return;
|
||||
pushOverlay(.chunk, dup);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Must run on the Qt main thread (touches QSystemTrayIcon / QPixmap).
|
||||
fn setStateMainThread(next: State) void {
|
||||
state = next;
|
||||
@@ -188,6 +232,7 @@ fn startRecording() void {
|
||||
// pw-record finalizes the WAV header on SIGTERM — kill() sends it.
|
||||
// stderr goes to a log so failures aren't silent.
|
||||
const stderr_log = std.fmt.allocPrint(allocator, "{s}/pw-record.log", .{RECORDINGS_DIR}) catch null;
|
||||
defer if (stderr_log) |sl| allocator.free(sl);
|
||||
const stderr_file: ?Io.File = if (stderr_log) |lp|
|
||||
Io.Dir.cwd().createFile(io, lp[1..], .{ .truncate = true }) catch null
|
||||
else
|
||||
@@ -265,7 +310,10 @@ fn processUtterance() void {
|
||||
|
||||
// --- agent ---
|
||||
setState(.thinking);
|
||||
const response = letta.infer(io, allocator, LETTA_AGENT_ID, text) catch |err| {
|
||||
|
||||
pushOverlay(.begin, null);
|
||||
if (allocator.dupe(u8, text) catch null) |user_dup| pushOverlay(.user, user_dup);
|
||||
const response = letta.streamInfer(io, allocator, LETTA_AGENT_ID, text, null, onLettaEvent) catch |err| {
|
||||
std.log.err("letta failed: {s}", .{@errorName(err)});
|
||||
setState(.idle);
|
||||
return;
|
||||
@@ -323,6 +371,10 @@ fn onEndConversation(_: QAction) callconv(.c) void {
|
||||
conversation_active = false;
|
||||
if (last_user_text) |t| allocator.free(t);
|
||||
last_user_text = null;
|
||||
if (overlay) |*ov| {
|
||||
ov.reset();
|
||||
ov.hide();
|
||||
}
|
||||
setState(.idle);
|
||||
}
|
||||
|
||||
@@ -401,6 +453,10 @@ pub fn main(init: std.process.Init) !void {
|
||||
tray_icon.show();
|
||||
main_thread_id = std.Thread.getCurrentId();
|
||||
|
||||
var ov = overlay_mod.Overlay.create(allocator);
|
||||
overlay = ov;
|
||||
_ = &ov;
|
||||
|
||||
// Own the whisper-server lifecycle: spawn AFTER the tray exists (setState
|
||||
// touches tray_icon). Create the log dir first.
|
||||
Io.Dir.createDirAbsolute(init.io, RECORDINGS_DIR, .default_dir) catch {};
|
||||
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
//! Floating translucent overlay: chat-style bubbles in a scroll area.
|
||||
|
||||
const std = @import("std");
|
||||
const qt6 = @import("libqt6zig");
|
||||
const QWidget = qt6.QWidget;
|
||||
const QLabel = qt6.QLabel;
|
||||
const QGuiApplication = qt6.QGuiApplication;
|
||||
|
||||
const WA_TranslucentBackground: i32 = 120;
|
||||
const WA_ShowWithoutActivating: i32 = 98;
|
||||
const FramelessWindowHint: i32 = 2048;
|
||||
const WindowStaysOnTopHint: i32 = 262144;
|
||||
|
||||
const WIDTH: i32 = 480;
|
||||
const HEIGHT: i32 = 360;
|
||||
const MARGIN: i32 = 24;
|
||||
|
||||
const STYLE_USER =
|
||||
\\background: rgba(181, 116, 255, 36); border: 1px solid rgba(181, 116, 255, 90);
|
||||
\\border-radius: 10px; padding: 8px 12px; color: #d6b8ff;
|
||||
;
|
||||
const STYLE_AGENT =
|
||||
\\background: rgba(127, 209, 138, 22); border: 1px solid rgba(127, 209, 138, 55);
|
||||
\\border-radius: 10px; padding: 8px 12px; color: #e8e4f5;
|
||||
;
|
||||
const STYLE_STEP =
|
||||
\\background: rgba(141, 134, 163, 26); border-radius: 6px;
|
||||
\\padding: 4px 8px; color: #9d96b3; font-family: monospace; font-size: 12px;
|
||||
;
|
||||
|
||||
pub const Overlay = struct {
|
||||
window: QWidget,
|
||||
scroll: qt6.QScrollArea,
|
||||
list: qt6.QVBoxLayout,
|
||||
|
||||
var drag_offset: ?qt6.QPoint = null;
|
||||
var container_ptr: ?QWidget = null;
|
||||
var window_ptr: ?QWidget = null;
|
||||
|
||||
// Click-to-expand tool steps.
|
||||
const Step = struct { label: QLabel, full: []u8, expanded: bool };
|
||||
var steps: std.ArrayList(Step) = .empty;
|
||||
|
||||
fn onStepClick(l: QLabel, _: qt6.QMouseEvent) callconv(.c) void {
|
||||
const a = alloc.?;
|
||||
for (steps.items) |*s| {
|
||||
if (s.label.ptr == l.ptr) {
|
||||
s.expanded = !s.expanded;
|
||||
const shown = if (s.expanded) s.full else truncText(s.full);
|
||||
const html = toHtml(a, shown) catch return;
|
||||
defer a.free(html);
|
||||
s.label.setText(html);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncText(t: []const u8) []const u8 {
|
||||
return if (t.len > 90) t[0..90] else t;
|
||||
}
|
||||
|
||||
// Active streaming reply label (chunk() re-renders its text).
|
||||
var reply_label: ?QLabel = null;
|
||||
var reply_buf: std.ArrayList(u8) = .empty;
|
||||
var alloc: ?std.mem.Allocator = null;
|
||||
var labels: std.ArrayList(QLabel) = .empty;
|
||||
|
||||
pub fn create(allocator: std.mem.Allocator) Overlay {
|
||||
alloc = allocator;
|
||||
const window = QWidget.new2();
|
||||
window.setAttribute2(WA_TranslucentBackground, true);
|
||||
window.setAttribute2(WA_ShowWithoutActivating, true);
|
||||
window.setWindowFlags(FramelessWindowHint | WindowStaysOnTopHint);
|
||||
|
||||
const container = QWidget.new2();
|
||||
container.setAttribute2(93, true); // WA_StyledBackground
|
||||
container_ptr = container;
|
||||
container.setStyleSheet("QWidget { background: rgba(18, 16, 28, 178); border-radius: 14px; }");
|
||||
|
||||
// The scrolling surface is a plain transparent widget — the styled
|
||||
// background stays put on the container, so corners never scroll away.
|
||||
const content = QWidget.new2();
|
||||
const list = qt6.QVBoxLayout.new2();
|
||||
list.setContentsMargins(14, 12, 14, 12);
|
||||
list.setSpacing(8);
|
||||
list.addStretch(); // messages stack from the top
|
||||
content.setLayout(list);
|
||||
|
||||
const scroll = qt6.QScrollArea.new2();
|
||||
scroll.setWidget(content);
|
||||
scroll.setWidgetResizable(true);
|
||||
scroll.setHorizontalScrollBarPolicy(1); // off
|
||||
scroll.setStyleSheet(
|
||||
\\QScrollArea { background: transparent; border: none; }
|
||||
\\QScrollBar:vertical { background: rgba(30, 26, 44, 120); width: 8px; border-radius: 4px; }
|
||||
\\QScrollBar::handle:vertical { background: rgba(141, 134, 163, 140); border-radius: 4px; min-height: 24px; }
|
||||
\\QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||
);
|
||||
|
||||
const handle = QWidget.new2();
|
||||
handle.setStyleSheet("QWidget { background: transparent; }");
|
||||
handle.setFixedHeight(18);
|
||||
|
||||
const outer = qt6.QVBoxLayout.new2();
|
||||
outer.setContentsMargins(1, 1, 1, 1); // keep inside the rounded corners
|
||||
outer.setSpacing(0);
|
||||
outer.addWidget(handle);
|
||||
outer.addWidget(scroll);
|
||||
container.setLayout(outer);
|
||||
|
||||
const winlay = qt6.QVBoxLayout.new2();
|
||||
winlay.setContentsMargins(0, 0, 0, 0);
|
||||
winlay.addWidget(container);
|
||||
window.setLayout(winlay);
|
||||
|
||||
const screen = QGuiApplication.primaryScreen();
|
||||
const geo = screen.availableGeometry();
|
||||
window.resize(WIDTH, HEIGHT);
|
||||
window.show();
|
||||
window.move2(qt6.QPoint.new4(geo.x() + geo.width() - WIDTH - MARGIN, geo.y() + MARGIN));
|
||||
window.hide();
|
||||
|
||||
handle.onMousePressEvent(onDragStart);
|
||||
handle.onMouseMoveEvent(onDragMove);
|
||||
handle.onMouseReleaseEvent(onDragEnd);
|
||||
window.onEnterEvent(onHoverEnter);
|
||||
window.onLeaveEvent(onHoverLeave);
|
||||
|
||||
window_ptr = window;
|
||||
return .{ .window = window, .scroll = scroll, .list = list };
|
||||
}
|
||||
|
||||
fn onHoverEnter(_: QWidget, _: qt6.QEnterEvent) callconv(.c) void {
|
||||
if (container_ptr) |c| c.setStyleSheet("QWidget { background: rgba(18, 16, 28, 255); border-radius: 14px; }");
|
||||
}
|
||||
|
||||
fn onHoverLeave(_: QWidget, _: qt6.QEvent) callconv(.c) void {
|
||||
if (container_ptr) |c| c.setStyleSheet("QWidget { background: rgba(18, 16, 28, 178); border-radius: 14px; }");
|
||||
}
|
||||
|
||||
fn onDragStart(_: QWidget, ev: qt6.QMouseEvent) callconv(.c) void {
|
||||
const win = window_ptr orelse return;
|
||||
const wpos = win.pos();
|
||||
drag_offset = qt6.QPoint.new4(ev.globalX() - wpos.x(), ev.globalY() - wpos.y());
|
||||
}
|
||||
|
||||
fn onDragMove(_: QWidget, ev: qt6.QMouseEvent) callconv(.c) void {
|
||||
const win = window_ptr orelse return;
|
||||
const off = drag_offset orelse return;
|
||||
win.move2(qt6.QPoint.new4(ev.globalX() - off.x(), ev.globalY() - off.y()));
|
||||
}
|
||||
|
||||
fn onDragEnd(_: QWidget, _: qt6.QMouseEvent) callconv(.c) void {
|
||||
drag_offset = null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- content --
|
||||
|
||||
fn makeLabel(text: []const u8, style: []const u8) QLabel {
|
||||
const l = QLabel.new3(text);
|
||||
l.setWordWrap(true);
|
||||
l.setTextFormat(1); // RichText
|
||||
l.setStyleSheet(style);
|
||||
return l;
|
||||
}
|
||||
|
||||
/// Insert a label before the trailing stretch.
|
||||
fn addLabel(self: *Overlay, l: QLabel) void {
|
||||
self.list.insertWidget(self.list.count() - 1, l);
|
||||
labels.append(alloc.?, l) catch {};
|
||||
scrollBottom(self);
|
||||
}
|
||||
|
||||
pub fn begin(self: *Overlay) void {
|
||||
endReply();
|
||||
self.window.show();
|
||||
scrollBottom(self);
|
||||
}
|
||||
|
||||
pub fn user(self: *Overlay, line: []const u8) void {
|
||||
const a = alloc.?;
|
||||
const html = toHtml(a, line) catch return;
|
||||
defer a.free(html);
|
||||
const l = makeLabel(html, STYLE_USER);
|
||||
self.addLabel(l);
|
||||
}
|
||||
|
||||
pub fn step(self: *Overlay, line: []const u8) void {
|
||||
const a = alloc.?;
|
||||
const full = a.dupe(u8, line) catch return;
|
||||
const html = toHtml(a, truncText(full)) catch {
|
||||
a.free(full);
|
||||
return;
|
||||
};
|
||||
defer a.free(html);
|
||||
const l = makeLabel(html, STYLE_STEP);
|
||||
l.setCursor(qt6.QCursor.new2(13)); // PointingHandCursor
|
||||
l.onMousePressEvent(onStepClick);
|
||||
steps.append(a, .{ .label = l, .full = full, .expanded = false }) catch {};
|
||||
self.addLabel(l);
|
||||
}
|
||||
|
||||
/// Streaming reply: one label, text re-rendered per chunk.
|
||||
pub fn chunk(self: *Overlay, text: []const u8) void {
|
||||
const a = alloc.?;
|
||||
if (reply_label == null) {
|
||||
const l = makeLabel("", STYLE_AGENT);
|
||||
reply_label = l;
|
||||
self.addLabel(l);
|
||||
}
|
||||
reply_buf.appendSlice(a, text) catch return;
|
||||
const html = toHtml(a, reply_buf.items) catch return;
|
||||
defer a.free(html);
|
||||
reply_label.?.setText(html);
|
||||
scrollBottom(self);
|
||||
}
|
||||
|
||||
pub fn reset(self: *Overlay) void {
|
||||
endReply();
|
||||
if (alloc) |a| {
|
||||
for (steps.items) |s| a.free(s.full);
|
||||
steps.clearRetainingCapacity();
|
||||
}
|
||||
for (labels.items) |l| l.delete();
|
||||
labels.clearRetainingCapacity();
|
||||
self.window.hide();
|
||||
}
|
||||
|
||||
pub fn hide(self: *Overlay) void {
|
||||
self.window.hide();
|
||||
}
|
||||
|
||||
fn endReply() void {
|
||||
reply_label = null;
|
||||
reply_buf.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
fn atBottom(self: *Overlay) bool {
|
||||
const bar = self.scroll.verticalScrollBar();
|
||||
return bar.maximum() - bar.value() <= 30;
|
||||
}
|
||||
|
||||
fn scrollBottom(self: *Overlay) void {
|
||||
const bar = self.scroll.verticalScrollBar();
|
||||
bar.setValue(bar.maximum());
|
||||
}
|
||||
};
|
||||
|
||||
/// Escape + minimal markdown: **bold**, *italic*, `code`.
|
||||
fn toHtml(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
|
||||
var out: std.ArrayList(u8) = .empty;
|
||||
errdefer out.deinit(allocator);
|
||||
var i: usize = 0;
|
||||
while (i < text.len) {
|
||||
const c = text[i];
|
||||
if (c == '&' or c == '<' or c == '>') {
|
||||
const esc = switch (c) {
|
||||
'&' => "&",
|
||||
'<' => "<",
|
||||
else => ">",
|
||||
};
|
||||
try out.appendSlice(allocator, esc);
|
||||
i += 1;
|
||||
} else if (c == '*' and i + 1 < text.len and text[i + 1] == '*') {
|
||||
const close = std.mem.indexOfPos(u8, text, i + 2, "**");
|
||||
if (close) |cl| {
|
||||
try out.appendSlice(allocator, "<b>");
|
||||
try out.appendSlice(allocator, text[i + 2 .. cl]);
|
||||
try out.appendSlice(allocator, "</b>");
|
||||
i = cl + 2;
|
||||
} else {
|
||||
try out.append(allocator, c);
|
||||
i += 1;
|
||||
}
|
||||
} else if (c == '*') {
|
||||
const close = std.mem.indexOfScalarPos(u8, text, i + 1, '*');
|
||||
if (close) |cl| {
|
||||
try out.appendSlice(allocator, "<i>");
|
||||
try out.appendSlice(allocator, text[i + 1 .. cl]);
|
||||
try out.appendSlice(allocator, "</i>");
|
||||
i = cl + 1;
|
||||
} else {
|
||||
try out.append(allocator, c);
|
||||
i += 1;
|
||||
}
|
||||
} else if (c == '`') {
|
||||
const close = std.mem.indexOfScalarPos(u8, text, i + 1, '`');
|
||||
if (close) |cl| {
|
||||
try out.appendSlice(allocator, "<code style=\"color:#b574ff;\">");
|
||||
try out.appendSlice(allocator, text[i + 1 .. cl]);
|
||||
try out.appendSlice(allocator, "</code>");
|
||||
i = cl + 1;
|
||||
} else {
|
||||
try out.append(allocator, c);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
try out.append(allocator, c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out.toOwnedSlice(allocator);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ pub fn start(alloc: std.mem.Allocator, io: Io, log_dir: []const u8) !void {
|
||||
"--port",
|
||||
port_str,
|
||||
"--convert",
|
||||
"--language", "auto",
|
||||
},
|
||||
.stdin = .ignore,
|
||||
.stdout = .ignore,
|
||||
|
||||
Reference in new issue
Block a user