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 wheel over a list longer than its window moves the list (ui.scroll's pointer), and the dialog opens as tall as its content (ui.open's rows)Browse files
poteat authored on Sep 13, 2026 parent f714e95 · commit 4025de58b16f
8 changed files+138 -20compared with f714e95
Mmods/diff/hooks/register.ts
Mmods/diff/hooks/views/body/body-scrolled-by.ts
Mmods/diff/hooks/views/body/index.ts
Amods/diff/hooks/views/body/is-wheel-over-list.ts
Mmods/diff/hooks/views/body/plan/body-layout-of.ts
Mmods/diff/hooks/views/body/types/body-layout.ts
Amods/diff/hooks/views/dialog-pane/dialog-rows-of.ts
Mmods/diff/hooks/views/dialog-pane/index.ts
@@ -1,4 +1,10 @@
1-import type { On, ResultOf, SessionMessage, Timer } from 'claude-code'
1+import type {
2+ On,
3+ PaneOpenArgs,
4+ ResultOf,
5+ SessionMessage,
6+ Timer,
7+} from 'claude-code'
28
39 import Ask from './ask'
410 import Backend from './backend'
@@ -35,6 +41,7 @@
3541 let probing: Promise<boolean> | null = null
3642 let sessionStartMs = 0
3743 let isPaneOpen = false
44+ let dialogRows: number | null = null
3845 let hasAutoOpened = false
3946 let columns: number | null = null
4047 let shownSessionId: string | null = null
@@ -141,7 +148,31 @@
141148 return true
142149 }
143150
151+ function dialogPane(): PaneOpenArgs {
152+ return {
153+ id: Names.PANE_ID,
154+ title: Names.PANE_TITLE,
155+ holdToasts: true,
156+ closeOnEscape: true,
157+ rows: Views.dialogRowsOf(model),
158+ }
159+ }
160+
161+ function fitDialog(engine: Host) {
162+ const rows = Views.dialogRowsOf(model)
163+
164+ const isStale =
165+ isPaneOpen && model.isFullscreen === false && rows !== dialogRows
166+
167+ if (isStale) {
168+ dialogRows = rows
169+ void engine.openPane(dialogPane()).catch(() => undefined)
170+ }
171+ }
172+
144173 function redraw(engine: Host) {
174+ fitDialog(engine)
175+
145176 if (timers.has('redraw')) {
146177 return
147178 }
@@ -352,13 +383,14 @@
352383 place: { ...model.place, top: 0, listStart: 0 },
353384 }
354385
355- await engine.openPane({
356- id: Names.PANE_ID,
357- title: Names.PANE_TITLE,
358- holdToasts: true,
359- ...(isDialog ? ({ focus: true, closeOnEscape: true } as const) : {}),
360- })
386+ dialogRows = isDialog ? Views.dialogRowsOf(model) : null
361387
388+ await engine.openPane(
389+ isDialog
390+ ? { ...dialogPane(), focus: true }
391+ : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true },
392+ )
393+
362394 isPaneOpen = true
363395
364396 const sessionId = await engine.sessionId().catch(() => null)
@@ -666,14 +698,10 @@
666698 model = { ...model, dialogView: 'list' }
667699 redraw(host)
668700
701+ dialogRows = Views.dialogRowsOf(model)
702+
669703 void host
670- .openPane({
671- id: Names.PANE_ID,
672- title: Names.PANE_TITLE,
673- focus: true,
674- closeOnEscape: true,
675- holdToasts: true,
676- })
704+ .openPane({ ...dialogPane(), focus: true })
677705 .catch(() => undefined)
678706
679707 return { deny: 'back to the file list' }
@@ -708,7 +736,15 @@
708736 return next(e)
709737 }
710738
711- model = { ...model, place: Views.bodyScrolledBy(model, e) }
739+ const isOverList = Views.isWheelOverList(model, e)
740+
741+ model = {
742+ ...model,
743+ place: isOverList
744+ ? Views.listScrolledBy(model, e.by)
745+ : Views.bodyScrolledBy(model, e),
746+ }
747+
712748 host.invalidate()
713749
714750 return {}
@@ -5,8 +5,8 @@
55 import Plan from './plan'
66
77 /**
8- * The docked pane's place after one of the person's scrolls: a wheel tick
9- * or arrow three rows, a page key the window's rows, Home and End the ends.
8+ * The docked pane's place after one of the person's scrolls of the body:
9+ * a wheel tick or arrow three rows, a page key the window, Home/End an end.
1010 *
1111 * Told apart by the rows the move asks for against the pane's body and
1212 * tree, as the engine sizes each; clamped to the body's extent.
@@ -1,5 +1,6 @@
11 export * from './body-scrolled-by.js'
22 export * from './draw-window.jsx'
3+export * from './is-wheel-over-list.js'
34 export * from './list-block-of.jsx'
45 export * from './list-scrolled-by.js'
56 export * from './place-at-file.js'
@@ -1,0 +1,28 @@
1+import type { UiScrollInput } from 'claude-code'
2+
3+import Limits from '../../limits'
4+import type PaneState from '../../pane-state'
5+import Plan from './plan'
6+
7+/**
8+ * Whether a scroll is a wheel tick over the docked list block while the
9+ * list overflows its window: it moves the list then, as ReplDiffSidebar's.
10+ *
11+ * @param model the pane's state
12+ * @param scroll the move: the cell the wheel was over, absent for keys
13+ * @returns whether the list takes it
14+ */
15+export function isWheelOverList(
16+ model: PaneState.PaneModel,
17+ scroll: Pick<UiScrollInput, 'pointer'>,
18+): boolean {
19+ const plan = Plan.dockPlanOf(model)
20+ const { listTop, listEnd } = Plan.bodyLayoutOf(model, plan)
21+ const row = scroll.pointer?.row ?? -1
22+
23+ return (
24+ row >= listTop &&
25+ row < listEnd &&
26+ plan.rows.length > Limits.MAX_SUMMARY_ROWS
27+ )
28+}
@@ -5,7 +5,7 @@
55
66 /**
77 * The docked pane's scrolling body laid out under its pinned rows: the
8- * segments, their rows, the window's rows, where each file's block starts.
8+ * segments and their rows, the window's rows, the files' and list's rows.
99 *
1010 * The pinned rows are counted as sidebarPane draws them (header, base or
1111 * turn line, todo bar, list block); read from the box last drawn (`place`),
@@ -54,11 +54,15 @@
5454 extent += Segments.segmentRowsOf(segment)
5555 }
5656
57+ const listTop = Limits.PANE_TOP_PAD_ROWS + headRows + 1
58+
5759 return {
5860 segments,
5961 extent,
6062 visibleRows,
6163 maxTop: Math.max(0, extent - visibleRows),
6264 tops,
65+ listTop,
66+ listEnd: hasList ? listTop + listRows - 2 : listTop,
6367 }
6468 }
@@ -1,8 +1,8 @@
11 import type { Segment } from './segment'
22
33 /**
4- * The docked pane's scrolling body laid out: its segments, their rows in
5- * all, the window's rows, its last top, the row each file's block starts.
4+ * The docked pane's scrolling body laid out: its segments and their rows,
5+ * the window, each file's first row, the list block's rows (end exclusive).
66 */
77 export type BodyLayout = {
88 segments: readonly Segment[]
@@ -10,4 +10,6 @@
1010 visibleRows: number
1111 maxTop: number
1212 tops: ReadonlyMap<string, number>
13+ listTop: number
14+ listEnd: number
1315 }
@@ -1,0 +1,46 @@
1+import Limits from '../../limits'
2+import PaneState from '../../pane-state'
3+import { dialogEntriesOf } from './dialog-entries-of'
4+import { dialogWindowOf } from './dialog-window-of'
5+
6+/**
7+ * The rows the dialog's content takes as dialogPane draws it, for the
8+ * `rows` an inline open asks for: title, blank, count, picker, body, hints.
9+ *
10+ * The body is the message line, the list's window with its `more files`
11+ * rows, or the file's name, rule and hunk lines (their wraps not counted:
12+ * the layout caps a long file anyway and its arrows scroll it).
13+ *
14+ * @param model the pane's state
15+ * @returns the rows wanted
16+ */
17+export function dialogRowsOf(model: PaneState.PaneModel): number {
18+ const entries = dialogEntriesOf(model)
19+
20+ const hasCount =
21+ PaneState.pickedTurnOf(model) !== undefined || model.data !== null
22+
23+ const isPaged = entries.length > Limits.MAX_VISIBLE_FILES
24+ const picked = entries.find(entry => entry.path === model.selectedPath)
25+ const isDetail = model.dialogView === 'detail' && picked !== undefined
26+
27+ const start = dialogWindowOf(
28+ model,
29+ entries.map(entry => entry.path),
30+ )
31+
32+ const lines = (picked?.body?.hunks ?? []).reduce(
33+ (sum, hunk) => sum + 1 + hunk.lines.length,
34+ 0,
35+ )
36+
37+ const listed = Math.min(entries.length - start, Limits.MAX_VISIBLE_FILES)
38+
39+ const isEmpty = entries.length === 0
40+ const listRows = isEmpty ? 1 : listed + (isPaged ? 2 : 0)
41+ const bodyRows = isDetail ? 2 + Math.max(1, lines) : listRows
42+
43+ return (
44+ 2 + Number(hasCount) + Number(model.turns.length > 0) + 2 + bodyRows + 1
45+ )
46+}
@@ -1,5 +1,6 @@
11 export * from './dialog-entries-of'
22 export * from './dialog-pane.jsx'
3+export * from './dialog-rows-of.js'
34 export * from './dialog-window-of'
45 export * from './list-body-of'
56 export * from './message-pane-of'