summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/auth_manager.zig5
-rw-r--r--src/config_file.zig69
-rw-r--r--src/debug_log.zig220
-rw-r--r--src/lua_runtime.zig140
-rw-r--r--src/luarocks_runtime.zig103
-rw-r--r--src/main.zig17
-rw-r--r--src/models_toml.zig40
-rw-r--r--src/tui_app.zig7
-rw-r--r--src/tui_components.zig167
-rw-r--r--src/tui_selectors.zig10
10 files changed, 719 insertions, 59 deletions
diff --git a/src/auth_manager.zig b/src/auth_manager.zig
index e26e28a..5504807 100644
--- a/src/auth_manager.zig
+++ b/src/auth_manager.zig
@@ -198,6 +198,11 @@ fn patchCredential(
c.base_url = base_url;
c.extra_headers = merged;
},
+ .openai_codex_responses => |*c| {
+ c.api_key = cred.api_key;
+ c.base_url = base_url;
+ c.extra_headers = merged;
+ },
}
}
diff --git a/src/config_file.zig b/src/config_file.zig
index 1823177..8f6725c 100644
--- a/src/config_file.zig
+++ b/src/config_file.zig
@@ -77,6 +77,10 @@ pub const Provider = struct {
/// the name used on the left of a `<provider>:<model>` reference.
name: []const u8,
style: APIStyle,
+ /// User-facing sub-dialect for styles that share a protocol family.
+ /// Currently only `style = "openai_responses"` supports `dialect = "codex"`,
+ /// which maps to internal `APIStyle.openai_codex_responses`.
+ dialect: ?[]const u8 = null,
base_url: []const u8,
/// The `auth.<name>` session this provider draws its credential from.
auth_name: []const u8,
@@ -90,6 +94,7 @@ pub const Provider = struct {
pub fn deinit(self: Provider, alloc: Allocator) void {
alloc.free(self.name);
+ if (self.dialect) |d| alloc.free(d);
alloc.free(self.base_url);
alloc.free(self.auth_name);
// `extra_headers` lives in the Config auth arena; not freed here.
@@ -213,6 +218,14 @@ pub fn buildProviderConfig(
.max_tokens = max_tokens,
.extra_headers = prov.extra_headers,
} },
+ .openai_codex_responses => return .{ .openai_codex_responses = .{
+ .api_key = api_key,
+ .base_url = prov.base_url,
+ .model = wire_model,
+ .reasoning = reasoning,
+ .max_tokens = max_tokens,
+ .extra_headers = prov.extra_headers,
+ } },
}
}
@@ -677,7 +690,16 @@ fn resolveProvider(
const style_str = (val.get("style") orelse return error.InvalidProvider).asString() orelse
return error.InvalidProvider;
- const style = std.meta.stringToEnum(APIStyle, style_str) orelse return error.InvalidProvider;
+ var style = std.meta.stringToEnum(APIStyle, style_str) orelse return error.InvalidProvider;
+ if (style == .openai_codex_responses) return error.InvalidProvider;
+ const dialect = if (tomlStr(val, "dialect")) |d| d else null;
+ if (dialect) |d| {
+ if (std.mem.eql(u8, style_str, "openai_responses") and std.mem.eql(u8, d, "codex")) {
+ style = .openai_codex_responses;
+ } else {
+ return error.InvalidProvider;
+ }
+ }
const base_url = (val.get("base_url") orelse return error.InvalidProvider).asString() orelse
return error.InvalidProvider;
@@ -699,6 +721,7 @@ fn resolveProvider(
return .{
.name = try allocator.dupe(u8, name),
.style = style,
+ .dialect = if (dialect) |d| try allocator.dupe(u8, d) else null,
.base_url = try allocator.dupe(u8, base_url),
.auth_name = try allocator.dupe(u8, auth_name),
.prompt_cache = prompt_cache,
@@ -1231,6 +1254,50 @@ test "resolve: provider extra_headers flow into the built provider config" {
try testing.expectEqualStrings("tok", pc.openai_chat.api_key);
}
+test "resolve: openai_responses codex dialect maps to internal API style" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ const src =
+ \\[providers.codex]
+ \\style = "openai_responses"
+ \\dialect = "codex"
+ \\base_url = "https://chatgpt.com/backend-api"
+ \\auth = "k"
+ \\
+ \\[auth.k]
+ \\key = "tok"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+
+ try testing.expectEqual(APIStyle.openai_codex_responses, cfg.provider("codex").?.style);
+ var defs = models_toml.ModelRegistry.init(a);
+ defer defs.deinit();
+ const pc = try buildProviderConfig(&cfg, &defs, .{ .provider = "codex", .model = "gpt-5.5" });
+ try testing.expectEqual(APIStyle.openai_codex_responses, pc.style());
+}
+
+test "resolve: internal openai_codex_responses style is not user-facing" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ const src =
+ \\[providers.codex]
+ \\style = "openai_codex_responses"
+ \\base_url = "https://chatgpt.com/backend-api"
+ \\auth = "k"
+ \\
+ \\[auth.k]
+ \\key = "tok"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+ try testing.expectError(error.InvalidProvider, resolve(a, &env, doc.root));
+}
+
test "resolve: prompt_cache defaults true and is parsed when set" {
const a = testing.allocator;
var env = emptyEnv(a);
diff --git a/src/debug_log.zig b/src/debug_log.zig
new file mode 100644
index 0000000..0adbe63
--- /dev/null
+++ b/src/debug_log.zig
@@ -0,0 +1,220 @@
+//! Debug-build log redirection.
+//!
+//! Debug builds emit a *lot* of `std.log.debug` traffic — most usefully the
+//! raw JSON of every provider API request and response. Writing that to stderr
+//! makes the interactive TUI unusable (every line scribbles over the frame).
+//!
+//! This module installs a custom `std.Options.logFn` (wired up via
+//! `std_options` in `main.zig`). In **Debug** builds it routes the entire log
+//! stream to a per-session file under
+//!
+//! ($PANTO_HOME | $XDG_DATA_HOME/panto | ~/.local/share/panto)/debug/<session-id>.log
+//!
+//! and writes *nothing* to the terminal, leaving the TUI pristine. In any
+//! other build mode it delegates to `std.log.defaultLog` (stderr, level-gated)
+//! so release behavior is unchanged.
+//!
+//! The file is opened with `O_TRUNC`, so each session starts fresh; the file
+//! holds exactly one session's logs and never grows across runs. (A *new*
+//! session id per invocation already gives a fresh file; truncation guards the
+//! resume case where the same id is reused.)
+//!
+//! The log function may be called from any thread (the `Io.Threaded` worker
+//! pool as well as the main loop), so all writes are serialized behind a
+//! mutex. Writes go straight to the fd via `std.c.write` — no `Io` handle is
+//! needed (and none is available at a `logFn` call site), matching the raw-fd
+//! approach already used by `tui_terminal.zig`.
+
+const std = @import("std");
+const builtin = @import("builtin");
+const Io = std.Io;
+const Allocator = std.mem.Allocator;
+
+const panto_home = @import("panto_home.zig");
+
+/// True only in Debug builds; gates the file-redirect path at comptime.
+const enabled = builtin.mode == .Debug;
+
+/// A tiny atomic spinlock serializing writes. `logFn` can be called from any
+/// thread but has no `Io` handle, so `Io.Mutex` (which needs one) is out;
+/// contention is rare and each critical section is a single bounded write.
+var log_lock = std.atomic.Value(bool).init(false);
+
+fn lockLog() void {
+ while (log_lock.cmpxchgWeak(false, true, .acquire, .monotonic) != null) {
+ std.atomic.spinLoopHint();
+ }
+}
+
+fn unlockLog() void {
+ log_lock.store(false, .release);
+}
+/// The open log file descriptor, or -1 before `init` (or if init failed).
+var log_fd: std.c.fd_t = -1;
+/// Monotonic per-line counter. Wall-clock time needs an `Io` handle (not
+/// available at a `logFn` call site), so lines are stamped with a sequence
+/// number instead — enough to order events within the session.
+var seq: u64 = 0;
+/// Buffer backing the per-call `Writer`. Guarded by `log_mutex`.
+var writer_buf: [16 * 1024]u8 = undefined;
+
+/// Open the per-session debug log file and arm `logFn` to write to it. No-op
+/// in non-Debug builds. Best-effort: any failure leaves `log_fd == -1`, and
+/// `logFn` silently drops messages (debug logging must never break startup).
+///
+/// Safe to call once, after the session id is known. `environ_map` is used to
+/// resolve `$PANTO_HOME`; `io` is used only to create the `debug/` directory.
+pub fn init(
+ allocator: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ session_id: []const u8,
+) void {
+ if (!enabled) return;
+
+ var layout = panto_home.resolve(allocator, environ_map) catch return;
+ defer layout.deinit();
+
+ const debug_dir = std.fs.path.join(allocator, &.{ layout.home, "debug" }) catch return;
+ defer allocator.free(debug_dir);
+
+ Io.Dir.cwd().createDirPath(io, debug_dir) catch |err| switch (err) {
+ error.PathAlreadyExists => {},
+ else => return,
+ };
+
+ const file_name = std.fmt.allocPrint(allocator, "{s}.log", .{session_id}) catch return;
+ defer allocator.free(file_name);
+ const path = std.fs.path.joinZ(allocator, &.{ debug_dir, file_name }) catch return;
+ defer allocator.free(path);
+
+ const flags = std.c.O{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true };
+ const fd = std.c.open(path.ptr, flags, @as(std.c.mode_t, 0o600));
+ if (fd < 0) return;
+
+ lockLog();
+ defer unlockLog();
+ log_fd = fd;
+ seq = 0;
+}
+
+/// Custom `std.Options.logFn`. Debug builds capture everything to the session
+/// file (terminal stays clean); other builds use the stderr default.
+pub fn logFn(
+ comptime level: std.log.Level,
+ comptime scope: @EnumLiteral(),
+ comptime format: []const u8,
+ args: anytype,
+) void {
+ if (!enabled) {
+ std.log.defaultLog(level, scope, format, args);
+ return;
+ }
+
+ lockLog();
+ defer unlockLog();
+
+ const fd = log_fd;
+ // Before the file is open (early bootstrap), drop the message rather than
+ // letting it reach the terminal — keeping the TUI pristine is the point.
+ if (fd < 0) return;
+
+ var fw: FdWriter = .{
+ .fd = fd,
+ .interface = .{ .vtable = &fd_vtable, .buffer = &writer_buf },
+ };
+ const w = &fw.interface;
+
+ seq += 1;
+ w.print("#{d:>6} {s}", .{ seq, level.asText() }) catch {};
+ if (scope != .default) w.print("({t})", .{scope}) catch {};
+ w.writeAll(": ") catch {};
+ w.print(format, args) catch {};
+ w.writeAll("\n") catch {};
+ w.flush() catch {};
+}
+
+/// A minimal `std.Io.Writer` whose sink is a raw file descriptor. Writes are
+/// best-effort (logging must never fault): short writes are looped, `EINTR` is
+/// retried, and any other error silently drops the remaining bytes.
+const FdWriter = struct {
+ fd: std.c.fd_t,
+ interface: std.Io.Writer,
+};
+
+const fd_vtable: std.Io.Writer.VTable = .{ .drain = drainFd };
+
+fn drainFd(w: *std.Io.Writer, data: []const []const u8, splat: usize) std.Io.Writer.Error!usize {
+ const self: *FdWriter = @alignCast(@fieldParentPtr("interface", w));
+ const fd = self.fd;
+
+ // 1. Flush any buffered bytes first (they were logically written already).
+ if (w.end != 0) {
+ writeAllFd(fd, w.buffer[0..w.end]);
+ w.end = 0;
+ }
+
+ // 2. Write each data slice in order; the last is repeated `splat` times.
+ if (data.len == 0) return 0;
+ var consumed: usize = 0;
+ for (data[0 .. data.len - 1]) |slice| {
+ writeAllFd(fd, slice);
+ consumed += slice.len;
+ }
+ const last = data[data.len - 1];
+ var i: usize = 0;
+ while (i < splat) : (i += 1) {
+ writeAllFd(fd, last);
+ consumed += last.len;
+ }
+ return consumed;
+}
+
+fn writeAllFd(fd: std.c.fd_t, bytes: []const u8) void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = std.c.write(fd, bytes.ptr + off, bytes.len - off);
+ if (n < 0) {
+ if (std.posix.errno(n) == .INTR) continue;
+ return; // best-effort: drop the rest
+ }
+ if (n == 0) return;
+ off += @intCast(n);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const testing = std.testing;
+
+test "FdWriter: buffered + splat writes reach the fd intact" {
+ const path = "/tmp/panto_debug_log_drain_test.txt";
+ const wr_flags = std.c.O{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true };
+ const fd = std.c.open(path, wr_flags, @as(std.c.mode_t, 0o600));
+ try testing.expect(fd >= 0);
+
+ {
+ // A deliberately tiny buffer forces several drains mid-message,
+ // exercising the buffer-flush path and the splat repetition.
+ var buf: [8]u8 = undefined;
+ var fw: FdWriter = .{
+ .fd = fd,
+ .interface = .{ .vtable = &fd_vtable, .buffer = &buf },
+ };
+ const w = &fw.interface;
+ try w.print("hello {d} world {s}", .{ 42, "xyz" });
+ try w.splatBytesAll("ab", 3); // splat: "ababab"
+ try w.flush();
+ }
+ _ = std.c.close(fd);
+
+ const rfd = std.c.open(path, std.c.O{ .ACCMODE = .RDONLY }, @as(std.c.mode_t, 0));
+ try testing.expect(rfd >= 0);
+ defer _ = std.c.close(rfd);
+ var rbuf: [128]u8 = undefined;
+ const n = std.c.read(rfd, &rbuf, rbuf.len);
+ try testing.expect(n > 0);
+ try testing.expectEqualStrings("hello 42 world xyzababab", rbuf[0..@intCast(n)]);
+}
diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig
index 2574fae..35247bb 100644
--- a/src/lua_runtime.zig
+++ b/src/lua_runtime.zig
@@ -87,6 +87,15 @@ pub const LuaRuntime = struct {
/// calls to drive libuv to completion. `0` until
/// `installScheduler` runs.
uv_run_ref: c_int = 0,
+ /// Registry ref to `require("luv").update_time`. libuv caches the
+ /// loop's notion of "now" and only refreshes it while `uv.run`
+ /// executes; the loop is idle between batches (and during a slow
+ /// model's streaming), so its clock goes stale. The scheduler calls
+ /// this at the start of each batch so handlers that arm timers
+ /// (e.g. the shell tool's timeout) compute deadlines against the
+ /// real current time, not a frozen one. `0` until
+ /// `installScheduler` runs.
+ uv_update_time_ref: c_int = 0,
/// Pointer to the in-flight batch, valid only for the duration of
/// one `invoke_batch` call. The `panto._record_result` C function
@@ -220,6 +229,7 @@ pub const LuaRuntime = struct {
try installRecordResult(self);
try installWrapperClosure(self);
try cacheUvRun(self);
+ try cacheUvUpdateTime(self);
}
/// Tear down the runtime: free every owned string, unref every
@@ -244,6 +254,9 @@ pub const LuaRuntime = struct {
if (self.uv_run_ref != 0) {
c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.uv_run_ref);
}
+ if (self.uv_update_time_ref != 0) {
+ c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.uv_update_time_ref);
+ }
// Free the UI event bridge (Lua handler/component refs + caches)
// BEFORE closing the state, since it unrefs registry entries.
@@ -724,6 +737,12 @@ fn runBatch(
return;
}
+ // Refresh libuv's cached loop clock before any handler arms a timer.
+ // The loop sits idle between batches (and while a slow model streams),
+ // so its notion of "now" is stale; a timeout armed against it would
+ // otherwise fire the instant `uv.run` refreshes time below.
+ refreshLoopClock(self);
+
var slots = try allocator.alloc(Slot, calls.len);
defer allocator.free(slots);
for (slots) |*s| s.* = .{};
@@ -1156,6 +1175,45 @@ fn cacheUvRun(self: *LuaRuntime) !void {
self.uv_run_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX);
}
+/// Cache `require("luv").update_time` so the scheduler can refresh the
+/// loop clock at the start of each batch. See `uv_update_time_ref`.
+fn cacheUvUpdateTime(self: *LuaRuntime) !void {
+ const L = self.L;
+ const snippet =
+ \\local uv = require("luv")
+ \\return uv.update_time
+ ;
+ if (c.luaL_loadstring(L, snippet) != 0) {
+ logTopAsError(L, "panto-lua: failed to compile luv.update_time lookup");
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ return RuntimeError.LuaInitFailed;
+ }
+ if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) {
+ logTopAsError(L, "panto-lua: require('luv') failed (was the bootstrap successful?)");
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ return RuntimeError.LuaInitFailed;
+ }
+ if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) {
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ return RuntimeError.LuaInitFailed;
+ }
+ self.uv_update_time_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX);
+}
+
+/// Refresh libuv's cached loop clock (`uv.update_time()`). Best-effort:
+/// a failure here only means timeout deadlines may be computed against a
+/// slightly stale clock, so we log and continue rather than failing the
+/// batch.
+fn refreshLoopClock(self: *LuaRuntime) void {
+ if (self.uv_update_time_ref == 0) return;
+ const L = self.L;
+ _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(self.uv_update_time_ref));
+ if (c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) {
+ logTopAsError(L, "panto-lua: uv.update_time failed");
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ }
+}
+
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
@@ -1778,6 +1836,88 @@ test "scheduler: two real std.shell calls in one batch do not deadlock" {
try testing.expect(std.mem.indexOf(u8, okText(results[1]), "second-done") != null);
}
+test "scheduler: shell timeout is measured from dispatch, not a stale loop clock" {
+ // Regression: libuv caches the loop's "now" and only refreshes it while
+ // `uv.run` runs. The loop is idle between batches, so a tool that arms a
+ // timeout timer against the stale clock would have it fire the instant
+ // `uv.run` refreshes time. We force staleness with `uv.sleep` (a blocking
+ // sleep that does NOT run the loop), then dispatch a 1s-timeout shell call
+ // and assert it does not spuriously time out.
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+ const luarocks_rt = bootstrapRealRuntime(rt) catch |e| return e;
+ defer luarocks_rt.deinit();
+
+ const tools_dir = try findAgentToolsDir();
+ defer testing.allocator.free(tools_dir);
+ const shell_path = try std.fs.path.join(testing.allocator, &.{ tools_dir, "shell.lua" });
+ defer testing.allocator.free(shell_path);
+ try rt.loadTool(shell_path, tools_dir);
+
+ var src = rt.toolSource();
+
+ // Prime the loop clock with one run.
+ {
+ const warm = [_]panto.ToolCall{.{ .tool_name = "std.shell", .input = "{\"command\":\"true\"}" }};
+ var wres: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
+ try src.vtable.invoke_batch(src.ctx, &warm, &wres, testing.allocator);
+ freeResults(&wres);
+ }
+
+ // Advance real time ~1.2s WITHOUT running the loop, so its cached clock
+ // goes stale relative to the wall clock by more than the timeout below.
+ {
+ const snippet = "require('luv').sleep(1200)";
+ if (c.luaL_loadstring(rt.L, snippet) != 0) return error.SleepSnippetFailed;
+ if (c.lua_pcallk(rt.L, 0, 0, 0, 0, null) != 0) return error.SleepSnippetFailed;
+ }
+
+ const calls = [_]panto.ToolCall{
+ .{ .tool_name = "std.shell", .input = "{\"command\":\"echo prompt-done\",\"timeout\":1}" },
+ };
+ var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
+ try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
+ defer freeResults(&results);
+
+ try testing.expect(std.mem.indexOf(u8, okText(results[0]), "prompt-done") != null);
+ try testing.expect(std.mem.indexOf(u8, okText(results[0]), "timed out") == null);
+}
+
+test "scheduler: three real std.shell calls with explicit timeout all succeed" {
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+ const luarocks_rt = bootstrapRealRuntime(rt) catch |e| return e;
+ defer luarocks_rt.deinit();
+
+ const tools_dir = try findAgentToolsDir();
+ defer testing.allocator.free(tools_dir);
+ const shell_path = try std.fs.path.join(testing.allocator, &.{ tools_dir, "shell.lua" });
+ defer testing.allocator.free(shell_path);
+ try rt.loadTool(shell_path, tools_dir);
+
+ var src = rt.toolSource();
+ const calls = [_]panto.ToolCall{
+ .{ .tool_name = "std.shell", .input = "{\"command\":\"echo alpha-done\",\"timeout\":30}" },
+ .{ .tool_name = "std.shell", .input = "{\"command\":\"echo bravo-done\",\"timeout\":30}" },
+ .{ .tool_name = "std.shell", .input = "{\"command\":\"echo charlie-done\",\"timeout\":30}" },
+ };
+ var results: [3]panto.ToolCallResult = .{
+ .{ .err = error.SourceDroppedCall },
+ .{ .err = error.SourceDroppedCall },
+ .{ .err = error.SourceDroppedCall },
+ };
+ try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
+ defer freeResults(&results);
+
+ try testing.expect(std.mem.indexOf(u8, okText(results[0]), "alpha-done") != null);
+ try testing.expect(std.mem.indexOf(u8, okText(results[1]), "bravo-done") != null);
+ try testing.expect(std.mem.indexOf(u8, okText(results[2]), "charlie-done") != null);
+ // None should have hit the timeout path.
+ for (results) |r| {
+ try testing.expect(std.mem.indexOf(u8, okText(r), "timed out") == null);
+ }
+}
+
// Reproduction: two REAL `std.read` calls in one batch. read.lua uses
// only synchronous `uv.fs_*` calls and never yields, so both should
// complete during dispatch without entering the drive loop.
diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig
index f76a60e..7f2e161 100644
--- a/src/luarocks_runtime.zig
+++ b/src/luarocks_runtime.zig
@@ -29,6 +29,7 @@
//! `<panto-binary> lua` (with the absolute path of the running `panto`
//! binary) into the luarocks config as `variables.LUA`.
+const builtin = @import("builtin");
const std = @import("std");
const Allocator = std.mem.Allocator;
const Io = std.Io;
@@ -70,6 +71,7 @@ pub const BootstrapError = error{
LuarocksInjectionFailed,
LuarocksInstallFailed,
LuarocksSearcherInstallFailed,
+ PantoModuleSigningFailed,
PathConfigFailed,
PantoExecutablePathUnknown,
} || Allocator.Error;
@@ -229,6 +231,98 @@ fn stagePantoModule(
var dir = try Io.Dir.cwd().openDir(io, layout.lib_lua_dir, .{});
defer dir.close(io);
try writeIfDifferent(allocator, io, dir, "panto.so", embedded_panto_so.bytes);
+ try ensurePantoModuleSignature(allocator, layout);
+}
+
+/// Ad-hoc re-sign the staged `panto.so` so macOS will `dlopen` it.
+///
+/// We deliberately spawn `codesign` via `posix_spawn` rather than
+/// `std.process.run`. `std.process.run` (via `Io.Threaded`) implements
+/// spawning as a raw `fork()` + `execve()`. On macOS a `fork()` from a
+/// process that has *any* live secondary thread runs the registered
+/// `pthread_atfork` child handlers in the forked child; system-framework
+/// handlers can dereference per-thread state that no longer exists in the
+/// child and fault (`EXC_BAD_ACCESS`). In a normal `panto` run bootstrap
+/// is still single-threaded here so the fork is safe, but under the
+/// multithreaded Zig test runner (whose pool already has worker threads,
+/// and which installs a debug `SIGSEGV` handler that turns that fault into
+/// an `abort()`) the spawned child dies with `SIGABRT` before `codesign`
+/// ever execs. `posix_spawn` is a single Darwin primitive that does *not*
+/// run userspace `pthread_atfork` handlers — Apple's recommended way to
+/// spawn from a multithreaded process — so it sidesteps the hazard
+/// entirely while behaving identically to the caller. The child's stdio is
+/// redirected to /dev/null (see below); failures surface via the exit
+/// status, which we translate into `error.PantoModuleSigningFailed`.
+fn ensurePantoModuleSignature(
+ allocator: Allocator,
+ layout: panto_home.Layout,
+) !void {
+ if (builtin.os.tag != .macos) return;
+
+ const module_path = try std.fs.path.joinZ(allocator, &.{ layout.lib_lua_dir, "panto.so" });
+ defer allocator.free(module_path);
+
+ const argv = [_:null]?[*:0]const u8{
+ "/usr/bin/codesign",
+ "--force",
+ "--sign",
+ "-",
+ module_path.ptr,
+ null,
+ };
+
+ // Redirect the child's stdio to /dev/null: `codesign` is silent on
+ // success and writes progress/errors to stderr, which we don't want
+ // leaking into panto's own output (and, under the test runner, racing
+ // with the build-server IPC). Failures are surfaced via the exit
+ // status and our own `std.log.err` below.
+ var actions: std.c.posix_spawn_file_actions_t = undefined;
+ if (std.c.posix_spawn_file_actions_init(&actions) != 0) {
+ return error.PantoModuleSigningFailed;
+ }
+ defer _ = std.c.posix_spawn_file_actions_destroy(&actions);
+ const rdonly: c_int = @bitCast(std.c.O{ .ACCMODE = .RDONLY });
+ const wronly: c_int = @bitCast(std.c.O{ .ACCMODE = .WRONLY });
+ _ = std.c.posix_spawn_file_actions_addopen(&actions, std.c.STDIN_FILENO, "/dev/null", rdonly, 0);
+ _ = std.c.posix_spawn_file_actions_addopen(&actions, std.c.STDOUT_FILENO, "/dev/null", wronly, 0);
+ _ = std.c.posix_spawn_file_actions_addopen(&actions, std.c.STDERR_FILENO, "/dev/null", wronly, 0);
+
+ var pid: std.c.pid_t = undefined;
+ const spawn_rc = std.c.posix_spawn(
+ &pid,
+ "/usr/bin/codesign",
+ &actions,
+ null,
+ &argv,
+ @ptrCast(std.c.environ),
+ );
+ if (spawn_rc != 0) {
+ std.log.err(
+ "failed to spawn codesign for staged panto module at {s}: posix_spawn errno={d}",
+ .{ module_path, spawn_rc },
+ );
+ return error.PantoModuleSigningFailed;
+ }
+
+ const status = blk: while (true) {
+ var status: c_int = undefined;
+ const rc = std.c.waitpid(pid, &status, 0);
+ if (rc == -1) {
+ const err = std.posix.errno(rc);
+ if (err == .INTR) continue;
+ std.log.err("failed to wait for codesign: errno={t}", .{err});
+ return error.PantoModuleSigningFailed;
+ }
+ break :blk @as(u32, @bitCast(status));
+ };
+
+ if (std.c.W.IFEXITED(status) and std.c.W.EXITSTATUS(status) == 0) return;
+
+ std.log.err(
+ "codesign failed for staged panto module at {s}: status=0x{x}",
+ .{ module_path, status },
+ );
+ return error.PantoModuleSigningFailed;
}
// ---------------------------------------------------------------------------
@@ -338,14 +432,13 @@ const default_base_config =
\\# exchange_expires_path = "expires_at"
\\# exchange_base_url_path = "endpoints.api"
\\
- \\# OpenAI Codex via a ChatGPT subscription (device login). Codex speaks the
- \\# OpenAI Responses API, not Chat Completions, so style = "openai_responses".
- \\# NOTE: this path is implemented but not yet verified against a live
- \\# ChatGPT plan — see docs/oauth-provider-auth.md.
+ \\# OpenAI Codex via a ChatGPT subscription (device login). Codex uses the
+ \\# Responses protocol with a stricter ChatGPT-subscription dialect.
\\#
\\# [providers.codex]
\\# style = "openai_responses"
- \\# base_url = "https://chatgpt.com/backend-api/codex"
+ \\# dialect = "codex"
+ \\# base_url = "https://chatgpt.com/backend-api"
\\# auth = "openai_codex"
\\#
\\# [providers.codex.extra_headers]
diff --git a/src/main.zig b/src/main.zig
index 58502ad..8ac5e8b 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -16,6 +16,12 @@ const glob = @import("glob.zig");
const system_prompt = @import("system_prompt.zig");
const command = @import("command.zig");
const command_compaction = @import("compaction.zig");
+const debug_log = @import("debug_log.zig");
+
+/// Route the process-wide log stream. In Debug builds this redirects every
+/// `std.log` call to a per-session file (keeping the TUI clean); other builds
+/// keep the stderr default. See `debug_log.zig`.
+pub const std_options: std.Options = .{ .logFn = debug_log.logFn };
// TUI foundation layer (Phase 1, sub-phase 1). Not yet wired into the REPL;
// referenced here so `zig build test` type-checks and exercises them.
@@ -60,6 +66,7 @@ test {
_ = system_prompt;
_ = command;
_ = command_compaction;
+ _ = debug_log;
_ = tui_terminal;
_ = tui_key;
_ = tui_input;
@@ -255,7 +262,9 @@ pub fn main(init: std.process.Init) !void {
// per-cwd `session_dir` (the CLI owns the cwd→dir grouping; the store
// is cwd-agnostic). Must outlive the agent, which appends through a
// `Session` handle minted/resolved below.
- const session_metadata = try std.fmt.allocPrint(alloc, "{{\"cwd\":{s}}}", .{try std.json.Stringify.valueAlloc(alloc, cwd, .{})});
+ const cwd_json = try std.json.Stringify.valueAlloc(alloc, cwd, .{});
+ defer alloc.free(cwd_json);
+ const session_metadata = try std.fmt.allocPrint(alloc, "{{\"cwd\":{s}}}", .{cwd_json});
defer alloc.free(session_metadata);
var session_store_impl = try panto.FileSystemJSONLStore.initWithMetadata(alloc, io, session_dir, session_metadata);
defer session_store_impl.deinit();
@@ -277,6 +286,12 @@ pub fn main(init: std.process.Init) !void {
// `session.info` is adopted by the agent below (`Agent.init` can't fail)
// and freed in the agent's `deinit`; no separate cleanup here.
+ // Arm the per-session debug log now that we know the session id. In Debug
+ // builds this opens `$PANTO_HOME/debug/<id>.log` and redirects all
+ // `std.log` output there; no-op in release. Best-effort — failures leave
+ // logging disabled rather than aborting startup.
+ debug_log.init(alloc, io, init.environ_map, session.info.id);
+
const is_resume = cli_flags.resume_kind != .none and session.info.message_count > 0;
// On resume, reconstruct the conversation from the store. (The
diff --git a/src/models_toml.zig b/src/models_toml.zig
index 955640d..622bdf6 100644
--- a/src/models_toml.zig
+++ b/src/models_toml.zig
@@ -21,7 +21,8 @@
//!
//! # anthropic_messages-only knobs:
//! api_version = <string> # Anthropic-Version header; default "2023-06-01"
-//! thinking = <string> # disabled | enabled | adaptive (default: disabled)
+//! thinking = <string> # disabled | enabled | adaptive (default: disabled,
+//! # or adaptive when `effort` is present)
//! effort = <string> # low | medium | high | xhigh | max (default: medium)
//! # only used when thinking = "adaptive"
//! thinking_budget_tokens = <int> # max reasoning tokens for thinking = "enabled"
@@ -268,13 +269,15 @@ fn ingestModel(
break :blk null;
};
+ const has_effort = v.get("effort") != null;
+
const thinking: Thinking = blk: {
if (v.get("thinking")) |t| {
if (t.asString()) |s| {
break :blk std.meta.stringToEnum(Thinking, s) orelse .disabled;
}
}
- break :blk .disabled;
+ break :blk if (has_effort) .adaptive else .disabled;
};
const effort: Effort = blk: {
@@ -527,6 +530,39 @@ test "parseInto: anthropic adaptive thinking with effort" {
try testing.expectEqual(false, def.thinking_interleaved); // default
}
+test "parseInto: anthropic effort implies adaptive thinking" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
+
+ const src =
+ \\[anthropic.opus]
+ \\model = "claude-opus-4-8"
+ \\effort = "high"
+ ;
+ try parseInto(&models, src);
+
+ const def = models.defs.get("anthropic", "opus").?;
+ try testing.expectEqual(Thinking.adaptive, def.thinking);
+ try testing.expectEqual(Effort.high, def.effort);
+}
+
+test "parseInto: explicit thinking overrides effort-implied adaptive thinking" {
+ var models = emptyModels(testing.allocator);
+ defer models.deinit();
+
+ const src =
+ \\[anthropic.haiku]
+ \\model = "claude-haiku-4-5-20251001"
+ \\thinking = "disabled"
+ \\effort = "high"
+ ;
+ try parseInto(&models, src);
+
+ const def = models.defs.get("anthropic", "haiku").?;
+ try testing.expectEqual(Thinking.disabled, def.thinking);
+ try testing.expectEqual(Effort.high, def.effort);
+}
+
test "parseInto: anthropic thinking disabled" {
var models = emptyModels(testing.allocator);
defer models.deinit();
diff --git a/src/tui_app.zig b/src/tui_app.zig
index 1182777..69eb8fa 100644
--- a/src/tui_app.zig
+++ b/src/tui_app.zig
@@ -1402,16 +1402,13 @@ pub fn runLoop(app: *App, term: *Terminal, opts: RunOptions) !void {
// that here. Otherwise seed the plain model label.
if (app.selectors == null) try app.footer.setModel(opts.model_label);
- // Session-start welcome banner as the first transcript entry. cwd is read
- // from the process; the model label comes from the run options. (Version
- // is not threaded through the run options yet; the banner omits it.)
+ // Session-start welcome banner as the first transcript entry.
{
const welcome = try app.spawnWelcome(.{
.version = opts.version,
.cwd = opts.cwd,
.model = opts.model_label,
});
- try welcome.setModel(opts.model_label);
if (opts.cwd.len != 0) try welcome.setCwd(opts.cwd);
if (opts.version.len != 0) try welcome.setVersion(opts.version);
}
@@ -2371,7 +2368,7 @@ test "spawnWelcome shows a session-start banner entry" {
defer h.teardown(alloc);
const w = try h.app.spawnWelcome(.{});
- try w.setModel("m");
+ _ = w;
try testing.expectEqual(@as(usize, 1), h.app.transcript.items.len);
try testing.expect(h.app.transcript.items[0].kind == .welcome);
}
diff --git a/src/tui_components.zig b/src/tui_components.zig
index e64b5bd..14c61d9 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -983,9 +983,10 @@ pub const InputBox = struct {
// Locate the cursor's (row, byte-col-in-row).
const focused = self.focusable.focused;
- // Walk lines, tracking byte offset so we know which row holds cursor.
- // `cursor_row` records which produced row carries the cursor block, so
- // the scroll-window below can keep it visible.
+ // Walk logical lines, but render HARD-WRAPPED visual rows. Wrapping is
+ // purely visual: no '\n' bytes are inserted into the editor buffer.
+ // `cursor_row` records which produced visual row carries the cursor
+ // block, so the scroll-window below can keep it visible.
var line_byte_start: usize = 0;
var produced_any = false;
var cursor_row: usize = 0;
@@ -994,22 +995,19 @@ pub const InputBox = struct {
const line_start = line_byte_start;
const line_end = line_start + line.len;
const cursor_in_line = focused and self.cursor >= line_start and self.cursor <= line_end and
- // The cursor belongs to the FIRST line whose range contains it
- // (at a '\n' boundary it stays on the line before the break,
- // i.e. == line_end). Disambiguate the boundary: if cursor ==
- // line_end and there are more lines, it belongs to the NEXT
- // line's start unless this is the last line.
+ // At an explicit '\n' boundary, the cursor belongs to the next
+ // logical line's start unless this is the final line.
(self.cursor < line_end or it.peek() == null);
- if (cursor_in_line) cursor_row = rows.items.len;
- const row = try self.renderRow(line, if (cursor_in_line) self.cursor - line_start else null, cursor_style, width, focused);
- try rows.append(a, row);
- produced_any = true;
+ const first_row = rows.items.len;
+ try self.appendWrappedRows(line, line_start, cursor_in_line, cursor_style, width, focused, &rows, &cursor_row);
+ produced_any = produced_any or rows.items.len > first_row;
line_byte_start = line_end + 1; // skip the '\n'
}
if (!produced_any) {
// Empty buffer: a single (possibly cursor-bearing) row.
const row = try self.renderRow("", if (focused) @as(?usize, 0) else null, cursor_style, width, focused);
+ if (focused) cursor_row = 0;
try rows.append(a, row);
}
@@ -1036,6 +1034,80 @@ pub const InputBox = struct {
return cacheLines(&self.cache);
}
+ /// Append the hard-wrapped visual rows for one logical line. The slices are
+ /// rendered immediately into owned row bytes; the editor buffer itself is
+ /// unchanged. Cursor placement follows terminal wrapping semantics: a
+ /// cursor exactly at a wrap boundary appears at column 0 of the next visual
+ /// row, not at the end of the previous one.
+ fn appendWrappedRows(
+ self: *InputBox,
+ line: []const u8,
+ line_start: usize,
+ cursor_in_line: bool,
+ cursor_style: Style,
+ width: usize,
+ focused: bool,
+ rows: *std.ArrayList([]const u8),
+ cursor_row: *usize,
+ ) !void {
+ const a = self.alloc;
+
+ if (width == 0) {
+ const row = try self.renderRow("", if (cursor_in_line) @as(?usize, 0) else null, cursor_style, width, focused);
+ if (cursor_in_line) cursor_row.* = rows.items.len;
+ try rows.append(a, row);
+ return;
+ }
+
+ if (line.len == 0) {
+ const row = try self.renderRow("", if (cursor_in_line) @as(?usize, 0) else null, cursor_style, width, focused);
+ if (cursor_in_line) cursor_row.* = rows.items.len;
+ try rows.append(a, row);
+ return;
+ }
+
+ var i: usize = 0;
+ while (i < line.len) {
+ const chunk_start = i;
+ var cols: usize = 0;
+ while (i < line.len) {
+ const seq_len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1;
+ const adv = @min(seq_len, line.len - i);
+ const cp = std.unicode.utf8Decode(line[i .. i + adv]) catch '?';
+ const w = codepointWidth(cp);
+ if (cols > 0 and cols + w > width) break;
+ i += adv;
+ cols += w;
+ if (cols >= width) break;
+ }
+ if (i == chunk_start) i = self.nextBoundary(line_start + i) - line_start; // defensive progress
+
+ const chunk = line[chunk_start..i];
+ const abs_start = line_start + chunk_start;
+ const abs_end = line_start + i;
+ var cursor_col: ?usize = null;
+ if (cursor_in_line) {
+ if (self.cursor >= abs_start and self.cursor < abs_end) {
+ cursor_col = self.cursor - abs_start;
+ } else if (i == line.len and self.cursor == abs_end) {
+ cursor_col = chunk.len;
+ }
+ }
+ if (cursor_col != null) cursor_row.* = rows.items.len;
+ const row = try self.renderRow(chunk, cursor_col, cursor_style, width, focused);
+ try rows.append(a, row);
+ }
+
+ // If the cursor is at the end of a line that exactly fills the last
+ // visual row, show the cursor on the following wrapped row instead of
+ // replacing the last visible character with the block.
+ if (cursor_in_line and self.cursor == line_start + line.len and displayWidth(line) % width == 0) {
+ const row = try self.renderRow("", @as(?usize, 0), cursor_style, width, focused);
+ cursor_row.* = rows.items.len;
+ try rows.append(a, row);
+ }
+ }
+
/// Build a dim, full-width horizontal rule (box-drawing `─`). Caller owns
/// the returned slice.
fn horizontalRule(self: *InputBox, width: usize) ![]u8 {
@@ -1079,6 +1151,7 @@ pub const InputBox = struct {
/// cursor (or a space at end-of-line) and emits CURSOR_MARKER there.
fn renderRow(self: *InputBox, line: []const u8, cursor_col: ?usize, cursor_style: Style, width: usize, focused: bool) ![]u8 {
const a = self.alloc;
+ if (width == 0) return a.dupe(u8, "");
// The cursor block consumes one visible column, so usable text width
// is width-1 when the cursor sits at/after the truncated end and we
// must show the block. To keep it simple and always-safe: truncate the
@@ -1632,19 +1705,18 @@ pub const Selector = struct {
};
// ===========================================================================
-// Welcome — session-start banner (plan §6: "version, cwd, model info")
+// Welcome — session-start banner
// ===========================================================================
/// A static banner shown as the first transcript entry at session start.
-/// Structured data in (version / cwd / model label via setters), lines out.
-/// Re-rendered only when one of the fields changes (markDirty), which in
-/// practice is once during bring-up.
+/// Structured data in (version / cwd via setters), lines out. Re-rendered
+/// only when one of the visible fields changes (markDirty), which in practice
+/// is once during bring-up.
pub const Welcome = struct {
alloc: std.mem.Allocator,
cache: RenderCache,
version: std.ArrayList(u8) = .empty,
cwd: std.ArrayList(u8) = .empty,
- model: std.ArrayList(u8) = .empty,
pub fn init(alloc: std.mem.Allocator) Welcome {
return .{ .alloc = alloc, .cache = RenderCache.init(alloc) };
@@ -1653,7 +1725,6 @@ pub const Welcome = struct {
pub fn deinit(self: *Welcome) void {
self.version.deinit(self.alloc);
self.cwd.deinit(self.alloc);
- self.model.deinit(self.alloc);
self.cache.deinit();
}
@@ -1673,11 +1744,6 @@ pub const Welcome = struct {
try self.setField(&self.cwd, value);
}
- /// Set the model label shown in the banner.
- pub fn setModel(self: *Welcome, value: []const u8) !void {
- try self.setField(&self.model, value);
- }
-
fn renderImpl(ptr: *anyopaque, width: usize, alloc: std.mem.Allocator) anyerror![]const []const u8 {
_ = alloc;
const self: *Welcome = @ptrCast(@alignCast(ptr));
@@ -1707,19 +1773,13 @@ pub const Welcome = struct {
try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ accent.open(), vis, accent.close() }));
}
- // Detail lines (dim, with leading space): cwd and model, only when set.
+ // Detail lines (dim, with leading space): cwd only when set.
if (self.cwd.items.len != 0) {
const txt = try std.fmt.allocPrint(a, " cwd: {s}", .{self.cwd.items});
defer a.free(txt);
const vis = truncateToCols(txt, width);
try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }));
}
- if (self.model.items.len != 0) {
- const txt = try std.fmt.allocPrint(a, " model: {s}", .{self.model.items});
- defer a.free(txt);
- const vis = truncateToCols(txt, width);
- try lines.append(a, try std.fmt.allocPrint(a, "{s}{s}{s}", .{ dim.open(), vis, dim.close() }));
- }
_ = plain_bg;
// Blank margin below.
@@ -2366,7 +2426,35 @@ test "InputBox: cursor block fits within width at end of a full line" {
for ("abcde") |c| try ib.applyKey(charKey(c, &[_]u8{c}));
// width 5, cursor at end: the block must not overflow.
const lines = try ib.comp().render(5, testing.allocator);
- try testing.expect(vw(lines[1]) <= 5);
+ for (lines) |ln| try testing.expect(vw(ln) <= 5);
+}
+
+test "InputBox: long logical line hard-wraps visually without inserting newlines" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.setFocused(true);
+ try typeStr(&ib, "abcdef");
+ const lines = try ib.comp().render(5, testing.allocator);
+ // Two content rows ("abcde" and "f"+cursor), plus top/bottom rules.
+ try testing.expectEqual(@as(usize, 4), lines.len);
+ try testing.expectEqualStrings("abcdef", ib.text.items);
+ try testing.expect(std.mem.indexOf(u8, lines[1], "abcde") != null);
+ try testing.expect(std.mem.indexOf(u8, lines[2], "f") != null);
+ try testing.expect(std.mem.indexOf(u8, lines[2], CURSOR_MARKER) != null);
+ for (lines) |ln| try testing.expect(vw(ln) <= 5);
+}
+
+test "InputBox: line cap applies to visual wrapped rows" {
+ var ib = InputBox.init(testing.allocator);
+ defer ib.deinit();
+ ib.line_cap = 2;
+ try typeStr(&ib, "abcde");
+ const lines = try ib.comp().render(2, testing.allocator);
+ // Three wrapped content rows are capped to the tail two, plus rules.
+ try testing.expectEqual(@as(usize, 4), lines.len);
+ try testing.expect(std.mem.indexOf(u8, lines[1], "cd") != null);
+ try testing.expect(std.mem.indexOf(u8, lines[2], "e") != null);
+ for (lines) |ln| try testing.expect(vw(ln) <= 2);
}
fn ctrlKey(letter: u8) Key {
@@ -2873,22 +2961,20 @@ test "components drive the real engine without a TTY" {
// -- Welcome / Thinking / CompactionSummary / ToolUse (P2) ------------------
-test "Welcome: renders title + cwd + model, all within width" {
+test "Welcome: renders title + cwd, all within width" {
var w = Welcome.init(testing.allocator);
defer w.deinit();
try w.setVersion("0.1.0");
try w.setCwd("/tmp/project");
- try w.setModel("anthropic:claude");
const lines = try w.comp().render(40, testing.allocator);
- // 3 content lines + 2 margin = 5.
- try testing.expectEqual(@as(usize, 5), lines.len);
+ // 2 content lines + 2 margin = 4.
+ try testing.expectEqual(@as(usize, 4), lines.len);
for (lines) |l| try testing.expect(vw(l) <= 40);
try testing.expect(std.mem.indexOf(u8, lines[1], "panto v0.1.0") != null);
try testing.expect(std.mem.indexOf(u8, lines[2], "/tmp/project") != null);
- try testing.expect(std.mem.indexOf(u8, lines[3], "anthropic:claude") != null);
}
-test "Welcome: title only when cwd/model unset" {
+test "Welcome: title only when cwd unset" {
var w = Welcome.init(testing.allocator);
defer w.deinit();
const lines = try w.comp().render(20, testing.allocator);
@@ -2902,11 +2988,10 @@ test "Welcome: honors the width contract at a tiny width" {
defer w.deinit();
try w.setVersion("0.1.0");
try w.setCwd("/a/very/long/working/directory/path/that/overflows");
- try w.setModel("anthropic:claude-some-very-long-model-id");
- // Width 6: every banner row (title + cwd + model) must truncate to fit.
- // 3 content lines + 2 margin = 5.
+ // Width 6: every banner row (title + cwd) must truncate to fit.
+ // 2 content lines + 2 margin = 4.
const lines = try w.comp().render(6, testing.allocator);
- try testing.expectEqual(@as(usize, 5), lines.len);
+ try testing.expectEqual(@as(usize, 4), lines.len);
for (lines) |l| try testing.expect(vw(l) <= 6);
}
diff --git a/src/tui_selectors.zig b/src/tui_selectors.zig
index 590d321..e76f82d 100644
--- a/src/tui_selectors.zig
+++ b/src/tui_selectors.zig
@@ -89,7 +89,7 @@ pub const ReasoningOption = struct {
pub fn forStyle(style: APIStyle) []const ReasoningOption {
return switch (style) {
// The Responses style takes the same reasoning-effort options.
- .openai_chat, .openai_responses => &openai,
+ .openai_chat, .openai_responses, .openai_codex_responses => &openai,
.anthropic_messages => &anthropic,
};
}
@@ -100,6 +100,7 @@ pub const ReasoningOption = struct {
.openai_chat => |r| switch (cfg.*) {
.openai_chat => |*c| c.reasoning = r,
.openai_responses => |*c| c.reasoning = r,
+ .openai_codex_responses => |*c| c.reasoning = r,
.anthropic_messages => {},
},
.anthropic_messages => |sel| switch (cfg.*) {
@@ -110,7 +111,7 @@ pub const ReasoningOption = struct {
c.effort = e;
},
},
- .openai_chat, .openai_responses => {},
+ .openai_chat, .openai_responses, .openai_codex_responses => {},
},
}
}
@@ -120,7 +121,8 @@ pub const ReasoningOption = struct {
pub fn matches(self: ReasoningOption, cfg: ProviderConfig) bool {
switch (self.value) {
.openai_chat => |r| return (cfg == .openai_chat and cfg.openai_chat.reasoning == r) or
- (cfg == .openai_responses and cfg.openai_responses.reasoning == r),
+ (cfg == .openai_responses and cfg.openai_responses.reasoning == r) or
+ (cfg == .openai_codex_responses and cfg.openai_codex_responses.reasoning == r),
.anthropic_messages => |sel| {
if (cfg != .anthropic_messages) return false;
const c = cfg.anthropic_messages;
@@ -173,7 +175,7 @@ pub fn adaptiveFallback(cfg: *ProviderConfig) bool {
c.thinking_budget_tokens = budget;
return true;
},
- .openai_chat, .openai_responses => return false,
+ .openai_chat, .openai_responses, .openai_codex_responses => return false,
}
}