Author SHA1 Message Date
branderson cd06db0274 Merge pull request 'Scroll copy mode 4 lines per mouse wheel tick instead of half a page' (#8) from tmux-wheel-scroll-4 into master
Reviewed-on: #8
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 18:58:20 -07:00
branderson eacaebfdef Scroll copy mode 4 lines per mouse wheel tick instead of half a page 2026-09-02 18:32:22 -07:00
branderson b0a2f0b132 Merge pull request 'Fix basedpyright install failure on Debian' (#7) from nvim-basedpyright-venv into master
Reviewed-on: #7
Reviewed-by: branderson <brad@branderson.io>
2026-08-17 14:42:55 -07:00
branderson 237a56fb6c Fix basedpyright install failure on Debian
Debian ships venv's ensurepip in a separate python3-venv package. Without
it, `python3 -m venv` exits 1, so mason cannot build the venv it installs
basedpyright into, and mason-lspconfig reports a failed install on every
startup.

Add python3-venv to the apt package list, and guard the basedpyright entry
in ensure_installed the same way the npm-sourced servers are already
guarded, so a machine missing it gets one warning instead of a failed
install every time.
2026-08-17 14:36:39 -07:00
branderson 7849b91f06 Merge pull request 'Fix multi-line paste corruption in nested tmux on 3.5/3.5a' (#6) from tmux-extended-keys-paste into master
Reviewed-on: #6
Reviewed-by: branderson <brad@branderson.io>
2026-08-17 14:27:55 -07:00
branderson 123c68dbae Fix multi-line paste corruption in nested tmux on 3.5/3.5a
Pasting multi-line text into a tmux session reached over ssh from inside
another tmux replaced every newline with a literal CSI 27;5;106~, i.e.
C-j -- the pasted newline -- rendered in modifyOtherKeys form rather than
sent as a newline.

tmux 3.5 rewrote extended-key handling and encodes keys inside a
bracketed paste; the layer further in passes those bytes through verbatim
instead of decoding them, so the application receives the escape as text.
A hop only corrupts when whatever sits downstream of it has asked for
modifyOtherKeys mode 2. A nested tmux always asks, because of the
"*:extkeys" terminal-feature above it; an application running directly
under screen-256color generally does not. That is why one tmux in the
chain was fine and two were not.

Confirmed by building 3.2a, 3.3a, 3.4, 3.5a, 3.6b and 3.7c and driving a
real bracketed paste through a pty into every outer/inner combination:
only 3.5 and 3.5a corrupt, and only on the requesting hop. Fixed upstream
in 3.6, so upgrading is the real answer where it is available.

For hosts pinned to a distro tmux, disable extended keys on exactly those
two versions. Nothing narrower works: extended-keys-format csi-u only
changes which escape lands in the text, and withholding the extkeys
terminal-feature stops a tmux asking its own parent for extended keys
without stopping it encoding them for its pane. With the guard in place
all sixteen version combinations paste correctly, and extended keys stay
on everywhere except 3.5/3.5a.

The guard tests the version with an awk END block rather than a bare
exit, so that empty input -- a tmux -V that failed for any reason --
leaves extended keys on instead of silently disabling them.

Also corrects the comment that attributed this to a Claude Code paste
tokenizer bug. It reproduces just as readily pasting into neovim.
2026-08-17 14:20:42 -07:00
branderson 86d19db1e9 Merge pull request 'Open Claude Code in its own tab on dejima auto-start' (#5) from nvim-claude-tab into master
Reviewed-on: #5
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 14:14:56 -07:00
branderson 068dd8fe25 Open Claude Code in its own tab on dejima auto-start
The auto-start left the Claude terminal only as a narrow right split. Follow it with a tab split so the same terminal buffer also gets a full-screen tab, while the original tab keeps the side-by-side layout with the editor.
2026-08-10 14:11:06 -07:00
branderson 1b9b98c424 Update gitea utils 2026-08-03 20:24:34 -07:00
branderson fe27e8178e Merge pull request 'Add tmux-fzf for session/window/pane navigation' (#4) from tmux-fzf-navigation into master
Reviewed-on: #4
2026-08-02 23:30:30 -07:00
9 changed files with 350 additions and 13 deletions
+1
View File
@@ -2,6 +2,7 @@ existing
minimal
install_local.sh
bin/dotfiles-install
config/dejima
config/i3/config.de
config/i3/config.local
config/gtk-3.0/bookmarks
+255
View File
@@ -0,0 +1,255 @@
#!/bin/bash
# Read and write Gitea issues from a shell - the issue-side counterpart to
# gitea-pr, and deliberately its twin rather than its sibling: both are
# copied into the sandbox image as single self-contained files, and gitea-pr
# also lives in a dotfiles checkout, so a shared library would be a third
# thing to keep in step across three places. The duplicated config/token/curl
# preamble is pinned in both files by test/static.bats instead.
set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
function usage() {
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
echo " gitea-issue view NUMBER"
echo " gitea-issue create --title TITLE [--body BODY | --body-file FILE]"
echo " gitea-issue comment NUMBER (--body BODY | --body-file FILE)"
echo " gitea-issue close NUMBER"
echo " gitea-issue reopen NUMBER"
echo ""
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
echo "Prefer it over --body for anything with quotes, backticks or newlines."
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
}
function require_config() {
if [ -z "${GITEA_SSH_HOST:-}" ]; then
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
"(e.g. gitea.example.com) to ~/.profile.local" >&2
exit 1
fi
if [ -z "${GITEA_API:-}" ]; then
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
exit 1
fi
}
function require_token() {
if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "No Gitea token found. Generate one in the Gitea web UI" \
"(Settings > Applications, scopes write:repository + write:issue)" \
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
"in the environment for this invocation only." >&2
exit 1
fi
}
# Feeds the auth header to curl as a config on stdin rather than as an
# argument. Command lines are world-readable through /proc, so `-H
# "Authorization: token $GITEA_TOKEN"` exposes the token to every other
# process on the host for as long as the request runs - and to anything that
# scrapes `ps` output, including the agents this repo exists to sandbox.
# printf is a bash builtin, so the token never lands in another argv.
function gitea_api() {
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" |
curl -sS -w '\n%{http_code}' -K - "$@"
}
# Splits gitea_api's combined output into the globals `json` and `http_code`
# and fails with the body on any non-2xx. Every call site did this by hand in
# gitea-pr; there are six of them here, and six hand-rolled copies is where
# one of them quietly stops checking.
function api_or_die() {
local what="$1"; shift
local response
response=$(gitea_api "$@")
http_code=$(echo "$response" | tail -1)
json=$(echo "$response" | sed '$d')
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
echo "Failed to $what (HTTP $http_code):" >&2
echo "$json" >&2
exit 1
fi
}
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
# out of argv: a body composed on a command line has to survive shell quoting,
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
# what English and code fences are made of. A file (or a heredoc into '-') has
# no such layer.
function read_body_file() {
local file="$1"
if [ "$file" = "-" ]; then
cat
return
fi
if [ ! -f "$file" ]; then
echo "No such body file: $file" >&2
exit 1
fi
cat "$file"
}
function gitea_owner_repo() {
local remote_url host owner_repo
remote_url=$(git remote get-url origin 2>/dev/null) || {
echo "No 'origin' remote found in this repository" >&2
exit 1
}
host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#')
if [ "$host" != "$GITEA_SSH_HOST" ]; then
echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-issue only supports this host" >&2
exit 1
fi
owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
echo "$owner_repo"
}
function require_number() {
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
echo "Expected an issue number, got '${1:-}'" >&2
exit 1
fi
}
# Sets `body` from --body/--body-file, rejecting both. Shared by create and
# comment so the two cannot drift into disagreeing about which one wins.
function parse_body_args() {
body=""
local body_file=""
parsed_title=""
while [ "$#" -gt 0 ]; do
case "$1" in
--title) parsed_title="$2"; shift 2 ;;
--body) body="$2"; shift 2 ;;
--body-file) body_file="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
# Rejected rather than ranked: silently preferring one would make a caller
# that passed both ship a body it didn't mean to.
if [ -n "$body" ] && [ -n "$body_file" ]; then
echo "Pass --body or --body-file, not both" >&2
exit 1
fi
if [ -n "$body_file" ]; then
body="$(read_body_file "$body_file")"
fi
}
if [ "$#" -lt 1 ]; then
usage
exit 1
fi
subcommand="$1"
shift
case "$subcommand" in
list|view|create|comment|close|reopen)
require_config
require_token
owner_repo=$(gitea_owner_repo)
owner="${owner_repo%%/*}"
repo="${owner_repo##*/}"
;;
*)
usage
exit 1
;;
esac
issues_api="${GITEA_API}/repos/${owner}/${repo}/issues"
case "$subcommand" in
list)
state="open"
limit="30"
while [ "$#" -gt 0 ]; do
case "$1" in
--state) state="$2"; shift 2 ;;
--limit) limit="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
case "$state" in
open|closed|all) ;;
*) echo "--state must be open, closed or all" >&2; exit 1 ;;
esac
if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
echo "--limit must be a number" >&2
exit 1
fi
# type=issues is load-bearing: Gitea's issues endpoint returns pull
# requests too, since a PR *is* an issue with a branch attached.
# Without it every open PR shows up here as an issue, which reads as
# a work list that is quietly twice as long as the real one.
api_or_die "list issues" \
"${issues_api}?state=${state}&type=issues&limit=${limit}"
echo "$json" | jq -r '.[] | "#\(.number)\t[\(.state)]\t\(.title)\t(\(.comments) comments)"'
;;
view)
require_number "${1:-}"
number="$1"
api_or_die "fetch issue #${number}" "${issues_api}/${number}"
echo "$json" | jq -r '"#\(.number) [\(.state)] \(.title)\n[\(.user.login) @ \(.created_at)]\n\(.body // "(no description)")\n"'
echo "=== Comments ==="
api_or_die "fetch comments for issue #${number}" "${issues_api}/${number}/comments"
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
;;
create)
parse_body_args "$@"
if [ -z "$parsed_title" ]; then
echo "gitea-issue create requires --title" >&2
exit 1
fi
body="**Filed by LLM agent**
${body}"
api_or_die "create issue" -X POST "$issues_api" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg title "$parsed_title" --arg body "$body" \
'{title: $title, body: $body}')"
echo "$json" | jq -r '.html_url'
;;
comment)
require_number "${1:-}"
number="$1"
shift
parse_body_args "$@"
if [ -z "$body" ]; then
echo "gitea-issue comment requires --body or --body-file" >&2
exit 1
fi
body="**Comment by LLM agent**
${body}"
api_or_die "comment on issue #${number}" -X POST "${issues_api}/${number}/comments" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg body "$body" '{body: $body}')"
echo "$json" | jq -r '.html_url'
;;
close|reopen)
require_number "${1:-}"
number="$1"
new_state="closed"
[ "$subcommand" = "reopen" ] && new_state="open"
api_or_die "${subcommand} issue #${number}" -X PATCH "${issues_api}/${number}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg state "$new_state" '{state: $state}')"
echo "$json" | jq -r '"#\(.number) is now \(.state): \(.html_url)"'
;;
esac
+35 -3
View File
@@ -4,10 +4,13 @@ set -euo pipefail
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
function usage() {
echo "Usage: gitea-pr create [--title TITLE] [--body BODY] [--base BRANCH]"
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
echo " gitea-pr comments PR_NUMBER"
echo " gitea-pr merge PR_NUMBER"
echo ""
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
echo "Prefer it over --body for anything with quotes, backticks or newlines."
echo ""
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
"set for this invocation only)."
@@ -16,12 +19,12 @@ function usage() {
function require_config() {
if [ -z "${GITEA_SSH_HOST:-}" ]; then
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
"(e.g. gitbox.bradina.localdomain) to ~/.profile.local" >&2
"(e.g. gitea.example.com) to ~/.profile.local" >&2
exit 1
fi
if [ -z "${GITEA_API:-}" ]; then
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
"(e.g. https://gitbox.branderson.io/api/v1) to ~/.profile.local" >&2
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
exit 1
fi
}
@@ -50,6 +53,24 @@ function gitea_api() {
curl -sS -w '\n%{http_code}' -K - "$@"
}
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
# out of argv: a body composed on a command line has to survive shell quoting,
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
# what English and code fences are made of. A file (or a heredoc into '-') has
# no such layer.
function read_body_file() {
local file="$1"
if [ "$file" = "-" ]; then
cat
return
fi
if [ ! -f "$file" ]; then
echo "No such body file: $file" >&2
exit 1
fi
cat "$file"
}
function gitea_owner_repo() {
local remote_url host owner_repo
remote_url=$(git remote get-url origin 2>/dev/null) || {
@@ -91,15 +112,26 @@ case "$subcommand" in
create)
title=""
body=""
body_file=""
base=""
while [ "$#" -gt 0 ]; do
case "$1" in
--title) title="$2"; shift 2 ;;
--body) body="$2"; shift 2 ;;
--body-file) body_file="$2"; shift 2 ;;
--base) base="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done
# Rejected rather than ranked: silently preferring one would make a
# caller that passed both ship a body it didn't mean to.
if [ -n "$body" ] && [ -n "$body_file" ]; then
echo "Pass --body or --body-file, not both" >&2
exit 1
fi
if [ -n "$body_file" ]; then
body="$(read_body_file "$body_file")"
fi
body="**Summary generated by LLM agent**
${body}"
+10
View File
@@ -47,6 +47,16 @@ if vim.env.DEJIMA then
vim.schedule(function()
require("lazy").load({ plugins = { "claudecode.nvim" } })
vim.cmd("ClaudeCode")
-- :ClaudeCode leaves the terminal window focused, so `tab split` gives
-- the same terminal buffer a full-screen window in its own tab while
-- the original tab keeps the narrow split next to the editor - one tab
-- to work in Claude, one to work side-by-side. Window-local options are
-- inherited by the split, and snacks' auto_insert (buffer-local) puts
-- the new window back into terminal mode on entry.
local claude_buf = require("claudecode.terminal").get_active_terminal_bufnr()
if claude_buf and vim.api.nvim_get_current_buf() == claude_buf then
vim.cmd("tab split")
end
end)
end,
})
+18 -1
View File
@@ -1,4 +1,21 @@
local servers = { "basedpyright", "ruff" }
-- ruff is a prebuilt GitHub release binary, but mason installs basedpyright
-- from PyPI into a venv. Debian ships venv's ensurepip separately
-- (python3-venv), and without it every startup fails the install.
local servers = { "ruff" }
local function has_python_venv()
if vim.fn.executable("python3") ~= 1 then
return false
end
vim.fn.system({ "python3", "-c", "import ensurepip" })
return vim.v.shell_error == 0
end
if has_python_venv() then
table.insert(servers, "basedpyright")
else
vim.notify("python3 venv unavailable (install python3-venv): skipping LSP server basedpyright", vim.log.levels.WARN)
end
local npm_servers = { "ts_ls", "svelte", "html", "cssls", "bashls" }
local function has_public_npm()
+18 -5
View File
@@ -87,13 +87,26 @@ if-shell "tmux -V | awk '{print $2}' | awk -F. '{if ($1*1000+$2 >= 3003) exit 0;
# itself using default-terminal above (screen-256color) -- 'xterm*' would
# never match that, breaking the nested case this is meant to support.
#
# Investigated as a suspect for multi-line paste getting corrupted with
# literal CSI 27;<mod>;<key>~ garbage in place of newlines (see Claude Code
# issues #43169 and #30239) -- turning this off did not fix that paste
# corruption, so it's an upstream Claude Code paste-tokenizer bug, not
# something caused by this setting. Restored.
# Not the cause of multi-line paste arriving with literal CSI 27;5;106~ in
# place of each newline (that being C-j, the pasted newline, in
# modifyOtherKeys form). That is a tmux 3.5/3.5a bug: 3.5 rewrote extended-key
# handling and encodes keys inside a bracketed paste, which the layer further
# in then passes through verbatim rather than decoding. Reproduced against
# 3.2a/3.3a/3.4/3.5a/3.6b/3.7c -- only 3.5 and 3.5a corrupt, and only on a hop
# whose downstream has asked for modifyOtherKeys mode 2, which a nested tmux
# always does and a bare application under screen-256color generally doesn't.
# Hence it shows up with tmux at both ends of the ssh and not with one.
# Fixed upstream in 3.6, so upgrade the offending layer where that's possible.
if-shell "tmux -V | awk '{print $2}' | awk -F. '{if ($1*1000+$2 >= 3002) exit 0; else exit 1}'" 'set -s extended-keys on; set -as terminal-features "*:extkeys"'
# Where it isn't -- a host pinned to a distro tmux -- give the paste back on
# just those two versions. Nothing narrower helps: csi-u format only changes
# which escape lands in the text, and withholding the extkeys feature above
# stops this tmux asking its own parent for extended keys without stopping it
# encoding them for the pane. Turning them off is the whole of the workaround,
# and confining it to 3.5/3.5a keeps them everywhere else.
if-shell "tmux -V | awk '{hit = ($2 ~ /^3[.]5/)} END {exit hit ? 0 : 1}'" 'set -s extended-keys off'
# Accept pane-originated OSC 52 clipboard-set attempts into a real tmux
# buffer. Only on-vs-off matters here -- off would refuse to even look at
# these escapes; "on" (rather than "external") is needed so the pane-set-
+5 -2
View File
@@ -4,8 +4,11 @@
bind-key -t vi-copy PPage page-up
bind-key -t vi-copy NPage page-down
bind-key -t vi-copy WheelUpPane halfpage-up
bind-key -t vi-copy WheelDownPane halfpage-down
# Mouse wheel scrolls 4 lines per tick, not tmux's half-page default (20 lines
# in a 40-line pane). Old bind-key has no -N; -R is the equivalent repeat
# count for keys in the copy-mode tables.
bind-key -R 4 -t vi-copy WheelUpPane scroll-up
bind-key -R 4 -t vi-copy WheelDownPane scroll-down
bind-key -t vi-copy v begin-selection
bind-key -t vi-copy y copy-pipe "$HOME/dotfiles/bin/clipboard-copy"
unbind-key -t vi-copy Enter
+5 -2
View File
@@ -3,8 +3,11 @@
bind-key -T copy-mode-vi PPage send-keys -X page-up
bind-key -T copy-mode-vi NPage send-keys -X page-down
bind-key -T copy-mode-vi WheelUpPane send-keys -X halfpage-up
bind-key -T copy-mode-vi WheelDownPane send-keys -X halfpage-down
# Mouse wheel scrolls 4 lines per tick, not tmux's half-page default (20 lines
# in a 40-line pane). -N is the repeat count; scroll-up/scroll-down take no
# direct amount argument and tmux resets the count after each command.
bind-key -T copy-mode-vi WheelUpPane send-keys -X -N 4 scroll-up
bind-key -T copy-mode-vi WheelDownPane send-keys -X -N 4 scroll-down
bind-key -T copy-mode-vi v send-keys -X begin-selection
bind-key -T copy-mode-vi y send-keys -X copy-pipe "$HOME/dotfiles/bin/clipboard-copy"
unbind-key -T copy-mode-vi Enter
+3
View File
@@ -6,6 +6,9 @@ powerline
thefuck
tree
nodejs
# Debian splits venv's ensurepip out of python3; mason needs `python3 -m venv`
# to install PyPI-sourced LSP servers (basedpyright)
python3-venv
zsh
htop
jq