SLOPSHOPPER

prompt-select

Shift+arrow / shift+home/end selects text in the prompt box; copy-on-select, type/paste/backspace replaces it

newprompt
v0.1.0no licenseupdated 2026-10-11jackyyeh5111/dotfiles/claude/mods/prompt-select
A shopper browsing a rack in a slop shop
README

dotfiles

Personal dotfiles + provisioning scripts for a brand new machine:

  • setup.sh — Debian/Ubuntu (apt)
  • setup.mac.sh — macOS (Homebrew)

Quickstart

git clone <this-repo-url> ~/dotfiles
cd ~/dotfiles

./setup.sh        # Debian/Ubuntu
./setup.mac.sh    # macOS

Both scripts are organized into one section per tool, check whether each tool is already installed before doing work again (safe to re-run), and back up (never delete) any existing config file before replacing it with a symlink. If any step fails, the script prints exactly which step, command, and line failed and stops — fix the issue and re-run it; completed steps are skipped automatically.

What setup.sh installs

  • Base packages: python3/pip, vim, htop, tmux, git, openssh-server, build tools
  • ripgrep, neofetch, fzf, bat, fd, xclip, astyle, eza
  • zsh + oh-my-zsh + zsh-autosuggestions
  • starship prompt, zoxide
  • Rust (rustup)
  • virtualenvwrapper
  • Docker (official convenience script)
  • Neovim (latest official release build, not the apt package)
  • yazi (latest official release build)
  • herdr
  • Claude Code
  • Symlinks all dotfiles into place (.zshrc, .vimrc, .tmux.conf, starship.toml, ghostty.config, herdr_config.toml, nvim/, yazi/*)
  • git config --global core.editor nvim
  • Best-effort bootstrap of nvim plugins (lazy.nvim) and yazi plugins (ya pkg install)

What setup.mac.sh installs

Same set, via Homebrew, minus what macOS already provides:

  • Homebrew itself (and the Xcode Command Line Tools check that precedes it)
  • Base packages: python3, git, tmux, vim, htop, wget
  • ripgrep, fastfetch, fzf, bat, fd, astyle, eza
  • oh-my-zsh + zsh-autosuggestions (the brew formula, which is what .zshrc.mac sources)
  • starship prompt, zoxide
  • Rust (rustup, so ~/.cargo/env exists for .zshrc.mac)
  • virtualenvwrapper (brew formula, so it lands in $(brew --prefix)/bin)
  • Docker Desktop (cask)
  • Neovim, yazi, herdr (brew), Claude Code (install script)
  • Ghostty (cask — unlike Linux, it's automated here)
  • Symlinks all dotfiles into place, with .zshrc.mac as ~/.zshrc
  • git config --global core.editor nvim
  • Best-effort bootstrap of nvim and yazi plugins

Differences from the Linux script, all deliberate:

  • No zsh, build tools, curl/unzip/gpg or xclip — macOS ships zsh and the clipboard is pbcopy; compilers come from the Command Line Tools.
  • No openssh-server — macOS has sshd built in, just toggled off.
  • No bat→batcat / fd→fdfind renames, so no ~/.local/bin shims.
  • neofetch is gone from homebrew-core (archived upstream); fastfetch takes its place.
  • The script warns at the end if .zshrc.mac's hardcoded /usr/local paths or /Users/jackyyeh home directory don't match this machine (they don't on Apple Silicon).

Manual steps

A few things are deliberately not automated because they're either interactive, machine/version-specific, or risky to guess at:

  • Default shell — if chsh didn't run automatically (e.g. non-interactive session), run: ``bash chsh -s $(command -v zsh) `` then log out and back in.
  • Docker group (Linux) — setup.sh adds you to the docker group, but that only takes effect after you log out/in (or run newgrp docker).
  • Docker Desktop (macOS) — setup.mac.sh installs the cask, but you must launch it once from /Applications before the docker CLI exists.
  • Remote Login (macOS) — macOS ships sshd but leaves it off; enable it in System Settings → General → Sharing → Remote Login.
  • Go — install from https://go.dev/dl/ (download the linux tarball and extract to /usr/local/go, matching the PATH entry already in .zshrc.linux).
  • Node.js — install nvm, then: ``bash nvm install --lts ` Note: .zshrc.linux also has a hardcoded PATH entry pointing at a specific past node version directory — harmless if it doesn't exist, just update it to match whatever nvm installs if you want node/npm always on PATH` without loading nvm.
  • Codex CLI — install per OpenAI's current instructions for the Codex CLI; left out here since the install method changes independently of this repo.
  • Ghostty (terminal app) — Linux only: install via your distro's package manager, snap, or from https://ghostty.org/download; setup.sh only symlinks ghostty.config into ~/.config/ghostty/config. On macOS setup.mac.sh installs the cask for you.
  • SSH key for GitHub — generate one and add it to your GitHub account if you'll be pushing over SSH: ``bash ssh-keygen -t ed25519 -C "your_email@example.com" ``
  • First nvim launch — setup.sh best-effort bootstraps plugins headlessly; if that step warned, just open nvim once and let lazy.nvim finish, then Mason will install LSP servers as configured in nvim/lua/plugins.lua.
Source 1 files
hooks/register.ts 120 lines
1import type { PromptDecoration, PromptEditInput, Register } from 'claude-code'
2
3// The live selection: `anchor` is where it started, the cursor is its other
4// end. Kept with the draft it belongs to, so any change made elsewhere
5// (submit, history recall, a fill) drops it.
6let sel: { anchor: number; text: string } | undefined
7
8const SELECT_KEYS = new Set(['left', 'right', 'up', 'down', 'home', 'end', 'pageup', 'pagedown'])
9
10const lineStart = (t: string, i: number) => t.lastIndexOf('\n', i - 1) + 1
11const lineEnd = (t: string, i: number) => {
12  const n = t.indexOf('\n', i)
13  return n === -1 ? t.length : n
14}
15
16// Word jump for shift+alt+arrow: skip spaces, then the word.
17const wordLeft = (t: string, i: number) => {
18  while (i > 0 && /\s/.test(t[i - 1])) i--
19  while (i > 0 && !/\s/.test(t[i - 1])) i--
20  return i
21}
22const wordRight = (t: string, i: number) => {
23  while (i < t.length && /\s/.test(t[i])) i++
24  while (i < t.length && !/\s/.test(t[i])) i++
25  return i
26}
27
28// Same column on the line above (dir -1) or below (dir 1); past the first or
29// last line it goes to the start or end of the draft.
30const verticalMove = (t: string, i: number, dir: -1 | 1) => {
31  const col = i - lineStart(t, i)
32  if (dir < 0) {
33    const s = lineStart(t, i)
34    if (s === 0) return 0
35    const prev = lineStart(t, s - 1)
36    return Math.min(prev + col, s - 1)
37  }
38  const e = lineEnd(t, i)
39  if (e === t.length) return t.length
40  return Math.min(e + 1 + col, lineEnd(t, e + 1))
41}
42
43// Where the cursor lands for one selecting key.
44// e.g. "ab\ncd", cursor 4 ("c|d"): up -> 1, home -> 3, pageup -> 0
45const target = (e: PromptEditInput, t: string, i: number) => {
46  const k = e.key!
47  switch (k.key) {
48    case 'left': return k.meta ? wordLeft(t, i) : Math.max(0, i - 1)
49    case 'right': return k.meta ? wordRight(t, i) : Math.min(t.length, i + 1)
50    case 'up': return verticalMove(t, i, -1)
51    case 'down': return verticalMove(t, i, 1)
52    // alt+shift+home/end is how Ghostty sends shift+up/down (see the note below).
53    case 'home': return k.meta ? verticalMove(t, i, -1) : lineStart(t, i) // fn+shift+left on a Mac
54    case 'end': return k.meta ? verticalMove(t, i, 1) : lineEnd(t, i) // fn+shift+right
55    case 'pageup': return 0 // fn+shift+up
56    default: return t.length // pagedown, fn+shift+down
57  }
58}
59
60const highlight = (a: number, b: number): PromptDecoration[] => [
61  { start: Math.min(a, b), end: Math.max(a, b), color: 'inverseText', backgroundColor: 'suggestion' },
62]
63
64/**
65 * Text selection in the main prompt box, editor style.
66 *
67 * shift+arrow, shift+home/end (fn+shift+left/right) and shift+pageup/pagedown
68 * (fn+shift+up/down) grow or shrink a highlighted selection; shift+alt+left/right
69 * goes by word. The selection is copied to the clipboard as it changes (like
70 * Ghostty's copy-on-select), so cmd+c just works. With text selected:
71 * backspace/delete removes it, typing or cmd+v replaces it, a plain arrow drops it.
72 *
73 * e.g. "hello world", cursor at end, shift+alt+left -> "world" highlighted and
74 * on the clipboard; then typing "there" -> "hello there".
75 *
76 * shift+up/down: the editor (Claude Code 2.1.296) drops them before any
77 * prompt.edit fires, so ghostty.config remaps them to alt+shift+home/end, which
78 * do reach here and select by line. Fullscreen eats shift+pageup/pagedown for
79 * scrolling, so those cases are dormant.
80 */
81export const register: Register = (on) => {
82  on('prompt.edit', async ($, e, next) => {
83    const { text, cursor } = e
84    const k = e.key
85    if (sel && sel.text !== text) sel = undefined
86
87    // A selecting key: move the cursor ourselves and keep the anchor.
88    if (k?.shift && !k.ctrl && SELECT_KEYS.has(k.key)) {
89      const anchor = sel?.anchor ?? cursor
90      const to = target(e, text, cursor)
91      if (to === anchor) {
92        sel = undefined
93        return { text, cursor: to }
94      }
95      sel = { anchor, text }
96      const a = Math.min(anchor, to), b = Math.max(anchor, to)
97      void $.ui.copy({ text: text.slice(a, b) })
98      return { text, cursor: to, decorations: highlight(anchor, to) }
99    }
100
101    if (!sel) return next(e)
102    const a = Math.min(sel.anchor, cursor), b = Math.max(sel.anchor, cursor)
103    sel = undefined
104
105    // backspace / delete: remove just the selection.
106    if (e.inputText === '' && (k?.key === 'backspace' || k?.key === 'delete')) {
107      return { text: text.slice(0, a) + text.slice(b), cursor: a }
108    }
109
110    // Typing or a paste: cut the selection, then let the editor insert as usual.
111    if (e.inputText !== '') {
112      const cut = text.slice(0, a) + text.slice(b)
113      return next({ ...e, text: cut, cursor: a, start: a, end: a })
114    }
115
116    // Anything else (a plain move, ctrl+k, ...): drop the selection, edit as usual.
117    return next(e)
118  })
119}
120