diff --git a/.github/clawpatch/factory-zc.config.toml b/.github/clawpatch/factory-zc.config.toml new file mode 100644 index 00000000000..8b171adadda --- /dev/null +++ b/.github/clawpatch/factory-zc.config.toml @@ -0,0 +1,26 @@ +# Isolated ZeroClaw config for the clawpatch CI "brain" (the agent that reviews PRs). +# __ZC_TOKEN__ is replaced at CI time from the ANTHROPIC_OAUTH_TOKEN repo secret. +# The Anthropic provider auto-detects the sk-ant-oat01- prefix and uses the OAuth path. +schema_version = 3 + +[providers.models.anthropic.factory] +model = "claude-sonnet-4-6" +api_key = "__ZC_TOKEN__" + +[agents.factory] +model_provider = "anthropic.factory" +enabled = true +risk_profile = "default" +runtime_profile = "default" + +# Review is read-only; a default (supervised) risk profile is sufficient for CI. +[risk_profiles.default] + +[runtime_profiles.default] +agentic = true +max_tool_iterations = 50 + +[acp] +default_agent = "factory" +max_sessions = 5 +session_timeout_secs = 3600 diff --git a/.github/workflows/clawpatch-review.yml b/.github/workflows/clawpatch-review.yml new file mode 100644 index 00000000000..e178e240a9e --- /dev/null +++ b/.github/workflows/clawpatch-review.yml @@ -0,0 +1,85 @@ +name: clawpatch (zeroclaw self-review) + +# Reviews the code changed in each PR using ZeroClaw's OWN coding agent as the brain: +# clawpatch (provider=acpx) -> acpx -> `zeroclaw acp` (stdio JSON-RPC) -> ZeroClaw agent -> Claude +# +# Requirements: +# - Repo secret ANTHROPIC_OAUTH_TOKEN: a Claude subscription OAuth token (sk-ant-oat01-..., +# minted with `claude setup-token`). ZeroClaw's Anthropic provider auto-detects the prefix. +# +# Behavior: non-blocking. Findings are posted to the job summary and uploaded as an artifact. +# This is a best-effort first cut โ€” validate on a real PR run and tune timeouts/model as needed. + +on: + pull_request: + branches: [master] + +permissions: + contents: read + +concurrency: + group: clawpatch-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install zeroclaw (prebuilt) + acpx + clawpatch + run: | + set -euo pipefail + ZC_VERSION="v0.8.0" + curl -fsSL "https://github.com/zeroclaw-labs/zeroclaw/releases/download/${ZC_VERSION}/zeroclaw-x86_64-unknown-linux-gnu.tar.gz" | tar -xz + sudo install -m 0755 zeroclaw /usr/local/bin/zeroclaw + zeroclaw --version + # acpx pinned to clawpatch's tested range; clawpatch from npm (swap for your fork if desired) + npm install -g 'acpx@^0.8.0' clawpatch + acpx --version + clawpatch --version + + - name: Configure the zeroclaw ACP brain + env: + ZC_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }} + run: | + set -euo pipefail + if [ -z "${ZC_TOKEN:-}" ]; then + echo "::warning::ANTHROPIC_OAUTH_TOKEN secret is not set โ€” the review step will be skipped." + fi + ZC_DIR="$RUNNER_TEMP/factory-zc" + mkdir -p "$ZC_DIR" + umask 077 + sed "s|__ZC_TOKEN__|${ZC_TOKEN:-}|" .github/clawpatch/factory-zc.config.toml > "$ZC_DIR/config.toml" + mkdir -p "$HOME/.acpx" + printf '{"defaultAgent":"zeroclaw","agents":{"zeroclaw":{"command":"zeroclaw","args":["acp","--config-dir","%s"]}}}\n' "$ZC_DIR" > "$HOME/.acpx/config.json" + echo "ZC_DIR=$ZC_DIR" >> "$GITHUB_ENV" + + - name: Review changed code via zeroclaw + continue-on-error: true + env: + CLAWPATCH_PROVIDER: acpx + CLAWPATCH_MODEL: zeroclaw + CLAWPATCH_ACPX_TIMEOUT_MS: '600000' + run: | + set -uo pipefail + clawpatch init || true + clawpatch ci --since "origin/${{ github.base_ref }}" --output clawpatch-report.md || true + if [ -s clawpatch-report.md ]; then + { echo '## ๐Ÿฆž clawpatch โ€” zeroclaw self-review'; echo; cat clawpatch-report.md; } >> "$GITHUB_STEP_SUMMARY" + else + echo 'clawpatch produced no report (no changed features, or the brain was unavailable).' >> "$GITHUB_STEP_SUMMARY" + fi + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: clawpatch-report + path: clawpatch-report.md + if-no-files-found: ignore diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 00000000000..11e7ac946d0 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,35 @@ +name: sync fork with upstream + +# Keeps this fork's `master` current with zeroclaw-labs/zeroclaw by merging upstream +# in daily. Fork-only files (the clawpatch CI) have no upstream counterpart, so the +# merge is clean; if a real conflict ever appears the run fails so you can resolve it. +on: + schedule: + - cron: "17 7 * * *" + workflow_dispatch: {} + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Merge upstream/master into master + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git remote add upstream https://github.com/zeroclaw-labs/zeroclaw.git + git fetch upstream master + BEFORE=$(git rev-parse HEAD) + git merge --no-edit upstream/master + if [ "$(git rev-parse HEAD)" != "$BEFORE" ]; then + git push origin HEAD:master + echo "synced: merged upstream/master into master" + else + echo "already up to date with upstream/master" + fi diff --git a/apps/zerocode/locales/en/zerocode.ftl b/apps/zerocode/locales/en/zerocode.ftl index bfcb5ff3e74..3b01b940398 100644 --- a/apps/zerocode/locales/en/zerocode.ftl +++ b/apps/zerocode/locales/en/zerocode.ftl @@ -7,7 +7,11 @@ zc-pane-quickstart = Quickstart zc-app-help-cycle-mode = Cycle mode zc-app-help-reload = Reload daemon +zc-app-help-selection-mode = Selection mode (mouse copy/paste) +zc-app-help-native-select = Select text natively (most terminals) zc-app-help-quit = Quit +zc-app-selection-mode = โ–ฃ SELECTION MODE ยท drag to copy, paste with your terminal ยท { $key } to exit +zc-app-selection-mode-compact = โ–ฃ { $key } = exit selection mode zc-app-press-any-key-to-close = Press any key to close zc-app-reload-line-1 = The daemon process stays running (same PID), but every diff --git a/apps/zerocode/src/app.rs b/apps/zerocode/src/app.rs index a90093cf361..58aad10fe25 100644 --- a/apps/zerocode/src/app.rs +++ b/apps/zerocode/src/app.rs @@ -164,6 +164,11 @@ pub async fn run( let mut show_help = false; let mut reload_confirm = false; let mut quit_confirm = false; + // Whether the terminal is reporting mouse events to the TUI. Toggling this + // off (selection mode) hands the mouse back to the terminal so the user can + // drag-select and copy/paste natively. Mirrors the capture enabled at + // startup in `config_manager::init_terminal`. + let mut mouse_capture_on = true; let mut reload_status: Option = None; let mut bar_area = Rect::default(); let mut content_area = Rect::default(); @@ -348,6 +353,7 @@ pub async fn run( rpc.tui_id(), CtxBar::new(ctx_input, ctx_max), needs_intervention, + !mouse_capture_on, ); // Help modal overlay (drawn last so it sits on top). @@ -369,6 +375,14 @@ pub async fn run( chord_keys(crate::keymap::GlobalAction::ReloadDaemon.resolved()), crate::i18n::t("zc-app-help-reload"), ), + HelpEntry::new( + chord_keys(crate::keymap::GlobalAction::ToggleMouseCapture.resolved()), + crate::i18n::t("zc-app-help-selection-mode"), + ), + HelpEntry::new( + vec!["Shift+drag".to_string()], + crate::i18n::t("zc-app-help-native-select"), + ), HelpEntry::new( chord_keys(crate::keymap::GlobalAction::Quit.resolved()), crate::i18n::t("zc-app-help-quit"), @@ -594,6 +608,17 @@ pub async fn run( continue; } + // Selection mode: flip terminal mouse capture so the user can + // drag-select and copy/paste with the terminal's own clipboard. + // Intentionally not gated on `in_text_input` โ€” it's a meta + // action that must work from any pane, even mid-compose โ€” and it + // never falls through to a pane handler. + if global == Some(GlobalAction::ToggleMouseCapture) { + mouse_capture_on = !mouse_capture_on; + let _ = config_manager::set_mouse_capture(term, mouse_capture_on); + continue; + } + let switch_to: Option = match global { Some(GlobalAction::PaneNavLeft) => Some(mode.cycle(-1)), Some(GlobalAction::PaneNavRight) => Some(mode.cycle(1)), @@ -790,6 +815,7 @@ fn draw_status_bar( tui_id: Option<&str>, ctx: CtxBar, needs_intervention: bool, + selection_mode: bool, ) { let (dot, label, style) = match state { ConnectionState::Connected => ( @@ -840,8 +866,39 @@ fn draw_status_bar( spans.push(Span::styled(label, style)); frame.render_widget(Paragraph::new(Line::from(spans)), right_area); - // Left: ctx bar, left-aligned in its own column. The bar is held back - // until the context-accounting feature is ready to show; there is no + // Left: selection-mode banner takes priority โ€” when mouse capture is off + // the user needs a visible reminder of the mode and how to leave it. The + // exit key is read from the resolved keymap (`resolved_bindings`), so a + // rebind of the toggle stays accurate โ€” matching the help modal. + if selection_mode { + use unicode_width::UnicodeWidthStr; + let exit_key = chords_for( + GlobalAction::resolved_bindings(), + GlobalAction::ToggleMouseCapture, + ); + let full = crate::i18n::t_args("zc-app-selection-mode", &[("key", &exit_key)]); + // When disconnected, the connection status can crowd the left column; + // fall back to a compact banner that leads with the exit key so the + // one instruction for leaving the mode is never the part clipped. + let text = if UnicodeWidthStr::width(full.as_str()) as u16 <= left_area.width { + full + } else { + crate::i18n::t_args("zc-app-selection-mode-compact", &[("key", &exit_key)]) + }; + frame.render_widget( + Paragraph::new(Span::styled( + text, + Style::default() + .fg(Color::Rgb(255, 200, 80)) + .add_modifier(Modifier::BOLD), + )), + left_area, + ); + return; + } + + // Otherwise the ctx bar, left-aligned in its own column. The bar is held + // back until the context-accounting feature is ready to show; there is no // user-facing switch โ€” the gate flips when the work lands. const SHOW_CTX_BAR: bool = false; if SHOW_CTX_BAR && let Some(w) = ctx.widget() { diff --git a/apps/zerocode/src/config_manager.rs b/apps/zerocode/src/config_manager.rs index fa513268504..4bb790e916b 100644 --- a/apps/zerocode/src/config_manager.rs +++ b/apps/zerocode/src/config_manager.rs @@ -55,6 +55,23 @@ pub(crate) fn init_terminal() -> Result { Ok(Terminal::new(CrosstermBackend::new(stdout))?) } +/// Toggle terminal mouse-event reporting at runtime. +/// +/// With capture on (the default), the TUI receives mouse events and the +/// terminal's own click-drag text selection is suppressed โ€” so a user cannot +/// select/copy with the mouse the usual way. Turning capture off hands the +/// mouse back to the terminal for native selection, copy, and paste; the app +/// stops seeing mouse events until it is turned back on. Best-effort: a +/// terminal that ignores the sequence simply keeps its current behavior. +pub(crate) fn set_mouse_capture(term: &mut Term, on: bool) -> Result<()> { + if on { + execute!(term.backend_mut(), EnableMouseCapture)?; + } else { + execute!(term.backend_mut(), DisableMouseCapture)?; + } + Ok(()) +} + pub(crate) fn restore_terminal(term: &mut Term) -> Result<()> { disable_raw_mode()?; // Pop the enhancement flags best-effort โ€” if they were never pushed (or the diff --git a/apps/zerocode/src/input_bar.rs b/apps/zerocode/src/input_bar.rs index 2d3858b4572..1984d1a5f7d 100644 --- a/apps/zerocode/src/input_bar.rs +++ b/apps/zerocode/src/input_bar.rs @@ -951,7 +951,7 @@ impl InputBarState { match action { Some(IbWidgetAction::Paste) => { - return self.handle_clipboard_image(); + return self.handle_paste_action(); } Some(IbWidgetAction::AutocompleteCancel) if self.autocomplete_active => { self.dismiss_autocomplete(); @@ -1288,6 +1288,34 @@ impl InputBarState { } } + /// Ctrl+V paste โ€” text first. + /// + /// A Ctrl+V almost always means "paste text", so read the clipboard's + /// text and insert it directly; only when the clipboard holds no text + /// (e.g. a copied screenshot) fall back to attaching a clipboard image. + /// Reading text first also skips the image-tool subprocess on the common + /// path. This complements bracketed paste (`Event::Paste`), which most + /// terminals deliver for a native paste; Ctrl+V covers terminals that do + /// not, and the explicit image-attach fallback. + /// + /// Tradeoff: when the clipboard exposes BOTH a text flavor and an image + /// (e.g. a browser "Copy Image" that also sets a URL), the text wins. To + /// attach such an image, save it and use `/attach ` or the Ctrl+A + /// file browser โ€” text is what the overwhelming majority of pastes want. + fn handle_paste_action(&mut self) -> InputBarAction { + if let Some(text) = clipboard::read_clipboard_text() { + // Strip a single trailing CRLF/LF so a one-line paste stays one + // line; interior newlines (genuine multi-line paste) are kept. + let text = text.strip_suffix('\n').unwrap_or(&text); + let text = text.strip_suffix('\r').unwrap_or(text); + if !text.is_empty() { + return self.handle_paste(text); + } + } + // No usable text on the clipboard โ†’ try an image attachment instead. + self.handle_clipboard_image() + } + fn handle_clipboard_image(&mut self) -> InputBarAction { match clipboard::read_clipboard_image() { Some((bytes, mime)) => { diff --git a/apps/zerocode/src/keymap/actions.rs b/apps/zerocode/src/keymap/actions.rs index 8a5cad61b38..312fe58b5b6 100644 --- a/apps/zerocode/src/keymap/actions.rs +++ b/apps/zerocode/src/keymap/actions.rs @@ -127,6 +127,7 @@ keyactions! { PaneNavLeft [Chord::with(KeyCode::Left, KeyModifiers::ALT), Chord::with(KeyCode::Char('b'), KeyModifiers::ALT)] => "prev pane", PaneNavRight [Chord::with(KeyCode::Right, KeyModifiers::ALT), Chord::with(KeyCode::Char('f'), KeyModifiers::ALT)] => "next pane", ReloadDaemon [Chord::ctrl('r')] => "reload daemon", + ToggleMouseCapture [Chord::ctrl('o')] => "selection mode (mouse copy/paste)", ConfirmYes [] => "confirm", ConfirmNo [] => "cancel", } diff --git a/clawpatch.config.json b/clawpatch.config.json new file mode 100644 index 00000000000..8961805b070 --- /dev/null +++ b/clawpatch.config.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "stateDir": ".clawpatch", + "include": ["**/*"], + "exclude": [ + "node_modules/**", + "dist/**", + "build/**", + "target/**", + ".build/**", + ".git/**", + ".clawpatch/**", + ".context/**" + ], + "provider": { + "name": "acpx", + "model": "zeroclaw", + "reasoningEffort": null, + "codexConfig": {} + }, + "commands": { "typecheck": null, "lint": null, "format": null, "test": null }, + "review": { + "maxContextFiles": 24, + "maxOwnedFiles": 12, + "maxFindingsPerFeature": 10, + "minConfidenceToFix": "medium" + }, + "git": { "requireCleanWorktreeForFix": true, "commit": false, "openPr": false }, + "registryVerifier": { "enabled": false } +}