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
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**GPU-accelerated terminal for running CLI coding agents in parallel.**

No chat panel. No wrapper around your agent. Just a fast, dark workspace where every `claude` / `codex` session lives in its own git worktree, every commit can be AI-drafted, and the browser, files, and git panels are one keystroke away.
No chat panel. No wrapper around your agent. Just a fast, dark workspace where every `claude` / `codex` session lives in its own git worktree, every commit can be AI-drafted, and the files and git panels are one keystroke away.

> Built with Tauri (Rust) + React/TypeScript. xterm.js + WebGL for the terminal. macOS only in v1.

Expand All @@ -15,7 +15,6 @@ A native macOS app for orchestrating CLI coding agents the way you already think
- **No harness, just a terminal.** Your agent runs in a real PTY with xterm.js + WebGL. Nothing in between you and the model.
- **Every agent gets its own git worktree.** Spawn five `claude` sessions on five branches and they never step on each other.
- **Live, plain-English summaries.** Every tab carries a one-line summary of what the agent is doing right now, auto-drafted when the PTY goes idle.
- **In-house browser daemon.** A headless Chrome at `127.0.0.1:4000` exposes `/screenshot`, `/navigate`, `/click`, `/type`, `/console/recent`. Faster than Chrome MCP. The same HTTP contract works from any agent terminal.
- **AI commits and AI PRs.** Stage changes, hit `⌘⏎`, get a Gemini Flash-Lite draft. `⌘⌥P` drafts a PR title + body and ships it via `gh`.
- **Highlight → ask.** Select code, press `⌘L`, get an inline answer in the margin. No side panel. No thread.
- **Per-project memory.** `rli-memory add` / `recall` from any pane. Auto-scoped to the active worktree. Multiple agents in parallel panes coordinate without a scratch file.
Expand Down
22 changes: 17 additions & 5 deletions src-tauri/resources/goonware-codex-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ ls /tmp/goonware-agent-*.sock >/dev/null 2>&1 || exit 0
GOONWARE_SID="${GOONWARE_SESSION_ID:-${GLI_SESSION_ID:-$RLI_SESSION_ID}}"
[ -z "$GOONWARE_SID" ] && exit 0

# Codex's hook protocol is similar to Claude's but emits fewer events
# (only SessionStart / UserPromptSubmit / Stop are reliably wired).
# That means we have no SessionEnd signal — the Rust side compensates
# with PID-based liveness monitoring, for which it needs the codex
# Codex's hook protocol mirrors Claude's, but only SessionStart /
# UserPromptSubmit / Stop are guaranteed on every Codex build — the
# richer events (PreToolUse / PostToolUse / Notification / PreCompact
# / SessionEnd) fire on newer CLIs and are forwarded verbatim when
# they do; the Rust classifier handles them all. Codex still has no
# reliable SessionEnd signal, so the Rust side compensates with
# PID-based liveness monitoring, for which it needs the codex
# process id. We walk up the parent process tree looking for "codex"
# so the Rust side has a PID to watch.
/usr/bin/python3 -c "
Expand Down Expand Up @@ -69,14 +72,23 @@ def codex_pid():
pid = info['ppid']
return None

# Same envelope shape as goonware-claude-hook.sh:
# - 'aux' carries Notification's sub-classifier (notification_type)
# so the Rust side can tell idle_prompt (→ Idle) from a real
# question (→ Waiting).
# - 'prompt' carries the user's typed text for UserPromptSubmit so
# the tab-subtitle summarizer works for codex tabs too. Captured
# here (inside codex's process tree) so Goonware never has to read
# ~/.codex/sessions/*.jsonl — same TCC rationale as Claude.
out = {
'provider': 'codex',
'session_id': payload.get('session_id', ''),
'transcript_path': payload.get('transcript_path', ''),
'cwd': payload.get('cwd', ''),
'event': payload.get('hook_event_name', ''),
'tool': payload.get('tool_name', ''),
'aux': '',
'aux': payload.get('notification_type', ''),
'prompt': payload.get('prompt', '') or '',
'goonware_session_id': '$GOONWARE_SID',
'goonware_instance_id': os.environ.get('GOONWARE_INSTANCE_ID', ''),
}
Expand Down
1 change: 1 addition & 0 deletions src-tauri/resources/goonware-gemini-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ out = {
'event': payload.get('hook_event_name', ''),
'tool': payload.get('tool_name', ''),
'aux': payload.get('notification_type', ''),
'prompt': payload.get('prompt', '') or '',
'goonware_session_id': '$GOONWARE_SID',
'goonware_instance_id': os.environ.get('GOONWARE_INSTANCE_ID', ''),
}
Expand Down
559 changes: 483 additions & 76 deletions src-tauri/src/agent_hooks.rs

