Skip to content
edmi-ui

Terminal

Streaming terminal output with ANSI colors. Always dark, never themed.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Terminal
running
$ pnpm test

RUN  v2.1.4 /app

 ✓ tests/drift.test.ts (4)
 ✗ tests/keeper.test.ts (3)
   → expected 0.02 to be less than 0.02

Test Files  1 failed | 1 passed
Terminal
running
$ pnpm test

RUN  v2.1.4 /app

 ✓ tests/drift.test.ts (4)
 ✗ tests/keeper.test.ts (3)
   → expected 0.02 to be less than 0.02

Test Files  1 failed | 1 passed
Terminal
running
$ pnpm test

RUN  v2.1.4 /app

 ✓ tests/drift.test.ts (4)
 ✗ tests/keeper.test.ts (3)
   → expected 0.02 to be less than 0.02

Test Files  1 failed | 1 passed

Uses ansi-to-react for the colors and button for the actions.

Terminal window
npx shadcn@latest add @edmi-ui/ai-terminal
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-terminal
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-terminal
Terminal window
bunx shadcn@latest add @edmi-ui/ai-terminal

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-terminal
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-terminal
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-terminal
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-terminal

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-terminal.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-terminal.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-terminal.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-terminal.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
Terminal,
TerminalActions,
TerminalClearButton,
TerminalContent,
TerminalCopyButton,
TerminalHeader,
TerminalStatus,
TerminalTitle,
} from "@/components/ai/terminal";
<Terminal
className="max-w-xl"
isStreaming
>
<TerminalHeader>
<div className="flex items-center">
<TerminalTitle />
<TerminalStatus />
</div>
<TerminalActions>
<TerminalCopyButton />
<TerminalClearButton />
</TerminalActions>
</TerminalHeader>
<TerminalContent />
</Terminal>
<script setup lang="ts">
import {
Terminal,
TerminalActions,
TerminalClearButton,
TerminalContent,
TerminalCopyButton,
TerminalHeader,
TerminalStatus,
TerminalTitle,
} from "@/components/ai/terminal";
</script>
<template>
<Terminal class="max-w-xl" is-streaming>
<TerminalHeader>
<div class="flex items-center">
<TerminalTitle />
<TerminalStatus />
</div>
<TerminalActions>
<TerminalCopyButton />
<TerminalClearButton />
</TerminalActions>
</TerminalHeader>
<TerminalContent />
</Terminal>
</template>
<script lang="ts">
import {
Terminal,
TerminalActions,
TerminalClearButton,
TerminalContent,
TerminalCopyButton,
TerminalHeader,
TerminalStatus,
TerminalTitle,
} from "$lib/components/ai/terminal";
</script>
<Terminal class="max-w-xl" isStreaming>
<TerminalHeader>
<div class="flex items-center">
<TerminalTitle />
<TerminalStatus />
</div>
<TerminalActions>
<TerminalCopyButton />
<TerminalClearButton />
</TerminalActions>
</TerminalHeader>
<TerminalContent />
</Terminal>
registry/components/ai/terminal.tsx
"use client";
import AnsiModule from "ansi-to-react";
import { cn } from "cn";
import type { ComponentProps, HTMLAttributes } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { TerminalIcon, CheckIcon, CopyIcon, TrashIcon } from "@phosphor-icons/react";
import { Button } from "@/registry/edmi/ui/button";
// ansi-to-react is CommonJS (`exports.default`): some bundlers hand back the module object, not the component.
const Ansi =
(AnsiModule as unknown as { default?: typeof AnsiModule }).default ??
AnsiModule;
interface TerminalContextType {
output: string;
isStreaming: boolean;
autoScroll: boolean;
onClear?: () => void;
}
const TerminalContext = createContext<TerminalContextType>({
autoScroll: true,
isStreaming: false,
output: "",
});
// The terminal is always dark, in every theme and mode (DESIGN §5b rule 4): fixed oklch literals from
// ai.css `.ai-term`, deliberately not tokens. ANSI colours map onto the same small palette.
const TERM_BG = "bg-[oklch(0.17_0.005_286)]";
const TERM_FG = "text-[oklch(0.92_0.003_286)]";
const TERM_BORDER = "border-[oklch(0.27_0.007_286)]";
const TERM_MUTED = "text-[oklch(0.72_0.01_286)]";
const TERM_ACTION =
"text-[oklch(0.72_0.01_286)] hover:bg-[oklch(0.25_0.007_286)] hover:text-[oklch(0.92_0.003_286)] focus-visible:outline-[oklch(0.78_0.15_155)]";
const ANSI_COLORS = [
"[&_.ansi-green-fg]:text-[oklch(0.78_0.15_155)]",
"[&_.ansi-bright-green-fg]:text-[oklch(0.78_0.15_155)]",
"[&_.ansi-yellow-fg]:text-[oklch(0.83_0.13_85)]",
"[&_.ansi-bright-yellow-fg]:text-[oklch(0.83_0.13_85)]",
"[&_.ansi-red-fg]:text-[oklch(0.7_0.17_20)]",
"[&_.ansi-bright-red-fg]:text-[oklch(0.7_0.17_20)]",
"[&_.ansi-blue-fg]:text-[oklch(0.72_0.13_255)]",
"[&_.ansi-bright-blue-fg]:text-[oklch(0.72_0.13_255)]",
"[&_.ansi-magenta-fg]:text-[oklch(0.74_0.14_320)]",
"[&_.ansi-bright-magenta-fg]:text-[oklch(0.74_0.14_320)]",
"[&_.ansi-cyan-fg]:text-[oklch(0.8_0.1_200)]",
"[&_.ansi-bright-cyan-fg]:text-[oklch(0.8_0.1_200)]",
"[&_.ansi-black-fg]:text-[oklch(0.58_0.01_286)]",
"[&_.ansi-bright-black-fg]:text-[oklch(0.58_0.01_286)]",
"[&_.ansi-bold]:font-semibold",
"[&_.ansi-dim]:opacity-60",
"[&_.ansi-italic]:italic",
"[&_.ansi-underline]:underline",
].join(" ");
export type TerminalHeaderProps = HTMLAttributes<HTMLDivElement>;
export const TerminalHeader = ({
className,
children,
...props
}: TerminalHeaderProps) => (
<div
data-slot="ai-terminal-header"
className={cn(
"flex h-9 items-center justify-between gap-2 border-b pr-1.5 pl-3 text-[12.5px]",
TERM_BORDER,
TERM_MUTED,
className,
)}
{...props}
>
{children}
</div>
);
export type TerminalTitleProps = HTMLAttributes<HTMLDivElement>;
export const TerminalTitle = ({
className,
children,
...props
}: TerminalTitleProps) => (
<div
data-slot="ai-terminal-title"
className={cn("flex items-center gap-2", className)}
{...props}
>
<TerminalIcon
className="size-3.5"
/>
{children ?? "Terminal"}
</div>
);
export type TerminalStatusProps = HTMLAttributes<HTMLDivElement>;
export const TerminalStatus = ({
className,
children,
...props
}: TerminalStatusProps) => {
const { isStreaming } = useContext(TerminalContext);
if (!isStreaming) {
return null;
}
return (
<div
data-slot="ai-terminal-status"
className={cn("ml-1.5 flex items-center gap-1.5", className)}
{...props}
>
{children ?? (
<>
<span className="size-1.5 rounded-full bg-[oklch(0.78_0.15_155)]" />
running
</>
)}
</div>
);
};
export type TerminalActionsProps = HTMLAttributes<HTMLDivElement>;
export const TerminalActions = ({
className,
children,
...props
}: TerminalActionsProps) => (
<div
data-slot="ai-terminal-actions"
className={cn("flex items-center gap-0.5", className)}
{...props}
>
{children}
</div>
);
export type TerminalCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const TerminalCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: TerminalCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { output } = useContext(TerminalContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(output);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
onError?.(error as Error);
}
}, [output, onCopy, onError, timeout]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[],
);
return (
<Button
aria-label="Copy output"
className={cn(TERM_ACTION, className)}
onClick={copyToClipboard}
size="icon-xs"
variant="ghost"
{...props}
>
{children ??
(isCopied ? (
<CheckIcon
className="size-3.5"
/>
) : (
<CopyIcon
className="size-3.5"
/>
))}
</Button>
);
};
export type TerminalClearButtonProps = ComponentProps<typeof Button>;
export const TerminalClearButton = ({
children,
className,
...props
}: TerminalClearButtonProps) => {
const { onClear } = useContext(TerminalContext);
if (!onClear) {
return null;
}
return (
<Button
aria-label="Clear output"
className={cn(TERM_ACTION, className)}
onClick={onClear}
size="icon-xs"
variant="ghost"
{...props}
>
{children ?? (
<TrashIcon
className="size-3.5"
/>
)}
</Button>
);
};
export type TerminalContentProps = HTMLAttributes<HTMLDivElement>;
export const TerminalContent = ({
className,
children,
...props
}: TerminalContentProps) => {
const { output, isStreaming, autoScroll } = useContext(TerminalContext);
const containerRef = useRef<HTMLDivElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: new output must re-run the scroll
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [output, autoScroll]);
return (
<div
data-slot="ai-terminal-content"
className={cn(
"max-h-96 overflow-auto px-3.5 py-2.5 font-mono text-[12.5px] leading-[1.7]",
ANSI_COLORS,
className,
)}
ref={containerRef}
{...props}
>
{children ?? (
<pre className="font-[inherit] break-words whitespace-pre-wrap">
<Ansi useClasses>{output}</Ansi>
{isStreaming && (
<span className="ml-0.5 inline-block h-3.5 w-[7px] animate-pulse bg-current align-[-2px]" />
)}
</pre>
)}
</div>
);
};
export type TerminalProps = HTMLAttributes<HTMLDivElement> & {
output: string;
isStreaming?: boolean;
autoScroll?: boolean;
onClear?: () => void;
};
export const Terminal = ({
output,
isStreaming = false,
autoScroll = true,
onClear,
className,
children,
...props
}: TerminalProps) => {
const contextValue = useMemo(
() => ({ autoScroll, isStreaming, onClear, output }),
[autoScroll, isStreaming, onClear, output],
);
return (
<TerminalContext.Provider value={contextValue}>
<div
data-slot="ai-terminal"
className={cn(
"flex flex-col overflow-hidden rounded-[calc(var(--radius)*1.2)] border",
TERM_BG,
TERM_FG,
TERM_BORDER,
className,
)}
{...props}
>
{children ?? (
<>
<TerminalHeader>
<div className="flex items-center">
<TerminalTitle />
<TerminalStatus />
</div>
<TerminalActions>
<TerminalCopyButton />
{onClear && <TerminalClearButton />}
</TerminalActions>
</TerminalHeader>
<TerminalContent />
</>
)}
</div>
</TerminalContext.Provider>
);
};
registry/components/ai/terminal/Terminal.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, getCurrentInstance, provide } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { TerminalKey } from "./context"
import TerminalActions from "./TerminalActions.vue"
import TerminalClearButton from "./TerminalClearButton.vue"
import TerminalContent from "./TerminalContent.vue"
import TerminalCopyButton from "./TerminalCopyButton.vue"
import TerminalHeader from "./TerminalHeader.vue"
import TerminalStatus from "./TerminalStatus.vue"
import TerminalTitle from "./TerminalTitle.vue"
const props = withDefaults(defineProps<{
output: string
isStreaming?: boolean
autoScroll?: boolean
class?: HTMLAttributes["class"]
}>(), {
isStreaming: false,
autoScroll: true,
})
const emit = defineEmits<{
(e: "clear"): void
}>()
const instance = getCurrentInstance()
// The clear button only shows when the parent listens to `clear`.
const hasClear = computed(() => !!instance?.vnode.props?.onClear)
provide(TerminalKey, {
output: computed(() => props.output),
isStreaming: computed(() => props.isStreaming),
autoScroll: computed(() => props.autoScroll),
hasClear,
onClear: () => emit("clear"),
})
</script>
<template>
<!-- Always dark, never themed: fixed oklch literals (ai.css .ai-term). -->
<div
data-slot="ai-terminal"
:class="cn(
'flex flex-col overflow-hidden rounded-[calc(var(--radius)*1.2)] border border-[oklch(0.27_0.007_286)] bg-[oklch(0.17_0.005_286)] text-[oklch(0.92_0.003_286)]',
props.class,
)"
>
<slot>
<TerminalHeader>
<div class="flex items-center">
<TerminalTitle />
<TerminalStatus />
</div>
<TerminalActions>
<TerminalCopyButton />
<TerminalClearButton v-if="hasClear" />
</TerminalActions>
</TerminalHeader>
<TerminalContent />
</slot>
</div>
</template>
registry/components/ai/terminal/TerminalActions.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div data-slot="ai-terminal-actions" :class="cn('flex items-center gap-0.5', props.class)">
<slot />
</div>
</template>
registry/components/ai/terminal/TerminalClearButton.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { Trash2Icon } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Button } from "@/registry/edmi/ui/button"
import { TERM_ACTION, useTerminalContext } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { hasClear, onClear } = useTerminalContext("TerminalClearButton")
</script>
<template>
<Button
v-if="hasClear"
type="button"
variant="ghost"
size="icon-xs"
aria-label="Clear output"
:class="cn(TERM_ACTION, props.class)"
@click="onClear"
>
<slot>
<Trash2Icon class="size-3.5" />
</slot>
</Button>
</template>
registry/components/ai/terminal/TerminalContent.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import Ansi from "ansi-to-vue3"
import { nextTick, ref, watch } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useTerminalContext } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { output, isStreaming, autoScroll } = useTerminalContext("TerminalContent")
const containerRef = ref<HTMLDivElement | null>(null)
watch(
[output, autoScroll],
() => {
if (autoScroll.value) {
nextTick(() => {
if (containerRef.value) {
containerRef.value.scrollTop = containerRef.value.scrollHeight
}
})
}
},
{ immediate: true },
)
// ANSI colours map onto the fixed terminal palette (ai.css .ai-term), not onto tokens.
const ansiColors = [
"[&_.ansi-green-fg]:text-[oklch(0.78_0.15_155)]",
"[&_.ansi-bright-green-fg]:text-[oklch(0.78_0.15_155)]",
"[&_.ansi-yellow-fg]:text-[oklch(0.83_0.13_85)]",
"[&_.ansi-bright-yellow-fg]:text-[oklch(0.83_0.13_85)]",
"[&_.ansi-red-fg]:text-[oklch(0.7_0.17_20)]",
"[&_.ansi-bright-red-fg]:text-[oklch(0.7_0.17_20)]",
"[&_.ansi-blue-fg]:text-[oklch(0.72_0.13_255)]",
"[&_.ansi-bright-blue-fg]:text-[oklch(0.72_0.13_255)]",
"[&_.ansi-magenta-fg]:text-[oklch(0.74_0.14_320)]",
"[&_.ansi-bright-magenta-fg]:text-[oklch(0.74_0.14_320)]",
"[&_.ansi-cyan-fg]:text-[oklch(0.8_0.1_200)]",
"[&_.ansi-bright-cyan-fg]:text-[oklch(0.8_0.1_200)]",
"[&_.ansi-black-fg]:text-[oklch(0.58_0.01_286)]",
"[&_.ansi-bright-black-fg]:text-[oklch(0.58_0.01_286)]",
"[&_.ansi-bold]:font-semibold",
"[&_.ansi-dim]:opacity-60",
"[&_.ansi-italic]:italic",
"[&_.ansi-underline]:underline",
].join(" ")
</script>
<template>
<div
ref="containerRef"
data-slot="ai-terminal-content"
:class="cn(
'max-h-96 overflow-auto px-3.5 py-2.5 font-mono text-[12.5px] leading-[1.7]',
ansiColors,
props.class,
)"
>
<slot>
<pre class="font-[inherit] break-words whitespace-pre-wrap"><Ansi use-classes>{{ output }}</Ansi><span
v-if="isStreaming"
class="ml-0.5 inline-block h-3.5 w-[7px] animate-pulse bg-current align-[-2px]"
/></pre>
</slot>
</div>
</template>
registry/components/ai/terminal/TerminalCopyButton.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { CheckIcon, CopyIcon } from "@lucide/vue"
import { onBeforeUnmount, ref } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Button } from "@/registry/edmi/ui/button"
import { TERM_ACTION, useTerminalContext } from "./context"
const props = withDefaults(defineProps<{
timeout?: number
class?: HTMLAttributes["class"]
}>(), {
timeout: 2000,
})
const emit = defineEmits<{
(e: "copy"): void
(e: "error", error: Error): void
}>()
const { output } = useTerminalContext("TerminalCopyButton")
const isCopied = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
async function copyToClipboard() {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
emit("error", new Error("Clipboard API not available"))
return
}
try {
await navigator.clipboard.writeText(output.value)
isCopied.value = true
emit("copy")
timer = setTimeout(() => {
isCopied.value = false
}, props.timeout)
}
catch (error) {
emit("error", error as Error)
}
}
onBeforeUnmount(() => clearTimeout(timer))
</script>
<template>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Copy output"
:class="cn(TERM_ACTION, props.class)"
@click="copyToClipboard"
>
<slot>
<CheckIcon v-if="isCopied" class="size-3.5" />
<CopyIcon v-else class="size-3.5" />
</slot>
</Button>
</template>
registry/components/ai/terminal/TerminalHeader.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="ai-terminal-header"
:class="cn(
'flex h-9 items-center justify-between gap-2 border-b border-[oklch(0.27_0.007_286)] pr-1.5 pl-3 text-[12.5px] text-[oklch(0.72_0.01_286)]',
props.class,
)"
>
<slot />
</div>
</template>
registry/components/ai/terminal/TerminalStatus.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useTerminalContext } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { isStreaming } = useTerminalContext("TerminalStatus")
</script>
<template>
<div
v-if="isStreaming"
data-slot="ai-terminal-status"
:class="cn('ml-1.5 flex items-center gap-1.5', props.class)"
>
<slot>
<span class="size-1.5 rounded-full bg-[oklch(0.78_0.15_155)]" />
running
</slot>
</div>
</template>
registry/components/ai/terminal/TerminalTitle.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { Terminal } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div data-slot="ai-terminal-title" :class="cn('flex items-center gap-2', props.class)">
<Terminal class="size-3.5" />
<slot>Terminal</slot>
</div>
</template>
registry/components/ai/terminal/context.ts
import type { ComputedRef, InjectionKey } from "vue";
import { inject } from "vue";
export interface TerminalContextValue {
output: ComputedRef<string>;
isStreaming: ComputedRef<boolean>;
autoScroll: ComputedRef<boolean>;
hasClear: ComputedRef<boolean>;
onClear: () => void;
}
export const TerminalKey: InjectionKey<TerminalContextValue> =
Symbol("Terminal");
export function useTerminalContext(
componentName: string,
): TerminalContextValue {
const context = inject(TerminalKey);
if (!context) {
throw new Error(`${componentName} must be used within Terminal`);
}
return context;
}
// The terminal is always dark, in every theme and mode (DESIGN §5b rule 4): fixed oklch literals from
// ai.css `.ai-term`, deliberately not tokens.
export const TERM_ACTION =
"text-[oklch(0.72_0.01_286)] hover:bg-[oklch(0.25_0.007_286)] hover:text-[oklch(0.92_0.003_286)] focus-visible:outline-[oklch(0.78_0.15_155)]";
registry/components/ai/terminal/index.ts
export { default as Terminal } from "./Terminal.vue";
export { default as TerminalActions } from "./TerminalActions.vue";
export { default as TerminalClearButton } from "./TerminalClearButton.vue";
export { default as TerminalContent } from "./TerminalContent.vue";
export { default as TerminalCopyButton } from "./TerminalCopyButton.vue";
export { default as TerminalHeader } from "./TerminalHeader.vue";
export { default as TerminalStatus } from "./TerminalStatus.vue";
export { default as TerminalTitle } from "./TerminalTitle.vue";
src/lib/registry/ai/terminal/index.ts
import Terminal from "./terminal.svelte";
import TerminalActions from "./terminal-actions.svelte";
import TerminalClearButton from "./terminal-clear-button.svelte";
import TerminalContent from "./terminal-content.svelte";
import TerminalCopyButton from "./terminal-copy-button.svelte";
import TerminalHeader from "./terminal-header.svelte";
import TerminalStatus from "./terminal-status.svelte";
import TerminalTitle from "./terminal-title.svelte";
export {
Terminal,
TerminalActions,
TerminalClearButton,
TerminalContent,
TerminalCopyButton,
TerminalHeader,
TerminalStatus,
TerminalTitle,
};
src/lib/registry/ai/terminal/terminal-actions.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
let { class: className, children }: { class?: string; children?: Snippet } = $props();
</script>
<div data-slot="ai-terminal-actions" class={cn("flex items-center gap-0.5", className)}>
{@render children?.()}
</div>
src/lib/registry/ai/terminal/terminal-clear-button.svelte
<script lang="ts">
import TrashIcon from "phosphor-svelte/lib/Trash";
import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { TERM_ACTION, useTerminalContext } from "./use-terminal.svelte.js";
let {
class: className,
children,
...restProps
}: Omit<ButtonProps, "onclick" | "href"> = $props();
const terminal = useTerminalContext();
</script>
{#if terminal.onClear}
<Button
aria-label="Clear output"
class={cn(TERM_ACTION, className)}
onclick={() => terminal.onClear?.()}
size="icon-xs"
type="button"
variant="ghost"
{...restProps}
>
{#if children}
{@render children()}
{:else}
<TrashIcon
class="size-3.5"
/>
{/if}
</Button>
{/if}
src/lib/registry/ai/terminal/terminal-content.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import Anser from "anser";
import type { Snippet } from "svelte";
import { tick } from "svelte";
import { useTerminalContext } from "./use-terminal.svelte.js";
let { class: className, children }: { class?: string; children?: Snippet } = $props();
const terminal = useTerminalContext();
let container = $state<HTMLDivElement | null>(null);
// Anser turns ANSI escapes into `ansi-<colour>` classes, mapped onto the fixed terminal palette below.
const chunks = $derived(
Anser.ansiToJson(terminal.output, { json: true, remove_empty: true, use_classes: true })
);
$effect(() => {
terminal.output;
if (terminal.autoScroll) {
tick().then(() => {
if (container) container.scrollTop = container.scrollHeight;
});
}
});
function chunkClass(chunk: { fg?: string | null; bg?: string | null; decoration?: string | null }) {
return [chunk.fg ? `${chunk.fg}-fg` : "", chunk.decoration ? `ansi-${chunk.decoration}` : ""]
.filter(Boolean)
.join(" ");
}
const ansiColors = [
"[&_.ansi-green-fg]:text-[oklch(0.78_0.15_155)]",
"[&_.ansi-bright-green-fg]:text-[oklch(0.78_0.15_155)]",
"[&_.ansi-yellow-fg]:text-[oklch(0.83_0.13_85)]",
"[&_.ansi-bright-yellow-fg]:text-[oklch(0.83_0.13_85)]",
"[&_.ansi-red-fg]:text-[oklch(0.7_0.17_20)]",
"[&_.ansi-bright-red-fg]:text-[oklch(0.7_0.17_20)]",
"[&_.ansi-blue-fg]:text-[oklch(0.72_0.13_255)]",
"[&_.ansi-bright-blue-fg]:text-[oklch(0.72_0.13_255)]",
"[&_.ansi-magenta-fg]:text-[oklch(0.74_0.14_320)]",
"[&_.ansi-bright-magenta-fg]:text-[oklch(0.74_0.14_320)]",
"[&_.ansi-cyan-fg]:text-[oklch(0.8_0.1_200)]",
"[&_.ansi-bright-cyan-fg]:text-[oklch(0.8_0.1_200)]",
"[&_.ansi-black-fg]:text-[oklch(0.58_0.01_286)]",
"[&_.ansi-bright-black-fg]:text-[oklch(0.58_0.01_286)]",
"[&_.ansi-bold]:font-semibold",
"[&_.ansi-dim]:opacity-60",
"[&_.ansi-italic]:italic",
"[&_.ansi-underline]:underline",
].join(" ");
</script>
<div
bind:this={container}
data-slot="ai-terminal-content"
class={cn(
"max-h-96 overflow-auto px-3.5 py-2.5 font-mono text-[12.5px] leading-[1.7]",
ansiColors,
className
)}
>
{#if children}
{@render children()}
{:else}
<pre class="font-[inherit] break-words whitespace-pre-wrap">{#each chunks as chunk, index (index)}<span
class={chunkClass(chunk)}>{chunk.content}</span
>{/each}{#if terminal.isStreaming}<span
class="ml-0.5 inline-block h-3.5 w-[7px] animate-pulse bg-current align-[-2px]"
></span>{/if}</pre>
{/if}
</div>
src/lib/registry/ai/terminal/terminal-copy-button.svelte
<script lang="ts">
import CheckIcon from "phosphor-svelte/lib/Check";
import CopyIcon from "phosphor-svelte/lib/Copy";
import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { onDestroy } from "svelte";
import { TERM_ACTION, useTerminalContext } from "./use-terminal.svelte.js";
let {
onCopy,
onError,
timeout = 2000,
class: className,
children,
...restProps
}: Omit<ButtonProps, "onclick" | "href"> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
} = $props();
const terminal = useTerminalContext();
let isCopied = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
async function copy() {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(terminal.output);
isCopied = true;
onCopy?.();
clearTimeout(timer);
timer = setTimeout(() => (isCopied = false), timeout);
} catch (error) {
onError?.(error as Error);
}
}
onDestroy(() => clearTimeout(timer));
</script>
<Button
aria-label="Copy output"
class={cn(TERM_ACTION, className)}
onclick={copy}
size="icon-xs"
type="button"
variant="ghost"
{...restProps}
>
{#if children}
{@render children()}
{:else if isCopied}
<CheckIcon
class="size-3.5"
/>
{:else}
<CopyIcon
class="size-3.5"
/>
{/if}
</Button>
src/lib/registry/ai/terminal/terminal-header.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
let { class: className, children }: { class?: string; children?: Snippet } = $props();
</script>
<div
data-slot="ai-terminal-header"
class={cn(
"flex h-9 items-center justify-between gap-2 border-b border-[oklch(0.27_0.007_286)] pr-1.5 pl-3 text-[12.5px] text-[oklch(0.72_0.01_286)]",
className
)}
>
{@render children?.()}
</div>
src/lib/registry/ai/terminal/terminal-status.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import { useTerminalContext } from "./use-terminal.svelte.js";
let { class: className, children }: { class?: string; children?: Snippet } = $props();
const terminal = useTerminalContext();
</script>
{#if terminal.isStreaming}
<div data-slot="ai-terminal-status" class={cn("ml-1.5 flex items-center gap-1.5", className)}>
{#if children}
{@render children()}
{:else}
<span class="size-1.5 rounded-full bg-[oklch(0.78_0.15_155)]"></span>
running
{/if}
</div>
{/if}
src/lib/registry/ai/terminal/terminal-title.svelte
<script lang="ts">
import TerminalIcon from "phosphor-svelte/lib/Terminal";
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
let { class: className, children }: { class?: string; children?: Snippet } = $props();
</script>
<div data-slot="ai-terminal-title" class={cn("flex items-center gap-2", className)}>
<TerminalIcon
class="size-3.5"
/>
{#if children}{@render children()}{:else}Terminal{/if}
</div>
src/lib/registry/ai/terminal/terminal.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import TerminalActions from "./terminal-actions.svelte";
import TerminalClearButton from "./terminal-clear-button.svelte";
import TerminalContent from "./terminal-content.svelte";
import TerminalCopyButton from "./terminal-copy-button.svelte";
import TerminalHeader from "./terminal-header.svelte";
import TerminalStatus from "./terminal-status.svelte";
import TerminalTitle from "./terminal-title.svelte";
import { setTerminalContext } from "./use-terminal.svelte.js";
let {
ref = $bindable(null),
output,
isStreaming = false,
autoScroll = true,
onClear,
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
output: string;
isStreaming?: boolean;
autoScroll?: boolean;
onClear?: () => void;
} = $props();
setTerminalContext({
get output() {
return output;
},
get isStreaming() {
return isStreaming;
},
get autoScroll() {
return autoScroll;
},
get onClear() {
return onClear;
},
});
</script>
<!-- Always dark, never themed: fixed oklch literals (ai.css .ai-term). -->
<div
bind:this={ref}
data-slot="ai-terminal"
class={cn(
"flex flex-col overflow-hidden rounded-[calc(var(--radius)*1.2)] border border-[oklch(0.27_0.007_286)] bg-[oklch(0.17_0.005_286)] text-[oklch(0.92_0.003_286)]",
className
)}
{...restProps}
>
{#if children}
{@render children()}
{:else}
<TerminalHeader>
<div class="flex items-center">
<TerminalTitle />
<TerminalStatus />
</div>
<TerminalActions>
<TerminalCopyButton />
{#if onClear}
<TerminalClearButton />
{/if}
</TerminalActions>
</TerminalHeader>
<TerminalContent />
{/if}
</div>
src/lib/registry/ai/terminal/use-terminal.svelte.ts
import { getContext, setContext } from "svelte";
export interface TerminalContext {
readonly output: string;
readonly isStreaming: boolean;
readonly autoScroll: boolean;
readonly onClear: (() => void) | undefined;
}
const KEY = Symbol("ai-terminal");
export function setTerminalContext(ctx: TerminalContext) {
setContext(KEY, ctx);
}
export function useTerminalContext(): TerminalContext {
const ctx = getContext<TerminalContext | undefined>(KEY);
if (!ctx) throw new Error("Terminal components must be used within Terminal");
return ctx;
}
// The terminal is always dark, in every theme and mode (DESIGN §5b rule 4): fixed oklch literals from
// ai.css `.ai-term`, deliberately not tokens.
export const TERM_ACTION =
"text-[oklch(0.72_0.01_286)] hover:bg-[oklch(0.25_0.007_286)] hover:text-[oklch(0.92_0.003_286)] focus-visible:outline-[oklch(0.78_0.15_155)]";
Part Prop Type Default
Terminal output, isStreaming, autoScroll, onClear string, boolean, boolean, () => void false, true
TerminalHeader, Title, Status, Actions, CopyButton, ClearButton, Content
  • The terminal is always dark in every theme and mode: its colors are fixed oklch literals (not tokens), as DESIGN §5b requires.
  • ANSI foreground colors map to a small fixed palette; the streaming cursor is a block.