Give the mouse back, retire the follow controls, update Claude per session #54

Merged
jknapp merged 3 commits from feat/mouse-release-retire-follow-update into main 2026-09-08 23:43:35 +00:00
15 changed files with 556 additions and 208 deletions
+38 -1
View File
@@ -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
+20
View File
@@ -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.
+27 -5
View File
@@ -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_<timestamp>.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
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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.
---
+6 -3
View File
@@ -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)
+187 -27
View File
@@ -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<String> {
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<String> {
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;
+20 -11
View File
@@ -206,6 +206,11 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>)
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<String> {
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
);
+9 -8
View File
@@ -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) {
</span>
</>
)}
{/* Right-aligned controls: Jump to Current + STT mic */}
{/* Right-aligned controls: mouse release + Notes + STT mic */}
<div className="ml-auto flex items-center gap-3 pl-2">
{activeSessionId && !terminalAtBottom && (
{activeSessionId && terminalMouseCaptured && (
<button
onClick={() => scrollActiveToBottom()}
data-mouse-release="true"
onClick={() => releaseActiveMouse()}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
title="Scroll the terminal to the latest output"
title="A program in the container is reading the mouse, so clicks and drags go to it instead of selecting text. Click, or press Ctrl+Shift+X, to take it back. To select text without taking it back, hold Shift while dragging (Option on macOS)."
>
Jump to Current
🖱 Mouse captured release
</button>
)}
<button
@@ -3,6 +3,7 @@ import { render, fireEvent, cleanup, act } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView";
import { useAppState } from "../../store/appState";
import { uploadHostFileToTerminal } from "../../lib/tauri-commands";
import { URL_TOAST_SELECTOR } from "./UrlToast";
/**
* The window-wide native drag-drop listener, captured at registration.
@@ -370,15 +371,34 @@ describe("TerminalView — where a dropped file lands", () => {
expect(vi.mocked(uploadHostFileToTerminal)).toHaveBeenCalledTimes(1);
});
it("uploads a file dropped onto the always-present Following toggle", async () => {
// The regression this file could not see. The toggle is `absolute top-2
// right-4 z-50` and is rendered unconditionally, so `elementFromPoint`
// returns *it* for the terminal's top-right corner — and a gate asking
it("uploads a file dropped onto the chrome painted over the terminal", async () => {
// The regression this file could not see. Chrome like the URL toast is a
// *sibling* of the xterm host painted over the pane, so
// `elementFromPoint` returns it rather than the host — and a gate asking
// "is what is painted here inside the xterm host?" answered no, forever,
// with no message and no log line. jsdom never ran that branch.
const view = await mountWithLayout();
const toggle = view.getByTitle(/Auto-scroll/i);
stubElementFromPoint(toggle);
//
// The original fixture was the always-rendered "▼ Following" toggle. That
// control is retired and the mouse-release button that could have replaced
// it lives in the status bar now, so the toast is what stands in — it is
// real chrome over the pane, which is the only property under test.
await mountWithLayout();
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({
payload: Array.from(
new TextEncoder().encode(
`\x1b]7777;open;${btoa("https://example.com/x")}\x07`,
),
),
});
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
const toast = document.querySelector(URL_TOAST_SELECTOR);
if (!toast) throw new Error("URL toast not shown");
stubElementFromPoint(toast);
await drop(780, 10);
@@ -594,3 +614,88 @@ describe("TerminalView — focus on request", () => {
expect(document.activeElement).toBe(helperTextarea(view.container));
});
});
describe("TerminalView — releasing a captured mouse", () => {
/** Feed raw bytes to the terminal as if the container had printed them, and
* let xterm drain its write queue (it parses asynchronously). */
async function emitBytes(text: string) {
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({ payload: Array.from(new TextEncoder().encode(text)) });
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
/** What the status bar would render from: the active terminal publishes the
* capture state, and the release action, into the store. The control itself
* lives in `StatusBar` deliberately, so it never sits on top of the TUI
* that is asking for the mouse. */
function captured(): boolean {
return useAppState.getState().terminalMouseCaptured;
}
it("shows nothing while the container has not grabbed the mouse", async () => {
mountSession("claude");
await act(async () => {});
expect(captured()).toBe(false);
});
it("surfaces a release control once the container turns mouse tracking on", async () => {
// `?1003h` is any-event tracking: every mouse *move* over the terminal is
// reported to the app. When the TUI that asked for it dies without
// resetting the mode, xterm keeps routing moves to the PTY and drops text
// selection — the freeze this control exists to break out of.
mountSession("claude");
await act(async () => {});
await emitBytes("\x1b[?1003h\x1b[?1006h");
expect(captured()).toBe(true);
});
it("clears the mode locally, without sending a byte to the container", async () => {
// The reset is written into xterm's own parser, not onto the wire. The
// program inside is usually gone; if it is not, it must not be told the
// user pulled the mouse back, or a live TUI would just re-grab it.
mountSession("claude");
await act(async () => {});
await emitBytes("\x1b[?1003h");
terminalInput.mockClear();
// Exactly what the status-bar button's onClick does.
const release = useAppState.getState().releaseActiveMouse;
await act(async () => {
release();
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
// The published flag is bound to the live mode, so it going false *is* the
// assertion that xterm's mouse tracking is back to "none".
expect(captured()).toBe(false);
expect(terminalInput).not.toHaveBeenCalled();
});
it("releases on Ctrl+Shift+X, for when the pointer itself is unusable", async () => {
const { container } = mountSession("claude");
await act(async () => {});
await emitBytes("\x1b[?1002h");
terminalInput.mockClear();
await act(async () => {
fireEvent.keyDown(helperTextarea(container), {
key: "X",
ctrlKey: true,
shiftKey: true,
});
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
expect(captured()).toBe(false);
// The chord must not also reach the container as input.
expect(terminalInput).not.toHaveBeenCalled();
});
});
+105 -125
View File
@@ -99,8 +99,8 @@ export default function TerminalView({ sessionId, active }: Props) {
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
const setTerminalMouseCaptured = useAppState(s => s.setTerminalMouseCaptured);
const setReleaseActiveMouse = useAppState(s => s.setReleaseActiveMouse);
const ssoBufferRef = useRef("");
const ssoTriggeredRef = useRef(false);
@@ -219,14 +219,11 @@ export default function TerminalView({ sessionId, active }: Props) {
return () => document.removeEventListener("keydown", onKeyDown, true);
}, []);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isAutoFollow, setIsAutoFollow] = useState(true);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const isAtBottomRef = useRef(true);
// Tracks user intent to follow output — only set to false by explicit user
// actions (mouse wheel up), not by xterm scroll events during writes.
const autoFollowRef = useRef(true);
const lastUserScrollTimeRef = useRef(0);
// True while the program in the container holds mouse reporting open (any of
// the DECSET ?1000/?1002/?1003 tracking modes). See `syncMouseCapture`.
const [mouseCaptured, setMouseCaptured] = useState(false);
const mouseCapturedRef = useRef(false);
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
// and the unmount-cleanup effect further down).
@@ -251,10 +248,10 @@ export default function TerminalView({ sessionId, active }: Props) {
//
// The rect asked about is the **pane wrapper**, not the xterm host inside it:
// the pane is what the user sees as "the terminal", gutter included, and the
// chrome painted over it (the Following toggle, the URL toast) is a sibling
// of the host rather than a child. Nothing painted over the pane refuses a
// drop on its own account — asking "is this element mine?" once turned every
// pixel under that chrome into a permanent dead zone.
// chrome painted over it (the mouse-release badge, the URL toast) is a
// sibling of the host rather than a child. Nothing painted over the pane
// refuses a drop on its own account — asking "is this element mine?" once
// turned every pixel under that chrome into a permanent dead zone.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
@@ -315,12 +312,60 @@ export default function TerminalView({ sessionId, active }: Props) {
};
}, [sessionId, sendInput]);
/**
* Reconcile the badge with xterm's live mouse-tracking mode.
*
* There is no event for this, but there does not need to be a poll either:
* the mode only ever changes because the container printed a DECSET/DECRST
* sequence, so checking once per write covers every transition, exactly when
* it happens. The ref gate keeps the common case (mode unchanged, thousands
* of writes a second) down to one string comparison and no re-render.
*/
const syncMouseCapture = useCallback(() => {
const term = termRef.current;
if (!term) return;
const captured = term.modes.mouseTrackingMode !== "none";
if (captured === mouseCapturedRef.current) return;
mouseCapturedRef.current = captured;
setMouseCaptured(captured);
}, []);
/**
* Take the mouse back from a program that grabbed it and never let go.
*
* A TUI that dies mid-menu (or is killed, or detaches) leaves its mouse
* tracking modes set. xterm goes on routing clicks, drags and under
* `?1003` every pointer *move* to the PTY, which kills text selection and
* floods the prompt with escape bytes. The result reads as a frozen
* terminal, and until now the only exit was closing the tab.
*
* The reset is `term.write`, deliberately, not `sendInput`: it goes into
* xterm's own parser and never onto the wire. The program that asked for
* tracking is usually already gone; if it is not, telling it the user pulled
* the mouse back would only invite it to grab again on its next repaint.
*/
const releaseMouse = useCallback(() => {
const term = termRef.current;
if (!term) return;
// The three tracking modes, then the two encodings they report in. All
// five, because a program is free to have set any combination and a
// leftover encoding mode outlives the tracking mode that motivated it.
term.write("\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l", syncMouseCapture);
}, [syncMouseCapture]);
useEffect(() => {
if (!containerRef.current) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
// Let the user select text even while a program holds the mouse.
// xterm's force-selection modifier is Shift everywhere *except* macOS,
// where it is Option and is gated behind this option, which defaults to
// false — so without this line Mac users have no force-select at all and
// the only way to copy from a mouse-driven TUI is to take the mouse back
// first. `SelectionService.shouldForceSelection`.
macOptionClickForcesSelection: true,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
theme: {
background: "#0d1117",
@@ -391,6 +436,14 @@ export default function TerminalView({ sessionId, active }: Props) {
useAppState.getState().sttToggle();
return false;
}
// Ctrl+Shift+X hands the mouse back. Same action as the badge, bound to
// a key because the failure this recovers from is *the pointer not
// working* — a control you have to click can be unreachable in exactly
// the situation that calls for it.
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "X") {
releaseMouse();
return false;
}
// Shift+Enter inserts a newline in Claude Code's prompt instead of
// submitting it. xterm.js does not consult `shiftKey` for Enter
// (`Keyboard.ts`, `case 13`), so without this branch Shift+Enter is
@@ -501,42 +554,6 @@ export default function TerminalView({ sessionId, active }: Props) {
);
});
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
// Captured during capture phase so it fires before xterm's own handler.
const handleWheel = (e: WheelEvent) => {
lastUserScrollTimeRef.current = Date.now();
if (e.deltaY < 0) {
autoFollowRef.current = false;
setIsAutoFollow(false);
isAtBottomRef.current = false;
setIsAtBottom(false);
}
};
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
// Track scroll position to show "Jump to Current" button.
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
let scrollStateRafId: number | null = null;
const scrollDisposable = term.onScroll(() => {
const buf = term.buffer.active;
const atBottom = buf.viewportY >= buf.baseY;
isAtBottomRef.current = atBottom;
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
if (atBottom && isUserScroll && !autoFollowRef.current) {
autoFollowRef.current = true;
setIsAutoFollow(true);
}
if (scrollStateRafId === null) {
scrollStateRafId = requestAnimationFrame(() => {
scrollStateRafId = null;
setIsAtBottom(isAtBottomRef.current);
});
}
});
// Track text selection to show copy hint in status bar
const selectionDisposable = term.onSelectionChange(() => {
setTerminalHasSelection(term.hasSelection());
@@ -599,15 +616,11 @@ export default function TerminalView({ sessionId, active }: Props) {
const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return;
term.write(data, () => {
if (autoFollowRef.current) {
term.scrollToBottom();
if (!isAtBottomRef.current) {
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}
});
// Scrolling on new output is xterm's own job, and it already gets it
// right: it follows the tail while the viewport is at the bottom and
// holds position while you are reading further up. The manual
// `scrollToBottom()` that used to live here fought that second half.
term.write(data, syncMouseCapture);
detector.feed(data);
// Scan for SSO refresh marker in terminal output
@@ -649,11 +662,18 @@ export default function TerminalView({ sessionId, active }: Props) {
resizeRafId = requestAnimationFrame(() => {
resizeRafId = null;
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
// Whether the viewport was following the tail has to be sampled
// *before* the fit: reflowing wrapped lines moves `baseY`, so asking
// afterwards cannot tell "was at the bottom" from "was pushed off it".
const wasAtBottom =
term.buffer.active.viewportY >= term.buffer.active.baseY;
fitAddon.fit();
resize(sessionId, term.cols, term.rows);
if (autoFollowRef.current) {
term.scrollToBottom();
}
// Only re-anchor a viewport that was already on the tail. This
// observer fires for any pane size change — opening the Notes dock,
// dragging the sidebar, resizing the window — and none of those are a
// reason to yank someone away from the scrollback they are reading.
if (wasAtBottom) term.scrollToBottom();
});
});
resizeObserver.observe(containerRef.current);
@@ -667,14 +687,11 @@ export default function TerminalView({ sessionId, active }: Props) {
osc52Disposable.dispose();
relayDisposable.dispose();
inputDisposable.dispose();
scrollDisposable.dispose();
selectionDisposable.dispose();
setTerminalHasSelection(false);
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.());
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
resizeObserver.disconnect();
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
@@ -723,10 +740,12 @@ export default function TerminalView({ sessionId, active }: Props) {
}
if (active) {
// Same rule as the resize observer: re-anchor only what was already
// anchored, so a tab left scrolled up comes back where it was left.
const wasAtBottom =
term.buffer.active.viewportY >= term.buffer.active.baseY;
fitRef.current?.fit();
if (autoFollowRef.current) {
term.scrollToBottom();
}
if (wasAtBottom) term.scrollToBottom();
term.focus();
}
}, [active, gpuRenderingSetting]);
@@ -826,39 +845,6 @@ export default function TerminalView({ sessionId, active }: Props) {
);
}, [urlPrompt, projectId, dismissUrlPrompt]);
const handleScrollToBottom = useCallback(() => {
const term = termRef.current;
if (term) {
autoFollowRef.current = true;
setIsAutoFollow(true);
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
}
}, []);
// Surface this terminal's scroll state to the status bar's "Jump to Current"
// control, but only while it's the active (visible) terminal.
useEffect(() => {
if (!active) return;
setTerminalAtBottom(isAtBottom);
setScrollActiveToBottom(handleScrollToBottom);
}, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
// On unmount, if this was the active terminal, clear the status-bar scroll
// state so it doesn't point at a disposed terminal. (Tab switches don't
// unmount — the deactivating terminal stays mounted but hidden — so this
// only fires when the active session is actually closed.)
useEffect(() => {
return () => {
if (activeRef.current) {
setTerminalAtBottom(true);
setScrollActiveToBottom(() => {});
}
};
}, [setTerminalAtBottom, setScrollActiveToBottom]);
const writeSelection = useCallback((mode: "trimmed" | "raw") => {
const term = termRef.current;
if (!term) return;
@@ -876,20 +862,26 @@ export default function TerminalView({ sessionId, active }: Props) {
setContextMenu({ x: e.clientX, y: e.clientY });
}, []);
const handleToggleAutoFollow = useCallback(() => {
const next = !autoFollowRef.current;
autoFollowRef.current = next;
setIsAutoFollow(next);
if (next) {
const term = termRef.current;
if (term) {
fitRef.current?.fit();
term.scrollToBottom();
isAtBottomRef.current = true;
setIsAtBottom(true);
// Surface the capture state and its escape hatch to the status bar, but only
// while this is the visible terminal.
useEffect(() => {
if (!active) return;
setTerminalMouseCaptured(mouseCaptured);
setReleaseActiveMouse(releaseMouse);
}, [active, mouseCaptured, releaseMouse, setTerminalMouseCaptured, setReleaseActiveMouse]);
// On unmount, if this was the active terminal, clear the status-bar state so
// it does not point at a disposed terminal. (Tab switches do not unmount —
// the deactivating terminal stays mounted but hidden — so this only fires
// when the active session is actually closed.)
useEffect(() => {
return () => {
if (activeRef.current) {
setTerminalMouseCaptured(false);
setReleaseActiveMouse(() => {});
}
}
}, []);
};
}, [setTerminalMouseCaptured, setReleaseActiveMouse]);
return (
<div
@@ -915,18 +907,6 @@ export default function TerminalView({ sessionId, active }: Props) {
{imagePasteMsg}
</div>
)}
{/* Auto-follow toggle - top right */}
<button
onClick={handleToggleAutoFollow}
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
isAutoFollow
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
}`}
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
>
{isAutoFollow ? "▼ Following" : "▽ Paused"}
</button>
{/* 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
+6 -5
View File
@@ -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")],
+2 -2
View File
@@ -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
+14 -10
View File
@@ -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<AppState>((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(),
+7 -1
View File
@@ -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)"