Commit graph

28 commits

Author SHA1 Message Date
patriceckhart
4dde65079d tui: bump welcome version-suffix duration to 1.5s, shorten ctrl+c hint
- welcomeVersionDuration is now 1500ms (was 1s). still ephemeral
  but gives a touch more time to read the splash version on
  faster terminals.
- ctrl+c "input cleared - ctrl+c again to exit" is now just
  "input cleared". the second-press behaviour is still wired and
  documented in /help; the long suffix was visual noise on a
  status line that already changes shape with each keystroke.
2026-04-19 13:26:16 +02:00
patriceckhart
6a4166429e tui: show binary version in the welcome banner for 1 second
splash variant of the welcome line:
  ▌ i'm zot (0.0.6). yet another coding agent harness.
reverts to:
  ▌ i'm zot. yet another coding agent harness.
after welcomeVersionDuration (1s). a one-shot AfterFunc fires
i.invalidate() so the banner refreshes on its own even if the
user has not typed anything by then.

dev builds without ldflags-injected version still print "(0.0.0)"
during the splash; release tags propagate through the makefile
into main.version normally.

bundles a small printhelp tagline tweak that was already staged.
2026-04-19 13:22:10 +02:00
patriceckhart
154420e938 tui: align /help descriptions across slash commands and keys
each section had its own label column width (10 vs 14) so the
descriptions formed two staircases at different x-positions.
compute one shared width from the longest label across BOTH
lists (with a 14-cell minimum so future entries don't compress
the column visually) and pad every label to it. now every
description starts at the same column.
2026-04-19 13:14:28 +02:00
patriceckhart
c4251d88fd tui: align status-bar spacing and indent assistant body
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.
2026-04-19 13:10:16 +02:00
patriceckhart
36e0efe9ea tui: quote drag-dropped file paths in the input editor
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.
2026-04-19 13:01:37 +02:00
patriceckhart
5a2cfb525e fix: keep transcript and cumulative cost across cross-provider /model swap
applyModelSelection's cross-provider branch built a fresh agent via
BuildAgentFor and assigned it to i.agent without copying anything
from the old one, so the user perceived /model anthropic->openai (or
vice versa) as a hard reset of the entire conversation. same-provider
swaps already worked because they only mutated agent.Model in place.

now the cross-provider path snapshots agent.Messages() and agent.Cost()
before constructing the replacement, then SetMessages + SeedCost on the
new agent so the transcript and the cumulative dollar meter both carry
across.

added core.Agent.SeedCost(provider.Usage) for the cost transfer.
messages already round-trip cleanly because tool names are normalised
to lowercase in the in-memory provider.Message representation; the
oauth-only Read/Write/Edit/Bash rename happens on the wire at request
build time, not in the transcript.

verified end-to-end via zot rpc:
  turn 1 (opus):    "remember teal" -> "ok"
  set_model:        opus -> sonnet
  turn 2 (sonnet):  "what color did i say?" -> "teal"

both same-provider (opus<->sonnet) and cross-provider (anthropic<->openai)
paths exercised.
2026-04-19 12:47:12 +02:00
patriceckhart
3ff6d9e6b7 fix(anthropic): drop claude-code identity cache marker
oauth requests now exceed anthropic's 4-breakpoint cache_control
limit when the conversation has 2+ user messages. previous layout
emitted 5 markers: identity + system + tools + 2 user messages.

drop the marker on the small claude-code identity line. it's a few
tokens and gets folded into the cached prefix implicitly when the
request matches turn-over-turn anyway. budget now: system + tools +
last 2 user messages = 4. fits.

reproduces the user-reported error:
  anthropic: http 400 ... A maximum of 4 blocks with cache_control
  may be provided. Found 5.

verified by sending two consecutive prompts through zot rpc on an
oauth credential -- first turn returns the assistant message
cleanly, second turn does too instead of 400ing.
2026-04-19 12:39:33 +02:00
patriceckhart
ebc5dad18c fix(rpc): wait for in-flight commands on stdin close, drop dup events
three real issues found by actually running zot rpc end-to-end:

1. piping a single command into the process and letting stdin close
   would race the agent loop against the process exit, swallowing
   the entire prompt response. fix: track in-flight prompt and
   compact goroutines in a sync.WaitGroup; run() now waits on it
   before returning.

