lazyreview 0.1.4

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.
Files changed (78) hide show
  1. package/.github/workflows/publish-npm.yml +51 -0
  2. package/.prettierrc +7 -0
  3. package/CLAUDE.md +50 -0
  4. package/README.md +119 -0
  5. package/dist/cli.js +3830 -0
  6. package/dist/cli.js.map +1 -0
  7. package/package.json +64 -0
  8. package/pnpm-workspace.yaml +5 -0
  9. package/src/app.tsx +235 -0
  10. package/src/cli.tsx +78 -0
  11. package/src/components/common/BorderedBox.tsx +41 -0
  12. package/src/components/common/Divider.tsx +31 -0
  13. package/src/components/common/EmptyState.tsx +35 -0
  14. package/src/components/common/FilterModal.tsx +117 -0
  15. package/src/components/common/LoadingIndicator.tsx +31 -0
  16. package/src/components/common/Modal.tsx +24 -0
  17. package/src/components/common/PaginationBar.tsx +56 -0
  18. package/src/components/common/SortModal.tsx +91 -0
  19. package/src/components/common/Spinner.tsx +28 -0
  20. package/src/components/common/index.ts +9 -0
  21. package/src/components/layout/HelpModal.tsx +61 -0
  22. package/src/components/layout/MainPanel.tsx +26 -0
  23. package/src/components/layout/Sidebar.tsx +71 -0
  24. package/src/components/layout/StatusBar.tsx +42 -0
  25. package/src/components/layout/TokenInputModal.tsx +92 -0
  26. package/src/components/layout/TopBar.tsx +44 -0
  27. package/src/components/layout/index.ts +6 -0
  28. package/src/components/pr/CommentsTab.tsx +92 -0
  29. package/src/components/pr/CommitsTab.tsx +142 -0
  30. package/src/components/pr/ConversationsTab.tsx +273 -0
  31. package/src/components/pr/FilesTab.tsx +532 -0
  32. package/src/components/pr/PRHeader.tsx +69 -0
  33. package/src/components/pr/PRListItem.tsx +92 -0
  34. package/src/components/pr/PRTabs.tsx +50 -0
  35. package/src/components/pr/index.ts +8 -0
  36. package/src/hooks/index.ts +12 -0
  37. package/src/hooks/useActivePanel.ts +54 -0
  38. package/src/hooks/useAppKeymap.ts +22 -0
  39. package/src/hooks/useAuth.ts +131 -0
  40. package/src/hooks/useConfig.ts +52 -0
  41. package/src/hooks/useFilter.ts +192 -0
  42. package/src/hooks/useGitHub.ts +260 -0
  43. package/src/hooks/useInputFocus.tsx +35 -0
  44. package/src/hooks/useListNavigation.ts +121 -0
  45. package/src/hooks/useLoading.ts +25 -0
  46. package/src/hooks/usePagination.ts +87 -0
  47. package/src/models/comment.ts +15 -0
  48. package/src/models/commit.ts +20 -0
  49. package/src/models/diff.ts +93 -0
  50. package/src/models/errors.ts +24 -0
  51. package/src/models/file-change.ts +22 -0
  52. package/src/models/index.ts +12 -0
  53. package/src/models/pull-request.ts +40 -0
  54. package/src/models/review.ts +17 -0
  55. package/src/models/user.ts +9 -0
  56. package/src/screens/InvolvedScreen.tsx +161 -0
  57. package/src/screens/MyPRsScreen.tsx +161 -0
  58. package/src/screens/PRDetailScreen.tsx +88 -0
  59. package/src/screens/PRListScreen.tsx +96 -0
  60. package/src/screens/ReviewRequestsScreen.tsx +161 -0
  61. package/src/screens/SettingsScreen.tsx +284 -0
  62. package/src/screens/ThisRepoScreen.tsx +175 -0
  63. package/src/screens/index.ts +7 -0
  64. package/src/services/Auth.ts +314 -0
  65. package/src/services/Config.ts +81 -0
  66. package/src/services/GitHubApi.ts +262 -0
  67. package/src/services/Loading.ts +54 -0
  68. package/src/services/index.ts +27 -0
  69. package/src/theme/index.ts +28 -0
  70. package/src/theme/themes.ts +84 -0
  71. package/src/theme/types.ts +28 -0
  72. package/src/utils/date.ts +28 -0
  73. package/src/utils/git.ts +67 -0
  74. package/src/utils/index.ts +2 -0
  75. package/src/utils/terminal.ts +25 -0
  76. package/tsconfig.json +24 -0
  77. package/tsup.config.ts +13 -0
  78. package/vitest.config.ts +26 -0
