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
| @@ -491,6 +491,7 @@ | ||
| 491 | 491 | ui: { Box, Text, Button, Select, Code }, |
| 492 | 492 | actions: actionsOf(host), |
| 493 | 493 | columns: e.props.bodyColumns, |
| 494 | + rows: e.props.scroll.bodyRows, | |
| 494 | 495 | }, |
| 495 | 496 | model, |
| 496 | 497 | e.props.placement, |
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | import Sections from '../sections' |
| 13 | 13 | import { fileRowOf } from './file-row-of' |
| 14 | 14 | import { listBodyOf } from './list-body-of' |
| 15 | +import { messagePaneOf } from './message-pane-of' | |
| 15 | 16 | |
| 16 | 17 | /** |
| 17 | 18 | * The pane over the repository's diff now (ReplDiffSidebarBody, one file's |
| @@ -19,9 +20,11 @@ | ||
| 19 | 20 | * |
| 20 | 21 | * The tests-and-generated toggle sits above the rows; the elision count, the |
| 21 | 22 | * 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. | |
| 23 | 26 | * |
| 24 | - * @param kit the elements, the handlers, the width | |
| 27 | + * @param kit the elements, the handlers, the width, the rows | |
| 25 | 28 | * @param model the pane's state |
| 26 | 29 | * @returns the pane's tree |
| 27 | 30 | */ |
| @@ -37,7 +40,13 @@ | ||
| 37 | 40 | } |
| 38 | 41 | |
| 39 | 42 | 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 | + }) | |
| 41 | 50 | } |
| 42 | 51 | |
| 43 | 52 | const noise = model.isNoiseShown ? 'shown' : 'hidden' |
| @@ -84,6 +93,16 @@ | ||
| 84 | 93 | const earlierRows = model.isPreSessionShown ? partition.preSession : [] |
| 85 | 94 | const isUntrackedNoted = data?.isUntrackedWithheld === true && !empty |
| 86 | 95 | 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 | + ) | |
| 87 | 106 | const detail = selected |
| 88 | 107 | ? [ |
| 89 | 108 | Sections.divider(kit), |
| @@ -104,10 +123,27 @@ | ||
| 104 | 123 | ] |
| 105 | 124 | : [] |
| 106 | 125 | |
| 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 | + | |
| 107 | 143 | return ( |
| 108 | 144 | <Box flexDirection="column"> |
| 109 | 145 | {Sections.present([ |
| 110 | - Sections.headerView(kit, empty?.headline ?? null, totals), | |
| 146 | + header, | |
| 111 | 147 | noteOf(baseLabel), |
| 112 | 148 | Sections.todoBar(kit, model.todos), |
| 113 | 149 | Sections.controlsView(kit, model), |
| @@ -115,12 +151,8 @@ | ||
| 115 | 151 | ...listed.map(line => |
| 116 | 152 | typeof line === 'string' ? noteOf(line) : rowOf(line), |
| 117 | 153 | ), |
| 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, | |
| 124 | 156 | earlierToggle, |
| 125 | 157 | ...earlierRows.map(rowOf), |
| 126 | 158 | ...detail, |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | export * from './current-pane.jsx' |
| 2 | 2 | export * from './file-row-of' |
| 3 | 3 | export * from './list-body-of' |
| 4 | +export * from './message-pane-of' | |
| 4 | 5 | |
| 5 | 6 | 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 @@ | ||
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | 5 | * 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`). | |
| 7 | 8 | */ |
| 8 | 9 | export type Kit = { |
| 9 | 10 | ui: Ui |
| 10 | 11 | actions: PaneActions |
| 11 | 12 | columns: number |
| 13 | + rows: number | |
| 12 | 14 | } |
| @@ -5,5 +5,6 @@ | ||
| 5 | 5 | export * from './sanitize-name' |
| 6 | 6 | export * from './tab-width' |
| 7 | 7 | export * from './truncate-start' |
| 8 | +export * from './wrapped-lines' | |
| 8 | 9 | |
| 9 | 10 | 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 @@ | ||
| 40 | 40 | |
| 41 | 41 | const right = Limits.PANE_RIGHT_PAD_COLUMNS |
| 42 | 42 | 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 | + } | |
| 44 | 48 | |
| 45 | 49 | return ( |
| 46 | 50 | <Box flexDirection="column" paddingTop={top} paddingRight={right}> |
| @@ -11,39 +11,32 @@ | ||
| 11 | 11 | |
| 12 | 12 | /** |
| 13 | 13 | * 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. | |
| 16 | 16 | * |
| 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 | - * | |
| 20 | 17 | * @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 | |
| 23 | 19 | * @returns the line |
| 24 | 20 | */ |
| 25 | 21 | export function headerView( |
| 26 | 22 | kit: Kit, |
| 27 | - headline: string | null, | |
| 28 | - totals: PaneState.HeaderTotals, | |
| 23 | + totals: PaneState.HeaderTotals | null, | |
| 29 | 24 | ): RenderElement { |
| 30 | 25 | 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 | + ] | |
| 43 | 36 | |
| 44 | 37 | return ( |
| 45 | 38 | <Box flexDirection="row"> |
| 46 | - {[headline === null ? counts : empty, <Box flexGrow={1} />, closeButton(kit)]} | |
| 39 | + {[...counts, <Box flexGrow={1} />, closeButton(kit)]} | |
| 47 | 40 | </Box> |
| 48 | 41 | ) |
| 49 | 42 | } |
| @@ -70,7 +70,7 @@ | ||
| 70 | 70 | return ( |
| 71 | 71 | <Box flexDirection="column"> |
| 72 | 72 | {Sections.present([ |
| 73 | - Sections.headerView(kit, null, PaneState.turnTotalsOf(turn)), | |
| 73 | + Sections.headerView(kit, PaneState.turnTotalsOf(turn)), | |
| 74 | 74 | Sections.dimNote( |
| 75 | 75 | kit, |
| 76 | 76 | `Turn ${turn.index} "${Layout.sanitizeName(turn.preview)}"`, |