2. each prompt emitted two consecutive done frames - one from the
   agent loop EvDone passing through EventToJSON, one added at the
   end of runPrompt. suppress EvDone in the prompt sink so only the
   explicit terminator remains.

3. cancelling a turn produced a spurious error frame on top of the
   turn_end stop=aborted that already carried the cancellation.
   suppress error frames when the underlying error is
   context.Canceled, in both prompt and compact paths.

verified manually: ping, get_state, get_models, set_model
(valid + invalid id), clear + get_messages, abort, malformed json,
unknown command, auth gate (missing/wrong/correct token),
stdin-close-while-prompt-running, in-process SDK Prompt, plus all
four reference clients parse cleanly and shell + python actually
drive the protocol.
2026-04-19 12:35:13 +02:00
patriceckhart
a670442c9e feat: zotcore SDK + zot rpc subprocess protocol
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
2026-04-19 12:26:48 +02:00
patriceckhart
d6ac856ee7 tui: switch busy spinner to cli-spinners 'dots3' preset
a single-cell 10-frame braille rotation at 80ms per step. matches
the well-known dots3 preset from sindresorhus/cli-spinners (MIT).
small, recognisable, occupies one terminal cell so the busyPrefix
in the status bar stays compact regardless of message length.
2026-04-19 11:52:41 +02:00
patriceckhart
fdef8ac614 core: drop empty session files instead of writing meta-only stubs
every zot launch was creating a session file with just a meta line,
even when the user exited without prompting. /sessions and ls -la
ended up showing dozens of empty entries.

now Session tracks messagesAppended and freshFile (true for
NewSession, false for OpenSession). Close() removes the file when
both conditions hold: this process created it AND no messages
were ever appended. resumed sessions are never auto-deleted even
if the resume run added nothing, since the prior content is real.

PruneEmptySessions sweeps existing meta-only stubs from the cwd's
session dir on each interactive launch. cheap (only reads enough
of each file to find a 'message' line) and fixes the existing
backlog automatically the first time you reopen zot in a project.
2026-04-19 11:38:57 +02:00
patriceckhart
0a69274cd6 tui: park viewport on the last turn after resume
opening a session via /sessions (or starting with --continue /
--resume / --session) used to drop the user at the live tail
(scrollOffset = 0). on a long session that means the visible
viewport showed only the last few rows of the final assistant
reply -- the rest of the conversation was loaded into the agent
correctly but invisible without scrolling. users read this as
'resume only restored a one-liner'.

now after both code paths we compute the row offset of the last
user message and park the viewport so that user prompt sits at
the top of the chat area, with the assistant's last reply right
below. older history is one scrollup away; pgdn or arrows snap
to the live tail. the existing 'viewing turn N of M' footer
shows up automatically since parkedTurn / parkedTotal are set.

shared scrollToLastTurn helper used by both the /sessions picker
(applySessionSelection) and Run() startup. the old applySession
code is now a thin wrapper that just invalidates caches and
delegates to scrollToLastTurn.
2026-04-19 11:34:40 +02:00
patriceckhart
75ed9d87c7 tui: render the final 'turn finished' frame without needing a nudge
two bugs in the redraw throttle were eating the post-turn frame:

1. requestRedraw, when it could redraw immediately (since >=
   redrawMinInterval), didn't reset pendingRedraw or stop the
   pendingTimer. so subsequent invalidates within redrawMinInterval
   saw pendingRedraw == true and were dropped, leaving the ui
   stale until a key event or scroll triggered a fresh path.

2. the tick branch only called drainPending while busy or with an
   open dialog. once a turn finished and i.busy went false the
   tick stopped clearing the pending flag, so any redraw that
   ended up scheduled (because the dirty channel was saturated
   under streaming load) had no second chance to fire.

fix:
- requestRedraw now stops the timer and clears pendingRedraw
  whenever it does an immediate redraw. throttle state stays
  consistent.
- tick branch always drains pending, busy or not. requestRedraw
  on top is still gated on busy/dialog so the spinner keeps
  animating without forcing a redraw on every tick when idle.

reproduction: long turn finishes -> spinner stops, but the final
assistant text only appears when the user scrolls or types a key.
fixed by both changes; either one alone leaves a small race window.
2026-04-19 11:31:03 +02:00
patriceckhart
d653cc179d tui: slash commands work while a turn is in flight
previously typing any slash command during a turn was rejected with
"cancel the current turn (esc) before running a slash command".
annoying and unnecessary for read-only commands, and the
destructive ones can cancel the turn for you.

