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

mods/diff: the empty state sits where the built-in centers it, the pre-session line at the footBrowse files
poteat authored on Sep 10, 2026 parent 8b41514 · commit 148e0f0930a1
12 changed files+230 -35compared with 8b41514
Mmods/diff/hooks/register.ts
Mmods/diff/hooks/views/current-pane/current-pane.tsx
Mmods/diff/hooks/views/current-pane/index.ts
Amods/diff/hooks/views/current-pane/message-pane-of/index.ts
Amods/diff/hooks/views/current-pane/message-pane-of/message-pane-of.tsx
Mmods/diff/hooks/views/kit/kit.ts
Mmods/diff/hooks/views/layout/index.ts
Amods/diff/hooks/views/layout/wrapped-lines/index.ts
Amods/diff/hooks/views/layout/wrapped-lines/wrapped-lines.ts
Mmods/diff/hooks/views/pane-view.tsx
Mmods/diff/hooks/views/sections/header-view.tsx
Mmods/diff/hooks/views/turn-pane/turn-pane.tsx
@@ -491,6 +491,7 @@
491491 ui: { Box, Text, Button, Select, Code },
492492 actions: actionsOf(host),
493493 columns: e.props.bodyColumns,
494+ rows: e.props.scroll.bodyRows,
494495 },
495496 model,
496497 e.props.placement,
@@ -12,6 +12,7 @@
1212 import Sections from '../sections'
1313 import { fileRowOf } from './file-row-of'
1414 import { listBodyOf } from './list-body-of'
15+import { messagePaneOf } from './message-pane-of'
1516
1617 /**
1718 * The pane over the repository's diff now (ReplDiffSidebarBody, one file's
@@ -19,9 +20,11 @@
1920 *
2021 * The tests-and-generated toggle sits above the rows; the elision count, the
2122 * withheld-untracked note, the pre-session line and rows below them; the
22- * selected file last.
23+ * selected file last. With no rows to list, the loading line, the empty
24+ * state or the too-many and only-hidden lines take the body instead
25+ * (messagePaneOf), the header keeping only the close.
2326 *
24- * @param kit the elements, the handlers, the width
27+ * @param kit the elements, the handlers, the width, the rows
2528 * @param model the pane's state
2629 * @returns the pane's tree
2730 */
@@ -37,7 +40,13 @@
3740 }
3841
3942 if (!data && !model.hasSettled) {
40- return <Box>{Sections.dimNote(kit, 'Loading diff…')}</Box>
43+ return messagePaneOf(kit, {
44+ top: [Sections.headerView(kit, null)],
45+ message: ['Loading diff…'],
46+ controls: null,
47+ earlier: null,
48+ rest: [],
49+ })
4150 }
4251
4352 const noise = model.isNoiseShown ? 'shown' : 'hidden'
@@ -84,6 +93,16 @@
8493 const earlierRows = model.isPreSessionShown ? partition.preSession : []
8594 const isUntrackedNoted = data?.isUntrackedWithheld === true && !empty
8695 const listed = listBodyOf(kit, { data, partition, totals, empty })
96+ const notes = listed.filter(line => typeof line === 'string')
97+ const hasRows = notes.length < listed.length
98+ const message = empty ? [empty.headline, ...notes] : hasRows ? [] : notes
99+ const header = Sections.headerView(kit, empty ? null : totals)
100+ const notShownNote = noteOf(
101+ totals.notShown > 0 ? `${totals.notShown} not shown` : null,
102+ )
103+ const untrackedNote = noteOf(
104+ isUntrackedNoted ? Names.untrackedWithheldTextOf(model.words.lister) : null,
105+ )
87106 const detail = selected
88107 ? [
89108 Sections.divider(kit),
@@ -104,10 +123,27 @@
104123 ]
105124 : []
106125
126+ if (message.length > 0) {
127+ return messagePaneOf(kit, {
128+ top: Sections.present([
129+ header,
130+ noteOf(baseLabel),
131+ Sections.todoBar(kit, model.todos),
132+ noiseToggle,
133+ notShownNote,
134+ untrackedNote,
135+ ]),
136+ message,
137+ controls: Sections.controlsView(kit, model),
138+ earlier: earlierToggle,
139+ rest: [...earlierRows.map(rowOf), ...detail],
140+ })
141+ }
142+
107143 return (
108144 <Box flexDirection="column">
109145 {Sections.present([
110- Sections.headerView(kit, empty?.headline ?? null, totals),
146+ header,
111147 noteOf(baseLabel),
112148 Sections.todoBar(kit, model.todos),
113149 Sections.controlsView(kit, model),
@@ -115,12 +151,8 @@
115151 ...listed.map(line =>
116152 typeof line === 'string' ? noteOf(line) : rowOf(line),
117153 ),
118- noteOf(totals.notShown > 0 ? `${totals.notShown} not shown` : null),
119- noteOf(
120- isUntrackedNoted
121- ? Names.untrackedWithheldTextOf(model.words.lister)
122- : null,
123- ),
154+ notShownNote,
155+ untrackedNote,
124156 earlierToggle,
125157 ...earlierRows.map(rowOf),
126158 ...detail,
@@ -1,5 +1,6 @@
11 export * from './current-pane.jsx'
22 export * from './file-row-of'
33 export * from './list-body-of'
4+export * from './message-pane-of'
45
56 export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './message-pane-of.jsx'
2+
3+export * as default from '.'
@@ -1,0 +1,84 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import type { Kit } from '../../kit'
7+import Layout from '../../layout'
8+
9+/**
10+ * The pane while a message stands in for the list (ReplDiffSidebar's
11+ * centered body: the first fetch, the empty state, the too-many and
12+ * only-hidden states): the header block, the built-in's blank row under it,
13+ * the message with the pickers under it, the pre-session line.
14+ *
15+ * With nothing listed below and the rows to hold it, the message's lines
16+ * sit where the built-in centers them (half the spare rows above, rounded
17+ * down; each wrapped line centered on its own), the pickers right under
18+ * them and the pre-session line at the foot; otherwise it all stacks from
19+ * the top.
20+ *
21+ * @param kit the elements, the width, the body's rows
22+ * @param pane the header block, the message's lines, the pickers, the
23+ * pre-session line, and what is listed under it
24+ * @returns the pane's tree
25+ */
26+export function messagePaneOf(
27+ kit: Kit,
28+ pane: {
29+ top: readonly RenderElement[]
30+ message: readonly string[]
31+ controls: RenderElement | null
32+ earlier: RenderElement | null
33+ rest: readonly RenderElement[]
34+ },
35+): RenderElement {
36+ const { Box, Text } = kit.ui
37+ const { top, controls, earlier, rest } = pane
38+ const lines = pane.message.flatMap(text =>
39+ Layout.wrappedLines(Layout.sanitizeName(text), kit.columns),
40+ )
41+ const drawn = lines.map(line => <Text dimColor>{line}</Text>)
42+ const controlRows = controls === null ? [] : [controls]
43+ const earlierRows = earlier === null ? [] : [earlier]
44+ const middleRows = kit.rows - top.length - 1 - (earlier === null ? 0 : 3)
45+ const above = Math.floor((middleRows - lines.length) / 2)
46+ const isCentered =
47+ rest.length === 0 &&
48+ middleRows - above - lines.length >= controlRows.length
49+
50+ if (!isCentered) {
51+ return (
52+ <Box flexDirection="column">
53+ {[
54+ ...top,
55+ <Box height={1} />,
56+ ...drawn,
57+ ...controlRows,
58+ ...earlierRows,
59+ ...rest,
60+ ]}
61+ </Box>
62+ )
63+ }
64+
65+ const spacer = above > 0 ? [<Box height={above} />] : []
66+ const foot = earlierRows.map(row => (
67+ <Box flexDirection="column" marginTop={1} paddingBottom={1}>
68+ {row}
69+ </Box>
70+ ))
71+
72+ return (
73+ <Box flexDirection="column" height={kit.rows}>
74+ {[
75+ ...top,
76+ <Box height={1} />,
77+ <Box flexDirection="column" height={middleRows} alignItems="center">
78+ {[...spacer, ...drawn, ...controlRows]}
79+ </Box>,
80+ ...foot,
81+ ]}
82+ </Box>
83+ )
84+}
@@ -3,10 +3,12 @@
33
44 /**
55 * What every part of one drawing is handed: the surface's elements, the
6- * controls' handlers, and the body's width (`props.bodyColumns`).
6+ * controls' handlers, the body's width (`props.bodyColumns`) and its rows
7+ * (`props.scroll.bodyRows`).
78 */
89 export type Kit = {
910 ui: Ui
1011 actions: PaneActions
1112 columns: number
13+ rows: number
1214 }
@@ -5,5 +5,6 @@
55 export * from './sanitize-name'
66 export * from './tab-width'
77 export * from './truncate-start'
8+export * from './wrapped-lines'
89
910 export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './wrapped-lines.js'
2+
3+export * as default from '.'
@@ -1,0 +1,71 @@
1+import { cellWidth } from '../cell-width'
2+
3+/**
4+ * A line cut into rows of at most `columns` cells as Ink's `wrap` cuts the
5+ * built-in's empty state before it centers each row: at spaces, the space a
6+ * row breaks on kept at its end, a word wider than a row cut across rows.
7+ *
8+ * @param text one sanitized line
9+ * @param columns the width, at least one cell
10+ * @returns the rows, at least one
11+ */
12+export function wrappedLines(text: string, columns: number): string[] {
13+ const width = Math.max(1, columns)
14+ const rows: string[] = ['']
15+ const words = text.split(' ')
16+
17+ for (const [index, word] of words.entries()) {
18+ const wordWidth = cellWidth(word)
19+ let rowWidth = cellWidth(rows[rows.length - 1] ?? '')
20+
21+ if (index !== 0) {
22+ if (rowWidth >= width) {
23+ rows.push('')
24+ rowWidth = 0
25+ }
26+
27+ rows[rows.length - 1] += ' '
28+ rowWidth += 1
29+ }
30+
31+ if (wordWidth > width) {
32+ const breaksHere = 1 + Math.floor((wordWidth - (width - rowWidth) - 1) / width)
33+ const breaksNext = Math.floor((wordWidth - 1) / width)
34+
35+ if (breaksNext < breaksHere) {
36+ rows.push('')
37+ }
38+
39+ const characters = [...word]
40+ let visible = cellWidth(rows[rows.length - 1] ?? '')
41+
42+ for (const [at, character] of characters.entries()) {
43+ const characterWidth = cellWidth(character)
44+
45+ if (visible + characterWidth <= width) {
46+ rows[rows.length - 1] += character
47+ } else {
48+ rows.push(character)
49+ visible = 0
50+ }
51+
52+ visible += characterWidth
53+
54+ if (visible === width && at < characters.length - 1) {
55+ rows.push('')
56+ visible = 0
57+ }
58+ }
59+
60+ continue
61+ }
62+
63+ if (rowWidth + wordWidth > width && rowWidth > 0 && wordWidth > 0) {
64+ rows.push('')
65+ }
66+
67+ rows[rows.length - 1] += word
68+ }
69+
70+ return rows
71+}
@@ -40,7 +40,11 @@
4040
4141 const right = Limits.PANE_RIGHT_PAD_COLUMNS
4242 const top = Limits.PANE_TOP_PAD_ROWS
43- const inset: Kit = { ...kit, columns: Math.max(1, kit.columns - right) }
43+ const inset: Kit = {
44+ ...kit,
45+ columns: Math.max(1, kit.columns - right),
46+ rows: Math.max(0, kit.rows - top),
47+ }
4448
4549 return (
4650 <Box flexDirection="column" paddingTop={top} paddingRight={right}>
@@ -11,39 +11,32 @@
1111
1212 /**
1313 * The pane's first line (ReplDiffSidebar's header row): `N files changed
14- * +A -R`, or the empty headline in its place when there is nothing to
15- * count, and the close `✕` at the right edge either way.
14+ * +A -R`, or nothing over an empty state, and the close `✕` at the right
15+ * edge either way.
1616 *
17- * The headline may name a branch the repository chose, so it is drawn as
18- * a name: no control or format character survives.
19- *
2017 * @param kit the drawing's kit; its elements draw the line
21- * @param headline the empty headline, or null to draw the counts
22- * @param totals the header's counts
18+ * @param totals the header's counts, or null when there is nothing to count
2319 * @returns the line
2420 */
2521 export function headerView(
2622 kit: Kit,
27- headline: string | null,
28- totals: PaneState.HeaderTotals,
23+ totals: PaneState.HeaderTotals | null,
2924 ): RenderElement {
3025 const { Box, Text } = kit.ui
31- const empty = (
32- <Text dimColor wrap="truncate-end">
33- {Layout.sanitizeName(headline ?? '')}
34- </Text>
35- )
36- const counts = (
37- <Text wrap="truncate-end">
38- <Text bold>{Layout.plural(totals.filesCount, 'file')}</Text>
39- {' changed '}
40- {diffStat(kit, totals.linesAdded, totals.linesRemoved)}
41- </Text>
42- )
26+ const counts =
27+ totals === null
28+ ? []
29+ : [
30+ <Text wrap="truncate-end">
31+ <Text bold>{Layout.plural(totals.filesCount, 'file')}</Text>
32+ {' changed '}
33+ {diffStat(kit, totals.linesAdded, totals.linesRemoved)}
34+ </Text>,
35+ ]
4336
4437 return (
4538 <Box flexDirection="row">
46- {[headline === null ? counts : empty, <Box flexGrow={1} />, closeButton(kit)]}
39+ {[...counts, <Box flexGrow={1} />, closeButton(kit)]}
4740 </Box>
4841 )
4942 }
@@ -70,7 +70,7 @@
7070 return (
7171 <Box flexDirection="column">
7272 {Sections.present([
73- Sections.headerView(kit, null, PaneState.turnTotalsOf(turn)),
73+ Sections.headerView(kit, PaneState.turnTotalsOf(turn)),
7474 Sections.dimNote(
7575 kit,
7676 `Turn ${turn.index} "${Layout.sanitizeName(turn.preview)}"`,