spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

diff: the dialog's selected row starts the focus ring (autoFocus) and the ring's walk selects, the five rows re-centring on it as the built-in's do (ui.focus)Browse files
poteat authored on Sep 13, 2026 parent 4992bdc · commit df235907a990
17 changed files+128 -67compared with 4992bdc
Mmods/diff/hooks/names/index.ts
Amods/diff/hooks/names/plugin-name.ts
Amods/diff/hooks/pane-state/dialog-focus/dialog-focus.ts
Amods/diff/hooks/pane-state/dialog-focus/index.ts
Mmods/diff/hooks/pane-state/index.ts
Mmods/diff/hooks/register.ts
Mmods/diff/hooks/views/body/list-scrolled-by.ts
Amods/diff/hooks/views/dialog-pane/dialog-focus-of.ts
Mmods/diff/hooks/views/dialog-pane/dialog-pane.tsx
Mmods/diff/hooks/views/dialog-pane/dialog-window-of/dialog-window-of.ts
Mmods/diff/hooks/views/dialog-pane/index.ts
Mmods/diff/hooks/views/pane-actions/pane-actions.ts
Mmods/diff/hooks/views/sections/dialog-file-row/dialog-file-row.tsx
Mmods/diff/hooks/views/sections/file-row/file-row.ts
Mmods/diff/hooks/views/sections/keyed-pressable/keyed-pressable.ts
Mmods/diff/hooks/views/sections/list-edge-button/list-edge-button.tsx
Mmods/diff/hooks/views/sections/list-row/list-row.tsx
@@ -3,6 +3,7 @@
33 export * from './keys'
44 export * from './pane-id.js'
55 export * from './pane-title.js'
6+export * from './plugin-name.js'
67 export * from './texts'
78
89 export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The plugin's own name, as the engine stamps it on the elements its render
3+ * hooks draw (`ui.press`, `ui.focus`).
4+ */
5+export const PLUGIN_NAME = 'diff'
@@ -1,0 +1,8 @@
1+/**
2+ * What the ring landing on a dialog row settles: the file now selected,
3+ * and the key of the row to land the ring on once the window re-centres.
4+ */
5+export type DialogFocus = {
6+ selectedPath: string
7+ landing: string
8+}
@@ -1,0 +1,3 @@
1+export type * from './dialog-focus.js'
2+
3+export * as default from '.'
@@ -2,6 +2,7 @@
22 export * from './after-new-session'
33 export * from './base-mode-of'
44 export * from './bodies'
5+export * from './dialog-focus'
56 export * from './dialog-title'
67 export * from './dialog-title-of.js'
78 export * from './empty-state'
@@ -467,13 +467,7 @@
467467 redraw(engine)
468468 },
469469 scrollList: delta => {
470- const place = Views.listScrolledBy(model, delta)
471- const isDocked = model.placement === 'dock'
472-
473- model = isDocked
474- ? { ...model, place }
475- : { ...model, place, selectedPath: null }
476-
470+ model = { ...model, place: Views.listScrolledBy(model, delta) }
477471 redraw(engine)
478472 },
479473 toggleNoise: () => {
@@ -729,6 +723,23 @@
729723 return result
730724 })
731725
726+ on('ui.focus', { plugin: Names.PLUGIN_NAME }, ($, e, next) => {
727+ const isListed =
728+ model.placement === 'inline' && model.dialogView === 'list' && host
729+
730+ const focus = isListed ? Views.dialogFocusOf(model, e.element) : null
731+
732+ if (!focus || !host) {
733+ return next(e)
734+ }
735+
736+ model = { ...model, selectedPath: focus.selectedPath }
737+ fitDialog(host)
738+ host.invalidate()
739+
740+ return next({ ...e, element: focus.landing })
741+ })
742+
732743 on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => {
733744 const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock'
734745
@@ -1,11 +1,10 @@
11 import Limits from '../../limits'
22 import type PaneState from '../../pane-state'
3-import DialogPane from '../dialog-pane'
43 import Plan from './plan'
54
65 /**
7- * The pane's place with its list window moved by so many files, clamped:
8- * docked, ReplDiffSidebar's scrollSummary; inline, the dialog's window.
6+ * The docked pane's place with the list window moved by so many files,
7+ * clamped to the list (ReplDiffSidebar's scrollSummary).
98 *
109 * @param model the pane's state
1110 * @param delta the files to move by, negative toward the top
@@ -15,19 +14,16 @@
1514 model: PaneState.PaneModel,
1615 delta: number,
1716 ): PaneState.PaneModel['place'] {
18- const isDocked = model.placement === 'dock'
19- const paths = DialogPane.dialogEntriesOf(model).map(entry => entry.path)
17+ const last = Math.max(
18+ 0,
19+ Plan.dockPlanOf(model).rows.length - Limits.MAX_SUMMARY_ROWS,
20+ )
2021
21- const last = isDocked
22- ? Plan.dockPlanOf(model).rows.length - Limits.MAX_SUMMARY_ROWS
23- : paths.length - Limits.MAX_VISIBLE_FILES
24-
25- const start = isDocked
26- ? Math.min(model.place.listStart, Math.max(0, last))
27- : DialogPane.dialogWindowOf(model, paths)
28-
2922 return {
3023 ...model.place,
31- listStart: Math.max(0, Math.min(Math.max(0, last), start + delta)),
24+ listStart: Math.max(
25+ 0,
26+ Math.min(last, Math.min(model.place.listStart, last) + delta),
27+ ),
3228 }
3329 }
@@ -1,0 +1,32 @@
1+import type PaneState from '../../pane-state'
2+import Sections from '../sections'
3+import { dialogEntriesOf } from './dialog-entries-of'
4+import { dialogWindowOf } from './dialog-window-of'
5+
6+/**
7+ * What the focus ring landing on a dialog row makes of the list: that file
8+ * selected, the window re-centred, the key of the row drawn where it sits.
9+ *
10+ * DiffFileList's selection. The engine keeps its ring by position, so the
11+ * ring is sent to the row that holds the selected file's place once the
12+ * window has moved; null for a key that is none of the listed rows'.
13+ *
14+ * @param model the pane's state
15+ * @param element the key of the element taking the ring
16+ * @returns the selected path and the key to land the ring on, or null
17+ */
18+export function dialogFocusOf(
19+ model: PaneState.PaneModel,
20+ element: string | undefined,
21+): PaneState.DialogFocus | null {
22+ const entries = dialogEntriesOf(model)
23+ const paths = entries.map(entry => entry.path)
24+ const keys = entries.map(entry => Sections.fileKeyOf(entry))
25+ const picked = keys.indexOf(element ?? '')
26+ const selectedPath = paths[picked] ?? null
27+ const after = dialogWindowOf({ selectedPath }, paths)
28+ const landing = keys[dialogWindowOf(model, paths) + picked - after]
29+ const isRow = selectedPath !== null && landing !== undefined
30+
31+ return isRow ? { selectedPath, landing } : null
32+}
@@ -18,9 +18,9 @@
1818 * The inline pane (DiffDialog): its title, the dim count, then the file
1919 * list's window, or the picked file's detail alone, and the key hints.
2020 *
21- * Every file lists in the built-in's order, five at a time between `more
22- * files` rows that move the window; the source picker stands in for the
23- * tabs. Arrows walk the rows, Enter views one, Escape backs out or closes.
21+ * Every file lists in the built-in's order, five at a time round the
22+ * selected row (`❯ `, where the focus ring starts and what its walk moves,
23+ * ui.focus); Enter views that file, Escape backs out or closes.
2424 *
2525 * @param kit the elements, the handlers, the width
2626 * @param model the pane's state
@@ -44,6 +44,7 @@
4444 const isTurn = model.source.kind === 'turn'
4545 const isPaged = entries.length > Limits.MAX_VISIBLE_FILES
4646 const picked = entries.find(entry => entry.path === model.selectedPath)
47+ const selected = picked ?? entries[0]
4748 const isDetail = model.dialogView === 'detail' && picked !== undefined
4849
4950 const start = dialogWindowOf(
@@ -89,7 +90,7 @@
8990 added: entry.added,
9091 removed: entry.removed,
9192 note: noteOf(entry),
92- isSelected: false,
93+ isSelected: entry === selected,
9394 },
9495 () => kit.actions.selectFile(entry.path),
9596 )
@@ -97,9 +98,8 @@
9798 function moreOf(way: 'up' | 'down', count: number): RenderElement {
9899 const arrow = way === 'up' ? '\u2191' : '\u2193'
99100 const words = Layout.plural(count, 'file').replace(' ', ' more ')
100- const edge = way === 'up' ? 'files-up' : 'files-down'
101101
102- return Sections.listEdgeButton(kit, edge, ` ${arrow} ${words}`)
102+ return Sections.dimNote(kit, ` ${arrow} ${words}`)
103103 }
104104
105105 const pagedEdgeOf = (way: 'up' | 'down', count: number) =>
@@ -3,23 +3,21 @@
33
44 /**
55 * The first of the MAX_VISIBLE_FILES rows the dialog lists (DiffFileList's
6- * window): centred on the file last viewed, else where its edge rows put it.
6+ * window): centred on the selected file, the first when none is, clamped.
77 *
8- * @param model the pane's state: the pick and the list's start
8+ * @param model the pane's state: the selection
99 * @param paths the listed files' paths, in order
1010 * @returns the first listed row's index
1111 */
12-export function dialogWindowOf(
13- model: Pick<PaneState.PaneModel, 'selectedPath' | 'place'>,
12+export const dialogWindowOf = (
13+ model: Pick<PaneState.PaneModel, 'selectedPath'>,
1414 paths: readonly string[],
15-): number {
16- const last = Math.max(0, paths.length - Limits.MAX_VISIBLE_FILES)
17- const picked = paths.indexOf(model.selectedPath ?? '')
18- const isPicked = model.selectedPath !== null && picked >= 0
19-
20- const wanted = isPicked
21- ? picked - Math.floor(Limits.MAX_VISIBLE_FILES / 2)
22- : model.place.listStart
23-
24- return Math.max(0, Math.min(last, wanted))
25-}
15+): number =>
16+ Math.max(
17+ 0,
18+ Math.min(
19+ paths.length - Limits.MAX_VISIBLE_FILES,
20+ Math.max(0, paths.indexOf(model.selectedPath ?? '')) -
21+ Math.floor(Limits.MAX_VISIBLE_FILES / 2),
22+ ),
23+ )
@@ -1,4 +1,5 @@
11 export * from './dialog-entries-of'
2+export * from './dialog-focus-of.js'
23 export * from './dialog-pane.jsx'
34 export * from './dialog-rows-of.js'
45 export * from './dialog-window-of'
@@ -10,8 +10,7 @@
1010 selectFile: (path: string) => void
1111
1212 /**
13- * Moves the list's window by so many files, clamped to the list; in the
14- * dialog the pick is dropped so the window leaves the file last viewed.
13+ * Moves the docked list's window by so many files, clamped to the list.
1514 */
1615 scrollList: (delta: number) => void
1716
@@ -14,7 +14,7 @@
1414
1515 /**
1616 * One file of the inline list (DiffFileList FileItem): the pointer before
17- * the selected one, the start-truncated name, its counts or note right.
17+ * the selected one, where the focus ring starts, the name, counts or note.
1818 *
1919 * @param kit the elements, the handlers, the width
2020 * @param row the file
@@ -42,7 +42,13 @@
4242
4343 return listRow(
4444 kit,
45- { key: row.key, label: `${mark} ${name}`, onPress, isDim: false },
45+ {
46+ key: row.key,
47+ label: `${mark} ${name}`,
48+ onPress,
49+ isDim: false,
50+ isAutoFocus: row.isSelected,
51+ },
4652 tail,
4753 )
4854 }
@@ -36,7 +36,7 @@
3636
3737 return listRow(
3838 kit,
39- { key: row.key, label, onPress, isDim: true },
39+ { key: row.key, label, onPress, isDim: true, isAutoFocus: false },
4040 diffStat(kit, row.added, row.removed),
4141 )
4242 }
@@ -2,9 +2,10 @@
22
33 /**
44 * A plain Button's face, press and address: a list row's Button, dim at
5- * rest where the built-in draws the row dim.
5+ * rest where the built-in's is, the focus ring's start where selected.
66 */
77 export type KeyedPressable = Pressable & {
88 key: string
99 isDim: boolean
10+ isAutoFocus: boolean
1011 }
@@ -3,39 +3,32 @@
33 /* @jsxFrag Fragment */
44 import type { RenderElement } from 'claude-code'
55
6-import Limits from '../../../limits'
76 import type { Kit } from '../../kit'
87
98 /**
10- * A list's `more` row as a dim plain Button that moves its window: the
11- * docked list's a file, under the built-in's keys too; the dialog's a page.
9+ * The docked list's `more above` or `more below` row as a dim plain Button
10+ * that moves its window a file, under the built-in's list keys too.
1211 *
13- * The built-in dialog moves its window with the selection; a plugin cannot
14- * see the person walk the rows, so there the row itself carries the move.
15- *
1612 * @param kit the elements and the handlers
17- * @param edge which list and which edge, the Button's key
13+ * @param edge which edge, the Button's key
1814 * @param label the row's text
1915 * @returns the Button
2016 */
2117 export function listEdgeButton(
2218 kit: Kit,
23- edge: 'list-up' | 'list-down' | 'files-up' | 'files-down',
19+ edge: 'list-up' | 'list-down',
2420 label: string,
2521 ): RenderElement {
2622 const { Button } = kit.ui
27- const isDocked = edge === 'list-up' || edge === 'list-down'
28- const isUp = edge === 'list-up' || edge === 'files-up'
29- const size = isDocked ? 1 : Limits.MAX_VISIBLE_FILES
30- const action = isUp ? 'app:diffFileListUp' : 'app:diffFileListDown'
23+ const isUp = edge === 'list-up'
3124
3225 return (
3326 <Button
3427 key={edge}
3528 plain
3629 dimColor
37- {...(isDocked ? { action } : {})}
38- onPress={() => kit.actions.scrollList(isUp ? -size : size)}
30+ action={isUp ? 'app:diffFileListUp' : 'app:diffFileListDown'}
31+ onPress={() => kit.actions.scrollList(isUp ? -1 : 1)}
3932 >
4033 {label}
4134 </Button>
@@ -7,12 +7,12 @@
77 import type { KeyedPressable } from '../keyed-pressable'
88
99 /**
10- * One row of a file list: a plain Button under its key, then whatever the
11- * row shows at its right edge (its counts, a note).
10+ * One list row: a plain Button as wide as its label, a spacer, then the
11+ * tail at the right edge (the counts, or a note).
1212 *
1313 * @param kit the elements
14- * @param row the Button's key, label and press, and whether it rests dim
15- * @param tail the element at the right edge
14+ * @param row the Button's key, label, press, rest style and ring start
15+ * @param tail what sits at the row's right edge
1616 * @returns the row element
1717 */
1818 export function listRow(
@@ -24,7 +24,13 @@
2424
2525 return (
2626 <Box flexDirection="row">
27- <Button key={row.key} plain dimColor={row.isDim} onPress={row.onPress}>
27+ <Button
28+ key={row.key}
29+ plain
30+ dimColor={row.isDim}
31+ {...(row.isAutoFocus ? ({ autoFocus: true } as const) : {})}
32+ onPress={row.onPress}
33+ >
2834 {row.label}
2935 </Button>
3036 <Box flexGrow={1} />