Chat view
An agent pane drawn as messages with a reply box: launch flags, the transcript followers, sending, waiting, messages and tool calls
An agent pane that Ovvi started can show its agent as messages with a reply
box, in place of its terminal. The agent keeps running in its herdr pane. The
chat is a second way to look at the same process, so a switch ends nothing.
It works the same on This Mac and on remote machines, for Claude Code, Codex,
Pi and OpenCode. What is not built yet is in
Future work. Research:
chat-view.md
and
agents/.
Which panes have it
- Agents that the New pane chooser, Start Agent Here or Relaunch with Chat started with Ovvi's flags (Launch flags). The second row of their card header has the Chat | TUI switch (Switching views).
- The app knows such a pane by a marker in the argv of its foreground
processes, from
pane.process_info(Program icons). This also works after the app relaunches, with no state kept in the app. A new launch in the same pane is a new chat. - Some agents drop their arguments: a Node CLI that sets its process title
(pi from npm) overwrites them on macOS, and herdr lists none. So the app
also keeps the launch it typed, per terminal. The first time herdr lists a
process named as the agent's command (by its name,
claude.exetoo, or itsargv[0]), the launch takes those processes. While they run, the pane is that launch's chat. An agent started again by hand in the terminal is not. The loader goes as soon as herdr reports the agent; the app looks for the launch's processes behind it, every 0.5 s for 10 s at most. The bare-command fallback has no launch, and a start that ends without the agent (the shell came back, the pane closed) drops its launch. Bound with no marker in sight, the launch also goes to herdr as the pane tokenovvi-chat(its command, marker and process ids, no time limit), so it outlives the app and a reconnect: the app reads it back at the first sight of the terminal, and the processes still have to run. It goes when herdr's server stops. - Every other pane shows only its terminal and no switch: plain terminals, agents started by hand or from herdr's TUI, and agents that herdr restores after a cold restart (herdr runs them again without the flags). An agent among them whose session the app knows shows Relaunch with Chat in place of the switch.
- The bare-command fallback. If the shell is in front again at once after the launch line (two checks in a row, from 1 s on), the chooser types the bare command once more, for every agent. An agent build that refuses Ovvi's flags then starts with no chat, not with no agent.
- An OpenCode build that ignores plugins (a vendor's own build) sends no
state line. After 15 s the chat says "OpenCode has not reported
its session" and the card flips to its terminal, once. The header shows
Error only while the card shows the chat. A late line (a slow first plugin
install) clears it when the card shows the chat again. The app does not
check the build up
front: its own
command -v opencodeis not the pane shell's, where an alias or a function wins.
Relaunch with Chat
An agent that runs without Ovvi's flags can start again with them, in the
same session. The Relaunch with Chat button sits where Chat | TUI sits,
with the help tag "Restart ‹Agent› with chat, in the same session". It
shows when herdr reports the agent, the app knows its session, the pane's
processes carry no marker, and the agent takes that session value. The
session is herdr's agent_session of the same agent, else the UUID after
resume in the agent's command line (codex resume <id>): Codex runs the
hook that reports its session to herdr only at its first prompt, a resumed
session's too. A session picked in codex resume's picker (or --last)
shows the button from its first prompt. Until then, a session switched to
inside Codex (/new, /resume) relaunches as the command line's. An agent in another pane runs it with ovvi chat <pane>,
which never asks: it refuses a working or waiting agent with busy
(CLI).
| Agent | Session from herdr | Added to the launch line |
|---|---|---|
| Claude Code | a UUID | --resume <id>, in place of --session-id T |
| Codex | a UUID | resume <id>, after the -c flags |
| Pi | the session file, an absolute path with no quote and no backslash | --session '<file>' |
| OpenCode | its session id | --session <id>, before --port |
- While the agent works or needs you, an alert asks first: "Restart ‹agent›?", with Restart and Cancel.
- The app makes the launch line first (for Pi and OpenCode with its run on the machine). If that fails, the notice "‹agent› could not start with chat" shows, and the agent keeps running.
- The card, or the floating window of a floated pane, shows the chooser's
loader. The app sends the Stop keys if the agent works or waits, then ⌃C
to clear the prompt, then types the quit command (
/exitfor Claude Code and OpenCode,/quitfor Codex and Pi) while the agent still runs. - When the shell is in front again and herdr no longer reports the agent, within 30 s, the app types the launch line. Otherwise the notice "‹agent› did not quit" shows. The wait is long because an agent runs its exit hooks before it ends (Claude Code's SessionEnd hooks), and each herdr call can take a second on a busy Mac.
- The loader waits for the agent as for a new one (New pane). There is no bare-command fallback: a plain command would start a new session.
- The card (or the floating window) opens on the chat, because the user asked for it.
When a step fails, a notice says why for 6 s, with Retry, which runs Relaunch with Chat again with the same agent and session (also when the agent has ended and the shell is in front):
| Notice | When |
|---|---|
| "‹agent› could not start with chat" | step 2 failed |
| "‹agent› did not quit" | step 4 ran out |
| "‹agent› did not start again" | the shell came back at once after the line (the agent refused it, or the command is not found in the pane's shell) |
| "‹agent› started again without chat" | herdr reports the agent, but none of its processes carries the marker or is named as the agent (after the 10 s search) |
| "‹agent› is not back after 10 s" | herdr reports no agent yet; the chat still opens if it comes |
- With no session from herdr there is no button. The app does not look for one in the agent's files.
Launch flags
T is a new lowercase UUID per launch. The chooser types the line into the
pane's shell, so the user's own alias or function for the command still runs
(for example a managed codex alias). The line reads the same in sh, bash,
zsh and fish (OpenCode: fish 3.4 or later), because its hooks hold no quote
and no backslash.
| Agent | Written first | The launch line | Marker |
|---|---|---|---|
| Claude Code | nothing | claude --session-id T --settings '<json>': inline hooks. A SessionStart hook appends the session's JSON (with transcript_path) to hooks.jsonl. A PermissionRequest hook for AskUserQuestion only waits for the chat's answer (Waiting) | the hooks' folder /.cache/ovvi/chat/T in the --settings value |
| Codex | on a resume, the session's rollout in hooks.jsonl | codex -c 'hooks.SessionStart=[…]' -c 'hooks.state={…}': a SessionStart hook that appends the session's JSON to hooks.jsonl, and that hook's trust hash | /.cache/ovvi/chat/T/ inside a hooks.SessionStart= argument |
| Pi | ~/.cache/ovvi/pi-ovvi-<sha8>.ts | pi -e ~/.cache/ovvi/pi-ovvi-<sha8>.ts --ovvi ~/.cache/ovvi/chat/T/pi.jsonl | that --ovvi path, in the argv of pi or pi.real |
| OpenCode | ~/.cache/ovvi/opencode/ovvi-tui.js and its tui.json; the launch's password file chat/T/pw | OVVI_OC_STATE=…/state.jsonl OPENCODE_TUI_CONFIG=…/tui.json OPENCODE_SERVER_PASSWORD=$(cat "…/pw") opencode --port 0 --hostname 127.0.0.1 -- ovvi-T | -- ovvi-T after --port (opencode ignores what follows --), and the state line's pid is a marked process's |
- Before the line, one run on the pane's machine writes what the line needs
(Pi and OpenCode): pi-ovvi, the OpenCode plugin and its
tui.jsongo whole (a temporary file, then a rename). If that run fails, the plain command starts, with no chat. Codex's run on a resume (below) only helps: when it fails or finds no rollout, the chat still starts and fills at the first message. - The flags write no user config. The user's own settings, hooks, plugins
and extensions keep working. One exception: a user's own
OPENCODE_TUI_CONFIGis replaced for a chat launch. - Codex runs its hook with
$SHELL -lcat each session's first prompt, not at launch, also for a resumed session. So before a resume line, the run on the pane's machine looks for the session's rollout,~/.codex/sessions/*/*/*/rollout-*-<id>.jsonl(or the same name with.zst, a compressed rollout, which Codex makes plain again as it resumes). It appends{"transcript_path": …}tohooks.jsonl, so the chat shows the session's history at once. The hook's line at the first prompt names the same file. The trust hash inhooks.stateis the one Codex keeps for that hook, so Codex shows no "Hooks need review" picker (Security). - pi-ovvi is a small pi extension that ships inside the app. Its file
is named by its content, so two versions of Ovvi never fight over one. It
appends one JSON line to the state file per change:
session {path},tree {leaf, at},blocked,unblocked,working,idleandshutdown. Only a TUI session reports. While an extension dialog is open, it tells herdr's pi integration that pi is blocked, soagent.promptrefuses. It needs pi 0.80.4 or later (agent_settled): an older pi stays Working. - The OpenCode plugin checks every 200 ms and appends a line when it
changes:
{pid, url, session, dialog}. It reads the server's address from the SDK client's protectedclient, so check it on each opencode release.
Where messages come from
| Agent | History | Phase |
|---|---|---|
| Claude Code | the transcript_path of the last SessionStart line | status in its session record ~/.claude/sessions/<pid>.json: idle and shell are ready, busy works, waiting waits. Without a record, herdr's status |
| Codex | the last transcript_path in hooks.jsonl (its rollout): the resume's line, then each SessionStart line that has one | herdr's status, and the screen check |
| Pi | the path of the last session line; tree lines move the branch (Pi's tree) | the state file alone: working, idle, blocked until unblocked; starting before the first session line and after shutdown |
| OpenCode | GET /session/<id>/message for the session in the last state line, then GET /event (opencode writes no transcript file) | a dialog or a pending request waits; a busy session.status works; else herdr's status |
- The followers run on the pane's machine, through
run: aProcesson This Mac, the relay on a remote machine (Running commands).Exec.followfollows a file astail -Fdoes. It waits for the file, tells its inode, where it reads from and where the file's whole lines end. It sends those whole lines withhead, after a seek (dd … count=0), then runstail -c +N -Ffrom their end. Both go through awk when the agent has lines to skip. macOS'stailcopies only about 17 MB a second, so it never reads the backlog. A new file is read whole, from its start: one Claude Code turn can pass 256 KiB. The chat takes all the lines that came since it last looked at once, so a burst (a long turn written at once) draws the chat once, not once per line.- The line filter. awk drops the lines that the agent's parser skips
(an awk pattern next to each parser) and prints
ovvi-skip <bytes>in their place, so the mark still counts the file's bytes. On a remote machine only the lines the chat reads cross the relay. Claude Code: the records that write anothertypefirst (api-request-*, …) and attachments other than queued prompts. Codex: every top-level type butevent_msgandturn_context. Pi's file passes whole (its tree needs every entry), with no awk: macOS's awk passes about 30 MB a second. A 796 KB Claude Code session sends 208 KB. - awk reads a FIFO that only
head, thentailwrite to. The wrapper's kill, sent again each second until it lands, endstail, or endsheadthrough a trap, so a dropped follow sends no more of its backlog, and awk ends too. mawk (Debian's and Ubuntu's awk) runs with-W interactive, or it holds lines until 8 KiB come. The filter runs on This Mac too. - Shown once loaded. The chat reads the file up to the end of its
whole lines at the open into a backlog, off screen, then shows it at
once. Lines after that show as they come. A read that is cut short keeps
what it read. The backlog is parsed off the main thread, one run of
lines at a time, with the parser's state handed over and back, so the
window stays smooth while a 108 MB session loads (a few seconds on a busy
Mac). Meanwhile the chat shows a loader (What it looks
like). Claude Code and pi write the file only with
the first message: the follow script says when the file is missing
(
ovvi-missing), and the chat then knows it has no history yet. OpenCode's first load and each event's rebuild run off the main thread too. - The chat keeps a mark (inode and offset) per file in memory, so a card shown again goes on from there. Another inode, or a shorter file, starts the file again.
- Current. A chat knows whether its history holds what the agent wrote. A chat that a launch of a new conversation made is current at once. A chat that resumed a session (Relaunch with Chat, a resume from Archived Sessions), one found running (after the app starts again) and one that nothing followed for a while (its card was out of view) are current once their transcript is read to its end, or is missing (OpenCode: once its history loads, or it has no session; Codex: also once its side file names no session, as Codex starts a new one only with the first prompt). Until then the chat shows a loader in Chat from its first frame, never the TUI or the empty chat, and then the chat whole and at the bottom: not the old rows and then a jump. A message sent meanwhile shows at once, and the history comes in above it.
Exec.watchprints a rewritten file (Claude Code's session record) whole on each change, checked every 0.3 s by one long-lived Perl run on the pane's machine (perlfrom itsPATH, as the relay's), which starts no process per check and ends when its stdin closes.- OpenCode's are
curlruns on the pane's machine againsthttp://127.0.0.1:<port>. The history loads after the event stream saysserver.connected, so no event falls between the two. Each event builds the history again whole (Simpler choice: a long session is megabytes).
- A new session line (
/clear,/new, a fork, a resume) starts the history again. - The adapter follows while a card shows the chat, and while it shows the TUI of a chat it showed before: a switch back to Chat then shows the rows at once, with no blank while the transcript is read again. Off screen, the header's state is herdr's. A dropped stream is followed again after 2 s, from the marks.
- A message shows when it is on disk, 0.1–2 s after the TUI draws it. There is no text word by word. While the agent works, the last row is Working's icon, still, and "Working" with the turn's time ("Working 29s"), the chat's one loader.
- Parsers. One small, lenient parser per agent, in HerdrKit
(
ClaudeTranscript,CodexRollout,PiTranscript,OpenCodeSession), fills oneChatHistory: user text, replies, thinking, tool calls paired with their results (an edit with its diff), todo lists, notes ("Interrupted", an API error, a compaction), turn ends, and the model and thinking effort of the last reply. Anything else is skipped. Five lines in a row that do not parse are a problem (Waiting). A tool the parser does not know shows its own name (an MCP tool's last part) and its first text argument by key. - Codex's parser reads only whole items (
item_completed),task_complete,turn_aborted,errorandturn_context. A plan reads as a reply. A subagent's start is anagentrow and each message sent to it anagent_sendrow, named by its path. Its end is no row. A tool between agents (wait) reads as an MCP tool. - pi's parser reads the calls of a codemode script from its result: each call's tool, arguments, time taken and error. They become rows of pi's own tools. pi cuts a large call's arguments at 200 characters; the row then takes the first path, command or pattern left in them.
- Markdown: parsed by Apple's
swift-markdown(cmark-gfm) and drawn with SwiftUI, block by block (What it looks like). - Model and effort. The model as the agent writes it. The effort is
Claude Code's
effortfield, Codex'sturn_context.effort, pi'sthinkingLeveland OpenCode'svariant, and none when the files have none.
The adapter interface
One protocol, ChatAdapter in Ovvi/Chat/ChatAdapter.swift, fits every
agent. Chooser.agents lists the four, each with its adapter.
protocol ChatAdapter: Sendable {
static var command: String { get } // the chooser's command, herdr's agent name
static func flags(_ token: String, resume: String?) -> String // added to the command
static func prepare(_ token: String, resume: String?, arguments: String, on exec: any Exec) async -> String?
// the whole line; nil: plain
static func resume(_ session: String) -> String? // the pane's session as launch flags; nil: no button
static var quit: String { get } // /exit or /quit
static var stopKeys: [String] { get }
static var resumesAnywhere: Bool { get }
init?(_ foreground: [Program.Foreground]) // the marker in pane.process_info's argv
init(token: String, pids: [Int]) // a launch bound by its process ids
var token: String { get }
@MainActor func follow(_ chat: Chat, on exec: any Exec) async // history, phase, question, problem
func answer(_ question: ChatQuestion, _ answers: [String: String], on exec: any Exec) async -> Bool // default: false
}The core (Chat, the views) does the rest: sending, Stop, text that starts
with /, the drawer and the layout. Chat.followSessions(_:on:late:skip:read:line:)
follows a side file of session lines, and the history file each one names,
for any adapter.
Ready, working, waiting
The phase is the adapter's own record when it has one, else herdr's status: needs you waits, working works, idle and finished are ready, and a pane herdr does not know yet is starting. While the chat is ready, the header's state label follows herdr, as the TUI's does: pi sits idle at its prompt while it waits on agents or background jobs, and herdr hears it as working. Send and Esc go by the chat's phase.
Codex's screen check
herdr reports a Codex picker or dialog as idle. So while herdr says idle or
working, the adapter reads the pane's last 8 lines every 0.5 s
(pane.read {source:"recent"}), one request over herdr's socket with no
process (Requests).
- Codex is ready when its input box (
› …, not a numbered picker row› 1. …) sits directly above its status row (… · …), among the last 4 rows with text. - Two misses in a row make the chat wait, so the live terminal shows and nothing is sent into the picker. When a card shows the chat again, the first read alone decides.
- It reads
recent, not the viewport, so a scrolled pane is not a picker.
Pi's tree
pi writes nothing to its transcript on /tree. pi-ovvi writes a tree
line with the new leaf and at, the transcript's size at the move.
- The leaf moves when the transcript is read up to
at. A move that the transcript was read past is dropped (a card shown again after turns in the terminal). - The parser keeps each entry's parent and its steps, so another branch is built again without a new read of the file.
branch_summaryand compaction are notes.custom_messageis skipped.
Sending and stopping
- Send: Return or the Send button gives
agent.prompt {target, text}, only while the agent is ready or working. ⇧Return and ⌥Return add a line. herdr pastes the text with bracketed paste and presses Enter. herdr refuses unless a foreground process has the agent's name. - While the agent works, Send stays on. Claude Code and OpenCode queue the text. Codex and Pi steer the turn with it: the history gets it after the running tool.
- Sent means the user entry showed up in the history, matched by its text. Until then the app draws the bubble itself, dimmed (70 % opacity, 50 % once "Not sent"), so a message queued or still on its way reads as not yet taken. Once the agent takes it, it goes from that dim to full over 0.2 s (at once with Reduce Motion); so does any user message that comes into an open chat. After 5 s, and once the agent no longer works or waits, "Not sent" shows under it in red, and the draft comes back when the reply box is empty. A late entry still clears it.
- Text that starts with
/is not sent. The card flips to its terminal, and the text is typed there withpane.send_input, with no Return. - Stop takes Send's place while the agent works and the reply box is
empty. It sends
pane.send_keysEsc (OpenCode: Esc twice, because the first only arms the interrupt). Never ⌃C. - Esc anywhere in the chat stops the agent as Stop does, while it works. While it is idle, Esc does nothing. The chat must have the keyboard: the reply box almost always has it, but not after a double-click selected text. While the drawer shows, its terminal takes Esc.
- The draft is kept in memory per pane, shared by every card of the pane.
Waiting
When the agent waits on something the chat can answer, a question card shows above the reply box. Its buttons are the options. An answered card stays, disabled, until the agent goes on.
- Claude Code's questions (AskUserQuestion, single choice). The hook
writes its pid to
ask, appends the question tohooks.jsonl, and waits foranswer, at most 30 minutes, whileaskstill holds its pid and Claude Code lives. The card writes the decision (allow, the questions echoed, the answers by question text) toanswer, whole, only whileaskis there. If the user answers in the TUI first, the session record leaveswaiting, the app removesask, and the hook exits. - OpenCode's permissions and questions, from the event stream (and
GET /permissionandGET /questionon connect), oldest first. A permission reads "Bash: touch x" with Allow once, Allow always and Reject (the TUI's words), sent toPOST /permission/<id>/reply. A question goes toPOST /question/<id>/reply. A request answered in the TUI sends arepliedevent, and the card goes. Every pending request on the TUI's own server counts, subagents' too. - A question with several answers (Claude Code's
multiSelect, OpenCode'smultiple) or with no options goes to the live terminal.
The live terminal. When the agent waits on anything else, the bottom of the card (45 % of its height, at least 160 pt) becomes the live terminal, in place of the reply box. Its header says "‹Agent› is asking in the terminal", with Open TUI, which flips the whole card. It gets the keyboard. When the agent works or is idle again, it goes, and the reply box takes the keyboard back. This covers tool approvals, Codex's questions, Pi's extension dialogs and every picker.
The chat cannot read the session. A red card says so, with the reason and Show TUI, and the header's state label says Error:
- "‹Agent› has not started a session": Claude Code or Pi after 10 s with no session, or a new Codex chat when a message is not sent and its hook never ran.
- "OpenCode has not reported its session" (an OpenCode build that ignores plugins), "OpenCode's server did not answer" or "did not give the session".
- "The transcript is not in a format the chat knows".
Messages
The chat shows every message of a turn, in order. There is one view, with no setting.
- The user's message, then each reply of the agent as its own bubble, and each todo list and note (an interruption, an error, a compaction).
- Each run of tool calls between two messages is one quiet line of its counts with a chevron, closed: "Ran 3 commands, read 2 files ›". A click opens it to a row per step, and a click again closes it.
- Thinking is a step of its run. Thinking with no tool call in its run has no row.
- Worked. Each turn the agent ends gets "Worked 1m 48s" under its last
row, in 12 pt secondary text: a turn of a reply alone too. It stays there
after later turns, while the next turn works and in a history read again.
It is the agent's own record of the turn's end: Claude Code's
turn_duration, Codex'stask_complete, OpenCode's reply that finishes with no more tool calls (timed from the user's message), and pi's final reply (stopReasonstoporlength, timed from the user's message), so a live turn and a loaded one look the same. A turn the user interrupts ends with its "Interrupted" note instead. While a turn runs, the loader under it says "Working" and the turn's time. - No line has a Working icon: the loader is the chat's last row. While a turn streams, a closed line changes only its counts.
- Made only when open. A line holds only where its steps are in the history. Its counts are worked out for the rows shown only. Its rows are made when it opens, from the history, and dropped when it closes, so a long chat with thousands of tool calls lays out no row for them. An open line stays open while the chat shows.
Tool calls
A run is one quiet line with no card: its summary and a chevron.
- The line and its open rows start 18 pt left of the messages' edge, as far out as the reply box (a bubble reaches 12 pt), and stop 48 pt short of the messages' right edge. The line's text stays on the messages' edge, and its open rows sit 12 pt in. The fill under the pointer covers that width.
- The summary uses pi-content-layout's words
(
tools.ts): a verb and a noun per tool for every agent's tool names, calls counted (not distinct files), on one line: the three largest counts first (a tie in the order the tools first ran), the others as "+N more", then the failures: "Ran 89 commands, talked to 69 agents, stopped 26 agents, +4 more, 4 failed". A tool it does not know reads "ran ‹tool› N times". The tool calls of a codemode script count as their own, failures too. - As in pi-content-layout's groups, thinking between tool calls is a step of the run (a "Thought" row inside it).
- Open, a run has one row per step: a symbol for its kind, the tool, its
target, its result (failed in red, a spinner while it runs), its time and
a chevron (
bash npm test exit 1 4.2s). The symbols are squares and rectangles of one size:doc.text(read),square.and.pencil(edit),terminal(command),doc.text.magnifyingglass(search),curlybraces.square(other) andtext.bubble(thinking). - A codemode script's calls are rows of their own under the codemode row, 20 pt in: every tool the summary counts has a row. They show once the script ends, with no result text unless one failed.
- A call opens on its own to its Input (an edit's diff instead, the first 40 lines) and its Output: 11 pt monospaced, selectable, at most 180 pt tall, then it scrolls both ways. The parsers keep each call's input and output whole, up to 20 000 characters.
- A todo list sits on a faint fill.
What it looks like
-
Bubbles, as in Messages. The user's messages are blue bubbles with white text on the right. The agent's replies are gray bubbles with the label color on the left. The colors are the ones macOS Messages draws (
Look.sentBubble,Look.receivedBubble):Bubble Light Dark Increased contrast The user's #0088FF#0091FF#0040DDThe agent's #E5E5EA#3B3B3Das light or dark A bubble has radius 15 and Messages' padding: 12 pt at its sides and 7 pt above and under its text (iMessage's single line: a 30 pt bubble, its text 12.5 pt from the sides, corners about 15 pt). Its text stays on the messages' 32 pt edge, so the bubble reaches 12 pt past it. 48 pt stay free on the bubble's other side. Both are Markdown, drawn the same way. The user's keeps its own line breaks, and its links are white. The summary line, tool rows, Working and Worked stay outside the bubbles.
-
Rows sit 10 pt apart, and a user's bubble has 8 pt more above and under it, so each turn reads as one block.
-
An empty chat of a new conversation puts the reply box in the middle of the card, under its header rows, and a header above the box, until the first message. The header is the agent's program glyph (the one in the tab, 64 pt, at half opacity) with a phrase 14 pt under it in 26 pt semibold, picked at random when the chat view is made, with the reply box's placeholder that goes with it (after the first message the box says "Ask Anything"):
Greeting Placeholder What's on your mind? Ask or tell me anything What are we building? Describe the feature Where should we start? Name a file, a bug or a goal What can I help with? Ask a question or give a task Ready when you are. Type your first message The greeting stays on one line and shrinks to fit a narrow card. 22 pt stay free between it and the box.
-
The first message. When the user sends it, the header fades, the reply box goes down to its place at the bottom, and the message rises from the box to the top of the messages: one motion of 0.22 s on the app's slide curve (none with Reduce Motion). The UI tests run with Reduce Motion: only the VM's frame strips check the motion.
-
A chat that may have a history (not current: resumed, found running, shown again after a time away) shows a small spinner in the middle and the reply box at the bottom until its history is read. The spinner shows after 0.3 s: a chat shown again usually catches up sooner, and its rows then come with no spinner between. A sent message stays in view while the transcript it starts loads (Claude Code and pi write the transcript with the first message). An empty chat has no scroll view: the scroll view is made with the rows, at the bottom (one made empty keeps a blank above a long history that comes later).
-
The rows fade in over 0.2 s when they first show in a card: a card just made (the rows come on the run loop turn after it), a switch back from the TUI, a history read to its end (at once with Reduce Motion).
-
The messages, the question card and the reply box sit in a 760 pt column, centered on any card, 32 pt in from its sides. The messages scroll, newest at the bottom, and fade in under the header. A chat shorter than the view sits at its top. In a floating window they start 28 pt under the title row, not 16 pt, because the card has no header there.
-
Scrolling. The messages are a plain stack, not a lazy one: every row in it is built, so every height is real, and an open, a split and Scroll to Bottom land exactly. (A lazy stack guesses the heights of the rows it has not built, and after a split shows a blank where it guesses wrong.)
-
The newest rows. The stack starts with a block of the newest rows: the rows that hold about 120 lines of text, at most 34 rows. A message counts as 1 line, plus a line for each line break and each 90 characters of text. A run of tool calls, Worked or a note counts as 2 lines, and a plan as its items plus 1. 120 lines are about two views of text, so the block fills a usual view. When the card is made (its tab first shows, or shows again after its page left the kept tab pages), the app builds the block and lays it out, so the block's size is the cost of that show. (31 rows of long replies take 394 ms of the main thread; 120 lines take about 124 ms, and a terminal tab 34 ms.) A switch to a kept tab page builds nothing.
-
The bottom. One rule holds the bottom: the scroll view's bottom anchor (SwiftUI's
defaultScrollAnchor(.bottom)for the first offset and for size changes;.topfor the alignment of a chat shorter than the view). Under its rows the stack has a blank that makes it as tall as the view, so a short chat's rows sit at the top and the chat is still at its bottom: as its rows pass the view (the first message's transcript, a stream of turns) or the view shrinks under them, the anchor follows. The chat opens at the bottom. Exactly at the bottom, it stays there as messages come and as the card resizes. Scrolled up, even by one point, it keeps its offset: new messages come below without moving what is in view. Nothing else moves the scroll as it nears or leaves the bottom, so a slow trackpad scroll moves the messages exactly as far as the fingers. -
Rows stay. At the bottom the stack shows the newest block. Once the chat leaves the exact bottom, the oldest row shown is fixed: new rows do not take rows off the top. The rows stay shown until the card closes or shows its TUI, or a new chat starts. A switch to another tab or space and back keeps them and the scroll place (its page is kept), and while its page is kept the chat goes on following its transcript. When the card is made again, it shows the newest block at the bottom, once its history is current.
-
The place on the top row. Scrolled up, the app knows the row at the top of the view and how far the view's top is into it. When the rows above that row change height (a new width) or come (Show Earlier), the view goes back to the same place on that row, to the point, also when a wider card's shorter content moved the scroll to the end. The row is the last one whose top is above the view's top, else the first row.
-
The app sets the scroll's place only for that and for Scroll to Bottom. It never gives the scroll view back a place the scroll view reported.
-
Show Earlier. When a chat has older rows, a glass pill "Show Earlier" (12 pt, 28 pt tall) is the stack's first row, centered, with the messages' top padding above it and the rows' 10 pt gap under it. A click shows a small spinner in place of its words, and the next block (the same rule: about 120 lines, at most 34 rows) comes above on the next run loop turn, so the spinner draws before the rows lay out. The rows in view stay where they are (the place on the top row). The rows it adds stay when the chat scrolls back to the bottom.
-
Resizing. A narrow card (under 824 pt, where the column narrows too) lays its messages out at a lone new width at once, with the reply box: a split's motion gives the card its final size once, so the messages and the box take it in the same frame and move with the card. A width that comes within 150 ms of the last one (a divider drag, a window resize) waits until the width stops changing for 150 ms, so a drag wraps the text once more at its end, not at every frame. Meanwhile the card clips the messages, or its fill shows beside them, and the reply box and the Scroll to Bottom button follow the card at once. Scrolled up, the view keeps its place on the top row.
-
The end. 32 pt stay free between the last row and the reply box (an empty last row, 22 pt, plus the rows' spacing).
-
Scroll to Bottom. While the bottom of the messages is more than 40 pt out of view, a round glass button with a down arrow (30 pt) sits 10 pt above the reply box, in the middle. A click scrolls to the stack's last row, the same place where a chat opens.
-
Over the messages. The question card and the reply box sit over the bottom of the messages, which scroll under the glass. The last message scrolls clear of the box as it grows. This is the one place where something sits over pane content. The live terminal keeps its own row under the messages. Simpler choice: a wheel over the reply box does not scroll the messages under it.
-
Markdown.
swift-markdown(cmark-gfm) parses it. 14 pt text on 20 pt lines (1.43: more room than Messages' 1.23, a little less than a web page's 1.5): a blank line separates paragraphs, and list items follow as lines. Headings are 22, 18 and 16 pt (bold for the first two levels), with 26 or 17 pt above them and 8 pt under them. 17 pt (a line) between other blocks; no gap between list items or before a nested list, 6 pt before an item's later paragraph, code block, quote or table.- List items hang behind their number, bullet (•, ◦, ▪ by depth) or task
box (
square,checkmark.square.fill), and a list item's later blocks line up under its text. Lists and quotes nest. - Inline code is at 0.9 of its text's size on a faint fill. Code blocks are 12.5 pt monospaced and scroll sideways. Quotes are secondary, behind a 3 pt bar. A rule is a divider. Strikethrough is drawn. HTML shows as written.
- A table sits in a faint frame (radius 8): the head row on a fill whose outer cells round their top corner, a line between rows, each column aligned as written. Each column is as wide as its text, up to 200 pt, where its text wraps. A table that fits the reply stretches to its width; a wider one keeps those widths and scrolls sideways in its own box, as a code block does, and a vertical scroll over it still scrolls the chat.
- Links. The chat finds links in its text as the terminal does
(
TerminalLink, Opening links and files): addresses, emails and paths, with:line. It looks in the user's messages, the replies, inline code, code blocks and a tool call's Input and Output, one line at a time. A path opens from the card's folder. A Markdown link ([label](…)) opens only anhttp,httpsormailtoaddress: its label hides where it goes, and a transcript could holdfile:orovvi:links. A bare name such asOvvi.app,example.comorwww.example.comis no link. An edit's diff has no links. - A link looks as a browser draws one with no style of the page's own
(Safari's, Chrome's and Firefox's default style sheet): underlined, in
the web's default link color (
#0000EElight,#9E9EFFdark; white in the user's bubble), with no fill behind it. Under the pointer it does not change, and the pointer is the pointing hand, as in a browser. A link in inline code (a path in backticks) keeps the code's fill. A link takes the press, as in a browser: a drag that starts on it selects nothing. A text with more than 100 links (a tool's output that lists paths) shows them with the text's I-beam: each link's click area costs layout. - An image shows as its description, linked to its address. The chat never fetches it: a reply could send what the agent read to any server.
- Footnotes read as written (
[^1]): swift-markdown has none.
- List items hang behind their number, bullet (•, ◦, ▪ by depth) or task
box (
-
The reply box. A glass box (radius 18), tinted with the card's own color at 45 % so that the messages under it stay faint, with "Ask Anything", 14 pt. Its text is 18 pt in from the box's sides, and the box is 14 pt in from the column, so the text lines up with the messages' 32 pt edges. It has 13 pt of padding on top and 11 pt at the bottom. It grows up to 10 lines, then scrolls. Under the text, on the left: the model and the effort in 11 pt secondary ("Opus 5.5 · high"). Claude's model ids read as the family and version. Other models show as the agent writes them. On the right, 11 pt in: a 22 pt round Send, always in the accent, or Stop. A click on Send with an empty box, or while the agent cannot take text, does nothing.
-
A click anywhere on the chat, on text too, gives the reply box the keyboard (the drawer's terminal while the drawer shows). A button or a link acts as well. A double or triple click, a ⇧-click or a ⌃-click leaves the keyboard where it is, so selected text can be copied.
-
The header. Row 1: the pane's name (Session titles) and the card buttons. Row 2: the program glyph, the agent's name and the folder, then the state, then Chat | TUI in the app's own pill segments (Pane card). The state is a state label: its icon and word in 11 pt, both in the status color: Working, Needs you, Done or Error, and none while idle (Status). An agent card with no chat shows herdr's state there the same way.
-
The reply box takes the keyboard when the chat appears and when the drawer goes. The keyboard moved to a chat card goes to its reply box.
Switching views
- Chat | TUI is in the card header, not a swipe. TUI is the agent's own terminal interface.
- A card of a marked pane opens on its TUI; the user switches to Chat. After
Relaunch with Chat, for an agent that an agent
started with
ovvi agent, and for every new agent when Settings ▸ Agents ▸ Open new agents in is Chat (Settings), it opens on the chat. An agent flips it withovvi view(CLI). The choice is kept in memory per pane, for every window of the session. The card keeps the keyboard across a switch: the reply box or the terminal. - A floated chat pane shows its chat in its floating window, with Chat | TUI in the window's title row (Floating pane).
- The switch changes only what the card draws: the agent, its turn and its pane go on. The terminal stream stays open while the chat shows.
Session titles
An agent pane's name is the user's herdr label, else its session's title, else its session's first typed prompt, else "Untitled". It shows in the card's row 1, the floating window's title row, the tab ("Claude Code" then the name in secondary text), the sidebar's agent rows and the hover card. It works for every agent pane, with or without a chat.
herdr's agent_session on each pane names the agent's session: an id for
Claude Code, Codex and OpenCode, the file for Pi. The app uses it only when
its agent is the pane's agent. One sh run on the pane's machine reads the
title, and again when the session, the terminal title or the status changes
(agents name a session during a turn, and pi's /name retitles its
terminal). The runs share the gate of the program asks, so a session's first
snapshot does not start every run at once.
| Agent | Where the title is |
|---|---|
| Claude Code | the last custom-title line (from /rename), else the last ai-title line, in the session's transcript ~/.claude/projects/*/<id>.jsonl |
| Codex | thread_name on the session's last line in ~/.codex/session_index.jsonl |
| Pi | the last session_info line (from /name) in the session file. pi names no session by itself |
| OpenCode | session.title in its database, read with sqlite3 -readonly. Its placeholders "New session - …" and "Child session - …" count as none |
With no title yet, the same run reads the first typed prompt: Claude Code's first user message in the
transcript's first 100 lines that is not a meta line or a command's wrapper (text that starts with <), Pi's first
user message in the session file's first 40 lines, and Codex's preview in its newest state_*.sqlite. OpenCode
titles its sessions itself. The prompt is read only while there is no title, and cut at 100 characters, then "…".
UI tests point the reads at a fixture home (-archivedHome), as Archived Sessions'.
- An id goes into the script only when it holds letters, digits,
-and_only. A file must be an absolute path. - A title or prompt goes on one line (line breaks become spaces), loses its control characters and is cut at 200 characters.
VoiceOver and identifiers
- Messages, replies, "Worked" and notes read as text with their words. A run and each call are buttons whose value is "open" or "closed". Send, Stop, Scroll to Bottom and Show Earlier are named; Show Earlier's value is "loading" while its rows come. The state reads as its word.
- Identifiers, after
chat.‹pane id›:.composer,.send,.stop,.model,.user,.sending,.not-sent,.reply,.worked,.empty,.loading,.stack,.tool,.tool.input,.tool.output,.note,.bottom,.earlier,.question,.option.‹option›,.drawer,.open-terminal,.problemand.show-terminal. The header haspane.‹id›.title,pane.‹id›.state,pane.‹id›.view.chat(.tui) andpane.‹id›.enable-chat(Relaunch with Chat).
Side files
All of them are under the home folder of the pane's machine, so they are the user's own on a shared machine.
| File | Written by |
|---|---|
~/.cache/ovvi/chat/T/ (0700) | the launch: hooks.jsonl, ask, answer (Claude Code), hooks.jsonl (Codex; on a resume the app writes its first line), pi.jsonl (Pi), state.jsonl and pw (OpenCode) |
~/.cache/ovvi/pi-ovvi-<sha8>.ts | the app, before a Pi launch |
~/.cache/ovvi/opencode/ovvi-tui.js, tui.json | the app, rewritten before each OpenCode launch |
The 30-day sweep. Each launch deletes the files under
~/.cache/ovvi/chat/ untouched for 30 days,
then empty folders more than a minute old, so a folder that a launch just
made stays. Claude Code's and Codex's hooks do it at each session start, and
the run before a Pi or OpenCode launch does it too. It goes by the files' times,
because an append does not change a folder's time. Simpler choice: a pane
open 30 days with no writes can lose its side files (OpenCode's server then
does not answer).
Security
- OpenCode's password. The server listens on 127.0.0.1 only, with a port
the system picks. Each launch gets 16 random bytes, in hex, in
chat/T/pw(0600, in a 0700 folder). The typed line moves it from the file into opencode's environment, so it never shows in argv or the scrollback.curlreads it from a here-document. opencode's tools inherit it (the same user). The state line's url must be exactlyhttp://127.0.0.1:<port>, and itspida marked process's. - Codex's hook trust. The app passes the trust hash of its own hook only:
sha256 of the hook's JSON with Codex's defaults, under the
-clayer's key. Codex runs that hook without a question, and every other hook needs review. No bypass flag. - Claude Code's hooks come in the inline
--settings, so nothing is written to the user's settings. The user's and any managed hooks run too. - Simpler choice: a home folder with a space gets no chat (
$HOMEis not quoted in the hooks). OpenCode'stui.jsonbreaks on a home folder with a".
Tests
The UI tests start a stand-in per agent, scripts/<command>-stand-in.py, by
its place in the chooser, on CI, on This Mac and on the remote test host. A
stand-in runs again under the agent's name (herdr's agent.prompt needs a
foreground process of that name), acts on the flags as the agent does, writes
the agent's format, and reacts to input: a dialog, a question, a slow turn, a
dropped prompt, an echo.
Check by hand
The stand-ins cannot cover the real agents. Start each one from the chooser, on This Mac and on a remote machine, in light mode and in Classic.
- Claude Code: a reply. A question answered in the chat, and one
answered in the TUI. A tool approval with permission prompts on (the
drawer).
/clearin the terminal: the chat starts the new session. - Codex: no "Hooks need review" picker. A reply, and Stop.
/modeltyped in the chat flips the card. A picker left open shows the drawer when you flip back./new, then a message. Resume a session from Archived Sessions, and one with Relaunch with Chat: the history shows before the first message and does not load again at it. Once with an old session whose rollout Codex compressed (.zst). - Pi: an extension dialog shows the drawer, and herdr refuses a send
meanwhile.
/treeto an earlier message,/new,/forkand/resume. A message sent during a long tool shows no "Not sent" until pi takes it. Esc during a reply and during a tool. - OpenCode (the npm build): a reply. A bash permission answered in the
chat, and one answered in the TUI. A question.
/sessionsto an old session. Stop. A remote machine needscurl. - An OpenCode build that ignores plugins: the card falls back to its terminal after 15 s.
- Relaunch with Chat: each agent started by hand, idle and working (the alert),
comes back in the same session with its chat. Codex keeps its hook after
resume.codex resume <id>with no prompt yet shows the button. - Titles: real sessions' titles in the tab and the sidebar's agent rows, after
/rename(Claude Code) and/name(Pi). - Managed launchers can rewrite
~/.claude/settings.jsonand reinstall a plugin into~/.codexon each start. That is their own behavior.
Choices
The decisions are D74 to D79, D82 to D86 and D95 on the Decisions page.