Mirror image-bearing tool output into an OpenAI-only user message so GPT vision models receive image bytes, and hide that synthetic message from the TUI transcript.
Resuming a session whose transcript contains an assistant
tool_use block without a matching tool_result in the next
message caused Anthropic (and OpenAI's responses API) to
refuse the first request with:
http 400: messages.N: `tool_use` ids were found without
`tool_result` blocks immediately after: toolu_...
Two ways the corrupt state gets onto disk:
- Older zot builds persisted the assistant tool_use row
before the tool_result row, then crashed or were killed
between the two writes.
- Earlier abort paths didn't drop the mid-turn assistant
message cleanly before it reached the session file.
OpenSession now passes the hydrated message slice through
repairToolUseResultPairs before returning it. For every
assistant tool_use whose id isn't covered by a tool_result in
the next message, the repair injects a stub
ToolResultBlock{
CallID: <id>,
Content: [TextBlock{"tool call was aborted; no result recorded."}],
IsError: true,
}
The stub is merged into the following tool-role message if
one exists (preserves row count), otherwise a new tool-role
message is inserted right after the assistant. Model sees
the aborted context and decides whether to retry.
Runs once per OpenSession call; the hot runtime path is
untouched. Live abort handling already drops partial
assistant messages, so this is purely a safety net for
legacy-corrupted files and the crash-between-writes case.
Tests in session_repair_test.go cover:
- stub appended when no tool-role message follows
- stub merged into partial tool-role message
- valid transcripts pass through unchanged
- nil/empty input handled safely
Two small fixes to how inline-image tool results render.
1) Extra blank row under the image.
RowsForInlineImage estimates the on-screen height from a
fixed cell aspect ratio. Real terminals (iTerm2, Ghostty,
Kitty, WezTerm) don't all match that ratio exactly, so the
image often paints one row past the estimate and the info
line below it ("image - image/png - 612x904 - 99 KB") gets
overwritten by the image's bottom edge. Reserving one extra
blank row between the image and the info line absorbs that
spillover on every terminal I tried.
Same symmetry for the trailing blank: the info line no
longer sits flush against the tool-block closing rule or
the next content below.
2) Info line indented to 4 spaces.
Every other tool-output row (renderRawFile's gutter,
renderBashResult, diff rows, the "... N more lines, M total"
collapse footer) starts at column 4. The image info line
used to start at column 2, which looked off next to the
other rows. Bumped it to 4 so the metadata line aligns with
the file content above and below it.
Text-only fallback (terminals without inline image support)
also picks up the trailing blank + 4-space indent so the two\nrender paths behave consistently.
Two rules for how esc resolves while a turn is running got
implemented this round:
1) Let the user open the slash popup during a busy turn.
The suggest render path used to short-circuit on i.busy, so
typing / while the agent was working did nothing. The
dispatcher in runSlash already handles the busy-state routing
per command (safe ones run immediately, destructive ones
cancel first), so dropping the guard was safe. Now / opens
the popup whether or not a turn is in flight.
2) Esc dismisses overlays before it cancels the turn.
The global key switch used to fire the busy-cancel
unconditionally on esc. That meant three common patterns
silently ripped the active turn away:
- Open the slash popup, press esc to dismiss it ->
turn cancelled.
- Run /help to see the key bindings while a turn was
running, press esc when done -> turn cancelled.
- An extension pushed a notify/display line, user pressed
esc to clear it -> turn cancelled.
The esc case now checks, in order:
- slash popup active -> break out of the switch, let the
popup's own esc handler (later in handleKey) close it
- helpBlock or extNotes non-empty -> clear them, invalidate,
return (turn keeps running)
- busy + cancelable -> cancel the turn (old behaviour)
- idle -> fall through to the editor which clears itself
Result: esc feels like a dismiss key that escalates. It nukes
the turn only when nothing else on screen wants it.
No change to dialog handlers \u2014 those already intercept esc in
their own return-false branches before the global switch ever
runs.
Three related tweaks to how the interactive mode drives its redraw
loop, the terminal cursor, and the status-bar layout.
1) Redraw-on-tick narrowed to things that actually animate.
The render loop used to force a redraw every 120ms for every
open dialog (model picker, jump, sessions, btw, etc). Most
of those are static pickers \u2014 the repaint was wasted and had
a visible side effect: the re-emitted hide-cursor / show-cursor
pair at the start of each frame cancels the terminal's blink
cycle for any dialog that hosts its own input field. Concretely
the blinking cursor in /btw never blinked, it just sat as a
steady reverse-video block.
Tick-driven redraw is now only triggered when i.busy (main
spinner animates) or btw.Loading() (side-chat spinner animates).
Static dialogs rely on the dirty-channel invalidations that
fire on key events, which is sufficient because nothing else
on screen is moving.
2) btw side-chat redraws on completion.
Consequence of (1): the btw goroutine that streams a model
response used to depend on the 120ms tick to make the final
answer visible. With the tick gone for idle dialogs, the
answer landed in d.turns but the screen stayed blank until
the user pressed a key.
btwDialog.Open and submit now take an invalidate callback
that the goroutine fires after completeTurn, including the
early-error branch. While the request is in flight,
btw.Loading() returns true so the tick keeps the spinner
animating; once the answer lands, a single invalidate()
redraws and the tick stops.
3) Cursor routed into btw while it's open.
btwDialog already had a CursorPos(width) method that returns
where the side-chat editor's caret sits within the dialog's
rendered rows. The host never used it \u2014 the real terminal
cursor stayed on the main editor below. Now when btw is
active the host picks up CursorPos and points the terminal
cursor there, so the blink shows in the correct input and
the main editor has no cursor.
4) Status bar idle-path spacing.
Idle row used to render as "<pad><pad>(provider) model" =
4 spaces before the model, so the line started at column 4
while busy and idle didn't match. Dropped one pad in the
idle branch; both paths now start at column 2, aligned with
the conversation column (' you' / ' zot' message markers).
No semantic change to transcripts or provider calls. All tests
pass.
Two small polishes.
- StatusBar: the busy branch used to emit three pad groups
before the provider/model block (pad + busyPrefix + pad +
pad). On screen that read as six spaces between the elapsed
counter and "(openai) gpt-5.4", which looked like a layout
bug. Collapsed to two pads so the gap matches the idle path
exactly: 2 spaces before the model block, 2 spaces between
the busy segment and the model block.
- The separator between the spinner message and the elapsed
counter was rendered as a plain unstyled "-", which the
terminal painted in the default foreground colour (white on
my dark theme) and stood out against the muted counter next
to it. Pulled the dash into its own FG256(Muted, ...) call so
the whole tail ("- 15s") shares the same grey tone.
Swept the TUI strings and README for the stray U+00B7 MIDDLE DOT
(\u00b7) separators left over from earlier UI iterations. They read
fine on terminals that render the glyph as a small bullet, but
on some fonts (especially the telegram desktop client, a few
linux terminal fonts) it renders as an off-center dot that
looks like a smudge or a broken pipe. Plain ' - ' is universally
readable and matches every other separator already in the
status bar and dialogs.
Touched:
README.md paragraph separators
modes/btw_dialog.go header joiner
modes/help.go table row separators
modes/interactive.go status bar tags, telegram mirror
modes/jump_dialog.go row separators
modes/login_dialog.go header joiners, status line
modes/model_dialog.go model + source joiner
modes/slash_suggest.go commands list
tui/view.go assorted tui separators
No functional change. go test ./... still passes.
Before: any paste with >= 2 newlines (3+ lines) collapsed to
[pasted text #N +L lines]. That fired too eagerly on short
three-line snippets the user wants to see inline, and never
fired on a 2000-character single-line dump that bloats the
editor just as badly.
New rule matches the shape widely used in other TUIs:
collapse when
lines > 10 OR
chars > 1000
Both triggers produce distinct placeholder shapes so you can
see at a glance which dimension tripped it:
[pasted text #1 +12 lines] line trigger
[pasted text #1 1500 chars] char trigger
When both triggers hit (e.g. 12 lines of 400 chars each) the
line-count shape wins \u2014 "+12 lines" reads more informatively
than a raw character count for multi-line content.
Implementation:
- pasteCollapseLineThreshold = 10
- pasteCollapseCharThreshold = 1000
- pasteShouldCollapse checks countLines(s) > 10 || len(s) > 1000
- formatPastePlaceholder picks the shape; line trigger wins
on ties
- pastePlaceholderRE widened with a non-capturing alternation
(\+\d+ lines?|\d+ chars?) so expansion works for both
shapes; capture group 1 is still just the id
- the strings.Contains fast-path in SubmitValue still matches
both shapes because both start with the same prefix
Tests rewritten around the new thresholds:
- TestPasteCollapseLineTrigger 11 lines -> +11 lines marker
- TestPasteCollapseCharTrigger 1500 chars, 1 line -> N chars
- TestPasteCollapseLinePrecedence 12 lines of 400 chars each ->
line shape wins
- TestPasteCollapseFallthrough 1 line / 2 lines / 10 lines /
1000 chars / 5 lines under
caps all stay inline
- TestPasteCollapseSequentialIDs mixed-shape placeholders
coexist, both expand
- TestPasteCollapseClearResetsMap unchanged logic, updated body
to trigger the new threshold
Running /login used to drop straight into the method / provider
pickers with no indication of what's already logged in. Easy to
accidentally log out a working subscription because you
forgot which provider you'd authenticated with last.
The dialog now takes a snapshot of auth.json when Open() runs
and renders it as a two-line header above both the method step
and the provider step:
login
\u2713 anthropic: subscription
\u2713 openai: api key
choose login method (\u2191/\u2193, enter, esc to cancel):
api key
subscription (claude pro/max \u00b7 chatgpt plus/pro)
Logged-in providers get a green check + their method
("api key" or "subscription"); providers with no credentials
get a muted \u2013 dash + "not logged in". When NEITHER provider is
logged in (first-run, fresh box) the status block is
suppressed entirely \u2014 a pair of "not logged in" rows there is
just noise when the user is already seconds away from picking
a method.
On the provider step each row also gets an inline method tag
so picking it implicitly replaces the existing credential:
login \u00b7 oauth
\u2713 anthropic: subscription
\u2713 openai: api key
choose provider:
anthropic (subscription)
openai (api key)
Implementation:
- New d.status map on loginDialog, populated by Open() from
auth.Credentials.Method(provider) which already returns
exactly the three states we care about: "apikey", "oauth",
or "".
- Open() gained a zotHome string arg so the dialog can
compute auth.json's path without importing the agent
package (which would be a cyclic import; modes is inside
agent). Both callers in interactive.go now pass
i.cfg.ZotHome.
- renderStatusLines() centralises the two-row block so
loginStepMethod and loginStepProvider share it.
Tests: the whole package compiles and vet-clean; go test
./... still passes. No new tests because the dialog is pure
rendering off captured state and the captured-state path is a
one-liner.
Two related changes to how the input handles the arrow keys.
1) History recall is gone.
The editor used to maintain a 200-entry ring of previously
submitted prompts and swap the current draft for a history
entry when up/down hit the top/bottom edge of the buffer.
In practice this mis-fired more than it helped: pressing up
on a single-line draft silently replaced it with an older
prompt. Users expect history recall (when they want it) on
a dedicated key, not as a side effect of cursor navigation.
Dropped: Editor.History, histIdx, savedDraft; PushHistory,
historyPrev, historyNext, findInHistory; every e.histIdx
assignment sprinkled across the mutating methods; the
PushHistory call sites in interactive.go (submit handler
and the slash-selector enter path). Clear() already wiped
the visible buffer, it now also doesn't touch history
because there's no history to wipe.
2) Up/Down navigate VISUAL rows, not logical buffer lines.
The TUI wraps a long single-line input across several
visual rows. Before this patch, up/down only moved between
entries in e.Lines, so when the cursor was on the second
visual row of a wrapped single line, up did nothing
(CursorR was already 0). Users saw their cursor stuck at
the bottom of a wrapped draft with no way to move up
except arrow-left until they wrapped around.
Added moveCursorVisual(dir int) which rebuilds the same
wrapped layout Render produces, records which visual row
the cursor currently occupies, computes the target visual
row = current + dir, then maps the current visual column
onto the rune index inside the target row's slice of its
logical line. Subsumes the multi-line logical-line case:
if a wrapped row straddles a logical-line boundary, the
mapping naturally advances CursorR.
The editor now records the latest width passed to Render so
moveCursorVisual can walk the same layout. Fallback
moveCursorLogical covers the edge case where Render hasn't
run yet (shouldn't happen in practice; kept for safety).
Tests: existing TestEditorCursorAfterMultilinePaste and
TestEditorCursorAfterLongPasteWithWrap still pass because they
test Editor.Insert followed by Render, not up/down traversal;
the new navigation leaves Render's cursor-position math
unchanged.
New built-in /study command that runs a single canned prompt:
"Read and understand everything in the current directory." The
first thing most sessions need is project context, and typing
the full sentence every time is friction; /study turns that
into one keystroke-saving shortcut.
Dispatched through the same queue-or-start path as a typed
prompt, so it behaves identically:
- idle -> startTurn(studyPrompt)
- busy -> queued behind the running turn, delivered next
Also added to the README slash-commands table so /help output
and the top-level docs stay in sync with slashCatalog.
A 200-line paste (log, stack trace, config blob) used to expand
the editor to 200 visible rows, burying the rest of the tui and
making the prompt awkward to edit. Now a paste of 3+ lines is
replaced in the editor with a short token like
[pasted text #1 +56 lines]
while the full body is stashed behind the scenes and expanded
back in right before the turn goes to the agent. Single-line
and two-line pastes fall through to the old inline insert path
(drag-dropped file paths, short snippets) so those still work
as before.
Implementation
Editor gained two private fields:
- pastes map[int]string full bodies keyed by id
- pasteSeq int monotonic id counter
KeyPaste branch: on content with >= 2 newlines, allocates the
next id, stores the raw body, inserts the placeholder token
at the cursor. Everything else stays on the existing
quotePastedFilePaths path.
Editor.Value() returns what's visible (placeholder).
Editor.SubmitValue() new method: runs pastePlaceholderRE over
the visible text and swaps each match
for its stored body. Called once at
submit time; non-destructive so history
recall (up-arrow) still shows the
placeholder form, not the replay.
Editor.Clear() drops the pastes map + resets pasteSeq
so ids from a previous turn can't leak.
SetValue() same reset: pastes map is tied to the
visible text and a SetValue replaces it.
interactive.go the one caller that reads the editor to
build a prompt now reads Value() for the
history entry and SubmitValue() for the
string that goes to the agent.
Tests
paste_collapse_test.go covers:
- placeholder shape + SubmitValue expansion
- single-line and two-line pastes skip the collapse path
- two separate pastes get distinct ids, both expand
- Clear() resets the map + counter
Tweaked the pre-existing TestEditorCursorAfterMultilinePaste
and TestEditorCursorAfterLongPasteWithWrap to use Editor.Insert
directly so they keep testing wrap / cursor math rather than
accidentally exercising the new collapse path.
CheckForUpdate used to trust any cache entry less than 12h old
whose CurrentAt matched the running binary. Problem: if a user
installs v0.0.72 at 16:05 (cache: up-to-date) and a v0.0.73
release ships at 18:27, relaunching zot at 18:29 hits the
fresh cache and never calls the github api \u2014 the update
banner stays hidden until either 12h pass or the binary is
rebuilt. Noticed in the wild: v0.0.73 latest, zot 0.0.72
running, no banner.
Refined logic: only short-circuit on a cached entry when it
already reports Available=true. If the cache says "up to
date" we let the flow fall through to fetchLatestRelease and
reconcile. The api call is a single ~4s timeout request;
cheap enough to do on every up-to-date launch, and the common
"cache already shows available" path still skips the network
entirely.
Also: manually cleared /Users/pat/Library/Application
Support/zot/update-check.json on my local box so the next
launch sees the new v0.0.73 release without waiting; that's
user state, not something to commit.
A single ctrl+c during a busy turn used to cancel the turn
(same as esc). That misfired a lot in practice because ctrl+c
is reflex muscle-memory ("be quiet" in a shell) rather than a
deliberate decision to kill a multi-minute model call you have
already paid tokens for. Users kept aborting expensive turns by
accident.
New behavior:
- busy + first ctrl+c -> arms the exit hint, status line
reads "press ctrl+c again to exit,
esc to cancel the turn"; the turn
keeps running.
- busy + second ctrl+c (within ctrlCExitWindow = 2s)
-> exits zot.
- busy + esc -> cancels the running turn (unchanged).
- idle + ctrl+c -> clears editor/queue as before;
second press within 2s exits.
The double-tap-to-exit pattern now works the same from busy and
idle, which also matches the habits from python repls and
similar tools.
Also:
- assistant body keeps a 4-cell right gutter that mirrors the
4-space left indent so wrapped prose sits in a symmetric
column instead of kissing the terminal edge on ultra-wide
windows. The prose cap itself is gone; the new
assistantBodyRightPad constant replaces maxAssistantWidth.
- README Keys table + Queued messages paragraph updated to
describe the new ctrl+c / esc split so the docs match the
code.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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'.
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.
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.
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.