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
6 changes: 6 additions & 0 deletions docs/nano.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ usual — nano changes what the *model* sees, not the UI. A mid-session
a `nano` chip beside the model name (e.g. `deepseek-v4-flash nano`),
driven by the backend's `system/init` frame.

The browser client (`clawcodex web --nano`) shows the same fact on three
surfaces — a green `nano` chip beside the composer's model picker (from
the welcome screen on, via `/api/status`), the chip riding the model
segment of the run-stats line, and a `Harness: nano` row in the session
details panel — all driven by the gateway's `session.info.nano`.

## What nano sends

| | nano | default |
Expand Down
11 changes: 11 additions & 0 deletions ui-web/src/conversation/ConversationRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
submitPrompt,
} from '../state/actions.ts'
import {
$backendNano,
$commands,
$connection,
$contextUsage,
Expand Down Expand Up @@ -80,9 +81,16 @@ export function ConversationRoot() {
const loading = useStore($sessionLoading)
const tab = useStore($conversationTab)
const trajectory = useStore($trajectory)
const backendNano = useStore($backendNano)
const stats = useMemo(() => trajectoryStats(trajectory), [trajectory])
const todos = useMemo(() => currentTodos(transcript.nodes), [transcript.nodes])

// The session's own truth once session.info reported it, else the backend's
// process-wide fact (/api/status) — same shape as the approval mode and
// model fallbacks in the composer below. The two can only disagree across a
// backend restart, and the session's word wins.
const nano = transcript.info.nano ?? backendNano

const [draft, setDraft] = useState('')
// null while in flight, so the panel can say "loading" rather than "no plan".
const [plan, setPlan] = useState<string | null>(null)
Expand Down Expand Up @@ -209,6 +217,7 @@ export function ConversationRoot() {
effort={effort}
hero={hero}
models={models}
nano={nano === true}
onApprovalModeChange={mode => {
void setApprovalMode(mode)
}}
Expand Down Expand Up @@ -374,6 +383,7 @@ export function ConversationRoot() {
{seatPanel}
<RunStatsBar
model={transcript.info.model}
nano={nano === true}
provider={transcript.info.provider}
stats={stats}
/>
Expand Down Expand Up @@ -427,6 +437,7 @@ export function ConversationRoot() {
{seatPanel}
<RunStatsBar
model={transcript.info.model}
nano={nano === true}
provider={transcript.info.provider}
stats={stats}
/>
Expand Down
15 changes: 15 additions & 0 deletions ui-web/src/conversation/InputBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@ import { EffortSelect } from './EffortSelect.tsx'
import { ModelSelect } from './ModelSelect.tsx'
import { PermissionSelect, type ApprovalMode } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
import pickerCss from './Pickers.module.css'

export interface InputBarProps {
approvalMode?: ApprovalMode
draft: string
effort: EffortOptionsResult
hero?: boolean
/** Nano mode (docs/nano.md) — renders a chip beside the model, like the TUI's. */
nano?: boolean
/** False when the session's model would 400 on an image. */
vision?: boolean
models: ModelOptionsResult
Expand Down Expand Up @@ -66,6 +69,7 @@ export function InputBar({
effort,
hero = false,
models,
nano = false,
onApprovalModeChange,
onDraftChange,
onEffortChange,
Expand Down Expand Up @@ -529,6 +533,17 @@ export function InputBar({
sessionProvider={sessionProvider}
/>
<EffortSelect onChange={onEffortChange} options={effort} />
{/* After effort, matching the TUI's status-line order
(`model effort nano`). A fact chip, not a control: nano is a
launch flag, so there is nothing to open or toggle here. */}
{nano && (
<span
className={pickerCss.nanoBadge}
title="Nano mode: six tools, minimal prompt (launched with --nano)"
>
nano
</span>
)}
</div>
<div className={css.trailing}>
<ContextMeter usage={usage} />
Expand Down
137 changes: 137 additions & 0 deletions ui-web/src/conversation/NanoBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* The nano chip (backend `--nano`, docs/nano.md) on its three surfaces:
* the composer row, the run-stats line, and the session details panel.
*
* The rule under every case: the chip is driven by an explicit `true` and
* nothing else — a backend that never says nano must never grow a badge.
*/

import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { DetailsPanel } from '../details/DetailsPanel.tsx'
import { $contextUsage, $sessionId, $transcript, $workspace } from '../state/store.ts'
import { emptyTranscript } from '../state/transcript.ts'
import type { TrajectoryStats } from '../state/trajectory.ts'
import { RunStatsBar } from '../trajectory/RunStatsBar.tsx'
import { InputBar } from './InputBar.tsx'

afterEach(() => {
cleanup()
$transcript.set(emptyTranscript())
$workspace.set('')
$sessionId.set(null)
$contextUsage.set(null)
})

function renderBar(nano?: boolean) {
render(
<InputBar
draft=""
effort={{ supported: false }}
models={{}}
nano={nano}
onApprovalModeChange={vi.fn()}
onDraftChange={vi.fn()}
onEffortChange={vi.fn()}
onModelChange={vi.fn()}
onStop={vi.fn()}
onSubmit={vi.fn()}
running={false}
usage={null}
/>,
)
}

describe('InputBar nano chip', () => {
it('renders the chip when the session is nano', () => {
renderBar(true)

expect(screen.getByText('nano')).toBeTruthy()
})

it('renders nothing by default', () => {
// Absent on older backends must stay absent here — a chip with no flag
// behind it would claim a mode the session is not in.
renderBar()

expect(screen.queryByText('nano')).toBeNull()
})

it('is a fact, not a control — no button role', () => {
renderBar(true)

const chip = screen.getByText('nano')

expect(chip.tagName).toBe('SPAN')
expect(chip.getAttribute('role')).toBeNull()
})
})

const NO_RUN: TrajectoryStats = {
cacheHitRatio: null,
inputTokens: 0,
llmMs: 0,
outputTokens: 0,
steps: 0,
throughput: null,
toolMs: 0,
ttftMs: null,
turns: 0,
}

describe('RunStatsBar nano chip', () => {
it('rides the model segment', () => {
render(
<RunStatsBar model="deepseek-v4-flash" nano provider="deepseek" stats={NO_RUN} />,
)

const model = screen.getByText('deepseek:deepseek-v4-flash')
const chip = screen.getByText('nano')

// Same group: whatever narrows the bar cannot shed the mode without also
// shedding the model it describes (the TUI stats-line contract).
expect(model.parentElement).toBe(chip.parentElement)
})

it('shows no chip without the flag', () => {
render(<RunStatsBar model="deepseek-v4-flash" provider="deepseek" stats={NO_RUN} />)

expect(screen.queryByText('nano')).toBeNull()
})

it('shows no chip with no model to describe', () => {
// The chip rides the model segment; with nothing to ride it stays off
// rather than floating as a lone token in an otherwise empty bar.
const { container } = render(<RunStatsBar nano stats={NO_RUN} />)

expect(container.firstChild).toBeNull()
})
})

describe('DetailsPanel harness row', () => {
it('names the harness when the session is nano', () => {
$transcript.set({
...emptyTranscript(),
info: { model: 'deepseek-v4-flash', nano: true, provider: 'deepseek' },
})

render(<DetailsPanel />)

expect(screen.getByText('Harness')).toBeTruthy()
expect(screen.getByText('nano')).toBeTruthy()
})

it('shows no harness row for a default session', () => {
// Default mode is not a fact worth a row — and strict === true keeps a
// backend that never reported the field silent too.
$transcript.set({
...emptyTranscript(),
info: { model: 'deepseek-v4-flash', provider: 'deepseek' },
})

render(<DetailsPanel />)

expect(screen.queryByText('Harness')).toBeNull()
})
})
23 changes: 23 additions & 0 deletions ui-web/src/conversation/Pickers.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,26 @@
display: none;
}
}

/* Nano mode fact chip (docs/nano.md). Sits among the picker triggers but is
not one: nano is a launch flag, so it has no menu, no hover, nothing to
toggle. Green because nano is the slim/eco profile — a fact, not a warning.
Deliberately NOT shed by the narrow-composer query above: like the TUI's
stats line, the mode must survive every narrowing. */
.nanoBadge {
flex: none;
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 10px;
background: var(--cc-alias-state-success-tertiary);
color: var(--cc-alias-state-success-primary);
font-size: 11px;
line-height: 20px;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
cursor: default;
user-select: none;
}
10 changes: 10 additions & 0 deletions ui-web/src/details/DetailsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ export function DetailsPanel() {
<dd title={info.model}>{info.model ?? '—'}</dd>
<dt>Provider</dt>
<dd>{info.provider ?? '—'}</dd>
{/* Only when the session IS nano: default mode is not a fact worth
a row, and strict === true keeps older backends silent. */}
{info.nano === true && (
<>
<dt>Harness</dt>
<dd title="Nano mode: six tools, minimal prompt (launched with --nano)">
nano
</dd>
</>
)}
{info.reasoning_effort !== undefined && (
<>
<dt>Effort</dt>
Expand Down
7 changes: 7 additions & 0 deletions ui-web/src/gateway/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export interface SessionInfoPayload {
cwd?: string
desktop_contract?: number
model?: string
/**
* Nano mode (backend `--nano`, docs/nano.md): six-tool pi-style minimal
* harness. Rendered as a chip beside the model name, like the TUI's.
* Compared strict `=== true` everywhere — absent on an older backend must
* stay falsy, never render a stale badge.
*/
nano?: boolean
provider?: string
reasoning_effort?: string
/** Whether the session's model accepts image input; false hides the attach control. */
Expand Down
21 changes: 12 additions & 9 deletions ui-web/src/state/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
SlashResult,
} from '../gateway/protocol.ts'
import {
$backendNano,
$bootError,
$bootPhase,
$commands,
Expand Down Expand Up @@ -133,23 +134,25 @@ export async function start(): Promise<void> {
}

/**
* The workspace the backend was started in.
* The backend facts the hero needs before any session exists.
*
* It is the directory the first session will run in, so the hero has to name
* it *before* that session exists — and only REST knows it that early
* (`session.info` carries a cwd, but not until a session is created).
* The workspace is the directory the first session will run in, and nano is
* whether that session will be a nano one — both have to be named *before*
* the session exists, and only REST knows them that early (`session.info`
* carries both, but not until a session is created).
*/
async function seedWorkspace(target: BackendTarget): Promise<void> {
if ($workspace.get() !== '') return

try {
const status = await apiGet<{ workspace?: string }>(target, '/status')
const status = await apiGet<{ nano?: boolean; workspace?: string }>(target, '/status')

if (typeof status.workspace === 'string' && status.workspace !== '') {
if ($workspace.get() === '' && typeof status.workspace === 'string' && status.workspace !== '') {
$workspace.set(status.workspace)
}

// Strict === true: an older backend without the field must stay falsy.
$backendNano.set(status.nano === true)
} catch {
/* the hero simply omits the workspace chip */
/* the hero simply omits the workspace chip and the nano badge */
}
}

Expand Down
11 changes: 11 additions & 0 deletions ui-web/src/state/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ export const $projects = atom<ProjectNode[]>([])
export const $projectsLoading = atom<boolean>(false)
export const $workspace = atom<string>('')

/**
* Whether this backend runs in nano mode (`clawcodex web --nano`).
*
* Nano is process-global on the backend — every session it hosts is nano — so
* this is a boot-time fact from `/api/status`, not session state. It exists
* for the welcome screen: `session.info` also carries `nano`, but only once a
* session does, and the composer should say what the first prompt will run on
* before it is sent.
*/
export const $backendNano = atom<boolean>(false)

export const $models = atom<ModelOptionsResult>({})
/** Effort ladder for the running model; `supported: false` hides the chip. */
export const $effort = atom<EffortOptionsResult>({ supported: false })
Expand Down
13 changes: 13 additions & 0 deletions ui-web/src/state/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,19 @@ describe('turn state', () => {
expect(state.info).toMatchObject({ approval_mode: 'manual', model: 'm', provider: 'p' })
})

it('keeps the nano flag across a session.info that omits it', () => {
// A mid-session model switch republishes session.info; the nano chip must
// survive it (matching the backend, where _install_provider keeps the
// nano registry) even if that republish carries no nano field.
const state = fold([
event('session.info', { model: 'm', nano: true }),
event('session.info', { model: 'm2', provider: 'p2' }),
])

expect(state.info.nano).toBe(true)
expect(state.info.model).toBe('m2')
})

it('accumulates usage across turns', () => {
const state = fold([
event('message.complete', {
Expand Down
14 changes: 14 additions & 0 deletions ui-web/src/trajectory/RunStatsBar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,17 @@
color: var(--cc-alias-label-caption);
white-space: nowrap;
}

/* Nano mode chip, riding the model segment (docs/nano.md). Same green as the
composer's badge so the two surfaces read as one fact. */
.nano {
flex: none;
padding: 0 6px;
border-radius: 8px;
background: var(--cc-alias-state-success-tertiary);
color: var(--cc-alias-state-success-primary);
font-size: 10px;
line-height: 15px;
font-weight: 600;
letter-spacing: 0.02em;
}
Loading
Loading