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,