Tab inserts the common prefix of matching paths, like a shell

Dotfiles kept in a bare git repo ($DOTFILES_HOME=~/personal_projects/dotfiles) with $HOME as the work tree.
git is shimmed by ~/scripts/dotfiles-shim/git (put first in PATH by ~/.zshrc_dotfiles_mode): in $HOME and in dirs not ignored by .dotfiles.gitignore, outside other repos, plain git, lazygit etc. operate on the dotfiles repo. GIT_DIR is not exported. Disable with DOTFILES_AUTO=0.don / dof from dotfiles.sh force / undo dotfiles mode in the current shell (export GIT_DIR and GIT_WORK_TREE).Architecture based on <https://github.com/jan-warchol/dotfiles>
Another interesting approach <https://mitxela.com/projects/dotfiles_management>
macOS: install Homebrew and git first:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
eval "$(/opt/homebrew/bin/brew shellenv)"
brew install git
export PATH="$(brew --prefix git)/bin/:$PATH"
Generate an SSH key and add ~/.ssh/id_ed25519.pub to github.com > Settings > SSH and GPG keys > New SSH key:
ssh-keygen -t ed25519 -C "your_email@example.com"
Clone the repo (bash/zsh on macOS and Linux, Git Bash or WSL on Windows):
# Bare repo outside $HOME's discovery path; the work tree ($HOME) is attached
# only by the git shim and don (GIT_WORK_TREE=$HOME).
# Do not set core.worktree: then a plain `git` inside the repo dir would operate on $HOME.
mkdir -p ~/personal_projects
git clone --bare git@github.com:rofrol/dotfiles.git ~/personal_projects/dotfiles
export DOTFILES_HOME=$HOME/personal_projects/dotfiles
git --git-dir=$DOTFILES_HOME config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
git --git-dir=$DOTFILES_HOME fetch origin
git --git-dir=$DOTFILES_HOME branch --set-upstream-to=origin/master master
# .gitignore is read by ripgrep and fd-find in Ubuntu WSL2 and git bash, so I need to use different file name
# absolute path, so it also works when git runs from a cwd other than $HOME
git --git-dir=$DOTFILES_HOME config core.excludesFile "$HOME/.dotfiles.gitignore"
# fails on existing files instead of overwriting them; review, then add -f if intended
git --git-dir=$DOTFILES_HOME --work-tree=$HOME checkout
# machine-local filter + pre-commit hook: keep Claude Code's autoMode block
# out of the tracked ~/.claude/settings.json (needs jq)
sh ~/scripts/dotfiles-setup-filters.sh
# new login shell: ~/.zprofile sets DOTFILES_HOME and sources dotfiles.sh,
# ~/.zshrc enables the git shim
exec zsh -l
Casks:
brew install --cask karabiner-elements alt-tab rectangle stats iina firefox google-chrome monitorcontrol
Formulas:
# coreutils for ls alias
# qpdf to decrypt and unprotect pdf files
# mkvtoolnix for mkvinfo and mkvextract
brew install git git-gui gh neovim ripgrep atuin fzf zsh-autosuggestions oh-my-posh zsh-git-prompt curl coreutils gnu-sed eza yt-dlp mpv qpdf mkvtoolnix alass ffmpeg
atuin login
git clone git@github.com:rofrol/LazyVim--starter.git ~/.config/nvim
mason in neovim needs npm:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
. ~/.nvm/nvm.sh # or restart the shell
nvm install node
nvm alias default node
~/.zshenv sets XDG_CONFIG_HOME="$HOME/.config" on macOS only, so tools that honour XDG keep their configuration in one place on both systems; on Linux the value is already the default and any explicit setting is left alone. Verified on 2026-09-30: gmailctl (through adrg/xdg), lazygit and superfile use ~/.config/<tool> with that variable set, which replaced most per-tool symlinks.
A shell variable reaches only what the shell starts. GUI apps opened from Finder/Dock inherit launchd's environment, launchd and cron jobs get a minimal environment, sudo resets it, containers need -e, bash does not read ~/.zshenv, and tmux keeps the environment from when its server started. I deliberately do not set it in launchd: the same app started from a terminal and from Finder would then use different configuration directories. When a tool ignores XDG, prefer its own override (CONFIG_DIR for lazygit, --config for gmailctl) and fall back to a symlink:
ln -s ~/.config/<tool>/config.toml "$HOME/Library/Application Support/<tool>/config.toml"
brew install diff-so-fancy
lazygit keeps config.yml, state.yml and its cached pull requests in $XDG_CONFIG_HOME/lazygit (~/.config/lazygit), so no symlink is needed.
superfile also follows XDG_CONFIG_HOME, but the old link ~/Library/Application Support/superfile -> ~/.config/superfile is kept as a fallback for contexts without the variable (bash, GUI launchers), so the tools cannot end up with two different configurations.
Nushell is not installed at the moment. When it comes back, it follows XDG_CONFIG_HOME like the tools above; the links below explain the macOS history and my earlier answer about changing its config location.
In iTerm2 set Preferences > Profiles > General > Command > Command to /opt/homebrew/bin/nu
brew install nushell starship
Run in nushell:
mkdir ~/.cache/starship
starship init nu | save -f ~/.cache/starship/init.nu
I have added some directories like /projects/ to .ignore, so that ripgrep or telescope in neovim do not search them.
A glob pattern starting with / is anchored to the directory containing the .ignore file (here $HOME), so it matches only that path, not projects/ in subdirectories.
<https://stackoverflow.com/questions/64373137/ripgrep-to-only-exclude-a-file-in-the-root-of-the-folder/64389725#64389725>
Install from <https://rustup.rs/>
On Ubuntu you need sudo apt install -y build-essential. On Windows install Visual Studio Build Tools with the "Desktop development with C++" workload.
cargo install ripgrep fd-find tokei cargo-watch cargo-edit watchexec-cli
git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm
Link the shared chrome directory into your profile. Profile locations:
~/Library/Application Support/Firefox/Profiles/your-profile~/.mozilla/firefox/your-profileln -s ~/.mozilla/firefox/shared/chrome ~/.mozilla/firefox/your-profile/
On Windows run cmd.exe as Administrator and:
mklink /d C:\Users\user\AppData\Roaming\Mozilla\Firefox\Profiles\your-profile\chrome C:\Users\user\.mozilla\firefox\shared\chrome
In Firefox run about:config and set
toolkit.legacyUserProfileCustomizations.stylesheets to true
sudo add-apt-repository ppa:git-core/ppa
sudo apt update
sudo apt install -y build-essential git gitk curl tig fzf libssl-dev
<https://git-scm.com/download/linux>
Install using git <https://github.com/junegunn/fzf#using-git>. Version from deb was too old for nvim integration script (0.20 vs 0.24).
sudo sh ~/bin/maps_capslock_to_escape.sh
Scoop is a Windows package manager. Install: <https://scoop.sh/>
scoop bucket add extras
scoop bucket add games
bin\scoop-install.bat
scoop update *
scoop list
scoop cleanup *
scoop cache rm *
# in bash need to escape *
scoop update \*
Shortcuts in %USERPROFILE%\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Scoop Apps
$ cd ~/scoop/buckets/main/
$ git status
# it showed that `bucket/proxychains.json` has local modifications but I could not get rid of them with `git checkout -f`
$ git fetch
$ git reset --hard origin/master
mpv from scoop does not read %APPDATA%\mpv. Portable one reads it.
Create env MPV_HOME (applies to newly started processes):
setx MPV_HOME "%USERPROFILE%\.config\mpv"
<https://github.com/mpv-player/mpv/blob/master/DOCS/man/mpv.rst#files-on-windows>
bash bin/mpv-lua-setup.sh
run bin/mpv-setup.bat as Administrator.
curl --create-dirs -so ~/scoop/apps/mpv/current/lua/json.lua https://raw.githubusercontent.com/craigmj/json4lua/master/json/json.lua
Maybe add thumbnails with icaros <https://www.majorgeeks.com/files/details/icaros.html> ?
For wezterm use this %USERPROFILE%\scoop\apps\wezterm\current\wezterm-gui.exe instead of wezterm.exe. Otherwise there will be problems.
Install UbuntuMono-R.ttf from <https://design.ubuntu.com/font/>
Install calibre-normal instead of calibre. For calibre (which is portable version) there is problem.
Long paths need to be enabled or it asks where to install. Otherwise scoop cannot create shim.
There are two versions:
"terminal.integrated.profiles.windows": {
"Git Bash": {
"path": "${env:USERPROFILE}\\scoop\\apps\\git\\current\\bin\\bash.exe"
}
},
"terminal.integrated.defaultProfile.windows": "Git Bash"
Add shortcut to %USERPROFILE%\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup
link.exe failed: exit code: 3221225781Install Visual Studio Build Tools with the "Desktop development with C++" workload (the old windows-build-tools npm package is deprecated).
On Windows 10 you need to set user env HOME pointing to %USERPROFILE% for Emacs to read ~/.config/emacs/init.el. Otherwise it will read from ~/AppData/Roaming/.emacs.d.
hooks/register.ts 105 lines1import type { PromptAutocompleteSuggestion, Register } from 'claude-code'
2
3/** Matches shown at most when no common prefix can be inserted. */
4const MAX_ROWS = 200
5
6/**
7 * The longest common prefix of `names`.
8 */
9function commonPrefix(names: readonly string[]): string {
10 // By code points, so a cut never splits a surrogate pair.
11 let prefix = Array.from(names[0] ?? '')
12 for (const name of names) while (!name.startsWith(prefix.join(''))) prefix.pop()
13 return prefix.join('')
14}
15
16/**
17 * Where the path at the cursor begins: at a double quote still open before
18 * the cursor, so a quoted path may hold spaces, else at the token. Single
19 * quotes count only at the token's start, as prose is full of apostrophes.
20 */
21export function pathStart(text: string, cursor: number, tokenStart: number): number {
22 let open = -1
23 for (let i = 0; i < cursor; i++) if (text[i] === '"') open = open < 0 ? i : -1
24 return open >= 0 && open < tokenStart ? open : tokenStart
25}
26
27/**
28 * What Tab should offer for `part`, the last component of a path, given the
29 * directory's entries (directories end with `/`): the sole match, else the
30 * matches' common prefix when it is longer than `part`, else every match.
31 * Hidden entries match only a `part` that starts with a dot.
32 */
33export function completions(part: string, names: readonly string[]): string[] {
34 const matches = names
35 .filter((name) => name.startsWith(part) && (part.startsWith('.') || !name.startsWith('.')))
36 // Sort by name alone, as a shell does: `a/` before `a-b/`.
37 .sort((a, b) => (a.replace(/\/$/, '') < b.replace(/\/$/, '') ? -1 : 1))
38 if (matches.length <= 1) return matches
39 const prefix = commonPrefix(matches)
40 return prefix.length > part.length ? [prefix] : matches.slice(0, MAX_ROWS)
41}
42
43/**
44 * Shell-style path completion for the typeahead. Claude Code shows these rows
45 * beneath its own and Tab takes the first, so offering only the common prefix
46 * makes Tab extend the path the way a shell does; once no longer prefix is
47 * shared, the rows list every match. `@` mentions, slash commands and `!`
48 * shell mode stay the engine's: the engine does not ask plugins there.
49 */
50export const register: Register = (on) => {
51 on('prompt.autocomplete', async ($, e, next) => {
52 const result = await next(e)
53 const start = pathStart(e.text, e.cursor, e.start)
54 const token = e.text.slice(start, e.cursor)
55 const quote = token[0] === '"' || token[0] === "'" ? token[0] : ''
56 const path = token.slice(quote.length)
57 const slash = path.lastIndexOf('/')
58 // A path needs a slash; a lone leading one at the start is a slash command.
59 if (token.startsWith('@') || slash < 0 || (start === 0 && slash === 0)) return result
60
61 const dir = path.slice(0, slash + 1)
62 const part = path.slice(slash + 1)
63 let listed = dir
64 if (dir.startsWith('~/')) {
65 const home = await $.env.get('HOME')
66 if (!home) return result
67 listed = home + dir.slice(1)
68 }
69
70 let names: string[]
71 try {
72 const entries = await $.fs.list(listed)
73 names = await Promise.all(
74 entries
75 .filter((entry) => entry.name.startsWith(part))
76 .map(async (entry) => {
77 let kind = entry.kind
78 // A symbolic link completes as what it leads to.
79 if (kind === 'other') kind = await $.fs.stat(listed + entry.name).then((s) => s.kind, () => 'other' as const)
80 return kind === 'dir' ? entry.name + '/' : entry.name
81 }),
82 )
83 } catch {
84 return result
85 }
86
87 const rows: PromptAutocompleteSuggestion[] = completions(part, names)
88 .filter((name) => name !== part)
89 .map((name) => {
90 const path = dir + name
91 const text = quote || !/\s/.test(path) ? quote + path : '"' + path
92 // Taking a row replaces the engine's token, which starts after any
93 // space inside the quotes.
94 return { text: text.slice(e.start - start) }
95 })
96 // With several matches and no longer prefix, Tab must not pick one: it
97 // takes the first row, so that row keeps the token as typed. The engine
98 // then closes the menu; arrows choose a match before Tab.
99 if (rows.length > 1) {
100 rows.unshift({ text: e.token, label: `${e.token} (${rows.length} matches, ↓ to choose)` })
101 }
102 return rows.length ? { suggestions: [...result.suggestions, ...rows] } : result
103 })
104}
105