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
48 changes: 44 additions & 4 deletions packages/cli/src/builder-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,17 +144,57 @@ export type RunActionExecution = {

export type ActionExecution = WriteActionExecution | RunActionExecution

function quoteUnsafeDescription(content: string): string {
// Small models commonly write a `description` value containing a colon
// (e.g. "Étape 1 : ..." or "...timeout: 60s..."), which YAML mis-parses
// as a nested mapping and chokes the whole frontmatter. Detect that case
// and wrap the value in double quotes ; the parser then reads it as a
// plain string.
const lines = content.split('\n')
let inFrontmatter = false
let fmFenceCount = 0
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] as string
if (line.trim() === '---') {
fmFenceCount += 1
inFrontmatter = fmFenceCount === 1
if (fmFenceCount === 2) break
continue
}
if (!inFrontmatter) continue
const m = /^(\s*description\s*:\s*)(.*)$/.exec(line)
if (!m) continue
const prefix = m[1] as string
const value = (m[2] as string).trim()
if (value.length === 0) continue
// Already quoted ? leave it alone.
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
continue
}
if (!value.includes(':')) continue
// Escape any embedded double quotes so the wrap stays valid.
const safe = value.replace(/"/g, '\\"')
lines[i] = `${prefix}"${safe}"`
}
return lines.join('\n')
}

