From 65fdd5514e52d12bda2f8a4aa143a5948900ddcb Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 16 Oct 2026 09:49:20 -0500 Subject: [PATCH] chat: messages sent while a turn runs wait in a queue While a turn ran, Enter did nothing: the chat only sent when idle, and the ACP client drops a prompt that arrives mid-turn (`session/prompt` is one turn at a time). Now Enter, while busy, puts the message (and what was attached) in a queue shown above the message box, each with an x to take it off. When the turn ends, the queue goes as one message, its texts in order. Stop puts the queue back in the message box rather than sending it. The chat keeps the queue, not a client, so it works the same for every client; it is sent from the plugin's `beginFrame`, never from inside a client's `chat.set_busy` call. The Sample client now holds its turn busy until its question is answered (or Stop), as an agent waits on a permission, so the queue can be seen without an agent. --- plugin.zig | 5 ++- src/Chat.zig | 101 ++++++++++++++++++++++++++++++++++++++++++++++++--- src/View.zig | 38 +++++++++++++++++-- 3 files changed, 135 insertions(+), 9 deletions(-) diff --git a/plugin.zig b/plugin.zig index 8a724c5..7919a85 100644 --- a/plugin.zig +++ b/plugin.zig @@ -96,7 +96,10 @@ fn drawExplorer(_: ?*anyopaque) anyerror!dvui.App.Result { fn beginFrame(_: *anyopaque) void { AcpClient.frame(); Explorer.frame(sdk.host()); - for (chats.values()) |c| View.beginFrame(c); + for (chats.values()) |c| { + c.sendQueued(); + View.beginFrame(c); + } } fn deinit(_: *anyopaque) void { diff --git a/src/Chat.zig b/src/Chat.zig index 7f917d8..558a05b 100644 --- a/src/Chat.zig +++ b/src/Chat.zig @@ -40,11 +40,26 @@ settings: []chat.Setting = &.{}, settings_arena: ?std.heap.ArenaAllocator = null, /// Files the person attached and has not sent yet: shown above the message box, sent with it. attached: std.ArrayListUnmanaged(Attachment) = .empty, +/// What the person sent while a turn ran, oldest first: shown above the message box, and sent +/// together as the next message when the turn ends (`sendQueued`). +queued: std.ArrayListUnmanaged(Queued) = .empty, view: View.State = .{}, /// The transcript's and the header's state when last written. saved_version: u64 = 0, header_dirty: bool = false, +/// A message waiting for the turn to end: its text and what was attached to it, owned. +pub const Queued = struct { + text: []u8, + attached: []Attachment, + + fn deinit(self: Queued, gpa: std.mem.Allocator) void { + for (self.attached) |a| gpa.free(a.path); + gpa.free(self.attached); + gpa.free(self.text); + } +}; + pub const Attachment = struct { path: []u8, mime: []const u8, @@ -188,6 +203,8 @@ fn deinitFields(self: *Chat) void { if (self.settings_arena) |*a| a.deinit(); for (self.attached.items) |a| self.gpa.free(a.path); self.attached.deinit(self.gpa); + for (self.queued.items) |q| q.deinit(self.gpa); + self.queued.deinit(self.gpa); self.view.deinit(self.gpa); self.transcript.deinit(); self.input.deinit(self.gpa); @@ -249,8 +266,15 @@ fn mimeOf(path: []const u8) []const u8 { /// What the person typed, to the chat's client: posted as theirs, then handed over. False when /// there is nothing to send or no client to send it to (the text stays where it was typed). pub fn send(self: *Chat, text: []const u8) bool { + if (!self.sendWith(text, self.attached.items)) return false; + for (self.attached.items) |a| self.gpa.free(a.path); + self.attached.clearRetainingCapacity(); + return true; +} + +fn sendWith(self: *Chat, text: []const u8, attached: []const Attachment) bool { const trimmed = std.mem.trim(u8, text, " \t\r\n"); - if ((trimmed.len == 0 and self.attached.items.len == 0) or !Commands.isRegistered(self.client_id)) return false; + if ((trimmed.len == 0 and attached.len == 0) or !Commands.isRegistered(self.client_id)) return false; // The message as the person sees it: their text, the images they attached shown, the files as // links. The client gets the same, by path. var arena_state: std.heap.ArenaAllocator = .init(self.gpa); @@ -259,7 +283,7 @@ pub fn send(self: *Chat, text: []const u8) bool { var shown: std.ArrayListUnmanaged(chat.Block) = .empty; var sent: std.ArrayListUnmanaged(chat.Block) = .empty; if (trimmed.len > 0) shown.append(arena, .{ .text = trimmed }) catch return false; - for (self.attached.items) |a| { + for (attached) |a| { if (a.image()) { const bytes = std.Io.Dir.cwd().readFileAlloc(dvui.io, a.path, arena, .limited(32 * 1024 * 1024)) catch continue; shown.append(arena, .{ .image = .{ .mime = a.mime, .bytes = bytes, .alt = std.fs.path.basename(a.path) } }) catch {}; @@ -272,22 +296,89 @@ pub fn send(self: *Chat, text: []const u8) bool { _ = self.transcript.post(.person, shown.items) catch return false; // A chat is named by the first thing asked in it, unless its client names it. if (self.title.items.len == 0) { - const line = if (trimmed.len > 0) trimmed[0 .. std.mem.indexOfScalar(u8, trimmed, '\n') orelse trimmed.len] else std.fs.path.basename(self.attached.items[0].path); + const line = if (trimmed.len > 0) trimmed[0 .. std.mem.indexOfScalar(u8, trimmed, '\n') orelse trimmed.len] else std.fs.path.basename(attached[0].path); var buf: [64]u8 = undefined; const cut = if (line.len > 60) std.fmt.bufPrint(&buf, "{s}...", .{line[0..57]}) catch line[0..60] else line; self.setTitle(cut); } self.view.follow = true; Commands.callClient(self.client_id, "chat_send", chat.Send{ .chat = self.chat_id, .text = trimmed, .folder = self.folder, .attached = sent.items }); - for (self.attached.items) |a| self.gpa.free(a.path); - self.attached.clearRetainingCapacity(); return true; } +/// While a turn runs: what the person typed, and what they attached, wait for it to end. ACP +/// takes one prompt at a time (`session/prompt` answers when the turn is over), and so does every +/// client here, so the chat keeps the queue, the same for all of them. False when there is +/// nothing to queue. +pub fn queue(self: *Chat, text: []const u8) bool { + const trimmed = std.mem.trim(u8, text, " \t\r\n"); + if (trimmed.len == 0 and self.attached.items.len == 0) return false; + const owned = self.gpa.dupe(u8, trimmed) catch return false; + const attached = self.attached.toOwnedSlice(self.gpa) catch { + self.gpa.free(owned); + return false; + }; + self.queued.append(self.gpa, .{ .text = owned, .attached = attached }) catch { + (Queued{ .text = owned, .attached = attached }).deinit(self.gpa); + return false; + }; + return true; +} + +pub fn unqueue(self: *Chat, index: usize) void { + if (index >= self.queued.items.len) return; + self.queued.orderedRemove(index).deinit(self.gpa); +} + +/// The turn is over: what queued goes as one message, its texts in the order they were written, +/// as a person who kept typing would have sent them. Called at the top of each frame, never from +/// inside a client's own call. +pub fn sendQueued(self: *Chat) void { + if (self.busy or self.queued.items.len == 0) return; + var arena_state: std.heap.ArenaAllocator = .init(self.gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var text: std.ArrayListUnmanaged(u8) = .empty; + var attached: std.ArrayListUnmanaged(Attachment) = .empty; + for (self.queued.items) |q| { + if (q.text.len > 0) { + if (text.items.len > 0) text.appendSlice(arena, "\n\n") catch return; + text.appendSlice(arena, q.text) catch return; + } + attached.appendSlice(arena, q.attached) catch return; + } + if (!self.sendWith(text.items, attached.items)) return; + for (self.queued.items) |q| q.deinit(self.gpa); + self.queued.clearRetainingCapacity(); +} + +/// Stop: the turn ends and what queued is not sent unasked. It goes back into the message box, +/// ahead of what is being typed, to send or not. pub fn cancel(self: *Chat) void { + self.unsendQueued(); Commands.callClient(self.client_id, "chat_cancel", chat.ChatOnly{ .chat = self.chat_id }); } +fn unsendQueued(self: *Chat) void { + if (self.queued.items.len == 0) return; + var text: std.ArrayList(u8) = .empty; + for (self.queued.items) |q| { + if (q.text.len > 0) { + text.appendSlice(self.gpa, q.text) catch return text.deinit(self.gpa); + text.appendSlice(self.gpa, "\n\n") catch return text.deinit(self.gpa); + } + } + text.appendSlice(self.gpa, self.input.items) catch return text.deinit(self.gpa); + self.input.deinit(self.gpa); + self.input = text; + for (self.queued.items) |q| { + for (q.attached) |a| self.attached.append(self.gpa, a) catch self.gpa.free(a.path); + self.gpa.free(q.attached); + self.gpa.free(q.text); + } + self.queued.clearRetainingCapacity(); +} + /// The person chose `option` of question `qid`. pub fn choose(self: *Chat, qid: chat.QuestionId, option: u32) void { const option_id = self.transcript.answer(qid, option) orelse return; diff --git a/src/View.zig b/src/View.zig index be01be5..1006d29 100644 --- a/src/View.zig +++ b/src/View.zig @@ -738,7 +738,8 @@ fn drawDots(c: *Chat, id_extra: usize) void { /// The message box: what is attached, the text, and under it a row of what goes with the message: /// + to attach files and images, the client's settings (an agent's model, its mode) as dropdowns, -/// and Send, or Stop while a turn runs. +/// and Send, or Stop while a turn runs. While it runs, Enter queues the message instead (above +/// the box, each with an x to take it back), and the queue goes when the turn ends. fn drawInput(c: *Chat) void { const theme = dvui.themeGet(); var col = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .gravity_y = 1, .padding = .{ .x = 12, .y = 8, .w = 12, .h = 10 } }); @@ -793,7 +794,7 @@ fn drawInput(c: *Chat) void { .multiline = true, .break_lines = true, .text = .{ .array_list = .{ .backing = &c.input, .allocator = c.gpa, .limit = 64 * 1024 } }, - .placeholder = "Message", + .placeholder = if (c.busy) "Queue a message for when this turn ends" else "Message", }, .{ .expand = .horizontal, .gravity_y = 1, @@ -816,7 +817,38 @@ fn drawInput(c: *Chat) void { te.deinit(); if (c.attached.items.len > 0) drawAttached(c); - if (send_now and !c.busy and c.send(c.input.items)) c.input.clearRetainingCapacity(); + if (c.queued.items.len > 0) drawQueued(c); + if (send_now) { + const done = if (c.busy) c.queue(c.input.items) else c.send(c.input.items); + if (done) c.input.clearRetainingCapacity(); + } +} + +/// What waits for the turn to end, oldest at the top: a quiet row each, its first line, how many +/// files go with it, and an x to take it off. +fn drawQueued(c: *Chat) void { + const theme = dvui.themeGet(); + var col = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .gravity_y = 1, .margin = .{ .h = 6 } }); + defer col.deinit(); + var remove: ?usize = null; + for (c.queued.items, 0..) |q, n| { + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .id_extra = n, + .background = true, + .color_fill = .{ .color = theme.color(.content, .fill).opacity(0.6) }, + .corners = .all(10), + .padding = .{ .x = 8, .y = 2, .w = 4, .h = 2 }, + .margin = .{ .h = 4 }, + }); + defer row.deinit(); + core.icon.icon(@src(), "queued", entypo.back_in_time, .{ .fill_color = .{ .color = faded() } }, .{ .gravity_y = 0.5, .min_size_content = .{ .w = 12, .h = 12 }, .margin = .{ .w = 6 } }); + if (dvui.buttonIcon(@src(), "Remove from the queue", entypo.cross, .{}, .{}, .{ .gravity_x = 1, .gravity_y = 0.5, .padding = .all(3), .corners = .all(8), .min_size_content = .{ .w = 10, .h = 10 }, .margin = .{ .x = 4 } })) remove = n; + if (q.attached.len > 0) dvui.label(@src(), "+{d}", .{q.attached.len}, .{ .gravity_x = 1, .gravity_y = 0.5, .padding = .{ .x = 4, .w = 4 }, .color_text = .{ .color = faded() } }); + const line = q.text[0 .. std.mem.indexOfScalar(u8, q.text, '\n') orelse q.text.len]; + dvui.labelNoFmt(@src(), if (line.len > 0) line else "(attachments)", .{}, .{ .gravity_y = 0.5, .padding = .{ .y = 2, .h = 2 }, .color_text = .{ .color = faded() } }); + } + if (remove) |n| c.unqueue(n); } /// The chat a file dialog's choice is for: the dialog's callback has no context of its own.