diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index bf5f4f3..3c3d8dc 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -28,6 +28,27 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + with: + # Put BuildKit in the host's network namespace so it can reach + # act_runner's cache service. + # + # The `docker-container` driver — which the multi-arch build below + # requires, since the plain `docker` driver cannot do + # linux/amd64+linux/arm64 — runs BuildKit in its *own* container on + # Docker's default bridge. act_runner advertises ACTIONS_CACHE_URL as + # an address the *job* container can reach, and nothing teaches the + # BuildKit container about it: the job could reach + # 192.168.1.126:40649 while the container actually making the request + # could not, and the build died with `no route to host`. + # + # `no route to host` is EHOSTUNREACH — a firewall rejecting, not a + # missing route (a wrong address times out instead) — which is what a + # default firewalld zone does to traffic arriving from the docker + # bridge. Sharing the host's namespace sidesteps the question + # entirely: the cache address becomes local to BuildKit. + # + # No effect on runners where this already worked. + driver-opts: network=host - name: Login to Gitea Container Registry uses: docker/login-action@v3 @@ -55,5 +76,21 @@ jobs: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} ghcr.io/shadowdao/triple-c-sandbox:latest ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }} + # `ignore-error` is what stops a cache failure failing a build that + # already succeeded. act_runner emulates the GitHub Actions cache + # service on the runner host's LAN address, and the `docker-container` + # builder `setup-buildx-action` creates could not route to it — + # every layer of both arches built, then the job died on + # `GetCacheEntryDownloadURL: no route to host` while exporting. + # + # On a pull_request `push:` above is false, so this job pushes + # nothing and the cache is its only output: failing it discarded a + # complete, successful validation of the Dockerfile for both + # architectures. A cache is an optimisation and must degrade to + # "slow", never to "red". + # + # The import is already non-fatal — the build ran all 37 layers after + # warning that it could not read the cache — so only the exporter + # needs the flag. cache-from: type=gha - cache-to: type=gha,mode=max + cache-to: type=gha,mode=max,ignore-error=true diff --git a/CLAUDE.md b/CLAUDE.md index 837cc43..0ab03ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -413,6 +413,26 @@ container is created once by a very long function where a dropped capability is existing toggle: the label fingerprints *the setting*, not the set of things the setting drives, so a project already at `true` gets no recreation at all on upgrade. +### Keeping Claude Code current + +`claude update` runs in **two** places, and both are needed: + +- `container/entrypoint.sh` runs it once per container start, before any session exists. +- `commands/terminal_commands.rs` (and its twin in `web_terminal/ws_handler.rs`) prepend it to the + command every Claude session launches with, because containers use a stop/start model and a + long-lived one would otherwise never re-check. + +Both are `timeout`-bounded and `|| echo`'d, so an offline or slow network delays a tab rather than +failing it, and **both take the same `flock` on `/tmp/.triple-c-claude-update.lock`**. That lock is +not tidiness: the entrypoint prints "container ready" only after its own update finishes, so +starting a project and immediately opening a tab — or opening two tabs at once — otherwise runs two +updaters against the same `~/.claude/bin`, and `|| echo` would hide a half-written install behind a +friendly message one line before `exec claude` ran it. `-E 0` makes losing the race a success, +because the holder just did the work. The per-session copy is what forced the non-Bedrock path from a bare `["claude", ...]` +argv into a `bash -c` wrapper — the flags and the session name are interpolated into a shell +string now, so **anything added there must go through `shell_quote_arg`**. Bash sessions are +deliberately untouched. + ### Container Lifecycle Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation. diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 56afc0a..3236da7 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -243,7 +243,7 @@ Anthropic-backend project uses that token without its own login. See │ │ │ │ │ │ │ └──────────────────────────────────────────────────┘ │ ├─────────────┴────────────────────────────────────────────────────────┤ -│ 2 project(s) · 1 running · 2 terminal(s) Jump to Current ↓ │ +│ 2 project(s) · 1 running · 2 terminal(s) Notes │ └──────────────────────────────────────────────────────────────────────┘ ``` @@ -268,8 +268,8 @@ Anthropic-backend project uses that token without its own login. See - **Main area** — Shows the active tab: a Project Home view or an xterm.js terminal. With no tabs open you get a welcome screen with Docker/image/project readiness checks. - **StatusBar** — Counts of total projects, running containers and open terminal sessions; the - **Jump to Current ↓** button when a terminal is scrolled up; and the microphone button when - speech-to-text is enabled. + **🖱 Mouse captured — release** button while a program in the terminal is holding the mouse; the + **Notes** toggle; and the microphone button when speech-to-text is enabled. --- @@ -1224,9 +1224,31 @@ Programs inside the container can copy text to your host clipboard. When a conta You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload. -### Jump to Current +### Scrolling -When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output. +Scrolling is the terminal's own: scroll up to read back and it holds position, scroll to the +bottom and it follows new output again. There is no follow toggle — an earlier **Following / +Paused** control and a **Jump to Current** button were retired once they stopped doing anything +useful, because Claude Code draws its interface on the alternate screen, which has no scrollback +for them to act on. + +### When the mouse stops working + +Some programs ask the terminal for the mouse, so that clicks and drags go to the program instead +of selecting text. If one of them exits without handing the mouse back, the terminal looks stuck: +you cannot select text, and stray characters can appear as you move the pointer. + +A **🖱 Mouse captured — release** button appears in the status bar whenever a program holds the +mouse. Click it, or press **Ctrl+Shift+X**, to take the mouse back. Nothing is sent into the +container — only the terminal's own state is reset. + +Note that holding the mouse is normal for programs like `htop`, `vim` and Claude Code itself, so +the button is showing most of the time you are in one. It is there for when a program exits +without handing the mouse back and the terminal is left stuck; releasing while a program is still +running just takes the mouse away from that program. + +To select text *without* taking the mouse back, hold **Shift** while dragging — or **Option** on +macOS. ### Files diff --git a/README.md b/README.md index a4c17f5..7245a8c 100644 --- a/README.md +++ b/README.md @@ -528,7 +528,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi | `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help | | `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering | | `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail | -| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic | +| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Notes toggle, STT mic | | `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls | | `app/src/components/projects/ProjectList.tsx` | Project list in sidebar | | `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control | diff --git a/ROADMAP.md b/ROADMAP.md index 50e8832..7d00dd0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,7 +58,7 @@ choice it never asked about. Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh, Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every -container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste, +container start *and* before every Claude session launches, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste, file drag-drop, STT), the web terminal, and workspace backup. --- diff --git a/TECHNICAL.md b/TECHNICAL.md index c82189d..1911733 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -62,10 +62,13 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`): - **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`. -- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`). +- **The STT mic lives in the global `StatusBar`, not a per-terminal overlay.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`). - **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript. -- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount. -- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store. +- **Scrolling is left to xterm, and the "Following" / "Jump to Current" controls that used to drive it are gone.** They were built for the normal buffer. Claude Code draws on the *alternate* screen, which has no scrollback, so in a Claude tab `viewportY` always equalled `baseY`, `isAtBottom` was permanently true and neither control could ever do anything — which is what made them look broken. **They did still work in `bash` tabs**, which run `bash -l` on the normal buffer; removing them is a real behaviour change there, and the justification is that xterm's native follow already covers it, not that nothing was lost. The manual `scrollToBottom()` on every write went with them — it fought that native behaviour, which follows the tail while the viewport is at the bottom and holds position while you read further up. `scrollToBottom()` remains only on activate and after a refit, and **both sample `viewportY >= baseY` before the `fit()`** so they re-anchor only a viewport that was already on the tail: the ResizeObserver fires for the Notes dock, the sidebar drag and any window resize, none of which are a reason to yank a reader to the bottom. +- **A program that grabs the mouse and dies must be escapable without closing the tab.** A TUI sets DECSET `?1000`/`?1002`/`?1003` and, if it exits without resetting them, xterm keeps routing clicks, drags and (under `?1003`) every pointer *move* to the PTY — text selection dies and escape bytes flood the prompt. `TerminalView` reconciles a badge against `term.modes.mouseTrackingMode` **in the `term.write()` callback**: the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling. Releasing writes the resets through `term.write`, **never `sendInput`** — the reset belongs to xterm's parser and must not reach the container, or a still-live TUI would simply re-grab the mouse on its next repaint. Bound to the control and to `Ctrl+Shift+X`, because the failure being recovered from is the pointer not working. +- **The release control lives in the `StatusBar`, not over the terminal.** Mouse tracking is the *normal* steady state of every mouse-driven TUI — htop, vim, lazygit and Claude Code all set `?1000`/`?1002` — so a badge painted at `absolute top-2 right-4 z-50` would be on screen for the entire life of those programs and would swallow clicks aimed at that program's own top-right corner, silently killing its mouse with no undo. The active `TerminalView` publishes `terminalMouseCaptured` and `releaseActiveMouse` through the store instead, the same way `terminalHasSelection` and `sttToggle` already do. +- **`macOptionClickForcesSelection: true` is set, and without it macOS has no force-select at all.** `SelectionService.shouldForceSelection` is `isMac ? altKey && macOptionClickForcesSelection : shiftKey`, and the option defaults to `false` — so the "hold Shift to select while a program holds the mouse" escape hatch is Shift everywhere else and **Option** on macOS, and existed on macOS only once this was turned on. +- **Set store function values via object-merge, not the updater form** — `set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `sttToggle`) into the Zustand store. ### bollard (Docker API) diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index f37ac2a..f7e1cd1 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -6,10 +6,58 @@ use crate::AppState; /// Build the command to run in the container terminal. /// -/// For Bedrock Profile projects, wraps `claude` in a bash script that validates -/// the AWS session first. If the SSO session is expired, runs `aws sso login` -/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon). +/// Always a `bash -c` script, because every session runs [`UPDATE_PRELUDE`] +/// before `exec claude`. For Bedrock Profile projects the script additionally +/// validates the AWS session first, and runs `aws sso login` if it has expired +/// so the user can re-authenticate (the URL is clickable via xterm.js +/// WebLinksAddon). fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec { + let settings = state.settings_store.get(); + build_claude_terminal_cmd( + project, + settings.global_aws.aws_profile.as_deref(), + session_name, + ) +} + +/// Shell line run immediately before `exec claude` in every Claude terminal +/// session. +/// +/// `container/entrypoint.sh` already runs `claude update` when the container +/// starts, but containers here use a stop/start (and often just keep running) +/// model, so a long-lived container's CLI goes stale between restarts. Running +/// it per session is what keeps a week-old container current. +/// +/// Deliberately non-fatal and time-bounded: `|| echo` swallows a failure (no +/// network, npm registry down) so a session always opens, and `timeout 60` +/// bounds how long a user waits for a terminal. +/// +/// **`flock` is load-bearing, not tidiness.** Nothing serialises this against +/// the entrypoint's own `claude update`, and the entrypoint prints "container +/// ready" only *after* its copy finishes — so "start the project, open a tab" +/// races two updaters against the same `~/.claude/bin` install, as does +/// opening two tabs at once. `|| echo` would then hide a half-written install +/// behind a friendly message and the very next line (`exec claude`) would run +/// it. `-w 90` gives the entrypoint's `timeout 120` copy room to finish rather +/// than failing the wait, and `-E 0` makes losing the race a success: the +/// other holder just updated, so there is nothing left to do. +pub(crate) const UPDATE_PRELUDE: &str = concat!( + "flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock ", + r#"timeout 60 claude update 2>&1 || echo "(update skipped — continuing)""#, +); + +/// Single-quote one argument for interpolation into a shell script string. +fn shell_quote_arg(arg: &str) -> String { + format!(" '{}'", arg.replace('\'', "'\\''")) +} + +/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS +/// profile rather than the whole [`AppState`]. +fn build_claude_terminal_cmd( + project: &Project, + global_aws_profile: Option<&str>, + session_name: Option<&str>, +) -> Vec { let is_bedrock_profile = project.backend == Backend::Bedrock && project .bedrock_config @@ -19,36 +67,27 @@ fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option< let permission_args = project.effective_permission_mode().cli_args(); + // The args are interpolated into a shell script string, so single-quote + // each one. + let name_flag = session_name + .filter(|n| !n.is_empty()) + .map(|n| format!(" -n{}", shell_quote_arg(n))) + .unwrap_or_default(); + let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect(); + let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag); + if !is_bedrock_profile { - let mut cmd = vec!["claude".to_string()]; - cmd.extend(permission_args); - if let Some(name) = session_name { - if !name.is_empty() { - cmd.push("-n".to_string()); - cmd.push(name.to_string()); - } - } - return cmd; + return vec![ + "bash".to_string(), + "-c".to_string(), + format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd), + ]; } - let profile = aws_commands::resolve_profile_for_project( - project, - state.settings_store.get().global_aws.aws_profile.as_deref(), - ); + let profile = aws_commands::resolve_profile_for_project(project, global_aws_profile); // Build a bash wrapper that validates credentials, re-auths if needed, // then exec's into claude. - let name_flag = session_name - .filter(|n| !n.is_empty()) - .map(|n| format!(" -n '{}'", n.replace('\'', "'\\''"))) - .unwrap_or_default(); - // The args are interpolated into a shell script string, so single-quote - // each one (same escaping style as name_flag above). - let permission_flags: String = permission_args - .iter() - .map(|a| format!(" '{}'", a.replace('\'', "'\\''"))) - .collect(); - let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag); let script = format!( r#" @@ -75,9 +114,11 @@ else echo "" fi fi +{update_prelude} {claude_cmd} "#, profile = profile, + update_prelude = UPDATE_PRELUDE, claude_cmd = claude_cmd ); @@ -325,6 +366,9 @@ pub async fn stop_audio_bridge( #[cfg(test)] mod tests { + use super::{build_claude_terminal_cmd, UPDATE_PRELUDE}; + use crate::models::Project; + /// A dropped file must be named the way the *user* named it. /// /// The bug this pins: `upload_host_file_to_terminal` derived the tar entry @@ -338,6 +382,122 @@ mod tests { /// answer comes from the spelling, and a path that does not name a file is /// refused rather than silently substituted (it used to fall back to /// `"dropped-file"`). + /// A `Project` with only the fields these tests care about set; the rest + /// come through serde so the test does not have to track every field. + fn project(backend: &str, bedrock_config: serde_json::Value) -> Project { + serde_json::from_value(serde_json::json!({ + "id": "p1", + "name": "Test", + "paths": [], + "container_id": null, + "status": "running", + "backend": backend, + "bedrock_config": bedrock_config, + "ollama_config": null, + "openai_compatible_config": null, + "allow_docker_access": false, + "full_permissions": false, + "ssh_key_path": null, + "git_user_name": null, + "git_user_email": null, + "created_at": "now", + "updated_at": "now" + })) + .expect("test project deserializes") + } + + /// Every Claude session updates the CLI before launching it. + /// + /// `container/entrypoint.sh` only updates at container *start*, and these + /// containers are long-lived, so a stale CLI is the normal case without + /// this. The plain (non-Bedrock) path therefore has to be a `bash -c` + /// wrapper rather than a bare `claude` argv. + #[test] + fn build_terminal_cmd_updates_before_launching_claude() { + let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None); + + assert_eq!(cmd[0], "bash"); + assert_eq!(cmd[1], "-c"); + assert!( + cmd[2].contains(UPDATE_PRELUDE), + "plain path must run the update prelude: {}", + cmd[2] + ); + assert!(cmd[2].contains("exec claude"), "got: {}", cmd[2]); + // The update has to happen *before* the exec, which never returns. + assert!( + cmd[2].find(UPDATE_PRELUDE).unwrap() < cmd[2].find("exec claude").unwrap(), + "prelude must precede the exec: {}", + cmd[2] + ); + assert!( + UPDATE_PRELUDE.contains("timeout 60") && UPDATE_PRELUDE.contains("||"), + "the update must stay time-bounded and non-fatal" + ); + } + + /// The session name is interpolated into a shell script, so a quote in it + /// must not break out of its single-quoted argument. + #[test] + fn build_terminal_cmd_escapes_a_quoted_session_name() { + let cmd = build_claude_terminal_cmd( + &project("anthropic", serde_json::Value::Null), + None, + Some("Bob's tab; rm -rf /"), + ); + + assert!( + cmd[2].contains(r#"exec claude -n 'Bob'\''s tab; rm -rf /'"#), + "session name must be single-quote escaped: {}", + cmd[2] + ); + } + + /// Permission flags travel the same escaped path, and an empty name adds + /// no `-n` at all. + #[test] + fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() { + let mut p = project("anthropic", serde_json::Value::Null); + p.full_permissions = true; + let cmd = build_claude_terminal_cmd(&p, None, Some("")); + + assert!( + cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"), + "got: {}", + cmd[2] + ); + assert!(!cmd[2].contains(" -n "), "empty name must add no flag: {}", cmd[2]); + } + + /// The Bedrock-profile path keeps its AWS validation *and* gains the + /// prelude, immediately before the exec. + #[test] + fn build_terminal_cmd_bedrock_validates_aws_and_updates() { + let cmd = build_claude_terminal_cmd( + &project("bedrock", serde_json::json!({ + "auth_method": "profile", + "aws_region": "us-east-1", + "aws_profile": "acme", + "model_id": null, + "disable_prompt_caching": false + })), + None, + Some("it's fine"), + ); + + assert_eq!(cmd[0], "bash"); + let script = &cmd[2]; + assert!(script.contains("aws sts get-caller-identity --profile 'acme'"), "got: {}", script); + assert!(script.contains("triple-c-sso-refresh"), "got: {}", script); + assert!(script.contains(UPDATE_PRELUDE), "got: {}", script); + assert!(script.contains(r#"exec claude -n 'it'\''s fine'"#), "got: {}", script); + assert!( + script.find(UPDATE_PRELUDE).unwrap() < script.find("exec claude").unwrap(), + "prelude must precede the exec: {}", + script + ); + } + #[test] fn a_dropped_file_keeps_the_name_the_user_dropped() { use crate::commands::file_commands::host_upload_name; diff --git a/app/src-tauri/src/web_terminal/ws_handler.rs b/app/src-tauri/src/web_terminal/ws_handler.rs index 50c198e..e38eb61 100644 --- a/app/src-tauri/src/web_terminal/ws_handler.rs +++ b/app/src-tauri/src/web_terminal/ws_handler.rs @@ -206,6 +206,11 @@ pub async fn handle_connection(socket: WebSocket, state: Arc) writer_handle.abort(); } +/// The desktop terminal's update prelude, reused verbatim. Shared rather than +/// copied so the web terminal cannot drift from it — a duplicated `const` with +/// a "keep these identical" comment is only as good as the next reader. +use crate::commands::terminal_commands::UPDATE_PRELUDE; + /// Build the command for a terminal session, mirroring terminal_commands.rs logic. fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec { let is_bedrock_profile = project.backend == Backend::Bedrock @@ -217,17 +222,6 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin let permission_args = project.effective_permission_mode().cli_args(); - if !is_bedrock_profile { - let mut cmd = vec!["claude".to_string()]; - cmd.extend(permission_args); - return cmd; - } - - let profile = aws_commands::resolve_profile_for_project( - project, - settings_store.get().global_aws.aws_profile.as_deref(), - ); - // The args are interpolated into a shell script string below, so // single-quote each one. let permission_flags: String = permission_args @@ -236,6 +230,19 @@ fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settin .collect(); let claude_cmd = format!("exec claude{}", permission_flags); + if !is_bedrock_profile { + return vec![ + "bash".to_string(), + "-c".to_string(), + format!("{}\n{}\n", UPDATE_PRELUDE, claude_cmd), + ]; + } + + let profile = aws_commands::resolve_profile_for_project( + project, + settings_store.get().global_aws.aws_profile.as_deref(), + ); + let script = format!( r#" echo "Validating AWS session for profile '{profile}'..." @@ -260,9 +267,11 @@ else echo "" fi fi +{update_prelude} {claude_cmd} "#, profile = profile, + update_prelude = UPDATE_PRELUDE, claude_cmd = claude_cmd ); diff --git a/app/src/components/layout/StatusBar.tsx b/app/src/components/layout/StatusBar.tsx index b6748f1..801232c 100644 --- a/app/src/components/layout/StatusBar.tsx +++ b/app/src/components/layout/StatusBar.tsx @@ -10,7 +10,7 @@ interface Props { export default function StatusBar({ stt }: Props) { const { projects, sessions, terminalHasSelection, activeSessionId, sttEnabled, - terminalAtBottom, scrollActiveToBottom, notesDockOpen, toggleNotesDock, + notesDockOpen, toggleNotesDock, terminalMouseCaptured, releaseActiveMouse, } = useAppState( useShallow(s => ({ projects: s.projects, @@ -18,10 +18,10 @@ export default function StatusBar({ stt }: Props) { terminalHasSelection: s.terminalHasSelection, activeSessionId: s.activeSessionId, sttEnabled: s.appSettings?.stt?.enabled, - terminalAtBottom: s.terminalAtBottom, - scrollActiveToBottom: s.scrollActiveToBottom, notesDockOpen: s.notesDockOpen, toggleNotesDock: s.toggleNotesDock, + terminalMouseCaptured: s.terminalMouseCaptured, + releaseActiveMouse: s.releaseActiveMouse, })) ); const running = projects.filter((p) => p.status === "running").length; @@ -60,15 +60,16 @@ export default function StatusBar({ stt }: Props) { )} - {/* Right-aligned controls: Jump to Current + STT mic */} + {/* Right-aligned controls: mouse release + Notes + STT mic */}
- {activeSessionId && !terminalAtBottom && ( + {activeSessionId && terminalMouseCaptured && ( )} {/* Padding lives on this wrapper, NOT on the xterm host element. xterm's FitAddon measures the host element it's mounted into; padding there causes the grid to overhang and clip the rightmost column / bottom diff --git a/app/src/lib/dropTarget.test.ts b/app/src/lib/dropTarget.test.ts index c173592..1b5c283 100644 --- a/app/src/lib/dropTarget.test.ts +++ b/app/src/lib/dropTarget.test.ts @@ -243,11 +243,12 @@ describe("dropTarget", () => { describe("chrome over a pane, with no dialog open", () => { /** Everything that is painted over a pane and is not a blocker. */ const CHROME: Array<[string, () => HTMLElement]> = [ - // `TerminalView`'s "▼ Following / ▽ Paused" toggle: `absolute top-2 - // right-4 z-50`, rendered unconditionally, and a *sibling* of the xterm - // host — so "does the pane contain what is painted here?" made the - // terminal's top-right corner a dead zone no user action could clear. - ["the Following/Paused toggle", () => document.createElement("button")], + // `TerminalView`'s mouse-release badge: `absolute top-2 right-4 z-50`, + // and a *sibling* of the xterm host — so "does the pane contain what is + // painted here?" made the terminal's top-right corner a dead zone no + // user action could clear. (The retired Following toggle held the same + // corner and produced the original bug.) + ["the mouse-release badge", () => document.createElement("button")], // `ToastHost`: `fixed bottom-4 right-4 z-[60]`, 24rem wide, over every // pane, and its error cards stay until dismissed. ["a toast card", () => document.createElement("div")], diff --git a/app/src/lib/dropTarget.ts b/app/src/lib/dropTarget.ts index 9357139..e46e009 100644 --- a/app/src/lib/dropTarget.ts +++ b/app/src/lib/dropTarget.ts @@ -26,8 +26,8 @@ * * - Asking `el.contains(document.elementFromPoint(x, y))` — "is the thing * painted here mine?" — refused drops onto anything painted *over* a pane - * that is not part of it: `TerminalView`'s always-rendered "▼ Following" - * toggle (a sibling of the xterm host), the URL toast, `ToastHost`'s stack. + * that is not part of it: `TerminalView`'s mouse-release badge (a sibling + * of the xterm host), the URL toast, `ToastHost`'s stack. * Permanent dead zones no user action could clear. * - Replacing that with "is a *blocking overlay* painted here?" removed the * dead zones and opened a hole instead. `elementFromPoint` returns the diff --git a/app/src/store/appState.ts b/app/src/store/appState.ts index d196b07..3216c45 100644 --- a/app/src/store/appState.ts +++ b/app/src/store/appState.ts @@ -205,16 +205,20 @@ interface AppState { // UI state terminalHasSelection: boolean; setTerminalHasSelection: (has: boolean) => void; + // Whether a program in the active terminal is holding mouse reporting open, + // and how to take it back. Surfaced so the release control can live in the + // status bar: painted over the terminal it would sit on top of whatever TUI + // is asking for the mouse, and swallow clicks aimed at that program's own + // top-right corner for as long as it ran. Only the active TerminalView + // writes these. + terminalMouseCaptured: boolean; + setTerminalMouseCaptured: (captured: boolean) => void; + releaseActiveMouse: () => void; + setReleaseActiveMouse: (fn: () => void) => void; // STT toggle for the active session, registered by App so the terminal's // Ctrl+Shift+M shortcut can trigger the single status-bar mic instance. sttToggle: () => void; setSttToggle: (fn: () => void) => void; - // Active terminal scroll state, surfaced so the status bar can host the - // "Jump to Current" control. Only the active TerminalView writes these. - terminalAtBottom: boolean; - setTerminalAtBottom: (v: boolean) => void; - scrollActiveToBottom: () => void; - setScrollActiveToBottom: (fn: () => void) => void; sidebarView: "projects" | "settings"; setSidebarView: (view: "projects" | "settings") => void; sidebarCollapsed: boolean; @@ -496,12 +500,12 @@ export const useAppState = create((set) => ({ // UI state terminalHasSelection: false, setTerminalHasSelection: (has) => set({ terminalHasSelection: has }), + terminalMouseCaptured: false, + setTerminalMouseCaptured: (captured) => set({ terminalMouseCaptured: captured }), + releaseActiveMouse: () => {}, + setReleaseActiveMouse: (fn) => set({ releaseActiveMouse: fn }), sttToggle: () => {}, setSttToggle: (fn) => set({ sttToggle: fn }), - terminalAtBottom: true, - setTerminalAtBottom: (v) => set({ terminalAtBottom: v }), - scrollActiveToBottom: () => {}, - setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }), sidebarView: "projects", setSidebarView: (view) => set({ sidebarView: view }), sidebarCollapsed: loadSidebarCollapsed(), diff --git a/container/entrypoint.sh b/container/entrypoint.sh index fb92c06..5480a4c 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -639,8 +639,14 @@ fi # any terminal session launches `claude`. Runs as the claude user (the CLI is # installed under /home/claude/.claude/bin). Non-fatal and time-bounded so a # slow or offline network never blocks container readiness. +# The lock is shared with the per-session update that every Claude terminal +# runs before `exec claude` (commands/terminal_commands.rs, UPDATE_PRELUDE). +# "Container ready" is printed *after* this finishes, so a user who starts a +# project and immediately opens a tab would otherwise have two updaters +# rewriting ~/.claude/bin at once, and the session's `|| echo` would hide the +# damage right before it ran the result. echo "entrypoint: checking for Claude Code updates..." -timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; claude update' \ +timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/home/claude/.local/bin:$PATH"; flock -w 90 -E 0 /tmp/.triple-c-claude-update.lock claude update' \ && echo "entrypoint: Claude Code is up to date" \ || echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"