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.
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 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.
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.
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".
Phase 1: extensions can register slash commands and push chat
notifications. Tools and event subscriptions land in later phases.
Architecture: each extension is its own subprocess. Zot launches
it on startup, completes a hello/hello_ack handshake over its
stdin/stdout, then routes slash commands the extension registered.
Crash isolation, language agnostic, works with any executable
that can read/write json lines.
What lands here:
- internal/extproto: shared wire-format types (Frame, HelloFromExt,
RegisterCommandFromExt, CommandResponseFromExt, NotifyFromExt,
HelloAckFromHost, CommandInvokedFromHost, ShutdownFromHost...).
Both the host and the SDK marshal/unmarshal the same types.
- internal/agent/extensions: discovery + lifecycle manager.
- Discover() walks $ZOT_HOME/extensions and ./.zot/extensions
(project-local first, global second; first wins for duplicates)
- Spawns each enabled extension, captures stderr to
$ZOT_HOME/logs/ext-<name>.log
- Reads frames in a goroutine, dispatches register_command and
notify, correlates command_response by id
- Stop() sends shutdown, waits 2s, then SIGTERM/SIGKILL
- HostHooks abstracts the tui callbacks (Notify/Submit/Insert/Display)
- Interactive bridge: extensions slot into the slash dispatcher
*after* the built-in catalog, so built-ins always win on conflict.
Extension-registered commands also flow into the autocomplete
popup and /help via slashSuggester.SetExtra. NotifyFromExt frames
render as muted [ext-name] notes above the editor.
- internal/agent/extcmd: `zot ext` CLI.
list / install <path|git-url> / remove / enable / disable / logs
- pkg/zotext: public Go SDK. Construct an Extension, register
Command(name, desc, fn), call Run(). Fn returns a Response built
with Prompt(), Insert(), Display(), Noop(), or Errorf(). Stderr
via Logf() so stdout stays clean for the protocol.
- examples/extensions/hello: working Go example registering /hello
and /summon, plus README + extension.json.
- docs/extensions.md: full protocol reference, including a
~30-line raw-Python example for users who don't want the SDK.
Tests: internal/agent/extensions/manager_test.go spawns a mock
extension via /bin/sh and exercises the full handshake -> register
-> invoke -> response cycle. Verifies the hello frame ordering,
correlation-by-id, and graceful shutdown.
Verified manually: built and installed the example, drove it via
stdin pipes, confirmed clean handshake + correct frame ordering
and shutdown_ack. Builds vet-clean on darwin / linux / windows.
Editor.Insert exported (was Editor.insert) so the extension hooks
can drop text into the input.
status bar:
every horizontal gap is now exactly 2 spaces, applied uniformly
via a single pad constant. before:
1 leading | 6 between model+stats | 4 between stats+cwd
after:
2 leading | 2 between model+stats | 2 between stats+cwd
produces e.g. " (openai) gpt-5.4 $0.000 (sub) 0.0%/400k ~/Sites/zot"
matching the editor's left inset so the bar lines up vertically
with the conversation column.
assistant body:
user-role messages render their text with a 4-space indent under
the "you" header. assistant text was rendering flush left, so the
conversation column visibly broke at every assistant turn. now
both renderMessage's RoleAssistant branch and Build's streaming
path:
- reduce the wrap width by the indent (4 cells)
- prefix every produced line with the indent
applies to plain text, markdown-rendered code fences / lists /
blockquotes, and tool-call summary lines (>>= name args). tool
result blocks were already indented and stay unchanged.
dragging a file onto the terminal pastes its path via bracketed
paste. before this, agents would receive raw paths with literal
spaces and parens that shells/tools then misinterpreted (e.g.
"/Users/pat/foo bar.png" parsed as two arguments).
the editor's KeyPaste handler now runs pastes through
quotePastedFilePaths which:
- inspects whitespace-separated tokens of single-line pastes
- normalises file:// urls (decode + scheme strip), backslash
space escapes (the macOS Terminal default for drag-drop), and
pre-existing single/double-quoted forms
- wraps tokens that look like paths (start with / or ~ and
contain a separator, no shell metacharacters) in single
quotes, splicing 'foo'\''bar' for embedded apostrophes
- leaves prose, multi-line code pastes, and anything with
metachars untouched
regression suite covers the 12 cases that surfaced while
implementing: backslash-escaped spaces, file:// urls, tilde
paths, multi-file drops, pre-quoted paths, embedded apostrophes,
plain prose, multiline pastes, metachar smuggling attempts, and
path-mixed-with-prose. all pass.
verified the build, vet, gofmt, and go test pipeline on darwin,
linux and windows targets.
two new ways to embed the zot agent runtime in third-party apps:
1. pkg/zotcore - public Go SDK
- Runtime type: New(Config), Prompt(ctx,text,imgs)->chan Event,
Cancel, Compact, SetModel, State, Messages, Cost, ListModels,
Close. Concurrent-safe; one prompt at a time per Runtime,
ErrBusy if you try to overlap. Spawn multiple Runtimes for
multiple projects.
- Public types mirror the JSON-RPC wire schema 1:1 so consumers
can share parsing code with the out-of-process clients.
- Internal core/agent/provider stay internal; SDK is a thin
facade that exposes only what's stable.
2. zot rpc subcommand - newline-delimited JSON on stdin/stdout
- 'zot rpc' (or 'zot --rpc') turns the agent runtime into a
subprocess that any language can drive via pipes.
- Commands: hello, prompt, abort, compact, get_state,
get_messages, clear, set_model, get_models, ping. Each
optionally carries an id; the matching response echoes it.
- Stream notifications: turn_start, user_message,
assistant_start, text_delta, tool_call, tool_progress,
tool_result, assistant_message, usage, turn_end, done,
error, compact_done. Same shape as the existing --json mode
events (modes.EventToJSON / ContentToJSON were exported
for reuse).
- Auth: optional ZOTCORE_RPC_TOKEN env var; first command
must be hello {token: ...} when set. Without the env var
the spawning process is implicitly trusted.
- Concurrency: one prompt or compact at a time per process,
enforced by a turnMu mutex. abort fires immediately
regardless. Stdin close exits the process.
3. docs/rpc.md - full schema reference
4. examples/rpc/{python,node,shell,go} - reference clients
5. examples/sdk - in-process Go embedding example
6. README updated with a new modes entry and an embedding section
/jump:
- new slash command; opens a picker listing every user turn in
the current session (timestamp relative, tool count badge, first
line of the prompt). \u2191/\u2193 + enter scrolls the viewport to put
that turn's user-message header at the top row. non-destructive,
transcript untouched
- runes extend a live filter; backspace shortens. '/jump <text>'
pre-applies the filter; exactly-one-match auto-jumps without
showing the picker
- while parked on a past turn the scroll-up note reads 'viewing
turn N of M \u00b7 pgdn to catch up' instead of the generic row
count. scrolling back to the tail (or starting a new turn, or
/clear) resets the parked state automatically
- view.go: new MessageAnchor type + BuildWithAnchors so the dialog
can resolve msgIdx -> first rendered row
perf for long transcripts (the whole ui stutters on ~50 messages):
- view.renderCache: per-message memoisation keyed by (fnv1a of
role+content, width, expandAll). finalised messages never change
so the cache hit rate is ~100% after the first render. streaming
partials and in-flight tool-call views stay uncached by design
- BuildWithAnchors now pre-sums line counts and allocates
in a single make() instead of 50 appends with log2(N) backing-
array memcpys
- truncateToWidth fast path: byte-length <= cols implies cell-width
<= cols, so we skip the rune-width loop entirely. covers the huge
majority of lines in a session
- cache purged on /clear, /compact completion, and session swap
(applySessionSelection); resize invalidates implicitly via the
width key. LRU eviction at 4x message count caps memory
impact: a 50-msg / 2000-line transcript went from unresponsive-
while-typing to drawing in well under a frame. measured locally
with go-perf traces; no change to correctness.
- rewrite resize_unix.go on top of golang.org/x/sys/unix so the
peek-stdin helper compiles on linux (Select returns (int, error),
Timeval.Usec is int64) as well as darwin (int32, error-only)
- promote golang.org/x/sys to a direct dep
- gofmt -w . (11 files of alignment drift from recent edits)
- install.sh / install.ps1: accept $GITHUB_TOKEN so the installers
work against the repo while it's private; no-op on public repos
- README: document the private-repo install paths (PAT for curl|bash
and powershell, GOPRIVATE for go install)