Skip to content
Closed
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
2 changes: 1 addition & 1 deletion TUI/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ pub const Model = struct {
}

pub fn destroyJob(self: *Model, job: *engine.Job) void {
if (job.threaded) job.thread.join();
engine.joinJob(job);
if (job.result) |r| self.alloc.free(r);
for (job.history) |t| self.alloc.free(t.text);
self.alloc.free(job.history);
Expand Down
75 changes: 71 additions & 4 deletions TUI/bgop.zig
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,36 @@ const turn = @import("turn.zig");
const Model = app.Model;
const Op = engine.BgOp;

const SpawnFn = *const fn (*Op) anyerror!std.Thread;

fn spawnOp(op: *Op) anyerror!std.Thread {
return std.Thread.spawn(.{}, engine.bgRun, .{op});
}

/// Spawn `kind` in the background, taking ownership of `turns` and `cmd`.
/// A non-empty `label` pushes a live row so the wait is visible. Returns false
/// when a turn or another op already holds the engine — the caller still owns
/// its inputs then.
pub fn start(self: *Model, kind: Op.Kind, turns: []engine.Turn, cmd: []const u8, label: []const u8) bool {
return startWithSpawn(self, kind, turns, cmd, label, spawnOp);
}

fn startWithSpawn(self: *Model, kind: Op.Kind, turns: []engine.Turn, cmd: []const u8, label: []const u8, spawn_fn: SpawnFn) bool {
if (self.bg != null or self.pending != null) return false;
const op = self.alloc.create(Op) catch return false;
// The op carries the same policy a turn does: `!cmd` goes through the
// engine's gate now, and /plan has to reach it (#551).
op.* = .{ .kind = kind, .gpa = self.alloc, .turns = turns, .cmd = cmd, .params = turn.paramsOf(self) };
if (label.len > 0) self.push(.pending, label) catch {};
self.bg = op;
if (std.Thread.spawn(.{}, engine.bgRun, .{op})) |th| {
if (spawn_fn(op)) |th| {
op.thread = th;
} else |_| {
// No thread available: run it inline rather than dropping the command.
// Running inline would freeze paint, input and cancellation — report
// the failed start through finish() without invoking engine work.
op.threaded = false;
engine.bgRun(op);
op.start_failed = true;
op.done.store(true, .release);
}
return true;
}
Expand All @@ -55,7 +67,13 @@ pub fn finish(self: *Model) void {
if (op.threaded) op.thread.join();
op.threaded = false; // release() must not join a second time
_ = turn.removePendingRows(self);
switch (op.kind) {
if (op.start_failed) {
self.push(.err, switch (op.kind) {
.compact => "compaction failed to start",
.bash => "command failed to start",
.files => "file list failed to start",
}) catch {};
} else switch (op.kind) {
.compact => applyCompact(self, op),
.bash => applyBash(self, op),
.files => applyFiles(self, op),
Expand Down Expand Up @@ -248,6 +266,55 @@ test "a second op is refused while one is in flight, and Esc cancels the live on
try testing.expect(m.bg == null);
}

test "thread spawn failure completes through finish without inline engine work (#537)" {
const Fake = struct {
var calls: usize = 0;
fn spawn(_: *Op) anyerror!std.Thread {
return error.InjectedSpawnFailure;
}
fn compact(_: ?*anyopaque, _: std.mem.Allocator, _: []const engine.Turn, _: *engine.CompactOut) bool {
calls += 1;
return false;
}
fn bash(_: ?*anyopaque, _: std.mem.Allocator, _: []const u8, _: engine.Params) ?[]const u8 {
calls += 1;
return null;
}
fn files(_: ?*anyopaque, _: std.mem.Allocator) ?[]const u8 {
calls += 1;
return null;
}
};
Fake.calls = 0;
engine.g_compact_fn = Fake.compact;
engine.g_bash_fn = Fake.bash;
engine.g_files_fn = Fake.files;
defer {
engine.g_compact_fn = null;
engine.g_bash_fn = null;
engine.g_files_fn = null;
}
const cases = [_]struct { kind: Op.Kind, message: []const u8 }{
.{ .kind = .compact, .message = "compaction failed to start" },
.{ .kind = .bash, .message = "command failed to start" },
.{ .kind = .files, .message = "file list failed to start" },
};
for (cases) |case| {
var m: Model = undefined;
m.setup(testing.allocator);
defer m.deinit();
try testing.expect(startWithSpawn(&m, case.kind, &.{}, "", "running", Fake.spawn));
const op = m.bg.?;
try testing.expect(op.start_failed and !op.threaded and op.done.load(.acquire));
try testing.expectEqual(@as(usize, 0), Fake.calls);
finish(&m);
try testing.expect(m.bg == null);
try testing.expectEqual(app.EntryKind.err, m.history.items[0].kind);
try testing.expectEqualStrings(case.message, m.history.items[0].text);
}
try testing.expectEqual(@as(usize, 0), Fake.calls);
}

test "quit gives a stuck op a bounded wait, then abandons it (#533/#534)" {
var m: Model = undefined;
m.setup(testing.allocator);
Expand Down
240 changes: 13 additions & 227 deletions TUI/dispatch.zig
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ const Effect = app.Effect;
/// a model turn all use the one engine, so they queue behind each other.
pub const busy_note = "an engine call is still running — press Esc to cancel it";

/// A production model switch replaces ReplCtx.provider and clears its fallback
/// flags. Turns and background ops borrow that same context, so the mutation
/// waits for the existing one-engine policy even within one input batch.
/// Browsing the picker remains UI-only; callers use this at confirmation.
pub fn refuseProviderMutation(self: *Model) bool {
if (self.pending == null and self.bg == null) return false;
self.push(.system, busy_note) catch {};
return true;
}

pub fn applyLine(self: *Model, raw: []const u8) Effect {
const line = std.mem.trim(u8, raw, " \t\r\n");
if (line.len == 0 and self.images.items.len == 0) return .stay;
Expand Down Expand Up @@ -105,6 +115,8 @@ pub fn runCommand(self: *Model, line: []const u8) Effect {
} else if (std.mem.eql(u8, canon, "/model")) {
if (arg.len == 0) {
self.openOverlay(.model);
} else if (refuseProviderMutation(self)) {
return .stay;
} else if (engine.g_model_fn) |f| {
// A hand-typed name names no provider, so the engine routes it —
// the picker is the surface that knows which seat was meant.
Expand Down Expand Up @@ -365,233 +377,7 @@ fn onOff(v: bool) []const u8 {
return if (v) "on" else "off";
}

test "applyLine /quit and /new" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
try std.testing.expectEqual(Effect.quit, applyLine(&m, "/quit"));
m.quit_requested = false;
try m.push(.user, "keep me");
_ = applyLine(&m, "/new");
try std.testing.expectEqual(app.Screen.welcome, m.screen);
try std.testing.expectEqual(@as(usize, 1), m.history.items.len); // system notice
}

test "/debug opens the observability overlay" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
_ = applyLine(&m, "/debug");
try std.testing.expectEqual(app.Overlay.debug, m.overlay);
}

test "/cache opens the same observability overlay" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
_ = applyLine(&m, "/cache");
try std.testing.expectEqual(app.Overlay.debug, m.overlay);
}

test "/usage is not a char-count view" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
_ = applyLine(&m, "/usage");
const text = m.history.items[m.history.items.len - 1].text;
try std.testing.expect(std.mem.indexOf(u8, text, "chars sent") == null);
try std.testing.expect(std.mem.indexOf(u8, text, "no session sink") != null);
}

test "rewind drops the last user turn" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
try m.push(.user, "one");
try m.push(.assistant, "two");
rewind(&m);
try std.testing.expectEqual(@as(usize, 1), m.history.items.len); // rewind notice
}

test "core pager commands change shipped model state" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
try std.testing.expectEqual(Effect.quit, applyLine(&m, "/exit"));
m.quit_requested = false;
try std.testing.expectEqual(Effect.quit, applyLine(&m, "/q"));
m.quit_requested = false;

_ = applyLine(&m, "/help");
try std.testing.expectEqual(app.Overlay.help, m.overlay);
m.closeOverlay();

try m.push(.user, "stay");
_ = applyLine(&m, "/home");
try std.testing.expectEqual(app.Screen.welcome, m.screen);
try std.testing.expectEqual(app.Focus.prompt, m.focus);

try std.testing.expectEqual(app.AgentMode.normal, m.mode);
_ = applyLine(&m, "/plan");
try std.testing.expectEqual(app.AgentMode.plan, m.mode);
_ = applyLine(&m, "/plan");
try std.testing.expectEqual(app.AgentMode.normal, m.mode);
_ = applyLine(&m, "/always-approve");
try std.testing.expectEqual(app.AgentMode.always_approve, m.mode);
_ = applyLine(&m, "/yolo");
try std.testing.expectEqual(app.AgentMode.normal, m.mode);

_ = applyLine(&m, "/settings");
try std.testing.expectEqual(app.Overlay.settings, m.overlay);
m.closeOverlay();

_ = applyLine(&m, "/model");
try std.testing.expectEqual(app.Overlay.model, m.overlay);
m.closeOverlay();

_ = applyLine(&m, "/clear");
try std.testing.expectEqual(app.Screen.welcome, m.screen);
}

test "/usage with a session HUD is the cost line, not chars" {
engine.g_hud_fn = struct {
fn f(kind: engine.HudKind, buf: []u8) usize {
if (kind != .usage) return 0;
const s = "1 api call(s) · 1200 in (200 cached) + 50 out tokens · $0.0123\n";
const n = @min(s.len, buf.len);
@memcpy(buf[0..n], s[0..n]);
return n;
}
}.f;
defer engine.g_hud_fn = null;
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
_ = applyLine(&m, "/cost");
const text = m.history.items[m.history.items.len - 1].text;
try std.testing.expect(std.mem.indexOf(u8, text, "api call(s)") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "$0.0123") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "chars sent") == null);
try std.testing.expect(std.mem.indexOf(u8, text, "offline") == null);
}

