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
54 changes: 53 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, TerminalJson } from '@/components/terminal'
import { TerminalProgress } from '@/components/terminal-progress'
import { LogDemo } from './log-demo'
import { PromptDemo } from './prompt-demo'
Expand Down Expand Up @@ -175,6 +175,58 @@ export default function PlaygroundPage() {
</TerminalOutput>
</Terminal>
</section>

<section className="flex flex-col gap-2">
<h2 className="text-lg font-semibold font-mono text-[var(--term-fg)]">
TerminalJson
</h2>
<p className="text-sm text-[var(--term-fg-dim)] font-mono">
Pretty-print JSON with collapsible objects and arrays. Click ▶/▼ to expand/collapse.
</p>
<Terminal title="api-response.json">
<TerminalCommand>curl https://api.example.com/user | jq</TerminalCommand>
<TerminalOutput type="info">
<TerminalJson
data={{
user: {
id: 12345,
name: "Alice Johnson",
email: "[email protected]",
verified: true,
profile: {
avatar: "https://example.com/avatar.jpg",
bio: "Software engineer and open source contributor",
location: "San Francisco, CA",
},
settings: {
theme: "dark",
notifications: {
email: true,
push: false,
sms: false,
},
privacy: {
profileVisible: true,
showEmail: false,
},
},
stats: {
followers: 432,
following: 89,
posts: 156,
},
},
metadata: {
timestamp: "2024-03-01T10:30:00Z",
version: "2.1.0",
cached: false,
},
}}
collapsedDepth={2}
/>
</TerminalOutput>
</Terminal>
</section>
</main>
)
}
222 changes: 222 additions & 0 deletions components/terminal-json.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
'use client'

import { useState } from 'react'

export interface TerminalJsonProps {
/** JSON object or array to display */
data: any
/** Initially collapsed depth (default: null = all expanded) */
collapsedDepth?: number | null
/** Show line numbers (default: false) */
showLineNumbers?: boolean
/** Enable copying individual values (default: false) */
copyable?: boolean
/** Color variant for values (default: 'blue') */
variant?: 'green' | 'blue' | 'yellow' | 'red' | 'purple' | 'cyan'
/** Additional CSS classes */
className?: string
}

/**
* Pretty-print and collapse/expand JSON objects with syntax highlighting.
* Recursively renders nested objects and arrays with terminal-style colors.
*
* @param data - JSON object or array to display
* @param collapsedDepth - Depth at which to initially collapse (null = all expanded)
* @param showLineNumbers - Display line numbers (default: false)
* @param copyable - Enable copy buttons for individual values (default: false)
* @param variant - Color variant for highlighted elements
* @param className - Additional classes applied to the root element
*
* @example
* ```tsx
* <TerminalJson
* data={{
* name: "Alice",
* age: 30,
* settings: { theme: "dark", notifications: true }
* }}
* />
*
* <TerminalJson
* data={apiResponse}
* collapsedDepth={2}
* showLineNumbers
* />
* ```
*/
export function TerminalJson({
data,
collapsedDepth = null,
showLineNumbers = false,
copyable = false,
variant = 'blue',
className = '',
}: TerminalJsonProps) {
return (
<div className={`font-mono text-sm ${className}`.trim()}>
<JsonNode
data={data}
depth={0}
collapsedDepth={collapsedDepth}
showLineNumbers={showLineNumbers}
copyable={copyable}
variant={variant}
/>
</div>
)
}

interface JsonNodeProps {
data: any
depth: number
collapsedDepth: number | null
showLineNumbers: boolean
copyable: boolean
variant: string
path?: string
}

function JsonNode({
data,
depth,
collapsedDepth,
showLineNumbers,
copyable,
variant,
path = '',
}: JsonNodeProps) {
const shouldStartCollapsed = collapsedDepth !== null && depth >= collapsedDepth
const [collapsed, setCollapsed] = useState(shouldStartCollapsed)

const indent = ' '.repeat(depth)
const type = getType(data)

// Primitive values
if (type === 'null' || type === 'boolean' || type === 'number' || type === 'string') {
return (
<span>
<span style={{ color: getColor(type) }}>{formatValue(data, type)}</span>
</span>
)
}

// Arrays
if (type === 'array') {
const arr = data as any[]
if (arr.length === 0) {
return <span className="text-[var(--term-fg-dim)]">[]</span>
}

return (
<span>
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className="text-[var(--term-fg)] hover:text-[var(--term-blue)] transition-colors"
>
{collapsed ? '▶' : '▼'}
</button>
<span className="text-[var(--term-fg-dim)]"> [</span>
{collapsed ? (
<span className="text-[var(--term-fg-dim)]">...{arr.length} items]</span>
) : (
<>
<div>
{arr.map((item, i) => (
<div key={i} className="ml-4">
<JsonNode
data={item}
depth={depth + 1}
collapsedDepth={collapsedDepth}
showLineNumbers={showLineNumbers}
copyable={copyable}
variant={variant}
path={`${path}[${i}]`}
/>
{i < arr.length - 1 && <span className="text-[var(--term-fg-dim)]">,</span>}
</div>
))}
</div>
<span className="text-[var(--term-fg-dim)]">]</span>
</>
)}
</span>
)
}

// Objects
if (type === 'object') {
const keys = Object.keys(data)
if (keys.length === 0) {
return <span className="text-[var(--term-fg-dim)]">{'{}'}</span>
}

return (
<span>
<button
type="button"
onClick={() => setCollapsed(!collapsed)}
className="text-[var(--term-fg)] hover:text-[var(--term-blue)] transition-colors"
>
{collapsed ? '▶' : '▼'}
</button>
<span className="text-[var(--term-fg-dim)]"> {'{'}</span>
{collapsed ? (
<span className="text-[var(--term-fg-dim)]">...{keys.length} keys{'}'}</span>
) : (
<>
<div>
{keys.map((key, i) => (
<div key={key} className="ml-4">
<span style={{ color: 'var(--term-cyan)' }}>"{key}"</span>
<span className="text-[var(--term-fg-dim)]">: </span>
<JsonNode
data={data[key]}
depth={depth + 1}
collapsedDepth={collapsedDepth}
showLineNumbers={showLineNumbers}
copyable={copyable}
variant={variant}
path={`${path}.${key}`}
/>
{i < keys.length - 1 && <span className="text-[var(--term-fg-dim)]">,</span>}
</div>
))}
</div>
<span className="text-[var(--term-fg-dim)]">{'}'}</span>
</>
)}
</span>
)
}

return <span className="text-[var(--term-fg-dim)]">undefined</span>
}

function getType(value: any): string {
if (value === null) return 'null'
if (Array.isArray(value)) return 'array'
return typeof value
}

function getColor(type: string): string {
switch (type) {
case 'string':
return 'var(--term-green)'
case 'number':
return 'var(--term-yellow)'
case 'boolean':
return 'var(--term-purple)'
case 'null':
return 'var(--term-fg-dim)'
default:
return 'var(--term-fg)'
}
}

function formatValue(value: any, type: string): string {
if (type === 'string') return `"${value}"`
if (type === 'null') return 'null'
return String(value)
}
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 { TerminalJson } from './terminal-json'