Type @ at a word start to complete lane-mail lanes (@@ passes through to the stock menu)

A Claude Code mod (a plugin with a hooks module; needs Claude Code 2.1.287 or newer) that makes @ at the prompt complete lane-mail lanes: @fs, @docs, @fs:otherhost.
@ typed at a word start (start of the box, or after whitespace) opens a picker in the band above the prompt. A mid-word @ (an email address) is left alone. While the picker is open the box shows a full-width @ and what you have typed after it (@al), then one space with the cursor before it (Claude Code sends no Ctrl-F for a cursor that cannot move, and the cursor would otherwise sit at the box's end); the space is removed again whenever the picker closes and is never sent. The mark is not an ASCII @ because that opens Claude Code's own menu over the picker; and the box must not be empty, because Claude Code sends no Enter or Backspace to a mod for an empty one.@lane:host).@ was, followed by a space. It does not send the prompt. With no match, the @ and what you typed are put back as plain text (also not sent).@ and what you typed become a plain @ and text (and Claude Code's own menu may open on it).@@: a second @ right after the first closes the picker and lets a single @ through, so Claude Code's own agent/file menu opens as usual.@ is not swallowed at all.lane-mail.sh send --from @<own lane> --to @<lane> --subject ... --body -). Whether to send, or only to refer to the lane, is Claude's call from your wording. The mod never sends anything itself.A lane on another host, such as @builder:hostb, is offered once it has sent this host a message. bin/list-lanes.sh reads the headers of every message in the local thread store ($LANE_MAIL_ROOT/threads/) and takes each From: @lane:host address. Picking one inserts the full @lane:host, and a prompt that mentions it gets the same lane-mail note as a local lane.
Limits:
From: counts. A To: or Cc: address (a mistyped one nobody answered, say) is never offered, and neither is an address quoted in a message body: only the header block, up to the first blank line, is read.@lane:host (or pick the peer-qualified form of a local lane name).Claude Code hands a mod only the keys the editor takes as an edit (letters, Backspace, Left/Right, ...). Tab, Esc, Up and Down never reach it, so the picker uses none of them; Enter reaches it as a submit.
| Key | In the picker |
|---|---|
letters, digits, - . : | filter |
| Ctrl-F | highlight the next match (down the band); stops at the last |
| Ctrl-B | highlight the previous match (up the band); stops at the first |
| Enter | insert the highlighted match (the band's > row); nothing is sent |
| Backspace | trim the filter; on an empty filter, drop the @ |
@ (on an empty filter) | @@: close, pass one @ to the stock menu |
| space or other punctuation, a cursor move | not a lane: cancel, keeping what was typed |
Ctrl-F and Ctrl-B are the keys because they are the ones that reach a mod (Up, Down, Tab, Ctrl-N/P do not); while the picker is open they never move the cursor or change the box, and with it closed they are the editor's own cursor-forward/back. Typing or trimming the filter puts the highlight back on the top match; typing more of the name (@fs: narrows to the cross-host forms) is the other way to reach a lane. The cursor ends at the end of the prompt after an Enter, wherever the @ was.
claude --plugin-dir mods/lane-complete
The lane list is read in the background every few seconds by bin/list-lanes.sh (never per keystroke), which takes the lane-mail root, peers file and registry directory from the lane-mail scripts. It looks for them in this checkout's scripts/ first, then wherever lane-mail.sh is on PATH.
claude plugin validate mods/lane-complete
claude plugin test mods/lane-complete
tests/lane-complete-list-lanes-test.sh
The picker logic is in hooks/lanes.ts and has no engine in it; hooks/register.tsx wires it to prompt.edit, prompt.submit and the band.
hooks/register.tsx 104 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LanePicker } from '../types'
5import { VISIBLE, finish, highlighted, matchLanes, mentionContext, mentions, parseLanes, plain, shown, step } from './lanes'
6import type { Lane } from './lanes'
7
8// How often the lane list is re-read, in the background; never per keystroke.
9const REFRESH_MS = 5000
10
11const picker = atom({ plugin: 'lane-complete', key: 'picker' } as const, null as LanePicker | null)
12
13// The lane list the hooks read: refreshed by a timer, so never read per
14// keystroke. A reload starts it empty and session.start fills it again; a
15// failed read keeps the last good list (a missed refresh is not worth a toast).
16let lanes: Lane[] = []
17
18async function refresh($: EngineInterface) {
19 try {
20 const ran = await $.process.run(['bash', `${$.plugin.root}/bin/list-lanes.sh`], { timeoutMs: 5000 })
21 if (ran.exitCode === 0) lanes = parseLanes(ran.stdout)
22 } catch {
23 // keep the last list
24 }
25}
26
27export const register: Register = on => {
28 on('session.start', async ($, e, next) => {
29 await refresh($)
30 $.clock.every(REFRESH_MS, () => refresh($))
31
32 return next(e)
33 })
34
35 // Every edit goes through the picker. Closed, it passes all but a word-start
36 // `@`; open, it owns the edits until Backspace-on-empty, a cursor move or a
37 // non-lane character closes it, or Enter takes the choice (below). The text
38 // itself is edited here, in the answer, so nothing depends on the band
39 // taking keyboard focus. Only edits arrive here: Enter, Tab, Esc and
40 // Up/Down never do.
41 on('prompt.edit', async ($, e, next) => {
42 const open = await read($, picker)
43 if (open === null && !(e.inputText === '@' && e.start === e.end)) return next(e)
44
45 const result = step(open, lanes, e)
46 if (result.picker !== open) await update($, picker, () => result.picker)
47
48 if (result.replay !== undefined) return next({ ...e, ...result.replay })
49 return result.box ?? next(e)
50 })
51
52 // Enter with the picker open is the choice, not a send: the submit is
53 // dropped and the box put back with the best match (or, with none, as it
54 // stands, `@` and filter in it as plain text) in place of the `@` and what was typed.
55 // Otherwise a submit never rewrites the text: it only adds a note for the
56 // model when the prompt names a lane.
57 on('prompt.submit', async ($, e, next) => {
58 const open = await read($, picker)
59 if (open !== null) {
60 await update($, picker, () => null)
61 if (e.text.trim() === shown(open).text.trim()) {
62 // The cursor ends at the box's end: fill has no way to place it.
63 await $.prompt.fill({ text: finish(open, lanes).text, mode: 'replace' })
64 return { drop: 'lane picked' }
65 }
66 // A box that is not the one shown was changed behind the picker: send it
67 // as it is, with a real `@` for the mark and without the space we added.
68 e = { ...e, text: plain(open, e.text).text }
69 }
70
71 const named = mentions(e.text, lanes)
72 if (named.length === 0) return next(e)
73
74 return next({ ...e, context: [...(e.context ?? []), mentionContext(named)] })
75 }).catch(($, e, next) => next(e)) // a failed note never costs the operator the prompt
76
77 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
78 const open = await read($, picker)
79 if (open === null) return next(e)
80
81 const { Box, Text } = $.ui.resolve(e)
82 const matches = matchLanes(lanes, open.filter)
83 const shown = matches.slice(0, VISIBLE)
84 const current = highlighted(open, lanes)
85
86 return (
87 <Box flexDirection="column">
88 <Text bold>
89 lane @{open.filter}
90 <Text dimColor> ({matches.length} of {lanes.length})</Text>
91 </Text>
92 {shown.length === 0 && <Text dimColor> no lane matches; Enter keeps @{open.filter} as typed</Text>}
93 {shown.map((lane, i) => (
94 <Text key={lane.address} bold={i === current} color={i === current ? 'cyan' : undefined} dimColor={lane.source === 'peer' && i !== current}>
95 {i === current ? '> ' : ' '}
96 {lane.address}
97 </Text>
98 ))}
99 <Text dimColor>Enter inserts the marked row · C-f next, C-b previous · keep typing to narrow · Backspace trims · space cancels · @ again: stock menu</Text>
100 </Box>
101 )
102 })
103}
104hooks/lanes.ts 268 lines1// The lane-complete logic with no engine in it: parsing the lane list,
2// filtering it, stepping the picker on one prompt.edit, and finding lane
3// mentions in a submitted prompt. register.tsx wires these to the hooks; the
4// tests drive them directly (prompt.edit has no call on the test's `$`).
5
6import type { LanePicker } from '../types'
7
8export type Lane = {
9 /** The lane-mail address as it is typed: `@lane` or `@lane:host`. */
10 address: string
11 source: 'live' | 'mailbox' | 'remote' | 'peer'
12}
13
14/** The part of a prompt.edit input the picker reads. */
15export type Edit = {
16 key?: { key: string; ctrl?: true; shift?: true; meta?: true }
17 text: string
18 cursor: number
19 start: number
20 end: number
21 inputText: string
22}
23
24/** What one edit did: the picker after it, and who answers the edit. */
25export type Step = {
26 picker: LanePicker | null
27 /** Set: answer the edit with this box and never call `next` (the key is ours). */
28 box?: { text: string; cursor: number }
29 /** Set: pass the edit on to `next` as if typed into this box (the `@` of an `@@`). */
30 replay?: { text: string; cursor: number; start: number; end: number }
31}
32
33/** How many matches the band shows at once. */
34export const VISIBLE = 6
35
36const NAME = /^[a-z0-9][a-z0-9.-]*$/
37const REMOTE = /^[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9.-]*$/
38const FILTER_CHARS = /^[A-Za-z0-9:.-]+$/
39
40/**
41 * Parses list-lanes.sh output (`<source>\t<name>` lines) into addresses:
42 * live lanes, then mailbox-only lanes, then remote lanes that have written
43 * here (`lane:host`), then each local lane qualified with each peer host (the
44 * peers file names hosts, not their lanes, and a lane is usually run on both
45 * ends of a link).
46 */
47export function parseLanes(stdout: string): Lane[] {
48 const live: string[] = []
49 const mailbox: string[] = []
50 const remote: string[] = []
51 const peers: string[] = []
52 for (const line of stdout.split('\n')) {
53 const [source, name] = line.split('\t')
54 if (name === undefined) continue
55 if (source === 'remote') {
56 if (REMOTE.test(name)) remote.push(name)
57 continue
58 }
59 if (!NAME.test(name)) continue
60 if (source === 'live') live.push(name)
61 else if (source === 'mailbox') mailbox.push(name)
62 else if (source === 'peer') peers.push(name)
63 }
64 const lanes: Lane[] = []
65 const seen = new Set<string>()
66 const add = (address: string, source: Lane['source']) => {
67 if (seen.has(address)) return
68 seen.add(address)
69 lanes.push({ address, source })
70 }
71 for (const name of live) add(`@${name}`, 'live')
72 for (const name of mailbox) add(`@${name}`, 'mailbox')
73 for (const name of remote) add(`@${name}`, 'remote')
74 for (const name of [...live, ...mailbox]) {
75 for (const peer of peers) add(`@${name}:${peer}`, 'peer')
76 }
77 return lanes
78}
79
80/** The lanes a typed filter leaves: prefix matches first, then substring ones. */
81export function matchLanes(lanes: readonly Lane[], filter: string): Lane[] {
82 const f = filter.toLowerCase()
83 const prefix: Lane[] = []
84 const inside: Lane[] = []
85 for (const lane of lanes) {
86 const name = lane.address.slice(1)
87 if (name.startsWith(f)) prefix.push(lane)
88 else if (name.includes(f)) inside.push(lane)
89 }
90 return [...prefix, ...inside]
91}
92
93function splice(text: string, at: number, insert: string) {
94 return { text: text.slice(0, at) + insert + text.slice(at), cursor: at + insert.length }
95}
96
97/** A `@` typed here would be a mention, not the tail of an email address. */
98function isWordStart(text: string, at: number): boolean {
99 return at === 0 || /\s/.test(text.charAt(at - 1))
100}
101
102/** The row the picker has highlighted, kept inside the visible matches. */
103export function highlighted(p: LanePicker, lanes: readonly Lane[], by = 0): number {
104 const rows = Math.min(matchLanes(lanes, p.filter).length, VISIBLE)
105 return Math.max(0, Math.min((p.index ?? 0) + by, rows - 1))
106}
107
108/** The box with the picker's choice at its anchor, in place of the `@` and filter; one space after it unless one follows. */
109function accept(p: LanePicker, address: string) {
110 const at = Math.min(p.anchor, p.text.length)
111 const space = /\s/.test(p.text.charAt(at)) ? '' : ' '
112 return splice(p.text, at, `${address}${space}`)
113}
114
115/**
116 * What stands in the box for the `@` while the picker is open: a full-width
117 * commercial at, which looks like one but is not one to Claude Code's own
118 * `@` menu (an ASCII `@` in the box opens that menu over the picker).
119 */
120export const MARK = '\uFF20'
121
122/**
123 * The box while the picker is open: the mark and what was typed after it sit
124 * in the text at the anchor, so the box is never empty (Claude Code fires no
125 * submit and no Backspace edit for an empty one) and the operator sees what
126 * they typed. `p.text` is the box without them.
127 *
128 * One space follows the filter, with the cursor before it: the editor emits
129 * no edit for a cursor-forward that cannot move (the cursor at the box's end,
130 * where it sits after a start-of-box `@al`), and C-f must always arrive. The
131 * space is ours alone: `plain` takes it out again on every way out.
132 */
133export function shown(p: LanePicker) {
134 const at = Math.min(p.anchor, p.text.length)
135 return { text: splice(p.text, at, `${MARK}${p.filter} `).text, cursor: at + 1 + p.filter.length }
136}
137
138/** Where `shown` puts its space (the cursor sits here). */
139function padAt(p: LanePicker): number {
140 return Math.min(p.anchor, p.text.length) + 1 + p.filter.length
141}
142
143/**
144 * A box as `shown` made it, as plain text: a real `@` where the mark is and
145 * the space taken out, which is what any way out of the picker leaves. A
146 * position in the shown box maps to `at(i)` here. A box that is not quite the
147 * shown one (changed behind the picker) loses only the parts that are there.
148 */
149export function plain(p: LanePicker, text: string) {
150 const anchor = Math.min(p.anchor, text.length)
151 const pad = padAt(p)
152 const marked = text.charAt(anchor) === MARK
153 const unmarked = marked ? `${text.slice(0, anchor)}@${text.slice(anchor + 1)}` : text
154 const padded = marked && text.charAt(pad) === ' '
155 return {
156 text: padded ? unmarked.slice(0, pad) + unmarked.slice(pad + 1) : unmarked,
157 at: (i: number) => (padded && i > pad ? i - 1 : i),
158 }
159}
160
161/**
162 * The box an Enter pressed with the picker open leaves: the highlighted match
163 * in place of the `@` and filter, or with none the `@` and filter as plain text.
164 * The caller drops the submit and puts this box back.
165 */
166export function finish(p: LanePicker, lanes: readonly Lane[]) {
167 const best = matchLanes(lanes, p.filter)[highlighted(p, lanes)]
168 return best === undefined ? { text: plain(p, shown(p).text).text, cursor: shown(p).cursor } : accept(p, best.address)
169}
170
171/**
172 * One prompt.edit through the picker. `picker` is null while closed. A closed
173 * picker opens on a swallowed word-start `@` (only when there are lanes to
174 * pick: with none, the `@` goes on to the stock menu), answering with the box
175 * that has the `@` in it; an open one swallows every edit until it closes.
176 *
177 * Only keys the editor takes as an edit reach prompt.edit: Enter, Tab, Esc and
178 * the arrows Up/Down never do, so none of them is handled here. Enter arrives
179 * as a prompt.submit and is answered by `finish`.
180 */
181export function step(picker: LanePicker | null, lanes: readonly Lane[], e: Edit): Step {
182 // A picker whose box is not the one shown is stale (the box was cleared or
183 // changed behind it): forget it and treat this edit as one with no picker.
184 if (picker !== null && shown(picker).text !== e.text) picker = null
185
186 if (picker === null) {
187 const isAt = e.inputText === '@' && e.start === e.end && isWordStart(e.text, e.start)
188 if (!isAt || lanes.length === 0) return { picker: null }
189 const opened = { anchor: e.start, text: e.text, filter: '' }
190 return { picker: opened, box: shown(opened) }
191 }
192
193 const key = e.key
194 const name = key !== undefined && key.key.length > 1 ? key.key : undefined
195 const isMod = key !== undefined && (key.ctrl === true || key.meta === true)
196 const open = (next: LanePicker): Step => ({ picker: next, box: shown(next) })
197 // Closed with the box as the edit leaves it, `@` and filter kept as plain text.
198 const close = (box: { text: string; cursor: number }): Step => ({ picker: null, box })
199
200 // Backspace trims the filter; on an empty one it undoes the `@` itself.
201 const isBackspace = name === 'backspace' || (name === undefined && e.inputText === '' && e.end > e.start && !isMod)
202 if (isBackspace) {
203 if (picker.filter === '') return close({ text: picker.text, cursor: Math.min(picker.anchor, picker.text.length) })
204 return open({ anchor: picker.anchor, text: picker.text, filter: picker.filter.slice(0, -1) })
205 }
206
207 // C-f / C-b are the editor's cursor-forward/back, which would cancel the
208 // picker; here they move the highlight (next / previous), clamped at both
209 // ends, and the box is answered as shown so the cursor never moves.
210 if (key !== undefined && key.ctrl === true && key.meta !== true && (key.key === 'f' || key.key === 'b')) {
211 return open({ ...picker, index: highlighted({ ...picker, index: highlighted(picker, lanes) }, lanes, key.key === 'f' ? 1 : -1) })
212 }
213
214 // `@@`: a second `@` right after the swallowed one goes on to the stock
215 // menu, as the one `@` it would have been: the edit is replayed on the box
216 // without ours.
217 if (e.inputText === '@' && picker.filter === '') {
218 const at = Math.min(picker.anchor, picker.text.length)
219 return { picker: null, replay: { text: picker.text, cursor: at, start: at, end: at } }
220 }
221
222 if (name === undefined && !isMod && FILTER_CHARS.test(e.inputText)) {
223 return open({ anchor: picker.anchor, text: picker.text, filter: picker.filter + e.inputText.toLowerCase() })
224 }
225
226 // Anything else (a space, punctuation, a paste of prose, a cursor move, an
227 // unknown key) is not a lane: the `@` and what was typed stay as plain text,
228 // with the character typed after them.
229 const kept = plain(picker, e.text)
230 if (name === undefined && !isMod && e.inputText !== '') {
231 const start = kept.at(e.start)
232 const text = kept.text.slice(0, start) + e.inputText + kept.text.slice(kept.at(e.end))
233 return close({ text, cursor: start + e.inputText.length })
234 }
235 return close({ text: kept.text, cursor: kept.at(e.cursor) })
236}
237
238/**
239 * The known lanes a submitted prompt mentions, in order of first mention.
240 * A mention is a whole `@lane` or `@lane:host` word (a trailing sentence
241 * mark is allowed) that is in the lane list: an `@file.ts` or an email
242 * address is never one.
243 */
244export function mentions(text: string, lanes: readonly Lane[]): string[] {
245 const known = new Set(lanes.map(l => l.address))
246 const found: string[] = []
247 const re = /(?:^|\s)(@[a-z0-9][a-z0-9.-]*(?::[a-z0-9][a-z0-9.-]*)?)(?=[.,;:!?)\]"']*(?:\s|$))/g
248 for (const m of text.matchAll(re)) {
249 // The pattern is greedy over dots and a sentence-final one is not part of the name.
250 const address = (m[1] ?? '').replace(/[.]+$/, '')
251 if (known.has(address) && !found.includes(address)) found.push(address)
252 }
253 return found
254}
255
256/** The note the model reads beside a prompt that mentions lanes. */
257export function mentionContext(addresses: readonly string[]): string {
258 const list = addresses.join(', ')
259 return [
260 `The operator's prompt mentions lane-mail ${addresses.length === 1 ? 'lane' : 'lanes'}: ${list}.`,
261 'Those are lane-mail addresses (an agent session\'s mailbox), not files or agents.',
262 'A lane can be messaged with:',
263 ' lane-mail.sh send --from @<your own lane> --to @<lane> --subject ... --body -',
264 '(message body on stdin; `lane-mail.sh registration` prints your own lane; `@lane:host` is a lane on another host).',
265 'Decide from the prompt\'s wording whether the operator wants a message sent or is only referring to the lane; send nothing unless they asked.',
266 ].join('\n')
267}
268types/index.d.ts 18 lines1// The mod's $.state contract: the lane picker, null while it is closed.
2export type LanePicker = {
3 /** Where the picked address goes: the cursor when the picker opened. */
4 anchor: number
5 /** The box text the picker opened on, without the `@` and filter it shows in the box; a box other than the one shown means it is stale. */
6 text: string
7 /** What the operator has typed since the swallowed `@`; Enter takes the highlighted match of it. */
8 filter: string
9 /** The highlighted row among the visible matches (C-f down, C-b up); absent is the top one, and any change of the filter puts it back there. */
10 index?: number
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'lane-complete': { picker: LanePicker | null }
16 }
17}
18