Terminal
Streaming terminal output with ANSI colors. Always dark, never themed.
$ 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$ 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$ 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
import { Terminal, TerminalActions, TerminalClearButton, TerminalContent, TerminalCopyButton, TerminalHeader, TerminalStatus, TerminalTitle,} from "@/components/ai/terminal";import { useState } from "react";
const initial = [ "\u001b[90m$\u001b[0m pnpm test", "", "\u001b[34mRUN\u001b[0m v2.1.4 /app", "", " \u001b[32m✓\u001b[0m tests/drift.test.ts \u001b[90m(4)\u001b[0m", " \u001b[31m✗\u001b[0m tests/keeper.test.ts \u001b[90m(3)\u001b[0m", " \u001b[31m→ expected 0.02 to be less than 0.02\u001b[0m", "", "\u001b[33mTest Files\u001b[0m \u001b[31m1 failed\u001b[0m | \u001b[32m1 passed\u001b[0m",].join("\n");
export default function Demo() { const [output, setOutput] = useState(initial);
return ( <Terminal className="max-w-xl" isStreaming onClear={() => setOutput("")} output={output} > <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";import { ref } from "vue";
const initial = [ "\u001b[90m$\u001b[0m pnpm test", "", "\u001b[34mRUN\u001b[0m v2.1.4 /app", "", " \u001b[32m✓\u001b[0m tests/drift.test.ts \u001b[90m(4)\u001b[0m", " \u001b[31m✗\u001b[0m tests/keeper.test.ts \u001b[90m(3)\u001b[0m", " \u001b[31m→ expected 0.02 to be less than 0.02\u001b[0m", "", "\u001b[33mTest Files\u001b[0m \u001b[31m1 failed\u001b[0m | \u001b[32m1 passed\u001b[0m",].join("\n");
const output = ref(initial);</script>
<template> <Terminal class="max-w-xl" is-streaming :output="output" @clear="output = ''"> <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";
const initial = [ "\u001b[90m$\u001b[0m pnpm test", "", "\u001b[34mRUN\u001b[0m v2.1.4 /app", "", " \u001b[32m✓\u001b[0m tests/drift.test.ts \u001b[90m(4)\u001b[0m", " \u001b[31m✗\u001b[0m tests/keeper.test.ts \u001b[90m(3)\u001b[0m", " \u001b[31m→ expected 0.02 to be less than 0.02\u001b[0m", "", "\u001b[33mTest Files\u001b[0m \u001b[31m1 failed\u001b[0m | \u001b[32m1 passed\u001b[0m", ].join("\n");
let output = $state(initial);</script>
<Terminal class="max-w-xl" isStreaming {output} onClear={() => (output = "")}> <TerminalHeader> <div class="flex items-center"> <TerminalTitle /> <TerminalStatus /> </div> <TerminalActions> <TerminalCopyButton /> <TerminalClearButton /> </TerminalActions> </TerminalHeader> <TerminalContent /></Terminal>Uses ansi-to-react for the colors and button for the actions.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-terminalpnpm dlx shadcn@latest add @edmi-ui/ai-terminalyarn dlx shadcn@latest add @edmi-ui/ai-terminalbunx shadcn@latest add @edmi-ui/ai-terminalPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install ansi-to-reactTerminal window pnpm add ansi-to-reactTerminal window yarn add ansi-to-reactTerminal window bun add ansi-to-reactAdd the Edmi components this one builds on:
button.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-terminalpnpm dlx shadcn-vue@latest add @edmi-ui/ai-terminalyarn dlx shadcn-vue@latest add @edmi-ui/ai-terminalbunx shadcn-vue@latest add @edmi-ui/ai-terminalPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @lucide/vue ansi-to-vue3Terminal window pnpm add @lucide/vue ansi-to-vue3Terminal window yarn add @lucide/vue ansi-to-vue3Terminal window bun add @lucide/vue ansi-to-vue3Add the Edmi components this one builds on:
button.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-terminal.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-terminal.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-terminal.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-terminal.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button.Copy the code from Component source into your project and update the import paths to match your aliases.
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>Component source
Section titled “Component source”"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> );};<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><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><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><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><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><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><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><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>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)]";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";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,};<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><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}<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><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><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><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}<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><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>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 |
✦ Edmi additions
Section titled “✦ Edmi additions”- The terminal is always dark in every theme and mode: its colors are fixed
oklchliterals (not tokens), as DESIGN §5b requires. - ANSI foreground colors map to a small fixed palette; the streaming cursor is a block.