@kitlangton/ghui 0.1.10 → 0.1.12
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.
- package/package.json +1 -1
- package/src/App.tsx +160 -32
- package/src/ui/FooterHints.tsx +6 -21
- package/src/ui/PullRequestDiffPane.tsx +12 -9
- package/src/ui/colors.ts +159 -2
- package/src/ui/diff.ts +49 -14
- package/src/ui/modals.tsx +95 -1
- package/src/ui/primitives.tsx +1 -1
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -11,12 +11,12 @@ import { formatShortDate, formatTimestamp } from "./date.js"
|
|
|
11
11
|
import { availableMergeActions, mergeInfoFromPullRequest } from "./mergeActions.js"
|
|
12
12
|
import { Observability } from "./observability.js"
|
|
13
13
|
import { GitHubService } from "./services/GitHubService.js"
|
|
14
|
-
import { colors } from "./ui/colors.js"
|
|
14
|
+
import { colors, setActiveTheme, themeDefinitions, type ThemeId } from "./ui/colors.js"
|
|
15
15
|
import { pullRequestDiffKey, splitPatchFiles, type PullRequestDiffState } from "./ui/diff.js"
|
|
16
16
|
import { DetailBody, DetailHeader, DetailPlaceholder, DetailsPane, getDetailBodyHeight, getDetailHeaderHeight, getDetailJunctionRows, getDetailsPaneHeight, LoadingPane, type DetailPlaceholderContent } from "./ui/DetailsPane.js"
|
|
17
17
|
import { FooterHints, type RetryProgress } from "./ui/FooterHints.js"
|
|
18
18
|
import { Divider, fitCell, PlainLine, SeparatorColumn } from "./ui/primitives.js"
|
|
19
|
-
import { initialLabelModalState, initialMergeModalState, LabelModal, MergeModal } from "./ui/modals.js"
|
|
19
|
+
import { initialLabelModalState, initialMergeModalState, initialThemeModalState, LabelModal, MergeModal, ThemeModal } from "./ui/modals.js"
|
|
20
20
|
import { groupBy, reviewLabel } from "./ui/pullRequests.js"
|
|
21
21
|
import { PullRequestDiffPane } from "./ui/PullRequestDiffPane.js"
|
|
22
22
|
import { PullRequestList } from "./ui/PullRequestList.js"
|
|
@@ -81,6 +81,8 @@ const pullRequestDiffCacheAtom = Atom.make<Record<string, PullRequestDiffState>>
|
|
|
81
81
|
|
|
82
82
|
const labelModalAtom = Atom.make(initialLabelModalState).pipe(Atom.keepAlive)
|
|
83
83
|
const mergeModalAtom = Atom.make(initialMergeModalState).pipe(Atom.keepAlive)
|
|
84
|
+
const themeIdAtom = Atom.make<ThemeId>("ghui").pipe(Atom.keepAlive)
|
|
85
|
+
const themeModalAtom = Atom.make(initialThemeModalState).pipe(Atom.keepAlive)
|
|
84
86
|
const labelCacheAtom = Atom.make<Record<string, readonly PullRequestLabel[]>>({}).pipe(Atom.keepAlive)
|
|
85
87
|
const pullRequestOverridesAtom = Atom.make<Record<string, PullRequestItem>>({}).pipe(Atom.keepAlive)
|
|
86
88
|
const usernameAtom = githubRuntime.atom(
|
|
@@ -118,6 +120,64 @@ const deleteLastWord = (value: string) => value.replace(/\s*\S+\s*$/, "")
|
|
|
118
120
|
|
|
119
121
|
const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error)
|
|
120
122
|
|
|
123
|
+
const clipboardCommands = (): readonly (readonly string[])[] => {
|
|
124
|
+
if (process.platform === "darwin") return [["pbcopy"]]
|
|
125
|
+
if (process.platform === "linux") {
|
|
126
|
+
return [
|
|
127
|
+
...(process.env.WAYLAND_DISPLAY ? [["wl-copy"]] : []),
|
|
128
|
+
["xclip", "-selection", "clipboard"],
|
|
129
|
+
["xsel", "--clipboard", "--input"],
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
return []
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const copyToClipboard = async (text: string) => {
|
|
136
|
+
const commands = clipboardCommands()
|
|
137
|
+
let lastError = ""
|
|
138
|
+
|
|
139
|
+
for (const command of commands) {
|
|
140
|
+
let proc: Bun.Subprocess<"pipe", "ignore", "pipe">
|
|
141
|
+
try {
|
|
142
|
+
proc = Bun.spawn({
|
|
143
|
+
cmd: [...command],
|
|
144
|
+
stdin: "pipe",
|
|
145
|
+
stdout: "ignore",
|
|
146
|
+
stderr: "pipe",
|
|
147
|
+
})
|
|
148
|
+
} catch (error) {
|
|
149
|
+
lastError = errorMessage(error)
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
proc.stdin.write(text)
|
|
154
|
+
proc.stdin.end()
|
|
155
|
+
|
|
156
|
+
const exitCode = await proc.exited
|
|
157
|
+
if (exitCode === 0) return
|
|
158
|
+
|
|
159
|
+
const stderr = await Bun.readableStreamToText(proc.stderr)
|
|
160
|
+
lastError = stderr.trim()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const installHint = process.platform === "linux" ? " Install wl-clipboard, xclip, or xsel." : ""
|
|
164
|
+
throw new Error(lastError || `Clipboard is not available.${installHint}`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const openPullRequestInBrowser = async (pullRequest: PullRequestItem) => {
|
|
168
|
+
const proc = Bun.spawn({
|
|
169
|
+
cmd: ["gh", "pr", "view", String(pullRequest.number), "--repo", pullRequest.repository, "--web"],
|
|
170
|
+
stdout: "ignore",
|
|
171
|
+
stderr: "pipe",
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const exitCode = await proc.exited
|
|
175
|
+
if (exitCode === 0) return
|
|
176
|
+
|
|
177
|
+
const stderr = await Bun.readableStreamToText(proc.stderr)
|
|
178
|
+
throw new Error(stderr.trim() || "Could not open PR in browser")
|
|
179
|
+
}
|
|
180
|
+
|
|
121
181
|
const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
|
|
122
182
|
const lines = [
|
|
123
183
|
pullRequest.title,
|
|
@@ -133,29 +193,13 @@ const copyPullRequestMetadata = async (pullRequest: PullRequestItem) => {
|
|
|
133
193
|
lines.push(pullRequest.checkSummary)
|
|
134
194
|
}
|
|
135
195
|
|
|
136
|
-
|
|
137
|
-
cmd: ["pbcopy"],
|
|
138
|
-
stdin: "pipe",
|
|
139
|
-
stdout: "ignore",
|
|
140
|
-
stderr: "pipe",
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
if (!proc.stdin) {
|
|
144
|
-
throw new Error("Clipboard is not available")
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
proc.stdin.write(lines.join("\n"))
|
|
148
|
-
proc.stdin.end()
|
|
149
|
-
|
|
150
|
-
const exitCode = await proc.exited
|
|
151
|
-
if (exitCode !== 0) {
|
|
152
|
-
const stderr = await Bun.readableStreamToText(proc.stderr)
|
|
153
|
-
throw new Error(stderr.trim() || "Could not copy PR metadata")
|
|
154
|
-
}
|
|
196
|
+
await copyToClipboard(lines.join("\n"))
|
|
155
197
|
}
|
|
156
198
|
|
|
157
199
|
const isShiftG = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "G" || key.name === "g" && key.shift
|
|
158
200
|
|
|
201
|
+
const isShiftT = (key: { readonly name: string; readonly shift?: boolean }) => key.name === "T" || key.name === "t" && key.shift
|
|
202
|
+
|
|
159
203
|
const getDetailPlaceholderContent = ({
|
|
160
204
|
status,
|
|
161
205
|
retryProgress,
|
|
@@ -217,6 +261,9 @@ export const App = () => {
|
|
|
217
261
|
const [pullRequestDiffCache, setPullRequestDiffCache] = useAtom(pullRequestDiffCacheAtom)
|
|
218
262
|
const [labelModal, setLabelModal] = useAtom(labelModalAtom)
|
|
219
263
|
const [mergeModal, setMergeModal] = useAtom(mergeModalAtom)
|
|
264
|
+
const [themeId, setThemeId] = useAtom(themeIdAtom)
|
|
265
|
+
const [themeModal, setThemeModal] = useAtom(themeModalAtom)
|
|
266
|
+
setActiveTheme(themeId)
|
|
220
267
|
const [labelCache, setLabelCache] = useAtom(labelCacheAtom)
|
|
221
268
|
const [pullRequestOverrides, setPullRequestOverrides] = useAtom(pullRequestOverridesAtom)
|
|
222
269
|
const retryProgress = useAtomValue(retryProgressAtom)
|
|
@@ -266,6 +313,10 @@ export const App = () => {
|
|
|
266
313
|
}, 2500)
|
|
267
314
|
}
|
|
268
315
|
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
renderer.setBackgroundColor(colors.background)
|
|
318
|
+
}, [renderer, themeId])
|
|
319
|
+
|
|
269
320
|
useEffect(() => () => {
|
|
270
321
|
if (noticeTimeoutRef.current !== null) {
|
|
271
322
|
clearTimeout(noticeTimeoutRef.current)
|
|
@@ -495,9 +546,44 @@ export const App = () => {
|
|
|
495
546
|
loadPullRequestDiff(selectedPullRequest)
|
|
496
547
|
}
|
|
497
548
|
|
|
549
|
+
const openSelectedPullRequestInBrowser = (pullRequest: PullRequestItem) => {
|
|
550
|
+
void openPullRequestInBrowser(pullRequest)
|
|
551
|
+
.then(() => flashNotice(`Opened #${pullRequest.number} in browser`))
|
|
552
|
+
.catch((error) => flashNotice(errorMessage(error)))
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const openThemeModal = () => {
|
|
556
|
+
setLabelModal(initialLabelModalState)
|
|
557
|
+
setMergeModal(initialMergeModalState)
|
|
558
|
+
setThemeModal({
|
|
559
|
+
open: true,
|
|
560
|
+
selectedIndex: Math.max(0, themeDefinitions.findIndex((theme) => theme.id === themeId)),
|
|
561
|
+
initialThemeId: themeId,
|
|
562
|
+
})
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const closeThemeModal = (confirm: boolean) => {
|
|
566
|
+
const selectedTheme = themeDefinitions[themeModal.selectedIndex]
|
|
567
|
+
if (!confirm) {
|
|
568
|
+
setThemeId(themeModal.initialThemeId)
|
|
569
|
+
} else if (selectedTheme) {
|
|
570
|
+
flashNotice(`Theme: ${selectedTheme.name}`)
|
|
571
|
+
}
|
|
572
|
+
setThemeModal(initialThemeModalState)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const moveThemeSelection = (delta: number) => {
|
|
576
|
+
const selectedIndex = Math.max(0, Math.min(themeDefinitions.length - 1, themeModal.selectedIndex + delta))
|
|
577
|
+
if (selectedIndex === themeModal.selectedIndex) return
|
|
578
|
+
const theme = themeDefinitions[selectedIndex]
|
|
579
|
+
if (theme && theme.id !== themeId) setThemeId(theme.id)
|
|
580
|
+
setThemeModal((current) => ({ ...current, selectedIndex }))
|
|
581
|
+
}
|
|
582
|
+
|
|
498
583
|
const openLabelModal = () => {
|
|
499
584
|
if (!selectedPullRequest) return
|
|
500
585
|
setMergeModal(initialMergeModalState)
|
|
586
|
+
setThemeModal(initialThemeModalState)
|
|
501
587
|
const repository = selectedPullRequest.repository
|
|
502
588
|
const cachedLabels = labelCache[repository]
|
|
503
589
|
if (cachedLabels) {
|
|
@@ -526,6 +612,7 @@ export const App = () => {
|
|
|
526
612
|
|
|
527
613
|
const openMergeModal = () => {
|
|
528
614
|
if (!selectedPullRequest) return
|
|
615
|
+
setThemeModal(initialThemeModalState)
|
|
529
616
|
const repository = selectedPullRequest.repository
|
|
530
617
|
const number = selectedPullRequest.number
|
|
531
618
|
const seededInfo = mergeInfoFromPullRequest(selectedPullRequest)
|
|
@@ -627,6 +714,10 @@ export const App = () => {
|
|
|
627
714
|
|
|
628
715
|
useKeyboard((key) => {
|
|
629
716
|
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
717
|
+
if (themeModal.open) {
|
|
718
|
+
closeThemeModal(false)
|
|
719
|
+
return
|
|
720
|
+
}
|
|
630
721
|
if (mergeModal.open) {
|
|
631
722
|
setMergeModal(initialMergeModalState)
|
|
632
723
|
return
|
|
@@ -639,6 +730,26 @@ export const App = () => {
|
|
|
639
730
|
return
|
|
640
731
|
}
|
|
641
732
|
|
|
733
|
+
if (themeModal.open) {
|
|
734
|
+
if (key.name === "escape") {
|
|
735
|
+
closeThemeModal(false)
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
if (key.name === "return" || key.name === "enter") {
|
|
739
|
+
closeThemeModal(true)
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
if (key.name === "up" || key.name === "k") {
|
|
743
|
+
moveThemeSelection(-1)
|
|
744
|
+
return
|
|
745
|
+
}
|
|
746
|
+
if (key.name === "down" || key.name === "j") {
|
|
747
|
+
moveThemeSelection(1)
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
return
|
|
751
|
+
}
|
|
752
|
+
|
|
642
753
|
if (mergeModal.open) {
|
|
643
754
|
const options = availableMergeActions(mergeModal.info)
|
|
644
755
|
if (key.name === "escape") {
|
|
@@ -803,8 +914,7 @@ export const App = () => {
|
|
|
803
914
|
return
|
|
804
915
|
}
|
|
805
916
|
if (key.name === "o" && selectedPullRequest) {
|
|
806
|
-
|
|
807
|
-
flashNotice(`Opened #${selectedPullRequest.number} in browser`)
|
|
917
|
+
openSelectedPullRequestInBrowser(selectedPullRequest)
|
|
808
918
|
return
|
|
809
919
|
}
|
|
810
920
|
return
|
|
@@ -881,8 +991,7 @@ export const App = () => {
|
|
|
881
991
|
return
|
|
882
992
|
}
|
|
883
993
|
if (key.name === "o" && selectedPullRequest) {
|
|
884
|
-
|
|
885
|
-
flashNotice(`Opened #${selectedPullRequest.number} in browser`)
|
|
994
|
+
openSelectedPullRequestInBrowser(selectedPullRequest)
|
|
886
995
|
return
|
|
887
996
|
}
|
|
888
997
|
if (key.name === "y" && selectedPullRequest) {
|
|
@@ -923,6 +1032,11 @@ export const App = () => {
|
|
|
923
1032
|
}
|
|
924
1033
|
}
|
|
925
1034
|
|
|
1035
|
+
if (isShiftT(key)) {
|
|
1036
|
+
openThemeModal()
|
|
1037
|
+
return
|
|
1038
|
+
}
|
|
1039
|
+
|
|
926
1040
|
if (key.name === "/") {
|
|
927
1041
|
setFilterDraft(filterQuery)
|
|
928
1042
|
setFilterMode(true)
|
|
@@ -1024,7 +1138,7 @@ export const App = () => {
|
|
|
1024
1138
|
setDetailScrollOffset(0)
|
|
1025
1139
|
return
|
|
1026
1140
|
}
|
|
1027
|
-
if (key.name === "p" && selectedPullRequest) {
|
|
1141
|
+
if ((key.name === "d" || key.name === "p") && selectedPullRequest) {
|
|
1028
1142
|
openDiffView()
|
|
1029
1143
|
return
|
|
1030
1144
|
}
|
|
@@ -1037,11 +1151,10 @@ export const App = () => {
|
|
|
1037
1151
|
return
|
|
1038
1152
|
}
|
|
1039
1153
|
if (key.name === "o" && selectedPullRequest) {
|
|
1040
|
-
|
|
1041
|
-
flashNotice(`Opened #${selectedPullRequest.number} in browser`)
|
|
1154
|
+
openSelectedPullRequestInBrowser(selectedPullRequest)
|
|
1042
1155
|
return
|
|
1043
1156
|
}
|
|
1044
|
-
if ((key.name === "
|
|
1157
|
+
if ((key.name === "s" || key.name === "S") && selectedPullRequest) {
|
|
1045
1158
|
const previousPullRequest = selectedPullRequest
|
|
1046
1159
|
const nextReviewStatus = selectedPullRequest.reviewStatus === "draft" ? "review" : "draft"
|
|
1047
1160
|
updatePullRequest(selectedPullRequest.url, (pullRequest) => ({
|
|
@@ -1108,10 +1221,14 @@ export const App = () => {
|
|
|
1108
1221
|
const mergeModalHeight = Math.min(16, (height ?? 24) - 4)
|
|
1109
1222
|
const mergeModalLeft = Math.floor((contentWidth - mergeModalWidth) / 2)
|
|
1110
1223
|
const mergeModalTop = Math.floor(((height ?? 24) - mergeModalHeight) / 2)
|
|
1224
|
+
const themeModalWidth = Math.min(58, Math.max(38, contentWidth - 12))
|
|
1225
|
+
const themeModalHeight = Math.min(10, (height ?? 24) - 4)
|
|
1226
|
+
const themeModalLeft = Math.floor((contentWidth - themeModalWidth) / 2)
|
|
1227
|
+
const themeModalTop = Math.floor(((height ?? 24) - themeModalHeight) / 2)
|
|
1111
1228
|
|
|
1112
1229
|
return (
|
|
1113
|
-
<box flexGrow={1} flexDirection="column">
|
|
1114
|
-
<box paddingLeft={1} paddingRight={1} flexDirection="column">
|
|
1230
|
+
<box flexGrow={1} flexDirection="column" backgroundColor={colors.background}>
|
|
1231
|
+
<box paddingLeft={1} paddingRight={1} flexDirection="column" backgroundColor={colors.panel}>
|
|
1115
1232
|
<PlainLine text={headerLine} fg={colors.muted} bold />
|
|
1116
1233
|
</box>
|
|
1117
1234
|
{isWideLayout && !detailFullView && !diffFullView && !isInitialLoading ? (
|
|
@@ -1132,6 +1249,7 @@ export const App = () => {
|
|
|
1132
1249
|
height={wideBodyHeight}
|
|
1133
1250
|
loadingIndicator={loadingIndicator}
|
|
1134
1251
|
scrollRef={diffScrollRef}
|
|
1252
|
+
themeId={themeId}
|
|
1135
1253
|
/>
|
|
1136
1254
|
) : isWideLayout && detailFullView ? (
|
|
1137
1255
|
<box flexGrow={1} flexDirection="column">
|
|
@@ -1200,7 +1318,7 @@ export const App = () => {
|
|
|
1200
1318
|
) : (
|
|
1201
1319
|
<Divider width={contentWidth} />
|
|
1202
1320
|
)}
|
|
1203
|
-
<box paddingLeft={1} paddingRight={1}>
|
|
1321
|
+
<box paddingLeft={1} paddingRight={1} backgroundColor={colors.panel}>
|
|
1204
1322
|
{footerNotice ? (
|
|
1205
1323
|
<PlainLine text={footerNotice} fg={colors.count} />
|
|
1206
1324
|
) : (
|
|
@@ -1238,6 +1356,16 @@ export const App = () => {
|
|
|
1238
1356
|
loadingIndicator={loadingIndicator}
|
|
1239
1357
|
/>
|
|
1240
1358
|
) : null}
|
|
1359
|
+
{themeModal.open ? (
|
|
1360
|
+
<ThemeModal
|
|
1361
|
+
state={themeModal}
|
|
1362
|
+
activeThemeId={themeId}
|
|
1363
|
+
modalWidth={themeModalWidth}
|
|
1364
|
+
modalHeight={themeModalHeight}
|
|
1365
|
+
offsetLeft={themeModalLeft}
|
|
1366
|
+
offsetTop={themeModalTop}
|
|
1367
|
+
/>
|
|
1368
|
+
) : null}
|
|
1241
1369
|
</box>
|
|
1242
1370
|
)
|
|
1243
1371
|
}
|
package/src/ui/FooterHints.tsx
CHANGED
|
@@ -86,6 +86,8 @@ export const FooterHints = ({
|
|
|
86
86
|
<TextLine>
|
|
87
87
|
<span fg={colors.count}>/</span>
|
|
88
88
|
<span fg={colors.muted}> filter </span>
|
|
89
|
+
<span fg={colors.count}>T</span>
|
|
90
|
+
<span fg={colors.muted}> theme </span>
|
|
89
91
|
{showFilterClear ? (
|
|
90
92
|
<>
|
|
91
93
|
<span fg={colors.count}>esc</span>
|
|
@@ -104,33 +106,16 @@ export const FooterHints = ({
|
|
|
104
106
|
</>
|
|
105
107
|
) : null}
|
|
106
108
|
<span fg={colors.count}>r</span>
|
|
107
|
-
<span fg={colors.muted}>{hasError ? " retry " : "
|
|
108
|
-
{hasSelection ? (
|
|
109
|
-
<>
|
|
110
|
-
<span fg={colors.count}>↑↓</span>
|
|
111
|
-
<span fg={colors.muted}> move </span>
|
|
112
|
-
</>
|
|
113
|
-
) : null}
|
|
114
|
-
{hasSelection && detailFullView ? (
|
|
115
|
-
<>
|
|
116
|
-
<span fg={colors.count}>esc</span>
|
|
117
|
-
<span fg={colors.muted}> back </span>
|
|
118
|
-
</>
|
|
119
|
-
) : hasSelection ? (
|
|
120
|
-
<>
|
|
121
|
-
<span fg={colors.count}>enter</span>
|
|
122
|
-
<span fg={colors.muted}> expand </span>
|
|
123
|
-
</>
|
|
124
|
-
) : null}
|
|
109
|
+
<span fg={colors.muted}>{hasError ? " retry " : " refresh "}</span>
|
|
125
110
|
{hasSelection ? (
|
|
126
111
|
<>
|
|
112
|
+
<span fg={colors.count}>s</span>
|
|
113
|
+
<span fg={colors.muted}> state </span>
|
|
127
114
|
<span fg={colors.count}>d</span>
|
|
128
|
-
<span fg={colors.muted}> draft </span>
|
|
129
|
-
<span fg={colors.count}>p</span>
|
|
130
115
|
<span fg={colors.muted}> diff </span>
|
|
131
116
|
<span fg={colors.count}>l</span>
|
|
132
117
|
<span fg={colors.muted}> labels </span>
|
|
133
|
-
<span fg={colors.count}>
|
|
118
|
+
<span fg={colors.count}>m</span>
|
|
134
119
|
<span fg={colors.muted}> merge </span>
|
|
135
120
|
<span fg={colors.count}>o</span>
|
|
136
121
|
<span fg={colors.muted}> open </span>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
2
|
import { useMemo, type Ref } from "react"
|
|
3
3
|
import type { PullRequestItem } from "../domain.js"
|
|
4
|
-
import { colors } from "./colors.js"
|
|
5
|
-
import {
|
|
4
|
+
import { colors, type ThemeId } from "./colors.js"
|
|
5
|
+
import { createDiffSyntaxStyle, diffStatText, patchRenderableLineCount, type PullRequestDiffState } from "./diff.js"
|
|
6
6
|
import { LoadingPane, StatusCard } from "./DetailsPane.js"
|
|
7
7
|
import { Divider, fitCell, PlainLine, TextLine } from "./primitives.js"
|
|
8
8
|
import { shortRepoName } from "./pullRequests.js"
|
|
@@ -37,6 +37,7 @@ export const PullRequestDiffPane = ({
|
|
|
37
37
|
height,
|
|
38
38
|
loadingIndicator,
|
|
39
39
|
scrollRef,
|
|
40
|
+
themeId,
|
|
40
41
|
}: {
|
|
41
42
|
pullRequest: PullRequestItem | null
|
|
42
43
|
diffState: PullRequestDiffState | undefined
|
|
@@ -47,6 +48,7 @@ export const PullRequestDiffPane = ({
|
|
|
47
48
|
height: number
|
|
48
49
|
loadingIndicator: string
|
|
49
50
|
scrollRef: Ref<ScrollBoxRenderable>
|
|
51
|
+
themeId: ThemeId
|
|
50
52
|
}) => {
|
|
51
53
|
const readyFiles = diffState?.status === "ready" ? diffState.files : []
|
|
52
54
|
const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
|
|
@@ -55,6 +57,7 @@ export const PullRequestDiffPane = ({
|
|
|
55
57
|
() => file ? patchRenderableLineCount(file.patch, view, wrapMode, paneWidth) : 1,
|
|
56
58
|
[file?.patch, view, wrapMode, paneWidth],
|
|
57
59
|
)
|
|
60
|
+
const syntaxStyle = useMemo(() => createDiffSyntaxStyle(), [themeId])
|
|
58
61
|
|
|
59
62
|
if (!pullRequest) {
|
|
60
63
|
return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
|
|
@@ -125,18 +128,18 @@ export const PullRequestDiffPane = ({
|
|
|
125
128
|
view={view}
|
|
126
129
|
syncScroll
|
|
127
130
|
filetype={file.filetype ?? "text"}
|
|
128
|
-
syntaxStyle={
|
|
131
|
+
syntaxStyle={syntaxStyle}
|
|
129
132
|
showLineNumbers
|
|
130
133
|
wrapMode={wrapMode}
|
|
131
|
-
addedBg=
|
|
132
|
-
removedBg=
|
|
133
|
-
contextBg=
|
|
134
|
+
addedBg={colors.diff.addedBg}
|
|
135
|
+
removedBg={colors.diff.removedBg}
|
|
136
|
+
contextBg={colors.diff.contextBg}
|
|
134
137
|
addedSignColor={colors.status.passing}
|
|
135
138
|
removedSignColor={colors.status.failing}
|
|
136
139
|
lineNumberFg={colors.muted}
|
|
137
|
-
lineNumberBg=
|
|
138
|
-
addedLineNumberBg=
|
|
139
|
-
removedLineNumberBg=
|
|
140
|
+
lineNumberBg={colors.diff.lineNumberBg}
|
|
141
|
+
addedLineNumberBg={colors.diff.addedLineNumberBg}
|
|
142
|
+
removedLineNumberBg={colors.diff.removedLineNumberBg}
|
|
140
143
|
selectionBg={colors.selectedBg}
|
|
141
144
|
selectionFg={colors.selectedText}
|
|
142
145
|
height={diffHeight}
|
package/src/ui/colors.ts
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
|
-
export
|
|
1
|
+
export type ThemeId = "ghui" | "tokyo-night" | "opencode"
|
|
2
|
+
|
|
3
|
+
export interface ColorPalette {
|
|
4
|
+
readonly background: string
|
|
5
|
+
readonly panel: string
|
|
6
|
+
readonly modalBackground: string
|
|
7
|
+
readonly text: string
|
|
8
|
+
readonly muted: string
|
|
9
|
+
readonly separator: string
|
|
10
|
+
readonly accent: string
|
|
11
|
+
readonly inlineCode: string
|
|
12
|
+
readonly error: string
|
|
13
|
+
readonly selectedBg: string
|
|
14
|
+
readonly selectedText: string
|
|
15
|
+
readonly count: string
|
|
16
|
+
readonly status: {
|
|
17
|
+
readonly draft: string
|
|
18
|
+
readonly approved: string
|
|
19
|
+
readonly changes: string
|
|
20
|
+
readonly review: string
|
|
21
|
+
readonly none: string
|
|
22
|
+
readonly passing: string
|
|
23
|
+
readonly pending: string
|
|
24
|
+
readonly failing: string
|
|
25
|
+
}
|
|
26
|
+
readonly repos: {
|
|
27
|
+
readonly opencode: string
|
|
28
|
+
readonly "effect-smol": string
|
|
29
|
+
readonly "opencode-console": string
|
|
30
|
+
readonly opencontrol: string
|
|
31
|
+
readonly default: string
|
|
32
|
+
}
|
|
33
|
+
readonly diff: {
|
|
34
|
+
readonly addedBg: string
|
|
35
|
+
readonly removedBg: string
|
|
36
|
+
readonly contextBg: string
|
|
37
|
+
readonly lineNumberBg: string
|
|
38
|
+
readonly addedLineNumberBg: string
|
|
39
|
+
readonly removedLineNumberBg: string
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ThemeDefinition {
|
|
44
|
+
readonly id: ThemeId
|
|
45
|
+
readonly name: string
|
|
46
|
+
readonly description: string
|
|
47
|
+
readonly colors: ColorPalette
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ghuiColors: ColorPalette = {
|
|
51
|
+
background: "#111018",
|
|
52
|
+
panel: "#161923",
|
|
53
|
+
modalBackground: "#1a1a2e",
|
|
2
54
|
text: "#ede7da",
|
|
3
55
|
muted: "#9f9788",
|
|
4
56
|
separator: "#6f685d",
|
|
@@ -25,4 +77,109 @@ export const colors = {
|
|
|
25
77
|
opencontrol: "#f59e0b",
|
|
26
78
|
default: "#93c5fd",
|
|
27
79
|
},
|
|
28
|
-
|
|
80
|
+
diff: {
|
|
81
|
+
addedBg: "#17351f",
|
|
82
|
+
removedBg: "#3a1e22",
|
|
83
|
+
contextBg: "transparent",
|
|
84
|
+
lineNumberBg: "#151515",
|
|
85
|
+
addedLineNumberBg: "#12301a",
|
|
86
|
+
removedLineNumberBg: "#35171b",
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const tokyoNightColors: ColorPalette = {
|
|
91
|
+
background: "#1a1b26",
|
|
92
|
+
panel: "#16161e",
|
|
93
|
+
modalBackground: "#24283b",
|
|
94
|
+
text: "#c0caf5",
|
|
95
|
+
muted: "#787c99",
|
|
96
|
+
separator: "#3b4261",
|
|
97
|
+
accent: "#7aa2f7",
|
|
98
|
+
inlineCode: "#bb9af7",
|
|
99
|
+
error: "#f7768e",
|
|
100
|
+
selectedBg: "#283457",
|
|
101
|
+
selectedText: "#ffffff",
|
|
102
|
+
count: "#ff9e64",
|
|
103
|
+
status: {
|
|
104
|
+
draft: "#e0af68",
|
|
105
|
+
approved: "#9ece6a",
|
|
106
|
+
changes: "#f7768e",
|
|
107
|
+
review: "#7dcfff",
|
|
108
|
+
none: "#787c99",
|
|
109
|
+
passing: "#9ece6a",
|
|
110
|
+
pending: "#e0af68",
|
|
111
|
+
failing: "#f7768e",
|
|
112
|
+
},
|
|
113
|
+
repos: {
|
|
114
|
+
opencode: "#7aa2f7",
|
|
115
|
+
"effect-smol": "#9ece6a",
|
|
116
|
+
"opencode-console": "#bb9af7",
|
|
117
|
+
opencontrol: "#ff9e64",
|
|
118
|
+
default: "#7dcfff",
|
|
119
|
+
},
|
|
120
|
+
diff: {
|
|
121
|
+
addedBg: "#203326",
|
|
122
|
+
removedBg: "#3a222c",
|
|
123
|
+
contextBg: "transparent",
|
|
124
|
+
lineNumberBg: "#16161e",
|
|
125
|
+
addedLineNumberBg: "#1b2f23",
|
|
126
|
+
removedLineNumberBg: "#33202a",
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const opencodeColors: ColorPalette = {
|
|
131
|
+
background: "#0a0a0a",
|
|
132
|
+
panel: "#141414",
|
|
133
|
+
modalBackground: "#1e1e1e",
|
|
134
|
+
text: "#eeeeee",
|
|
135
|
+
muted: "#808080",
|
|
136
|
+
separator: "#484848",
|
|
137
|
+
accent: "#fab283",
|
|
138
|
+
inlineCode: "#7fd88f",
|
|
139
|
+
error: "#e06c75",
|
|
140
|
+
selectedBg: "#323232",
|
|
141
|
+
selectedText: "#eeeeee",
|
|
142
|
+
count: "#fab283",
|
|
143
|
+
status: {
|
|
144
|
+
draft: "#f5a742",
|
|
145
|
+
approved: "#7fd88f",
|
|
146
|
+
changes: "#e06c75",
|
|
147
|
+
review: "#5c9cf5",
|
|
148
|
+
none: "#808080",
|
|
149
|
+
passing: "#7fd88f",
|
|
150
|
+
pending: "#f5a742",
|
|
151
|
+
failing: "#e06c75",
|
|
152
|
+
},
|
|
153
|
+
repos: {
|
|
154
|
+
opencode: "#fab283",
|
|
155
|
+
"effect-smol": "#7fd88f",
|
|
156
|
+
"opencode-console": "#9d7cd8",
|
|
157
|
+
opencontrol: "#f5a742",
|
|
158
|
+
default: "#5c9cf5",
|
|
159
|
+
},
|
|
160
|
+
diff: {
|
|
161
|
+
addedBg: "#20303b",
|
|
162
|
+
removedBg: "#37222c",
|
|
163
|
+
contextBg: "transparent",
|
|
164
|
+
lineNumberBg: "#141414",
|
|
165
|
+
addedLineNumberBg: "#1b2b34",
|
|
166
|
+
removedLineNumberBg: "#2d1f26",
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const themeDefinitions: readonly ThemeDefinition[] = [
|
|
171
|
+
{ id: "ghui", name: "GHUI", description: "Warm parchment accents on a deep slate background", colors: ghuiColors },
|
|
172
|
+
{ id: "tokyo-night", name: "Tokyo Night", description: "Cool indigo surfaces with neon editor accents", colors: tokyoNightColors },
|
|
173
|
+
{ id: "opencode", name: "OpenCode", description: "Charcoal panels with peach, violet, and blue highlights", colors: opencodeColors },
|
|
174
|
+
] as const
|
|
175
|
+
|
|
176
|
+
let activeTheme = themeDefinitions[0]!
|
|
177
|
+
|
|
178
|
+
export const colors: ColorPalette = { ...ghuiColors }
|
|
179
|
+
|
|
180
|
+
export const getThemeDefinition = (id: ThemeId) => themeDefinitions.find((theme) => theme.id === id) ?? themeDefinitions[0]!
|
|
181
|
+
|
|
182
|
+
export const setActiveTheme = (id: ThemeId) => {
|
|
183
|
+
activeTheme = getThemeDefinition(id)
|
|
184
|
+
Object.assign(colors, activeTheme.colors)
|
|
185
|
+
}
|
package/src/ui/diff.ts
CHANGED
|
@@ -13,21 +13,21 @@ export type PullRequestDiffState =
|
|
|
13
13
|
| { readonly status: "ready"; readonly patch: string; readonly files: readonly DiffFilePatch[] }
|
|
14
14
|
| { readonly status: "error"; readonly error: string }
|
|
15
15
|
|
|
16
|
-
export const
|
|
17
|
-
keyword: { fg: parseColor(
|
|
18
|
-
"keyword.import": { fg: parseColor(
|
|
19
|
-
string: { fg: parseColor(
|
|
16
|
+
export const createDiffSyntaxStyle = () => SyntaxStyle.fromStyles({
|
|
17
|
+
keyword: { fg: parseColor(colors.accent), bold: true },
|
|
18
|
+
"keyword.import": { fg: parseColor(colors.accent), bold: true },
|
|
19
|
+
string: { fg: parseColor(colors.inlineCode) },
|
|
20
20
|
comment: { fg: parseColor(colors.muted), italic: true },
|
|
21
|
-
number: { fg: parseColor(
|
|
22
|
-
boolean: { fg: parseColor(
|
|
23
|
-
constant: { fg: parseColor(
|
|
24
|
-
function: { fg: parseColor(
|
|
25
|
-
"function.call": { fg: parseColor(
|
|
26
|
-
constructor: { fg: parseColor(
|
|
27
|
-
type: { fg: parseColor(
|
|
28
|
-
operator: { fg: parseColor(
|
|
21
|
+
number: { fg: parseColor(colors.status.review) },
|
|
22
|
+
boolean: { fg: parseColor(colors.status.review) },
|
|
23
|
+
constant: { fg: parseColor(colors.status.review) },
|
|
24
|
+
function: { fg: parseColor(colors.status.passing) },
|
|
25
|
+
"function.call": { fg: parseColor(colors.status.passing) },
|
|
26
|
+
constructor: { fg: parseColor(colors.status.draft) },
|
|
27
|
+
type: { fg: parseColor(colors.status.draft) },
|
|
28
|
+
operator: { fg: parseColor(colors.status.failing) },
|
|
29
29
|
variable: { fg: parseColor(colors.text) },
|
|
30
|
-
property: { fg: parseColor(
|
|
30
|
+
property: { fg: parseColor(colors.status.review) },
|
|
31
31
|
bracket: { fg: parseColor(colors.text) },
|
|
32
32
|
punctuation: { fg: parseColor(colors.text) },
|
|
33
33
|
default: { fg: parseColor(colors.text) },
|
|
@@ -89,6 +89,41 @@ const patchFileName = (patch: string) => {
|
|
|
89
89
|
return nextLine ? unquoteDiffPath(nextLine.slice(4).trim()) : "diff"
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
const hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
|
|
93
|
+
|
|
94
|
+
const formatHunkRange = (start: string, count: number) => `${start},${count}`
|
|
95
|
+
|
|
96
|
+
const normalizeHunkLineCounts = (patch: string) => {
|
|
97
|
+
const lines = patch.split("\n")
|
|
98
|
+
const normalized = [...lines]
|
|
99
|
+
|
|
100
|
+
for (let index = 0; index < lines.length; index++) {
|
|
101
|
+
const match = lines[index]!.match(hunkHeaderPattern)
|
|
102
|
+
if (!match) continue
|
|
103
|
+
|
|
104
|
+
let oldCount = 0
|
|
105
|
+
let newCount = 0
|
|
106
|
+
for (let lineIndex = index + 1; lineIndex < lines.length; lineIndex++) {
|
|
107
|
+
const line = lines[lineIndex]!
|
|
108
|
+
if (line.startsWith("@@ ") || line.startsWith("diff --git ")) break
|
|
109
|
+
|
|
110
|
+
const prefix = line[0]
|
|
111
|
+
if (prefix === " ") {
|
|
112
|
+
oldCount += 1
|
|
113
|
+
newCount += 1
|
|
114
|
+
} else if (prefix === "-") {
|
|
115
|
+
oldCount += 1
|
|
116
|
+
} else if (prefix === "+") {
|
|
117
|
+
newCount += 1
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
normalized[index] = `@@ -${formatHunkRange(match[1]!, oldCount)} +${formatHunkRange(match[3]!, newCount)} @@${match[5]!}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return normalized.join("\n")
|
|
125
|
+
}
|
|
126
|
+
|
|
92
127
|
export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
|
|
93
128
|
const trimmed = patch.trimEnd()
|
|
94
129
|
if (trimmed.length === 0) return []
|
|
@@ -101,7 +136,7 @@ export const splitPatchFiles = (patch: string): readonly DiffFilePatch[] => {
|
|
|
101
136
|
return matches.map((match, index) => {
|
|
102
137
|
const start = match.index ?? 0
|
|
103
138
|
const end = index + 1 < matches.length ? matches[index + 1]!.index ?? trimmed.length : trimmed.length
|
|
104
|
-
const filePatch = trimmed.slice(start, end).trimEnd()
|
|
139
|
+
const filePatch = normalizeHunkLineCounts(trimmed.slice(start, end).trimEnd())
|
|
105
140
|
const name = patchFileName(filePatch)
|
|
106
141
|
return { name, filetype: filetypeForPath(name), patch: filePatch }
|
|
107
142
|
})
|
package/src/ui/modals.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core"
|
|
2
2
|
import type { PullRequestLabel, PullRequestMergeInfo } from "../domain.js"
|
|
3
3
|
import { availableMergeActions } from "../mergeActions.js"
|
|
4
|
-
import { colors } from "./colors.js"
|
|
4
|
+
import { colors, themeDefinitions, type ThemeId } from "./colors.js"
|
|
5
5
|
import { centerCell, Divider, fitCell, ModalFrame, PlainLine, TextLine } from "./primitives.js"
|
|
6
6
|
import { labelColor, shortRepoName } from "./pullRequests.js"
|
|
7
7
|
|
|
@@ -25,6 +25,12 @@ export interface MergeModalState {
|
|
|
25
25
|
readonly error: string | null
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export interface ThemeModalState {
|
|
29
|
+
readonly open: boolean
|
|
30
|
+
readonly selectedIndex: number
|
|
31
|
+
readonly initialThemeId: ThemeId
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
export const initialLabelModalState: LabelModalState = {
|
|
29
35
|
open: false,
|
|
30
36
|
repository: null,
|
|
@@ -45,6 +51,12 @@ export const initialMergeModalState: MergeModalState = {
|
|
|
45
51
|
error: null,
|
|
46
52
|
}
|
|
47
53
|
|
|
54
|
+
export const initialThemeModalState: ThemeModalState = {
|
|
55
|
+
open: false,
|
|
56
|
+
selectedIndex: 0,
|
|
57
|
+
initialThemeId: "ghui",
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
|
|
49
61
|
if (!info) return "Loading merge status from GitHub."
|
|
50
62
|
if (info.state !== "open") return "This pull request is not open."
|
|
@@ -247,3 +259,85 @@ export const MergeModal = ({
|
|
|
247
259
|
</ModalFrame>
|
|
248
260
|
)
|
|
249
261
|
}
|
|
262
|
+
|
|
263
|
+
export const ThemeModal = ({
|
|
264
|
+
state,
|
|
265
|
+
activeThemeId,
|
|
266
|
+
modalWidth,
|
|
267
|
+
modalHeight,
|
|
268
|
+
offsetLeft,
|
|
269
|
+
offsetTop,
|
|
270
|
+
}: {
|
|
271
|
+
state: ThemeModalState
|
|
272
|
+
activeThemeId: ThemeId
|
|
273
|
+
modalWidth: number
|
|
274
|
+
modalHeight: number
|
|
275
|
+
offsetLeft: number
|
|
276
|
+
offsetTop: number
|
|
277
|
+
}) => {
|
|
278
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
279
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
280
|
+
const rowWidth = innerWidth
|
|
281
|
+
const maxVisible = Math.max(1, modalHeight - 7)
|
|
282
|
+
const selectedIndex = Math.max(0, Math.min(state.selectedIndex, themeDefinitions.length - 1))
|
|
283
|
+
const selectedTheme = themeDefinitions[selectedIndex]!
|
|
284
|
+
const scrollStart = Math.min(
|
|
285
|
+
Math.max(0, themeDefinitions.length - maxVisible),
|
|
286
|
+
Math.max(0, selectedIndex - maxVisible + 1),
|
|
287
|
+
)
|
|
288
|
+
const visibleThemes = themeDefinitions.slice(scrollStart, scrollStart + maxVisible)
|
|
289
|
+
const countText = `${selectedIndex + 1}/${themeDefinitions.length}`
|
|
290
|
+
const title = "Themes"
|
|
291
|
+
const headerGap = Math.max(1, contentWidth - title.length - countText.length)
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
295
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
296
|
+
<TextLine>
|
|
297
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
298
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
299
|
+
<span fg={colors.muted}>{countText}</span>
|
|
300
|
+
</TextLine>
|
|
301
|
+
</box>
|
|
302
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
303
|
+
<PlainLine text={fitCell(selectedTheme.description, contentWidth)} fg={colors.muted} />
|
|
304
|
+
</box>
|
|
305
|
+
<Divider width={innerWidth} />
|
|
306
|
+
<box height={maxVisible} flexDirection="column">
|
|
307
|
+
{visibleThemes.map((theme, index) => {
|
|
308
|
+
const actualIndex = scrollStart + index
|
|
309
|
+
const isSelected = actualIndex === selectedIndex
|
|
310
|
+
const isActive = theme.id === activeThemeId
|
|
311
|
+
const marker = isActive ? "✓" : " "
|
|
312
|
+
const swatchWidth = 6
|
|
313
|
+
const nameWidth = Math.max(1, rowWidth - swatchWidth - 3)
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<TextLine key={theme.id} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
317
|
+
<span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
|
|
318
|
+
<span> </span>
|
|
319
|
+
<span>{fitCell(theme.name, nameWidth)}</span>
|
|
320
|
+
<span bg={theme.colors.background}> </span>
|
|
321
|
+
<span bg={theme.colors.panel}> </span>
|
|
322
|
+
<span bg={theme.colors.accent}> </span>
|
|
323
|
+
<span bg={theme.colors.status.passing}> </span>
|
|
324
|
+
<span bg={theme.colors.status.failing}> </span>
|
|
325
|
+
<span bg={theme.colors.status.review}> </span>
|
|
326
|
+
</TextLine>
|
|
327
|
+
)
|
|
328
|
+
})}
|
|
329
|
+
</box>
|
|
330
|
+
<Divider width={innerWidth} />
|
|
331
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
332
|
+
<TextLine>
|
|
333
|
+
<span fg={colors.count}>↑↓</span>
|
|
334
|
+
<span fg={colors.muted}> preview </span>
|
|
335
|
+
<span fg={colors.count}>enter</span>
|
|
336
|
+
<span fg={colors.muted}> select </span>
|
|
337
|
+
<span fg={colors.count}>esc</span>
|
|
338
|
+
<span fg={colors.muted}> cancel</span>
|
|
339
|
+
</TextLine>
|
|
340
|
+
</box>
|
|
341
|
+
</ModalFrame>
|
|
342
|
+
)
|
|
343
|
+
}
|