Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion app/playground/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TerminalApp } from '@/components/terminal-app'
import { Terminal, TerminalCommand, TerminalDiff, TerminalOutput, TerminalSpinner, TerminalBadge, ThemeSwitcher } from '@/components/terminal'
import { Terminal, TerminalCommand, TerminalDiff, TerminalOutput, TerminalSpinner, TerminalBadge, ThemeSwitcher, TerminalPager } from '@/components/terminal'
import { TerminalProgress } from '@/components/terminal-progress'
import { LogDemo } from './log-demo'
import { PromptDemo } from './prompt-demo'
Expand Down Expand Up @@ -72,6 +72,44 @@ export default function PlaygroundPage() {
<LogDemo />
</section>

<section className="flex flex-col gap-2">
<h2 className="text-lg font-semibold font-mono text-[var(--term-fg)]">
TerminalPager
</h2>
<p className="text-sm text-[var(--term-fg-dim)] font-mono">
Navigate through long content (like less/more). Use Space/↓ for next, b/↑ for previous.
</p>
<Terminal title="pager-demo.sh">
<TerminalCommand>cat long-file.log | less</TerminalCommand>
<TerminalPager
content={[
'[2024-03-01 10:00:01] INFO: Application started',
'[2024-03-01 10:00:02] INFO: Database connected',
'[2024-03-01 10:00:03] INFO: Cache initialized',
'[2024-03-01 10:00:04] DEBUG: Loading configuration',
'[2024-03-01 10:00:05] INFO: Server listening on port 3000',
'[2024-03-01 10:00:06] INFO: Worker thread 1 started',
'[2024-03-01 10:00:07] INFO: Worker thread 2 started',
'[2024-03-01 10:00:08] INFO: Worker thread 3 started',
'[2024-03-01 10:00:09] INFO: Worker thread 4 started',
'[2024-03-01 10:00:10] INFO: Ready to accept connections',
'[2024-03-01 10:00:11] INFO: Health check passed',
'[2024-03-01 10:00:12] INFO: Metrics endpoint active',
'[2024-03-01 10:00:13] DEBUG: Memory usage: 128MB',
'[2024-03-01 10:00:14] INFO: First request received',
'[2024-03-01 10:00:15] INFO: Request processed in 45ms',
'[2024-03-01 10:00:16] INFO: Cache hit ratio: 87%',
'[2024-03-01 10:00:17] DEBUG: Connection pool: 5/10',
'[2024-03-01 10:00:18] INFO: Batch job scheduled',
'[2024-03-01 10:00:19] INFO: Background task started',
'[2024-03-01 10:00:20] INFO: System status: healthy',
]}
pageSize={8}
variant="cyan"
/>
</Terminal>
</section>

<section className="flex flex-col gap-2">
<h2 className="text-lg font-semibold font-mono text-[var(--term-fg)]">
Copy Button
Expand Down
187 changes: 187 additions & 0 deletions components/terminal-pager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
'use client'

import { useState, useEffect, useCallback, useRef } from 'react'

export interface TerminalPagerProps {
/** Content to paginate (string or array of lines) */
content: string | string[]
/** Lines per page (default: 10) */
pageSize?: number
/** Callback when pager is exited */
onExit?: () => void
/** Show controls hint (default: true) */
showControls?: boolean
/** Color variant (default: 'blue') */
variant?: 'green' | 'blue' | 'yellow' | 'red' | 'purple' | 'cyan'
/** Additional CSS classes */
className?: string
}

const variantColors: Record<string, string> = {
green: 'var(--term-green)',
blue: 'var(--term-blue)',
yellow: 'var(--term-yellow)',
red: 'var(--term-red)',
purple: 'var(--term-purple)',
cyan: 'var(--term-cyan)',
}

/**
* Navigate through long output with keyboard controls (like `less` or `more`).
* Displays content page-by-page with progress indicator.
*
* @param content - Content to paginate (string or array of lines)
* @param pageSize - Number of lines per page (default: 10)
* @param onExit - Callback when user exits (q or Esc key)
* @param showControls - Display keyboard controls hint (default: true)
* @param variant - Color for progress indicator
* @param className - Additional classes applied to the root element
*
* @example
* ```tsx
* <TerminalPager
* content={longLogContent}
* pageSize={15}
* onExit={() => console.log('Pager closed')}
* />
*
* <TerminalPager
* content={['Line 1', 'Line 2', 'Line 3', ...]}
* variant="green"
* />
* ```
*/
export function TerminalPager({
content,
pageSize = 10,
onExit,
showControls = true,
variant = 'blue',
className = '',
}: TerminalPagerProps) {
const [currentPage, setCurrentPage] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const color = variantColors[variant]

// Convert content to lines array
const lines = typeof content === 'string' ? content.split('\n') : content
const totalPages = Math.max(1, Math.ceil(lines.length / pageSize))
const progress = Math.round(((currentPage + 1) / totalPages) * 100)

// Get current page lines
const startLine = currentPage * pageSize
const endLine = Math.min(startLine + pageSize, lines.length)
const currentLines = lines.slice(startLine, endLine)

const goToNextPage = useCallback(() => {
setCurrentPage((prev) => Math.min(prev + 1, totalPages - 1))
}, [totalPages])

const goToPrevPage = useCallback(() => {
setCurrentPage((prev) => Math.max(prev - 1, 0))
}, [])

const goToFirstPage = useCallback(() => {
setCurrentPage(0)
}, [])

const goToLastPage = useCallback(() => {
setCurrentPage(totalPages - 1)
}, [totalPages])

const handleExit = useCallback(() => {
onExit?.()
}, [onExit])

const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
switch (e.key) {
case ' ':
case 'Enter':
case 'j':
case 'ArrowDown':
e.preventDefault()
goToNextPage()
break
case 'b':
case 'k':
case 'ArrowUp':
e.preventDefault()
goToPrevPage()
break
case 'g':
e.preventDefault()
goToFirstPage()
break
case 'G':
e.preventDefault()
goToLastPage()
break
case 'q':
case 'Escape':
e.preventDefault()
handleExit()
break
}
},
[goToNextPage, goToPrevPage, goToFirstPage, goToLastPage, handleExit],
)

