Commit graph

99 commits

Author SHA1 Message Date
patriceckhart
5a70030bb6 tweak(tui): update banner emits the short domain install command
recommendedUpdateCommand() used to produce a raw.github URL
which is two redirects off the domain the rest of the project
hard-codes. The update-available banner in the TUI now matches
the README and website install snippets:

  curl -fsSL https://zot.patriceckhart.com/install.sh | bash
  iwr -useb https://zot.patriceckhart.com/install.ps1 | iex

Same surface (the domain 301s straight to raw.githubusercontent
via proxy.ts on the site) so users keep working even if we
later move the scripts. No functional behavior change.
2026-04-20 17:47:58 +02:00
patriceckhart
9d0b1d0882 fix(install.sh): guard empty-array expansion for bash 3.2
curl | bash on macOS runs the script under /bin/bash, which is
still 3.2 (Apple has stayed there for licensing reasons since
GPLv3). In 3.2 a bare ${CURL_AUTH[@]} on an empty array under
set -u throws "CURL_AUTH[@]: unbound variable" and aborts \u2014 so
every public-repo install on macOS died on line 129 before the
tarball could even be fetched.

Bash 4+ handles empty-array expansion fine, so this only ever
bit macOS users. The standard 3.2-compatible workaround is
${CURL_AUTH[@]+"${CURL_AUTH[@]}"}, which expands to nothing
when the array is empty and to its contents otherwise. Applied
at every call site that expanded the array unconditionally.
Added a comment at the declaration explaining why the guard is
there so the next person doesn't take it back out.

Verified against /bin/bash 3.2 on macOS: the unbound-variable
error is gone; with and without GITHUB_TOKEN the script now
proceeds to the download step.
2026-04-20 17:24:36 +02:00
patriceckhart
ad60f82390 docs(readme): point install one-liners at zot.patriceckhart.com
The website already redirects /install.sh and /install.ps1 to
the raw github files with a 301, so the short domain is the
stable public entry point for the installers. Updated the three
command snippets in the install section to match.

Nothing else moves \u2014 the rest of the github URLs in the readme
(release page, clone, go install) still use github.com directly
since those aren't proxied.
2026-04-20 17:12:26 +02:00
patriceckhart
ec3b7a7d48 fix(tui): scroll the /sessions picker when the list overflows
On a terminal too short to show every row, /sessions used to
render the whole list top-to-bottom \u2014 the cursor would move but
the overflowing rows at the bottom got clipped off the screen
and you could never reach them visually. up/down still worked
logically but the user had no way to see which row was
currently selected past the cutoff.

The dialog now keeps a viewport around the cursor. MaxRows is
set by the interactive host each frame to (terminal rows - 12)
with a min of 3, so the viewport grows with the window. The
cursor stays two rows inside the top/bottom edge of the
viewport (one row when the viewport itself is very small) so
you can see what's coming next. When content is hidden above or
below, a muted "\u2191 N more above" / "\u2193 N more below" marker
replaces the offscreen rows so you know there's more.

Keys: up / down move one row (unchanged), PgUp / PgDn jump one
page (viewport size minus 1 for overlap), Home / End go to the
first / last entry, Enter / Esc unchanged. The hint line in the
dialog header now mentions pgup/pgdn so it's discoverable.

Empty-list behaviour is unchanged; the no-sessions message
still renders as before.
2026-04-20 16:11:49 +02:00
patriceckhart
e1fdf4d42e tweak(tui): stable spinner phrase per turn
The status-bar quip used to rotate every 2.5s through
funnyWorkingLines while a turn ran. On a long response that
meant five or six different phrases during one reply, which
reads as activity that isn't actually happening (the model is
just still streaming the same answer).

Start() now picks a random index once, Message() returns that
same index until the next Start(). Next turn picks a different
phrase so the set still feels varied across a session.

Dropped the unused lastSwap timer field that drove the rotation
and its assignments in Start/StartFixed.
2026-04-20 16:00:10 +02:00
patriceckhart
8927ac15dc feat(tui): show read's line range in the tool header
read calls now render their requested line range next to the
path, so you can see at a glance what slice of the file the
model looked at.

  before:  ▸ read /Users/pat/Developer/zot/internal/tui/view.go
  after :  ▸ read /Users/pat/Developer/zot/internal/tui/view.go:723-772
           ▸ read /Users/pat/Developer/zot/internal/tui/view.go:100-
           ▸ read /Users/pat/Developer/zot/internal/tui/view.go

The ":START-END" suffix appears when the call had a limit arg;
the ":START-" (open-ended) form appears when only offset was
supplied; no suffix appears for whole-file reads (the common
case). Other tools (write, edit, bash) are unchanged - their
args don't carry a range.

Implementation:

  - shortArgs -> ShortArgs (exported), now takes the tool name
    as a first arg so it can add shape-specific decorations.
    For read, parses offset/limit from the args and appends the
    range; for everything else it falls back to the old
    path-or-command truncated-at-60 shape.
  - The truncation budget shrinks by the length of the suffix
    so absurdly long paths still leave the range visible (path
    gets the "..." in the middle, range stays intact at the
    tail).
  - toInt helper coerces float64 (json.Unmarshal's default),
    int, and numeric strings so we survive the occasional model
    that returns "100" instead of 100.
  - Dropped the duplicate unexported shortArgs in interactive.go
    (pre-dated the tui package's version). All call sites now
    go through tui.ShortArgs(name, args); the json import that
    only the local copy needed is gone too.

No format string changes elsewhere; the extension intercept
protocol, rpc wire schema, and session file format don't see
the header string.
2026-04-20 15:55:34 +02:00
patriceckhart
bb50aa3044 feat(tui): context diffs + framed tool blocks + paced streaming
Overhauls how tool calls render in the chat so the transcript
reads as a sequence of self-contained action blocks with inline
diffs instead of nested result boxes full of file contents. Plus
a few polish items around markdown rendering, code fences, and
streaming output.

Tool-call framing

  Every tool call (read, write, edit, bash) is now rendered as
  a block bracketed by full-width muted horizontal rules. Inside
  the block: the "tool name path" header, then the body (file
  content, diff, or shell output). No more nested "result"
  sub-header or rules-within-rules around the body. Works for
  both the live streaming overlay (during a turn) and the
  finalised transcript (after the turn ends).

  Duplicate suppression: while a turn is in flight, the
  transcript often already contains an assistant ToolCallBlock
  OR a tool-role ToolResultBlock for the same call the live
  overlay is still tracking. Without a skip check both copies
  render at the same time, producing visual doubling and a
  flicker. Build() now collects every finalised tool id from
  the transcript (matching on either ToolCallBlock.ID or
  ToolResultBlock.CallID) and the live overlay skips any live
  entry whose id is already in that set.

  Streaming state also clears the live toolCalls map on
  EvAssistantStart so a completed round's live entries can't
  carry over into the next turn's overlay.

Context diffs for the edit tool

  The edit tool used to emit a full-file unified diff with a
  "--- path / +++ path" header and every unchanged line prefixed
  with a space. For a small edit in a thousand-line file that's
  a transcript wall. The generator now keeps only
  diffContextLines (=3) unchanged lines on each side of every
  +/- row and collapses longer runs of unchanged content into
  a single "..." marker row. The legacy header is dropped: the
  surrounding tool-call header already shows the path, and the
  "applied N edit(s) to X" prose prefix is dropped for the same
  reason (the diff speaks for itself; the edit count lives in
  Details for json/rpc consumers).

  View-side: a new looksLikeUnifiedDiff detects the stripped
  format (rows start with +/-/space, with at least one +/-) and
  routes through a new renderUnifiedDiff helper that draws each
  row with a combined sign+number gutter ("+123", "-123",
  " 123") in the add / remove / muted colours. The "..." marker
  renders as a horizontal-ellipsis in muted type. Unchanged
  context code stays muted so the eye lands on the changes.

  renderDiffRow was rewritten to share the single gutter format
  between all three row types and to fall back to a muted code
  colour for the unchanged rows so context reads as background.

System-prompt nudge

  Added a short line to the default identity telling the model
  to prefer the edit tool for in-place mutations and the write
  tool for creating or fully replacing files, and to avoid
  using bash + redirect tricks (cat >> foo, echo >> foo, sed
  -i, tee) to mutate files. Those bash approaches render as
  opaque shell output whereas edit renders as a readable diff.

Markdown cleanup

  Code fences in assistant prose no longer get horizontal rules
  around them. Syntax highlighting + the accent colour of
  un-lang'd fences already signal "this is code"; a rule around
  a one-line rm -rf is pure noise and on ultra-wide terminals
  produces an edge-to-edge stroke that dwarfs the snippet it
  wraps.

  Partial-fence handling: if the model's output is truncated
  mid-fence (rare, but happens on aborted streams), the
  buffered content now flushes at end of input instead of
  disappearing.

Streaming-overlay guards

  - Empty streaming blocks (streamOn=true, Streaming="") no
    longer render their "zot" bar. Used to appear as a stray
    empty message bubble above the tool overlay on turns whose
    first content was a tool_use, not text.

  - The live-streaming toolCalls overlay is kept in sync with
    the transcript's finalised entries (described above) so the
    hand-off from "streaming preview" to "finalised in
    transcript" happens without a doubled frame.

renderToolCall split

  The function now has two shapes:

    - streaming (Streaming=true, no Result): render only the
      header and the live body. The live body is already framed
      by wrapLiveBody's own top+bottom rules; adding more would
      produce four-lines-per-block and a visible extra rule at
      the bottom while the user watches the tool run.

    - finished (Result present): opening rule, header, body,
      closing rule. Matches the transcript-side framing in
      renderMessage exactly.

toolBlockRule helper

  Single source for the muted horizontal separator used for
  tool blocks. Spans the full content width; clamps at a
  minimum of 8 cells so dialogs can still call Build on
  absurdly narrow widths without panicking.

refreshToolPaths unchanged

  Kept as-is; the earlier attempt to thread tool-names and raw
  args through it was reverted because the eventual renderer
  didn't need them.

Tested manually with mixed read/write/edit/bash sequences on
both api-key and oauth-subscription anthropic paths. Typewriter
streaming (from the earlier pacer patch) still works; tool
blocks render cleanly once and don't flicker during the stream.
2026-04-20 15:50:39 +02:00
patriceckhart
b29c6e7e2d chore: drop homebrew distribution
The homebrew-tap repo was never created and maintaining a
separate tap for a small tool adds release-pipeline surface
for no real benefit (install.sh and go install cover macos
already). Removed from:

- README.md install section
- .goreleaser.yaml brews block + the release header that
  advertised the brew one-liner
- .github/workflows/release.yml env export for
  HOMEBREW_TAP_TOKEN (no longer consumed)

No other surfaces referenced it. Installers (install.sh /
install.ps1) never mentioned brew.
2026-04-20 14:17:58 +02:00
patriceckhart
bc9e57e884 feat(tui): smooth typewriter streaming across providers and turns
Assistant replies now visibly type out character-by-character at
a steady pace regardless of how the underlying provider chunks
its stream. Tool-using turns render their final summary in the
right place with no "written between two tool calls" duplication
and no reflow jump when the typing finishes.

Three bugs, one behaviour fix.

1) EvAssistantStart was unhandled.

   The core emits EvAssistantStart at the top of every oneTurn
   including every follow-up after a tool round-trip. The tui
   was ignoring the event, so after the first EvAssistantMessage
   closed out the tool_use message, streamOn stayed false and
   every subsequent EvTextDelta filled the streaming buffer
   invisibly. The final summary then appeared all at once when
   EvAssistantMessage fired at the end of the follow-up turn.

   handleEvent now has a case core.EvAssistantStart that resets
   the streaming buffer and flips streamOn back on, so the
   follow-up summary streams the same way the first reply does.
   EvTextDelta also sets streamOn=true as a belt-and-suspenders
   against stray delta sequences with no preceding start.

2) Oauth/subscription streaming chunks were too large.

   Anthropics api-key channel drip-streams tokens, so a 400-char
   summary arrives as ~25 small text_delta events and looks like
   a typewriter without any extra work. The oauth channel
   (anthropic-beta: oauth-2025-04-20) coalesces the same summary
   into 3-4 fat chunks of 100+ chars each, so the user sees a
   blank pane, then the whole paragraph lands in one frame.

   Introduced a streaming pacer goroutine that uncouples "what
   the provider sent us" from "what we paint on screen". Each
   EvTextDelta now appends into i.streamPending. A ticker at
   16ms drains paintPaceRate=6 runes per tick from streamPending
   into the rendered i.streaming buffer, invalidating after
   every move. Result: ~375 runes/sec typewriter pace that looks
   identical regardless of upstream chunk shape. For long
   replies the pacer can run slightly behind the model but
   drains to zero within a second of the last delta.

   When EvAssistantMessage arrives while the pacer still has
   buffered runes, the handler sets streamFlushPending=true and
   returns without clearing. The pacer finishes draining, then
   on the next empty tick clears streamFlushPending + streaming
   + streamOn in one shot. Short turns that finish before the
   pacer does anything stay on the synchronous reset path so we
   don't wait on a ticker for zero work.

   Abort paths (turn cancel, compact done, EvTurnEnd with
   StopAborted) call a new resetStreamingStateLocked helper that
   atomically clears streaming, streamPending, streamFlushPending
   and streamOn so a fresh turn never inherits leftover runes.