read-only commands run immediately, parallel to the streaming
turn: /help, /jump, /sessions, /lock, /unlock, /exit.

destructive commands trigger cancelAndWaitForIdle first (cancels
the turn ctx, polls i.busy at 10ms intervals, gives up after 2s
as a safety cap so a wedged http stream cannot freeze the ui
forever): /clear, /compact, /login, /logout, /model. once the
turn goroutine has wound down they run on the now-quiet agent.

slashCancelsTurn(head) in slash_suggest.go is the single source
of truth for which commands need the wait. readme updated to
match the new behaviour.
2026-04-19 11:21:37 +02:00
patriceckhart
64704875d2 tui: /jump to scroll to past turns, render cache for long transcripts
/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.
2026-04-18 12:22:16 +02:00
patriceckhart
8e546bde70 tui: show 'update available' banner at top of chat
- internal/agent/update.go: check github releases api for a newer
  tag than the compiled-in version, cached in $ZOT_HOME/update-check.json
  with a 12h ttl so startup never hits the network twice. honours
  $GITHUB_TOKEN for the window while the repo is private; falls back
  to silent no-op on any failure. skipped entirely on dev builds
  (version = 0.0.0 or dev)
- internal/agent/modes/update_banner.go: yellow-framed block with
  the new version, the current version, the one-liner install
  command appropriate for the platform, and a link to the release
  page. rendered above the welcome / transcript so it's the first
  thing the user sees
- wired through via InteractiveConfig.UpdateInfoChan to avoid an
  import cycle (modes -> agent). cli.go kicks off the check async
  and feeds the result in

note: no 'dismiss' key yet \u2014 the banner stays until you update to
the shown version. if the nagging gets annoying we can add a
per-version dismiss cache later.
2026-04-18 11:49:22 +02:00
patriceckhart
728cf4e2b1 tui: allow chat scroll (up/down/pgup/pgdn) while the agent is busy
the up/down handlers required !i.busy before routing to scrollBy,
which meant you couldn't scroll back through a long streaming reply
while it was still arriving. dropped the busy check \u2014 chat scroll
now works in both states, consistent with pgup/pgdn which never
had the restriction.
2026-04-18 11:33:05 +02:00
patriceckhart
c0f685f498 tui: show /help at the bottom of the transcript instead of the top
the help block was prepended to the chat, which pushed any existing
conversation off the top of the viewport on anything but the
shortest sessions. appending it (with scrollOffset=0 so the viewport
sticks to the bottom) means /help is always visible right above the
editor, exactly where the user's eye is already looking.

login / model / sessions dialogs already render in the bottom-sticky
band between chat and editor, so they weren't affected.
2026-04-18 11:31:06 +02:00
patriceckhart
c0adbc4315 fix ci on windows: close reopened session in TestSessionRoundTrip
OpenSession returns a Session whose writer holds an append handle to
the jsonl file. The test never closed it, so t.TempDir's cleanup hit
'The process cannot access the file because it is being used by
another process' on windows (posix happily deletes open files).
Register a t.Cleanup that closes the reopened session.
2026-04-18 11:01:42 +02:00
patriceckhart
682c64f494 fix ci on windows: split detach helper into posix/windows variants
syscall.SysProcAttr.Setsid is posix-only — unknown field on windows.
Extracted the detach-on-start logic into a detachChild function
variable, implemented in botcmd_unix.go (Setsid) and botcmd_windows.go
(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP creation flags).
2026-04-18 10:58:10 +02:00
patriceckhart
2158c272af fix ci: portable syscall.Select via x/sys/unix; gofmt pass
- 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)
2026-04-18 10:55:42 +02:00
patriceckhart
6324668df8 add auto compaction 2026-04-18 10:34:08 +02:00
patriceckhart
dbe6763736 add collapsible code blocks 2026-04-18 10:30:29 +02:00
patriceckhart
6bb3e9e23f fix code formatting 2026-04-18 10:23:02 +02:00
patriceckhart
a4e6cde56f fix code highlighting 2026-04-18 10:16:06 +02:00
patriceckhart
091d5df5ef add logo to callback page 2026-04-18 10:15:53 +02:00
patriceckhart
d8a0cba4fc add telegram bot bridge 2026-04-18 09:15:46 +02:00
patriceckhart
6cced27476 initial commit 2026-04-17 20:36:38 +02:00