useEffect(() => {
const container = containerRef.current
if (!container) return

// Focus container on mount for keyboard events
container.focus()

window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])

return (
<div
ref={containerRef}
tabIndex={0}
className={`flex flex-col font-mono text-sm outline-none ${className}`.trim()}
role="region"
aria-label="Paginated content"
>
{/* Content */}
<div className="flex flex-col">
{currentLines.map((line, i) => (
<div key={startLine + i} className="text-[var(--term-fg)]">
{line}
</div>
))}
</div>

{/* Progress indicator */}
{totalPages > 1 && (
<div
className="mt-2 flex items-center gap-2 border-t border-[var(--glass-border)] pt-2"
style={{ color }}
>
<span className="font-bold">-- More --</span>
<span className="text-[var(--term-fg-dim)]">
({progress}% · {currentPage + 1}/{totalPages})
</span>
</div>
)}

{/* Controls hint */}
{showControls && (
<div className="mt-1 text-xs text-[var(--term-fg-dim)]">
{totalPages > 1 ? (
<>
<span className="opacity-70">
Space/↓: next · b/↑: back · g: first · G: last · q: quit
</span>
</>
) : (
<span className="opacity-70">q: quit</span>
)}
</div>
)}
</div>
)
}
1 change: 1 addition & 0 deletions components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,3 +264,4 @@ export { TerminalAutocomplete, useAutocomplete, COMMON_COMMANDS, COMMON_FLAGS, f
export { TerminalGhosttyTheme, GhosttyThemePicker } from './terminal-ghostty'
export { ThemeSwitcher } from './theme-switcher'
export { TerminalBadge } from './terminal-badge'
export { TerminalPager } from './terminal-pager'