Skip to content
Merged
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
32 changes: 32 additions & 0 deletions apps/web/src/addons/terminal-shell/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";

import { TerminalShellStatus, terminalShellRootProps } from ".";

describe("terminal shell addon", () => {
it("exposes a stable host hook for its scoped styles", () => {
expect(terminalShellRootProps["data-terminal-shell"]).toBe("true");
expect(terminalShellRootProps.className).toContain("terminal-shell");
});

it("renders ready status without working-only controls", () => {
const markup = renderToStaticMarkup(
<TerminalShellStatus activeTerminalCount={0} isWorking={false} workingStepLabel={null} />,
);

expect(markup).toContain("Ready");
expect(markup).not.toContain("esc to interrupt");
expect(markup).not.toContain("terminal active");
});

it("renders working status and pluralizes active terminals", () => {
const markup = renderToStaticMarkup(
<TerminalShellStatus activeTerminalCount={2} isWorking workingStepLabel="Running command" />,
);

expect(markup).toContain('data-terminal-shell-status-state="working"');
expect(markup).toContain("Running command");
expect(markup).toContain("esc to interrupt");
expect(markup).toContain("2 terminals active");
});
});
34 changes: 34 additions & 0 deletions apps/web/src/addons/terminal-shell/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export const terminalShellRootProps = {
className: "terminal-shell relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background",
"data-terminal-shell": "true",
} as const;

export type TerminalShellStatusProps = {
readonly isWorking: boolean;
readonly workingStepLabel: string | null;
readonly activeTerminalCount: number;
};

export function TerminalShellStatus({
isWorking,
workingStepLabel,
activeTerminalCount,
}: TerminalShellStatusProps) {
return (
<div
aria-live="polite"
className="flex items-center gap-2 px-1 pb-1.5 text-[11px] text-muted-foreground"
data-terminal-shell-status="true"
>
<span data-terminal-shell-status-state={isWorking ? "working" : "ready"}>
{isWorking ? (workingStepLabel ?? "Working") : "Ready"}
</span>
{isWorking ? <span aria-hidden="true">· esc to interrupt</span> : null}
{activeTerminalCount > 0 ? (
<span>
· {activeTerminalCount} terminal{activeTerminalCount === 1 ? "" : "s"} active
</span>
) : null}
</div>
);
}
198 changes: 198 additions & 0 deletions apps/web/src/addons/terminal-shell/terminal-shell.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/* Fork-local presentation addon. Keep its selectors scoped to the host hook so
upstream chat markup remains the source of truth. */
[data-terminal-shell="true"] {
--terminal-shell-background: #11161d;
--terminal-shell-surface: #1b2230;
--terminal-shell-border: rgb(255 255 255 / 9%);
--terminal-shell-muted: #8f9aa8;
--terminal-shell-foreground: #e6e9ed;
--terminal-shell-accent: #52e7d1;
--terminal-shell-selection: rgb(82 231 209 / 18%);
background: var(--terminal-shell-background);
color: var(--terminal-shell-foreground);
font-family: var(--font-mono);
}

[data-terminal-shell="true"] [data-chat-header] {
min-height: 2.75rem;
border-bottom: 1px solid var(--terminal-shell-border);
background: var(--terminal-shell-background);
color: var(--terminal-shell-muted);
font-family: var(--font-mono);
}

[data-terminal-shell="true"] [data-chat-header] h2,
[data-terminal-shell="true"] [data-chat-header] button,
[data-terminal-shell="true"] [data-chat-header] [data-project-favicon] {
font-family: var(--font-mono);
}

[data-terminal-shell="true"] [data-chat-workspace-drop-target="true"] {
background: var(--terminal-shell-background);
}

[data-terminal-shell="true"] [data-timeline-root="true"] {
max-width: none;
padding-inline: clamp(1rem, 4vw, 4rem);
font-family: var(--font-mono);
font-size: 0.9375rem;
line-height: 1.55;
}

[data-terminal-shell="true"] [data-timeline-row-kind="message"] {
padding-block: 0.35rem;
}

[data-terminal-shell="true"] [data-message-role="assistant"] > div {
padding-inline: 0;
}

[data-terminal-shell="true"] [data-message-role="user"] > .group {
align-items: flex-start;
gap: 0;
}

[data-terminal-shell="true"] [data-message-role="user"] > .group > div:first-child {
max-width: none;
border: 0;
border-radius: 0;
background: var(--terminal-shell-surface);
padding: 0.5rem 0.625rem;
color: var(--terminal-shell-foreground);
}

[data-terminal-shell="true"] [data-message-role="user"] > .group > div:first-child::before {
margin-inline-end: 0.375rem;
color: var(--terminal-shell-accent);
content: ">";
line-height: 1.55;
}

[data-terminal-shell="true"]
[data-message-role="user"]
> .group
> div:first-child
> div:first-child {
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
}

[data-terminal-shell="true"] [data-message-role="user"] [data-user-message-footer="true"] {
max-width: none;
justify-content: flex-start;
padding-inline-start: 1.5rem;
color: var(--terminal-shell-muted);
}

[data-terminal-shell="true"] [data-message-role="assistant"] a,
[data-terminal-shell="true"] [data-message-role="assistant"] code,
[data-terminal-shell="true"] [data-message-role="user"] code {
color: var(--terminal-shell-accent);
}

