Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ t3term is a Rust terminal client for the T3 Code server. README.md documents the
## Who does what

- The user sets scope and merges every pull request. No agent merges a pull request, whether it changes the UI or not.
- GPT-6.1 Sol (xhigh) orchestrates the sprint. It runs all testing and computer use itself, reviews each pull request, prepares it for merge and writes its walkthrough.
- GPT-6.1 Sol (xhigh) orchestrates the sprint. It runs all testing and computer use itself, reviews each pull request and prepares it for merge.
- Claude Opus 5.5 (xhigh) writes the implementation, the tests and the fixes. It is the only agent that writes code.

## Pull requests

- Keep them small and frequent. Open a draft early, push at each checkpoint and mark it ready when the work is done.
- Keep a decision log in the description, in the format of pr-walkthrough's `references/decision-log.md`: what you noticed, how you confirmed it, the options you chose and rejected, what review changed and the known limits.
- Every pull request gets a short walkthrough made with the pr-walkthrough skill (https://github.com/krishhgg/pr-walkthrough) that teaches the Rust it uses. The pages stay on the reader's machine, so never commit them.
- Keep the description short and lead with what the pull request changes. End it with a decision log under these five headings:
- What I noticed: the problem or gap that started the work.
- How I confirmed it: the code, contract or run that showed it.
- Options: the approach chosen and the ones rejected, with why.
- What review changed: what Greptile, Sol or the user asked for and what changed as a result.
- Known limits: what the pull request leaves out or doesn't handle.
- Changes to how the TUI looks carry screenshots or recordings. Keep UI and non-UI changes in separate pull requests where practical.
- Greptile reviews every pull request. A pull request is ready for the user when CI passes and every Greptile finding is fixed or answered.

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ A menu choice turns its chip blue and goes to T3 with the thread's next message,

![Choosing a model, effort and mode in the TUI, then sending](docs/screenshots/picker.gif)

The chips and menus follow T3's provider list as it changes. The TUI subscribes to T3's server config, so when T3 adds or drops a provider, renames a model or gives a provider a status such as `error`, the chips and any open menu show it without reading the list again. The model menu shows a status after the provider's name. An open menu keeps its highlight on the same model as rows move. When a draft's provider goes away, its chip shows the thread's model, and the draft keeps the choice until the provider comes back. If T3 refuses the subscription, each menu reads the list with `server.getConfig` when it opens.

Ultrathink applies to that one message and comes back only if that message fails to send. T3 refuses a mode change while a run is active, so the TUI leaves the message in the composer to send once the run ends. That includes the switch back to Build below. A message that fails after you have typed something else or opened another thread is kept: the status line says so, Ctrl+R swaps it with the composer's text, and opening its thread with an empty composer brings it back. A send that times out can still reach T3, so if the thread later shows it, the TUI drops its kept copy rather than send it twice.

### Plan mode
Expand Down Expand Up @@ -306,6 +308,7 @@ The choices on `send` and `settings` are `--model`, `--effort`, `--option ID=VAL
- **Auth.** On macOS, t3term keeps one login per T3 server in the login Keychain under the service `t3term`. It lasts 30 days, the same as T3's own default, and holds only `orchestration:read` and `orchestration:operate`. Each run checks it against `/api/auth/session`, and if it expires within a day or the server rejects it, t3term issues a new one and revokes the old one, so you never log in by hand. `t3term logout` revokes and deletes it.
- **RPC.** Effect RPC over one `/ws` connection. The client acks every stream chunk and pings every 10 seconds. If no frame arrives for 30 seconds it treats the socket as dead.
- **State.** Each V2 event carries the whole updated entity, so the reducer upserts it by id. After a dropped connection the client resubscribes with `afterSequence` and skips any replayed event it already applied.
- **Server config.** The TUI subscribes to `subscribeServerConfig` with an empty payload. Each subscription opens with a snapshot of the whole config and goes on with changes to the provider list, the settings or the keybindings. The stream has no cursor, so after a dropped connection the TUI keeps the config it has until the new subscription's snapshot replaces it. Retries wait 250 ms at first, doubling up to 10 seconds, and a snapshot brings the wait back to 250 ms. A server that refuses the subscription, because it lacks the method or the session lacks `orchestration:read`, gets a `server.getConfig` read each time a menu opens. The CLI reads `server.getConfig` when a command needs the model list.
- **Rendering.** The TUI draws only after input or a server event, at most 30 times a second. The open thread keeps its transcript blocks between frames and describes an item again only after T3 changes it, so a frame drawn for a key or the clock describes nothing. Each block keeps its wrapped lines until it changes or the width does, and only the visible rows are copied into a frame. While the open thread has a run going or a card on screen reads Working or Goal, a once-a-second tick advances the spinners and clocks, and that tick stops when none does. While a thread is snoozed, one timer waits for the soonest snooze to end, because no server event marks that moment. The same redraw moves the card back and shows Woke. Otherwise an idle TUI wakes only for input or server events.

<details>
Expand All @@ -323,7 +326,7 @@ Set `T3TERM_NO_SAVED_LOGIN=1` to use a session that lasts one run instead. t3ter
cargo test
```

The unit tests cover the reducers, Markdown wrapping, the composer, the model menus, the transcript and the lines under a file change, the tasks drawer, the thread error banner, hiding and resizing the sidebar and auth command parsing. The TUI tests draw frames on ratatui's test backend with a client that never connects, and a test build keeps saved settings in memory, so no test writes `~/.config/t3term/settings.json`. `tests/fake_server.rs` drives the real RPC client against a fake Effect RPC server, dropping the socket mid-stream to check the resume cursor, chunk acks, batched frames, duplicate suppression and error decoding. `tests/cli.rs` runs the built binary with a temporary home and checks the `--json` error and exit code when the server is gone, when it isn't on protocol 2 and when the TUI has no terminal. It also runs `t3term read` against a fake server and a fake `t3` that issues a made-up session. It checks that the plain output drops the control characters of a checklist, a compaction summary and an edit's file name, and that `--json` writes them as escapes that decode to the text T3 sent. Each edit prints as one row, without its operations, error or patch. Another test runs `t3term send --wait` against a fake server that also plays T3's side of the WebSocket. It checks that each finished handoff in the turn prints its marker and endpoints on stderr once, even when T3 sends the item again, and that `--json` prints only the result. A test of `t3term wait` checks that each edit in the turn prints its row on stderr once, when it finishes, however often T3 sends it again. None of them reach a real T3 server or the Keychain, and CI runs them on macOS for every pull request.
The unit tests cover the reducers, Markdown wrapping, the composer, the model menus, the transcript and the lines under a file change, the tasks drawer, the thread error banner, hiding and resizing the sidebar and auth command parsing. The TUI tests draw frames on ratatui's test backend with a client that never connects, and a test build keeps saved settings in memory, so no test writes `~/.config/t3term/settings.json`. `tests/fake_server.rs` drives the real RPC client against a fake Effect RPC server, dropping the socket mid-stream to check the resume cursor, chunk acks, batched frames, duplicate suppression and error decoding. Its config tests check the subscription's exact request, a fresh snapshot with no cursor after each drop, the backoff, stopping when the TUI goes while a watch connects or waits, and both ways T3 refuses the subscription. The TUI's config tests run on a runtime that never polls its tasks, so they can count each model-list read the TUI starts and show which changes reach the chips and menus without one. `tests/cli.rs` runs the built binary with a temporary home and checks the `--json` error and exit code when the server is gone, when it isn't on protocol 2 and when the TUI has no terminal. It also runs `t3term read` against a fake server and a fake `t3` that issues a made-up session. It checks that the plain output drops the control characters of a checklist, a compaction summary and an edit's file name, and that `--json` writes them as escapes that decode to the text T3 sent. Each edit prints as one row, without its operations, error or patch. Another test runs `t3term send --wait` against a fake server that also plays T3's side of the WebSocket. It checks that each finished handoff in the turn prints its marker and endpoints on stderr once, even when T3 sends the item again, and that `--json` prints only the result. A test of `t3term wait` checks that each edit in the turn prints its row on stderr once, when it finishes, however often T3 sends it again. None of them reach a real T3 server or the Keychain, and CI runs them on macOS for every pull request.

`docs/screenshots/` also holds `before-tui.png`, `after-tui.png`, `approval.png`, `streaming.gif`, `picker-model.png` and `tool-calls-failed.png`.

Expand Down
1 change: 1 addition & 0 deletions docs/sprint.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Rows are tracked by URL, because GitHub numbers issues and pull requests from on
| 12 | https://github.com/krishhgg/t3term/pull/19 | Resize the main sidebar by dragging its edge or with [ and ], remembered between runs | |
| 13 | https://github.com/krishhgg/t3term/pull/20 | File changes in tool output: each edit's operations and a failed edit's error, with a bounded patch preview when a server sends one | |
| 14 | https://github.com/krishhgg/t3term/pull/21 | A dismissible banner over the conversation for the thread's error, from a failed send or from T3 | |
| 15 | https://github.com/krishhgg/t3term/pull/22 | Live server config in the TUI, so provider and model changes reach the chips and menus without a reread | |

## Maintenance review PRs

Expand Down
120 changes: 98 additions & 22 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,15 @@ impl Client {
after_sequence,
)
}

