浏览代码

ink updates (#241)

* ink updates

* no emojis

* design

* fit content

* fixes

* fixes
Chris Tate 3 月之前
父节点
当前提交
453484985a

+ 81 - 31
examples/ink-chat/src/app.tsx

@@ -85,29 +85,72 @@ function getStepHint(type: string, isLast: boolean): string {
 }
 
 // ---------------------------------------------------------------------------
-// System prompt — embeds catalog documentation so the AI knows what components
-// are available and how to output JSONL patches.
+// System prompt — handwritten design guidance + catalog documentation.
+// Follows the same pattern as examples/chat/lib/agent.ts: a rich
+// AGENT_INSTRUCTIONS string with catalog.prompt() appended at the end.
 // ---------------------------------------------------------------------------
 
-const SYSTEM_PROMPT = catalog.prompt({
-  system:
-    "You are a terminal chat assistant. Use tools for real-time data (web_search, get_weather, get_hacker_news, get_github_repo, get_crypto_price), then render results as rich terminal UIs.",
+const AGENT_INSTRUCTIONS = `You are a terminal assistant that renders polished, information-dense UIs. You call tools for real-time data, then build clean terminal dashboards.
+
+WORKFLOW:
+1. Call the appropriate tools to gather real data. Use web_search for topics not covered by the specialized tools (get_weather, get_hacker_news, get_github_repo, get_crypto_price).
+2. While tools run, output a single short status line (e.g. "Looking up weather data..."). This is the ONLY text allowed outside the spec fence.
+3. After tools return, output ALL content inside a \`\`\`spec fence. Never write paragraphs of prose outside the fence.
+4. For simple text replies (greetings, clarifications), still use a \`\`\`spec with a Markdown component.
+
+DESIGN PRINCIPLES:
+- HIERARCHY: Every response needs clear visual structure. Start with an h1 Heading for the topic. Use h2 Headings for subsections. Use Card to group related content into shaded areas — Cards render as subtle background fills, not bordered boxes.
+- LEAD WITH THE STORY: Open with a brief Markdown paragraph (2-3 sentences) that tells the user the key insight or takeaway. Don't just dump data — frame it.
+- SUMMARY METRICS: After the narrative, show 2-4 Metric components for the most important numbers. Metric displays a dim label, bold value, and optional colored trend (up=green, down=red). Group them in a horizontal Box (flexDirection: row, gap: 3) so they read like a dashboard header. Use KeyValue only for simple label:value pairs that don't need emphasis.
+- DETAIL SECTIONS: Below the summary, use h2 Headings to introduce each section, followed by a single focused visualization (Table, BarChart, or set of KeyValues).
+- ONE REPRESENTATION PER DATA POINT: Never show the same value as both a number and a percentage and a bar. Pick the most meaningful format. Use BarChart with showValues:true OR showPercentage:true, not both.
+- TABLES: Always set explicit column widths so columns don't collapse. Use headerColor:"cyan". Keep column headers short (abbreviate if needed). Right-align numeric columns.
+- CHARTS: Use distinct colors per bar in BarChart. Good palette: cyan, green, yellow, magenta, blue, red. Use Sparkline for compact inline trends alongside other content.
+- COLOR STRATEGY: Use color with intention, not decoration. cyan for labels and headers. green for positive values, growth, success. red for negative values, decline, errors. yellow for warnings or neutral highlights. dimColor:true for secondary/supporting text. Avoid coloring everything — contrast comes from restraint.
+- TABLES: Use borderStyle:"single" on Tables for a clean outline. Do NOT put Tables inside Cards — Tables have their own border and don't need additional wrapping.
+- SPACING: Use gap:1 between sections. Don't over-pad. Keep the UI compact and scannable. NEVER add padding to the root element — the app already provides outer padding.
+- WIDTH: Target 80 columns. Set explicit widths on Tables (total columns should sum to ~70-75). Use wrap:"truncate-end" on Text in tight spaces.
+- CALLOUTS: Use Callout for key takeaways, important notes, tips, and warnings. Set type (info/tip/warning/important) for a colored left border accent. Keep content concise — one key point per Callout.
+- TIMELINES: Use Timeline for historical events, step-by-step processes, and milestones. Set status per item (completed/current/upcoming) for colored dots. Include dates when available.
+- NEVER use emojis anywhere — not in text, labels, titles, table cells, Heading text, or component props. Plain text only.
+
+DASHBOARD PATTERN (use for data-heavy responses):
+Root Box (column, gap:1) >
+  Heading (h1, topic title)
+  Markdown (2-3 sentence summary with key takeaway)
+  Box (row, gap:3) > [Metric, Metric, Metric] (top-line metrics, no Card)
+  Heading (h2, section title)
+  Table (borderStyle:"single")
+  Card (title:"Section Name") > BarChart (bar charts go in a titled Card — the Card title replaces h2)
+  Callout (type:"tip", key takeaway or closing note)
+Card wrapping rules: Wrap BarCharts in a Card with a title. Do NOT wrap Metrics or Tables in Cards — Metrics stand alone, Tables have their own border.
+
+COMPARISON PATTERN:
+Use BarChart when you want the user to see relative magnitudes at a glance.
+Use Table when there are 3+ columns of mixed data types.
+Never use both for the same data.
+
+TREND PATTERN:
+Use Sparkline for compact inline trend next to a KeyValue.
+Use BarChart with year/period labels for detailed time-series.
+
+INTERACTIVITY:
+- You can create interactive forms, surveys, and selection interfaces. The user navigates with arrow keys, selects with Space/Enter, and types into text fields.
+- ALWAYS include a submit action on interactive UIs. Add a Text or StatusLine telling the user how to submit. Wire submit events to a "submit" action — the app collects form state automatically.
+- ALWAYS populate the state field with sensible defaults for all bound values.
+- Use $bindState on interactive components for two-way binding. Example: { "value": { "$state": "/choice" }, "$bindState": { "value": "/choice" } }.
+- Use Tabs for multi-section surveys. Use ConfirmInput for yes/no prompts.
+- After receiving form data, acknowledge the user's choices meaningfully — don't just echo them back.
+
+${catalog.prompt({
   mode: "inline",
   customRules: [
-    "ALL text MUST go inside the spec using the Markdown component. NEVER write prose, summaries, or explanations outside the ```spec fence. The ONLY text allowed outside the fence is a single short status like 'Searching…' while tools run.",
-    "For text-only answers (greetings, clarifications), still output a ```spec with a Markdown component.",
-    "Prefer Table for structured data (forecasts, leaderboards, language breakdowns) and KeyValue for label-value pairs (stats, metadata).",
-
-    // Interactive components
-    "INTERACTIVE UIs: You can create interactive forms, surveys, and selection interfaces that the user actually interacts with in the terminal. The user navigates with arrow keys, selects with Space/Enter, and types into text fields.",
-    "When creating interactive UIs, ALWAYS include a submit action. Add an element (usually a Text or StatusLine) that tells the user to press Enter or a specific key to submit. Wire up the submit event to a 'submit' action — the app will collect the form state and send it back to you automatically.",
-    "For interactive specs, ALWAYS populate the state field with sensible defaults for all bound values. Example: if a Select binds to /framework, include state: { framework: 'react' }.",
-    'IMPORTANT: Use $bindState on interactive components so their values are written back to state. Example for Select: { "value": { "$state": "/choice" }, "$bindState": { "value": "/choice" } }.',
-    "For surveys/quizzes with multiple sections, use Tabs to organize them. Bind the active tab to state and use visible conditions on children.",
-    "For confirmation prompts, use ConfirmInput with a clear message. The confirm/deny events can trigger actions.",
-    "After receiving submitted form data, acknowledge the user's choices and respond to them — don't just repeat what they selected.",
+    "ALL text MUST go inside the spec using the Markdown component. The ONLY text outside the fence is a short tool-status line.",
+    "For text-only answers, still output a spec with a Markdown component.",
+    "Prefer Table for structured data and KeyValue for label-value pairs.",
+    "NEVER use emojis anywhere in your output. Plain text only.",
   ],
-});
+})}`;
 
 // ---------------------------------------------------------------------------
 // Types
@@ -236,7 +279,6 @@ function MessageView({ message }: { message: Message }) {
 
   return (
     <Box flexDirection="column" marginBottom={1}>
-      <Text bold>AI:</Text>
       {message.spec ? (
         <JSONUIProvider initialState={message.spec.state ?? {}}>
           <DisableFocus />
@@ -317,9 +359,6 @@ function LiveInteractiveSpec({
   if (interactiveKeys.length === 0) {
     return (
       <Box flexDirection="column" marginBottom={1}>
-        <Text bold color="green">
-          AI:
-        </Text>
         <JSONUIProvider store={store} handlers={handlers}>
           <Renderer spec={spec} />
         </JSONUIProvider>
@@ -331,9 +370,6 @@ function LiveInteractiveSpec({
 
   return (
     <Box flexDirection="column" marginBottom={1}>
-      <Text bold color="green">
-        AI:
-      </Text>
       {interactiveKeys.length > 1 && (
         <Text dimColor>
           Step {step + 1} of {interactiveKeys.length}
@@ -417,7 +453,7 @@ export function App() {
     try {
       const result = streamText({
         model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
-        system: SYSTEM_PROMPT,
+        system: AGENT_INSTRUCTIONS,
         messages: history,
         temperature: 0.7,
         abortSignal: controller.signal,
@@ -535,11 +571,26 @@ export function App() {
   return (
     <Box flexDirection="column" padding={1} minHeight={stdout.rows}>
       {/* Header */}
-      <Box marginBottom={1}>
-        <Text bold>json-render terminal chat</Text>
-        <Text dimColor> (Ctrl+C to exit)</Text>
+      <Box marginBottom={1} gap={1}>
+        <Text bold color="cyan">
+          json-render
+        </Text>
+        <Text dimColor>Ctrl+C to exit</Text>
       </Box>
 
+      {/* Empty state — show example prompts when no conversation yet */}
+      {messages.length === 0 && !isStreaming && (
+        <Box flexDirection="column" marginBottom={1}>
+          <Text dimColor>Try asking:</Text>
+          <Box flexDirection="column" paddingLeft={2} marginTop={1} gap={0}>
+            <Text dimColor>{"  weather in tokyo"}</Text>
+            <Text dimColor>{"  top hacker news stories"}</Text>
+            <Text dimColor>{"  tell me about vercel/next.js"}</Text>
+            <Text dimColor>{"  bitcoin price"}</Text>
+          </Box>
+        </Box>
+      )}
+
       {/* Message history — collapsed when interactive wizard is active */}
       {liveSpec && !isStreaming ? (
         <>
@@ -568,10 +619,9 @@ export function App() {
       {/* Live spec preview while streaming */}
       {isStreaming && streamingSpec && streamingSpec.root && (
         <Box flexDirection="column" marginBottom={1}>
-          <Text bold>AI:</Text>
           <JSONUIProvider initialState={streamingSpec.state ?? {}}>
             <DisableFocus />
-            <Renderer spec={streamingSpec} />
+            <Renderer spec={streamingSpec} loading />
           </JSONUIProvider>
         </Box>
       )}

+ 2 - 1
examples/ink-chat/src/tools.ts

@@ -275,7 +275,8 @@ export const getGitHubRepo = tool({
       const languageBreakdown = Object.entries(languages)
         .map(([lang, bytes]) => ({
           language: lang,
-          percentage: Math.round((bytes / totalBytes) * 100),
+          percentage:
+            totalBytes > 0 ? Math.round((bytes / totalBytes) * 100) : 0,
         }))
         .sort((a, b) => b.percentage - a.percentage)
         .slice(0, 6);

+ 2 - 1
packages/ink/package.json

@@ -57,7 +57,8 @@
   "scripts": {
     "build": "tsup",
     "dev": "tsup --watch",
-    "check-types": "tsc --noEmit"
+    "check-types": "tsc --noEmit",
+    "typecheck": "tsc --noEmit"
   },
   "dependencies": {
     "@json-render/core": "workspace:*",

+ 77 - 11
packages/ink/src/catalog.ts

@@ -259,11 +259,12 @@ export const standardComponentDefinitions = {
       borderStyle: z
         .enum(["single", "double", "round", "bold", "classic"])
         .nullable(),
+      backgroundColor: z.string().nullable(),
       headerColor: z.string().nullable(),
     }),
     slots: [],
     description:
-      "Tabular data display with headers and rows. Each row is a record mapping column keys to string values.",
+      "Tabular data display with headers and rows. Each row is a record mapping column keys to string values. Set both borderStyle and backgroundColor together so borders share the same shading.",
     example: {
       columns: [
         { header: "Name", key: "name", width: 20 },
@@ -306,7 +307,7 @@ export const standardComponentDefinitions = {
     example: {
       title: "package.json",
       subtitle: "Modified 2 hours ago",
-      leading: "📄",
+      leading: "*",
       trailing: "2.1 KB",
     },
   },
@@ -314,16 +315,13 @@ export const standardComponentDefinitions = {
   Card: {
     props: z.object({
       title: z.string().nullable(),
-      borderStyle: z
-        .enum(["single", "double", "round", "bold", "classic"])
-        .nullable(),
-      borderColor: z.string().nullable(),
+      backgroundColor: z.string().nullable(),
       padding: z.number().nullable(),
     }),
     slots: ["default"],
     description:
-      "Bordered container with optional title. Use for grouping related content with a visual boundary.",
-    example: { title: "Details", borderStyle: "round", padding: 1 },
+      "Shaded container with optional title. Renders as a filled background area for grouping related content. Default background is a subtle dark shade.",
+    example: { title: "Details", padding: 1 },
   },
 
   KeyValue: {
@@ -363,6 +361,74 @@ export const standardComponentDefinitions = {
     example: { text: "Build completed successfully", status: "success" },
   },
 
+  Metric: {
+    props: z.object({
+      label: z.string(),
+      value: z.string(),
+      detail: z.string().nullable(),
+      trend: z.enum(["up", "down", "neutral"]).nullable(),
+    }),
+    slots: [],
+    description:
+      "Key metric display with prominent value and optional trend indicator. Use for important numbers that deserve visual emphasis (price, temperature, stars, market cap).",
+    example: {
+      label: "Price",
+      value: "$70,686",
+      detail: "24h change",
+      trend: "up",
+    },
+  },
+
+  Callout: {
+    props: z.object({
+      type: z.enum(["info", "tip", "warning", "important"]).nullable(),
+      title: z.string().nullable(),
+      content: z.string(),
+    }),
+    slots: [],
+    description:
+      "Highlighted callout block with colored left border. Use for key takeaways, tips, warnings, or important notes that should stand out from surrounding content.",
+    example: {
+      type: "tip",
+      title: "Key Takeaway",
+      content:
+        "Revenue peaked in FY2022 driven by iPhone 13/14 upgrade cycles.",
+    },
+  },
+
+  Timeline: {
+    props: z.object({
+      items: z.array(
+        z.object({
+          title: z.string(),
+          description: z.string().nullable(),
+          date: z.string().nullable(),
+          status: z.enum(["completed", "current", "upcoming"]).nullable(),
+        }),
+      ),
+    }),
+    slots: [],
+    description:
+      "Vertical timeline showing ordered events, steps, or milestones. Each item has a status-colored dot, title, optional date, and optional description.",
+    example: {
+      items: [
+        {
+          title: "Project Started",
+          description: "Initial commit and setup",
+          date: "Jan 2024",
+          status: "completed",
+        },
+        {
+          title: "Beta Release",
+          description: "Public beta launched",
+          date: "Mar 2024",
+          status: "current",
+        },
+        { title: "v1.0", date: "Q2 2024", status: "upcoming" },
+      ],
+    },
+  },
+
   // ==========================================================================
   // Interactive Components
   // ==========================================================================
@@ -468,9 +534,9 @@ export const standardComponentDefinitions = {
       "Tab bar navigation. Navigate with left/right arrow keys. Use $bindState on value to bind the active tab to state. Place child content inside and use visible conditions on children to show content for the active tab.",
     example: {
       tabs: [
-        { label: "Overview", value: "overview", icon: "📊" },
-        { label: "Logs", value: "logs", icon: "📋" },
-        { label: "Settings", value: "settings", icon: "⚙" },
+        { label: "Overview", value: "overview" },
+        { label: "Logs", value: "logs" },
+        { label: "Settings", value: "settings" },
       ],
     },
   },

+ 166 - 27
packages/ink/src/components/standard.tsx

@@ -325,6 +325,7 @@ function TableComponent({ element }: ComponentRenderProps) {
     }>;
     rows?: Array<Record<string, string>>;
     borderStyle?: string;
+    backgroundColor?: string;
     headerColor?: string;
   };
 
@@ -363,7 +364,12 @@ function TableComponent({ element }: ComponentRenderProps) {
     | undefined;
 
   return (
-    <Box flexDirection="column" borderStyle={borderStyleProp}>
+    <Box
+      flexDirection="column"
+      alignSelf="flex-start"
+      borderStyle={borderStyleProp}
+      backgroundColor={p.backgroundColor}
+    >
       {/* Header */}
       <Box>
         {columns.map((col, i) => (
@@ -375,15 +381,19 @@ function TableComponent({ element }: ComponentRenderProps) {
       {/* Separator */}
       <Text dimColor>{colWidths.map((w) => "─".repeat(w)).join("─")}</Text>
       {/* Rows */}
-      {rows.map((row, rowIdx) => (
-        <Box key={rowIdx}>
-          {columns.map((col, i) => (
-            <Text key={col.key}>
-              {padCell(row[col.key] || "—", colWidths[i]!, col.align)}
-            </Text>
-          ))}
-        </Box>
-      ))}
+      {rows.map((row, rowIdx) => {
+        const rowKey =
+          columns.map((c) => row[c.key] ?? "").join("\0") || String(rowIdx);
+        return (
+          <Box key={rowKey} gap={0}>
+            {columns.map((col, i) => (
+              <Text key={col.key}>
+                {padCell(row[col.key] || "—", colWidths[i]!, col.align)}
+              </Text>
+            ))}
+          </Box>
+        );
+      })}
     </Box>
   );
 }
@@ -402,7 +412,7 @@ function ListComponent({ element }: ComponentRenderProps) {
   return (
     <Box flexDirection="column" gap={p.spacing ?? 0}>
       {items.map((item, i) => (
-        <Box key={i} gap={1}>
+        <Box key={`${i}:${item}`} gap={1}>
           <Text dimColor>{p.ordered ? `${i + 1}.` : bullet}</Text>
           <Text>{item}</Text>
         </Box>
@@ -434,16 +444,15 @@ function ListItemComponent({ element }: ComponentRenderProps) {
 function CardComponent({ element, children }: ComponentRenderProps) {
   const p = element.props as {
     title?: string;
-    borderStyle?: "single" | "double" | "round" | "bold" | "classic";
-    borderColor?: string;
+    backgroundColor?: string;
     padding?: number;
   };
 
   return (
     <Box
       flexDirection="column"
-      borderStyle={p.borderStyle ?? "round"}
-      borderColor={p.borderColor}
+      alignSelf="flex-start"
+      backgroundColor={p.backgroundColor ?? "#1a1a1a"}
       padding={p.padding ?? 1}
     >
       {p.title ? (
@@ -537,10 +546,136 @@ function StatusLineComponent({ element }: ComponentRenderProps) {
   );
 }
 
+// =============================================================================
+// Metric, Callout, Timeline
+// =============================================================================
+
+const TREND_CONFIG: Record<string, { prefix: string; color: string }> = {
+  up: { prefix: "+", color: "green" },
+  down: { prefix: "", color: "red" },
+  neutral: { prefix: "~", color: "gray" },
+};
+
+function MetricComponent({ element }: ComponentRenderProps) {
+  const p = element.props as {
+    label?: string;
+    value?: string;
+    detail?: string;
+    trend?: "up" | "down" | "neutral";
+  };
+
+  const trend = p.trend ? TREND_CONFIG[p.trend] : null;
+
+  return (
+    <Box flexDirection="column">
+      <Text dimColor>{p.label ?? ""}</Text>
+      <Box gap={1}>
+        <Text bold>{p.value ?? ""}</Text>
+        {trend && p.detail ? (
+          <Text color={trend.color}>
+            {trend.prefix}
+            {p.detail}
+          </Text>
+        ) : null}
+      </Box>
+      {!trend && p.detail ? <Text dimColor>{p.detail}</Text> : null}
+    </Box>
+  );
+}
+
+const CALLOUT_CONFIG: Record<string, { label: string; color: string }> = {
+  info: { label: "INFO", color: "blue" },
+  tip: { label: "TIP", color: "green" },
+  warning: { label: "WARNING", color: "yellow" },
+  important: { label: "IMPORTANT", color: "magenta" },
+};
+
+function CalloutComponent({ element }: ComponentRenderProps) {
+  const p = element.props as {
+    type?: "info" | "tip" | "warning" | "important";
+    title?: string;
+    content?: string;
+  };
+
+  const config = CALLOUT_CONFIG[p.type ?? "info"] ?? CALLOUT_CONFIG.info!;
+
+  return (
+    <Box
+      flexDirection="column"
+      borderStyle="bold"
+      borderLeft
+      borderRight={false}
+      borderTop={false}
+      borderBottom={false}
+      borderColor={config.color}
+      paddingLeft={1}
+    >
+      {p.title ? (
+        <Text bold color={config.color}>
+          {p.title}
+        </Text>
+      ) : null}
+      <Text>{p.content ?? ""}</Text>
+    </Box>
+  );
+}
+
+const TIMELINE_DOT: Record<string, { dot: string; color: string }> = {
+  completed: { dot: "●", color: "green" },
+  current: { dot: "◆", color: "cyan" },
+  upcoming: { dot: "○", color: "gray" },
+};
+
+function TimelineComponent({ element }: ComponentRenderProps) {
+  const p = element.props as {
+    items?: Array<{
+      title?: string;
+      description?: string;
+      date?: string;
+      status?: "completed" | "current" | "upcoming";
+    }>;
+  };
+
+  const items = p.items ?? [];
+
+  return (
+    <Box flexDirection="column" gap={1}>
+      {items.map((item, i) => {
+        const cfg =
+          TIMELINE_DOT[item.status ?? "upcoming"] ?? TIMELINE_DOT.upcoming!;
+        return (
+          <Box key={i} flexDirection="column">
+            <Box gap={1}>
+              <Text color={cfg.color}>{cfg.dot}</Text>
+              <Text bold>{item.title ?? ""}</Text>
+              {item.date ? <Text dimColor>{item.date}</Text> : null}
+            </Box>
+            {item.description ? (
+              <Box paddingLeft={2}>
+                <Text dimColor>{item.description}</Text>
+              </Box>
+            ) : null}
+          </Box>
+        );
+      })}
+    </Box>
+  );
+}
+
 // =============================================================================
 // Interactive Components
 // =============================================================================
 
+/** Count code points instead of UTF-16 code units (handles emoji/surrogate pairs). */
+function codePointLength(s: string): number {
+  return Array.from(s).length;
+}
+
+/** Slice by code point index instead of UTF-16 offset. */
+function codePointSlice(s: string, start: number, end?: number): string {
+  return Array.from(s).slice(start, end).join("");
+}
+
 function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
   const p = element.props as {
     placeholder?: string;
@@ -556,11 +691,11 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
   );
 
   const currentValue = value ?? "";
-  const [cursorPos, setCursorPos] = useState(currentValue.length);
+  const [cursorPos, setCursorPos] = useState(codePointLength(currentValue));
 
   // Keep cursor in bounds when value changes externally
   useEffect(() => {
-    setCursorPos((prev) => Math.min(prev, currentValue.length));
+    setCursorPos((prev) => Math.min(prev, codePointLength(currentValue)));
   }, [currentValue]);
 
   useInput(
@@ -573,8 +708,8 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
       if (key.backspace || key.delete) {
         if (cursorPos > 0) {
           const newVal =
-            currentValue.slice(0, cursorPos - 1) +
-            currentValue.slice(cursorPos);
+            codePointSlice(currentValue, 0, cursorPos - 1) +
+            codePointSlice(currentValue, cursorPos);
           setValue(newVal);
           setCursorPos((prev) => prev - 1);
           emit("change");
@@ -588,7 +723,9 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
         return;
       }
       if (key.rightArrow) {
-        setCursorPos((prev) => Math.min(currentValue.length, prev + 1));
+        setCursorPos((prev) =>
+          Math.min(codePointLength(currentValue), prev + 1),
+        );
         return;
       }
 
@@ -598,11 +735,11 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
 
       if (input) {
         const newVal =
-          currentValue.slice(0, cursorPos) +
+          codePointSlice(currentValue, 0, cursorPos) +
           input +
-          currentValue.slice(cursorPos);
+          codePointSlice(currentValue, cursorPos);
         setValue(newVal);
-        setCursorPos((prev) => prev + input.length);
+        setCursorPos((prev) => prev + codePointLength(input));
         emit("change");
       }
     },
@@ -611,13 +748,12 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
 
   const displayValue = currentValue
     ? p.mask
-      ? p.mask.repeat(currentValue.length)
+      ? p.mask.repeat(codePointLength(currentValue))
       : currentValue
     : "";
 
-  // Render with cursor position indicator
-  const before = displayValue.slice(0, cursorPos);
-  const after = displayValue.slice(cursorPos);
+  const before = codePointSlice(displayValue, 0, cursorPos);
+  const after = codePointSlice(displayValue, cursorPos);
 
   return (
     <Box gap={1}>
@@ -1178,6 +1314,9 @@ export const standardComponents: ComponentRegistry = {
   KeyValue: KeyValueComponent,
   Link: LinkComponent,
   StatusLine: StatusLineComponent,
+  Metric: MetricComponent,
+  Callout: CalloutComponent,
+  Timeline: TimelineComponent,
   TextInput: TextInputComponent,
   Select: SelectComponent,
   MultiSelect: MultiSelectComponent,

+ 18 - 7
packages/ink/src/contexts/actions.tsx

@@ -101,8 +101,8 @@ export interface PendingConfirmation {
 export interface ActionContextValue {
   /** Registered action handlers */
   handlers: Record<string, ActionHandler>;
-  /** Actions currently executing (for loading indicators) */
-  loadingActions: Set<string>;
+  /** Actions currently executing (count of in-flight executions per action name) */
+  loadingActions: Map<string, number>;
   /** Pending confirmation dialog */
   pendingConfirmation: PendingConfirmation | null;
   /** Execute an action binding */
@@ -141,7 +141,9 @@ export function ActionProvider({
   initialHandlersRef.current = initialHandlers;
   const navigateRef = useRef(navigate);
   navigateRef.current = navigate;
-  const [loadingActions, setLoadingActions] = useState<Set<string>>(new Set());
+  const [loadingActions, setLoadingActions] = useState<Map<string, number>>(
+    new Map(),
+  );
   const [pendingConfirmation, setPendingConfirmation] =
     useState<PendingConfirmation | null>(null);
   // Ref tracks current pending confirmation so overlapping confirms can
@@ -267,7 +269,11 @@ export function ActionProvider({
       }
 
       const actionName = resolved.action;
-      setLoadingActions((prev) => new Set(prev).add(actionName));
+      setLoadingActions((prev) => {
+        const next = new Map(prev);
+        next.set(actionName, (next.get(actionName) ?? 0) + 1);
+        return next;
+      });
       try {
         await executeAction({
           action: resolved,
@@ -281,8 +287,13 @@ export function ActionProvider({
         });
       } finally {
         setLoadingActions((prev) => {
-          const next = new Set(prev);
-          next.delete(actionName);
+          const next = new Map(prev);
+          const count = (next.get(actionName) ?? 1) - 1;
+          if (count <= 0) {
+            next.delete(actionName);
+          } else {
+            next.set(actionName, count);
+          }
           return next;
         });
       }
@@ -343,7 +354,7 @@ export function useAction(binding: ActionBinding): {
 } {
   const { execute, loadingActions } = useActions();
   const executeAction = useCallback(() => execute(binding), [execute, binding]);
-  const isLoading = loadingActions.has(binding.action);
+  const isLoading = (loadingActions.get(binding.action) ?? 0) > 0;
   return { execute: executeAction, isLoading };
 }
 

+ 1 - 1
packages/ink/src/hooks.ts

@@ -99,7 +99,7 @@ function parsePatchLine(line: string): ParseResult {
   // Recovery: strip trailing extra braces/brackets one at a time
   // LLMs commonly generate extra closing characters in nested JSON
   let attempt = trimmed;
-  for (let i = 0; i < 3; i++) {
+  for (let i = 0; i < 8; i++) {
     const last = attempt[attempt.length - 1];
     if (last === "}" || last === "]") {
       attempt = attempt.slice(0, -1);

+ 10 - 0
packages/ink/src/renderer.tsx

@@ -5,6 +5,7 @@ import React, {
   useCallback,
   useMemo,
 } from "react";
+import { Text } from "ink";
 import type {
   UIElement,
   Spec,
@@ -268,6 +269,9 @@ const ElementRenderer = React.memo(function ElementRenderer({
           console.warn(
             `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
           );
+          return (
+            <Text key={childKey} color="red">{`[Missing: ${childKey}]`}</Text>
+          );
         }
         return null;
       }
@@ -346,6 +350,12 @@ function RepeatChildren({
                   console.warn(
                     `[json-render] Missing element "${childKey}" referenced as child of "${element.type}" (repeat). This element will not render.`,
                   );
+                  return (
+                    <Text
+                      key={`${key}:${childKey}`}
+                      color="red"
+                    >{`[Missing: ${childKey}]`}</Text>
+                  );
                 }
                 return null;
               }