@@ -0,0 +1,56 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { useTheme } from '../../theme/index'
4
+
5
+ interface PaginationBarProps {
6
+ readonly currentPage: number
7
+ readonly totalPages: number
8
+ readonly totalItems: number
9
+ readonly startIndex: number
10
+ readonly endIndex: number
11
+ readonly hasNextPage: boolean
12
+ readonly hasPrevPage: boolean
13
+ }
14
+
15
+ export function PaginationBar({
16
+ currentPage,
17
+ totalPages,
18
+ totalItems,
19
+ startIndex,
20
+ endIndex,
21
+ hasNextPage,
22
+ hasPrevPage,
23
+ }: PaginationBarProps): React.ReactElement {
24
+ const theme = useTheme()
25
+
26
+ if (totalPages <= 1) {
27
+ return (
28
+ <Box paddingX={1}>
29
+ <Text color={theme.colors.muted}>
30
+ {totalItems} item{totalItems !== 1 ? 's' : ''}
31
+ </Text>
32
+ </Box>
33
+ )
34
+ }
35
+
36
+ return (
37
+ <Box paddingX={1} gap={2}>
38
+ <Text color={theme.colors.muted}>
39
+ {startIndex + 1}-{endIndex} of {totalItems}
40
+ </Text>
41
+ <Box gap={1}>
42
+ <Text color={hasPrevPage ? theme.colors.accent : theme.colors.muted}>
43
+ {hasPrevPage ? '← [p]rev' : '← prev'}
44
+ </Text>
45
+ <Text color={theme.colors.muted}>│</Text>
46
+ <Text color={theme.colors.text}>
47
+ Page {currentPage}/{totalPages}
48
+ </Text>
49
+ <Text color={theme.colors.muted}>│</Text>
50
+ <Text color={hasNextPage ? theme.colors.accent : theme.colors.muted}>
51
+ {hasNextPage ? '[n]ext →' : 'next →'}
52
+ </Text>
53
+ </Box>
54
+ </Box>
55
+ )
56
+ }
@@ -0,0 +1,91 @@
1
+ import React, { useMemo } from 'react'
2
+ import { Box, Text, useInput } from 'ink'
3
+ import SelectInput from 'ink-select-input'
4
+ import { useTheme } from '../../theme/index'
5
+ import { Modal } from './Modal'
6
+ import type { SortField, SortDirection } from '../../hooks/useFilter'
7
+
8
+ const SORT_OPTIONS: readonly { key: SortField; label: string }[] = [
9
+ { key: 'updated', label: 'Last Updated' },
10
+ { key: 'created', label: 'Created Date' },
11
+ { key: 'repo', label: 'Repository' },
12
+ { key: 'author', label: 'Author' },
13
+ { key: 'title', label: 'Title' },
14
+ ]
15
+
16
+ interface SortModalProps {
17
+ readonly currentSort: SortField
18
+ readonly sortDirection: SortDirection
19
+ readonly onSortChange: (sortBy: SortField) => void
20
+ readonly onSortDirectionToggle: () => void
21
+ readonly onClose: () => void
22
+ }
23
+
24
+ export function SortModal({
25
+ currentSort,
26
+ sortDirection,
27
+ onSortChange,
28
+ onSortDirectionToggle,
29
+ onClose,
30
+ }: SortModalProps): React.ReactElement {
31
+ const theme = useTheme()
32
+
33
+ useInput((input, key) => {
34
+ if (key.escape || input === 's') {
35
+ onClose()
36
+ }
37
+ })
38
+
39
+ const items = useMemo(
40
+ () =>
41
+ SORT_OPTIONS.map((option) => ({
42
+ label: `${option.label}${option.key === currentSort ? (sortDirection === 'desc' ? ' ↓' : ' ↑') : ''}`,
43
+ value: option.key,
44
+ })),
45
+ [currentSort, sortDirection],
46
+ )
47
+
48
+ const initialIndex = Math.max(
49
+ 0,
50
+ SORT_OPTIONS.findIndex((o) => o.key === currentSort),
51
+ )
52
+
53
+ const handleSelect = (item: { label: string; value: SortField }) => {
54
+ if (item.value === currentSort) {
55
+ onSortDirectionToggle()
56
+ } else {
57
+ onSortChange(item.value)
58
+ }
59
+ onClose()
60
+ }
61
+
62
+ return (
63
+ <Modal>
64
+ <Box
65
+ flexDirection="column"
66
+ borderStyle="round"
67
+ borderColor={theme.colors.accent}
68
+ // @ts-ignore
69
+ backgroundColor={theme.colors.bg}
70
+ paddingX={2}
71
+ paddingY={1}
72
+ gap={1}
73
+ >
74
+ <Text color={theme.colors.accent} bold>
75
+ Sort by
76
+ </Text>
77
+
78
+ <SelectInput
79
+ items={items}
80
+ initialIndex={initialIndex}
81
+ onSelect={handleSelect}
82
+ isFocused={true}
83
+ />
84
+
85
+ <Text color={theme.colors.muted} dimColor>
86
+ j/k: move | Enter: select | Esc: close
87
+ </Text>
88
+ </Box>
89
+ </Modal>
90
+ )
91
+ }
@@ -0,0 +1,28 @@
1
+ import React, { useState, useEffect } from 'react'
2
+ import { Text } from 'ink'
3
+ import { useTheme } from '../../theme/index'
4
+
5
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
6
+
7
+ interface SpinnerProps {
8
+ readonly label?: string
9
+ }
10
+
11
+ export function Spinner({ label }: SpinnerProps): React.ReactElement {
12
+ const theme = useTheme()
13
+ const [frame, setFrame] = useState(0)
14
+
15
+ useEffect(() => {
16
+ const timer = setInterval(() => {
17
+ setFrame((prev) => (prev + 1) % SPINNER_FRAMES.length)
18
+ }, 80)
19
+
20
+ return () => clearInterval(timer)
21
+ }, [])
22
+
23
+ return (
24
+ <Text color={theme.colors.accent}>
25
+ {SPINNER_FRAMES[frame]} {label}
26
+ </Text>
27
+ )
28
+ }
@@ -0,0 +1,9 @@
1
+ export { LoadingIndicator } from './LoadingIndicator'
2
+ export { EmptyState } from './EmptyState'
3
+ export { Divider } from './Divider'
4
+ export { Spinner } from './Spinner'
5
+ export { BorderedBox } from './BorderedBox'
6
+ export { Modal } from './Modal'
7
+ export { PaginationBar } from './PaginationBar'
8
+ export { FilterModal } from './FilterModal'
9
+ export { SortModal } from './SortModal'
@@ -0,0 +1,61 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { useTheme } from '../../theme/index'
4
+ import { Divider } from '../common/Divider'
5
+ import { Modal } from '../common/Modal'
6
+
7
+ interface HelpModalProps {
8
+ readonly onClose: () => void
9
+ }
10
+
11
+ const shortcuts = [
12
+ { key: 'j / k', description: 'Move down / up' },
13
+ { key: 'Enter', description: 'Select / Open' },
14
+ { key: 'Tab', description: 'Switch focus panel' },
15
+ { key: 'b', description: 'Toggle sidebar' },
16
+ { key: '/', description: 'Search / Filter PRs' },
17
+ { key: 's', description: 'Sort PRs' },
18
+ { key: 'n / p', description: 'Next / Previous page' },
19
+ { key: '1 / 2 / 3', description: 'Switch PR detail tabs' },
20
+ { key: 'q', description: 'Back / Quit' },
21
+ { key: '?', description: 'Toggle this help' },
22
+ { key: 'Ctrl+c', description: 'Force quit' },
23
+ ]
24
+
25
+ export function HelpModal({ onClose }: HelpModalProps): React.ReactElement {
26
+ const theme = useTheme()
27
+
28
+ return (
29
+ <Modal>
30
+ <Box
31
+ flexDirection="column"
32
+ borderStyle="round"
33
+ borderColor={theme.colors.accent}
34
+ // @ts-ignore
35
+ backgroundColor={theme.colors.bg}
36
+ paddingX={2}
37
+ paddingY={1}
38
+ gap={1}
39
+ >
40
+ <Text color={theme.colors.accent} bold>
41
+ Keyboard Shortcuts
42
+ </Text>
43
+ <Divider />
44
+ <Box flexDirection="column">
45
+ {shortcuts.map((s) => (
46
+ <Box key={s.key} gap={2}>
47
+ <Box width={16}>
48
+ <Text color={theme.colors.warning}>{s.key}</Text>
49
+ </Box>
50
+ <Text color={theme.colors.text}>{s.description}</Text>
51
+ </Box>
52
+ ))}
53
+ </Box>
54
+ <Divider />
55
+ <Text color={theme.colors.muted} dimColor>
56
+ Press ? to close
57
+ </Text>
58
+ </Box>
59
+ </Modal>
60
+ )
61
+ }
@@ -0,0 +1,26 @@
1
+ import React from 'react'
2
+ import { Box } from 'ink'
3
+ import { useTheme } from '../../theme/index'
4
+
5
+ interface MainPanelProps {
6
+ readonly children: React.ReactNode
7
+ readonly isActive?: boolean
8
+ }
9
+
10
+ export function MainPanel({
11
+ children,
12
+ isActive = false,
13
+ }: MainPanelProps): React.ReactElement {
14
+ const theme = useTheme()
15
+
16
+ return (
17
+ <Box
18
+ flexDirection="column"
19
+ flexGrow={1}
20
+ borderStyle="single"
21
+ borderColor={isActive ? theme.colors.accent : theme.colors.border}
22
+ >
23
+ {children}
24
+ </Box>
25
+ )
26
+ }
@@ -0,0 +1,71 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { useTheme } from '../../theme/index'
4
+
5
+ export const SIDEBAR_ITEMS = [
6
+ 'Involved',
7
+ 'My PRs',
8
+ 'For Review',
9
+ 'This Repo',
10
+ 'Settings',
11
+ ] as const
12
+
13
+ export type SidebarItem = (typeof SIDEBAR_ITEMS)[number]
14
+
15
+ const sidebarIcons: Record<SidebarItem, string> = {
16
+ Involved: '◆',
17
+ 'My PRs': '●',
18
+ 'For Review': '◎',
19
+ 'This Repo': '◈',
20
+ Settings: '⚙',
21
+ }
22
+
23
+ interface SidebarProps {
24
+ readonly selectedIndex: number
25
+ readonly visible: boolean
26
+ readonly isActive: boolean
27
+ }
28
+
29
+ export function Sidebar({
30
+ selectedIndex,
31
+ visible,
32
+ isActive,
33
+ }: SidebarProps): React.ReactElement | null {
34
+ const theme = useTheme()
35
+
36
+ if (!visible) return null
37
+
38
+ return (
39
+ <Box
40
+ flexDirection="column"
41
+ width={24}
42
+ borderStyle="single"
43
+ borderColor={isActive ? theme.colors.accent : theme.colors.border}
44
+ >
45
+ <Box paddingX={1} paddingY={0}>
46
+ <Text color={theme.colors.accent} bold={isActive} dimColor={!isActive}>
47
+ Navigation
48
+ </Text>
49
+ </Box>
50
+ <Box flexDirection="column" paddingTop={1}>
51
+ {SIDEBAR_ITEMS.map((label, index) => {
52
+ const isSelected = index === selectedIndex
53
+ const icon = sidebarIcons[label]
54
+ return (
55
+ <Box key={label} paddingX={1}>
56
+ <Text
57
+ color={isSelected ? theme.colors.accent : theme.colors.text}
58
+ backgroundColor={isSelected ? theme.colors.selection : undefined}
59
+ bold={isSelected}
60
+ dimColor={!isActive && !isSelected}
61
+ >
62
+ {isSelected ? '▸ ' : ' '}
63
+ {icon} {label}
64
+ </Text>
65
+ </Box>
66
+ )
67
+ })}
68
+ </Box>
69
+ </Box>
70
+ )
71
+ }
@@ -0,0 +1,42 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { Spinner } from '../common/Spinner'
4
+ import { useTheme } from '../../theme/index'
5
+ import { useLoading } from '../../hooks/useLoading'
6
+ import type { Panel } from '../../hooks/useActivePanel'
7
+
8
+ const PANEL_HINTS: Record<Panel, string> = {
9
+ sidebar: 'j/k:nav gg/G:top/bottom Enter:select Tab:list b:toggle ?:help q:quit',
10
+ list: 'j/k:nav gg/G:top/bottom Enter:detail Esc:sidebar Tab:next ?:help q:quit',
11
+ detail: 'j/k:scroll Tab:tabs Esc:list ?:help q:quit',
12
+ }
13
+
14
+ interface StatusBarProps {
15
+ readonly activePanel?: Panel
16
+ }
17
+
18
+ export function StatusBar({ activePanel = 'sidebar' }: StatusBarProps): React.ReactElement {
19
+ const theme = useTheme()
20
+ const loadingState = useLoading()
21
+ const hints = PANEL_HINTS[activePanel]
22
+
23
+ return (
24
+ <Box
25
+ height={1}
26
+ width="100%"
27
+ justifyContent="space-between"
28
+ paddingX={1}
29
+ >
30
+ <Box>
31
+ {loadingState.isLoading ? (
32
+ <Spinner label={loadingState.message ?? 'Loading...'} />
33
+ ) : (
34
+ <Text color={theme.colors.success}>Ready</Text>
35
+ )}
36
+ </Box>
37
+ <Box gap={1}>
38
+ <Text color={theme.colors.muted}>{hints}</Text>
39
+ </Box>
40
+ </Box>
41
+ )
42
+ }
@@ -0,0 +1,92 @@
1
+ import React, { useState, useEffect } from 'react'
2
+ import { Box, Text, useInput } from 'ink'
3
+ import { TextInput } from '@inkjs/ui'
4
+ import { useTheme } from '../../theme/index'
5
+ import { useInputFocus } from '../../hooks/useInputFocus'
6
+ import { Modal } from '../common/Modal'
7
+
8
+ interface TokenInputModalProps {
9
+ readonly onClose: () => void
10
+ readonly onSubmit: (token: string) => void
11
+ readonly error?: string | null
12
+ }
13
+
14
+ export function TokenInputModal({
15
+ onSubmit,
16
+ error,
17
+ }: TokenInputModalProps): React.ReactElement {
18
+ const theme = useTheme()
19
+ const { setInputActive } = useInputFocus()
20
+ const [value, setValue] = useState('')
21
+
22
+ // Disable global shortcuts while this modal is open
23
+ useEffect(() => {
24
+ setInputActive(true)
25
+ return () => setInputActive(false)
26
+ }, [setInputActive])
27
+
28
+ const handleSubmit = (): void => {
29
+ const trimmed = value.trim()
30
+ if (trimmed) {
31
+ onSubmit(trimmed)
32
+ }
33
+ }
34
+
35
+ useInput((_input, key) => {
36
+ if (key.return) {
37
+ handleSubmit()
38
+ }
39
+ })
40
+
41
+ return (
42
+ <Modal>
43
+ <Box
44
+ flexDirection="column"
45
+ borderStyle="round"
46
+ borderColor={theme.colors.accent}
47
+ // @ts-ignore
48
+ backgroundColor={theme.colors.bg}
49
+ paddingX={2}
50
+ paddingY={1}
51
+ gap={1}
52
+ >
53
+ <Text color={theme.colors.accent} bold>
54
+ GitHub Token Required
55
+ </Text>
56
+ <Box flexDirection="column">
57
+ <Text color={theme.colors.text}>
58
+ No GitHub token found in your environment.
59
+ </Text>
60
+ <Text color={theme.colors.muted}>
61
+ Please enter your GitHub Personal Access Token:
62
+ </Text>
63
+ </Box>
64
+ {error && <Text color={theme.colors.error}>Error: {error}</Text>}
65
+ <Box
66
+ borderStyle="single"
67
+ borderColor={theme.colors.border}
68
+ paddingX={1}
69
+ width={50}
70
+ >
71
+ <TextInput defaultValue={value} onChange={setValue} />
72
+ </Box>
73
+ <Box flexDirection="column">
74
+ <Text color={theme.colors.muted} dimColor>
75
+ The token will be saved to ~/.config/lazyreview/.token
76
+ </Text>
77
+ <Text color={theme.colors.muted} dimColor>
78
+ Press Enter to submit, Ctrl+C to quit.
79
+ </Text>
80
+ </Box>
81
+ <Box flexDirection="column" marginTop={1}>
82
+ <Text color={theme.colors.info}>
83
+ Get a token at: github.com/settings/tokens
84
+ </Text>
85
+ <Text color={theme.colors.muted}>
86
+ Required scopes: repo, read:user
87
+ </Text>
88
+ </Box>
89
+ </Box>
90
+ </Modal>
91
+ )
92
+ }
@@ -0,0 +1,44 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { useTheme } from '../../theme/index'
4
+
5
+ interface TopBarProps {
6
+ readonly username: string
7
+ readonly provider: string
8
+ readonly repoPath?: string
9
+ }
10
+
11
+ export function TopBar({
12
+ username,
13
+ provider,
14
+ repoPath,
15
+ }: TopBarProps): React.ReactElement {
16
+ const theme = useTheme()
17
+
18
+ return (
19
+ <Box
20
+ height={1}
21
+ width="100%"
22
+ justifyContent="space-between"
23
+ paddingX={1}
24
+ marginTop={0.6}
25
+ >
26
+ <Box gap={1}>
27
+ <Text color={theme.colors.accent} bold>
28
+ LazyReview
29
+ </Text>
30
+ {repoPath && (
31
+ <>
32
+ <Text color={theme.colors.muted}>│</Text>
33
+ <Text color={theme.colors.text}>{repoPath}</Text>
34
+ </>
35
+ )}
36
+ </Box>
37
+ <Box gap={1}>
38
+ <Text color={theme.colors.muted}>{provider}</Text>
39
+ <Text color={theme.colors.muted}>│</Text>
40
+ <Text color={theme.colors.secondary}>{username}</Text>
41
+ </Box>
42
+ </Box>
43
+ )
44
+ }
@@ -0,0 +1,6 @@
1
+ export { TopBar } from './TopBar'
2
+ export { Sidebar, SIDEBAR_ITEMS } from './Sidebar'
3
+ export type { SidebarItem } from './Sidebar'
4
+ export { MainPanel } from './MainPanel'
5
+ export { StatusBar } from './StatusBar'
6
+ export { HelpModal } from './HelpModal'
@@ -0,0 +1,92 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+ import { Box, Text, useStdout } from 'ink'
3
+ import { ScrollList, type ScrollListRef } from 'ink-scroll-list'
4
+ import { useTheme } from '../../theme/index'
5
+ import { useListNavigation } from '../../hooks/useListNavigation'
6
+ import type { Comment } from '../../models/comment'
7
+ import { timeAgo } from '../../utils/date'
8
+ import { EmptyState } from '../common/EmptyState'
9
+
10
+ interface CommentsTabProps {
11
+ readonly comments: readonly Comment[]
12
+ }
13
+
14
+ interface CommentItemProps {
15
+ readonly item: Comment
16
+ readonly isFocus: boolean
17
+ }
18
+
19
+ function CommentItem({ item, isFocus }: CommentItemProps): React.ReactElement {
20
+ const theme = useTheme()
21
+
22
+ return (
23
+ <Box
24
+ flexDirection="column"
25
+ paddingX={1}
26
+ paddingY={0}
27
+ borderStyle={isFocus ? 'single' : undefined}
28
+ borderColor={isFocus ? theme.colors.accent : undefined}
29
+ >
30
+ <Box gap={1}>
31
+ <Text color={theme.colors.secondary} bold>
32
+ {item.user.login}
33
+ </Text>
34
+ <Text color={theme.colors.muted}>{timeAgo(item.created_at)}</Text>
35
+ {item.path && (
36
+ <>
37
+ <Text color={theme.colors.muted}>on</Text>
38
+ <Text color={theme.colors.info}>{item.path}</Text>
39
+ {item.line && (
40
+ <Text color={theme.colors.muted}>:{item.line}</Text>
41
+ )}
42
+ </>
43
+ )}
44
+ </Box>
45
+ <Box paddingLeft={2}>
46
+ <Text color={theme.colors.text} wrap="wrap">
47
+ {item.body}
48
+ </Text>
49
+ </Box>
50
+ </Box>
51
+ )
52
+ }
53
+
54
+ export function CommentsTab({
55
+ comments,
56
+ }: CommentsTabProps): React.ReactElement {
57
+ const { stdout } = useStdout()
58
+ const listRef = useRef<ScrollListRef>(null)
59
+ const viewportHeight = Math.max(1, Math.floor((stdout?.rows ?? 24) - 8) / 4)
60
+
61
+ const { selectedIndex } = useListNavigation({
62
+ itemCount: comments.length,
63
+ viewportHeight,
64
+ isActive: true,
65
+ })
66
+
67
+ useEffect(() => {
68
+ const handleResize = (): void => listRef.current?.remeasure()
69
+ stdout?.on('resize', handleResize)
70
+ return () => {
71
+ stdout?.off('resize', handleResize)
72
+ }
73
+ }, [stdout])
74
+
75
+ if (comments.length === 0) {
76
+ return <EmptyState message="No comments yet" />
77
+ }
78
+
79
+ return (
80
+ <Box flexDirection="column" flexGrow={1} overflow="hidden" height={viewportHeight}>
81
+ <ScrollList ref={listRef} selectedIndex={selectedIndex} scrollAlignment="auto">
82
+ {comments.map((comment, index) => (
83
+ <CommentItem
84
+ key={comment.id}
85
+ item={comment}
86
+ isFocus={index === selectedIndex}
87
+ />
88
+ ))}
89
+ </ScrollList>
90
+ </Box>
91
+ )
92
+ }