diff --git a/docs/dashboard-frontend.md b/docs/dashboard-frontend.md index cf66da07e..c705ce992 100644 --- a/docs/dashboard-frontend.md +++ b/docs/dashboard-frontend.md @@ -6077,10 +6077,13 @@ else `default_mode`. **Install and log in** calls `agent.install` with `enhanced` set when Enhanced is chosen and shows the answer: the log tail in an **Install output** disclosure, open when `error` is set, and **Install failed** with the error. -Once the agent is installed it mounts an Environment dock with the agent's -login command typed. A gateway without `agent.install` types the -install command there instead, and one without a terminal socket prints the -`aether terminal` commands. **Check** re-reads `agent.list` and lists +Once the agent is installed it mounts an Environment dock. The login or +install command appears above the dock. Use **Copy login command** or +**Copy install command**, paste at a shell prompt, review the command, then +press Enter. Resize the dock by dragging its visible top grip or using the expand/shrink button; +both stop at the space available to the dock. A gateway without a terminal +socket prints the `aether terminal` commands. +**Check** re-reads `agent.list` and lists **Installed**, **Enhanced installed** and **Login found** or **No login found**, or **Signed out:** or **Sign-in refused:** with `login_problem` when the server reports one; diff --git a/docs/harnesses.md b/docs/harnesses.md index 511af66f0..bfa18ba6d 100644 --- a/docs/harnesses.md +++ b/docs/harnesses.md @@ -94,8 +94,8 @@ You do this once per agent, per person. `--enhanced` to install the Enhanced adapter too (see [Enhanced adapters](#enhanced-adapters)). In the dashboard, **Set up** on the **Agents** page or onboarding's **Agent** step does the same: it lets you - pick Standard or Enhanced, installs the agent, and opens a terminal with the - login command typed. + pick Standard or Enhanced, installs the agent, and opens a terminal. Use + **Copy login command**, paste at a shell prompt, review it, then press Enter. The environment has no `aether` command (only `aether-internal`), so run `aether` commands on your computer, never in this shell. diff --git a/docs/terminal.md b/docs/terminal.md index f75d62c64..f91112f7c 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -127,6 +127,10 @@ text size** (on a phone, **Fit to width** in its place), **Copy selection**, **C image…**. Sessions add **Captures…**. **Copy screen** copies the visible rows, which is how to copy on a touch screen. +During agent sign-in, resize the Environment terminal by dragging its visible +top grip or using its expand/shrink button. It stops at the height available +to it. + ### Messages and notes Talk to the agent in the **Chat** view's composer. Press **Enter** or @@ -310,9 +314,9 @@ by UNKNOWN port 65535`: ## Sign in to agents and set up GitHub -The Agents page and onboarding embed the environment terminal with the agent's -login command typed in. Press Enter to run it. Login state lives in your home, -so you do not save the environment. See +The Agents page and onboarding show the login or install command above the +terminal. Copy the command, paste at a shell prompt, review it, then press Enter. +Login state lives in your home, so you do not save the environment. See [environment-home.md](environment-home.md#set-up-an-agent) and [GitHub](environment-home.md#connect-github). diff --git a/web/src/components/agents/agent-setup.test.tsx b/web/src/components/agents/agent-setup.test.tsx index 989367d93..fe6968ebf 100644 --- a/web/src/components/agents/agent-setup.test.tsx +++ b/web/src/components/agents/agent-setup.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, within } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { AgentSetup } from '@/components/agents/agent-setup' import { ModeComparison } from '@/components/agents/mode-comparison' import type { Api } from '@/lib/api' @@ -6,6 +6,9 @@ import type { AgentInfo, GatewayCapabilities } from '@/lib/types' import { useStore } from '@/store' import { agentInfo, fakeApi } from '@/test/fixtures' +const { terminalDock } = vi.hoisted(() => ({ terminalDock: vi.fn(() => null) })) +vi.mock('@/routes/environment/terminal-dock', () => ({ TerminalDock: terminalDock })) + const caps: GatewayCapabilities = { gateway: 'local', methods: ['*'], ws: ['events', 'attach'] } const claude = agentInfo({ display_name: 'Claude Code', installed: false, enhanced: 'adapter', default_mode: 'tui' }) @@ -21,6 +24,7 @@ const codex = agentInfo({ const custom = agentInfo({ name: 'mycli', display_name: 'mycli', source: 'member', glyph: 'custom', enhanced: 'none', install_script: undefined }) beforeEach(() => { + terminalDock.mockClear() useStore.setState(useStore.getInitialState(), true) useStore.setState({ capabilities: caps }) }) @@ -225,3 +229,38 @@ describe('agent setup', () => { expect(screen.getByText(/aether terminal/).textContent).toContain('curl -fsSL https://claude.ai/install.sh | bash') }) }) + + +describe('copying setup commands', () => { + afterEach(() => vi.unstubAllGlobals()) + + it.each([ + ['login', codex, 'Copy login command', 'codex login --device-auth', ['*']], + ['install', { ...codex, installed: false, enhanced_installed: false }, 'Copy install command', codex.install_script!, []], + ] as const)('copies the exact %s command without submitting terminal input', async (_kind, agent, label, command, methods) => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + useStore.setState({ capabilities: { ...caps, methods: [...methods], ws: ['terminal'] } }) + setUp(agent) + expect(writeText).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: label })) + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(command)) + expect(terminalDock).toHaveBeenCalled() + for (const [props] of terminalDock.mock.calls as unknown as [{ initialLine?: string; runCommand?: unknown }][]) { + expect(props.initialLine).toBeUndefined() + expect(props.runCommand).toBeUndefined() + } + }) + + it('selects the displayed login command when clipboard access fails', async () => { + vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Clipboard denied')) } }) + useStore.setState({ capabilities: { ...caps, ws: ['terminal'] } }) + setUp(codex) + + fireEvent.click(screen.getByRole('button', { name: 'Copy login command' })) + + await waitFor(() => expect(window.getSelection()?.toString()).toBe('codex login --device-auth')) + }) +}) diff --git a/web/src/components/agents/agent-setup.tsx b/web/src/components/agents/agent-setup.tsx index 5c75c87a4..aa9160ce1 100644 --- a/web/src/components/agents/agent-setup.tsx +++ b/web/src/components/agents/agent-setup.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useState } from 'react' +import { type ReactNode, useRef, useState } from 'react' import { enhancedSupported, label, loginFor, ready, setupMode, signedOutState } from '@/components/agents/agent-copy' import { ModeComparison, type SetupMode } from '@/components/agents/mode-comparison' import { AgentGlyph } from '@/components/ui/agent-glyph' @@ -10,6 +10,7 @@ import { Spinner } from '@/components/ui/spinner' import { StatusDot } from '@/components/ui/status-dot' import type { Api } from '@/lib/api' import { message } from '@/lib/format' +import { copyText } from '@/lib/clipboard' import type { AgentInfo, AgentInstallResult } from '@/lib/types' import { TerminalDock } from '@/routes/environment/terminal-dock' import { useStore } from '@/store' @@ -51,6 +52,7 @@ export function AgentSetup({ /** Pins Enhanced for a caller that cannot work without its adapter, so the mode choice is skipped. */ requireEnhanced?: boolean }) { + const commandSource = useRef(null) const caps = useCapability() const [agent, setAgent] = useState(initial) const [mode, setMode] = useState(() => requireEnhanced ? 'acp' : setupMode(initial, useStore.getState().launchDefaults[initial.name]?.mode)) @@ -69,6 +71,7 @@ export function AgentSetup({ const hasTerminal = caps.hasWS('terminal') const login = loginFor(agent) const typedInstall = enhanced ? agent.enhanced_install_script ?? agent.install_script : agent.install_script + const setupCommand = canInstall ? login?.command : typedInstall const check = async () => { setChecking(true) @@ -148,7 +151,7 @@ export function AgentSetup({ ) : (