3) The finalised assistant message double-painted during the
   drain window.

   When EvAssistantMessage fires, the agent appends the full
   assistant message to a.messages. The tui reads the message
   list on every redraw, so the complete text appeared in the
   transcript immediately while the pacer was still spelling it
   out below. Two copies on screen, one complete, one partial -
   the complete one was what the user actually read.

   redraw() now hides i.view.Messages[-1] while
   streamFlushPending is true, so during the drain only the
   streaming overlay is visible. When the pacer clears the flag
   the overlay disappears and the finalised message returns in
   the same frame with identical vertical footprint (both use
   the same "zot" header plus the same markdown-rendered body),
   so the swap reads as the caret landing on the last rune.

4) Live tool-call overlay carried over across turns.

   While i.busy=true the view always appended every entry from
   i.toolOrder/i.toolCalls under the streaming block. After a
   tool round-trip those entries were already folded into the
   transcript as an assistant(tool_use) message plus a tool role
   message with the result, so the next turn's summary rendered
   sandwiched between the finalised tool_use block above and the
   live tool-call block below showing the same tool. The user
   saw the summary "written between two reads".

   The EvAssistantStart handler now resets i.toolCalls and
   i.toolOrder. Any tools from the previous round are entirely
   represented in the transcript at that point; the next
   EvToolUseStart repopulates the overlay for the new round.
   No more duplicate rendering.

Misc: extracted assistantMessageSideEffects so OnAssistant +
telegram mirroring fire on message arrival regardless of which
code path (sync-reset vs pacer-drain) handles the visual
transition. Also extracted the narrow duplicate-detection guard
in redraw so follow-up turns' typewriter streaming survives the
last-message-is-assistant invariant that holds across a tool
round-trip.

Tested manually with both short ("summarize this file") and
long ("read this package to understand it") flows on the oauth
channel; both now stream visibly.
2026-04-20 13:26:26 +02:00
patriceckhart
676b5d7510 fix(tui): restore typewriter streaming for turns after tool use
The first assistant turn always streamed correctly, but any
follow-up assistant text after a tool round-trip popped in all
at once when the turn ended instead of typewriter-streaming.

Two bugs, fixed together:

1) handleEvent had no case for EvAssistantStart. The core emits
   this event at the top of every oneTurn including every
   follow-up, but the tui was ignoring it. So after the first
   EvAssistantMessage fired (for the tool_use message), streamOn
   stayed false, subsequent EvTextDelta events filled i.streaming
   but never made it on-screen because StreamingActive wasn't
   flipped back on. Added case core.EvAssistantStart that resets
   the buffer and sets streamOn=true. Belt-and-suspenders: the
   EvTextDelta case also sets streamOn=true so stray delta
   sequences without a preceding start still render.

2) The belt-and-suspenders guard in redraw() was too aggressive:
   it hid streaming whenever the last transcript message was
   assistant-role, which is always true during a follow-up turn
   (the last message is the assistant tool_use from the previous
   oneTurn). Narrowed to a strict duplicate check: only hide
   streaming when assistantText(lastMsg) == streamingBuffer,
   meaning EvAssistantMessage already promoted this exact text
   into the transcript but the next render tick hasnt flipped
   streamOn off yet. That is the only actual race this guard
   was protecting against.

Added a tiny assistantText helper (concatenate TextBlocks of a
message) to implement the dedupe check. Kept in the same file;
no new package API.
2026-04-20 12:56:31 +02:00
patriceckhart
616eed3bd6 fix(tui): persist transcript to session file after every turn
Previously the tui lazily flushed the agent messages to the
session file only at exit via WriteNewTranscript, plus opt-in via
/session export or /session tree. That meant a mid-session crash,
kill -9, or power loss dropped the entire conversation from disk
even though the summary was visible in the scrollback.

Now the turn-drain goroutine in startTurn() calls FlushSession()
right after i.agent.Prompt returns, while the turn memory is
still hot. FlushSession is the same idempotent helper used by
/session export and /session tree: it appends only the rows past
the current baseline and advances the baseline, so double writes
cant happen even if the exit-time flush also fires.

Ordering in the goroutine: lock -> clear busy/streamOn/cancel ->
read the flush callback -> unlock -> flush -> relock for the
queue-drain and auto-compact decisions. The short unlocked
window is safe because no other goroutine reads those fields at
that moment (busy is already false).

No new config hook; reuses the existing FlushSession the cli
wires in.
2026-04-20 12:11:15 +02:00
patriceckhart
1dafef8fea assets: refresh zot logo to cleaner pixel-art Z
New 1024x1024 source replaces the previous 744x744 pixel-art
Z at internal/assets/zot-logo.png. Same file is:

  - embedded into the binary via //go:embed (served at /logo.png
    by both the oauth callback and the api-key login http
    servers)
  - referenced in README.md's top <img> tag at width=130

No code or layout change. Auth pages already render it via a
CSS image-rendering: pixelated rule so the larger source
downscales cleanly.

All tests pass; go install produces a binary with the new bytes
embedded.
2026-04-20 12:01:43 +02:00
patriceckhart
c09a5c5559 chore(tui): reorder slash catalog
Group related commands side by side in the /-popup: /sessions
and /session next to each other, /compact near /btw, /jail near
/skills, etc. Pure data reshuffle, no behaviour change.
2026-04-20 11:41:45 +02:00
patriceckhart
c08057804d docs(readme): dedicated /session subsection with fork + tree
Table row already covered the four ops in a dense one-liner; added
a full "### /session" subsection next to /sessions with one
paragraph per op (export, import, fork, tree) spelling out
defaults, path-handling, and the parent/child invariants behind
the tree view.
2026-04-20 11:37:52 +02:00
patriceckhart
7794a253b9 feat(session): /session fork + /session tree
Branch semantics for conversations: rewind to a past user message
and continue from there in a new session, with a visual tree
picker to switch between branches later.

