Skip to content
Closed
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
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Principles that guide every decision in dux. If a change conflicts with a tenet,
- **Clickable controls need misclick-safe spacing.** Separate adjacent click targets enough that an imprecise click cannot accidentally activate a different item. When a checkbox sits above modal buttons, include a blank row or equivalent spacing between the checkbox and the buttons.
- **Animations and periodic refreshes use wall-clock time, not tick counts.** Tying visual updates to tick rate couples frame cadence to logic timing. Use `Instant::elapsed()` (or equivalent) so animations stay smooth and refreshes stay consistent regardless of how often ticks fire.
- **The embedded-PTY caret is the host hardware cursor, never a painted block.** Render the agent/terminal cursor by moving the real terminal cursor onto the PTY cursor cell (`set_cursor_position`) and never pre-paint that cell into a styled cursor block. We do this deliberately for **accessibility and UX with CJK IME**: the OS anchors composition popups (Korean/Japanese/Chinese input) to the hardware cursor, so without it composition renders at the terminal origin instead of the agent prompt. Pre-painting the cell as well also reintroduces an invisible-caret bug under hosts like Alacritty that draw their block cursor by inverting the cell (inverting an already-styled cursor cell cancels it out). This means the caret look is the host terminal's cursor and is not theme-colored; that tradeoff is intentional.
- **The TUI does not disambiguate Shift-Enter; Ctrl-J is the supported soft-newline key.** In interactive mode the TUI forwards raw stdin bytes to the child PTY verbatim and never pushes the Kitty keyboard-enhancement flags (`PushKeyboardEnhancementFlags`). Under the legacy terminal protocol the host sends the *same* byte (CR, `\r`/0x0D) for both Enter and Shift-Enter, so there is no distinct Shift-Enter for dux to forward. Making it distinct would require pushing enhancement flags, which (a) only works on host terminals that implement the Kitty protocol and (b) changes the encoding of *every* key — and because interactive input is a verbatim passthrough, those re-encoded bytes reach the child, so it would break providers that don't speak the protocol. We deliberately do **not** do this. `Ctrl-J` already sends LF (0x0A), which agent CLIs treat as a soft newline, so it is the supported way to insert a newline without submitting in the TUI. Users who want a literal Shift-Enter binding can get it by running dux inside a multiplexer/terminal already configured for the Kitty protocol (e.g. tmux). Only the **web UI** rewrites Shift-Enter to LF (see below), because there the browser key event is available before xterm encodes it.

### Web UI (server mode)

