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
26 changes: 23 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Two halves that fill each other's gaps:

**tmux must win backend detection whenever `$TMUX` is set.** Inside kitty running tmux,
`kitty @ get-text` returns tmux's *rendered viewport*, not the shell's scrollback, and tmux
swallows the OSC 133 prompt marks that kitty's `last_cmd_output` depends on. Asking kitty
swallows the OSC 133 prompt marks that kitty's output extents depend on. Asking kitty
there returns plausible but wrong text. Order lives in `backend::detect`.

**Scrollback coordinates are absolute, never pane-relative.** `capture-pane -S/-E` are
Expand All @@ -45,14 +45,34 @@ it, tcap installs its own trap. Both branches are covered by
**Command text reaches the binary as a single argv element**, so quoting, backslashes and
newlines need no escaping.

**Only tmux can reach past the most recent command.** kitty exposes a `last_cmd_output`
**Only tmux can reach past the most recent command.** kitty exposes a latest-output
extent and nothing older; iTerm2 exposes only its latest prompt. `-c 2` on those must fail
with a pointer to tmux, never return the wrong block.

**kitty has three traps, all found in the field rather than by tests.** `get-text` must be
asked for `last_non_empty_output`, not `last_cmd_output`: kitty counts the running `tcap` as
the current command, so the latter returns tcap's own empty output. `--match id:$KITTY_WINDOW_ID`
is required or kitty reads the *focused* window, which need not be the one tcap runs in — and
`--match` is a `get-text` option, so it must follow the subcommand. Remote control works over
either the escape-code channel or `--to $KITTY_LISTEN_ON`; under `allow_remote_control
socket-only` only the socket works, so the error must advise `listen_on`, not
`allow_remote_control yes`.

**The capture marker records that tcap's own text reached the screen**, so the next capture on
a boundary-less backend can refuse rather than return it. It is written only where captured
*content* is printed — not for hints or errors, which are not that text, and marking those made
`tcap --output | llm` refuse the following capture, with the outcome flipping on `--quiet`.

One case it cannot see: `tcap --output | llm` pipes tcap's stdout, but the downstream tool
writes its answer to the terminal, so kitty's last non-empty output is that answer with no
marker set. The next capture proceeds and is labelled approximate rather than refused. tcap
cannot observe what a downstream process printed, so this is a limitation to state, not a check
to add.

## Verification status

- **tmux** — fully working, covered by end-to-end tests.
- **kitty** — implemented against `--extent=last_cmd_output`, verified present in kitty 0.48.2.
- **kitty** — implemented against `--extent=last_non_empty_output`, verified live in kitty 0.48.2.
- **iTerm2** — written to the documented Python API but **never run**; iTerm2 was not
installed on the machine it was written on. Treat as unverified.
- **WezTerm** — detection only. Deliberately not implemented: there is no verified way to map
Expand Down
9 changes: 8 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "tcap"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
description = "Capture the last command and its output from your terminal"
license = "MIT"
Expand All @@ -12,6 +12,7 @@ path = "src/main.rs"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive", "env"] }
libc = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
Expand Down
36 changes: 26 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ tcap --output # output only, no header
tcap --command # the command text only
tcap --raw # verbatim, ANSI colour intact, no header
tcap --json # {command, exit_code, cwd, duration_ms, output, source}
# plus "approximate": true when the backend cannot vouch
# that the output belongs to that command
tcap --copy # also copy to the clipboard
```

Expand Down Expand Up @@ -140,7 +142,7 @@ Norway problem, where `quiet: no` silently parses as `false`.
| Terminal | Last command | Older commands (`-c 2`) | Notes |
|---|---|---|---|
| **tmux** | yes | **yes** | The only backend that can reach back |
| **kitty** | yes | no | Needs `allow_remote_control yes` |
| **kitty** | yes | no | Needs remote control (`yes`, or `socket-only` + `listen_on`) |
| **iTerm2** | yes | no | Needs Shell Integration + `pip install iterm2` |
| **WezTerm** | not yet | no | Detected; run inside tmux |
| Ghostty, Terminal.app, Alacritty | no | no | No remote-control API — run inside tmux |
Expand All @@ -149,13 +151,26 @@ Two things worth knowing:

**tmux wins whenever it is running.** Inside kitty running tmux, asking kitty for
its scrollback returns tmux's *rendered viewport*, not the shell's real history —
and tmux swallows the prompt marks kitty's `last_cmd_output` depends on. So tmux
and tmux swallows the prompt marks kitty's output extents depend on. So tmux
is always treated as authoritative when it is in the stack.

**Only tmux can reach past the most recent command.** kitty exposes a
`last_cmd_output` extent and nothing for older commands; iTerm2 exposes only its
latest prompt. `tcap -c 2` therefore fails with a pointer to tmux rather than
silently returning the wrong block.
**Only tmux can reach past the most recent command.** kitty exposes only the
latest output and nothing older; iTerm2 exposes only its latest prompt. `tcap -c
2` therefore fails with a pointer to tmux rather than silently returning the
wrong block.

**On kitty, output is the last *non-empty* output.** kitty counts the running
`tcap` as the current command, so it has to be asked for the last output that
wasn't empty. The consequence: if your last command printed nothing, kitty
returns an *older* command's output while the header names the last one. The
annotated header carries a `# note:` line saying so, and `--json` sets
`"approximate": true`. Two `tcap` runs in a row are a **hard error** rather than
a label, since there the mismatch is certain. tmux has no such
ambiguity, because the shell hook records real boundaries. kitty also
needs remote control reachable — either `allow_remote_control yes`, or
`socket-only` with a `listen_on` socket in a directory only you can open
(`${TMPDIR}` on macOS, `${XDG_RUNTIME_DIR}` on Linux). A socket in `/tmp` is
reachable by anyone with write permission on it.