/session fork
  Opens the /jump turn picker in fork mode. Pick any past user
  message; zot copies every message from the session start up to
  and including that turn into a new session file, records the
  parent id + fork point in the new meta, and swaps the running
  agent onto the new branch. The parent session file stays on
  disk unchanged; you can return to it later via /session tree.

/session tree
  Shows every session in the current cwd arranged by parent/child
  relationships. Depth-first flatten with two-space indent per
  level; the current session is tagged "[current]". Pick any
  other entry to switch into it (same semantics as /sessions).

Why both commands:
  /sessions remains the "flat list of everything in this
  directory" resume picker. /session tree is the fork-aware
  variant. /session fork is the equivalent of git branch; /session
  tree is the equivalent of checkout.

core additions:

  SessionMeta gains two fields:
    - Parent    string  (parent session ID, empty for roots)
    - ForkPoint int     (0-indexed message position of the cut)

  core.BranchSession(parentPath, root, cwd, version, upToIdx)
    Reads the parent session, writes a new session file in
    SessionsDir(root, cwd) containing the first upToIdx message
    rows + any usage rows that came before the cut. The new meta
    records Parent=<parent id>, ForkPoint=<upToIdx>, fresh id,
    cwd, Started, Version.

  core.BuildSessionTree(root, cwd) []*TreeNode
    Walks every session file in the cwd dir, reads each one's
    meta, links children to parents by ID. Returns the forest
    rooted at parentless sessions. Missing-parent sessions (if
    the parent file was manually deleted) surface as roots so
    they stay discoverable.

  core.FindSessionByID(root, cwd, id) string
    O(n) lookup used when resolving a tree pick back to a file
    path. Files in the dir are small in practice.

  readSessionMeta helper (unexported) reads just the first line
    of a session file and decodes the meta; avoids loading the
    whole transcript when BuildSessionTree only needs the
    parent/id pair.

tui additions:

  session_tree_dialog.go
    Flat list with indent-based nesting to match the other
    picker dialogs' shape. Up/down moves; enter switches; esc
    cancels. Rows show "<relative-when> <prompt-preview> N msgs"
    with a muted "[current]" tag on the current session.

  interactive.go
    - sessionTreeDialog field + constructor.
    - /session fork / /session tree cases in doSessionOp.
    - doSessionFork flips pendingFork=true and opens the
      jumpDialog over the agent's current messages.
    - The jump-dialog key handler checks pendingFork; if set,
      routes the selection to applyForkSelection instead of the
      normal applyJumpSelection. pendingFork clears on select
      OR on dismiss so a later plain /jump isn't hijacked.
    - applyForkSelection calls FlushSession (so the branch gets
      everything in memory, not just what was lazy-flushed),
      then core.BranchSession, then LoadSession to swap.
    - doSessionTree calls FlushSession first so the tree shows
      the true current message count, then
      core.BuildSessionTree, then hands the forest to the tree
      dialog.
    - applySessionTreeSelection hands the picked path to
      LoadSession.

tests:

  TestBranchSessionCopiesPrefix
    Parent with three messages; branch at upToIdx=2; verify the
    child has exactly 2 messages, parent ID matches, fork point
    = 2, ID rotated.

  TestBuildSessionTree
    Parent + 2 branches off it; verify roots=[parent],
    roots[0].Children has both branches.

README: /session row expanded to cover all four ops.
2026-04-20 11:10:56 +02:00
patriceckhart
9b063d871b fix(session): export the full running conversation, handle quoted paths
Two bugs in yesterday's /session export + import:

1. Quoted / tilde paths weren't normalised.
   Drag-drop paste in the tui auto-quotes dropped file paths so
   the shell-style `/session import 'foo bar.zotsession'` stays
   well-formed. But the /session handler's expandTilde checked
   for a leading '~' and the string's first char was a literal
   quote, so the tilde never expanded and stat failed with
   "no such file or directory".

   unquotePath helper now strips a matching pair of surrounding
   single or double quotes before expandTilde runs. Applies to
   both export (dst) and import (src).

2. Export was writing only the meta row when called mid-session.
   The tui's default persistence strategy writes agent messages
   to the session file lazily: WriteNewTranscript runs once
   when the tui exits, NOT after every turn. Meanwhile the
   running agent's messages live in a sync.Mutex-guarded slice
   in core.Agent.messages. /session export was reading the file
   bytes off disk, which at that point only contained the meta
   row plus whatever was there on startup.

   New FlushSession hook on InteractiveConfig: the cli wires it
   to WriteNewTranscript against the current agent, then
   advances sessBaselineMsgs so the tui's own exit-time flush
   doesn't double-write. /session export calls the hook right
   before ExportSession, so the file on disk reflects the full
   running transcript at the moment the user hit enter.

Tests:

- internal/core/session_portable_test.go was already exercising
  ExportSession/ImportSession against on-disk files; this fix
  lives in the cli/modes glue, not in core.

- internal/agent/modes ad-hoc TestUnquotePathThenExpandTilde
  (run locally, not committed) covered the 8 tilde+quote
  combinations.

Verified: create a fresh session, type "hello", reply, "foo",
reply, run /session export. Exported .zotsession now contains
the meta row + 2 user + 2 assistant + 1 usage row. Re-import
into a different cwd via /session import <path>, /sessions to
confirm it lands as a resumable entry.
2026-04-20 10:19:53 +02:00
patriceckhart
ef80f9cd80 feat(session): /session export + import with portable .zotsession file
Lets one user hand a conversation off to another machine or
user. New slash command:

  /session                    picker with export / import rows
  /session export             defaults to ~/Downloads/<name>.zotsession
  /session export ~/foo       writes ~/foo.zotsession
  /session export ~/bar/x.zs  writes to that exact path (ext added if missing)
  /session import <path>      loads and switches to it

Exported file is the same jsonl the live session writes, with
the meta row rewritten to strip the source user's cwd. The
importer rotates the id and cwd to claim the copy, so the
imported session becomes a first-class entry in the current
user's sessions/ directory and shows up in /sessions,
/jump, and on-disk summaries like any other.

core/session_portable.go (new)
  - ExportSession(src, dst) string  returns the resolved
    output path. dst can be a file, a directory, or a bare
    name missing the .zotsession ext; all three shapes land
    somewhere sensible.
  - ImportSession(src, root, cwd, version) string  returns
    the newly-created session file path, ready for
    OpenSession.
  - firstUserPrompt() + slugify() build descriptive
    "20260420-080305-3f268850-say-hello-in-one-sentence.zotsession"
    filenames when exporting into a directory.

core/session_portable_test.go (new)
  - Full round trip: write → export → import into a
    different cwd → OpenSession → message payloads match.
  - Verifies the exported meta drops the original cwd.
  - Verifies the .zotsession extension is appended when
    missing from dst.

modes/session_ops_dialog.go (new)
  - Tiny picker matching the telegramDialog / logoutDialog
    shape: arrow keys, enter, esc. Two rows (export / import)
    with muted hint text.

modes/interactive.go
  - sessionOpsDialog field + constructor + key dispatch +
    render selector, identical boilerplate to the other small
    dialogs.
  - openSessionOpsDialog, doSessionOp, doSessionExport,
    doSessionImport. Export uses CurrentSessionPath (new
    config hook); import calls core.ImportSession then routes
    through the existing LoadSession so the agent switches to
    the new file.
  - defaultExportDir (~/Downloads → ~ → /tmp fallback),
    expandTilde, friendlyPath helpers.

cli.go
  - CurrentSessionPath: sess.Path getter wired into the
    interactive config.

slash_suggest.go + README
  - /session listed in the slash catalog and the README
    commands table, with a short description of the two
    direct forms.

Not wired into the session_dialog.go picker (which stays
resume-only); a later change could add "export this one"
directly from the picker rows if that's useful.
2026-04-20 10:04:33 +02:00
patriceckhart
9a32f9cf5c docs(readme): document /telegram bridge + split from standalone daemon
README: the Telegram section now leads with "two ways to run it"
and splits into a "From inside the TUI" subsection (covering
/telegram connect/disconnect/status, the you:/zot: mirroring
convention, the · tg · status tag, and the refuse-when-daemon-
running guard) followed by the existing "Standalone daemon"
subsection (unchanged content, renamed heading).

No code change; description only.
2026-04-20 09:35:25 +02:00
patriceckhart
625c2382b7 feat(telegram): mirror tui prompts into telegram thread
When the telegram bridge is connected, messages you type in the
zot tui now also appear in the paired chat so the telegram
transcript stays a complete record of the session. Format:

  you: <what you typed>         <- from tui editor, grey bubble
  zot: <assistant reply>        <- reply to a tui prompt
  <your telegram dm>            <- your own blue bubble
  <assistant reply, bare>       <- reply to a telegram dm, no prefix

The "zot: " prefix is only attached when the turn was initiated
from the tui side. Telegram-initiated turns reply bare so the
thread reads as a normal back-and-forth with the bot; the "you: "
bubble from the tui side would otherwise pair awkwardly with a
DM-initiated bare reply.

