# Terminal

> Streamed command output that behaves like a real terminal: ANSI colours, progress-bar rewrites, stick-to-bottom, live clock and exit-code badge.

- **Slug:** `terminal` · **Category:** workspace · **Status:** experimental
- **Import:** `import { AnsiColor, AnsiScreen, AnsiSpan, AnsiStyle, Terminal, TerminalStatus } from "@kura-ui/react/terminal";`
- **Docs:** https://kuro.abstergo.fyi/components/terminal
- **Registry item:** https://kuro.abstergo.fyi/r/terminal.json
- **Tags:** terminal, shell, logs, ansi, streaming, command, agent, workspace

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/terminal.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add terminal
# npm package
npm install @kura-ui/react
```

## When to use

- Show a shell command an agent ran (pnpm test, git status, a build) with its output, while it runs and after.
- Stream a child process's stdout (AsyncIterable<string>) or a growing log string into the UI.
- Long logs where the tail matters: collapse to the last N lines with “Show all N lines”.
- A dev-server or watcher log with a Stop control in the header.

## When not to use

- Source code or a snippet to copy — use code-block.
- Reviewing file edits — use diff-view.
- A tool call whose output is structured JSON — use tool-call.
- Interactive shells that take keyboard input — this renders output only (no PTY, no input line).

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `command` | `string` | — | no | The command, shown on the prompt line (`$ pnpm test`) and in the accessible name. |
| `lines` | `readonly string[] \| string` | — | no | Output as lines or one growing string. ANSI SGR (colours 30–37/90–97, 256/truecolour approximated, bold, dim, italic, underline, inverse) and cursor controls (\r, erase line, cursor up, backspace) are rendered; OSC titles/links are stripped. Append-only updates are parsed incrementally. |
| `stream` | `AsyncIterable<string>` | — | no | Output as a stream of chunks; takes precedence over `lines`. Chunks may split lines and escape sequences. Status is “running” until it ends. |
| `onStreamEnd` | `() => void` | — | no | Called once `stream` has ended. |
| `status` | `"idle" \| "running" \| "done" \| "failed"` | — | no | Lifecycle. Default: from exitCode (0 → done, otherwise failed), "running" while a stream is open, else "idle" (no glyph). |
| `exitCode` | `number \| null` | — | no | Exit code, shown as a badge once finished (red when non-zero). |
| `startedAt` | `Date \| number` | — | no | Start time for the live clock. Default: when status became running. |
| `duration` | `number` | — | no | Final duration in ms for persisted runs. Default: measured from running → finished in this session. |
| `cwd` | `string` | — | no | Working directory in the header, e.g. "~/acme/web". |
| `title` | `React.ReactNode` | — | no | Header title, e.g. "Run tests". Default: the cwd, or "Terminal". |
| `prompt` | `string` | `"$"` | no | Prompt glyph before the command. |
| `maxHeight` | `number` | `320` | no | Viewport height in px. Output sticks to the bottom while the user is there; scrolling up shows “Jump to latest” / “N new lines”. |
| `maxLines` | `number` | — | no | Collapse to the last N lines with a “Show all N lines” control at the top. |
| `expanded` | `boolean` | — | no | Showing every line past maxLines (controlled). |
| `defaultExpanded` | `boolean` | `false` | no | Initial expanded state when uncontrolled. |
| `onExpandedChange` | `(expanded: boolean) => void` | — | no | Called when “Show all” / “Show last N” is used. |
| `wrap` | `boolean` | `true` | no | Soft-wrap long lines. |
| `copyable` | `boolean` | `true` | no | Show the copy button: copies `$ command` plus the plain output (no escape codes). |
| `onCopy` | `(text: string) => void` | — | no | Called with the copied text after a successful copy. |
| `header` | `boolean` | `true` | no | Show the header row. |
| `actions` | `React.ReactNode` | — | no | Extra header controls before the copy button, e.g. a Stop Button. |

Native attributes of the root element, `className` and `ref` are passed through.

## Anatomy

- **Terminal** (`data-slot="terminal"`) — `data-status="idle" | "running" | "done" | "failed"`, `data-state="collapsed" | "expanded"` when maxLines applies.
- **Header** (`data-slot="terminal-header"`) — Status glyph, title (`terminal-title`), cwd, live clock / duration (`terminal-elapsed`), exit badge (`terminal-exit-code`), actions, copy.
- **Output** (`data-slot="terminal-output"`) — `role="log"` scroll viewport (aria-live off), keyboard-scrollable; prompt line (`terminal-command`) and lines (`terminal-line`).
- **Cursor** (`data-slot="terminal-cursor"`) — Block cursor at the live edge while running.
- **Expand** (`data-slot="terminal-expand"`) — “Show all N lines” row when collapsed (`terminal-collapse` to collapse again).
- **Jump** (`data-slot="terminal-jump"`) — Floating “Jump to latest” / “N new lines” pill when scrolled away from the bottom.

## Accessibility

- Role: `log`
- The output is a role=log region named “Output of <command>” with aria-live off, so streaming never floods a screen reader.
- A separate polite status announces only transitions: “pnpm test running”, “pnpm test failed, exit code 1”, and copy confirmation.
- The viewport is focusable (Tab) and scrolls with arrow keys / Page Up / Page Down.
- Status is text as well as colour: the exit badge reads “exit 1”; the glyph is decorative next to it.
- Reduced motion: The cursor stops pulsing, the running glyph stops spinning, and the copy icon swap and pill entrance become plain fades. Jump to latest still scrolls smoothly only if the browser allows it.

| Keys | Action |
| --- | --- |
| Tab | Focus the output, then the expand, jump, action and copy buttons |
| Arrow keys / Page Up / Page Down | Scroll the focused output |
| Enter / Space | Activate Show all / Jump to latest / Copy |

## Notes for agents

- Pass raw process output; don't strip ANSI codes. Escape sequences split across chunks are buffered.
- For a growing string, only append — replacing earlier text triggers a full re-parse (still correct, just slower).
- Keep the same Terminal mounted from running → done so the measured duration works; for history pass `duration`.
- `stream` is consumed once; create a new iterable to restart. Set `exitCode` in onStreamEnd to finish with a badge.
- Colours map to Kura tokens; override any of them with --kura-terminal-{red,green,yellow,blue,magenta,cyan,white,gray,black} on a parent.
- Helpers exported alongside: parseAnsi(text), stripAnsi(text), AnsiScreen (incremental).

## Examples

### Finished command (minimal)

`git status --short` with coloured output and an exit badge.

```tsx
import { Terminal } from "@kura-ui/react/terminal";

