diff --git a/apps/daemon/src/chat-routes.ts b/apps/daemon/src/chat-routes.ts index 76d93ea2c2..fd7fd89259 100644 --- a/apps/daemon/src/chat-routes.ts +++ b/apps/daemon/src/chat-routes.ts @@ -37,7 +37,7 @@ function reconcileAssistantMessageOnRunEnd( export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { const { db, design } = ctx; const { sendApiError, createSseResponse } = ctx.http; - const { startChatRun } = ctx.chat; + const { startChatRun, submitToolResultToRun } = ctx.chat; const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents; const { handleCritiqueArtifact, @@ -219,6 +219,47 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) { res.json(body); }); + // Feed a `tool_result` content block into a running stream-json child. + // Currently used to answer Claude's `AskUserQuestion` tool: the host UI + // collects the user's choice, the web POSTs the formatted answer here, + // and the daemon writes a JSONL line into the still-open stdin. Without + // this path Claude auto-errors the tool in headless mode and falls back + // to a markdown duplicate of the same options. + app.post('/api/runs/:id/tool-result', (req, res) => { + if (typeof submitToolResultToRun !== 'function') { + return sendApiError(res, 501, 'NOT_IMPLEMENTED', 'tool-result wiring is not available'); + } + const body = (req.body || {}) as { + toolUseId?: unknown; + content?: unknown; + isError?: unknown; + }; + const toolUseId = typeof body.toolUseId === 'string' ? body.toolUseId : ''; + const content = typeof body.content === 'string' ? body.content : ''; + const isError = body.isError === true; + if (!toolUseId) { + return sendApiError(res, 400, 'BAD_REQUEST', 'toolUseId is required'); + } + const result = submitToolResultToRun(req.params.id, toolUseId, content, isError); + if (!result || !result.ok) { + const reason = result && result.reason ? result.reason : 'unknown'; + if (reason === 'not_found') { + return sendApiError(res, 404, 'NOT_FOUND', 'run not found'); + } + if (reason === 'run_terminal' || reason === 'stdin_closed') { + return sendApiError(res, 410, 'GONE', `run is no longer accepting tool results (${reason})`); + } + if (reason === 'stdin_text_mode') { + return sendApiError(res, 400, 'BAD_REQUEST', 'run does not support interactive tool results'); + } + if (reason === 'bad_tool_use_id') { + return sendApiError(res, 400, 'BAD_REQUEST', 'toolUseId is invalid'); + } + return sendApiError(res, 500, 'INTERNAL', `tool result write failed: ${reason}`); + } + res.json({ ok: true }); + }); + app.post('/api/chat', (req, res) => { if (isDaemonShuttingDown()) { return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down'); diff --git a/apps/daemon/src/claude-stream.ts b/apps/daemon/src/claude-stream.ts index d5b6af299f..01a10e18e7 100644 --- a/apps/daemon/src/claude-stream.ts +++ b/apps/daemon/src/claude-stream.ts @@ -113,6 +113,17 @@ export function createClaudeStreamHandler(onEvent: EventSink) { currentMessageId = typeof obj.message.id === 'string' ? obj.message.id : currentMessageId; const msgId = typeof obj.message.id === 'string' ? obj.message.id : null; const alreadyStreamed = msgId ? textStreamed.has(msgId) : false; + // Per-turn `stop_reason` is emitted as `turn_end` AFTER the content + // blocks have been processed (see below). When `--include-partial- + // messages` is unsupported, tool_use events surface only from the + // assistant wrapper here — emitting `turn_end` before that loop + // would let the daemon's stdin-close handler see an empty + // `pendingHostAnswers` set and close stdin before the + // AskUserQuestion tool_use was registered, which made the round + // trip silently fail. Read the stop_reason now, emit after. + const stopReason = typeof obj.message.stop_reason === 'string' + ? obj.message.stop_reason + : null; for (const block of obj.message.content) { if (!isRecord(block)) continue; if (block.type === 'tool_use') { @@ -142,6 +153,13 @@ export function createClaudeStreamHandler(onEvent: EventSink) { onEvent({ type: 'thinking_delta', delta: block.thinking }); } } + // Surface the turn_end signal now that every tool_use in this + // assistant message has been emitted, so the daemon's stdin-close + // handler has the up-to-date `pendingHostAnswers` set before + // deciding whether to close stream-json input stdin. + if (stopReason) { + onEvent({ type: 'turn_end', stopReason }); + } return; } diff --git a/apps/daemon/src/prompts/system.ts b/apps/daemon/src/prompts/system.ts index 162cada04f..3c61c38c04 100644 --- a/apps/daemon/src/prompts/system.ts +++ b/apps/daemon/src/prompts/system.ts @@ -412,6 +412,19 @@ export function composeSystemPrompt({ const mcpDirective = renderConnectedExternalMcpDirective(connectedExternalMcp); if (mcpDirective) parts.push(mcpDirective); + // Claude only: nudge the model toward the `AskUserQuestion` tool for + // mid-conversation clarifications. Without this hint Claude tends to fall + // back to a markdown bulleted list of options, which the chat UI cannot + // turn into clickable buttons. Discovery (turn 1) is still owned by the + // `` flow defined in DISCOVERY_AND_PHILOSOPHY; this only + // covers follow-ups where the next action depends on a small set of + // choices the user can pick quickly. + if (agentId === 'claude') { + parts.push( + "\n\n---\n\n## Clarifying questions\n\nWhen you need a mid-conversation clarification AND the natural answer is one of a small finite set of choices (2-4 options per question), call the `AskUserQuestion` tool instead of writing a bulleted list in markdown. The host chat renders the tool call as inline choice buttons; a markdown list renders as plain text and forces the user to type a reply. Skip the tool when the answer is naturally free-form text, when the answer needs more than ~4 options, or when you only have one yes/no choice to ask. First-turn discovery still uses the `` workflow described earlier; `AskUserQuestion` is for follow-ups only.\n\n**When you call `AskUserQuestion`, that tool call is the entire response.** Do NOT also write the same questions or options as markdown text alongside it, do NOT add a trailing prose paragraph like \"what sounds right?\", do NOT hedge by listing the options twice. Emit the tool call and stop generating tokens. The host is waiting on the tool's `tool_result` and will resume your turn the moment the user answers. Anything you write before, between, or after the tool call in the same message just duplicates what the card already shows and confuses the user.", + ); + } + return parts.join(''); } diff --git a/apps/daemon/src/runtimes/defs/claude.ts b/apps/daemon/src/runtimes/defs/claude.ts index 6d204e0037..25d6d6f3e2 100644 --- a/apps/daemon/src/runtimes/defs/claude.ts +++ b/apps/daemon/src/runtimes/defs/claude.ts @@ -44,7 +44,14 @@ export const claudeAgentDef = { // cursor/qwen entries below. buildArgs: (_prompt, _imagePaths, extraAllowedDirs = [], options = {}) => { const caps = agentCapabilities.get('claude') || {}; - const args = ['-p', '--output-format', 'stream-json', '--verbose']; + // `--input-format stream-json` lets the daemon stream multiple JSONL + // messages into stdin instead of closing it after the initial prompt. + // This is what lets us answer Claude's `AskUserQuestion` tool calls + // with a real `tool_result` block — without it claude-code auto errors + // the tool because it cannot prompt the user interactively in headless + // mode, and the model falls back to a markdown duplicate of the same + // options. + const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose']; // `--include-partial-messages` lands richer streaming events but only // exists in newer Claude Code builds. Older installs reject it with // "unknown option" and exit 1, killing the chat. Gate on the probe. @@ -66,5 +73,6 @@ export const claudeAgentDef = { return args; }, promptViaStdin: true, + promptInputFormat: 'stream-json', streamFormat: 'claude-stream-json', } satisfies RuntimeAgentDef; diff --git a/apps/daemon/src/runtimes/types.ts b/apps/daemon/src/runtimes/types.ts index c120a4d952..2787f7fc78 100644 --- a/apps/daemon/src/runtimes/types.ts +++ b/apps/daemon/src/runtimes/types.ts @@ -52,6 +52,13 @@ export type RuntimeAgentDef = { helpArgs?: string[]; capabilityFlags?: Record; promptViaStdin?: boolean; + // Format for the user prompt fed via stdin. Default is plain text (the + // entire prompt buffer goes in raw, then stdin is closed). When set to + // 'stream-json' the daemon writes a single JSONL line wrapping the prompt + // as an Anthropic user message (so tool_result blocks can later be + // injected into the same stdin without re-spawning the child). Only + // honored for adapters that also set `promptViaStdin: true`. + promptInputFormat?: 'text' | 'stream-json'; eventParser?: string; env?: Record; listModels?: RuntimeListModels; diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 54280b612f..e5061d2e70 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -4374,6 +4374,42 @@ export async function startServer({ lastAgentEventPhase = summarizeAgentEventForInactivity(ev); noteAgentActivity(); send('agent', ev); + // Stream-json input mode keeps the child's stdin open across the + // turn so we can answer interactive tools like `AskUserQuestion` + // with a real `tool_result`. The child has no other way to know + // the conversation is over, though — without an EOF it sits idle + // until the inactivity watchdog kills it. Bookkeeping here: + // - tool_use(AskUserQuestion): record the id so we know we owe + // the model a tool_result before the turn can end. + // - turn_end (per-turn synthesized from `stop_reason`): fire on + // `end_turn` etc. but NOT on `tool_use` — that stop reason + // means the model paused mid-tool, not "turn complete". + // - usage (session result at EOF in single-shot mode). + try { + if (run.stdinOpen) { + if ( + ev && + typeof ev === 'object' && + ev.type === 'tool_use' && + (ev.name === 'AskUserQuestion' || ev.name === 'ask_user_question') && + typeof ev.id === 'string' + ) { + if (!run.pendingHostAnswers) run.pendingHostAnswers = new Set(); + run.pendingHostAnswers.add(ev.id); + } else if ( + ev && + typeof ev === 'object' && + ((ev.type === 'turn_end' && ev.stopReason !== 'tool_use') || + ev.type === 'usage') && + (!run.pendingHostAnswers || run.pendingHostAnswers.size === 0) + ) { + if (run.child && run.child.stdin && !run.child.stdin.destroyed) { + try { run.child.stdin.end(); } catch {} + } + run.stdinOpen = false; + } + } + } catch {} }); child.stdout.on('data', (chunk) => claude.feed(chunk)); child.on('close', () => claude.flush()); @@ -4566,10 +4602,87 @@ export async function startServer({ design.runs.finish(run, status, code, signal); }); if (writePromptToChildStdin && child.stdin) { - child.stdin.end(composed, 'utf8'); + const promptInputFormat = runtimeAdapter.promptInputFormat(); + if (promptInputFormat === 'stream-json') { + // Wrap the prompt as an Anthropic user message and write it as one + // JSONL line. Do NOT close stdin: claude-code keeps reading further + // messages until EOF, which is what lets us inject a `tool_result` + // block later when the user answers an `AskUserQuestion` card. The + // stdin is closed implicitly when the child exits (run terminates, + // user cancels, or the model finishes without an outstanding tool + // call). + const userMessage = JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: composed }], + }, + }); + try { + child.stdin.write(`${userMessage}\n`, 'utf8'); + } catch (err) { + // Swallow EPIPE here for the same reason as the listener above — + // a fast-exiting child has already routed its failure through + // stderr / exit handlers. + if (err && err.code !== 'EPIPE') throw err; + } + run.stdinOpen = true; + } else { + child.stdin.end(composed, 'utf8'); + } } }; + // Send a `tool_result` content block into a still-running stream-json + // child. Used for interactive tools that the host answers (currently: + // Claude's `AskUserQuestion`). The run must still be active and its + // stdin must still be open — we never re-spawn a closed child. + const submitToolResultToRun = (runId, toolUseId, content, isError = false) => { + const run = design.runs.get(runId); + if (!run) return { ok: false, reason: 'not_found' }; + if (design.runs.isTerminal(run.status)) { + return { ok: false, reason: 'run_terminal' }; + } + if (!run.child || !run.child.stdin || run.child.stdin.destroyed) { + return { ok: false, reason: 'stdin_closed' }; + } + if (!run.stdinOpen) { + return { ok: false, reason: 'stdin_text_mode' }; + } + if (typeof toolUseId !== 'string' || !toolUseId) { + return { ok: false, reason: 'bad_tool_use_id' }; + } + const safeContent = typeof content === 'string' ? content : String(content ?? ''); + const userMessage = JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolUseId, + content: safeContent, + is_error: !!isError, + }, + ], + }, + }); + try { + run.child.stdin.write(`${userMessage}\n`, 'utf8'); + } catch (err) { + return { ok: false, reason: 'write_failed', error: err && err.message }; + } + // Clear this id from the pending set. If the assistant's response + // already finished (a `usage`/result event arrived before the user + // submitted), close stdin now so the child can exit. Otherwise the + // `usage` event handler in `emitAgentEvent` will close it on the + // next turn end. + if (run.pendingHostAnswers) { + run.pendingHostAnswers.delete(toolUseId); + } + return { ok: true }; + }; + orbitService.setRunHandler(async ({ trigger, startedAt, @@ -4887,7 +5000,7 @@ export async function startServer({ routines: { routineService }, validation: validationDeps, finalize: finalizeDeps, - chat: { startChatRun }, + chat: { startChatRun, submitToolResultToRun }, agents: agentDeps, critique: critiqueDeps, lifecycle: { isDaemonShuttingDown: () => daemonShuttingDown }, @@ -4903,7 +5016,7 @@ export async function startServer({ db, design, http: httpDeps, - chat: { startChatRun }, + chat: { startChatRun, submitToolResultToRun }, agents: agentDeps, critique: critiqueDeps, validation: validationDeps, diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index bfaafed899..8ee7e29856 100644 --- a/apps/web/src/components/AssistantMessage.tsx +++ b/apps/web/src/components/AssistantMessage.tsx @@ -1,7 +1,8 @@ -import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ToolCard } from "./ToolCard"; import { renderMarkdown } from "../runtime/markdown"; import { projectFileUrl } from "../providers/registry"; +import { submitChatRunToolResult } from "../providers/daemon"; import { splitOnQuestionForms, type QuestionForm, @@ -76,7 +77,17 @@ export function AssistantMessage({ }: Props) { const t = useT(); const events = message.events ?? []; - const blocks = buildBlocks(events); + // Claude sometimes hedges by emitting a markdown duplicate of the same + // questions alongside an `AskUserQuestion` tool call. The card already + // shows the questions + options, so suppressing the trailing prose + // avoids rendering the same content twice. The system prompt asks the + // model not to do this; this is the belt-and-suspenders. + // The chat-pane-level PinnedTodoBar renders the canonical TodoWrite card + // above the composer, so we strip any TodoWrite tool-groups out of the + // per-message flow to avoid the same task list rendering twice. + const blocks = stripTodoToolGroups( + suppressAskUserQuestionFallbackText(buildBlocks(events)), + ); const usage = events.find((e) => e.kind === "usage") as | Extract | undefined; @@ -116,6 +127,22 @@ export function AssistantMessage({ const [locallySubmitted, setLocallySubmitted] = useState>( () => new Set() ); + // Route interactive tool answers (currently AskUserQuestion) back to the + // still-open stream-json child via the daemon. We resolve to `true` on + // success so the card can flip into its answered state; on `false` (run + // already terminated, stdin closed, etc.) the card falls back to the + // plain-text `onSubmitForm` path so the user is never stuck. Only wire + // this when we have an active run id and the message is the latest turn + // — older messages whose run is gone use the fallback exclusively. + const onAnswerToolUse = useCallback( + async (toolUseId: string, content: string) => { + if (!isLast) return false; + if (!message.runId) return false; + const resp = await submitChatRunToolResult(message.runId, toolUseId, content); + return resp.ok; + }, + [isLast, message.runId], + ); return (
@@ -161,6 +188,9 @@ export function AssistantMessage({ runSucceeded={runSucceeded} projectFileNames={projectFileNames} onRequestOpenFile={onRequestOpenFile} + isLast={!!isLast} + onSubmitForm={onSubmitForm} + onAnswerToolUse={onAnswerToolUse} /> ); } @@ -943,22 +973,88 @@ interface ToolItem { result?: Extract; } +// Snapshot tools (the call IS the state, later calls supersede earlier +// ones) and tools the model retries verbatim under headless-mode errors +// are noisy when stacked. Collapse identical-input neighbors to the most +// recent. Currently: +// - TodoWrite / todowrite: the input replaces the previous list, so the +// latest call is the only one worth showing; older identical or +// superseded snapshots are pure duplication. +// - AskUserQuestion / ask_user_question: claude-code -p auto-errors the +// tool and the model retries with identical input until it gives up. +// Other tool names pass through untouched. +const SNAPSHOT_TOOL_NAMES = new Set([ + "AskUserQuestion", + "ask_user_question", + "TodoWrite", + "todowrite", +]); + +function dedupeSnapshotToolRetries(items: ToolItem[]): ToolItem[] { + if (items.length <= 1) return items; + const allSnapshot = items.every((it) => SNAPSHOT_TOOL_NAMES.has(it.use.name)); + if (!allSnapshot) return items; + // For TodoWrite specifically, the LATEST call always wins regardless of + // input — it is a state replace, not an append. For AskUserQuestion we + // group by input so a sequence of distinct questions (rare) renders as + // distinct cards. The cheap unifying behavior: keep the last item per + // `(name, JSON.stringify(input))` key; for TodoWrite a single name+input + // is the snapshot identity, for AskUserQuestion it's the question text. + const lastByKey = new Map(); + for (const it of items) { + let key: string; + try { + key = `${it.use.name}:${JSON.stringify(it.use.input)}`; + } catch { + key = it.use.id; + } + lastByKey.set(key, it); + } + // For TodoWrite groups, additionally collapse to just the most recent + // item overall (a later call supersedes an earlier one even when inputs + // differ). We detect by checking whether all items share a TodoWrite + // name after the input-key dedupe above. + const collapsed = Array.from(lastByKey.values()); + const allTodoWrite = collapsed.every( + (it) => it.use.name === "TodoWrite" || it.use.name === "todowrite", + ); + if (allTodoWrite && collapsed.length > 1) { + return [collapsed[collapsed.length - 1]!]; + } + return collapsed; +} + function ToolGroupCard({ items, runStreaming, runSucceeded, projectFileNames, onRequestOpenFile, + isLast, + onSubmitForm, + onAnswerToolUse, }: { items: ToolItem[]; runStreaming: boolean; runSucceeded: boolean; projectFileNames?: Set; onRequestOpenFile?: (name: string) => void; + isLast?: boolean; + onSubmitForm?: (text: string) => void; + onAnswerToolUse?: (toolUseId: string, content: string) => Promise | boolean; }) { const t = useT(); const [open, setOpen] = useState(false); + // `claude-code -p` (headless) auto errors `AskUserQuestion` because it + // cannot prompt the user, so the model retries the call up to ~4 times + // within a single turn. Each retry produces an identical tool_use event, + // which used to render as a stack of duplicate question cards. Collapse + // consecutive AskUserQuestion uses with identical input to the LAST one + // (it has the most up to date tool_use id, which is what we route the + // answer against if a backend tool_result wire is added later). + items = dedupeSnapshotToolRetries(items); + // A run of one tool collapses to that tool's card directly so we don't // wrap a single child in a redundant disclosure. if (items.length === 1) { @@ -970,6 +1066,9 @@ function ToolGroupCard({ runSucceeded={runSucceeded} projectFileNames={projectFileNames} onRequestOpenFile={onRequestOpenFile} + isLast={isLast} + onSubmitForm={onSubmitForm} + onAnswerToolUse={onAnswerToolUse} /> ); } @@ -994,21 +1093,26 @@ function ToolGroupCard({ - {open ? ( -
- {items.map((it, i) => ( - - ))} +
+
+
+ {items.map((it, i) => ( + + ))} +
- ) : null} +
); } @@ -1115,6 +1219,45 @@ type Block = * single tool-group block so the chat surface stays compact during chains * of edits / reads. */ +// Drop any tool-group composed entirely of TodoWrite calls. ChatPane +// renders one canonical TodoCard above the composer using +// `latestTodosFromConversation`, so leaving the same task list inline in +// each assistant message just duplicates the view. +function stripTodoToolGroups(blocks: Block[]): Block[] { + return blocks.filter((block) => { + if (block.kind !== "tool-group") return true; + return !block.items.every( + (it) => it.use.name === "TodoWrite" || it.use.name === "todowrite", + ); + }); +} + +// Hide text blocks that follow an `AskUserQuestion` tool use in the same +// assistant message. Claude tends to also write the same questions as +// markdown text alongside the tool call. The card already shows the +// content; the prose is hedge that duplicates and confuses the user. +function suppressAskUserQuestionFallbackText(blocks: Block[]): Block[] { + let seenAskUserQuestion = false; + const filtered: Block[] = []; + for (const block of blocks) { + if (block.kind === "tool-group") { + const hasAuq = block.items.some( + (it) => + it.use.name === "AskUserQuestion" || + it.use.name === "ask_user_question", + ); + if (hasAuq) seenAskUserQuestion = true; + filtered.push(block); + continue; + } + if (seenAskUserQuestion && block.kind === "text") { + continue; + } + filtered.push(block); + } + return filtered; +} + function buildBlocks(events: AgentEvent[]): Block[] { const out: Block[] = []; const resultByToolId = new Map< diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index 68dbc625bc..91e7373ceb 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -4,6 +4,8 @@ import type { Dict } from '../i18n/types'; import { copyToClipboard } from '../lib/copy-to-clipboard'; import { projectRawUrl } from '../providers/registry'; import type { TodoItem } from '../runtime/todos'; +import { latestTodoWriteInputFromMessages } from '../runtime/todos'; +import { TodoCard } from './ToolCard'; import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, ChatMessageFeedbackChange, Conversation, PreviewComment, ProjectFile, ProjectMetadata, SkillSummary } from '../types'; import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime'; import { commentsToAttachments, simplePositionLabel } from '../comments'; @@ -317,6 +319,11 @@ export function ChatPane({ const [tab, setTab] = useState('chat'); const [showConvList, setShowConvList] = useState(false); const [scrolledFromBottom, setScrolledFromBottom] = useState(false); + // The user can dismiss the pinned task list once everything is complete. + // We key the dismissal on the snapshot (serialized TodoWrite input) so + // the next time the agent emits a different snapshot the card returns, + // but the same snapshot stays hidden across renders / streaming ticks. + const [dismissedPinnedTodoKey, setDismissedPinnedTodoKey] = useState(null); const lastAssistantId = [...messages].reverse().find((m) => m.role === 'assistant')?.id; const hasActiveRunMessage = messages.some( (m) => m.role === 'assistant' && isActiveRunStatus(m.runStatus), @@ -707,18 +714,28 @@ export function ChatPane({ })} {error ?
{error}
: null}
- {scrolledFromBottom ? ( - - ) : null} + {/* Always mounted so the CSS transition can play in both + directions; the `chat-jump-btn-active` class flips the + slide + opacity, and `aria-hidden` + `tabIndex={-1}` + keep it out of the a11y tree when it's not visible. */} + + void; +}) { + // `exiting` lets the dismiss click play a slide-down transition before + // the slot tears down. Without it React would unmount immediately and + // the card would pop out without animation. + const [exiting, setExiting] = useState(false); + const input = latestTodoWriteInputFromMessages(messages); + if (input == null) return null; + let snapshotKey: string; + try { + snapshotKey = JSON.stringify(input); + } catch { + snapshotKey = String(input); + } + if (snapshotKey === dismissedKey) return null; + return ( +
+ { + if (exiting) return; + setExiting(true); + // Match the slide-out duration in CSS (220ms) — once the + // transition completes the snapshot key is recorded as + // dismissed and the slot is unmounted by the early return. + window.setTimeout(() => onDismiss(snapshotKey), 220); + }} + /> +
+ ); +} + function CommentsPanel({ comments, attachedComments, diff --git a/apps/web/src/components/ToolCard.tsx b/apps/web/src/components/ToolCard.tsx index e996fbe062..5fa05b3f76 100644 --- a/apps/web/src/components/ToolCard.tsx +++ b/apps/web/src/components/ToolCard.tsx @@ -31,6 +31,21 @@ interface Props { // Lifts a basename up to ProjectView so it can focus the matching tab // in FileWorkspace. onRequestOpenFile?: (name: string) => void; + // True only for tool calls inside the most recent assistant message. + // Older AskUserQuestion cards lock to a read-only summary so the user + // cannot re-answer a stale question. + isLast?: boolean; + // Submit handler for interactive tool cards (currently AskUserQuestion). + // Opaque to ToolCard. ChatPane / ProjectView wire it into the normal + // chat send pipeline so the answer flows back to the model as the next + // user message. Used as a fallback when no live `onAnswerToolUse` route + // is available. + onSubmitForm?: (text: string) => void; + // When set, AskUserQuestion cards route the user's pick directly back to + // the still-open stream-json child via /api/runs/:id/tool-result instead + // of sending it as a fresh user message. AssistantMessage derives it + // from `message.runId` so each assistant turn targets its own run. + onAnswerToolUse?: (toolUseId: string, content: string) => Promise | boolean; } export function ToolCard({ @@ -40,6 +55,9 @@ export function ToolCard({ runSucceeded, projectFileNames, onRequestOpenFile, + isLast, + onSubmitForm, + onAnswerToolUse, }: Props) { const name = use.name; const isStreaming = runStreaming ?? false; @@ -58,6 +76,19 @@ export function ToolCard({ } } const ctx: FileToolCtx = { projectFileNames, onRequestOpenFile }; + if (name === 'AskUserQuestion' || name === 'ask_user_question') + return ( + + ); if (name === 'TodoWrite' || name === 'todowrite') return ; if (name === 'Write' || name === 'write' || name === 'create_file') return ; @@ -99,32 +130,334 @@ function OpenInTabButton({ filePath, ctx }: { filePath: string; ctx: FileToolCtx ); } -function TodoCard({ input, runStreaming, runSucceeded }: { input: unknown; runStreaming: boolean; runSucceeded: boolean }) { +// Inline rendering of Claude's `AskUserQuestion` tool. The tool ships as +// part of the Claude Agent SDK (no daemon-side execution); when the model +// calls it the host is expected to collect the user's selection and feed +// it back as the tool result. We hijack the same `onSubmitForm` channel +// QuestionFormView uses, which ChatPane / ProjectView route into a normal +// chat send, so the answer arrives at the next turn as a user message. +// +// Input shape (per the SDK): +// { questions: [{ question, header, options: [{ label, description }], +// multiSelect }, ...] } +// We accept either array of objects or array of plain strings for `options` +// to stay tolerant of small protocol drift. +type AuqOption = { label: string; description?: string }; +type AuqQuestion = { + question: string; + header?: string; + options: AuqOption[]; + multiSelect: boolean; +}; + +function parseAskUserQuestionInput(input: unknown): AuqQuestion[] { + const obj = (input ?? {}) as { questions?: unknown }; + if (!Array.isArray(obj.questions)) return []; + const result: AuqQuestion[] = []; + for (const raw of obj.questions) { + if (!raw || typeof raw !== 'object') continue; + const q = raw as Record; + const question = typeof q.question === 'string' ? q.question : ''; + if (!question) continue; + const header = typeof q.header === 'string' ? q.header : undefined; + const multiSelect = q.multiSelect === true; + const rawOptions = Array.isArray(q.options) ? q.options : []; + const options: AuqOption[] = []; + for (const opt of rawOptions) { + if (typeof opt === 'string') options.push({ label: opt }); + else if (opt && typeof opt === 'object') { + const o = opt as Record; + const label = typeof o.label === 'string' ? o.label : ''; + if (!label) continue; + const description = typeof o.description === 'string' ? o.description : undefined; + options.push(description ? { label, description } : { label }); + } + } + if (options.length === 0) continue; + result.push({ question, header, options, multiSelect }); + } + return result; +} + +function AskUserQuestionCard({ + toolUseId, + input, + result, + runStreaming, + runSucceeded, + isLast, + onSubmitForm, + onAnswerToolUse, +}: { + toolUseId: string; + input: unknown; + result?: Props['result']; + runStreaming: boolean; + runSucceeded: boolean; + isLast: boolean; + onSubmitForm?: (text: string) => void; + onAnswerToolUse?: (toolUseId: string, content: string) => Promise | boolean; +}) { + const t = useT(); + const questions = parseAskUserQuestionInput(input); + // Initial selections: empty string per question for single-select, empty + // array for multi-select. Indexing by question text keeps things simple + // because the SDK does not assign explicit ids. + const [selections, setSelections] = useState>(() => { + const seed: Record = {}; + for (const q of questions) seed[q.question] = q.multiSelect ? [] : ''; + return seed; + }); + // Track local submission so the card locks immediately on Submit. We + // cannot rely on `result` alone because `claude-code -p` ships an auto + // error tool_result that does not represent a real answer. + const [submitted, setSubmitted] = useState(false); + if (questions.length === 0) { + return ; + } + // Real answer == non-error upstream tool_result OR our local submit + // flag. Headless mode's auto error never lands in this bucket. + const hasRealAnswer = (!!result && !result.isError) || submitted; + // After a page reload the locally-held `selections` is empty because + // useState resets, so the card would render its locked summary with no + // chips highlighted. The persisted answer lives on `result.content` + // (the tool_result the daemon wrote back into the run); parse it so the + // user's pick stays visible across reloads. Multi-select answers were + // joined with `, ` at submit time so we split them back here. + const answeredSelections = (() => { + if (!result || result.isError || !result.content) return null; + const out: Record = {}; + const pairs = result.content.split('\n\n'); + for (const pair of pairs) { + const newlineIdx = pair.indexOf('\n'); + if (newlineIdx === -1) continue; + const q = pair.slice(0, newlineIdx).trim(); + const a = pair.slice(newlineIdx + 1).trim(); + if (!q) continue; + const question = questions.find((qq) => qq.question === q); + if (!question) continue; + out[q] = question.multiSelect + ? a.split(',').map((s) => s.trim()).filter(Boolean) + : a; + } + return out; + })(); + // While the user is actively picking (card not yet locked), the local + // `selections` is authoritative. Once locked, prefer the persisted + // answer if available so reloads / cached messages still highlight. + const effectiveSelections = hasRealAnswer && answeredSelections + ? answeredSelections + : selections; + // We need at least one viable submit channel to be interactive: the live + // `onAnswerToolUse` (preferred — feeds the tool_result back into the + // open stream-json child) or the legacy `onSubmitForm` (fallback that + // sends the answer as a fresh user message). + const canSubmit = !!onAnswerToolUse || !!onSubmitForm; + const locked = hasRealAnswer || !isLast || !canSubmit; + const ready = questions.every((q) => { + const v = selections[q.question]; + return Array.isArray(v) ? v.length > 0 : typeof v === 'string' && v.trim().length > 0; + }); + function pickSingle(question: string, label: string) { + if (locked) return; + setSelections((prev) => ({ ...prev, [question]: label })); + } + function toggleMulti(question: string, label: string) { + if (locked) return; + setSelections((prev) => { + const current = Array.isArray(prev[question]) ? (prev[question] as string[]) : []; + const next = current.includes(label) + ? current.filter((v) => v !== label) + : [...current, label]; + return { ...prev, [question]: next }; + }); + } + async function handleSubmit() { + if (locked || !ready) return; + const lines = questions.map((q) => { + const v = selections[q.question]; + const answer = Array.isArray(v) ? v.join(', ') : (v ?? ''); + return `${q.question}\n${answer}`; + }); + const formatted = lines.join('\n\n'); + // Prefer the direct tool-result route: keeps the answer scoped to the + // open stream-json child so claude-code's `AskUserQuestion` returns + // without an auto error. Fall back to onSubmitForm only if no run is + // wired up (e.g. older messages where the run already terminated). + if (onAnswerToolUse) { + setSubmitted(true); + try { + const ok = await onAnswerToolUse(toolUseId, formatted); + if (ok === false) { + // Live route failed (run gone, stdin closed). Revert the local + // lock and try the legacy fallback so the user is not stuck. + setSubmitted(false); + onSubmitForm?.(formatted); + } + } catch { + setSubmitted(false); + onSubmitForm?.(formatted); + } + return; + } + if (onSubmitForm) { + setSubmitted(true); + onSubmitForm(formatted); + } + } + // Status pill driven by our own answered/pending state, not the upstream + // tool_result. claude-code's headless auto-error would otherwise surface + // as a misleading `error` badge on what is really an open question. When + // the card is locked because the user moved past it without a real + // answer, drop the pill entirely. + const statusLabel = hasRealAnswer + ? t('tool.askQuestionAnswered') + : !locked + ? t('tool.askQuestionPending') + : null; + // Use the awaiting variant instead of `op-status-running`: pending input + // is a steady waiting state, not active work, so the global pulsing + // animation on `op-status-running` is misleading and noisy. + const statusClass = hasRealAnswer ? 'op-status-ok' : 'op-status-awaiting'; + return ( +
+
+ ? + {t('tool.askQuestion')} + {statusLabel ? ( + {statusLabel} + ) : null} +
+
+ {questions.map((q) => { + const selected = effectiveSelections[q.question]; + return ( +
+ {q.header ? ( +
{q.header}
+ ) : null} +
{q.question}
+
+ {q.options.map((opt) => { + const isOn = Array.isArray(selected) + ? selected.includes(opt.label) + : selected === opt.label; + return ( + + ); + })} +
+
+ ); + })} +
+ {!locked ? ( +
+ +
+ ) : null} +
+ ); +} + +export function TodoCard({ input, runStreaming, runSucceeded, onDismiss }: { input: unknown; runStreaming: boolean; runSucceeded: boolean; onDismiss?: () => void }) { const t = useT(); const todos = parseTodoWriteInput(input); + // Mirror the pattern other agent UIs (Cursor, Codex) use: default the + // todo list to expanded while there is in-progress work or anything + // pending, collapse it to a one-line summary when everything is done. + // The user can flip it manually via the header button — that local + // override sticks for the lifetime of this card. + const hasInProgress = todos.some((todo) => todo.status === 'in_progress'); + const hasPending = todos.some((todo) => todo.status !== 'completed'); + const defaultExpanded = todos.length > 0 && (hasInProgress || hasPending || runStreaming); + const [overrideExpanded, setOverrideExpanded] = useState(null); + const expanded = overrideExpanded ?? defaultExpanded; if (todos.length === 0) return ; - const done = todos.filter((todo) => todo.status === 'completed').length; + // The counter reads as "active progress / total" — a task that is + // currently in_progress counts toward the numerator alongside completed + // ones, matching how Cursor / Codex tally tasks. Without this the user + // sees 0/4 the entire time the first task is being worked, which is + // confusing because something is clearly underway. + const inProgressTodo = todos.find((todo) => todo.status === 'in_progress'); + const completed = todos.filter((todo) => todo.status === 'completed').length; + const done = todos.filter( + (todo) => todo.status === 'completed' || todo.status === 'in_progress', + ).length; + // All-complete state surfaces the Done dismiss button (when wired) so the + // pinned task list can be cleared once the whole plan is finished. + const allComplete = completed === todos.length; + const showDismiss = !!onDismiss && allComplete; return ( -
-
- ☐ - {t('tool.todos')} - - {done}/{todos.length} - +
+
+ + {showDismiss ? ( + + ) : null} +
+
+
+
    + {todos.map((todo, i) => ( +
  • + + {todo.status === 'completed' ? '✓' : todo.status === 'in_progress' ? '◐' : '○'} + + + {todo.status === 'in_progress' && todo.activeForm ? todo.activeForm : todo.content} + +
  • + ))} +
+
-
    - {todos.map((todo, i) => ( -
  • - - {todo.status === 'completed' ? '✓' : todo.status === 'in_progress' ? '◐' : '○'} - - - {todo.status === 'in_progress' && todo.activeForm ? todo.activeForm : todo.content} - -
  • - ))} -
); } @@ -251,9 +584,13 @@ function BashCard({ input, result, runStreaming, runSucceeded }: { input: unknow ) : null}
{truncate(command, 400)}
- {open && result ? ( -
{truncate(result.content, 4000)}
- ) : null} +
+
+ {result ? ( +
{truncate(result.content, 4000)}
+ ) : null} +
+
); } diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 3a00f50a80..a35c3c3d1a 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -945,6 +945,14 @@ export const ar: Dict = { 'tool.openInTab': 'فتح {name} في علامة تبويب', 'tool.open': 'فتح', 'tool.todos': 'المهام', + 'tool.askQuestion': 'سؤال', + 'tool.askQuestionSubmit': 'إرسال', + 'tool.askQuestionPending': 'في انتظار إجابتك', + 'tool.askQuestionAnswered': 'تمت الإجابة', + 'tool.todosExpand': 'عرض المهام', + 'tool.todosCollapse': 'إخفاء المهام', + 'tool.todosDone': 'تم', + 'tool.todosDismiss': 'إغلاق قائمة المهام', 'tool.write': 'كتابة', 'tool.edit': 'تعديل', 'tool.read': 'قراءة', diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index 0210751e98..3fd6f5ab5c 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -833,6 +833,14 @@ export const de: Dict = { 'tool.openInTab': '{name} in einem Tab öffnen', 'tool.open': 'öffnen', 'tool.todos': 'Todos', + 'tool.askQuestion': 'Frage', + 'tool.askQuestionSubmit': 'Senden', + 'tool.askQuestionPending': 'Warte auf deine Antwort', + 'tool.askQuestionAnswered': 'Beantwortet', + 'tool.todosExpand': 'Aufgaben einblenden', + 'tool.todosCollapse': 'Aufgaben ausblenden', + 'tool.todosDone': 'Fertig', + 'tool.todosDismiss': 'Aufgabenliste schließen', 'tool.write': 'Write', 'tool.edit': 'Edit', 'tool.read': 'Read', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index f65035e8fb..b3509ce3f2 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -1117,6 +1117,14 @@ export const en: Dict = { 'tool.openInTab': 'Open {name} in a tab', 'tool.open': 'open', 'tool.todos': 'Todos', + 'tool.askQuestion': 'Question', + 'tool.askQuestionSubmit': 'Submit', + 'tool.askQuestionPending': 'Awaiting your answer', + 'tool.askQuestionAnswered': 'Answered', + 'tool.todosExpand': 'Show tasks', + 'tool.todosCollapse': 'Hide tasks', + 'tool.todosDone': 'Done', + 'tool.todosDismiss': 'Dismiss the task list', 'tool.write': 'Write', 'tool.edit': 'Edit', 'tool.read': 'Read', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 28c69b8649..138d36b0a5 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -834,6 +834,14 @@ export const esES: Dict = { 'tool.openInTab': 'Abrir {name} en una pestaña', 'tool.open': 'abrir', 'tool.todos': 'Tareas', + 'tool.askQuestion': 'Pregunta', + 'tool.askQuestionSubmit': 'Enviar', + 'tool.askQuestionPending': 'Esperando tu respuesta', + 'tool.askQuestionAnswered': 'Respondido', + 'tool.todosExpand': 'Mostrar tareas', + 'tool.todosCollapse': 'Ocultar tareas', + 'tool.todosDone': 'Listo', + 'tool.todosDismiss': 'Descartar lista de tareas', 'tool.write': 'Escribir', 'tool.edit': 'Editar', 'tool.read': 'Leer', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index 8cd57ae419..998461603b 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -928,6 +928,14 @@ export const fa: Dict = { 'tool.openInTab': 'باز کردن {name} در یک تب', 'tool.open': 'باز کردن', 'tool.todos': 'وظایف', + 'tool.askQuestion': 'پرسش', + 'tool.askQuestionSubmit': 'ارسال', + 'tool.askQuestionPending': 'در انتظار پاسخ شما', + 'tool.askQuestionAnswered': 'پاسخ داده شد', + 'tool.todosExpand': 'نمایش وظایف', + 'tool.todosCollapse': 'پنهان کردن وظایف', + 'tool.todosDone': 'انجام شد', + 'tool.todosDismiss': 'بستن فهرست وظایف', 'tool.write': 'نوشتن', 'tool.edit': 'ویرایش', 'tool.read': 'خواندن', diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index 81098e0611..e6dcccfa2c 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -945,6 +945,14 @@ export const fr: Dict = { 'tool.openInTab': 'Ouvrir {name} dans un onglet', 'tool.open': 'ouvrir', 'tool.todos': 'Tâches', + 'tool.askQuestion': 'Question', + 'tool.askQuestionSubmit': 'Envoyer', + 'tool.askQuestionPending': 'En attente de votre réponse', + 'tool.askQuestionAnswered': 'Répondu', + 'tool.todosExpand': 'Afficher les tâches', + 'tool.todosCollapse': 'Masquer les tâches', + 'tool.todosDone': 'Terminé', + 'tool.todosDismiss': 'Fermer la liste des tâches', 'tool.write': 'Écrire', 'tool.edit': 'Modifier', 'tool.read': 'Lire', diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index 2e80917488..a49d76784a 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -945,6 +945,14 @@ export const hu: Dict = { 'tool.openInTab': '{name} megnyitása lapon', 'tool.open': 'megnyitás', 'tool.todos': 'Feladatok', + 'tool.askQuestion': 'Kérdés', + 'tool.askQuestionSubmit': 'Beküldés', + 'tool.askQuestionPending': 'Várjuk a válaszodat', + 'tool.askQuestionAnswered': 'Megválaszolva', + 'tool.todosExpand': 'Feladatok mutatása', + 'tool.todosCollapse': 'Feladatok elrejtése', + 'tool.todosDone': 'Kész', + 'tool.todosDismiss': 'Feladatlista elvetése', 'tool.write': 'Írás', 'tool.edit': 'Szerkesztés', 'tool.read': 'Olvasás', diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index 0f2f64a987..605d0a1539 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -1097,6 +1097,14 @@ export const id: Dict = { 'tool.openInTab': 'Buka {name} di tab', 'tool.open': 'Buka', 'tool.todos': 'Todo', + 'tool.askQuestion': 'Pertanyaan', + 'tool.askQuestionSubmit': 'Kirim', + 'tool.askQuestionPending': 'Menunggu jawabanmu', + 'tool.askQuestionAnswered': 'Dijawab', + 'tool.todosExpand': 'Tampilkan tugas', + 'tool.todosCollapse': 'Sembunyikan tugas', + 'tool.todosDone': 'Selesai', + 'tool.todosDismiss': 'Tutup daftar tugas', 'tool.write': 'Tulis', 'tool.edit': 'Edit', 'tool.read': 'Baca', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 56778edcd4..d5ae7d775e 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -832,6 +832,14 @@ export const ja: Dict = { 'tool.openInTab': '{name} をタブで開く', 'tool.open': '開く', 'tool.todos': 'Todos', + 'tool.askQuestion': '質問', + 'tool.askQuestionSubmit': '送信', + 'tool.askQuestionPending': '回答をお待ちしています', + 'tool.askQuestionAnswered': '回答済み', + 'tool.todosExpand': 'タスクを表示', + 'tool.todosCollapse': 'タスクを非表示', + 'tool.todosDone': '完了', + 'tool.todosDismiss': 'タスクリストを閉じる', 'tool.write': '書き込み', 'tool.edit': '編集', 'tool.read': '読み取り', diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index eb34fcc36b..c9e4321ddd 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -945,6 +945,14 @@ export const ko: Dict = { 'tool.openInTab': '탭에서 {name} 열기', 'tool.open': '열기', 'tool.todos': '할 일 (Todos)', + 'tool.askQuestion': '질문', + 'tool.askQuestionSubmit': '제출', + 'tool.askQuestionPending': '답변 대기 중', + 'tool.askQuestionAnswered': '답변 완료', + 'tool.todosExpand': '할 일 표시', + 'tool.todosCollapse': '할 일 숨기기', + 'tool.todosDone': '완료', + 'tool.todosDismiss': '할 일 목록 닫기', 'tool.write': '작성 (Write)', 'tool.edit': '편집 (Edit)', 'tool.read': '읽기 (Read)', diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index 265a01148b..aad61e6b1a 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -945,6 +945,14 @@ export const pl: Dict = { 'tool.openInTab': 'Otwórz {name} w karcie', 'tool.open': 'otwórz', 'tool.todos': 'Zadania', + 'tool.askQuestion': 'Pytanie', + 'tool.askQuestionSubmit': 'Wyślij', + 'tool.askQuestionPending': 'Czeka na Twoją odpowiedź', + 'tool.askQuestionAnswered': 'Udzielono odpowiedzi', + 'tool.todosExpand': 'Pokaż zadania', + 'tool.todosCollapse': 'Ukryj zadania', + 'tool.todosDone': 'Gotowe', + 'tool.todosDismiss': 'Zamknij listę zadań', 'tool.write': 'Zapisz', 'tool.edit': 'Edytuj', 'tool.read': 'Czytaj', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index d64e74bef7..f55b436086 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -986,6 +986,14 @@ export const ptBR: Dict = { 'tool.openInTab': 'Abrir {name} em uma aba', 'tool.open': 'abrir', 'tool.todos': 'Tarefas', + 'tool.askQuestion': 'Pergunta', + 'tool.askQuestionSubmit': 'Enviar', + 'tool.askQuestionPending': 'Aguardando sua resposta', + 'tool.askQuestionAnswered': 'Respondido', + 'tool.todosExpand': 'Mostrar tarefas', + 'tool.todosCollapse': 'Ocultar tarefas', + 'tool.todosDone': 'Concluído', + 'tool.todosDismiss': 'Fechar lista de tarefas', 'tool.write': 'Escrever', 'tool.edit': 'Editar', 'tool.read': 'Ler', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index 823ac2099f..7de2ebab34 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -986,6 +986,14 @@ export const ru: Dict = { 'tool.openInTab': 'Открыть {name} во вкладке', 'tool.open': 'открыть', 'tool.todos': 'Задачи', + 'tool.askQuestion': 'Вопрос', + 'tool.askQuestionSubmit': 'Отправить', + 'tool.askQuestionPending': 'Ожидание вашего ответа', + 'tool.askQuestionAnswered': 'Отвечено', + 'tool.todosExpand': 'Показать задачи', + 'tool.todosCollapse': 'Скрыть задачи', + 'tool.todosDone': 'Готово', + 'tool.todosDismiss': 'Закрыть список задач', 'tool.write': 'Записать', 'tool.edit': 'Редактировать', 'tool.read': 'Читать', diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index 7daea16517..d09ada9fb6 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -938,6 +938,14 @@ export const th: Dict = { 'tool.openInTab': 'จับเรียก {name} ไว้ให้เข้าหน้าใหม่ของแท็บ', 'tool.open': 'เข้าไป', 'tool.todos': 'สิ่งที่วางแผนต้องทำ', + 'tool.askQuestion': 'คำถาม', + 'tool.askQuestionSubmit': 'ส่ง', + 'tool.askQuestionPending': 'รอคำตอบของคุณ', + 'tool.askQuestionAnswered': 'ตอบแล้ว', + 'tool.todosExpand': 'แสดงงาน', + 'tool.todosCollapse': 'ซ่อนงาน', + 'tool.todosDone': 'เสร็จสิ้น', + 'tool.todosDismiss': 'ปิดรายการงาน', 'tool.write': 'ใส่เขียนลงไป', 'tool.edit': 'จัดการปรับปรุง', 'tool.read': 'มองเห็นภาพ', diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index b490c66585..52d757fd33 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -932,6 +932,14 @@ export const tr: Dict = { 'tool.openInTab': '{name}’ı bir sekmede aç ', 'tool.open': 'aç', 'tool.todos': 'Yapılacaklar', + 'tool.askQuestion': 'Soru', + 'tool.askQuestionSubmit': 'Gönder', + 'tool.askQuestionPending': 'Cevabınız bekleniyor', + 'tool.askQuestionAnswered': 'Cevaplandı', + 'tool.todosExpand': 'Görevleri göster', + 'tool.todosCollapse': 'Görevleri gizle', + 'tool.todosDone': 'Tamam', + 'tool.todosDismiss': 'Görev listesini kapat', 'tool.write': 'Yaz', 'tool.edit': 'Düzenle', 'tool.read': 'Oku', diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index 0bc29346b3..3eb25d491b 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -987,6 +987,14 @@ export const uk: Dict = { 'tool.openInTab': 'Відкрити {name} на вкладці', 'tool.open': 'відкрити', 'tool.todos': 'Завдання', + 'tool.askQuestion': 'Питання', + 'tool.askQuestionSubmit': 'Надіслати', + 'tool.askQuestionPending': 'Очікую вашої відповіді', + 'tool.askQuestionAnswered': 'Відповідь надано', + 'tool.todosExpand': 'Показати завдання', + 'tool.todosCollapse': 'Сховати завдання', + 'tool.todosDone': 'Готово', + 'tool.todosDismiss': 'Закрити список завдань', 'tool.write': 'Написати', 'tool.edit': 'Редагувати', 'tool.read': 'Читати', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index ecc0002a31..1495e3c363 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -1083,6 +1083,14 @@ export const zhCN: Dict = { 'tool.openInTab': '在标签页中打开 {name}', 'tool.open': '打开', 'tool.todos': '待办', + 'tool.askQuestion': '问题', + 'tool.askQuestionSubmit': '提交', + 'tool.askQuestionPending': '等待你的回答', + 'tool.askQuestionAnswered': '已回答', + 'tool.todosExpand': '展开任务', + 'tool.todosCollapse': '收起任务', + 'tool.todosDone': '完成', + 'tool.todosDismiss': '关闭任务列表', 'tool.write': '写入', 'tool.edit': '编辑', 'tool.read': '读取', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index 5928cf0155..ced41b56e6 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -1036,6 +1036,14 @@ export const zhTW: Dict = { 'tool.openInTab': '在分頁中開啟 {name}', 'tool.open': '開啟', 'tool.todos': '待辦', + 'tool.askQuestion': '問題', + 'tool.askQuestionSubmit': '提交', + 'tool.askQuestionPending': '等待你的回答', + 'tool.askQuestionAnswered': '已回答', + 'tool.todosExpand': '展開任務', + 'tool.todosCollapse': '收起任務', + 'tool.todosDone': '完成', + 'tool.todosDismiss': '關閉任務清單', 'tool.write': '寫入', 'tool.edit': '編輯', 'tool.read': '讀取', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 0431ff1ff3..a6d225590e 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -1370,6 +1370,14 @@ export interface Dict { 'tool.openInTab': string; 'tool.open': string; 'tool.todos': string; + 'tool.askQuestion': string; + 'tool.askQuestionSubmit': string; + 'tool.askQuestionPending': string; + 'tool.askQuestionAnswered': string; + 'tool.todosExpand': string; + 'tool.todosCollapse': string; + 'tool.todosDone': string; + 'tool.todosDismiss': string; 'tool.write': string; 'tool.edit': string; 'tool.read': string; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 53bdb374ce..5f90536fce 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1191,6 +1191,24 @@ code { flex-direction: column; gap: 8px; background: var(--bg-panel); + position: relative; + z-index: 1; +} +/* Elevation kicks in only when a pinned todo card is sitting directly + * above the composer. Without the card the chat scroll already provides + * its own visual edge; the shadow would just darken empty whitespace. */ +.chat-pinned-todo + .composer { + box-shadow: 0 -10px 22px -10px rgba(0, 0, 0, 0.22); +} +/* Dark mode: the same alpha disappears against the dark panel, so push + * it harder and let it spread a bit further. */ +[data-theme="dark"] .chat-pinned-todo + .composer { + box-shadow: 0 -14px 28px -12px rgba(0, 0, 0, 0.65); +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .chat-pinned-todo + .composer { + box-shadow: 0 -14px 28px -12px rgba(0, 0, 0, 0.65); + } } .composer-shell { border: 1px solid var(--border); @@ -11014,6 +11032,123 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) { .op-icon-edit { background: var(--amber-bg); color: var(--amber); } .op-icon-read { background: var(--blue-bg); color: var(--blue); } .op-title { font-weight: 500; } + +/* + * Inline rendering of Claude's AskUserQuestion tool. Question text reads + * like a normal assistant message, options live as a row of chip buttons + * the user picks from. Once an answer ships (or the user has moved past + * the turn) the card locks into a read only state: chips remain visible + * but unclickable so the user can see what was asked. + */ +.op-ask-question .op-card-head { padding-bottom: 4px; } +.op-ask-question-body { + display: flex; + flex-direction: column; + gap: 14px; + padding: 4px 12px 12px; +} +.op-ask-question-field { + display: flex; + flex-direction: column; + gap: 8px; +} +.op-ask-question-header { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} +.op-ask-question-prompt { + font-size: 13px; + line-height: 1.45; + color: var(--text); +} +.op-ask-question-options { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.op-ask-question-option { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 7px 11px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-panel); + color: var(--text); + font-size: 12.5px; + font-family: inherit; + cursor: pointer; + text-align: left; + transition: border-color 120ms ease, background 120ms ease; +} +.op-ask-question-option:hover { border-color: var(--accent); } +.op-ask-question-option.on { + border-color: var(--accent); + background: var(--accent-bg, var(--bg-subtle)); +} +.op-ask-question-option:disabled { + cursor: default; + opacity: 0.85; +} +.op-ask-question-option-label { font-weight: 500; } +.op-ask-question-option-desc { + font-size: 11px; + color: var(--text-muted); + white-space: normal; +} +.op-ask-question-foot { + display: flex; + justify-content: flex-end; + padding: 4px 12px 12px; +} +/* Match the composer's Send button styling so the two actions read as + * the same control family. The composer-send is bordered with the + * accent color and uses the same padding/typography; we drop the icon + * by skipping the gap/svg children entirely. */ +.op-ask-question-submit { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--accent); + border: 1px solid var(--accent); + border-radius: var(--radius); + color: white; + font-weight: 500; + padding: 4px 14px; + font-size: 12.5px; + cursor: pointer; +} +.op-ask-question-submit:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} +.op-ask-question-submit:disabled { + opacity: 0.5; + cursor: default; +} +/* + * Locked card state: unchosen options fade back into the card surface + * while the user's pick keeps its accent outline so the answer stays + * legible after submission. Without the `:not(.on)` qualifier the lock + * would wipe the selected state and the card would look unanswered. + */ +.op-ask-question-locked .op-ask-question-option:not(.on) { + border-color: var(--border-soft); + background: transparent; + color: var(--text-muted); +} +.op-ask-question-locked .op-ask-question-option.on { + border-color: var(--accent); + background: var(--accent-bg, var(--bg-subtle)); + cursor: default; +} +.op-ask-question-locked .op-ask-question-option.on .op-ask-question-option-desc { + color: var(--text-muted); +} .op-meta { color: var(--text-muted); font-size: 11.5px; } .op-desc { font-style: italic; } .op-path { @@ -11044,6 +11179,16 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) { color: var(--purple); animation: pulse 1.6s ease-in-out infinite; } +/* + * Static waiting-for-input variant. Same purple chrome as `op-status-running` + * but no pulse: an AskUserQuestion card is parked until the user clicks, not + * actively working, so the animation is misleading. + */ +.op-status-awaiting { + border-color: var(--purple-border); + background: var(--purple-bg); + color: var(--purple); +} .op-status-ok { border-color: var(--green-border); background: var(--green-bg); color: var(--green); } .op-status-error { border-color: var(--red-border); background: var(--red-bg); color: var(--red); } .op-toggle { @@ -12033,6 +12178,141 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) { color: var(--text-strong); } +/* + * Pinned task list above the chat composer. The card sits just above the + * prompt input, persistent for the whole conversation rather than buried + * in a single assistant message. It mirrors the Cursor / Codex / Claude + * Code TTY pattern where the todo list is always one click away while you + * are typing the next prompt. The card is collapsible via its own + * header — see `.op-todo-toggle` below. + */ +.chat-pinned-todo { + position: relative; + padding: 0 10px; + transition: + transform 220ms cubic-bezier(0.23, 1, 0.32, 1), + opacity 200ms cubic-bezier(0.23, 1, 0.32, 1); +} +/* Slide-down dismiss: matches the global animation philosophy + * (strong ease-out, ~200ms). The wrapper animates away while the + * Done click still resolves the snapshot key. */ +.chat-pinned-todo-exit { + transform: translateY(24px); + opacity: 0; + pointer-events: none; +} +/* Soft fade above the pinned card so chat text scrolling beneath it + * dissolves into the panel background instead of getting hard-clipped + * at the card's top edge. Sits in the gutter directly above the card, + * pulled up by negative bottom so it overlaps the chat scroll area. */ +.chat-pinned-todo::before { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 100%; + height: 28px; + background: linear-gradient(to top, var(--bg-panel), transparent); + pointer-events: none; +} +.chat-pinned-todo .op-card.op-todo { + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08); + /* Square the bottom corners so the card reads as tucked under the + * composer below it, rather than as a floating pill with a gap. */ + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom: 0; +} + +/* + * Shared accordion expand/collapse motion. `grid-template-rows: 0fr -> 1fr` + * is the modern way to animate auto-height content; pair it with a + * fade so the body doesn't pop in flat. Strong ease-out curve, with + * asymmetric timing (enter 200ms, exit 140ms) per Emil Kowalski's + * framework — entrance reads as considered, exit is decisive. + */ +.accordion-collapsible { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: + grid-template-rows 140ms cubic-bezier(0.23, 1, 0.32, 1), + opacity 100ms cubic-bezier(0.23, 1, 0.32, 1); +} +.accordion-collapsible.open { + grid-template-rows: 1fr; + opacity: 1; + transition: + grid-template-rows 200ms cubic-bezier(0.23, 1, 0.32, 1), + opacity 200ms cubic-bezier(0.23, 1, 0.32, 1); +} +.accordion-collapsible-inner { + min-height: 0; + overflow: hidden; +} + +/* + * Make the todo card header a clickable expand/collapse affordance. + * Default is expanded while work is in flight; the user can flip it via + * the chevron at the right. Removing the bottom border in the collapsed + * state keeps the card looking like a single pill rather than an empty + * disclosure with a hanging line. + */ +.op-todo .op-todo-head { + display: flex; + align-items: stretch; + padding: 0; +} +.op-todo .op-todo-toggle { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + padding: 8px 12px; + background: transparent; + border: 0; + cursor: pointer; + text-align: left; + font: inherit; + color: inherit; +} +.op-todo .op-todo-done { + display: inline-flex; + align-items: center; + margin: 6px 10px 6px 0; + padding: 2px 12px; + font-size: 11px; + font-weight: 500; + border: 1px solid var(--green-border); + border-radius: var(--radius-pill); + background: var(--green-bg); + color: var(--green); + cursor: pointer; +} +.op-todo .op-todo-done:hover { + filter: brightness(1.05); +} +.op-todo-collapsed .op-card-head { + border-bottom-color: transparent; +} +.op-todo-current { + margin-left: 8px; + font-size: 12px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; +} +.op-todo-chev { + margin-left: 4px; + font-size: 11px; + color: var(--text-muted); + transition: transform 120ms ease; +} + /* Question form — interactive form a planning agent can post into chat */ .question-form { margin: 8px 0; @@ -13007,8 +13287,13 @@ body.entry-resizing { cursor: col-resize; user-select: none; } .chat-jump-btn { position: absolute; bottom: 12px; - left: 50%; - transform: translateX(-50%); + /* Margin-based centering (instead of `transform: translateX(-50%)`) + * so the slide-in / slide-out transition can animate `transform` + * without overwriting a centering translate. */ + left: 0; + right: 0; + margin: 0 auto; + width: max-content; display: inline-flex; align-items: center; gap: 6px; @@ -13021,7 +13306,32 @@ body.entry-resizing { cursor: col-resize; user-select: none; } box-shadow: var(--shadow-sm); cursor: pointer; z-index: 6; - animation: pop-in 200ms cubic-bezier(0.21, 1.02, 0.73, 1); + /* Default = hidden. The slide tucks below and scales down slightly to + * read as folding into the composer. Strong ease-out curve + * (cubic-bezier(0.23, 1, 0.32, 1)) replaces both built-in `ease` and + * the previous slightly-overshoot variant — Emil Kowalski's framework: + * UI animations should use strong custom ease-out, and entrance pills + * should not bounce. Exit is faster than enter (asymmetric timing): + * the user has already chosen to dismiss; system response should be + * decisive. */ + opacity: 0; + transform: translateY(8px) scale(0.9); + visibility: hidden; + pointer-events: none; + transition: + opacity 140ms cubic-bezier(0.23, 1, 0.32, 1), + transform 140ms cubic-bezier(0.23, 1, 0.32, 1), + visibility 0s linear 140ms; +} +.chat-jump-btn-active { + opacity: 1; + transform: translateY(0) scale(1); + visibility: visible; + pointer-events: auto; + transition: + opacity 200ms cubic-bezier(0.23, 1, 0.32, 1), + transform 200ms cubic-bezier(0.23, 1, 0.32, 1), + visibility 0s linear 0s; } .chat-jump-btn:hover { background: var(--bg-subtle); border-color: var(--border-strong); } diff --git a/apps/web/src/providers/daemon.ts b/apps/web/src/providers/daemon.ts index 5ad08503bf..1c9b5a7f0d 100644 --- a/apps/web/src/providers/daemon.ts +++ b/apps/web/src/providers/daemon.ts @@ -293,6 +293,30 @@ export async function fetchChatRunStatus(runId: string): Promise { + try { + const resp = await fetch(`/api/runs/${encodeURIComponent(runId)}/tool-result`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toolUseId, content, isError: !!options.isError }), + }); + return { ok: resp.ok, status: resp.status }; + } catch { + return { ok: false }; + } +} + export async function listActiveChatRuns( projectId: string, conversationId: string, diff --git a/apps/web/src/runtime/todos.ts b/apps/web/src/runtime/todos.ts index ccf6ba7130..c043369adf 100644 --- a/apps/web/src/runtime/todos.ts +++ b/apps/web/src/runtime/todos.ts @@ -45,6 +45,27 @@ export function unfinishedTodosFromEvents(events: AgentEvent[] | undefined): Tod return latestTodosFromEvents(events).filter((todo) => todo.status !== 'completed'); } +// Walk the conversation in reverse to find the most recent TodoWrite +// tool_use, return its raw input so callers can hand it to a `TodoCard` +// without re-implementing the discovery logic. Returns `null` when no +// TodoWrite has been emitted yet in this conversation. +export function latestTodoWriteInputFromMessages( + messages: ReadonlyArray<{ events?: AgentEvent[] | undefined }> | undefined, +): unknown | null { + if (!messages || messages.length === 0) return null; + for (let mi = messages.length - 1; mi >= 0; mi -= 1) { + const events = messages[mi]?.events; + if (!events || events.length === 0) continue; + for (let ei = events.length - 1; ei >= 0; ei -= 1) { + const event = events[ei]; + if (event?.kind !== 'tool_use') continue; + if (!isTodoWriteToolName(event.name)) continue; + return event.input; + } + } + return null; +} + function isTodoWriteToolName(name: string): boolean { return name === 'TodoWrite' || name === 'todowrite'; } diff --git a/design-systems/foldar/DESIGN.md b/design-systems/foldar/DESIGN.md new file mode 100644 index 0000000000..641d9cbf82 --- /dev/null +++ b/design-systems/foldar/DESIGN.md @@ -0,0 +1,299 @@ +# FoldAR — Design Specification + +A reference document for the FoldAR visual language. The brand is a Texas-based firearms manufacturer whose patented folding-barrel system lets full-capability rifles be carried discreetly and deployed in seconds. Audience: civilians, law enforcement, military. + +The aesthetic is **engineered, restrained, accountable** — never flashy. Treat the system as the output of a manufacturer of precise tools, not a lifestyle brand. + +--- + +## 1. Principles + +1. **Restraint over decoration.** Black, white, one red, one grey. No gradients. No illustration. No emoji. +2. **Numbers are sacred.** Every number is monospaced and carries an explicit unit. Statistics earn their place; pad nothing. +3. **Sharp by default.** 0px corners on marketing, brochure, hero, and product cards. Radii only appear in transactional checkout UI. +4. **Red is rationed.** Accent red appears on at most one or two datapoints per view. Never as a background, never as decoration. +5. **The grid is visible.** 1px `#D9D9D9` dividers, measurement lines, and column rules are part of the language — they say *this thing was engineered*. +6. **Motion is functional.** Fade + translate, 180–240ms. No bounces, no parallax, no spring overshoot. + +--- + +## 2. Color + +### Palette (the only four colors that matter) + +| Token | Hex | Role | +| ----- | --- | ---- | +| `--fa-black` | `#000000` | Dominant typography and backgrounds | +| `--fa-white` | `#FFFFFF` | Contrast surface, paper | +| `--fa-red` | `#F43139` | Accent — key numbers, CTAs, highlight lines | +| `--fa-fog` | `#D9D9D9` | Default divider, secondary text, thin borders | + +### Supporting neutrals + +| Token | Hex | Use | +| ----- | --- | --- | +| `--fa-ink` | `#272727` | Panel strokes on black | +| `--fa-coal` | `#444444` | Data-block borders, half-step on dark surfaces | +| `--fa-stone` | `#6C6C6C` | Secondary text | +| `--fa-ash` | `#868D96` | Quiet UI text, muted labels | +| `--fa-paper` | `#E3E7E9` | Large surfaces, disabled | + +### Red, by alpha + +| Token | Use | +| ----- | --- | +| `--fa-red-10` | CTA fill (with `backdrop-filter: blur(17.6px)`) | +| `--fa-red-20` | Highlight overlays | +| `--fa-red-40` | Press / focus states | + +### Transactional UI palette (checkout only) + +A separate, softer palette is permitted **only** in checkout/account chrome — `--fa-ui-bg`, `--fa-ui-subtle`, `--fa-ui-border`, `--fa-ui-success-*`, `--fa-ui-link` (`#016FD0`). It does not leak into marketing. + +### Rules + +- No gradients anywhere in the brand language. +- No tints of red beyond the four alpha steps above. +- No new accents. If you need to differentiate, vary type, weight, or position — not hue. +- Concrete texture (`assets/concrete.jpg` at 23% opacity) is the only acceptable surface treatment beyond solid black or white. + +--- + +## 3. Typography + +Three voices. Not four. + +### Display — `DIN Condensed` / Oswald (substitute) + +- ALL CAPS for every headline and label. +- Tracked `+0.06em` to `+0.08em`. +- Used for: hero copy, section titles, button labels, SKU codes. +- Substitute: **Oswald** (Google Fonts) until the licensed DIN file ships. + +### Body — `SF Pro` / Inter (substitute) + +- Sentence case. +- Used for: paragraphs, UI body, form labels, supporting copy. +- 14px web, 13px print, 1.5 line-height. +- Substitute: **Inter** (Google Fonts). + +### Data — `Space Mono` + +- Every number, price, measurement, SKU, counter, statistic. +- Always paired with its unit (`16.25"`, `300m`, `<1.5s`). +- `Space Mono Bold` for micro-labels. + +### Type scale + +| Token | Size | Use | +| ----- | ---- | --- | +| `--fa-text-hero` | `clamp(48, 6vw, 96)` | Brochure full-bleed | +| `--fa-text-display` | `40px` | Section eyebrows on black | +| `--fa-text-h1` | `36px` | Page title | +| `--fa-text-h2` | `24px` | Section headline | +| `--fa-text-h3` | `20px` | Sub-section | +| `--fa-text-label` | `16px` | ALL-CAPS label | +| `--fa-text-stat` | `46px` | Mono stat counter | +| `--fa-text-body-web` | `14px` | Web body | +| `--fa-text-body` | `13px` | Print body | +| `--fa-text-micro` | `10px` | Footnote / micro | + +### Tracking + +`--fa-track-tight` `-0.01em` · `--fa-track-wide` `+0.03em` · `--fa-track-wider` `+0.06em` · `--fa-track-widest` `+0.08em`. + +--- + +## 4. Voice & content + +**Person.** Third-person and second-person imperative. "The operator," "your rifle," "those entrusted with protecting others." `we` only appears in supporting paragraphs, never in hero copy. + +**Casing.** +- ALL CAPS — headings, section labels, data labels. +- Sentence case — body paragraphs. +- lowercase — occasionally mixed into caps headlines as a deliberate accent (e.g. *"TRUSTED BY CIVILIANS, LAW ENFORCEMENT, AND military"*). +- Product names — `FoldAR®`, `Watchman`, `Shepherd`, `Sentinel`, `Remnant`, `nanoRemnant`. The word `rifle` is a common noun. + +**SKUs** — all-caps dashed: `16-556-S-PR-PKG`. + +**Tone references** (verbatim from the brochure): +- THE RIFLE THAT STAYS WITH YOU +- WHEN SECONDS MATTER, YOUR RIFLE MUST ALREADY BE WITH YOU +- ELIMINATING THE DELAY BETWEEN THREAT AND RESPONSE +- ONE RIFLE. MULTIPLE MISSIONS. +- BUILT FOR THOSE WHO PROTECT + +**Numbers always carry units.** `Folded Length: 16.25"`. `Engagement Distance: 300m`. `Deployment Speed: <1.5s`. + +**Never:** emoji, exclamation marks, marketing warmth, lifestyle adjectives. + +--- + +## 5. Spacing & grid + +8pt print rhythm, 4pt web rhythm. Tokens: `--fa-space-1` through `--fa-space-30` (`4px`–`120px`). + +### Page grids + +| Surface | Width | Side gutter | Columns / baseline | +| ------- | ----- | ----------- | ------------------ | +| Website | 1920 | 120px | 12 columns | +| Brochure | 612 × 792 (US Letter) | 58.75pt | 8pt baseline | + +The grid is meant to be visible. Use 1px `#D9D9D9` rules and measurement annotations liberally — they read as engineering drawings, not decoration. + +--- + +## 6. Corner radii + +| Radius | Use | +| ------ | --- | +| `0px` | Marketing, brochure, hero, product cards — the default | +| `2px` | Tiny UI (payment chips) | +| `4–6px` | Checkout / transactional UI only | +| `100px` | PayPal-style payment pills only | + +Never invent new radii. If a card needs to feel "softer," it's the wrong card. + +--- + +## 7. Borders & dividers + +- `1px solid #D9D9D9` — default hairline (`--fa-border-hairline`). +- `1px solid #444444` — half-step neutral on dark panels (`--fa-border-ink`). +- `1px solid #F43139` — the red CTA outline (`--fa-border-red`). +- `0.5px #D9D9D9` vertical rule — used to separate logo lockups from tagline paragraphs. + +--- + +## 8. Elevation + +Almost flat. One exception: + +``` +--fa-shadow-hero: 24px 24px 32px 0 rgba(0, 0, 0, 0.25); +``` + +Applied only to product hero renders sitting on the slider. Cards, buttons, panels, and inputs all stay flat. + +The only blur in the system is `backdrop-filter: blur(17.6px)` on the red CTA fill when it sits over photography. + +--- + +## 9. Components + +### The signature CTA — cut-corner red outline button + +The single most recognizable component in the system. Specs: + +- 50px tall, 170px min-width, 24px horizontal padding. +- 1px red outline, 10% red fill, 17.6px backdrop blur. +- 12px triangular cut on the upper-left corner, filled solid red. +- Label: `DIN Condensed Bold 24px / 100%`, ALL CAPS, `+0.08em` tracking. +- **Hover:** 120ms — fill flips to solid red, text to white. +- **Press:** 80ms — `scale(0.98)`. + +Class: `.fa-cta`. Do not replace this with a pill, a rounded button, or a flat fill — it is the brand. + +### Data tile + +Outlined block (`1px #444` on black, `1px #D9D9D9` on white). Mono label up top, large mono number below, unit suffix in `--fa-stone`. One red number per tile maximum. + +### Section eyebrow + headline pair + +A `Space Mono 8/13, +0.5px` micro-cap label sits above a `DIN Condensed` headline. The eyebrow is always grey or red; the headline is always full-strength foreground. + +### Spec row + +Mono label (left) · 1px fog rule fill · mono value with unit (right). Used throughout brochure spreads and product detail. + +### Surfaces + +- `.fa-surface-paper` — white, black ink. +- `.fa-surface-ink` — black, white ink. +- `.fa-surface-concrete` — white with `assets/concrete.jpg` at 23% opacity. Reserved for premium marketing panels (e.g. product slider). + +--- + +## 10. Iconography + +A custom 24×24 line-icon set, stroke 1.5–1.67px, no fills, rounded joins. Style: close to **Hugeicons Pro (Stroke Rounded)**. + +**Current substitute:** **Lucide** (MIT) at 1.5px stroke, served from `unpkg.com/lucide-static`. Swap to the proprietary FoldAR set when SVGs are dropped into `assets/icons/`. + +Unicode-as-icon is permitted sparingly: `®`, `©`, `™`, `→`, `×`. Emoji never. + +--- + +## 11. Imagery + +Two acceptable modes: + +1. **Studio product renders** — pure white background, neutral lighting, optional `--fa-shadow-hero` directional shadow. +2. **Training-environment photography** — black-and-grey, muted, desaturated. Cool tone only. Grain acceptable. + +Never warm. Never lifestyle-soft. Never illustrated. Never AI-generated character art. + +When real assets are missing, draw a placeholder — a 1px fog-bordered rectangle with a mono label like `[PRODUCT SHOT — WATCHMAN 16, FOLDED]`. + +--- + +## 12. Logo & wordmarks + +- Full FoldAR wordmark: `assets/logo.svg` (light) and `assets/logo-dark.svg` (dark). +- Product wordmarks: `wordmark-{watchman, shepherd, sentinel, remnant, nanoremnant}.webp`. +- Header lockup is a condensed monogram derived from scaling the full wordmark. + +Do not invent new logos. Do not recolor the existing ones outside the four-color palette. + +--- + +## 13. Motion + +| State | Duration | Easing | Properties | +| ----- | -------- | ------ | ---------- | +| Hover | 120ms | `cubic-bezier(0.2, 0, 0, 1)` | color, opacity | +| Press | 80ms | same | `transform: scale(0.98)` | +| Page transition | 180–240ms | same | opacity + translateY(8px) | + +No bounces, no springs, no parallax, no auto-playing video loops in hero. + +### Hover affordances + +- **Links:** color shifts `#141414` → `#F43139`. +- **Buttons:** outline → filled (red → white text). +- **Images:** no hover state. + +--- + +## 14. Layout chrome + +- **Header (marketing):** 128px tall, 48px logo, 42px menu gutter, fixed. +- **Section labels:** `Space Mono 8/13, +0.5px` micro-caps above large `DIN Condensed` headlines. +- **Data callouts:** 1px `#444` outlined blocks on black surfaces. +- **Footer:** spec-row layout, mono labels, fog dividers. + +--- + +## 15. What to ask the user for + +Production fidelity requires a few items not yet supplied: + +- **Real font files** — DIN Condensed, Nimbus Mono PS, SF Pro (currently substituted with Oswald, JetBrains Mono, Inter). Drop into `fonts/`. +- **Proprietary icon set** — currently substituted with Lucide. Drop SVGs into `assets/icons/` and the substitution will be swapped. +- **Accurate product prices** if publishing customer-facing material; current prices are illustrative. + +--- + +## 16. Source files in this project + +| Path | Contents | +| ---- | -------- | +| `colors_and_type.css` | All tokens — palette, type scale, semantic utilities, the CTA | +| `fonts/` | Local webfont files | +| `assets/` | Logos, wordmarks, hero renders, photography | +| `preview/*.html` | Atomic reference cards (type, color, spacing, components, brand) | +| `ui_kits/website/` | React-style recreation of the marketing site | +| `ui_kits/brochure/` | Print brochure recreation | +| `README.md` | Full narrative — voice, foundations, font matrix | +| `SKILL.md` | Agent skill manifest for cross-project use |