Implementation is small:

  bridge.go
    - OnUserTyped(text): sends with "you: " prefix. Called from
      the interactive submit path when the bridge is active.
    - OnAssistantText(text): sends with "zot: " prefix by
      default, or bare when nextReplyFromTelegram is set.
    - nextReplyFromTelegram is flipped to true inside
      handleUpdate right before calling Host.SubmitOrQueue, and
      back to false when the reply is flushed. One-slot flag,
      safe against the actual serial turn drain the agent uses.
    - On Start(), if Config.AllowedUserID is already known from
      a previous session, prepopulate chatID so the bridge can
      send immediately without waiting for a handshake DM
      (private-chat id == user id on telegram).
    - sendToPaired consolidates the chunk-and-send plumbing so
      OnUserTyped, OnAssistantText, and future tap points share
      one path.

  interactive.go
    - The editor submit path now calls telegramBridge.OnUserTyped
      on a goroutine (network write off the event loop) before
      queuing or starting the turn. No-op when the bridge is
      stopped or no chat is paired.

No user-visible setup change: /telegram connect / disconnect /
status work the same; the two-way mirror is automatic once
connected.
2026-04-20 09:33:03 +02:00
patriceckhart
098a79743d feat(tui): /telegram connect | disconnect | status
The Telegram bridge can now mirror into the running TUI session.
Runs inside the zot process (no daemon needed); DMs from the
paired user become prompts in the current agent, and the
assistant's final text is sent back to Telegram. You see the full
conversation in the TUI in real time and on your phone.

UI:
  - /telegram or /tg with no arg opens a picker (connect /
    disconnect / status) that reflects current state.
  - /telegram connect  starts the bridge. Refuses if bot.json
    has no token (tells you to run `zot telegram-bot setup`) or
    if the background daemon is already polling.
  - /telegram disconnect  stops the bridge cleanly.
  - /telegram status  one-liner: "connected as @botname, paired
    with user X" / "background daemon running (pid N)" /
    "not configured" / "disconnected".
  - Status bar gets a "· tg · ~/cwd" tag while the bridge is
    active, next to the "· jailed ·" tag if that's also on.

