stage-tui 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ import { useKeyboard } from "@opentui/react"
2
+ import type { Dispatch, SetStateAction } from "react"
3
+
4
+ import { COMMIT_FOCUS_ORDER, MAIN_FOCUS_ORDER, type FocusTarget, type TopAction } from "../ui/types"
5
+
6
+ type UseGitTuiKeyboardParams = {
7
+ renderer: {
8
+ destroy: () => void
9
+ getSelection?: () => { getSelectedText: () => string } | null
10
+ copyToClipboardOSC52?: (text: string) => boolean
11
+ }
12
+ commitDialogOpen: boolean
13
+ branchDialogOpen: boolean
14
+ branchDialogMode: "select" | "create" | "confirm"
15
+ historyDialogOpen: boolean
16
+ historyDialogMode: "list" | "action"
17
+ shortcutsDialogOpen: boolean
18
+ setCommitDialogOpen: Dispatch<SetStateAction<boolean>>
19
+ setFocus: Dispatch<SetStateAction<FocusTarget>>
20
+ focus: FocusTarget
21
+ fileCount: number
22
+ moveToPreviousFile: () => void
23
+ moveToNextFile: () => void
24
+ openBranchDialog: () => void
25
+ closeBranchDialog: () => void
26
+ showBranchDialogList: () => void
27
+ submitBranchSelection: () => Promise<void>
28
+ submitBranchStrategy: () => Promise<void>
29
+ openHistoryDialog: () => Promise<void>
30
+ closeHistoryDialog: () => void
31
+ backToHistoryCommitList: () => void
32
+ submitHistoryCommitSelection: () => Promise<void>
33
+ submitHistoryAction: () => Promise<void>
34
+ commitChanges: () => Promise<void>
35
+ createBranchAndCheckout: () => Promise<void>
36
+ openCommitDialog: () => void
37
+ openShortcutsDialog: () => void
38
+ closeShortcutsDialog: () => void
39
+ runTopAction: (action: TopAction) => Promise<void>
40
+ toggleSelectedFileInCommit: () => void
41
+ }
42
+
43
+ export function useGitTuiKeyboard({
44
+ renderer,
45
+ commitDialogOpen,
46
+ branchDialogOpen,
47
+ branchDialogMode,
48
+ historyDialogOpen,
49
+ historyDialogMode,
50
+ shortcutsDialogOpen,
51
+ setCommitDialogOpen,
52
+ setFocus,
53
+ focus,
54
+ fileCount,
55
+ moveToPreviousFile,
56
+ moveToNextFile,
57
+ openBranchDialog,
58
+ closeBranchDialog,
59
+ showBranchDialogList,
60
+ submitBranchSelection,
61
+ submitBranchStrategy,
62
+ openHistoryDialog,
63
+ closeHistoryDialog,
64
+ backToHistoryCommitList,
65
+ submitHistoryCommitSelection,
66
+ submitHistoryAction,
67
+ commitChanges,
68
+ createBranchAndCheckout,
69
+ openCommitDialog,
70
+ openShortcutsDialog,
71
+ closeShortcutsDialog,
72
+ runTopAction,
73
+ toggleSelectedFileInCommit,
74
+ }: UseGitTuiKeyboardParams) {
75
+ useKeyboard((key) => {
76
+ const hasNonShiftModifier = key.ctrl || key.meta || key.option || key.super || key.hyper
77
+ const isPlainShortcutKey = !hasNonShiftModifier
78
+ const isMetaCopy = (key.meta || key.super) && !key.ctrl && !key.option && !key.hyper && key.name === "c"
79
+ const isHelpKey = isPlainShortcutKey && (key.name === "?" || ((key.name === "/" || key.name === "slash") && key.shift))
80
+ const isSpaceKey = key.name === "space" || key.name === " "
81
+ const isEnter = key.name === "return" || key.name === "linefeed"
82
+ const isDialogOpen = commitDialogOpen || branchDialogOpen || historyDialogOpen || shortcutsDialogOpen
83
+
84
+ if (isMetaCopy) {
85
+ const selectedText = renderer.getSelection?.()?.getSelectedText?.()
86
+ if (selectedText && selectedText.length > 0) {
87
+ renderer.copyToClipboardOSC52?.(selectedText)
88
+ key.preventDefault()
89
+ key.stopPropagation()
90
+ return
91
+ }
92
+ }
93
+
94
+ if (!commitDialogOpen && !branchDialogOpen && !historyDialogOpen && isHelpKey) {
95
+ key.preventDefault()
96
+ key.stopPropagation()
97
+ if (shortcutsDialogOpen) {
98
+ closeShortcutsDialog()
99
+ } else {
100
+ openShortcutsDialog()
101
+ }
102
+ return
103
+ }
104
+
105
+ if (shortcutsDialogOpen) {
106
+ if (key.name === "escape") {
107
+ key.preventDefault()
108
+ key.stopPropagation()
109
+ closeShortcutsDialog()
110
+ }
111
+ return
112
+ }
113
+
114
+ if (historyDialogOpen && isEnter) {
115
+ key.preventDefault()
116
+ key.stopPropagation()
117
+ if (historyDialogMode === "action") {
118
+ void submitHistoryAction()
119
+ } else {
120
+ void submitHistoryCommitSelection()
121
+ }
122
+ return
123
+ }
124
+
125
+ if (branchDialogOpen && isEnter) {
126
+ key.preventDefault()
127
+ key.stopPropagation()
128
+ if (branchDialogMode === "create") {
129
+ void createBranchAndCheckout()
130
+ } else if (branchDialogMode === "confirm") {
131
+ void submitBranchStrategy()
132
+ } else {
133
+ void submitBranchSelection()
134
+ }
135
+ return
136
+ }
137
+
138
+ if (commitDialogOpen && isEnter) {
139
+ key.preventDefault()
140
+ key.stopPropagation()
141
+ void commitChanges()
142
+ return
143
+ }
144
+
145
+ if (key.name === "escape") {
146
+ if (historyDialogOpen) {
147
+ if (historyDialogMode === "action") {
148
+ backToHistoryCommitList()
149
+ } else {
150
+ closeHistoryDialog()
151
+ }
152
+ return
153
+ }
154
+ if (branchDialogOpen) {
155
+ if (branchDialogMode === "select") {
156
+ closeBranchDialog()
157
+ } else {
158
+ showBranchDialogList()
159
+ }
160
+ return
161
+ }
162
+ if (commitDialogOpen) {
163
+ setCommitDialogOpen(false)
164
+ setFocus("files")
165
+ return
166
+ }
167
+ renderer.destroy()
168
+ return
169
+ }
170
+
171
+ if (key.name === "tab") {
172
+ key.preventDefault()
173
+ key.stopPropagation()
174
+ if (branchDialogOpen) {
175
+ setFocus(branchDialogMode === "create" ? "branch-create" : "branch-dialog-list")
176
+ return
177
+ }
178
+ if (historyDialogOpen) {
179
+ setFocus(historyDialogMode === "action" ? "history-actions" : "history-commits")
180
+ return
181
+ }
182
+ const order = commitDialogOpen ? COMMIT_FOCUS_ORDER : MAIN_FOCUS_ORDER
183
+ setFocus((current) => {
184
+ const currentIndex = order.findIndex((item) => item === current)
185
+ if (currentIndex < 0) return order[0] ?? "files"
186
+ const nextIndex = key.shift ? (currentIndex - 1 + order.length) % order.length : (currentIndex + 1) % order.length
187
+ return order[nextIndex] ?? "files"
188
+ })
189
+ return
190
+ }
191
+
192
+ if (!isDialogOpen && isPlainShortcutKey && key.name === "c") {
193
+ key.preventDefault()
194
+ key.stopPropagation()
195
+ openCommitDialog()
196
+ return
197
+ }
198
+
199
+ if (!isDialogOpen && isPlainShortcutKey && key.name === "b") {
200
+ key.preventDefault()
201
+ key.stopPropagation()
202
+ openBranchDialog()
203
+ return
204
+ }
205
+
206
+ if (!isDialogOpen && isPlainShortcutKey && key.name === "h") {
207
+ key.preventDefault()
208
+ key.stopPropagation()
209
+ void openHistoryDialog()
210
+ return
211
+ }
212
+
213
+ if (!isDialogOpen && isPlainShortcutKey && focus === "files" && fileCount > 0 && isSpaceKey) {
214
+ key.preventDefault()
215
+ key.stopPropagation()
216
+ toggleSelectedFileInCommit()
217
+ return
218
+ }
219
+
220
+ if (!isDialogOpen && isPlainShortcutKey && focus === "files" && fileCount > 0 && key.name === "up") {
221
+ key.preventDefault()
222
+ key.stopPropagation()
223
+ moveToPreviousFile()
224
+ return
225
+ }
226
+ if (!isDialogOpen && isPlainShortcutKey && focus === "files" && fileCount > 0 && key.name === "down") {
227
+ key.preventDefault()
228
+ key.stopPropagation()
229
+ moveToNextFile()
230
+ return
231
+ }
232
+
233
+ if (!isDialogOpen && key.ctrl && key.name === "r") {
234
+ key.preventDefault()
235
+ key.stopPropagation()
236
+ void runTopAction("refresh")
237
+ return
238
+ }
239
+ if (!isDialogOpen && key.ctrl && key.name === "f") {
240
+ key.preventDefault()
241
+ key.stopPropagation()
242
+ void runTopAction("fetch")
243
+ return
244
+ }
245
+ if (!isDialogOpen && key.ctrl && key.name === "l") {
246
+ key.preventDefault()
247
+ key.stopPropagation()
248
+ void runTopAction("pull")
249
+ return
250
+ }
251
+ if (!isDialogOpen && key.ctrl && key.name === "p") {
252
+ key.preventDefault()
253
+ key.stopPropagation()
254
+ void runTopAction("push")
255
+ return
256
+ }
257
+
258
+ if (!isDialogOpen && isPlainShortcutKey && key.name === "r") {
259
+ key.preventDefault()
260
+ key.stopPropagation()
261
+ void runTopAction("refresh")
262
+ return
263
+ }
264
+ if (!isDialogOpen && isPlainShortcutKey && key.name === "f") {
265
+ key.preventDefault()
266
+ key.stopPropagation()
267
+ void runTopAction("fetch")
268
+ return
269
+ }
270
+ if (!isDialogOpen && isPlainShortcutKey && key.name === "l") {
271
+ key.preventDefault()
272
+ key.stopPropagation()
273
+ void runTopAction("pull")
274
+ return
275
+ }
276
+ if (!isDialogOpen && isPlainShortcutKey && key.name === "p") {
277
+ key.preventDefault()
278
+ key.stopPropagation()
279
+ void runTopAction("push")
280
+ return
281
+ }
282
+
283
+ if (key.ctrl && key.name === "return") {
284
+ key.preventDefault()
285
+ key.stopPropagation()
286
+ if (commitDialogOpen) {
287
+ void commitChanges()
288
+ } else {
289
+ openCommitDialog()
290
+ }
291
+ }
292
+ })
293
+ }
@@ -0,0 +1,107 @@
1
+ import type { InputRenderable, SelectOption } from "@opentui/core"
2
+ import type { RefObject } from "react"
3
+
4
+ import type { UiTheme } from "../theme"
5
+ import type { FocusTarget } from "../types"
6
+
7
+ type BranchDialogProps = {
8
+ open: boolean
9
+ mode: "select" | "create" | "confirm"
10
+ focus: FocusTarget
11
+ currentBranch: string
12
+ branchOptions: SelectOption[]
13
+ branchOptionsKey: string
14
+ branchIndex: number
15
+ onBranchChange: (index: number) => void
16
+ branchStrategyOptions: SelectOption[]
17
+ branchStrategyIndex: number
18
+ onBranchStrategyChange: (index: number) => void
19
+ branchName: string
20
+ branchNameRef: RefObject<InputRenderable | null>
21
+ onBranchNameInput: (value: string) => void
22
+ theme: UiTheme
23
+ }
24
+
25
+ export function BranchDialog({
26
+ open,
27
+ mode,
28
+ focus,
29
+ currentBranch,
30
+ branchOptions,
31
+ branchOptionsKey,
32
+ branchIndex,
33
+ onBranchChange,
34
+ branchStrategyOptions,
35
+ branchStrategyIndex,
36
+ onBranchStrategyChange,
37
+ branchName,
38
+ branchNameRef,
39
+ onBranchNameInput,
40
+ theme,
41
+ }: BranchDialogProps) {
42
+ if (!open) return null
43
+
44
+ return (
45
+ <box
46
+ style={{
47
+ width: "100%",
48
+ flexGrow: 1,
49
+ paddingLeft: 6,
50
+ paddingRight: 6,
51
+ paddingTop: 8,
52
+ paddingBottom: 4,
53
+ }}
54
+ >
55
+ <box style={{ width: "100%", maxWidth: 88, gap: 1, flexDirection: "column", flexGrow: 1 }}>
56
+ <text fg={theme.colors.title}>change branch</text>
57
+ <text fg={theme.colors.subtleText}>current: {currentBranch}</text>
58
+ {mode === "select" ? (
59
+ <>
60
+ <text fg={theme.colors.subtleText}>enter to checkout | select & enter to create branch | esc to close</text>
61
+ <select
62
+ key={branchOptionsKey}
63
+ style={{ width: "100%", height: "100%", textColor: theme.colors.selectText }}
64
+ options={branchOptions}
65
+ selectedIndex={branchIndex}
66
+ showDescription={true}
67
+ focused={focus === "branch-dialog-list"}
68
+ selectedBackgroundColor={theme.colors.selectSelectedBackground}
69
+ selectedTextColor={theme.colors.selectSelectedText}
70
+ focusedTextColor={theme.colors.selectFocusedText}
71
+ onChange={onBranchChange}
72
+ />
73
+ </>
74
+ ) : mode === "confirm" ? (
75
+ <>
76
+ <text fg={theme.colors.subtleText}>what should happen to your working changes? | enter to continue | esc to go back</text>
77
+ <select
78
+ key={`${branchOptionsKey}-strategy`}
79
+ style={{ width: "100%", height: "100%", textColor: theme.colors.selectText }}
80
+ options={branchStrategyOptions}
81
+ selectedIndex={branchStrategyIndex}
82
+ showDescription={true}
83
+ focused={focus === "branch-dialog-list"}
84
+ selectedBackgroundColor={theme.colors.selectSelectedBackground}
85
+ selectedTextColor={theme.colors.selectSelectedText}
86
+ focusedTextColor={theme.colors.selectFocusedText}
87
+ onChange={onBranchStrategyChange}
88
+ />
89
+ </>
90
+ ) : (
91
+ <>
92
+ <text fg={theme.colors.subtleText}>enter to create and checkout | invalid chars become "-" | esc to go back</text>
93
+ <input
94
+ ref={branchNameRef}
95
+ value={branchName}
96
+ onInput={onBranchNameInput}
97
+ placeholder="new branch name"
98
+ focused={focus === "branch-create"}
99
+ textColor={theme.colors.inputText}
100
+ focusedTextColor={theme.colors.inputFocusedText}
101
+ />
102
+ </>
103
+ )}
104
+ </box>
105
+ </box>
106
+ )
107
+ }
@@ -0,0 +1,68 @@
1
+ import type { InputRenderable, TextareaRenderable } from "@opentui/core"
2
+ import type { RefObject } from "react"
3
+
4
+ import type { UiTheme } from "../theme"
5
+ import type { FocusTarget } from "../types"
6
+
7
+ type CommitDialogProps = {
8
+ open: boolean
9
+ focus: FocusTarget
10
+ summary: string
11
+ descriptionRenderKey: number
12
+ summaryRef: RefObject<InputRenderable | null>
13
+ descriptionRef: RefObject<TextareaRenderable | null>
14
+ onSummaryInput: (value: string) => void
15
+ theme: UiTheme
16
+ }
17
+
18
+ export function CommitDialog({
19
+ open,
20
+ focus,
21
+ summary,
22
+ descriptionRenderKey,
23
+ summaryRef,
24
+ descriptionRef,
25
+ onSummaryInput,
26
+ theme,
27
+ }: CommitDialogProps) {
28
+ if (!open) return null
29
+
30
+ return (
31
+ <box
32
+ style={{
33
+ width: "100%",
34
+ flexGrow: 1,
35
+ paddingLeft: 6,
36
+ paddingRight: 6,
37
+ paddingTop: 4,
38
+ paddingBottom: 3,
39
+ gap: 1,
40
+ }}
41
+ >
42
+ <text fg={theme.colors.title}>commit changes</text>
43
+ <text fg={theme.colors.subtleText}>enter to commit | esc to cancel</text>
44
+ <box style={{ width: "100%", height: 3, flexDirection: "column", marginTop: 1 }}>
45
+ <input
46
+ ref={summaryRef}
47
+ value={summary}
48
+ onInput={onSummaryInput}
49
+ placeholder="summary (required)"
50
+ focused={focus === "commit-summary"}
51
+ textColor={theme.colors.inputText}
52
+ focusedTextColor={theme.colors.inputFocusedText}
53
+ />
54
+ </box>
55
+ <box style={{ width: "100%", flexGrow: 1 }}>
56
+ <textarea
57
+ key={descriptionRenderKey}
58
+ ref={descriptionRef}
59
+ initialValue=""
60
+ placeholder="description (optional)"
61
+ focused={focus === "commit-description"}
62
+ textColor={theme.colors.secondaryInputText}
63
+ focusedTextColor={theme.colors.inputText}
64
+ />
65
+ </box>
66
+ </box>
67
+ )
68
+ }
@@ -0,0 +1,87 @@
1
+ import type { SelectOption } from "@opentui/core"
2
+
3
+ import type { UiTheme } from "../theme"
4
+ import type { FocusTarget } from "../types"
5
+
6
+ type CommitHistoryDialogProps = {
7
+ open: boolean
8
+ mode: "list" | "action"
9
+ focus: FocusTarget
10
+ currentBranch: string
11
+ commitOptions: SelectOption[]
12
+ commitIndex: number
13
+ onCommitChange: (index: number) => void
14
+ actionOptions: SelectOption[]
15
+ actionIndex: number
16
+ onActionChange: (index: number) => void
17
+ selectedCommitTitle: string
18
+ theme: UiTheme
19
+ }
20
+
21
+ export function CommitHistoryDialog({
22
+ open,
23
+ mode,
24
+ focus,
25
+ currentBranch,
26
+ commitOptions,
27
+ commitIndex,
28
+ onCommitChange,
29
+ actionOptions,
30
+ actionIndex,
31
+ onActionChange,
32
+ selectedCommitTitle,
33
+ theme,
34
+ }: CommitHistoryDialogProps) {
35
+ if (!open) return null
36
+
37
+ return (
38
+ <box
39
+ style={{
40
+ width: "100%",
41
+ flexGrow: 1,
42
+ paddingLeft: 6,
43
+ paddingRight: 6,
44
+ paddingTop: 8,
45
+ paddingBottom: 4,
46
+ }}
47
+ >
48
+ <box style={{ width: "100%", maxWidth: 96, flexDirection: "column", gap: 1, flexGrow: 1 }}>
49
+ <text fg={theme.colors.title}>commit history</text>
50
+ <text fg={theme.colors.subtleText}>current branch: {currentBranch}</text>
51
+
52
+ {mode === "list" ? (
53
+ <>
54
+ <text fg={theme.colors.subtleText}>enter to choose action for selected commit | esc to close</text>
55
+ <select
56
+ style={{ width: "100%", height: "100%", textColor: theme.colors.selectText }}
57
+ options={commitOptions}
58
+ selectedIndex={commitIndex}
59
+ showDescription={true}
60
+ focused={focus === "history-commits"}
61
+ selectedBackgroundColor={theme.colors.selectSelectedBackground}
62
+ selectedTextColor={theme.colors.selectSelectedText}
63
+ focusedTextColor={theme.colors.selectFocusedText}
64
+ onChange={onCommitChange}
65
+ />
66
+ </>
67
+ ) : (
68
+ <>
69
+ <text fg={theme.colors.text}>{selectedCommitTitle}</text>
70
+ <text fg={theme.colors.subtleText}>choose action | enter confirm | esc back</text>
71
+ <select
72
+ style={{ width: "100%", height: 4, textColor: theme.colors.selectText }}
73
+ options={actionOptions}
74
+ selectedIndex={actionIndex}
75
+ showDescription={true}
76
+ focused={focus === "history-actions"}
77
+ selectedBackgroundColor={theme.colors.selectSelectedBackground}
78
+ selectedTextColor={theme.colors.selectSelectedText}
79
+ focusedTextColor={theme.colors.selectFocusedText}
80
+ onChange={onActionChange}
81
+ />
82
+ </>
83
+ )}
84
+ </box>
85
+ </box>
86
+ )
87
+ }
@@ -0,0 +1,108 @@
1
+ import type { UiTheme } from "../theme"
2
+ import type { FileRow, FocusTarget } from "../types"
3
+
4
+ type DiffWorkspaceProps = {
5
+ fileRows: FileRow[]
6
+ fileIndex: number
7
+ selectedFilePath: string | null
8
+ focus: FocusTarget
9
+ terminalWidth: number
10
+ terminalHeight: number
11
+ diffText: string
12
+ diffMessage: string | null
13
+ diffFiletype: string | undefined
14
+ diffView: "unified" | "split"
15
+ theme: UiTheme
16
+ }
17
+
18
+ export function DiffWorkspace({
19
+ fileRows,
20
+ fileIndex,
21
+ selectedFilePath,
22
+ focus,
23
+ terminalWidth,
24
+ terminalHeight,
25
+ diffText,
26
+ diffMessage,
27
+ diffFiletype,
28
+ diffView,
29
+ theme,
30
+ }: DiffWorkspaceProps) {
31
+ const visibleRows = Math.max(1, terminalHeight - 6)
32
+ const changesPaneWidth = getChangesPaneWidth(terminalWidth)
33
+ const { start, end } = getVisibleRange(fileRows.length, fileIndex, visibleRows)
34
+ const rows = fileRows.slice(start, end)
35
+
36
+ return (
37
+ <box style={{ flexDirection: "row", flexGrow: 1, gap: 1, paddingLeft: 1, paddingRight: 1 }}>
38
+ <box style={{ width: changesPaneWidth, flexDirection: "column" }}>
39
+ <text fg={theme.colors.mutedText}>changes ({fileRows.length})</text>
40
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
41
+ {rows.map((row, rowIndex) => {
42
+ const absoluteIndex = start + rowIndex
43
+ const selected = absoluteIndex === fileIndex
44
+ return (
45
+ <box key={row.path} style={{ height: 1, flexDirection: "row", ...(selected ? { backgroundColor: theme.colors.selectedRowBackground } : {}) }}>
46
+ <text fg={row.included ? theme.colors.text : theme.colors.subtleText}>{row.included ? "[x]" : "[ ]"}</text>
47
+ <text fg={theme.colors.subtleText}> </text>
48
+ <text fg={row.statusColor}>{row.statusSymbol}</text>
49
+ <text fg={theme.colors.subtleText}> </text>
50
+ {row.directory ? <text fg={theme.colors.subtleText}>{row.directory}</text> : null}
51
+ <text fg={selected || focus === "files" ? theme.colors.inputFocusedText : theme.colors.text}>{row.filename}</text>
52
+ </box>
53
+ )
54
+ })}
55
+ </box>
56
+ </box>
57
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
58
+ <text fg={theme.colors.mutedText}>{selectedFilePath ?? "no file selected"}</text>
59
+ {diffMessage ? (
60
+ <box style={{ width: "100%", height: "100%", paddingLeft: 1, paddingTop: 1 }}>
61
+ <text fg={theme.colors.subtleText}>{diffMessage}</text>
62
+ </box>
63
+ ) : (
64
+ <diff
65
+ key={selectedFilePath ?? "no-selection"}
66
+ diff={diffText}
67
+ view={diffView}
68
+ filetype={diffFiletype}
69
+ syntaxStyle={theme.diffSyntaxStyle}
70
+ showLineNumbers={true}
71
+ wrapMode="none"
72
+ lineNumberFg={theme.colors.diffLineNumber}
73
+ addedBg={theme.colors.diffAddedBackground}
74
+ removedBg={theme.colors.diffRemovedBackground}
75
+ addedContentBg={theme.colors.diffAddedBackground}
76
+ removedContentBg={theme.colors.diffRemovedBackground}
77
+ addedLineNumberBg={theme.colors.diffAddedLineNumberBackground}
78
+ removedLineNumberBg={theme.colors.diffRemovedLineNumberBackground}
79
+ fg={theme.colors.diffForeground}
80
+ style={{ width: "100%", height: "100%" }}
81
+ />
82
+ )}
83
+ </box>
84
+ </box>
85
+ )
86
+ }
87
+
88
+ function getChangesPaneWidth(terminalWidth: number): number {
89
+ if (!Number.isFinite(terminalWidth) || terminalWidth <= 0) return 42
90
+ const minimumWidth = 24
91
+ const minimumDiffWidth = 48
92
+ const preferredWidth = Math.floor(terminalWidth * 0.28)
93
+ const maxAllowedWidth = Math.max(minimumWidth, terminalWidth - minimumDiffWidth)
94
+ return clamp(preferredWidth, minimumWidth, maxAllowedWidth)
95
+ }
96
+
97
+ function clamp(value: number, min: number, max: number): number {
98
+ return Math.max(min, Math.min(max, value))
99
+ }
100
+
101
+ function getVisibleRange(total: number, selectedIndex: number, windowSize: number): { start: number; end: number } {
102
+ if (total <= 0) return { start: 0, end: 0 }
103
+ const safeSize = Math.max(windowSize, 1)
104
+ const maxStart = Math.max(total - safeSize, 0)
105
+ const centeredStart = Math.max(selectedIndex - Math.floor(safeSize / 2), 0)
106
+ const start = Math.min(centeredStart, maxStart)
107
+ return { start, end: Math.min(start + safeSize, total) }
108
+ }