diff --git a/build.zig b/build.zig
index 045139f..bc48bcc 100644
--- a/build.zig
+++ b/build.zig
@@ -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",
};
diff --git a/src/letta.zig b/src/letta.zig
index 4bf202b..8873479 100644
--- a/src/letta.zig
+++ b/src/letta.zig
@@ -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;
+}
diff --git a/src/main.zig b/src/main.zig
index 86cc1f1..f0571d7 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -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 {};
diff --git a/src/overlay.zig b/src/overlay.zig
new file mode 100644
index 0000000..35baaf5
--- /dev/null
+++ b/src/overlay.zig
@@ -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, "");
+ try out.appendSlice(allocator, text[i + 2 .. cl]);
+ try out.appendSlice(allocator, "");
+ 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, "");
+ try out.appendSlice(allocator, text[i + 1 .. cl]);
+ try out.appendSlice(allocator, "");
+ 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, "");
+ try out.appendSlice(allocator, text[i + 1 .. cl]);
+ try out.appendSlice(allocator, "");
+ i = cl + 1;
+ } else {
+ try out.append(allocator, c);
+ i += 1;
+ }
+ } else {
+ try out.append(allocator, c);
+ i += 1;
+ }
+ }
+ return out.toOwnedSlice(allocator);
+}
diff --git a/src/whisper.zig b/src/whisper.zig
index f35a8bc..0c57e7f 100644
--- a/src/whisper.zig
+++ b/src/whisper.zig
@@ -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,