How it's wired:

  internal/agent/modes/telegram/bridge.go (new)
    A slim Bridge type that owns the long-poll loop + typing
    indicator + reply sender but delegates the agent side to a
    Host interface. Not an agent itself - just a courier that
    pushes inbound DMs at a host and relays outbound text.

  internal/agent/modes/telegram_dialog.go (new)
    Picker with connect / disconnect / status rows. Shape
    mirrors the logout dialog: arrow keys, enter, esc.

  internal/agent/modes/interactive.go
    - New SubmitOrQueue(text, images) that runs if idle or
      queues if busy. Telegram Host calls this so DMs use the
      same queuing semantics as the user's editor submit.
    - New CancelTurn() for when Telegram sends /stop.
    - telegramHost adapter wires the Interactive to the
      bridge without a cyclic import (bridge lives in
      modes/telegram, interactive in modes; the adapter is
      in modes so it's fine).
    - EvAssistantMessage handler now also forwards the final
      visible text to the bridge when active (goroutine, so
      the network call doesn't hold the event-loop lock).
    - Bridge is stopped on zot exit via a defer in Run().

  internal/tui/view.go
    StatusBarParams gains Telegram bool; the cwd line builds a
    composite "· jailed · tg · ~/cwd" when both tags apply.

  internal/agent/modes/slash_suggest.go
    /telegram added to the slash catalog.

Collision safety:
  /telegram connect refuses when the background daemon
  (telegram.IsRunning via bot.pid) is alive. Two concurrent
  long-poll consumers of the same bot always race and one
  drops half the updates; refusing up-front beats half-working
  silently. Message tells the user exactly what to do.

Attachments:
  Image attachments arriving in Telegram are downloaded and
  queued as user-prompt images (same code path as drag-drop).
  Non-image attachments are ignored for now.

Pairing:
  First Telegram user to DM /start claims the bridge; the id
  is persisted to bot.json so subsequent connects are already
  paired. Anyone else DMing the bot gets "this bot is paired
  with a different user."

README: /telegram row added to the slash-commands table.
2026-04-20 09:18:04 +02:00
patriceckhart
71933343de feat(tui,bash): shell-log style bash results
Bash tool results now render in the TUI like a terminal log:

    ──────────────────────────────────────────────
        $ npm run build
            (accent color)

        > example@1.0.0 build
        > webpack --mode production
        built in 2340ms

        [exit 0]  Took 2.4s
            (muted color)
    ──────────────────────────────────────────────

bash.go: prefixes every result with `$ <command>\n`, adds a
trailing `Took X.Ys` after the `[exit N]` marker, stores the
elapsed duration in Details.duration_ms for programmatic use.
New humanDuration helper formats the duration as "0.1s" for
sub-minute runs, "2m3s" / "1h5m" above that.

view.go: renderBashResult styles three zones:
  - first line (starts with "$ ") in accent
  - the "[exit N]  Took X.Ys" footer line in muted
  - everything in between on the default tool-output color
Detected automatically by looking for "$ " at the top of a
tool_result block, so no plumbing changes needed.

Result text stays plain-text so the model sees the same shell-log
format when it reasons about the command's outcome. That matches
how a human would see it in their own terminal and doesn't need
any special escape-code stripping on the model's side.
2026-04-20 09:03:24 +02:00
patriceckhart
b6fc3fd886 rename: /lock -> /jail, /unlock -> /unjail
User-facing slash commands renamed to /jail and /unjail. The
internal Sandbox type (Lock/Unlock/Locked methods, atomic.Bool
field) keeps its mutex-style names because those describe the
implementation, not the feature. Everything the user sees swaps:

- slashCatalog: /jail + /unjail entries and descriptions.
- runSlash handlers: case "/jail" / case "/unjail"; status line
  reports "jailed to <cwd>" / "unjailed".
- Status bar tag: "· jailed · ~/cwd" (was "· locked ·").
- Sandbox error messages: "jailed: path X is outside sandbox
  root Y (use /unjail to disable)" etc.
- README: table rows, section heading, body text, busy-mode
  section all updated.
- Website (/Users/pat/Sites/zot): Tools section prose updated.
- SDK doc comment in pkg/zotcore refers to /jail.

Internal identifiers (Sandbox, Lock(), Unlock(), Locked(),
CheckPath, CheckCommand, slashCancelsTurn switch) unchanged.

Verified: go vet clean, go test -race ./... clean, bun
typecheck + lint + build clean on the site.
2026-04-20 08:57:40 +02:00
patriceckhart
c610a3a645 feat(tui): live-stream file body during write/edit tool calls
You see the file being composed in real time now. While the model
is typing the tool_use JSON, the TUI renders a rules-wrapped
syntax-highlighted preview that grows as deltas arrive. When the
tool actually runs, the preview transitions to the final result
without flicker.

Before: the tool header appeared post-response, then "wrote N bytes"
for write / "applied 1 edit" for edit. No live feedback.

Now: as soon as the `path` field parses out of the partial JSON,
the header shows `▸ write /Users/pat/Desktop/demo.ts`. As the
`content` / `newText` string streams in, each delta extends the
highlighted preview body immediately. Collapsed at the usual
preview height with the standard `ctrl+o to expand` footer.

Implementation:

- internal/core/events.go: three new AgentEvent types,
  EvToolUseStart / EvToolUseArgs / EvToolUseEnd. They carry the
  tool id, name, and raw JSON deltas from the provider stream.
- internal/core/agent.go: forwards the equivalent provider events
  instead of dropping them. EvToolCall (with fully-parsed args)
  still fires at EventDone as before, so existing consumers
  don't need to change.
- internal/tui/partialjson.go: small escape-aware extractor that
  pulls one string field's value out of a partial JSON buffer as
  it grows. Handles \\ \" \n \t \r \b \f \/ and \uXXXX escapes;
  tolerates trailing incomplete escapes (returns the complete
  prefix and waits for more bytes). Second helper,
  ExtractLastNewText, walks to the most recent "newText":"..."
  inside an edits array so edit's streaming preview shows the
  edit currently being composed (not an earlier one that's
  already finished).
- internal/tui/view.go: ToolCallView gains Streaming, RawJSONBuf,
  LivePath fields. renderToolCall dispatches to renderLiveToolBody
  while Streaming=true and Result=="". For `write` it shows the
  partial `content`; for `edit` it shows `  edit N (streaming)`
  plus the partial `newText`. Shared wrapLiveBody keeps the rule
  + collapse boilerplate in one place.
- internal/agent/modes/interactive.go: handles the three new
  events. EvToolUseStart pre-creates the ToolCallView so the
  header appears instantly; EvToolUseArgs appends the delta and
  refreshes LivePath; EvToolUseEnd flips Streaming off. The
  pre-existing EvToolCall branch now updates the already-created
  view rather than replacing it.
- internal/agent/modes/json.go: emits tool_use_start /
  tool_use_args / tool_use_end events so `zot --json` consumers
  can build their own live previews.
- internal/agent/tools/write.go: tool result is now the written
  file body (same shape as read's result) with total_lines +
  start_line details. Keeps the visual transition from streaming
  preview to final result seamless, and gives the model the file
  contents in its own tool_result for follow-up turns.

Tests:

- internal/tui/partialjson_test.go: 9 cases on
  ExtractPartialStringField (complete, partial mid-word, escape
  variants, unfinished escapes) and 4 on ExtractLastNewText
  (no newText, partial, complete, multi-edit).

Verified end-to-end via `zot --json "write ..."` and
`zot --json "edit ..."` against the real API: 246 tool_use_args
delta events on a 30-line write, preview fields extracted live,
final file written correctly.
2026-04-20 08:37:14 +02:00
patriceckhart
3e59a3cfd3 fix(tui): three cursor/alignment fixes in the editor and /help
1. Drag-dropped long paths no longer strand the prompt glyph on
   its own line. wrapLine() used to break before rune-splitting an
   oversized token, which produced:

     row 0: "▌"
     row 1: "  '/var/folders/.../TemporaryItems/NSIRD_screencaptu"
     row 2: "  re_CohJs2/Screenshot 2026-04-19 at 20.15.44.png..."

   Because the prompt ("▌ ") was a separately-tokenised prefix,
   overflow broke the line after writing it and started the long
   token on row 1. That also shifted locateCursor's rune-walk, so
   the terminal cursor drew in the wrong column after the user
   typed anything past the paste. Fix: when the token will need
   rune-by-rune splitting anyway (wider than width - contW), skip
   the precautionary newline and stream runes from the current
   column, wrapping naturally. Added two regression tests.

2. The spinner glyph and the funny-line message now render in
   Theme.Assistant (the same cyan as the `▍ zot` role label) so
   the busy band reads coherently with the rest of the chat.
   Elapsed time stays muted; model name, stats, cost, context
   meter, and cwd are unchanged. Fixed double-coloring in
   StatusBar: the outer Accent wrapper was overriding the spinner
   color the caller had set, so pre-colored segments now pass
   through unmodified.

3. /help key-binding column alignment. Single-cell multibyte
   runes like ← → · were being measured by byte length (3 bytes
   each) instead of display width (1 cell), which overshot the
   labelWidth calc AND caused the pad() function to return the
   raw string without adding spaces. The `alt+← / alt+→` row
   ended shorter than its neighbours and its description started
   in the wrong column. Fix: use runewidth.StringWidth everywhere
   in help.go's alignment math.
2026-04-19 20:25:34 +02:00
patriceckhart
f8c309e3ca feat(auth,tui): dark login pages + /logout picker
Two unrelated UX improvements bundled:

1. Login pages (all of them) now use the TUI-matching dark style.
   Swapped the shared monoStyle from white/black to:
     - background #0a0a0a
     - white body text
     - Geist Mono via Google Fonts @import
     - accent #7ed3fc on every occurrence of the word "zot"
   Applies to: /apikey index, /apikey form, api-key success, oauth
   success, oauth error. The three pages that were still white
   (index, form, error) now match the TUI's dark look end-to-end.
   Input focus ring and button hover flipped to white-on-dark.

2. /logout without an argument opens a picker.
   New logout_dialog.go modelled on the existing small-list dialogs
   (model picker shape, session picker size). Lists only the
   providers the user is actually logged into, each with an
   (apikey) or (oauth) tag. When both are logged in, an extra
   "all" row is appended. When nothing is stored, /logout reports
   "no credentials stored; already logged out" and doesn't open
   an empty dialog.

   /logout anthropic, /logout openai, /logout all still work
   exactly as before (direct, no dialog).

Also includes the user's earlier edit to defaultIdentity:
"operating inside zot, a coding agent harness" rewording.
2026-04-19 20:14:22 +02:00
patriceckhart
e1c1e0e609 fix(cli): load extensions in print and json modes too
runPrintMode and runJSONMode never constructed the extension
manager, so --ext and installed extensions were silently ignored in
non-interactive flows. Only the interactive TUI and rpc mode were
loading them. The symptom: 'zot -e ~/path/to/weather -p "..."'
would spawn nothing, no log, and the model had no weather tool.

Added shared helpers used by both print and json:
  - setupNonInteractiveExtensions: same --ext + Discover sequence
    as interactive, plus the session_start event and MergeExtensionTools.
  - wireNonInteractiveAgentExtHooks: same BeforeToolExecute /
    BeforeTurn / BeforeAssistantMessage / OnEvent plumbing so guard
    extensions, event interceptors, and extension-contributed tools
    work identically in one-shot runs.
  - nonInteractiveExtHooks: minimal HostHooks impl. Notify goes to
    stderr so extensions can still log; Submit / Insert / Display
    are no-ops because there's no TUI to steer.

Verified end-to-end:

  zot -e ~/Developer/zot/examples/extensions/weather \
      -p 'use the weather tool for Berlin'
  -> 'Berlin: 16°C, fog. (deterministic demo)'

Before the fix, the same command silently fell back to bash/curl
suggestions because no tool was ever registered.
2026-04-19 20:00:36 +02:00
patriceckhart
1be3f85a47 fix(tui): cursor after multi-line paste lands in wrong column
wrapLine()'s internal newLine() toggled the firstLine flag BEFORE
checking it, so the very first wrap continuation flushed to the
output WITHOUT the cont indent. Second and later continuations
were fine. Visible as:

  0: '▌ this is a very long first line that'
  1: 'will wrap around terminal boundaries'       <- no indent
  2: '  still wrapping further past this point'   <- indented

Downstream, locateCursor() in the editor assumed continuation rows
always start with cont and stripped its width when counting runes.
When the first continuation didn't actually have it, the stripping
was a no-op but the leadW was still added, so the reported visual
column for the cursor drifted by cont-width (2 cells) to the right.

Effect for the user: after drag-dropping a multi-line payload (or
pasting any text where the first paragraph wraps), the terminal
cursor rendered mid-text instead of at the end of the pasted
content. Typing still appended at the correct logical position,
so keystrokes landed in the right place in the buffer, it was
purely visual drift.

Fix: in newLine(), always write cont to cur after flushing (and
after setting firstLine = false). That makes the second row, and
every subsequent wrap continuation, carry the indent consistently.

Added three regression tests:
  - wrapLine directly: every row >= 1 has cont prefix
  - editor multi-line paste: cursor lands at logical end with
    correct visual (row, col)
  - editor long-paste-with-wrap: wrap continuations all indented
    AND cursor still lands at correct column
2026-04-19 19:50:19 +02:00
patriceckhart
00b3d9dc76 fix(tui): align slash popup descriptions across built-in + extension commands
The slash-command popup padded every command name to 10 chars. Any
command longer than 10 (/reload-ext at 11, /clear-notes at 12)
skipped the padding entirely, so its description started further
right than the others, breaking column alignment between the
built-in section and the extension section.

Fix: compute the widest name across the whole match list (both
groups) and pad every row to that width. Minimum 10 so short lists
don't look cramped. Both sections now share one description
column x-position.
2026-04-19 19:34:32 +02:00
patriceckhart
1303e18c26 docs(prompt): hyphenate 'zero-overhead-tool'
Small spelling fix to the system prompt: the canonical form is
hyphenated throughout, 'zero-overhead-tool'. Verified the model
returns exactly that when asked what zot means.
2026-04-19 19:24:29 +02:00
patriceckhart
b188801c32 perf(prompt): slim system prompt from 1064 tokens to 126 tokens
Earlier I bloated the default system prompt on purpose to cross
Anthropic's 1024-token cacheable-prefix floor, on the theory that
small prompts lose every fresh session to R=0. That theory turned
out to be wrong: the real reason fresh sessions looked expensive
was the double-counting bug in Stream() (message_start and
message_delta both ship cumulative usage, we were summing both).
Once that was fixed, the padding stopped earning its bytes.

New default:

  You are zot, a lightweight terminal coding agent. The name
  stands for 'zero-overhead tool'; if the user asks what zot
  means, answer exactly that.

  Your output renders in a TUI that understands markdown for
  prose and plain text for tool output. Use markdown freely,
  keep answers concise, and let tool calls speak for themselves
  rather than narrating them in prose before you invoke them.
  Act first, then summarise what you did.

Removed the tool-listing section (the provider already advertises
tools in the request's tools[] array, so listing them in prose was
pure duplication) and the full operating-guidelines block
(frontier models already internalise "prefer edit over write",
"read before editing", "don't run sudo", etc.).

Benchmarked head-to-head on a fresh 2-turn session with the same
scenario:

  heavy prompt (1064 tokens total):
    turn 1: in=7 R=0 W=7550 out=117    $0.0501
    turn 2: in=6 R=2714 W=2176 out=61  $0.0165
    total:                              $0.0666

  slim prompt (126 tokens total):
    turn 1: in=1522 R=0 W=3636 out=111 $0.0334
    turn 2: in=6 R=3642 W=60 out=71    $0.0040
    total:                              $0.0374

Slim is 44% cheaper across two turns. Turn 2 is where it really
pays off (W=60 vs W=2176): every extra token in the base prompt
gets re-written on every turn because the trailing-user cache
checkpoint keeps advancing.

--system-prompt, --append-system-prompt, and $ZOT_HOME/SYSTEM.md
still work and take precedence for users who want more biasing.

'what does zot mean?' still returns exactly 'zero-overhead tool'.
2026-04-19 19:23:16 +02:00
patriceckhart
d50eed7b3c docs(prompt): teach zot what its name means
Adds one sentence to the default system prompt so the model has a
canonical answer when the user asks what zot stands for:
"zero-overhead tool".

Verified: zot -p "what does zot mean?" now returns exactly that.
2026-04-19 19:18:35 +02:00
patriceckhart
e2f2092478 fix(no-yolo): don't auto-refuse tool calls in non-interactive modes
Previously --no-yolo in -p / --json / rpc modes auto-refused every
tool call. That made the flag dangerous to pass to scripts: a
single --no-yolo in a shell config or wrapper script would silently
break any tool-using prompt.

New behaviour:
  - Default: every mode is yolo (tools run freely, no prompts).
  - --no-yolo + interactive TUI: confirm dialog before each tool.
  - --no-yolo + -p / --json / rpc: stderr warning and ignore the
    flag. Tools run freely; scripts keep working.

The TUI confirm dialog and /yolo runtime toggle still work as
before. Also removed the unused wireNoYoloAutoRefuse helper and
simplified core.NewConfirmGate's doc comment.
2026-04-19 19:17:05 +02:00
patriceckhart
ac6d556f0a feat(tool-gate): --no-yolo flag, confirm dialog, /yolo runtime toggle
Adds a per-tool-call confirmation gate. Default stays yolo mode
(tools run freely, same as today). Pass --no-yolo to require
explicit user approval before each tool invocation.

Interactive TUI:
  A dialog appears before every tool call. Shows the tool name and
  a one-line preview of its args (command / path / url / etc.)
  with four choices, selectable by arrow keys or numeric shortcut:

    1. yes                     (run this call)
    2. yes, always this tool   (skip prompts for this tool,
                                session-scoped)
    3. yes, always             (skip prompts for every tool,
                                session-scoped)
    4. no                      (refuse and let the model try
                                something else)

  Esc/ctrl+c refuses the current prompt. Esc during a running turn
  both cancels the turn AND drains any pending confirm so the
  agent goroutine doesn't deadlock. Multiple pending confirms are
  queued and answered one at a time with a count visible in the
  header.

  Type /yolo to disable the gate for the rest of the session
  (equivalent to the "yes, always" choice but without needing a
  pending prompt). Any currently-open confirm auto-allows so the
  agent keeps moving.

Print / JSON / RPC modes:
  No interactive prompt is available, so every tool call is
  auto-refused with a reason the model can learn from:
  "tool call refused: --no-yolo is active and there is no
  interactive prompt in this mode; ask the user what to do
  instead". Observed behaviour: the model pivots to asking the
  user directly instead of looping on the same tool.

Implementation:
  internal/core/confirm.go
    - ConfirmDecision, Confirmer interface
    - ConfirmGate with session-scoped memory for "always this tool"
      and "always everything" decisions, both concurrency-safe
    - BuildPreview: turns {"command":"ls"} into "ls", etc.
    - Lives in core to avoid a modes -> agent import cycle
  internal/core/confirm_test.go
    - Tests: nil gate allows, nil-inner refuses with reason, one-
      shot allow doesn't remember, remember-tool short-circuits
      only same tool, remember-all short-circuits everything,
      refusal reasons surface, empty-reason gets a default,
      runtime AllowAll works, BuildPreview handles each field
  internal/agent/modes/confirm_dialog.go
    - Queue-based dialog, HandleKey wiring, CancelAll and
      AllowAllPending for the two exit cases
  internal/agent/modes/interactive.go
    - InteractiveConfig gains NoYolo + ConfirmGate fields
    - Interactive implements core.Confirmer via a response channel
    - Confirm dialog dispatched FIRST in the key-handler chain so
      keys never leak to other dialogs while the agent is blocked
    - Esc-while-busy also calls confirmDialog.CancelAll so the
      agent unblocks
    - /yolo slash command handled in runSlash
  internal/agent/cli.go
    - Constructs the ConfirmGate when args.NoYolo is set,
      BeforeToolExecute calls it first, extensions only see calls
      the user already approved
    - After iv is built, SetConfirmer(iv) wires the gate's inner
      so interactive + gate share the same struct
    - wireNoYoloAutoRefuse() for print / json modes
  internal/agent/args.go
    - --no-yolo flag and help text
  internal/agent/modes/slash_suggest.go
    - /yolo added to slashCatalog

Verified end-to-end: fresh zot --no-yolo -p "read sample.ts" now
returns "I can't read files in this mode (--no-yolo without an
interactive prompt). How would you like to proceed" instead of
actually reading.
2026-04-19 19:12:45 +02:00
patriceckhart
f371687654 perf(anthropic): fix cost double-count, tighten caching, correct catalog
The status-bar was showing 2x the real cost. Anthropic's SSE stream
sends the full cumulative usage payload on both message_start AND
message_delta, and our code was summing them with += on each. Cache
tokens, the biggest cost component on multi-turn sessions, were
therefore counted twice on every single API call.

Fix: assign instead of accumulate within one Stream() invocation.
Cross-call accumulation still happens correctly in
core.CostTracker.Add(). Verified end-to-end: a truly fresh "read
sample.ts on desktop" session that used to report $0.15 now reports
$0.07 with the same cache-hit rate.

While chasing that, audited and corrected the rest of the request
pipeline so the cache actually hits cleanly.

Provider layer (internal/provider/anthropic.go):
  - cache_control on the Claude Code identity line (was uncached),
    giving Anthropic a first stable checkpoint independent of the
    user system prompt. Turns a cold start from R=0 into R>0 for
    any subsequent fresh session within the cache TTL.
  - tool_result blocks go in their OWN new user message instead of
    merging into the preceding user message. Merging was mutating
    the prior user message's content array between turns, busting
    byte-identical prefix match in Anthropic's cache.
  - tagLastUserCache: exactly one cache_control on the last user
    message (was two), so identity + sysprompt + last-tool +
    last-user fits Anthropic's 4-breakpoint budget exactly.
  - user-agent dropped its "(external, cli)" suffix to match the
    canonical Claude Code string exactly.
  - ZOT_DEBUG_ANTHROPIC=<path> env hook appends each outgoing
    request body (one JSON object per line) to that file. Off by
    default; for debugging cache / cost issues in the field.
  - Usage field handling now correctly assigns the latest value
    from each SSE event instead of summing.

Core (internal/core/tool.go):
  - Registry.Specs() now sorts tools alphabetically. Go map
    iteration order is randomized per call; randomized tool arrays
    were breaking Anthropic's byte-level prefix match on every
    single call within a session.

System prompt (internal/agent/systemprompt.go):
  - Restored a substantial default prompt with structured tools +
    operating guidelines sections. The earlier aggressive trim
    dropped us under Anthropic's 1024-token minimum cacheable
    prefix floor: prefixes below 1024 tokens are silently NOT
    cached by Anthropic, so every fresh session started cold with
    R=0 no matter what else we did.
  - Current default ~1040 tokens on its own; with identity and
    tools it's ~1400, comfortably above the 1024 floor.
  - --system-prompt, --append-system-prompt, and
    $ZOT_HOME/SYSTEM.md escape hatches all still work and take
    precedence.

Model catalog (internal/provider/models.go):
  - claude-opus-4-5: 1M ctx / 128k max -> 200k ctx / 64k max. I had
    over-extrapolated; 1M context is a 4.6+ feature.
  - gpt-5.4: 400k -> 272k. Canonical value on both the OpenAI
    direct API and the ChatGPT Codex OAuth backend.
  - gpt-5.1, gpt-5.2, gpt-5.3, gpt-5.4-mini: pinned to 272k.
    OpenAI advertises 400k on direct and Codex caps at 272k. zot
    serves both from one catalog row per id, so we pin to the
    smaller number to keep the context-usage meter honest under
    subscription auth. Direct-API users see a conservative estimate
    instead of an inflated one.

README:
  - Tiny capitalization touch-up on the opening line.
2026-04-19 18:57:18 +02:00
patriceckhart
05d0df91b8 perf(prompt): cut system prompt to the bone (410 -> 54 tokens)
Three levers, all dead-simple, compounded savings.

1) System prompt rewritten to a one-line identity.
   Was 410 tokens (identity + tool listing duplicating the tool
   schemas + operating guidelines that frontier models already
   internalise). Now 54 tokens:

       You are zot, a lightweight terminal coding agent. Be
       concise, act on the user's request directly, and reply
       with a short summary when done.

   The old 'You have the following tools available:' block listed
   every tool by name and description, which the provider sends
   alongside the actual tool schemas. Pure duplication. Dropped.
   Operating guidelines (prefer edit over write, read before
   editing, don't apologize, etc.) are ~150 tokens of advice the
   model already follows by default. Dropped.

2) Tool descriptions trimmed.
   read:  long paragraph   -> 'Read a file. Images (png/jpg/gif/webp) return inline.'
   write: long paragraph   -> 'Write a file. Creates parent dirs. Overwrites.'
   edit:  long paragraph   -> 'Edit a file via exact-match replacements. Each oldText must be unique in the file.'
   bash:  long paragraph   -> 'Run a shell command. stdout+stderr merged.'
   skill: 2-sentence para  -> 'Load a named skill's instructions. Use when the user's request matches a skill listed above.'

3) Tool schemas minified.
   Every schema was pretty-printed JSON with per-field descriptions
   that reiterated the tool's own description ('Path to the file
   to read (relative or absolute)'). The model infers the obvious
   from property names. Schemas now single-line, type+required
   only. Saves ~20-40 bytes per schema, 5 tools = ~150 bytes per
   request.