Expand All @@ -34,6 +35,7 @@ The web UI (`crates/dux-web/web/`, React + Vite + Tailwind v4) has its own conve
- **Row actions collapse into one `⋯` menu.** When a list row (changed files, projects, sessions) needs more than one action, put them in a single `DropdownMenu` opened by an `Ellipsis`/`EllipsisVertical` trigger — not a strip of inline buttons. The trigger reveals on hover and consumes **no layout space** when idle: animate it in with a `max-width`+`opacity` transition (or, in the sidebar, the absolute `translate-x`+`opacity` of `SidebarMenuAction showOnHover`). Keep it revealed while the menu is open via the Base UI trigger's `data-popup-open` attribute (`md:has-[[data-popup-open]]:…`, or `data-[popup-open]:…` on the trigger itself) — Base UI does **not** set `aria-expanded=true` on an open menu trigger, so keying off `aria-expanded` silently never matches and the action un-reveals (dragging the anchored popover) the instant the cursor leaves the row. Always show it on touch (`max-md:` overrides), and add `motion-reduce:transition-none`. The trigger must `stopPropagation` so it doesn't fire the row's own click.
- **Menu items: icon + neutral color; "…" marks a further step.** Every item in a dropdown `⋯` menu keeps a leading lucide icon — this applies to all of them (agent, Changes, project); none ship an iconless item. (The Ctrl-K command palette is intentionally *not* an icon menu — it is a two-column text table, so this rule does not extend to it.) Append a trailing ellipsis `…` to any item that opens a dialog or needs confirmation (matching the Ctrl-K palette and the project/session menus). **Default destructive items to neutral color** — the `…` plus the confirmation dialog are the danger signal, not the color. (Most destructive `DropdownMenuItem`s use neutral text; reserve `text-destructive`/`variant="destructive"` for confirm buttons inside the dialog.) **The one deliberate exception is the agent ⋯ menu's "Delete agent…" entry**, which is tinted red at rest and brighter on hover (`variant="destructive"` plus `not-focus:text-destructive/70!` to dim the rest state). It is the most consequential per-agent action, so it carries the extra visual weight; the confirmation dialog still gates it. Do not propagate this red treatment to other destructive menu items without a deliberate decision.
- **Destructive actions always confirm.** A destructive item opens a dedicated `Dialog` (Cancel `autoFocus`, the confirm button `variant="destructive"`, misclick-safe spacing) gated by a store target — follow the existing per-action dialog pattern (e.g. `ConfirmDiscardFileDialog`); there is no shared `useConfirm` hook.
- **Shift-Enter inserts a soft newline (LF), not a submit.** The terminal pane intercepts keystrokes at the key-event layer via xterm's `attachCustomKeyEventHandler`, because xterm collapses both Enter and Shift-Enter to CR before `onData` sees them — they are indistinguishable at the data layer. The decision lives in the pure, unit-tested helpers in `lib/termkeys.ts`, not in the component: `softNewline(event)` matches only a bare Shift-Enter `keydown` (no other modifier, and **never mid-IME-composition** — it bails on `isComposing`/`keyCode === 229` so a CJK confirm keystroke is never rewritten), and `softNewlineAction(event, ctx)` folds in the runtime context (ownership, armed latch) to return what the handler must do. The component closure is a thin applicator: on a match it `preventDefault`s (so the browser drops no stray newline into the hidden textarea) and `stopPropagation`s (so the handled key doesn't bubble to window shortcuts) and returns `false` (so xterm emits no CR); only the input owner then writes the `LF` (0x0A, the Ctrl-J byte), and only the owner's path scrolls to the live edge, clears any selection, and consumes an armed one-shot Ctrl/Alt latch — matching the side effects `onData` would otherwise provide. Every other key returns `true` and is encoded by xterm as usual. Because a mobile soft keyboard can't produce the chord, the mobile accessory bar carries a dedicated `⇧↵` key as a second entry point; both it and the physical-key handler write through the one shared `writeSoftNewline(term, pty)` helper so their landing behavior (scroll/clear/send) can never drift. This is web-only on purpose (the TUI cannot disambiguate Shift-Enter — see the TUI tenet above). Keep the branching in the `termkeys.ts` helpers so it stays testable without mounting xterm.
- **One marker for git status.** Render every changed-file status marker through the shared `FileStatusIcon` (lucide `File-*` icons), in the changes pane and the editor's tree/search alike — don't re-implement it inline. The raw status is interpreted once by the pure, unit-tested `fileStatusMeta(status) → { kind, label }` in `lib/changedFiles.ts`; the icon is neutral with a tooltip label.
- **Touch targets ≥44px.** Interactive controls get a `max-md:min-h-11` / `max-md:size-11` (44px) target on phones; desktop density is restored with `md:`.
- **The working-agent icon bob is required, and must settle back to rest.** While an agent streams output (`session.working`, the server-sourced boolean), its `Bot` icon bobs in the sidebar and mobile rows (`--animate-agent-working` / `@keyframes agent-working` in `index.css`) as a deliberate "working" cue. Do not remove it as "cleanup." Two hard requirements: (1) it bounces only while working; (2) when work stops it must glide back to the rest position (`translateY(0)`) and never freeze at the top or bottom of the bounce (or anywhere mid-cycle). Requirement 2 is why the icon always carries `motion-safe:transition-transform motion-safe:duration-300` next to the conditional `motion-safe:animate-agent-working`: dropping the animation class returns the transform to its base value and the transition eases it back to rest. Keep both classes together, and keep the keyframes resting at `0%`/`100%`. The bob and the name shimmer (`.agent-name-shimmer`) are the row's two working cues; both fire on the same active-and-working flag from `agentRowVisual` so they stay in lockstep.
Expand All @@ -45,6 +47,16 @@ The web UI (`crates/dux-web/web/`, React + Vite + Tailwind v4) has its own conve
- **Claude, Codex, OpenCode, and Copilot are the defaults.**
- **No protocol layer.** No JSON-RPC, no custom message format, no adapter binaries. The CLI runs exactly as it would in a normal terminal.

### Agent Tabs