const red = (s: string) => `\x1b[31m${s}\x1b[39m`;

export function TerminalMinimal() {
  return (
    <Terminal
      className="w-full max-w-2xl"
      cwd="~/acme/web"
      command="git status --short"
      exitCode={0}
      lines={[
        ` ${red("M")} src/auth/client.ts`,
        ` ${red("M")} src/middleware.ts`,
        ` ${red("M")} src/auth/__tests__/login.test.ts`,
        ` ${red("D")} src/lib/legacy-session.ts`,
      ]}
    />
  );
}
```

### Streaming test run (full)

`pnpm test` streams vitest output line by line (live clock, cursor, stick-to-bottom), ends with one failing test and exit 1.

```tsx
"use client";

import * as React from "react";
import { Terminal } from "@kura-ui/react/terminal";

const TEST_OUTPUT = [
  "",
  `> @acme/web@0.42.0 test ${dim("/Users/kura/acme/web")}`,
  "> vitest run",
  "",
  ` ${badge("RUN", 36)} ${cyan("v3.2.4")} ${gray("/Users/kura/acme/web")}`,
  "",
  ` ${green("✓")} ${dim("src/auth/__tests__/")}session.test.ts ${gray("(6 tests)")} ${gray("41ms")}`,
  ` ${green("✓")} ${dim("src/")}middleware.test.ts ${gray("(4 tests)")} ${gray("18ms")}`,
  ` ${green("✓")} ${dim("src/routes/__tests__/")}account.test.ts ${gray("(9 tests)")} ${gray("112ms")}`,
  ` ${yellow("❯")} ${dim("src/auth/__tests__/")}login.test.ts ${gray("(5 tests | ")}${red("1 failed")}${gray(")")} ${gray("87ms")}`,
  `   ${green("✓")} login ${gray(">")} detects an expired session ${gray("2ms")}`,
  `   ${red("×")} login ${gray(">")} refreshes an expired session ${gray("14ms")}`,
  `     ${red("→ expected 'tok_expired' to be 'tok_next' // Object.is equality")}`,
  ` ${green("✓")} ${dim("src/lib/__tests__/")}http.test.ts ${gray("(7 tests)")} ${gray("9ms")}`,
  "",
  red(`⎯⎯⎯⎯⎯⎯⎯ ${bold("Failed Tests 1")} ⎯⎯⎯⎯⎯⎯⎯`),
  "",
  ` ${badge("FAIL", 31)} src/auth/__tests__/login.test.ts ${gray(">")} login ${gray(">")} refreshes an expired session`,
  `${red(bold("AssertionError"))}${red(": expected 'tok_expired' to be 'tok_next' // Object.is equality")}`,
  "",
  `${green("- Expected")}`,
  `${red("+ Received")}`,
  "",
  `${green("- tok_next")}`,
  `${red("+ tok_expired")}`,
  "",
  ` ${cyan("❯")} src/auth/__tests__/login.test.ts:18:32`,
  gray("     16|     const next = await refreshSession(session);"),
  gray('     17|     expect(next.user.id).toBe("u_1");'),
  `     18|     expect(next.refreshToken).toBe(${green('"tok_next"')});`,
  `       | ${red("                               ^")}`,
  "",
  ` ${dim("Test Files")}  ${bold(red("1 failed"))} ${gray("|")} ${bold(green("4 passed"))} ${gray("(5)")}`,
  `      ${dim("Tests")}  ${bold(red("1 failed"))} ${gray("|")} ${bold(green("30 passed"))} ${gray("(31)")}`,
  `   ${dim("Start at")}  14:02:11`,
  `   ${dim("Duration")}  1.42s ${gray("(transform 212ms, setup 0ms, collect 610ms, tests 267ms)")}`,
  "",
  `${red(" ELIFECYCLE ")} ${red("Test failed. See above for more details.")}`,
];