Net effect on a fresh OAuth turn, measured end-to-end:

   request body:   3205 bytes -> ~1600 bytes
   system prompt:  410 tokens -> 54 tokens
   tools payload:  ~400 tokens -> ~100 tokens

Escape hatches preserved: --system-prompt (per-run), --append-
system-prompt (per-run, repeatable), and $ZOT_HOME/SYSTEM.md
(persistent) all still work and take precedence over the built-
in identity when set.
2026-04-19 17:39:38 +02:00
patriceckhart
f5719c6be1 perf(read): drop line numbers from model-facing output
The read tool used to prefix every line with '%6d\t' (6-digit
line number + tab), which added ~7 bytes / ~2 tokens per line to
every read. On typical source files that's 15-20% of the read's
token budget, repeated on every subsequent turn as the tool
result stays in context.

The line numbers weren't earning their keep: the model edits via
exact-match text replacement, never line ranges, and the tui has
always been capable of drawing its own gutter. Now it does:

- Tool output is raw file bytes, no prefix.
- A 'start_line' detail is attached to the ToolResult so the tui
  knows where to start counting.
- The tui renders a synthetic gutter over the raw content using
  the existing renderNumberedFile path (new: renderRawFile), so
  on-screen it still looks exactly like cat -n.

The old numbered format is still recognised for legacy transcripts
saved before this commit (looksLikeNumberedFile guard stays).

