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",
|
"qcoreapplication",
|
||||||
"qguiapplication",
|
"qguiapplication",
|
||||||
"qaction",
|
"qaction",
|
||||||
|
"qabstractscrollarea",
|
||||||
|
"qabstractslider",
|
||||||
|
"qboxlayout",
|
||||||
|
"qcursor",
|
||||||
|
"qevent",
|
||||||
|
"qlabel",
|
||||||
|
"qlayout",
|
||||||
|
"qobject",
|
||||||
"qbrush",
|
"qbrush",
|
||||||
"qcolor",
|
"qcolor",
|
||||||
|
"qpoint",
|
||||||
|
"qscreen",
|
||||||
|
"qscrollarea",
|
||||||
"qicon",
|
"qicon",
|
||||||
"qpainter",
|
"qpainter",
|
||||||
"qpixmap",
|
"qpixmap",
|
||||||
@@ -25,6 +36,7 @@ pub fn build(b: *std.Build) void {
|
|||||||
"qrect",
|
"qrect",
|
||||||
"qsystemtrayicon",
|
"qsystemtrayicon",
|
||||||
"qt6zigthreading",
|
"qt6zigthreading",
|
||||||
|
"qtextedit",
|
||||||
"qwidget",
|
"qwidget",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+127
@@ -41,3 +41,130 @@ pub fn infer(
|
|||||||
|
|
||||||
return output.toOwnedSlice(allocator);
|
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 QRectF = qt6.QRectF;
|
||||||
const threading = qt6.threading;
|
const threading = qt6.threading;
|
||||||
const letta = @import("letta.zig");
|
const letta = @import("letta.zig");
|
||||||
|
const overlay_mod = @import("overlay.zig");
|
||||||
|
|
||||||
// ------------------------------------------------------------- constants ---
|
// ------------------------------------------------------------- constants ---
|
||||||
|
|
||||||
@@ -82,6 +83,49 @@ var current_pixmap: ?QPixmap = null;
|
|||||||
var main_thread_id: std.Thread.Id = 0;
|
var main_thread_id: std.Thread.Id = 0;
|
||||||
var pending_state: State = .idle;
|
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).
|
/// Must run on the Qt main thread (touches QSystemTrayIcon / QPixmap).
|
||||||
fn setStateMainThread(next: State) void {
|
fn setStateMainThread(next: State) void {
|
||||||
state = next;
|
state = next;
|
||||||
@@ -188,6 +232,7 @@ fn startRecording() void {
|
|||||||
// pw-record finalizes the WAV header on SIGTERM — kill() sends it.
|
// pw-record finalizes the WAV header on SIGTERM — kill() sends it.
|
||||||
// stderr goes to a log so failures aren't silent.
|
// 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;
|
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|
|
const stderr_file: ?Io.File = if (stderr_log) |lp|
|
||||||
Io.Dir.cwd().createFile(io, lp[1..], .{ .truncate = true }) catch null
|
Io.Dir.cwd().createFile(io, lp[1..], .{ .truncate = true }) catch null
|
||||||
else
|
else
|
||||||
@@ -265,7 +310,10 @@ fn processUtterance() void {
|
|||||||
|
|
||||||
// --- agent ---
|
// --- agent ---
|
||||||
setState(.thinking);
|
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)});
|
std.log.err("letta failed: {s}", .{@errorName(err)});
|
||||||
setState(.idle);
|
setState(.idle);
|
||||||
return;
|
return;
|
||||||
@@ -323,6 +371,10 @@ fn onEndConversation(_: QAction) callconv(.c) void {
|
|||||||
conversation_active = false;
|
conversation_active = false;
|
||||||
if (last_user_text) |t| allocator.free(t);
|
if (last_user_text) |t| allocator.free(t);
|
||||||
last_user_text = null;
|
last_user_text = null;
|
||||||
|
if (overlay) |*ov| {
|
||||||
|
ov.reset();
|
||||||
|
ov.hide();
|
||||||
|
}
|
||||||
setState(.idle);
|
setState(.idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +453,10 @@ pub fn main(init: std.process.Init) !void {
|
|||||||
tray_icon.show();
|
tray_icon.show();
|
||||||
main_thread_id = std.Thread.getCurrentId();
|
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
|
// Own the whisper-server lifecycle: spawn AFTER the tray exists (setState
|
||||||
// touches tray_icon). Create the log dir first.
|
// touches tray_icon). Create the log dir first.
|
||||||
Io.Dir.createDirAbsolute(init.io, RECORDINGS_DIR, .default_dir) catch {};
|
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",
|
||||||
port_str,
|
port_str,
|
||||||
"--convert",
|
"--convert",
|
||||||
|
"--language", "auto",
|
||||||
},
|
},
|
||||||
.stdin = .ignore,
|
.stdin = .ignore,
|
||||||
.stdout = .ignore,
|
.stdout = .ignore,
|
||||||
|
|||||||
Reference in new issue
Block a user