test "every slash name printed in /help dispatches" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
m.openOverlay(.help);
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const text = try @import("chrome.zig").overlay(&m, arena.allocator(), 80);
m.closeOverlay();

var seen: usize = 0;
var i: usize = 0;
while (i < text.len) {
if (text[i] != '/') {
i += 1;
continue;
}
var j = i + 1;
while (j < text.len and (std.ascii.isAlphanumeric(text[j]) or text[j] == '-')) j += 1;
if (j == i + 1) {
i += 1;
continue;
}
const name = text[i..j];
const before = m.history.items.len;
const effect = applyLine(&m, name);
if (std.mem.eql(u8, name, "/quit") or std.mem.eql(u8, name, "/exit") or std.mem.eql(u8, name, "/q")) {
try std.testing.expectEqual(Effect.quit, effect);
m.quit_requested = false;
} else {
try std.testing.expectEqual(Effect.stay, effect);
}
if (m.history.items.len > before) {
const last = m.history.items[m.history.items.len - 1].text;
try std.testing.expect(std.mem.indexOf(u8, last, "unknown command") == null);
}
seen += 1;
i = j;
}
try std.testing.expect(seen >= 9);
}

test "/image attaches a path the next send carries as @[path]" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
_ = applyLine(&m, "/image /tmp/shot.png");
try std.testing.expectEqual(@as(usize, 1), m.images.items.len);
_ = applyLine(&m, "what is this");
var user_text: []const u8 = "";
for (m.history.items) |e| {
if (e.kind == .user) user_text = e.text;
}
try std.testing.expect(std.mem.indexOf(u8, user_text, "@[/tmp/shot.png]") != null);
try std.testing.expect(std.mem.indexOf(u8, user_text, "what is this") != null);
try std.testing.expectEqual(@as(usize, 0), m.images.items.len);
}

