- `available_providers` holds the static enum tag names; drop the dupe
loop, its errdefer and the per-string frees.
- `reconcileModel` returns `error.ModelNotAvailable` directly instead of
a `use`/`abort` union the caller only mapped to that same error.
- `runCommand`/`printCommandResult` take `Command.ToolCall`; the caller
already has it, so the unreachable "no tool mapping" branches go.
- `handleSave` reuses `rememberSavePath`, which now propagates its
allocation failure so a first save under OOM warns instead of
unwrapping a null `save_path`.
- `SlashCommand.all_names` is a comptime `++` of the three name lists.
- `buildUserMessageParts` reads the attachment once for both text and
image, and prepends the text part instead of copying the list.
- `printSeverity`/`formatBulletLine` use `allocPrint` and the shared
`emitStderr`; drop an unreachable `ends_ws` check in `renderMetaHint`.
Every data command went through printAssistant, so /markdown was styled by
accident while /tree, /getUrl and /evaluate strings had `*`/`_` eaten as
emphasis, and /markdown lost table alignment past Stream's 16 KiB buffer.
- printData renders only markdown as markdown; JSON is re-indented and the
rest prints verbatim.
- render iterates the source directly and aligns tables from the slice;
Fence holds the fence state shared with Stream.
- Links are OSC 8 hyperlinks around the label only, with the label
inline-rendered; `` drops the `!`; escapes follow CommonMark
so what the page dumper escapes is unescaped.
Agent held the same Browser/Session/Notification/registry fields and
teardown order as mcp/Server.zig, plus its own "enableConsoleCapture
after every newSession" for /reset. ToolSession.restartSession() owns
that now; init is written in terms of it.
Claude-Session: https://claude.ai/code/session_01M6WGk8wZSE28efFQkYT9SK
The renderer knows that layout reflows to the width and that height 0
means the whole content, so the bounding rule moves next to measure()
and only measures when the strip isn't already fixed: the default
viewport shot no longer pays a second layout pass. The limits are
constants spliced into the tool description, not a caller-tunable that
nothing tuned. expireImages runs inside prune so there is one end-of-
turn hook, and re-homes a stripped result instead of casting away const.
An inline screenshot is re-sent on every request for as long as it sits
in history, and a full-page render could reach 1920x16384. Inline images
are now rendered at most 1280 wide and 4096 tall (measured after the
reflow, so short pages aren't padded), files keep full size; the
conversation keeps only the newest two images, older tool results keep
their text with a note; the MCP transport releases its buffer after a
large response.
The slash path's result has two consumers: the terminal, which can't
show an image, and the conversation, which can. Opt in when a model is
attached and forward the image through the same adapter the model-driven
path uses, and stop mapping a failed adapter to a text-only success.
MCP's ImageContent and CallToolResult take their payload type like
TextContent does; resolveScope reuses resolveTarget; needsLocator folds
into replayRequires.
zenai tool results now carry image parts (lightpanda-io/zenai#12, #13),
so the model-driven tool path opts into inline images: a screenshot
without `path` reaches the model as text plus the PNG, on every backend.
The slash-command path still needs `path`, its result goes to the
terminal.
Callers pass CallOpts.inline_image; execScreenshot rejects a path-less
call before navigating or rendering, which removes the per-consumer
guards and covers the model-driven tool path that had none. MCP image
content is a protocol type, the screenshot recording rule joins the
recorder's replayRequires predicate, and the viewport-to-Opts mapping,
node-scope ladder and save-path helpers are shared instead of copied.
The PNG renderer was reachable from CDP and fetch --dump png only. The
tool renders the page or one node; with `path` it writes the file and
returns its location (agent, PandaScript, MCP), without it MCP returns
the image inline as base64 content. ToolResult carries the prepared
image so the transport streams it; the agent and script runtime reject
the inline form since their tool results are text. An inline screenshot
is not recorded, as it has no replayable form.
Terminal.zig references md_term and prompt_assist from a test block, but
nothing referenced Terminal, so those 30-odd tests never ran. Adding it
to Agent.zig's discovery block surfaced one rotted test sink in
js_highlight.zig still using ArrayList.writer(), ported to
Io.Writer.Allocating.
`engineKey` (on `lp.environ().getPosix`) replaces the env-var wrapper and
the three hand-spelled set/keyless/missing decisions in the cascade, the
explicit path, and the REPL query. `.auto`'s "always a rung" invariant is
now a comptime assert instead of an unreachable sentinel. HTTP retries are
disabled in `apiSearch` for every path — the cascade or the model is the
retry, and the tool's `timeout` argument bounds one attempt — rather than
threaded through as a parameter the table no longer set. The cascade
sentence is one shared constant for the tool description and the REPL
help, and search gets its own default timeout instead of borrowing the
navigation one.
The DuckDuckGo HTML endpoint the search tool fell back to is disallowed by
its robots.txt, so it is removed: the `duckduckgo` engine, the scrape via
`performGoto`, and the tool's browser-side dependency (`execSearch` no
longer needs a session). In its place `.auto` is a single walk over the
API engines in table order — each when its key is set — with Keenable
last, keyed if `KEENABLE_API_KEY` is set and its public endpoint
otherwise. Search therefore always has a rung with zero configuration,
and a set key is never shadowed by a keyless retry.
Every search now goes through zenai's `std.http.Client`, which had no
timeout at all; a stalled response would have blocked the MCP server's
shared browser thread for every session. zenai now bounds each attempt
(lightpanda-io/zenai#10, pinned here), and the tool's `timeout` argument
drives it, defaulting to 10 s. Retries are a property of the call path:
off in the cascade, where the next rung is the retry, and the engine
default for an explicit engine.
Cleanups from the #3252 review: `keyless` is derived from the client's
`api_key` type instead of a hand-set table flag; `engineIndex` no longer
depends on enum declaration order; one `searchKeyStatus` query replaces
three pub helpers for the REPL; the engine order and env-var list in the
tool description and `/searchEngine` help are generated from the table;
an empty result title renders as its URL instead of `****`.
Adds keenable to the search tool's engines (KEENABLE_API_KEY, tried
after brave/tavily/exa in .auto). Unlike the other engines it also
answers without any key: its client routes an empty key to the public
endpoint (rate-limited per client IP), so .auto now tries that as the
last rung before the DuckDuckGo scrape, and an explicit
/searchEngine keenable works keyless instead of erroring. Engine table
entries gain init_options so the Keenable client can carry the
lightpanda attribution header.
Needs the zenai Keenable search client (lightpanda-io/zenai PR #9); the
zon pin points at that branch until it lands.
Some models (gpt-5.x via codex) zero-fill optional tool params instead
of omitting them, so every tree/markdown/html call carried
backendNodeId: 0, failed with NodeNotFound, and got retried blind --
one 'go to hacker news' burned 19 tool calls and 18 full page loads.
Registry ids start at 1, so 0 can never name a real node. Make
0-means-omitted part of the tool contract: state it in the schema
descriptions and normalize it in parseValue. Required ids
(nodeDetails) are untouched.
In-band guidance alone was not enough -- the model re-emitted the
identical call even when the error spelled out the fix -- but keep it
for genuinely stale ids: NodeNotFound and FrameNotLoaded now carry an
actionable message in the agent, slash-command, and MCP paths instead
of a bare error name.
Replace the Anthropic Claude-Code-import flow with OpenAI Codex (ChatGPT
subscription) OAuth: device-code login, a real token store (auth.json,
0600) with refresh-grant, and JWT ChatGPT-Account-Id extraction. Codex is
now a distinct zenai provider, so the bearer-mode credential collapses to
provider==.codex; account_id threads to the client via InitOptions.
Unit-tested (build green): JWT account-id extraction, token store
round-trip, OAuth request-body encoding, token-response parsing. The
device-login / refresh network path is unverified pending a subscription.
Pins zenai to the codex branch (6967324).
Add Brave as a second API-backed web search engine: the search tool's
auto mode tries Brave then Tavily (keyed on BRAVE_API_KEY/TAVILY_API_KEY)
before the DuckDuckGo scrape. A new /searchEngine REPL metacommand pins
the engine explicitly (auto|tavily|brave|duckduckgo) — an explicit
engine surfaces its failures instead of silently degrading — and is
persisted to .lp-agent.zon like the other REPL settings.
Bumps zenai to pick up the zenai.search.brave client.
Built against https://github.com/lightpanda-io/zig-v8-fork/tree/zig-0.16 but
it doesn't require a new v8 build.
Built against https://github.com/lightpanda-io/boringssl-zig/tree/zig-0.16
since the current fork we point to isn't updated.
A global std.Io instance, lp.io. Way easier this way and requires 0 changes to
our libcurl integration / event loop.
Network code uses a new layer that does what Zig 0.15's posix package used to
do. Again, quicker migration that way. But, as long as we have the global IO,
and given the half-baked nature of networking in std.Io 0.16, this just makes
sense. Things can be migrated as needed.
The std.time.* -> std.Io.Timestamp/Clock/Duration resulted in _a lot_ of
changes. ArrayList = .{} -> ArrayList -> .empty also resulted in a lot of
changes, but that's obviously superficial. As is the trimLeft/trimRight ->
trimStart/trimEnd rename.
Locking adopt the `Uncancelable` variants, e.g. mutex.lockUncancelable() to
preserve the error-free signature (and, because cancellation would be something
we'd have to put more thought into).
std.json.ObjectMap is now unmanaged, so the allocator had to be passed along.
However, there's still a deprecated managed variant of MemoryPool, so I switched
to it (we can do a small follow up PR to move to the unmanaged after).
I tried use_llvm = false, but it locks my computer, consuming RAM until MacOS
gives me a popup I've never seen before, begging me to start killing processes.
Agent and the networking stuff saw the most significant changes.
- attachCompleter now takes the CompletionSource: Agent no longer
reaches through terminal.assist to configure what it just attached,
and attach/configure can't be sequenced wrong.
- history_paths flows through one channel: attach() loads the initial
history from the same State field modeCallback swaps from, instead of
setupRepl receiving a second copy that had to agree.
- all_names + closestCommand move to SlashCommand.zig, the command
registry - prompt_assist consumes it like everyone else and
Terminal's closestCommand re-export disappears.
- picker treats a tty that refuses raw mode as NotInteractive and
degrades to the line prompt instead of leaking termios errnos to
callers that can only blanket-catch.
- kitty keyboard flags become named ansi.zig constants shared by
Terminal.readLine and picker's RawTerminal.
- skipWhitespace was std.mem.indexOfNonePos; single-use style consts
inline into the style table; drop the dead CompletionSource default
and doc-comment splice in Spinner.
Completion, ghost hints, and prompt highlighting (~640 lines) were
cohabiting with Terminal's readline lifecycle and output printing. They
now live in prompt_assist.zig, which owns the isocline callbacks, the
ps-* style palette, and the slash-command name table. The C callbacks
read a small prompt_assist.State (js_mode, completion_source,
history_paths) embedded in Terminal, replacing the three loose fields.
Pure code motion otherwise; the valueAt/renderSchemaHint tests move
with their code and stay in the suite via Terminal's test hook.
The numbered/interactive picker (ChoiceState, RawTerminal, render loop)
was ~215 self-contained lines inside Terminal.zig. It runs before - or
without - the isocline REPL (provider selection during setup), so
settings.zig no longer imports the isocline-configured Terminal at all.
Pure code motion; the ChoiceState tests move with it and stay in the
suite via Agent.zig's test hook.
- Add highlighting for JS functions, methods, types, and template
interpolations.
- Introduce teal color and adjust cyan to bright cyan.
- Import `ansi.zig` directly instead of through `Terminal`.
- Add PandaScript semantics note to the agent's system prompt.
- Refactor markdown table rendering and streaming.
Introduces an incremental markdown renderer (`md_term.Stream`) to
format streamed assistant responses in real time. Buffers lines,
maintains code fence state across chunks, and temporarily withholds
tables to render them with proper column alignment once complete.
The /save script documentation lived as a hand-maintained string in
Agent.zig whose primitives table drifted whenever a tool changed. Move
it to src/script/skill.zig and render the reference (signatures, option
lists, enums, defaults, per-parameter descriptions) from Schema.all()
at first use, keeping the curated per-tool notes behind an exhaustive
switch on Tool so a new or renamed tool is a compile error until its
doc entry exists.
The rendered skill is shared by three consumers:
- the /save and revision system prompts (built lazily in Agent.zig)
- a new mcp://skill/pandascript resource
- `zig build skills`, which writes zig-out/skills/<name>/SKILL.md with
Claude Code frontmatter via a registry-based generator exe, so
future Lightpanda skills are one registry entry each
Schema.FieldEntry now retains per-parameter schema descriptions, which
previously existed only in the raw JSON.
`stream_active` relied on every runTools exit path balancing pause with
`endStreamedText`. If one is ever missed, a stale-true flag skips the
spinner pause on the next turn, interleaving frames with streamed text.
Reset it alongside `streamed_text` so the invariant is local.
Streaming defaulted on for every mode, so one-shot `--task` and script
runs interleaved intermediate assistant commentary into stdout, which
wrappers treat as the answer. Gate the stream hook on `Terminal.isRepl`
so non-interactive modes keep the buffered final answer.
Streaming wrote assistant deltas straight to stdout regardless of
`suppress_answer`, so `--save`/capture turns (which set it to hide the
answer) leaked model text past the `runTurn` guard. Pass a null stream
hook when the turn's answer is suppressed.