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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,17 @@ jobs:
# why: lint the DEFAULT/shipping feature config too — CI only ever linted
# the tray build, so a clippy regression on the default build slipped through.
- run: cargo clippy --locked --all-targets -- -D warnings
# Overlay feature (macOS: real transparent PopUp window + objc2 panel harden).
- run: cargo build --locked --features overlay
- run: cargo clippy --locked --all-targets --features overlay -- -D warnings
- run: cargo clippy --locked --all-targets --features tray,overlay -- -D warnings
# why: run the test suite — the command_palette fuzzy-match tests existed but
# CI never executed them, so an agent could break scoring invisibly.
- run: cargo test --locked
# why: the overlay unit tests (OverlayState/anchor math, JobStatus + HudState
# reducers) are gated behind --features overlay, so run them explicitly or they
# never execute. The reducers/anchor math are platform-independent — runs on Linux too.
- run: cargo test --locked --features overlay

linux:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -84,9 +92,16 @@ jobs:
# why: lint the DEFAULT/shipping feature config too — CI only ever linted
# the tray build, so a clippy regression on the default build slipped through.
- run: cargo clippy --locked --all-targets -- -D warnings
# Overlay feature (Linux: compile-only no-op path — no LayerShell in v1).
- run: cargo build --locked --features overlay
- run: cargo clippy --locked --all-targets --features overlay -- -D warnings
# why: run the test suite — the command_palette fuzzy-match tests existed but
# CI never executed them, so an agent could break scoring invisibly.
- run: cargo test --locked
# why: the overlay unit tests (OverlayState/anchor math, JobStatus + HudState
# reducers) are gated behind --features overlay, so run them explicitly or they
# never execute. The reducers/anchor math are platform-independent — runs on Linux too.
- run: cargo test --locked --features overlay

# why: supply-chain gate (advisories / bans / sources / licenses) via cargo-deny.
# deny.toml is already seeded and passes locally (`cargo deny check` is green). This lands
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

13 changes: 11 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,22 @@ tray-icon = { version = "0.24", optional = true }
[features]
default = []
tray = ["dep:tray-icon", "dep:objc2", "dep:objc2-app-kit", "dep:objc2-foundation"]
# Floating overlay surfaces (transparent always-on-top PopUp window). macOS-only
# in v1; on Linux it compiles to a no-op (no LayerShell yet). Reuses the SAME
# objc2 stack as `tray` (no new compiled crate) and adds raw-window-handle (rwh
# 0.6 is already in the tree via gpui, so this is a `dep:` surfacing, not a new dep).
overlay = ["dep:objc2", "dep:objc2-app-kit", "dep:objc2-foundation", "dep:raw-window-handle"]

# objc2 is only used to hide the macOS dock icon — pull it on macOS only, so a
# Linux/Windows `--features tray` build never tries to compile Apple crates.
# objc2 is only used to hide the macOS dock icon (tray) and harden the overlay
# panel — pull it on macOS only, so a Linux/Windows `--features tray`/`overlay`
# build never tries to compile Apple crates.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = { version = "0.6", optional = true }
objc2-app-kit = { version = "0.3", optional = true }
objc2-foundation = { version = "0.3", optional = true }
# Recover the raw NSView from a gpui Window to harden the overlay panel (objc2
# bridge). rwh 0.6.2 is already in Cargo.lock via gpui — no new compiled crate.
raw-window-handle = { version = "0.6", optional = true }

[profile.release]
strip = true
Expand Down
32 changes: 32 additions & 0 deletions docs/LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,3 +561,35 @@ renders real rows.

**4. Filter and search in memory.** The palette matches its registry with a synchronous, dependency-
free fuzzy scorer — no I/O, no background thread per keystroke (§16). Do the same for your own pickers.

---

## 18. Floating panels / hovering windows — transparent HUD surfaces {#overlay}

Building a floating panel (a status rail, a recording pill, a HUD) means opening a *second*
always-on-top window and making it genuinely transparent. The whole working example is `src/overlay/`
(`--features overlay`, macOS; full spec in `docs/overlay.md`). The non-obvious knobs:

- **Open it as a transparent `PopUp`.** `WindowKind::PopUp` (non-activating NSPanel, all-Spaces) +
`WindowBackgroundAppearance::Transparent` + `focus: false` + `titlebar: None`, sized to content and
anchored off `PlatformDisplay::visible_bounds()`. Never `cx.activate()` to summon it (that steals the
foreground app's activation).
- **Do NOT wrap the view in gpui-component `Root`.** `Root::render` paints `cx.theme().background` over
the *entire* window — that's the "dark box" that looks like a transparency failure but isn't. Return
the view entity **directly** from the `open_window` build closure. The cost: you lose Root's
tooltip/notification/modal layers — fine for a simple surface, so only add `Root` back if you need them.
- **Kill the window's OS shadow if your panel is heavily rounded.** gpui keeps a macOS window shadow on
transparent windows (it gives the window a near-opaque backing on purpose, *"to avoid broken shadow"*).
That shadow is a rounded-**rectangle** the size of the window — behind a `rounded_full` pill it shows
as a mismatched frame poking past the rounded ends. Disable it with `NSWindow::setHasShadow(false)` and
let the content render its **own** `shadow_lg` instead. A `rounded_xl` panel roughly matches the window
rect, so it can keep the window shadow. The knob here is `harden::harden_panel(window, hide_shadow)`:
the bottom pill passes `true` (frameless float), the top rail passes `false` (keeps the window shadow).
- **Stop clicks from stealing focus.** `PopUp` is non-activating, but a click still makes the panel
*key* — `setBecomesKeyOnlyIfNeeded(true)` on the NSPanel (also in `harden_panel`) keeps the focused app's
text caret. Both of these live in `src/overlay/harden.rs`, the one place with `unsafe` (the raw
`NSView → NSWindow` bridge), fenced with `// SAFETY:` + a scoped `#[allow(unsafe_code)]`.
- **Push state in from the background the usual way.** Stash a `WeakEntity<YourView>` in a `Global`,
hop to the main thread, `upgrade()` (None = closed, a free no-op), mutate, `cx.notify()` — the
background-job spine (§17, `docs/background-jobs.md`). `WindowKind::PopUp` fires hover even while the
app is inactive, so `.hover()`/`.on_click()` on styled `div`s just work.
185 changes: 185 additions & 0 deletions docs/background-jobs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# Background jobs in Deck

How to run work that outlives a keystroke — a multi-turn agent talking to an API for
minutes, a listener that waits hours for a signal — without freezing the UI or pulling in
a second async runtime.

**The omakase take:** GPUI already *is* your async runtime. Deck ships an opinionated,
zero-dependency job pattern built on it, and documents two escape hatches for when you want
the tokio ecosystem or a different stack. Pick the default, or swap a layer — nothing here
locks you in.

> Performance rule first (see `docs/LEARNINGS.md` §17 and `CLAUDE.md`): **never block the
> render thread on I/O.** Everything below runs work off the UI thread and pushes results
> back with `cx.notify()`. That rule is why this doc exists.

---

## 1. You already have a runtime — don't add one

GPUI bundles a full executor (the same one Zed runs its editor, language servers, and agent
panel on). You do **not** need `tokio`, `async-std`, or a thread-pool crate for the runtime
itself. Verified API (`gpui/src/executor.rs`, `gpui/src/app/async_context.rs`):

| You want | Call | Notes |
|---|---|---|
| Run hours-long / blocking / CPU work off the UI thread | `cx.background_executor().spawn(fut)` | future must be `Send + 'static` (`executor.rs:89`) |
| Run async work that touches UI state | `cx.spawn(async move \|cx\| { … })` → `AsyncApp` | main-thread, future need not be `Send` (`async_context.rs:204`) |
| Sleep / poll | `cx.background_executor().timer(Duration)` | returns `Task<()>` (`executor.rs:162`); **never** `std::thread::sleep` on the UI thread |
| Bounded wait | `executor.block_with_timeout(dur, fut)` | `executor.rs:364` |
| Fire-and-forget with error logging | `task.detach_and_log_err(cx)` | `TaskExt`, `executor.rs:33` |

**`Task<T>` is the handle, and dropping it cancels the work** (at the next `.await` point).
That gives you structured cancellation for free: hold the `Task` on an entity, drop the
entity (or replace the `Task`), and the job stops. Call `.detach()` only when you truly want
it to outlive its handle. `tray.rs:62-78` is the canonical "listen forever, push to UI" loop.

---

## 2. The omakase pattern: a cancellable job that reports status

The shape Deck recommends for any long-running job (an agent turn loop, a watcher, an
import): an entity owns a `Task` plus a status enum; the job runs on the **background**
executor and pushes status back through a **`WeakEntity` + `cx.notify()`** (the same spine
the overlay's agent-row uses — see `docs/overlay.md` Child #1).

```rust
pub enum JobStatus {
Idle,
Running { turn: usize, note: SharedString },
Retrying { attempt: u32, after: Duration },
Failed(SharedString),
Done(SharedString),
}

pub struct AgentJob {
status: JobStatus,
task: Option<Task<()>>, // dropping this cancels the run
}

impl AgentJob {
pub fn start(&mut self, cx: &mut Context<Self>) {
let this = cx.entity().downgrade(); // WeakEntity<AgentJob>
self.task = Some(cx.background_executor().spawn(async move {
let mut attempt = 0;
loop {
match run_one_turn().await { // your async API call
Ok(turn) => {
// hop back to the app to mutate UI state + repaint
let _ = this.update(/* AsyncApp */ cx, |job, cx| {
job.status = JobStatus::Running { turn, note: "ok".into() };
cx.notify(); // smallest entity that changed
});
if turn.is_final() { break; }
attempt = 0;
}
Err(e) if attempt < MAX_RETRIES => {
attempt += 1;
let backoff = retry_backoff(attempt); // pure, unit-tested below
// … set Retrying status via this.update(…) …
cx.background_executor().timer(backoff).await;
}
Err(e) => { /* set Failed via this.update(…); */ break; }
}
}
}));
// Note: the closure above needs an AsyncApp to call `this.update`; in real code use
// `cx.spawn(async move |cx| { … cx.background_executor().spawn(...) … })` so you hold
// an AsyncApp, or pass results out via a channel and apply them on the foreground.
}

pub fn cancel(&mut self) { self.task = None; } // drop = cancel
}
```

Two true things to internalize:
- **`WeakEntity::upgrade()` returning `None` is your "UI is gone" signal** — pushes after the
window/entity closes simply no-op. No leaks, no panics. (This is exactly how the overlay
spec handles a closed HUD.)
- **`this.update(...)` returns a `Result`** — handle it (`?` or `let _ =`); a dropped
fallible result trips `unused_must_use` (denied in this repo).

### Retry/backoff is pure and testable (zero-dep)

Keep the backoff math out of the async closure so it unit-tests like
`command_palette::fuzzy()`:

```rust
fn retry_backoff(attempt: u32) -> Duration {
// exp backoff, capped — swap for the `backoff` crate if you want jitter/decorrelation
let secs = (1u64 << attempt.min(6)).min(60);
Duration::from_secs(secs)
}

#[cfg(test)]
mod tests {
#[test] fn backoff_grows_then_caps() { /* 2,4,8,…,60,60 */ }
#[test] fn status_transitions_terminal() { /* Failed/Done don't resume */ }
}
```

---

## 3. The HTTP / API client — the one real decision

GPUI runs *futures* but ships **no HTTP client**. Deck is UI-only today, so this is
greenfield. Three stacks, each a deliberate tradeoff:

| Option | How | Pros | Cons | Dep cost |
|---|---|---|---|---|
| **`gpui_tokio` + reqwest / official SDKs** *(recommended for agents)* | `gpui_tokio::init(cx)` once, then `Tokio::spawn(cx, async { reqwest … }) -> Task<Result<R, JoinError>>` (`gpui_tokio.rs:55`) | Unlocks the mature tokio ecosystem: `reqwest`, `async-openai`, the Anthropic SDKs, streaming, TLS. Results come back as GPUI `Task`s. | Runs a second (tokio) runtime alongside GPUI's. New deps. | new: `gpui_tokio` (first-party) + `reqwest`/SDK — approval-gated (DoD #4) |
| **Blocking client on the bg pool** | `cx.background_executor().spawn(\|\| ureq::post(...))` | Dead simple, robust, no second runtime. The bg pool tolerates blocking. | A blocking call **can't cancel mid-flight** (drop only cancels at await points). No streaming ergonomics. | new: `ureq` — approval-gated |
| **Zed's `http_client`** | the `http_client` crate in the gpui stack | Already in the wider tree; matches Zed's abstraction. | Heavier, Zed-shaped API; more than a starter needs. | new: `http_client` |

**Omakase pick:** `gpui_tokio` + `reqwest` (or the official Anthropic SDK) for anything
agent-shaped — multi-turn, streaming, cancellation all work cleanly. It's a first-party gpui
crate, so the bridge is low-risk; the SDK is your call. Reach for blocking `ureq` only for a
one-shot call where simplicity beats cancellability.

**Escape hatch (the "pick your own stack" promise):** the job pattern in §2 is
client-agnostic — it only cares that `run_one_turn()` returns a `Future`. Swap reqwest for
ureq, or tokio for a smol-native client, by changing that one function. Nothing else moves.

---

## 4. Cancellation, the honest version

- **Async path:** hold the `Task`; drop it to cancel at the next `.await`. For mid-request
cancel, `futures::select!` the request against a cancel channel (`async-channel` /
`flume`), or use `tokio_util::sync::CancellationToken` when on the tokio bridge.
- **Blocking path:** a `ureq`/blocking call on the bg pool **cannot** be interrupted; it runs
to completion (or its own timeout). Set a request timeout; don't promise instant cancel.
- A **panic** inside a `Task` aborts that task only — the app survives — but there's **no
auto-restart**. Wrap the loop body, convert errors to `Failed`, and let the user retry.

---

## 5. Listening for hours, and the macOS gotcha

A watcher (a socket, a channel, `notify` for filesystem events, a unix signal via
`signal-hook`) is just a background task that awaits in a loop and `cx.update`s on each event
— identical to `tray.rs`'s menu-event drain. Hours-long is fine: GPUI's bg thread pool is
persistent, and a native desktop process keeps its threads alive (no mobile/web sandbox
killing you).

**The one real platform caveat: macOS App Nap.** When your app is in the background, macOS
can throttle its timers and CPU (App Nap), which stalls a long agent run or a polling
listener. If you need guaranteed background progress, disable it via
`NSProcessInfo.processInfo.beginActivityWithOptions(.userInitiated | .idleSystemSleepDisabled, reason:)`
through objc2 — the same objc2 path `tray.rs:94-104` already uses for the dock policy. Hold
the returned activity token for the duration of the work, drop it when idle. Linux has no
equivalent throttle.

---

## 6. What Deck ships vs. what you add

- **Free, in the box (zero-dep):** the executor, `Task` cancellation, `timer`, the §2 job
pattern, pure retry/backoff logic. The runtime is *not* a dependency decision.
- **Approval-gated (DoD #4 — new deps):** any HTTP client, `gpui_tokio`, official agent SDKs,
`backoff`, `notify`, `signal-hook`, App-Nap objc2 (reuses the `tray` feature's objc2). Add
them behind a feature flag (mirror `--features tray`) so the default fork stays lean.

The omakase default is: lean core + the §2 pattern, and you opt into a client stack when you
wire a real agent. That keeps "fork it, rename it, ship it" honest while leaving every stack
choice reversible.
Loading
Loading