- **A tab is a provider session inside the agent's one shared worktree, not a new worktree.** One `AgentSession` (one branch, one sidebar row) hosts N tabs; every tab runs against the same checkout and edits the same files. The unified tab strip also carries the agent's companion terminals as terminal tabs. Tabs are never separate sidebar agents.
- **Exactly one tab resumes, and it is positional (leftmost).** Only the leftmost provider tab launches with the provider's resume flag (e.g. `--continue`); every other provider tab always starts fresh, including after a restart-restore. This is a hard rule, not a nicety: provider `continue` is directory-scoped, not conversation-id-scoped, so two resuming tabs would reopen the *same* most-recent conversation and show identical content. Decide eligibility in one place (`resume_eligible_tab`) and make `should_resume_session` delegate to it, so the render badge, the spine, and every launch/reconnect path agree on which single tab resumes.
- **The primary tab is the agent, pinned leftmost.** Closing the primary tab detaches the agent (it stays in the sidebar, reopenable); closing a secondary tab removes it, which is destructive and not recoverable, so confirm it while its process is live (see the Destructive-actions tenet). Only secondary tabs reorder. Deleting the agent tears down *every* tab's PTY, not just the primary's.
- **New tabs default to the project provider and start fresh; provider is per-tab.** Adding a tab spawns the project `default_provider` immediately; retarget a tab via `ctrl+p` (TUI) or its `⋯` menu (web), effective on the tab's next launch (a pending reconnect), mirroring the existing agent-provider change.
- **Tab state is derived runtime state: keep it in SQLite, never in config.** Persist secondary tabs in an additive `agent_tabs` table (backward compatible — existing databases start with zero rows and behave exactly as before). The primary tab has no row; it is derived from the `agent_sessions` row. Do not write tab provider or order into portable project config (see the Configuration tenets on portable-vs-derived state).
- **Runtime provider state is keyed by tab id, with the primary tab id equal to the session id.** The `providers` map, `running_provider_pins`, resume-fallback state, and the PTY activity/streaming maps all key by tab id; keeping `tab_id == session_id` for the primary keeps every existing session-keyed lookup working. Session-scoped labels (sidebar, agent title) still resolve the primary tab; only the center pane resolves the focused tab.
- **Tabs honor each surface's existing UI tenets.** TUI: the strip is themed through `theme.rs` (`dux.tab_*` tokens wired through every theme), and tab actions are Center-scope, so they are inactive in fullscreen/interactive mode — exit fullscreen to switch. Web: Chrome-style pills below the commands bar, deep-linkable, reusing the shared `⋯`-menu, destructive-confirm, touch-target, and working-bob tenets above.

### Git and Data Safety

- **Worktrees are user data.** Never removed or mutated casually. Deletion requires explicit user confirmation.
Expand Down
92 changes: 47 additions & 45 deletions crates/dux-web/web/src/components/AccessoryBar.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
import type * as React from "react"
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ChevronsDown,
ChevronsUp,
} from "lucide-react"
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp } from "lucide-react"

import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

// A viewport scroll intent emitted by the second accessory row. The mobile
// scrollbar is a slim touch target and a small drag jumps a long way when there
// is a lot of scrollback, so these buttons drive xterm's scroll API directly
// (see TerminalPane.onScroll).
export type ScrollDir = "top" | "pageUp" | "pageDown" | "bottom"
// A viewport page-scroll intent emitted by the second accessory row's PgUp/PgDn
// keys. The mobile scrollbar is a slim touch target and a small drag jumps a long
// way when there is a lot of scrollback, so these buttons drive xterm's scroll
// API directly (see TerminalPane.onScroll).
export type ScrollDir = "pageUp" | "pageDown"

