@kitlangton/ghui 0.1.6 → 0.1.8

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,28 @@
1
+ export const colors = {
2
+ text: "#ede7da",
3
+ muted: "#9f9788",
4
+ separator: "#6f685d",
5
+ accent: "#f4a51c",
6
+ inlineCode: "#d7c5a1",
7
+ error: "#f97316",
8
+ selectedBg: "#1d2430",
9
+ selectedText: "#f8fafc",
10
+ count: "#d7c5a1",
11
+ status: {
12
+ draft: "#f59e0b",
13
+ approved: "#7dd3a3",
14
+ changes: "#f87171",
15
+ review: "#93c5fd",
16
+ none: "#9f9788",
17
+ passing: "#7dd3a3",
18
+ pending: "#f4a51c",
19
+ failing: "#f87171",
20
+ },
21
+ repos: {
22
+ opencode: "#60a5fa",
23
+ "effect-smol": "#34d399",
24
+ "opencode-console": "#f472b6",
25
+ opencontrol: "#f59e0b",
26
+ default: "#93c5fd",
27
+ },
28
+ } as const
package/src/ui/diff.ts ADDED
@@ -0,0 +1,220 @@
1
+ import { parseColor, SyntaxStyle } from "@opentui/core"
2
+ import type { PullRequestItem } from "../domain.js"
3
+ import { colors } from "./colors.js"
4
+
5
+ export interface DiffFilePatch {
6
+ readonly name: string
7
+ readonly filetype: string | undefined
8
+ readonly patch: string
9
+ }
10
+
11
+ export type PullRequestDiffState =
12
+ | { readonly status: "loading" }
13
+ | { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
14
+ | { readonly status: "error"; readonly error: string }
15
+
16
+ export const diffSyntaxStyle = SyntaxStyle.fromStyles({
17
+ keyword: { fg: parseColor("#f4a51c"), bold: true },
18
+ "keyword.import": { fg: parseColor("#f4a51c"), bold: true },
19
+ string: { fg: parseColor("#d7c5a1") },
20
+ comment: { fg: parseColor(colors.muted), italic: true },
21
+ number: { fg: parseColor("#93c5fd") },
22
+ boolean: { fg: parseColor("#93c5fd") },
23
+ constant: { fg: parseColor("#93c5fd") },
24
+ function: { fg: parseColor("#7dd3a3") },
25
+ "function.call": { fg: parseColor("#7dd3a3") },
26
+ constructor: { fg: parseColor("#f59e0b") },
27
+ type: { fg: parseColor("#f59e0b") },
28
+ operator: { fg: parseColor("#f87171") },
29
+ variable: { fg: parseColor(colors.text) },
30
+ property: { fg: parseColor("#93c5fd") },
31
+ bracket: { fg: parseColor(colors.text) },
32
+ punctuation: { fg: parseColor(colors.text) },
33
+ default: { fg: parseColor(colors.text) },
34
+ })
35
+
36
+ const extensionFiletypes: Record<string, string> = {
37
+ c: "c",
38
+ cc: "cpp",
39
+ cpp: "cpp",
40
+ cs: "csharp",
41
+ css: "css",
42
+ go: "go",
43
+ h: "c",
44
+ hpp: "cpp",
45
+ html: "html",
46
+ java: "java",
47
+ js: "javascript",
48
+ jsx: "javascript",
49
+ json: "json",
50
+ kt: "kotlin",
51
+ md: "markdown",
52
+ mjs: "javascript",
53
+ py: "python",
54
+ rs: "rust",
55
+ rb: "ruby",
56
+ sh: "bash",
57
+ svelte: "svelte",
58
+ toml: "toml",
59
+ ts: "typescript",
60
+ tsx: "typescript",
61
+ txt: "text",
62
+ vue: "vue",
63
+ yaml: "yaml",
64
+ yml: "yaml",
65
+ zig: "zig",
66
+ }
67
+
68
+ const filetypeForPath = (path: string) => {
69
+ const basename = path.split("/").at(-1) ?? path
70
+ if (basename === "Dockerfile") return "dockerfile"
71
+ const extension = basename.includes(".") ? basename.split(".").at(-1)?.toLowerCase() : undefined
72
+ return extension ? extensionFiletypes[extension] : undefined
73
+ }
74
+
75
+ const unquoteDiffPath = (path: string) => path.replace(/^"|"$/g, "").replace(/^a\//, "").replace(/^b\//, "")
76
+
77
+ const patchFileName = (patch: string) => {
78
+ const diffLine = patch.split("\n").find((line) => line.startsWith("diff --git "))
79
+ if (diffLine) {
80
+ const match = diffLine.match(/^diff --git\s+(\S+)\s+(\S+)/)
81
+ if (match) {
82
+ const next = unquoteDiffPath(match[2]!)
83
+ if (next !== "/dev/null") return next
84
+ return unquoteDiffPath(match[1]!)
85
+ }
86
+ }
87
+
88
+ const nextLine = patch.split("\n").find((line) => line.startsWith("+++ "))
89
+ return nextLine ? unquoteDiffPath(nextLine.slice(4).trim()) : "diff"
90
+ }
91
+
92
+ export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
93
+ const trimmed = patch.trimEnd()
94
+ if (trimmed.length === 0) return []
95
+
96
+ const matches = [...trimmed.matchAll(/^diff --git .+$/gm)]
97
+ if (matches.length === 0) {
98
+ return [{ name: "diff", filetype: undefined, patch: trimmed }]
99
+ }
100
+
101
+ return matches.map((match, index) => {
102
+ const start = match.index ?? 0
103
+ const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
104
+ const filePatch = trimmed.slice(start, end).trimEnd()
105
+ const name = patchFileName(filePatch)
106
+ return { name, filetype: filetypeForPath(name), patch: filePatch }
107
+ })
108
+ }
109
+
110
+ export const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repository}#${pullRequest.number}`
111
+
112
+ export const diffStatText = (pullRequest: PullRequestItem) => {
113
+ if (!pullRequest.detailLoaded) return "loading details"
114
+ const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
115
+ return [
116
+ pullRequest.additions > 0 ? `+${pullRequest.additions}` : null,
117
+ pullRequest.deletions > 0 ? `-${pullRequest.deletions}` : null,
118
+ files,
119
+ ].filter((part): part is string => part !== null).join(" ")
120
+ }
121
+
122
+ const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
123
+ if (wrapMode === "none") return 1
124
+ return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
125
+ }
126
+
127
+ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
128
+ let maxLineNumber = 1
129
+ let hasSigns = false
130
+ let oldLine = 0
131
+ let newLine = 0
132
+
133
+ for (const line of lines) {
134
+ const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
135
+ if (hunk) {
136
+ oldLine = Number(hunk[1])
137
+ newLine = Number(hunk[2])
138
+ maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
139
+ continue
140
+ }
141
+
142
+ const firstChar = line[0]
143
+ if (firstChar === "-") {
144
+ hasSigns = true
145
+ maxLineNumber = Math.max(maxLineNumber, oldLine)
146
+ oldLine++
147
+ } else if (firstChar === "+") {
148
+ hasSigns = true
149
+ maxLineNumber = Math.max(maxLineNumber, newLine)
150
+ newLine++
151
+ } else if (firstChar === " ") {
152
+ maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
153
+ oldLine++
154
+ newLine++
155
+ }
156
+ }
157
+
158
+ const digits = Math.floor(Math.log10(maxLineNumber)) + 1
159
+ return Math.max(3, digits + 2) + (hasSigns ? 2 : 0)
160
+ }
161
+
162
+ export const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
163
+ const lines = patch.split("\n")
164
+ const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
165
+ const splitPaneWidth = Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
166
+ const unifiedPaneWidth = Math.max(1, width - lineNumberGutterWidth)
167
+ const contentWidth = view === "split" ? splitPaneWidth : unifiedPaneWidth
168
+ let count = 0
169
+ let inHunk = false
170
+ let deletions: number[] = []
171
+ let additions: number[] = []
172
+
173
+ const flushChangeBlock = () => {
174
+ if (deletions.length === 0 && additions.length === 0) return
175
+ if (view === "split") {
176
+ const rows = Math.max(deletions.length, additions.length)
177
+ for (let index = 0; index < rows; index++) {
178
+ const deletionCount = index < deletions.length ? deletions[index]! : 1
179
+ const additionCount = index < additions.length ? additions[index]! : 1
180
+ count += Math.max(deletionCount, additionCount)
181
+ }
182
+ } else {
183
+ for (const deletion of deletions) count += deletion
184
+ for (const addition of additions) count += addition
185
+ }
186
+ deletions = []
187
+ additions = []
188
+ }
189
+
190
+ for (const line of lines) {
191
+ if (line.startsWith("@@")) {
192
+ flushChangeBlock()
193
+ inHunk = true
194
+ continue
195
+ }
196
+
197
+ if (!inHunk) continue
198
+
199
+ const firstChar = line[0]
200
+ if (firstChar === "\\") continue
201
+
202
+ if (firstChar === "-") {
203
+ deletions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
204
+ continue
205
+ }
206
+
207
+ if (firstChar === "+") {
208
+ additions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
209
+ continue
210
+ }
211
+
212
+ if (firstChar === " ") {
213
+ flushChangeBlock()
214
+ count += estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode)
215
+ }
216
+ }
217
+
218
+ flushChangeBlock()
219
+ return Math.max(1, count)
220
+ }
@@ -0,0 +1,294 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import type { PullRequestLabel, PullRequestMergeAction, PullRequestMergeInfo } from "../domain.js"
3
+ import { colors } from "./colors.js"
4
+ import { centerCell, Divider, fitCell, ModalFrame, PlainLine, TextLine } from "./primitives.js"
5
+ import { labelColor, shortRepoName } from "./pullRequests.js"
6
+
7
+ export interface LabelModalState {
8
+ readonly open: boolean
9
+ readonly repository: string | null
10
+ readonly query: string
11
+ readonly selectedIndex: number
12
+ readonly availableLabels: readonly PullRequestLabel[]
13
+ readonly loading: boolean
14
+ }
15
+
16
+ export interface MergeModalState {
17
+ readonly open: boolean
18
+ readonly repository: string | null
19
+ readonly number: number | null
20
+ readonly selectedIndex: number
21
+ readonly loading: boolean
22
+ readonly running: boolean
23
+ readonly info: PullRequestMergeInfo | null
24
+ readonly error: string | null
25
+ }
26
+
27
+ interface MergeModalOption {
28
+ readonly action: PullRequestMergeAction
29
+ readonly title: string
30
+ readonly description: string
31
+ readonly danger?: boolean
32
+ }
33
+
34
+ export const initialLabelModalState: LabelModalState = {
35
+ open: false,
36
+ repository: null,
37
+ query: "",
38
+ selectedIndex: 0,
39
+ availableLabels: [],
40
+ loading: false,
41
+ }
42
+
43
+ export const initialMergeModalState: MergeModalState = {
44
+ open: false,
45
+ repository: null,
46
+ number: null,
47
+ selectedIndex: 0,
48
+ loading: false,
49
+ running: false,
50
+ info: null,
51
+ error: null,
52
+ }
53
+
54
+ const isCleanlyMergeable = (info: PullRequestMergeInfo) =>
55
+ info.state === "open" &&
56
+ !info.isDraft &&
57
+ info.mergeable === "mergeable" &&
58
+ info.reviewStatus !== "changes" &&
59
+ info.reviewStatus !== "review" &&
60
+ info.checkStatus !== "pending" &&
61
+ info.checkStatus !== "failing"
62
+
63
+ export const mergeModalOptions = (info: PullRequestMergeInfo | null): readonly MergeModalOption[] => {
64
+ if (!info || info.state !== "open") return []
65
+ const options: MergeModalOption[] = []
66
+
67
+ if (isCleanlyMergeable(info)) {
68
+ options.push({
69
+ action: "squash",
70
+ title: "Squash merge now",
71
+ description: "Merge this pull request and delete the branch.",
72
+ })
73
+ }
74
+
75
+ if (!info.autoMergeEnabled && !info.isDraft && info.mergeable !== "conflicting") {
76
+ options.push({
77
+ action: "auto",
78
+ title: "Enable auto-merge",
79
+ description: "Squash merge automatically after GitHub requirements pass.",
80
+ })
81
+ }
82
+
83
+ if (info.autoMergeEnabled) {
84
+ options.push({
85
+ action: "disable-auto",
86
+ title: "Disable auto-merge",
87
+ description: "Cancel the pending GitHub auto-merge request.",
88
+ })
89
+ }
90
+
91
+ if (!info.isDraft && info.mergeable !== "conflicting") {
92
+ options.push({
93
+ action: "admin",
94
+ title: "Admin override merge",
95
+ description: "Bypass unmet merge requirements with --admin.",
96
+ danger: true,
97
+ })
98
+ }
99
+
100
+ return options
101
+ }
102
+
103
+ const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
104
+ if (!info) return "Loading merge status from GitHub."
105
+ if (info.state !== "open") return "This pull request is not open."
106
+ if (info.isDraft) return "Draft pull requests cannot be merged."
107
+ if (info.mergeable === "conflicting") return "This branch has merge conflicts."
108
+ return "No merge actions are currently available."
109
+ }
110
+
111
+ export const mergeActionPastTense = (action: PullRequestMergeAction) => {
112
+ if (action === "auto") return "Enabled auto-merge"
113
+ if (action === "disable-auto") return "Disabled auto-merge"
114
+ if (action === "admin") return "Admin merged"
115
+ return "Merged"
116
+ }
117
+
118
+ export const LabelModal = ({
119
+ state,
120
+ currentLabels,
121
+ modalWidth,
122
+ modalHeight,
123
+ offsetLeft,
124
+ offsetTop,
125
+ loadingIndicator,
126
+ }: {
127
+ state: LabelModalState
128
+ currentLabels: readonly PullRequestLabel[]
129
+ modalWidth: number
130
+ modalHeight: number
131
+ offsetLeft: number
132
+ offsetTop: number
133
+ loadingIndicator: string
134
+ }) => {
135
+ const innerWidth = Math.max(16, modalWidth - 2)
136
+ const contentWidth = Math.max(14, innerWidth - 2)
137
+ const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
138
+ const filtered = state.availableLabels.filter((label) =>
139
+ state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
140
+ )
141
+ const maxVisible = Math.max(1, modalHeight - 8)
142
+ const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
143
+ const scrollStart = Math.min(
144
+ Math.max(0, filtered.length - maxVisible),
145
+ Math.max(0, selectedIndex - maxVisible + 1),
146
+ )
147
+ const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
148
+ const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
149
+ const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
150
+ const headerGap = Math.max(1, contentWidth - title.length - countText.length)
151
+ const queryText = state.query.length > 0 ? state.query : "type to filter labels"
152
+ const queryPrefix = state.query.length > 0 ? "/ " : "/ "
153
+ const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
154
+
155
+ return (
156
+ <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
157
+ <box height={1} paddingLeft={1} paddingRight={1}>
158
+ <TextLine>
159
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
160
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
161
+ <span fg={colors.muted}>{countText}</span>
162
+ </TextLine>
163
+ </box>
164
+ <box height={1} paddingLeft={1} paddingRight={1}>
165
+ <TextLine>
166
+ <span fg={colors.count}>{queryPrefix}</span>
167
+ <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
168
+ </TextLine>
169
+ </box>
170
+ <Divider width={innerWidth} />
171
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
172
+ {state.loading ? (
173
+ <PlainLine text={centerCell(`${loadingIndicator} Loading labels`, contentWidth)} fg={colors.muted} />
174
+ ) : visibleLabels.length === 0 ? (
175
+ <PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", contentWidth)} fg={colors.muted} />
176
+ ) : (
177
+ visibleLabels.map((label, index) => {
178
+ const actualIndex = scrollStart + index
179
+ const isActive = currentNames.has(label.name.toLowerCase())
180
+ const isSelected = actualIndex === selectedIndex
181
+ const marker = isActive ? "✓" : " "
182
+ const nameWidth = Math.max(1, contentWidth - 5)
183
+ return (
184
+ <box key={label.name} height={1}>
185
+ <TextLine bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
186
+ <span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
187
+ <span> </span>
188
+ <span bg={labelColor(label)}> </span>
189
+ <span> {fitCell(label.name, nameWidth)}</span>
190
+ </TextLine>
191
+ </box>
192
+ )
193
+ })
194
+ )}
195
+ </box>
196
+ <box flexGrow={1} />
197
+ <Divider width={innerWidth} />
198
+ <box height={1} paddingLeft={1} paddingRight={1}>
199
+ <TextLine>
200
+ <span fg={colors.count}>↑↓</span>
201
+ <span fg={colors.muted}> move </span>
202
+ <span fg={colors.count}>esc</span>
203
+ <span fg={colors.muted}> close</span>
204
+ {filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
205
+ </TextLine>
206
+ </box>
207
+ </ModalFrame>
208
+ )
209
+ }
210
+
211
+ export const MergeModal = ({
212
+ state,
213
+ modalWidth,
214
+ modalHeight,
215
+ offsetLeft,
216
+ offsetTop,
217
+ loadingIndicator,
218
+ }: {
219
+ state: MergeModalState
220
+ modalWidth: number
221
+ modalHeight: number
222
+ offsetLeft: number
223
+ offsetTop: number
224
+ loadingIndicator: string
225
+ }) => {
226
+ const innerWidth = Math.max(16, modalWidth - 2)
227
+ const contentWidth = Math.max(14, innerWidth - 2)
228
+ const options = mergeModalOptions(state.info)
229
+ const selectedIndex = options.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, options.length - 1))
230
+ const title = state.info ? `Merge #${state.info.number}` : state.number ? `Merge #${state.number}` : "Merge"
231
+ const rightText = state.running ? "running" : state.loading ? "loading" : state.info?.autoMergeEnabled ? "auto on" : "manual"
232
+ const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
233
+ const repo = state.info?.repository ?? state.repository
234
+ const statusLine = state.info
235
+ ? `${shortRepoName(state.info.repository)} ${state.info.mergeable} ${state.info.reviewStatus} ${state.info.checkSummary ?? state.info.checkStatus}`
236
+ : repo ? shortRepoName(repo) : ""
237
+ const optionRows = Math.max(1, Math.floor((modalHeight - 9) / 2))
238
+ const visibleOptions = options.slice(0, optionRows)
239
+
240
+ return (
241
+ <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
242
+ <box height={1} paddingLeft={1} paddingRight={1}>
243
+ <TextLine>
244
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
245
+ <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
246
+ <span fg={state.running || state.loading ? colors.status.pending : colors.muted}>{rightText}</span>
247
+ </TextLine>
248
+ </box>
249
+ <box height={1} paddingLeft={1} paddingRight={1}>
250
+ <PlainLine text={fitCell(statusLine, contentWidth)} fg={colors.muted} />
251
+ </box>
252
+ <Divider width={innerWidth} />
253
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
254
+ {state.loading ? (
255
+ <PlainLine text={centerCell(`${loadingIndicator} Loading merge status`, contentWidth)} fg={colors.muted} />
256
+ ) : state.error ? (
257
+ <PlainLine text={centerCell(state.error, contentWidth)} fg={colors.error} />
258
+ ) : visibleOptions.length === 0 ? (
259
+ <PlainLine text={centerCell(mergeUnavailableReason(state.info), contentWidth)} fg={colors.muted} />
260
+ ) : (
261
+ visibleOptions.map((option, index) => {
262
+ const isSelected = index === selectedIndex
263
+ const titleColor = option.danger ? colors.error : isSelected ? colors.selectedText : colors.text
264
+ const titleWidth = Math.max(1, contentWidth - 1)
265
+ const descriptionWidth = Math.max(1, contentWidth - 1)
266
+
267
+ return (
268
+ <box key={option.action} height={2} flexDirection="column">
269
+ <TextLine bg={isSelected ? colors.selectedBg : undefined}>
270
+ <span fg={titleColor}> {fitCell(option.title, titleWidth)}</span>
271
+ </TextLine>
272
+ <TextLine bg={isSelected ? colors.selectedBg : undefined}>
273
+ <span fg={colors.muted}> {fitCell(option.description, descriptionWidth)}</span>
274
+ </TextLine>
275
+ </box>
276
+ )
277
+ })
278
+ )}
279
+ </box>
280
+ <box flexGrow={1} />
281
+ <Divider width={innerWidth} />
282
+ <box height={1} paddingLeft={1} paddingRight={1}>
283
+ <TextLine>
284
+ <span fg={colors.count}>↑↓</span>
285
+ <span fg={colors.muted}> move </span>
286
+ <span fg={colors.count}>enter</span>
287
+ <span fg={colors.muted}> confirm </span>
288
+ <span fg={colors.count}>esc</span>
289
+ <span fg={colors.muted}> close</span>
290
+ </TextLine>
291
+ </box>
292
+ </ModalFrame>
293
+ )
294
+ }
@@ -0,0 +1,111 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import type React from "react"
3
+ import { colors } from "./colors.js"
4
+
5
+ export const fitCell = (text: string, width: number, align: "left" | "right" = "left") => {
6
+ const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
7
+ return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
8
+ }
9
+
10
+ export const trimCell = (text: string, width: number) => text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
11
+
12
+ export const centerCell = (text: string, width: number) => {
13
+ const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
14
+ const left = Math.floor((width - trimmed.length) / 2)
15
+ return `${" ".repeat(Math.max(0, left))}${trimmed}`.padEnd(width, " ")
16
+ }
17
+
18
+ export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: string; fg?: string; bold?: boolean }) => (
19
+ <box height={1}>
20
+ {bold ? (
21
+ <text wrapMode="none" truncate fg={fg} attributes={TextAttributes.BOLD}>
22
+ {text}
23
+ </text>
24
+ ) : (
25
+ <text wrapMode="none" truncate fg={fg}>
26
+ {text}
27
+ </text>
28
+ )}
29
+ </box>
30
+ )
31
+
32
+ export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
33
+ <box height={1}>
34
+ {bg ? (
35
+ <text wrapMode="none" truncate fg={fg} bg={bg}>
36
+ {children}
37
+ </text>
38
+ ) : (
39
+ <text wrapMode="none" truncate fg={fg}>
40
+ {children}
41
+ </text>
42
+ )}
43
+ </box>
44
+ )
45
+
46
+ export const SectionTitle = ({ title }: { title: string }) => (
47
+ <TextLine>
48
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>
49
+ {title}
50
+ </span>
51
+ </TextLine>
52
+ )
53
+
54
+ export const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
55
+ if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
56
+ return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
57
+ }
58
+
59
+ return <PlainLine text={`${"─".repeat(junctionAt)}${junctionChar}${"─".repeat(Math.max(0, width - junctionAt - 1))}`} fg={colors.separator} />
60
+ }
61
+
62
+ export const SeparatorColumn = ({ height, junctionRows }: { height: number; junctionRows?: readonly number[] }) => {
63
+ const junctions = new Set(junctionRows)
64
+ return (
65
+ <box width={1} height={height} flexDirection="column">
66
+ {Array.from({ length: height }, (_, index) => (
67
+ <PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />
68
+ ))}
69
+ </box>
70
+ )
71
+ }
72
+
73
+ export const ModalFrame = ({
74
+ children,
75
+ left,
76
+ top,
77
+ width,
78
+ height,
79
+ junctionRows = [],
80
+ backgroundColor = "#1a1a2e",
81
+ }: {
82
+ children: React.ReactNode
83
+ left: number
84
+ top: number
85
+ width: number
86
+ height: number
87
+ junctionRows?: readonly number[]
88
+ backgroundColor?: string
89
+ }) => {
90
+ const innerWidth = Math.max(1, width - 2)
91
+ const innerHeight = Math.max(1, height - 2)
92
+ const junctions = new Set(junctionRows)
93
+
94
+ return (
95
+ <box position="absolute" left={left} top={top} width={width} height={height} flexDirection="column" backgroundColor={backgroundColor}>
96
+ <PlainLine text={`┌${"─".repeat(innerWidth)}┐`} fg={colors.separator} />
97
+ <box height={innerHeight} flexDirection="row">
98
+ <box width={1} height={innerHeight} flexDirection="column">
99
+ {Array.from({ length: innerHeight }, (_, index) => <PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />)}
100
+ </box>
101
+ <box width={innerWidth} height={innerHeight} flexDirection="column">
102
+ {children}
103
+ </box>
104
+ <box width={1} height={innerHeight} flexDirection="column">
105
+ {Array.from({ length: innerHeight }, (_, index) => <PlainLine key={index} text={junctions.has(index) ? "┤" : "│"} fg={colors.separator} />)}
106
+ </box>
107
+ </box>
108
+ <PlainLine text={`└${"─".repeat(innerWidth)}┘`} fg={colors.separator} />
109
+ </box>
110
+ )
111
+ }