/// Streams the server's configuration, as `spawn_config_watch` describes.
pub fn watch_config(self: &Arc<Self>) -> mpsc::UnboundedReceiver<WatchEvent> {
let client = self.clone();
spawn_config_watch(move || {
let client = client.clone();
Box::pin(async move { client.rpc().await })
})
}
}

/// T3 can restart the agent's session to apply these settings, so t3term changes them only
Expand All @@ -457,7 +466,8 @@ fn busy_for_settings(run: &Value) -> anyhow::Error {
#[derive(Debug, Clone)]
pub enum WatchEvent {
Item(Value),
/// The connection dropped; the watcher is reconnecting and will resume after the last sequence.
/// The connection dropped. The watcher is reconnecting, and resumes after the last sequence
/// or starts again from a snapshot, as its stream does.
Reconnecting {
reason: String,
retry_in: Duration,
Expand All @@ -466,6 +476,33 @@ pub enum WatchEvent {
Failed(String),
}

/// Opens a connection for a watcher. Each reconnect calls it again.
type Connect = dyn Fn() -> Pin<Box<dyn Future<Output = Result<RpcClient>> + Send>> + Send + Sync;

/// Where a watcher's stream starts again after a dropped connection.
#[derive(Clone, Copy)]
enum Resume {
/// After the highest event sequence delivered, from this one at first. The orchestration
/// streams work this way, and their `{"kind": "synchronized"}` marker says a subscription
/// has caught up.
AfterSequence(Option<u64>),
/// From a new snapshot. A stream with no cursor, such as the server config, sends the whole
/// state first, as `{"type": "snapshot"}`.
Snapshot,
}

impl Resume {
/// Whether `item` shows the subscription working, so the next drop retries quickly.
fn healthy(self, item: &Value) -> bool {
match self {
Resume::AfterSequence(_) => {
item.get("kind").and_then(Value::as_str) == Some("synchronized")
}
Resume::Snapshot => item.get("type").and_then(Value::as_str) == Some("snapshot"),
}
}
}

/// Runs a subscription until its receiver is dropped. Tracks the highest sequence it delivered so a
/// resubscribe replays only what the consumer has not seen.
pub fn spawn_watch<F>(
Expand All @@ -477,12 +514,50 @@ pub fn spawn_watch<F>(
where
F: Fn() -> Pin<Box<dyn Future<Output = Result<RpcClient>> + Send>> + Send + Sync + 'static,
{
watch(
Box::new(connect),
tag,
payload,
Resume::AfterSequence(after_sequence),
)
}

/// Runs T3's config subscription, `subscribeServerConfig`, until its receiver is dropped. Each
/// subscription opens with a snapshot of the whole configuration and goes on with changes to
/// parts of it. The stream has no cursor, so a resubscribe asks for nothing but a new snapshot.
/// The payload is empty, which leaves out the theme and usage-limit events t3term doesn't read.
pub fn spawn_config_watch<F>(connect: F) -> mpsc::UnboundedReceiver<WatchEvent>
where
F: Fn() -> Pin<Box<dyn Future<Output = Result<RpcClient>> + Send>> + Send + Sync + 'static,
{
watch(
Box::new(connect),
"subscribeServerConfig",
json!({}),
Resume::Snapshot,
)
}

fn watch(
connect: Box<Connect>,
tag: &'static str,
payload: Value,
resume: Resume,
) -> mpsc::UnboundedReceiver<WatchEvent> {
let (events, receiver) = mpsc::unbounded_channel();
tokio::spawn(async move {
let mut last_sequence = after_sequence;
let mut last_sequence = match resume {
Resume::AfterSequence(sequence) => sequence,
Resume::Snapshot => None,
};
let mut backoff = Duration::from_millis(250);
loop {
let reason = match connect().await {
let connected = tokio::select! {
connected = connect() => connected,
// The consumer went away, such as a TUI that quit.
_ = events.closed() => return,
};
let reason = match connected {
Err(e) => e.to_string(),
Ok(rpc) => {
let mut request = payload.clone();
Expand All @@ -499,26 +574,12 @@ where
};
match next {
Some(Ok(item)) => {
if item.get("kind").and_then(Value::as_str)
== Some("synchronized")
{
if resume.healthy(&item) {
backoff = Duration::from_millis(250);
}
// Enrichment snapshots do not move the resume cursor.
let enrichment =
item.get("resolvedRepositoryIdentityRoots").is_some();
let sequence = if enrichment {
None
} else {
item.get("sequence")
.or_else(|| item.get("snapshotSequence"))
.or_else(|| {
item.get("snapshot")
.and_then(|s| s.get("snapshotSequence"))
})
.and_then(Value::as_u64)
};
if let Some(sequence) = sequence {
if let Resume::AfterSequence(_) = resume
&& let Some(sequence) = delivered_sequence(&item)
{
last_sequence = Some(
last_sequence.map_or(sequence, |l| l.max(sequence)),
);
Expand Down Expand Up @@ -547,9 +608,24 @@ where
{
return;
}
tokio::time::sleep(backoff).await;
tokio::select! {
_ = tokio::time::sleep(backoff) => {}
_ = events.closed() => return,
}
backoff = (backoff * 2).min(MAX_BACKOFF);
}
});
receiver
}

/// The event sequence an orchestration item carries, which a resubscribe resumes after.
fn delivered_sequence(item: &Value) -> Option<u64> {
// Enrichment snapshots do not move the resume cursor.
if item.get("resolvedRepositoryIdentityRoots").is_some() {
return None;
}
item.get("sequence")
.or_else(|| item.get("snapshotSequence"))
.or_else(|| item.get("snapshot").and_then(|s| s.get("snapshotSequence")))
.and_then(Value::as_u64)
}
Loading
Loading