{typedInstall - ? 'The install command is typed into your Environment terminal below. Press Enter to run it.' + ? <>Run {typedInstall} in your Environment terminal below. : `Install the ${agent.executable ?? agent.name} executable into ~/.local/bin from your Environment terminal below.`}

)} @@ -172,10 +175,22 @@ export function AgentSetup({ <> {login && (

- Log in once: {login.command} is typed into your environment terminal. {login.hint} + Log in once by running {login.command} in your environment terminal. {login.hint}

)} - + {setupCommand && ( + <> +

Paste the command at a shell prompt, review it, then press Enter.

+ + + )} + ) : ( <> diff --git a/web/src/components/dock.test.ts b/web/src/components/dock.test.ts index 0de198bb4..6f6989e7e 100644 --- a/web/src/components/dock.test.ts +++ b/web/src/components/dock.test.ts @@ -36,7 +36,56 @@ describe('Dock controls', () => { expect(screen.getByRole('status')).toHaveProperty('textContent', 'At most 2 tabs') }) - it('trades the drag separator for half and full heights on a touch screen', () => { + it('shows desktop resize affordances and sizes within the available height', () => { + atViewport(1440, { height: 900, pointer: 'fine' }) + const onHeightChange = vi.fn() + const props = (height: number) => ({ + tabs: [{ id: 't1', label: 't1' }], + activeTab: 't1', + onSelectTab: vi.fn(), + maxTabs: 1, + height, + onHeightChange, + collapsed: false, + onToggleCollapse: vi.fn(), + children: createElement('div'), + }) + + const view = render(createElement(Dock, props(240))) + screen.getByRole('separator', { name: 'Resize terminal' }) + fireEvent.click( + screen.getByRole('button', { name: 'Expand terminal to its available height' }), + ) + expect(onHeightChange).toHaveBeenLastCalledWith(700) + + view.rerender(createElement(Dock, props(700))) + fireEvent.click( + screen.getByRole('button', { name: 'Shrink terminal to half its available height' }), + ) + expect(onHeightChange).toHaveBeenLastCalledWith(350) + }) + + it('keeps fill docks free of resize and collapse controls', () => { + atViewport(1440, { height: 900, pointer: 'fine' }) + render(createElement(Dock, { + tabs: [{ id: 't1', label: 't1' }], + activeTab: 't1', + onSelectTab: vi.fn(), + maxTabs: 1, + height: 240, + onHeightChange: vi.fn(), + collapsed: false, + onToggleCollapse: vi.fn(), + containment: 'fill', + children: createElement('div'), + })) + + expect(screen.queryByRole('separator')).toBeNull() + expect(screen.queryByRole('button', { name: /available height/ })).toBeNull() + expect(screen.queryByRole('button', { name: 'Collapse terminal' })).toBeNull() + }) + + it('trades the drag separator for half and maximum heights on a touch screen', () => { // A phone: the separator is a drag, and a finger has no drag to give it. atViewport(390, { height: 844, pointer: 'coarse' }) const onHeightChange = vi.fn() @@ -55,13 +104,13 @@ describe('Dock controls', () => { const view = render(createElement(Dock, props(240))) expect(screen.queryByRole('separator')).toBeNull() - // Half and full are measured from the room the dock has, which here is + // Half and maximum are measured from the room the dock has, which here is // the viewport less the shell's own chrome. - fireEvent.click(screen.getByRole('button', { name: /full screen/ })) + fireEvent.click(screen.getByRole('button', { name: /available height/ })) expect(onHeightChange).toHaveBeenLastCalledWith(644) view.rerender(createElement(Dock, props(644))) - fireEvent.click(screen.getByRole('button', { name: /half the screen/ })) + fireEvent.click(screen.getByRole('button', { name: /half its available height/ })) expect(onHeightChange).toHaveBeenLastCalledWith(322) }) diff --git a/web/src/components/dock.tsx b/web/src/components/dock.tsx index e84c41d94..fb71aa6ac 100644 --- a/web/src/components/dock.tsx +++ b/web/src/components/dock.tsx @@ -115,7 +115,6 @@ export function Dock({ ) const min = Math.min(requiredMinimum, max) const currentHeight = Math.min(max, Math.max(min, height)) - // A finger cannot drag the edge, so touch toggles between half and full height. const halfHeight = Math.min(max, Math.max(min, Math.round(max / 2))) const full = currentHeight >= max const index = Math.max( @@ -254,10 +253,16 @@ export function Dock({ className={cn( focusRing, // Without `touch-none` the browser claims a pen or trackpad drag - // as a pan and cancels the pointer stream this listens to. - 'absolute inset-x-0 -top-px z-10 h-1 cursor-row-resize touch-none bg-transparent transition-colors hover:bg-accent/20 focus-visible:bg-accent/20', + // as a pan and cancels the pointer stream this listens to. The + // strip ends at the dock edge so it cannot cover header controls. + 'group absolute inset-x-0 -top-1.5 z-10 flex h-1.5 cursor-row-resize touch-none items-center justify-center bg-transparent transition-colors hover:bg-accent/20 focus-visible:bg-accent/20', )} - /> + > +