Measured: sample.ts (388 lines) used to cost 14957 bytes to send,
now costs 12291 bytes (raw file). Saves ~670 tokens per read of a
medium file; the same fraction applies to larger files too.

Tests: TestReadOffsetLimit rewritten to assert raw output +
start_line detail. TUI renderToolText signature grew one int
(startLine) plumbed through renderToolResultContent.
2026-04-19 17:33:05 +02:00
patriceckhart
5cc54822cd fix(skills): tell the model where each skill body lives
The system-prompt addendum now tags every skill with a source
pointer: '[builtin]' for skills embedded in the zot binary, or the
SKILL.md path (HOME collapsed to ~) for user-installed ones. The
body still loads on demand through the 'skill' tool by name, so
no behaviour change for execution, this is pure disambiguation.

Before:
  - write-zot-extension — Help the user create a new zot extension...

After:
  - write-zot-extension [builtin]: Help the user create a new zot extension...
  - code-review [~/Library/Application Support/zot/skills/code-review/SKILL.md]: ...

Why: built-in skills have no filesystem path because their
markdown is embedded in the binary. Without the [builtin] tag the
model had no way to distinguish them from user skills, and could
mistakenly try to read a nonexistent file. The path pointer for
user skills also helps the model cite where guidance came from
and reason about trust (builtin vs project-local vs global).

Test updated; addendum grew to ~123 tokens for a typical 3-skill
setup (code-review, test-fix, write-zot-extension).
2026-04-19 17:24:05 +02:00
patriceckhart
1a2ab427fe fix(tui): hide empty sessions from the /sessions picker
Zero-message sessions no longer appear in the picker. Covers three
cases: the currently-running session (its file exists but no prompt
has landed in it yet), sessions the user exited immediately after
/clear, and any stale empties that PruneEmptySessions hasn't swept
yet.

Resuming an empty session was always a no-op, so nothing is lost.
The '(empty)' fallback summary in formatSessionRowPlain stays as
defense in case MessageCount > 0 but FirstUserText is blank.
2026-04-19 17:16:45 +02:00
patriceckhart
961f99da9e chore: scrub stray "pi" references from source comments
Five leftover comments in internal/tui/ mentioned "pi" / "pi's" /
"pi-style" / "pi's cli-highlight" / "pi's extToLang" from the old
development era. Rewritten to describe the behaviour on its own
terms with no external reference.

Also renamed the two tui helpers piFormatTokens -> formatTokens and
piContextUsage -> contextUsage so the source grep stays clean.

No behaviour change, all tests pass.
2026-04-19 17:06:45 +02:00
patriceckhart
99c9ba8062 feat(ext): phase 4 - full-event interception, arg rewrites, /reload-ext
Clears every deferred extension todo in one push:

1) Interception expands to three events: tool_call (already shipped),
   turn_start (gate the turn before the model call, e.g. rate-limit /
   business-hour), and assistant_message (suppress or rewrite the
   user-visible text while keeping the model's original output in
   the transcript).

2) Tool-call args can now be rewritten mid-flight. An interceptor
   returning modified_args replaces the JSON the tool actually
   receives, without the model seeing the rewrite. Chains: each
   subscriber sees the previous one's output, letting guards
   successively redact / patch / augment. Invalid JSON is dropped
   safely.

3) /reload-ext hot-reloads every extension without restarting zot.
   The manager gracefully shuts down all running subprocesses,
   re-reads extension.json from disk, respawns (including --ext
   paths remembered from startup), and the host rebuilds the agent's
   tool registry in-place so freshly-registered tools are callable
   immediately.

Wire-format changes (extproto):
- EventInterceptResponseFromExt gains modified_args and replace_text
  fields (both optional, ignored when block=true).
- EventInterceptFromHost gains Step (for turn_start) and Text (for
  assistant_message) alongside the existing tool_call payload.

Core agent changes:
- BeforeToolExecute signature now returns (allowed, reason,
  modifiedArgs json.RawMessage). Non-nil+valid JSON args replace
  tc.Arguments before Tool.Execute runs.
- New BeforeTurn hook, invoked in runLoop before oneTurn. Blocking
  cancels the turn with an EvTurnEnd{StopError} carrying the reason.
- New BeforeAssistantMessage hook, invoked after finalMsg is
  assembled but before the EvAssistantMessage emit. Supports
  suppress (block=true) and text rewrite (replace_text). Transcript
  always gets the original; UI gets the rewritten text.
- New SetTools(reg) so /reload-ext can swap the registry on the
  live agent under the agent mutex.

Manager changes:
- InterceptToolCall now returns InterceptResult (Block, Reason,
  ModifiedArgs, ReplaceText), with a chain that folds rewrites.
- New InterceptTurnStart and InterceptAssistantMessage.
- New Reload(ctx, grace) tears down and respawns everything,
  returning ReloadStats{Stopped, Loaded, Ready, Errors}.
- New SetOnReload(fn) callback the host uses to rebuild the agent
  tool registry after a reload.
- LoadExplicit remembers --ext paths so Reload respawns them.
- subscribe accepts "tool_call", "turn_start", "assistant_message"
  under "intercept".

SDK (pkg/zotext):
- New handler types: ToolCallHandler, TurnStartHandler,
  AssistantMessageHandler, and their decision structs
  (ToolCallDecision with ModifiedArgs, AssistantMessageDecision
  with ReplaceText).
- New registration methods: InterceptToolCallX (rich variant of
  the existing InterceptToolCall), InterceptTurnStart,
  InterceptAssistantMessage.
- dispatchIntercept routes per-event with panic recovery and
  always emits exactly one event_intercept_response.

TUI:
- /reload-ext slash command registered in slashCatalog and
  runSlash. Added to slashCancelsTurn so it waits for idle like
  /compact does.
- runReloadExt shows a "reloading extensions..." status, runs the
  Manager.Reload on a goroutine, and reports the resulting stats.

Tests:
- internal/core/intercept_test.go: verifies args are actually
  rewritten on the way to Tool.Execute, malformed JSON is ignored,
  and block surfaces the reason as an error ToolResult.
- internal/agent/extensions/intercept_test.go: end-to-end with a
  bash extension subprocess that blocks rm -rf, rewrites other bash
  args to "echo GUARDED:", passes through read calls, allows
  turn_start, and redacts SECRET in assistant messages. Second test
  verifies Reload respawns the subprocess, re-registers its command,
  and fires the onReload callback.

Docs:
- docs/extensions.md: rewrote the intercept section to cover all
  three events, added a table of event_intercept_response fields,
  documented the /reload-ext hot-reload command, expanded the SDK
  section with examples of every handler, moved the old "future"
  items into a shipped Phase 4.
- README.md: extensions summary mentions intercept beyond tool_call,
  /reload-ext added to the slash-commands table and to the
  turn-cancel list in "Queued messages".
2026-04-19 17:02:04 +02:00
patriceckhart
9f031f941a docs: complete README, proper capitalization, add 130x130 logo
- Add the zot logo at the top of the README, sized to 130x130 via
  an HTML <img> tag so GitHub honours the dimensions. Reuses the
  existing internal/assets/zot-logo.png that's already embedded in
  the binary for the login callback pages, so the README is
  self-contained.
- Proper English capitalization in prose and section headings
  (intro bullets kept lowercase per the custom intro).
- Drop em-dashes in favour of periods, commas, semicolons.
- No emojis anywhere.
- Fill in the previously-missing flags: --ext / -e, --no-ext,
  --with-skills, --no-skill.
- Add /skills row to the slash-commands table, plus a dedicated
  /skills section describing the picker.
- New dedicated Extensions section documenting zot ext install /
  list / logs / enable / disable / remove, --ext for development,
  and pointing at examples/extensions + docs/extensions.md.
- New dedicated Skills section documenting the discovery layout,
  --with-skills opt-in, and the claude / agents compatibility
  paths.
- Updated $ZOT_HOME tree to include skills/ and extensions/.
- Read-only-while-busy slash list in "Queued messages" updated to
  include /skills.
- Source layout table expanded with internal/agent/extensions,
  internal/extproto, internal/skills, pkg/zotcore, pkg/zotext.
- JSON mode now links docs/rpc.md for the schema instead of the
  stale instructions.md §8 reference.
- ctrl+o description specifies which tools' output it collapses.
2026-04-19 16:25:48 +02:00
patriceckhart
b9e7517149 feat(tui): show github release notes once after upgrading
The first time a user launches a newer zot binary, the tui pops
a dismissible overlay with the release notes for that version.
Press any key to close; the version goes into config.json's
last_changelog_shown so the same notes never reappear.