Large diffs are not rendered by default.

42 changes: 41 additions & 1 deletion src-tauri/src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,13 @@ pub fn fs_rename(from: String, to: String) -> Result<String, String> {
if from_path == to_path {
return Ok(to);
}
if to_path.exists() {
// On macOS's default case-insensitive APFS, a case-only rename
// ('readme.md' → 'README.md') makes `to` resolve to the SOURCE
// file's own inode, so `exists()` is true even though nothing
// distinct would be clobbered. If `from` and `to` are the same
// underlying file, fall through — `fs::rename` performs the
// in-place case change. Genuinely distinct targets still error.
if to_path.exists() && !same_file(from_path, to_path) {
let name = to_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
Expand All @@ -594,6 +600,28 @@ pub fn fs_rename(from: String, to: String) -> Result<String, String> {
Ok(to)
}

/// True when `a` and `b` name the same underlying file (same device +
/// inode). Uses `symlink_metadata` so a symlink compares as the link
/// entry itself rather than its target. This is what lets `fs_rename`
/// distinguish a case-only rename on a case-insensitive volume from a
/// genuine collision with a different file.
#[cfg(unix)]
fn same_file(a: &Path, b: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
match (fs::symlink_metadata(a), fs::symlink_metadata(b)) {
(Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
_ => false,
}
}

/// Non-unix fallback: no inode identity available, so never claim two
/// distinct paths are the same file (preserves the strict no-clobber
/// behavior). The app only ships on macOS, so this is belt-and-braces.
#[cfg(not(unix))]
fn same_file(_a: &Path, _b: &Path) -> bool {
false
}

/// Permanently delete a file or directory. Powers the file tree's
/// right-click → Delete; the frontend gates it behind a confirm dialog
/// because this is irreversible (no Trash round-trip — moving to
Expand Down Expand Up @@ -853,6 +881,18 @@ mod tests {
.unwrap_err();
assert!(err.contains("already exists"), "got: {err}");
assert_eq!(fs::read_to_string(&other).unwrap(), "keep");

// A case-only rename must not be mistaken for a collision on
// case-insensitive filesystems (the default on macOS).
let lower = dir.path().join("case-name.txt");
let upper = dir.path().join("CASE-NAME.txt");
fs::write(&lower, b"same file").unwrap();
fs_rename(
lower.to_string_lossy().into_owned(),
upper.to_string_lossy().into_owned(),
)
.unwrap();
assert_eq!(fs::read_to_string(&upper).unwrap(), "same file");
}

#[test]
Expand Down
62 changes: 55 additions & 7 deletions src-tauri/src/pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,9 @@ async fn enter_review_state(cwd: &str, base: &str) -> Option<String> {
/// an ancestor of `head_sha` (the soft reset is still in effect); if
/// the user or an agent committed on top, resetting would drop those
/// commits from the branch, so we leave everything alone and report
/// false.
/// false. Errs when the ancestry check itself fails (e.g. `head_sha`
/// no longer resolves after a force-push + prune) — callers must treat
/// that as "do not touch the worktree", not as a benign skip.
async fn restore_review_state(cwd: &str, head_sha: &str) -> Result<bool, String> {
let head = run_git_checked(cwd, &["rev-parse", "HEAD"])
.await?
Expand All @@ -745,16 +747,28 @@ async fn restore_review_state(cwd: &str, head_sha: &str) -> Result<bool, String>
if head == head_sha {
return Ok(false);
}
let ancestor = Command::new("git")
let out = Command::new("git")
.args(["merge-base", "--is-ancestor", "HEAD", head_sha])
.current_dir(cwd)
.output()
.await
.map_err(|e| format!("spawn git: {e}"))?
.status
.success();
if !ancestor {
return Ok(false);
.map_err(|e| format!("spawn git: {e}"))?;
match out.status.code() {
// HEAD is an ancestor — the soft reset is still in effect.
Some(0) => {}
// Definitive "not an ancestor": someone committed on top —
// resetting would drop those commits, so leave everything be.
Some(1) => return Ok(false),
// Anything else (e.g. 128: head_sha unresolvable after a
// force-push + prune) means git couldn't answer the question.
// Neither resetting nor pretending "user committed on top" is
// safe, so surface it and let the caller abort.
_ => {
return Err(format!(
"git merge-base --is-ancestor HEAD {head_sha} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
}
run_git_checked(cwd, &["reset", "--soft", head_sha]).await?;
Ok(true)
Expand Down Expand Up @@ -1824,6 +1838,40 @@ mod tests {
assert_eq!(head_after.trim(), new_head, "takeover commit must survive");
}

#[tokio::test]
async fn checkout_return_aborts_when_head_sha_is_unresolvable() {
// A force-push + prune can leave the recorded head sha pointing
// at nothing. That is NOT the "user committed on top" case:
// proceeding would check out the original branch with the whole
// PR diff still staged. The return must abort and leave the
// review checkout exactly as it was.
let (clone, _bare, _pr_head) = build_pr_branch_repo();
let cwd = clone.path().to_str().unwrap();
enter_review_state(cwd, "main").await.expect("enter");

let bogus = "0123456789abcdef0123456789abcdef01234567";
let err = pr_checkout_return(
cwd.to_string(),
"main".to_string(),
false,
Some(bogus.to_string()),
)
.await
.expect_err("unresolvable head sha must abort the return");
assert!(err.contains(bogus), "error should name the sha: {err}");

// Still in the review checkout on the PR branch...
let branch = run_sync(clone.path(), &["symbolic-ref", "--short", "HEAD"]);
assert_eq!(branch.trim(), "pr-branch", "must not switch branches");
// ...with the PR diff still staged where it belongs — not
// carried onto main.
let staged = run_sync(clone.path(), &["diff", "--cached", "--name-only"]);
assert!(
staged.contains("a.txt") && staged.contains("b.txt"),
"review state must remain intact: {staged}"
);
}

#[tokio::test]
async fn checkout_return_with_head_sha_restores_branch_before_switching() {
let (clone, _bare, pr_head) = build_pr_branch_repo();
Expand Down
27 changes: 27 additions & 0 deletions src/lib/claudeUsage.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
detectAgentBanner,
detectClaude,
formatDuration,
formatTokenCount,
Expand Down Expand Up @@ -32,6 +33,32 @@ describe("detectClaude", () => {
});
});

describe("detectAgentBanner", () => {
test("classifies the Claude banner", () => {
expect(detectAgentBanner("│ ✻ Welcome to Claude")).toBe("claude");
expect(detectAgentBanner("Claude Code v1.2.3")).toBe("claude");
});

test("classifies the Codex banner case-insensitively", () => {
expect(detectAgentBanner("OpenAI Codex (v0.42.0)")).toBe("codex");
expect(detectAgentBanner(">_ openai codex")).toBe("codex");
});

test("classifies the Gemini banner", () => {
expect(detectAgentBanner("Tips for getting started:")).toBe("gemini");
});

test("returns null for plain shell output", () => {
expect(detectAgentBanner("$ ls -la\ntotal 42")).toBe(null);
expect(detectAgentBanner("")).toBe(null);
});

test("the bare CLI name is not a banner", () => {
expect(detectAgentBanner("codex")).toBe(null);
expect(detectAgentBanner("gemini")).toBe(null);
});
});

describe("formatTokenCount", () => {
test("under 1k stays as a plain integer", () => {
expect(formatTokenCount(0)).toBe("0");
Expand Down
24 changes: 24 additions & 0 deletions src/lib/claudeUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ const CLAUDE_MARKERS = [
"✻ welcome",
];

/** Codex CLI's startup banner prints "OpenAI Codex (vX.Y.Z)". */
const CODEX_MARKERS = ["openai codex"];

/** Gemini CLI greets with a tips block under its ASCII-art banner. */
const GEMINI_MARKERS = ["welcome to gemini", "tips for getting started"];

export type AgentBannerCli = "claude" | "codex" | "gemini";

/**
* Returns true when `text` contains a confident marker that Claude is
* running in this PTY. Used by BlockTerminal for UI-mode switching —
Expand All @@ -84,6 +92,22 @@ export function detectClaude(text: string): boolean {
return CLAUDE_MARKERS.some((m) => lower.includes(m));
}

/**
* Provider-agnostic banner sniff: classify which agent CLI's startup
* banner appears in `text`, or null when none does. Same contract as
* {@link detectClaude} but covers every agent in the roster, so a
* codex/gemini launched through a wrapper script (where the command
* line never says "codex") still flips the pane into agent mode.
*/
export function detectAgentBanner(text: string): AgentBannerCli | null {
if (!text) return null;
const lower = text.toLowerCase();
if (CLAUDE_MARKERS.some((m) => lower.includes(m))) return "claude";
if (CODEX_MARKERS.some((m) => lower.includes(m))) return "codex";
if (GEMINI_MARKERS.some((m) => lower.includes(m))) return "gemini";
return null;
}

export interface ModelBreakdown {
messages: number;
input_tokens: number;
Expand Down
50 changes: 0 additions & 50 deletions src/shell/MainColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,6 @@ export function MainColumn() {
</div>
);
}

/* ------------------------------------------------------------------
Breadcrumb
------------------------------------------------------------------ */
Expand Down Expand Up @@ -774,16 +773,6 @@ function TabContent({
draggingTabId: string | null;
}) {
const split = !!splitTab;
// TEMP DEBUG — remove before commit (live-state mirror for the render
// investigation; a module-level interval at the bottom of this file
// posts it to a local diagnostics listener).
(window as unknown as Record<string, unknown>).__paneState = {
wt: worktree.id,
splitTabIdRaw: worktree.splitTabId ?? null,
activeTabId: worktree.activeTabId ?? null,
active: tab ? { id: tab.id, kind: tab.kind } : null,
split: splitTab ? { id: splitTab.id, kind: splitTab.kind } : null,
};
// Terminal-kind tabs go through the always-mounted keepalive
// layer; non-terminal kinds (diff, markdown, all-changes,
// project-settings) mount on demand. The keepalive layer is
Expand Down Expand Up @@ -1820,42 +1809,3 @@ function MissingWorktreeView({
</div>
);
}



// TEMP DEBUG — remove before commit. Posts a 2s heartbeat of the pane
// layout (state mirror + drop-zone children boxes + visibility) to a
// local diagnostics listener so pane-rendering failures can be caught
// in the exact moment they happen.
{
const g = window as unknown as { __paneDump?: number; __paneState?: unknown };
if (g.__paneDump) window.clearInterval(g.__paneDump);
g.__paneDump = window.setInterval(() => {
const z = document.querySelector("[data-tab-drop-zone]");
const kids = z
? Array.from(z.children).map((c) => {
const e = c as HTMLElement;
const r = e.getBoundingClientRect();
const cs = getComputedStyle(e);
return {
rect: { x: r.x, y: r.y, w: r.width, h: r.height },
vis: cs.visibility,
z: cs.zIndex,
kidCount: e.children.length,
text: (e.textContent ?? "").slice(0, 40),
};
})
: null;
const zr = z?.getBoundingClientRect();
fetch("http://localhost:8787/dump", {
method: "POST",
body: JSON.stringify({
t: new Date().toISOString(),
win: { w: window.innerWidth, h: window.innerHeight },
zone: zr ? { x: zr.x, y: zr.y, w: zr.width, h: zr.height } : null,
state: g.__paneState ?? null,
kids,
}),
}).catch(() => {});
}, 2000);
}
Loading
Loading