function normalizeAgentMd(content: string): string {
// Small models often confuse the protocol separator (`---` between path
// and content) with the YAML frontmatter opener and forget to write a
// leading `---`. If the content looks like raw frontmatter (starts with a
// recognized key), prepend `---` so it parses cleanly.
const trimmed = content.replace(/^\s+/, '')
if (trimmed.startsWith('---')) return content
if (/^(name|description|model|sandbox|maxTurns)\s*:/m.test(trimmed)) {
return `---\n${content.replace(/^\s+/, '')}`
let normalized = content
if (!trimmed.startsWith('---')) {
if (/^(name|description|model|sandbox|maxTurns)\s*:/m.test(trimmed)) {
normalized = `---\n${content.replace(/^\s+/, '')}`
}
}
return content
return quoteUnsafeDescription(normalized)
}

const AGENT_PATH_RE = /^(agents\/[a-z][a-z0-9-]*)\/[^/]+$/
Expand Down
55 changes: 50 additions & 5 deletions packages/cli/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@
// └──────────────┘ ← terminal bottom (FIXED)
//
// PgUp / PgDn / Ctrl+E scroll the chat transcript inside Welcome.
// Tab / Shift+Tab cycle focus through Mission Control cards (only when
// the prompt input is empty so it doesn't fight TextInput). Enter on a
// focused card opens a full-screen CardDetail view ; Esc closes it.

import { Box, useInput, useStdin } from 'ink'
import React from 'react'
import { useChatContext } from '../hooks/useChatContext.tsx'
import { useCardFocus } from '../hooks/useCardFocus.ts'
import { useLanguage } from '../i18n/LanguageContext.tsx'
import { CardDetail } from './CardDetail.tsx'
import { MissionControl } from './MissionControl.tsx'
import { ProviderLogo } from './ProviderLogo.tsx'
import { Splash } from './Splash.tsx'
Expand All @@ -22,25 +27,65 @@ import { Welcome } from './Welcome.tsx'
export function App(): React.JSX.Element {
const { lang } = useLanguage()
const { isRawModeSupported } = useStdin()
const { scrollUp, scrollDown, scrollToBottom, pending, state } = useChatContext()
const { scrollUp, scrollDown, scrollToBottom, pending, state, promptDraft } =
useChatContext()
const focus = useCardFocus(state.actions)
const rows = process.stdout.rows ?? 30
const cols = process.stdout.columns ?? 80
const hasPending = pending !== null
const hasActions = state.actions.length > 0
const promptIsEmpty = promptDraft.length === 0

// Tab/Enter is only meaningful when there are actions, the prompt is
// empty (so TextInput doesn't lose its keystrokes), and no permission
// dialog is showing.
const cardKeysActive =
isRawModeSupported &&
lang !== null &&
!focus.detailOpen &&
!hasPending &&
hasActions &&
promptIsEmpty

useInput(
(_, key) => {
(input, key) => {
if (key.pageUp) scrollUp()
else if (key.pageDown) scrollDown()
else if (key.ctrl && _ === 'e') scrollToBottom()
else if (key.ctrl && input === 'e') scrollToBottom()
else if (cardKeysActive && key.tab && key.shift) focus.cycleBack()
else if (cardKeysActive && key.tab) focus.cycle()
else if (cardKeysActive && key.return) focus.open()
// Esc clears the card focus (only when something is focused and
// the prompt is empty, so we never swallow an Esc the user meant
// for cancelling input).
else if (
key.escape &&
promptIsEmpty &&
!hasPending &&
focus.focusedId !== null
) {
focus.clearFocus()
}
},
{ isActive: isRawModeSupported && lang !== null },
{ isActive: isRawModeSupported && lang !== null && !focus.detailOpen },
)

// Detail view : modal full-screen replacement.
if (focus.detailOpen && focus.focusedId !== null) {
const action = state.actions.find((a) => a.id === focus.focusedId)
if (action) {
return <CardDetail action={action} onClose={focus.close} />
}
}

return (
<Box flexDirection="column" height={rows} width={cols}>
<Box flexShrink={1} flexDirection="column" overflow="hidden">
{hasActions ? <MissionControl actions={state.actions} /> : <Splash />}
{hasActions ? (
<MissionControl actions={state.actions} focusedId={focus.focusedId} />
) : (
<Splash />
)}
</Box>
{/* Spacer pushes Welcome to the bottom AND parks the provider logo
at the bottom-right of the top zone (just above the Welcome
Expand Down
157 changes: 157 additions & 0 deletions packages/cli/src/components/CardDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Full-screen detail view for a single Mission Control action.
//
// Mounted by App when useCardFocus reports detailOpen=true. Replaces
// both Mission Control AND Welcome — the user gets the entire screen
// to read the full content of the action they pressed Enter on.
//
// Scrolls line-by-line with PgUp / PgDn / arrow up/down. Esc closes.

import { Box, Text, useInput } from 'ink'
import React, { useState } from 'react'
import type { Action, ActionStatus, RunAction, WriteAction } from '../actions/types.ts'
import { C } from '../theme/colors.ts'
import {
type HighlightedLine,
type Segment,
highlightPlain,
highlightYamlText,
} from './syntax.ts'

const STATUS_LABEL: Record<ActionStatus, string> = {
proposed: 'PROPOSED',
approved: 'APPROVED',
running: 'RUNNING',
done: 'DONE',
failed: 'FAILED',
declined: 'DECLINED',
}

const STATUS_COLOR: Record<ActionStatus, string> = {
proposed: C.orange,
approved: C.orangeBright,
running: C.yellow,
done: C.green,
failed: C.red,
declined: C.grey,
}

function buildLines(action: Action): HighlightedLine[] {
if (action.kind === 'write') {
return highlightYamlText(action.content)
}
// run : prompt then output
const out: HighlightedLine[] = []
out.push([{ text: '── prompt ──', color: C.grey, dim: true }])
out.push(...highlightPlain(action.prompt))
out.push([{ text: '' }])
out.push([{ text: '── output ──', color: C.grey, dim: true }])
if (action.output.length > 0) {
out.push(...highlightPlain(action.output))
} else {
out.push([{ text: '(empty)', color: C.grey, dim: true }])
}
if (action.status === 'failed' && action.error) {
out.push([{ text: '' }])
out.push([{ text: `✗ ${action.error}`, color: C.red }])
}
return out
}

function headerFor(action: Action): string {
if (action.kind === 'write') return `write ${action.path}`
return `run ${action.agent}`
}

export function CardDetail({
action,
onClose,
}: {
action: Action
onClose: () => void
}): React.JSX.Element {
const rows = process.stdout.rows ?? 30
const cols = process.stdout.columns ?? 80
const lines = buildLines(action)

// Reserve : 2 rows for the title bar, 2 rows for the footer hint, 1
// separator. Body gets the rest.
const bodyHeight = Math.max(5, rows - 5)
const [offset, setOffset] = useState(0)
const maxOffset = Math.max(0, lines.length - bodyHeight)

useInput((input, key) => {
if (key.escape || input === 'q') {
onClose()
return
}
if (key.pageUp) setOffset((o) => Math.max(0, o - bodyHeight))
else if (key.pageDown) setOffset((o) => Math.min(maxOffset, o + bodyHeight))
else if (key.upArrow) setOffset((o) => Math.max(0, o - 1))
else if (key.downArrow) setOffset((o) => Math.min(maxOffset, o + 1))
else if (input === 'g') setOffset(0)
else if (input === 'G') setOffset(maxOffset)
})

const visible = lines.slice(offset, offset + bodyHeight)
const totalLines = lines.length
const lastShown = Math.min(totalLines, offset + bodyHeight)

return (
<Box flexDirection="column" height={rows} width={cols}>
{/* Title bar */}
<Box paddingX={2}>
<Text color={STATUS_COLOR[action.status]} bold>
{`[${STATUS_LABEL[action.status]}]`}
</Text>
<Text color={C.grey} dimColor>
{' detail '}
</Text>
<Text color={C.white}>{headerFor(action)}</Text>
</Box>
<Text color={C.grey} dimColor>
{'─'.repeat(cols)}
</Text>

{/* Body */}
<Box flexDirection="column" paddingX={2} height={bodyHeight}>
{visible.map((segments: HighlightedLine, i: number) => {
const lineNo = offset + i + 1
return (
<Box key={`l-${(offset + i).toString()}`}>
<Text color={C.grey} dimColor>
{`${lineNo.toString().padStart(4, ' ')} `}
</Text>
{segments.map((seg: Segment, j: number) => (
<Text
key={`s-${i.toString()}-${j.toString()}`}
color={seg.color}
dimColor={seg.dim}
bold={seg.bold}
>
{seg.text}
</Text>
))}
</Box>
)
})}
</Box>

{/* Footer */}
<Text color={C.grey} dimColor>
{'─'.repeat(cols)}
</Text>
<Box paddingX={2} justifyContent="space-between">
<Box>
<Text color={C.grey} dimColor>
{`lines ${(offset + 1).toString()}..${lastShown.toString()} of ${totalLines.toString()}`}
</Text>
</Box>
<Box>
<Text color={C.grey} dimColor>
{'[↑↓ / PgUp/PgDn] scroll [g/G] top/bottom [Esc / q] close'}
</Text>
</Box>
</Box>
</Box>
)
}
Loading
Loading