Lifecycle:
  - dev builds (version "" / "dev" / "0.0.0"): no fetch ever
  - first-ever launch (no LastChangelogShown stored): seed it
    silently with the current version so fresh installs don't
    get release notes dumped at them
  - subsequent launches with the same version: skipped (config
    already records that version was shown)
  - launch with a different version: fetch the release page from
    https://api.github.com/repos/patriceckhart/zot/releases/tags/v<ver>
    and open the dialog if the body is non-empty
  - dismiss writes LastChangelogShown so it never repeats

Components:
  - internal/agent/changelog.go: FetchChangelog/Async, and the
    Should/Mark/Seed helpers around config.LastChangelogShown.
    Honours $GITHUB_TOKEN exactly like the install scripts and
    the existing update check, so private-repo fetches work
    with auth.
  - internal/agent/modes/changelog_dialog.go: the overlay.
    Markdown body via the existing RenderMarkdown pipeline,
    scrollable with up/down/pgup/pgdn, any other key dismisses.
  - internal/agent/modes/interactive.go: new ChangelogChan and
    OnChangelogDismiss config fields, single-shot select case
    in Run() that opens the dialog when a payload arrives.
  - internal/agent/cli.go: spawns the fetch goroutine, gates it
    on ShouldShowChangelog, wires OnChangelogDismiss to
    MarkChangelogShown so the version is persisted.

Best-effort: timeouts at 4s, missing tag => silent skip, network
failure => silent skip + retry on next launch (no
LastChangelogShown update if we never showed anything).

Documented in the README under the SYSTEM.md note.
2026-04-19 16:12:13 +02:00
patriceckhart
fbad128c4c feat(skills): user skills now opt-in via --with-skills
Behaviour change: a fresh zot run now loads only the built-in
skills compiled into the binary. User-installed SKILL.md files
under $ZOT_HOME/skills/, .zot/skills/, .claude/skills/, or
.agents/skills/ stay dormant until the user explicitly opts in
with --with-skills.

Three discrete modes:

  zot                       built-ins only (default)
  zot --with-skills         built-ins + user skills
  zot --no-skill            nothing (no skill tool, no manifest)

Rationale: a fresh install should have a deterministic skill
set, regardless of what's already lying around in $ZOT_HOME from
old experiments. Built-ins ship with the binary so they're
auditable; user skills are loaded only when the user explicitly
asks for them.

Discover() gained an includeUser bool (was 3 args, now 4). The
in-tree caller updated; the test that exercised the old signature
gets includeUser=true so its existing assertions still hold.
scanUserSkills() split out so the includeUser=false path is a
cheap no-op.

End-to-end verified live:
  default              -> write-zot-extension (only)
  --with-skills        -> write-zot-extension + code-review + test-fix
  --no-skill           -> no skill tool, no manifest at all
2026-04-19 16:03:26 +02:00
patriceckhart
e9ffc74442 feat(skills): --no-skill flag to disable all skills (including built-ins)
Mirrors --no-ext: one flag that skips skill discovery + the
`skill` tool registration entirely for one run. Useful for
clean-room runs where you want zero extra context biasing the
model.

Defaults are unchanged:
  - user-installed skills under $ZOT_HOME/skills/, .zot/skills/,
    .claude/skills/, .agents/skills/ load normally
  - built-in skills compiled into the binary (currently the
    write-zot-extension authoring guide) load normally
  - both groups appear in the system-prompt manifest and are
    loadable via the `skill` tool

With --no-skill (or --no-skills):
  - skills.Discover() is not called
  - the `skill` tool is not registered
  - no "Available skills" addendum is appended to the system
    prompt
  - /skills picker is empty

Wired into both interactive and rpc modes via the existing
SkillSnapshot wiring (returns nil when args.NoSkill is set, so
the picker also stays empty).

End-to-end verified live:
  default            : tool list includes "skill"
  --no-skill         : tool list is read, write, edit, bash only
                       (no skill, no extra context manifest)
2026-04-19 15:58:38 +02:00
patriceckhart
2cffe048c9 feat(skills): built-in extension-author skill, hidden from /skills
The model now ships with a `write-zot-extension` skill compiled
into the binary. When the user asks for help authoring a zot
extension (slash command, LLM tool, audit hook, permission gate)
the model sees the skill in its system-prompt manifest, calls
the `skill` tool to load the body on demand, and walks the user
through the right answer with the wire format, manifest shape,
SDK examples (Go + TS + Python), and dev workflow already in
context. No need for the user to be in the zot repo or to ask
the model to read docs/extensions.md first.

Built-in skills:
  - shipped via //go:embed at internal/skills/builtin/
  - merged into Discover()'s output AFTER user skills, so a
    user-installed skill with the same name shadows the built-in
    (drop your own SKILL.md at $ZOT_HOME/skills/write-zot-extension/
    to customise)
  - marked Builtin: true on the Skill struct
  - hidden from user-facing surfaces: VisibleSkills() filters them
    so /skills only shows skills the user actually installed or
    shipped in their project

The model side stays unchanged: system-prompt manifest still lists
built-ins (so the model knows they exist), the `skill` tool still
loads them on demand. Only the picker is filtered.

Verified live:
  prompt: "List the names of the skills you have available"
  -> code-review, test-fix, write-zot-extension

  prompt: "I want to write a zot extension that adds a slash
           command /pwd which inserts the current directory path
           into the editor. What language should I use, and what
           files do I need to create?"
  -> [tool_call] skill({"name":"write-zot-extension"})
  -> body returned
  -> the model produces a complete extension with the right
     manifest, the right hello/register/ready frames, action:
     insert correctly chosen, and a remark about cwd capture.

The picker filter has its own unit test
(TestVisibleSkillsHidesBuiltins) and the existing Discover test
was updated to expect the built-in count without hardcoding it.
2026-04-19 15:55:25 +02:00
patriceckhart
e425dbba59 feat(systemprompt): $ZOT_HOME/SYSTEM.md as a persistent override
Resolution order for the system prompt is now:

  1. --system-prompt <text>    (per-run override; highest)
  2. $ZOT_HOME/SYSTEM.md       (persistent user override; new)
  3. built-in defaultIdentity + defaultGuidelines

When SYSTEM.md exists and --system-prompt is empty, its contents
replace the entire identity + guidelines block (same semantics
as --system-prompt). The tool list + skill manifest + appended
sections + date/cwd footer are still added on top, so the file
should contain just the identity / behavior text.

readUserSystemPrompt swallows file errors on purpose: a missing
or unreadable SYSTEM.md falls back to the built-in default
rather than crashing the run. Cached on Resolved.systemCustom
so MergeExtensionTools' system-prompt rebuild path also picks
up the override.

End-to-end verified live:
  with SYSTEM.md (pirate persona): "Arrr, I be Zot, a scallywag..."
  without:                         "I'm zot, a lightweight terminal..."

Documented in README's $ZOT_HOME tree + the --system-prompt
flag note.
2026-04-19 15:47:57 +02:00
patriceckhart
84dbd86aee feat(extensions): --no-ext to skip discovery for one run
Useful for clean-room runs ("does this prompt work without my
extensions interfering?") and for reproducing issues without
auto-loaded guardrails.

  zot --no-ext              # zero extensions
  zot --no-ext --ext ./x    # only x; nothing else gets discovered

Explicit --ext paths still load on top, so --no-ext is "skip the
implicit scan", not "block all extensions". Lets you run with
exactly one extension without uninstalling the rest.

Wired into both interactive (cli.go) and rpc (rpc.go) modes.
Help text updated.

The "── extensions ────" divider in the slash autocomplete
already hides itself when no extension commands are registered
(empty s.extra short-circuits allCatalog before the header
insertion path), so --no-ext naturally produces a clean popup
with no orphan rule. pruneOrphanHeaders also drops the rule
when filter input narrows the extension group to zero matches.

End-to-end verified live:
  default                  : agent sees weather + read_notes
  --no-ext                 : agent sees only the 5 built-ins
  --no-ext --ext scratchpad: agent sees built-ins + read_notes
2026-04-19 15:42:34 +02:00
patriceckhart
e9b466a1ce tui: blank row after the slash-popup hint line 2026-04-19 15:36:47 +02:00
patriceckhart
4f2655a8b8 tui: blank row before the extensions divider in slash autocomplete 2026-04-19 15:36:13 +02:00
patriceckhart
789665fb1c tui: stable order + "── extensions ──" divider in slash autocomplete
Two related fixes for the popup that opens when you type /:

1) Extension commands no longer jump around between renders.
   SetExtra now sorts the incoming list by name, so map-iteration
   randomness in Manager.Commands() doesn't reshuffle the popup
   on every keystroke.

2) Visual grouping. The popup now inserts a header row between the
   built-in commands and the extension-registered ones:

       /help       show key bindings ...
       ...
       /exit       exit zot
       ── extensions ─────────────────────────
       /hello      say hello (optional name)
       /note       append text to the scratchpad
       ...

   Headers are non-navigable: Up/Down skip across them, the cursor
   never lands on one, and Selection / matches resolve through them.
   pruneOrphanHeaders drops a header when filtering eliminated every
   command underneath it (typing "/he" no longer shows a lone
   "── extensions ──" divider after the matching built-ins).

Removed the now-redundant "(ext: name)" suffix on extension
descriptions; the header makes the source obvious.

slashCommand grew a Header bool; existing two-element struct
literals in slashCatalog converted to named fields.

Tests pass with -race; vet clean on linux/windows/darwin.
2026-04-19 15:34:41 +02:00