// The mobile soft keyboard can't produce Esc, Tab, Ctrl-chords, arrow keys, or
// a usable way to scroll back through output, which a terminal needs
// constantly. The accessory bar supplies them as fixed rows of touch targets
// that sit directly above the on-screen keyboard: row one is keys, row two is
// scroll controls.
// The mobile soft keyboard can't produce Esc, Tab, Ctrl-chords, a Shift-Enter
// soft newline, cursor arrows, or a usable way to page through output, which a
// terminal needs constantly. The accessory bar supplies them as two fixed rows of
// touch targets directly above the on-screen keyboard: row one is the modifier /
// special keys (Esc, Tab, Ctrl, Alt, and the ⇧↵ newline), row two is navigation
// (the four cursor arrows plus PgUp/PgDn page scrolling).
//
// Presentational only: this component decides layout and emits intents. All
// behavior (which byte sequence to send, cursor-key mode, one-shot latch
Expand All @@ -32,6 +26,9 @@ interface AccessoryBarProps {
// applying any latched Alt prefix and consulting cursor-key mode for arrows.
onEsc: () => void
onTab: () => void
// Insert a soft newline (LF / Ctrl-J) — the touch equivalent of Shift-Enter,
// which no soft keyboard can produce.
onNewline: () => void
onArrow: (dir: "up" | "down" | "left" | "right") => void
// Viewport scroll intents for the second row. The parent scrolls the xterm
// viewport (not the PTY) so the user can read back without the scrollbar.
Expand All @@ -46,11 +43,12 @@ interface AccessoryBarProps {

// CRITICAL: every bar button calls preventDefault() on pointerdown so the press
// can't shift focus off the xterm hidden textarea before the handler runs, and
// we fire on pointerdown (not click) for a snappy, latency-free feel. The KEY
// row relies on this to keep the textarea focused and the soft keyboard open;
// the SCROLL row reuses the same handler for that focus/sequencing guarantee but
// then deliberately blurs in TerminalPane.onScroll to dismiss the keyboard for
// reading. So "keeps focus" is the key-row contract, not a universal one.
// we fire on pointerdown (not click) for a snappy, latency-free feel. The input
// keys (Esc, Tab, Ctrl, Alt, the ⇧↵ newline, and the cursor arrows) rely on this
// to keep the textarea focused and the soft keyboard open; the PgUp/PgDn
// page-scroll keys reuse the same handler for that focus/sequencing guarantee but
// then deliberately blur in TerminalPane.onScroll to dismiss the keyboard for
// reading. So "keeps focus" is the input-key contract, not a universal one.
function keyDown(handler: () => void) {
return (event: React.PointerEvent) => {
event.preventDefault()
Expand Down Expand Up @@ -95,21 +93,24 @@ function KeyButton({
export function AccessoryBar({
onEsc,
onTab,
onNewline,
onArrow,
onScroll,
ctrl,
alt,
onToggleCtrl,
onToggleAlt,
}: AccessoryBarProps) {
// Two flex rows stacked: keys on top, scroll controls below; gap-1.5 between
// the rows so a fat-finger tap on a key row doesn't catch the scroll row
// directly beneath it. Safe-area insets are NOT applied here: in normal layout
// the status bar sits below this bar (so it isn't the screen's bottom edge),
// and in fullscreen the enclosing column pads its own bottom — both handled by
// ancestors (see App.tsx mobile root and TerminalPane's fullscreen column).
// Two flex rows stacked: modifier/special keys on top, navigation (arrows +
// page scroll) below; gap-1.5 between the rows so a fat-finger tap on the top
// row doesn't catch the row directly beneath it. Safe-area insets are NOT
// applied here: in normal layout the status bar sits below this bar (so it
// isn't the screen's bottom edge), and in fullscreen the enclosing column pads
// its own bottom — both handled by ancestors (see App.tsx mobile root and
// TerminalPane's fullscreen column).
return (
<div className="flex shrink-0 flex-col gap-1.5 border-t bg-background px-1 py-1">
{/* Row one — modifier / special keys sent to the program. */}
<div className="flex items-center gap-1">
<KeyButton label="Esc" onPointerDown={keyDown(onEsc)} />
<KeyButton label="Tab" onPointerDown={keyDown(onTab)} />
Expand All @@ -123,6 +124,19 @@ export function AccessoryBar({
pressed={alt}
onPointerDown={keyDown(onToggleAlt)}
/>
<KeyButton
label="⇧↵"
ariaLabel="Insert newline"
onPointerDown={keyDown(onNewline)}
/>
</div>
{/* Row two — navigation. The four cursor arrows (sent to the program, keep
focus) and PgUp/PgDn (scroll the xterm viewport, blur to dismiss the
keyboard; see onScroll) do OPPOSITE things to focus, so a divider with
breathing room separates the two clusters — a mistap on PgUp while
aiming for → would otherwise yank the keyboard away (misclick-safe
spacing, per the CLAUDE.md tenet). */}
<div className="flex items-center gap-1">
<KeyButton ariaLabel="Left" onPointerDown={keyDown(() => onArrow("left"))}>
<ArrowLeft />
</KeyButton>
Expand All @@ -138,16 +152,10 @@ export function AccessoryBar({
>
<ArrowRight />
</KeyButton>
</div>
{/* Scroll controls: jump to top, page up/down, jump to the latest output.
Extremes are icons; page steps are keycap labels mirroring PgUp/PgDn. */}
<div className="flex items-center gap-1">
<KeyButton
ariaLabel="Scroll to top"
onPointerDown={keyDown(() => onScroll("top"))}
>
<ChevronsUp />
</KeyButton>
<div
aria-hidden="true"
className="mx-1.5 w-px shrink-0 self-stretch bg-border"
/>
<KeyButton
label="PgUp"
ariaLabel="Page up"
Expand All @@ -158,12 +166,6 @@ export function AccessoryBar({
ariaLabel="Page down"
onPointerDown={keyDown(() => onScroll("pageDown"))}
/>
<KeyButton
ariaLabel="Scroll to latest"
onPointerDown={keyDown(() => onScroll("bottom"))}
>
<ChevronsDown />
</KeyButton>
</div>
</div>
)
Expand Down
Loading
Loading