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

Personal dotfiles + provisioning scripts for a brand new machine:
setup.sh — Debian/Ubuntu (apt)setup.mac.sh — macOS (Homebrew)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.
setup.sh installs.zshrc, .vimrc, .tmux.conf, starship.toml, ghostty.config, herdr_config.toml, nvim/, yazi/*)git config --global core.editor nvimlazy.nvim) and yazi plugins (ya pkg install)setup.mac.sh installsSame set, via Homebrew, minus what macOS already provides:
zsh-autosuggestions (the brew formula, which is what .zshrc.mac sources)~/.cargo/env exists for .zshrc.mac)$(brew --prefix)/bin).zshrc.mac as ~/.zshrcgit config --global core.editor nvimDifferences from the Linux script, all deliberate:
pbcopy; compilers come from the Command Line Tools.bat→batcat / fd→fdfind renames, so no ~/.local/bin shims.fastfetch takes its place..zshrc.mac's hardcoded /usr/local paths or /Users/jackyyeh home directory don't match this machine (they don't on Apple Silicon).A few things are deliberately not automated because they're either interactive, machine/version-specific, or risky to guess at:
chsh didn't run automatically (e.g. non-interactive session), run: ``bash chsh -s $(command -v zsh) `` then log out and back in.setup.sh adds you to the docker group, but that only takes effect after you log out/in (or run newgrp docker).setup.mac.sh installs the cask, but you must launch it once from /Applications before the docker CLI exists./usr/local/go, matching the PATH entry already in .zshrc.linux).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.setup.sh only symlinks ghostty.config into ~/.config/ghostty/config. On macOS setup.mac.sh installs the cask for you.bash ssh-keygen -t ed25519 -C "your_email@example.com" ``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.hooks/register.ts 120 lines1import 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