test "looksLikeImagePath accepts file URLs and extensions" {
try std.testing.expect(looksLikeImagePath("/tmp/a.png"));
try std.testing.expect(looksLikeImagePath("file:///Users/me/x.JPEG"));
try std.testing.expect(!looksLikeImagePath("hello.png is a format"));
try std.testing.expect(!looksLikeImagePath("readme.md"));
try std.testing.expect(looksLikeImagePath("/Users/me/My Shot.png"));
try std.testing.expect(!looksLikeImagePath("see /tmp/a.png"));
}

test "/effort with no arg opens the effort menu" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
m.effort = .high;
try std.testing.expectEqual(app.Effect.stay, applyLine(&m, "/effort"));
try std.testing.expectEqual(app.Overlay.effort, m.overlay);
try std.testing.expectEqual(@as(usize, @intFromEnum(engine.Effort.high)), m.overlay_sel);
try std.testing.expectEqual(app.Effect.stay, applyLine(&m, "/effort low"));
try std.testing.expectEqual(engine.Effort.low, m.effort);
}

test "/vim-mode toggles and /jump without turns explains" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
try std.testing.expect(!m.vim_mode);
_ = applyLine(&m, "/vim-mode");
try std.testing.expect(m.vim_mode);
_ = applyLine(&m, "/vim");
try std.testing.expect(!m.vim_mode);
_ = applyLine(&m, "/jump");
const last = m.history.items[m.history.items.len - 1].text;
try std.testing.expect(std.mem.indexOf(u8, last, "nothing to jump") != null);
try m.push(.user, "hi");
_ = applyLine(&m, "/jump");
try std.testing.expectEqual(app.Overlay.jump, m.overlay);
}

test "/btw queues an aside while a turn is pending" {
var m: Model = undefined;
m.setup(std.testing.allocator);
defer m.deinit();
const job = try std.testing.allocator.create(engine.Job);
job.* = .{ .gpa = std.testing.allocator, .history = &.{}, .params = .{}, .stream = .{}, .threaded = false };
m.pending = job;
defer {
m.pending = null;
std.testing.allocator.destroy(job);
}
_ = applyLine(&m, "/btw remember the tests");
try std.testing.expectEqual(@as(usize, 1), m.steer_queue.items.len);
try std.testing.expectEqualStrings("remember the tests", m.steer_queue.items[0]);
}

test "lastLines caps ! output to the tail" {
try std.testing.expectEqualStrings("c\nd", lastLines("a\nb\nc\nd", 2));
try std.testing.expectEqualStrings("a\nb", lastLines("a\nb", 5));
}

test {
_ = @import("dispatch_tests.zig"); // overflow tests (600-line cap)
_ = @import("dispatch_command_tests.zig");
}
Loading