Skip to content

Recipes

Common patterns for building Silvery apps.

tsx
import { Box, Text, useInputLayer } from "@silvery/term"

function ConfirmDialog({ message, onConfirm, onCancel }) {
  useInputLayer("confirm-dialog", (input, key) => {
    if (input === "y") {
      onConfirm()
      return true
    }
    if (input === "n" || key.escape) {
      onCancel()
      return true
    }
    return false
  })

  return (
    <Box borderStyle="round" paddingX={2} paddingY={1} flexDirection="column">
      <Text>{message}</Text>
      <Text dimColor>[y] Confirm [n] Cancel</Text>
    </Box>
  )
}

// Usage: conditionally render above your main content
function App() {
  const [showConfirm, setShowConfirm] = useState(false)
  return (
    <Box flexDirection="column">
      <MainContent />
      {showConfirm && (
        <ConfirmDialog
          message="Delete this item?"
          onConfirm={() => {
            deleteItem()
            setShowConfirm(false)
          }}
          onCancel={() => setShowConfirm(false)}
        />
      )}
    </Box>
  )
}

Search-Filter List

tsx
import { Box, Text, TextInput, useContentRect } from "@silvery/term"

function FilterList({ items }) {
  const [query, setQuery] = useState("")
  const [cursor, setCursor] = useState(0)
  const filtered = items.filter((item) => item.toLowerCase().includes(query.toLowerCase()))

  return (
    <Box flexDirection="column">
      <Box>
        <Text>Search: </Text>
        <TextInput
          value={query}
          onChange={(v) => {
            setQuery(v)
            setCursor(0)
          }}
        />
      </Box>
      {filtered.map((item, i) => (
        <Text key={item} color={i === cursor ? "green" : undefined}>
          {i === cursor ? "> " : "  "}
          {item}
        </Text>
      ))}
      <Text dimColor>
        {filtered.length} / {items.length} items
      </Text>
    </Box>
  )
}

Master-Detail Layout

tsx
import { Box, Text, useContentRect } from "@silvery/term"

function MasterDetail({ items, selectedIndex }) {
  const selected = items[selectedIndex]

  return (
    <Box flexDirection="row" width="100%">
      {/* Master: fixed-width list */}
      <Box flexDirection="column" width={30} borderStyle="single">
        {items.map((item, i) => (
          <Text key={item.id} inverse={i === selectedIndex}>
            {item.title}
          </Text>
        ))}
      </Box>

      {/* Detail: fills remaining space */}
      <Box flexDirection="column" flexGrow={1} paddingLeft={1}>
        <Text bold>{selected.title}</Text>
        <Text>{selected.body}</Text>
      </Box>
    </Box>
  )
}

Streaming Output (AI/LLM)

tsx
import { Box, Text, Static } from "@silvery/term"

function StreamingChat({ messages, streamingText }) {
  return (
    <Box flexDirection="column">
      {/* Completed messages scroll off the top */}
      <Static items={messages}>
        {(msg) => (
          <Box key={msg.id}>
            <Text bold color={msg.role === "user" ? "blue" : "green"}>
              {msg.role}:
            </Text>
            <Text> {msg.content}</Text>
          </Box>
        )}
      </Static>

      {/* Currently streaming response stays at bottom */}
      {streamingText && (
        <Box>
          <Text color="green">assistant: </Text>
          <Text>{streamingText}</Text>
          <Text dimColor>{"▌"}</Text>
        </Box>
      )}
    </Box>
  )
}

Pin a footer or status bar to the bottom of a container using stickyBottom:

tsx
import { Box, Text } from "@silvery/term"

function Layout({ children }) {
  return (
    <Box height="100%" flexDirection="column">
      <Box flexGrow={1}>{children}</Box>
      <Box position="sticky" stickyBottom={0} height={1} backgroundColor="blue">
        <Text color="white"> Status: Ready </Text>
      </Box>
    </Box>
  )
}

The footer stays at the bottom regardless of content height. When content grows to fill the container, the footer moves to its natural position (which is the bottom anyway).

Progress Tracking

tsx
import { Box, Text, ProgressBar, Spinner } from "@silvery/term"

function TaskProgress({ tasks }) {
  const done = tasks.filter((t) => t.status === "done").length
  const running = tasks.find((t) => t.status === "running")

  return (
    <Box flexDirection="column" gap={1}>
      <Box>
        <Text bold>Progress: </Text>
        <ProgressBar value={done / tasks.length} width={40} />
        <Text>
          {" "}
          {done}/{tasks.length}
        </Text>
      </Box>

      {running && (
        <Box>
          <Spinner type="dots" />
          <Text> {running.label}</Text>
        </Box>
      )}

      {tasks.map((task) => (
        <Text key={task.id} color={task.status === "done" ? "green" : "gray"}>
          {task.status === "done" ? "✓" : "○"} {task.label}
        </Text>
      ))}
    </Box>
  )
}

Released under the MIT License.