Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions packages/cli/src/generated/blocks/composer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// @generated by kern v3.5.8 — DO NOT EDIT. Source: src/kern/blocks/composer.kern
// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/blocks/composer.kern

import React from 'react';
import { Box, Text } from 'ink';
Expand Down Expand Up @@ -75,10 +75,20 @@ const ComposerView = React.memo(function ComposerView({ mode, replState, planMod
const selIdx = Math.min(Math.max(0, selectedChoiceIndex ?? 0), Math.max(0, choiceList.length - 1));
const renderChoiceRow = (c: any, i: number, accent: string) => {
const active = i === selIdx;
// Structured-ask options ([ASK] marker) carry a one-line trade-off
// description; render it dim under the label so the menu reads like a
// Claude-Code AskUserQuestion. Plain choices (no description) keep the
// original single-row shape.
const desc = typeof c.description === 'string' ? c.description.trim() : '';
return (
<Text key={`choice-${i}`}>
<Text color={active ? (c.color ?? accent) : '#6b7280'} bold={active}>{active ? '❯ ' : ' '}{i + 1}{'. '}{c.label}</Text>
</Text>
<Box key={`choice-${i}`} flexDirection="column">
<Text>
<Text color={active ? (c.color ?? accent) : '#6b7280'} bold={active}>{active ? '❯ ' : ' '}{i + 1}{'. '}{c.label}</Text>
</Text>
{desc ? (
<Text dimColor>{' '}{truncateCodeLine(desc, Math.max(24, termWidth - 12))}</Text>
) : null}
</Box>
);
};
return (
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/src/generated/cesar/ask-marker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/cesar/ask-marker.kern

/**
* One selectable answer: short label plus an optional one-line trade-off description rendered dim under the row.
*/
// @kern-source: ask-marker:21
export interface AskOption {
label: string;
description?: string;
}

/**
* A validated structured question: the prompt line and 2–6 options.
*/
// @kern-source: ask-marker:26
export interface StructuredAsk {
question: string;
options: AskOption[];
}

/**
* The parsed ask from the LAST well-formed block, or null when absent/malformed.
*
* True when an [ASK]…[/ASK] block was located (even if its body was malformed) — so the caller knows to strip it and can warn on ask:null.
*
* Response text with every [ASK]…[/ASK] block removed, ready to display.
*/
// @kern-source: ask-marker:31
export interface AskMarkerResult {
ask: StructuredAsk | null;
found: boolean;
rest: string;
}

// @kern-source: ask-marker:39
export const MAX_ASK_OPTIONS: number = 6;

// @kern-source: ask-marker:40
export const MIN_ASK_OPTIONS: number = 2;

// @kern-source: ask-marker:41
export const MAX_ASK_LABEL_CHARS: number = 100;

// @kern-source: ask-marker:42
export const MAX_ASK_DESCRIPTION_CHARS: number = 200;

// @kern-source: ask-marker:43
export const MAX_ASK_QUESTION_CHARS: number = 300;

/**
* Extract a structured ask from [ASK]…[/ASK] blocks. The last well-formed block wins; ALL blocks are stripped from rest. A located-but-malformed block (bad JSON, missing question, fewer than 2 valid options) returns ask:null with found:true so the caller can warn. Options are capped at 6; labels/descriptions/question are length-clamped.
*/
// @kern-source: ask-marker:45
export function parseAskMarker(response: string): AskMarkerResult {
const text = String(response ?? '');
const blockRe = /\[ASK\]([\s\S]*?)\[\/ASK\]/gi;
const matches = text.match(blockRe);
if (!matches || matches.length === 0) {
return { ask: null, found: false, rest: text };
}
const rest = text.replace(blockRe, '').trim();
// Re-scan to capture bodies (match() loses capture groups). Walk ALL blocks
// and keep the LAST one that validates — a model that changed its mind
// mid-stream re-emits, and the latest snapshot is authoritative (same
// last-wins contract as [TODOS]).
const clamp = (value: string, max: number): string =>
value.length > max ? value.slice(0, max - 1).trimEnd() + '…' : value;
let ask: StructuredAsk | null = null;
const re2 = /\[ASK\]([\s\S]*?)\[\/ASK\]/gi;
const bodies: string[] = [];
let m = re2.exec(text);
while (m !== null) {
bodies.push(String(m[1] ?? '').trim());
m = re2.exec(text);
}
for (const body of bodies) {
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
continue; // malformed body — keep scanning; found stays true
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
const obj = parsed as Record<string, unknown>;
const question = obj.question !== undefined && obj.question !== null ? String(obj.question).trim() : '';
if (!question) continue;
if (!Array.isArray(obj.options)) continue;
const options: AskOption[] = [];
for (const raw of (obj.options as unknown[]).slice(0, MAX_ASK_OPTIONS)) {
if (!raw || typeof raw !== 'object') continue;
const r = raw as Record<string, unknown>;
const label = r.label !== undefined && r.label !== null ? String(r.label).trim() : '';
if (!label) continue;
const description = r.description !== undefined && r.description !== null
? String(r.description).trim()
: '';
options.push({
label: clamp(label, MAX_ASK_LABEL_CHARS),
...(description ? { description: clamp(description, MAX_ASK_DESCRIPTION_CHARS) } : {}),
});
}
if (options.length < MIN_ASK_OPTIONS) continue;
ask = { question: clamp(question, MAX_ASK_QUESTION_CHARS), options };
}
return { ask, found: true, rest };
}
Loading