const std = @import("std"); const panto = @import("panto"); const lua_bridge = @import("lua_bridge.zig"); const lua_runtime = @import("lua_runtime.zig"); const lua_event_bridge = @import("lua_event_bridge.zig"); const extension_loader = @import("extension_loader.zig"); const panto_home = @import("panto_home.zig"); const luarocks_runtime = @import("luarocks_runtime.zig"); const self_exe = @import("self_exe.zig"); const subcommand = @import("subcommand.zig"); const session_paths = @import("session_paths.zig"); const models_toml = @import("models_toml.zig"); const config_file = @import("config_file.zig"); const glob = @import("glob.zig"); const system_prompt = @import("system_prompt.zig"); const command = @import("command.zig"); const command_compaction = @import("compaction.zig"); // 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. const tui_terminal = @import("tui_terminal.zig"); const tui_key = @import("tui_key.zig"); const tui_input = @import("tui_input.zig"); const tui_theme = @import("tui_theme.zig"); const tui_component = @import("tui_component.zig"); const tui_engine = @import("tui_engine.zig"); const tui_components = @import("tui_components.zig"); const tui_event = @import("tui_event.zig"); const tui_app = @import("tui_app.zig"); const tui_selectors = @import("tui_selectors.zig"); // Shorthand alias for the Lua C API. The bridge module owns the actual // `@cImport`; we re-use it here so the smoke check uses identical types. const lua = lua_bridge.c; test { // Test contract: deliberate error-path tests should not produce visible // log output. Code logs at `.err` for genuine production failures and // `.warn` for expected-failure paths exercised by tests; the test // runner's logger gates on `std.testing.log_level`, which defaults to // `.warn`. Raising it to `.err` silences expected warnings without // changing production behavior. Anything that *should* be visible in a // passing test must use `std.debug.print` or assert via the testing API. std.testing.log_level = .err; std.testing.refAllDecls(@This()); _ = lua_bridge; _ = lua_runtime; _ = lua_event_bridge; _ = extension_loader; _ = panto_home; _ = luarocks_runtime; _ = self_exe; _ = subcommand; _ = models_toml; _ = config_file; _ = glob; _ = system_prompt; _ = command; _ = command_compaction; _ = tui_terminal; _ = tui_key; _ = tui_input; _ = tui_theme; _ = tui_component; _ = tui_engine; _ = tui_components; _ = tui_event; _ = tui_app; _ = tui_selectors; } /// Spin up a Lua interpreter, run a no-op, tear it down. Catches /// link/compile errors on the Lua dependency at the earliest moment. /// Logs only in debug builds; release builds run silently. fn luaSmokeCheck() void { const L = lua.luaL_newstate() orelse { std.log.err("lua: luaL_newstate returned null", .{}); return; }; defer lua.lua_close(L); lua.luaL_openlibs(L); // Push _VERSION to confirm libs loaded. _ = lua.lua_getglobal(L, "_VERSION"); const ver = lua.lua_tolstring(L, -1, null); if (ver != null) { std.log.debug("lua: {s} linked OK", .{ver}); } lua.lua_settop(L, 0); } /// Print a friendly message for a config.toml load failure before the /// process exits non-zero. fn reportConfigError(err: anyerror) void { switch (err) { error.InvalidConfigToml => std.debug.print( "error: a config.toml failed to parse (see log above).\n", .{}, ), error.InvalidProvider => std.debug.print( "error: a [providers.*] entry is malformed (need style + base_url).\n", .{}, ), error.InvalidModelRef => std.debug.print( "error: defaults.model must look like \"provider:model\".\n", .{}, ), error.UnknownDefaultProvider => std.debug.print( "error: defaults.model names a provider that isn't configured (or whose API key env var isn't set).\n", .{}, ), error.PolicyConflict => std.debug.print( "error: a tool/extension pattern appears in both allow and deny.\n", .{}, ), error.NoHomeDirectory => std.debug.print( "error: cannot resolve config paths — set HOME or XDG_CONFIG_HOME/XDG_DATA_HOME.\n", .{}, ), else => std.debug.print("error: failed to load config: {s}\n", .{@errorName(err)}), } } /// Print a friendly message for a model-selection failure. fn reportModelError(err: anyerror, cfg: *const config_file.Config) void { switch (err) { error.NoModelSelected => { std.debug.print( "error: no model selected. Set `defaults.model = \"provider:alias\"` in config.toml.\n", .{}, ); if (cfg.providers.len == 0) { std.debug.print( " (no providers resolved — check that an API key or its env var is present.)\n", .{}, ); } }, error.UnknownProvider => std.debug.print( "error: the selected model names an unconfigured provider.\n", .{}, ), else => std.debug.print("error: model selection failed: {s}\n", .{@errorName(err)}), } } pub fn main(init: std.process.Init) !void { const alloc = init.gpa; const io = init.io; // Smoke test: prove Lua is linked. Slice 1 — no extension runtime // wired up yet, this just confirms the static lib comes through. luaSmokeCheck(); // Resolve the absolute path of the running panto binary. Needed // both by `panto lua` (we re-exec ourselves through a wrapper // luarocks invokes) and by the agent's bootstrap. const panto_path = try self_exe.selfExePathAlloc(alloc); defer alloc.free(panto_path); // Subcommand dispatch: `panto lua` and `panto bootstrap` short // out of the agent loop, but still run the same luarocks bootstrap // pipeline so first-run setup happens consistently. switch (try subcommand.dispatch( alloc, io, init.environ_map, init.minimal.args, panto_path, )) { .done => return, .agent => {}, } // Parse the agent-mode flags. Currently only `--resume []`. const cli_flags = try parseAgentFlags(alloc, init.minimal.args); defer cli_flags.deinit(alloc); var stdout_buffer: [4096]u8 = undefined; var stdout_file = std.Io.File.stdout().writer(io, &stdout_buffer); const stdout = &stdout_file.interface; // The TUI reads raw bytes from the tty fd directly (raw mode); we only // need the stdin file handle, not a buffered reader. const stdin_handle = std.Io.File.stdin().handle; // Resolve where this project's sessions live. var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; const cwd_n = try std.process.currentPath(io, &cwd_buf); const cwd = cwd_buf[0..cwd_n]; const session_dir = try session_paths.sessionDirForCwd(alloc, init.environ_map, cwd); defer alloc.free(session_dir); // Load the merged config.toml (base → user → project). Providers, // default model, and tool/extension policy all come from here. var app_config = config_file.load(alloc, io, init.environ_map, cwd) catch |err| { reportConfigError(err); std.process.exit(1); }; defer app_config.deinit(); // Load models.toml — model aliases (wire name + knobs) and pricing. // A missing file is fine (empty registries); cost lookups return // null, formatted as "unknown" by the display layer. const models_toml_path = try models_toml.configPath(alloc, init.environ_map); defer alloc.free(models_toml_path); var models = try models_toml.loadFromPath(alloc, io, models_toml_path); defer models.deinit(); std.log.debug( "models.toml: {d} model def(s), {d} price entr(ies) from {s}", .{ models.defs.count(), models.pricing.count(), models_toml_path }, ); // `models.pricing` is parsed and ready; the print-based CLI doesn't // surface per-turn cost yet (that lands with the TUI frontend). // Choose the active model and assemble the provider config. const model_ref = app_config.selectModel(&models.defs, null) catch |err| { reportModelError(err, &app_config); std.process.exit(1); }; const provider_config = config_file.buildProviderConfig(&app_config, &models.defs, model_ref) catch |err| { reportModelError(err, &app_config); std.process.exit(1); }; // Resolve the optional compaction-model override into a ProviderConfig. // On any resolution failure we log and fall back to no override (the // active chat model is used for compaction). const compaction_provider_config: ?panto.ProviderConfig = blk: { const ref = app_config.compaction_model_ref orelse break :blk null; break :blk config_file.buildProviderConfig(&app_config, &models.defs, ref) catch |err| { std.log.warn("compaction_model resolution failed ({t}); using active model", .{err}); break :blk null; }; }; const compaction_cfg: panto.CompactionConfig = .{ .keep_verbatim = app_config.compaction_keep_verbatim orelse 20_000, .model = compaction_provider_config, }; // Process-global HTTP client: one connection pool for every provider / // base_url the agent may switch to. Torn down at shutdown. panto.init(alloc, io); defer panto.deinit(); const banner_model_initial: []const u8 = switch (provider_config) { inline else => |c| c.model, }; const banner_provider_initial: []const u8 = model_ref.provider; // The session store: a directory-backed JSONL catalog rooted at the // 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, .{})}); defer alloc.free(session_metadata); var session_store_impl = try panto.FileSystemJSONLStore.initWithMetadata(alloc, io, session_dir, session_metadata); defer session_store_impl.deinit(); const session_store = session_store_impl.store(); // Create or resume the session. Resume failures (missing/ambiguous id) // are user errors — print a tidy message and exit 1 rather than // printing a Zig stack trace. var session = openSession( session_store, cli_flags, session_dir, stdout, &stdout_file, ) catch |err| switch (err) { error.SessionNotFound, error.AmbiguousSessionId => std.process.exit(1), else => return err, }; // `session.info` is adopted by the agent below (`Agent.init` can't fail) // and freed in the agent's `deinit`; no separate cleanup here. const is_resume = cli_flags.resume_kind != .none and session.info.message_count > 0; // On resume, reconstruct the conversation from the store. (The // dangling-prompt recovery feature was dropped in R2.) On a fresh // session, the agent starts with an empty conversation. var adopted_conversation: ?panto.Conversation = null; if (is_resume) { adopted_conversation = try session.load(); const sid = session.info.id; try stdout.print( "resumed session {s} ({d} messages)\n", .{ sid[0..@min(8, sid.len)], session.info.message_count }, ); } // Assemble the active configuration snapshot and build the agent now, // before luarocks bootstrap and extension loading. The agent adopts the // session handle (for free persistence) and the resumed conversation // (or starts fresh). The agent owns its own tool registry (post-R1); // extensions register their tool source onto it *after* the agent is // built but before the first turn, so in-place registration is visible. // System-prompt seeding/reconciliation below runs *through* the agent // so those entries persist. var active_config: panto.Config = .{ .provider = provider_config, .compaction = compaction_cfg, }; const agent = try panto.Agent.init( alloc, io, &active_config, session, adopted_conversation, ); defer agent.deinit(); // Spin up the long-lived Lua runtime. All Lua extensions load into // one `lua_State`; module-global state survives across calls. The // runtime registers with the agent as a single `ToolSource` named // `panto-lua`. var rt = try lua_runtime.LuaRuntime.create(alloc); defer rt.deinit(); // Bootstrap luarocks against the Lua runtime's lua_State — same // pipeline as `panto lua` and `panto bootstrap`. After this, // `require("luarocks.*")` works and any pinned batteries from the // manifest are installed under $PANTO_HOME. const luarocks_rt = try luarocks_runtime.bootstrap( alloc, io, init.environ_map, rt.L, panto_path, ); defer luarocks_rt.deinit(); // Source the system prompt now that the base agent tree has been // staged to `$PANTO_HOME/agent` (the bootstrap above writes the // bundled `SYSTEM.md` there). The prompt is sourced by convention // from SYSTEM.md / APPEND_SYSTEM.md across the base/user/project // layers; the base layer is `luarocks_rt.layout.agent_dir`. var sp_arena = std.heap.ArenaAllocator.init(alloc); defer sp_arena.deinit(); if (is_resume) { // SYSTEM.md / APPEND_SYSTEM.md may have changed since this log was // created; reconcile without rewriting history. try system_prompt.reconcileResume( sp_arena.allocator(), io, init.environ_map, luarocks_rt.layout.agent_dir, agent, ); } else { // Fresh session — source and install the system prompt. try system_prompt.seedFresh( sp_arena.allocator(), io, init.environ_map, luarocks_rt.layout.agent_dir, agent, ); } // Bootstrap staged `panto.so` onto the embedded VM's cpath and // configured `package.path`/`cpath`; now wire `require('panto')` to // the native module + the CLI's `ext` subtable. Must run before // `installScheduler` (which writes onto the module table) and before // any extension loads (which `require('panto')`). try rt.installPantoModule(); // luv is installed (or already present) at this point; wire the // libuv-driven coroutine scheduler before any extensions get a // chance to register tools that might want to yield. try rt.installScheduler(); // Discover Lua extensions across three layers — base // ($PANTO_HOME/agent), user ($XDG_CONFIG_HOME/panto or // $HOME/.config/panto), and project (./.panto). Project shadows // user shadows base; tool-name collisions across surviving // entries abort startup. const n_ext_tools = extension_loader.discoverAndLoad( alloc, io, init.environ_map, luarocks_rt.layout.agent_dir, rt, .{ .extensions = &app_config.extensions, .tools = &app_config.tools, }, ) catch |err| { std.log.err("extension discovery failed: {t}", .{err}); return err; }; std.log.debug("extensions: {d} tool(s) registered", .{n_ext_tools}); if (n_ext_tools > 0) { try agent.registerToolSource(rt.toolSource()); } // Resolve the compaction system prompt (COMPACTION.md across layers, // last wins; built-in default otherwise) and arm automatic compaction. // Owned by `sp_arena`, which lives for the whole REPL. const compaction_prompt = try system_prompt.resolveCompaction( sp_arena.allocator(), io, init.environ_map, luarocks_rt.layout.agent_dir, ); // The compaction prompt is resolved after extension load; set it on the // config the agent re-reads each turn (visible before the first turn). active_config.compaction.compaction_prompt = compaction_prompt; // Build the slash-command registry and register builtins, then append // any commands declared by Lua extensions (harvested at load time). var cmd_registry = command.Registry.init(alloc); defer cmd_registry.deinit(); try command_compaction.register(&cmd_registry); // Append slash commands declared by Lua extensions via // `panto.ext.register_command`. A name collision with a builtin (or // between two extensions) surfaces as `error.DuplicateCommand` and // aborts startup, matching the tool-name collision policy. for (rt.commandList()) |lua_cmd| { cmd_registry.registerLua( lua_cmd.name, lua_cmd.description, lua_cmd.handler_ref, ) catch |err| { std.log.err( "lua: failed to register command '/{s}': {t}", .{ lua_cmd.name, err }, ); return err; }; } // Command output is captured into an in-memory buffer (the command // Context's `stdout`) and flushed into the TUI transcript after each // dispatch — TUI-safe: handlers never write raw bytes mid-frame. This is // the documented minimal choice for slash-command output under the TUI; a // richer routing (per-command transcript components) can refine it later. var cmd_capture = std.Io.Writer.Allocating.init(alloc); defer cmd_capture.deinit(); var cmd_ctx: command.Context = .{ .allocator = alloc, .agent = agent, .stdout = &cmd_capture.writer, .stdout_file = &stdout_file, .compaction_prompt = compaction_prompt, .provider_name = banner_provider_initial, .model_name = banner_model_initial, .lua_rt = rt, }; // -- TUI bring-up ------------------------------------------------------ // // Full replacement of the print REPL (version control is the safety net). // The terminal enters raw mode + bracketed paste; the engine drives a // differential render of the transcript + pinned input box + footer; the // app loop pumps libpanto's pull Stream into component state. The terminal // is restored on every exit path (defer) and on crash (signal handlers // installed by `enableRawMode` + the panic-restore hook). var term = tui_terminal.Terminal.init(stdin_handle, init.environ_map.*) catch |err| switch (err) { tui_terminal.Error.NotATty => { std.debug.print( "error: panto's TUI requires an interactive terminal (stdin/stdout must be a tty).\n", .{}, ); std.process.exit(1); }, else => return err, }; try term.enableRawMode(); defer term.deinit(); const size = term.refreshSize(); var tui_writer_buf: [16 * 1024]u8 = undefined; var tui_file = std.Io.File.stdout().writer(io, &tui_writer_buf); var engine = tui_engine.Engine.init( alloc, &tui_file.interface, size.cols, size.rows, term.caps.synchronized_output, ); defer engine.deinit(); var input_box = tui_components.InputBox.init(alloc); defer input_box.deinit(); var footer = tui_components.Footer.init(alloc); defer footer.deinit(); var io_clock = tui_app.IoClock.init(io); var app = tui_app.App.init( alloc, &engine, io_clock.clock(), &input_box, &footer, ); defer app.deinit(); // Wire the Lua extension UI event bridge to the App's event bus: this // registers every `panto.ext.on(...)` handler (harvested at extension // load time) into the bus, in registration order, so extensions can // wrap/replace built-in components and a Lua `panto.ext.emit(...)` can // drive the same bus. With no Lua handlers this is a no-op. try rt.eventBridge().attachBus(app.eventBus()); // Install the override-release hook so a Lua-backed override that is // SUPERSEDED by a later mid-stream swap (e.g. a `tool_details` handler // replacing the `tool (?)` default's prior override) has its luaL_ref + // RenderCache freed. Without this, each swapped Lua component would leak // for the life of the runtime. The hook recognizes a bridged component by // its vtable identity and ignores native components (see // `EventBridge.releaseOverride`). // // TEARDOWN ORDERING CONTRACT (load-bearing — do not reorder these decls): // `app` is declared AFTER `rt`, so `defer app.deinit()` runs BEFORE // `defer rt.deinit()` (defers are LIFO). The bridge (owned by `rt`) // therefore outlives the App's teardown. This matters because the // release hook below points into the bridge: if the bridge were freed // first, any later hook invocation would be a use-after-free. // It is safe today because `App.deinit` frees only its own default // `kind` boxes and NEVER invokes `override_release_fn` — the surviving // (non-superseded) Lua overrides are freed by `EventBridge.deinit` when // `rt.deinit()` runs. The hook fires only during live mid-stream swaps, // while both App and bridge are alive. If you ever make `App.deinit` // call the release hook, or move `rt` to outlive `app`, revisit this. app.setOverrideRelease( @ptrCast(rt.eventBridge()), lua_event_bridge.EventBridge.releaseOverrideThunk, ); const Flusher = struct { fn flush(ctx: *anyopaque) void { const fw: *std.Io.File.Writer = @ptrCast(@alignCast(ctx)); fw.interface.flush() catch {}; } }; app.setFlusher(&tui_file, Flusher.flush); const model_label = try std.fmt.allocPrint( alloc, "{s}:{s}", .{ banner_provider_initial, banner_model_initial }, ); defer alloc.free(model_label); // Runtime model/reasoning selectors (Ctrl+M / Ctrl+R). Live-session only: // a pick rebuilds the provider config and pushes it to the agent via // `setConfig`; nothing is written back to config.toml. Borrows the // long-lived `app_config`, `models.defs`, and `active_config`. const selector_ctrl = try tui_app.SelectorController.init( alloc, &app, agent, &app_config, &models.defs, &active_config, model_label, ); defer selector_ctrl.deinit(); app.setSelectors(selector_ctrl); // The render engine writes through a buffered file writer; flush after the // loop so the final frame and teardown sequences reach the terminal. defer tui_file.interface.flush() catch {}; tui_app.runLoop(&app, &term, .{ .agent = agent, .cmd_registry = &cmd_registry, .cmd_ctx = &cmd_ctx, .cmd_capture = &cmd_capture, .model_label = model_label, .cwd = cwd, .io = io, .environ = init.environ_map, }) catch |err| switch (err) { // Clean user-initiated exit (Ctrl+C / Ctrl+D). Not an error. error.UserExit => {}, else => { // Restore the terminal before surfacing the diagnostic so the // message is readable. term.deinit(); tui_file.interface.flush() catch {}; std.debug.print("\n[fatal: {s}]\n", .{@errorName(err)}); return err; }, }; } // ----------------------------------------------------------------------------- // CLI flag parsing // ----------------------------------------------------------------------------- const AgentFlags = struct { /// `--resume` without an id: resume the most recent session. /// `--resume `: resume the session whose id has this prefix. /// Not present: start a new session. resume_kind: ResumeKind = .none, resume_id: ?[]const u8 = null, // owned pub fn deinit(self: AgentFlags, alloc: std.mem.Allocator) void { if (self.resume_id) |id| alloc.free(id); } }; const ResumeKind = enum { none, most_recent, by_id }; fn parseAgentFlags(alloc: std.mem.Allocator, args: std.process.Args) !AgentFlags { var flags: AgentFlags = .{}; errdefer flags.deinit(alloc); var it = args.iterate(); defer it.deinit(); _ = it.next(); // argv[0] while (it.next()) |a| { if (std.mem.eql(u8, a, "--resume")) { // Peek at the next arg. If it exists and doesn't start with `-`, // treat it as a session id (or prefix). const next = it.next(); if (next) |id| { if (id.len > 0 and id[0] != '-') { flags.resume_kind = .by_id; flags.resume_id = try alloc.dupe(u8, id); continue; } else { // Not an id; rewind by treating it as a separate flag. // The Args API doesn't support rewind, so handle inline. flags.resume_kind = .most_recent; if (std.mem.eql(u8, id, "--resume")) { // back-to-back --resume; second resets, fine. continue; } // Otherwise, fall through and process this token as a flag. // (Currently we don't have other flags; ignore unknowns.) std.log.warn("panto: ignoring unknown argument '{s}'", .{id}); continue; } } flags.resume_kind = .most_recent; continue; } // Future agent-mode flags would land here. Unknown args are tolerated // (the user might be passing something we don't recognize yet). } return flags; } // ----------------------------------------------------------------------------- // Session bootstrap // ----------------------------------------------------------------------------- /// Mint or resolve the `Session` to drive, against the catalog `store`. /// Fresh sessions are created on demand; resume resolves by id or picks the /// most recent. The returned `Session` owns its `info` (freed via the /// agent's `deinit`, which adopts it). fn openSession( store: panto.SessionStore, flags: AgentFlags, session_dir: []const u8, stdout: *std.Io.Writer, stdout_file: *std.Io.File.Writer, ) !panto.Session { switch (flags.resume_kind) { .none => return store.create(), .most_recent => { if (try store.latest()) |sess| return sess; try stdout.print("no sessions to resume; starting fresh.\n", .{}); try stdout_file.flush(); return store.create(); }, .by_id => { const id = flags.resume_id.?; if (try store.resolve(id)) |sess| return sess; try stdout.print("error: no session matching '{s}' in {s}\n", .{ id, session_dir }); try stdout_file.flush(); return error.SessionNotFound; }, } }