Quellcode durchsuchen

ink updates (#241)

* ink updates

* no emojis

* design

* fit content

* fixes

* fixes
Chris Tate vor 3 Monaten
Ursprung
Commit
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",
   mode: "inline",
   customRules: [
   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
 // Types
@@ -236,7 +279,6 @@ function MessageView({ message }: { message: Message }) {
 
 
   return (
   return (
     <Box flexDirection="column" marginBottom={1}>
     <Box flexDirection="column" marginBottom={1}>
-      <Text bold>AI:</Text>
       {message.spec ? (
       {message.spec ? (
         <JSONUIProvider initialState={message.spec.state ?? {}}>
         <JSONUIProvider initialState={message.spec.state ?? {}}>
           <DisableFocus />
           <DisableFocus />
@@ -317,9 +359,6 @@ function LiveInteractiveSpec({
   if (interactiveKeys.length === 0) {
   if (interactiveKeys.length === 0) {
     return (
     return (
       <Box flexDirection="column" marginBottom={1}>
       <Box flexDirection="column" marginBottom={1}>
-        <Text bold color="green">
-          AI:
-        </Text>
         <JSONUIProvider store={store} handlers={handlers}>
         <JSONUIProvider store={store} handlers={handlers}>
           <Renderer spec={spec} />
           <Renderer spec={spec} />
         </JSONUIProvider>
         </JSONUIProvider>
@@ -331,9 +370,6 @@ function LiveInteractiveSpec({
 
 
   return (
   return (
     <Box flexDirection="column" marginBottom={1}>
     <Box flexDirection="column" marginBottom={1}>
-      <Text bold color="green">
-        AI:
-      </Text>
       {interactiveKeys.length > 1 && (
       {interactiveKeys.length > 1 && (
         <Text dimColor>
         <Text dimColor>
           Step {step + 1} of {interactiveKeys.length}
           Step {step + 1} of {interactiveKeys.length}
@@ -417,7 +453,7 @@ export function App() {
     try {
     try {
       const result = streamText({
       const result = streamText({
         model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
         model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
-        system: SYSTEM_PROMPT,
+        system: AGENT_INSTRUCTIONS,
         messages: history,
         messages: history,
         temperature: 0.7,
         temperature: 0.7,
         abortSignal: controller.signal,
         abortSignal: controller.signal,
@@ -535,11 +571,26 @@ export function App() {
   return (
   return (
     <Box flexDirection="column" padding={1} minHeight={stdout.rows}>
     <Box flexDirection="column" padding={1} minHeight={stdout.rows}>
       {/* Header */}
       {/* 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>
       </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 */}
       {/* Message history — collapsed when interactive wizard is active */}
       {liveSpec && !isStreaming ? (
       {liveSpec && !isStreaming ? (
         <>
         <>
@@ -568,10 +619,9 @@ export function App() {
       {/* Live spec preview while streaming */}
       {/* Live spec preview while streaming */}
       {isStreaming && streamingSpec && streamingSpec.root && (
       {isStreaming && streamingSpec && streamingSpec.root && (
         <Box flexDirection="column" marginBottom={1}>
         <Box flexDirection="column" marginBottom={1}>
-          <Text bold>AI:</Text>
           <JSONUIProvider initialState={streamingSpec.state ?? {}}>
           <JSONUIProvider initialState={streamingSpec.state ?? {}}>
             <DisableFocus />
             <DisableFocus />
-            <Renderer spec={streamingSpec} />
+            <Renderer spec={streamingSpec} loading />
           </JSONUIProvider>
           </JSONUIProvider>
         </Box>
         </Box>
       )}
       )}

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

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

+ 2 - 1
packages/ink/package.json

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

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

@@ -259,11 +259,12 @@ export const standardComponentDefinitions = {
       borderStyle: z
       borderStyle: z
         .enum(["single", "double", "round", "bold", "classic"])
         .enum(["single", "double", "round", "bold", "classic"])
         .nullable(),
         .nullable(),
+      backgroundColor: z.string().nullable(),
       headerColor: z.string().nullable(),
       headerColor: z.string().nullable(),
     }),
     }),
     slots: [],
     slots: [],
     description:
     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: {
     example: {
       columns: [
       columns: [
         { header: "Name", key: "name", width: 20 },
         { header: "Name", key: "name", width: 20 },
@@ -306,7 +307,7 @@ export const standardComponentDefinitions = {
     example: {
     example: {
       title: "package.json",
       title: "package.json",
       subtitle: "Modified 2 hours ago",
       subtitle: "Modified 2 hours ago",
-      leading: "📄",
+      leading: "*",
       trailing: "2.1 KB",
       trailing: "2.1 KB",
     },
     },
   },
   },
@@ -314,16 +315,13 @@ export const standardComponentDefinitions = {
   Card: {
   Card: {
     props: z.object({
     props: z.object({
       title: z.string().nullable(),
       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(),
       padding: z.number().nullable(),
     }),
     }),
     slots: ["default"],
     slots: ["default"],
     description:
     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: {
   KeyValue: {
@@ -363,6 +361,74 @@ export const standardComponentDefinitions = {
     example: { text: "Build completed successfully", status: "success" },
     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
   // 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.",
       "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: {
     example: {
       tabs: [
       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>>;
     rows?: Array<Record<string, string>>;
     borderStyle?: string;
     borderStyle?: string;
+    backgroundColor?: string;
     headerColor?: string;
     headerColor?: string;
   };
   };
 
 
@@ -363,7 +364,12 @@ function TableComponent({ element }: ComponentRenderProps) {
     | undefined;
     | undefined;
 
 
   return (
   return (
-    <Box flexDirection="column" borderStyle={borderStyleProp}>
+    <Box
+      flexDirection="column"
+      alignSelf="flex-start"
+      borderStyle={borderStyleProp}
+      backgroundColor={p.backgroundColor}
+    >
       {/* Header */}
       {/* Header */}
       <Box>
       <Box>
         {columns.map((col, i) => (
         {columns.map((col, i) => (
@@ -375,15 +381,19 @@ function TableComponent({ element }: ComponentRenderProps) {
       {/* Separator */}
       {/* Separator */}
       <Text dimColor>{colWidths.map((w) => "─".repeat(w)).join("─")}</Text>
       <Text dimColor>{colWidths.map((w) => "─".repeat(w)).join("─")}</Text>
       {/* Rows */}
       {/* 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>
     </Box>
   );
   );
 }
 }
@@ -402,7 +412,7 @@ function ListComponent({ element }: ComponentRenderProps) {
   return (
   return (
     <Box flexDirection="column" gap={p.spacing ?? 0}>
     <Box flexDirection="column" gap={p.spacing ?? 0}>
       {items.map((item, i) => (
       {items.map((item, i) => (
-        <Box key={i} gap={1}>
+        <Box key={`${i}:${item}`} gap={1}>
           <Text dimColor>{p.ordered ? `${i + 1}.` : bullet}</Text>
           <Text dimColor>{p.ordered ? `${i + 1}.` : bullet}</Text>
           <Text>{item}</Text>
           <Text>{item}</Text>
         </Box>
         </Box>
@@ -434,16 +444,15 @@ function ListItemComponent({ element }: ComponentRenderProps) {
 function CardComponent({ element, children }: ComponentRenderProps) {
 function CardComponent({ element, children }: ComponentRenderProps) {
   const p = element.props as {
   const p = element.props as {
     title?: string;
     title?: string;
-    borderStyle?: "single" | "double" | "round" | "bold" | "classic";
-    borderColor?: string;
+    backgroundColor?: string;
     padding?: number;
     padding?: number;
   };
   };
 
 
   return (
   return (
     <Box
     <Box
       flexDirection="column"
       flexDirection="column"
-      borderStyle={p.borderStyle ?? "round"}
-      borderColor={p.borderColor}
+      alignSelf="flex-start"
+      backgroundColor={p.backgroundColor ?? "#1a1a1a"}
       padding={p.padding ?? 1}
       padding={p.padding ?? 1}
     >
     >
       {p.title ? (
       {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
 // 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) {
 function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
   const p = element.props as {
   const p = element.props as {
     placeholder?: string;
     placeholder?: string;
@@ -556,11 +691,11 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
   );
   );
 
 
   const currentValue = value ?? "";
   const currentValue = value ?? "";
-  const [cursorPos, setCursorPos] = useState(currentValue.length);
+  const [cursorPos, setCursorPos] = useState(codePointLength(currentValue));
 
 
   // Keep cursor in bounds when value changes externally
   // Keep cursor in bounds when value changes externally
   useEffect(() => {
   useEffect(() => {
-    setCursorPos((prev) => Math.min(prev, currentValue.length));
+    setCursorPos((prev) => Math.min(prev, codePointLength(currentValue)));
   }, [currentValue]);
   }, [currentValue]);
 
 
   useInput(
   useInput(
@@ -573,8 +708,8 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
       if (key.backspace || key.delete) {
       if (key.backspace || key.delete) {
         if (cursorPos > 0) {
         if (cursorPos > 0) {
           const newVal =
           const newVal =
-            currentValue.slice(0, cursorPos - 1) +
-            currentValue.slice(cursorPos);
+            codePointSlice(currentValue, 0, cursorPos - 1) +
+            codePointSlice(currentValue, cursorPos);
           setValue(newVal);
           setValue(newVal);
           setCursorPos((prev) => prev - 1);
           setCursorPos((prev) => prev - 1);
           emit("change");
           emit("change");
@@ -588,7 +723,9 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
         return;
         return;
       }
       }
       if (key.rightArrow) {
       if (key.rightArrow) {
-        setCursorPos((prev) => Math.min(currentValue.length, prev + 1));
+        setCursorPos((prev) =>
+          Math.min(codePointLength(currentValue), prev + 1),
+        );
         return;
         return;
       }
       }
 
 
@@ -598,11 +735,11 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
 
 
       if (input) {
       if (input) {
         const newVal =
         const newVal =
-          currentValue.slice(0, cursorPos) +
+          codePointSlice(currentValue, 0, cursorPos) +
           input +
           input +
-          currentValue.slice(cursorPos);
+          codePointSlice(currentValue, cursorPos);
         setValue(newVal);
         setValue(newVal);
-        setCursorPos((prev) => prev + input.length);
+        setCursorPos((prev) => prev + codePointLength(input));
         emit("change");
         emit("change");
       }
       }
     },
     },
@@ -611,13 +748,12 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
 
 
   const displayValue = currentValue
   const displayValue = currentValue
     ? p.mask
     ? p.mask
-      ? p.mask.repeat(currentValue.length)
+      ? p.mask.repeat(codePointLength(currentValue))
       : 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 (
   return (
     <Box gap={1}>
     <Box gap={1}>
@@ -1178,6 +1314,9 @@ export const standardComponents: ComponentRegistry = {
   KeyValue: KeyValueComponent,
   KeyValue: KeyValueComponent,
   Link: LinkComponent,
   Link: LinkComponent,
   StatusLine: StatusLineComponent,
   StatusLine: StatusLineComponent,
+  Metric: MetricComponent,
+  Callout: CalloutComponent,
+  Timeline: TimelineComponent,
   TextInput: TextInputComponent,
   TextInput: TextInputComponent,
   Select: SelectComponent,
   Select: SelectComponent,
   MultiSelect: MultiSelectComponent,
   MultiSelect: MultiSelectComponent,

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

@@ -101,8 +101,8 @@ export interface PendingConfirmation {
 export interface ActionContextValue {
 export interface ActionContextValue {
   /** Registered action handlers */
   /** Registered action handlers */
   handlers: Record<string, ActionHandler>;
   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 */
   /** Pending confirmation dialog */
   pendingConfirmation: PendingConfirmation | null;
   pendingConfirmation: PendingConfirmation | null;
   /** Execute an action binding */
   /** Execute an action binding */
@@ -141,7 +141,9 @@ export function ActionProvider({
   initialHandlersRef.current = initialHandlers;
   initialHandlersRef.current = initialHandlers;
   const navigateRef = useRef(navigate);
   const navigateRef = useRef(navigate);
   navigateRef.current = navigate;
   navigateRef.current = navigate;
-  const [loadingActions, setLoadingActions] = useState<Set<string>>(new Set());
+  const [loadingActions, setLoadingActions] = useState<Map<string, number>>(
+    new Map(),
+  );
   const [pendingConfirmation, setPendingConfirmation] =
   const [pendingConfirmation, setPendingConfirmation] =
     useState<PendingConfirmation | null>(null);
     useState<PendingConfirmation | null>(null);
   // Ref tracks current pending confirmation so overlapping confirms can
   // Ref tracks current pending confirmation so overlapping confirms can
@@ -267,7 +269,11 @@ export function ActionProvider({
       }
       }
 
 
       const actionName = resolved.action;
       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 {
       try {
         await executeAction({
         await executeAction({
           action: resolved,
           action: resolved,
@@ -281,8 +287,13 @@ export function ActionProvider({
         });
         });
       } finally {
       } finally {
         setLoadingActions((prev) => {
         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;
           return next;
         });
         });
       }
       }
@@ -343,7 +354,7 @@ export function useAction(binding: ActionBinding): {
 } {
 } {
   const { execute, loadingActions } = useActions();
   const { execute, loadingActions } = useActions();
   const executeAction = useCallback(() => execute(binding), [execute, binding]);
   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 };
   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
   // Recovery: strip trailing extra braces/brackets one at a time
   // LLMs commonly generate extra closing characters in nested JSON
   // LLMs commonly generate extra closing characters in nested JSON
   let attempt = trimmed;
   let attempt = trimmed;
-  for (let i = 0; i < 3; i++) {
+  for (let i = 0; i < 8; i++) {
     const last = attempt[attempt.length - 1];
     const last = attempt[attempt.length - 1];
     if (last === "}" || last === "]") {
     if (last === "}" || last === "]") {
       attempt = attempt.slice(0, -1);
       attempt = attempt.slice(0, -1);

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

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