async function* replay(lines: string[], signal: { cancelled: boolean }) {
  for (const [i, line] of lines.entries()) {
    // Collection is slow, results arrive in bursts, the summary lands at once.
    const wait = i < 4 ? 260 : i < 14 ? 180 + (i % 3) * 140 : 35;
    await new Promise((r) => setTimeout(r, wait));
    if (signal.cancelled) return;
    yield `${line}\n`;
  }
}

const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;

const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;

const red = (s: string) => `\x1b[31m${s}\x1b[39m`;

const green = (s: string) => `\x1b[32m${s}\x1b[39m`;

const yellow = (s: string) => `\x1b[33m${s}\x1b[39m`;

const cyan = (s: string) => `\x1b[36m${s}\x1b[39m`;

const gray = (s: string) => `\x1b[90m${s}\x1b[39m`;

const badge = (s: string, color: 31 | 32 | 34 | 36) => `\x1b[1;7;${color}m ${s} \x1b[0m`;

export function TerminalFull() {
  const [stream, setStream] = React.useState<AsyncIterable<string>>();
  const [exitCode, setExitCode] = React.useState<number | null>(null);
  React.useEffect(() => {
    const signal = { cancelled: false };
    setStream(replay(TEST_OUTPUT, signal));
    return () => {
      signal.cancelled = true;
    };
  }, []);
  return (
    <Terminal
      className="w-full max-w-2xl"
      title="Run tests"
      cwd="~/acme/web"
      command="pnpm test"
      stream={stream}
      status={exitCode == null ? "running" : undefined}
      exitCode={exitCode}
      onStreamEnd={() => setExitCode(1)}
      maxHeight={360}
    />
  );
}
```

### Collapsed build log (state)

A Next.js build collapsed to the last 9 lines; one line is a \r-rewritten progress counter.

```tsx
import { Terminal } from "@kura-ui/react/terminal";