[data-terminal-shell="true"] [data-message-role="assistant"] a {
text-decoration-color: color-mix(in srgb, var(--terminal-shell-accent) 65%, transparent);
}

[data-terminal-shell="true"] [data-message-role="assistant"] pre,
[data-terminal-shell="true"] [data-message-role="user"] pre {
border-color: var(--terminal-shell-border);
border-radius: 2px;
background: rgb(0 0 0 / 22%);
}

[data-terminal-shell="true"] .chat-composer-glass-shell {
--chat-composer-glass-surface: var(--terminal-shell-surface);
--chat-composer-outline: var(--terminal-shell-border);
max-width: none;
}

[data-terminal-shell="true"] .chat-composer-glass-shell::before,
[data-terminal-shell="true"] .chat-composer-glass-host::after {
border-radius: 3px;
}

[data-terminal-shell="true"] .chat-composer-glass-shell::before {
background: var(--terminal-shell-surface);
box-shadow: 0 -14px 34px -28px rgb(0 0 0 / 80%);
}

[data-terminal-shell="true"] .chat-composer-glass-host,
[data-terminal-shell="true"] [data-chat-composer-main-surface="true"],
[data-terminal-shell="true"] [data-chat-composer-surface="true"] {
border-radius: 3px;
}

[data-terminal-shell="true"] [data-chat-composer-form="true"] {
max-width: none;
}

[data-terminal-shell="true"] [data-chat-composer-main-surface="true"] {
border: 1px solid var(--terminal-shell-border);
background: var(--terminal-shell-surface);
box-shadow: none;
}

[data-terminal-shell="true"] [data-chat-composer-surface="true"] {
background: transparent;
}

[data-terminal-shell="true"] [data-testid="composer-editor"] {
min-height: 3.5rem;
padding-block: 0.15rem;
font-family: var(--font-mono);
font-size: 0.9375rem;
line-height: 1.55;
caret-color: var(--terminal-shell-accent);
}

[data-terminal-shell="true"] [data-testid="composer-editor"]::selection {
background: var(--terminal-shell-selection);
}

[data-terminal-shell="true"] [data-chat-composer-main-surface="true"] button:hover {
background: rgb(255 255 255 / 7%);
}

[data-terminal-shell="true"] [data-chat-composer-overlay] {
background: linear-gradient(to top, var(--terminal-shell-background) 0%, transparent 100%);
padding-top: 1.5rem;
}

[data-terminal-shell="true"] [data-terminal-shell-status="true"] {
font-family: var(--font-mono);
letter-spacing: 0.01em;
}

[data-terminal-shell="true"] [data-terminal-shell-status-state] {
color: var(--terminal-shell-muted);
}

[data-terminal-shell="true"] [data-terminal-shell-status-state]::before {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
margin-inline-end: 0.5rem;
border-radius: 999px;
background: var(--terminal-shell-muted);
content: "";
vertical-align: 0.03em;
}

[data-terminal-shell="true"] [data-terminal-shell-status-state="working"] {
color: var(--terminal-shell-accent);
}

[data-terminal-shell="true"] [data-terminal-shell-status-state="working"]::before {
background: var(--terminal-shell-accent);
box-shadow: 0 0 0 3px rgb(82 231 209 / 10%);
}

@media (max-width: 639px) {
[data-terminal-shell="true"] [data-timeline-root="true"] {
padding-inline: 1rem;
font-size: 0.875rem;
}
}
27 changes: 7 additions & 20 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ import {
} from "../state/server";
import { terminalEnvironment } from "../state/terminal";
import { threadEnvironment, useEnvironmentThread } from "../state/threads";
import { TerminalShellStatus, terminalShellRootProps } from "../addons/terminal-shell";
import {
requestOlderThreadTurns,
threadHasOlderTurns,
Expand Down Expand Up @@ -6440,10 +6441,7 @@ function ChatViewContent(props: ChatViewProps) {
composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable);

return (
<div
className="terminal-shell relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background"
data-terminal-shell="true"
>
<div {...terminalShellRootProps}>
{rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null}
<div
className={cn(
Expand Down Expand Up @@ -6608,22 +6606,11 @@ function ChatViewContent(props: ChatViewProps) {
>
<div className="pointer-events-auto relative z-10">
{!isDraftHeroState ? (
<div
aria-live="polite"
className="flex items-center gap-2 px-1 pb-1.5 text-[11px] text-muted-foreground"
data-terminal-shell-status="true"
>
<span data-terminal-shell-status-state={isWorking ? "working" : "ready"}>
{isWorking ? (workingStepLabel ?? "Working") : "Ready"}
</span>
{isWorking ? <span aria-hidden="true">· esc to interrupt</span> : null}
{terminalUiState.terminalIds.length > 0 ? (
<span>
· {terminalUiState.terminalIds.length} terminal
{terminalUiState.terminalIds.length === 1 ? "" : "s"} active
</span>
) : null}
</div>
<TerminalShellStatus
activeTerminalCount={terminalUiState.terminalIds.length}
isWorking={isWorking}
workingStepLabel={workingStepLabel}
/>
) : null}
{isDraftHeroState ? (
<div className="absolute inset-x-0 bottom-full z-0">
Expand Down
Loading