Skip to content
Draft
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
11 changes: 7 additions & 4 deletions docs/dashboard-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions docs/harnesses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions docs/terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

Expand Down
41 changes: 40 additions & 1 deletion web/src/components/agents/agent-setup.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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'
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' })
Expand All @@ -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 })
})
Expand Down Expand Up @@ -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'))
})
})
23 changes: 19 additions & 4 deletions web/src/components/agents/agent-setup.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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<HTMLElement>(null)
const caps = useCapability()
const [agent, setAgent] = useState(initial)
const [mode, setMode] = useState<SetupMode>(() => requireEnhanced ? 'acp' : setupMode(initial, useStore.getState().launchDefaults[initial.name]?.mode))
Expand All @@ -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)
Expand Down Expand Up @@ -148,7 +151,7 @@ export function AgentSetup({
) : (
<p className="text-ui text-text">
{typedInstall
? 'The install command is typed into your Environment terminal below. Press Enter to run it.'
? <>Run <Code ref={commandSource}>{typedInstall}</Code> in your Environment terminal below.</>
: `Install the ${agent.executable ?? agent.name} executable into ~/.local/bin from your Environment terminal below.`}
</p>
)}
Expand All @@ -172,10 +175,22 @@ export function AgentSetup({
<>
{login && (
<p className="text-ui text-text">
Log in once: <Code>{login.command}</Code> is typed into your environment terminal. {login.hint}
Log in once by running <Code ref={canInstall ? commandSource : undefined}>{login.command}</Code> in your environment terminal. {login.hint}
</p>
)}
<TerminalDock client={client} openOnMount initialLine={canInstall ? login?.command : typedInstall} />
{setupCommand && (
<>
<p className="text-ui text-text">Paste the command at a shell prompt, review it, then press Enter.</p>
<Button
type="button"
variant="secondary"
onClick={() => void copyText(setupCommand, commandSource.current)}
>
{canInstall ? 'Copy login command' : 'Copy install command'}
</Button>
</>
)}
<TerminalDock client={client} openOnMount />
Comment thread
AviationNerd marked this conversation as resolved.
Resolved
</>
) : (
<>
Expand Down
57 changes: 53 additions & 4 deletions web/src/components/dock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
})

Expand Down
19 changes: 12 additions & 7 deletions web/src/components/dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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',
)}
/>
>
<span
aria-hidden="true"
className="h-1 w-8 rounded-full bg-icon-faint/70 transition-colors group-hover:bg-accent group-focus-visible:bg-accent"
/>
</div>
)}
<div
ref={headerRef}
Expand Down Expand Up @@ -379,16 +384,16 @@ export function Dock({
{actions}
</div>
)}
{!collapsed && coarse && !fill && (
{!collapsed && !fill && (
<Button
className="max-[640px]:order-2"
type="button"
variant="ghost"
size="icon"
label={
full
? 'Shrink terminal to half the screen'
: 'Expand terminal to the full screen'
? 'Shrink terminal to half its available height'
: 'Expand terminal to its available height'
}
aria-controls={dockID}
onClick={() => onHeightChange(full ? halfHeight : max)}
Expand Down
11 changes: 11 additions & 0 deletions web/src/components/xterm-host.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ describe('xterm links', () => {
})
})

describe('xterm input', () => {
it('keeps Alt-click from moving the shell cursor', async () => {
let ready: Terminal | null = null
const view = render(<Probe onReady={(terminal) => (ready = terminal)} />)
await waitFor(() => expect(ready).not.toBeNull())

expect((ready as Terminal | null)?.options.altClickMovesCursor).toBe(false)
view.unmount()
})
})

describe('server-owned protocol responses', () => {
it('suppresses actual parser queries while preserving mixed color setters, Unicode and real input', async () => {
let ready: Terminal | null = null
Expand Down
1 change: 1 addition & 0 deletions web/src/components/xterm-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@ export function useXterm({
scrollback: Math.max(0, Math.floor(scrollback)),
scrollOnEraseInDisplay: true,
cursorBlink: false,
altClickMovesCursor: false,
linkHandler: { activate: (_event, uri) => openLink(uri) },
})
const disposeResponder = installServerResponder(
Expand Down
10 changes: 10 additions & 0 deletions web/src/routes/environment/terminal-dock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,16 @@ describe('environment terminal dock', () => {
expect(useStore.getState().envTerminal.collapsed).toBe(true)
})

it('keeps a newly opened tab selected when mounted open', async () => {
vi.mocked(api.terminalStatus).mockResolvedValue({ running: true, tabs: ['main'] })
render(<TerminalDock openOnMount />)
await waitFor(() => expect(useStore.getState().envTerminal.activeTab).toBe('main'))

fireEvent.click(await screen.findByRole('button', { name: 'Add terminal tab' }))

await waitFor(() => expect(useStore.getState().envTerminal.activeTab).toBe('t2'))
})

it('opens the dock when a collapsed strip is asked for a tab', async () => {
vi.mocked(api.terminalStatus).mockResolvedValue({ running: true, tabs: ['main'] })
useStore.setState({ envTerminal: initialEnvTerminal })
Expand Down
4 changes: 3 additions & 1 deletion web/src/routes/environment/terminal-dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,10 @@ export function TerminalDock({
setCollapsed(false)
}, [openOnMount, setCollapsed])

const selectedOnMount = useRef(false)
useEffect(() => {
if (!openOnMount) return
if (!openOnMount || selectedOnMount.current) return
selectedOnMount.current = true
if (!dock.tabs.includes('main')) {
openTab()
} else if (dock.activeTab !== 'main') {
Expand Down
Loading