const BUILD_OUTPUT = [
  `   ${bold("▲ Next.js 16.0.1")}`,
  `   - Environments: .env.production`,
  "",
  `   Creating an optimized production build ...`,
  ` ${green("✓")} Compiled successfully in 8.4s`,
  ` ${green("✓")} Linting and checking validity of types`,
  ` ${green("✓")} Collecting page data`,
  // A progress line rewritten in place with carriage returns.
  `   Generating static pages (0/14)\r   Generating static pages (7/14)\r ${green("✓")} Generating static pages (14/14)`,
  ` ${green("✓")} Finalizing page optimization`,
  "",
  `${bold("Route (app)")}                         ${bold("Size")}  ${bold("First Load JS")}`,
  `┌ ○ /                              5.21 kB         ${green("112 kB")}`,
  `├ ○ /_not-found                       990 B         ${green("103 kB")}`,
  `├ ƒ /account                        3.84 kB         ${green("118 kB")}`,
  `├ ƒ /api/health                       142 B         ${green("102 kB")}`,
  `├ ○ /login                          2.67 kB         ${green("115 kB")}`,
  `└ ○ /signup                         2.71 kB         ${green("115 kB")}`,
  `+ First Load JS shared by all       ${green("102 kB")}`,
  "",
  `ƒ Middleware                        ${green("34.1 kB")}`,
  "",
  `○  (Static)   prerendered as static content`,
  `ƒ  (Dynamic)  server-rendered on demand`,
];

const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;

const green = (s: string) => `\x1b[32m${s}\x1b[39m`;

export function TerminalCollapsed() {
  return (
    <Terminal
      className="w-full max-w-2xl"
      title="Build"
      cwd="~/acme/web"
      command="pnpm build"
      lines={BUILD_OUTPUT}
      exitCode={0}
      duration={23_400}
      maxLines={9}
      wrap={false}
    />
  );
}
```

### Dev server with Stop (composition)

A Button in `actions` stops a dev server that keeps logging.

```tsx
"use client";

import { Square } from "lucide-react";
import * as React from "react";
import { Button } from "@kura-ui/react/button";
import { Terminal } from "@kura-ui/react/terminal";

const green = (s: string) => `\x1b[32m${s}\x1b[39m`;

const DEV_LINES = [
  `  ${bold("▲ Next.js 16.0.1")} (Turbopack)`,
  `  - Local:        ${cyan("http://localhost:3000")}`,
  `  - Network:      ${cyan("http://192.168.1.24:3000")}`,
  "",
  ` ${green("✓")} Starting...`,
  ` ${green("✓")} Ready in 1.1s`,
  ` ${gray("○")} Compiling /login ...`,
  ` ${green("✓")} Compiled /login in 640ms`,
  ` GET /login ${green("200")} in 702ms`,
  ` POST /api/auth/session ${green("200")} in 48ms`,
  ` GET /account ${green("200")} in 131ms`,
  ` GET /api/auth/session ${yellow("401")} in 12ms`,
  ` GET /login?next=%2Faccount ${green("200")} in 22ms`,
];

const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;

const yellow = (s: string) => `\x1b[33m${s}\x1b[39m`;

const cyan = (s: string) => `\x1b[36m${s}\x1b[39m`;

const gray = (s: string) => `\x1b[90m${s}\x1b[39m`;

export function TerminalComposition() {
  const [lines, setLines] = React.useState<string[]>(DEV_LINES.slice(0, 6));
  const [stopped, setStopped] = React.useState(false);
  React.useEffect(() => {
    if (stopped) return;
    const id = setInterval(() => {
      setLines((prev) =>
        prev.length < DEV_LINES.length
          ? [...prev, DEV_LINES[prev.length]!]
          : [...prev, ` GET /api/health ${green("200")} in ${4 + (prev.length % 7)}ms`],
      );
    }, 1400);
    return () => clearInterval(id);
  }, [stopped]);
  return (
    <Terminal
      className="w-full max-w-2xl"
      title="Dev server"
      cwd="~/acme/web"
      command="pnpm dev"
      lines={stopped ? [...lines, "^C"] : lines}
      status={stopped ? "done" : "running"}
      maxHeight={240}
      actions={
        !stopped && (
          <Button size="xs" variant="ghost" onClick={() => setStopped(true)}>
            <Square aria-hidden className="size-3 fill-current" />
            Stop
          </Button>
        )
      }
    />
  );
}
```

## Dependencies

- npm: `lucide-react`
- Kura registry items: `kura-theme`, `icon-button`, `kura-format`, `status-glyph`, `use-controllable-state`, `use-elapsed`, `utils`
- Related: `diff-view`, `file-changes`, `code-block`, `tool-call`, `agent-timeline`
