@kitlangton/ghui 0.1.19 → 0.1.20

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/src/ui/modals.tsx CHANGED
@@ -1,11 +1,10 @@
1
- import { TextAttributes } from "@opentui/core"
2
1
  import { Data } from "effect"
3
2
  import { formatShortDate, formatTimestamp } from "../date.js"
4
3
  import type { PullRequestLabel, PullRequestMergeInfo, PullRequestReviewComment } from "../domain.js"
5
4
  import { availableMergeActions } from "../mergeActions.js"
6
5
  import { clampCursor, commentEditorLines, cursorLineIndexForLines } from "./commentEditor.js"
7
6
  import { colors, filterThemeDefinitions, themeDefinitions, type ThemeId } from "./colors.js"
8
- import { centerCell, Divider, fitCell, ModalFrame, PlainLine, TextLine } from "./primitives.js"
7
+ import { centerCell, Filler, fitCell, HintRow, PlainLine, StandardModal, standardModalDims, TextLine } from "./primitives.js"
9
8
  import { labelColor, shortRepoName } from "./pullRequests.js"
10
9
 
11
10
  export interface LabelModalState {
@@ -51,6 +50,22 @@ export interface ThemeModalState {
51
50
  readonly initialThemeId: ThemeId
52
51
  }
53
52
 
53
+ export interface CommandPaletteState {
54
+ readonly query: string
55
+ readonly selectedIndex: number
56
+ }
57
+
58
+ export interface OpenRepositoryModalState {
59
+ readonly query: string
60
+ readonly error: string | null
61
+ }
62
+
63
+ export const filterLabels = (labels: readonly PullRequestLabel[], query: string) => {
64
+ const normalized = query.trim().toLowerCase()
65
+ if (normalized.length === 0) return labels
66
+ return labels.filter((label) => label.name.toLowerCase().includes(normalized))
67
+ }
68
+
54
69
  export const initialLabelModalState: LabelModalState = {
55
70
  repository: null,
56
71
  query: "",
@@ -94,6 +109,16 @@ export const initialThemeModalState: ThemeModalState = {
94
109
  initialThemeId: "ghui",
95
110
  }
96
111
 
112
+ export const initialCommandPaletteState: CommandPaletteState = {
113
+ query: "",
114
+ selectedIndex: 0,
115
+ }
116
+
117
+ export const initialOpenRepositoryModalState: OpenRepositoryModalState = {
118
+ query: "",
119
+ error: null,
120
+ }
121
+
97
122
  export type Modal = Data.TaggedEnum<{
98
123
  None: {}
99
124
  Label: LabelModalState
@@ -102,6 +127,8 @@ export type Modal = Data.TaggedEnum<{
102
127
  Comment: CommentModalState
103
128
  CommentThread: CommentThreadModalState
104
129
  Theme: ThemeModalState
130
+ CommandPalette: CommandPaletteState
131
+ OpenRepository: OpenRepositoryModalState
105
132
  }>
106
133
 
107
134
  export const Modal = Data.taggedEnum<Modal>()
@@ -117,8 +144,52 @@ export const modalInitialStates = {
117
144
  Comment: initialCommentModalState,
118
145
  CommentThread: initialCommentThreadModalState,
119
146
  Theme: initialThemeModalState,
147
+ CommandPalette: initialCommandPaletteState,
148
+ OpenRepository: initialOpenRepositoryModalState,
120
149
  } as const satisfies { [Tag in Exclude<ModalTag, "None">]: ModalState<Tag> }
121
150
 
151
+ export const OpenRepositoryModal = ({
152
+ state,
153
+ modalWidth,
154
+ modalHeight,
155
+ offsetLeft,
156
+ offsetTop,
157
+ }: {
158
+ state: OpenRepositoryModalState
159
+ modalWidth: number
160
+ modalHeight: number
161
+ offsetLeft: number
162
+ offsetTop: number
163
+ }) => {
164
+ const { contentWidth } = standardModalDims(modalWidth, modalHeight)
165
+ const inputText = state.query.length > 0 ? state.query : "owner/name or GitHub URL"
166
+
167
+ return (
168
+ <StandardModal
169
+ left={offsetLeft}
170
+ top={offsetTop}
171
+ width={modalWidth}
172
+ height={modalHeight}
173
+ title="Open Repository"
174
+ headerRight={{ text: "owner/name" }}
175
+ subtitle={
176
+ <TextLine>
177
+ <span fg={colors.count}>› </span>
178
+ <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(inputText, Math.max(1, contentWidth - 2))}</span>
179
+ </TextLine>
180
+ }
181
+ bodyPadding={1}
182
+ footer={<HintRow items={[{ key: "enter", label: "open" }, { key: "ctrl-u", label: "clear" }, { key: "ctrl-w", label: "word" }, { key: "esc", label: "cancel" }]} />}
183
+ >
184
+ {state.error ? (
185
+ <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} />
186
+ ) : (
187
+ <PlainLine text={fitCell("Switches to the selected repository view.", contentWidth)} fg={colors.muted} />
188
+ )}
189
+ </StandardModal>
190
+ )
191
+ }
192
+
122
193
  const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
123
194
  if (!info) return "Loading merge status from GitHub."
124
195
  if (info.state !== "open") return "This pull request is not open."
@@ -144,14 +215,9 @@ export const LabelModal = ({
144
215
  offsetTop: number
145
216
  loadingIndicator: string
146
217
  }) => {
147
- const innerWidth = Math.max(16, modalWidth - 2)
148
- const contentWidth = Math.max(14, innerWidth - 2)
149
- const rowWidth = innerWidth
218
+ const { contentWidth, bodyHeight: maxVisible, rowWidth } = standardModalDims(modalWidth, modalHeight)
150
219
  const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
151
- const filtered = state.availableLabels.filter((label) =>
152
- state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
153
- )
154
- const maxVisible = Math.max(1, modalHeight - 7)
220
+ const filtered = filterLabels(state.availableLabels, state.query)
155
221
  const labelMessageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
156
222
  const labelMessageBottomRows = Math.max(0, maxVisible - labelMessageTopRows - 1)
157
223
  const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
@@ -162,62 +228,25 @@ export const LabelModal = ({
162
228
  const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
163
229
  const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
164
230
  const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
165
- const headerGap = Math.max(1, contentWidth - title.length - countText.length)
166
231
  const queryText = state.query.length > 0 ? state.query : "type to filter labels"
167
- const queryPrefix = state.query.length > 0 ? "/ " : "/ "
232
+ const queryPrefix = "/ "
168
233
  const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
169
234
 
170
235
  return (
171
- <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
172
- <box height={1} paddingLeft={1} paddingRight={1}>
173
- <TextLine>
174
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
175
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
176
- <span fg={colors.muted}>{countText}</span>
177
- </TextLine>
178
- </box>
179
- <box height={1} paddingLeft={1} paddingRight={1}>
236
+ <StandardModal
237
+ left={offsetLeft}
238
+ top={offsetTop}
239
+ width={modalWidth}
240
+ height={modalHeight}
241
+ title={title}
242
+ headerRight={{ text: countText }}
243
+ subtitle={
180
244
  <TextLine>
181
245
  <span fg={colors.count}>{queryPrefix}</span>
182
246
  <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
183
247
  </TextLine>
184
- </box>
185
- <Divider width={innerWidth} />
186
- <box height={maxVisible} flexDirection="column">
187
- {state.loading ? (
188
- <>
189
- {Array.from({ length: labelMessageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
190
- <PlainLine text={centerCell(`${loadingIndicator} Loading labels`, rowWidth)} fg={colors.muted} />
191
- {Array.from({ length: labelMessageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
192
- </>
193
- ) : visibleLabels.length === 0 ? (
194
- <>
195
- {Array.from({ length: labelMessageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
196
- <PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", rowWidth)} fg={colors.muted} />
197
- {Array.from({ length: labelMessageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
198
- </>
199
- ) : (
200
- visibleLabels.map((label, index) => {
201
- const actualIndex = scrollStart + index
202
- const isActive = currentNames.has(label.name.toLowerCase())
203
- const isSelected = actualIndex === selectedIndex
204
- const marker = isActive ? "✓" : " "
205
- const nameWidth = Math.max(1, rowWidth - 5)
206
- return (
207
- <box key={label.name} height={1}>
208
- <TextLine bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
209
- <span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
210
- <span> </span>
211
- <span bg={labelColor(label)}> </span>
212
- <span> {fitCell(label.name, nameWidth)}</span>
213
- </TextLine>
214
- </box>
215
- )
216
- })
217
- )}
218
- </box>
219
- <Divider width={innerWidth} />
220
- <box height={1} paddingLeft={1} paddingRight={1}>
248
+ }
249
+ footer={
221
250
  <TextLine>
222
251
  <span fg={colors.count}>↑↓</span>
223
252
  <span fg={colors.muted}> move </span>
@@ -225,8 +254,40 @@ export const LabelModal = ({
225
254
  <span fg={colors.muted}> close</span>
226
255
  {filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
227
256
  </TextLine>
228
- </box>
229
- </ModalFrame>
257
+ }
258
+ >
259
+ {state.loading ? (
260
+ <>
261
+ <Filler rows={labelMessageTopRows} prefix="top" />
262
+ <PlainLine text={centerCell(`${loadingIndicator} Loading labels`, rowWidth)} fg={colors.muted} />
263
+ <Filler rows={labelMessageBottomRows} prefix="bottom" />
264
+ </>
265
+ ) : visibleLabels.length === 0 ? (
266
+ <>
267
+ <Filler rows={labelMessageTopRows} prefix="top" />
268
+ <PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", rowWidth)} fg={colors.muted} />
269
+ <Filler rows={labelMessageBottomRows} prefix="bottom" />
270
+ </>
271
+ ) : (
272
+ visibleLabels.map((label, index) => {
273
+ const actualIndex = scrollStart + index
274
+ const isActive = currentNames.has(label.name.toLowerCase())
275
+ const isSelected = actualIndex === selectedIndex
276
+ const marker = isActive ? "✓" : " "
277
+ const nameWidth = Math.max(1, rowWidth - 5)
278
+ return (
279
+ <box key={label.name} height={1}>
280
+ <TextLine bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
281
+ <span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
282
+ <span> </span>
283
+ <span bg={labelColor(label)}> </span>
284
+ <span> {fitCell(label.name, nameWidth)}</span>
285
+ </TextLine>
286
+ </box>
287
+ )
288
+ })
289
+ )}
290
+ </StandardModal>
230
291
  )
231
292
  }
232
293
 
@@ -245,80 +306,61 @@ export const MergeModal = ({
245
306
  offsetTop: number
246
307
  loadingIndicator: string
247
308
  }) => {
248
- const innerWidth = Math.max(16, modalWidth - 2)
249
- const contentWidth = Math.max(14, innerWidth - 2)
250
- const rowWidth = innerWidth
309
+ const { contentWidth, bodyHeight: optionAreaHeight, rowWidth } = standardModalDims(modalWidth, modalHeight)
251
310
  const options = availableMergeActions(state.info)
252
311
  const selectedIndex = options.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, options.length - 1))
253
312
  const title = state.info ? `Merge #${state.info.number}` : state.number ? `Merge #${state.number}` : "Merge"
254
313
  const rightText = state.running ? `${loadingIndicator} running` : state.loading ? `${loadingIndicator} loading` : state.info?.autoMergeEnabled ? "auto on" : "manual"
255
- const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
256
314
  const repo = state.info?.repository ?? state.repository
257
315
  const statusLine = state.info
258
316
  ? `${shortRepoName(state.info.repository)} ${state.info.mergeable} ${state.info.reviewStatus} ${state.info.checkSummary ?? state.info.checkStatus}`
259
317
  : repo ? shortRepoName(repo) : ""
260
- const optionAreaHeight = Math.max(1, modalHeight - 7)
261
318
  const optionRows = Math.max(1, Math.floor(optionAreaHeight / 2))
262
319
  const visibleOptions = options.slice(0, optionRows)
263
320
  const loadingTopRows = Math.max(0, Math.floor((optionAreaHeight - 1) / 2))
264
321
  const loadingBottomRows = Math.max(0, optionAreaHeight - loadingTopRows - 1)
265
322
 
266
323
  return (
267
- <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
268
- <box height={1} paddingLeft={1} paddingRight={1}>
269
- <TextLine>
270
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
271
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
272
- <span fg={state.running || state.loading ? colors.status.pending : colors.muted}>{rightText}</span>
273
- </TextLine>
274
- </box>
275
- <box height={1} paddingLeft={1} paddingRight={1}>
276
- <PlainLine text={fitCell(statusLine, contentWidth)} fg={colors.muted} />
277
- </box>
278
- <Divider width={innerWidth} />
279
- <box height={optionAreaHeight} flexDirection="column">
280
- {state.loading ? (
281
- <>
282
- {Array.from({ length: loadingTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
283
- <PlainLine text={centerCell(`${loadingIndicator} Loading merge status`, rowWidth)} fg={colors.muted} />
284
- {Array.from({ length: loadingBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
285
- </>
286
- ) : state.error ? (
287
- <PlainLine text={centerCell(state.error, rowWidth)} fg={colors.error} />
288
- ) : visibleOptions.length === 0 ? (
289
- <PlainLine text={centerCell(mergeUnavailableReason(state.info), rowWidth)} fg={colors.muted} />
290
- ) : (
291
- visibleOptions.map((option, index) => {
292
- const isSelected = index === selectedIndex
293
- const titleColor = option.danger ? colors.error : isSelected ? colors.selectedText : colors.text
294
- const titleWidth = Math.max(1, rowWidth - 1)
295
- const descriptionWidth = Math.max(1, rowWidth - 1)
296
-
297
- return (
298
- <box key={option.action} height={2} flexDirection="column">
299
- <TextLine bg={isSelected ? colors.selectedBg : undefined}>
300
- <span fg={titleColor}> {fitCell(option.title, titleWidth)}</span>
301
- </TextLine>
302
- <TextLine bg={isSelected ? colors.selectedBg : undefined}>
303
- <span fg={colors.muted}> {fitCell(option.description, descriptionWidth)}</span>
304
- </TextLine>
305
- </box>
306
- )
307
- })
308
- )}
309
- </box>
310
- <Divider width={innerWidth} />
311
- <box height={1} paddingLeft={1} paddingRight={1}>
312
- <TextLine>
313
- <span fg={colors.count}>↑↓</span>
314
- <span fg={colors.muted}> move </span>
315
- <span fg={colors.count}>enter</span>
316
- <span fg={colors.muted}> confirm </span>
317
- <span fg={colors.count}>esc</span>
318
- <span fg={colors.muted}> close</span>
319
- </TextLine>
320
- </box>
321
- </ModalFrame>
324
+ <StandardModal
325
+ left={offsetLeft}
326
+ top={offsetTop}
327
+ width={modalWidth}
328
+ height={modalHeight}
329
+ title={title}
330
+ headerRight={{ text: rightText, pending: state.running || state.loading }}
331
+ subtitle={<PlainLine text={fitCell(statusLine, contentWidth)} fg={colors.muted} />}
332
+ footer={<HintRow items={[{ key: "↑↓", label: "move" }, { key: "enter", label: "confirm" }, { key: "esc", label: "close" }]} />}
333
+ >
334
+ {state.loading ? (
335
+ <>
336
+ <Filler rows={loadingTopRows} prefix="top" />
337
+ <PlainLine text={centerCell(`${loadingIndicator} Loading merge status`, rowWidth)} fg={colors.muted} />
338
+ <Filler rows={loadingBottomRows} prefix="bottom" />
339
+ </>
340
+ ) : state.error ? (
341
+ <PlainLine text={centerCell(state.error, rowWidth)} fg={colors.error} />
342
+ ) : visibleOptions.length === 0 ? (
343
+ <PlainLine text={centerCell(mergeUnavailableReason(state.info), rowWidth)} fg={colors.muted} />
344
+ ) : (
345
+ visibleOptions.map((option, index) => {
346
+ const isSelected = index === selectedIndex
347
+ const titleColor = option.danger ? colors.error : isSelected ? colors.selectedText : colors.text
348
+ const titleWidth = Math.max(1, rowWidth - 1)
349
+ const descriptionWidth = Math.max(1, rowWidth - 1)
350
+
351
+ return (
352
+ <box key={option.action} height={2} flexDirection="column">
353
+ <TextLine bg={isSelected ? colors.selectedBg : undefined}>
354
+ <span fg={titleColor}> {fitCell(option.title, titleWidth)}</span>
355
+ </TextLine>
356
+ <TextLine bg={isSelected ? colors.selectedBg : undefined}>
357
+ <span fg={colors.muted}> {fitCell(option.description, descriptionWidth)}</span>
358
+ </TextLine>
359
+ </box>
360
+ )
361
+ })
362
+ )}
363
+ </StandardModal>
322
364
  )
323
365
  }
324
366
 
@@ -337,55 +379,38 @@ export const CloseModal = ({
337
379
  offsetTop: number
338
380
  loadingIndicator: string
339
381
  }) => {
340
- const innerWidth = Math.max(16, modalWidth - 2)
341
- const contentWidth = Math.max(14, innerWidth - 2)
382
+ const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
342
383
  const title = state.number ? `Close #${state.number}` : "Close pull request"
343
384
  const rightText = state.running ? `${loadingIndicator} closing` : "confirm"
344
- const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
345
385
  const repo = state.repository ? shortRepoName(state.repository) : ""
346
- const titleLines = [
347
- fitCell(repo, contentWidth),
348
- fitCell(state.title, contentWidth),
349
- ]
350
- const bodyHeight = Math.max(1, modalHeight - 7)
386
+ const titleLines = [fitCell(repo, contentWidth), fitCell(state.title, contentWidth)]
351
387
  const topRows = Math.max(0, Math.floor((bodyHeight - titleLines.length - 2) / 2))
352
388
  const bottomRows = Math.max(0, bodyHeight - topRows - titleLines.length - 2)
353
389
 
354
390
  return (
355
- <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
356
- <box height={1} paddingLeft={1} paddingRight={1}>
357
- <TextLine>
358
- <span fg={colors.error} attributes={TextAttributes.BOLD}>{title}</span>
359
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
360
- <span fg={state.running ? colors.status.pending : colors.muted}>{rightText}</span>
361
- </TextLine>
362
- </box>
363
- <box height={1} paddingLeft={1} paddingRight={1}>
364
- <PlainLine text={fitCell("This will close the pull request without merging it.", contentWidth)} fg={colors.muted} />
365
- </box>
366
- <Divider width={innerWidth} />
367
- <box height={bodyHeight} flexDirection="column" paddingLeft={1} paddingRight={1}>
368
- {state.error ? (
369
- <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} />
370
- ) : (
371
- <>
372
- {Array.from({ length: topRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
373
- <PlainLine text={titleLines[0]!} fg={colors.muted} />
374
- <PlainLine text={titleLines[1]!} fg={colors.text} bold />
375
- {Array.from({ length: bottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
376
- </>
377
- )}
378
- </box>
379
- <Divider width={innerWidth} />
380
- <box height={1} paddingLeft={1} paddingRight={1}>
381
- <TextLine>
382
- <span fg={colors.count}>enter</span>
383
- <span fg={colors.muted}> close </span>
384
- <span fg={colors.count}>esc</span>
385
- <span fg={colors.muted}> cancel</span>
386
- </TextLine>
387
- </box>
388
- </ModalFrame>
391
+ <StandardModal
392
+ left={offsetLeft}
393
+ top={offsetTop}
394
+ width={modalWidth}
395
+ height={modalHeight}
396
+ title={title}
397
+ titleFg={colors.error}
398
+ headerRight={{ text: rightText, pending: state.running }}
399
+ subtitle={<PlainLine text={fitCell("This will close the pull request without merging it.", contentWidth)} fg={colors.muted} />}
400
+ bodyPadding={1}
401
+ footer={<HintRow items={[{ key: "enter", label: "close" }, { key: "esc", label: "cancel" }]} />}
402
+ >
403
+ {state.error ? (
404
+ <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} />
405
+ ) : (
406
+ <>
407
+ <Filler rows={topRows} prefix="top" />
408
+ <PlainLine text={titleLines[0]!} fg={colors.muted} />
409
+ <PlainLine text={titleLines[1]!} fg={colors.text} bold />
410
+ <Filler rows={bottomRows} prefix="bottom" />
411
+ </>
412
+ )}
413
+ </StandardModal>
389
414
  )
390
415
  }
391
416
 
@@ -404,12 +429,8 @@ export const CommentModal = ({
404
429
  offsetLeft: number
405
430
  offsetTop: number
406
431
  }) => {
407
- const innerWidth = Math.max(16, modalWidth - 2)
408
- const contentWidth = Math.max(14, innerWidth - 2)
432
+ const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
409
433
  const title = "Comment"
410
- const rightText = "enter save"
411
- const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
412
- const bodyHeight = Math.max(1, modalHeight - 7)
413
434
  const editorHeight = Math.max(1, bodyHeight - (state.error ? 1 : 0))
414
435
  const lineRanges = commentEditorLines(state.body)
415
436
  const cursor = clampCursor(state.body, state.cursor)
@@ -446,34 +467,20 @@ export const CommentModal = ({
446
467
  }
447
468
 
448
469
  return (
449
- <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
450
- <box height={1} paddingLeft={1} paddingRight={1}>
451
- <TextLine>
452
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
453
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
454
- <span fg={colors.muted}>{rightText}</span>
455
- </TextLine>
456
- </box>
457
- <box height={1} paddingLeft={1} paddingRight={1}>
458
- <PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />
459
- </box>
460
- <Divider width={innerWidth} />
461
- <box height={bodyHeight} flexDirection="column" paddingLeft={1} paddingRight={1}>
462
- {state.error ? <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} /> : null}
463
- {visibleLines.map(renderEditorLine)}
464
- </box>
465
- <Divider width={innerWidth} />
466
- <box height={1} paddingLeft={1} paddingRight={1}>
467
- <TextLine>
468
- <span fg={colors.count}>enter</span>
469
- <span fg={colors.muted}> save </span>
470
- <span fg={colors.count}>shift-enter</span>
471
- <span fg={colors.muted}> newline </span>
472
- <span fg={colors.count}>esc</span>
473
- <span fg={colors.muted}> cancel</span>
474
- </TextLine>
475
- </box>
476
- </ModalFrame>
470
+ <StandardModal
471
+ left={offsetLeft}
472
+ top={offsetTop}
473
+ width={modalWidth}
474
+ height={modalHeight}
475
+ title={title}
476
+ headerRight={{ text: "enter save" }}
477
+ subtitle={<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />}
478
+ bodyPadding={1}
479
+ footer={<HintRow items={[{ key: "enter", label: "save" }, { key: "shift-enter", label: "newline" }, { key: "esc", label: "cancel" }]} />}
480
+ >
481
+ {state.error ? <PlainLine text={fitCell(state.error, contentWidth)} fg={colors.error} /> : null}
482
+ {visibleLines.map(renderEditorLine)}
483
+ </StandardModal>
477
484
  )
478
485
  }
479
486
 
@@ -530,49 +537,32 @@ export const CommentThreadModal = ({
530
537
  offsetLeft: number
531
538
  offsetTop: number
532
539
  }) => {
533
- const innerWidth = Math.max(16, modalWidth - 2)
534
- const contentWidth = Math.max(14, innerWidth - 2)
540
+ const { contentWidth, bodyHeight } = standardModalDims(modalWidth, modalHeight)
535
541
  const title = "Thread"
536
542
  const countText = comments.length === 1 ? "1 comment" : `${comments.length} comments`
537
- const headerGap = Math.max(1, contentWidth - title.length - countText.length)
538
- const bodyHeight = Math.max(1, modalHeight - 7)
539
543
  const rows = commentThreadRows(comments, contentWidth)
540
544
  const maxScroll = Math.max(0, rows.length - bodyHeight)
541
545
  const scrollOffset = Math.max(0, Math.min(state.scrollOffset, maxScroll))
542
546
  const visibleRows = rows.slice(scrollOffset, scrollOffset + bodyHeight)
543
547
 
544
548
  return (
545
- <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
546
- <box height={1} paddingLeft={1} paddingRight={1}>
547
- <TextLine>
548
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
549
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
550
- <span fg={colors.muted}>{countText}</span>
551
- </TextLine>
552
- </box>
553
- <box height={1} paddingLeft={1} paddingRight={1}>
554
- <PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />
555
- </box>
556
- <Divider width={innerWidth} />
557
- <box height={bodyHeight} flexDirection="column" paddingLeft={1} paddingRight={1}>
558
- {visibleRows.length === 0 ? (
559
- <PlainLine text={fitCell("No comments on this line.", contentWidth)} fg={colors.muted} />
560
- ) : visibleRows.map((row) => (
561
- <PlainLine key={row.key} text={fitCell(row.text, contentWidth)} fg={row.fg} bold={row.bold ?? false} />
562
- ))}
563
- </box>
564
- <Divider width={innerWidth} />
565
- <box height={1} paddingLeft={1} paddingRight={1}>
566
- <TextLine>
567
- <span fg={colors.count}>↑↓</span>
568
- <span fg={colors.muted}> scroll </span>
569
- <span fg={colors.count}>a</span>
570
- <span fg={colors.muted}> comment </span>
571
- <span fg={colors.count}>esc</span>
572
- <span fg={colors.muted}> close</span>
573
- </TextLine>
574
- </box>
575
- </ModalFrame>
549
+ <StandardModal
550
+ left={offsetLeft}
551
+ top={offsetTop}
552
+ width={modalWidth}
553
+ height={modalHeight}
554
+ title={title}
555
+ headerRight={{ text: countText }}
556
+ subtitle={<PlainLine text={fitCell(anchorLabel, contentWidth)} fg={colors.muted} />}
557
+ bodyPadding={1}
558
+ footer={<HintRow items={[{ key: "↑↓", label: "scroll" }, { key: "a", label: "comment" }, { key: "esc", label: "close" }]} />}
559
+ >
560
+ {visibleRows.length === 0 ? (
561
+ <PlainLine text={fitCell("No comments on this line.", contentWidth)} fg={colors.muted} />
562
+ ) : visibleRows.map((row) => (
563
+ <PlainLine key={row.key} text={fitCell(row.text, contentWidth)} fg={row.fg} bold={row.bold ?? false} />
564
+ ))}
565
+ </StandardModal>
576
566
  )
577
567
  }
578
568
 
@@ -591,11 +581,8 @@ export const ThemeModal = ({
591
581
  offsetLeft: number
592
582
  offsetTop: number
593
583
  }) => {
594
- const innerWidth = Math.max(16, modalWidth - 2)
595
- const contentWidth = Math.max(14, innerWidth - 2)
596
- const rowWidth = innerWidth
584
+ const { contentWidth, bodyHeight: maxVisible, rowWidth } = standardModalDims(modalWidth, modalHeight)
597
585
  const filteredThemes = filterThemeDefinitions(state.query)
598
- const maxVisible = Math.max(1, modalHeight - 7)
599
586
  const activeIndex = filteredThemes.findIndex((theme) => theme.id === activeThemeId)
600
587
  const selectedIndex = Math.max(0, activeIndex)
601
588
  const selectedTheme = filteredThemes[selectedIndex] ?? themeDefinitions.find((theme) => theme.id === activeThemeId) ?? themeDefinitions[0]!
@@ -605,8 +592,6 @@ export const ThemeModal = ({
605
592
  )
606
593
  const visibleThemes = filteredThemes.slice(scrollStart, scrollStart + maxVisible)
607
594
  const countText = `${filteredThemes.length === 0 ? 0 : selectedIndex + 1}/${filteredThemes.length}`
608
- const title = "Themes"
609
- const headerGap = Math.max(1, contentWidth - title.length - countText.length)
610
595
  const subtitleText = state.filterMode ? (state.query.length > 0 ? state.query : "type to filter themes") : selectedTheme.description
611
596
  const queryPrefix = "/ "
612
597
  const subtitleWidth = Math.max(1, contentWidth - (state.filterMode ? queryPrefix.length : 0))
@@ -614,68 +599,51 @@ export const ThemeModal = ({
614
599
  const messageBottomRows = Math.max(0, maxVisible - messageTopRows - 1)
615
600
 
616
601
  return (
617
- <ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
618
- <box height={1} paddingLeft={1} paddingRight={1}>
602
+ <StandardModal
603
+ left={offsetLeft}
604
+ top={offsetTop}
605
+ width={modalWidth}
606
+ height={modalHeight}
607
+ title="Themes"
608
+ headerRight={{ text: countText }}
609
+ subtitle={state.filterMode ? (
619
610
  <TextLine>
620
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
621
- <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
622
- <span fg={colors.muted}>{countText}</span>
611
+ <span fg={colors.count}>{queryPrefix}</span>
612
+ <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(subtitleText, subtitleWidth)}</span>
623
613
  </TextLine>
624
- </box>
625
- <box height={1} paddingLeft={1} paddingRight={1}>
626
- {state.filterMode ? (
627
- <TextLine>
628
- <span fg={colors.count}>{queryPrefix}</span>
629
- <span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(subtitleText, subtitleWidth)}</span>
614
+ ) : (
615
+ <PlainLine text={fitCell(subtitleText, subtitleWidth)} fg={colors.muted} />
616
+ )}
617
+ footer={<HintRow items={[{ key: "↑↓", label: "preview" }, { key: "/", label: "filter" }, { key: "enter", label: "select" }, { key: "esc", label: "cancel" }]} />}
618
+ >
619
+ {visibleThemes.length === 0 ? (
620
+ <>
621
+ <Filler rows={messageTopRows} prefix="top" />
622
+ <PlainLine text={centerCell("No matching themes", rowWidth)} fg={colors.muted} />
623
+ <Filler rows={messageBottomRows} prefix="bottom" />
624
+ </>
625
+ ) : visibleThemes.map((theme, index) => {
626
+ const actualIndex = scrollStart + index
627
+ const isSelected = actualIndex === selectedIndex
628
+ const isActive = theme.id === activeThemeId
629
+ const marker = isActive ? "✓" : " "
630
+ const swatchWidth = 6
631
+ const nameWidth = Math.max(1, rowWidth - swatchWidth - 3)
632
+
633
+ return (
634
+ <TextLine key={theme.id} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
635
+ <span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
636
+ <span> </span>
637
+ <span>{fitCell(theme.name, nameWidth)}</span>
638
+ <span bg={theme.colors.background}> </span>
639
+ <span bg={theme.colors.modalBackground}> </span>
640
+ <span bg={theme.colors.accent}> </span>
641
+ <span bg={theme.colors.status.passing}> </span>
642
+ <span bg={theme.colors.status.failing}> </span>
643
+ <span bg={theme.colors.status.review}> </span>
630
644
  </TextLine>
631
- ) : (
632
- <PlainLine text={fitCell(subtitleText, subtitleWidth)} fg={colors.muted} />
633
- )}
634
- </box>
635
- <Divider width={innerWidth} />
636
- <box height={maxVisible} flexDirection="column">
637
- {visibleThemes.length === 0 ? (
638
- <>
639
- {Array.from({ length: messageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
640
- <PlainLine text={centerCell("No matching themes", rowWidth)} fg={colors.muted} />
641
- {Array.from({ length: messageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
642
- </>
643
- ) : visibleThemes.map((theme, index) => {
644
- const actualIndex = scrollStart + index
645
- const isSelected = actualIndex === selectedIndex
646
- const isActive = theme.id === activeThemeId
647
- const marker = isActive ? "✓" : " "
648
- const swatchWidth = 6
649
- const nameWidth = Math.max(1, rowWidth - swatchWidth - 3)
650
-
651
- return (
652
- <TextLine key={theme.id} bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
653
- <span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
654
- <span> </span>
655
- <span>{fitCell(theme.name, nameWidth)}</span>
656
- <span bg={theme.colors.background}> </span>
657
- <span bg={theme.colors.modalBackground}> </span>
658
- <span bg={theme.colors.accent}> </span>
659
- <span bg={theme.colors.status.passing}> </span>
660
- <span bg={theme.colors.status.failing}> </span>
661
- <span bg={theme.colors.status.review}> </span>
662
- </TextLine>
663
- )
664
- })}
665
- </box>
666
- <Divider width={innerWidth} />
667
- <box height={1} paddingLeft={1} paddingRight={1}>
668
- <TextLine>
669
- <span fg={colors.count}>↑↓</span>
670
- <span fg={colors.muted}> preview </span>
671
- <span fg={colors.count}>/</span>
672
- <span fg={colors.muted}> filter </span>
673
- <span fg={colors.count}>enter</span>
674
- <span fg={colors.muted}> select </span>
675
- <span fg={colors.count}>esc</span>
676
- <span fg={colors.muted}> cancel</span>
677
- </TextLine>
678
- </box>
679
- </ModalFrame>
645
+ )
646
+ })}
647
+ </StandardModal>
680
648
  )
681
649
  }