Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion plugin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
101 changes: 96 additions & 5 deletions src/Chat.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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 {};
Expand All @@ -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;
Expand Down
38 changes: 35 additions & 3 deletions src/View.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
Loading