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.