Each tool block now renders as a labelled box:
\u250c\u2500 bash ls -la \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 $ ls -la \u2502
\u2502 total ... \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
instead of the open top/bottom horizontal rules used previously.
The tool name + short args are embedded in the top edge so each
call has a self-contained frame; body lines get vertical edges
with a 1-cell inner gutter; image escapes are passed through
un-bordered so the graphics layer isn't smeared.
Each tool_result message owns its complete box (top + body +
bottom). When the model batches multiple tool_use blocks in one
assistant message, the assistant message emits no box pieces \u2014
each result renders its own adjacent box, looking up the call's
label from a new toolCallLabels map. This avoids the prior bug
where two tool_use blocks in a row produced two unclosed top
edges with no body between them.
The streaming live overlay (renderToolCall) draws the same
top/body/bottom directly so an in-flight write/edit looks the
same as the finalised transcript form. Body lines have their
first 2 leading-indent cells stripped so content lands at a
consistent column inside the box rather than drifting right.
Inter-message blanks come from Build()'s natural separator;
no extra blanks are emitted around boxes, so two consecutive
boxes are separated by exactly one blank row.
normalisePathToken's heuristic only fires for unix-shaped paths
(leading /, ~, or file:// with a / path). On Windows, t.TempDir()
returns paths like C:\Users\RUNNER~1\AppData\Local\Temp\...
which never match those prefixes, so the test inputs never get
quoted and every assertion fails. The function is unix-only by
design (drag-and-drop on macOS/Linux); skip the test on Windows
rather than carry a parallel Windows test that would just
duplicate platform-specific quoting logic.
normalisePathToken used a purely syntactic heuristic ("starts with
/ or ~ and contains / or .") to decide whether a pasted token was
a drag-dropped file path. URL path segments pasted from a browser
address bar (e.g. /de/downloads/dokumentenarchiv) matched that
shape and got collapsed to a [file:N:basename] chip or wrapped in
single quotes, which is never what the user meant.
Add a pathExists check (with ~ / ~/ expansion via os.UserHomeDir)
and gate normalisePathToken on it. Real drag-and-drop from a file
manager still works because those paths exist; URL fragments and
fictional paths fall through untouched.
Rewrote quote_paste_test.go to build fixtures under t.TempDir() so
the existence check has something real to stat, plus regression
cases for URL-segment and non-existent-path pastes.
scrollOffset is measured from the bottom of the chat buffer, so when
the agent appends new lines while the user has scrolled up to read
history, the visible window slides down through the buffer and the
content the user was reading drifts off the top.
Track the previous chat line count and column width across redraws.
While the user is in free-scroll (scrollOffset > 0) and the terminal
hasn't been resized, bump scrollOffset by the chat-length delta so
the visible content stays pinned. Compensation is skipped on resize
(line counts aren't comparable across reflows) and when following
the tail (scrollOffset == 0), where new content should keep pushing
the viewport as before.
Each assistant API message used to draw its own "zot" header, so a
turn that round-tripped through several tool_use / tool_result pairs
showed multiple headers (or, when the first assistant message was
pure tool_use, the header appeared late and earlier tool blocks
visibly snapped underneath it once the model started emitting text).
Track whether the previous non-compaction message in the transcript
belongs to the same turn (assistant or tool role) and suppress the
header on subsequent assistant messages and on the streaming overlay
while a turn is already open. Header now marks the speaker boundary
(you <-> agent), not the underlying API message boundary.
SubmitOrQueue was discarding the images parameter. Now passes them through to startTurnWithImages so photos sent via Telegram are included in the prompt.
Forces full attribute reset before clearing each row when selection highlights are present. VS Code's xterm.js doesn't reliably clear background colors on row overwrite without an explicit reset.
Press r to rename the selected session. Uses append-based rename lines so the active session is not corrupted. Native terminal cursor in rename input. Title shown in picker if set.
Login and logout dialogs show descriptive labels (Anthropic Claude Pro/Max, OpenAI ChatGPT Plus/Pro). Unknown saved providers fall back to an available one instead of crashing. Model picker only shows models from logged-in providers.
Version strings like '0.1.12 (25b2bd4, ...)' were used as-is for GitHub API lookups and comparisons, causing changelog to never show. Now strips to semver only.
Changelog dialog now shows only the changelog section from release notes with headings in accent color. Works for local 0.0.0 builds (fetches latest release). Full-width highlight bars fixed everywhere via erase-to-EOL and trailing ANSI preservation in truncateToWidth. Session ops dialog fixed. README documents the @ file picker.
Type @ to browse files in the working directory. Up/down navigate, right arrow opens directories, left arrow goes back, enter selects. Files insert as [file:name], directories as [dir:name/]. Drag-dropped folders also show as [dir:] chips. Separate counters for file and dir chips.
Drag-dropped or pasted file paths are shown as compact [file:name] chips in the editor instead of the full path. Expanded back to the full quoted path on submit. URLs are never collapsed.
Wraps OAuth clients with a RefreshingClient that checks token expiry before every Stream call. Refreshes transparently and rebuilds the underlying client with the fresh token. Fixes sessions silently dying after the 1-hour token lifetime.
Adds --provider ollama with auto-detection of local ollama at localhost:11434. No API key required for local models. Optional --api-key and --base-url for remote/authenticated instances. Uses the OpenAI chat completions client internally. Unknown models are accepted without catalog entries. Updated README with ollama documentation.
Calls Invalidate() on tool result, assistant message commit, and turn end to prevent stale code fragments from bleeding into the status bar when the diff renderer misses row changes.
Adds baseUrl support in models.json for local models (ollama, vLLM, etc). Migrates all install URLs and references from zot.patriceckhart.com to www.zot.sh.
Reads $ZOT_HOME/models.json at startup and merges user-defined models into the active catalog with highest precedence. Provider keys like openai-codex are normalized. Documented in README.
Reduces gutter padding between line numbers and code, replaces tab characters with 4 spaces in code panels so Go and other tab-indented languages render at consistent width.
Watches for context cancellation in a goroutine and kills the entire process group plus closes the pipe immediately, instead of waiting for cmd.Wait() which deadlocks when child processes hold the pipe open.
Compaction no longer streams the summary into the chat. The spinner shows while compacting and users can queue prompts that fire after completion. The compaction info appears in the status line with ctrl+o to expand. Orphaned tool_result blocks in the preserved tail are stripped to prevent Anthropic rejection.
Sets Setpgid on bash commands and sends SIGTERM/SIGKILL to the negative pgid so backgrounded children are cleaned up on esc. Also splits the status bar onto multiple lines on narrow terminals when a spinner is active.
Makes zot --help use zot's TUI palette and section layout, pads columns consistently across terminals, expands section rules to terminal width, and adds extension install guidance in the help output.
Adds the todo panel example under examples/extensions, updates example manifests and READMEs to match the current extension API, and surfaces extension install/load commands in zot --help.
Runs the local callback server and the manual copy-code flow in parallel. Displays a real input field (with blinking cursor) for pasting the authorization code / redirect URL / code#state. Anthropic manual variant uses the console copy-code redirect URI to bypass localhost.
Add open_panel / panel_render / panel_close / panel_key to the extension protocol, expose extension_dir + data_dir in hello_ack, wire panel rendering and key routing through the interactive TUI, extend the Go SDK, and document the new capability. Also fix doubled user-message indent and redundant assistant wrap.
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.