When something is missing, `tcap` says what and why:

Expand All @@ -177,9 +192,10 @@ and the next begins. `tcap` fills those gaps from two sides:

- **Shell hooks** (`tcap init`) record the command text, exit code, cwd and
duration on every prompt.
- **Terminal adapters** retrieve the actual text. kitty and iTerm2 know their own
command boundaries; tmux does not (it has no OSC 133 support), so the hook also
records where output starts and ends.
- **Terminal adapters** retrieve the actual text. tmux has no OSC 133 support, so
the hook also records where output starts and ends — which is why it is the
only backend with exact boundaries. iTerm2 tracks its own prompts; kitty can
only return its last non-empty output.

Those tmux coordinates are stored as `history_size + cursor_y`, which is stable
under scrolling: as lines scroll off, `history_size` grows by exactly as much as
Expand All @@ -189,7 +205,7 @@ under scrolling: as lines scroll off, `history_size` grows by exactly as much as
## Development

```sh
cargo test # 49 unit + 11 end-to-end tests
cargo test # 61 unit + 11 end-to-end tests
git config core.hooksPath .githooks # run fmt, clippy and tests before every push
```

Expand Down
18 changes: 13 additions & 5 deletions npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ tcap --output # output only, no header
tcap --command # the command text only
tcap --raw # verbatim, ANSI colour intact
tcap --json # {command, exit_code, cwd, duration_ms, output, source}
# plus "approximate": true when the backend cannot vouch
# that the output belongs to that command
tcap --copy # also copy to the clipboard
tcap --max-bytes 8000 # elide the middle to fit a smaller context window
```
Expand All @@ -96,17 +98,23 @@ unrelated jobs.
| Terminal | Last command | Older (`-c 2`) | Needs |
|---|---|---|---|
| **tmux** | yes | **yes** | nothing |
| **kitty** | yes | no | `allow_remote_control yes` |
| **kitty** | yes | no | remote control: `yes`, or `socket-only` + `listen_on` |
| **iTerm2** | yes | no | Shell Integration + `pip install iterm2` |
| **WezTerm** | not yet | no | run inside tmux |
| Ghostty, Terminal.app, Alacritty | no | no | run inside tmux |

Two things worth knowing up front:

**Only tmux can reach past the most recent command.** kitty exposes a
`last_cmd_output` extent and nothing older; iTerm2 exposes only its latest
prompt. So `tcap -c 2` fails there with a pointer to tmux rather than quietly
returning the wrong block.
**Only tmux can reach past the most recent command.** kitty exposes only the
latest output and nothing older; iTerm2 exposes only its latest prompt. So `tcap
-c 2` fails there with a pointer to tmux rather than quietly returning the wrong
block.

**On kitty, output is the last *non-empty* output**, because kitty counts the
running `tcap` as the current command. A command that printed nothing therefore
yields an older output under the current header; it is labelled with a `# note:`
line and `"approximate": true` in `--json`. Two `tcap` runs in a row are a hard
error instead, since the mismatch is certain there. tmux has no such ambiguity.

**tmux wins whenever it's running.** Inside kitty running tmux, asking kitty for
its scrollback returns tmux's rendered viewport rather than the shell's real
Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agora-build/tcap",
"version": "0.1.0",
"version": "0.1.1",
"description": "Capture the last command and its output from your terminal, ready to pipe into an LLM",
"bin": {
"tcap": "bin/tcap"
Expand Down
25 changes: 18 additions & 7 deletions src/backend/iterm2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,19 @@ impl ITerm2 {
Self
}

/// Materialise the shim next to our state so we are not re-writing it into
/// a world-writable temp path on every call.
/// Materialise the shim under the state directory.
///
/// Goes through the same ownership-checked, `O_NOFOLLOW` path as every other
/// state file. This one matters more than most: the file is handed straight
/// to `python3`, so a symlink or a pre-created file here would be code
/// execution, not just a truncated file.
fn shim_path() -> Result<std::path::PathBuf> {
let dir = crate::state::state_dir();
std::fs::create_dir_all(&dir)?;
let path = dir.join("iterm2_capture.py");
let stale = std::fs::read_to_string(&path)
let path = crate::state::state_dir().join("iterm2_capture.py");
let stale = crate::state::secure_read_file(&path)
.map(|existing| existing != SHIM)
.unwrap_or(true);
if stale {
std::fs::write(&path, SHIM)?;
crate::state::secure_write(&path, SHIM.as_bytes())?;
}
Ok(path)
}
Expand Down Expand Up @@ -108,6 +110,15 @@ impl Backend for ITerm2 {
"iterm2"
}

/// Assumed, not verified. `async_get_last_prompt` may resolve to the prompt
/// tcap is itself running under — the same trap that made kitty's
/// `last_cmd_output` return tcap's own empty output — in which case this
/// should be `false` and captures should be labelled approximate. Nobody has
/// run this backend; `tcap doctor` under iTerm2 is what would settle it.
fn has_command_boundaries(&self) -> bool {
true
}

fn fetch(&self, index: usize, _rec: Option<&Record>, raw: bool) -> Result<Fetched> {
if index > 1 {
return Err(anyhow!(
Expand Down
Loading
Loading