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:
pierreandLetta Code committed 2026-09-01 20:37:45 +03:00
1 parent 5b050c09e3
commit 2b2235ff86
5 files changed
+500 -1

No files matched your search

+127
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
'&' => "&amp;",
'<' => "&lt;",
else => "&gt;",
};
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);
}
+1
View File
@@ -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,